Compare commits

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

15 commits

Author SHA1 Message Date
6b0def543c remove duplicate code in files.models.FileManager
All checks were successful
/ test (push) Successful in 2m32s
/ deploy (push) Successful in 4m25s
2025-02-09 19:38:17 +01:00
1568252112 show all item timestamps in timeline
All checks were successful
/ test (push) Successful in 2m32s
/ deploy (push) Successful in 4m24s
2025-02-09 19:23:29 +01:00
9e0540d133 added a check in the make_reply function to ensure that mails have a body
All checks were successful
/ test (push) Successful in 2m42s
/ deploy (push) Successful in 5m38s
2025-02-09 17:24:42 +01:00
c2bcd53749 Disable send mail button when there is no text 2025-02-09 17:23:48 +01:00
86b4220eaa The Comment button is now disabled when there is no text and the AsyncButton can now be disabled without setting it to inProgress 2025-02-09 17:23:48 +01:00
13994a111e show pending_suspected_spam tickets in default overview
All checks were successful
/ test (push) Successful in 2m29s
2025-02-09 11:25:28 +01:00
554bc70413 when the 'X-Spam' flag is set in the mail header, set the state to 'pending_suspected_spam' and do not send auto-reply
All checks were successful
/ test (push) Successful in 2m37s
2025-02-07 23:34:39 +01:00
9395226c5f disable automatic ticket state change
All checks were successful
/ test (push) Successful in 2m27s
/ deploy (push) Successful in 3m13s
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
24 changed files with 10052 additions and 133 deletions

View file

@ -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'])

View file

@ -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'])

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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'])

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.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
View file

View 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'])

View file

@ -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):

View file

@ -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

View file

@ -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):

View file

@ -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'])

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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>

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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

View file

@ -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&nbsp;DHL&nbsp;contact&nbsp;to&nbsp;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&nbsp;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
})]);

View file

@ -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>

View file

@ -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>