Compare commits

...

15 commits

Author SHA1 Message Date
c0388e2b54 stash 2024-11-03 19:31:12 +01:00
a7af4f9cdd add dev docker 2024-11-03 19:24:14 +01:00
98dfc33385 stash events admin frontend 2024-11-03 19:24:10 +01:00
37da5b38b1 stash 2024-11-03 19:23:53 +01:00
fe2f8b3b05 stash 2024-11-03 19:23:53 +01:00
e81bf94dc1 stash 2024-11-03 19:23:53 +01:00
165bec6496 stash 2024-11-03 19:23:53 +01:00
03f21feb2f stash 2024-11-03 19:23:53 +01:00
e58779b98f stash 2024-11-03 19:23:53 +01:00
40184f12ee stash 2024-11-03 19:23:53 +01:00
4d8406bd7c stash 2024-11-03 19:23:53 +01:00
1cfeb34a4c stash 2024-11-03 19:23:53 +01:00
bbbc6869c1 stash 2024-11-03 19:23:53 +01:00
dbeb9f4116 stash 2024-11-03 19:23:53 +01:00
bcc056e451 stash 2024-11-03 19:23:49 +01:00
59 changed files with 1857 additions and 299 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "web/extras"]
path = web/extras
url = https://git.neulandlabor.de/j3d1/vue-extras.git

14
core/.coveragerc Normal file
View file

@ -0,0 +1,14 @@
[run]
source = .
[report]
fail_under = 100
show_missing = True
skip_covered = True
omit =
*/tests/*
*/migrations/*
core/asgi.py
core/wsgi.py
core/settings.py
manage.py

View file

@ -10,5 +10,8 @@ class ExtendedUserAdmin(UserAdmin):
ordering = ('username',)
filter_horizontal = ('groups', 'user_permissions', 'permissions')
# def permissions(self, obj):
# return ', '.join(obj.get_all_permissions())
admin.site.register(ExtendedUser, ExtendedUserAdmin)

View file

@ -12,25 +12,7 @@ from knox.models import AuthToken
from knox.views import LoginView as KnoxLoginView
from authentication.models import ExtendedUser
class UserSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
groups = serializers.SlugRelatedField(many=True, read_only=True, slug_field='name')
class Meta:
model = ExtendedUser
fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
read_only_fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
def get_permissions(self, obj):
return list(set(obj.get_permissions()))
@receiver(post_save, sender=ExtendedUser)
def create_auth_token(sender, instance=None, created=False, **kwargs):
if created:
AuthToken.objects.create(user=instance)
from authentication.serializers import UserSerializer, GroupSerializer
class UserViewSet(viewsets.ModelViewSet):
@ -38,26 +20,17 @@ class UserViewSet(viewsets.ModelViewSet):
serializer_class = UserSerializer
class GroupSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
members = serializers.SerializerMethodField()
class Meta:
model = Group
fields = ('id', 'name', 'permissions', 'members')
def get_permissions(self, obj):
return ["*:" + p.codename for p in obj.permissions.all()]
def get_members(self, obj):
return [u.username for u in obj.user_set.all()]
class GroupViewSet(viewsets.ModelViewSet):
queryset = Group.objects.all()
serializer_class = GroupSerializer
@receiver(post_save, sender=ExtendedUser)
def create_auth_token(sender, instance=None, created=False, **kwargs):
if created:
AuthToken.objects.create(user=instance)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def selfUser(request):

View file

@ -0,0 +1,32 @@
from rest_framework import serializers
from django.contrib.auth.models import Group
from authentication.models import ExtendedUser
class UserSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
groups = serializers.SlugRelatedField(many=True, read_only=True, slug_field='name')
class Meta:
model = ExtendedUser
fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
read_only_fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
def get_permissions(self, obj):
return list(set(obj.get_permissions()))
class GroupSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
members = serializers.SerializerMethodField()
class Meta:
model = Group
fields = ('id', 'name', 'permissions', 'members')
def get_permissions(self, obj):
return ["*:" + p.codename for p in obj.permissions.all()]
def get_members(self, obj):
return [u.username for u in obj.user_set.all()]

View file

@ -29,7 +29,9 @@ SECRET_KEY = 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost')]
PRIMARY_HOST = os.getenv('HTTP_HOST', 'localhost')
ALLOWED_HOSTS = [PRIMARY_HOST]
MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost')
@ -40,6 +42,10 @@ LEGACY_USER_PASSWORD = os.getenv('LEGACY_API_PASSWORD', 'legacy_password')
SYSTEM3_VERSION = "0.0.0-dev.0"
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi')
TELEGRAM_GROUP_CHAT_ID = os.getenv('TELEGRAM_GROUP_CHAT_ID', '-1234567890')
# Application definition
INSTALLED_APPS = [
@ -55,6 +61,7 @@ INSTALLED_APPS = [
'drf_yasg',
'channels',
'authentication',
'notifications',
'files',
'tickets',
'inventory',

View file

@ -31,5 +31,6 @@ urlpatterns = [
path('api/2/', include('mail.api_v2')),
path('api/2/', include('notify_sessions.api_v2')),
path('api/2/', include('authentication.api_v2')),
path('api/2/', include('notifications.api_v2')),
path('api/', get_info),
]

View file

@ -1,4 +1,4 @@
from django.urls import path
from django.urls import re_path
from django.contrib.auth.decorators import permission_required
from rest_framework import routers, viewsets
from rest_framework.decorators import api_view, permission_classes
@ -6,7 +6,9 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from inventory.models import Event, Container, Item
from inventory.serializers import EventSerializer, ContainerSerializer, ItemSerializer
from inventory.serializers import EventSerializer, ContainerSerializer, ItemSerializer, SearchResultSerializer
from base64 import b64decode
class EventViewSet(viewsets.ModelViewSet):
@ -20,18 +22,26 @@ class ContainerViewSet(viewsets.ModelViewSet):
queryset = Container.objects.all()
def filter_items(items, query):
query_tokens = query.split(' ')
for item in items:
value = 0
for token in query_tokens:
if token in item.description:
value += 1
if value > 0:
yield {'search_score': value, 'item': item}
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@permission_required('view_item', raise_exception=True)
@permission_classes([])
# @permission_classes([IsAuthenticated])
# @permission_required('view_item', raise_exception=True)
def search_items(request, event_slug, query):
try:
event = Event.objects.get(slug=event_slug)
query_tokens = query.split(' ')
q = Item.objects.filter(event=event)
for token in query_tokens:
if token:
q = q.filter(description__icontains=token)
return Response(ItemSerializer(q, many=True).data)
items = filter_items(Item.objects.filter(event=event), b64decode(query).decode('utf-8'))
return Response(SearchResultSerializer(items, many=True).data)
except Event.DoesNotExist:
return Response(status=404)
@ -99,8 +109,12 @@ router.register(r'boxes', ContainerViewSet, basename='boxes')
router.register(r'box', ContainerViewSet, basename='boxes')
urlpatterns = router.urls + [
path('<event_slug>/items/', item),
path('<event_slug>/items/<query>/', search_items),
path('<event_slug>/item/', item),
path('<event_slug>/item/<id>/', item_by_id),
# path('<event_slug>/items/', item),
# path('<event_slug>/items/<query>/', search_items),
# path('<event_slug>/item/', item),
# path('<event_slug>/item/<id>/', item_by_id),
re_path(r'^(?P<event_slug>[\w-]+)/items/$', item, name='item'),
re_path(r'^(?P<event_slug>[\w-]+)/items/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_items, name='search_items'),
re_path(r'^(?P<event_slug>[\w-]+)/item/$', item, name='item'),
re_path(r'^(?P<event_slug>[\w-]+)/item/(?P<id>\d+)/$', item_by_id, name='item_by_id'),
]

View file

@ -3,12 +3,21 @@ from rest_framework import serializers
from files.models import File
from inventory.models import Event, Container, Item
from mail.models import EventAddress
class EventAdressSerializer(serializers.ModelSerializer):
class Meta:
model = EventAddress
fields = ['address']
class EventSerializer(serializers.ModelSerializer):
addresses = EventAdressSerializer(many=True, required=False)
class Meta:
model = Event
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end']
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end', 'addresses']
read_only_fields = ['eid']
@ -86,3 +95,14 @@ class ItemSerializer(serializers.ModelSerializer):
validated_data.pop('dataImage')
instance.files.add(file)
return super().update(instance, validated_data)
class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField()
item = ItemSerializer()
def to_representation(self, instance):
return {**ItemSerializer(instance['item']).data, 'search_score': instance['search_score']}
class Meta:
model = Item

View file

@ -54,3 +54,15 @@ class EventTestCase(TestCase):
response = client.delete(f'/api/2/events/{event.eid}/')
self.assertEqual(response.status_code, 204)
self.assertEqual(len(Event.objects.all()), 1)
def test_items2(self):
from mail.models import EventAddress
event1 = Event.objects.create(slug='TEST1', name='Event')
EventAddress.objects.create(event=Event.objects.get(slug='TEST1'), address='foo@bar.baz')
response = self.client.get('/api/2/events/')
self.assertEqual(response.status_code, 200)
self.assertEqual(1, len(response.json()))
self.assertEqual('TEST1', response.json()[0]['slug'])
self.assertEqual('Event', response.json()[0]['name'])
self.assertEqual(1, len(response.json()[0]['addresses']))

View file

@ -7,6 +7,8 @@ from authentication.models import ExtendedUser
from files.models import File
from inventory.models import Event, Container, Item
from base64 import b64encode
class ItemTestCase(TestCase):
@ -169,3 +171,74 @@ class ItemTestCase(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['uid'], item1.uid)
class ItemSearchTestCase(TestCase):
def setUp(self):
super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event')
self.box = Container.objects.create(name='BOX')
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all())
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
self.item1 = Item.objects.create(container=self.box, event=self.event, description='abc def')
self.item2 = Item.objects.create(container=self.box, event=self.event, description='def ghi')
self.item3 = Item.objects.create(container=self.box, event=self.event, description='jkl mno pqr')
self.item4 = Item.objects.create(container=self.box, event=self.event, description='stu vwx')
def test_search(self):
search_query = b64encode(b'abc').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(self.item1.uid, response.json()[0]['uid'])
self.assertEqual('abc def', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box'])
self.assertEqual(self.box.cid, response.json()[0]['cid'])
self.assertEqual(1, response.json()[0]['search_score'])
def test_search2(self):
search_query = b64encode(b'def').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(2, len(response.json()))
self.assertEqual(self.item1.uid, response.json()[0]['uid'])
self.assertEqual('abc def', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box'])
self.assertEqual(self.box.cid, response.json()[0]['cid'])
self.assertEqual(1, response.json()[0]['search_score'])
self.assertEqual(self.item2.uid, response.json()[1]['uid'])
self.assertEqual('def ghi', response.json()[1]['description'])
self.assertEqual('BOX', response.json()[1]['box'])
self.assertEqual(self.box.cid, response.json()[1]['cid'])
self.assertEqual(1, response.json()[0]['search_score'])
def test_search3(self):
search_query = b64encode(b'jkl').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(self.item3.uid, response.json()[0]['uid'])
self.assertEqual('jkl mno pqr', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box'])
self.assertEqual(self.box.cid, response.json()[0]['cid'])
self.assertEqual(1, response.json()[0]['search_score'])
def test_search4(self):
search_query = b64encode(b'abc def').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(2, len(response.json()))
self.assertEqual(self.item1.uid, response.json()[0]['uid'])
self.assertEqual('abc def', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box'])
self.assertEqual(self.box.cid, response.json()[0]['cid'])
self.assertEqual(2, response.json()[0]['search_score'])
self.assertEqual(self.item2.uid, response.json()[1]['uid'])
self.assertEqual('def ghi', response.json()[1]['description'])
self.assertEqual('BOX', response.json()[1]['box'])
self.assertEqual(self.box.cid, response.json()[1]['cid'])
self.assertEqual(1, response.json()[1]['search_score'])

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2024-11-03 18:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0004_alter_event_created_at_alter_item_created_at'),
('mail', '0004_alter_emailattachment_file'),
]
operations = [
migrations.AlterField(
model_name='eventaddress',
name='event',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='addresses', to='inventory.event'),
),
]

View file

@ -3,6 +3,7 @@ import random
from django.db import models
from django_softdelete.models import SoftDeleteModel
from authentication.models import ExtendedUser
from core.settings import MAIL_DOMAIN
from files.models import AbstractFile
from inventory.models import Event
@ -31,10 +32,13 @@ class Email(SoftDeleteModel):
class EventAddress(models.Model):
id = models.AutoField(primary_key=True)
event = models.ForeignKey(Event, models.SET_NULL, null=True)
event = models.ForeignKey(Event, models.SET_NULL, null=True, related_name='addresses')
address = models.CharField(max_length=255)
class EmailAttachment(AbstractFile):
email = models.ForeignKey(Email, models.CASCADE, related_name='attachments', null=True)
name = models.CharField(max_length=255)

View file

@ -1,4 +1,5 @@
import logging
from re import match
import aiosmtplib
from channels.layers import get_channel_layer
@ -6,10 +7,15 @@ from channels.db import database_sync_to_async
from django.core.files.base import ContentFile
from mail.models import Email, EventAddress, EmailAttachment
from notifications.templates import render_auto_reply
from notify_sessions.models import SystemEvent
from tickets.models import IssueThread
class SpecialMailException(Exception):
pass
def find_quoted_printable(s, marker):
positions = [i for i in range(len(s)) if s.lower().startswith('=?utf-8?' + marker + '?', i)]
for pos in positions:
@ -82,6 +88,22 @@ def make_reply(reply_email, references=None, event=None):
return reply
def make_notification(message, to, title): # TODO where should replies to this go
from email.message import EmailMessage
from core.settings import MAIL_DOMAIN
notification = EmailMessage()
notification["From"] = "notifications@%s" % MAIL_DOMAIN
notification["To"] = to
notification["Subject"] = f"[C3LF Notification]%s" % title
# notification["Reply-To"] = f"{event}@{MAIL_DOMAIN}"
# notification["In-Reply-To"] = email.reference
# notification["Message-ID"] = email.id + "@" + MAIL_DOMAIN
notification.set_content(message)
return notification
async def send_smtp(message):
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
@ -180,13 +202,23 @@ def receive_email(envelope, log=None):
header_in_reply_to = parsed.get('In-Reply-To')
header_message_id = parsed.get('Message-ID')
if header_from != envelope.mail_from:
log.warning("Header from does not match envelope from")
log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}")
# if header_from != envelope.mail_from:
# log.warning("Header from does not match envelope from")
# log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}")
#
# if header_to != envelope.rcpt_tos[0]:
# log.warning("Header to does not match envelope to")
# log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}")
if header_to != envelope.rcpt_tos[0]:
log.warning("Header to does not match envelope to")
log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}")
# handle undelivered mail header_from : 'Mail Delivery System <MAILER-DAEMON@...'
if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "":
log.warning("Ignoring mailer daemon")
raise SpecialMailException("Ignoring mailer daemon")
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")
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
@ -213,16 +245,7 @@ def receive_email(envelope, log=None):
references = collect_references(active_issue_thread)
if not sender.startswith('noreply'):
subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]"
body = '''Your request (#{}) has been received and will be reviewed by our lost&found angels.
We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \
workload is high. We will not forget about your request and get back in touch once we have updated information on your \
request. Requests for devices, wallets, credit cards or similar items will be handled with priority.
If you happen to find your lost item or just want to add additional information, please reply to this email. Please \
do not create a new request.
Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid())
body = render_auto_reply(active_issue_thread)
reply_email = Email.objects.create(
sender=recipient, recipient=sender, body=body, subject=subject,
in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread)
@ -233,7 +256,7 @@ Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid
active_issue_thread.state = 'pending_open'
active_issue_thread.save()
return email, new, reply
return email, new, reply, active_issue_thread
class LMTPHandler:
@ -255,7 +278,7 @@ class LMTPHandler:
content = None
try:
content = envelope.content
email, new, reply = await receive_email(envelope, log)
email, new, reply, thread = await receive_email(envelope, log)
log.info(f"Created email {email.id}")
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
reference=email.id)
@ -263,14 +286,28 @@ class LMTPHandler:
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"}
)
"message": "email received"})
log.info(f"Sent message to frontend")
if new and reply:
log.info('Sending message to %s' % reply['To'])
await send_smtp(reply)
log.info("Sent auto reply")
if thread:
await channel_layer.group_send(
'general', {"type": "generic.event", "name": "user_notification", "event_id": systemevent.id,
"ticket_id": thread.id, "new": new})
else:
print("No thread found")
return '250 Message accepted for delivery'
except SpecialMailException as e:
import uuid
random_filename = 'special-' + str(uuid.uuid4())
with open(random_filename, 'wb') as f:
f.write(content)
log.warning(f"Special mail exception: {e} saved to {random_filename}")
return '250 Message accepted for delivery'
except Exception as e:
from hashlib import sha256

View file

@ -760,7 +760,6 @@ dGVzdGltYWdl
response = self.client.post(f'/api/2/tickets/{issue_thread.id}/reply/', {
'message': 'test'
})
aiosmtplib.send.assert_called_once()
self.assertEqual(response.status_code, 201)
self.assertEqual(5, len(Email.objects.all()))
self.assertEqual(5, len(Email.objects.filter(issue_thread=issue_thread)))
@ -776,6 +775,7 @@ dGVzdGltYWdl
self.assertEqual('test subject', IssueThread.objects.all()[0].name)
self.assertEqual('pending_new', IssueThread.objects.all()[0].state)
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
aiosmtplib.send.assert_called_once()
def test_mail_4byte_unicode_emoji(self):
from aiosmtpd.smtp import Envelope

View file

@ -0,0 +1,20 @@
from django.contrib.auth.models import Permission
from django.test import TestCase
from authentication.models import ExtendedUser
from notifications.models import UserNotificationChannel
class UserNotificationTestCase(TestCase):
def setUp(self):
super().setUp()
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all())
self.user.save()
self.channel = UserNotificationChannel.objects.create(user=self.user, channel_type='telegram',
channel_target='123456789',
event_filter='*', active=True)
async def test_telegram_notify(self):
pass

View file

View file

@ -0,0 +1,15 @@
from django.contrib import admin
from notifications.models import MessageTemplate, UserNotificationChannel
class MessageTemplateAdmin(admin.ModelAdmin):
pass
class UserNotificationChannelAdmin(admin.ModelAdmin):
pass
admin.site.register(MessageTemplate, MessageTemplateAdmin)
admin.site.register(UserNotificationChannel, UserNotificationChannelAdmin)

View file

@ -0,0 +1,51 @@
from django.contrib.auth.decorators import permission_required
from rest_framework import routers, viewsets
from django.urls import re_path
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from notifications.models import MessageTemplate, UserNotificationChannel
from rest_framework import serializers
from notifications.templates import TEMPLATE_VARS
from authentication.serializers import UserSerializer
class MessageTemplateSerializer(serializers.ModelSerializer):
class Meta:
model = MessageTemplate
fields = '__all__'
class UserNotificationChannelSerializer(serializers.ModelSerializer):
user = UserSerializer()
class Meta:
model = UserNotificationChannel
fields = '__all__'
class MessageTemplateViewSet(viewsets.ModelViewSet):
serializer_class = MessageTemplateSerializer
queryset = MessageTemplate.objects.all()
class UserNotificationChannelViewSet(viewsets.ModelViewSet):
serializer_class = UserNotificationChannelSerializer
queryset = UserNotificationChannel.objects.all()
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@permission_required('tickets.add_issuethread_manual', raise_exception=True) # TDOO: change this permission
def get_template_vars(self):
return Response(TEMPLATE_VARS, status=200)
router = routers.SimpleRouter()
router.register(r'message_templates', MessageTemplateViewSet)
router.register(r'user_notification_channels', UserNotificationChannelViewSet)
urlpatterns = ([
re_path('message_template_variables', get_template_vars),
] + router.urls)

View file

@ -0,0 +1,16 @@
auto_reply_body = '''Your request (#{{ ticket_uuid }}) has been received and will be reviewed by our lost&found angels.
We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \
workload is high. We will not forget about your request and get back in touch once we have updated information on your \
request. Requests for devices, wallets, credit cards or similar items will be handled with priority.
If you happen to find your lost item or just want to add additional information, please reply to this email. Please \
do not create a new request.
Your c3lf (Cloakroom + Lost&Found) Team'''
new_issue_notification = '''New issue "{{ ticket_name | limit_length }}" [{{ ticket_uuid }}] created
{{ ticket_url }}'''
reply_issue_notification = '''Reply to issue "{{ ticket_name }}" [{{ ticket_uuid }}] (was {{ previous_state_pretty }})
{{ ticket_url }}'''

View file

@ -0,0 +1,85 @@
import asyncio
from aiohttp.client import ClientSession
from channels.layers import get_channel_layer
from channels.db import database_sync_to_async
from urllib.parse import quote as urlencode
from core.settings import TELEGRAM_BOT_TOKEN, TELEGRAM_GROUP_CHAT_ID
from mail.protocol import send_smtp, make_notification
from notifications.models import UserNotificationChannel
from notifications.templates import render_notification_new_ticket_async, render_notification_reply_ticket_async
from tickets.models import IssueThread
async def http_get(url):
async with ClientSession() as session:
async with session.get(url) as response:
return await response.text()
async def telegram_notify(message, chat_id):
encoded_message = urlencode(message)
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage?chat_id={chat_id}&text={encoded_message}"
return await http_get(url)
async def email_notify(message, title, email):
mail = make_notification(message, email, title)
await send_smtp(mail)
class NotificationDispatcher:
channel_layer = None
room_group_name = "general"
def __init__(self):
self.channel_layer = get_channel_layer('default')
if not self.channel_layer:
raise Exception("Could not get channel layer")
@database_sync_to_async
def get_notification_targets(self):
channels = UserNotificationChannel.objects.filter(active=True)
return list(channels)
@database_sync_to_async
def get_ticket(self, ticket_id):
return IssueThread.objects.filter(id=ticket_id).select_related('event').first()
async def run_forever(self):
# Infinite loop to continuously listen for messages
print("Listening for messages...")
channel_name = await self.channel_layer.new_channel()
await self.channel_layer.group_add(self.room_group_name, channel_name)
print("Channel name:", channel_name)
while True:
# Blocking receive to get the message from the channel layer
message = await self.channel_layer.receive(channel_name)
if (message and 'type' in message and message['type'] == 'generic.event' and 'name' in message and
message['name'] == 'user_notification'):
if 'ticket_id' in message and 'event_id' in message and 'new' in message:
ticket = await self.get_ticket(message['ticket_id'])
await self.dispatch(ticket, message['event_id'], message['new'])
else:
print("Error: Invalid message format")
async def dispatch(self, ticket, event_id, new):
message = await render_notification_new_ticket_async(
ticket) if new else await render_notification_reply_ticket_async(ticket)
title = f"[#{ticket.short_uuid()}] {ticket.name}"
print("Dispatching message:", message, "with event_id:", event_id)
targets = await self.get_notification_targets()
jobs = []
jobs.append(telegram_notify(message, TELEGRAM_GROUP_CHAT_ID))
for target in targets:
if target.channel_type == 'telegram':
print("Sending telegram notification to:", target.channel_target)
jobs.append(telegram_notify(message, target.channel_target))
elif target.channel_type == 'email':
print("Sending email notification to:", target.channel_target)
jobs.append(email_notify(message, title, target.channel_target))
else:
print("Unknown channel type:", target.channel_type)
await asyncio.gather(*jobs)

View file

@ -0,0 +1,51 @@
# Generated by Django 4.2.7 on 2024-05-03 21:02
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from notifications.defaults import auto_reply_body, new_issue_notification, reply_issue_notification
from notifications.models import MessageTemplate
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
def create_required_templates(apps, schema_editor):
MessageTemplate.objects.create(name='auto_reply', message=auto_reply_body, marked_required=True)
MessageTemplate.objects.create(name='new_issue_notification', message=new_issue_notification,
marked_required=True)
MessageTemplate.objects.create(name='reply_issue_notification', message=reply_issue_notification,
marked_required=True)
operations = [
migrations.CreateModel(
name='MessageTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('message', models.TextField()),
('created', models.DateTimeField(auto_now_add=True)),
('marked_confidential', models.BooleanField(default=False)),
('marked_required', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name='UserNotificationChannel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel_type',
models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)),
('channel_target', models.CharField(max_length=255)),
('event_filter', models.CharField(max_length=255)),
('active', models.BooleanField(default=True)),
('created', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.RunPython(create_required_templates),
]

View file

@ -0,0 +1,29 @@
from django.db import models
from authentication.models import ExtendedUser
class MessageTemplate(models.Model):
name = models.CharField(max_length=255)
message = models.TextField()
created = models.DateTimeField(auto_now_add=True)
marked_confidential = models.BooleanField(default=False)
marked_required = models.BooleanField(default=False) # may not be deleted
def __str__(self):
return self.name
class UserNotificationChannel(models.Model):
user = models.ForeignKey(ExtendedUser, models.CASCADE)
channel_type = models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)
channel_target = models.CharField(max_length=255)
event_filter = models.CharField(max_length=255)
active = models.BooleanField(default=True)
created = models.DateTimeField(auto_now_add=True)
def validate_constraints(self, exclude=None): # TODO: email -> emailaddress, telegram -> chatid
return True
def __str__(self):
return self.user.username + '(' + self.channel_type + ')'

View file

@ -0,0 +1,69 @@
import jinja2
from channels.db import database_sync_to_async
from core.settings import PRIMARY_HOST
from notifications.models import MessageTemplate
TEMPLATE_VARS = ['ticket_name', 'ticket_uuid', 'ticket_id', 'ticket_url',
'current_state', 'previous_state', 'current_state_pretty', 'previous_state_pretty',
'event_slug', 'event_name',
'username', 'user_nick',
'web_host'] # TODO customer_name, tracking_code
def limit_length(s, length=50):
if len(s) > length:
return s[:(length - 3)] + "..."
return s
def ticket_url(ticket):
eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded
return f"https://{PRIMARY_HOST}/{eventslug}/ticket/{ticket.id}/"
def render_template(template, **kwargs):
try:
environment = jinja2.Environment()
environment.filters['limit_length'] = limit_length
tmpl = MessageTemplate.objects.get(name=template)
template = environment.from_string(tmpl.message)
return template.render(**kwargs, web_host=PRIMARY_HOST)
except MessageTemplate.DoesNotExist:
return None
def get_ticket_vars(ticket):
states = list(ticket.state_changes.order_by('-timestamp'))
return {
'ticket_name': ticket.name,
'ticket_uuid': ticket.short_uuid(),
'ticket_id': ticket.id,
'ticket_url': ticket_url(ticket),
'current_state': states[0].state if states else 'none',
'previous_state': states[1].state if len(states) > 1 else 'none',
'current_state_pretty': states[0].get_state_display() if states else 'none',
'previous_state_pretty': states[1].get_state_display() if len(states) > 1 else 'none',
'event_slug': ticket.event.slug if ticket.event else "37C3", # TODO 37C3 should not be hardcoded
'event_name': ticket.event.name if ticket.event else "37C3",
}
def render_auto_reply(ticket):
return render_template('auto_reply', **get_ticket_vars(ticket))
def render_notification_new_ticket(ticket):
return render_template('new_issue_notification', **get_ticket_vars(ticket))
def render_notification_reply_ticket(ticket):
return render_template('reply_issue_notification', **get_ticket_vars(ticket))
async def render_notification_new_ticket_async(ticket):
return await database_sync_to_async(render_notification_new_ticket)(ticket)
async def render_notification_reply_ticket_async(ticket):
return await database_sync_to_async(render_notification_reply_ticket)(ticket)

View file

View file

@ -0,0 +1,21 @@
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
def notify_sessions(event, data):
def wrapper(func):
def wrapped(*args, **kwargs):
ret = func(*args, **kwargs)
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
event,
{
'type': 'notify',
'data': data,
}
)
return ret
return wrapped
return wrapper

View file

@ -1,3 +1,6 @@
aiodns==3.2.0
aiohttp==3.9.5
aiosignal==1.3.1
aiosmtpd==1.4.4.post2
aiosmtplib==3.0.1
anyio==4.1.0
@ -28,6 +31,7 @@ django-rest-knox==4.2.0
django-soft-delete==0.9.21
djangorestframework==3.14.0
drf-yasg==1.21.7
frozenlist==1.4.1
h11==0.14.0
hyperlink==21.0.0
idna==3.4
@ -38,11 +42,13 @@ Jinja2==3.1.2
MarkupSafe==2.1.3
msgpack==1.0.7
msgpack-python==0.5.6
multidict==6.0.5
openapi-codec==1.3.2
packaging==23.2
Pillow==10.1.0
pyasn1==0.5.1
pyasn1-modules==0.3.0
pycares==4.4.0
pycparser==2.21
pyOpenSSL==23.3.0
python-dotenv==1.0.0
@ -65,4 +71,5 @@ urllib3==2.1.0
uvicorn==0.24.0.post1
watchfiles==0.21.0
websockets==12.0
yarl==1.9.4
zope.interface==6.1

View file

@ -1,3 +1,6 @@
aiodns==3.2.0
aiohttp==3.9.5
aiosignal==1.3.1
aiosmtpd==1.4.4.post2
aiosmtplib==3.0.1
asgiref==3.7.2

7
core/server.py Normal file → Executable file
View file

@ -12,6 +12,7 @@ django.setup()
from helper import init_loop
from mail.protocol import LMTPHandler
from mail.socket import UnixSocketLMTPController
from notifications.dispatch import NotificationDispatcher
class UvicornServer(uvicorn.Server):
@ -54,6 +55,11 @@ async def lmtp(loop):
log.info("LMTP done")
async def notifications(loop):
dispatcher = NotificationDispatcher()
await dispatcher.run_forever()
def main():
import sdnotify
import setproctitle
@ -67,6 +73,7 @@ def main():
loop.create_task(web(loop))
# loop.create_task(tcp(loop))
loop.create_task(lmtp(loop))
loop.create_task(notifications(loop))
n = sdnotify.SystemdNotifier()
n.notify("READY=1")
log.info("Server ready")

View file

@ -1,4 +1,5 @@
import logging
from base64 import b64decode
from django.urls import re_path
from django.contrib.auth.decorators import permission_required
@ -10,11 +11,12 @@ from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from core.settings import MAIL_DOMAIN
from inventory.models import Event
from mail.models import Email
from mail.protocol import send_smtp, make_reply, collect_references
from notify_sessions.models import SystemEvent
from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher
from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer
from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer, SearchResultSerializer
class IssueViewSet(viewsets.ModelViewSet):
@ -22,6 +24,11 @@ class IssueViewSet(viewsets.ModelViewSet):
queryset = IssueThread.objects.all()
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = CommentSerializer
queryset = Comment.objects.all()
class ShippingVoucherViewSet(viewsets.ModelViewSet):
serializer_class = ShippingVoucherSerializer
queryset = ShippingVoucher.objects.all()
@ -55,7 +62,7 @@ def reply(request, pk):
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@permission_required('tickets.add_issuethread_manual', raise_exception=True)
def manual_ticket(request):
def manual_ticket(request, event_slug):
if 'name' not in request.data:
return Response({'status': 'error', 'message': 'missing name'}, status=status.HTTP_400_BAD_REQUEST)
if 'sender' not in request.data:
@ -116,13 +123,42 @@ def add_comment(request, pk):
return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
def filter_issues(issues, query):
query_tokens = query.split(' ')
for issue in issues:
value = 0
for token in query_tokens:
if token in issue.description:
value += 1
if value > 0:
yield {'search_score': value, 'issue': issue}
@api_view(['GET'])
@permission_classes([])
# @permission_classes([IsAuthenticated])
# @permission_required('view_item', raise_exception=True)
def search_issues(request, event_slug, query):
try:
event = Event.objects.get(slug=event_slug)
items = filter_issues(IssueThread.objects.filter(event=event), b64decode(query).decode('utf-8'))
return Response(SearchResultSerializer(items, many=True).data)
except Event.DoesNotExist:
return Response(status=404)
router = routers.SimpleRouter()
router.register(r'tickets', IssueViewSet, basename='issues')
# router.register(r'comments', CommentViewSet, basename='comments')
router.register(r'shipping_vouchers', ShippingVoucherViewSet, basename='shipping_vouchers')
# [-A-Za-z0-9+/]*={0,3}
urlpatterns = ([
re_path(r'tickets/states/$', get_available_states, name='get_available_states'),
re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),
re_path(r'^tickets/(?P<pk>\d+)/comment/$', add_comment, name='add_comment'),
re_path(r'^tickets/manual/$', manual_ticket, name='manual_ticket'),
re_path(r'^tickets/states/$', get_available_states, name='get_available_states'),
re_path(r'^(?P<event_slug>[\w-]+)/tickets/manual/$', manual_ticket, name='manual_ticket'),
re_path(r'^(?P<event_slug>[\w-]+)/tickets/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_issues,
name='search_issues'),
] + router.urls)

View file

@ -1,5 +1,5 @@
from django.db import models
from django.utils import timezone
from django.db import models
from django_softdelete.models import SoftDeleteModel
from authentication.models import ExtendedUser

View file

@ -52,8 +52,8 @@ class IssueSerializer(serializers.ModelSerializer):
ret = super().to_internal_value(data)
if 'state' in data:
ret['state'] = data['state']
# if 'assigned_to' in data:
# ret['assigned_to'] = data['assigned_to']
# if 'assigned_to' in data:
# ret['assigned_to'] = data['assigned_to']
return ret
def validate(self, attrs):
@ -134,3 +134,14 @@ class IssueSerializer(serializers.ModelSerializer):
def get_queryset(self):
return IssueThread.objects.all().order_by('-last_activity')
class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField()
item = IssueSerializer()
def to_representation(self, instance):
return {**IssueSerializer(instance['item']).data, 'search_score': instance['search_score']}
class Meta:
model = IssueThread

View file

@ -3,11 +3,14 @@ from datetime import datetime, timedelta
from django.test import TestCase, Client
from authentication.models import ExtendedUser
from inventory.models import Event
from mail.models import Email, EmailAttachment
from tickets.models import IssueThread, StateChange, Comment
from django.contrib.auth.models import Permission
from knox.models import AuthToken
from base64 import b64encode
class IssueApiTest(TestCase):
@ -230,7 +233,7 @@ class IssueApiTest(TestCase):
self.assertEqual(file2.hash, response.json()[0]['timeline'][1]['attachments'][1]['hash'])
def test_manual_creation(self):
response = self.client.post('/api/2/tickets/manual/',
response = self.client.post('/api/2/evt/tickets/manual/',
{'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'},
content_type='application/json')
self.assertEqual(response.status_code, 201)
@ -247,6 +250,16 @@ class IssueApiTest(TestCase):
self.assertEqual(timeline[1]['subject'], 'test issue')
self.assertEqual(timeline[1]['body'], 'test')
# def test_post_comment(self):
# issue = IssueThread.objects.create(
# name="test issue",
# )
# response = self.client.post('/api/2/comments/', {'comment': 'test', 'issue_thread': issue.id})
# self.assertEqual(response.status_code, 201)
# self.assertEqual(response.json()['comment'], 'test')
# self.assertEqual(response.json()['issue_thread'], issue.id)
# self.assertEqual(response.json()['timestamp'], response.json()['timestamp'])
def test_post_comment_altenative(self):
issue = IssueThread.objects.create(
name="test issue",
@ -305,3 +318,21 @@ class IssueApiTest(TestCase):
self.assertEqual('pending_new', timeline[0]['state'])
self.assertEqual('assignment', timeline[1]['type'])
self.assertEqual(self.user.username, timeline[1]['assigned_to'])
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.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
def test_search(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())

View file

@ -9,3 +9,5 @@ LEGACY_API_USER={{ legacy_api_user }}
LEGACY_API_PASSWORD={{ legacy_api_password }}
MEDIA_ROOT=/var/www/c3lf-sys3/userfiles
STATIC_ROOT=/var/www/c3lf-sys3/staticfiles
TELEGRAM_GROUP_CHAT_ID={{ telegram_group_chat_id }}
TELEGRAM_BOT_TOKEN={{ telegram_bot_token }}

View file

@ -0,0 +1,13 @@
FROM python:3.11-bookworm
LABEL authors="lagertonne"
ENV PYTHONUNBUFFERED 1
RUN mkdir /code
WORKDIR /code
COPY requirements.dev.txt /code/
COPY requirements.prod.txt /code/
RUN apt update && apt install -y mariadb-client
RUN pip install -r requirements.dev.txt
RUN pip install -r requirements.prod.txt
RUN pip install mysqlclient
COPY .. /code/

View file

@ -0,0 +1,6 @@
FROM docker.io/node:22
RUN mkdir /web
WORKDIR /web
COPY package.json /web/
RUN npm install

View file

@ -0,0 +1,33 @@
services:
core:
build:
context: ../../core
dockerfile: ../deploy/dev/Dockerfile.backend
command: bash -c 'python manage.py migrate && python manage.py runserver 0.0.0.0:8000'
environment:
- HTTP_HOST=core
#- DATABASE_URL
volumes:
- ../../core:/code
ports:
- "8000:8000"
frontend:
build:
context: ../../web
dockerfile: ../deploy/dev/Dockerfile.frontend
command: npm run serve
volumes:
- ../../web:/web:ro
- /web/node_modules
- ./vue.config.js:/web/vue.config.js
ports:
- "8080:8080"
db:
image: mariadb
environment:
MARIADB_RANDOM_ROOT_PASSWORD: true
MARIADB_DATABASE: system3
MARIADB_USER: system3
MARIADB_PASSWORD: system3

27
deploy/dev/vue.config.js Normal file
View file

@ -0,0 +1,27 @@
// vue.config.js
module.exports = {
devServer: {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Methods": "*"
},
proxy: {
'^/media/2': {
target: 'http://core:8000/',
},
'^/api/2': {
target: 'http://core:8000/',
},
'^/api/1': {
target: 'http://core:8000/',
},
'^/ws/2': {
target: 'http://core:8000/',
ws: true,
logLevel: 'debug',
},
}
}
}

View file

@ -8,6 +8,7 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@chenfengyuan/vue-qrcode": "^2.0.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",

View file

@ -0,0 +1,133 @@
<template>
<div class="async-wrapper" :class="{ 'loaded': loaded }">
<div class="deferred">
<slot></slot>
</div>
<div class="loader-wrapper">
<div class="loader-ellipsis">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AsyncLoader',
props: {
loaded: {
type: Boolean,
default: false
}
}
};
</script>
<style>
.async-wrapper {
position: relative;
}
.async-wrapper > .deferred {
width: 100%;
height: 100%;
display: none;
}
.async-wrapper.loaded > .deferred {
display: block;
}
.async-wrapper > .loader-wrapper {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis {
color: #17a2b8;
}
.async-wrapper.loaded > .loader-wrapper {
display: none;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis,
.async-wrapper > .loader-wrapper > .loader-ellipsis div {
box-sizing: border-box;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis div {
position: absolute;
top: 33.33333px;
width: 13.33333px;
height: 13.33333px;
border-radius: 50%;
background: currentColor;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(1) {
left: 8px;
animation: loader-ellipsis1 0.6s infinite;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(2) {
left: 8px;
animation: loader-ellipsis2 0.6s infinite;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(3) {
left: 32px;
animation: loader-ellipsis2 0.6s infinite;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(4) {
left: 56px;
animation: loader-ellipsis3 0.6s infinite;
}
@keyframes loader-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes loader-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes loader-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
</style>

View file

@ -1,6 +1,31 @@
<template>
<div class="row">
<div class="col-lg-3 col-xl-2">
<!--div class="card bg-dark text-light mb-2" id="filters">
<div class="card-body">
<h5 class="card-title text-info">Sort & Filter</h5>
<div class="form-group" v-for="(column, index) in columns" :key="index">
<label>{{ column }}</label>
<div class="input-group">
<div class="input-group-prepend">
<button
:class="[ 'btn', column === sortBy ? 'btn-outline-info' : 'btn-outline-secondary' ]"
type="button"
@click="toggleSort(column)">
<font-awesome-icon :icon="getSortIcon(column)"/>
</button>
</div>
<input
type="text"
class="form-control"
placeholder="filter"
:value="filters[column]"
@input="changeFilter(column, $event.target.value)"
>
</div>
</div>
</div>
</div-->
</div>
<div class="col-lg-9 col-xl-8">
<div class="w-100"
@ -58,6 +83,13 @@ export default {
} else {
this.collapsed = this.sections.map(() => true);
}
//this.$router.push({...this.$router.currentRoute, query: {...this.$router.currentRoute.query, layout}});
//this.collapsed = this.sections.map(() => true);
/*this.columns.map(e => ({
k: e,
v: this.$store.getters.getFilters[e]
})).filter(e => e.v).forEach(e => this.setFilter(e.k, e.v));*/
},
computed: {
grouped_items() {

View file

@ -29,16 +29,7 @@
</router-link>
</li>
</ul>
<form class="form-inline mt-1 my-lg-auto my-xl-auto w-100 d-inline mr-1" v-if="hasPermissions">
<input
class="form-control w-100"
type="search"
placeholder="Search"
aria-label="Search"
@input="searchEventItems($event.target.value)"
disabled
>
</form>
<SearchBox v-if="hasPermissions" class="mt-1 my-lg-auto my-xl-auto w-100 d-inline mr-1"/>
<div class="custom-control-inline mr-1" v-if="hasPermissions">
<div class="btn-group btn-group-toggle mr-1" v-if="isItemView()">
<button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')">
@ -103,9 +94,13 @@
<script>
import {mapState, mapActions, mapMutations, mapGetters} from 'vuex';
import SearchBox from "@/components/inputs/SearchBox.vue";
export default {
name: 'Navbar',
components: {
SearchBox
},
data: () => ({
views: [
{'title': 'items', 'path': 'items'},
@ -122,7 +117,7 @@ export default {
...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions", "layout", "route"]),
},
methods: {
...mapActions(['changeEvent', 'changeView', 'searchEventItems']),
...mapActions(['changeEvent', 'changeView']),
...mapMutations(['logout']),
navigateTo(link) {
if (this.route.path !== link)

View file

@ -0,0 +1,105 @@
<template>
<table class="table table-striped table-dark">
<thead>
<tr>
<th scope="col" v-for="(column, index) in columns" :key="index"
v-if="columnHasData[index]||columnHasSlot[index]">
<div class="input-group" v-if="columnHasData[index]">
<div class="input-group-prepend">
<button
:class="[ 'btn', column === sortBy ? 'btn-outline-info' : 'btn-outline-secondary' ]"
@click="toggleSort(column)"
>
{{ column }}
<span :class="{ 'text-info': column === sortBy }">
<font-awesome-icon :icon="getSortIcon(column)"/>
</span>
</button>
</div>
<input
type="text"
class="form-control"
placeholder="filter"
:value="filters[column]"
@input="changeFilter(column, $event.target.value)"
>
</div>
<span v-else-if="columnHasSlot[index]">
{{ column }}
</span>
</th>
<th>
<slot name="header_actions"/>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in internalItems" :key="item[keyName]" @click="$emit('itemActivated', item)">
<td v-for="(column, index) in columns" :key="index" v-if="columnHasSlot[index]||columnHasData[index]">
<slot v-if="columnHasSlot[index]" :name="column" :item="item"/>
<span v-else-if="columnHasData[index]">
{{ item[column] }}
</span>
<span v-else>
{{ column }}
</span>
</td>
<td>
<slot v-bind:item="item" name="actions"/>
</td>
</tr>
</tbody>
</table>
</template>
<script>
import DataContainer from '@/mixins/data-container';
import router from '../router';
export default {
name: 'SlotTable',
mixins: [DataContainer],
data() {
return {
columnHasSlot: [],
columnHasData: []
}
},
created() {
this.columns.map(e => ({
k: e,
v: this.$store.getters.getFilters[e]
})).filter(e => e.v).forEach(e => this.setFilter(e.k, e.v));
},
mounted() {
this.columnHasSlot = this.columns.map(e => Object.keys(this.$slots).includes(e));
this.columnHasData = this.columns.map(e => this.items.reduce((a, b) => a || b[e] !== undefined, false));
//console.log(this.columnHasData, this.columnHasSlot, this.columns, Object.keys(this.$slots), this.$slots);
for (let slot in this.$slots) {
console.log(`Slot: ${slot}`);
console.log(`Data: ${this.$slots[slot]}`);
}
},
beforeUpdate() {
this.columnHasSlot = this.columns.map(e => Object.keys(this.$slots).includes(e));
this.columnHasData = this.columns.map(e => this.items.reduce((a, b) => a || b[e] !== undefined, false));
},
methods: {
changeFilter(col, val) {
this.setFilter(col, val);
let newquery = Object.entries({
...this.$store.getters.getFilters,
[col]: val
}).reduce((a, [k, v]) => (v ? {...a, [k]: v} : a), {});
router.push({query: newquery});
},
},
};
</script>
<style>
.table-body-move {
transition: transform 1s;
}
</style>

View file

@ -40,10 +40,10 @@
<div class="">
<textarea placeholder="add comment..." v-model="newComment" class="form-control">
</textarea>
<button class="btn btn-primary float-right" @click="addCommentAndClear">
<AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
<font-awesome-icon icon="comment"/>
Save Comment
</button>
</AsyncButton>
</div>
</div>
</li>
@ -58,10 +58,10 @@
<div>
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
</textarea>
<button class="btn btn-primary float-right" @click="sendMailAndClear">
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear">
<font-awesome-icon icon="envelope"/>
Send Mail
</button>
</AsyncButton>
</div>
</div>
</li>
@ -77,11 +77,12 @@ import {mapActions, mapGetters} from "vuex";
import TimelineAssignment from "@/components/TimelineAssignment.vue";
import TimelineRelatedItem from "@/components/TimelineRelatedItem.vue";
import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue";
export default {
name: 'Timeline',
components: {
TimelineShippingVoucher,
TimelineShippingVoucher, AsyncButton,
TimelineRelatedItem, TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail
},
props: {
@ -103,18 +104,17 @@ export default {
},
},
methods: {
...mapActions(['fetchShippingVouchers']),
sendMailAndClear: function () {
this.$emit('sendMail', this.newMail);
...mapActions(['sendMail', 'postComment']),
sendMailAndClear: async function () {
//this.$emit('sendMail', this.newMail);
await this.sendMail(this.newMail);
this.newMail = "";
},
addCommentAndClear: function () {
this.$emit('addComment', this.newComment);
addCommentAndClear: async function () {
//this.$emit('addComment', this.newComment);
await this.postComment(this.newComment);
this.newComment = "";
}
},
mounted() {
this.fetchShippingVouchers();
}
};
</script>

View file

@ -0,0 +1,48 @@
<template>
<div class="toast" :class="color && ('border-' + color)" role="alert" ref="toast" data-autohide="false">
<div class="toast-header" :class="[color && ('bg-' + color), color && 'text-light']">
<strong class="mr-auto pr-3">{{ title }}</strong>
<small>{{ displayTime }}</small>
<button type="button" class="ml-2 mb-1 close" @click="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!--div class="toast-body" v-html="message">{{ message }}</div-->
</div>
</template>
<script>
import $ from 'jquery';
import 'bootstrap/js/dist/toast';
import {DateTime} from 'luxon';
export default {
name: 'Toast',
props: ['title', 'message', 'color'],
data: () => ({
creationTime: DateTime.local(),
displayTime: 'just now',
timer: undefined
}),
mounted() {
const {toast} = this.$refs;
$(toast).toast('show');
this.timer = setInterval(this.updateDisplayTime, 1000);
},
methods: {
close() {
const {toast} = this.$refs;
$(toast).toast('hide');
window.setTimeout(() => {
this.$emit('close');
}, 500);
},
updateDisplayTime() {
this.displayTime = this.creationTime.toRelative();
}
},
beforeDestroy() {
clearInterval(this.timer);
}
};
</script>

View file

@ -0,0 +1,47 @@
<template>
<button @click.stop="handleClick" :disabled="disabled">
<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>
</button>
</template>
<script>
export default {
name: 'AsyncButton',
data() {
return {
disabled: false,
};
},
props: {
task: {
type: Function,
required: true,
},
},
methods: {
async handleClick() {
console.log("AsyncButton.handleClick() called");
if (this.task && typeof this.task === 'function') {
this.disabled = true;
try {
await this.task();
} catch (e) {
console.error(e);
} finally {
this.disabled = false;
}
}
},
}
};
</script>
<style scoped>
.spinner-border {
vertical-align: -0.125em;
}
</style>

View file

@ -0,0 +1,112 @@
<template>
<div contenteditable @input="onchange" ref="text">
</div>
</template>
<script>
export default {
name: 'FormatedText',
props: {
value: {
type: String,
required: true
},
format: {
type: Function,
default: null
}
},
data() {
return {
selection: {start: 0, end: 0, direction: 'forward', type: 'Caret'}
};
},
emits: ['input'],
methods: {
rawhtml(value) {
if (typeof this.format === 'function') {
return this.format(value.replace(/ /g, '&nbsp;'));
} else {
return value;
}
},
onchange(event) {
const div = this.$refs.text;
const sel = window.getSelection();
if (sel.rangeCount > 0) {
this.selection.start = this.calculateOffset(div, sel.anchorNode, sel.anchorOffset);
this.selection.end = this.calculateOffset(div, sel.focusNode, sel.focusOffset);
this.selection.direction = sel.direction;
this.selection.type = sel.type;
}
this.$emit('input', event.target.innerText.replace(/&nbsp;/g, ' ').replace(/\xA0/g, ' '));
},
calculateOffset(container, node, offset) {
let position = 0;
let found = false;
const walk = (elem) => {
if (elem === node) {
found = true;
return;
}
if (elem.nodeType === 3) {
position += elem.length;
} else {
for (let i = 0; i < elem.childNodes.length; i++) {
walk(elem.childNodes[i]);
if (found) {
return;
}
}
}
};
walk(container);
return position + offset;
},
findNode(container, offset) {
let position = 0;
let found = false;
let node = null;
const walk = (elem) => {
if (position + elem.length >= offset) {
found = true;
node = elem;
return;
}
if (elem.nodeType === 3) {
position += elem.length;
} else {
for (let i = 0; i < elem.childNodes.length; i++) {
walk(elem.childNodes[i]);
if (found) {
return;
}
}
}
};
walk(container);
return [node, offset - position]
},
},
watch: {
value() {
if (this.selection) {
const div = this.$refs.text;
div.innerHTML = this.rawhtml(this.value);
const range = document.createRange();
const sel = window.getSelection();
range.setStart(...this.findNode(div, this.selection.start));
range.setEnd(...this.findNode(div, this.selection.end));
sel.removeAllRanges();
sel.addRange(range);
}
}
},
mounted() {
const div = this.$refs.text;
div.innerHTML = this.rawhtml(this.value);
}
};
</script>

View file

@ -0,0 +1,43 @@
<template>
<input
class="form-control w-100"
type="search"
placeholder="Search"
aria-label="Search"
v-model="search_query"
@keyup.enter="dispatchSearch"
>
</template>
<script>
import {mapActions, mapGetters} from "vuex";
export default {
name: 'SearchBox',
data() {
return {
search_query: ''
}
},
computed: {
...mapGetters(['getActiveView'])
},
methods: {
...mapActions(['searchEventItems', 'searchEventTickets']),
isItemView() {
return this.getActiveView === 'items' || this.getActiveView === 'item';
},
isTicketView() {
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket';
},
dispatchSearch() {
if (this.isItemView()) {
this.searchEventItems(this.search_query);
} else if (this.isTicketView()) {
this.searchEventTickets(this.search_query);
}
}
}
};
</script>

View file

@ -1,5 +1,6 @@
import {createApp} from 'vue'
import App from './App.vue';
import VueQrcode from '@chenfengyuan/vue-qrcode';
import store from './store';
import router from './router';
@ -52,5 +53,6 @@ library.add(faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, fa
const app = createApp(App).use(store).use(router);
app.component(VueQrcode.name, VueQrcode);
app.component('font-awesome-icon', FontAwesomeIcon);
app.mount('#app')

View file

@ -4,6 +4,7 @@ import store from '@/store';
import Items from './views/Items';
import Boxes from './views/Boxes';
import Files from './views/Files';
import Error from './views/Error';
import HowTo from './views/HowTo';
import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue';
@ -13,9 +14,11 @@ import Ticket from "@/views/Ticket.vue";
import Admin from "@/views/admin/Admin.vue";
import Empty from "@/views/Empty.vue";
import Events from "@/views/admin/Events.vue";
import Settings from "@/views/admin/Settings.vue";
import AccessControl from "@/views/admin/AccessControl.vue";
import {default as BoxesAdmin} from "@/views/admin/Boxes.vue"
import Shipping from "@/views/admin/Shipping.vue";
import Notifications from "@/views/admin/Notifications.vue";
const routes = [
{path: '/', redirect: '/37C3/items', meta: {requiresAuth: false}},
@ -58,6 +61,10 @@ const routes = [
path: 'events/', name: 'events', component: Events, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
{
path: 'settings/', name: 'settings', component: Settings, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
{
path: '', name: 'admin', component: Debug, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
@ -74,9 +81,14 @@ const routes = [
path: 'shipping/', name: 'shipping', component: Shipping, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
{
path: 'notifications/', name: 'notifications', component: Notifications, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
}
]
},
{path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}},
//{path: '*', component: Error},
];
const router = createRouter({
@ -110,3 +122,4 @@ router.afterEach((to, from) => {
});
export default router;

View file

@ -19,10 +19,14 @@ const store = createStore({
users: [],
groups: [],
state_options: [],
messageTemplates: [],
messageTemplateVariables: [],
shippingVouchers: [],
userNotificationChannels: [],
lastEvent: '37C3',
lastUsed: {},
searchQuery: '',
remember: false,
user: {
username: null,
@ -41,7 +45,9 @@ const store = createStore({
users: 0,
groups: 0,
states: 0,
messageTemplates: 0,
shippingVouchers: 0,
userNotificationChannels: 0,
},
persistent_loaded: false,
shared_loaded: false,
@ -49,6 +55,7 @@ const store = createStore({
showAddBoxModal: false,
showAddEventModal: false,
test: ['foo', 'bar', 'baz'],
shippingVoucherTypes: {
'2kg-de': '2kg Paket (DE)',
@ -218,13 +225,27 @@ const store = createStore({
user.permissions = null;
state.user = user;
},
setTest(state, test) {
state.test = test;
},
setThumbnail(state, {url, data}) {
state.thumbnailCache[url] = data;
},
setMessageTemplates(state, templates) {
state.messageTemplates = templates;
state.fetchedData = {...state.fetchedData, messageTemplates: Date.now()};
},
setMessageTemplateVariables(state, variables) {
state.messageTemplateVariables = variables;
},
setShippingVouchers(state, codes) {
state.shippingVouchers = codes;
state.fetchedData = {...state.fetchedData, shippingVouchers: Date.now()};
},
setUserNotificationChannels(state, channels) {
state.userNotificationChannels = channels;
state.fetchedData = {...state.fetchedData, userNotificationChannels: Date.now()};
},
},
actions: {
async login({commit}, {username, password, remember}) {
@ -355,10 +376,9 @@ const store = createStore({
}
},
async searchEventItems({commit, getters, state}, query) {
const foo = utf8.encode(query);
const bar = base64.encode(foo);
const encoded_query = base64.encode(utf8.encode(query));
const {data, success} = await http.get(`/2/${getters.getEventSlug}/items/${bar}/`, state.user.token);
const {data, success} = await http.get(`/2/${getters.getEventSlug}/items/${encoded_query}/`, state.user.token);
if (data && success)
commit('replaceLoadedItems', data);
},
@ -407,6 +427,13 @@ const store = createStore({
if (data && success)
commit('replaceTickets', data);
},
async searchEventTickets({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query));
const {data, success} = await http.get(`/2/${getters.getEventSlug}/tickets/${encoded_query}/`, state.user.token);
if (data && success)
commit('replaceTickets', data);
},
async sendMail({commit, dispatch, state}, {id, message}) {
const {data, success} = await http.post(`/2/tickets/${id}/reply/`, {message}, state.user.token);
if (data && success) {
@ -452,6 +479,39 @@ const store = createStore({
const {data, success} = await http.patch(`/2/tickets/${id}/`, ticket, state.user.token);
commit('updateTicket', data);
},
async fetchMessageTemplates({commit, state}) {
if (!state.user.token) return;
if (state.messageTemplates.length > 0) return;
const {data, success} = await http.get('/2/message_templates/', state.user.token);
if (data && success) {
commit('setMessageTemplates', data);
}
},
async updateMessageTemplate({dispatch, state}, template) {
const {data, success} = await http.patch(`/2/message_templates/${template.id}/`,
{'message': template.message}, state.user.token);
if (data && success) {
state.fetchedData.messageTemplates = 0;
dispatch('fetchMessageTemplates');
}
},
async fetchMessageTemplateVariables({commit, state}) {
if (!state.user.token) return;
if (state.messageTemplateVariables.length > 0) return;
const {data, success} = await http.get('/2/message_template_variables/', state.user.token);
if (data && success) {
commit('setMessageTemplateVariables', data);
}
},
async createMessageTemplate({dispatch, state}, template_name) {
const {data, success} = await http.post('/2/message_templates/', {
name: template_name,
message: '-'
}, state.user.token);
if (data && success) {
dispatch('fetchMessageTemplates');
}
},
async fetchShippingVouchers({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.shippingVouchers > Date.now() - 1000 * 60 * 60 * 24) return;
@ -478,7 +538,15 @@ const store = createStore({
state.fetchedData.tickets = 0;
await Promise.all([dispatch('loadTickets'), dispatch('fetchShippingVouchers')]);
}
}
},
async fetchUserNotificationChannels({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.userNotificationChannels > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/user_notification_channels/', state.user.token);
if (data && success) {
commit('setUserNotificationChannels', data);
}
},
},
plugins: [
persistentStatePlugin({ // TODO change remember to some kind of enable field
@ -506,6 +574,8 @@ const store = createStore({
"groups",
"loadedBoxes",
"loadedItems",
"messageTemplates",
"messageTemplatesVariables",
"shippingVouchers",
],
watch: [
@ -517,6 +587,8 @@ const store = createStore({
"groups",
"loadedBoxes",
"loadedItems",
"messageTemplates",
"messageTemplatesVariables",
"shippingVouchers",
],
mutations: [

View file

@ -1,77 +1,82 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<Modal title="Edit Item" v-if="editingItem" @close="closeEditingModal()">
<template #body>
<EditItem
:item="editingItem"
badge="uid"
<AsyncLoader :loaded="loadedItems.length > 0">
<div class="container-fluid px-xl-5 mt-3">
<Modal title="Edit Item" v-if="editingItem" @close="closeEditingModal()">
<template #body>
<EditItem
:item="editingItem"
badge="uid"
/>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="closeEditingModal()">Cancel</button>
<button type="button" class="btn btn-success" @click="saveEditingItem()">Save Changes</button>
</template>
</Modal>
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="closeLightboxModal()"/>
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['uid', 'description', 'box']"
:items="loadedItems"
:keyName="'uid'"
@itemActivated="openLightboxModalWith($event)"
>
<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="['uid', 'description', 'box']"
:items="loadedItems"
:keyName="'uid'"
v-slot="{ item }"
@itemActivated="openLightboxModalWith($event)"
>
<AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`"
class="card-img-top img-fluid"
/>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="closeEditingModal()">Cancel</button>
<button type="button" class="btn btn-success" @click="saveEditingItem()">Save Changes</button>
</template>
</Modal>
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="closeLightboxModal()"/>
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['uid', 'description', 'box']"
:items="loadedItems"
:keyName="'uid'"
@itemActivated="openLightboxModalWith($event)"
>
<template #actions="{ item }">
<div class="card-body">
<h6 class="card-title">{{ item.description }}</h6>
<h6 class="card-subtitle text-secondary">uid: {{ item.uid }} box: {{ item.box }}</h6>
<div class="row mx-auto mt-2">
<div class="btn-group">
<button class="btn btn-success"
<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-secondary" @click.stop="openEditingModalWith(item)" title="edit">
<button class="btn btn-outline-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)"
<button class="btn btn-outline-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="['uid', 'description', 'box']"
:items="loadedItems"
:keyName="'uid'"
v-slot="{ item }"
@itemActivated="openLightboxModalWith($event)"
>
<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">uid: {{ item.uid }} 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="openEditingModalWith(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>
</Cards>
</div>
</AsyncLoader>
</template>
<script>
@ -82,6 +87,7 @@ import EditItem from '@/components/EditItem';
import {mapActions, mapGetters, mapState} from 'vuex';
import Lightbox from '../components/Lightbox';
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
export default {
name: 'Items',
@ -89,7 +95,7 @@ export default {
lightboxHash: null,
editingItem: null,
}),
components: {AuthenticatedImage, Lightbox, Table, Cards, Modal, EditItem},
components: {AsyncLoader, AuthenticatedImage, Lightbox, Table, Cards, Modal, EditItem},
computed: {
...mapState(['loadedItems']),
...mapGetters(['layout']),

View file

@ -1,67 +1,73 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-header">
<h3>Ticket #{{ ticket.id }} - {{ ticket.name }}</h3>
</div>
<Timeline :timeline="ticket.timeline" @sendMail="handleMail" @addComment="handleComment"/>
<div class="card-footer d-flex justify-content-between">
<button class="btn btn-secondary mr-2" @click="$router.go(-1)">Back</button>
<!--button class="btn btn-danger" @click="deleteItem({type: 'tickets', id: ticket.id})">
<font-awesome-icon icon="trash"/>
Delete
</button-->
<div class="btn-group">
<select class="form-control" v-model="ticket.assigned_to">
<option v-for="user in users" :value="user.username">{{ user.username }}</option>
</select>
<button class="form-control btn btn-success" @click="assigTicket(ticket)">
Assign&nbsp;Ticket
</button>
<AsyncLoader :loaded="ticket.id">
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-header">
<h3>Ticket #{{ ticket.id }} - {{ ticket.name }}</h3>
</div>
<div class="btn-group">
<select class="form-control" v-model="ticket.state">
<option v-for="status in state_options" :value="status.value">{{ status.text }}</option>
</select>
<button class="form-control btn btn-success" @click="changeTicketStatus(ticket)">
Change&nbsp;Status
</button>
<Timeline :timeline="ticket.timeline" @sendMail="handleMail" @addComment="handleComment"/>
<div class="card-footer d-flex justify-content-between">
<button class="btn btn-secondary mr-2" @click="$router.go(-1)">Back</button>
<!--button class="btn btn-danger" @click="deleteItem({type: 'tickets', id: ticket.id})">
<font-awesome-icon icon="trash"/>
Delete
</button-->
<div class="btn-group">
<select class="form-control" v-model="ticket.assigned_to">
<option v-for="user in users" :value="user.username">{{ user.username }}</option>
</select>
<button class="form-control btn btn-success" @click="assignTicket(ticket)">
Assign&nbsp;Ticket
</button>
</div>
<div class="btn-group">
<select class="form-control" v-model="ticket.state">
<option v-for="status in state_options" :value="status.value">{{
status.text
}}
</option>
</select>
<button class="form-control btn btn-success" @click="changeTicketStatus(ticket)">
Change&nbsp;Status
</button>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between">
<ClipboardButton :payload="shippingEmail" class="btn btn-primary">
<font-awesome-icon icon="clipboard"/>
Copy&nbsp;DHL&nbsp;contact&nbsp;to&nbsp;clipboard
</ClipboardButton>
<div class="btn-group">
<select class="form-control" v-model="shipping_voucher_type">
<option v-for="type in availableShippingVoucherTypes.filter(t=>t.count>0)"
:value="type.id">{{ type.name }}
</option>
</select>
<button class="form-control btn btn-success"
@click="claimShippingVoucher({ticket: ticket.id, shipping_voucher_type}).then(()=>shipping_voucher_type=null)"
:disabled="!shipping_voucher_type">
Claim&nbsp;Shipping&nbsp;Voucher
</button>
<div class="card-footer d-flex justify-content-between">
<ClipboardButton :payload="shippingEmail" class="btn btn-primary">
<font-awesome-icon icon="clipboard"/>
Copy&nbsp;DHL&nbsp;contact&nbsp;to&nbsp;clipboard
</ClipboardButton>
<div class="btn-group">
<select class="form-control" v-model="shipping_voucher_type">
<option v-for="type in availableShippingVoucherTypes.filter(t=>t.count>0)"
:value="type.id">{{ type.name }}
</option>
</select>
<button class="form-control btn btn-success"
@click="claimShippingVoucher({ticket: ticket.id, shipping_voucher_type}).then(()=>shipping_voucher_type=null)"
:disabled="!shipping_voucher_type">
Claim&nbsp;Shipping&nbsp;Voucher
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AsyncLoader>
</template>
<script>
import {mapActions, mapGetters, mapState} from 'vuex';
import Timeline from "@/components/Timeline.vue";
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
export default {
name: 'Ticket',
components: {ClipboardButton, Timeline},
components: {AsyncLoader, ClipboardButton, Timeline},
data() {
return {
shipping_voucher_type: null
@ -83,7 +89,7 @@ export default {
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
...mapActions(['claimShippingVoucher']),
...mapActions(['claimShippingVoucher', 'fetchShippingVouchers']),
handleMail(mail) {
this.sendMail({
id: this.ticket.id,
@ -102,7 +108,7 @@ export default {
state: ticket.state
})
},
assigTicket(ticket) {
assignTicket(ticket) {
this.updateTicketPartial({
id: ticket.id,
assigned_to: ticket.assigned_to
@ -110,7 +116,8 @@ export default {
},
},
mounted() {
this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets(), this.loadUsers()]);
this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets(), this.loadUsers(),
this.fetchShippingVouchers()]);
}
};
</script>

View file

@ -1,51 +1,53 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to']"
:items="tickets"
:keyName="'id'"
v-if="layout === 'table'"
>
<template #actions="{ item }">
<div class="btn-group">
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
@click.prevent="gotoDetail(item)">
<font-awesome-icon icon="eye"/>
View
</a>
</div>
</template>
</Table>
<AsyncLoader :loaded="tickets.length > 0">
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<SlotTable
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to', 'actions', 'actions2']"
:items="tickets.map(formatTicket)"
:keyName="'id'"
v-if="layout === 'table'"
>
<template v-slot:actions="{item}">
<div class="btn-group">
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
@click.prevent="gotoDetail(item)">
<font-awesome-icon icon="eye"/>
View
</a>
</div>
</template>
</SlotTable>
</div>
</div>
</div>
<CollapsableCards v-if="layout === 'tasks'" :items="tickets"
:columns="['id', 'name', 'last_activity', 'assigned_to']"
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
<CollapsableCards v-if="layout === 'tasks'" :items="tickets"
:columns="['id', 'name', 'last_activity', 'assigned_to']"
: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>
<div class="btn-group">
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
@click.prevent="gotoDetail(item)">
<font-awesome-icon icon="eye"/>
View
</a>
</div>
</td>
</tr>
</template>
</CollapsableCards>
</div>
<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>
<div class="btn-group">
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
@click.prevent="gotoDetail(item)">
<font-awesome-icon icon="eye"/>
View
</a>
</div>
</td>
</tr>
</template>
</CollapsableCards>
</div>
</AsyncLoader>
</template>
<script>
@ -54,12 +56,13 @@ import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
import {mapActions, mapGetters, mapState} from 'vuex';
import Lightbox from '../components/Lightbox';
import Table from '@/components/Table';
import SlotTable from "@/components/SlotTable.vue";
import CollapsableCards from "@/components/CollapsableCards.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
export default {
name: 'Tickets',
components: {Lightbox, Table, Cards, Modal, EditItem, CollapsableCards},
components: {AsyncLoader, Lightbox, SlotTable, Cards, Modal, EditItem, CollapsableCards},
computed: {
...mapState(['tickets']),
...mapGetters(['stateInfo', 'getEventSlug', 'layout']),

View file

@ -8,9 +8,15 @@
<li class="nav-item">
<router-link class="nav-link" :to="{name: 'admin'}" active-class="dummy" exact-active-class="active">Dashboard</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="{name: 'settings'}" active-class="active">Settings</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="{name: 'events'}" active-class="active">Events</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="{name: 'notifications'}" active-class="active">Notifications</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="{name: 'shipping'}" active-class="active">Shipping</router-link>
</li>

View file

@ -1,39 +1,54 @@
<template>
<div>
<!--qr-code :text="qr_url" color="#000" bg-color="#fff" error-level="H" class="qr-code"></qr-code-->
<h3 class="text-center">Events</h3>
<!--p>{{ events }}</p-->
<ul>
<li v-for="event in events" :key="event.id">
{{ event.slug }}
</li>
</ul>
<h3 class="text-center">Items</h3>
<!--p>{{ loadedItems }}</p-->
<ul>
<li v-for="item in loadedItems" :key="item.id">
{{ item.description }}
</li>
</ul>
<h3 class="text-center">Boxes</h3>
<!--p>{{ loadedBoxes }}</p-->
<ul>
<li v-for="box in loadedBoxes" :key="box.id">
{{ box.name }}
</li>
</ul>
<h3 class="text-center">Issues</h3>
<!--p>{{ issues }}</p-->
<ul>
<li v-for="issue in tickets" :key="issue.id">
{{ issue.id }}
</li>
</ul>
<div>
<ul>
<li>
<button class="btn btn-primary" @click="addTest('test')">+</button>
</li>
<li v-for="(t, index) in test" :key="index">
{{ t }}
<button class="btn btn-link" @click="removeTest(index)">-</button>
</li>
</ul>
</div>
<vue-qrcode :value="qr_url" tag="svg" :size="200" :options="{errorCorrectionLevel: 'H'}"></vue-qrcode>
<!--qr-code :text="" color="#000" bg-color="#fff" error-level="H" ></qr-code-->
<h3 class="text-center">Events</h3>
<!--p>{{ events }}</p-->
<span>{{ events.length }} loaded events</span>
<ul class="hidden">
<li v-for="event in events" :key="event.id">
{{ event.slug }}
</li>
</ul>
<h3 class="text-center">Items</h3>
<!--p>{{ loadedItems }}</p-->
<span>{{ loadedItems.length }} loaded items</span>
<ul class="hidden">
<li v-for="item in loadedItems" :key="item.id">
{{ item.description }}
</li>
</ul>
<h3 class="text-center">Boxes</h3>
<!--p>{{ loadedBoxes }}</p-->
<span>{{ loadedBoxes.length }} loaded boxes</span>
<ul class="hidden">
<li v-for="box in loadedBoxes" :key="box.id">
{{ box.name }}
</li>
</ul>
<h3 class="text-center">Issues</h3>
<!--p>{{ issues }}</p-->
<ul>
<li v-for="issue in tickets" :key="issue.id">
{{ issue.id }}
</li>
</ul>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import {mapActions, mapMutations, mapState} from 'vuex';
import Table from '@/components/Table';
export default {
@ -41,6 +56,8 @@ export default {
components: {Table},
computed: {
...mapState(['events', 'loadedItems', 'loadedBoxes', 'tickets']),
...mapState(['events', 'loadedItems', 'loadedBoxes']),//, 'mails', 'issues', 'systemEvents']),
...mapState(['test']),
qr_url() {
return window.location.href;
}
@ -48,6 +65,17 @@ export default {
methods: {
...mapActions(['changeEvent', 'loadTickets']),
...mapActions(['changeEvent']),//, 'loadMails', 'loadIssues', 'loadSystemEvents']),
...mapMutations(['setTest']),
addTest(test) {
const tests = [...this.test, test];
this.setTest(tests);
},
removeTest(index) {
const tests = [...this.test];
tests.splice(index, 1);
this.setTest(tests);
}
},
mounted() {
this.loadTickets();
@ -56,7 +84,4 @@ export default {
</script>
<style>
.qr-code img {
border: #fff solid 7px
}
</style>

View file

@ -0,0 +1,53 @@
<template>
<div>
<Table :items="userNotificationChannels.map(channel => ({...channel, username: channel.user.username || {}}))"
:columns="['id', 'username', 'channel_type', 'channel_target', 'event_filter', /*'active', 'created'*/]">
<template #actions="{ item }">
<div class="btn-group">
<button class="btn btn-danger" @click.stop="">
<font-awesome-icon icon="trash"/>
delete
</button>
</div>
</template>
</Table>
<div class="card bg-dark">
<div class="card-body">
<div class="input-group">
<select class="form-control">
<option value="1">user</option>
<option value="2">admin</option>
</select>
<select class="form-control">
<option value="email">Email</option>
<option value="telegram">Telegram</option>
</select>
<input type="text" class="form-control" placeholder="channel_target">
<input type="text" class="form-control" value="*">
<div class="input-group-append">
<button class="btn btn-primary">Add</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
export default {
name: 'Notifications',
components: {Table},
computed: mapState(['userNotificationChannels']),
methods: mapActions(['fetchUserNotificationChannels']),
mounted() {
this.fetchUserNotificationChannels();
}
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,109 @@
<template>
<h3 class="text-center">Available Message Template Variables</h3>
<p>
<span v-for="(variable, key) in messageTemplateVariables" :key="key" class="badge badge-primary"
style="margin: 5px;">
{{ variable }}
</span>
</p>
<h3 class="text-center">Message Templates</h3>
<div v-for="template in messageTemplatesIntermediate" :key="template.id" class="card bg-dark"
style="margin-bottom: 10px;">
<div class="card-header">{{ template.name }}</div>
<FormatedText :value="template.message" :format="formatText" class="card-body"
@input="changeMessageTemplate(template.id, $event)"/>
<div class="card-body">
<button class="btn btn-primary" @click="resetMessageTemplate(template.id)"
:disabled="messageTemplates.find(t => t.id === template.id).message === template.message">Reset
</button>
<button class="btn btn-success" @click="saveMessageTemplate(template.id)"
:disabled="messageTemplates.find(t => t.id === template.id).message === template.message">Save
</button>
</div>
</div>
<div class="card bg-dark">
<div class="card-body">
<div class="input-group">
<input type="text" class="form-control" v-model="newTemplateName" placeholder="New Template Name">
<button class="btn btn-success input-group-btn" @click="createMessageTemplateAndReset()"
ref="createButton">Create
</button>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
import FormatedText from "@/components/inputs/FormatedText.vue";
export default {
name: 'Settings',
components: {FormatedText, Table},
data() {
return {
messageTemplatesIntermediate: [],
newTemplateName: '',
};
},
computed: mapState(['messageTemplates', 'messageTemplateVariables']),
methods: {
...mapActions(['fetchMessageTemplates', 'fetchMessageTemplateVariables', 'updateMessageTemplate', 'createMessageTemplate']),
formatText(value) {
return value.replace(/{{(.+?)}}/g, (match, key) => {
return `<span class="text-primary">{{${key}}}</span>`;
}).replace(/\n/g, '<br>').replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;');
},
changeMessageTemplate(id, message) {
console.log(id, message);
this.messageTemplatesIntermediate.forEach(template => {
if (template.id === id) {
template.message = message;
}
});
},
saveMessageTemplate(id) {
this.updateMessageTemplate(this.messageTemplatesIntermediate.find(template => template.id === id));
},
resetMessageTemplate(id) {
this.messageTemplatesIntermediate.find(template => template.id === id).message =
this.messageTemplates.find(template => template.id === id).message;
},
async createMessageTemplateAndReset() {
this.$refs.createButton.disabled = true;
await this.createMessageTemplate(this.newTemplateName);
this.newTemplateName = '';
this.$refs.createButton.disabled = false;
},
},
mounted() {
this.fetchMessageTemplates().then(() => {
this.messageTemplatesIntermediate = JSON.parse(JSON.stringify(this.messageTemplates));
});
this.fetchMessageTemplateVariables();
},
watch: {
messageTemplates() {
for (const template of this.messageTemplates) {
if (!this.messageTemplatesIntermediate.find(t => t.id === template.id)) {
this.messageTemplatesIntermediate.push(JSON.parse(JSON.stringify(template)));
}
}
for (const template of this.messageTemplatesIntermediate) {
if (!this.messageTemplates.find(t => t.id === template.id)) {
this.messageTemplatesIntermediate = this.messageTemplatesIntermediate.filter(t => t.id !== template.id);
}
}
}
}
};
</script>
<style scoped>
pre {
white-space: pre-wrap;
word-wrap: break-word;
color: inherit;
}
</style>