Compare commits
15 commits
jedi/searc
...
testing
Author | SHA1 | Date | |
---|---|---|---|
6b0def543c | |||
1568252112 | |||
9e0540d133 | |||
c2bcd53749 | |||
86b4220eaa | |||
13994a111e | |||
554bc70413 | |||
9395226c5f | |||
c26152d3c5 | |||
70516db074 | |||
2677f4b8b6 | |||
fbbf8352cf | |||
4ea74637a3 | |||
f133ae9e60 | |||
0fa52645c2 |
24 changed files with 10052 additions and 133 deletions
|
@ -1,6 +1,5 @@
|
|||
from django.core.files.base import ContentFile
|
||||
from django.db import models, IntegrityError
|
||||
from django_softdelete.models import SoftDeleteModel
|
||||
|
||||
from inventory.models import Item
|
||||
|
||||
|
@ -10,7 +9,8 @@ def hash_upload(instance, filename):
|
|||
|
||||
|
||||
class FileManager(models.Manager):
|
||||
def get_or_create(self, **kwargs):
|
||||
|
||||
def __file_data_helper(self, **kwargs):
|
||||
if 'data' in kwargs and type(kwargs['data']) == str:
|
||||
import base64
|
||||
from hashlib import sha256
|
||||
|
@ -31,6 +31,10 @@ class FileManager(models.Manager):
|
|||
pass
|
||||
else:
|
||||
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
|
||||
return kwargs
|
||||
|
||||
def get_or_create(self, **kwargs):
|
||||
kwargs = self.__file_data_helper(**kwargs)
|
||||
try:
|
||||
return self.get(hash=kwargs['hash']), False
|
||||
except self.model.DoesNotExist:
|
||||
|
@ -39,26 +43,7 @@ class FileManager(models.Manager):
|
|||
return obj, True
|
||||
|
||||
def create(self, **kwargs):
|
||||
if 'data' in kwargs and type(kwargs['data']) == str:
|
||||
import base64
|
||||
from hashlib import sha256
|
||||
raw = kwargs['data']
|
||||
if not raw.startswith('data:'):
|
||||
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
|
||||
raw = raw.split(';base64,')
|
||||
if len(raw) != 2:
|
||||
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
|
||||
mime_type = raw[0].split(':')[1]
|
||||
content = base64.b64decode(raw[1], validate=True)
|
||||
kwargs.pop('data')
|
||||
content_hash = sha256(content).hexdigest()
|
||||
kwargs['file'] = ContentFile(content, content_hash)
|
||||
kwargs['hash'] = content_hash
|
||||
kwargs['mime_type'] = mime_type
|
||||
elif 'file' in kwargs and 'hash' in kwargs and type(kwargs['file']) == ContentFile and 'mime_type' in kwargs:
|
||||
pass
|
||||
else:
|
||||
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
|
||||
kwargs = self.__file_data_helper(**kwargs)
|
||||
if not self.filter(hash=kwargs['hash']).exists():
|
||||
obj = super().create(**kwargs)
|
||||
obj.file.save(content=kwargs['file'], name=kwargs['hash'])
|
||||
|
|
|
@ -39,13 +39,61 @@ class ItemViewSet(viewsets.ModelViewSet):
|
|||
|
||||
def filter_items(items, query):
|
||||
query_tokens = query.split(' ')
|
||||
matches = []
|
||||
for item in items:
|
||||
value = 0
|
||||
if "I#" + str(item.id) in query:
|
||||
value += 12
|
||||
matches.append(
|
||||
{'type': 'item_id', 'text': f'is exactly {item.id} and matched "I#{item.id}"'})
|
||||
elif "#" + str(item.id) in query:
|
||||
value += 11
|
||||
matches.append(
|
||||
{'type': 'item_id', 'text': f'is exactly {item.id} and matched "#{item.id}"'})
|
||||
elif str(item.id) in query:
|
||||
value += 10
|
||||
matches.append({'type': 'item_id', 'text': f'is exactly {item.id}'})
|
||||
for issue in item.related_issues:
|
||||
if "T#" + issue.short_uuid() in query:
|
||||
value += 8
|
||||
matches.append({'type': 'ticket_uuid',
|
||||
'text': f'is exactly {issue.short_uuid()} and matched "T#{issue.short_uuid()}"'})
|
||||
elif "#" + issue.short_uuid() in query:
|
||||
value += 5
|
||||
matches.append({'type': 'ticket_uuid',
|
||||
'text': f'is exactly {issue.short_uuid()} and matched "#{issue.short_uuid()}"'})
|
||||
elif issue.short_uuid() in query:
|
||||
value += 3
|
||||
matches.append({'type': 'ticket_uuid', 'text': f'is exactly {issue.short_uuid()}'})
|
||||
if "T#" + str(issue.id) in query:
|
||||
value += 8
|
||||
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id} and matched "T#{issue.id}"'})
|
||||
elif "#" + str(issue.id) in query:
|
||||
value += 5
|
||||
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id} and matched "#{issue.id}"'})
|
||||
elif str(issue.id) in query:
|
||||
value += 3
|
||||
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id}'})
|
||||
for comment in issue.comments.all():
|
||||
for token in query_tokens:
|
||||
if token in comment.comment:
|
||||
value += 1
|
||||
matches.append({'type': 'ticket_comment', 'text': f'contains {token}'})
|
||||
for token in query_tokens:
|
||||
if token in issue.name:
|
||||
value += 1
|
||||
matches.append({'type': 'ticket_name', 'text': f'contains {token}'})
|
||||
for token in query_tokens:
|
||||
if token in item.description:
|
||||
value += 1
|
||||
matches.append({'type': 'item_description', 'text': f'contains {token}'})
|
||||
for comment in item.comments.all():
|
||||
for token in query_tokens:
|
||||
if token in comment.comment:
|
||||
value += 1
|
||||
matches.append({'type': 'comment', 'text': f'contains {token}'})
|
||||
if value > 0:
|
||||
yield {'search_score': value, 'item': item}
|
||||
yield {'search_score': value, 'item': item, 'search_matches': matches}
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
from itertools import groupby
|
||||
|
||||
from django.db import models
|
||||
from django.db.models.signals import pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from django_softdelete.models import SoftDeleteModel, SoftDeleteManager
|
||||
|
||||
|
||||
|
@ -64,6 +67,11 @@ class Item(SoftDeleteModel):
|
|||
return '[' + str(self.id) + ']' + self.description
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Item)
|
||||
def item_updated(sender, instance, **kwargs):
|
||||
instance.updated_at = timezone.now()
|
||||
|
||||
|
||||
class Container(SoftDeleteModel):
|
||||
id = models.AutoField(primary_key=True)
|
||||
name = models.CharField(max_length=255)
|
||||
|
|
|
@ -132,15 +132,33 @@ class ItemSerializer(BasicItemSerializer):
|
|||
'cid': placement.container.id,
|
||||
'box': placement.container.name
|
||||
})
|
||||
|
||||
if obj.created_at:
|
||||
timeline.append({
|
||||
'type': 'created',
|
||||
'timestamp': obj.created_at,
|
||||
})
|
||||
if obj.returned_at:
|
||||
timeline.append({
|
||||
'type': 'returned',
|
||||
'timestamp': obj.returned_at,
|
||||
})
|
||||
if obj.deleted_at:
|
||||
timeline.append({
|
||||
'type': 'deleted',
|
||||
'timestamp': obj.deleted_at,
|
||||
})
|
||||
return sorted(timeline, key=lambda x: x['timestamp'])
|
||||
|
||||
|
||||
class SearchResultSerializer(serializers.Serializer):
|
||||
search_score = serializers.IntegerField()
|
||||
search_matches = serializers.ListField(child=serializers.DictField())
|
||||
item = ItemSerializer()
|
||||
|
||||
def to_representation(self, instance):
|
||||
return {**ItemSerializer(instance['item']).data, 'search_score': instance['search_score']}
|
||||
return {**ItemSerializer(instance['item']).data, 'search_score': instance['search_score'],
|
||||
'search_matches': instance['search_matches']}
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
|
|
|
@ -63,28 +63,28 @@ class ItemTestCase(TestCase):
|
|||
self.assertEqual(response.json()[0]['file'], None)
|
||||
self.assertEqual(response.json()[0]['returned'], False)
|
||||
self.assertEqual(response.json()[0]['event'], self.event.slug)
|
||||
self.assertEqual(len(response.json()[0]['timeline']), 4)
|
||||
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'placement')
|
||||
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'comment')
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'issue_relation')
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'placement')
|
||||
self.assertEqual(response.json()[0]['timeline'][1]['id'], comment.id)
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['id'], match.id)
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['id'], placement.id)
|
||||
self.assertEqual(response.json()[0]['timeline'][0]['box'], 'BOX1')
|
||||
self.assertEqual(response.json()[0]['timeline'][0]['cid'], self.box1.id)
|
||||
self.assertEqual(response.json()[0]['timeline'][1]['comment'], 'test')
|
||||
self.assertEqual(response.json()[0]['timeline'][1]['timestamp'],
|
||||
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['status'], 'possible')
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'],
|
||||
match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['name'], "test issue")
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['event'], "EVENT")
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['state'], "pending_new")
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['box'], 'BOX2')
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['cid'], self.box2.id)
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
|
||||
self.assertEqual(len(response.json()[0]['timeline']), 5)
|
||||
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'created')
|
||||
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'placement')
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'comment')
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'issue_relation')
|
||||
self.assertEqual(response.json()[0]['timeline'][4]['type'], 'placement')
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['id'], comment.id)
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['id'], match.id)
|
||||
self.assertEqual(response.json()[0]['timeline'][4]['id'], placement.id)
|
||||
self.assertEqual(response.json()[0]['timeline'][1]['box'], 'BOX1')
|
||||
self.assertEqual(response.json()[0]['timeline'][1]['cid'], self.box1.id)
|
||||
self.assertEqual(response.json()[0]['timeline'][0]['timestamp'], item.created_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['comment'], 'test')
|
||||
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'], comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['status'], 'possible')
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'], match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['issue_thread']['name'], "test issue")
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['issue_thread']['event'], "EVENT")
|
||||
self.assertEqual(response.json()[0]['timeline'][3]['issue_thread']['state'], "pending_new")
|
||||
self.assertEqual(response.json()[0]['timeline'][4]['box'], 'BOX2')
|
||||
self.assertEqual(response.json()[0]['timeline'][4]['cid'], self.box2.id)
|
||||
self.assertEqual(response.json()[0]['timeline'][4]['timestamp'],
|
||||
placement.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
||||
self.assertEqual(len(response.json()[0]['related_issues']), 1)
|
||||
self.assertEqual(response.json()[0]['related_issues'][0]['name'], "test issue")
|
||||
|
|
|
@ -53,6 +53,12 @@ def unescape_simplified_quoted_printable(s, encoding='utf-8'):
|
|||
return quopri.decodestring(s).decode(encoding)
|
||||
|
||||
|
||||
def decode_inline_encodings(s):
|
||||
s = unescape_and_decode_quoted_printable(s)
|
||||
s = unescape_and_decode_base64(s)
|
||||
return s
|
||||
|
||||
|
||||
def ascii_strip(s):
|
||||
if not s:
|
||||
return None
|
||||
|
@ -87,17 +93,17 @@ def make_reply(reply_email, references=None, event=None):
|
|||
reply_email.save()
|
||||
if references:
|
||||
reply["References"] = " ".join(references)
|
||||
|
||||
reply.set_content(reply_email.body)
|
||||
|
||||
return reply
|
||||
|
||||
if reply_email.body != "":
|
||||
reply.set_content(reply_email.body)
|
||||
return reply
|
||||
else:
|
||||
raise SpecialMailException("mail content emty")
|
||||
|
||||
async def send_smtp(message):
|
||||
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
|
||||
|
||||
|
||||
def find_active_issue_thread(in_reply_to, address, subject, event):
|
||||
def find_active_issue_thread(in_reply_to, address, subject, event, spam=False):
|
||||
from re import match
|
||||
uuid_match = match(r'^ticket\+([a-f0-9-]{36})@', address)
|
||||
if uuid_match:
|
||||
|
@ -108,7 +114,8 @@ def find_active_issue_thread(in_reply_to, address, subject, event):
|
|||
if reply_to.exists():
|
||||
return reply_to.first().issue_thread, False
|
||||
else:
|
||||
issue = IssueThread.objects.create(name=subject, event=event)
|
||||
issue = IssueThread.objects.create(name=subject, event=event,
|
||||
initial_state='pending_suspected_spam' if spam else 'pending_new')
|
||||
return issue, True
|
||||
|
||||
|
||||
|
@ -128,10 +135,13 @@ def decode_email_segment(segment, charset, transfer_encoding):
|
|||
decode_as = 'cp1251'
|
||||
elif charset == 'iso-8859-1':
|
||||
decode_as = 'latin1'
|
||||
segment = unescape_and_decode_quoted_printable(segment)
|
||||
segment = unescape_and_decode_base64(segment)
|
||||
if transfer_encoding == 'quoted-printable':
|
||||
segment = unescape_simplified_quoted_printable(segment, decode_as)
|
||||
elif transfer_encoding == 'base64':
|
||||
import base64
|
||||
segment = base64.b64decode(segment).decode('utf-8')
|
||||
else:
|
||||
segment = decode_inline_encodings(segment.decode('utf-8'))
|
||||
return segment
|
||||
|
||||
|
||||
|
@ -156,7 +166,7 @@ def parse_email_body(raw, log=None):
|
|||
segment = part.get_payload()
|
||||
if not segment:
|
||||
continue
|
||||
segment = decode_email_segment(segment, charset, part.get('Content-Transfer-Encoding'))
|
||||
segment = decode_email_segment(segment.encode('utf-8'), charset, part.get('Content-Transfer-Encoding'))
|
||||
log.debug(segment)
|
||||
body = body + segment
|
||||
elif 'attachment' in cdispo or 'inline' in cdispo:
|
||||
|
@ -189,7 +199,8 @@ def parse_email_body(raw, log=None):
|
|||
else:
|
||||
log.warning("Unknown content type %s", parsed.get_content_type())
|
||||
body = "Unknown content type"
|
||||
body = decode_email_segment(body, parsed.get_content_charset(), parsed.get('Content-Transfer-Encoding'))
|
||||
body = decode_email_segment(body.encode('utf-8'), parsed.get_content_charset(),
|
||||
parsed.get('Content-Transfer-Encoding'))
|
||||
log.debug(body)
|
||||
|
||||
return parsed, body, attachments
|
||||
|
@ -203,6 +214,8 @@ def receive_email(envelope, log=None):
|
|||
header_to = parsed.get('To')
|
||||
header_in_reply_to = ascii_strip(parsed.get('In-Reply-To'))
|
||||
header_message_id = ascii_strip(parsed.get('Message-ID'))
|
||||
maybe_spam = parsed.get('X-Spam')
|
||||
suspected_spam = (maybe_spam and maybe_spam.lower() == 'yes')
|
||||
|
||||
if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "":
|
||||
log.warning("Ignoring mailer daemon")
|
||||
|
@ -210,18 +223,20 @@ def receive_email(envelope, log=None):
|
|||
|
||||
if Email.objects.filter(reference=header_message_id).exists(): # break before issue thread is created
|
||||
log.warning("Email already exists")
|
||||
raise Exception("Email already exists")
|
||||
raise SpecialMailException("Email already exists")
|
||||
|
||||
recipient = envelope.rcpt_tos[0].lower() if envelope.rcpt_tos else header_to.lower()
|
||||
sender = envelope.mail_from if envelope.mail_from else header_from
|
||||
subject = ascii_strip(parsed.get('Subject'))
|
||||
if not subject:
|
||||
subject = "No subject"
|
||||
subject = unescape_and_decode_quoted_printable(subject)
|
||||
subject = unescape_and_decode_base64(subject)
|
||||
subject = decode_inline_encodings(subject)
|
||||
recipient = decode_inline_encodings(recipient)
|
||||
sender = decode_inline_encodings(sender)
|
||||
target_event = find_target_event(recipient)
|
||||
|
||||
active_issue_thread, new = find_active_issue_thread(header_in_reply_to, recipient, subject, target_event)
|
||||
active_issue_thread, new = find_active_issue_thread(
|
||||
header_in_reply_to, recipient, subject, target_event, suspected_spam)
|
||||
|
||||
from hashlib import sha256
|
||||
random_filename = 'mail-' + sha256(envelope.content).hexdigest()
|
||||
|
@ -239,7 +254,7 @@ def receive_email(envelope, log=None):
|
|||
if new:
|
||||
# auto reply if new issue
|
||||
references = collect_references(active_issue_thread)
|
||||
if not sender.startswith('noreply'):
|
||||
if not sender.startswith('noreply') and not sender.startswith('no-reply') and not suspected_spam:
|
||||
subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]"
|
||||
body = '''Your request (#{}) has been received and will be reviewed by our lost&found angels.
|
||||
|
||||
|
@ -252,7 +267,7 @@ do not create a new request.
|
|||
|
||||
Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid())
|
||||
reply_email = Email.objects.create(
|
||||
sender=recipient, recipient=sender, body=body, subject=ascii_strip(subject),
|
||||
sender=recipient, recipient=sender, body=body, subject=subject,
|
||||
in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread)
|
||||
reply = make_reply(reply_email, references, event=target_event.slug if target_event else None)
|
||||
else:
|
||||
|
@ -288,10 +303,10 @@ class LMTPHandler:
|
|||
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
|
||||
reference=email.id)
|
||||
log.info(f"Created system event {systemevent.id}")
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
||||
"message": "email received"})
|
||||
#channel_layer = get_channel_layer()
|
||||
#await channel_layer.group_send(
|
||||
# 'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
||||
# "message": "email received"})
|
||||
log.info(f"Sent message to frontend")
|
||||
if new and reply:
|
||||
log.info('Sending message to %s' % reply['To'])
|
||||
|
|
|
@ -165,7 +165,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
|
|||
self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body)
|
||||
self.assertTrue(Email.objects.all()[0].raw_file.path)
|
||||
|
||||
def test_handle_base64(self):
|
||||
def test_handle_base64_inline(self):
|
||||
from aiosmtpd.smtp import Envelope
|
||||
from asgiref.sync import async_to_sync
|
||||
import aiosmtplib
|
||||
|
@ -186,6 +186,35 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
|
|||
self.assertEqual('Text mit Base64-Kodierung: äöüß', Email.objects.all()[0].body)
|
||||
self.assertTrue(Email.objects.all()[0].raw_file.path)
|
||||
|
||||
def test_handle_base64_transfer_encoding(self):
|
||||
from aiosmtpd.smtp import Envelope
|
||||
from asgiref.sync import async_to_sync
|
||||
import aiosmtplib
|
||||
aiosmtplib.send = make_mocked_coro()
|
||||
handler = LMTPHandler()
|
||||
server = mock.Mock()
|
||||
session = mock.Mock()
|
||||
envelope = Envelope()
|
||||
envelope.mail_from = 'test1@test'
|
||||
envelope.rcpt_tos = ['test2@test']
|
||||
envelope.content = b'''Subject: test
|
||||
From: test3@test
|
||||
To: test4@test
|
||||
Message-ID: <1@test>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
VGVzdCBtaXQgQmFzZTY0LUtvZGllcnVuZzogw6TDtsO8w58='''
|
||||
|
||||
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
|
||||
self.assertEqual(result, '250 Message accepted for delivery')
|
||||
self.assertEqual(len(Email.objects.all()), 2)
|
||||
self.assertEqual(len(IssueThread.objects.all()), 1)
|
||||
aiosmtplib.send.assert_called_once()
|
||||
self.assertEqual('test', Email.objects.all()[0].subject)
|
||||
self.assertEqual('Test mit Base64-Kodierung: äöüß', Email.objects.all()[0].body)
|
||||
self.assertTrue(Email.objects.all()[0].raw_file.path)
|
||||
|
||||
def test_handle_client_reply(self):
|
||||
issue_thread = IssueThread.objects.create(
|
||||
name="test",
|
||||
|
@ -783,6 +812,44 @@ dGVzdGltYWdl
|
|||
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
|
||||
aiosmtplib.send.assert_called_once()
|
||||
|
||||
def test_mail_spam_header(self):
|
||||
from aiosmtpd.smtp import Envelope
|
||||
from asgiref.sync import async_to_sync
|
||||
import aiosmtplib
|
||||
aiosmtplib.send = make_mocked_coro()
|
||||
handler = LMTPHandler()
|
||||
server = mock.Mock()
|
||||
session = mock.Mock()
|
||||
envelope = Envelope()
|
||||
envelope.mail_from = 'test1@test'
|
||||
envelope.rcpt_tos = ['test2@test']
|
||||
envelope.content = b'''Subject: test
|
||||
From: test1@test
|
||||
To: test2@test
|
||||
Message-ID: <1@test>
|
||||
X-Spam: Yes
|
||||
|
||||
test'''
|
||||
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
|
||||
|
||||
self.assertEqual(result, '250 Message accepted for delivery')
|
||||
self.assertEqual(len(Email.objects.all()), 1) # do not send auto reply if spam is suspected
|
||||
self.assertEqual(len(IssueThread.objects.all()), 1)
|
||||
aiosmtplib.send.assert_not_called()
|
||||
self.assertEqual('test', Email.objects.all()[0].subject)
|
||||
self.assertEqual('test1@test', Email.objects.all()[0].sender)
|
||||
self.assertEqual('test2@test', Email.objects.all()[0].recipient)
|
||||
self.assertEqual('test', Email.objects.all()[0].body)
|
||||
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[0].issue_thread)
|
||||
self.assertEqual('<1@test>', Email.objects.all()[0].reference)
|
||||
self.assertEqual(None, Email.objects.all()[0].in_reply_to)
|
||||
self.assertEqual('test', IssueThread.objects.all()[0].name)
|
||||
self.assertEqual('pending_suspected_spam', IssueThread.objects.all()[0].state)
|
||||
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
|
||||
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
|
||||
self.assertEqual(1, len(states))
|
||||
self.assertEqual('pending_suspected_spam', states[0].state)
|
||||
|
||||
def test_mail_4byte_unicode_emoji(self):
|
||||
from aiosmtpd.smtp import Envelope
|
||||
from asgiref.sync import async_to_sync
|
||||
|
|
0
core/testdata.py
Normal file
0
core/testdata.py
Normal file
|
@ -102,12 +102,6 @@ def manual_ticket(request, event_slug):
|
|||
subject=request.data['name'],
|
||||
body=request.data['body'],
|
||||
)
|
||||
systemevent = SystemEvent.objects.create(type='email received', reference=email.id)
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
||||
"message": "email received"}
|
||||
)
|
||||
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
@ -133,48 +127,75 @@ def add_comment(request, pk):
|
|||
issue_thread=issue,
|
||||
comment=request.data['comment'],
|
||||
)
|
||||
systemevent = SystemEvent.objects.create(type='comment added', reference=comment.id)
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
||||
"message": "comment added"}
|
||||
)
|
||||
return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
def filter_issues(issues, query):
|
||||
query_tokens = query.lower().split(' ')
|
||||
matches = []
|
||||
for issue in issues:
|
||||
value = 0
|
||||
if issue.short_uuid() in query:
|
||||
if "T#" + issue.short_uuid() in query:
|
||||
value += 12
|
||||
matches.append(
|
||||
{'type': 'ticket_uuid', 'text': f'is exactly {issue.short_uuid()} and matched "T#{issue.short_uuid()}"'})
|
||||
elif "#" + issue.short_uuid() in query:
|
||||
value += 11
|
||||
matches.append(
|
||||
{'type': 'ticket_uuid', 'text': f'is exactly {issue.short_uuid()} and matched "#{issue.short_uuid()}"'})
|
||||
elif issue.short_uuid() in query:
|
||||
value += 10
|
||||
matches.append({'type': 'ticket_uuid', 'text': f'is exactly {issue.short_uuid()}'})
|
||||
if "T#" + str(issue.id) in query:
|
||||
value += 10
|
||||
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id} and matched "T#{issue.id}"'})
|
||||
elif "#" + str(issue.id) in query:
|
||||
value += 9
|
||||
value += 7
|
||||
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id} and matched "#{issue.id}"'})
|
||||
elif str(issue.id) in query:
|
||||
value += 4
|
||||
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id}'})
|
||||
for item in issue.related_items:
|
||||
if "I#" + str(item.id) in query:
|
||||
value += 8
|
||||
matches.append({'type': 'item_id', 'text': f'is exactly {item.id} and matched "I#{item.id}"'})
|
||||
elif "#" + str(item.id) in query:
|
||||
value += 5
|
||||
matches.append({'type': 'item_id', 'text': f'is exactly {item.id} and matched "#{item.id}"'})
|
||||
elif str(item.id) in query:
|
||||
value += 3
|
||||
matches.append({'type': 'item_id', 'text': f'is exactly {item.id}'})
|
||||
for token in query_tokens:
|
||||
if token in item.description.lower():
|
||||
value += 1
|
||||
matches.append({'type': 'item_description', 'text': f'contains {token}'})
|
||||
for comment in item.comments.all():
|
||||
for token in query_tokens:
|
||||
if token in comment.comment.lower():
|
||||
value += 1
|
||||
matches.append({'type': 'item_comment', 'text': f'contains {token}'})
|
||||
for token in query_tokens:
|
||||
if token in issue.name.lower():
|
||||
value += 1
|
||||
matches.append({'type': 'ticket_name', 'text': f'contains {token}'})
|
||||
for comment in issue.comments.all():
|
||||
for token in query_tokens:
|
||||
if token in comment.comment.lower():
|
||||
value += 1
|
||||
matches.append({'type': 'ticket_comment', 'text': f'contains {token}'})
|
||||
for email in issue.emails.all():
|
||||
for token in query_tokens:
|
||||
if token in email.subject.lower():
|
||||
value += 1
|
||||
matches.append({'type': 'email_subject', 'text': f'contains {token}'})
|
||||
if token in email.body.lower():
|
||||
value += 1
|
||||
matches.append({'type': 'email_body', 'text': f'contains {token}'})
|
||||
if token in email.sender.lower():
|
||||
value += 1
|
||||
matches.append({'type': 'email_sender', 'text': f'contains {token}'})
|
||||
if value > 0:
|
||||
yield {'search_score': value, 'issue': issue}
|
||||
yield {'search_score': value, 'issue': issue, 'search_matches': matches}
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
|
|
|
@ -16,6 +16,7 @@ STATE_CHOICES = (
|
|||
('pending_physical_confirmation', 'Needs to be confirmed physically'),
|
||||
('pending_return', 'Needs to be returned'),
|
||||
('pending_postponed', 'Postponed'),
|
||||
('pending_suspected_spam', 'Suspected Spam'),
|
||||
('waiting_details', 'Waiting for details'),
|
||||
('waiting_pre_shipping', 'Waiting for Address/Shipping Info'),
|
||||
('closed_returned', 'Closed: Returned'),
|
||||
|
@ -46,6 +47,11 @@ class IssueThread(SoftDeleteModel):
|
|||
event = models.ForeignKey(Event, null=True, on_delete=models.SET_NULL, related_name='issue_threads')
|
||||
manually_created = models.BooleanField(default=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if 'initial_state' in kwargs:
|
||||
self._initial_state = kwargs.pop('initial_state')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def short_uuid(self):
|
||||
return self.uuid[:8]
|
||||
|
||||
|
@ -110,8 +116,9 @@ def set_uuid(sender, instance, **kwargs):
|
|||
|
||||
@receiver(post_save, sender=IssueThread)
|
||||
def create_issue_thread(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
StateChange.objects.create(issue_thread=instance, state='pending_new')
|
||||
if created and instance.state_changes.count() == 0:
|
||||
initial_state = getattr(instance, '_initial_state', None)
|
||||
StateChange.objects.create(issue_thread=instance, state=initial_state if initial_state else 'pending_new')
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
|
|
|
@ -139,10 +139,12 @@ class IssueSerializer(BasicIssueSerializer):
|
|||
|
||||
class SearchResultSerializer(serializers.Serializer):
|
||||
search_score = serializers.IntegerField()
|
||||
search_matches = serializers.ListField(child=serializers.DictField())
|
||||
issue = IssueSerializer()
|
||||
|
||||
def to_representation(self, instance):
|
||||
return {**IssueSerializer(instance['issue']).data, 'search_score': instance['search_score']}
|
||||
return {**IssueSerializer(instance['issue']).data, 'search_score': instance['search_score'],
|
||||
'search_matches': instance['search_matches']}
|
||||
|
||||
class Meta:
|
||||
model = IssueThread
|
||||
|
|
|
@ -9,6 +9,7 @@ class RelationSerializer(serializers.ModelSerializer):
|
|||
class Meta:
|
||||
model = ItemRelation
|
||||
fields = ('id', 'status', 'timestamp', 'item', 'issue_thread')
|
||||
read_only_fields = ('id', 'timestamp')
|
||||
|
||||
|
||||
class BasicIssueSerializer(serializers.ModelSerializer):
|
||||
|
|
|
@ -4,6 +4,7 @@ from django.test import TestCase, Client
|
|||
|
||||
from authentication.models import ExtendedUser
|
||||
from inventory.models import Event, Container, Item
|
||||
from inventory.models import Comment as ItemComment
|
||||
from mail.models import Email, EmailAttachment
|
||||
from tickets.models import IssueThread, StateChange, Comment, ItemRelation, Assignment
|
||||
from django.contrib.auth.models import Permission
|
||||
|
@ -407,16 +408,16 @@ class IssueSearchTest(TestCase):
|
|||
mail1 = Email.objects.create(
|
||||
subject='test',
|
||||
body='test aBc',
|
||||
sender='test',
|
||||
recipient='test',
|
||||
sender='bar@test',
|
||||
recipient='2@test',
|
||||
issue_thread=issue,
|
||||
timestamp=now,
|
||||
)
|
||||
mail2 = Email.objects.create(
|
||||
subject='test',
|
||||
subject='Re: test',
|
||||
body='test',
|
||||
sender='test',
|
||||
recipient='test',
|
||||
sender='2@test',
|
||||
recipient='1@test',
|
||||
issue_thread=issue,
|
||||
in_reply_to=mail1.reference,
|
||||
timestamp=now + timedelta(seconds=2),
|
||||
|
@ -436,6 +437,11 @@ class IssueSearchTest(TestCase):
|
|||
item=self.item,
|
||||
timestamp=now + timedelta(seconds=5),
|
||||
)
|
||||
item_comment = ItemComment.objects.create(
|
||||
item=self.item,
|
||||
comment="baz",
|
||||
timestamp=now + timedelta(seconds=6),
|
||||
)
|
||||
search_query = b64encode(b'abC').decode('utf-8')
|
||||
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
|
||||
self.assertEqual(200, response.status_code)
|
||||
|
@ -465,3 +471,21 @@ class IssueSearchTest(TestCase):
|
|||
self.assertGreater(score3, score2)
|
||||
self.assertGreater(score2, score1)
|
||||
self.assertGreater(score1, 0)
|
||||
|
||||
search_query = b64encode(b'foo').decode('utf-8')
|
||||
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(1, len(response.json()))
|
||||
self.assertEqual(issue.id, response.json()[0]['id'])
|
||||
|
||||
search_query = b64encode(b'bar').decode('utf-8')
|
||||
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(1, len(response.json()))
|
||||
self.assertEqual(issue.id, response.json()[0]['id'])
|
||||
|
||||
search_query = b64encode(b'baz').decode('utf-8')
|
||||
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(1, len(response.json()))
|
||||
self.assertEqual(issue.id, response.json()[0]['id'])
|
||||
|
|
|
@ -7,6 +7,7 @@ services:
|
|||
environment:
|
||||
- HTTP_HOST=core
|
||||
- DB_FILE=dev.db
|
||||
- DEBUG_MODE_ACTIVE=true
|
||||
volumes:
|
||||
- ../../core:/code
|
||||
- ../testdata.py:/code/testdata.py
|
||||
|
|
9399
web/package-lock.json
generated
Normal file
9399
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -24,6 +24,15 @@
|
|||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'placement'">
|
||||
<font-awesome-icon icon="archive"/>
|
||||
</span>
|
||||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'created'">
|
||||
<font-awesome-icon icon="archive"/>
|
||||
</span>
|
||||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'returned'">
|
||||
<font-awesome-icon icon="archive"/>
|
||||
</span>
|
||||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'deleted'">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
</span>
|
||||
<span class="timeline-item-icon faded-icon" v-else>
|
||||
<font-awesome-icon icon="pen"/>
|
||||
</span>
|
||||
|
@ -35,6 +44,9 @@
|
|||
<TimelineShippingVoucher v-else-if="item.type === 'shipping_voucher'" :item="item"/>
|
||||
<TimelinePlacement v-else-if="item.type === 'placement'" :item="item"/>
|
||||
<TimelineRelatedTicket v-else-if="item.type === 'issue_relation'" :item="item"/>
|
||||
<TimelineCreated v-else-if="item.type === 'created'" :item="item"/>
|
||||
<TimelineReturned v-else-if="item.type === 'returned'" :item="item"/>
|
||||
<TimelineDeleted v-else-if="item.type === 'deleted'" :item="item"/>
|
||||
<p v-else>{{ item }}</p>
|
||||
</li>
|
||||
<li class="timeline-item">
|
||||
|
@ -58,10 +70,16 @@ import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue";
|
|||
import AsyncButton from "@/components/inputs/AsyncButton.vue";
|
||||
import TimelinePlacement from "@/components/TimelinePlacement.vue";
|
||||
import TimelineRelatedTicket from "@/components/TimelineRelatedTicket.vue";
|
||||
import TimelineCreated from "@/components/TimelineCreated.vue";
|
||||
import TimelineReturned from "@/components/TimelineReturned.vue";
|
||||
import TimelineDeleted from "@/components/TimelineDeleted.vue";
|
||||
|
||||
export default {
|
||||
name: 'Timeline',
|
||||
components: {
|
||||
TimelineDeleted,
|
||||
TimelineReturned,
|
||||
TimelineCreated,
|
||||
TimelineRelatedTicket,
|
||||
TimelinePlacement,
|
||||
TimelineShippingVoucher,
|
||||
|
@ -203,4 +221,4 @@ a {
|
|||
}
|
||||
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
|
83
web/src/components/TimelineCreated.vue
Normal file
83
web/src/components/TimelineCreated.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="timeline-item-description"><span>created by
|
||||
<i class="avatar | small">
|
||||
<font-awesome-icon icon="user"/>
|
||||
</i>
|
||||
<a href="#">$USER</a> at <time :datetime="timestamp">{{ timestamp }}</time></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {mapState} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'TimelineCreated',
|
||||
props: {
|
||||
'item': {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
'timestamp': function () {
|
||||
return new Date(this.item.timestamp).toLocaleString();
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
.timeline-item-description {
|
||||
display: flex;
|
||||
padding-top: 6px;
|
||||
gap: 8px;
|
||||
color: var(--gray);
|
||||
|
||||
img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
/*color: var(--c-grey-500);*/
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
outline: 0; /* Don't actually do this */
|
||||
color: var(--info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
&.small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
83
web/src/components/TimelineDeleted.vue
Normal file
83
web/src/components/TimelineDeleted.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="timeline-item-description"><span>marked deleted by
|
||||
<i class="avatar | small">
|
||||
<font-awesome-icon icon="user"/>
|
||||
</i>
|
||||
<a href="#">$USER</a> at <time :datetime="timestamp">{{ timestamp }}</time></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {mapState} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'TimelineDeleted',
|
||||
props: {
|
||||
'item': {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
'timestamp': function () {
|
||||
return new Date(this.item.timestamp).toLocaleString();
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
.timeline-item-description {
|
||||
display: flex;
|
||||
padding-top: 6px;
|
||||
gap: 8px;
|
||||
color: var(--gray);
|
||||
|
||||
img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
/*color: var(--c-grey-500);*/
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
outline: 0; /* Don't actually do this */
|
||||
color: var(--info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
&.small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
83
web/src/components/TimelineReturned.vue
Normal file
83
web/src/components/TimelineReturned.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="timeline-item-description"><span>marked returned by
|
||||
<i class="avatar | small">
|
||||
<font-awesome-icon icon="user"/>
|
||||
</i>
|
||||
<a href="#">$USER</a> at <time :datetime="timestamp">{{ timestamp }}</time></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {mapState} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'TimelineReturned',
|
||||
props: {
|
||||
'item': {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
'timestamp': function () {
|
||||
return new Date(this.item.timestamp).toLocaleString();
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
.timeline-item-description {
|
||||
display: flex;
|
||||
padding-top: 6px;
|
||||
gap: 8px;
|
||||
color: var(--gray);
|
||||
|
||||
img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
/*color: var(--c-grey-500);*/
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
outline: 0; /* Don't actually do this */
|
||||
color: var(--info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
&.small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<button @click.stop="handleClick" :disabled="disabled">
|
||||
<button @click.stop="handleClick" :disabled="disabled || inProgress">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"
|
||||
:class="{'d-none': !disabled}"></span>
|
||||
<span class="ml-2" :class="{'d-none': !disabled}">In Progress...</span>
|
||||
<span :class="{'d-none': disabled}"><slot></slot></span>
|
||||
:class="{'d-none': !inProgress}"></span>
|
||||
<span class="ml-2" :class="{'d-none': !inProgress}">In Progress...</span>
|
||||
<span :class="{'d-none': inProgress}"><slot></slot></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
@ -13,7 +13,7 @@ export default {
|
|||
name: 'AsyncButton',
|
||||
data() {
|
||||
return {
|
||||
disabled: false,
|
||||
inProgress: false,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
|
@ -21,17 +21,21 @@ export default {
|
|||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async handleClick() {
|
||||
if (this.task && typeof this.task === 'function') {
|
||||
this.disabled = true;
|
||||
this.inProgress = true;
|
||||
try {
|
||||
await this.task();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.disabled = false;
|
||||
this.inProgress = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -43,4 +47,4 @@ export default {
|
|||
.spinner-border {
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
@ -61,7 +61,6 @@ const store = createStore({
|
|||
'2kg-de': '2kg Paket (DE)',
|
||||
'5kg-de': '5kg Paket (DE)',
|
||||
'10kg-de': '10kg Paket (DE)',
|
||||
'2kg-eu': '2kg Paket (EU)',
|
||||
'5kg-eu': '5kg Paket (EU)',
|
||||
'10kg-eu': '10kg Paket (EU)',
|
||||
}
|
||||
|
@ -77,10 +76,26 @@ const store = createStore({
|
|||
getEventTickets: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllTickets : getters.getAllTickets.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
|
||||
isItemsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedItems : Object.keys(state.loadedItems).includes(getters.getEventSlug),
|
||||
isTicketsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedTickets : Object.keys(state.loadedTickets).includes(getters.getEventSlug),
|
||||
getItemsSearchResults: (state, getters) => state.loadedItemSearchResults[getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || [],
|
||||
getTicketsSearchResults: (state, getters) => state.loadedTicketSearchResults[getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || [],
|
||||
isItemsSearchLoaded: (state, getters) => Object.keys(state.loadedItemSearchResults).includes(getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))),
|
||||
isTicketsSearchLoaded: (state, getters) => Object.keys(state.loadedTicketSearchResults).includes(getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))),
|
||||
getItemsSearchResults: (state, getters) => {
|
||||
if (getters.getEventSlug === 'all') {
|
||||
return state.events.map(e => {
|
||||
return state.loadedItemSearchResults[e.slug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || []
|
||||
}).flat();
|
||||
} else {
|
||||
return state.loadedItemSearchResults[getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || []
|
||||
}
|
||||
},
|
||||
getTicketsSearchResults: (state, getters) => {
|
||||
if (getters.getEventSlug === 'all') {
|
||||
return state.events.map(e => {
|
||||
return state.loadedTicketSearchResults[e.slug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || []
|
||||
}).flat();
|
||||
} else {
|
||||
return state.loadedTicketSearchResults[getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || []
|
||||
}
|
||||
},
|
||||
isItemsSearchLoaded: (state, getters) => Object.keys(state.loadedItemSearchResults).includes(getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))) || getters.getEventSlug === 'all',
|
||||
isTicketsSearchLoaded: (state, getters) => Object.keys(state.loadedTicketSearchResults).includes(getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))) || getters.getEventSlug === 'all',
|
||||
getActiveView: state => router.currentRoute.value.name || 'items',
|
||||
getFilters: state => router.currentRoute.value.query,
|
||||
getBoxes: state => state.loadedBoxes,
|
||||
|
@ -379,26 +394,39 @@ const store = createStore({
|
|||
},
|
||||
async loadEventItems({commit, getters, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.items > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
try {
|
||||
const slug = getters.getEventSlug;
|
||||
const {data, success} = await getters.session.get(`/2/${slug}/items/`);
|
||||
if (data && success) {
|
||||
commit('setItems', {slug, items: data});
|
||||
const load = async (slug) => {
|
||||
try {
|
||||
const {data, success} = await getters.session.get(`/2/${slug}/items/`);
|
||||
if (data && success) {
|
||||
commit('setItems', {slug, items: data});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error loading items");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error loading items");
|
||||
}
|
||||
const slug = getters.getEventSlug;
|
||||
if (slug === 'all') {
|
||||
await Promise.all(state.events.map(e => load(e.slug)));
|
||||
} else {
|
||||
await load(slug);
|
||||
}
|
||||
},
|
||||
async searchEventItems({commit, getters, state}, query) {
|
||||
const encoded_query = base64.encode(utf8.encode(query));
|
||||
const load = async (slug) => {
|
||||
if (Object.keys(state.loadedItemSearchResults).includes(slug + '/' + encoded_query)) return;
|
||||
const {
|
||||
data, success
|
||||
} = await getters.session.get(`/2/${slug}/items/${encoded_query}/`);
|
||||
if (data && success) {
|
||||
commit('setItemSearchResults', {slug, query: encoded_query, items: data});
|
||||
}
|
||||
}
|
||||
const slug = getters.getEventSlug;
|
||||
if (Object.keys(state.loadedItemSearchResults).includes(slug + '/' + encoded_query)) return;
|
||||
const {
|
||||
data, success
|
||||
} = await getters.session.get(`/2/${slug}/items/${encoded_query}/`);
|
||||
if (data && success) {
|
||||
commit('setItemSearchResults', {slug, query: encoded_query, items: data});
|
||||
if (slug === 'all') {
|
||||
await Promise.all(state.events.map(e => load(e.slug)));
|
||||
} else {
|
||||
await load(slug);
|
||||
}
|
||||
},
|
||||
async loadBoxes({commit, state, getters}) {
|
||||
|
@ -446,12 +474,19 @@ const store = createStore({
|
|||
},
|
||||
async searchEventTickets({commit, getters, state}, query) {
|
||||
const encoded_query = base64.encode(utf8.encode(query));
|
||||
const load = async (slug) => {
|
||||
if (Object.keys(state.loadedTicketSearchResults).includes(slug + '/' + encoded_query)) return;
|
||||
const {
|
||||
data, success
|
||||
} = await getters.session.get(`/2/${slug}/tickets/${encoded_query}/`);
|
||||
if (data && success) commit('setTicketSearchResults', {slug, query: encoded_query, items: data});
|
||||
}
|
||||
const slug = getters.getEventSlug;
|
||||
if (Object.keys(state.loadedTicketSearchResults).includes(slug + '/' + encoded_query)) return;
|
||||
const {
|
||||
data, success
|
||||
} = await getters.session.get(`/2/${slug}/tickets/${encoded_query}/`);
|
||||
if (data && success) commit('setTicketSearchResults', {slug, query: encoded_query, items: data});
|
||||
if (slug === 'all') {
|
||||
await Promise.all(state.events.map(e => load(e.slug)));
|
||||
} else {
|
||||
await load(slug);
|
||||
}
|
||||
},
|
||||
async sendMail({commit, dispatch, state, getters}, {id, message}) {
|
||||
const {data, success} = await getters.session.post(`/2/tickets/${id}/reply/`, {message},
|
||||
|
@ -528,6 +563,14 @@ const store = createStore({
|
|||
state.fetchedData.tickets = 0;
|
||||
await Promise.all([dispatch('loadTickets'), dispatch('fetchShippingVouchers')]);
|
||||
}
|
||||
},
|
||||
async linkTicketItem({dispatch, state, getters}, {ticket_id, item_id}) {
|
||||
const {data, success} = await getters.session.post(`/2/matches/`, {issue_thread: ticket_id, item: item_id});
|
||||
if (data && success) {
|
||||
state.fetchedData.tickets = 0;
|
||||
state.fetchedData.items = 0;
|
||||
await Promise.all([dispatch('loadTickets'), dispatch('loadEventItems')]);
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [persistentStatePlugin({ // TODO change remember to some kind of enable field
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<textarea placeholder="add comment..." v-model="newComment"
|
||||
class="form-control">
|
||||
</textarea>
|
||||
<AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
|
||||
<AsyncButton class="btn btn-secondary float-right" :task="addCommentAndClear" :disabled="!newComment">
|
||||
<font-awesome-icon icon="comment"/>
|
||||
Save Comment
|
||||
</AsyncButton>
|
||||
|
@ -25,7 +25,7 @@
|
|||
</div>
|
||||
</template>
|
||||
<template v-slot:timeline_action2>
|
||||
<span class="timeline-item-icon | faded-icon">
|
||||
<span class="timeline-item-icon | filled-icon">
|
||||
<font-awesome-icon icon="envelope"/>
|
||||
</span>
|
||||
<div class="new-mail card bg-dark">
|
||||
|
@ -35,7 +35,7 @@
|
|||
<div>
|
||||
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
|
||||
</textarea>
|
||||
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear">
|
||||
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear" :disabled="!newMail">
|
||||
<font-awesome-icon icon="envelope"/>
|
||||
Send Mail
|
||||
</AsyncButton>
|
||||
|
@ -81,6 +81,13 @@
|
|||
<font-awesome-icon icon="clipboard"/>
|
||||
Copy DHL contact to clipboard
|
||||
</ClipboardButton>
|
||||
<div class="btn-group">
|
||||
<input type="text" class="form-control" v-model="item_id">
|
||||
<button class="form-control btn btn-success" :disabled="!item_id"
|
||||
@click="linkTicketItem({ticket_id: ticket.id, item_id: parseInt(item_id)}).then(()=>item_id='')">
|
||||
Link Item
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<select class="form-control" v-model="shipping_voucher_type">
|
||||
<option v-for="type in availableShippingVoucherTypes.filter(t=>t.count>0)"
|
||||
|
@ -141,6 +148,7 @@ export default {
|
|||
selected_state: null,
|
||||
selected_assignee: null,
|
||||
shipping_voucher_type: null,
|
||||
item_id: "",
|
||||
newMail: "",
|
||||
newComment: ""
|
||||
}
|
||||
|
@ -166,6 +174,7 @@ export default {
|
|||
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
|
||||
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
|
||||
...mapActions(['claimShippingVoucher', 'fetchShippingVouchers']),
|
||||
...mapActions(['linkTicketItem']),
|
||||
...mapMutations(['openLightboxModalWith']),
|
||||
changeTicketStatus() {
|
||||
this.ticket.state = this.selected_state;
|
||||
|
@ -198,10 +207,10 @@ export default {
|
|||
},
|
||||
mounted() {
|
||||
this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(() => {
|
||||
if (this.ticket.state === "pending_new") {
|
||||
this.selected_state = "pending_open";
|
||||
this.changeTicketStatus()
|
||||
}
|
||||
//if (this.ticket.state === "pending_new") {
|
||||
// this.selected_state = "pending_open";
|
||||
// this.changeTicketStatus()
|
||||
//}
|
||||
this.selected_state = this.ticket.state;
|
||||
this.selected_assignee = this.ticket.assigned_to
|
||||
})]);
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
:columns="['id', 'name', 'last_activity', 'assigned_to',
|
||||
...(getEventSlug==='all'?['event']:[])]"
|
||||
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
|
||||
'pending_physical_confirmation','pending_return','pending_postponed'].map(stateInfo)">
|
||||
'pending_physical_confirmation','pending_return','pending_postponed','pending_suspected_spam'].map(stateInfo)">
|
||||
<template #section_header="{index, section, count}">
|
||||
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
|
||||
</template>
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
:columns="['id', 'name', 'last_activity', 'assigned_to',
|
||||
...(getEventSlug==='all'?['event']:[])]"
|
||||
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
|
||||
'pending_physical_confirmation','pending_return','pending_postponed'].map(stateInfo)">
|
||||
'pending_physical_confirmation','pending_return','pending_postponed','pending_suspected_spam'].map(stateInfo)">
|
||||
<template #section_header="{index, section, count}">
|
||||
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
|
||||
</template>
|
||||
|
|
Loading…
Add table
Reference in a new issue