Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
9ac1e9706f added a check in the make_reply function to ensure that mails have a body
All checks were successful
/ test (pull_request) Successful in 2m36s
/ test (push) Successful in 2m42s
/ deploy (push) Successful in 5m29s
2025-01-27 20:17:50 +01:00
89707113bb Disable send mail button when there is no text 2025-01-27 20:17:29 +01:00
5565e5f5d0 The Comment button is now disabled when there is no text and the AsyncButton can now be disabled without setting it to inProgress 2025-01-27 20:17:23 +01:00
9395226c5f disable automatic ticket state change
Some checks failed
/ deploy (push) Blocked by required conditions
/ test (push) Has been cancelled
2025-01-27 20:01:31 +01:00
c26152d3c5 fix frontend bug in ticket view
All checks were successful
/ test (push) Successful in 2m36s
2025-01-27 19:57:22 +01:00
70516db074 ~ change "the algorithm" ~
All checks were successful
/ test (push) Successful in 2m31s
2025-01-26 20:03:06 +01:00
2677f4b8b6 link item to ticket frontend
All checks were successful
/ test (push) Successful in 2m44s
2025-01-26 19:56:25 +01:00
fbbf8352cf don't report "Internal Server Error" if mail already exists
All checks were successful
/ test (push) Successful in 2m50s
/ deploy (push) Successful in 7m3s
2025-01-20 19:43:01 +01:00
4ea74637a3 finally get a grip on utf-8
All checks were successful
/ deploy (push) Successful in 4m24s
/ test (push) Successful in 2m26s
2025-01-20 18:30:42 +01:00
f133ae9e60 allow searching while "all" event is selected 2025-01-20 17:39:13 +01:00
0fa52645c2 handle plain base64 as transfer-encoding in incoming mails
All checks were successful
/ test (push) Successful in 2m31s
/ deploy (push) Successful in 3m19s
2025-01-18 22:15:04 +01:00
65755feb2f add UI for backend search
All checks were successful
/ test (push) Successful in 2m25s
/ deploy (push) Successful in 3m19s
2025-01-13 16:29:37 +01:00
ebb13b2a85 fix bug in tickets search api and add tests 2025-01-13 16:29:37 +01:00
5f0d9b8626 add more tests for encodings
All checks were successful
/ test (push) Successful in 2m52s
2025-01-12 13:29:44 +01:00
3635a55e39 fix error in mail parsing: not all 'inline' elements ara attachments
All checks were successful
/ test (push) Successful in 2m43s
2025-01-09 17:19:31 +01:00
33 changed files with 10387 additions and 101 deletions

View file

@ -39,13 +39,61 @@ class ItemViewSet(viewsets.ModelViewSet):
def filter_items(items, query): def filter_items(items, query):
query_tokens = query.split(' ') query_tokens = query.split(' ')
matches = []
for item in items: for item in items:
value = 0 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: for token in query_tokens:
if token in item.description: if token in item.description:
value += 1 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: if value > 0:
yield {'search_score': value, 'item': item} yield {'search_score': value, 'item': item, 'search_matches': matches}
@api_view(['GET']) @api_view(['GET'])

View file

@ -137,10 +137,12 @@ class ItemSerializer(BasicItemSerializer):
class SearchResultSerializer(serializers.Serializer): class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField() search_score = serializers.IntegerField()
search_matches = serializers.ListField(child=serializers.DictField())
item = ItemSerializer() item = ItemSerializer()
def to_representation(self, instance): 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: class Meta:
model = Item model = Item

View file

@ -0,0 +1,26 @@
Subject: test
From: test1@test
To: test2@test
Message-ID: <1@test>
Content-Type: multipart/alternative; boundary="abc"
--abc
Content-Type: text/plain; charset=utf-8
test1
--abc
Content-Type: image/jpeg; name="test.jpg"
Content-Disposition: inline; filename="test.jpg"
Content-Transfer-Encoding: base64
Content-ID: <1>
X-Attachment-Id: 1
dGVzdGltYWdl
--abc
Content-Type: text/plain; charset=utf-8
test2
--abc--

View file

@ -0,0 +1,5 @@
Subject: test
From: <test1@test>
To: test2@test
Message-ID: <1@test>

View file

@ -0,0 +1,8 @@
Subject: test
From: test1@test
To: test2@test
Message-ID: <1@test>
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
hello =C3=A4=C3=B6=C3=BC

View file

@ -0,0 +1,6 @@
Subject: test
From: test3@test
To: test4@localhost
Message-ID: <1@test>
test

View file

@ -0,0 +1,6 @@
Subject: =?UTF-8?Q?suche_M=C3=BCtze?=
From: test3@test
To: test4@test
Message-ID: <1@test>
Text mit Quoted-Printable-Kodierung: =?utf-8?Q?=C3=A4=C3=B6=C3=BC=C3=9F?=

View file

@ -0,0 +1,7 @@
Subject: test
From: test1@test
To: test2@test
Message-ID: <1@test>
Content-Type: text/html; charset=iso-8859-1
hello äöü

View file

@ -0,0 +1,21 @@
Subject: test
From: test1@test
To: test2@test
Message-ID: <1@test>
Content-Type: multipart/mixed; boundary="abc"
--abc
Content-Type: text/plain; charset=utf-8
test1
--abc
Content-Type: image/jpeg; name="test.jpg"
Content-Disposition: attachment; filename="test.jpg"
Content-Transfer-Encoding: base64
Content-ID: <1>
X-Attachment-Id: 1
dGVzdGltYWdl
--abc--

View file

@ -0,0 +1,11 @@
Subject: test
From: test1@test
To: test2@test
Message-ID: <1@test>
Content-Type: text/html; charset=utf-8
<div>
<div>
<p>test</p>
</div>
</div>

View file

@ -0,0 +1,6 @@
Subject: test =?utf-8?Q?=C3=A4?=
From: test3@test
To: test4@test
Message-ID: <1@test>
Text mit Quoted-Printable-Kodierung: =?utf-8?Q?=C3=A4=C3=B6=C3=BC=C3=9F?=

View file

@ -0,0 +1,6 @@
Subject: =?utf-8?B?dGVzdA==?=
From: test3@test
To: test4@test
Message-ID: <1@test>
Text mit Base64-Kodierung: =?utf-8?B?w6TDtsO8w58=?=

View file

@ -0,0 +1,6 @@
Subject: test
From: test1@test
To: test_event@localhost
Message-ID: <1@test>
test

View file

@ -0,0 +1,5 @@
From: noreply@test
To: test2@test
Message-ID: <1@test>
test

View file

@ -0,0 +1,7 @@
Subject: test
From: test1@test
To: test2@test
Message-ID: <1@test>
Content-Type: text/html; charset=utf-8
thank you =?utf-8?Q?=F0=9F=98=8A?=

View file

@ -48,9 +48,21 @@ def unescape_and_decode_base64(s):
return decoded return decoded
def unescape_simplified_quoted_printable(s): def unescape_simplified_quoted_printable(s, encoding='utf-8'):
import quopri import quopri
return quopri.decodestring(s).decode('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
return ''.join([c for c in str(s) if 128 > ord(c) > 31])
def collect_references(issue_thread): def collect_references(issue_thread):
@ -81,11 +93,11 @@ def make_reply(reply_email, references=None, event=None):
reply_email.save() reply_email.save()
if references: if references:
reply["References"] = " ".join(references) reply["References"] = " ".join(references)
if reply_email.body != "":
reply.set_content(reply_email.body) reply.set_content(reply_email.body)
return reply return reply
else:
raise SpecialMailException("mail content emty")
async def send_smtp(message): async def send_smtp(message):
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False) await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
@ -116,6 +128,22 @@ def find_target_event(address):
return None return None
def decode_email_segment(segment, charset, transfer_encoding):
decode_as = 'utf-8'
if charset == 'windows-1251':
decode_as = 'cp1251'
elif charset == 'iso-8859-1':
decode_as = 'latin1'
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
def parse_email_body(raw, log=None): def parse_email_body(raw, log=None):
import email import email
from hashlib import sha256 from hashlib import sha256
@ -127,21 +155,24 @@ def parse_email_body(raw, log=None):
if parsed.is_multipart(): if parsed.is_multipart():
for part in parsed.walk(): for part in parsed.walk():
ctype = part.get_content_type() ctype = part.get_content_type()
charset = part.get_content_charset()
cdispo = str(part.get('Content-Disposition')) cdispo = str(part.get('Content-Disposition'))
if ctype == 'multipart/mixed':
log.debug("Ignoring Multipart %s %s", ctype, cdispo)
# skip any text/plain (txt) attachments # skip any text/plain (txt) attachments
if ctype == 'text/plain' and 'attachment' not in cdispo: elif ctype == 'text/plain' and 'attachment' not in cdispo:
segment = part.get_payload() segment = part.get_payload()
if not segment: if not segment:
continue continue
segment = unescape_and_decode_quoted_printable(segment) segment = decode_email_segment(segment.encode('utf-8'), charset, part.get('Content-Transfer-Encoding'))
segment = unescape_and_decode_base64(segment)
if part.get('Content-Transfer-Encoding') == 'quoted-printable':
segment = unescape_simplified_quoted_printable(segment)
log.debug(segment) log.debug(segment)
body = body + segment body = body + segment
elif 'attachment' in cdispo or 'inline' in cdispo: elif 'attachment' in cdispo or 'inline' in cdispo:
file = ContentFile(part.get_payload(decode=True)) content = part.get_payload(decode=True)
if content is None:
continue
file = ContentFile(content)
chash = sha256(file.read()).hexdigest() chash = sha256(file.read()).hexdigest()
name = part.get_filename() name = part.get_filename()
if name is None: if name is None:
@ -167,10 +198,8 @@ def parse_email_body(raw, log=None):
else: else:
log.warning("Unknown content type %s", parsed.get_content_type()) log.warning("Unknown content type %s", parsed.get_content_type())
body = "Unknown content type" body = "Unknown content type"
body = unescape_and_decode_quoted_printable(body) body = decode_email_segment(body.encode('utf-8'), parsed.get_content_charset(),
body = unescape_and_decode_base64(body) parsed.get('Content-Transfer-Encoding'))
if parsed.get('Content-Transfer-Encoding') == 'quoted-printable':
body = unescape_simplified_quoted_printable(body)
log.debug(body) log.debug(body)
return parsed, body, attachments return parsed, body, attachments
@ -182,8 +211,8 @@ def receive_email(envelope, log=None):
header_from = parsed.get('From') header_from = parsed.get('From')
header_to = parsed.get('To') header_to = parsed.get('To')
header_in_reply_to = parsed.get('In-Reply-To') header_in_reply_to = ascii_strip(parsed.get('In-Reply-To'))
header_message_id = parsed.get('Message-ID') header_message_id = ascii_strip(parsed.get('Message-ID'))
if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "": if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "":
log.warning("Ignoring mailer daemon") log.warning("Ignoring mailer daemon")
@ -191,15 +220,16 @@ def receive_email(envelope, log=None):
if Email.objects.filter(reference=header_message_id).exists(): # break before issue thread is created if Email.objects.filter(reference=header_message_id).exists(): # break before issue thread is created
log.warning("Email already exists") 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() 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 sender = envelope.mail_from if envelope.mail_from else header_from
subject = parsed.get('Subject') subject = ascii_strip(parsed.get('Subject'))
if not subject: if not subject:
subject = "No subject" subject = "No subject"
subject = unescape_and_decode_quoted_printable(subject) subject = decode_inline_encodings(subject)
subject = unescape_and_decode_base64(subject) recipient = decode_inline_encodings(recipient)
sender = decode_inline_encodings(sender)
target_event = find_target_event(recipient) 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)
@ -209,7 +239,8 @@ def receive_email(envelope, log=None):
email = Email.objects.create( email = Email.objects.create(
sender=sender, recipient=recipient, body=body, subject=subject, reference=header_message_id, sender=sender, recipient=recipient, body=body, subject=subject, reference=header_message_id,
in_reply_to=header_in_reply_to, raw_file=ContentFile(envelope.content, name=random_filename), event=target_event, in_reply_to=header_in_reply_to, raw_file=ContentFile(envelope.content, name=random_filename),
event=target_event,
issue_thread=active_issue_thread) issue_thread=active_issue_thread)
for attachment in attachments: for attachment in attachments:
email.attachments.add(attachment) email.attachments.add(attachment)
@ -268,10 +299,10 @@ class LMTPHandler:
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received', systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
reference=email.id) reference=email.id)
log.info(f"Created system event {systemevent.id}") log.info(f"Created system event {systemevent.id}")
channel_layer = get_channel_layer() #channel_layer = get_channel_layer()
await channel_layer.group_send( #await channel_layer.group_send(
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id, # 'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
"message": "email received"}) # "message": "email received"})
log.info(f"Sent message to frontend") log.info(f"Sent message to frontend")
if new and reply: if new and reply:
log.info('Sending message to %s' % reply['To']) log.info('Sending message to %s' % reply['To'])

View file

@ -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.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue(Email.objects.all()[0].raw_file.path) 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 aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
import aiosmtplib 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.assertEqual('Text mit Base64-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue(Email.objects.all()[0].raw_file.path) 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): def test_handle_client_reply(self):
issue_thread = IssueThread.objects.create( issue_thread = IssueThread.objects.create(
name="test", name="test",
@ -887,6 +916,59 @@ hello \xe4\xf6\xfc'''
self.assertEqual(1, len(states)) self.assertEqual(1, len(states))
self.assertEqual('pending_new', states[0].state) self.assertEqual('pending_new', states[0].state)
def test_mail_windows_1252(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>
Content-Type: text/html; charset=windows-1252
Content-Transfer-Encoding: quoted-printable
=0D=0Ahello='''
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
self.assertEqual('250 Message accepted for delivery', result)
self.assertEqual(2, len(Email.objects.all()))
self.assertEqual(1, len(IssueThread.objects.all()))
aiosmtplib.send.assert_called_once()
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('\r\nhello', 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(expected_auto_reply_subject.format('test', IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].subject)
self.assertEqual('test2@test', Email.objects.all()[1].sender)
self.assertEqual('test1@test', Email.objects.all()[1].recipient)
self.assertEqual(expected_auto_reply.format(IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].body)
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[1].issue_thread)
self.assertTrue(Email.objects.all()[1].reference.startswith("<"))
self.assertTrue(Email.objects.all()[1].reference.endswith("@localhost>"))
self.assertEqual("<1@test>", Email.objects.all()[1].in_reply_to)
self.assertEqual('test', IssueThread.objects.all()[0].name)
self.assertEqual('pending_new', 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_new', states[0].state)
def test_mail_quoted_printable_transfer_encoding(self): def test_mail_quoted_printable_transfer_encoding(self):
from aiosmtpd.smtp import Envelope from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
@ -939,3 +1021,146 @@ hello =C3=A4=C3=B6=C3=BC'''
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0]) states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
self.assertEqual(1, len(states)) self.assertEqual(1, len(states))
self.assertEqual('pending_new', states[0].state) self.assertEqual('pending_new', states[0].state)
def test_text_with_attachment2(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>
Content-Type: multipart/mixed; boundary="abc"
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
--abc
Content-Type: text/plain; charset=utf-8
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
test1
--abc
Content-Type: image/jpeg; name="test.jpg"
Content-Disposition: attachment; filename="test.jpg"
Content-Transfer-Encoding: base64
Content-ID: <1>
X-Attachment-Id: 1
dGVzdGltYWdl
--abc--'''
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('test1@test', Email.objects.all()[0].sender)
self.assertEqual('test2@test', Email.objects.all()[0].recipient)
self.assertEqual('test1\n', 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(expected_auto_reply_subject.format('test', IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].subject)
self.assertEqual('test2@test', Email.objects.all()[1].sender)
self.assertEqual('test1@test', Email.objects.all()[1].recipient)
self.assertEqual(expected_auto_reply.format(IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].body)
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[1].issue_thread)
self.assertTrue(Email.objects.all()[1].reference.startswith("<"))
self.assertTrue(Email.objects.all()[1].reference.endswith("@localhost>"))
self.assertEqual("<1@test>", Email.objects.all()[1].in_reply_to)
self.assertEqual('test', IssueThread.objects.all()[0].name)
self.assertEqual('pending_new', 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_new', states[0].state)
self.assertEqual(1, len(EmailAttachment.objects.all()))
self.assertEqual(1, EmailAttachment.objects.all()[0].id)
self.assertEqual('image/jpeg', EmailAttachment.objects.all()[0].mime_type)
self.assertEqual('test.jpg', EmailAttachment.objects.all()[0].name)
file_content = EmailAttachment.objects.all()[0].file.read()
self.assertEqual(b'testimage', file_content)
def test_text_non_utf8_in_multipart(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>
Content-Type: multipart/alternative; boundary="abc"
--abc
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 8bit
test1
--abc
Content-Type: text/plain; charset=iso-8859-1
Content-Transfer-Encoding: quoted-printable
hello =E4
--abc
Content-Type: text/plain; charset=windows-1252
Content-Transfer-Encoding: quoted-printable
=0D=0Ahello
--abc--'''
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('test1@test', Email.objects.all()[0].sender)
self.assertEqual('test2@test', Email.objects.all()[0].recipient)
self.assertEqual('test1\nhello ä\n\r\nhello\n', 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(expected_auto_reply_subject.format('test', IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].subject)
self.assertEqual('test2@test', Email.objects.all()[1].sender)
self.assertEqual('test1@test', Email.objects.all()[1].recipient)
self.assertEqual(expected_auto_reply.format(IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].body)
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[1].issue_thread)
self.assertTrue(Email.objects.all()[1].reference.startswith("<"))
self.assertTrue(Email.objects.all()[1].reference.endswith("@localhost>"))
self.assertEqual("<1@test>", Email.objects.all()[1].in_reply_to)
self.assertEqual('test', IssueThread.objects.all()[0].name)
self.assertEqual('pending_new', 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_new', states[0].state)

0
core/testdata.py Normal file
View file

View file

@ -102,12 +102,6 @@ def manual_ticket(request, event_slug):
subject=request.data['name'], subject=request.data['name'],
body=request.data['body'], 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) return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
@ -133,24 +127,75 @@ def add_comment(request, pk):
issue_thread=issue, issue_thread=issue,
comment=request.data['comment'], 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) return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
def filter_issues(issues, query): def filter_issues(issues, query):
query_tokens = query.split(' ') query_tokens = query.lower().split(' ')
matches = []
for issue in issues: for issue in issues:
value = 0 value = 0
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 += 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: for token in query_tokens:
if token in issue.description: if token in item.description.lower():
value += 1 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: if value > 0:
yield {'search_score': value, 'issue': issue} yield {'search_score': value, 'issue': issue, 'search_matches': matches}
@api_view(['GET']) @api_view(['GET'])
@ -160,7 +205,10 @@ def search_issues(request, event_slug, query):
event = Event.objects.get(slug=event_slug) event = Event.objects.get(slug=event_slug)
if not request.user.has_event_perm(event, 'view_issuethread'): if not request.user.has_event_perm(event, 'view_issuethread'):
return Response(status=403) return Response(status=403)
items = filter_issues(IssueThread.objects.filter(event=event), b64decode(query).decode('utf-8')) serializer = IssueSerializer()
queryset = IssueThread.objects.filter(event=event)
items = filter_issues(queryset.prefetch_related(*serializer.Meta.prefetch_related_fields),
b64decode(query).decode('utf-8'))
return Response(SearchResultSerializer(items, many=True).data) return Response(SearchResultSerializer(items, many=True).data)
except Event.DoesNotExist: except Event.DoesNotExist:
return Response(status=404) return Response(status=404)

View file

@ -139,10 +139,12 @@ class IssueSerializer(BasicIssueSerializer):
class SearchResultSerializer(serializers.Serializer): class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField() search_score = serializers.IntegerField()
item = IssueSerializer() search_matches = serializers.ListField(child=serializers.DictField())
issue = IssueSerializer()
def to_representation(self, instance): def to_representation(self, instance):
return {**IssueSerializer(instance['item']).data, 'search_score': instance['search_score']} return {**IssueSerializer(instance['issue']).data, 'search_score': instance['search_score'],
'search_matches': instance['search_matches']}
class Meta: class Meta:
model = IssueThread model = IssueThread

View file

@ -9,6 +9,7 @@ class RelationSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemRelation model = ItemRelation
fields = ('id', 'status', 'timestamp', 'item', 'issue_thread') fields = ('id', 'status', 'timestamp', 'item', 'issue_thread')
read_only_fields = ('id', 'timestamp')
class BasicIssueSerializer(serializers.ModelSerializer): class BasicIssueSerializer(serializers.ModelSerializer):

View file

@ -4,6 +4,7 @@ from django.test import TestCase, Client
from authentication.models import ExtendedUser from authentication.models import ExtendedUser
from inventory.models import Event, Container, Item from inventory.models import Event, Container, Item
from inventory.models import Comment as ItemComment
from mail.models import Email, EmailAttachment from mail.models import Email, EmailAttachment
from tickets.models import IssueThread, StateChange, Comment, ItemRelation, Assignment from tickets.models import IssueThread, StateChange, Comment, ItemRelation, Assignment
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@ -383,15 +384,108 @@ class IssueSearchTest(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event')
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all()) self.user.user_permissions.add(*Permission.objects.all())
self.user.save() self.user.save()
self.event = Event.objects.create(slug='EVENT', name='Event')
self.box = Container.objects.create(name='box1')
self.item = Item.objects.create(container=self.box, description="foo", event=self.event)
self.token = AuthToken.objects.create(user=self.user) self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
def test_search(self): def test_search_empty_result(self):
search_query = b64encode(b'abc').decode('utf-8') search_query = b64encode(b'abc').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/') response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code) self.assertEqual(200, response.status_code)
self.assertEqual([], response.json()) self.assertEqual([], response.json())
def test_search(self):
now = datetime.now()
issue = IssueThread.objects.create(
name="test issue Abc",
event=self.event,
)
mail1 = Email.objects.create(
subject='test',
body='test aBc',
sender='bar@test',
recipient='2@test',
issue_thread=issue,
timestamp=now,
)
mail2 = Email.objects.create(
subject='Re: test',
body='test',
sender='2@test',
recipient='1@test',
issue_thread=issue,
in_reply_to=mail1.reference,
timestamp=now + timedelta(seconds=2),
)
assignment = Assignment.objects.create(
issue_thread=issue,
assigned_to=self.user,
timestamp=now + timedelta(seconds=3),
)
comment = Comment.objects.create(
issue_thread=issue,
comment="test deF",
timestamp=now + timedelta(seconds=4),
)
match = ItemRelation.objects.create(
issue_thread=issue,
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)
self.assertEqual(1, len(response.json()))
self.assertEqual(issue.id, response.json()[0]['id'])
score2 = response.json()[0]['search_score']
search_query = b64encode(b'dEf').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'])
score1 = response.json()[0]['search_score']
search_query = b64encode(b'ghi').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(0, len(response.json()))
search_query = b64encode(b'Abc def').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'])
score3 = response.json()[0]['search_score']
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'])

View file

@ -7,6 +7,7 @@ services:
environment: environment:
- HTTP_HOST=core - HTTP_HOST=core
- DB_FILE=dev.db - DB_FILE=dev.db
- DEBUG_MODE_ACTIVE=true
volumes: volumes:
- ../../core:/code - ../../core:/code
- ../testdata.py:/code/testdata.py - ../testdata.py:/code/testdata.py

9399
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -115,10 +115,10 @@ export default {
this.$router.push(link); this.$router.push(link);
}, },
isItemView() { isItemView() {
return this.getActiveView === 'items' || this.getActiveView === 'item'; return this.getActiveView === 'items' || this.getActiveView === 'item' || this.getActiveView === 'item_search';
}, },
isTicketView() { isTicketView() {
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket'; return this.getActiveView === 'tickets' || this.getActiveView === 'ticket' || this.getActiveView === 'ticket_search';
}, },
setLayout(layout) { setLayout(layout) {
if (this.route.query.layout === layout) if (this.route.query.layout === layout)

View file

@ -1,9 +1,9 @@
<template> <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" <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"
:class="{'d-none': !disabled}"></span> :class="{'d-none': !inProgress}"></span>
<span class="ml-2" :class="{'d-none': !disabled}">In Progress...</span> <span class="ml-2" :class="{'d-none': !inProgress}">In Progress...</span>
<span :class="{'d-none': disabled}"><slot></slot></span> <span :class="{'d-none': inProgress}"><slot></slot></span>
</button> </button>
</template> </template>
@ -13,7 +13,7 @@ export default {
name: 'AsyncButton', name: 'AsyncButton',
data() { data() {
return { return {
disabled: false, inProgress: false,
}; };
}, },
props: { props: {
@ -21,17 +21,21 @@ export default {
type: Function, type: Function,
required: true, required: true,
}, },
disabled: {
type: Boolean,
required: false,
},
}, },
methods: { methods: {
async handleClick() { async handleClick() {
if (this.task && typeof this.task === 'function') { if (this.task && typeof this.task === 'function') {
this.disabled = true; this.inProgress = true;
try { try {
await this.task(); await this.task();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
this.disabled = false; this.inProgress = false;
} }
} }
}, },

View file

@ -12,6 +12,7 @@
<script> <script>
import {mapActions, mapGetters} from "vuex"; import {mapActions, mapGetters} from "vuex";
import router from "@/router";
export default { export default {
name: 'SearchBox', name: 'SearchBox',
@ -21,21 +22,34 @@ export default {
} }
}, },
computed: { computed: {
...mapGetters(['getActiveView']) ...mapGetters(['getActiveView', 'route']),
},
watch: {
route() {
this.search_query = this.route.params.search || '';
}
}, },
methods: { methods: {
...mapActions(['searchEventItems', 'searchEventTickets']), ...mapActions(['searchEventItems', 'searchEventTickets']),
isItemView() { isItemView() {
return this.getActiveView === 'items' || this.getActiveView === 'item'; return this.getActiveView === 'items' || this.getActiveView === 'item' || this.getActiveView === 'item_search';
}, },
isTicketView() { isTicketView() {
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket'; return this.getActiveView === 'tickets' || this.getActiveView === 'ticket' || this.getActiveView === 'ticket_search';
}, },
dispatchSearch() { dispatchSearch() {
if (this.isItemView()) { if (this.isItemView()) {
this.searchEventItems(this.search_query); router.push({
name: "item_search",
query: this.route.query,
params: {...this.route.params, search: this.search_query}
});
} else if (this.isTicketView()) { } else if (this.isTicketView()) {
this.searchEventTickets(this.search_query); router.push({
name: "ticket_search",
query: this.route.query,
params: {...this.route.params, search: this.search_query}
});
} }
} }
} }

View file

@ -2,6 +2,7 @@ import {createRouter, createWebHistory} from 'vue-router'
import store from '@/store'; import store from '@/store';
import Item from "@/views/Item.vue"; import Item from "@/views/Item.vue";
import ItemSearch from "@/views/ItemSearch.vue";
import Items from '@/views/Items'; import Items from '@/views/Items';
import Boxes from '@/views/Boxes'; import Boxes from '@/views/Boxes';
import Files from '@/views/Files'; import Files from '@/views/Files';
@ -10,6 +11,7 @@ import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue'; import Register from '@/views/Register.vue';
import Dashboard from "@/views/admin/Dashboard.vue"; import Dashboard from "@/views/admin/Dashboard.vue";
import Tickets from "@/views/Tickets.vue"; import Tickets from "@/views/Tickets.vue";
import TicketSearch from "@/views/TicketSearch.vue";
import Ticket from "@/views/Ticket.vue"; import Ticket from "@/views/Ticket.vue";
import Admin from "@/views/admin/Admin.vue"; import Admin from "@/views/admin/Admin.vue";
import Empty from "@/views/Empty.vue"; import Empty from "@/views/Empty.vue";
@ -27,6 +29,10 @@ const routes = [
path: '/:event/items/', name: 'items', component: Items, meta: path: '/:event/items/', name: 'items', component: Items, meta:
{requiresAuth: true, requiresPermission: 'view_item'} {requiresAuth: true, requiresPermission: 'view_item'}
}, },
{
path: '/:event/items/:search', name: 'item_search', component: ItemSearch, meta:
{requiresAuth: true, requiresPermission: 'view_item'}
},
{ {
path: '/:event/item/:id/', name: 'item', component: Item, meta: path: '/:event/item/:id/', name: 'item', component: Item, meta:
{requiresAuth: true, requiresPermission: 'view_item'} {requiresAuth: true, requiresPermission: 'view_item'}
@ -43,6 +49,10 @@ const routes = [
path: '/:event/tickets/', name: 'tickets', component: Tickets, meta: path: '/:event/tickets/', name: 'tickets', component: Tickets, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'} {requiresAuth: true, requiresPermission: 'view_issuethread'}
}, },
{
path: '/:event/tickets/:search', name: 'ticket_search', component: TicketSearch, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'}
},
{ {
path: '/:event/ticket/:id/', name: 'ticket', component: Ticket, meta: path: '/:event/ticket/:id/', name: 'ticket', component: Ticket, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'} {requiresAuth: true, requiresPermission: 'view_issuethread'}

View file

@ -9,6 +9,7 @@ import persistentStatePlugin from "@/persistent-state-plugin";
const store = createStore({ const store = createStore({
state: { state: {
//shared
keyIncrement: 0, keyIncrement: 0,
events: [], events: [],
items: [], items: [],
@ -22,9 +23,12 @@ const store = createStore({
loadedItems: {}, loadedItems: {},
loadedTickets: {}, loadedTickets: {},
loadedItemSearchResults: {},
loadedTicketSearchResults: {},
//local
lastEvent: 'all', lastEvent: 'all',
lastUsed: {}, lastUsed: {},
searchQuery: '',
remember: false, remember: false,
user: { user: {
username: null, username: null,
@ -57,7 +61,6 @@ const store = createStore({
'2kg-de': '2kg Paket (DE)', '2kg-de': '2kg Paket (DE)',
'5kg-de': '5kg Paket (DE)', '5kg-de': '5kg Paket (DE)',
'10kg-de': '10kg Paket (DE)', '10kg-de': '10kg Paket (DE)',
'2kg-eu': '2kg Paket (EU)',
'5kg-eu': '5kg Paket (EU)', '5kg-eu': '5kg Paket (EU)',
'10kg-eu': '10kg Paket (EU)', '10kg-eu': '10kg Paket (EU)',
} }
@ -66,12 +69,33 @@ const store = createStore({
route: state => router.currentRoute.value, route: state => router.currentRoute.value,
session: state => http_session(state.user.token), session: state => http_session(state.user.token),
getEventSlug: state => router.currentRoute.value.params.event ? router.currentRoute.value.params.event : state.lastEvent, getEventSlug: state => router.currentRoute.value.params.event ? router.currentRoute.value.params.event : state.lastEvent,
searchQuery: state => router.currentRoute.value.params.search,
getAllItems: state => Object.values(state.loadedItems).flat(), getAllItems: state => Object.values(state.loadedItems).flat(),
getAllTickets: state => Object.values(state.loadedTickets).flat(), getAllTickets: state => Object.values(state.loadedTickets).flat(),
getEventItems: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllItems : getters.getAllItems.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')), getEventItems: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllItems : getters.getAllItems.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
getEventTickets: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllTickets : getters.getAllTickets.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')), 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), 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), isTicketsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedTickets : Object.keys(state.loadedTickets).includes(getters.getEventSlug),
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', getActiveView: state => router.currentRoute.value.name || 'items',
getFilters: state => router.currentRoute.value.query, getFilters: state => router.currentRoute.value.query,
getBoxes: state => state.loadedBoxes, getBoxes: state => state.loadedBoxes,
@ -106,9 +130,9 @@ const store = createStore({
layout: (state, getters) => { layout: (state, getters) => {
if (router.currentRoute.value.query.layout) if (router.currentRoute.value.query.layout)
return router.currentRoute.value.query.layout; return router.currentRoute.value.query.layout;
if (getters.getActiveView === 'items') if (getters.getActiveView === 'items' || getters.getActiveView === 'item_search')
return 'cards'; return 'cards';
if (getters.getActiveView === 'tickets') if (getters.getActiveView === 'tickets' || getters.getActiveView === 'ticket_search')
return 'tasks'; return 'tasks';
}, },
isLoggedIn(state) { isLoggedIn(state) {
@ -147,6 +171,10 @@ const store = createStore({
state.loadedItems[slug] = items; state.loadedItems[slug] = items;
state.loadedItems = {...state.loadedItems}; state.loadedItems = {...state.loadedItems};
}, },
setItemSearchResults(state, {slug, query, items}) {
state.loadedItemSearchResults[slug + '/' + query] = items;
state.loadedItemSearchResults = {...state.loadedItemSearchResults};
},
replaceItems(state, items) { replaceItems(state, items) {
const groups = Object.groupBy(items, i => i.event ? i.event : 'none') const groups = Object.groupBy(items, i => i.event ? i.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedItems[key] = value; for (const [key, value] of Object.entries(groups)) state.loadedItems[key] = value;
@ -167,6 +195,10 @@ const store = createStore({
state.loadedTickets[slug] = tickets; state.loadedTickets[slug] = tickets;
state.loadedTickets = {...state.loadedTickets}; state.loadedTickets = {...state.loadedTickets};
}, },
setTicketSearchResults(state, {slug, query, items}) {
state.loadedTicketSearchResults[slug + '/' + query] = items;
state.loadedTicketSearchResults = {...state.loadedTicketSearchResults};
},
replaceTickets(state, tickets) { replaceTickets(state, tickets) {
const groups = Object.groupBy(tickets, t => t.event ? t.event : 'none') const groups = Object.groupBy(tickets, t => t.event ? t.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedTickets[key] = value; for (const [key, value] of Object.entries(groups)) state.loadedTickets[key] = value;
@ -362,9 +394,8 @@ const store = createStore({
}, },
async loadEventItems({commit, getters, state}) { async loadEventItems({commit, getters, state}) {
if (!state.user.token) return; if (!state.user.token) return;
if (state.fetchedData.items > Date.now() - 1000 * 60 * 60 * 24) return; const load = async (slug) => {
try { try {
const slug = getters.getEventSlug;
const {data, success} = await getters.session.get(`/2/${slug}/items/`); const {data, success} = await getters.session.get(`/2/${slug}/items/`);
if (data && success) { if (data && success) {
commit('setItems', {slug, items: data}); commit('setItems', {slug, items: data});
@ -372,15 +403,30 @@ const store = createStore({
} catch (e) { } catch (e) {
console.error("Error loading items"); 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) { async searchEventItems({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query)); const encoded_query = base64.encode(utf8.encode(query));
const slug = getters.getEventSlug; const load = async (slug) => {
if (Object.keys(state.loadedItemSearchResults).includes(slug + '/' + encoded_query)) return;
const { const {
data, success data, success
} = await getters.session.get(`/2/${slug}/items/${encoded_query}/`); } = await getters.session.get(`/2/${slug}/items/${encoded_query}/`);
if (data && success) { if (data && success) {
commit('setItems', {slug, items: data}); commit('setItemSearchResults', {slug, query: encoded_query, items: data});
}
}
const slug = getters.getEventSlug;
if (slug === 'all') {
await Promise.all(state.events.map(e => load(e.slug)));
} else {
await load(slug);
} }
}, },
async loadBoxes({commit, state, getters}) { async loadBoxes({commit, state, getters}) {
@ -428,11 +474,19 @@ const store = createStore({
}, },
async searchEventTickets({commit, getters, state}, query) { async searchEventTickets({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query)); const encoded_query = base64.encode(utf8.encode(query));
const load = async (slug) => {
if (Object.keys(state.loadedTicketSearchResults).includes(slug + '/' + encoded_query)) return;
const { const {
data, success data, success
} = await getters.session.get(`/2/${getters.getEventSlug}/tickets/${encoded_query}/`); } = await getters.session.get(`/2/${slug}/tickets/${encoded_query}/`);
if (data && success) commit('replaceTickets', data); if (data && success) commit('setTicketSearchResults', {slug, query: encoded_query, items: data});
}
const slug = getters.getEventSlug;
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}) { async sendMail({commit, dispatch, state, getters}, {id, message}) {
const {data, success} = await getters.session.post(`/2/tickets/${id}/reply/`, {message}, const {data, success} = await getters.session.post(`/2/tickets/${id}/reply/`, {message},
@ -509,6 +563,14 @@ const store = createStore({
state.fetchedData.tickets = 0; state.fetchedData.tickets = 0;
await Promise.all([dispatch('loadTickets'), dispatch('fetchShippingVouchers')]); 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 plugins: [persistentStatePlugin({ // TODO change remember to some kind of enable field

View file

@ -0,0 +1,114 @@
<template>
<AsyncLoader :loaded="isItemsSearchLoaded">
<div class="container-fluid px-xl-5 mt-3">
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['id', 'description', 'box']"
:items="getItemsSearchResults"
:keyName="'id'"
@itemActivated="showItemDetail"
>
<template #actions="{ item }">
<div class="btn-group">
<button class="btn btn-success"
@click.stop="confirm('return Item?') && markItemReturned(item)"
title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-secondary" @click.stop="openEditingModalWith(item)" title="edit">
<font-awesome-icon icon="edit"/>
</button>
<button class="btn btn-danger" @click.stop="confirm('delete Item?') && deleteItem(item)"
title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div>
</template>
</Table>
</div>
</div>
<Cards
v-if="layout === 'cards'"
:columns="['id', 'description', 'box']"
:items="getItemsSearchResults"
:keyName="'id'"
v-slot="{ item }"
@itemActivated="item => openLightboxModalWith(item.file)"
>
<AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`"
class="card-img-top img-fluid"
/>
<div class="card-body">
<h6 class="card-title">{{ item.description }}</h6>
<h6 class="card-subtitle text-secondary">id: {{ item.id }} box: {{ item.box }}</h6>
<div class="row mx-auto mt-2">
<div class="btn-group">
<button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-outline-secondary" @click.stop="showItemDetail(item)"
title="edit">
<font-awesome-icon icon="edit"/>
</button>
<button class="btn btn-outline-danger"
@click.stop="confirm('delete Item?') && deleteItem(item)"
title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div>
</div>
</div>
</Cards>
</div>
</AsyncLoader>
</template>
<script>
import {mapActions, mapGetters, mapMutations, mapState} from 'vuex';
import Table from '@/components/Table';
import Cards from '@/components/Cards';
import Modal from '@/components/Modal';
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default {
name: 'Items',
data: () => ({
lightboxHash: null,
editingItem: null,
}),
components: {AsyncLoader, AuthenticatedImage, Table, Cards, Modal},
computed: {
...mapGetters(['getItemsSearchResults', 'isItemsSearchLoaded', 'layout', 'getEventSlug', 'searchQuery']),
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'searchEventItems', 'updateItem', 'scheduleAfterInit']),
...mapMutations(['openLightboxModalWith']),
showItemDetail(item) {
router.push({name: 'item', params: {id: item.id}});
},
confirm(message) {
return window.confirm(message);
}
},
watch: {
searchQuery() {
this.scheduleAfterInit(() => [this.searchEventItems(this.searchQuery)]);
},
getEventSlug() {
this.scheduleAfterInit(() => [this.searchEventItems(this.searchQuery)]);
}
},
mounted() {
this.scheduleAfterInit(() => [this.searchEventItems(this.searchQuery)]);
}
};
</script>
<style scoped>
</style>

View file

@ -17,7 +17,7 @@
<textarea placeholder="add comment..." v-model="newComment" <textarea placeholder="add comment..." v-model="newComment"
class="form-control"> class="form-control">
</textarea> </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"/> <font-awesome-icon icon="comment"/>
Save Comment Save Comment
</AsyncButton> </AsyncButton>
@ -25,7 +25,7 @@
</div> </div>
</template> </template>
<template v-slot:timeline_action2> <template v-slot:timeline_action2>
<span class="timeline-item-icon | faded-icon"> <span class="timeline-item-icon | filled-icon">
<font-awesome-icon icon="envelope"/> <font-awesome-icon icon="envelope"/>
</span> </span>
<div class="new-mail card bg-dark"> <div class="new-mail card bg-dark">
@ -35,7 +35,7 @@
<div> <div>
<textarea placeholder="reply mail..." v-model="newMail" class="form-control"> <textarea placeholder="reply mail..." v-model="newMail" class="form-control">
</textarea> </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"/> <font-awesome-icon icon="envelope"/>
Send Mail Send Mail
</AsyncButton> </AsyncButton>
@ -81,6 +81,13 @@
<font-awesome-icon icon="clipboard"/> <font-awesome-icon icon="clipboard"/>
Copy&nbsp;DHL&nbsp;contact&nbsp;to&nbsp;clipboard Copy&nbsp;DHL&nbsp;contact&nbsp;to&nbsp;clipboard
</ClipboardButton> </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&nbsp;Item
</button>
</div>
<div class="btn-group"> <div class="btn-group">
<select class="form-control" v-model="shipping_voucher_type"> <select class="form-control" v-model="shipping_voucher_type">
<option v-for="type in availableShippingVoucherTypes.filter(t=>t.count>0)" <option v-for="type in availableShippingVoucherTypes.filter(t=>t.count>0)"
@ -141,6 +148,7 @@ export default {
selected_state: null, selected_state: null,
selected_assignee: null, selected_assignee: null,
shipping_voucher_type: null, shipping_voucher_type: null,
item_id: "",
newMail: "", newMail: "",
newComment: "" newComment: ""
} }
@ -166,6 +174,7 @@ export default {
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']), ...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']), ...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
...mapActions(['claimShippingVoucher', 'fetchShippingVouchers']), ...mapActions(['claimShippingVoucher', 'fetchShippingVouchers']),
...mapActions(['linkTicketItem']),
...mapMutations(['openLightboxModalWith']), ...mapMutations(['openLightboxModalWith']),
changeTicketStatus() { changeTicketStatus() {
this.ticket.state = this.selected_state; this.ticket.state = this.selected_state;
@ -198,10 +207,10 @@ export default {
}, },
mounted() { mounted() {
this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(() => { this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(() => {
if (this.ticket.state === "pending_new") { //if (this.ticket.state === "pending_new") {
this.selected_state = "pending_open"; // this.selected_state = "pending_open";
this.changeTicketStatus() // this.changeTicketStatus()
} //}
this.selected_state = this.ticket.state; this.selected_state = this.ticket.state;
this.selected_assignee = this.ticket.assigned_to this.selected_assignee = this.ticket.assigned_to
})]); })]);

View file

@ -0,0 +1,102 @@
<template>
<AsyncLoader :loaded="isTicketsSearchLoaded">
<div class="container-fluid px-xl-5 mt-3">
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to',
...(getEventSlug==='all'?['event']:[])]"
:items="getTicketsSearchResults.map(formatTicket)"
:keyName="'id'"
>
<template v-slot:actions="{item}">
<div class="btn-group">
<router-link :to="{name: 'ticket', params: {id: item.id}}" class="btn btn-primary"
title="view">
<font-awesome-icon icon="eye"/>
View
</router-link>
</div>
</template>
</Table>
</div>
</div>
<CollapsableCards v-if="layout === 'tasks'" :items="getTicketsSearchResults"
: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)">
<template #section_header="{index, section, count}">
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
</template>
<template #section_body="{item}">
<tr>
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.last_activity }}</td>
<td>{{ item.assigned_to }}</td>
<td v-if="getEventSlug==='all'">{{ item.event }}</td>
<td>
<div class="btn-group">
<router-link :to="{name: 'ticket', params: {id: item.id}}" class="btn btn-primary"
title="view">
<font-awesome-icon icon="eye"/>
View
</router-link>
</div>
</td>
</tr>
</template>
</CollapsableCards>
</div>
</AsyncLoader>
</template>
<script>
import Cards from '@/components/Cards';
import {mapActions, mapGetters, mapState} from 'vuex';
import Table from '@/components/Table';
import CollapsableCards from "@/components/CollapsableCards.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default {
name: 'TicketSearch',
components: {AsyncLoader, Table, Cards, CollapsableCards},
computed: {
...mapGetters(['getTicketsSearchResults', 'isTicketsSearchLoaded', 'stateInfo', 'getEventSlug', 'layout', 'searchQuery']),
},
methods: {
...mapActions(['searchEventTickets', 'fetchTicketStates', 'scheduleAfterInit']),
gotoDetail(ticket) {
router.push({name: 'ticket', params: {id: ticket.id}});
},
formatTicket(ticket) {
return {
id: ticket.id,
name: ticket.name,
state: this.stateInfo(ticket.state).text,
stateColor: this.stateInfo(ticket.state).color,
last_activity: ticket.last_activity,
assigned_to: ticket.assigned_to,
event: ticket.event
};
}
},
watch: {
searchQuery() {
this.scheduleAfterInit(() => [this.searchEventTickets(this.searchQuery)]);
},
getEventSlug() {
this.scheduleAfterInit(() => [this.searchEventTickets(this.searchQuery)]);
}
},
mounted() {
this.scheduleAfterInit(() => [this.fetchTicketStates(), this.searchEventTickets(this.searchQuery)]);
}
};
</script>
<style scoped>
</style>