Compare commits

..

30 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
598f758332 reduce shadow logouts, by actually logging out when token fails or is expired
All checks were successful
/ deploy (push) Successful in 4m33s
/ test (push) Successful in 2m56s
2024-12-24 15:56:24 +01:00
66a03a9dca metrics: Fix inverted return state in metrics
All checks were successful
/ test (push) Successful in 2m37s
/ deploy (push) Successful in 4m25s
2024-12-24 07:05:47 +01:00
f266133d14 bugfix: use correct url in manual ticket creation
All checks were successful
/ test (pull_request) Successful in 4m9s
/ deploy (push) Successful in 5m17s
/ test (push) Successful in 2m45s
2024-12-23 17:16:17 +01:00
b393767871 deploy: Fix hostname in postfix config
All checks were successful
/ test (push) Successful in 2m38s
/ deploy (push) Successful in 4m29s
/ test (pull_request) Successful in 2m39s
2024-12-23 15:02:41 +01:00
02d0c1498d deploy: Add dkim signing with rspamd
Some checks are pending
/ deploy (push) Blocked by required conditions
/ test (push) Successful in 2m38s
/ test (pull_request) Successful in 2m37s
2024-12-23 14:57:42 +01:00
198a9ccaa0 deploy: Remove mail relay host 2024-12-23 14:56:38 +01:00
22ac146110 metrics: Make labels more speakable
All checks were successful
/ test (push) Successful in 2m40s
/ deploy (push) Successful in 4m35s
2024-12-23 14:36:08 +01:00
2886d887f5 metrics: Make metrics actually usable
All checks were successful
/ test (push) Successful in 2m40s
/ deploy (push) Successful in 4m25s
With this commit, we should get stats for the item count per event per
returned-state.
2024-12-23 13:20:44 +00:00
06c0d6cf44 metrics: Add the first (simple) custom metrics 2024-12-23 13:20:44 +00:00
1e23151021 deploy: Fix dkim-key selection in rspamd
All checks were successful
/ test (push) Successful in 2m41s
/ deploy (push) Successful in 4m52s
2024-12-22 21:26:45 +01:00
ed7186cbbd add testdata to docker setup. use sqlite in 'dev' scenario and mailpit in 'testing'
All checks were successful
/ test (push) Successful in 2m44s
/ deploy (push) Successful in 3m33s
2024-12-22 21:12:57 +01:00
c512da2660 use id instead of eid in Events.vue and use index independent of sorting 2024-12-22 21:12:57 +01:00
e4fa48eb75 force loading of items after switching to another event 2024-12-22 21:12:57 +01:00
b31f3758e0 cicd: Fix failure when .ssh exists
All checks were successful
/ test (push) Successful in 2m38s
/ deploy (push) Successful in 4m23s
2024-12-19 13:44:17 +00:00
9141752fdf fix container selection in AddItemModal.vue
Some checks failed
/ test (push) Successful in 3m39s
/ deploy (push) Failing after 1m59s
2024-12-19 14:25:36 +01:00
52 changed files with 10630 additions and 197 deletions

View file

@ -35,7 +35,7 @@ jobs:
- name: Populate relevant files
run: |
mkdir ~/.ssh
mkdir -p ~/.ssh
echo "${{ secrets.C3LF_SSH_TESTING }}" > ~/.ssh/id_ed25519
chmod 0600 ~/.ssh/id_ed25519
ls -lah ~/.ssh
@ -43,7 +43,7 @@ jobs:
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_ed25519
echo "andromeda.lab.or.it ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDXPoO0PE+B9PYwbGaLo98zhbmjAkp6eBtVeZe43v/+T" >> ~/.ssh/known_hosts
mkdir /etc/ansible
mkdir -p /etc/ansible
echo "${{ secrets.C3LF_INVENTORY_TESTING }}" > /etc/ansible/hosts
- name: Check ansible version

35
core/core/metrics.py Normal file
View file

@ -0,0 +1,35 @@
from django.apps import apps
from prometheus_client.core import CounterMetricFamily, REGISTRY
from django.db.models import Case, Value, When, BooleanField, Count
from inventory.models import Item
class ItemCountCollector(object):
def collect(self):
counter = CounterMetricFamily("item_count", "Current number of items", labels=['event', 'returned_state'])
yield counter
if not apps.models_ready or not apps.apps_ready:
return
queryset = (
Item.all_objects
.annotate(
returned=Case(
When(returned_at__isnull=True, then=Value(False)),
default=Value(True),
output_field=BooleanField()
)
)
.values('event__slug', 'returned', 'event_id')
.annotate(amount=Count('id'))
.order_by('event__slug', 'returned') # Optional: order by slug and returned
)
for e in queryset:
counter.add_metric([e["event__slug"].lower(), str(e["returned"])], e["amount"])
yield counter
REGISTRY.register(ItemCountCollector())

View file

@ -19,6 +19,8 @@ from django.urls import path, include
from .version import get_info
from .metrics import *
urlpatterns = [
path('djangoadmin/', admin.site.urls),
path('api/2/', include('inventory.api_v2')),

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

@ -137,10 +137,12 @@ class ItemSerializer(BasicItemSerializer):
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

@ -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
def unescape_simplified_quoted_printable(s):
def unescape_simplified_quoted_printable(s, encoding='utf-8'):
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):
@ -81,11 +93,11 @@ def make_reply(reply_email, references=None, event=None):
reply_email.save()
if references:
reply["References"] = " ".join(references)
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)
@ -116,6 +128,22 @@ def find_target_event(address):
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):
import email
from hashlib import sha256
@ -127,21 +155,24 @@ def parse_email_body(raw, log=None):
if parsed.is_multipart():
for part in parsed.walk():
ctype = part.get_content_type()
charset = part.get_content_charset()
cdispo = str(part.get('Content-Disposition'))
if ctype == 'multipart/mixed':
log.debug("Ignoring Multipart %s %s", ctype, cdispo)
# 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()
if not segment:
continue
segment = unescape_and_decode_quoted_printable(segment)
segment = unescape_and_decode_base64(segment)
if part.get('Content-Transfer-Encoding') == 'quoted-printable':
segment = unescape_simplified_quoted_printable(segment)
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:
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()
name = part.get_filename()
if name is None:
@ -167,10 +198,8 @@ def parse_email_body(raw, log=None):
else:
log.warning("Unknown content type %s", parsed.get_content_type())
body = "Unknown content type"
body = unescape_and_decode_quoted_printable(body)
body = unescape_and_decode_base64(body)
if parsed.get('Content-Transfer-Encoding') == 'quoted-printable':
body = unescape_simplified_quoted_printable(body)
body = decode_email_segment(body.encode('utf-8'), parsed.get_content_charset(),
parsed.get('Content-Transfer-Encoding'))
log.debug(body)
return parsed, body, attachments
@ -182,8 +211,8 @@ def receive_email(envelope, log=None):
header_from = parsed.get('From')
header_to = parsed.get('To')
header_in_reply_to = parsed.get('In-Reply-To')
header_message_id = parsed.get('Message-ID')
header_in_reply_to = ascii_strip(parsed.get('In-Reply-To'))
header_message_id = ascii_strip(parsed.get('Message-ID'))
if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "":
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
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 = parsed.get('Subject')
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)
@ -209,7 +239,8 @@ def receive_email(envelope, log=None):
email = Email.objects.create(
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)
for attachment in attachments:
email.attachments.add(attachment)
@ -268,10 +299,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

@ -142,7 +142,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('test ä', Email.objects.all()[0].subject)
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_quoted_printable_2(self):
from aiosmtpd.smtp import Envelope
@ -163,9 +163,9 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('suche_Mütze', Email.objects.all()[0].subject)
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 asgiref.sync import async_to_sync
import aiosmtplib
@ -184,7 +184,36 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('test', Email.objects.all()[0].subject)
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):
issue_thread = IssueThread.objects.create(
@ -232,7 +261,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
self.assertEqual(IssueThread.objects.all()[0].name, 'test')
self.assertEqual(IssueThread.objects.all()[0].state, 'pending_new')
self.assertEqual(IssueThread.objects.all()[0].assigned_to, None)
self.assertTrue( Email.objects.all()[2].raw_file.path)
self.assertTrue(Email.objects.all()[2].raw_file.path)
def test_handle_client_reply_2(self):
issue_thread = IssueThread.objects.create(
@ -285,7 +314,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
self.assertEqual(IssueThread.objects.all()[0].name, 'test')
self.assertEqual(IssueThread.objects.all()[0].state, 'pending_open')
self.assertEqual(IssueThread.objects.all()[0].assigned_to, None)
self.assertTrue( Email.objects.all()[2].raw_file.path)
self.assertTrue(Email.objects.all()[2].raw_file.path)
def test_mail_reply(self):
issue_thread = IssueThread.objects.create(
@ -887,6 +916,59 @@ hello \xe4\xf6\xfc'''
self.assertEqual(1, len(states))
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):
from aiosmtpd.smtp import Envelope
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])
self.assertEqual(1, len(states))
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'],
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,24 +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.split(' ')
query_tokens = query.lower().split(' ')
matches = []
for issue in issues:
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:
if token in issue.description:
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'])
@ -160,7 +205,10 @@ def search_issues(request, event_slug, query):
event = Event.objects.get(slug=event_slug)
if not request.user.has_event_perm(event, 'view_issuethread'):
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)
except Event.DoesNotExist:
return Response(status=404)

View file

@ -139,10 +139,12 @@ class IssueSerializer(BasicIssueSerializer):
class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField()
item = IssueSerializer()
search_matches = serializers.ListField(child=serializers.DictField())
issue = IssueSerializer()
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:
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
@ -383,15 +384,108 @@ class IssueSearchTest(TestCase):
def setUp(self):
super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event')
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all())
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.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')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
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

@ -345,6 +345,13 @@
notify:
- restart postfix
- name: configure rspamd dkim
template:
src: templates/rspamd-dkim.cf.j2
dest: /etc/rspamd/local.d/dkim_signing.conf
notify:
- restart rspamd
- name: configure rspamd
copy:
content: |

View file

@ -32,12 +32,11 @@ smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = polaris.c3lf.de
myhostname = polaris.lab.or.it
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
myorigin = /etc/mailname
mydestination = $myhostname, , localhost
relayhost = firefly.lab.or.it
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0
recipient_delimiter = +

View file

@ -0,0 +1,79 @@
# local.d/dkim_signing.conf
enabled = true;
# If false, messages with empty envelope from are not signed
allow_envfrom_empty = true;
# If true, envelope/header domain mismatch is ignored
allow_hdrfrom_mismatch = false;
# If true, multiple from headers are allowed (but only first is used)
allow_hdrfrom_multiple = false;
# If true, username does not need to contain matching domain
allow_username_mismatch = false;
# Default path to key, can include '$domain' and '$selector' variables
path = "/var/lib/rspamd/dkim/$domain.$selector.key";
# Default selector to use
selector = "dkim";
# If false, messages from authenticated users are not selected for signing
sign_authenticated = true;
# If false, messages from local networks are not selected for signing
sign_local = true;
# Map file of IP addresses/subnets to consider for signing
# sign_networks = "/some/file"; # or url
# Symbol to add when message is signed
symbol = "DKIM_SIGNED";
# Whether to fallback to global config
try_fallback = true;
# Domain to use for DKIM signing: can be "header" (MIME From), "envelope" (SMTP From), "recipient" (SMTP To), "auth" (SMTP username) or directly specified domain name
use_domain = "header";
# Domain to use for DKIM signing when sender is in sign_networks ("header"/"envelope"/"auth")
#use_domain_sign_networks = "header";
# Domain to use for DKIM signing when sender is a local IP ("header"/"envelope"/"auth")
#use_domain_sign_local = "header";
# Whether to normalise domains to eSLD
use_esld = true;
# Whether to get keys from Redis
use_redis = false;
# Hash for DKIM keys in Redis
key_prefix = "DKIM_KEYS";
# map of domains -> names of selectors (since rspamd 1.5.3)
#selector_map = "/etc/rspamd/dkim_selectors.map";
# map of domains -> paths to keys (since rspamd 1.5.3)
#path_map = "/etc/rspamd/dkim_paths.map";
# If `true` get pubkey from DNS record and check if it matches private key
check_pubkey = false;
# Set to `false` if you want to skip signing if public and private keys mismatch
allow_pubkey_mismatch = true;
# Domain specific settings
domain {
# Domain name is used as key
c3lf.de {
# Private key path
path = "/var/lib/rspamd/dkim/{{ mail_domain }}.key";
# Selector
selector = "{{ mail_domain }}";
}
}

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

0
web/node_modules/.forgit_fordocker generated vendored Normal file
View file

9399
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,29 @@
<div>
<Modal v-if="isModal" title="Add Item" @close="$emit('close')">
<template #body>
<EditItem :item="item"/>
<div>
<InputPhoto
:model="item"
field="file"
:on-capture="storeImage"
/>
<InputString
label="description"
:model="item"
field="description"
:validation-fn="str => str && str.length > 0"
/>
<div class="form-group">
<label for="box">box</label>
<InputCombo
label="box"
:model="item"
nameKey="box"
uniqueKey="cid"
:options="boxes"
/>
</div>
</div>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="$emit('close')">Cancel</button>
@ -13,33 +35,40 @@
</template>
<script>
import {mapActions, mapGetters, mapState} from "vuex";
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
import {mapActions, mapState} from "vuex";
import InputCombo from "@/components/inputs/InputCombo.vue";
import InputPhoto from "@/components/inputs/InputPhoto.vue";
import InputString from "@/components/inputs/InputString.vue";
export default {
name: 'AddItemModal',
components: {Modal, EditItem},
components: {InputString, InputPhoto, InputCombo, Modal},
props: ['isModal'],
data: () => ({
item: {}
}),
computed: {
...mapState(['lastUsed'])
...mapState(['lastUsed']),
...mapGetters(['getBoxes']),
boxes({getBoxes}) {
return getBoxes.map(obj => ({cid: obj.id, box: obj.name}));
}
},
methods: {
...mapActions(['postItem', 'loadBoxes', 'scheduleAfterInit']),
saveNewItem() {
this.postItem(this.item).then(() => {
async saveNewItem() {
await this.postItem(this.item);
this.$emit('close');
});
},
storeImage(image) {
this.item.dataImage = image;
}
},
created() {
this.item = {box: this.lastUsed.box || '', cid: this.lastUsed.cid || ''};
},
mounted() {
this.scheduleAfterInit(() => [this.loadBoxes()]);
this.scheduleAfterInit(() => [this.loadBoxes().then(() => {
this.item = {box: this.lastUsed.box || '', cid: this.lastUsed.cid || ''};
})])
}
};
</script>

View file

@ -19,11 +19,10 @@
<script>
import {mapActions} from 'vuex';
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
export default {
name: 'AddTicketModal',
components: {Modal, EditItem},
components: {Modal},
props: ['isModal'],
data: () => ({
ticket: {

View file

@ -1,50 +0,0 @@
<template>
<div>
<h6>Editing Item <span class="badge badge-secondary">#{{ item.uid }}</span></h6>
<InputPhoto
:model="item"
field="file"
:on-capture="storeImage"
/>
<InputString
label="description"
:model="item"
field="description"
:validation-fn="str => str && str.length > 0"
/>
<div class="form-group">
<label for="box">box</label>
<InputCombo
label="box"
:model="item"
nameKey="box"
uniqueKey="cid"
:options="boxes"
/>
</div>
</div>
</template>
<script>
import InputString from './inputs/InputString';
import InputCombo from './inputs/InputCombo';
import {mapGetters} from 'vuex';
import InputPhoto from './inputs/InputPhoto';
export default {
name: 'EditItem',
components: {InputPhoto, InputCombo, InputString},
props: ['item'],
computed: {
...mapGetters(['getBoxes']),
boxes({getBoxes}) {
return getBoxes.map(obj => ({cid: obj.cid, box: obj.name}));
}
},
methods: {
storeImage(image) {
this.item.dataImage = image;
}
}
};
</script>

View file

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

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;
}
}
},

View file

@ -43,11 +43,11 @@ export default {
props: ['label', 'model', 'nameKey', 'uniqueKey', 'options', 'onOptionAdd'],
data: ({options, model, nameKey, uniqueKey}) => ({
internalName: model[nameKey],
selectedOption: options.filter(e => e[uniqueKey] == model[uniqueKey])[0],
selectedOption: options.filter(e => e[uniqueKey] === model[uniqueKey])[0],
addingOption: false
}),
computed: {
isValid: ({options, nameKey, internalName}) => options.some(e => e[nameKey] == internalName),
isValid: ({options, nameKey, internalName}) => options.some(e => e[nameKey] === internalName),
sortedOptions: ({
options,
nameKey
@ -56,7 +56,7 @@ export default {
watch: {
internalName(newValue) {
if (this.isValid) {
if (!this.selectedOption || newValue != this.selectedOption[this.nameKey]) {
if (!this.selectedOption || newValue !== this.selectedOption[this.nameKey]) {
this.selectedOption = this.options.filter(e => e[this.nameKey] === newValue)[0];
}
this.model[this.nameKey] = this.selectedOption[this.nameKey];

View file

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

View file

@ -20,6 +20,9 @@ export default (config) => (store) => {
}
});
store.state[config.isLoadedKey] = true;
if ('validate' in config) {
config.validate(store.state);
}
}
const reload = initialize;

View file

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

View file

@ -9,6 +9,7 @@ import persistentStatePlugin from "@/persistent-state-plugin";
const store = createStore({
state: {
//shared
keyIncrement: 0,
events: [],
items: [],
@ -22,9 +23,12 @@ const store = createStore({
loadedItems: {},
loadedTickets: {},
loadedItemSearchResults: {},
loadedTicketSearchResults: {},
//local
lastEvent: 'all',
lastUsed: {},
searchQuery: '',
remember: false,
user: {
username: null,
@ -57,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)',
}
@ -66,12 +69,33 @@ const store = createStore({
route: state => router.currentRoute.value,
session: state => http_session(state.user.token),
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(),
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')),
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) => {
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,
@ -106,9 +130,9 @@ const store = createStore({
layout: (state, getters) => {
if (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';
if (getters.getActiveView === 'tickets')
if (getters.getActiveView === 'tickets' || getters.getActiveView === 'ticket_search')
return 'tasks';
},
isLoggedIn(state) {
@ -147,6 +171,10 @@ const store = createStore({
state.loadedItems[slug] = items;
state.loadedItems = {...state.loadedItems};
},
setItemSearchResults(state, {slug, query, items}) {
state.loadedItemSearchResults[slug + '/' + query] = items;
state.loadedItemSearchResults = {...state.loadedItemSearchResults};
},
replaceItems(state, items) {
const groups = Object.groupBy(items, i => i.event ? i.event : 'none')
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 = {...state.loadedTickets};
},
setTicketSearchResults(state, {slug, query, items}) {
state.loadedTicketSearchResults[slug + '/' + query] = items;
state.loadedTicketSearchResults = {...state.loadedTicketSearchResults};
},
replaceTickets(state, tickets) {
const groups = Object.groupBy(tickets, t => t.event ? t.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedTickets[key] = value;
@ -339,7 +371,7 @@ const store = createStore({
commit('replaceEvents', [...state.events.filter(e => e.id !== event_id)])
}
},
async updateEvent({commit, dispatch, state}, {id, partial_event}){
async updateEvent({commit, dispatch, state}, {id, partial_event}) {
const {data, success} = await http.patch(`/2/events/${id}/`, partial_event, state.user.token);
if (success) {
commit('replaceEvents', [...state.events.filter(e => e.id !== id), data])
@ -362,9 +394,8 @@ const store = createStore({
},
async loadEventItems({commit, getters, state}) {
if (!state.user.token) return;
if (state.fetchedData.items > Date.now() - 1000 * 60 * 60 * 24) return;
const load = async (slug) => {
try {
const slug = getters.getEventSlug;
const {data, success} = await getters.session.get(`/2/${slug}/items/`);
if (data && success) {
commit('setItems', {slug, items: data});
@ -372,15 +403,30 @@ const store = createStore({
} 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 slug = getters.getEventSlug;
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('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}) {
@ -428,11 +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/${getters.getEventSlug}/tickets/${encoded_query}/`);
if (data && success) commit('replaceTickets', data);
} = 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 (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},
@ -443,7 +497,8 @@ const store = createStore({
}
},
async postManualTicket({commit, dispatch, state, getters}, {sender, message, title,}) {
const {data, success} = await getters.session.post(`/2/tickets/manual/`, {
const slug = getters.getEventSlug;
const {data, success} = await getters.session.post(`/2/${slug !== 'all' ? slug : 'none'}/tickets/manual/`, {
name: title, sender, body: message, recipient: 'mail@c3lf.de'
});
await dispatch('loadTickets');
@ -456,7 +511,10 @@ const store = createStore({
}
},
async postItemComment({commit, dispatch, state, getters}, {id, message}) {
const {data, success} = await getters.session.post(`/2/${getters.getEventSlug}/item/${id}/comment/`, {comment: message});
const {
data,
success
} = await getters.session.post(`/2/${getters.getEventSlug}/item/${id}/comment/`, {comment: message});
if (data && success) {
state.fetchedData.items = 0;
await dispatch('loadEventItems');
@ -505,12 +563,29 @@ 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
prefix: "lf_",
debug: false,
isLoadedKey: "persistent_loaded",
validate: (state) => {
if (state.user && state.user.expiry && state.user.token) {
const as_date = new Date(state.user.expiry);
if (as_date < new Date()) {
state.user.token = null;
state.user.expiry = null;
}
}
},
state: ["remember", "user", "events", "lastUsed",]
}), sharedStatePlugin({
debug: false,

View file

@ -1,3 +1,5 @@
import store from '@/store'
function ticketStateColorLookup(ticket) {
if (ticket.startsWith('closed_')) {
return 'secondary';
@ -36,6 +38,8 @@ const http = {
"Authorization": `Token ${token}`,
},
});
if (response.status === 401)
throw {http_status: response.status};
const success = response.status === 200 || response.status === 201;
return {data: await response.json() || {}, success};
},
@ -51,6 +55,8 @@ const http = {
},
body: JSON.stringify(data),
});
if (response.status === 401)
throw {http_status: response.status};
const success = response.status === 200 || response.status === 201;
return {data: await response.json() || {}, success};
},
@ -66,6 +72,8 @@ const http = {
},
body: JSON.stringify(data),
});
if (response.status === 401)
throw {http_status: response.status};
const success = response.status === 200 || response.status === 201;
return {data: await response.json() || {}, success};
},
@ -81,6 +89,8 @@ const http = {
},
body: JSON.stringify(data),
});
if (response.status === 401)
throw {http_status: response.status};
const success = response.status === 200 || response.status === 201;
return {data: await response.json() || {}, success};
},
@ -95,17 +105,34 @@ const http = {
"Authorization": `Token ${token}`,
},
});
if (response.status === 401)
throw {http_status: response.status};
const success = response.status === 204;
return {data: await response.text() || {}, success};
}
}
const http_session = token => ({
get: async (url) => await http.get(url, token),
post: async (url, data) => await http.post(url, data, token),
put: async (url, data) => await http.put(url, data, token),
patch: async (url, data) => await http.patch(url, data, token),
delete: async (url) => await http.delete(url, token),
get: async (url) => await http.get(url, token).catch((e) => {
if (e.http_status === 401) store.commit('logout');
return {data: {}, success: false};
}),
post: async (url, data) => await http.post(url, data, token).catch((e) => {
if (e.http_status === 401) store.commit('logout');
return {data: {}, success: false};
}),
put: async (url, data) => await http.put(url, data, token).catch((e) => {
if (e.http_status === 401) store.commit('logout');
return {data: {}, success: false};
}),
patch: async (url, data) => await http.patch(url, data, token).catch((e) => {
if (e.http_status === 401) store.commit('logout');
return {data: {}, success: false};
}),
delete: async (url) => await http.delete(url, token).catch((e) => {
if (e.http_status === 401) store.commit('logout');
return {data: {}, success: false};
}),
});
export {ticketStateColorLookup, ticketStateIconLookup, http, http_session};

View file

@ -112,14 +112,12 @@ import InputString from "@/components/inputs/InputString.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import InputPhoto from "@/components/inputs/InputPhoto.vue";
import Modal from "@/components/Modal.vue";
import EditItem from "@/components/EditItem.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue";
export default {
name: 'Item',
components: {
AsyncButton,
EditItem,
Modal, InputPhoto, AuthenticatedImage, InputString, InputCombo, AsyncLoader, ClipboardButton, Timeline
},
data() {

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

@ -67,11 +67,10 @@
</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 EditItem from '@/components/EditItem';
import {mapActions, mapGetters, mapMutations} from 'vuex';
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
@ -82,9 +81,9 @@ export default {
lightboxHash: null,
editingItem: null,
}),
components: {AsyncLoader, AuthenticatedImage, Table, Cards, Modal, EditItem},
components: {AsyncLoader, AuthenticatedImage, Table, Cards, Modal},
computed: {
...mapGetters(['getEventItems', 'isItemsLoaded', 'layout']),
...mapGetters(['getEventItems', 'isItemsLoaded', 'layout', 'getEventSlug']),
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']),
@ -96,6 +95,11 @@ export default {
return window.confirm(message);
}
},
watch: {
getEventSlug() {
this.scheduleAfterInit(() => [this.loadEventItems()]);
}
},
mounted() {
this.scheduleAfterInit(() => [this.loadEventItems()]);
}

View file

@ -72,7 +72,7 @@ export default {
name: 'Login',
data() {
return {
msg: 'Welcome to ' + window.location.hostname,
msg: 'Lost&Found Team Login',
username: '',
password: '',
remember: false

View file

@ -87,7 +87,7 @@ export default {
name: 'Register',
data() {
return {
msg: 'Register',
msg: 'Register as team member',
password2: '',
form: {
username: '',

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

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

View file

@ -1,6 +1,7 @@
<template>
<AsyncLoader :loaded="events.length > 0">
<ExpandableTable v-if="!!events" :columns="['slug', 'name']" :items="events" :keyName="'slug'">
<ExpandableTable v-if="!!events" :columns="['slug', 'name']" :items="events.map((e,i)=>({idx: i, ...e}))"
:keyName="'slug'">
<template v-slot:header_actions>
<button class="btn btn-success" @click.prevent="openAddEventModal">
<font-awesome-icon icon="plus"/>
@ -43,7 +44,7 @@
<div class="mt-3">
<label class="mr-3">Addresses: </label>
<div v-for="(address, a_id) in item.addresses" class="btn-group btn-group-sm mr-3"
@click.stop="deleteAddress(id, a_id)">
@click.stop="deleteAddress(item.idx, a_id)">
<button class="btn btn-secondary" disabled style="opacity: 1">
{{ address }}
</button>
@ -52,8 +53,9 @@
</button>
</div>
<div class="btn-group btn-group-sm">
<input type="text" v-model="new_address[id]">
<button class="btn btn-secondary" @click.stop="addAddress(id)" style="white-space: nowrap;">
<input type="text" v-model="new_address[item.idx]">
<button class="btn btn-secondary" @click.stop="addAddress(item.idx)"
style="white-space: nowrap;">
<font-awesome-icon icon="envelope"/>&nbsp;add
</button>
</div>
@ -88,11 +90,11 @@ export default {
if (!this.events[id].addresses.includes(a))
this.events[id].addresses.push(a)
this.new_address[id] = ""
this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}});
this.updateEvent({id: this.events[id].id, partial_event: {addresses: this.events[id].addresses}});
},
deleteAddress(id, a_id) {
this.events[id].addresses = this.events[id].addresses.filter((e, i) => i !== a_id);
this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}});
this.updateEvent({id: this.events[id].id, partial_event: {addresses: this.events[id].addresses}});
}
},
};