Compare commits
57 commits
Author | SHA1 | Date | |
---|---|---|---|
c3c56f227b | |||
7048cd573a | |||
a8caaa7d0b | |||
bb7c3264d6 | |||
f601dba159 | |||
e7c8ca6757 | |||
badbbf8717 | |||
4a712240fc | |||
6d3f9f2a4c | |||
2640f42988 | |||
90a4cea758 | |||
4fbfaf3062 | |||
e8a38d1202 | |||
9074309f01 | |||
a2dd06f2f9 | |||
21fddfe631 | |||
a7827e53cc | |||
67c9496413 | |||
1cacbc8c91 | |||
69046208fb | |||
7e81ef133e | |||
337bd9c885 | |||
88ecfc0f61 | |||
0a124a19d8 | |||
7f21fbaa3f | |||
9daf82c13a | |||
98a944902f | |||
7d1d8529fd | |||
7ef40324ba | |||
0e78ac4d5b | |||
312e9a465a | |||
08bdafae1b | |||
07c3767376 | |||
016143c109 | |||
a88747a0c4 | |||
c0b77f04ee | |||
7eb591e103 | |||
00262729fc | |||
d9d7a218b1 | |||
c06e7f1826 | |||
c0fd29ba36 | |||
6cb55c9574 | |||
4aca7e41fd | |||
b356a85628 | |||
43fc551fc8 | |||
4963b3eb68 | |||
7660a39203 | |||
6ff3e3b89c | |||
31af2fbe63 | |||
a83846e2c7 | |||
be96901129 | |||
4645a2f48c | |||
30cddebdc8 | |||
a3c6baa67d | |||
e0679e965d | |||
b962ff3dbc | |||
0c39f52c00 |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
[submodule "web/vendor/vuex-router-sync"]
|
||||
path = web/vendor/vuex-router-sync
|
||||
url = https://github.com/vuejs/vuex-router-sync.git
|
1
README.md
Normal file
1
README.md
Normal file
|
@ -0,0 +1 @@
|
|||
ansible-playbook deploy/ansible/playbooks/deploy-c3lf-sys3.yml --inventory=deploy/ansible/inventory.yml
|
14
core/.coveragerc
Normal file
14
core/.coveragerc
Normal 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
|
|
@ -10,8 +10,8 @@ class ExtendedUserAdmin(UserAdmin):
|
|||
ordering = ('username',)
|
||||
filter_horizontal = ('groups', 'user_permissions', 'permissions')
|
||||
|
||||
def permissions(self, obj):
|
||||
return ', '.join(obj.get_all_permissions())
|
||||
# def permissions(self, obj):
|
||||
# return ', '.join(obj.get_all_permissions())
|
||||
|
||||
|
||||
admin.site.register(ExtendedUser, ExtendedUserAdmin)
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
from authentication.models import ExtendedUser
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
|
@ -11,6 +9,7 @@ class Migration(migrations.Migration):
|
|||
]
|
||||
|
||||
def create_legacy_user(apps, schema_editor):
|
||||
ExtendedUser = apps.get_model('authentication', 'ExtendedUser')
|
||||
ExtendedUser.objects.create_user(settings.LEGACY_USER_NAME, 'mail@' + settings.MAIL_DOMAIN,
|
||||
settings.LEGACY_USER_PASSWORD)
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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),
|
||||
]
|
||||
|
|
|
@ -33,7 +33,7 @@ def media_urls(request, hash):
|
|||
headers={
|
||||
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=31536000, private',
|
||||
'Cache-Control': 'max-age=31536000, private, immutable',
|
||||
'Expires': datetime.utcnow() + timedelta(days=365),
|
||||
'Age': 0,
|
||||
'ETag': file.hash,
|
||||
|
@ -74,7 +74,7 @@ def thumbnail_urls(request, size, hash):
|
|||
headers={
|
||||
'X-Accel-Redirect': f'/redirect_thumbnail/{size}/{hash_path}',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=31536000, private',
|
||||
'Cache-Control': 'max-age=31536000, private, immutable',
|
||||
'Expires': datetime.utcnow() + timedelta(days=365),
|
||||
'Age': 0,
|
||||
'ETag': file.hash + "_" + str(size),
|
||||
|
|
|
@ -90,4 +90,6 @@ class AbstractFile(models.Model):
|
|||
|
||||
class File(AbstractFile):
|
||||
item = models.ForeignKey(Item, models.CASCADE, db_column='iid', null=True, blank=True, related_name='files')
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
return self.hash
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.urls import re_path
|
||||
from rest_framework import routers, viewsets, serializers
|
||||
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||
|
@ -87,7 +86,7 @@ class ItemSerializer(serializers.ModelSerializer):
|
|||
def update(self, instance, validated_data):
|
||||
if 'returned' in validated_data:
|
||||
if validated_data['returned']:
|
||||
validated_data['returned_at'] = datetime.now()
|
||||
validated_data['returned_at'] = timezone.now()
|
||||
validated_data.pop('returned')
|
||||
if 'dataImage' in validated_data:
|
||||
file = File.objects.create(data=validated_data['dataImage'])
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.urls import path, re_path
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from rest_framework import routers, viewsets, serializers
|
||||
|
@ -78,7 +77,7 @@ class ItemSerializer(serializers.ModelSerializer):
|
|||
if container:
|
||||
internal['container'] = container
|
||||
if returned:
|
||||
internal['returned_at'] = datetime.now()
|
||||
internal['returned_at'] = timezone.now()
|
||||
return internal
|
||||
|
||||
def validate(self, attrs):
|
||||
|
@ -96,7 +95,7 @@ class ItemSerializer(serializers.ModelSerializer):
|
|||
def update(self, instance, validated_data):
|
||||
if 'returned' in validated_data:
|
||||
if validated_data['returned']:
|
||||
validated_data['returned_at'] = datetime.now()
|
||||
validated_data['returned_at'] = timezone.now()
|
||||
validated_data.pop('returned')
|
||||
if 'dataImage' in validated_data:
|
||||
file = File.objects.create(data=validated_data['dataImage'])
|
||||
|
|
|
@ -35,6 +35,9 @@ class Item(SoftDeleteModel):
|
|||
('match_item', 'Can match item')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return '[' + str(self.uid) + ']' + self.description
|
||||
|
||||
|
||||
class Container(SoftDeleteModel):
|
||||
cid = models.AutoField(primary_key=True)
|
||||
|
@ -42,6 +45,9 @@ class Container(SoftDeleteModel):
|
|||
created_at = models.DateTimeField(blank=True, null=True)
|
||||
updated_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return '[' + str(self.cid) + ']' + self.name
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
eid = models.AutoField(primary_key=True)
|
||||
|
@ -53,3 +59,6 @@ class Event(models.Model):
|
|||
post_end = models.DateTimeField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(null=True, auto_now_add=True)
|
||||
updated_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return '[' + str(self.slug) + ']' + self.name
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth.models import Permission
|
||||
from knox.models import AuthToken
|
||||
|
@ -164,7 +163,7 @@ class ItemTestCase(TestCase):
|
|||
response = self.client.get(f'/api/2/{self.event.slug}/item/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.json()), 2)
|
||||
item2.returned_at = datetime.now()
|
||||
item2.returned_at = timezone.now()
|
||||
item2.save()
|
||||
response = self.client.get(f'/api/2/{self.event.slug}/item/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import files.models
|
||||
from mail.models import Email
|
||||
from mail.protocol import parse_email_body
|
||||
|
||||
|
||||
|
@ -24,6 +23,7 @@ class Migration(migrations.Migration):
|
|||
]
|
||||
|
||||
def generate_email_attachments(apps, schema_editor):
|
||||
Email = apps.get_model('mail', 'Email')
|
||||
for email in Email.objects.all():
|
||||
raw = email.raw
|
||||
if raw is None or raw == '':
|
||||
|
|
|
@ -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
|
||||
|
@ -38,3 +39,6 @@ class EventAddress(models.Model):
|
|||
class EmailAttachment(AbstractFile):
|
||||
email = models.ForeignKey(Email, models.CASCADE, related_name='attachments', null=True)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
import logging
|
||||
from re import match
|
||||
|
||||
import aiosmtplib
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.layers import get_channel_layer
|
||||
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,8 +88,23 @@ def make_reply(reply_email, references=None, event=None):
|
|||
return reply
|
||||
|
||||
|
||||
async def send_smtp(message, log):
|
||||
log.info('Sending message to %s' % message['To'])
|
||||
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)
|
||||
|
||||
|
||||
|
@ -148,9 +169,9 @@ def parse_email_body(raw, log=None):
|
|||
attachments.append(attachment)
|
||||
if 'inline' in cdispo:
|
||||
body = body + f'<img src="cid:{attachment.id}">'
|
||||
log.info("Image", ctype, attachment.id)
|
||||
log.info("Image %s %s", ctype, attachment.id)
|
||||
else:
|
||||
log.info("Attachment", ctype, cdispo)
|
||||
log.info("Attachment %s %s", ctype, cdispo)
|
||||
else:
|
||||
if parsed.get_content_type() == 'text/plain':
|
||||
body = parsed.get_payload()
|
||||
|
@ -161,7 +182,7 @@ def parse_email_body(raw, log=None):
|
|||
soup = BeautifulSoup(body, 'html.parser')
|
||||
body = re.sub(r'([\r\n]+.?)*[\r\n]', r'\n', soup.get_text()).strip('\n')
|
||||
else:
|
||||
log.warning("Unknown content type", parsed.get_content_type())
|
||||
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)
|
||||
|
@ -172,6 +193,7 @@ def parse_email_body(raw, log=None):
|
|||
return parsed, body, attachments
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def receive_email(envelope, log=None):
|
||||
parsed, body, attachments = parse_email_body(envelope.content, log)
|
||||
|
||||
|
@ -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,25 +278,41 @@ class LMTPHandler:
|
|||
content = None
|
||||
try:
|
||||
content = envelope.content
|
||||
email, new, reply = await sync_to_async(receive_email)(envelope, log)
|
||||
email, new, reply, thread = await receive_email(envelope, log)
|
||||
log.info(f"Created email {email.id}")
|
||||
systemevent = await sync_to_async(SystemEvent.objects.create)(type='email received', reference=email.id)
|
||||
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"}
|
||||
)
|
||||
"message": "email received"})
|
||||
log.info(f"Sent message to frontend")
|
||||
|
||||
if new and reply:
|
||||
await send_smtp(reply, log)
|
||||
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 Exception as e:
|
||||
except SpecialMailException as e:
|
||||
import uuid
|
||||
random_filename = 'mail-' + str(uuid.uuid4())
|
||||
random_filename = 'special-' + str(uuid.uuid4())
|
||||
with open(random_filename, 'wb') as f:
|
||||
f.write(content)
|
||||
log.error(type(e), e, f"Saved email to {random_filename}")
|
||||
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
|
||||
random_filename = 'mail-' + sha256(content).hexdigest()
|
||||
with open(random_filename, 'wb') as f:
|
||||
f.write(content)
|
||||
log.error(f"Saved email to {random_filename} because of error %s (%s)", e, type(e))
|
||||
return '451 Internal server error'
|
||||
|
|
20
core/mail/tests/v2/test_user_notifications.py
Normal file
20
core/mail/tests/v2/test_user_notifications.py
Normal 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
|
0
core/notifications/__init__.py
Normal file
0
core/notifications/__init__.py
Normal file
15
core/notifications/admin.py
Normal file
15
core/notifications/admin.py
Normal 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)
|
37
core/notifications/api_v2.py
Normal file
37
core/notifications/api_v2.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
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
|
||||
from rest_framework import serializers
|
||||
|
||||
from notifications.templates import TEMPLATE_VARS
|
||||
|
||||
|
||||
class MessageTemplateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MessageTemplate
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class MessageTemplateViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = MessageTemplateSerializer
|
||||
queryset = MessageTemplate.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)
|
||||
|
||||
urlpatterns = ([
|
||||
re_path('message_template_variables', get_template_vars),
|
||||
] + router.urls)
|
16
core/notifications/defaults.py
Normal file
16
core/notifications/defaults.py
Normal 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 }}'''
|
85
core/notifications/dispatch.py
Normal file
85
core/notifications/dispatch.py
Normal 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)
|
48
core/notifications/migrations/0001_initial.py
Normal file
48
core/notifications/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
# 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)
|
||||
MessageTemplate.objects.create(name='new_issue_notification', message=new_issue_notification)
|
||||
MessageTemplate.objects.create(name='reply_issue_notification', message=reply_issue_notification)
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
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),
|
||||
]
|
0
core/notifications/migrations/__init__.py
Normal file
0
core/notifications/migrations/__init__.py
Normal file
28
core/notifications/models.py
Normal file
28
core/notifications/models.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
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)
|
||||
|
||||
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 + ')'
|
69
core/notifications/templates.py
Normal file
69
core/notifications/templates.py
Normal 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)
|
0
core/notifications/tests/__init__.py
Normal file
0
core/notifications/tests/__init__.py
Normal 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
|
||||
|
|
|
@ -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
7
core/server.py
Normal file → Executable 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")
|
||||
|
|
|
@ -47,8 +47,7 @@ def reply(request, pk):
|
|||
body=request.data['message'],
|
||||
in_reply_to=first_mail.reference,
|
||||
)
|
||||
log = logging.getLogger('mail.log')
|
||||
async_to_sync(send_smtp)(make_reply(mail, references), log)
|
||||
async_to_sync(send_smtp)(make_reply(mail, references))
|
||||
|
||||
return Response({'status': 'ok'}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
|
|
@ -2,17 +2,15 @@
|
|||
|
||||
from django.db import migrations, models
|
||||
|
||||
from tickets.models import IssueThread
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0005_remove_issuethread_last_activity'),
|
||||
]
|
||||
|
||||
def set_uuid(apps, schema_editor):
|
||||
import uuid
|
||||
IssueThread = apps.get_model('tickets', 'IssueThread')
|
||||
for issue_thread in IssueThread.objects.all():
|
||||
issue_thread.uuid = str(uuid.uuid4())
|
||||
issue_thread.save()
|
||||
|
|
20
core/tickets/migrations/0009_issuethread_event.py
Normal file
20
core/tickets/migrations/0009_issuethread_event.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 4.2.7 on 2024-05-03 21:21
|
||||
|
||||
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'),
|
||||
('tickets', '0008_alter_issuethread_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issuethread',
|
||||
name='event',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_threads', to='inventory.event'),
|
||||
),
|
||||
]
|
|
@ -33,6 +33,7 @@ class IssueThread(SoftDeleteModel):
|
|||
id = models.AutoField(primary_key=True)
|
||||
uuid = models.CharField(max_length=255, unique=True, null=False, blank=False)
|
||||
name = models.CharField(max_length=255)
|
||||
event = models.ForeignKey(Event, null=True, on_delete=models.SET_NULL, related_name='issue_threads')
|
||||
manually_created = models.BooleanField(default=False)
|
||||
|
||||
def short_uuid(self):
|
||||
|
@ -64,6 +65,9 @@ class IssueThread(SoftDeleteModel):
|
|||
return
|
||||
self.assignments.create(assigned_to=value)
|
||||
|
||||
def __str__(self):
|
||||
return '[' + str(self.id) + '][' + self.short_uuid() + '] ' + self.name
|
||||
|
||||
class Meta:
|
||||
permissions = [
|
||||
('send_mail', 'Can send mail'),
|
||||
|
@ -91,6 +95,9 @@ class Comment(models.Model):
|
|||
comment = models.TextField()
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.issue_thread) + ' comment #' + str(self.id)
|
||||
|
||||
|
||||
class StateChange(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
|
@ -98,9 +105,15 @@ class StateChange(models.Model):
|
|||
state = models.CharField(max_length=255, choices=STATE_CHOICES, default='pending_new')
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.issue_thread) + ' state change to ' + self.state
|
||||
|
||||
|
||||
class Assignment(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='assignments')
|
||||
assigned_to = models.ForeignKey(ExtendedUser, on_delete=models.CASCADE, related_name='assigned_tickets')
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.issue_thread) + ' assigned to ' + self.assigned_to.username
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
|
||||
- name: Check if a reboot is needed for debian
|
||||
register: reboot_required_file
|
||||
stat: path=/var/run/reboot-required get_md5=no
|
||||
stat: path=/var/run/reboot-required get_checksum=no
|
||||
|
||||
- name: Reboot the Debian or Ubuntu server
|
||||
reboot:
|
||||
|
|
|
@ -11,6 +11,8 @@ Restart=always
|
|||
RestartSec=5
|
||||
User=www-data
|
||||
Group=www-data
|
||||
StandardOutput=append:/var/www/c3lf-sys3/service.info.log
|
||||
StandardError=append:/var/www/c3lf-sys3/service.error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -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 }}
|
27
deploy/nginx-ws-proxy.conf
Normal file
27
deploy/nginx-ws-proxy.conf
Normal file
|
@ -0,0 +1,27 @@
|
|||
http {
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
upstream websocket {
|
||||
server staging.c3lf.de:443;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8082;
|
||||
access_log /home/jedi/Projects/c3lf-system-3/deploy/foo.log;
|
||||
location / {
|
||||
proxy_pass https://websocket;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Origin "https://staging.c3lf.de/";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events {}
|
||||
|
||||
pid /home/jedi/Projects/c3lf-system-3/deploy/nginx.pid;
|
11286
web/package-lock.json
generated
Normal file
11286
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,43 +1,43 @@
|
|||
{
|
||||
"name": "c3cloc",
|
||||
"name": "c3lf",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"serve": "vue-cli-service serve --modern",
|
||||
"build": "vue-cli-service build --modern",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||
"@fortawesome/vue-fontawesome": "^0.1.8",
|
||||
"axios": "^1.6.2",
|
||||
"base-64": "^0.1.0",
|
||||
"@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",
|
||||
"axios": "^1.6.5",
|
||||
"base-64": "^1.0.0",
|
||||
"bootstrap": "^4.3.1",
|
||||
"core-js": "^3.3.2",
|
||||
"core-js": "^3.35.1",
|
||||
"jquery": "^3.4.1",
|
||||
"lodash": "^4.17.15",
|
||||
"luxon": "^1.21.3",
|
||||
"popper.js": "^1.16.0",
|
||||
"popper.js": "^1.16.1",
|
||||
"ramda": "^0.26.1",
|
||||
"sass": "^1.19.0",
|
||||
"sass-loader": "^10.4.1",
|
||||
"utf8": "^3.0.0",
|
||||
"vue": "^2.6.10",
|
||||
"vue-debounce": "^2.2.0",
|
||||
"vue-qrcode-component": "^2.1.1",
|
||||
"vue-router": "^3.1.3",
|
||||
"vuex": "^3.1.2",
|
||||
"vuex-router-sync": "^5.0.0",
|
||||
"vuex-shared-mutations": "^1.0.2",
|
||||
"vue": "^3.2.47",
|
||||
"vue-router": "^4.1.6",
|
||||
"vuex": "^4.1.0",
|
||||
"vuex-router-sync": "file:vendor/vuex-router-sync",
|
||||
"yarn": "^1.22.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^5.0.8",
|
||||
"@vue/cli-service": "^5.0.8",
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"vue-template-compiler": "^2.6.10",
|
||||
"webpack": "^5"
|
||||
"webpack": "^5",
|
||||
"webpack-dev-server": "^4.15.1"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
|
|
|
@ -32,13 +32,11 @@ export default {
|
|||
},
|
||||
data: () => ({
|
||||
addItemModalOpen: false,
|
||||
addTicketModalOpen: false,
|
||||
notify_socket: null,
|
||||
socket_toast: null,
|
||||
addTicketModalOpen: false
|
||||
}),
|
||||
methods: {
|
||||
...mapMutations(['removeToast', 'createToast', 'closeAddBoxModal', 'openAddBoxModal']),
|
||||
...mapActions(['loadEventItems', 'loadTickets']),
|
||||
...mapActions(['loadEvents']),
|
||||
openAddItemModal() {
|
||||
this.addItemModalOpen = true;
|
||||
},
|
||||
|
@ -50,72 +48,10 @@ export default {
|
|||
},
|
||||
closeAddTicketModal() {
|
||||
this.addTicketModalOpen = false;
|
||||
},
|
||||
tryConnect() {
|
||||
if (!this.notify_socket || this.notify_socket.readyState !== WebSocket.OPEN) {
|
||||
//if (this.socket_toast) {
|
||||
// this.removeToast(this.socket_toast.key);
|
||||
// this.socket_toast = null;
|
||||
//}
|
||||
//this.socket_toast = this.createToast({
|
||||
// title: "Connecting...",
|
||||
// message: "Connecting to websocket...",
|
||||
// color: "warning"
|
||||
//});
|
||||
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
this.notify_socket = new WebSocket(scheme + '://' + window.location.host + '/ws/2/notify/');
|
||||
this.notify_socket.onopen = (e) => {
|
||||
//if (this.socket_toast) {
|
||||
// this.removeToast(this.socket_toast.key);
|
||||
// this.socket_toast = null;
|
||||
//}
|
||||
//this.socket_toast = this.createToast({
|
||||
// title: "Connection established",
|
||||
// message: JSON.stringify(e),
|
||||
// color: "success"
|
||||
//});
|
||||
//console.log(e);
|
||||
};
|
||||
this.notify_socket.onclose = (e) => {
|
||||
//if (this.socket_toast) {
|
||||
// this.removeToast(this.socket_toast.key);
|
||||
// this.socket_toast = null;
|
||||
//}
|
||||
//this.socket_toast = this.createToast({
|
||||
// title: "Connection closed",
|
||||
// message: JSON.stringify(e),
|
||||
// color: "danger"
|
||||
//});
|
||||
//console.log(e);
|
||||
setTimeout(() => {
|
||||
this.tryConnect();
|
||||
}, 1000);
|
||||
};
|
||||
this.notify_socket.onerror = (e) => {
|
||||
//if (this.socket_toast) {
|
||||
// this.removeToast(this.socket_toast.key);
|
||||
// this.socket_toast = null;
|
||||
//}
|
||||
//this.socket_toast = this.createToast({
|
||||
// title: "Connection error",
|
||||
// message: JSON.stringify(e),
|
||||
// color: "danger"
|
||||
//});
|
||||
//console.log(e);
|
||||
setTimeout(() => {
|
||||
this.tryConnect();
|
||||
}, 1000);
|
||||
};
|
||||
this.notify_socket.onmessage = (e) => {
|
||||
let data = JSON.parse(e.data);
|
||||
this.loadEventItems()
|
||||
this.loadTickets()
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.tryConnect();
|
||||
document.title = document.location.hostname;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<script>
|
||||
|
||||
import {mapActions} from "vuex";
|
||||
import {mapActions, mapGetters, mapMutations} from "vuex";
|
||||
|
||||
export default {
|
||||
name: "AuthenticatedImage",
|
||||
|
@ -16,6 +16,7 @@ export default {
|
|||
type: String,
|
||||
required: true
|
||||
},
|
||||
cached: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -23,21 +24,37 @@ export default {
|
|||
servers: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['getThumbnail']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchImage']),
|
||||
loadImage() {
|
||||
this.fetchImage(this.src).then((response) => {
|
||||
const mime_type = response.headers.get("content-type");
|
||||
response.arrayBuffer().then((buf) => {
|
||||
const base64 = btoa(new Uint8Array(buf)
|
||||
.reduce((data, byte) => data + String.fromCharCode(byte), ""));
|
||||
this.image_data = "data:" + mime_type + ";base64," + base64;
|
||||
...mapMutations(['setThumbnail']),
|
||||
async loadImage() {
|
||||
const response = await this.fetchImage(this.src);
|
||||
const mime_type = response.headers.get("content-type");
|
||||
const buf = await response.arrayBuffer();
|
||||
const base64 = btoa(new Uint8Array(buf)
|
||||
.reduce((data, byte) => data + String.fromCharCode(byte), ""));
|
||||
this.image_data = "data:" + mime_type + ";base64," + base64;
|
||||
if (this.cached)
|
||||
this.setThumbnail({
|
||||
url: this.src,
|
||||
data: this.image_data
|
||||
});
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadImage();
|
||||
setTimeout(() => {
|
||||
if (this.cached) {
|
||||
const c = this.getThumbnail(this.src);
|
||||
if (c) {
|
||||
this.image_data = c;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.loadImage();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -125,7 +125,7 @@ export default {
|
|||
...mapActions(['changeEvent', 'changeView', 'searchEventItems']),
|
||||
...mapMutations(['logout']),
|
||||
navigateTo(link) {
|
||||
if (this.$router.currentRoute.path !== link)
|
||||
if (this.state.route.path !== link)
|
||||
this.$router.push(link);
|
||||
},
|
||||
isItemView() {
|
||||
|
@ -135,9 +135,9 @@ export default {
|
|||
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket';
|
||||
},
|
||||
setLayout(layout) {
|
||||
if (this.$router.currentRoute.query.layout === layout)
|
||||
if (this.state.route.query.layout === layout)
|
||||
return;
|
||||
this.$router.push({...this.$router.currentRoute, query: {...this.$router.currentRoute.query, layout}});
|
||||
this.$router.push({...this.state.route, query: {...this.state.route.query, layout}});
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
105
web/src/components/SlotTable.vue
Normal file
105
web/src/components/SlotTable.vue
Normal 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>
|
|
@ -25,8 +25,8 @@
|
|||
<ul>
|
||||
<li v-for="attachment in item.attachments" @click="openLightboxModalWith(attachment)">
|
||||
<AuthenticatedImage :src="`/media/2/256/${attachment.hash}/`" :alt="attachment.name"
|
||||
v-if="attachment.mime_type.startsWith('image/')"/>
|
||||
<AuthenticatedDataLink :href="`/media/2/256/${attachment.hash}/`" :download="attachment.name"
|
||||
v-if="attachment.mime_type.startsWith('image/')" cached/>
|
||||
<AuthenticatedDataLink :href="`/media/2/${attachment.hash}/`" :download="attachment.name"
|
||||
v-else/>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -25,7 +25,15 @@ export default {
|
|||
computed: {
|
||||
...mapState(['state_options']),
|
||||
lookupState: function () {
|
||||
return this.state_options.find(state => state.value === this.item.state);
|
||||
try {
|
||||
if (this.item.state)
|
||||
return this.state_options.find(state => state.value === this.item.state);
|
||||
} catch (e) {
|
||||
}
|
||||
return {
|
||||
text: 'Unknown',
|
||||
value: 'unknown'
|
||||
};
|
||||
},
|
||||
colorLookup: function () {
|
||||
if (this.item.state.startsWith('closed_')) {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toast-body" v-html="message">{{ message }}</div>
|
||||
<!--div class="toast-body" v-html="message">{{ message }}</div-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import Vue from 'vue';
|
||||
import {createApp} from 'vue'
|
||||
import App from './App.vue';
|
||||
import VueQrcode from '@chenfengyuan/vue-qrcode';
|
||||
//import {sync} from './vendor/vuex-router-sync/index';
|
||||
import {sync} from 'vuex-router-sync';
|
||||
import store from './store';
|
||||
import router from './router';
|
||||
|
||||
// bootstrap
|
||||
import 'jquery/dist/jquery.min.js';
|
||||
//import 'jquery/dist/jquery.min.js';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap/dist/js/bootstrap.min.js';
|
||||
|
||||
|
@ -46,20 +48,15 @@ import {
|
|||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
|
||||
|
||||
import vueDebounce from 'vue-debounce';
|
||||
|
||||
library.add(faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, faSortUp, faSortDown, faTh, faList,
|
||||
faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave, faEye, faComment, faUser, faComments, faEnvelope,
|
||||
faArchive, faMinus, faExclamation, faHourglass, faClipboard, faTasks, faAngleDown, faAngleRight);
|
||||
Vue.component('font-awesome-icon', FontAwesomeIcon);
|
||||
|
||||
|
||||
sync(store, router);
|
||||
|
||||
new Vue({
|
||||
el: '#app',
|
||||
store,
|
||||
router,
|
||||
render: h => h(App),
|
||||
});
|
||||
const app = createApp(App).use(store).use(router);
|
||||
|
||||
Vue.use(vueDebounce);
|
||||
app.component(VueQrcode.name, VueQrcode);
|
||||
app.component('font-awesome-icon', FontAwesomeIcon);
|
||||
app.mount('#app')
|
71
web/src/persistent-state-plugin/index.js
Normal file
71
web/src/persistent-state-plugin/index.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
import {isProxy, toRaw} from 'vue';
|
||||
|
||||
export default (config) => (store) => {
|
||||
if (!('isLoadedKey' in config)) {
|
||||
throw new Error("isLoadedKey not defined in config");
|
||||
}
|
||||
|
||||
const initialize = () => {
|
||||
config.state.forEach(k => {
|
||||
try {
|
||||
if (config.debug) console.log("localStorage init", k, localStorage.getItem(config.prefix + k));
|
||||
const parsed = JSON.parse(localStorage.getItem(config.prefix + k));
|
||||
if (parsed !== store.state[k] && parsed !== null) {
|
||||
store.state[k] = parsed;
|
||||
} else {
|
||||
if (config.debug) console.log("localStorage not loaded", k, localStorage.getItem(config.prefix + k));
|
||||
}
|
||||
} catch (e) {
|
||||
if (config.debug) console.log("localStorage parse error", k, e);
|
||||
}
|
||||
});
|
||||
store.state[config.isLoadedKey] = true;
|
||||
}
|
||||
|
||||
const reload = initialize;
|
||||
|
||||
if (store.state[config.isLoadedKey] !== true)
|
||||
initialize();
|
||||
|
||||
addEventListener('storage', reload);
|
||||
|
||||
if ('state' in config) {
|
||||
config.state.forEach((member) => {
|
||||
store.watch((state, getters) => state[member], (newValue, oldValue) => {
|
||||
try {
|
||||
if (config.debug) console.log('watch', member,
|
||||
isProxy(newValue) ? toRaw(newValue) : newValue,
|
||||
isProxy(oldValue) ? toRaw(oldValue) : oldValue);
|
||||
const key = config.prefix + member;
|
||||
const encoded = JSON.stringify(isProxy(newValue) ? toRaw(newValue) : newValue);
|
||||
if (encoded !== localStorage.getItem(key)) {
|
||||
if (config.debug) console.log("localStorage replace", member, localStorage.getItem(key), encoded);
|
||||
if (newValue === null)
|
||||
localStorage.removeItem(key);
|
||||
else
|
||||
localStorage.setItem(key, encoded);
|
||||
} else {
|
||||
if (config.debug) console.log("localStorage not saved", member, localStorage.getItem(key), encoded);
|
||||
}
|
||||
} catch (e) {
|
||||
if (config.debug) console.log("localsorage save error", member, e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ('clearingMutation' in config) {
|
||||
store.subscribe((mutation, state) => {
|
||||
if (mutation.type === config.clearingMutation) {
|
||||
removeEventListener('storage', reload)
|
||||
for (let key in config.state) {
|
||||
localStorage.removeItem(config.prefix + key);
|
||||
}
|
||||
for (let key in config.state) {
|
||||
store.state[key] = null;
|
||||
}
|
||||
addEventListener('storage', reload)
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
|
@ -1,23 +1,24 @@
|
|||
import {createRouter, createWebHistory} from 'vue-router'
|
||||
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 VueRouter from 'vue-router';
|
||||
import Vue from 'vue';
|
||||
import Login from '@/views/Login.vue';
|
||||
import Register from '@/views/Register.vue';
|
||||
import Debug from "@/views/admin/Debug.vue";
|
||||
import Tickets from "@/views/Tickets.vue";
|
||||
import Ticket from "@/views/Ticket.vue";
|
||||
import Admin from "@/views/admin/Admin.vue";
|
||||
import store from "@/store";
|
||||
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"
|
||||
|
||||
Vue.use(VueRouter);
|
||||
//Vue.use(VueRouter);
|
||||
|
||||
const routes = [
|
||||
{path: '/', redirect: '/37C3/items', meta: {requiresAuth: false}},
|
||||
|
@ -60,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'}
|
||||
|
@ -75,11 +80,12 @@ const routes = [
|
|||
]
|
||||
},
|
||||
{path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}},
|
||||
{path: '*', component: Error},
|
||||
//{path: '*', component: Error},
|
||||
];
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
linkActiveClass: "active",
|
||||
routes,
|
||||
});
|
||||
|
||||
|
@ -101,13 +107,11 @@ router.beforeEach((to, from, next) => {
|
|||
});
|
||||
|
||||
router.afterEach((to, from) => {
|
||||
if (to.params.event) {
|
||||
if (to.params.event && to.params.event !== store.state.lastEvent) {
|
||||
//console.log('update last event', to.params.event);
|
||||
store.commit('updateLastEvent', to.params.event);
|
||||
}
|
||||
if (to.query.layout !== from.query.layout) {
|
||||
store.commit('triggerLayoutChange', to.query.layout);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
325
web/src/shared-state-plugin/index.js
Normal file
325
web/src/shared-state-plugin/index.js
Normal file
|
@ -0,0 +1,325 @@
|
|||
import {isProxy, toRaw} from 'vue';
|
||||
|
||||
export default (config) => {
|
||||
if (!('isLoadedKey' in config)) {
|
||||
throw new Error("isLoadedKey not defined in config");
|
||||
}
|
||||
if (('asyncFetch' in config) && !('lastfetched' in config)) {
|
||||
throw new Error("asyncFetch defined but lastfetched not defined in config");
|
||||
}
|
||||
|
||||
if (config.debug) console.log('plugin created');
|
||||
|
||||
/** may only be called from worker */
|
||||
|
||||
const clone = (obj) => {
|
||||
if (isProxy(obj)) {
|
||||
obj = toRaw(obj);
|
||||
}
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
if (obj.__proto__ === ({}).__proto__) {
|
||||
return Object.assign({}, obj);
|
||||
}
|
||||
if (obj.__proto__ === [].__proto__) {
|
||||
return obj.slice();
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
const deepEqual = (a, b) => {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (a === null || b === null) {
|
||||
return false;
|
||||
}
|
||||
if (a.__proto__ === ({}).__proto__ && b.__proto__ === ({}).__proto__) {
|
||||
|
||||
if (Object.keys(a).length !== Object.keys(b).length) {
|
||||
return false;
|
||||
}
|
||||
for (let key in b) {
|
||||
if (!(key in a)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (let key in a) {
|
||||
if (!(key in b)) {
|
||||
return false;
|
||||
}
|
||||
if (!deepEqual(a[key], b[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (a.__proto__ === [].__proto__ && b.__proto__ === [].__proto__) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!deepEqual(a[i], b[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const worker_fun = function (self, ctx) {
|
||||
/* globals WebSocket, SharedWorker, onconnect, onmessage, postMessage, close, location */
|
||||
|
||||
let intialized = false;
|
||||
let state = {};
|
||||
let ports = [];
|
||||
let notify_socket;
|
||||
|
||||
const tryConnect = () => {
|
||||
if (self.WebSocket === undefined) {
|
||||
if (ctx.debug) console.log("no websocket support");
|
||||
return;
|
||||
}
|
||||
if (!notify_socket || notify_socket.readyState !== WebSocket.OPEN) {
|
||||
// global location is not useful in worker loaded from data url
|
||||
const scheme = ctx.location.protocol === "https:" ? "wss" : "ws";
|
||||
if (ctx.debug) console.log("connecting to", scheme + '://' + ctx.location.host + '/ws/2/notify/');
|
||||
notify_socket = new WebSocket(scheme + '://' + ctx.location.host + '/ws/2/notify/');
|
||||
notify_socket.onopen = (e) => {
|
||||
if (ctx.debug) console.log("open", JSON.stringify(e));
|
||||
};
|
||||
notify_socket.onclose = (e) => {
|
||||
if (ctx.debug) console.log("close", JSON.stringify(e));
|
||||
setTimeout(() => {
|
||||
tryConnect();
|
||||
}, 1000);
|
||||
};
|
||||
notify_socket.onerror = (e) => {
|
||||
if (ctx.debug) console.log("error", JSON.stringify(e));
|
||||
setTimeout(() => {
|
||||
tryConnect();
|
||||
}, 1000);
|
||||
};
|
||||
notify_socket.onmessage = (e) => {
|
||||
let data = JSON.parse(e.data);
|
||||
if (ctx.debug) console.log("message", data);
|
||||
//this.loadEventItems()
|
||||
//this.loadTickets()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deepEqual = (a, b) => {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (a === null || b === null) {
|
||||
return false;
|
||||
}
|
||||
if (a.__proto__ === ({}).__proto__ && b.__proto__ === ({}).__proto__) {
|
||||
|
||||
if (Object.keys(a).length !== Object.keys(b).length) {
|
||||
return false;
|
||||
}
|
||||
for (let key in b) {
|
||||
if (!(key in a)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (let key in a) {
|
||||
if (!(key in b)) {
|
||||
return false;
|
||||
}
|
||||
if (!deepEqual(a[key], b[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (a.__proto__ === [].__proto__ && b.__proto__ === [].__proto__) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!deepEqual(a[i], b[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const handle_message = (message_data, reply, others, all) => {
|
||||
switch (message_data.type) {
|
||||
case 'state_init':
|
||||
if (!intialized) {
|
||||
intialized = true;
|
||||
state = message_data.state;
|
||||
reply({type: 'state_init', first: true});
|
||||
} else {
|
||||
reply({type: 'state_init', first: false, state: state});
|
||||
}
|
||||
break;
|
||||
case 'state_diff':
|
||||
if (message_data.key in state) {
|
||||
if (!deepEqual(state[message_data.key], message_data.old_value)) {
|
||||
if (ctx.debug) console.log("state diff old value mismatch | state:", state[message_data.key], " old:", message_data.old_value);
|
||||
}
|
||||
if (!deepEqual(state[message_data.key], message_data.new_value)) {
|
||||
if (ctx.debug) console.log("state diff changed | state:", state[message_data.key], " new:", message_data.new_value);
|
||||
state[message_data.key] = message_data.new_value;
|
||||
others(message_data);
|
||||
} else {
|
||||
if (ctx.debug) console.log("state diff no change | state:", state[message_data.key], " new:", message_data.new_value);
|
||||
}
|
||||
} else {
|
||||
if (ctx.debug) console.log("state diff key not found", message_data.key);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (ctx.debug) console.log("unknown message", message_data);
|
||||
}
|
||||
}
|
||||
|
||||
onconnect = (connect_event) => {
|
||||
const port = connect_event.ports[0];
|
||||
ports.push(port);
|
||||
port.onmessage = (message_event) => {
|
||||
const reply = (message_data) => {
|
||||
port.postMessage(message_data);
|
||||
}
|
||||
const others = (message_data) => {
|
||||
for (let i = 0; i < ports.length; i++) {
|
||||
if (ports[i] !== port) {
|
||||
ports[i].postMessage(message_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
const all = (message_data) => {
|
||||
for (let i = 0; i < ports.length; i++) {
|
||||
ports[i].postMessage(message_data);
|
||||
}
|
||||
}
|
||||
handle_message(message_event.data, reply, others, all);
|
||||
}
|
||||
port.start();
|
||||
if (ctx.debug) console.log("worker connected", JSON.stringify(connect_event));
|
||||
tryConnect();
|
||||
}
|
||||
|
||||
if (ctx.debug) console.log("worker loaded");
|
||||
}
|
||||
|
||||
const worker_context = {
|
||||
location: {
|
||||
protocol: location.protocol, host: location.host
|
||||
}, bug: config.debug
|
||||
}
|
||||
const worker_code = '(' + worker_fun.toString() + ')(self,' + JSON.stringify(worker_context) + ')';
|
||||
const worker_url = 'data:application/javascript;base64,' + btoa(worker_code);
|
||||
|
||||
const worker = new SharedWorker(worker_url, 'vuex-shared-state-plugin');
|
||||
worker.port.start();
|
||||
if (config.debug) console.log('worker started');
|
||||
|
||||
const updateWorkerState = (key, new_value, old_value = null) => {
|
||||
if (new_value === old_value) {
|
||||
if (config.debug) console.log('updateWorkerState: no change', key, new_value);
|
||||
return;
|
||||
}
|
||||
if (new_value === undefined) {
|
||||
if (config.debug) console.log('updateWorkerState: undefined', key, new_value);
|
||||
return;
|
||||
}
|
||||
worker.port.postMessage({
|
||||
type: 'state_diff',
|
||||
key: key,
|
||||
new_value: isProxy(new_value) ? toRaw(new_value) : new_value,
|
||||
old_value: isProxy(old_value) ? toRaw(old_value) : old_value
|
||||
});
|
||||
}
|
||||
|
||||
const registerInitialState = (keys, local_state) => {
|
||||
const value = keys.reduce((obj, key) => {
|
||||
obj[key] = isProxy(local_state[key]) ? toRaw(local_state[key]) : local_state[key];
|
||||
return obj;
|
||||
}, {});
|
||||
if (config.debug) console.log('registerInitilState', value);
|
||||
worker.port.postMessage({
|
||||
type: 'state_init', state: value
|
||||
});
|
||||
}
|
||||
|
||||
return (store) => {
|
||||
|
||||
worker.port.onmessage = function (e) {
|
||||
switch (e.data.type) {
|
||||
case 'state_init':
|
||||
if (config.debug) console.log('state_init', e.data);
|
||||
if (e.data.first) {
|
||||
if (config.debug) console.log('worker state initialized');
|
||||
} else {
|
||||
for (let key in e.data.state) {
|
||||
if (key in store.state) {
|
||||
if (config.debug) console.log('worker state init received', key, clone(e.data.state[key]));
|
||||
if (!deepEqual(store.state[key], e.data.state[key])) {
|
||||
store.state[key] = e.data.state[key];
|
||||
}
|
||||
} else {
|
||||
if (config.debug) console.log("state init key not found", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
store.state[config.isLoadedKey] = true;
|
||||
if ('afterInit' in config) {
|
||||
setTimeout(() => {
|
||||
store.dispatch(config.afterInit);
|
||||
}, 0);
|
||||
}
|
||||
break;
|
||||
case 'state_diff':
|
||||
if (config.debug) console.log('state_diff', e.data);
|
||||
if (e.data.key in store.state) {
|
||||
if (config.debug) console.log('worker state update', e.data.key, clone(e.data.new_value));
|
||||
//TODO this triggers the watcher again, but we don't want that
|
||||
store.state[e.data.key] = e.data.new_value;
|
||||
} else {
|
||||
if (config.debug) console.log("state diff key not found", e.data.key);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (config.debug) console.log("unknown message", e.data);
|
||||
}
|
||||
};
|
||||
|
||||
registerInitialState(config.state, store.state);
|
||||
|
||||
if ('mutations' in config) {
|
||||
store.subscribe((mutation, state) => {
|
||||
if (mutation.type in config.mutations) {
|
||||
console.log(mutation.type, mutation.payload);
|
||||
console.log(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
/*if ('actions' in config) {
|
||||
store.subscribeAction((action, state) => {
|
||||
if (action.type in config.actions) {
|
||||
console.log(action.type, action.payload);
|
||||
console.log(state);
|
||||
}
|
||||
});
|
||||
}*/
|
||||
if ('state' in config) {
|
||||
config.watch.forEach((member) => {
|
||||
store.watch((state, getters) => state[member], (newValue, oldValue) => {
|
||||
if (config.debug) console.log('watch', member, clone(newValue), clone(oldValue));
|
||||
updateWorkerState(member, newValue, oldValue);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
492
web/src/store.js
Normal file
492
web/src/store.js
Normal file
|
@ -0,0 +1,492 @@
|
|||
import {createStore} from 'vuex';
|
||||
import router from './router';
|
||||
|
||||
import * as base64 from 'base-64';
|
||||
import * as utf8 from 'utf8';
|
||||
import {ticketStateColorLookup, ticketStateIconLookup, http} from "@/utils";
|
||||
import sharedStatePlugin from "@/shared-state-plugin";
|
||||
import persistentStatePlugin from "@/persistent-state-plugin";
|
||||
|
||||
const store = createStore({
|
||||
state: {
|
||||
keyIncrement: 0,
|
||||
events: [],
|
||||
loadedItems: [],
|
||||
itemCache: {},
|
||||
loadedBoxes: [],
|
||||
toasts: [],
|
||||
tickets: [],
|
||||
users: [],
|
||||
groups: [],
|
||||
state_options: [],
|
||||
messageTemplates: [],
|
||||
messageTemplateVariables: [],
|
||||
test: ['foo', 'bar', 'baz'],
|
||||
|
||||
lastEvent: '37C3',
|
||||
remember: false,
|
||||
user: {
|
||||
username: null,
|
||||
password: null,
|
||||
permissions: [],
|
||||
token: null,
|
||||
expiry: null,
|
||||
},
|
||||
|
||||
thumbnailCache: {},
|
||||
fetchedData: {
|
||||
events: 0,
|
||||
items: 0,
|
||||
boxes: 0,
|
||||
tickets: 0,
|
||||
users: 0,
|
||||
groups: 0,
|
||||
states: 0,
|
||||
},
|
||||
persistent_loaded: false,
|
||||
shared_loaded: false,
|
||||
afterInitHandlers: [],
|
||||
|
||||
showAddBoxModal: false,
|
||||
},
|
||||
getters: {
|
||||
getEventSlug: state => state.route && state.route.params.event ? state.route.params.event : state.lastEvent,
|
||||
getActiveView: state => state.route.name || 'items',
|
||||
getFilters: state => state.route.query,
|
||||
getBoxes: state => state.loadedBoxes,
|
||||
checkPermission: state => (event, perm) => state.user.permissions &&
|
||||
(state.user.permissions.includes(`${event}:${perm}`) || state.user.permissions.includes(`*:${perm}`)),
|
||||
hasPermissions: state => state.user.permissions && state.user.permissions.length > 0,
|
||||
activeUser: state => state.user.username || 'anonymous',
|
||||
stateInfo: state => (slug) => {
|
||||
const obj = state.state_options.filter((s) => s.value === slug)[0];
|
||||
if (obj) {
|
||||
return {
|
||||
color: ticketStateColorLookup(obj.value),
|
||||
icon: ticketStateIconLookup(obj.value),
|
||||
slug: obj.value,
|
||||
text: obj.text,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: 'exclamation',
|
||||
slug: slug,
|
||||
text: 'Unknown'
|
||||
}
|
||||
}
|
||||
},
|
||||
layout: (state, getters) => {
|
||||
if (state.route.query.layout)
|
||||
return state.route.query.layout;
|
||||
if (getters.getActiveView === 'items')
|
||||
return 'cards';
|
||||
if (getters.getActiveView === 'tickets')
|
||||
return 'tasks';
|
||||
},
|
||||
isLoggedIn(state) {
|
||||
return state.user && state.user.username !== null && state.user.token !== null;
|
||||
},
|
||||
getThumbnail: (state) => (url) => {
|
||||
if (!url) return null;
|
||||
if (!(url in state.thumbnailCache))
|
||||
return null;
|
||||
return state.thumbnailCache[url];
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
updateLastEvent(state, slug) {
|
||||
state.lastEvent = slug;
|
||||
},
|
||||
replaceEvents(state, events) {
|
||||
state.events = events;
|
||||
state.fetchedData = {...state.fetchedData, events: Date.now()};
|
||||
},
|
||||
replaceTicketStates(state, states) {
|
||||
state.state_options = states;
|
||||
state.fetchedData = {...state.fetchedData, states: Date.now()};
|
||||
},
|
||||
changeView(state, {view, slug}) {
|
||||
router.push({path: `/${slug}/${view}`});
|
||||
},
|
||||
replaceLoadedItems(state, newItems) {
|
||||
state.loadedItems = newItems;
|
||||
state.fetchedData = {...state.fetchedData, items: Date.now()}; // TODO: manage caching items for different events and search results correctly
|
||||
},
|
||||
setItemCache(state, {slug, items}) {
|
||||
state.itemCache[slug] = items;
|
||||
},
|
||||
replaceBoxes(state, loadedBoxes) {
|
||||
state.loadedBoxes = loadedBoxes;
|
||||
state.fetchedData = {...state.fetchedData, boxes: Date.now()};
|
||||
},
|
||||
updateItem(state, updatedItem) {
|
||||
const item = state.loadedItems.filter(({uid}) => uid === updatedItem.uid)[0];
|
||||
Object.assign(item, updatedItem);
|
||||
},
|
||||
removeItem(state, item) {
|
||||
state.loadedItems = state.loadedItems.filter(it => it !== item);
|
||||
},
|
||||
appendItem(state, item) {
|
||||
state.loadedItems.push(item);
|
||||
},
|
||||
replaceTickets(state, tickets) {
|
||||
state.tickets = tickets;
|
||||
state.fetchedData = {...state.fetchedData, tickets: Date.now()};
|
||||
},
|
||||
replaceUsers(state, users) {
|
||||
state.users = users;
|
||||
state.fetchedData = {...state.fetchedData, users: Date.now()};
|
||||
},
|
||||
replaceGroups(state, groups) {
|
||||
state.groups = groups;
|
||||
state.fetchedData = {...state.fetchedData, groups: Date.now()};
|
||||
},
|
||||
updateTicket(state, updatedTicket) {
|
||||
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
|
||||
Object.assign(ticket, updatedTicket);
|
||||
},
|
||||
openAddBoxModal(state) {
|
||||
state.showAddBoxModal = true;
|
||||
},
|
||||
closeAddBoxModal(state) {
|
||||
state.showAddBoxModal = false;
|
||||
},
|
||||
createToast(state, {title, message, color}) {
|
||||
var toast = {title, message, color, key: state.keyIncrement}
|
||||
state.toasts.push(toast);
|
||||
state.keyIncrement += 1;
|
||||
return toast;
|
||||
},
|
||||
removeToast(state, key) {
|
||||
state.toasts = state.toasts.filter(toast => toast.key !== key);
|
||||
},
|
||||
setRemember(state, remember) {
|
||||
state.remember = remember;
|
||||
},
|
||||
setUser(state, user) {
|
||||
state.user.username = user;
|
||||
},
|
||||
setPassword(state, password) {
|
||||
state.user.password = password;
|
||||
},
|
||||
setPermissions(state, permissions) {
|
||||
state.user.permissions = permissions;
|
||||
},
|
||||
setToken(state, {token, expiry}) {
|
||||
const user = {...state.user};
|
||||
user.token = token;
|
||||
user.expiry = expiry;
|
||||
state.user = user;
|
||||
},
|
||||
setUserInfo(state, user) {
|
||||
state.user = user;
|
||||
},
|
||||
logout(state) {
|
||||
const user = {...state.user};
|
||||
user.user = null;
|
||||
user.password = null;
|
||||
user.token = null;
|
||||
user.expiry = null;
|
||||
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;
|
||||
},
|
||||
setMessageTemplateVariables(state, variables) {
|
||||
state.messageTemplateVariables = variables;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async login({commit}, {username, password, remember}) {
|
||||
commit('setRemember', remember);
|
||||
try {
|
||||
const data = await fetch('/api/2/login/', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: username, password: password}),
|
||||
credentials: 'omit'
|
||||
}).then(r => r.json())
|
||||
if (data && data.token) {
|
||||
const {data: {permissions}} = await http.get('/2/self/', data.token);
|
||||
commit('setUserInfo', {...data, permissions, username, password});
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async reloadToken({commit, state, getters}) {
|
||||
try {
|
||||
if (state.user.username && state.user.password) {
|
||||
const data = await fetch('/api/2/login/', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: state.user.username, password: state.user.password}),
|
||||
credentials: 'omit'
|
||||
}).then(r => r.json()).catch(e => console.error(e))
|
||||
if (data && data.token) {
|
||||
commit('setToken', data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
//credentials failed, logout
|
||||
store.commit('logout');
|
||||
},
|
||||
//async verifyToken({commit, state}) {
|
||||
async afterLogin({dispatch, state}) {
|
||||
let promises = [];
|
||||
promises.push(dispatch('loadBoxes'));
|
||||
promises.push(dispatch('fetchTicketStates'));
|
||||
promises.push(dispatch('loadEventItems'));
|
||||
promises.push(dispatch('loadTickets'));
|
||||
if (!state.user.permissions) {
|
||||
promises.push(dispatch('loadUserInfo'));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
},
|
||||
async afterSharedInit({dispatch, state}) {
|
||||
const handlers = state.afterInitHandlers;
|
||||
state.afterInitHandlers = [];
|
||||
await Promise.all(handlers.map(h => h()).flat());
|
||||
},
|
||||
scheduleAfterInit({dispatch, state}, handler) {
|
||||
if (state.shared_loaded) {
|
||||
Promise.all(handler()).then(() => {
|
||||
});
|
||||
} else {
|
||||
state.afterInitHandlers.push(handler);
|
||||
}
|
||||
},
|
||||
async fetchImage({state}, url) {
|
||||
return await fetch(url, {headers: {'Authorization': `Token ${state.user.token}`}});
|
||||
},
|
||||
async loadUserInfo({commit, state}) {
|
||||
const {data, success} = await http.get('/2/self/', state.user.token);
|
||||
commit('setPermissions', data.permissions);
|
||||
},
|
||||
async loadEvents({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.events > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
const {data, success} = await http.get('/2/events/', state.user.token);
|
||||
if (data && success)
|
||||
commit('replaceEvents', data);
|
||||
},
|
||||
async fetchTicketStates({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.states > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
const {data, success} = await http.get('/2/tickets/states/', state.user.token);
|
||||
if (data && success)
|
||||
commit('replaceTicketStates', data);
|
||||
},
|
||||
changeEvent({dispatch, getters, commit}, eventName) {
|
||||
router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
|
||||
dispatch('loadEventItems');
|
||||
},
|
||||
changeView({getters}, link) {
|
||||
router.push({path: `/${getters.getEventSlug}/${link.path}/`});
|
||||
},
|
||||
showBoxContent({getters}, box) {
|
||||
router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
|
||||
},
|
||||
async loadEventItems({commit, getters, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.items > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
try {
|
||||
commit('replaceLoadedItems', []);
|
||||
const slug = getters.getEventSlug;
|
||||
if (slug in state.itemCache) {
|
||||
commit('replaceLoadedItems', state.itemCache[slug]);
|
||||
}
|
||||
const {data, success} = await http.get(`/2/${slug}/items/`, state.user.token);
|
||||
if (data && success) {
|
||||
commit('replaceLoadedItems', data);
|
||||
commit('setItemCache', {slug, items: data});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error loading items");
|
||||
}
|
||||
},
|
||||
async searchEventItems({commit, getters, state}, query) {
|
||||
const foo = utf8.encode(query);
|
||||
const bar = base64.encode(foo);
|
||||
|
||||
const {data, success} = await http.get(`/2/${getters.getEventSlug}/items/${bar}/`, state.user.token);
|
||||
if (data && success)
|
||||
commit('replaceLoadedItems', data);
|
||||
},
|
||||
async loadBoxes({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.boxes > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
const {data, success} = await http.get('/2/boxes/', state.user.token);
|
||||
if (data && success)
|
||||
commit('replaceBoxes', data);
|
||||
},
|
||||
async createBox({commit, dispatch, state}, box) {
|
||||
const {data, success} = await http.post('/2/boxes/', box, state.user.token);
|
||||
commit('replaceBoxes', data);
|
||||
dispatch('loadBoxes').then(() => {
|
||||
commit('closeAddBoxModal');
|
||||
});
|
||||
},
|
||||
async deleteBox({commit, dispatch, state}, box_id) {
|
||||
await http.delete(`/2/boxes/${box_id}/`, state.user.token);
|
||||
dispatch('loadBoxes');
|
||||
},
|
||||
async updateItem({commit, getters, state}, item) {
|
||||
const {
|
||||
data,
|
||||
success
|
||||
} = await http.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, item, state.user.token);
|
||||
commit('updateItem', data);
|
||||
},
|
||||
async markItemReturned({commit, getters, state}, item) {
|
||||
await http.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true}, state.user.token);
|
||||
commit('removeItem', item);
|
||||
},
|
||||
async deleteItem({commit, getters, state}, item) {
|
||||
await http.delete(`/2/${getters.getEventSlug}/item/${item.uid}/`, item, state.user.token);
|
||||
commit('removeItem', item);
|
||||
},
|
||||
async postItem({commit, getters, state}, item) {
|
||||
commit('updateLastUsed', {box: item.box, cid: item.cid});
|
||||
const {data, success} = await http.post(`/2/${getters.getEventSlug}/item/`, item, state.user.token);
|
||||
commit('appendItem', data);
|
||||
},
|
||||
async loadTickets({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.tickets > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
const {data, success} = await http.get('/2/tickets/', 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);
|
||||
await dispatch('loadTickets');
|
||||
},
|
||||
async postManualTicket({commit, dispatch, state}, {sender, message, title,}) {
|
||||
const {data, success} = await http.post(`/2/tickets/manual/`, {
|
||||
name: title,
|
||||
sender,
|
||||
body: message,
|
||||
recipient: 'mail@c3lf.de'
|
||||
}, state.user.token);
|
||||
await dispatch('loadTickets');
|
||||
},
|
||||
async postComment({commit, dispatch, state}, {id, message}) {
|
||||
const {data, success} = await http.post(`/2/tickets/${id}/comment/`, {comment: message}, state.user.token);
|
||||
await dispatch('loadTickets');
|
||||
},
|
||||
async loadUsers({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.users > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
const {data, success} = await http.get('/2/users/', state.user.token);
|
||||
if (data && success)
|
||||
commit('replaceUsers', data);
|
||||
},
|
||||
async loadGroups({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.groups > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
const {data, success} = await http.get('/2/groups/', state.user.token);
|
||||
if (data && success)
|
||||
commit('replaceGroups', data);
|
||||
},
|
||||
async updateTicket({commit, state}, ticket) {
|
||||
const {data, success} = await http.put(`/2/tickets/${ticket.id}/`, ticket, state.user.token);
|
||||
commit('updateTicket', data);
|
||||
},
|
||||
async updateTicketPartial({commit, state}, {id, ...ticket}) {
|
||||
const {data, success} = await http.patch(`/2/tickets/${id}/`, ticket, state.user.token);
|
||||
commit('updateTicket', data);
|
||||
},
|
||||
async fetchMessageTemplates({commit, state}) {
|
||||
const {data, success} = await http.get('/2/message_templates/', state.user.token);
|
||||
if (data && success) {
|
||||
commit('setMessageTemplates', data);
|
||||
}
|
||||
},
|
||||
async fetchMessageTemplateVariables({commit, state}) {
|
||||
const {data, success} = await http.get('/2/message_template_variables/', state.user.token);
|
||||
if (data && success) {
|
||||
commit('setMessageTemplateVariables', data);
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
persistentStatePlugin({ // TODO change remember to some kind of enable field
|
||||
prefix: "lf_",
|
||||
debug: false,
|
||||
isLoadedKey: "persistent_loaded",
|
||||
state: [
|
||||
"remember",
|
||||
"user",
|
||||
"events",
|
||||
]
|
||||
}),
|
||||
sharedStatePlugin({
|
||||
debug: false,
|
||||
isLoadedKey: "shared_loaded",
|
||||
clearingMutation: "logout",
|
||||
afterInit: "afterSharedInit",
|
||||
state: [
|
||||
"test",
|
||||
"state_options",
|
||||
"fetchedData",
|
||||
"tickets",
|
||||
"users",
|
||||
"groups",
|
||||
"loadedBoxes",
|
||||
"loadedItems",
|
||||
"messageTemplates",
|
||||
"messageTemplatesVariables",
|
||||
],
|
||||
watch: [
|
||||
"test",
|
||||
"state_options",
|
||||
"fetchedData",
|
||||
"tickets",
|
||||
"users",
|
||||
"groups",
|
||||
"loadedBoxes",
|
||||
"loadedItems",
|
||||
"messageTemplates",
|
||||
"messageTemplatesVariables",
|
||||
],
|
||||
mutations: [
|
||||
//"replaceTickets",
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
store.watch((state) => state.user, (user) => {
|
||||
console.log('user changed', user);
|
||||
if (store.getters.isLoggedIn) {
|
||||
if (store.state.route.name === 'login' && store.state.route.query.redirect)
|
||||
router.push(store.state.route.query.redirect);
|
||||
else if (store.state.route.name === 'login')
|
||||
router.push('/');
|
||||
} else {
|
||||
if (store.state.route.name !== 'login') {
|
||||
router.push({
|
||||
name: 'login',
|
||||
query: {redirect: store.state.route.fullPath},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default store;
|
|
@ -1,441 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import AxiosBootstrap from 'axios';
|
||||
import * as _ from 'lodash/fp';
|
||||
import router from '../router';
|
||||
|
||||
import * as base64 from 'base-64';
|
||||
import * as utf8 from 'utf8';
|
||||
import {ticketStateColorLookup, ticketStateIconLookup} from "@/utils";
|
||||
import createMutationsSharer from "vuex-shared-mutations";
|
||||
|
||||
Vue.use(Vuex);
|
||||
const axios = AxiosBootstrap.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
axios.interceptors.response.use(response => response, error => {
|
||||
if (error.response.status === 401) {
|
||||
console.log('401 interceptor fired');
|
||||
store.dispatch('reloadToken').then((ok) => {
|
||||
if (ok) {
|
||||
error.config.headers['Authorization'] = `Token ${store.state.token}`;
|
||||
return axios.request(error.config);
|
||||
}
|
||||
});
|
||||
} else if (error.response.status === 403) {
|
||||
const message = `
|
||||
<h3>Access denied.</h3>
|
||||
<p>
|
||||
url: ${error.config.url}
|
||||
<br>
|
||||
method: ${error.config.method}
|
||||
<br>
|
||||
response-body: ${error.response && error.response.body}
|
||||
</p>
|
||||
`;
|
||||
store.commit('createToast', {title: 'Error: Access denied', message, color: 'danger'});
|
||||
return Promise.reject(error)
|
||||
} else {
|
||||
console.error('error interceptor fired', error.message);
|
||||
|
||||
if (error.isAxiosError) {
|
||||
const message = `
|
||||
<h3>A HTTP ${error.config.method} request failed.</h3>
|
||||
<p>
|
||||
url: ${error.config.url}
|
||||
<br>
|
||||
timeout: ${!!error.request.timeout}
|
||||
<br>
|
||||
response-body: ${error.response && error.response.body}
|
||||
</p>
|
||||
`;
|
||||
store.commit('createToast', {title: 'Error: HTTP', message, color: 'danger'});
|
||||
} else {
|
||||
store.commit('createToast', {title: 'Error: Unknown', message: error.toString(), color: 'danger'});
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state: {
|
||||
keyIncrement: 0,
|
||||
events: [],
|
||||
loadedItems: [],
|
||||
itemCache: {},
|
||||
loadedBoxes: [],
|
||||
toasts: [],
|
||||
tickets: [],
|
||||
users: [],
|
||||
groups: [],
|
||||
lastEvent: localStorage.getItem('lf_lastEvent') || '37C3',
|
||||
lastUsed: JSON.parse(localStorage.getItem('lf_lastUsed') || '{}'),
|
||||
remember: false,
|
||||
user: null,
|
||||
password: null,
|
||||
userPermissions: [],
|
||||
token: null,
|
||||
state_options: [],
|
||||
token_expiry: null,
|
||||
local_loaded: false,
|
||||
showAddBoxModal: false,
|
||||
toggle: false,
|
||||
},
|
||||
getters: {
|
||||
getEventSlug: state => state.route && state.route.params.event ? state.route.params.event : state.lastEvent,
|
||||
getActiveView: state => state.route.name || 'items',
|
||||
getFilters: state => state.route.query,
|
||||
getBoxes: state => state.loadedBoxes,
|
||||
checkPermission: state => (event, perm) => state.userPermissions.includes(`${event}:${perm}`) || state.userPermissions.includes(`*:${perm}`),
|
||||
hasPermissions: state => state.userPermissions.length > 0,
|
||||
stateInfo: state => (slug) => {
|
||||
const obj = state.state_options.filter((s) => s.value === slug)[0];
|
||||
if (obj) {
|
||||
return {
|
||||
color: ticketStateColorLookup(obj.value),
|
||||
icon: ticketStateIconLookup(obj.value),
|
||||
slug: obj.value,
|
||||
text: obj.text,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: 'exclamation',
|
||||
slug: slug,
|
||||
text: 'Unknown'
|
||||
}
|
||||
}
|
||||
},
|
||||
layout: (state, getters) => {
|
||||
state.toggle = !state.toggle;
|
||||
if (router.currentRoute.query.layout)
|
||||
return router.currentRoute.query.layout;
|
||||
if (getters.getActiveView === 'items')
|
||||
return 'cards';
|
||||
if (getters.getActiveView === 'tickets')
|
||||
return 'tasks';
|
||||
},
|
||||
isLoggedIn(state) {
|
||||
if (!state.local_loaded) {
|
||||
state.remember = localStorage.getItem('remember') === 'true';
|
||||
state.user = localStorage.getItem('user');
|
||||
//state.password = localStorage.getItem('password');
|
||||
state.userPermissions = JSON.parse(localStorage.getItem('permissions') || '[]');
|
||||
state.token = localStorage.getItem('token');
|
||||
state.token_expiry = localStorage.getItem('token_expiry');
|
||||
state.local_loaded = true;
|
||||
axios.defaults.headers.common['Authorization'] = `Token ${state.token}`;
|
||||
}
|
||||
|
||||
return state.user !== null && state.token !== null;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
updateLastUsed(state, diff) {
|
||||
state.lastUsed = _.extend(state.lastUsed, diff);
|
||||
localStorage.setItem('lf_lastUsed', JSON.stringify(state.lastUsed));
|
||||
},
|
||||
updateLastEvent(state, slug) {
|
||||
state.lastEvent = slug;
|
||||
localStorage.setItem('lf_lastEvent', slug);
|
||||
},
|
||||
replaceEvents(state, events) {
|
||||
state.events = events;
|
||||
},
|
||||
replaceTicketStates(state, states) {
|
||||
state.state_options = states;
|
||||
},
|
||||
changeView(state, {view, slug}) {
|
||||
router.push({path: `/${slug}/${view}`});
|
||||
},
|
||||
replaceLoadedItems(state, newItems) {
|
||||
state.loadedItems = newItems;
|
||||
},
|
||||
setItemCache(state, {slug, items}) {
|
||||
state.itemCache[slug] = items;
|
||||
},
|
||||
replaceBoxes(state, loadedBoxes) {
|
||||
state.loadedBoxes = loadedBoxes;
|
||||
},
|
||||
updateItem(state, updatedItem) {
|
||||
const item = state.loadedItems.filter(({uid}) => uid === updatedItem.uid)[0];
|
||||
Object.assign(item, updatedItem);
|
||||
},
|
||||
removeItem(state, item) {
|
||||
state.loadedItems = state.loadedItems.filter(it => it !== item);
|
||||
},
|
||||
appendItem(state, item) {
|
||||
state.loadedItems.push(item);
|
||||
},
|
||||
replaceTickets(state, tickets) {
|
||||
state.tickets = tickets;
|
||||
},
|
||||
replaceUsers(state, users) {
|
||||
state.users = users;
|
||||
},
|
||||
replaceGroups(state, groups) {
|
||||
state.groups = groups;
|
||||
},
|
||||
updateTicket(state, updatedTicket) {
|
||||
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
|
||||
Object.assign(ticket, updatedTicket);
|
||||
},
|
||||
openAddBoxModal(state) {
|
||||
state.showAddBoxModal = true;
|
||||
},
|
||||
closeAddBoxModal(state) {
|
||||
state.showAddBoxModal = false;
|
||||
},
|
||||
createToast(state, {title, message, color}) {
|
||||
var toast = {title, message, color, key: state.keyIncrement}
|
||||
state.toasts.push(toast);
|
||||
state.keyIncrement += 1;
|
||||
return toast;
|
||||
},
|
||||
removeToast(state, key) {
|
||||
state.toasts = state.toasts.filter(toast => toast.key !== key);
|
||||
},
|
||||
setRemember(state, remember) {
|
||||
state.remember = remember;
|
||||
localStorage.setItem('remember', remember);
|
||||
},
|
||||
setUser(state, user) {
|
||||
state.user = user;
|
||||
if (user)
|
||||
localStorage.setItem('user', user);
|
||||
},
|
||||
setPassword(state, password) {
|
||||
state.password = password;
|
||||
},
|
||||
setPermissions(state, permissions) {
|
||||
state.userPermissions = permissions;
|
||||
if (permissions)
|
||||
localStorage.setItem('permissions', JSON.stringify(permissions));
|
||||
},
|
||||
setToken(state, {token, expiry}) {
|
||||
state.token = token;
|
||||
state.token_expiry = expiry;
|
||||
if (token)
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('token_expiry', expiry);
|
||||
},
|
||||
logout(state) {
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('permissions');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('token_expiry');
|
||||
if (router.currentRoute.name !== 'login')
|
||||
router.push('/login');
|
||||
},
|
||||
triggerLayoutChange(state) {
|
||||
state.toggle = !state.toggle;
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async login({commit, dispatch, state}, {username, password, remember}) {
|
||||
commit('setRemember', remember);
|
||||
try {
|
||||
const data = await fetch('/api/2/login/', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: username, password: password}),
|
||||
credentials: 'omit'
|
||||
}).then(r => r.json())
|
||||
if (data && data.token) {
|
||||
commit('setToken', data);
|
||||
commit('setUser', username);
|
||||
commit('setPassword', password);
|
||||
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
|
||||
dispatch('afterLogin');
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async reloadToken({commit, state}) {
|
||||
try {
|
||||
if (state.password) {
|
||||
const data = await fetch('/api/2/login/', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: state.user, password: state.password}),
|
||||
credentials: 'omit'
|
||||
}).then(r => r.json()).catch(e => console.error(e))
|
||||
if (data && data.token) {
|
||||
commit('setToken', data);
|
||||
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
//credentials failed, logout
|
||||
store.commit('logout');
|
||||
},
|
||||
//async verifyToken({commit, state}) {
|
||||
async afterLogin({dispatch}) {
|
||||
const boxes = dispatch('loadBoxes');
|
||||
const states = dispatch('fetchTicketStates');
|
||||
const items = dispatch('loadEventItems');
|
||||
const tickets = dispatch('loadTickets');
|
||||
const user = dispatch('loadUserInfo');
|
||||
await Promise.all([boxes, items, tickets, user, states]);
|
||||
},
|
||||
async fetchImage({state}, url) {
|
||||
return await fetch(url, {headers: {'Authorization': `Token ${state.token}`}});
|
||||
},
|
||||
async loadUserInfo({commit}) {
|
||||
const {data} = await axios.get('/2/self/');
|
||||
commit('setUser', data.username);
|
||||
commit('setPermissions', data.permissions);
|
||||
},
|
||||
async loadEvents({commit}) {
|
||||
const {data} = await axios.get('/2/events/');
|
||||
commit('replaceEvents', data);
|
||||
},
|
||||
async fetchTicketStates({commit}) {
|
||||
const {data} = await axios.get('/2/tickets/states/');
|
||||
commit('replaceTicketStates', data);
|
||||
},
|
||||
changeEvent({dispatch, getters, commit}, eventName) {
|
||||
router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
|
||||
dispatch('loadEventItems');
|
||||
},
|
||||
changeView({getters}, link) {
|
||||
router.push({path: `/${getters.getEventSlug}/${link.path}/`});
|
||||
},
|
||||
showBoxContent({getters}, box) {
|
||||
router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
|
||||
},
|
||||
async loadEventItems({commit, getters, state}) {
|
||||
try {
|
||||
commit('replaceLoadedItems', []);
|
||||
const slug = getters.getEventSlug;
|
||||
if (slug in state.itemCache) {
|
||||
commit('replaceLoadedItems', state.itemCache[slug]);
|
||||
}
|
||||
const {data} = await axios.get(`/2/${slug}/items/`);
|
||||
commit('replaceLoadedItems', data);
|
||||
commit('setItemCache', {slug, items: data});
|
||||
} catch (e) {
|
||||
console.error("Error loading items");
|
||||
}
|
||||
},
|
||||
async searchEventItems({commit, getters}, query) {
|
||||
const foo = utf8.encode(query);
|
||||
const bar = base64.encode(foo);
|
||||
|
||||
const {data} = await axios.get(`/2/${getters.getEventSlug}/items/${bar}/`);
|
||||
commit('replaceLoadedItems', data);
|
||||
},
|
||||
async loadBoxes({commit}) {
|
||||
const {data} = await axios.get('/2/boxes/');
|
||||
commit('replaceBoxes', data);
|
||||
},
|
||||
async createBox({commit, dispatch}, box) {
|
||||
const {data} = await axios.post('/2/boxes/', box);
|
||||
commit('replaceBoxes', data);
|
||||
dispatch('loadBoxes').then(() => {
|
||||
commit('closeAddBoxModal');
|
||||
});
|
||||
},
|
||||
async deleteBox({commit, dispatch}, box_id) {
|
||||
await axios.delete(`/2/boxes/${box_id}/`);
|
||||
dispatch('loadBoxes');
|
||||
},
|
||||
async updateItem({commit, getters}, item) {
|
||||
const {data} = await axios.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
|
||||
commit('updateItem', data);
|
||||
},
|
||||
async markItemReturned({commit, getters}, item) {
|
||||
await axios.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true});
|
||||
commit('removeItem', item);
|
||||
},
|
||||
async deleteItem({commit, getters}, item) {
|
||||
await axios.delete(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
|
||||
commit('removeItem', item);
|
||||
},
|
||||
async postItem({commit, getters}, item) {
|
||||
commit('updateLastUsed', {box: item.box, cid: item.cid});
|
||||
const {data} = await axios.post(`/2/${getters.getEventSlug}/item/`, item);
|
||||
commit('appendItem', data);
|
||||
},
|
||||
async loadTickets({commit}) {
|
||||
const {data} = await axios.get('/2/tickets/');
|
||||
commit('replaceTickets', data);
|
||||
},
|
||||
async sendMail({commit, dispatch}, {id, message}) {
|
||||
const {data} = await axios.post(`/2/tickets/${id}/reply/`, {message});
|
||||
await dispatch('loadTickets');
|
||||
},
|
||||
async postManualTicket({commit, dispatch}, {sender, message, title,}) {
|
||||
const {data} = await axios.post(`/2/tickets/manual/`, {
|
||||
name: title,
|
||||
sender,
|
||||
body: message,
|
||||
recipient: 'mail@c3lf.de'
|
||||
});
|
||||
await dispatch('loadTickets');
|
||||
},
|
||||
async postComment({commit, dispatch}, {id, message}) {
|
||||
const {data} = await axios.post(`/2/tickets/${id}/comment/`, {comment: message});
|
||||
await dispatch('loadTickets');
|
||||
},
|
||||
async loadUsers({commit}) {
|
||||
const {data} = await axios.get('/2/users/');
|
||||
commit('replaceUsers', data);
|
||||
},
|
||||
async loadGroups({commit}) {
|
||||
const {data} = await axios.get('/2/groups/');
|
||||
commit('replaceGroups', data);
|
||||
},
|
||||
async updateTicket({commit}, ticket) {
|
||||
const {data} = await axios.put(`/2/tickets/${ticket.id}/`, ticket);
|
||||
commit('updateTicket', data);
|
||||
},
|
||||
async updateTicketPartial({commit}, {id, ...ticket}) {
|
||||
const {data} = await axios.patch(`/2/tickets/${id}/`, ticket);
|
||||
commit('updateTicket', data);
|
||||
}
|
||||
},
|
||||
plugins: [createMutationsSharer({
|
||||
predicate: [
|
||||
'replaceLoadedItems',
|
||||
'setItemCache',
|
||||
'setLayout',
|
||||
'replaceBoxes',
|
||||
'updateItem',
|
||||
'removeItem',
|
||||
'appendItem',
|
||||
'replaceTickets',
|
||||
'replaceUsers',
|
||||
'replaceGroups',
|
||||
'updateTicket',
|
||||
'openAddBoxModal',
|
||||
'closeAddBoxModal',
|
||||
'createToast',
|
||||
'removeToast',
|
||||
'setRemember',
|
||||
'setUser',
|
||||
'setPermissions',
|
||||
'setToken',
|
||||
'logout',
|
||||
]
|
||||
})],
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
||||
store.dispatch('loadEvents').then(() => {
|
||||
if (store.getters.isLoggedIn) {
|
||||
axios.defaults.headers.common['Authorization'] = `Token ${store.state.token}`;
|
||||
store.dispatch('afterLogin');
|
||||
}
|
||||
});
|
|
@ -24,4 +24,80 @@ function ticketStateIconLookup(ticket) {
|
|||
return 'exclamation';
|
||||
}
|
||||
|
||||
export {ticketStateColorLookup, ticketStateIconLookup};
|
||||
const http = {
|
||||
get: async (url, token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch('/api' + url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Token ${token}`,
|
||||
},
|
||||
});
|
||||
const success = response.status === 200 || response.status === 201;
|
||||
return {data: await response.json() || {}, success};
|
||||
},
|
||||
post: async (url, data, token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch('/api' + url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Token ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const success = response.status === 200 || response.status === 201;
|
||||
return {data: await response.json() || {}, success};
|
||||
},
|
||||
put: async (url, data, token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch('/api' + url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Token ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const success = response.status === 200 || response.status === 201;
|
||||
return {data: await response.json() || {}, success};
|
||||
},
|
||||
patch: async (url, data, token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch('/api' + url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Token ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const success = response.status === 200 || response.status === 201;
|
||||
return {data: await response.json() || {}, success};
|
||||
},
|
||||
delete: async (url, token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch('/api' + url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Token ${token}`,
|
||||
},
|
||||
});
|
||||
const success = response.status === 200 || response.status === 201;
|
||||
return {data: await response.json() || {}, success};
|
||||
}
|
||||
}
|
||||
|
||||
export {ticketStateColorLookup, ticketStateIconLookup, http};
|
|
@ -4,9 +4,16 @@
|
|||
<div class="col-xl-8 offset-xl-2">
|
||||
<div class="card bg-dark text-light mb-2" id="filters">
|
||||
<div class="card-header">
|
||||
<h3 class="text-center">User: {{user}}</h3>
|
||||
<h3 class="text-center">User: {{ activeUser }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body" v-if="hasPermissions">
|
||||
<p>Your Account is activated. Got to
|
||||
<router-link :to="{name: 'items', params: {event: getEventSlug}}">Items</router-link>
|
||||
or
|
||||
<router-link :to="{name: 'tickets', params: {event: getEventSlug}}">Tickets</router-link>
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-body" v-else>
|
||||
<p>Your Account is not yet activated. Please contact an admin.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -16,11 +23,13 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from "vuex";
|
||||
import {mapGetters, mapState} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'Empty',
|
||||
computed: mapState(['user']),
|
||||
computed: {
|
||||
...mapGetters(['hasPermissions', 'getEventSlug', 'activeUser']),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
@ -23,13 +23,15 @@
|
|||
>
|
||||
<template #actions="{ item }">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-success" @click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
|
||||
<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">
|
||||
<button class="btn btn-danger" @click.stop="confirm('delete Item?') && deleteItem(item)"
|
||||
title="delete">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -45,7 +47,7 @@
|
|||
v-slot="{ item }"
|
||||
@itemActivated="openLightboxModalWith($event)"
|
||||
>
|
||||
<AuthenticatedImage v-if="item.file"
|
||||
<AuthenticatedImage v-if="item.file" cached
|
||||
:src="`/media/2/256/${item.file}/`"
|
||||
class="card-img-top img-fluid"
|
||||
/>
|
||||
|
@ -93,7 +95,7 @@ export default {
|
|||
...mapGetters(['layout']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['deleteItem', 'markItemReturned']),
|
||||
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'scheduleAfterInit']),
|
||||
openLightboxModalWith(item) {
|
||||
this.lightboxHash = item.file;
|
||||
},
|
||||
|
@ -113,6 +115,9 @@ export default {
|
|||
confirm(message) {
|
||||
return window.confirm(message);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.scheduleAfterInit(() => [this.loadEventItems()]);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -100,7 +100,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
input{
|
||||
input {
|
||||
background-color: var(--dark);
|
||||
border: var(--gray) 1px solid;;
|
||||
|
||||
|
|
|
@ -62,7 +62,7 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
|
||||
...mapActions(['loadTickets', 'loadUsers', 'fetchTicketStates']),
|
||||
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
|
||||
handleMail(mail) {
|
||||
this.sendMail({
|
||||
id: this.ticket.id,
|
||||
|
@ -88,10 +88,8 @@ export default {
|
|||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchTicketStates()
|
||||
this.loadTickets()
|
||||
this.loadUsers()
|
||||
mounted() {
|
||||
this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets(), this.loadUsers()]);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -2,13 +2,13 @@
|
|||
<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"
|
||||
<SlotTable
|
||||
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to', 'actions', 'actions2']"
|
||||
:items="tickets.map(formatTicket)"
|
||||
:keyName="'id'"
|
||||
v-if="layout === 'table'"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<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)">
|
||||
|
@ -17,7 +17,7 @@
|
|||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</SlotTable>
|
||||
</div>
|
||||
</div>
|
||||
<CollapsableCards v-if="layout === 'tasks'" :items="tickets"
|
||||
|
@ -54,18 +54,18 @@ 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";
|
||||
|
||||
export default {
|
||||
name: 'Tickets',
|
||||
components: {Lightbox, Table, Cards, Modal, EditItem, CollapsableCards},
|
||||
components: {Lightbox, SlotTable, Cards, Modal, EditItem, CollapsableCards},
|
||||
computed: {
|
||||
...mapState(['tickets']),
|
||||
...mapGetters(['stateInfo', 'getEventSlug', 'layout']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['loadTickets', 'fetchTicketStates']),
|
||||
...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']),
|
||||
gotoDetail(ticket) {
|
||||
this.$router.push({name: 'ticket', params: {id: ticket.id}});
|
||||
},
|
||||
|
@ -80,9 +80,8 @@ export default {
|
|||
};
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchTicketStates();
|
||||
this.loadTickets();
|
||||
mounted() {
|
||||
this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets()]);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -6,7 +6,10 @@
|
|||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs">
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{name: 'admin'}" active-class="active" exact>Dashboard</router-link>
|
||||
<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>
|
||||
|
|
|
@ -1,78 +1,108 @@
|
|||
<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">Mails</h3>
|
||||
<!--p>{{ mails }}</p-->
|
||||
<ul>
|
||||
<li v-for="mail in mails" :key="mail.id">
|
||||
{{ mail.id }}
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="text-center">Issues</h3>
|
||||
<!--p>{{ issues }}</p-->
|
||||
<ul>
|
||||
<li v-for="issue in issues" :key="issue.id">
|
||||
{{ issue.id }}
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="text-center">System Events</h3>
|
||||
<!--p>{{ systemEvents }}</p-->
|
||||
<ul>
|
||||
<li v-for="systemEvent in systemEvents" :key="systemEvent.id">
|
||||
{{ systemEvent.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">Mails</h3>
|
||||
<!--p>{{ mails }}</p-->
|
||||
<span>{{ mails.length }} loaded mails</span>
|
||||
<ul class="hidden">
|
||||
<li v-for="mail in mails" :key="mail.id">
|
||||
{{ mail.id }}
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="text-center">Issues</h3>
|
||||
<!--p>{{ issues }}</p-->
|
||||
<span>{{ issues.length }} loaded issues</span>
|
||||
<ul class="hidden">
|
||||
<li v-for="issue in issues" :key="issue.id">
|
||||
{{ issue.id }}
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="text-center">System Events</h3>
|
||||
<!--p>{{ systemEvents }}</p-->
|
||||
<span>{{ systemEvents.length }} loaded system events</span>
|
||||
<ul class="hidden">
|
||||
<li v-for="systemEvent in systemEvents" :key="systemEvent.id">
|
||||
{{ systemEvent.id }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import {mapActions, mapMutations, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
export default {
|
||||
name: 'Debug',
|
||||
components: {Table},
|
||||
data: () => ({
|
||||
mails: [],
|
||||
issues: [],
|
||||
systemEvents: []
|
||||
}),
|
||||
computed: {
|
||||
...mapState(['events', 'loadedItems', 'loadedBoxes', 'mails', 'issues', 'systemEvents']),
|
||||
...mapState(['events', 'loadedItems', 'loadedBoxes']),//, 'mails', 'issues', 'systemEvents']),
|
||||
...mapState(['test']),
|
||||
qr_url() {
|
||||
return window.location.href;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['changeEvent', 'loadMails', 'loadIssues', 'loadSystemEvents']),
|
||||
|
||||
...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.loadMails();
|
||||
this.loadIssues();
|
||||
this.loadSystemEvents();
|
||||
//this.loadMails();
|
||||
//this.loadIssues();
|
||||
//this.loadSystemEvents();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.qr-code img {
|
||||
border: #fff solid 7px
|
||||
}
|
||||
</style>
|
35
web/src/views/admin/Settings.vue
Normal file
35
web/src/views/admin/Settings.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<h3 class="text-center">Message Templates</h3>
|
||||
<ul>
|
||||
<li v-for="template in messageTemplates" :key="template.id">
|
||||
{{ template.name }}<br>
|
||||
<p>{{ template.message }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="text-center">Message Template Variables</h3>
|
||||
<ul>
|
||||
<li v-for="(variable, key) in messageTemplateVariables" :key="key">
|
||||
{{ variable }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
components: {Table},
|
||||
computed: mapState(['messageTemplates', 'messageTemplateVariables']),
|
||||
methods: mapActions(['fetchMessageTemplates', 'fetchMessageTemplateVariables']),
|
||||
mounted() {
|
||||
this.fetchMessageTemplates();
|
||||
this.fetchMessageTemplateVariables();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
1
web/vendor/vuex-router-sync
vendored
Submodule
1
web/vendor/vuex-router-sync
vendored
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 7b8bdeec5e3127c7877842193253ac234487d097
|
31
web/vue.config.js
Normal file
31
web/vue.config.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
// vue.config.js
|
||||
|
||||
module.exports = {
|
||||
devServer: {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
"Access-Control-Allow-Methods": "*"
|
||||
},
|
||||
proxy: {
|
||||
'^/media/2': {
|
||||
target: 'https://staging.c3lf.de/',
|
||||
changeOrigin: true
|
||||
},
|
||||
'^/api/2': {
|
||||
target: 'https://staging.c3lf.de/',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'^/api/1': {
|
||||
target: 'https://staging.c3lf.de/',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'^/ws/2': {
|
||||
target: 'http://127.0.0.1:8082/',
|
||||
//changeOrigin: true,
|
||||
ws: true,
|
||||
logLevel: 'debug',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue