Compare commits

...

57 commits

Author SHA1 Message Date
jedi c3c56f227b stash 2024-05-10 22:07:38 +02:00
jedi 7048cd573a stash 2024-05-09 23:54:29 +02:00
jedi a8caaa7d0b stash 2024-05-09 23:51:18 +02:00
jedi bb7c3264d6 stash 2024-05-09 23:24:43 +02:00
jedi f601dba159 stash 2024-05-09 23:09:19 +02:00
jedi e7c8ca6757 stash 2024-05-09 22:52:01 +02:00
jedi badbbf8717 stash 2024-05-04 03:26:32 +02:00
jedi 4a712240fc stash 2024-05-04 03:08:00 +02:00
jedi 6d3f9f2a4c stash 2024-05-04 03:03:21 +02:00
jedi 2640f42988 stash 2024-05-04 02:07:13 +02:00
jedi 90a4cea758 stash 2024-05-04 01:50:03 +02:00
jedi 4fbfaf3062 stash 2024-05-04 01:43:59 +02:00
jedi e8a38d1202 stash 2024-05-04 01:36:46 +02:00
jedi 9074309f01 stash 2024-05-04 01:13:43 +02:00
jedi a2dd06f2f9 stash 2024-05-04 00:59:11 +02:00
jedi 21fddfe631 stash 2024-05-04 00:54:20 +02:00
jedi a7827e53cc stash 2024-05-04 00:45:28 +02:00
jedi 67c9496413 stash 2024-05-04 00:43:40 +02:00
jedi 1cacbc8c91 stash 2024-05-04 00:37:41 +02:00
jedi 69046208fb stash 2024-05-04 00:29:09 +02:00
jedi 7e81ef133e stash 2024-05-04 00:05:06 +02:00
jedi 337bd9c885 stash 2024-05-03 23:58:38 +02:00
jedi 88ecfc0f61 stash 2024-05-03 23:43:43 +02:00
jedi 0a124a19d8 stash 2024-05-02 23:02:49 +02:00
jedi 7f21fbaa3f stash 2024-05-02 22:53:13 +02:00
jedi 9daf82c13a stash 2024-05-02 22:37:34 +02:00
jedi 98a944902f stash 2024-04-26 16:38:41 +02:00
jedi 7d1d8529fd stash 2024-04-26 16:16:23 +02:00
jedi 7ef40324ba stash 2024-04-26 16:12:58 +02:00
jedi 0e78ac4d5b stash 2024-04-26 16:07:36 +02:00
jedi 312e9a465a stash 2024-04-26 15:56:15 +02:00
jedi 08bdafae1b stash 2024-04-26 15:27:56 +02:00
jedi 07c3767376 stash 2024-04-26 01:35:12 +02:00
jedi 016143c109 stash 2024-04-26 01:28:44 +02:00
jedi a88747a0c4 stash 2024-04-26 01:22:08 +02:00
jedi c0b77f04ee stash 2024-04-26 01:13:18 +02:00
jedi 7eb591e103 stash 2024-04-26 00:56:12 +02:00
jedi 00262729fc stash 2024-04-26 00:31:34 +02:00
jedi d9d7a218b1 stash 2024-04-26 00:23:37 +02:00
jedi c06e7f1826 stash 2024-04-26 00:18:04 +02:00
jedi c0fd29ba36 stash 2024-04-26 00:10:26 +02:00
jedi 6cb55c9574 stash 2024-04-25 23:58:49 +02:00
jedi 4aca7e41fd stash 2024-04-25 23:54:06 +02:00
jedi b356a85628 stash 2024-04-25 23:54:06 +02:00
jedi 43fc551fc8 stash 2024-04-25 23:54:06 +02:00
jedi 4963b3eb68 stash 2024-04-25 23:54:06 +02:00
jedi 7660a39203 stash 2024-04-25 23:54:06 +02:00
jedi 6ff3e3b89c stash 2024-04-25 23:54:06 +02:00
jedi 31af2fbe63 stash 2024-04-25 23:54:06 +02:00
jedi a83846e2c7 stash 2024-04-25 23:54:06 +02:00
jedi be96901129 stash 2024-04-25 23:54:06 +02:00
jedi 4645a2f48c stash 2024-04-25 23:54:06 +02:00
jedi 30cddebdc8 stash 2024-04-25 23:54:06 +02:00
jedi a3c6baa67d stash 2024-04-25 23:54:06 +02:00
jedi e0679e965d stash 2024-04-25 23:54:06 +02:00
jedi b962ff3dbc stash 2024-04-25 23:54:06 +02:00
jedi 0c39f52c00 stash 2024-04-25 23:54:06 +02:00
64 changed files with 13163 additions and 704 deletions

3
.gitmodules vendored Normal file
View 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
View file

@ -0,0 +1 @@
ansible-playbook deploy/ansible/playbooks/deploy-c3lf-sys3.yml --inventory=deploy/ansible/inventory.yml

14
core/.coveragerc Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 == '':

View file

@ -3,6 +3,7 @@ import random
from django.db import models
from django_softdelete.models import SoftDeleteModel
from authentication.models import ExtendedUser
from core.settings import MAIL_DOMAIN
from files.models import AbstractFile
from inventory.models import Event
@ -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)

View file

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

View file

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

View file

View file

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

View file

@ -0,0 +1,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)

View file

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

View file

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

View file

@ -0,0 +1,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),
]

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

View file

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

View file

View file

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

View file

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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_')) {

View file

@ -7,7 +7,7 @@
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="toast-body" v-html="message">{{ message }}</div>
<!--div class="toast-body" v-html="message">{{ message }}</div-->
</div>
</template>

View file

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

View 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)
}
});
}
};

View file

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

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

View file

@ -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');
}
});

View file

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

View file

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

View file

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

View file

@ -100,7 +100,7 @@ export default {
</script>
<style scoped>
input{
input {
background-color: var(--dark);
border: var(--gray) 1px solid;;

View file

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

View file

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

View file

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

View file

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

View 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

@ -0,0 +1 @@
Subproject commit 7b8bdeec5e3127c7877842193253ac234487d097

31
web/vue.config.js Normal file
View 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',
},
}
}
}