Compare commits

..

1 commit

Author SHA1 Message Date
b3f107d596 [WIP] add user notifications and text templates 2024-11-17 00:38:52 +01:00
63 changed files with 1724 additions and 1389 deletions

View file

@ -1,6 +1,5 @@
on:
pull_request:
push:
jobs:
test:

View file

@ -15,11 +15,9 @@ import sys
import dotenv
from pathlib import Path
def truthy_str(s):
return s.lower() in ['true', '1', 't', 'y', 'yes', 'yeah', 'yup', 'certainly', 'sure', 'positive', 'uh-huh', '👍']
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@ -49,6 +47,10 @@ SYSTEM3_VERSION = "0.0.0-dev.0"
ACTIVE_SPAM_TRAINING = truthy_str(os.getenv('ACTIVE_SPAM_TRAINING', 'False'))
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 = [
@ -65,6 +67,7 @@ INSTALLED_APPS = [
'drf_yasg',
'channels',
'authentication',
'notifications',
'files',
'tickets',
'inventory',
@ -146,8 +149,7 @@ else:
'USER': os.getenv('DB_USER', 'system3'),
'PASSWORD': os.getenv('DB_PASSWORD', 'system3'),
'OPTIONS': {
'charset': 'utf8mb4',
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"
'charset': 'utf8mb4'
}
}
}

View file

@ -21,6 +21,9 @@ from .version import get_info
urlpatterns = [
path('djangoadmin/', admin.site.urls),
path('api/1/', include('inventory.api_v1')),
path('api/1/', include('files.api_v1')),
path('api/1/', include('files.media_v1')),
path('api/2/', include('inventory.api_v2')),
path('api/2/', include('files.api_v2')),
path('media/2/', include('files.media_v2')),
@ -28,6 +31,7 @@ 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),
path('', include('django_prometheus.urls')),
]

16
core/core/wsgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()

27
core/files/api_v1.py Normal file
View file

@ -0,0 +1,27 @@
from rest_framework import serializers, viewsets, routers
from files.models import File
class FileSerializer(serializers.ModelSerializer):
data = serializers.CharField(max_length=1000000, write_only=True)
class Meta:
model = File
fields = ['hash', 'data']
read_only_fields = ['hash']
class FileViewSet(viewsets.ModelViewSet):
serializer_class = FileSerializer
queryset = File.objects.all()
lookup_field = 'hash'
permission_classes = []
authentication_classes = []
router = routers.SimpleRouter(trailing_slash=False)
router.register(r'files', FileViewSet, basename='files')
router.register(r'file', FileViewSet, basename='files')
urlpatterns = router.urls

65
core/files/media_v1.py Normal file
View file

@ -0,0 +1,65 @@
import os
from django.http import HttpResponse
from django.urls import path
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.response import Response
from core.settings import MEDIA_ROOT
from files.models import File
@swagger_auto_schema(method='GET', auto_schema=None)
@api_view(['GET'])
@permission_classes([])
@authentication_classes([])
def media_urls(request, hash):
try:
file = File.objects.get(hash=hash)
hash_path = file.file
return HttpResponse(status=status.HTTP_200_OK,
content_type=file.mime_type,
headers={
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
'Access-Control-Allow-Origin': '*',
}) # TODO Expires and Cache-Control
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
@swagger_auto_schema(method='GET', auto_schema=None)
@api_view(['GET'])
@permission_classes([])
@authentication_classes([])
def thumbnail_urls(request, hash):
size = 200
try:
file = File.objects.get(hash=hash)
hash_path = file.file
if not os.path.exists(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}'):
from PIL import Image
image = Image.open(file.file)
image.thumbnail((size, size))
rgb_image = image.convert('RGB')
thumb_dir = os.path.dirname(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}')
if not os.path.exists(thumb_dir):
os.makedirs(thumb_dir)
rgb_image.save(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}', 'jpeg', quality=90)
return HttpResponse(status=status.HTTP_200_OK,
content_type="image/jpeg",
headers={
'X-Accel-Redirect': f'/redirect_thumbnail/{size}/{hash_path}',
'Access-Control-Allow-Origin': '*',
}) # TODO Expires and Cache-Control
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
urlpatterns = [
path('thumbs/<path:hash>', thumbnail_urls),
path('images/<path:hash>', media_urls),
]

View file

@ -1,24 +0,0 @@
# Generated by Django 4.2.7 on 2024-11-21 22:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('files', '0002_alter_file_file'),
]
def set_creation_date(apps, schema_editor):
File = apps.get_model('files', 'File')
for file in File.objects.all():
if file.created_at is None:
if not file.item.created_at is None:
file.created_at = file.item.created_at
else:
file.created_at = max(File.objects.filter(
id__lt=file.id, created_at__isnull=False).values_list('created_at', flat=True))
file.save()
operations = [
migrations.RunPython(set_creation_date),
]

View file

View file

@ -0,0 +1,68 @@
from django.test import TestCase, Client
from django.core.files.base import ContentFile
from files.models import File
from inventory.models import Event, Container, Item
client = Client()
class FileTestCase(TestCase):
def setUp(self):
super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event')
self.box = Container.objects.create(name='BOX')
def test_create_file_raw(self):
from hashlib import sha256
content = b"foo"
chash = sha256(content).hexdigest()
item = Item.objects.create(container=self.box, event=self.event, description='1')
file = File.objects.create(file=ContentFile(b"foo"), mime_type='text/plain', hash=chash, item=item)
file.save()
self.assertEqual(1, len(File.objects.all()))
self.assertEqual(content, File.objects.all()[0].file.read())
self.assertEqual(chash, File.objects.all()[0].hash)
def test_list_files(self):
import base64
item = File.objects.create(data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8'))
response = client.get('/api/1/files')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()[0]['hash'], item.hash)
self.assertEqual(len(response.json()[0]['hash']), 64)
self.assertEqual(len(File.objects.all()), 1)
self.assertEqual(File.objects.all()[0].file.read(), b"foo")
def test_one_file(self):
import base64
item = File.objects.create(data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8'))
response = client.get(f'/api/1/file/{item.hash}')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['hash'], item.hash)
self.assertEqual(len(response.json()['hash']), 64)
self.assertEqual(len(File.objects.all()), 1)
self.assertEqual(File.objects.all()[0].file.read(), b"foo")
def test_create_file(self):
import base64
Item.objects.create(container=self.box, event=self.event, description='1')
item = Item.objects.create(container=self.box, event=self.event, description='2')
response = client.post('/api/1/file',
{'data': "data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')},
content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(len(response.json()['hash']), 64)
self.assertEqual(len(File.objects.all()), 1)
self.assertEqual(File.objects.all()[0].file.read(), b"foo")
def test_delete_file(self):
import base64
item = Item.objects.create(container=self.box, event=self.event, description='1')
File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8'))
file = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"bar").decode('utf-8'))
self.assertEqual(len(File.objects.all()), 2)
response = client.delete(f'/api/1/file/{file.hash}')
self.assertEqual(response.status_code, 204)

View file

@ -1,6 +1,6 @@
from django.contrib import admin
from inventory.models import Item, Container, ItemPlacement, Comment, Event
from inventory.models import Item, Container, Event
class ItemAdmin(admin.ModelAdmin):
@ -22,17 +22,3 @@ class EventAdmin(admin.ModelAdmin):
admin.site.register(Event, EventAdmin)
class ItemPlacementAdmin(admin.ModelAdmin):
pass
admin.site.register(ItemPlacement, ItemPlacementAdmin)
class CommentAdmin(admin.ModelAdmin):
pass
admin.site.register(Comment, CommentAdmin)

150
core/inventory/api_v1.py Normal file
View file

@ -0,0 +1,150 @@
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
from rest_framework.response import Response
from files.models import File
from inventory.models import Event, Container, Item
from inventory.serializers import EventSerializer, ContainerSerializer
class EventViewSet(viewsets.ModelViewSet):
serializer_class = EventSerializer
queryset = Event.objects.all()
permission_classes = []
authentication_classes = []
class ContainerViewSet(viewsets.ModelViewSet):
serializer_class = ContainerSerializer
queryset = Container.objects.all()
permission_classes = []
authentication_classes = []
class ItemSerializer(serializers.ModelSerializer):
dataImage = serializers.CharField(write_only=True, required=False)
cid = serializers.SerializerMethodField()
box = serializers.SerializerMethodField()
file = serializers.SerializerMethodField()
class Meta:
model = Item
fields = ['cid', 'box', 'uid', 'description', 'file', 'dataImage']
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 to_internal_value(self, data):
if 'cid' in data:
container = Container.objects.get(cid=data['cid'])
internal = super().to_internal_value(data)
internal['container'] = container
return internal
return super().to_internal_value(data)
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)
@api_view(['GET'])
@permission_classes([])
@authentication_classes([])
def search_items(request, event_slug, query):
try:
event = Event.objects.get(slug=event_slug)
query_tokens = query.split(' ')
q = Item.objects.filter(event=event)
for token in query_tokens:
if token:
q = q.filter(description__icontains=token)
return Response(ItemSerializer(q, many=True).data)
except Event.DoesNotExist:
return Response(status=404)
@api_view(['GET', 'POST'])
@permission_classes([])
@authentication_classes([])
def item(request, event_slug):
try:
event = Event.objects.get(slug=event_slug)
if request.method == 'GET':
return Response(ItemSerializer(Item.objects.filter(event=event), many=True).data)
elif request.method == 'POST':
validated_data = ItemSerializer(data=request.data)
if validated_data.is_valid():
validated_data.save(event=event)
return Response(validated_data.data, status=201)
except Event.DoesNotExist:
return Response(status=404)
@api_view(['GET', 'PUT', 'DELETE'])
@permission_classes([])
@authentication_classes([])
def item_by_id(request, event_slug, id):
try:
event = Event.objects.get(slug=event_slug)
item = Item.objects.get(event=event, uid=id)
if request.method == 'GET':
return Response(ItemSerializer(item).data)
elif request.method == 'PUT':
validated_data = ItemSerializer(item, data=request.data)
if validated_data.is_valid():
validated_data.save()
return Response(validated_data.data)
elif request.method == 'DELETE':
item.delete()
return Response(status=204)
except Item.DoesNotExist:
return Response(status=404)
except Event.DoesNotExist:
return Response(status=404)
urlpatterns = [
re_path('events/?$', EventViewSet.as_view({'get': 'list', 'post': 'create'})),
re_path('events/(?P<pk>[0-9]+)/?$',
EventViewSet.as_view({'get': 'retrieve', 'put': 'update', 'delete': 'destroy'})),
re_path('boxes/?$', ContainerViewSet.as_view({'get': 'list', 'post': 'create'})),
re_path('boxes/(?P<pk>[0-9]+)/?$',
ContainerViewSet.as_view({'get': 'retrieve', 'put': 'update', 'delete': 'destroy'})),
re_path('box/?$', ContainerViewSet.as_view({'get': 'list', 'post': 'create'})),
re_path('box/(?P<pk>[0-9]+)/?$',
ContainerViewSet.as_view({'get': 'retrieve', 'put': 'update', 'delete': 'destroy'})),
re_path('(?P<event_slug>[a-zA-Z0-9]+)/items/?$', item),
re_path('(?P<event_slug>[a-zA-Z0-9]+)/items/(?P<query>[^/]+)/?$', search_items),
re_path('(?P<event_slug>[a-zA-Z0-9]+)/item/?$', item),
re_path('(?P<event_slug>[a-zA-Z0-9]+)/item/(?P<id>[0-9]+)/?$', item_by_id),
]

View file

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

View file

@ -1,52 +0,0 @@
# Generated by Django 4.2.7 on 2024-11-19 22:56
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'),
]
operations = [
migrations.RenameField(
model_name='container',
old_name='cid',
new_name='id',
),
migrations.RenameField(
model_name='event',
old_name='eid',
new_name='id',
),
migrations.RenameField(
model_name='item',
old_name='iid',
new_name='id',
),
migrations.RenameField(
model_name='item',
old_name='uid',
new_name='uid_deprecated',
),
migrations.AlterUniqueTogether(
name='item',
unique_together=set(),
),
migrations.AlterField(
model_name='item',
name='container',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.container'),
),
migrations.AlterField(
model_name='item',
name='event',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.event'),
),
migrations.AlterUniqueTogether(
name='item',
unique_together={('uid_deprecated', 'event')},
),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 4.2.7 on 2024-11-20 01:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('inventory', '0005_rename_cid_container_id_rename_eid_event_id_and_more'),
]
operations = [
migrations.AlterModelTable(
name='event',
table='common_event',
),
]

View file

@ -1,52 +0,0 @@
# Generated by Django 4.2.7 on 2024-11-23 15:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0006_alter_event_table'),
]
def set_initial_container(apps, schema_editor):
Item = apps.get_model('inventory', 'Item')
for item in Item.objects.all():
item.container_history.get_or_create(container=item.container_old)
item.save()
operations = [
migrations.RenameField(
model_name='item',
old_name='container',
new_name='container_old',
),
migrations.CreateModel(
name='ItemPlacement',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('container',
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_history',
to='inventory.container')),
('item',
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='container_history',
to='inventory.item')),
],
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('comment', models.TextField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments',
to='inventory.item')),
],
),
migrations.RunPython(set_initial_container),
migrations.RemoveField(
model_name='item',
name='container_old',
),
]

View file

@ -1,102 +1,56 @@
from itertools import groupby
from django.db import models
from django.core.files.base import ContentFile
from django.db import models, IntegrityError
from django_softdelete.models import SoftDeleteModel, SoftDeleteManager
class ItemManager(SoftDeleteManager):
def create(self, **kwargs):
container = kwargs.pop('container')
if 'uid_deprecated' in kwargs:
raise ValueError('uid_deprecated must not be set manually')
uid_deprecated = Item.all_objects.filter(event=kwargs['event']).count() + 1
kwargs['uid_deprecated'] = uid_deprecated
item = super().create(**kwargs)
item.container = container
return item
if 'uid' in kwargs:
raise ValueError('uid must not be set manually')
uid = Item.all_objects.filter(event=kwargs['event']).count() + 1
kwargs['uid'] = uid
return super().create(**kwargs)
def get_queryset(self):
return super().get_queryset().filter(returned_at__isnull=True)
class Item(SoftDeleteModel):
id = models.AutoField(primary_key=True)
uid_deprecated = models.IntegerField()
iid = models.AutoField(primary_key=True)
uid = models.IntegerField()
description = models.TextField()
event = models.ForeignKey('Event', models.CASCADE)
event = models.ForeignKey('Event', models.CASCADE, db_column='eid')
container = models.ForeignKey('Container', models.CASCADE, db_column='cid')
returned_at = models.DateTimeField(blank=True, null=True)
created_at = models.DateTimeField(null=True, auto_now_add=True)
updated_at = models.DateTimeField(blank=True, null=True)
@property
def container(self):
try:
return self.container_history.order_by('-timestamp').first().container
except AttributeError:
return None
@container.setter
def container(self, value):
if self.container == value:
return
self.container_history.create(container=value)
@property
def related_issues(self):
groups = groupby(self.issue_relation_changes.all(), lambda rel: rel.issue_thread.id)
return [sorted(v, key=lambda r: r.timestamp)[0].issue_thread for k, v in groups]
objects = ItemManager()
all_objects = models.Manager()
class Meta:
unique_together = (('uid_deprecated', 'event'),)
unique_together = (('uid', 'event'),)
permissions = [
('match_item', 'Can match item')
]
def __str__(self):
return '[' + str(self.id) + ']' + self.description
return '[' + str(self.uid) + ']' + self.description
class Container(SoftDeleteModel):
id = models.AutoField(primary_key=True)
cid = models.AutoField(primary_key=True)
name = models.CharField(max_length=255)
created_at = models.DateTimeField(blank=True, null=True)
updated_at = models.DateTimeField(blank=True, null=True)
@property
def items(self):
try:
history = self.item_history.order_by('-timestamp').all()
return [v for k, v in groupby(history, key=lambda item: item.item.id)]
except AttributeError:
return []
def __str__(self):
return '[' + str(self.id) + ']' + self.name
class ItemPlacement(models.Model):
id = models.AutoField(primary_key=True)
item = models.ForeignKey('Item', models.CASCADE, related_name='container_history')
container = models.ForeignKey('Container', models.CASCADE, related_name='item_history')
timestamp = models.DateTimeField(auto_now_add=True)
class Comment(models.Model):
id = models.AutoField(primary_key=True)
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='comments')
comment = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
def __str__(self):
return str(self.item) + ' comment #' + str(self.id)
return '[' + str(self.cid) + ']' + self.name
class Event(models.Model):
id = models.AutoField(primary_key=True)
eid = models.AutoField(primary_key=True)
name = models.CharField(max_length=255)
slug = models.CharField(max_length=255, unique=True)
start = models.DateTimeField(blank=True, null=True)
@ -108,6 +62,3 @@ class Event(models.Model):
def __str__(self):
return '[' + str(self.slug) + ']' + self.name
class Meta:
db_table = 'common_event'

View file

@ -1,29 +1,24 @@
from django.utils import timezone
from rest_framework import serializers
from rest_framework.relations import SlugRelatedField
from files.models import File
from inventory.models import Event, Container, Item
from inventory.shared_serializers import BasicItemSerializer
from mail.models import EventAddress
from tickets.shared_serializers import BasicIssueSerializer
class EventAdressSerializer(serializers.ModelSerializer):
class Meta:
model = EventAddress
fields = ['address']
class EventSerializer(serializers.ModelSerializer):
addresses = SlugRelatedField(many=True, slug_field='address', queryset=EventAddress.objects.all())
addresses = EventAdressSerializer(many=True, required=False)
class Meta:
model = Event
fields = ['id', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end', 'addresses']
read_only_fields = ['id']
def to_internal_value(self, data):
data = data.copy()
addresses = data.pop('addresses', None)
dict = super().to_internal_value(data)
if addresses:
dict['addresses'] = [EventAddress.objects.get_or_create(address=x)[0] for x in addresses]
return dict
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end', 'addresses']
read_only_fields = ['eid']
class ContainerSerializer(serializers.ModelSerializer):
@ -31,29 +26,44 @@ class ContainerSerializer(serializers.ModelSerializer):
class Meta:
model = Container
fields = ['id', 'name', 'itemCount']
read_only_fields = ['id', 'itemCount']
fields = ['cid', 'name', 'itemCount']
read_only_fields = ['cid', 'itemCount']
def get_itemCount(self, instance):
return len(instance.items)
return Item.objects.filter(container=instance.cid).count()
class ItemSerializer(BasicItemSerializer):
timeline = serializers.SerializerMethodField()
class ItemSerializer(serializers.ModelSerializer):
dataImage = serializers.CharField(write_only=True, required=False)
related_issues = BasicIssueSerializer(many=True, read_only=True)
cid = serializers.SerializerMethodField()
box = serializers.SerializerMethodField()
file = serializers.SerializerMethodField()
returned = serializers.SerializerMethodField(required=False)
class Meta:
model = Item
fields = ['cid', 'box', 'id', 'description', 'file', 'dataImage', 'returned', 'event', 'related_issues',
'timeline']
read_only_fields = ['id']
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(id=data['cid'])
container = Container.objects.get(cid=data['cid'])
if 'returned' in data:
returned = data['returned']
internal = super().to_internal_value(data)
@ -64,8 +74,6 @@ class ItemSerializer(BasicItemSerializer):
return internal
def validate(self, attrs):
if not 'container' in attrs and not self.partial:
raise serializers.ValidationError("This field cannot be empty.")
return super().validate(attrs)
def create(self, validated_data):
@ -87,34 +95,3 @@ class ItemSerializer(BasicItemSerializer):
validated_data.pop('dataImage')
instance.files.add(file)
return super().update(instance, validated_data)
@staticmethod
def get_timeline(obj):
timeline = []
for comment in obj.comments.all():
timeline.append({
'type': 'comment',
'id': comment.id,
'timestamp': comment.timestamp,
'comment': comment.comment,
})
for relation in (obj.issue_relation_changes.all()):
timeline.append({
'type': 'issue_relation',
'id': relation.id,
'status': relation.status,
'timestamp': relation.timestamp,
'issue_thread': BasicIssueSerializer(relation.issue_thread).data,
})
return sorted(timeline, key=lambda x: x['timestamp'])
class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField()
item = ItemSerializer()
def to_representation(self, instance):
return {**ItemSerializer(instance['item']).data, 'search_score': instance['search_score']}
class Meta:
model = Item

View file

@ -1,31 +0,0 @@
from rest_framework import serializers
from inventory.models import Event, Item
class BasicItemSerializer(serializers.ModelSerializer):
cid = serializers.SerializerMethodField()
box = serializers.SerializerMethodField()
file = serializers.SerializerMethodField()
returned = serializers.SerializerMethodField(required=False)
event = serializers.SlugRelatedField(slug_field='slug', queryset=Event.objects.all(),
allow_null=True, required=False)
class Meta:
model = Item
fields = ['cid', 'box', 'id', 'description', 'file', 'returned', 'event']
read_only_fields = ['id']
def get_cid(self, instance):
return instance.container.id if instance.container else None
def get_box(self, instance):
return instance.container.name if instance.container else None
def get_file(self, instance):
if len(instance.files.all()) > 0:
return sorted(instance.files.all(), key=lambda x: x.created_at, reverse=True)[0].hash
return None
def get_returned(self, instance):
return instance.returned_at is not None

View file

View file

@ -0,0 +1,34 @@
from django.test import TestCase, Client
client = Client()
class ApiTest(TestCase):
def test_root(self):
from core.settings import SYSTEM3_VERSION
response = client.get('/api/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["framework_version"], SYSTEM3_VERSION)
def test_events(self):
response = client.get('/api/1/events')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
def test_containers(self):
response = client.get('/api/1/boxes')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
def test_files(self):
response = client.get('/api/1/files')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
def test_items(self):
from inventory.models import Event
Event.objects.create(slug='TEST1', name='Event')
response = client.get('/api/1/TEST1/items')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])

View file

@ -0,0 +1,59 @@
from django.test import TestCase, Client
from inventory.models import Container
client = Client()
class ContainerTestCase(TestCase):
def test_empty(self):
response = client.get('/api/1/boxes')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
def test_members(self):
Container.objects.create(name='BOX')
response = client.get('/api/1/boxes')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['cid'], 1)
self.assertEqual(response.json()[0]['name'], 'BOX')
self.assertEqual(response.json()[0]['itemCount'], 0)
def test_multi_members(self):
Container.objects.create(name='BOX 1')
Container.objects.create(name='BOX 2')
Container.objects.create(name='BOX 3')
response = client.get('/api/1/boxes')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 3)
def test_create_container(self):
response = client.post('/api/1/box', {'name': 'BOX'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['cid'], 1)
self.assertEqual(response.json()['name'], 'BOX')
self.assertEqual(response.json()['itemCount'], 0)
self.assertEqual(len(Container.objects.all()), 1)
self.assertEqual(Container.objects.all()[0].cid, 1)
self.assertEqual(Container.objects.all()[0].name, 'BOX')
def test_update_container(self):
from rest_framework.test import APIClient
box = Container.objects.create(name='BOX 1')
response = APIClient().put(f'/api/1/box/{box.cid}', {'name': 'BOX 2'})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['cid'], 1)
self.assertEqual(response.json()['name'], 'BOX 2')
self.assertEqual(response.json()['itemCount'], 0)
self.assertEqual(len(Container.objects.all()), 1)
self.assertEqual(Container.objects.all()[0].cid, 1)
self.assertEqual(Container.objects.all()[0].name, 'BOX 2')
def test_delete_container(self):
box = Container.objects.create(name='BOX 1')
Container.objects.create(name='BOX 2')
self.assertEqual(len(Container.objects.all()), 2)
response = client.delete(f'/api/1/box/{box.cid}')
self.assertEqual(response.status_code, 204)
self.assertEqual(len(Container.objects.all()), 1)

View file

@ -0,0 +1,56 @@
from django.test import TestCase, Client
from inventory.models import Event
client = Client()
class EventTestCase(TestCase):
def test_empty(self):
response = client.get('/api/1/events')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
def test_members(self):
Event.objects.create(slug='EVENT', name='Event')
response = client.get('/api/1/events')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['slug'], 'EVENT')
self.assertEqual(response.json()[0]['name'], 'Event')
def test_multi_members(self):
Event.objects.create(slug='EVENT1', name='Event 1')
Event.objects.create(slug='EVENT2', name='Event 2')
Event.objects.create(slug='EVENT3', name='Event 3')
response = client.get('/api/1/events')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 3)
def test_create_event(self):
response = client.post('/api/1/events', {'slug': 'EVENT', 'name': 'Event'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['slug'], 'EVENT')
self.assertEqual(response.json()['name'], 'Event')
self.assertEqual(len(Event.objects.all()), 1)
self.assertEqual(Event.objects.all()[0].slug, 'EVENT')
self.assertEqual(Event.objects.all()[0].name, 'Event')
def test_update_event(self):
from rest_framework.test import APIClient
event = Event.objects.create(slug='EVENT1', name='Event 1')
response = APIClient().put(f'/api/1/events/{event.eid}', {'slug': 'EVENT2', 'name': 'Event 2 new'})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['slug'], 'EVENT2')
self.assertEqual(response.json()['name'], 'Event 2 new')
self.assertEqual(len(Event.objects.all()), 1)
self.assertEqual(Event.objects.all()[0].slug, 'EVENT2')
self.assertEqual(Event.objects.all()[0].name, 'Event 2 new')
def test_remove_event(self):
event = Event.objects.create(slug='EVENT1', name='Event 1')
Event.objects.create(slug='EVENT2', name='Event 2')
self.assertEqual(len(Event.objects.all()), 2)
response = client.delete(f'/api/1/events/{event.eid}')
self.assertEqual(response.status_code, 204)
self.assertEqual(len(Event.objects.all()), 1)

View file

@ -0,0 +1,133 @@
from django.test import TestCase, Client
from files.models import File
from inventory.models import Event, Container, Item
client = Client()
class ItemTestCase(TestCase):
def setUp(self):
super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event')
self.box = Container.objects.create(name='BOX')
def test_empty(self):
response = client.get(f'/api/1/{self.event.slug}/item')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'[]')
def test_members(self):
item = Item.objects.create(container=self.box, event=self.event, description='1')
response = client.get(f'/api/1/{self.event.slug}/item')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(),
[{'uid': 1, 'description': '1', 'box': 'BOX', 'cid': self.box.cid, 'file': None}])
def test_members_with_file(self):
import base64
item = Item.objects.create(container=self.box, event=self.event, description='1')
file = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8'))
response = client.get(f'/api/1/{self.event.slug}/item')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(),
[{'uid': 1, 'description': '1', 'box': 'BOX', 'cid': self.box.cid, 'file': file.hash}])
def test_multi_members(self):
Item.objects.create(container=self.box, event=self.event, description='1')
Item.objects.create(container=self.box, event=self.event, description='2')
Item.objects.create(container=self.box, event=self.event, description='3')
response = client.get(f'/api/1/{self.event.slug}/item')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 3)
def test_create_item(self):
response = client.post(f'/api/1/{self.event.slug}/item', {'cid': self.box.cid, 'description': '1'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json(),
{'uid': 1, 'description': '1', 'box': 'BOX', 'cid': self.box.cid, 'file': None})
self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].uid, 1)
self.assertEqual(Item.objects.all()[0].description, '1')
self.assertEqual(Item.objects.all()[0].container.cid, self.box.cid)
def test_create_item_with_file(self):
import base64
response = client.post(f'/api/1/{self.event.slug}/item',
{'cid': self.box.cid, 'description': '1',
'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode(
'utf-8')}, content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['uid'], 1)
self.assertEqual(response.json()['description'], '1')
self.assertEqual(response.json()['box'], 'BOX')
self.assertEqual(response.json()['cid'], self.box.cid)
self.assertEqual(len(response.json()['file']), 64)
self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].uid, 1)
self.assertEqual(Item.objects.all()[0].description, '1')
self.assertEqual(Item.objects.all()[0].container.cid, self.box.cid)
self.assertEqual(len(File.objects.all()), 1)
def test_update_item(self):
item = Item.objects.create(container=self.box, event=self.event, description='1')
response = client.put(f'/api/1/{self.event.slug}/item/{item.uid}', {'description': '2'},
content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(),
{'uid': 1, 'description': '2', 'box': 'BOX', 'cid': self.box.cid, 'file': None})
self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].uid, 1)
self.assertEqual(Item.objects.all()[0].description, '2')
self.assertEqual(Item.objects.all()[0].container.cid, self.box.cid)
def test_update_item_with_file(self):
import base64
item = Item.objects.create(container=self.box, event=self.event, description='1')
response = client.put(f'/api/1/{self.event.slug}/item/{item.uid}',
{'description': '2',
'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')},
content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['uid'], 1)
self.assertEqual(response.json()['description'], '2')
self.assertEqual(response.json()['box'], 'BOX')
self.assertEqual(response.json()['cid'], self.box.cid)
self.assertEqual(len(response.json()['file']), 64)
self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].uid, 1)
self.assertEqual(Item.objects.all()[0].description, '2')
self.assertEqual(Item.objects.all()[0].container.cid, self.box.cid)
self.assertEqual(len(File.objects.all()), 1)
def test_delete_item(self):
item = Item.objects.create(container=self.box, event=self.event, description='1')
Item.objects.create(container=self.box, event=self.event, description='2')
self.assertEqual(len(Item.objects.all()), 2)
response = client.delete(f'/api/1/{self.event.slug}/item/{item.uid}')
self.assertEqual(response.status_code, 204)
self.assertEqual(len(Item.objects.all()), 1)
def test_delete_item2(self):
Item.objects.create(container=self.box, event=self.event, description='1')
item2 = Item.objects.create(container=self.box, event=self.event, description='2')
self.assertEqual(len(Item.objects.all()), 2)
response = client.delete(f'/api/1/{self.event.slug}/item/{item2.uid}')
self.assertEqual(response.status_code, 204)
self.assertEqual(len(Item.objects.all()), 1)
item3 = Item.objects.create(container=self.box, event=self.event, description='3')
self.assertEqual(item3.uid, 3)
self.assertEqual(len(Item.objects.all()), 2)
def test_item_count(self):
Item.objects.create(container=self.box, event=self.event, description='1')
Item.objects.create(container=self.box, event=self.event, description='2')
response = client.get('/api/1/boxes')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['itemCount'], 2)
def test_item_nonexistent(self):
response = client.get(f'/api/1/NOEVENT/item')
self.assertEqual(response.status_code, 404)

View file

@ -24,7 +24,7 @@ class ContainerTestCase(TestCase):
response = self.client.get('/api/2/boxes/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['id'], 1)
self.assertEqual(response.json()[0]['cid'], 1)
self.assertEqual(response.json()[0]['name'], 'BOX')
self.assertEqual(response.json()[0]['itemCount'], 0)
@ -39,28 +39,28 @@ class ContainerTestCase(TestCase):
def test_create_container(self):
response = self.client.post('/api/2/box/', {'name': 'BOX'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['cid'], 1)
self.assertEqual(response.json()['name'], 'BOX')
self.assertEqual(response.json()['itemCount'], 0)
self.assertEqual(len(Container.objects.all()), 1)
self.assertEqual(Container.objects.all()[0].id, 1)
self.assertEqual(Container.objects.all()[0].cid, 1)
self.assertEqual(Container.objects.all()[0].name, 'BOX')
def test_update_container(self):
box = Container.objects.create(name='BOX 1')
response = self.client.put(f'/api/2/box/{box.id}/', {'name': 'BOX 2'}, content_type='application/json')
response = self.client.put(f'/api/2/box/{box.cid}/', {'name': 'BOX 2'}, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['cid'], 1)
self.assertEqual(response.json()['name'], 'BOX 2')
self.assertEqual(response.json()['itemCount'], 0)
self.assertEqual(len(Container.objects.all()), 1)
self.assertEqual(Container.objects.all()[0].id, 1)
self.assertEqual(Container.objects.all()[0].cid, 1)
self.assertEqual(Container.objects.all()[0].name, 'BOX 2')
def test_delete_container(self):
box = Container.objects.create(name='BOX 1')
Container.objects.create(name='BOX 2')
self.assertEqual(len(Container.objects.all()), 2)
response = self.client.delete(f'/api/2/box/{box.id}/')
response = self.client.delete(f'/api/2/box/{box.cid}/')
self.assertEqual(response.status_code, 204)
self.assertEqual(len(Container.objects.all()), 1)

View file

@ -39,7 +39,7 @@ class EventTestCase(TestCase):
def test_update_event(self):
from rest_framework.test import APIClient
event = Event.objects.create(slug='EVENT1', name='Event 1')
response = APIClient().put(f'/api/2/events/{event.id}/', {'slug': 'EVENT2', 'name': 'Event 2 new'})
response = APIClient().put(f'/api/2/events/{event.eid}/', {'slug': 'EVENT2', 'name': 'Event 2 new'})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['slug'], 'EVENT2')
self.assertEqual(response.json()['name'], 'Event 2 new')
@ -47,32 +47,18 @@ class EventTestCase(TestCase):
self.assertEqual(Event.objects.all()[0].slug, 'EVENT2')
self.assertEqual(Event.objects.all()[0].name, 'Event 2 new')
def test_update_event(self):
from rest_framework.test import APIClient
event = Event.objects.create(slug='EVENT1', name='Event 1')
response = APIClient().patch(f'/api/2/events/{event.id}/', {'addresses': ['foo@bar.baz', 'foo1@bar.baz']})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['slug'], 'EVENT1')
self.assertEqual(response.json()['name'], 'Event 1')
self.assertEqual(2, len(response.json()['addresses']))
self.assertEqual('foo@bar.baz', response.json()['addresses'][0])
self.assertEqual('foo1@bar.baz', response.json()['addresses'][1])
self.assertEqual(len(Event.objects.all()), 1)
self.assertEqual(Event.objects.all()[0].slug, 'EVENT1')
self.assertEqual(Event.objects.all()[0].name, 'Event 1')
def test_remove_event(self):
event = Event.objects.create(slug='EVENT1', name='Event 1')
Event.objects.create(slug='EVENT2', name='Event 2')
self.assertEqual(len(Event.objects.all()), 2)
response = client.delete(f'/api/2/events/{event.id}/')
response = client.delete(f'/api/2/events/{event.eid}/')
self.assertEqual(response.status_code, 204)
self.assertEqual(len(Event.objects.all()), 1)
def test_event_with_address(self):
def test_items2(self):
from mail.models import EventAddress
event1 = Event.objects.create(slug='TEST1', name='Event')
EventAddress.objects.create(event=event1, address='foo@bar.baz')
EventAddress.objects.create(event=Event.objects.get(slug='TEST1'), address='foo@bar.baz')
response = self.client.get('/api/2/events/')
self.assertEqual(response.status_code, 200)
self.assertEqual(1, len(response.json()))
@ -80,15 +66,3 @@ class EventTestCase(TestCase):
self.assertEqual('Event', response.json()[0]['name'])
self.assertEqual(1, len(response.json()[0]['addresses']))
def test_items_remove_addresss(self):
from mail.models import EventAddress
from rest_framework.test import APIClient
event1 = Event.objects.create(slug='TEST1', name='Event')
EventAddress.objects.create(event=event1, address='foo@bar.baz')
EventAddress.objects.create(event=event1, address='fo1o@bar.baz')
response = APIClient().patch(f'/api/2/events/{event1.id}/', {'addresses': ['foo1@bar.baz']})
self.assertEqual(response.status_code, 200)
self.assertEqual('TEST1', response.json()['slug'])
self.assertEqual('Event', response.json()['name'])
self.assertEqual(1, len(response.json()['addresses']))
self.assertEqual('foo1@bar.baz', response.json()['addresses'][0])

View file

@ -1,5 +1,3 @@
from datetime import datetime, timedelta
from django.utils import timezone
from django.test import TestCase, Client
from django.contrib.auth.models import Permission
@ -7,11 +5,7 @@ from knox.models import AuthToken
from authentication.models import ExtendedUser
from files.models import File
from inventory.models import Event, Container, Item, Comment
from base64 import b64encode
from tickets.models import IssueThread, ItemRelation
from inventory.models import Event, Container, Item
class ItemTestCase(TestCase):
@ -24,57 +18,19 @@ class ItemTestCase(TestCase):
self.user.user_permissions.add(*Permission.objects.all())
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
self.issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
def test_empty(self):
response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'[]')
def test_members_and_timeline(self):
now = datetime.now()
def test_members(self):
item = Item.objects.create(container=self.box, event=self.event, description='1')
comment = Comment.objects.create(
item=item,
comment="test",
timestamp=now + timedelta(seconds=3),
)
match = ItemRelation.objects.create(
issue_thread=self.issue,
item = item,
timestamp=now + timedelta(seconds=5),
)
response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['id'], item.id)
self.assertEqual(response.json()[0]['description'], '1')
self.assertEqual(response.json()[0]['box'], 'BOX')
self.assertEqual(response.json()[0]['cid'], self.box.id)
self.assertEqual(response.json()[0]['file'], None)
self.assertEqual(response.json()[0]['returned'], False)
self.assertEqual(response.json()[0]['event'], self.event.slug)
self.assertEqual(len(response.json()[0]['timeline']), 2)
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'issue_relation')
self.assertEqual(response.json()[0]['timeline'][0]['id'], comment.id)
self.assertEqual(response.json()[0]['timeline'][1]['id'], match.id)
self.assertEqual(response.json()[0]['timeline'][0]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][0]['timestamp'],
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][1]['status'], 'possible')
self.assertEqual(response.json()[0]['timeline'][1]['timestamp'],
match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][1]['issue_thread']['name'], "test issue")
self.assertEqual(response.json()[0]['timeline'][1]['issue_thread']['event'], "EVENT")
self.assertEqual(response.json()[0]['timeline'][1]['issue_thread']['state'], "pending_new")
self.assertEqual(len(response.json()[0]['related_issues']), 1)
self.assertEqual(response.json()[0]['related_issues'][0]['name'], "test issue")
self.assertEqual(response.json()[0]['related_issues'][0]['event'], "EVENT")
self.assertEqual(response.json()[0]['related_issues'][0]['state'], "pending_new")
self.assertEqual(response.json(),
[{'uid': 1, 'description': '1', 'box': 'BOX', 'cid': self.box.cid, 'file': None,
'returned': False}])
def test_members_with_file(self):
import base64
@ -82,32 +38,9 @@ class ItemTestCase(TestCase):
file = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8'))
response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['id'], item.id)
self.assertEqual(response.json()[0]['description'], '1')
self.assertEqual(response.json()[0]['box'], 'BOX')
self.assertEqual(response.json()[0]['cid'], self.box.id)
self.assertEqual(response.json()[0]['file'], file.hash)
self.assertEqual(response.json()[0]['returned'], False)
self.assertEqual(response.json()[0]['event'], self.event.slug)
self.assertEqual(len(response.json()[0]['related_issues']), 0)
def test_members_with_two_file(self):
import base64
item = Item.objects.create(container=self.box, event=self.event, description='1')
file1 = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8'))
file2 = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"bar").decode('utf-8'))
response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['id'], item.id)
self.assertEqual(response.json()[0]['description'], '1')
self.assertEqual(response.json()[0]['box'], 'BOX')
self.assertEqual(response.json()[0]['cid'], self.box.id)
self.assertEqual(response.json()[0]['file'], file2.hash)
self.assertEqual(response.json()[0]['returned'], False)
self.assertEqual(response.json()[0]['event'], self.event.slug)
self.assertEqual(len(response.json()[0]['related_issues']), 0)
self.assertEqual(response.json(),
[{'uid': 1, 'description': '1', 'box': 'BOX', 'cid': self.box.cid, 'file': file.hash,
'returned': False}])
def test_multi_members(self):
Item.objects.create(container=self.box, event=self.event, description='1')
@ -118,89 +51,71 @@ class ItemTestCase(TestCase):
self.assertEqual(len(response.json()), 3)
def test_create_item(self):
response = self.client.post(f'/api/2/{self.event.slug}/item/', {'cid': self.box.id, 'description': '1'})
response = self.client.post(f'/api/2/{self.event.slug}/item/', {'cid': self.box.cid, 'description': '1'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['description'], '1')
self.assertEqual(response.json()['box'], 'BOX')
self.assertEqual(response.json()['cid'], self.box.id)
self.assertEqual(response.json()['file'], None)
self.assertEqual(response.json()['returned'], False)
self.assertEqual(response.json()['event'], self.event.slug)
self.assertEqual(len(response.json()['related_issues']), 0)
self.assertEqual(response.json(),
{'uid': 1, 'description': '1', 'box': 'BOX', 'cid': self.box.cid, 'file': None,
'returned': False})
self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].uid, 1)
self.assertEqual(Item.objects.all()[0].description, '1')
self.assertEqual(Item.objects.all()[0].container.id, self.box.id)
def test_create_item_without_container(self):
response = self.client.post(f'/api/2/{self.event.slug}/item/', {'description': '1'})
self.assertEqual(response.status_code, 400)
def test_create_item_without_description(self):
response = self.client.post(f'/api/2/{self.event.slug}/item/', {'cid': self.box.id})
self.assertEqual(response.status_code, 400)
self.assertEqual(Item.objects.all()[0].container.cid, self.box.cid)
def test_create_item_with_file(self):
import base64
response = self.client.post(f'/api/2/{self.event.slug}/item/',
{'cid': self.box.id, 'description': '1',
{'cid': self.box.cid, 'description': '1',
'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode(
'utf-8')}, content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['uid'], 1)
self.assertEqual(response.json()['description'], '1')
self.assertEqual(response.json()['box'], 'BOX')
self.assertEqual(response.json()['id'], self.box.id)
self.assertEqual(response.json()['cid'], self.box.cid)
self.assertEqual(len(response.json()['file']), 64)
self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].uid, 1)
self.assertEqual(Item.objects.all()[0].description, '1')
self.assertEqual(Item.objects.all()[0].container.id, self.box.id)
self.assertEqual(Item.objects.all()[0].container.cid, self.box.cid)
self.assertEqual(len(File.objects.all()), 1)
def test_update_item(self):
item = Item.objects.create(container=self.box, event=self.event, description='1')
response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/', {'description': '2'},
response = self.client.put(f'/api/2/{self.event.slug}/item/{item.uid}/', {'description': '2'},
content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['id'], item.id)
self.assertEqual(response.json()['description'], '2')
self.assertEqual(response.json()['box'], 'BOX')
self.assertEqual(response.json()['cid'], self.box.id)
self.assertEqual(response.json()['file'], None)
self.assertEqual(response.json()['returned'], False)
self.assertEqual(response.json()['event'], self.event.slug)
self.assertEqual(len(response.json()['related_issues']), 0)
self.assertEqual(response.json(),
{'uid': 1, 'description': '2', 'box': 'BOX', 'cid': self.box.cid, 'file': None,
'returned': False})
self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].uid, 1)
self.assertEqual(Item.objects.all()[0].description, '2')
self.assertEqual(Item.objects.all()[0].container.id, self.box.id)
self.assertEqual(Item.objects.all()[0].container.cid, self.box.cid)
def test_update_item_with_file(self):
import base64
item = Item.objects.create(container=self.box, event=self.event, description='1')
response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/',
response = self.client.put(f'/api/2/{self.event.slug}/item/{item.uid}/',
{'description': '2',
'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')},
content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['uid'], 1)
self.assertEqual(response.json()['description'], '2')
self.assertEqual(response.json()['box'], 'BOX')
self.assertEqual(response.json()['id'], self.box.id)
self.assertEqual(response.json()['cid'], self.box.cid)
self.assertEqual(len(response.json()['file']), 64)
self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].uid, 1)
self.assertEqual(Item.objects.all()[0].description, '2')
self.assertEqual(Item.objects.all()[0].container.id, self.box.id)
self.assertEqual(Item.objects.all()[0].container.cid, self.box.cid)
self.assertEqual(len(File.objects.all()), 1)
def test_delete_item(self):
item = Item.objects.create(container=self.box, event=self.event, description='1')
Item.objects.create(container=self.box, event=self.event, description='2')
self.assertEqual(len(Item.objects.all()), 2)
response = self.client.delete(f'/api/2/{self.event.slug}/item/{item.id}/')
response = self.client.delete(f'/api/2/{self.event.slug}/item/{item.uid}/')
self.assertEqual(response.status_code, 204)
self.assertEqual(len(Item.objects.all()), 1)
@ -208,11 +123,11 @@ class ItemTestCase(TestCase):
Item.objects.create(container=self.box, event=self.event, description='1')
item2 = Item.objects.create(container=self.box, event=self.event, description='2')
self.assertEqual(len(Item.objects.all()), 2)
response = self.client.delete(f'/api/2/{self.event.slug}/item/{item2.id}/')
response = self.client.delete(f'/api/2/{self.event.slug}/item/{item2.uid}/')
self.assertEqual(response.status_code, 204)
self.assertEqual(len(Item.objects.all()), 1)
item3 = Item.objects.create(container=self.box, event=self.event, description='3')
self.assertEqual(item3.id, 3)
self.assertEqual(item3.uid, 3)
self.assertEqual(len(Item.objects.all()), 2)
def test_item_count(self):
@ -233,7 +148,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()), 1)
response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/', {'returned': True},
response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.uid}/', {'returned': True},
content_type='application/json')
self.assertEqual(response.status_code, 200)
item.refresh_from_db()
@ -253,75 +168,4 @@ 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()), 1)
self.assertEqual(response.json()[0]['id'], item1.id)
class ItemSearchTestCase(TestCase):
def setUp(self):
super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event')
self.box = Container.objects.create(name='BOX')
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all())
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
self.item1 = Item.objects.create(container=self.box, event=self.event, description='abc def')
self.item2 = Item.objects.create(container=self.box, event=self.event, description='def ghi')
self.item3 = Item.objects.create(container=self.box, event=self.event, description='jkl mno pqr')
self.item4 = Item.objects.create(container=self.box, event=self.event, description='stu vwx')
def test_search(self):
search_query = b64encode(b'abc').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(self.item1.id, response.json()[0]['id'])
self.assertEqual('abc def', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box'])
self.assertEqual(self.box.id, response.json()[0]['cid'])
self.assertEqual(1, response.json()[0]['search_score'])
def test_search2(self):
search_query = b64encode(b'def').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(2, len(response.json()))
self.assertEqual(self.item1.id, response.json()[0]['id'])
self.assertEqual('abc def', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box'])
self.assertEqual(self.box.id, response.json()[0]['cid'])
self.assertEqual(1, response.json()[0]['search_score'])
self.assertEqual(self.item2.id, response.json()[1]['id'])
self.assertEqual('def ghi', response.json()[1]['description'])
self.assertEqual('BOX', response.json()[1]['box'])
self.assertEqual(self.box.id, response.json()[1]['cid'])
self.assertEqual(1, response.json()[0]['search_score'])
def test_search3(self):
search_query = b64encode(b'jkl').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(self.item3.id, response.json()[0]['id'])
self.assertEqual('jkl mno pqr', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box'])
self.assertEqual(self.box.id, response.json()[0]['cid'])
self.assertEqual(1, response.json()[0]['search_score'])
def test_search4(self):
search_query = b64encode(b'abc def').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(2, len(response.json()))
self.assertEqual(self.item1.id, response.json()[0]['id'])
self.assertEqual('abc def', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box'])
self.assertEqual(self.box.id, response.json()[0]['cid'])
self.assertEqual(2, response.json()[0]['search_score'])
self.assertEqual(self.item2.id, response.json()[1]['id'])
self.assertEqual('def ghi', response.json()[1]['description'])
self.assertEqual('BOX', response.json()[1]['box'])
self.assertEqual(self.box.id, response.json()[1]['cid'])
self.assertEqual(1, response.json()[1]['search_score'])
self.assertEqual(response.json()[0]['uid'], item1.uid)

View file

@ -29,13 +29,13 @@ class Email(SoftDeleteModel):
self.save()
def train_spam(self):
if ACTIVE_SPAM_TRAINING and self.raw_file.path:
if ACTIVE_SPAM_TRAINING:
import subprocess
path = self.raw_file.path
subprocess.run(["rspamc", "learn_spam", path])
def train_ham(self):
if ACTIVE_SPAM_TRAINING and self.raw_file.path:
if ACTIVE_SPAM_TRAINING:
import subprocess
path = self.raw_file.path
subprocess.run(["rspamc", "learn_ham", path])

View file

@ -7,6 +7,7 @@ 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
@ -87,11 +88,27 @@ def make_reply(reply_email, references=None, event=None):
return reply
def make_notification(message, to, title): # TODO where should replies to this go
from email.message import EmailMessage
from core.settings import MAIL_DOMAIN
notification = EmailMessage()
notification["From"] = "notifications@%s" % MAIL_DOMAIN
notification["To"] = to
notification["Subject"] = f"[C3LF Notification]%s" % title
# notification["Reply-To"] = f"{event}@{MAIL_DOMAIN}"
# notification["In-Reply-To"] = email.reference
# notification["Message-ID"] = email.id + "@" + MAIL_DOMAIN
notification.set_content(message)
return notification
async def send_smtp(message):
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
def find_active_issue_thread(in_reply_to, address, subject, event):
def find_active_issue_thread(in_reply_to, address, subject):
from re import match
uuid_match = match(r'^ticket\+([a-f0-9-]{36})@', address)
if uuid_match:
@ -102,7 +119,7 @@ def find_active_issue_thread(in_reply_to, address, subject, event):
if reply_to.exists():
return reply_to.first().issue_thread, False
else:
issue = IssueThread.objects.create(name=subject, event=event)
issue = IssueThread.objects.create(name=subject)
return issue, True
@ -202,14 +219,11 @@ def receive_email(envelope, log=None):
subject = unescape_and_decode_base64(subject)
target_event = find_target_event(recipient)
active_issue_thread, new = find_active_issue_thread(header_in_reply_to, recipient, subject, target_event)
from hashlib import sha256
random_filename = 'mail-' + sha256(envelope.content).hexdigest()
active_issue_thread, new = find_active_issue_thread(header_in_reply_to, recipient, subject)
email = Email.objects.create(
sender=sender, recipient=recipient, body=body, subject=subject, reference=header_message_id,
in_reply_to=header_in_reply_to, raw_file=ContentFile(envelope.content, name=random_filename), event=target_event,
in_reply_to=header_in_reply_to, raw_file=ContentFile(envelope.content), event=target_event,
issue_thread=active_issue_thread)
for attachment in attachments:
email.attachments.add(attachment)
@ -221,16 +235,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)
@ -273,11 +278,19 @@ class LMTPHandler:
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
"message": "email received"})
log.info(f"Sent message to frontend")
if new and reply:
log.info('Sending message to %s' % reply['To'])
await send_smtp(reply)
log.info("Sent auto reply")
if thread:
await channel_layer.group_send(
'general', {"type": "generic.event", "name": "user_notification", "event_id": systemevent.id,
"ticket_id": thread.id, "new": new})
else:
print("No thread found")
return '250 Message accepted for delivery'
except SpecialMailException as e:
import uuid

View file

@ -142,7 +142,6 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('test ä', Email.objects.all()[0].subject)
self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_quoted_printable_2(self):
from aiosmtpd.smtp import Envelope
@ -163,7 +162,6 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('suche_Mütze', Email.objects.all()[0].subject)
self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_base64(self):
from aiosmtpd.smtp import Envelope
@ -184,7 +182,6 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('test', Email.objects.all()[0].subject)
self.assertEqual('Text mit Base64-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_client_reply(self):
issue_thread = IssueThread.objects.create(
@ -232,7 +229,6 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
self.assertEqual(IssueThread.objects.all()[0].name, 'test')
self.assertEqual(IssueThread.objects.all()[0].state, 'pending_new')
self.assertEqual(IssueThread.objects.all()[0].assigned_to, None)
self.assertTrue( Email.objects.all()[2].raw_file.path)
def test_handle_client_reply_2(self):
issue_thread = IssueThread.objects.create(
@ -285,7 +281,6 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
self.assertEqual(IssueThread.objects.all()[0].name, 'test')
self.assertEqual(IssueThread.objects.all()[0].state, 'pending_open')
self.assertEqual(IssueThread.objects.all()[0].assigned_to, None)
self.assertTrue( Email.objects.all()[2].raw_file.path)
def test_mail_reply(self):
issue_thread = IssueThread.objects.create(
@ -389,7 +384,6 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
self.assertEqual(1, len(states))
self.assertEqual('pending_new', states[0].state)
self.assertEqual(event, IssueThread.objects.all()[0].event)
def test_mail_html_body(self):
from aiosmtpd.smtp import Envelope

View file

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

View file

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

View file

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

View file

@ -1,4 +1,4 @@
from base64 import b64decode
import logging
from django.urls import re_path
from django.contrib.auth.decorators import permission_required
@ -14,24 +14,13 @@ from inventory.models import Event
from mail.models import Email
from mail.protocol import send_smtp, make_reply, collect_references
from notify_sessions.models import SystemEvent
from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher, ItemRelation
from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer, SearchResultSerializer
from tickets.shared_serializers import RelationSerializer
from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher
from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer
class IssueViewSet(viewsets.ModelViewSet):
serializer_class = IssueSerializer
queryset = IssueThread.objects.all().prefetch_related('state_changes', 'comments', 'emails', 'emails__attachments', 'assignments', 'item_relation_changes', 'shipping_vouchers')
class RelationViewSet(viewsets.ModelViewSet):
serializer_class = RelationSerializer
queryset = ItemRelation.objects.all()
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = CommentSerializer
queryset = Comment.objects.all()
queryset = IssueThread.objects.all()
class ShippingVoucherViewSet(viewsets.ModelViewSet):
@ -67,7 +56,7 @@ def reply(request, pk):
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@permission_required('tickets.add_issuethread_manual', raise_exception=True)
def manual_ticket(request, event_slug):
def manual_ticket(request):
if 'name' not in request.data:
return Response({'status': 'error', 'message': 'missing name'}, status=status.HTTP_400_BAD_REQUEST)
if 'sender' not in request.data:
@ -77,16 +66,8 @@ def manual_ticket(request, event_slug):
if 'body' not in request.data:
return Response({'status': 'error', 'message': 'missing body'}, status=status.HTTP_400_BAD_REQUEST)
event = None
if event_slug != 'none':
try:
event = Event.objects.get(slug=event_slug)
except:
return Response({'status': 'error', 'message': 'invalid event'}, status=status.HTTP_400_BAD_REQUEST)
issue = IssueThread.objects.create(
name=request.data['name'],
event=event,
manually_created=True,
)
email = Email.objects.create(
@ -136,40 +117,13 @@ def add_comment(request, pk):
return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
def filter_issues(issues, query):
query_tokens = query.split(' ')
for issue in issues:
value = 0
for token in query_tokens:
if token in issue.description:
value += 1
if value > 0:
yield {'search_score': value, 'issue': issue}
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def search_issues(request, event_slug, query):
try:
event = Event.objects.get(slug=event_slug)
if not request.user.has_event_perm(event, 'view_issuethread'):
return Response(status=403)
items = filter_issues(IssueThread.objects.filter(event=event), b64decode(query).decode('utf-8'))
return Response(SearchResultSerializer(items, many=True).data)
except Event.DoesNotExist:
return Response(status=404)
router = routers.SimpleRouter()
router.register(r'tickets', IssueViewSet, basename='issues')
router.register(r'matches', RelationViewSet, basename='matches')
router.register(r'shipping_vouchers', ShippingVoucherViewSet, basename='shipping_vouchers')
urlpatterns = ([
re_path(r'tickets/states/$', get_available_states, name='get_available_states'),
re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),
re_path(r'^tickets/(?P<pk>\d+)/comment/$', add_comment, name='add_comment'),
re_path(r'^(?P<event_slug>[\w-]+)/tickets/manual/$', manual_ticket, name='manual_ticket'),
re_path(r'^(?P<event_slug>[\w-]+)/tickets/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_issues,
name='search_issues'),
re_path(r'^tickets/manual/$', manual_ticket, name='manual_ticket'),
re_path(r'^tickets/states/$', get_available_states, name='get_available_states'),
] + router.urls)

View file

@ -1,29 +0,0 @@
# Generated by Django 4.2.7 on 2024-11-20 23:58
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0006_alter_event_table'),
('tickets', '0011_train_old_spam'),
]
operations = [
migrations.RemoveField(
model_name='issuethread',
name='related_items',
),
migrations.AlterField(
model_name='itemrelation',
name='issue_thread',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_relation_changes', to='tickets.issuethread'),
),
migrations.AlterField(
model_name='itemrelation',
name='item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_relation_changes', to='inventory.item'),
),
]

View file

@ -1,7 +1,5 @@
from itertools import groupby
from django.utils import timezone
from django.db import models
from django.utils import timezone
from django_softdelete.models import SoftDeleteModel
from authentication.models import ExtendedUser
@ -45,6 +43,7 @@ class IssueThread(SoftDeleteModel):
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]
@ -52,11 +51,7 @@ class IssueThread(SoftDeleteModel):
@property
def state(self):
try:
state_changes = sorted(self.state_changes.all(), key=lambda x: x.timestamp, reverse=True)
if state_changes:
return state_changes[0].state
else:
return None
return self.state_changes.order_by('-timestamp').first().state
except AttributeError:
return 'none'
@ -71,11 +66,7 @@ class IssueThread(SoftDeleteModel):
@property
def assigned_to(self):
try:
assignments = sorted(self.assignments.all(), key=lambda x: x.timestamp, reverse=True)
if assignments:
return assignments[0].assigned_to
else:
return None
return self.assignments.order_by('-timestamp').first().assigned_to
except AttributeError:
return None
@ -85,11 +76,6 @@ class IssueThread(SoftDeleteModel):
return
self.assignments.create(assigned_to=value)
@property
def related_items(self):
groups = groupby(self.item_relation_changes.all(), lambda rel: rel.item.id)
return [sorted(v, key=lambda r: r.timestamp)[0].item for k, v in groups]
def __str__(self):
return '[' + str(self.id) + '][' + self.short_uuid() + '] ' + self.name
@ -146,8 +132,8 @@ class Assignment(models.Model):
class ItemRelation(models.Model):
id = models.AutoField(primary_key=True)
issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='item_relation_changes')
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='issue_relation_changes')
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')

View file

@ -1,11 +1,9 @@
from rest_framework import serializers
from authentication.models import ExtendedUser
from inventory.models import Event
from inventory.shared_serializers import BasicItemSerializer
from mail.api_v2 import AttachmentSerializer
from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher
from tickets.shared_serializers import BasicIssueSerializer
from inventory.serializers import ItemSerializer
class CommentSerializer(serializers.ModelSerializer):
@ -38,20 +36,24 @@ class ShippingVoucherSerializer(serializers.ModelSerializer):
read_only_fields = ('id', 'timestamp', 'used_at')
class IssueSerializer(BasicIssueSerializer):
class IssueSerializer(serializers.ModelSerializer):
timeline = serializers.SerializerMethodField()
last_activity = serializers.SerializerMethodField()
related_items = BasicItemSerializer(many=True, read_only=True)
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', 'related_items', 'event')
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']
return ret
def validate(self, attrs):
@ -63,12 +65,14 @@ class IssueSerializer(BasicIssueSerializer):
@staticmethod
def get_last_activity(self):
try:
last_state_change = max([t.timestamp for t in self.state_changes.all()]) if self.state_changes.exists() else None
last_comment = max([t.timestamp for t in self.comments.all()]) if self.comments.exists() else None
last_mail = max([t.timestamp for t in self.emails.all()]) if self.emails.exists() else None
last_assignment = max([t.timestamp for t in self.assignments.all()]) if self.assignments.exists() else None
last_relation = max([t.timestamp for t in self.item_relation_changes.all()]) if self.item_relation_changes.exists() else None
last_state_change = self.state_changes.order_by('-timestamp').first().timestamp \
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
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)
@ -110,13 +114,13 @@ class IssueSerializer(BasicIssueSerializer):
'timestamp': assignment.timestamp,
'assigned_to': assignment.assigned_to.username,
})
for relation in (obj.item_relation_changes.all()):
for relation in obj.item_relations.all():
timeline.append({
'type': 'item_relation',
'id': relation.id,
'status': relation.status,
'timestamp': relation.timestamp,
'item': BasicItemSerializer(relation.item).data,
'item': ItemSerializer(relation.item).data,
})
for shipping_voucher in obj.shipping_vouchers.all():
timeline.append({
@ -128,14 +132,5 @@ class IssueSerializer(BasicIssueSerializer):
})
return sorted(timeline, key=lambda x: x['timestamp'])
class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField()
item = IssueSerializer()
def to_representation(self, instance):
return {**IssueSerializer(instance['item']).data, 'search_score': instance['search_score']}
class Meta:
model = IssueThread
def get_queryset(self):
return IssueThread.objects.all().order_by('-last_activity')

View file

@ -1,23 +0,0 @@
from rest_framework import serializers
from authentication.models import ExtendedUser
from inventory.models import Event
from tickets.models import IssueThread, ItemRelation
class RelationSerializer(serializers.ModelSerializer):
class Meta:
model = ItemRelation
fields = ('id', 'status', 'timestamp', 'item', 'issue_thread')
class BasicIssueSerializer(serializers.ModelSerializer):
assigned_to = serializers.SlugRelatedField(slug_field='username', queryset=ExtendedUser.objects.all(),
allow_null=True, required=False)
event = serializers.SlugRelatedField(slug_field='slug', queryset=Event.objects.all(),
allow_null=True, required=False)
class Meta:
model = IssueThread
fields = ('id', 'name', 'state', 'assigned_to', 'uuid', 'event')
read_only_fields = ('id', 'timeline', 'last_activity', 'uuid', 'related_items')

View file

@ -1,149 +0,0 @@
from datetime import datetime, timedelta
from django.test import TestCase, Client
from authentication.models import ExtendedUser
from inventory.models import Event, Container, Item
from mail.models import Email, EmailAttachment
from tickets.models import IssueThread, StateChange, Comment, ItemRelation
from django.contrib.auth.models import Permission
from knox.models import AuthToken
from base64 import b64encode
class IssueItemMatchApiTest(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.event = Event.objects.create(slug='evt')
self.box = Container.objects.create(name='box1')
self.item = Item.objects.create(container=self.box, description="foo", event=self.event)
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
now = datetime.now()
self.issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
self.mail1 = Email.objects.create(
subject='test',
body='test',
sender='test',
recipient='test',
issue_thread=self.issue,
timestamp=now,
)
self.comment = Comment.objects.create(
issue_thread=self.issue,
comment="test",
timestamp=now + timedelta(seconds=3),
)
self.match = ItemRelation.objects.create(
issue_thread=self.issue,
item=self.item,
timestamp=now + timedelta(seconds=5),
)
def test_issues(self):
response = self.client.get('/api/2/tickets/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['id'], self.issue.id)
self.assertEqual(response.json()[0]['name'], "test issue")
self.assertEqual(response.json()[0]['state'], "pending_new")
self.assertEqual(response.json()[0]['event'], "evt")
self.assertEqual(response.json()[0]['assigned_to'], None)
self.assertEqual(response.json()[0]['uuid'], self.issue.uuid)
self.assertEqual(response.json()[0]['last_activity'], self.match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(len(response.json()[0]['timeline']), 4)
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'state')
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'mail')
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][1]['id'], self.mail1.id)
self.assertEqual(response.json()[0]['timeline'][2]['id'], self.comment.id)
self.assertEqual(response.json()[0]['timeline'][0]['state'], 'pending_new')
self.assertEqual(response.json()[0]['timeline'][1]['sender'], 'test')
self.assertEqual(response.json()[0]['timeline'][1]['recipient'], 'test')
self.assertEqual(response.json()[0]['timeline'][1]['subject'], 'test')
self.assertEqual(response.json()[0]['timeline'][1]['body'], 'test')
self.assertEqual(response.json()[0]['timeline'][1]['timestamp'],
self.mail1.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][2]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'],
self.comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][3]['status'], 'possible')
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
self.match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][3]['item']['description'], "foo")
self.assertEqual(response.json()[0]['timeline'][3]['item']['event'], "evt")
self.assertEqual(response.json()[0]['timeline'][3]['item']['box'], "box1")
self.assertEqual(response.json()[0]['related_items'][0]['description'], "foo")
self.assertEqual(response.json()[0]['related_items'][0]['event'], "evt")
self.assertEqual(response.json()[0]['related_items'][0]['box'], "box1")
def test_members(self):
response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['id'], self.item.id)
self.assertEqual(response.json()[0]['description'], 'foo')
self.assertEqual(response.json()[0]['box'], 'box1')
self.assertEqual(response.json()[0]['cid'], self.box.id)
self.assertEqual(response.json()[0]['file'], None)
self.assertEqual(response.json()[0]['returned'], False)
self.assertEqual(response.json()[0]['event'], self.event.slug)
self.assertEqual(len(response.json()[0]['related_issues']), 1)
self.assertEqual(response.json()[0]['related_issues'][0]['id'], self.issue.id)
self.assertEqual(response.json()[0]['related_issues'][0]['name'], "test issue")
def test_add_match(self):
response = self.client.get('/api/2/matches/')
self.assertEqual(1, len(response.json()))
item = Item.objects.create(container=self.box, event=self.event, description='1')
issue = IssueThread.objects.create(name="test issue", event=self.event)
response = self.client.post(f'/api/2/matches/',
{'item': item.id, 'issue_thread': issue.id},
content_type='application/json')
self.assertEqual(response.status_code, 201)
response = self.client.get('/api/2/matches/')
self.assertEqual(2, len(response.json()))
response = self.client.get('/api/2/tickets/')
self.assertEqual(4, len(response.json()[0]['timeline']))
self.assertEqual('item_relation', response.json()[0]['timeline'][3]['type'])
self.assertEqual('possible', response.json()[0]['timeline'][3]['status'])
self.assertEqual(1, len(response.json()[0]['related_items']))
def test_change_match_state(self):
response = self.client.get('/api/2/matches/')
self.assertEqual(1, len(response.json()))
response = self.client.get('/api/2/tickets/')
self.assertEqual(4, len(response.json()[0]['timeline']))
self.assertEqual('item_relation', response.json()[0]['timeline'][3]['type'])
self.assertEqual('possible', response.json()[0]['timeline'][3]['status'])
self.assertEqual(1, len(response.json()[0]['related_items']))
response = self.client.post(f'/api/2/matches/',
{'item': self.item.id, 'issue_thread': self.issue.id, 'status': 'confirmed'},
content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['status'], 'confirmed')
self.assertEqual(response.json()['id'], 2)
response = self.client.get('/api/2/matches/')
self.assertEqual(2, len(response.json()))
response = self.client.get('/api/2/tickets/')
self.assertEqual(5, len(response.json()[0]['timeline']))
self.assertEqual('item_relation', response.json()[0]['timeline'][3]['type'])
self.assertEqual('possible', response.json()[0]['timeline'][3]['status'])
self.assertEqual('item_relation', response.json()[0]['timeline'][4]['type'])
self.assertEqual('confirmed', response.json()[0]['timeline'][4]['status'])
self.assertEqual(1, len(response.json()[0]['related_items']))

View file

@ -3,14 +3,11 @@ from datetime import datetime, timedelta
from django.test import TestCase, Client
from authentication.models import ExtendedUser
from inventory.models import Event, Container, Item
from mail.models import Email, EmailAttachment
from tickets.models import IssueThread, StateChange, Comment, ItemRelation, Assignment
from tickets.models import IssueThread, StateChange, Comment
from django.contrib.auth.models import Permission
from knox.models import AuthToken
from base64 import b64encode
class IssueApiTest(TestCase):
@ -19,9 +16,6 @@ class IssueApiTest(TestCase):
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all())
self.user.save()
self.event = Event.objects.create(slug='evt')
self.box = Container.objects.create(name='box1')
self.item = Item.objects.create(container=self.box, description="foo", event=self.event)
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
@ -34,7 +28,6 @@ class IssueApiTest(TestCase):
now = datetime.now()
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
mail1 = Email.objects.create(
subject='test',
@ -53,24 +46,14 @@ class IssueApiTest(TestCase):
in_reply_to=mail1.reference,
timestamp=now + timedelta(seconds=2),
)
assignment = Assignment.objects.create(
issue_thread=issue,
assigned_to=self.user,
timestamp=now + timedelta(seconds=3),
)
comment = Comment.objects.create(
issue_thread=issue,
comment="test",
timestamp=now + timedelta(seconds=4),
)
match = ItemRelation.objects.create(
issue_thread=issue,
item=self.item,
timestamp=now + timedelta(seconds=5),
timestamp=now + timedelta(seconds=3),
)
self.assertEqual('pending_new', issue.state)
self.assertEqual('test issue', issue.name)
self.assertEqual(self.user, issue.assigned_to)
self.assertEqual(None, issue.assigned_to)
self.assertEqual(36, len(issue.uuid))
response = self.client.get('/api/2/tickets/')
self.assertEqual(response.status_code, 200)
@ -78,17 +61,14 @@ class IssueApiTest(TestCase):
self.assertEqual(response.json()[0]['id'], issue.id)
self.assertEqual(response.json()[0]['name'], "test issue")
self.assertEqual(response.json()[0]['state'], "pending_new")
self.assertEqual(response.json()[0]['event'], "evt")
self.assertEqual(response.json()[0]['assigned_to'], self.user.username)
self.assertEqual(response.json()[0]['assigned_to'], None)
self.assertEqual(response.json()[0]['uuid'], issue.uuid)
self.assertEqual(response.json()[0]['last_activity'], match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(len(response.json()[0]['timeline']), 6)
self.assertEqual(response.json()[0]['last_activity'], comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(len(response.json()[0]['timeline']), 4)
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'state')
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'mail')
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'mail')
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'assignment')
self.assertEqual(response.json()[0]['timeline'][4]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][5]['type'], 'item_relation')
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][1]['id'], mail1.id)
self.assertEqual(response.json()[0]['timeline'][2]['id'], mail2.id)
self.assertEqual(response.json()[0]['timeline'][3]['id'], comment.id)
@ -105,35 +85,20 @@ class IssueApiTest(TestCase):
self.assertEqual(response.json()[0]['timeline'][2]['body'], 'test')
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'],
mail2.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][3]['assigned_to'], self.user.username)
self.assertEqual(response.json()[0]['timeline'][3]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
assignment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][4]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][4]['timestamp'],
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][5]['status'], 'possible')
self.assertEqual(response.json()[0]['timeline'][5]['timestamp'],
match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][5]['item']['description'], "foo")
self.assertEqual(response.json()[0]['timeline'][5]['item']['event'], "evt")
self.assertEqual(response.json()[0]['timeline'][5]['item']['box'], "box1")
self.assertEqual(response.json()[0]['related_items'][0]['description'], "foo")
self.assertEqual(response.json()[0]['related_items'][0]['event'], "evt")
self.assertEqual(response.json()[0]['related_items'][0]['box'], "box1")
def test_issues_incomplete_timeline(self):
now = datetime.now()
issue1 = IssueThread.objects.create(
name="test issue",
event=self.event,
)
issue2 = IssueThread.objects.create(
name="test issue",
event=self.event,
)
issue3 = IssueThread.objects.create(
name="test issue",
event=self.event,
)
mail1 = Email.objects.create(
subject='test',
@ -153,11 +118,8 @@ class IssueApiTest(TestCase):
self.assertEqual(200, response.status_code)
self.assertEqual(3, len(response.json()))
self.assertEqual(issue1.id, response.json()[0]['id'])
self.assertEqual("evt", response.json()[0]['event'])
self.assertEqual(issue2.id, response.json()[1]['id'])
self.assertEqual("evt", response.json()[1]['event'])
self.assertEqual(issue3.id, response.json()[2]['id'])
self.assertEqual("evt", response.json()[2]['event'])
self.assertEqual(issue1.state_changes.first().timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
response.json()[0]['last_activity'])
self.assertEqual(mail1.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
@ -191,7 +153,6 @@ class IssueApiTest(TestCase):
now = datetime.now()
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
mail1 = Email.objects.create(
subject='test',
@ -228,7 +189,6 @@ class IssueApiTest(TestCase):
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(issue.id, response.json()[0]['id'])
self.assertEqual("evt", response.json()[0]['event'])
self.assertEqual('pending_new', response.json()[0]['state'])
self.assertEqual('test issue', response.json()[0]['name'])
self.assertEqual(None, response.json()[0]['assigned_to'])
@ -270,14 +230,13 @@ class IssueApiTest(TestCase):
self.assertEqual(file2.hash, response.json()[0]['timeline'][1]['attachments'][1]['hash'])
def test_manual_creation(self):
response = self.client.post('/api/2/evt/tickets/manual/',
response = self.client.post('/api/2/tickets/manual/',
{'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'},
content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['state'], 'pending_new')
self.assertEqual(response.json()['name'], 'test issue')
self.assertEqual(response.json()['assigned_to'], None)
self.assertEqual("evt", response.json()['event'])
timeline = response.json()['timeline']
self.assertEqual(len(timeline), 2)
self.assertEqual(timeline[0]['type'], 'state')
@ -288,35 +247,9 @@ class IssueApiTest(TestCase):
self.assertEqual(timeline[1]['subject'], 'test issue')
self.assertEqual(timeline[1]['body'], 'test')
def test_manual_creation_none(self):
response = self.client.post('/api/2/none/tickets/manual/',
{'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'},
content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['state'], 'pending_new')
self.assertEqual(response.json()['name'], 'test issue')
self.assertEqual(response.json()['assigned_to'], None)
self.assertEqual(None, response.json()['event'])
timeline = response.json()['timeline']
self.assertEqual(len(timeline), 2)
self.assertEqual(timeline[0]['type'], 'state')
self.assertEqual(timeline[0]['state'], 'pending_new')
self.assertEqual(timeline[1]['type'], 'mail')
self.assertEqual(timeline[1]['sender'], 'test')
self.assertEqual(timeline[1]['recipient'], 'test')
self.assertEqual(timeline[1]['subject'], 'test issue')
self.assertEqual(timeline[1]['body'], 'test')
def test_manual_creation_invalid(self):
response = self.client.post('/api/2/foobar/tickets/manual/',
{'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'},
content_type='application/json')
self.assertEqual(response.status_code, 400)
def test_post_comment_altenative(self):
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
response = self.client.post(f'/api/2/tickets/{issue.id}/comment/', {'comment': 'test'})
self.assertEqual(response.status_code, 201)
@ -327,7 +260,6 @@ class IssueApiTest(TestCase):
def test_post_alt_comment_empty(self):
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
response = self.client.post(f'/api/2/tickets/{issue.id}/comment/', {'comment': ''})
self.assertEqual(response.status_code, 400)
@ -335,7 +267,6 @@ class IssueApiTest(TestCase):
def test_state_change(self):
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'pending_open'},
content_type='application/json')
@ -353,7 +284,6 @@ class IssueApiTest(TestCase):
def test_state_change_invalid_state(self):
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'invalid'},
content_type='application/json')
@ -362,14 +292,12 @@ class IssueApiTest(TestCase):
def test_assign_user(self):
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'assigned_to': self.user.username},
content_type='application/json')
self.assertEqual(200, response.status_code)
self.assertEqual('pending_new', response.json()['state'])
self.assertEqual('test issue', response.json()['name'])
self.assertEqual("evt", response.json()['event'])
self.assertEqual(self.user.username, response.json()['assigned_to'])
timeline = response.json()['timeline']
self.assertEqual(2, len(timeline))
@ -377,21 +305,3 @@ class IssueApiTest(TestCase):
self.assertEqual('pending_new', timeline[0]['state'])
self.assertEqual('assignment', timeline[1]['type'])
self.assertEqual(self.user.username, timeline[1]['assigned_to'])
class IssueSearchTest(TestCase):
def setUp(self):
super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event')
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all())
self.user.save()
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
def test_search(self):
search_query = b64encode(b'abc').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual([], response.json())

View file

@ -13,3 +13,5 @@ STATIC_ROOT=/var/www/c3lf-sys3/staticfiles
ACTIVE_SPAM_TRAINING=True
DEBUG_MODE_ACTIVE={{ debug_mode_active }}
DJANGO_SECRET_KEY={{ django_secret_key }}
TELEGRAM_GROUP_CHAT_ID={{ telegram_group_chat_id }}
TELEGRAM_BOT_TOKEN={{ telegram_bot_token }}

View file

@ -1,124 +0,0 @@
<template>
<table class="table table-striped table-dark">
<thead>
<tr>
<th>
</th>
<th scope="col" v-for="(column, index) in columns" :key="index">
<div class="input-group">
<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>
</th>
<th>
<slot name="header_actions"/>
</th>
</tr>
</thead>
<tbody v-for="(item, index) in internalItems" :key="item[keyName]" style="border-top: none">
<tr @click="toggle(index)">
<td>
<font-awesome-icon icon="angle-right" style="width: 1em;" v-if="collapsed[index]"/>
<font-awesome-icon icon="angle-down" style="width: 1em;" v-else/>
</td>
<td v-for="(column, index) in columns" :key="index">{{ item[column] }}</td>
<td>
<slot v-bind:id="index" v-bind:item="item" name="actions"/>
</td>
</tr>
<tr v-if="!collapsed[index]">
<td :colspan="columns.length + 2">
<slot v-bind:id="index" v-bind:item="item" name="detail"/>
</td>
</tr>
</tbody>
</table>
</template>
<script>
import DataContainer from '@/mixins/data-container';
import router from '../router';
import {mapGetters} from "vuex";
export default {
name: 'ExpandableTable',
mixins: [DataContainer],
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));
const query = this.route ? (this.route.query ? this.route.query.collapsed : null) : null;
if (query !== null && query !== undefined) {
this.collapsed = this.unpackInt(parseInt(query), this.internalItems.length);
} else {
this.collapsed = this.internalItems.map(() => true);
}
},
data() {
return {
collapsed: [],
};
},
computed: {
...mapGetters(['route']),
},
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});
},
packInt(arr) {
return arr.reduce((a, e, i) => a + (e ? 0 : 2 ** i), 0);
},
unpackInt(n, l) {
return [...Array(l)].map((e, i) => (n & 2 ** i) === 0);
},
toggle(index) {
const collapsed = [...this.collapsed]
collapsed[index] = !collapsed[index];
this.collapsed = collapsed;
},
},
watch: {
collapsed: {
handler() {
const encoded = this.packInt(this.collapsed).toString()
if (this.route.query.collapsed !== encoded)
this.$router.push({
...this.route,
query: {...this.route.query, collapsed: encoded}
});
},
deep: true,
},
},
};
</script>
<style>
.table-body-move {
transition: transform 1s;
}
</style>

View file

@ -6,8 +6,7 @@
{{ getEventSlug }}
</button>
<div class="dropdown-menu bg-dark" aria-labelledby="dropdownMenuButton">
<a class="dropdown-item text-light" href="#" v-for="(event, index) in selectableEvents"
v-bind:key="index"
<a class="dropdown-item text-light" href="#" v-for="(event, index) in events" v-bind:key="index"
:class="{ active: event.slug === getEventSlug }" @click="changeEvent(event)">{{ event.slug }}</a>
</div>
</div>
@ -49,12 +48,12 @@
</button>
</div>
<button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addItemClicked')"
v-if="isItemView() && getEventSlug !== 'all'">
v-if="isItemView()">
<font-awesome-icon icon="plus"/>
<span class="d-none d-md-inline">&nbsp;Add Item</span>
</button>
<button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addTicketClicked')"
v-if="isTicketView() && getEventSlug !== 'all'">
v-if="isTicketView()">
<font-awesome-icon icon="plus"/>
<span class="d-none d-md-inline">&nbsp;Add Ticket</span>
</button>
@ -65,6 +64,19 @@
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<!--li class="nav-item dropdown">
<button class="btn nav-link dropdown-toggle" type="button" id="dropdownMenuButton2"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ getActiveView }}
</button>
<ul class="dropdown-menu bg-dark" aria-labelledby="dropdownMenuButton2">
<li class="" v-for="(link, index) in views" v-bind:key="index"
:class="{ active: link.path === getActiveView }">
<a class="nav-link text-nowrap" href="#" @click="changeView(link)">{{ link.title }}</a>
</li>
</ul>
</li-->
<li class="nav-item" v-for="(link, index) in links" v-bind:key="index">
<a class="nav-link text-nowrap" :href="link.path" @click.prevent="navigateTo(link.path)">
{{ link.title }}
@ -103,9 +115,6 @@ export default {
computed: {
...mapState(['events']),
...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions", "layout", "route"]),
selectableEvents() {
return [{slug: 'all'}, ...this.events, {slug: 'none'}];
}
},
methods: {
...mapActions(['changeEvent', 'changeView']),

View file

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

View file

@ -13,9 +13,11 @@ import Ticket from "@/views/Ticket.vue";
import Admin from "@/views/admin/Admin.vue";
import Empty from "@/views/Empty.vue";
import Events from "@/views/admin/Events.vue";
import Settings from "@/views/admin/Settings.vue";
import AccessControl from "@/views/admin/AccessControl.vue";
import {default as BoxesAdmin} from "@/views/admin/Boxes.vue"
import Shipping from "@/views/admin/Shipping.vue";
import Notifications from "@/views/admin/Notifications.vue";
const routes = [
{path: '/', redirect: '/37C3/items', meta: {requiresAuth: false}},
@ -58,6 +60,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: Dashboard, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
@ -74,6 +80,10 @@ const routes = [
path: 'shipping/', name: 'shipping', component: Shipping, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
{
path: 'notifications/', name: 'notifications', component: Notifications, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
}
]
},
{path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}},

View file

@ -3,7 +3,7 @@ import router from './router';
import * as base64 from 'base-64';
import * as utf8 from 'utf8';
import {ticketStateColorLookup, ticketStateIconLookup, http, http_session} from "@/utils";
import {ticketStateColorLookup, ticketStateIconLookup, http} from "@/utils";
import sharedStatePlugin from "@/shared-state-plugin";
import persistentStatePlugin from "@/persistent-state-plugin";
@ -11,18 +11,20 @@ const store = createStore({
state: {
keyIncrement: 0,
events: [],
items: [],
loadedItems: [],
itemCache: {},
loadedBoxes: [],
toasts: [],
tickets: [],
users: [],
groups: [],
state_options: [],
messageTemplates: [],
messageTemplateVariables: [],
shippingVouchers: [],
userNotificationChannels: [],
loadedItems: {},
loadedTickets: {},
lastEvent: 'all',
lastEvent: '37C3',
lastUsed: {},
searchQuery: '',
remember: false,
@ -43,7 +45,9 @@ const store = createStore({
users: 0,
groups: 0,
states: 0,
messageTemplates: 0,
shippingVouchers: 0,
userNotificationChannels: 0,
},
persistent_loaded: false,
shared_loaded: false,
@ -63,14 +67,7 @@ const store = createStore({
},
getters: {
route: state => router.currentRoute.value,
session: state => http_session(state.user.token),
getEventSlug: state => router.currentRoute.value.params.event ? router.currentRoute.value.params.event : state.lastEvent,
getAllItems: state => Object.values(state.loadedItems).flat(),
getAllTickets: state => Object.values(state.loadedTickets).flat(),
getEventItems: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllItems : getters.getAllItems.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
getEventTickets: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllTickets : getters.getAllTickets.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
isItemsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedItems : Object.keys(state.loadedItems).includes(getters.getEventSlug),
isTicketsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedTickets : Object.keys(state.loadedTickets).includes(getters.getEventSlug),
getActiveView: state => router.currentRoute.value.name || 'items',
getFilters: state => router.currentRoute.value.query,
getBoxes: state => state.loadedBoxes,
@ -138,48 +135,35 @@ const store = createStore({
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()};
},
setItems(state, {slug, items}) {
state.loadedItems[slug] = items;
state.loadedItems = {...state.loadedItems};
console.log(state.loadedItems)
},
replaceItems(state, items) {
const groups = Object.groupBy(items, i => i.event ? i.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedItems[key] = value;
state.loadedItems = {...state.loadedItems};
console.log(state.loadedItems)
},
updateItem(state, updatedItem) {
const item = state.loadedItems[updatedItem.event?updatedItem.event:'none'].filter(
({uid}) => uid === updatedItem.uid)[0];
const item = state.loadedItems.filter(({uid}) => uid === updatedItem.uid)[0];
Object.assign(item, updatedItem);
},
removeItem(state, item) {
state.loadedItems[item.event?item.event:'none'] = state.loadedItems[item.event].filter(it => it !== item);
state.loadedItems = state.loadedItems.filter(it => it !== item);
},
appendItem(state, item) {
state.loadedItems[item.event?item.event:'none'].push(item);
},
setTickets(state, {slug, tickets}) {
state.loadedTickets[slug] = tickets;
state.loadedTickets = {...state.loadedTickets};
console.log(state.loadedTickets)
state.loadedItems.push(item);
},
replaceTickets(state, tickets) {
const groups = Object.groupBy(tickets, t => t.event ? t.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedTickets[key] = value;
state.loadedTickets = {...state.loadedTickets};
console.log(state.loadedTickets)
state.tickets = tickets;
state.fetchedData = {...state.fetchedData, tickets: Date.now()};
},
updateTicket(state, updatedTicket) {
const ticket = state.loadedTickets[updatedTicket.event?updatedTicket.event:'none'].filter(
({id}) => id === updatedTicket.id)[0];
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
Object.assign(ticket, updatedTicket);
state.loadedTickets = {...state.loadedTickets};
state.tickets = [...state.tickets];
},
replaceUsers(state, users) {
state.users = users;
@ -243,10 +227,21 @@ const store = createStore({
setThumbnail(state, {url, data}) {
state.thumbnailCache[url] = data;
},
setMessageTemplates(state, templates) {
state.messageTemplates = templates;
state.fetchedData = {...state.fetchedData, messageTemplates: Date.now()};
},
setMessageTemplateVariables(state, variables) {
state.messageTemplateVariables = variables;
},
setShippingVouchers(state, codes) {
state.shippingVouchers = codes;
state.fetchedData = {...state.fetchedData, shippingVouchers: Date.now()};
},
setUserNotificationChannels(state, channels) {
state.userNotificationChannels = channels;
state.fetchedData = {...state.fetchedData, userNotificationChannels: Date.now()};
},
},
actions: {
async login({commit}, {username, password, remember}) {
@ -270,7 +265,7 @@ const store = createStore({
return false;
}
},
async reloadToken({commit, state}) {
async reloadToken({commit, state, getters}) {
try {
if (state.user.username && state.user.password) {
const data = await fetch('/api/2/login/', {
@ -318,58 +313,59 @@ const store = createStore({
async fetchImage({state}, url) {
return await fetch(url, {headers: {'Authorization': `Token ${state.user.token}`}});
},
async loadUserInfo({commit, getters}) {
const {data, success} = await getters.session.get('/2/self/');
async loadUserInfo({commit, state}) {
const {data, success} = await http.get('/2/self/', state.user.token);
commit('setPermissions', data.permissions);
},
async loadEvents({commit, state, getters}) {
async loadEvents({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.events > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await getters.session.get('/2/events/');
if (data && success) commit('replaceEvents', data);
const {data, success} = await http.get('/2/events/', state.user.token);
if (data && success)
commit('replaceEvents', data);
},
async createEvent({commit, dispatch, state, getters}, event) {
const {data, success} = await getters.session.post('/2/events/', event);
if (data && success) commit('replaceEvents', [...state.events, data]);
async createEvent({commit, dispatch, state}, event) {
const {data, success} = await http.post('/2/events/', event, state.user.token);
if (data && success)
commit('replaceEvents', [...state.events, data]);
},
async deleteEvent({commit, dispatch, state, getters}, event_id) {
const {data, success} = await getters.session.delete(`/2/events/${event_id}/`);
async deleteEvent({commit, dispatch, state}, event_id) {
const {data, success} = await http.delete(`/2/events/${event_id}/`, state.user.token);
if (success) {
await dispatch('loadEvents')
commit('replaceEvents', [...state.events.filter(e => e.eid !== event_id)])
}
},
async updateEvent({commit, dispatch, state}, {id, partial_event}){
console.log(id, partial_event);
const {data, success} = await http.patch(`/2/events/${id}/`, partial_event, state.user.token);
if (success) {
commit('replaceEvents', [...state.events.filter(e => e.eid !== id), data])
}
},
async fetchTicketStates({commit, state, getters}) {
async fetchTicketStates({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.states > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await getters.session.get('/2/tickets/states/');
if (data && success) commit('replaceTicketStates', data);
const {data, success} = await http.get('/2/tickets/states/', state.user.token);
if (data && success)
commit('replaceTicketStates', data);
},
async changeEvent({dispatch, getters, commit}, eventName) {
await router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
//dispatch('loadEventItems');
changeEvent({dispatch, getters, commit}, eventName) {
router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
dispatch('loadEventItems');
},
async changeView({getters}, link) {
await router.push({path: `/${getters.getEventSlug}/${link.path}/`});
changeView({getters}, link) {
router.push({path: `/${getters.getEventSlug}/${link.path}/`});
},
async showBoxContent({getters}, box) {
await router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
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;
const {data, success} = await getters.session.get(`/2/${slug}/items/`);
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('setItems', {slug, items: data});
commit('replaceLoadedItems', data);
commit('setItemCache', {slug, items: data});
}
} catch (e) {
console.error("Error loading items");
@ -377,155 +373,233 @@ const store = createStore({
},
async searchEventItems({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query));
const slug = getters.getEventSlug;
const {
data, success
} = await getters.session.get(`/2/${slug}/items/${encoded_query}/`);
if (data && success) {
commit('setItems', {slug, items: data});
}
const {data, success} = await http.get(`/2/${getters.getEventSlug}/items/${encoded_query}/`, state.user.token);
if (data && success)
commit('replaceLoadedItems', data);
},
async loadBoxes({commit, state, getters}) {
async loadBoxes({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.boxes > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await getters.session.get('/2/boxes/');
if (data && success) commit('replaceBoxes', data);
const {data, success} = await http.get('/2/boxes/', state.user.token);
if (data && success)
commit('replaceBoxes', data);
},
async createBox({commit, dispatch, state, getters}, box) {
const {data, success} = await getters.session.post('/2/boxes/', box);
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, getters}, box_id) {
await getters.session.delete(`/2/boxes/${box_id}/`);
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 getters.session.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
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 getters.session.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true},
state.user.token);
await http.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true}, state.user.token);
commit('removeItem', item);
},
async deleteItem({commit, getters, state}, item) {
await getters.session.delete(`/2/${getters.getEventSlug}/item/${item.uid}/`, 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 getters.session.post(`/2/${getters.getEventSlug}/item/`, item);
const {data, success} = await http.post(`/2/${getters.getEventSlug}/item/`, item, state.user.token);
commit('appendItem', data);
},
async loadTickets({commit, state, getters}) {
async loadTickets({commit, state}) {
if (!state.user.token) return;
//if (state.fetchedData.tickets > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await getters.session.get('/2/tickets/');
if (data && success) commit('replaceTickets', data);
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 searchEventTickets({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query));
const {
data, success
} = await getters.session.get(`/2/${getters.getEventSlug}/tickets/${encoded_query}/`);
if (data && success) commit('replaceTickets', data);
const {data, success} = await http.get(`/2/${getters.getEventSlug}/tickets/${encoded_query}/`, state.user.token);
if (data && success)
commit('replaceTickets', data);
},
async sendMail({commit, dispatch, state, getters}, {id, message}) {
const {data, success} = await getters.session.post(`/2/tickets/${id}/reply/`, {message},
state.user.token);
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, getters}, {sender, message, title,}) {
const {data, success} = await getters.session.post(`/2/tickets/manual/`, {
name: title, sender, body: message, recipient: 'mail@c3lf.de'
});
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, getters}, {id, message}) {
const {data, success} = await getters.session.post(`/2/tickets/${id}/comment/`, {comment: message});
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, getters}) {
async loadUsers({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.users > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await getters.session.get('/2/users/');
if (data && success) commit('replaceUsers', data);
const {data, success} = await http.get('/2/users/', state.user.token);
if (data && success)
commit('replaceUsers', data);
},
async loadGroups({commit, state, getters}) {
async loadGroups({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.groups > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await getters.session.get('/2/groups/');
if (data && success) commit('replaceGroups', data);
const {data, success} = await http.get('/2/groups/', state.user.token);
if (data && success)
commit('replaceGroups', data);
},
async updateTicket({commit, state, getters}, ticket) {
const {data, success} = await getters.session.put(`/2/tickets/${ticket.id}/`, ticket);
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, getters}, {id, ...ticket}) {
const {data, success} = await getters.session.patch(`/2/tickets/${id}/`, ticket);
async updateTicketPartial({commit, state}, {id, ...ticket}) {
const {data, success} = await http.patch(`/2/tickets/${id}/`, ticket, state.user.token);
commit('updateTicket', data);
},
async fetchShippingVouchers({commit, state, getters}) {
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 getters.session.get('/2/shipping_vouchers/');
const {data, success} = await http.get('/2/shipping_vouchers/', state.user.token);
if (data && success) {
commit('setShippingVouchers', data);
}
},
async createShippingVoucher({dispatch, state, getters}, code) {
const {data, success} = await getters.session.post('/2/shipping_vouchers/', code);
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, getters}, {ticket, shipping_voucher_type}) {
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 getters.session.patch(`/2/shipping_vouchers/${id}/`, {issue_thread: ticket});
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')]);
}
},
async fetchUserNotificationChannels({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.userNotificationChannels > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/user_notification_channels/', state.user.token);
if (data && success) {
commit('setUserNotificationChannels', data);
}
},
plugins: [persistentStatePlugin({ // TODO change remember to some kind of enable field
},
plugins: [
persistentStatePlugin({ // TODO change remember to some kind of enable field
prefix: "lf_",
debug: false,
isLoadedKey: "persistent_loaded",
state: ["remember", "user", "events", "lastUsed",]
}), sharedStatePlugin({
state: [
"remember",
"user",
"events",
"lastUsed",
]
}),
sharedStatePlugin({
debug: false,
isLoadedKey: "shared_loaded",
clearingMutation: "logout",
afterInit: "afterSharedInit",
state: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes", "loadedTickets", "shippingVouchers",],
watch: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes", "loadedTickets", "shippingVouchers",],
mutations: [//"replaceTickets",
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) {
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') {
else if (router.currentRoute.value.name === 'login')
router.push('/');
}
} else {
if (router.currentRoute.value.name !== 'login') {
router.push({

View file

@ -100,12 +100,4 @@ const http = {
}
}
const http_session = token => ({
get: async (url) => await http.get(url, token),
post: async (url, data) => await http.post(url, data, token),
put: async (url, data) => await http.put(url, data, token),
patch: async (url, data) => await http.patch(url, data, token),
delete: async (url) => await http.delete(url, token),
});
export {ticketStateColorLookup, ticketStateIconLookup, http, http_session};
export {ticketStateColorLookup, ticketStateIconLookup, http};

View file

@ -1,5 +1,5 @@
<template>
<AsyncLoader :loaded="isItemsLoaded">
<AsyncLoader :loaded="loadedItems.length > 0">
<div class="container-fluid px-xl-5 mt-3">
<Modal title="Edit Item" v-if="editingItem" @close="closeEditingModal()">
<template #body>
@ -18,7 +18,7 @@
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['uid', 'description', 'box']"
:items="getEventItems"
:items="loadedItems"
:keyName="'uid'"
@itemActivated="openLightboxModalWith($event)"
>
@ -44,7 +44,7 @@
<Cards
v-if="layout === 'cards'"
:columns="['uid', 'description', 'box']"
:items="getEventItems"
:items="loadedItems"
:keyName="'uid'"
v-slot="{ item }"
@itemActivated="openLightboxModalWith($event)"
@ -97,8 +97,8 @@ export default {
}),
components: {AsyncLoader, AuthenticatedImage, Lightbox, Table, Cards, Modal, EditItem},
computed: {
...mapState([]),
...mapGetters(['getEventItems', 'isItemsLoaded', 'layout']),
...mapState(['loadedItems']),
...mapGetters(['layout']),
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']),

View file

@ -80,11 +80,11 @@ export default {
}
},
computed: {
...mapState(['state_options', 'users']),
...mapGetters(['availableShippingVoucherTypes', 'getAllTickets', 'route']),
...mapState(['tickets', 'state_options', 'users']),
...mapGetters(['availableShippingVoucherTypes']),
ticket() {
const id = parseInt(this.route.params.id)
const ret = this.getAllTickets.find(ticket => ticket.id === id);
const id = parseInt(this.$route.params.id)
const ret = this.tickets.find(ticket => ticket.id === id);
return ret ? ret : {};
},
shippingEmail() {
@ -128,8 +128,7 @@ export default {
if (this.ticket.state == "pending_new"){
this.selected_state = "pending_open";
this.changeTicketStatus(this.ticket)
}
;
};
this.selected_state = this.ticket.state;
this.selected_assignee = this.ticket.assigned_to
})]);

View file

@ -1,12 +1,11 @@
<template>
<AsyncLoader :loaded="isTicketsLoaded">
<AsyncLoader :loaded="tickets.length > 0">
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to',
...(getEventSlug==='all'?['event']:[])]"
:items="getEventTickets.map(formatTicket)"
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to', 'actions', 'actions2']"
:items="tickets.map(formatTicket)"
:keyName="'id'"
v-if="layout === 'table'"
>
@ -22,9 +21,8 @@
</Table>
</div>
</div>
<CollapsableCards v-if="layout === 'tasks'" :items="getEventTickets"
:columns="['id', 'name', 'last_activity', 'assigned_to',
...(getEventSlug==='all'?['event']:[])]"
<CollapsableCards v-if="layout === 'tasks'" :items="tickets"
:columns="['id', 'name', 'last_activity', 'assigned_to']"
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
'pending_physical_confirmation','pending_return','pending_postponed'].map(stateInfo)">
<template #section_header="{index, section, count}">
@ -36,7 +34,6 @@
<td>{{ item.name }}</td>
<td>{{ item.last_activity }}</td>
<td>{{ item.assigned_to }}</td>
<td v-if="getEventSlug==='all'">{{ item.event }}</td>
<td>
<div class="btn-group">
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
@ -67,7 +64,8 @@ export default {
name: 'Tickets',
components: {AsyncLoader, Lightbox, Table, Cards, Modal, EditItem, CollapsableCards},
computed: {
...mapGetters(['getEventTickets', 'isTicketsLoaded', 'stateInfo', 'getEventSlug', 'layout']),
...mapState(['tickets']),
...mapGetters(['stateInfo', 'getEventSlug', 'layout']),
},
methods: {
...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']),
@ -81,8 +79,7 @@ export default {
state: this.stateInfo(ticket.state).text,
stateColor: this.stateInfo(ticket.state).color,
last_activity: ticket.last_activity,
assigned_to: ticket.assigned_to,
event: ticket.event
assigned_to: ticket.assigned_to
};
}
},

View file

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

View file

@ -1,6 +1,9 @@
<template>
<AsyncLoader :loaded="events.length > 0">
<ExpandableTable v-if="!!events" :columns="['slug', 'name']" :items="events" :keyName="'slug'">
<Table
:columns="['slug', 'name']"
:items="events"
:keyName="'slug'"
>
<template v-slot:header_actions>
<button class="btn btn-success" @click.prevent="openAddEventModal">
<font-awesome-icon icon="plus"/>
@ -9,94 +12,39 @@
</template>
<template v-slot:actions="{ item }">
<div class="btn-group">
<button class="btn btn-secondary" @click.stop="changeEvent(item)" style="white-space: nowrap;">
<font-awesome-icon icon="archive"/>&nbsp;use
<button class="btn btn-secondary" @click.stop="changeEvent(item)">
<font-awesome-icon icon="archive"/>
use
</button>
<button class="btn btn-danger" @click.stop="safeDeleteEvent(item.eid)" style="white-space: nowrap;">
<font-awesome-icon icon="trash"/>&nbsp;delete
</button>
</div>
</template>
<template v-slot:detail="{id, item }">
<div class="row">
<div class="col">
<input type="date" class="form-control form-control-sm" title="Buildup Start"
v-model="item.pre_start" disabled style="opacity: 1"
:max="item.start" @focus="prepare_date_field">
</div>
<div class="col">
<input type="date" class="form-control form-control-sm" title="Official Event Start"
v-model="item.start" disabled style="opacity: 1"
:min="item.pre_start" :max="item.end" @focus="prepare_date_field">
</div>
<div class="col">
<input type="date" class="form-control form-control-sm" title="Official Event End"
v-model="item.end" disabled style="opacity: 1"
:min="item.start" :max="item.post_end" @focus="prepare_date_field">
</div>
<div class="col">
<input type="date" class="form-control form-control-sm" title="Teardown End"
v-model="item.post_end" disabled style="opacity: 1"
:min="item.end" @focus="prepare_date_field">
</div>
</div>
<div class="mt-3">
<label class="mr-3">Addresses: </label>
<div v-for="(address, a_id) in item.addresses" class="btn-group btn-group-sm mr-3"
@click.stop="deleteAddress(id, a_id)">
<button class="btn btn-secondary" disabled style="opacity: 1">
{{ address }}
</button>
<button class="btn btn-danger">
<button class="btn btn-danger" @click.stop="safeDeleteEvent(item.eid)">
<font-awesome-icon icon="trash"/>
delete
</button>
</div>
<div class="btn-group btn-group-sm">
<input type="text" v-model="new_address[id]">
<button class="btn btn-secondary" @click.stop="addAddress(id)" style="white-space: nowrap;">
<font-awesome-icon icon="envelope"/>&nbsp;add
</button>
</div>
</div>
</template>
</ExpandableTable>
</AsyncLoader>
</Table>
</template>
<script>
import {mapActions, mapMutations, mapState} from 'vuex';
import ExpandableTable from "@/components/ExpandableTable.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import Table from '@/components/Table';
export default {
name: 'Events',
components: {AsyncLoader, ExpandableTable},
components: {Table},
computed: mapState(['events']),
data() {
return {new_address: []}
},
methods: {
...mapActions(['changeEvent', 'deleteEvent', 'updateEvent']),
...mapActions(['changeEvent', 'deleteEvent']),
...mapMutations(['openAddEventModal']),
safeDeleteEvent(id) {
if (confirm('do you want to completely delete this event and related data?')) {
this.deleteEvent(id)
}
},
addAddress(id) {
const a = this.new_address[id];
if (!this.events[id].addresses.includes(a))
this.events[id].addresses.push(a)
this.new_address[id] = ""
this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}});
},
deleteAddress(id, a_id) {
this.events[id].addresses = this.events[id].addresses.filter((e, i) => i !== a_id);
this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}});
}
},
};
</script>
<style scoped>
</style>

View file

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

View file

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