Compare commits
143 commits
7150e4744c
...
dc57a90cf1
Author | SHA1 | Date | |
---|---|---|---|
dc57a90cf1 | |||
f88dd9580d | |||
f355639d88 | |||
a2633bff7a | |||
373bc7ad40 | |||
cc630ae03c | |||
8ac7b91ba8 | |||
383c02d55b | |||
804c47a3b8 | |||
5af3e72218 | |||
4738651b66 | |||
75798e38e4 | |||
69ce11c331 | |||
4799a7cd5d | |||
4c7e2040cd | |||
13430c0f04 | |||
9c3fedc5c3 | |||
39ce06d180 | |||
0bf28321da | |||
473b80d171 | |||
da075d162f | |||
ab88e50d82 | |||
74b16ae82b | |||
f3c238134b | |||
6d9101f915 | |||
520d07621c | |||
b0acceebdd | |||
a54cb45689 | |||
6381d4ebcb | |||
9b6c2ab5a3 | |||
dc0b0f818e | |||
9871394833 | |||
0a55af3b9d | |||
09017ca49a | |||
44d4059d94 | |||
d3907d0d0f | |||
22740aadf5 | |||
a4c1a6fc95 | |||
acf76c1d15 | |||
3628eb3239 | |||
1ede9b0930 | |||
6b11cb8393 | |||
db1e5a8249 | |||
7c10fb76b9 | |||
f8e9c285f6 | |||
b08f9a7127 | |||
33a7423fef | |||
0239b68c11 | |||
0df129d7a0 | |||
602205e096 | |||
2a0fd8649d | |||
3ebe51645a | |||
d234b73e6f | |||
20ab9860f1 | |||
f29209b257 | |||
7da64f0612 | |||
4d3e68079a | |||
3d45572697 | |||
54b9cefc5d | |||
8f1cc0bba3 | |||
3a2a6328b0 | |||
7eb6073c3c | |||
bf65ce3469 | |||
f3eb64b488 | |||
2fd9ae4dbc | |||
e2cfe70af7 | |||
98366c8909 | |||
75c13e2d8b | |||
73de3982b1 | |||
275240433e | |||
b4662f00be | |||
761870dc82 | |||
c8e5bc298c | |||
2415bf27da | |||
7828766050 | |||
97fd7da33f | |||
fed0fb2acf | |||
20c8cd866e | |||
f7c6b9d128 | |||
235bf249e2 | |||
d3b832f45a | |||
68e146ffd4 | |||
a93781233d | |||
8c01e37224 | |||
b7fb0c6fb3 | |||
4a8a9fa90f | |||
7f8aee935e | |||
5b0ad7b520 | |||
0e4717cc1e | |||
d9d4d5092f | |||
dbbd63ab1e | |||
83299bba7a | |||
f9830affea | |||
381ecda1ce | |||
9f02c41c9c | |||
556525557b | |||
9a22c411b1 | |||
9e2d59a2f6 | |||
53f72fb848 | |||
62ce4e95bf | |||
ce11d2dff1 | |||
a8a3c36fab | |||
6af69aed69 | |||
432b61c70a | |||
e361556188 | |||
6b47ebc50c | |||
a39802b5bf | |||
fdfefed004 | |||
7871fb4f56 | |||
437c5395e8 | |||
6a4e881051 | |||
aa87e70a73 | |||
82b9f747e2 | |||
79fec60229 | |||
06ebd4bd8f | |||
e2bdeedfe3 | |||
186392a801 | |||
e88bbc75f3 | |||
0e84994b62 | |||
fe93366bd2 | |||
c1e7a8910d | |||
6d07b3eefc | |||
6bfebc1f5f | |||
9ebd172cb7 | |||
f0344d1efe | |||
44c3efeadf | |||
173ec12084 | |||
0891269285 | |||
fbbc72feab | |||
2af52c6991 | |||
f289bb4762 | |||
a83fa12fef | |||
7e985cdfbd | |||
0f7a83c0d8 | |||
2f354130da | |||
a59509a432 | |||
7d1786f143 | |||
4152034e4a | |||
67375bd281 | |||
e91b64ca97 | |||
facefc1cc7 | |||
575d43acbd | |||
bb71c44aa7 |
82 changed files with 14329 additions and 860 deletions
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
[submodule "web/vendor/vuex-router-sync"]
|
||||
path = web/vendor/vuex-router-sync
|
||||
url = https://github.com/vuejs/vuex-router-sync.git
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
ansible-playbook deploy/ansible/playbooks/deploy-c3lf-sys3.yml --inventory=deploy/ansible/inventory.yml
|
||||
|
||||
ssh root@andromeda.lab.or.it -A -L8080:localhost:11334
|
14
core/.coveragerc
Normal file
14
core/.coveragerc
Normal file
|
@ -0,0 +1,14 @@
|
|||
[run]
|
||||
source = .
|
||||
|
||||
[report]
|
||||
fail_under = 100
|
||||
show_missing = True
|
||||
skip_covered = True
|
||||
omit =
|
||||
*/tests/*
|
||||
*/migrations/*
|
||||
core/asgi.py
|
||||
core/wsgi.py
|
||||
core/settings.py
|
||||
manage.py
|
|
@ -10,8 +10,8 @@ class ExtendedUserAdmin(UserAdmin):
|
|||
ordering = ('username',)
|
||||
filter_horizontal = ('groups', 'user_permissions', 'permissions')
|
||||
|
||||
def permissions(self, obj):
|
||||
return ', '.join(obj.get_all_permissions())
|
||||
# def permissions(self, obj):
|
||||
# return ', '.join(obj.get_all_permissions())
|
||||
|
||||
|
||||
admin.site.register(ExtendedUser, ExtendedUserAdmin)
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
from authentication.models import ExtendedUser
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
|
@ -11,6 +9,7 @@ class Migration(migrations.Migration):
|
|||
]
|
||||
|
||||
def create_legacy_user(apps, schema_editor):
|
||||
ExtendedUser = apps.get_model('authentication', 'ExtendedUser')
|
||||
ExtendedUser.objects.create_user(settings.LEGACY_USER_NAME, 'mail@' + settings.MAIL_DOMAIN,
|
||||
settings.LEGACY_USER_PASSWORD)
|
||||
|
||||
|
|
|
@ -29,7 +29,9 @@ SECRET_KEY = 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv
|
|||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost')]
|
||||
PRIMARY_HOST = os.getenv('HTTP_HOST', 'localhost')
|
||||
|
||||
ALLOWED_HOSTS = [PRIMARY_HOST]
|
||||
|
||||
MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost')
|
||||
|
||||
|
@ -40,6 +42,10 @@ LEGACY_USER_PASSWORD = os.getenv('LEGACY_API_PASSWORD', 'legacy_password')
|
|||
|
||||
SYSTEM3_VERSION = "0.0.0-dev.0"
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi')
|
||||
|
||||
TELEGRAM_GROUP_CHAT_ID = os.getenv('TELEGRAM_GROUP_CHAT_ID', '-1234567890')
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
|
@ -55,6 +61,7 @@ INSTALLED_APPS = [
|
|||
'drf_yasg',
|
||||
'channels',
|
||||
'authentication',
|
||||
'notifications',
|
||||
'files',
|
||||
'tickets',
|
||||
'inventory',
|
||||
|
|
|
@ -31,5 +31,6 @@ urlpatterns = [
|
|||
path('api/2/', include('mail.api_v2')),
|
||||
path('api/2/', include('notify_sessions.api_v2')),
|
||||
path('api/2/', include('authentication.api_v2')),
|
||||
path('api/2/', include('notifications.api_v2')),
|
||||
path('api/', get_info),
|
||||
]
|
||||
|
|
|
@ -33,7 +33,7 @@ def media_urls(request, hash):
|
|||
headers={
|
||||
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=31536000, private',
|
||||
'Cache-Control': 'max-age=31536000, private, immutable',
|
||||
'Expires': datetime.utcnow() + timedelta(days=365),
|
||||
'Age': 0,
|
||||
'ETag': file.hash,
|
||||
|
@ -74,7 +74,7 @@ def thumbnail_urls(request, size, hash):
|
|||
headers={
|
||||
'X-Accel-Redirect': f'/redirect_thumbnail/{size}/{hash_path}',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=31536000, private',
|
||||
'Cache-Control': 'max-age=31536000, private, immutable',
|
||||
'Expires': datetime.utcnow() + timedelta(days=365),
|
||||
'Age': 0,
|
||||
'ETag': file.hash + "_" + str(size),
|
||||
|
|
|
@ -90,4 +90,6 @@ class AbstractFile(models.Model):
|
|||
|
||||
class File(AbstractFile):
|
||||
item = models.ForeignKey(Item, models.CASCADE, db_column='iid', null=True, blank=True, related_name='files')
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
return self.hash
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.urls import re_path
|
||||
from rest_framework import routers, viewsets, serializers
|
||||
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||
|
@ -7,13 +6,7 @@ from rest_framework.response import Response
|
|||
|
||||
from files.models import File
|
||||
from inventory.models import Event, Container, Item
|
||||
|
||||
|
||||
class EventSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end']
|
||||
read_only_fields = ['eid']
|
||||
from inventory.serializers import EventSerializer, ContainerSerializer
|
||||
|
||||
|
||||
class EventViewSet(viewsets.ModelViewSet):
|
||||
|
@ -23,18 +16,6 @@ class EventViewSet(viewsets.ModelViewSet):
|
|||
authentication_classes = []
|
||||
|
||||
|
||||
class ContainerSerializer(serializers.ModelSerializer):
|
||||
itemCount = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Container
|
||||
fields = ['cid', 'name', 'itemCount']
|
||||
read_only_fields = ['cid', 'itemCount']
|
||||
|
||||
def get_itemCount(self, instance):
|
||||
return Item.objects.filter(container=instance.cid).count()
|
||||
|
||||
|
||||
class ContainerViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ContainerSerializer
|
||||
queryset = Container.objects.all()
|
||||
|
@ -87,7 +68,7 @@ class ItemSerializer(serializers.ModelSerializer):
|
|||
def update(self, instance, validated_data):
|
||||
if 'returned' in validated_data:
|
||||
if validated_data['returned']:
|
||||
validated_data['returned_at'] = datetime.now()
|
||||
validated_data['returned_at'] = timezone.now()
|
||||
validated_data.pop('returned')
|
||||
if 'dataImage' in validated_data:
|
||||
file = File.objects.create(data=validated_data['dataImage'])
|
||||
|
|
|
@ -1,21 +1,12 @@
|
|||
from datetime import datetime
|
||||
|
||||
from django.urls import path, re_path
|
||||
from django.urls import path
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from rest_framework import routers, viewsets, serializers
|
||||
from rest_framework import routers, viewsets
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from files.models import File
|
||||
from inventory.models import Event, Container, Item
|
||||
|
||||
|
||||
class EventSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end']
|
||||
read_only_fields = ['eid']
|
||||
from inventory.serializers import EventSerializer, ContainerSerializer, ItemSerializer
|
||||
|
||||
|
||||
class EventViewSet(viewsets.ModelViewSet):
|
||||
|
@ -24,87 +15,11 @@ class EventViewSet(viewsets.ModelViewSet):
|
|||
permission_classes = []
|
||||
|
||||
|
||||
class ContainerSerializer(serializers.ModelSerializer):
|
||||
itemCount = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Container
|
||||
fields = ['cid', 'name', 'itemCount']
|
||||
read_only_fields = ['cid', 'itemCount']
|
||||
|
||||
def get_itemCount(self, instance):
|
||||
return Item.objects.filter(container=instance.cid).count()
|
||||
|
||||
|
||||
class ContainerViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ContainerSerializer
|
||||
queryset = Container.objects.all()
|
||||
|
||||
|
||||
class ItemSerializer(serializers.ModelSerializer):
|
||||
dataImage = serializers.CharField(write_only=True, required=False)
|
||||
cid = serializers.SerializerMethodField()
|
||||
box = serializers.SerializerMethodField()
|
||||
file = serializers.SerializerMethodField()
|
||||
returned = serializers.SerializerMethodField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ['cid', 'box', 'uid', 'description', 'file', 'dataImage', 'returned']
|
||||
read_only_fields = ['uid']
|
||||
|
||||
def get_cid(self, instance):
|
||||
return instance.container.cid
|
||||
|
||||
def get_box(self, instance):
|
||||
return instance.container.name
|
||||
|
||||
def get_file(self, instance):
|
||||
if len(instance.files.all()) > 0:
|
||||
return instance.files.all().order_by('-created_at')[0].hash
|
||||
return None
|
||||
|
||||
def get_returned(self, instance):
|
||||
return instance.returned_at is not None
|
||||
|
||||
def to_internal_value(self, data):
|
||||
container = None
|
||||
returned = False
|
||||
if 'cid' in data:
|
||||
container = Container.objects.get(cid=data['cid'])
|
||||
if 'returned' in data:
|
||||
returned = data['returned']
|
||||
internal = super().to_internal_value(data)
|
||||
if container:
|
||||
internal['container'] = container
|
||||
if returned:
|
||||
internal['returned_at'] = datetime.now()
|
||||
return internal
|
||||
|
||||
def validate(self, attrs):
|
||||
return super().validate(attrs)
|
||||
|
||||
def create(self, validated_data):
|
||||
if 'dataImage' in validated_data:
|
||||
file = File.objects.create(data=validated_data['dataImage'])
|
||||
validated_data.pop('dataImage')
|
||||
item = Item.objects.create(**validated_data)
|
||||
item.files.set([file])
|
||||
return item
|
||||
return Item.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if 'returned' in validated_data:
|
||||
if validated_data['returned']:
|
||||
validated_data['returned_at'] = datetime.now()
|
||||
validated_data.pop('returned')
|
||||
if 'dataImage' in validated_data:
|
||||
file = File.objects.create(data=validated_data['dataImage'])
|
||||
validated_data.pop('dataImage')
|
||||
instance.files.add(file)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@permission_required('view_item', raise_exception=True)
|
||||
|
|
|
@ -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
|
||||
|
|
88
core/inventory/serializers.py
Normal file
88
core/inventory/serializers.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
|
||||
from files.models import File
|
||||
from inventory.models import Event, Container, Item
|
||||
|
||||
|
||||
class EventSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end']
|
||||
read_only_fields = ['eid']
|
||||
|
||||
|
||||
class ContainerSerializer(serializers.ModelSerializer):
|
||||
itemCount = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Container
|
||||
fields = ['cid', 'name', 'itemCount']
|
||||
read_only_fields = ['cid', 'itemCount']
|
||||
|
||||
def get_itemCount(self, instance):
|
||||
return Item.objects.filter(container=instance.cid).count()
|
||||
|
||||
|
||||
class ItemSerializer(serializers.ModelSerializer):
|
||||
dataImage = serializers.CharField(write_only=True, required=False)
|
||||
cid = serializers.SerializerMethodField()
|
||||
box = serializers.SerializerMethodField()
|
||||
file = serializers.SerializerMethodField()
|
||||
returned = serializers.SerializerMethodField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ['cid', 'box', 'uid', 'description', 'file', 'dataImage', 'returned']
|
||||
read_only_fields = ['uid']
|
||||
|
||||
def get_cid(self, instance):
|
||||
return instance.container.cid
|
||||
|
||||
def get_box(self, instance):
|
||||
return instance.container.name
|
||||
|
||||
def get_file(self, instance):
|
||||
if len(instance.files.all()) > 0:
|
||||
return instance.files.all().order_by('-created_at')[0].hash
|
||||
return None
|
||||
|
||||
def get_returned(self, instance):
|
||||
return instance.returned_at is not None
|
||||
|
||||
def to_internal_value(self, data):
|
||||
container = None
|
||||
returned = False
|
||||
if 'cid' in data:
|
||||
container = Container.objects.get(cid=data['cid'])
|
||||
if 'returned' in data:
|
||||
returned = data['returned']
|
||||
internal = super().to_internal_value(data)
|
||||
if container:
|
||||
internal['container'] = container
|
||||
if returned:
|
||||
internal['returned_at'] = timezone.now()
|
||||
return internal
|
||||
|
||||
def validate(self, attrs):
|
||||
return super().validate(attrs)
|
||||
|
||||
def create(self, validated_data):
|
||||
if 'dataImage' in validated_data:
|
||||
file = File.objects.create(data=validated_data['dataImage'])
|
||||
validated_data.pop('dataImage')
|
||||
item = Item.objects.create(**validated_data)
|
||||
item.files.set([file])
|
||||
return item
|
||||
return Item.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if 'returned' in validated_data:
|
||||
if validated_data['returned']:
|
||||
validated_data['returned_at'] = timezone.now()
|
||||
validated_data.pop('returned')
|
||||
if 'dataImage' in validated_data:
|
||||
file = File.objects.create(data=validated_data['dataImage'])
|
||||
validated_data.pop('dataImage')
|
||||
instance.files.add(file)
|
||||
return super().update(instance, validated_data)
|
|
@ -1,5 +1,4 @@
|
|||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth.models import Permission
|
||||
from knox.models import AuthToken
|
||||
|
@ -164,7 +163,7 @@ class ItemTestCase(TestCase):
|
|||
response = self.client.get(f'/api/2/{self.event.slug}/item/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.json()), 2)
|
||||
item2.returned_at = datetime.now()
|
||||
item2.returned_at = timezone.now()
|
||||
item2.save()
|
||||
response = self.client.get(f'/api/2/{self.event.slug}/item/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import files.models
|
||||
from mail.models import Email
|
||||
from mail.protocol import parse_email_body
|
||||
|
||||
|
||||
|
@ -24,6 +23,7 @@ class Migration(migrations.Migration):
|
|||
]
|
||||
|
||||
def generate_email_attachments(apps, schema_editor):
|
||||
Email = apps.get_model('mail', 'Email')
|
||||
for email in Email.objects.all():
|
||||
raw = email.raw
|
||||
if raw is None or raw == '':
|
||||
|
|
|
@ -3,6 +3,7 @@ import random
|
|||
from django.db import models
|
||||
from django_softdelete.models import SoftDeleteModel
|
||||
|
||||
from authentication.models import ExtendedUser
|
||||
from core.settings import MAIL_DOMAIN
|
||||
from files.models import AbstractFile
|
||||
from inventory.models import Event
|
||||
|
@ -38,3 +39,6 @@ class EventAddress(models.Model):
|
|||
class EmailAttachment(AbstractFile):
|
||||
email = models.ForeignKey(Email, models.CASCADE, related_name='attachments', null=True)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
import logging
|
||||
from re import match
|
||||
|
||||
import aiosmtplib
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.layers import get_channel_layer
|
||||
from channels.db import database_sync_to_async
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from mail.models import Email, EventAddress, EmailAttachment
|
||||
from notifications.templates import render_auto_reply
|
||||
from notify_sessions.models import SystemEvent
|
||||
from tickets.models import IssueThread
|
||||
|
||||
|
||||
class SpecialMailException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def find_quoted_printable(s, marker):
|
||||
positions = [i for i in range(len(s)) if s.lower().startswith('=?utf-8?' + marker + '?', i)]
|
||||
for pos in positions:
|
||||
|
@ -82,8 +88,23 @@ def make_reply(reply_email, references=None, event=None):
|
|||
return reply
|
||||
|
||||
|
||||
async def send_smtp(message, log):
|
||||
log.info('Sending message to %s' % message['To'])
|
||||
def make_notification(message, to, title): # TODO where should replies to this go
|
||||
from email.message import EmailMessage
|
||||
from core.settings import MAIL_DOMAIN
|
||||
notification = EmailMessage()
|
||||
notification["From"] = "notifications@%s" % MAIL_DOMAIN
|
||||
notification["To"] = to
|
||||
notification["Subject"] = f"[C3LF Notification]%s" % title
|
||||
# notification["Reply-To"] = f"{event}@{MAIL_DOMAIN}"
|
||||
# notification["In-Reply-To"] = email.reference
|
||||
# notification["Message-ID"] = email.id + "@" + MAIL_DOMAIN
|
||||
|
||||
notification.set_content(message)
|
||||
|
||||
return notification
|
||||
|
||||
|
||||
async def send_smtp(message):
|
||||
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
|
||||
|
||||
|
||||
|
@ -148,9 +169,9 @@ def parse_email_body(raw, log=None):
|
|||
attachments.append(attachment)
|
||||
if 'inline' in cdispo:
|
||||
body = body + f'<img src="cid:{attachment.id}">'
|
||||
log.info("Image", ctype, attachment.id)
|
||||
log.info("Image %s %s", ctype, attachment.id)
|
||||
else:
|
||||
log.info("Attachment", ctype, cdispo)
|
||||
log.info("Attachment %s %s", ctype, cdispo)
|
||||
else:
|
||||
if parsed.get_content_type() == 'text/plain':
|
||||
body = parsed.get_payload()
|
||||
|
@ -161,7 +182,7 @@ def parse_email_body(raw, log=None):
|
|||
soup = BeautifulSoup(body, 'html.parser')
|
||||
body = re.sub(r'([\r\n]+.?)*[\r\n]', r'\n', soup.get_text()).strip('\n')
|
||||
else:
|
||||
log.warning("Unknown content type", parsed.get_content_type())
|
||||
log.warning("Unknown content type %s", parsed.get_content_type())
|
||||
body = "Unknown content type"
|
||||
body = unescape_and_decode_quoted_printable(body)
|
||||
body = unescape_and_decode_base64(body)
|
||||
|
@ -172,6 +193,7 @@ def parse_email_body(raw, log=None):
|
|||
return parsed, body, attachments
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def receive_email(envelope, log=None):
|
||||
parsed, body, attachments = parse_email_body(envelope.content, log)
|
||||
|
||||
|
@ -180,13 +202,23 @@ def receive_email(envelope, log=None):
|
|||
header_in_reply_to = parsed.get('In-Reply-To')
|
||||
header_message_id = parsed.get('Message-ID')
|
||||
|
||||
if header_from != envelope.mail_from:
|
||||
log.warning("Header from does not match envelope from")
|
||||
log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}")
|
||||
# if header_from != envelope.mail_from:
|
||||
# log.warning("Header from does not match envelope from")
|
||||
# log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}")
|
||||
#
|
||||
# if header_to != envelope.rcpt_tos[0]:
|
||||
# log.warning("Header to does not match envelope to")
|
||||
# log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}")
|
||||
|
||||
if header_to != envelope.rcpt_tos[0]:
|
||||
log.warning("Header to does not match envelope to")
|
||||
log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}")
|
||||
# handle undelivered mail header_from : 'Mail Delivery System <MAILER-DAEMON@...'
|
||||
|
||||
if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "":
|
||||
log.warning("Ignoring mailer daemon")
|
||||
raise SpecialMailException("Ignoring mailer daemon")
|
||||
|
||||
if Email.objects.filter(reference=header_message_id).exists(): # break before issue thread is created
|
||||
log.warning("Email already exists")
|
||||
raise Exception("Email already exists")
|
||||
|
||||
recipient = envelope.rcpt_tos[0].lower() if envelope.rcpt_tos else header_to.lower()
|
||||
sender = envelope.mail_from if envelope.mail_from else header_from
|
||||
|
@ -213,16 +245,7 @@ def receive_email(envelope, log=None):
|
|||
references = collect_references(active_issue_thread)
|
||||
if not sender.startswith('noreply'):
|
||||
subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]"
|
||||
body = '''Your request (#{}) has been received and will be reviewed by our lost&found angels.
|
||||
|
||||
We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \
|
||||
workload is high. We will not forget about your request and get back in touch once we have updated information on your \
|
||||
request. Requests for devices, wallets, credit cards or similar items will be handled with priority.
|
||||
|
||||
If you happen to find your lost item or just want to add additional information, please reply to this email. Please \
|
||||
do not create a new request.
|
||||
|
||||
Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid())
|
||||
body = render_auto_reply(active_issue_thread)
|
||||
reply_email = Email.objects.create(
|
||||
sender=recipient, recipient=sender, body=body, subject=subject,
|
||||
in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread)
|
||||
|
@ -233,7 +256,7 @@ Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid
|
|||
active_issue_thread.state = 'pending_open'
|
||||
active_issue_thread.save()
|
||||
|
||||
return email, new, reply
|
||||
return email, new, reply, active_issue_thread
|
||||
|
||||
|
||||
class LMTPHandler:
|
||||
|
@ -255,25 +278,41 @@ class LMTPHandler:
|
|||
content = None
|
||||
try:
|
||||
content = envelope.content
|
||||
email, new, reply = await sync_to_async(receive_email)(envelope, log)
|
||||
email, new, reply, thread = await receive_email(envelope, log)
|
||||
log.info(f"Created email {email.id}")
|
||||
systemevent = await sync_to_async(SystemEvent.objects.create)(type='email received', reference=email.id)
|
||||
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
|
||||
reference=email.id)
|
||||
log.info(f"Created system event {systemevent.id}")
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
||||
"message": "email received"}
|
||||
)
|
||||
"message": "email received"})
|
||||
log.info(f"Sent message to frontend")
|
||||
|
||||
if new and reply:
|
||||
await send_smtp(reply, log)
|
||||
log.info('Sending message to %s' % reply['To'])
|
||||
await send_smtp(reply)
|
||||
log.info("Sent auto reply")
|
||||
|
||||
if thread:
|
||||
await channel_layer.group_send(
|
||||
'general', {"type": "generic.event", "name": "user_notification", "event_id": systemevent.id,
|
||||
"ticket_id": thread.id, "new": new})
|
||||
else:
|
||||
print("No thread found")
|
||||
|
||||
return '250 Message accepted for delivery'
|
||||
except Exception as e:
|
||||
except SpecialMailException as e:
|
||||
import uuid
|
||||
random_filename = 'mail-' + str(uuid.uuid4())
|
||||
random_filename = 'special-' + str(uuid.uuid4())
|
||||
with open(random_filename, 'wb') as f:
|
||||
f.write(content)
|
||||
log.error(type(e), e, f"Saved email to {random_filename}")
|
||||
log.warning(f"Special mail exception: {e} saved to {random_filename}")
|
||||
return '250 Message accepted for delivery'
|
||||
except Exception as e:
|
||||
from hashlib import sha256
|
||||
random_filename = 'mail-' + sha256(content).hexdigest()
|
||||
with open(random_filename, 'wb') as f:
|
||||
f.write(content)
|
||||
log.error(f"Saved email to {random_filename} because of error %s (%s)", e, type(e))
|
||||
return '451 Internal server error'
|
||||
|
|
20
core/mail/tests/v2/test_user_notifications.py
Normal file
20
core/mail/tests/v2/test_user_notifications.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
|
||||
from authentication.models import ExtendedUser
|
||||
from notifications.models import UserNotificationChannel
|
||||
|
||||
|
||||
class UserNotificationTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
|
||||
self.user.user_permissions.add(*Permission.objects.all())
|
||||
self.user.save()
|
||||
self.channel = UserNotificationChannel.objects.create(user=self.user, channel_type='telegram',
|
||||
channel_target='123456789',
|
||||
event_filter='*', active=True)
|
||||
|
||||
async def test_telegram_notify(self):
|
||||
pass
|
0
core/notifications/__init__.py
Normal file
0
core/notifications/__init__.py
Normal file
15
core/notifications/admin.py
Normal file
15
core/notifications/admin.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from notifications.models import MessageTemplate, UserNotificationChannel
|
||||
|
||||
|
||||
class MessageTemplateAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class UserNotificationChannelAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
admin.site.register(MessageTemplate, MessageTemplateAdmin)
|
||||
admin.site.register(UserNotificationChannel, UserNotificationChannelAdmin)
|
37
core/notifications/api_v2.py
Normal file
37
core/notifications/api_v2.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
from django.contrib.auth.decorators import permission_required
|
||||
from rest_framework import routers, viewsets
|
||||
from django.urls import re_path
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from notifications.models import MessageTemplate
|
||||
from rest_framework import serializers
|
||||
|
||||
from notifications.templates import TEMPLATE_VARS
|
||||
|
||||
|
||||
class MessageTemplateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MessageTemplate
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class MessageTemplateViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = MessageTemplateSerializer
|
||||
queryset = MessageTemplate.objects.all()
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@permission_required('tickets.add_issuethread_manual', raise_exception=True) # TDOO: change this permission
|
||||
def get_template_vars(self):
|
||||
return Response(TEMPLATE_VARS, status=200)
|
||||
|
||||
|
||||
router = routers.SimpleRouter()
|
||||
router.register(r'message_templates', MessageTemplateViewSet)
|
||||
|
||||
urlpatterns = ([
|
||||
re_path('message_template_variables', get_template_vars),
|
||||
] + router.urls)
|
16
core/notifications/defaults.py
Normal file
16
core/notifications/defaults.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
auto_reply_body = '''Your request (#{{ ticket_uuid }}) has been received and will be reviewed by our lost&found angels.
|
||||
|
||||
We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \
|
||||
workload is high. We will not forget about your request and get back in touch once we have updated information on your \
|
||||
request. Requests for devices, wallets, credit cards or similar items will be handled with priority.
|
||||
|
||||
If you happen to find your lost item or just want to add additional information, please reply to this email. Please \
|
||||
do not create a new request.
|
||||
|
||||
Your c3lf (Cloakroom + Lost&Found) Team'''
|
||||
|
||||
new_issue_notification = '''New issue "{{ ticket_name | limit_length }}" [{{ ticket_uuid }}] created
|
||||
{{ ticket_url }}'''
|
||||
|
||||
reply_issue_notification = '''Reply to issue "{{ ticket_name }}" [{{ ticket_uuid }}] (was {{ previous_state_pretty }})
|
||||
{{ ticket_url }}'''
|
85
core/notifications/dispatch.py
Normal file
85
core/notifications/dispatch.py
Normal file
|
@ -0,0 +1,85 @@
|
|||
import asyncio
|
||||
|
||||
from aiohttp.client import ClientSession
|
||||
from channels.layers import get_channel_layer
|
||||
from channels.db import database_sync_to_async
|
||||
from urllib.parse import quote as urlencode
|
||||
|
||||
from core.settings import TELEGRAM_BOT_TOKEN, TELEGRAM_GROUP_CHAT_ID
|
||||
from mail.protocol import send_smtp, make_notification
|
||||
from notifications.models import UserNotificationChannel
|
||||
from notifications.templates import render_notification_new_ticket_async, render_notification_reply_ticket_async
|
||||
from tickets.models import IssueThread
|
||||
|
||||
|
||||
async def http_get(url):
|
||||
async with ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
return await response.text()
|
||||
|
||||
|
||||
async def telegram_notify(message, chat_id):
|
||||
encoded_message = urlencode(message)
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage?chat_id={chat_id}&text={encoded_message}"
|
||||
return await http_get(url)
|
||||
|
||||
|
||||
async def email_notify(message, title, email):
|
||||
mail = make_notification(message, email, title)
|
||||
await send_smtp(mail)
|
||||
|
||||
|
||||
class NotificationDispatcher:
|
||||
channel_layer = None
|
||||
room_group_name = "general"
|
||||
|
||||
def __init__(self):
|
||||
self.channel_layer = get_channel_layer('default')
|
||||
if not self.channel_layer:
|
||||
raise Exception("Could not get channel layer")
|
||||
|
||||
@database_sync_to_async
|
||||
def get_notification_targets(self):
|
||||
channels = UserNotificationChannel.objects.filter(active=True)
|
||||
return list(channels)
|
||||
|
||||
@database_sync_to_async
|
||||
def get_ticket(self, ticket_id):
|
||||
return IssueThread.objects.filter(id=ticket_id).select_related('event').first()
|
||||
|
||||
async def run_forever(self):
|
||||
# Infinite loop to continuously listen for messages
|
||||
print("Listening for messages...")
|
||||
channel_name = await self.channel_layer.new_channel()
|
||||
await self.channel_layer.group_add(self.room_group_name, channel_name)
|
||||
print("Channel name:", channel_name)
|
||||
while True:
|
||||
# Blocking receive to get the message from the channel layer
|
||||
message = await self.channel_layer.receive(channel_name)
|
||||
|
||||
if (message and 'type' in message and message['type'] == 'generic.event' and 'name' in message and
|
||||
message['name'] == 'user_notification'):
|
||||
if 'ticket_id' in message and 'event_id' in message and 'new' in message:
|
||||
ticket = await self.get_ticket(message['ticket_id'])
|
||||
await self.dispatch(ticket, message['event_id'], message['new'])
|
||||
else:
|
||||
print("Error: Invalid message format")
|
||||
|
||||
async def dispatch(self, ticket, event_id, new):
|
||||
message = await render_notification_new_ticket_async(
|
||||
ticket) if new else await render_notification_reply_ticket_async(ticket)
|
||||
title = f"[#{ticket.short_uuid()}] {ticket.name}"
|
||||
print("Dispatching message:", message, "with event_id:", event_id)
|
||||
targets = await self.get_notification_targets()
|
||||
jobs = []
|
||||
jobs.append(telegram_notify(message, TELEGRAM_GROUP_CHAT_ID))
|
||||
for target in targets:
|
||||
if target.channel_type == 'telegram':
|
||||
print("Sending telegram notification to:", target.channel_target)
|
||||
jobs.append(telegram_notify(message, target.channel_target))
|
||||
elif target.channel_type == 'email':
|
||||
print("Sending email notification to:", target.channel_target)
|
||||
jobs.append(email_notify(message, title, target.channel_target))
|
||||
else:
|
||||
print("Unknown channel type:", target.channel_type)
|
||||
await asyncio.gather(*jobs)
|
51
core/notifications/migrations/0001_initial.py
Normal file
51
core/notifications/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
# Generated by Django 4.2.7 on 2024-05-03 21:02
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
from notifications.defaults import auto_reply_body, new_issue_notification, reply_issue_notification
|
||||
from notifications.models import MessageTemplate
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
def create_required_templates(apps, schema_editor):
|
||||
MessageTemplate.objects.create(name='auto_reply', message=auto_reply_body, marked_required=True)
|
||||
MessageTemplate.objects.create(name='new_issue_notification', message=new_issue_notification,
|
||||
marked_required=True)
|
||||
MessageTemplate.objects.create(name='reply_issue_notification', message=reply_issue_notification,
|
||||
marked_required=True)
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MessageTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('message', models.TextField()),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('marked_confidential', models.BooleanField(default=False)),
|
||||
('marked_required', models.BooleanField(default=False)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserNotificationChannel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('channel_type',
|
||||
models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)),
|
||||
('channel_target', models.CharField(max_length=255)),
|
||||
('event_filter', models.CharField(max_length=255)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(create_required_templates),
|
||||
]
|
0
core/notifications/migrations/__init__.py
Normal file
0
core/notifications/migrations/__init__.py
Normal file
29
core/notifications/models.py
Normal file
29
core/notifications/models.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
from django.db import models
|
||||
|
||||
from authentication.models import ExtendedUser
|
||||
|
||||
|
||||
class MessageTemplate(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
marked_confidential = models.BooleanField(default=False)
|
||||
marked_required = models.BooleanField(default=False) # may not be deleted
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class UserNotificationChannel(models.Model):
|
||||
user = models.ForeignKey(ExtendedUser, models.CASCADE)
|
||||
channel_type = models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)
|
||||
channel_target = models.CharField(max_length=255)
|
||||
event_filter = models.CharField(max_length=255)
|
||||
active = models.BooleanField(default=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def validate_constraints(self, exclude=None): # TODO: email -> emailaddress, telegram -> chatid
|
||||
return True
|
||||
|
||||
def __str__(self):
|
||||
return self.user.username + '(' + self.channel_type + ')'
|
69
core/notifications/templates.py
Normal file
69
core/notifications/templates.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
import jinja2
|
||||
from channels.db import database_sync_to_async
|
||||
from core.settings import PRIMARY_HOST
|
||||
|
||||
from notifications.models import MessageTemplate
|
||||
|
||||
TEMPLATE_VARS = ['ticket_name', 'ticket_uuid', 'ticket_id', 'ticket_url',
|
||||
'current_state', 'previous_state', 'current_state_pretty', 'previous_state_pretty',
|
||||
'event_slug', 'event_name',
|
||||
'username', 'user_nick',
|
||||
'web_host'] # TODO customer_name, tracking_code
|
||||
|
||||
|
||||
def limit_length(s, length=50):
|
||||
if len(s) > length:
|
||||
return s[:(length - 3)] + "..."
|
||||
return s
|
||||
|
||||
|
||||
def ticket_url(ticket):
|
||||
eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded
|
||||
return f"https://{PRIMARY_HOST}/{eventslug}/ticket/{ticket.id}/"
|
||||
|
||||
|
||||
def render_template(template, **kwargs):
|
||||
try:
|
||||
environment = jinja2.Environment()
|
||||
environment.filters['limit_length'] = limit_length
|
||||
tmpl = MessageTemplate.objects.get(name=template)
|
||||
template = environment.from_string(tmpl.message)
|
||||
return template.render(**kwargs, web_host=PRIMARY_HOST)
|
||||
except MessageTemplate.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def get_ticket_vars(ticket):
|
||||
states = list(ticket.state_changes.order_by('-timestamp'))
|
||||
return {
|
||||
'ticket_name': ticket.name,
|
||||
'ticket_uuid': ticket.short_uuid(),
|
||||
'ticket_id': ticket.id,
|
||||
'ticket_url': ticket_url(ticket),
|
||||
'current_state': states[0].state if states else 'none',
|
||||
'previous_state': states[1].state if len(states) > 1 else 'none',
|
||||
'current_state_pretty': states[0].get_state_display() if states else 'none',
|
||||
'previous_state_pretty': states[1].get_state_display() if len(states) > 1 else 'none',
|
||||
'event_slug': ticket.event.slug if ticket.event else "37C3", # TODO 37C3 should not be hardcoded
|
||||
'event_name': ticket.event.name if ticket.event else "37C3",
|
||||
}
|
||||
|
||||
|
||||
def render_auto_reply(ticket):
|
||||
return render_template('auto_reply', **get_ticket_vars(ticket))
|
||||
|
||||
|
||||
def render_notification_new_ticket(ticket):
|
||||
return render_template('new_issue_notification', **get_ticket_vars(ticket))
|
||||
|
||||
|
||||
def render_notification_reply_ticket(ticket):
|
||||
return render_template('reply_issue_notification', **get_ticket_vars(ticket))
|
||||
|
||||
|
||||
async def render_notification_new_ticket_async(ticket):
|
||||
return await database_sync_to_async(render_notification_new_ticket)(ticket)
|
||||
|
||||
|
||||
async def render_notification_reply_ticket_async(ticket):
|
||||
return await database_sync_to_async(render_notification_reply_ticket)(ticket)
|
0
core/notifications/tests/__init__.py
Normal file
0
core/notifications/tests/__init__.py
Normal file
|
@ -1,3 +1,6 @@
|
|||
aiodns==3.2.0
|
||||
aiohttp==3.9.5
|
||||
aiosignal==1.3.1
|
||||
aiosmtpd==1.4.4.post2
|
||||
aiosmtplib==3.0.1
|
||||
anyio==4.1.0
|
||||
|
@ -28,6 +31,7 @@ django-rest-knox==4.2.0
|
|||
django-soft-delete==0.9.21
|
||||
djangorestframework==3.14.0
|
||||
drf-yasg==1.21.7
|
||||
frozenlist==1.4.1
|
||||
h11==0.14.0
|
||||
hyperlink==21.0.0
|
||||
idna==3.4
|
||||
|
@ -38,11 +42,13 @@ Jinja2==3.1.2
|
|||
MarkupSafe==2.1.3
|
||||
msgpack==1.0.7
|
||||
msgpack-python==0.5.6
|
||||
multidict==6.0.5
|
||||
openapi-codec==1.3.2
|
||||
packaging==23.2
|
||||
Pillow==10.1.0
|
||||
pyasn1==0.5.1
|
||||
pyasn1-modules==0.3.0
|
||||
pycares==4.4.0
|
||||
pycparser==2.21
|
||||
pyOpenSSL==23.3.0
|
||||
python-dotenv==1.0.0
|
||||
|
@ -65,4 +71,5 @@ urllib3==2.1.0
|
|||
uvicorn==0.24.0.post1
|
||||
watchfiles==0.21.0
|
||||
websockets==12.0
|
||||
yarl==1.9.4
|
||||
zope.interface==6.1
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
aiodns==3.2.0
|
||||
aiohttp==3.9.5
|
||||
aiosignal==1.3.1
|
||||
aiosmtpd==1.4.4.post2
|
||||
aiosmtplib==3.0.1
|
||||
asgiref==3.7.2
|
||||
|
|
7
core/server.py
Normal file → Executable file
7
core/server.py
Normal file → Executable file
|
@ -12,6 +12,7 @@ django.setup()
|
|||
from helper import init_loop
|
||||
from mail.protocol import LMTPHandler
|
||||
from mail.socket import UnixSocketLMTPController
|
||||
from notifications.dispatch import NotificationDispatcher
|
||||
|
||||
|
||||
class UvicornServer(uvicorn.Server):
|
||||
|
@ -54,6 +55,11 @@ async def lmtp(loop):
|
|||
log.info("LMTP done")
|
||||
|
||||
|
||||
async def notifications(loop):
|
||||
dispatcher = NotificationDispatcher()
|
||||
await dispatcher.run_forever()
|
||||
|
||||
|
||||
def main():
|
||||
import sdnotify
|
||||
import setproctitle
|
||||
|
@ -67,6 +73,7 @@ def main():
|
|||
loop.create_task(web(loop))
|
||||
# loop.create_task(tcp(loop))
|
||||
loop.create_task(lmtp(loop))
|
||||
loop.create_task(notifications(loop))
|
||||
n = sdnotify.SystemdNotifier()
|
||||
n.notify("READY=1")
|
||||
log.info("Server ready")
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from tickets.models import IssueThread, Comment, StateChange
|
||||
from tickets.models import IssueThread, Comment, StateChange, Assignment, ItemRelation, ShippingVoucher
|
||||
|
||||
|
||||
class IssueThreadAdmin(admin.ModelAdmin):
|
||||
|
@ -15,6 +15,21 @@ class StateChangeAdmin(admin.ModelAdmin):
|
|||
pass
|
||||
|
||||
|
||||
class AssignmentAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class ItemRelationAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class ShippingVouchersAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
admin.site.register(IssueThread, IssueThreadAdmin)
|
||||
admin.site.register(Comment, CommentAdmin)
|
||||
admin.site.register(StateChange, StateChangeAdmin)
|
||||
admin.site.register(Assignment, AssignmentAdmin)
|
||||
admin.site.register(ItemRelation, ItemRelationAdmin)
|
||||
admin.site.register(ShippingVoucher, ShippingVouchersAdmin)
|
||||
|
|
|
@ -13,8 +13,8 @@ from core.settings import MAIL_DOMAIN
|
|||
from mail.models import Email
|
||||
from mail.protocol import send_smtp, make_reply, collect_references
|
||||
from notify_sessions.models import SystemEvent
|
||||
from tickets.models import IssueThread, Comment, STATE_CHOICES
|
||||
from tickets.serializers import IssueSerializer, CommentSerializer
|
||||
from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher
|
||||
from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer
|
||||
|
||||
|
||||
class IssueViewSet(viewsets.ModelViewSet):
|
||||
|
@ -27,6 +27,11 @@ class CommentViewSet(viewsets.ModelViewSet):
|
|||
queryset = Comment.objects.all()
|
||||
|
||||
|
||||
class ShippingVoucherViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ShippingVoucherSerializer
|
||||
queryset = ShippingVoucher.objects.all()
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@permission_required('tickets.add_issuethread', raise_exception=True)
|
||||
|
@ -47,8 +52,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)
|
||||
|
||||
|
@ -119,7 +123,8 @@ def add_comment(request, pk):
|
|||
|
||||
router = routers.SimpleRouter()
|
||||
router.register(r'tickets', IssueViewSet, basename='issues')
|
||||
router.register(r'comments', CommentViewSet, basename='comments')
|
||||
#router.register(r'comments', CommentViewSet, basename='comments')
|
||||
router.register(r'shipping_vouchers', ShippingVoucherViewSet, basename='shipping_vouchers')
|
||||
|
||||
urlpatterns = ([
|
||||
re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),
|
||||
|
|
|
@ -2,17 +2,15 @@
|
|||
|
||||
from django.db import migrations, models
|
||||
|
||||
from tickets.models import IssueThread
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0005_remove_issuethread_last_activity'),
|
||||
]
|
||||
|
||||
def set_uuid(apps, schema_editor):
|
||||
import uuid
|
||||
IssueThread = apps.get_model('tickets', 'IssueThread')
|
||||
for issue_thread in IssueThread.objects.all():
|
||||
issue_thread.uuid = str(uuid.uuid4())
|
||||
issue_thread.save()
|
||||
|
|
20
core/tickets/migrations/0009_issuethread_event.py
Normal file
20
core/tickets/migrations/0009_issuethread_event.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 4.2.7 on 2024-05-03 21:21
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0004_alter_event_created_at_alter_item_created_at'),
|
||||
('tickets', '0008_alter_issuethread_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issuethread',
|
||||
name='event',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_threads', to='inventory.event'),
|
||||
),
|
||||
]
|
25
core/tickets/migrations/0010_itemrelation.py
Normal file
25
core/tickets/migrations/0010_itemrelation.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 4.2.7 on 2024-05-22 18:19
|
||||
|
||||
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', '0009_issuethread_event'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ItemRelation',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('status', models.CharField(choices=[('possible', 'Possible'), ('confirmed', 'Confirmed'), ('discarded', 'Discarded'), ('provided', 'Provided')], default='possible', max_length=255)),
|
||||
('issue_thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_relations', to='tickets.issuethread')),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issues', to='inventory.item')),
|
||||
],
|
||||
),
|
||||
]
|
19
core/tickets/migrations/0011_issuethread_related_items.py
Normal file
19
core/tickets/migrations/0011_issuethread_related_items.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 4.2.7 on 2024-06-14 17:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0004_alter_event_created_at_alter_item_created_at'),
|
||||
('tickets', '0010_itemrelation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issuethread',
|
||||
name='related_items',
|
||||
field=models.ManyToManyField(through='tickets.ItemRelation', to='inventory.item'),
|
||||
),
|
||||
]
|
25
core/tickets/migrations/0012_shippingvoucher.py
Normal file
25
core/tickets/migrations/0012_shippingvoucher.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 4.2.7 on 2024-06-18 14:54
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0011_issuethread_related_items'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ShippingVoucher',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('voucher', models.CharField(max_length=255)),
|
||||
('type', models.CharField(max_length=255)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('used_at', models.DateTimeField(null=True)),
|
||||
('issue_thread', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shipping_vouchers', to='tickets.issuethread')),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -1,8 +1,9 @@
|
|||
from django.utils import timezone
|
||||
from django.db import models
|
||||
from django_softdelete.models import SoftDeleteModel
|
||||
|
||||
from authentication.models import ExtendedUser
|
||||
from inventory.models import Event
|
||||
from inventory.models import Event, Item
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
|
@ -28,12 +29,21 @@ STATE_CHOICES = (
|
|||
('found_closed', 'Item Found and stored externally and closed'),
|
||||
)
|
||||
|
||||
RELATION_STATUS_CHOICES = (
|
||||
('possible', 'Possible'),
|
||||
('confirmed', 'Confirmed'),
|
||||
('discarded', 'Discarded'),
|
||||
('provided', 'Provided'),
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
related_items = models.ManyToManyField(Item, through='ItemRelation')
|
||||
|
||||
def short_uuid(self):
|
||||
return self.uuid[:8]
|
||||
|
@ -64,6 +74,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 +104,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 +114,45 @@ 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
|
||||
|
||||
|
||||
class ItemRelation(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='item_relations')
|
||||
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='issues')
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
status = models.CharField(max_length=255, choices=RELATION_STATUS_CHOICES, default='possible')
|
||||
|
||||
def __str__(self):
|
||||
return str(self.issue_thread) + ' related to ' + str(self.item)
|
||||
|
||||
|
||||
class ShippingVoucher(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='shipping_vouchers', null=True)
|
||||
voucher = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=255)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
used_at = models.DateTimeField(null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.voucher + ' (' + self.type + ')'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.used_at is None and self.issue_thread is not None:
|
||||
self.used_at = timezone.now()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
|
|
@ -2,7 +2,8 @@ from rest_framework import serializers
|
|||
|
||||
from authentication.models import ExtendedUser
|
||||
from mail.api_v2 import AttachmentSerializer
|
||||
from tickets.models import IssueThread, Comment, STATE_CHOICES
|
||||
from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher
|
||||
from inventory.serializers import ItemSerializer
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
|
@ -28,23 +29,31 @@ class StateSerializer(serializers.Serializer):
|
|||
return obj['value']
|
||||
|
||||
|
||||
class ShippingVoucherSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ShippingVoucher
|
||||
fields = ('id', 'voucher', 'type', 'timestamp', 'issue_thread', 'used_at')
|
||||
read_only_fields = ('id', 'timestamp', 'used_at')
|
||||
|
||||
|
||||
class IssueSerializer(serializers.ModelSerializer):
|
||||
timeline = serializers.SerializerMethodField()
|
||||
last_activity = serializers.SerializerMethodField()
|
||||
assigned_to = serializers.SlugRelatedField(slug_field='username', queryset=ExtendedUser.objects.all(),
|
||||
allow_null=True, required=False)
|
||||
related_items = ItemSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueThread
|
||||
fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid')
|
||||
read_only_fields = ('id', 'timeline', 'last_activity', 'uuid')
|
||||
fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items')
|
||||
read_only_fields = ('id', 'timeline', 'last_activity', 'uuid', 'related_items')
|
||||
|
||||
def to_internal_value(self, data):
|
||||
ret = super().to_internal_value(data)
|
||||
if 'state' in data:
|
||||
ret['state'] = data['state']
|
||||
# if 'assigned_to' in data:
|
||||
# ret['assigned_to'] = data['assigned_to']
|
||||
# if 'assigned_to' in data:
|
||||
# ret['assigned_to'] = data['assigned_to']
|
||||
return ret
|
||||
|
||||
def validate(self, attrs):
|
||||
|
@ -60,7 +69,12 @@ class IssueSerializer(serializers.ModelSerializer):
|
|||
if self.state_changes.count() > 0 else None
|
||||
last_comment = self.comments.order_by('-timestamp').first().timestamp if self.comments.count() > 0 else None
|
||||
last_mail = self.emails.order_by('-timestamp').first().timestamp if self.emails.count() > 0 else None
|
||||
args = [x for x in [last_state_change, last_comment, last_mail] if x is not None]
|
||||
last_assignment = self.assignments.order_by('-timestamp').first().timestamp if \
|
||||
self.assignments.count() > 0 else None
|
||||
last_relation = self.item_relations.order_by('-timestamp').first().timestamp if \
|
||||
self.item_relations.count() > 0 else None
|
||||
args = [x for x in [last_state_change, last_comment, last_mail, last_assignment, last_relation] if
|
||||
x is not None]
|
||||
return max(args)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
@ -100,6 +114,22 @@ class IssueSerializer(serializers.ModelSerializer):
|
|||
'timestamp': assignment.timestamp,
|
||||
'assigned_to': assignment.assigned_to.username,
|
||||
})
|
||||
for relation in obj.item_relations.all():
|
||||
timeline.append({
|
||||
'type': 'item_relation',
|
||||
'id': relation.id,
|
||||
'status': relation.status,
|
||||
'timestamp': relation.timestamp,
|
||||
'item': ItemSerializer(relation.item).data,
|
||||
})
|
||||
for shipping_voucher in obj.shipping_vouchers.all():
|
||||
timeline.append({
|
||||
'type': 'shipping_voucher',
|
||||
'id': shipping_voucher.id,
|
||||
'timestamp': shipping_voucher.used_at,
|
||||
'voucher': shipping_voucher.voucher,
|
||||
'voucher_type': shipping_voucher.type,
|
||||
})
|
||||
return sorted(timeline, key=lambda x: x['timestamp'])
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
41
core/tickets/tests/v2/test_shipping_vouchers.py
Normal file
41
core/tickets/tests/v2/test_shipping_vouchers.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
from django.test import TestCase, Client
|
||||
|
||||
from authentication.models import ExtendedUser
|
||||
from mail.models import Email, EmailAttachment
|
||||
from tickets.models import IssueThread, StateChange, Comment, ShippingVoucher
|
||||
from django.contrib.auth.models import Permission
|
||||
from knox.models import AuthToken
|
||||
|
||||
|
||||
class ShippingVoucherApiTest(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.token = AuthToken.objects.create(user=self.user)
|
||||
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
|
||||
|
||||
def test_issues_empty(self):
|
||||
response = self.client.get('/api/2/shipping_vouchers/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), [])
|
||||
|
||||
def test_issues_list(self):
|
||||
ShippingVoucher.objects.create(voucher='1234', type='2kg-eu')
|
||||
response = self.client.get('/api/2/shipping_vouchers/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()[0]['voucher'], '1234')
|
||||
self.assertEqual(response.json()[0]['used_at'], None)
|
||||
self.assertEqual(response.json()[0]['issue_thread'], None)
|
||||
self.assertEqual(response.json()[0]['type'], '2kg-eu')
|
||||
|
||||
def test_issues_create(self):
|
||||
response = self.client.post('/api/2/shipping_vouchers/', {'voucher': '1234', 'type': '2kg-eu'})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.json()['voucher'], '1234')
|
||||
self.assertEqual(response.json()['used_at'], None)
|
||||
self.assertEqual(response.json()['issue_thread'], None)
|
|
@ -247,15 +247,15 @@ class IssueApiTest(TestCase):
|
|||
self.assertEqual(timeline[1]['subject'], 'test issue')
|
||||
self.assertEqual(timeline[1]['body'], 'test')
|
||||
|
||||
def test_post_comment(self):
|
||||
issue = IssueThread.objects.create(
|
||||
name="test issue",
|
||||
)
|
||||
response = self.client.post('/api/2/comments/', {'comment': 'test', 'issue_thread': issue.id})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.json()['comment'], 'test')
|
||||
self.assertEqual(response.json()['issue_thread'], issue.id)
|
||||
self.assertEqual(response.json()['timestamp'], response.json()['timestamp'])
|
||||
#def test_post_comment(self):
|
||||
# issue = IssueThread.objects.create(
|
||||
# name="test issue",
|
||||
# )
|
||||
# response = self.client.post('/api/2/comments/', {'comment': 'test', 'issue_thread': issue.id})
|
||||
# self.assertEqual(response.status_code, 201)
|
||||
# self.assertEqual(response.json()['comment'], 'test')
|
||||
# self.assertEqual(response.json()['issue_thread'], issue.id)
|
||||
# self.assertEqual(response.json()['timestamp'], response.json()['timestamp'])
|
||||
|
||||
def test_post_comment_altenative(self):
|
||||
issue = IssueThread.objects.create(
|
||||
|
|
|
@ -11,6 +11,11 @@
|
|||
name: postfix
|
||||
state: restarted
|
||||
|
||||
- name: restart rspamd
|
||||
service:
|
||||
name: rspamd
|
||||
state: restarted
|
||||
|
||||
- name: restart mariadb
|
||||
service:
|
||||
name: mariadb
|
||||
|
@ -51,7 +56,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:
|
||||
|
@ -82,6 +87,7 @@
|
|||
- default-libmysqlclient-dev
|
||||
- build-essential
|
||||
- postfix
|
||||
- rspamd
|
||||
- git
|
||||
- pkg-config
|
||||
- npm
|
||||
|
@ -339,6 +345,16 @@
|
|||
notify:
|
||||
- restart postfix
|
||||
|
||||
- name: configure rspamd
|
||||
copy:
|
||||
content: |
|
||||
write_servers = "localhost";
|
||||
read_servers = "localhost";
|
||||
dest: /etc/rspamd/local.d/redis.conf
|
||||
notify:
|
||||
- restart rspamd
|
||||
|
||||
|
||||
- name: UFW allow smtp
|
||||
ufw:
|
||||
rule: allow
|
||||
|
|
|
@ -11,6 +11,8 @@ Restart=always
|
|||
RestartSec=5
|
||||
User=www-data
|
||||
Group=www-data
|
||||
StandardOutput=append:/var/www/c3lf-sys3/service.info.log
|
||||
StandardError=append:/var/www/c3lf-sys3/service.error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -9,3 +9,5 @@ LEGACY_API_USER={{ legacy_api_user }}
|
|||
LEGACY_API_PASSWORD={{ legacy_api_password }}
|
||||
MEDIA_ROOT=/var/www/c3lf-sys3/userfiles
|
||||
STATIC_ROOT=/var/www/c3lf-sys3/staticfiles
|
||||
TELEGRAM_GROUP_CHAT_ID={{ telegram_group_chat_id }}
|
||||
TELEGRAM_BOT_TOKEN={{ telegram_bot_token }}
|
|
@ -48,3 +48,6 @@ maillog_file = /var/log/mail.log
|
|||
|
||||
virtual_mailbox_domains = {{ mail_domain }}
|
||||
virtual_transport=c3lf-sys3:unix:/var/www/c3lf-sys3/lmtp.sock
|
||||
|
||||
smtpd_milters = inet:localhost:11332
|
||||
milter_default_action = accept
|
27
deploy/nginx-ws-proxy.conf
Normal file
27
deploy/nginx-ws-proxy.conf
Normal file
|
@ -0,0 +1,27 @@
|
|||
http {
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
upstream websocket {
|
||||
server staging.c3lf.de:443;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8082;
|
||||
access_log /home/jedi/Projects/c3lf-system-3/deploy/foo.log;
|
||||
location / {
|
||||
proxy_pass https://websocket;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Origin "https://staging.c3lf.de/";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events {}
|
||||
|
||||
pid /home/jedi/Projects/c3lf-system-3/deploy/nginx.pid;
|
11286
web/package-lock.json
generated
Normal file
11286
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,43 +1,40 @@
|
|||
{
|
||||
"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",
|
||||
"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",
|
||||
"yarn": "^1.22.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^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,
|
||||
|
|
|
@ -5,40 +5,30 @@
|
|||
<AddBoxModal v-if="showAddBoxModal && isLoggedIn" @close="closeAddBoxModal()" isModal="true"/>
|
||||
<Navbar v-if="isLoggedIn" @addItemClicked="openAddItemModal()" @addTicketClicked="openAddTicketModal()"/>
|
||||
<router-view/>
|
||||
<div aria-live="polite" aria-atomic="true" v-if="isLoggedIn"
|
||||
class="d-flex justify-content-end align-items-start fixed-top mx-1 my-5 py-3"
|
||||
style="min-height: 200px; z-index: 100000; pointer-events: none">
|
||||
<Toast v-for="(toast , index) in toasts" :key="index" :title="toast.title" :message="toast.message"
|
||||
:color="toast.color"
|
||||
@close="removeToast(toast.key)" style="pointer-events: auto"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Navbar from '@/components/Navbar';
|
||||
import AddItemModal from '@/components/AddItemModal';
|
||||
import Toast from './components/Toast';
|
||||
import {mapState, mapMutations, mapActions, mapGetters} from 'vuex';
|
||||
import AddTicketModal from "@/components/AddTicketModal.vue";
|
||||
import AddBoxModal from "@/components/AddBoxModal.vue";
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: {AddBoxModal, Toast, Navbar, AddItemModal, AddTicketModal},
|
||||
components: {AddBoxModal, Navbar, AddItemModal, AddTicketModal},
|
||||
computed: {
|
||||
...mapState(['loadedItems', 'layout', 'toasts', 'showAddBoxModal']),
|
||||
...mapGetters(['isLoggedIn']),
|
||||
},
|
||||
data: () => ({
|
||||
addItemModalOpen: false,
|
||||
addTicketModalOpen: false,
|
||||
notify_socket: null,
|
||||
socket_toast: null,
|
||||
addTicketModalOpen: false
|
||||
}),
|
||||
methods: {
|
||||
...mapMutations(['removeToast', 'createToast', 'closeAddBoxModal', 'openAddBoxModal']),
|
||||
...mapActions(['loadEventItems', 'loadTickets']),
|
||||
...mapActions(['loadEvents', 'scheduleAfterInit']),
|
||||
openAddItemModal() {
|
||||
this.addItemModalOpen = true;
|
||||
},
|
||||
|
@ -50,72 +40,11 @@ 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;
|
||||
this.scheduleAfterInit(() => [this.loadEvents()]);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
<script>
|
||||
import Modal from '@/components/Modal';
|
||||
import EditItem from '@/components/EditItem';
|
||||
import {mapActions, mapState} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'AddItemModal',
|
||||
|
@ -23,15 +24,22 @@ export default {
|
|||
data: () => ({
|
||||
item: {}
|
||||
}),
|
||||
created() {
|
||||
this.item = {box: this.$store.state.lastUsed.box || '', cid: this.$store.state.lastUsed.cid || ''};
|
||||
computed: {
|
||||
...mapState(['lastUsed'])
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['postItem', 'loadBoxes', 'scheduleAfterInit']),
|
||||
saveNewItem() {
|
||||
this.$store.dispatch('postItem', this.item).then(() => {
|
||||
this.postItem(this.item).then(() => {
|
||||
this.$emit('close');
|
||||
});
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.item = {box: this.lastUsed.box || '', cid: this.lastUsed.cid || ''};
|
||||
},
|
||||
mounted() {
|
||||
this.scheduleAfterInit(() => [this.loadBoxes()]);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions} from 'vuex';
|
||||
import Modal from '@/components/Modal';
|
||||
import EditItem from '@/components/EditItem';
|
||||
|
||||
|
@ -32,11 +33,12 @@ export default {
|
|||
}
|
||||
}),
|
||||
created() {
|
||||
this.ticket = {box: this.$store.state.lastUsed.box || '', cid: this.$store.state.lastUsed.cid || ''};
|
||||
this.ticket = {};
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['postManualTicket']),
|
||||
saveNewTicket() {
|
||||
this.$store.dispatch('postManualTicket', this.ticket).then(() => {
|
||||
this.postManualTicket(this.ticket).then(() => {
|
||||
this.$emit('close');
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -48,6 +48,8 @@
|
|||
|
||||
<script>
|
||||
|
||||
import {mapGetters} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'CollapsableCards',
|
||||
props: {
|
||||
|
@ -75,7 +77,7 @@ export default {
|
|||
};
|
||||
},
|
||||
created() {
|
||||
const query = this.$router.currentRoute.query.collapsed;
|
||||
const query = this.$router.currentRoute ? (this.$router.currentRoute.query ? this.$router.currentRoute.query.collapsed : null) : null;
|
||||
if (query !== null && query !== undefined) {
|
||||
this.collapsed = this.unpackInt(parseInt(query), this.sections.length);
|
||||
} else {
|
||||
|
@ -93,7 +95,7 @@ export default {
|
|||
grouped_items() {
|
||||
return this.sections.map(section => this.items.filter(item => item[this.keyName] === section.slug));
|
||||
},
|
||||
|
||||
...mapGetters(['route']),
|
||||
},
|
||||
methods: {
|
||||
packInt(arr) {
|
||||
|
@ -112,8 +114,11 @@ export default {
|
|||
collapsed: {
|
||||
handler() {
|
||||
const encoded = this.packInt(this.collapsed).toString()
|
||||
if (this.$router.currentRoute.query.collapsed !== encoded)
|
||||
this.$router.push({...this.$router.currentRoute, query: {...this.$router.currentRoute.query, collapsed: encoded}});
|
||||
if (this.route.query.collapsed !== encoded)
|
||||
this.$router.push({
|
||||
...this.$router.currentRoute,
|
||||
query: {...this.$router.currentRoute.query, collapsed: encoded}
|
||||
});
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
|
|
|
@ -119,13 +119,13 @@ export default {
|
|||
emits: ['addItemClicked', 'addTicketClicked'],
|
||||
computed: {
|
||||
...mapState(['events']),
|
||||
...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions", "layout"]),
|
||||
...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions", "layout", "route"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['changeEvent', 'changeView', 'searchEventItems']),
|
||||
...mapMutations(['logout']),
|
||||
navigateTo(link) {
|
||||
if (this.$router.currentRoute.path !== link)
|
||||
if (this.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.route.query.layout === layout)
|
||||
return;
|
||||
this.$router.push({...this.$router.currentRoute, query: {...this.$router.currentRoute.query, layout}});
|
||||
this.$router.push({...this.route, query: {...this.route.query, layout}});
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
105
web/src/components/SlotTable.vue
Normal file
105
web/src/components/SlotTable.vue
Normal file
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<table class="table table-striped table-dark">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" v-for="(column, index) in columns" :key="index"
|
||||
v-if="columnHasData[index]||columnHasSlot[index]">
|
||||
<div class="input-group" v-if="columnHasData[index]">
|
||||
<div class="input-group-prepend">
|
||||
<button
|
||||
:class="[ 'btn', column === sortBy ? 'btn-outline-info' : 'btn-outline-secondary' ]"
|
||||
@click="toggleSort(column)"
|
||||
>
|
||||
{{ column }}
|
||||
<span :class="{ 'text-info': column === sortBy }">
|
||||
<font-awesome-icon :icon="getSortIcon(column)"/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="filter"
|
||||
:value="filters[column]"
|
||||
@input="changeFilter(column, $event.target.value)"
|
||||
>
|
||||
</div>
|
||||
<span v-else-if="columnHasSlot[index]">
|
||||
{{ column }}
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<slot name="header_actions"/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in internalItems" :key="item[keyName]" @click="$emit('itemActivated', item)">
|
||||
<td v-for="(column, index) in columns" :key="index" v-if="columnHasSlot[index]||columnHasData[index]">
|
||||
<slot v-if="columnHasSlot[index]" :name="column" :item="item"/>
|
||||
<span v-else-if="columnHasData[index]">
|
||||
{{ item[column] }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ column }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<slot v-bind:item="item" name="actions"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DataContainer from '@/mixins/data-container';
|
||||
import router from '../router';
|
||||
|
||||
export default {
|
||||
name: 'SlotTable',
|
||||
mixins: [DataContainer],
|
||||
data() {
|
||||
return {
|
||||
columnHasSlot: [],
|
||||
columnHasData: []
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.columns.map(e => ({
|
||||
k: e,
|
||||
v: this.$store.getters.getFilters[e]
|
||||
})).filter(e => e.v).forEach(e => this.setFilter(e.k, e.v));
|
||||
|
||||
},
|
||||
mounted() {
|
||||
this.columnHasSlot = this.columns.map(e => Object.keys(this.$slots).includes(e));
|
||||
this.columnHasData = this.columns.map(e => this.items.reduce((a, b) => a || b[e] !== undefined, false));
|
||||
//console.log(this.columnHasData, this.columnHasSlot, this.columns, Object.keys(this.$slots), this.$slots);
|
||||
for (let slot in this.$slots) {
|
||||
console.log(`Slot: ${slot}`);
|
||||
console.log(`Data: ${this.$slots[slot]}`);
|
||||
}
|
||||
},
|
||||
beforeUpdate() {
|
||||
this.columnHasSlot = this.columns.map(e => Object.keys(this.$slots).includes(e));
|
||||
this.columnHasData = this.columns.map(e => this.items.reduce((a, b) => a || b[e] !== undefined, false));
|
||||
},
|
||||
methods: {
|
||||
changeFilter(col, val) {
|
||||
this.setFilter(col, val);
|
||||
let newquery = Object.entries({
|
||||
...this.$store.getters.getFilters,
|
||||
[col]: val
|
||||
}).reduce((a, [k, v]) => (v ? {...a, [k]: v} : a), {});
|
||||
router.push({query: newquery});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.table-body-move {
|
||||
transition: transform 1s;
|
||||
}
|
||||
</style>
|
|
@ -8,12 +8,19 @@
|
|||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'comment'">
|
||||
<font-awesome-icon icon="comment"/>
|
||||
</span>
|
||||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'state'" :class="'bg-' + stateInfo(item.state).color">
|
||||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'state'"
|
||||
:class="'bg-' + stateInfo(item.state).color">
|
||||
<font-awesome-icon :icon="stateInfo(item.state).icon"/>
|
||||
</span>
|
||||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'assignment'" :class="'bg-secondary'">
|
||||
<font-awesome-icon icon="user"/>
|
||||
</span>
|
||||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'item_relation'">
|
||||
<font-awesome-icon icon="object-group"/>
|
||||
</span>
|
||||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'shipping_voucher'">
|
||||
<font-awesome-icon icon="truck"/>
|
||||
</span>
|
||||
<span class="timeline-item-icon faded-icon" v-else>
|
||||
<font-awesome-icon icon="pen"/>
|
||||
</span>
|
||||
|
@ -21,6 +28,8 @@
|
|||
<TimelineComment v-else-if="item.type === 'comment'" :item="item"/>
|
||||
<TimelineStateChange v-else-if="item.type === 'state'" :item="item"/>
|
||||
<TimelineAssignment v-else-if="item.type === 'assignment'" :item="item"/>
|
||||
<TimelineRelatedItem v-else-if="item.type === 'item_relation'" :item="item"/>
|
||||
<TimelineShippingVoucher v-else-if="item.type === 'shipping_voucher'" :item="item"/>
|
||||
<p v-else>{{ item }}</p>
|
||||
</li>
|
||||
<li class="timeline-item">
|
||||
|
@ -66,10 +75,15 @@ import TimelineComment from "@/components/TimelineComment.vue";
|
|||
import TimelineStateChange from "@/components/TimelineStateChange.vue";
|
||||
import {mapGetters} from "vuex";
|
||||
import TimelineAssignment from "@/components/TimelineAssignment.vue";
|
||||
import TimelineRelatedItem from "@/components/TimelineRelatedItem.vue";
|
||||
import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue";
|
||||
|
||||
export default {
|
||||
name: 'Timeline',
|
||||
components: {TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail},
|
||||
components: {
|
||||
TimelineShippingVoucher,
|
||||
TimelineRelatedItem, TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail
|
||||
},
|
||||
props: {
|
||||
timeline: {
|
||||
type: Array,
|
||||
|
|
|
@ -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>
|
||||
|
|
252
web/src/components/TimelineRelatedItem.vue
Normal file
252
web/src/components/TimelineRelatedItem.vue
Normal file
|
@ -0,0 +1,252 @@
|
|||
<template>
|
||||
<div class="timeline-item-wrapper">
|
||||
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="closeLightboxModal()"/>
|
||||
<div class="timeline-item-description">
|
||||
<i class="avatar | small">
|
||||
<font-awesome-icon icon="user"/>
|
||||
</i>
|
||||
<span><!--a href="#">$USER</a--> linked item <span class="badge badge-secondary">#{{ item.item.uid }} </span> on <time
|
||||
:datetime="timestamp">{{ timestamp }}</time> as <span class="badge badge-primary">{{ item.status }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card bg-dark">
|
||||
<div class="row">
|
||||
<div class="col" style="min-width: 4em;">
|
||||
<AuthenticatedImage v-if="item.item.file" cached
|
||||
:src="`/media/2/256/${item.item.file}/`"
|
||||
class="d-block w-100 card-img-left"
|
||||
@click="openLightboxModalWith(item.item)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-secondary">uid: {{ item.item.uid }} box: {{ item.item.box }}</h6>
|
||||
<h6 class="card-title">{{ item.item.description }}</h6>
|
||||
<!--div class="row mx-auto mt-2">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-success"
|
||||
@click.stop="confirm('return Item?') && markItemReturned(item.item)"
|
||||
title="returned">
|
||||
<font-awesome-icon icon="check"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @click.stop="openEditingModalWith(item.item)"
|
||||
title="edit">
|
||||
<font-awesome-icon icon="edit"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
@click.stop="confirm('delete Item?') && deleteItem(item.item)"
|
||||
title="delete">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ item }}</p-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--button class="show-replies">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-arrow-forward"
|
||||
width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M15 11l4 4l-4 4m4 -4h-11a4 4 0 0 1 0 -8h1"/>
|
||||
</svg>
|
||||
Show 3 replies
|
||||
<span class="avatar-list">
|
||||
<i class="avatar | small">
|
||||
<font-awesome-icon icon="user"/>
|
||||
</i>
|
||||
<i class="avatar | small">
|
||||
<font-awesome-icon icon="user"/>
|
||||
</i>
|
||||
<i class="avatar | small">
|
||||
<font-awesome-icon icon="user"/>
|
||||
</i>
|
||||
</span>
|
||||
</button-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import AuthenticatedDataLink from "@/components/AuthenticatedDataLink.vue";
|
||||
import Lightbox from "@/components/Lightbox.vue";
|
||||
|
||||
export default {
|
||||
name: 'TimelineRelatedItem',
|
||||
components: {Lightbox, AuthenticatedImage, AuthenticatedDataLink},
|
||||
data() {
|
||||
return {
|
||||
lightboxHash: null,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
'item': {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
'timestamp': function () {
|
||||
return new Date(this.item.timestamp).toLocaleString();
|
||||
},
|
||||
'body': function () {
|
||||
return this.item.body.replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>');
|
||||
}
|
||||
|
||||
},
|
||||
methods: {
|
||||
openLightboxModalWith(attachment) {
|
||||
this.lightboxHash = attachment.hash;
|
||||
},
|
||||
closeLightboxModal() { // Closes the editing modal and discards the edited copy of the item.
|
||||
this.lightboxHash = null;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card-img-left {
|
||||
border-top-left-radius: calc(.25rem - 1px);
|
||||
border-bottom-left-radius: calc(.25rem - 1px);
|
||||
}
|
||||
|
||||
/*img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}*/
|
||||
|
||||
|
||||
.timeline-item-description {
|
||||
display: flex;
|
||||
padding-top: 6px;
|
||||
gap: 8px;
|
||||
color: var(--gray);
|
||||
|
||||
img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
/*color: var(--c-grey-500);*/
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
outline: 0; /* Don't actually do this */
|
||||
color: var(--info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--gray);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
&.small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 0;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
margin-top: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background-color: var(--gray);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 99em;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--info);
|
||||
}
|
||||
|
||||
&.square {
|
||||
border-radius: 50%;
|
||||
color: var(--gray);
|
||||
background-color: var(--dark);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.show-replies {
|
||||
color: var(--gray);
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--info);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > * {
|
||||
position: relative;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
background: var(--dark);
|
||||
margin-right: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
92
web/src/components/TimelineShippingVoucher.vue
Normal file
92
web/src/components/TimelineShippingVoucher.vue
Normal file
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<div class="timeline-item-description">
|
||||
<i class="avatar | small">
|
||||
<font-awesome-icon icon="user"/>
|
||||
</i>
|
||||
<span><a href="#">$USER</a> has claimed shipping voucher
|
||||
<ClipboardButton class="btn btn-primary badge badge-pill" title="Copy shipping voucher to clipboard"
|
||||
:payload="item.voucher">{{ item.voucher }}
|
||||
<font-awesome-icon icon="clipboard"/>
|
||||
</ClipboardButton> of type <span class="badge badge-pill badge-secondary">{{ item.voucher_type }}</span> for this ticket at <time
|
||||
:datetime="timestamp">{{ timestamp }}</time>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {mapState} from "vuex";
|
||||
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
|
||||
|
||||
export default {
|
||||
name: 'TimelineShippingVoucher',
|
||||
components: {ClipboardButton},
|
||||
props: {
|
||||
'item': {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['state_options']),
|
||||
'timestamp': function () {
|
||||
return new Date(this.item.timestamp).toLocaleString();
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
.timeline-item-description {
|
||||
display: flex;
|
||||
padding-top: 6px;
|
||||
gap: 8px;
|
||||
color: var(--gray);
|
||||
|
||||
img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
/*color: var(--c-grey-500);*/
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
outline: 0; /* Don't actually do this */
|
||||
color: var(--info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
&.small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -25,7 +25,15 @@ export default {
|
|||
computed: {
|
||||
...mapState(['state_options']),
|
||||
lookupState: function () {
|
||||
return this.state_options.find(state => state.value === this.item.state);
|
||||
try {
|
||||
if (this.item.state)
|
||||
return this.state_options.find(state => state.value === this.item.state);
|
||||
} catch (e) {
|
||||
}
|
||||
return {
|
||||
text: 'Unknown',
|
||||
value: 'unknown'
|
||||
};
|
||||
},
|
||||
colorLookup: function () {
|
||||
if (this.item.state.startsWith('closed_')) {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toast-body" v-html="message">{{ message }}</div>
|
||||
<!--div class="toast-body" v-html="message">{{ message }}</div-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
112
web/src/components/inputs/FormatedText.vue
Normal file
112
web/src/components/inputs/FormatedText.vue
Normal file
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<div contenteditable @input="onchange" ref="text">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormatedText',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
format: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selection: {start: 0, end: 0, direction: 'forward', type: 'Caret'}
|
||||
};
|
||||
},
|
||||
emits: ['input'],
|
||||
methods: {
|
||||
rawhtml(value) {
|
||||
if (typeof this.format === 'function') {
|
||||
return this.format(value.replace(/ /g, ' '));
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
},
|
||||
onchange(event) {
|
||||
const div = this.$refs.text;
|
||||
const sel = window.getSelection();
|
||||
if (sel.rangeCount > 0) {
|
||||
this.selection.start = this.calculateOffset(div, sel.anchorNode, sel.anchorOffset);
|
||||
this.selection.end = this.calculateOffset(div, sel.focusNode, sel.focusOffset);
|
||||
this.selection.direction = sel.direction;
|
||||
this.selection.type = sel.type;
|
||||
}
|
||||
this.$emit('input', event.target.innerText.replace(/ /g, ' ').replace(/\xA0/g, ' '));
|
||||
},
|
||||
calculateOffset(container, node, offset) {
|
||||
let position = 0;
|
||||
let found = false;
|
||||
const walk = (elem) => {
|
||||
if (elem === node) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
if (elem.nodeType === 3) {
|
||||
position += elem.length;
|
||||
} else {
|
||||
for (let i = 0; i < elem.childNodes.length; i++) {
|
||||
walk(elem.childNodes[i]);
|
||||
if (found) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(container);
|
||||
return position + offset;
|
||||
},
|
||||
findNode(container, offset) {
|
||||
let position = 0;
|
||||
let found = false;
|
||||
let node = null;
|
||||
const walk = (elem) => {
|
||||
if (position + elem.length >= offset) {
|
||||
found = true;
|
||||
node = elem;
|
||||
return;
|
||||
}
|
||||
if (elem.nodeType === 3) {
|
||||
position += elem.length;
|
||||
} else {
|
||||
for (let i = 0; i < elem.childNodes.length; i++) {
|
||||
walk(elem.childNodes[i]);
|
||||
if (found) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(container);
|
||||
return [node, offset - position]
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value() {
|
||||
if (this.selection) {
|
||||
const div = this.$refs.text;
|
||||
div.innerHTML = this.rawhtml(this.value);
|
||||
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.setStart(...this.findNode(div, this.selection.start));
|
||||
range.setEnd(...this.findNode(div, this.selection.end));
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const div = this.$refs.text;
|
||||
div.innerHTML = this.rawhtml(this.value);
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -1,11 +1,9 @@
|
|||
import Vue from 'vue';
|
||||
import {createApp} from 'vue'
|
||||
import App from './App.vue';
|
||||
import {sync} from 'vuex-router-sync';
|
||||
import VueQrcode from '@chenfengyuan/vue-qrcode';
|
||||
import store from './store';
|
||||
import router from './router';
|
||||
|
||||
// bootstrap
|
||||
import 'jquery/dist/jquery.min.js';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap/dist/js/bootstrap.min.js';
|
||||
|
||||
|
@ -42,24 +40,19 @@ import {
|
|||
faClipboard,
|
||||
faTasks,
|
||||
faAngleRight,
|
||||
faAngleDown
|
||||
faAngleDown,
|
||||
faTruck,
|
||||
faObjectGroup
|
||||
} 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);
|
||||
faArchive, faMinus, faExclamation, faHourglass, faClipboard, faTasks, faAngleDown, faAngleRight, faTruck, faObjectGroup);
|
||||
|
||||
sync(store, router);
|
||||
|
||||
new Vue({
|
||||
el: '#app',
|
||||
store,
|
||||
router,
|
||||
render: h => h(App),
|
||||
});
|
||||
const app = createApp(App).use(store).use(router);
|
||||
|
||||
Vue.use(vueDebounce);
|
||||
app.component(VueQrcode.name, VueQrcode);
|
||||
app.component('font-awesome-icon', FontAwesomeIcon);
|
||||
app.mount('#app')
|
71
web/src/persistent-state-plugin/index.js
Normal file
71
web/src/persistent-state-plugin/index.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
import {isProxy, toRaw} from 'vue';
|
||||
|
||||
export default (config) => (store) => {
|
||||
if (!('isLoadedKey' in config)) {
|
||||
throw new Error("isLoadedKey not defined in config");
|
||||
}
|
||||
|
||||
const initialize = () => {
|
||||
config.state.forEach(k => {
|
||||
try {
|
||||
if (config.debug) console.log("localStorage init", k, localStorage.getItem(config.prefix + k));
|
||||
const parsed = JSON.parse(localStorage.getItem(config.prefix + k));
|
||||
if (parsed !== store.state[k] && parsed !== null) {
|
||||
store.state[k] = parsed;
|
||||
} else {
|
||||
if (config.debug) console.log("localStorage not loaded", k, localStorage.getItem(config.prefix + k));
|
||||
}
|
||||
} catch (e) {
|
||||
if (config.debug) console.log("localStorage parse error", k, e);
|
||||
}
|
||||
});
|
||||
store.state[config.isLoadedKey] = true;
|
||||
}
|
||||
|
||||
const reload = initialize;
|
||||
|
||||
if (store.state[config.isLoadedKey] !== true)
|
||||
initialize();
|
||||
|
||||
addEventListener('storage', reload);
|
||||
|
||||
if ('state' in config) {
|
||||
config.state.forEach((member) => {
|
||||
store.watch((state, getters) => state[member], (newValue, oldValue) => {
|
||||
try {
|
||||
if (config.debug) console.log('watch', member,
|
||||
isProxy(newValue) ? toRaw(newValue) : newValue,
|
||||
isProxy(oldValue) ? toRaw(oldValue) : oldValue);
|
||||
const key = config.prefix + member;
|
||||
const encoded = JSON.stringify(isProxy(newValue) ? toRaw(newValue) : newValue);
|
||||
if (encoded !== localStorage.getItem(key)) {
|
||||
if (config.debug) console.log("localStorage replace", member, localStorage.getItem(key), encoded);
|
||||
if (newValue === null)
|
||||
localStorage.removeItem(key);
|
||||
else
|
||||
localStorage.setItem(key, encoded);
|
||||
} else {
|
||||
if (config.debug) console.log("localStorage not saved", member, localStorage.getItem(key), encoded);
|
||||
}
|
||||
} catch (e) {
|
||||
if (config.debug) console.log("localsorage save error", member, e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ('clearingMutation' in config) {
|
||||
store.subscribe((mutation, state) => {
|
||||
if (mutation.type === config.clearingMutation) {
|
||||
removeEventListener('storage', reload)
|
||||
for (let key in config.state) {
|
||||
localStorage.removeItem(config.prefix + key);
|
||||
}
|
||||
for (let key in config.state) {
|
||||
store.state[key] = null;
|
||||
}
|
||||
addEventListener('storage', reload)
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
|
@ -1,23 +1,24 @@
|
|||
import {createRouter, createWebHistory} from 'vue-router'
|
||||
import store from '@/store';
|
||||
|
||||
import Items from './views/Items';
|
||||
import Boxes from './views/Boxes';
|
||||
import Files from './views/Files';
|
||||
import Error from './views/Error';
|
||||
import HowTo from './views/HowTo';
|
||||
import VueRouter from 'vue-router';
|
||||
import Vue from 'vue';
|
||||
import Login from '@/views/Login.vue';
|
||||
import Register from '@/views/Register.vue';
|
||||
import Debug from "@/views/admin/Debug.vue";
|
||||
import Tickets from "@/views/Tickets.vue";
|
||||
import Ticket from "@/views/Ticket.vue";
|
||||
import Admin from "@/views/admin/Admin.vue";
|
||||
import store from "@/store";
|
||||
import Empty from "@/views/Empty.vue";
|
||||
import Events from "@/views/admin/Events.vue";
|
||||
import Settings from "@/views/admin/Settings.vue";
|
||||
import AccessControl from "@/views/admin/AccessControl.vue";
|
||||
import {default as BoxesAdmin} from "@/views/admin/Boxes.vue"
|
||||
|
||||
Vue.use(VueRouter);
|
||||
import Shipping from "@/views/admin/Shipping.vue";
|
||||
import Notifications from "@/views/admin/Notifications.vue";
|
||||
|
||||
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'}
|
||||
|
@ -72,14 +77,23 @@ const routes = [
|
|||
path: 'boxes/', name: 'admin_boxes', component: BoxesAdmin, meta:
|
||||
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||
},
|
||||
{
|
||||
path: 'shipping/', name: 'shipping', component: Shipping, meta:
|
||||
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||
},
|
||||
{
|
||||
path: 'notifications/', name: 'notifications', component: Notifications, meta:
|
||||
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||
}
|
||||
]
|
||||
},
|
||||
{path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}},
|
||||
{path: '*', component: Error},
|
||||
//{path: '*', component: Error},
|
||||
];
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
linkActiveClass: "active",
|
||||
routes,
|
||||
});
|
||||
|
||||
|
@ -101,13 +115,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;
|
||||
|
||||
|
|
345
web/src/shared-state-plugin/index.js
Normal file
345
web/src/shared-state-plugin/index.js
Normal file
|
@ -0,0 +1,345 @@
|
|||
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');
|
||||
|
||||
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 toRawRecursive = (obj) => {
|
||||
if (isProxy(obj)) {
|
||||
obj = toRaw(obj);
|
||||
}
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
if (obj.__proto__ === ({}).__proto__) {
|
||||
const new_obj = {};
|
||||
for (let key in obj) {
|
||||
new_obj[key] = toRawRecursive(obj[key]);
|
||||
}
|
||||
return new_obj;
|
||||
}
|
||||
if (obj.__proto__ === [].__proto__) {
|
||||
return obj.map((item) => toRawRecursive(item));
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/** may only be called from worker */
|
||||
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) ? toRawRecursive(new_value) : new_value,
|
||||
old_value: isProxy(old_value) ? toRawRecursive(old_value) : old_value
|
||||
});
|
||||
}
|
||||
|
||||
const registerInitialState = (keys, local_state) => {
|
||||
const value = keys.reduce((obj, key) => {
|
||||
obj[key] = isProxy(local_state[key]) ? toRawRecursive(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);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
577
web/src/store.js
Normal file
577
web/src/store.js
Normal file
|
@ -0,0 +1,577 @@
|
|||
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: [],
|
||||
shippingVouchers: [],
|
||||
|
||||
lastEvent: '37C3',
|
||||
lastUsed: {},
|
||||
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,
|
||||
messageTemplates: 0,
|
||||
shippingVouchers: 0,
|
||||
},
|
||||
persistent_loaded: false,
|
||||
shared_loaded: false,
|
||||
afterInitHandlers: [],
|
||||
|
||||
showAddBoxModal: false,
|
||||
test: ['foo', 'bar', 'baz'],
|
||||
|
||||
shippingVoucherTypes: {
|
||||
'2kg-de': '2kg Paket (DE)',
|
||||
'5kg-de': '5kg Paket (DE)',
|
||||
'10kg-de': '10kg Paket (DE)',
|
||||
'2kg-eu': '2kg Paket (EU)',
|
||||
'5kg-eu': '5kg Paket (EU)',
|
||||
'10kg-eu': '10kg Paket (EU)',
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
route: state => router.currentRoute.value,
|
||||
getEventSlug: state => router.currentRoute.value.params.event ? router.currentRoute.value.params.event : state.lastEvent,
|
||||
getActiveView: state => router.currentRoute.value.name || 'items',
|
||||
getFilters: state => router.currentRoute.value.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'
|
||||
}
|
||||
}
|
||||
},
|
||||
availableShippingVoucherTypes: state => {
|
||||
return Object.keys(state.shippingVoucherTypes).map(key => {
|
||||
var count = state.shippingVouchers.filter(voucher => voucher.type === key && voucher.issue_thread === null).length;
|
||||
return {id: key, count: count, name: state.shippingVoucherTypes[key]};
|
||||
});
|
||||
},
|
||||
layout: (state, getters) => {
|
||||
if (router.currentRoute.value.query.layout)
|
||||
return router.currentRoute.value.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: {
|
||||
updateLastUsed(state, diff) {
|
||||
state.lastUsed = {...state.lastUsed, ...diff};
|
||||
},
|
||||
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()};
|
||||
},
|
||||
updateTicket(state, updatedTicket) {
|
||||
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
|
||||
Object.assign(ticket, updatedTicket);
|
||||
state.tickets = [...state.tickets];
|
||||
},
|
||||
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()};
|
||||
},
|
||||
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;
|
||||
state.fetchedData = {...state.fetchedData, messageTemplates: Date.now()};
|
||||
},
|
||||
setMessageTemplateVariables(state, variables) {
|
||||
state.messageTemplateVariables = variables;
|
||||
},
|
||||
setShippingVouchers(state, codes) {
|
||||
state.shippingVouchers = codes;
|
||||
state.fetchedData = {...state.fetchedData, shippingVouchers: Date.now()};
|
||||
},
|
||||
},
|
||||
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);
|
||||
if (data && success) {
|
||||
state.fetchedData.tickets = 0;
|
||||
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);
|
||||
if (data && success) {
|
||||
state.fetchedData.tickets = 0;
|
||||
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}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.messageTemplates.length > 0) return;
|
||||
const {data, success} = await http.get('/2/message_templates/', state.user.token);
|
||||
if (data && success) {
|
||||
commit('setMessageTemplates', data);
|
||||
}
|
||||
},
|
||||
async updateMessageTemplate({dispatch, state}, template) {
|
||||
const {data, success} = await http.patch(`/2/message_templates/${template.id}/`,
|
||||
{'message': template.message}, state.user.token);
|
||||
if (data && success) {
|
||||
state.fetchedData.messageTemplates = 0;
|
||||
dispatch('fetchMessageTemplates');
|
||||
}
|
||||
},
|
||||
async fetchMessageTemplateVariables({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.messageTemplateVariables.length > 0) return;
|
||||
const {data, success} = await http.get('/2/message_template_variables/', state.user.token);
|
||||
if (data && success) {
|
||||
commit('setMessageTemplateVariables', data);
|
||||
}
|
||||
},
|
||||
async createMessageTemplate({dispatch, state}, template_name) {
|
||||
const {data, success} = await http.post('/2/message_templates/', {
|
||||
name: template_name,
|
||||
message: '-'
|
||||
}, state.user.token);
|
||||
if (data && success) {
|
||||
dispatch('fetchMessageTemplates');
|
||||
}
|
||||
},
|
||||
async fetchShippingVouchers({commit, state}) {
|
||||
if (!state.user.token) return;
|
||||
if (state.fetchedData.shippingVouchers > Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
const {data, success} = await http.get('/2/shipping_vouchers/', state.user.token);
|
||||
if (data && success) {
|
||||
commit('setShippingVouchers', data);
|
||||
}
|
||||
},
|
||||
async createShippingVoucher({dispatch, state}, code) {
|
||||
const {data, success} = await http.post('/2/shipping_vouchers/', code, state.user.token);
|
||||
if (data && success) {
|
||||
state.fetchedData.shippingVouchers = 0;
|
||||
dispatch('fetchShippingVouchers');
|
||||
}
|
||||
},
|
||||
async claimShippingVoucher({dispatch, state}, {ticket, shipping_voucher_type}) {
|
||||
const id = state.shippingVouchers.filter(voucher => voucher.type === shipping_voucher_type && voucher.issue_thread === null)[0].id;
|
||||
const {
|
||||
data,
|
||||
success
|
||||
} = await http.patch(`/2/shipping_vouchers/${id}/`, {issue_thread: ticket}, state.user.token);
|
||||
if (data && success) {
|
||||
state.fetchedData.shippingVouchers = 0;
|
||||
state.fetchedData.tickets = 0;
|
||||
await Promise.all([dispatch('loadTickets'), dispatch('fetchShippingVouchers')]);
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
persistentStatePlugin({ // TODO change remember to some kind of enable field
|
||||
prefix: "lf_",
|
||||
debug: false,
|
||||
isLoadedKey: "persistent_loaded",
|
||||
state: [
|
||||
"remember",
|
||||
"user",
|
||||
"events",
|
||||
"lastUsed",
|
||||
]
|
||||
}),
|
||||
sharedStatePlugin({
|
||||
debug: false,
|
||||
isLoadedKey: "shared_loaded",
|
||||
clearingMutation: "logout",
|
||||
afterInit: "afterSharedInit",
|
||||
state: [
|
||||
"test",
|
||||
"state_options",
|
||||
"fetchedData",
|
||||
"tickets",
|
||||
"users",
|
||||
"groups",
|
||||
"loadedBoxes",
|
||||
"loadedItems",
|
||||
"messageTemplates",
|
||||
"messageTemplatesVariables",
|
||||
"shippingVouchers",
|
||||
],
|
||||
watch: [
|
||||
"test",
|
||||
"state_options",
|
||||
"fetchedData",
|
||||
"tickets",
|
||||
"users",
|
||||
"groups",
|
||||
"loadedBoxes",
|
||||
"loadedItems",
|
||||
"messageTemplates",
|
||||
"messageTemplatesVariables",
|
||||
"shippingVouchers",
|
||||
],
|
||||
mutations: [
|
||||
//"replaceTickets",
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
store.watch((state) => state.user, (user) => {
|
||||
if (store.getters.isLoggedIn) {
|
||||
if (router.currentRoute.value.name === 'login' && router.currentRoute.value.query.redirect)
|
||||
router.push(router.currentRoute.value.query.redirect);
|
||||
else if (router.currentRoute.value.name === 'login')
|
||||
router.push('/');
|
||||
} else {
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
router.push({
|
||||
name: 'login',
|
||||
query: {redirect: router.currentRoute.value.fullPath},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default store;
|
|
@ -1,441 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import AxiosBootstrap from 'axios';
|
||||
import * as _ from 'lodash/fp';
|
||||
import router from '../router';
|
||||
|
||||
import * as base64 from 'base-64';
|
||||
import * as utf8 from 'utf8';
|
||||
import {ticketStateColorLookup, ticketStateIconLookup} from "@/utils";
|
||||
import createMutationsSharer from "vuex-shared-mutations";
|
||||
|
||||
Vue.use(Vuex);
|
||||
const axios = AxiosBootstrap.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
axios.interceptors.response.use(response => response, error => {
|
||||
if (error.response.status === 401) {
|
||||
console.log('401 interceptor fired');
|
||||
store.dispatch('reloadToken').then((ok) => {
|
||||
if (ok) {
|
||||
error.config.headers['Authorization'] = `Token ${store.state.token}`;
|
||||
return axios.request(error.config);
|
||||
}
|
||||
});
|
||||
} else if (error.response.status === 403) {
|
||||
const message = `
|
||||
<h3>Access denied.</h3>
|
||||
<p>
|
||||
url: ${error.config.url}
|
||||
<br>
|
||||
method: ${error.config.method}
|
||||
<br>
|
||||
response-body: ${error.response && error.response.body}
|
||||
</p>
|
||||
`;
|
||||
store.commit('createToast', {title: 'Error: Access denied', message, color: 'danger'});
|
||||
return Promise.reject(error)
|
||||
} else {
|
||||
console.error('error interceptor fired', error.message);
|
||||
|
||||
if (error.isAxiosError) {
|
||||
const message = `
|
||||
<h3>A HTTP ${error.config.method} request failed.</h3>
|
||||
<p>
|
||||
url: ${error.config.url}
|
||||
<br>
|
||||
timeout: ${!!error.request.timeout}
|
||||
<br>
|
||||
response-body: ${error.response && error.response.body}
|
||||
</p>
|
||||
`;
|
||||
store.commit('createToast', {title: 'Error: HTTP', message, color: 'danger'});
|
||||
} else {
|
||||
store.commit('createToast', {title: 'Error: Unknown', message: error.toString(), color: 'danger'});
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state: {
|
||||
keyIncrement: 0,
|
||||
events: [],
|
||||
loadedItems: [],
|
||||
itemCache: {},
|
||||
loadedBoxes: [],
|
||||
toasts: [],
|
||||
tickets: [],
|
||||
users: [],
|
||||
groups: [],
|
||||
lastEvent: localStorage.getItem('lf_lastEvent') || '37C3',
|
||||
lastUsed: JSON.parse(localStorage.getItem('lf_lastUsed') || '{}'),
|
||||
remember: false,
|
||||
user: null,
|
||||
password: null,
|
||||
userPermissions: [],
|
||||
token: null,
|
||||
state_options: [],
|
||||
token_expiry: null,
|
||||
local_loaded: false,
|
||||
showAddBoxModal: false,
|
||||
toggle: false,
|
||||
},
|
||||
getters: {
|
||||
getEventSlug: state => state.route && state.route.params.event ? state.route.params.event : state.lastEvent,
|
||||
getActiveView: state => state.route.name || 'items',
|
||||
getFilters: state => state.route.query,
|
||||
getBoxes: state => state.loadedBoxes,
|
||||
checkPermission: state => (event, perm) => state.userPermissions.includes(`${event}:${perm}`) || state.userPermissions.includes(`*:${perm}`),
|
||||
hasPermissions: state => state.userPermissions.length > 0,
|
||||
stateInfo: state => (slug) => {
|
||||
const obj = state.state_options.filter((s) => s.value === slug)[0];
|
||||
if (obj) {
|
||||
return {
|
||||
color: ticketStateColorLookup(obj.value),
|
||||
icon: ticketStateIconLookup(obj.value),
|
||||
slug: obj.value,
|
||||
text: obj.text,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: 'exclamation',
|
||||
slug: slug,
|
||||
text: 'Unknown'
|
||||
}
|
||||
}
|
||||
},
|
||||
layout: (state, getters) => {
|
||||
state.toggle = !state.toggle;
|
||||
if (router.currentRoute.query.layout)
|
||||
return router.currentRoute.query.layout;
|
||||
if (getters.getActiveView === 'items')
|
||||
return 'cards';
|
||||
if (getters.getActiveView === 'tickets')
|
||||
return 'tasks';
|
||||
},
|
||||
isLoggedIn(state) {
|
||||
if (!state.local_loaded) {
|
||||
state.remember = localStorage.getItem('remember') === 'true';
|
||||
state.user = localStorage.getItem('user');
|
||||
//state.password = localStorage.getItem('password');
|
||||
state.userPermissions = JSON.parse(localStorage.getItem('permissions') || '[]');
|
||||
state.token = localStorage.getItem('token');
|
||||
state.token_expiry = localStorage.getItem('token_expiry');
|
||||
state.local_loaded = true;
|
||||
axios.defaults.headers.common['Authorization'] = `Token ${state.token}`;
|
||||
}
|
||||
|
||||
return state.user !== null && state.token !== null;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
updateLastUsed(state, diff) {
|
||||
state.lastUsed = _.extend(state.lastUsed, diff);
|
||||
localStorage.setItem('lf_lastUsed', JSON.stringify(state.lastUsed));
|
||||
},
|
||||
updateLastEvent(state, slug) {
|
||||
state.lastEvent = slug;
|
||||
localStorage.setItem('lf_lastEvent', slug);
|
||||
},
|
||||
replaceEvents(state, events) {
|
||||
state.events = events;
|
||||
},
|
||||
replaceTicketStates(state, states) {
|
||||
state.state_options = states;
|
||||
},
|
||||
changeView(state, {view, slug}) {
|
||||
router.push({path: `/${slug}/${view}`});
|
||||
},
|
||||
replaceLoadedItems(state, newItems) {
|
||||
state.loadedItems = newItems;
|
||||
},
|
||||
setItemCache(state, {slug, items}) {
|
||||
state.itemCache[slug] = items;
|
||||
},
|
||||
replaceBoxes(state, loadedBoxes) {
|
||||
state.loadedBoxes = loadedBoxes;
|
||||
},
|
||||
updateItem(state, updatedItem) {
|
||||
const item = state.loadedItems.filter(({uid}) => uid === updatedItem.uid)[0];
|
||||
Object.assign(item, updatedItem);
|
||||
},
|
||||
removeItem(state, item) {
|
||||
state.loadedItems = state.loadedItems.filter(it => it !== item);
|
||||
},
|
||||
appendItem(state, item) {
|
||||
state.loadedItems.push(item);
|
||||
},
|
||||
replaceTickets(state, tickets) {
|
||||
state.tickets = tickets;
|
||||
},
|
||||
replaceUsers(state, users) {
|
||||
state.users = users;
|
||||
},
|
||||
replaceGroups(state, groups) {
|
||||
state.groups = groups;
|
||||
},
|
||||
updateTicket(state, updatedTicket) {
|
||||
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
|
||||
Object.assign(ticket, updatedTicket);
|
||||
},
|
||||
openAddBoxModal(state) {
|
||||
state.showAddBoxModal = true;
|
||||
},
|
||||
closeAddBoxModal(state) {
|
||||
state.showAddBoxModal = false;
|
||||
},
|
||||
createToast(state, {title, message, color}) {
|
||||
var toast = {title, message, color, key: state.keyIncrement}
|
||||
state.toasts.push(toast);
|
||||
state.keyIncrement += 1;
|
||||
return toast;
|
||||
},
|
||||
removeToast(state, key) {
|
||||
state.toasts = state.toasts.filter(toast => toast.key !== key);
|
||||
},
|
||||
setRemember(state, remember) {
|
||||
state.remember = remember;
|
||||
localStorage.setItem('remember', remember);
|
||||
},
|
||||
setUser(state, user) {
|
||||
state.user = user;
|
||||
if (user)
|
||||
localStorage.setItem('user', user);
|
||||
},
|
||||
setPassword(state, password) {
|
||||
state.password = password;
|
||||
},
|
||||
setPermissions(state, permissions) {
|
||||
state.userPermissions = permissions;
|
||||
if (permissions)
|
||||
localStorage.setItem('permissions', JSON.stringify(permissions));
|
||||
},
|
||||
setToken(state, {token, expiry}) {
|
||||
state.token = token;
|
||||
state.token_expiry = expiry;
|
||||
if (token)
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('token_expiry', expiry);
|
||||
},
|
||||
logout(state) {
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('permissions');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('token_expiry');
|
||||
if (router.currentRoute.name !== 'login')
|
||||
router.push('/login');
|
||||
},
|
||||
triggerLayoutChange(state) {
|
||||
state.toggle = !state.toggle;
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async login({commit, dispatch, state}, {username, password, remember}) {
|
||||
commit('setRemember', remember);
|
||||
try {
|
||||
const data = await fetch('/api/2/login/', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: username, password: password}),
|
||||
credentials: 'omit'
|
||||
}).then(r => r.json())
|
||||
if (data && data.token) {
|
||||
commit('setToken', data);
|
||||
commit('setUser', username);
|
||||
commit('setPassword', password);
|
||||
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
|
||||
dispatch('afterLogin');
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async reloadToken({commit, state}) {
|
||||
try {
|
||||
if (state.password) {
|
||||
const data = await fetch('/api/2/login/', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: state.user, password: state.password}),
|
||||
credentials: 'omit'
|
||||
}).then(r => r.json()).catch(e => console.error(e))
|
||||
if (data && data.token) {
|
||||
commit('setToken', data);
|
||||
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
//credentials failed, logout
|
||||
store.commit('logout');
|
||||
},
|
||||
//async verifyToken({commit, state}) {
|
||||
async afterLogin({dispatch}) {
|
||||
const boxes = dispatch('loadBoxes');
|
||||
const states = dispatch('fetchTicketStates');
|
||||
const items = dispatch('loadEventItems');
|
||||
const tickets = dispatch('loadTickets');
|
||||
const user = dispatch('loadUserInfo');
|
||||
await Promise.all([boxes, items, tickets, user, states]);
|
||||
},
|
||||
async fetchImage({state}, url) {
|
||||
return await fetch(url, {headers: {'Authorization': `Token ${state.token}`}});
|
||||
},
|
||||
async loadUserInfo({commit}) {
|
||||
const {data} = await axios.get('/2/self/');
|
||||
commit('setUser', data.username);
|
||||
commit('setPermissions', data.permissions);
|
||||
},
|
||||
async loadEvents({commit}) {
|
||||
const {data} = await axios.get('/2/events/');
|
||||
commit('replaceEvents', data);
|
||||
},
|
||||
async fetchTicketStates({commit}) {
|
||||
const {data} = await axios.get('/2/tickets/states/');
|
||||
commit('replaceTicketStates', data);
|
||||
},
|
||||
changeEvent({dispatch, getters, commit}, eventName) {
|
||||
router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
|
||||
dispatch('loadEventItems');
|
||||
},
|
||||
changeView({getters}, link) {
|
||||
router.push({path: `/${getters.getEventSlug}/${link.path}/`});
|
||||
},
|
||||
showBoxContent({getters}, box) {
|
||||
router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
|
||||
},
|
||||
async loadEventItems({commit, getters, state}) {
|
||||
try {
|
||||
commit('replaceLoadedItems', []);
|
||||
const slug = getters.getEventSlug;
|
||||
if (slug in state.itemCache) {
|
||||
commit('replaceLoadedItems', state.itemCache[slug]);
|
||||
}
|
||||
const {data} = await axios.get(`/2/${slug}/items/`);
|
||||
commit('replaceLoadedItems', data);
|
||||
commit('setItemCache', {slug, items: data});
|
||||
} catch (e) {
|
||||
console.error("Error loading items");
|
||||
}
|
||||
},
|
||||
async searchEventItems({commit, getters}, query) {
|
||||
const foo = utf8.encode(query);
|
||||
const bar = base64.encode(foo);
|
||||
|
||||
const {data} = await axios.get(`/2/${getters.getEventSlug}/items/${bar}/`);
|
||||
commit('replaceLoadedItems', data);
|
||||
},
|
||||
async loadBoxes({commit}) {
|
||||
const {data} = await axios.get('/2/boxes/');
|
||||
commit('replaceBoxes', data);
|
||||
},
|
||||
async createBox({commit, dispatch}, box) {
|
||||
const {data} = await axios.post('/2/boxes/', box);
|
||||
commit('replaceBoxes', data);
|
||||
dispatch('loadBoxes').then(() => {
|
||||
commit('closeAddBoxModal');
|
||||
});
|
||||
},
|
||||
async deleteBox({commit, dispatch}, box_id) {
|
||||
await axios.delete(`/2/boxes/${box_id}/`);
|
||||
dispatch('loadBoxes');
|
||||
},
|
||||
async updateItem({commit, getters}, item) {
|
||||
const {data} = await axios.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
|
||||
commit('updateItem', data);
|
||||
},
|
||||
async markItemReturned({commit, getters}, item) {
|
||||
await axios.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true});
|
||||
commit('removeItem', item);
|
||||
},
|
||||
async deleteItem({commit, getters}, item) {
|
||||
await axios.delete(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
|
||||
commit('removeItem', item);
|
||||
},
|
||||
async postItem({commit, getters}, item) {
|
||||
commit('updateLastUsed', {box: item.box, cid: item.cid});
|
||||
const {data} = await axios.post(`/2/${getters.getEventSlug}/item/`, item);
|
||||
commit('appendItem', data);
|
||||
},
|
||||
async loadTickets({commit}) {
|
||||
const {data} = await axios.get('/2/tickets/');
|
||||
commit('replaceTickets', data);
|
||||
},
|
||||
async sendMail({commit, dispatch}, {id, message}) {
|
||||
const {data} = await axios.post(`/2/tickets/${id}/reply/`, {message});
|
||||
await dispatch('loadTickets');
|
||||
},
|
||||
async postManualTicket({commit, dispatch}, {sender, message, title,}) {
|
||||
const {data} = await axios.post(`/2/tickets/manual/`, {
|
||||
name: title,
|
||||
sender,
|
||||
body: message,
|
||||
recipient: 'mail@c3lf.de'
|
||||
});
|
||||
await dispatch('loadTickets');
|
||||
},
|
||||
async postComment({commit, dispatch}, {id, message}) {
|
||||
const {data} = await axios.post(`/2/tickets/${id}/comment/`, {comment: message});
|
||||
await dispatch('loadTickets');
|
||||
},
|
||||
async loadUsers({commit}) {
|
||||
const {data} = await axios.get('/2/users/');
|
||||
commit('replaceUsers', data);
|
||||
},
|
||||
async loadGroups({commit}) {
|
||||
const {data} = await axios.get('/2/groups/');
|
||||
commit('replaceGroups', data);
|
||||
},
|
||||
async updateTicket({commit}, ticket) {
|
||||
const {data} = await axios.put(`/2/tickets/${ticket.id}/`, ticket);
|
||||
commit('updateTicket', data);
|
||||
},
|
||||
async updateTicketPartial({commit}, {id, ...ticket}) {
|
||||
const {data} = await axios.patch(`/2/tickets/${id}/`, ticket);
|
||||
commit('updateTicket', data);
|
||||
}
|
||||
},
|
||||
plugins: [createMutationsSharer({
|
||||
predicate: [
|
||||
'replaceLoadedItems',
|
||||
'setItemCache',
|
||||
'setLayout',
|
||||
'replaceBoxes',
|
||||
'updateItem',
|
||||
'removeItem',
|
||||
'appendItem',
|
||||
'replaceTickets',
|
||||
'replaceUsers',
|
||||
'replaceGroups',
|
||||
'updateTicket',
|
||||
'openAddBoxModal',
|
||||
'closeAddBoxModal',
|
||||
'createToast',
|
||||
'removeToast',
|
||||
'setRemember',
|
||||
'setUser',
|
||||
'setPermissions',
|
||||
'setToken',
|
||||
'logout',
|
||||
]
|
||||
})],
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
||||
store.dispatch('loadEvents').then(() => {
|
||||
if (store.getters.isLoggedIn) {
|
||||
axios.defaults.headers.common['Authorization'] = `Token ${store.state.token}`;
|
||||
store.dispatch('afterLogin');
|
||||
}
|
||||
});
|
|
@ -24,4 +24,80 @@ function ticketStateIconLookup(ticket) {
|
|||
return 'exclamation';
|
||||
}
|
||||
|
||||
export {ticketStateColorLookup, ticketStateIconLookup};
|
||||
const http = {
|
||||
get: async (url, token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch('/api' + url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Token ${token}`,
|
||||
},
|
||||
});
|
||||
const success = response.status === 200 || response.status === 201;
|
||||
return {data: await response.json() || {}, success};
|
||||
},
|
||||
post: async (url, data, token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch('/api' + url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Token ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const success = response.status === 200 || response.status === 201;
|
||||
return {data: await response.json() || {}, success};
|
||||
},
|
||||
put: async (url, data, token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch('/api' + url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Token ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const success = response.status === 200 || response.status === 201;
|
||||
return {data: await response.json() || {}, success};
|
||||
},
|
||||
patch: async (url, data, token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch('/api' + url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Token ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const success = response.status === 200 || response.status === 201;
|
||||
return {data: await response.json() || {}, success};
|
||||
},
|
||||
delete: async (url, token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch('/api' + url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Token ${token}`,
|
||||
},
|
||||
});
|
||||
const success = response.status === 200 || response.status === 201;
|
||||
return {data: await response.json() || {}, success};
|
||||
}
|
||||
}
|
||||
|
||||
export {ticketStateColorLookup, ticketStateIconLookup, http};
|
|
@ -4,9 +4,16 @@
|
|||
<div class="col-xl-8 offset-xl-2">
|
||||
<div class="card bg-dark text-light mb-2" id="filters">
|
||||
<div class="card-header">
|
||||
<h3 class="text-center">User: {{user}}</h3>
|
||||
<h3 class="text-center">User: {{ activeUser }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body" v-if="hasPermissions">
|
||||
<p>Your Account is activated. Got to
|
||||
<router-link :to="{name: 'items', params: {event: getEventSlug}}">Items</router-link>
|
||||
or
|
||||
<router-link :to="{name: 'tickets', params: {event: getEventSlug}}">Tickets</router-link>
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-body" v-else>
|
||||
<p>Your Account is not yet activated. Please contact an admin.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -16,11 +23,13 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from "vuex";
|
||||
import {mapGetters, mapState} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'Empty',
|
||||
computed: mapState(['user']),
|
||||
computed: {
|
||||
...mapGetters(['hasPermissions', 'getEventSlug', 'activeUser']),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
@ -23,13 +23,15 @@
|
|||
>
|
||||
<template #actions="{ item }">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-success" @click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
|
||||
<button class="btn btn-success"
|
||||
@click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
|
||||
<font-awesome-icon icon="check"/>
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click.stop="openEditingModalWith(item)" title="edit">
|
||||
<font-awesome-icon icon="edit"/>
|
||||
</button>
|
||||
<button class="btn btn-danger" @click.stop="confirm('delete Item?') && deleteItem(item)" title="delete">
|
||||
<button class="btn btn-danger" @click.stop="confirm('delete Item?') && deleteItem(item)"
|
||||
title="delete">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -45,7 +47,7 @@
|
|||
v-slot="{ item }"
|
||||
@itemActivated="openLightboxModalWith($event)"
|
||||
>
|
||||
<AuthenticatedImage v-if="item.file"
|
||||
<AuthenticatedImage v-if="item.file" cached
|
||||
:src="`/media/2/256/${item.file}/`"
|
||||
class="card-img-top img-fluid"
|
||||
/>
|
||||
|
@ -93,7 +95,7 @@ export default {
|
|||
...mapGetters(['layout']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['deleteItem', 'markItemReturned']),
|
||||
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']),
|
||||
openLightboxModalWith(item) {
|
||||
this.lightboxHash = item.file;
|
||||
},
|
||||
|
@ -107,12 +109,15 @@ export default {
|
|||
this.editingItem = null;
|
||||
},
|
||||
saveEditingItem() { // Saves the edited copy of the item.
|
||||
this.$store.dispatch('updateItem', this.editingItem);
|
||||
this.updateItem(this.editingItem);
|
||||
this.closeEditingModal();
|
||||
},
|
||||
confirm(message) {
|
||||
return window.confirm(message);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.scheduleAfterInit(() => [this.loadEventItems()]);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -100,7 +100,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
input{
|
||||
input {
|
||||
background-color: var(--dark);
|
||||
border: var(--gray) 1px solid;;
|
||||
|
||||
|
|
|
@ -13,10 +13,6 @@
|
|||
<font-awesome-icon icon="trash"/>
|
||||
Delete
|
||||
</button-->
|
||||
<ClipboardButton :payload="shippingEmail" class="btn btn-primary">
|
||||
<font-awesome-icon icon="clipboard"/>
|
||||
Copy DHL contact to clipboard
|
||||
</ClipboardButton>
|
||||
<div class="btn-group">
|
||||
<select class="form-control" v-model="ticket.assigned_to">
|
||||
<option v-for="user in users" :value="user.username">{{ user.username }}</option>
|
||||
|
@ -34,6 +30,24 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
<ClipboardButton :payload="shippingEmail" class="btn btn-primary">
|
||||
<font-awesome-icon icon="clipboard"/>
|
||||
Copy DHL contact to clipboard
|
||||
</ClipboardButton>
|
||||
<div class="btn-group">
|
||||
<select class="form-control" v-model="shipping_voucher_type">
|
||||
<option v-for="type in availableShippingVoucherTypes.filter(t=>t.count>0)"
|
||||
:value="type.id">{{ type.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="form-control btn btn-success"
|
||||
@click="claimShippingVoucher({ticket: ticket.id, shipping_voucher_type}).then(()=>shipping_voucher_type=null)"
|
||||
:disabled="!shipping_voucher_type">
|
||||
Claim Shipping Voucher
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -41,15 +55,21 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import {mapActions, mapGetters, mapState} from 'vuex';
|
||||
import Timeline from "@/components/Timeline.vue";
|
||||
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
|
||||
|
||||
export default {
|
||||
name: 'Ticket',
|
||||
components: {ClipboardButton, Timeline},
|
||||
data() {
|
||||
return {
|
||||
shipping_voucher_type: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['tickets', 'state_options', 'users']),
|
||||
...mapGetters(['availableShippingVoucherTypes']),
|
||||
ticket() {
|
||||
const id = parseInt(this.$route.params.id)
|
||||
const ret = this.tickets.find(ticket => ticket.id === id);
|
||||
|
@ -62,7 +82,8 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
|
||||
...mapActions(['loadTickets', 'loadUsers', 'fetchTicketStates']),
|
||||
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
|
||||
...mapActions(['claimShippingVoucher']),
|
||||
handleMail(mail) {
|
||||
this.sendMail({
|
||||
id: this.ticket.id,
|
||||
|
@ -86,12 +107,10 @@ export default {
|
|||
id: ticket.id,
|
||||
assigned_to: ticket.assigned_to
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchTicketStates()
|
||||
this.loadTickets()
|
||||
this.loadUsers()
|
||||
mounted() {
|
||||
this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets(), this.loadUsers()]);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -2,13 +2,13 @@
|
|||
<div class="container-fluid px-xl-5 mt-3">
|
||||
<div class="row">
|
||||
<div class="col-xl-8 offset-xl-2">
|
||||
<Table
|
||||
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to']"
|
||||
:items="tickets"
|
||||
<SlotTable
|
||||
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to', 'actions', 'actions2']"
|
||||
:items="tickets.map(formatTicket)"
|
||||
:keyName="'id'"
|
||||
v-if="layout === 'table'"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<template v-slot:actions="{item}">
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
|
||||
@click.prevent="gotoDetail(item)">
|
||||
|
@ -17,7 +17,7 @@
|
|||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</SlotTable>
|
||||
</div>
|
||||
</div>
|
||||
<CollapsableCards v-if="layout === 'tasks'" :items="tickets"
|
||||
|
@ -54,18 +54,18 @@ import Modal from '@/components/Modal';
|
|||
import EditItem from '@/components/EditItem';
|
||||
import {mapActions, mapGetters, mapState} from 'vuex';
|
||||
import Lightbox from '../components/Lightbox';
|
||||
import Table from '@/components/Table';
|
||||
import SlotTable from "@/components/SlotTable.vue";
|
||||
import CollapsableCards from "@/components/CollapsableCards.vue";
|
||||
|
||||
export default {
|
||||
name: 'Tickets',
|
||||
components: {Lightbox, Table, Cards, Modal, EditItem, CollapsableCards},
|
||||
components: {Lightbox, SlotTable, Cards, Modal, EditItem, CollapsableCards},
|
||||
computed: {
|
||||
...mapState(['tickets']),
|
||||
...mapGetters(['stateInfo', 'getEventSlug', 'layout']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['loadTickets', 'fetchTicketStates']),
|
||||
...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']),
|
||||
gotoDetail(ticket) {
|
||||
this.$router.push({name: 'ticket', params: {id: ticket.id}});
|
||||
},
|
||||
|
@ -80,9 +80,8 @@ export default {
|
|||
};
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchTicketStates();
|
||||
this.loadTickets();
|
||||
mounted() {
|
||||
this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets()]);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -6,11 +6,20 @@
|
|||
<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>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{name: 'notifications'}" active-class="active">Notifications</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{name: 'shipping'}" active-class="active">Shipping</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{name: 'admin_boxes'}" active-class="active">Boxes</router-link>
|
||||
</li>
|
||||
|
|
|
@ -1,78 +1,87 @@
|
|||
<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">Issues</h3>
|
||||
<!--p>{{ issues }}</p-->
|
||||
<ul>
|
||||
<li v-for="issue in tickets" :key="issue.id">
|
||||
{{ issue.id }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import {mapActions, mapMutations, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
export default {
|
||||
name: 'Debug',
|
||||
components: {Table},
|
||||
computed: {
|
||||
...mapState(['events', 'loadedItems', 'loadedBoxes', 'mails', 'issues', 'systemEvents']),
|
||||
...mapState(['events', 'loadedItems', 'loadedBoxes', 'tickets']),
|
||||
...mapState(['events', 'loadedItems', 'loadedBoxes']),//, 'mails', 'issues', 'systemEvents']),
|
||||
...mapState(['test']),
|
||||
qr_url() {
|
||||
return window.location.href;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['changeEvent', 'loadMails', 'loadIssues', 'loadSystemEvents']),
|
||||
...mapActions(['changeEvent', 'loadTickets']),
|
||||
|
||||
...mapActions(['changeEvent']),//, 'loadMails', 'loadIssues', 'loadSystemEvents']),
|
||||
...mapMutations(['setTest']),
|
||||
addTest(test) {
|
||||
const tests = [...this.test, test];
|
||||
this.setTest(tests);
|
||||
},
|
||||
removeTest(index) {
|
||||
const tests = [...this.test];
|
||||
tests.splice(index, 1);
|
||||
this.setTest(tests);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadMails();
|
||||
this.loadIssues();
|
||||
this.loadSystemEvents();
|
||||
this.loadTickets();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.qr-code img {
|
||||
border: #fff solid 7px
|
||||
}
|
||||
</style>
|
36
web/src/views/admin/Notifications.vue
Normal file
36
web/src/views/admin/Notifications.vue
Normal file
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<Table
|
||||
:columns="['slug', 'name']"
|
||||
:items="events"
|
||||
:keyName="'slug'"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" @click.stop="changeEvent(item)">
|
||||
<font-awesome-icon icon="archive"/>
|
||||
use
|
||||
</button>
|
||||
<button class="btn btn-danger" @click.stop="">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
delete
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
export default {
|
||||
name: 'Notifications',
|
||||
components: {Table},
|
||||
computed: mapState(['events']),
|
||||
methods: mapActions(['changeEvent']),
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
109
web/src/views/admin/Settings.vue
Normal file
109
web/src/views/admin/Settings.vue
Normal file
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<h3 class="text-center">Available Message Template Variables</h3>
|
||||
<p>
|
||||
<span v-for="(variable, key) in messageTemplateVariables" :key="key" class="badge badge-primary"
|
||||
style="margin: 5px;">
|
||||
{{ variable }}
|
||||
</span>
|
||||
</p>
|
||||
<h3 class="text-center">Message Templates</h3>
|
||||
<div v-for="template in messageTemplatesIntermediate" :key="template.id" class="card bg-dark"
|
||||
style="margin-bottom: 10px;">
|
||||
<div class="card-header">{{ template.name }}</div>
|
||||
<FormatedText :value="template.message" :format="formatText" class="card-body"
|
||||
@input="changeMessageTemplate(template.id, $event)"/>
|
||||
<div class="card-body">
|
||||
<button class="btn btn-primary" @click="resetMessageTemplate(template.id)"
|
||||
:disabled="messageTemplates.find(t => t.id === template.id).message === template.message">Reset
|
||||
</button>
|
||||
<button class="btn btn-success" @click="saveMessageTemplate(template.id)"
|
||||
:disabled="messageTemplates.find(t => t.id === template.id).message === template.message">Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-dark">
|
||||
<div class="card-body">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" v-model="newTemplateName" placeholder="New Template Name">
|
||||
<button class="btn btn-success input-group-btn" @click="createMessageTemplateAndReset()"
|
||||
ref="createButton">Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
import FormatedText from "@/components/inputs/FormatedText.vue";
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
components: {FormatedText, Table},
|
||||
data() {
|
||||
return {
|
||||
messageTemplatesIntermediate: [],
|
||||
newTemplateName: '',
|
||||
};
|
||||
},
|
||||
computed: mapState(['messageTemplates', 'messageTemplateVariables']),
|
||||
methods: {
|
||||
...mapActions(['fetchMessageTemplates', 'fetchMessageTemplateVariables', 'updateMessageTemplate', 'createMessageTemplate']),
|
||||
formatText(value) {
|
||||
return value.replace(/{{(.+?)}}/g, (match, key) => {
|
||||
return `<span class="text-primary">{{${key}}}</span>`;
|
||||
}).replace(/\n/g, '<br>').replace(/\t/g, ' ');
|
||||
},
|
||||
changeMessageTemplate(id, message) {
|
||||
console.log(id, message);
|
||||
this.messageTemplatesIntermediate.forEach(template => {
|
||||
if (template.id === id) {
|
||||
template.message = message;
|
||||
}
|
||||
});
|
||||
},
|
||||
saveMessageTemplate(id) {
|
||||
this.updateMessageTemplate(this.messageTemplatesIntermediate.find(template => template.id === id));
|
||||
},
|
||||
resetMessageTemplate(id) {
|
||||
this.messageTemplatesIntermediate.find(template => template.id === id).message =
|
||||
this.messageTemplates.find(template => template.id === id).message;
|
||||
},
|
||||
async createMessageTemplateAndReset() {
|
||||
this.$refs.createButton.disabled = true;
|
||||
await this.createMessageTemplate(this.newTemplateName);
|
||||
this.newTemplateName = '';
|
||||
this.$refs.createButton.disabled = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchMessageTemplates().then(() => {
|
||||
this.messageTemplatesIntermediate = JSON.parse(JSON.stringify(this.messageTemplates));
|
||||
});
|
||||
this.fetchMessageTemplateVariables();
|
||||
},
|
||||
watch: {
|
||||
messageTemplates() {
|
||||
for (const template of this.messageTemplates) {
|
||||
if (!this.messageTemplatesIntermediate.find(t => t.id === template.id)) {
|
||||
this.messageTemplatesIntermediate.push(JSON.parse(JSON.stringify(template)));
|
||||
}
|
||||
}
|
||||
for (const template of this.messageTemplatesIntermediate) {
|
||||
if (!this.messageTemplates.find(t => t.id === template.id)) {
|
||||
this.messageTemplatesIntermediate = this.messageTemplatesIntermediate.filter(t => t.id !== template.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
99
web/src/views/admin/Shipping.vue
Normal file
99
web/src/views/admin/Shipping.vue
Normal file
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<div>
|
||||
<h3>Shipping Vouchers</h3>
|
||||
<div class="mt-3">
|
||||
<h5>Shipping Voucher Types</h5>
|
||||
<span v-for="(type, key) in availableShippingVoucherTypes" :key="key" class="mr-2">
|
||||
<span v-if="type.count > 2" class="badge badge-success">{{ type.name }} - {{ type.count }}</span>
|
||||
<span v-else-if="type.count > 0" class="badge badge-warning" v-if="type.count > 0">
|
||||
{{ type.name }} - {{ type.count }}
|
||||
</span>
|
||||
<span v-else class="badge badge-danger">{{ type.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<h5>Available Shipping Vouchers</h5>
|
||||
<ul>
|
||||
<li v-for="voucher in shippingVouchers" :key="voucher.voucher">
|
||||
<span v-if="voucher.issue_thread == null">{{ voucher.type }} - {{ voucher.voucher }}</span>
|
||||
<span v-else><s style="color:var(--danger)">{{ voucher.type }} - {{ voucher.voucher }}</s> used in
|
||||
<router-link :to="'/'+ getEventSlug + '/ticket/' + voucher.issue_thread">#{{
|
||||
voucher.issue_thread
|
||||
}}</router-link></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<textarea class="form-control mb-3" rows="5" placeholder="Shipping Voucher List" v-model="bulk_vouchers"
|
||||
v-if="bulk"></textarea>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" placeholder="Shipping Voucher" v-model="voucher" v-if="!bulk">
|
||||
<select class="form-control" v-model="type">
|
||||
<option v-for="it in Object.keys(shippingVoucherTypes)" :value="it">{{
|
||||
shippingVoucherTypes[it]
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<div class="input-group-prepend">
|
||||
<div class="input-group-text">
|
||||
<input type="checkbox" v-model="bulk" class="mr-2" id="bulk" style="margin: 0;">
|
||||
<label for="bulk" style="margin: 0;">Bulk</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary form-control" @click="createSingleOrBulkShippingVoucher">
|
||||
<font-awesome-icon icon="plus"/>
|
||||
{{ (bulk ? "Add Shipping Vouchers" : "Add Shipping Voucher") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters, mapState} from 'vuex';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
export default {
|
||||
name: 'Shipping',
|
||||
components: {Table},
|
||||
data() {
|
||||
return {
|
||||
voucher: '',
|
||||
bulk_vouchers: '',
|
||||
type: '2kg-eu',
|
||||
bulk: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['shippingVouchers', 'shippingVoucherTypes']),
|
||||
...mapGetters(['getEventSlug', 'availableShippingVoucherTypes']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchShippingVouchers', 'createShippingVoucher']),
|
||||
createSingleOrBulkShippingVoucher() {
|
||||
if (this.bulk) {
|
||||
const list = this.bulk_vouchers.split('\n');
|
||||
if (confirm('Are you sure you want to add ' + list.length + ' shipping vouchers as ' + this.type + '?')) {
|
||||
const jobs = list.map(voucher => {
|
||||
return this.createShippingVoucher({voucher: voucher.trim(), type: this.type});
|
||||
});
|
||||
Promise.all(jobs).then(() => {
|
||||
this.bulk_vouchers = '';
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.createShippingVoucher({voucher: this.voucher, type: this.type}).then(() => {
|
||||
this.voucher = '';
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchShippingVouchers();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
1
web/vendor/vuex-router-sync
vendored
Submodule
1
web/vendor/vuex-router-sync
vendored
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 7b8bdeec5e3127c7877842193253ac234487d097
|
31
web/vue.config.js
Normal file
31
web/vue.config.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
// vue.config.js
|
||||
|
||||
module.exports = {
|
||||
devServer: {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
"Access-Control-Allow-Methods": "*"
|
||||
},
|
||||
proxy: {
|
||||
'^/media/2': {
|
||||
target: 'https://staging.c3lf.de/',
|
||||
changeOrigin: true
|
||||
},
|
||||
'^/api/2': {
|
||||
target: 'https://staging.c3lf.de/',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'^/api/1': {
|
||||
target: 'https://staging.c3lf.de/',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'^/ws/2': {
|
||||
target: 'http://127.0.0.1:8082/',
|
||||
//changeOrigin: true,
|
||||
ws: true,
|
||||
logLevel: 'debug',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue