Compare commits

...

20 commits

Author SHA1 Message Date
82b761e7f1 save raw_mails as file 2024-11-09 03:02:57 +01:00
7f7cf26a5d stash 2024-11-09 03:02:57 +01:00
c9821e3d6d stash 2024-11-09 03:02:57 +01:00
e35c7eab74 stash 2024-11-09 03:02:57 +01:00
a0f9edc71c stash 2024-11-09 03:02:57 +01:00
282337656c stash 2024-11-09 03:02:57 +01:00
8cd66cc6e2 stash 2024-11-09 03:02:57 +01:00
6f98bf4942 stash 2024-11-09 03:02:57 +01:00
c471aeb6b2 stash 2024-11-09 03:02:57 +01:00
7b0524a219 stash 2024-11-09 03:02:57 +01:00
656cd33050 stash 2024-11-09 03:02:57 +01:00
11cd752249 stash 2024-11-09 03:02:57 +01:00
dfce7e7532 stash 2024-11-09 03:02:57 +01:00
0a2c854b88 stash 2024-11-09 03:02:57 +01:00
2552632a2b stash 2024-11-09 03:02:53 +01:00
5a6349c5d3 train spam on state change to 'closed_spam' 2024-11-09 02:58:21 +01:00
a6a8b0defe add functions to train mails as spam/ham 2024-11-09 00:03:21 +01:00
4272aab643 save raw_mails as file 2024-11-08 22:54:57 +01:00
0c4995db2b add docker env for integration testing 2024-11-08 20:09:51 +01:00
269f02c2ce Add django standard metrics 2024-11-07 19:51:31 +01:00
56 changed files with 1804 additions and 117 deletions

3
.gitmodules vendored Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,9 @@ import sys
import dotenv import dotenv
from pathlib import Path 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'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@ -24,12 +27,14 @@ dotenv.load_dotenv(BASE_DIR / '.env')
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv' SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = truthy_str(os.getenv('DEBUG_MODE_ACTIVE', 'False'))
ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost')] PRIMARY_HOST = os.getenv('HTTP_HOST', 'localhost')
ALLOWED_HOSTS = [PRIMARY_HOST]
MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost') MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost')
@ -40,6 +45,12 @@ LEGACY_USER_PASSWORD = os.getenv('LEGACY_API_PASSWORD', 'legacy_password')
SYSTEM3_VERSION = "0.0.0-dev.0" 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 # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -50,11 +61,13 @@ INSTALLED_APPS = [
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django_extensions', 'django_extensions',
'django_prometheus',
'rest_framework', 'rest_framework',
'knox', 'knox',
'drf_yasg', 'drf_yasg',
'channels', 'channels',
'authentication', 'authentication',
'notifications',
'files', 'files',
'tickets', 'tickets',
'inventory', 'inventory',
@ -85,6 +98,7 @@ SWAGGER_SETTINGS = {
} }
MIDDLEWARE = [ MIDDLEWARE = [
'django_prometheus.middleware.PrometheusBeforeMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
@ -92,6 +106,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
] ]
ROOT_URLCONF = 'core.urls' ROOT_URLCONF = 'core.urls'
@ -134,7 +149,8 @@ else:
'USER': os.getenv('DB_USER', 'system3'), 'USER': os.getenv('DB_USER', 'system3'),
'PASSWORD': os.getenv('DB_PASSWORD', 'system3'), 'PASSWORD': os.getenv('DB_PASSWORD', 'system3'),
'OPTIONS': { 'OPTIONS': {
'charset': 'utf8mb4' 'charset': 'utf8mb4',
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"
} }
} }
} }
@ -204,10 +220,12 @@ CHANNEL_LAYERS = {
'default': { 'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer', 'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': { 'CONFIG': {
'hosts': [('localhost', 6379)], 'hosts': [(os.getenv('REDIS_HOST', 'localhost'), 6379)],
}, },
} }
} }
PROMETHEUS_METRIC_NAMESPACE = 'c3lf'
TEST_RUNNER = 'core.test_runner.FastTestRunner' TEST_RUNNER = 'core.test_runner.FastTestRunner'

View file

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

View file

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

View file

@ -1,25 +1,49 @@
from django.utils import timezone from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
from rest_framework.relations import SlugRelatedField
from files.models import File from files.models import File
from inventory.models import Event, Container, Item from inventory.models import Event, Container, Item
from mail.models import EventAddress from mail.models import EventAddress
class EventAdressSerializer(serializers.ModelSerializer): #class EventAdressSerializer(serializers.ModelSerializer):
class Meta: # class Meta:
model = EventAddress # model = EventAddress
fields = ['address'] # fields = ['address']
# def to_internal_value(self, data):
# if not isinstance(data, str):
# raise serializers.ValidationError('This field must be a string.')
#
# def create(self, validated_data):
# return EventAddress.objects.create(**validated_data)
#
# def validate(self, data):
# return isinstance(data, str)
class EventSerializer(serializers.ModelSerializer): class EventSerializer(serializers.ModelSerializer):
addresses = EventAdressSerializer(many=True, required=False) #addresses = EventAdressSerializer(many=True, required=False)
addresses = SlugRelatedField(many=True, slug_field='address', queryset=EventAddress.objects.all())
class Meta: class Meta:
model = Event model = Event
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end', 'addresses'] fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end', 'addresses']
read_only_fields = ['eid'] read_only_fields = ['eid']
# def update(self, instance, validated_data):
# addresses = validated_data.pop('addresses', None)
# instance.save(validated_data)
# if addresses:
# for address in addresses:
# nested_instance, created = EventAddress.objects.get_or_create(address=address)
# instance.addresses.add(nested_instance)
#
# return instance
class ContainerSerializer(serializers.ModelSerializer): class ContainerSerializer(serializers.ModelSerializer):
itemCount = serializers.SerializerMethodField() itemCount = serializers.SerializerMethodField()
@ -95,3 +119,14 @@ class ItemSerializer(serializers.ModelSerializer):
validated_data.pop('dataImage') validated_data.pop('dataImage')
instance.files.add(file) instance.files.add(file)
return super().update(instance, validated_data) return super().update(instance, validated_data)
class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField()
item = ItemSerializer()
def to_representation(self, instance):
return {**ItemSerializer(instance['item']).data, 'search_score': instance['search_score']}
class Meta:
model = Item

View file

@ -47,6 +47,18 @@ class EventTestCase(TestCase):
self.assertEqual(Event.objects.all()[0].slug, 'EVENT2') self.assertEqual(Event.objects.all()[0].slug, 'EVENT2')
self.assertEqual(Event.objects.all()[0].name, 'Event 2 new') 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.eid}/', {'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(len(Event.objects.all()), 1)
self.assertEqual(Event.objects.all()[0].slug, 'EVENT1')
self.assertEqual(Event.objects.all()[0].name, 'Event 1')
self.assertEqual(1, len(response.json()[0]['addresses']))
def test_remove_event(self): def test_remove_event(self):
event = Event.objects.create(slug='EVENT1', name='Event 1') event = Event.objects.create(slug='EVENT1', name='Event 1')
Event.objects.create(slug='EVENT2', name='Event 2') Event.objects.create(slug='EVENT2', name='Event 2')
@ -65,4 +77,3 @@ class EventTestCase(TestCase):
self.assertEqual('TEST1', response.json()[0]['slug']) self.assertEqual('TEST1', response.json()[0]['slug'])
self.assertEqual('Event', response.json()[0]['name']) self.assertEqual('Event', response.json()[0]['name'])
self.assertEqual(1, len(response.json()[0]['addresses'])) self.assertEqual(1, len(response.json()[0]['addresses']))

View file

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

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-11-06 06:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mail', '0005_alter_eventaddress_event'),
]
operations = [
migrations.AlterField(
model_name='eventaddress',
name='address',
field=models.CharField(max_length=255, unique=True),
),
]

View file

@ -0,0 +1,36 @@
# Generated by Django 4.2.7 on 2024-11-08 20:37
from django.core.files.base import ContentFile
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mail', '0005_alter_eventaddress_event'),
]
def move_raw_mails_to_file(apps, schema_editor):
Email = apps.get_model('mail', 'Email')
for email in Email.objects.all():
raw_content = email.raw
path = "mail_{}".format(email.id)
if len(raw_content):
email.raw_file.save(path, ContentFile(raw_content))
email.save()
operations = [
migrations.AddField(
model_name='email',
name='raw_file',
field=models.FileField(null=True, upload_to='raw_mail/'),
),
migrations.RunPython(move_raw_mails_to_file),
migrations.RemoveField(
model_name='email',
name='raw',
),
migrations.AlterField(
model_name='email',
name='raw_file',
field=models.FileField(upload_to='raw_mail/'),
),
]

View file

@ -0,0 +1,36 @@
# Generated by Django 4.2.7 on 2024-11-08 20:37
from django.core.files.base import ContentFile
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mail', '0006_alter_eventaddress_address'),
]
def move_raw_mails_to_file(apps, schema_editor):
Email = apps.get_model('mail', 'Email')
for email in Email.objects.all():
raw_content = email.raw
email.raw_file = ContentFile(raw_content)
email.raw = None
email.save()
operations = [
migrations.AddField(
model_name='email',
name='raw_file',
field=models.FileField(null=True, upload_to='raw_mail/'),
),
migrations.RunPython(move_raw_mails_to_file),
migrations.RemoveField(
model_name='email',
name='raw',
),
migrations.AlterField(
model_name='email',
name='raw_file',
field=models.FileField(upload_to='raw_mail/'),
),
]

View file

@ -3,6 +3,8 @@ import random
from django.db import models from django.db import models
from django_softdelete.models import SoftDeleteModel from django_softdelete.models import SoftDeleteModel
from core.settings import MAIL_DOMAIN, ACTIVE_SPAM_TRAINING
from authentication.models import ExtendedUser
from core.settings import MAIL_DOMAIN from core.settings import MAIL_DOMAIN
from files.models import AbstractFile from files.models import AbstractFile
from inventory.models import Event from inventory.models import Event
@ -18,7 +20,7 @@ class Email(SoftDeleteModel):
recipient = models.CharField(max_length=255) recipient = models.CharField(max_length=255)
reference = models.CharField(max_length=255, null=True, unique=True) reference = models.CharField(max_length=255, null=True, unique=True)
in_reply_to = models.CharField(max_length=255, null=True) in_reply_to = models.CharField(max_length=255, null=True)
raw = models.TextField() raw_file = models.FileField(upload_to='raw_mail/')
issue_thread = models.ForeignKey(IssueThread, models.SET_NULL, null=True, related_name='emails') issue_thread = models.ForeignKey(IssueThread, models.SET_NULL, null=True, related_name='emails')
event = models.ForeignKey(Event, models.SET_NULL, null=True) event = models.ForeignKey(Event, models.SET_NULL, null=True)
@ -28,11 +30,23 @@ class Email(SoftDeleteModel):
self.reference = f'<{random.randint(0, 1000000000):09}@{MAIL_DOMAIN}>' self.reference = f'<{random.randint(0, 1000000000):09}@{MAIL_DOMAIN}>'
self.save() self.save()
def train_spam(self):
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:
import subprocess
path = self.raw_file.path
subprocess.run(["rspamc", "learn_ham", path])
class EventAddress(models.Model): class EventAddress(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
event = models.ForeignKey(Event, models.SET_NULL, null=True, related_name='addresses') event = models.ForeignKey(Event, models.SET_NULL, null=True, related_name='addresses')
address = models.CharField(max_length=255) address = models.CharField(max_length=255, unique=True)
class EmailAttachment(AbstractFile): class EmailAttachment(AbstractFile):

View file

@ -7,6 +7,7 @@ from channels.db import database_sync_to_async
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from mail.models import Email, EventAddress, EmailAttachment from mail.models import Email, EventAddress, EmailAttachment
from notifications.templates import render_auto_reply
from notify_sessions.models import SystemEvent from notify_sessions.models import SystemEvent
from tickets.models import IssueThread from tickets.models import IssueThread
@ -87,6 +88,22 @@ def make_reply(reply_email, references=None, event=None):
return reply 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): async def send_smtp(message):
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False) await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
@ -185,6 +202,16 @@ def receive_email(envelope, log=None):
header_in_reply_to = parsed.get('In-Reply-To') header_in_reply_to = parsed.get('In-Reply-To')
header_message_id = parsed.get('Message-ID') header_message_id = parsed.get('Message-ID')
# if header_from != envelope.mail_from:
# log.warning("Header from does not match envelope from")
# log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}")
#
# if header_to != envelope.rcpt_tos[0]:
# log.warning("Header to does not match envelope to")
# log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}")
# handle undelivered mail header_from : 'Mail Delivery System <MAILER-DAEMON@...'
if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "": if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "":
log.warning("Ignoring mailer daemon") log.warning("Ignoring mailer daemon")
raise SpecialMailException("Ignoring mailer daemon") raise SpecialMailException("Ignoring mailer daemon")
@ -206,7 +233,7 @@ def receive_email(envelope, log=None):
email = Email.objects.create( email = Email.objects.create(
sender=sender, recipient=recipient, body=body, subject=subject, reference=header_message_id, sender=sender, recipient=recipient, body=body, subject=subject, reference=header_message_id,
in_reply_to=header_in_reply_to, raw=envelope.content, event=target_event, in_reply_to=header_in_reply_to, raw_file=ContentFile(envelope.content), event=target_event,
issue_thread=active_issue_thread) issue_thread=active_issue_thread)
for attachment in attachments: for attachment in attachments:
email.attachments.add(attachment) email.attachments.add(attachment)
@ -218,16 +245,7 @@ def receive_email(envelope, log=None):
references = collect_references(active_issue_thread) references = collect_references(active_issue_thread)
if not sender.startswith('noreply'): if not sender.startswith('noreply'):
subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]" subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]"
body = '''Your request (#{}) has been received and will be reviewed by our lost&found angels. body = render_auto_reply(active_issue_thread)
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())
reply_email = Email.objects.create( reply_email = Email.objects.create(
sender=recipient, recipient=sender, body=body, subject=subject, sender=recipient, recipient=sender, body=body, subject=subject,
in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread) in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread)
@ -270,11 +288,19 @@ class LMTPHandler:
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id, 'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
"message": "email received"}) "message": "email received"})
log.info(f"Sent message to frontend") log.info(f"Sent message to frontend")
if new and reply: if new and reply:
log.info('Sending message to %s' % reply['To']) log.info('Sending message to %s' % reply['To'])
await send_smtp(reply) await send_smtp(reply)
log.info("Sent auto 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' return '250 Message accepted for delivery'
except SpecialMailException as e: except SpecialMailException as e:
import uuid import uuid

View file

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

View file

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

View file

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

View file

@ -73,3 +73,5 @@ watchfiles==0.21.0
websockets==12.0 websockets==12.0
yarl==1.9.4 yarl==1.9.4
zope.interface==6.1 zope.interface==6.1
django-prometheus==2.3.1
prometheus_client==0.21.0

View file

@ -41,3 +41,5 @@ urllib3==2.1.0
uvicorn==0.24.0.post1 uvicorn==0.24.0.post1
watchfiles==0.21.0 watchfiles==0.21.0
websockets==12.0 websockets==12.0
django-prometheus==2.3.1
prometheus_client==0.21.0

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

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

View file

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

View file

@ -0,0 +1,31 @@
# Generated by Django 4.2.7 on 2024-06-23 02:17
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('mail', '0006_email_raw_file'),
('tickets', '0010_issuethread_event_itemrelation_and_more'),
]
def train_old_mails(apps, schema_editor):
from tickets.models import IssueThread
for t in IssueThread.objects.all():
try:
state = t.state
i = 0
for e in t.emails.all():
if e.raw_file:
if state == 'closed_spam' and i == 0:
e.train_spam()
else:
e.train_ham()
i += 1
except:
pass
operations = [
migrations.RunPython(train_old_mails),
]

View file

@ -1,5 +1,5 @@
from django.db import models
from django.utils import timezone from django.utils import timezone
from django.db import models
from django_softdelete.models import SoftDeleteModel from django_softdelete.models import SoftDeleteModel
from authentication.models import ExtendedUser from authentication.models import ExtendedUser
@ -60,6 +60,8 @@ class IssueThread(SoftDeleteModel):
if self.state == value: if self.state == value:
return return
self.state_changes.create(state=value) self.state_changes.create(state=value)
if value == 'closed_spam' and self.emails.exists():
self.emails.first().train_spam()
@property @property
def assigned_to(self): def assigned_to(self):

View file

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

View file

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

@ -12,3 +12,5 @@ c3lf-nodes:
main_email: <main_email> main_email: <main_email>
legacy_api_user: <legacy_api_user> legacy_api_user: <legacy_api_user>
legacy_api_password: <legacy_api_password> legacy_api_password: <legacy_api_password>
debug_mode_active: false
django_secret_key: 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv'

View file

@ -1,3 +1,4 @@
REDIS_HOST=localhost
DB_HOST=localhost DB_HOST=localhost
DB_PORT=3306 DB_PORT=3306
DB_NAME=c3lf_sys3 DB_NAME=c3lf_sys3
@ -9,3 +10,8 @@ LEGACY_API_USER={{ legacy_api_user }}
LEGACY_API_PASSWORD={{ legacy_api_password }} LEGACY_API_PASSWORD={{ legacy_api_password }}
MEDIA_ROOT=/var/www/c3lf-sys3/userfiles MEDIA_ROOT=/var/www/c3lf-sys3/userfiles
STATIC_ROOT=/var/www/c3lf-sys3/staticfiles 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

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

View file

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

View file

@ -0,0 +1,55 @@
services:
redis:
image: redis
ports:
- "6379:6379"
db:
image: mariadb
environment:
MARIADB_RANDOM_ROOT_PASSWORD: true
MARIADB_DATABASE: system3
MARIADB_USER: system3
MARIADB_PASSWORD: system3
volumes:
- mariadb_data:/var/lib/mysql
ports:
- "3306:3306"
core:
build:
context: ../../core
dockerfile: ../deploy/testing/Dockerfile.backend
command: bash -c 'python manage.py migrate && python /code/server.py'
environment:
- HTTP_HOST=core
- REDIS_HOST=redis
- DB_HOST=db
- DB_PORT=3306
- DB_NAME=system3
- DB_USER=system3
- DB_PASSWORD=system3
volumes:
- ../../core:/code
ports:
- "8000:8000"
depends_on:
- db
- redis
frontend:
build:
context: ../../web
dockerfile: ../deploy/testing/Dockerfile.frontend
command: npm run serve
volumes:
- ../../web:/web:ro
- /web/node_modules
- ./vue.config.js:/web/vue.config.js
ports:
- "8080:8080"
depends_on:
- core
volumes:
mariadb_data:

View file

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

View file

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

View file

@ -1,6 +1,31 @@
<template> <template>
<div class="row"> <div class="row">
<div class="col-lg-3 col-xl-2"> <div class="col-lg-3 col-xl-2">
<!--div class="card bg-dark text-light mb-2" id="filters">
<div class="card-body">
<h5 class="card-title text-info">Sort & Filter</h5>
<div class="form-group" v-for="(column, index) in columns" :key="index">
<label>{{ column }}</label>
<div class="input-group">
<div class="input-group-prepend">
<button
:class="[ 'btn', column === sortBy ? 'btn-outline-info' : 'btn-outline-secondary' ]"
type="button"
@click="toggleSort(column)">
<font-awesome-icon :icon="getSortIcon(column)"/>
</button>
</div>
<input
type="text"
class="form-control"
placeholder="filter"
:value="filters[column]"
@input="changeFilter(column, $event.target.value)"
>
</div>
</div>
</div>
</div-->
</div> </div>
<div class="col-lg-9 col-xl-8"> <div class="col-lg-9 col-xl-8">
<div class="w-100" <div class="w-100"
@ -48,11 +73,10 @@ export default {
data() { data() {
return { return {
collapsed: [], collapsed: [],
symbols: ['▲', '▼', '▶', '◀'],
}; };
}, },
created() { created() {
const query = this.$router.currentRoute ? (this.$router.currentRoute.query ? this.$router.currentRoute.query.collapsed : null) : null; const query = this.route ? (this.route.query ? this.route.query.collapsed : null) : null;
if (query !== null && query !== undefined) { if (query !== null && query !== undefined) {
this.collapsed = this.unpackInt(parseInt(query), this.sections.length); this.collapsed = this.unpackInt(parseInt(query), this.sections.length);
} else { } else {
@ -84,8 +108,8 @@ export default {
const encoded = this.packInt(this.collapsed).toString() const encoded = this.packInt(this.collapsed).toString()
if (this.route.query.collapsed !== encoded) if (this.route.query.collapsed !== encoded)
this.$router.push({ this.$router.push({
...this.$router.currentRoute, ...this.route,
query: {...this.$router.currentRoute.query, collapsed: encoded} query: {...this.route.query, collapsed: encoded}
}); });
}, },
deep: true, deep: true,

View file

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

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

View file

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

View file

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

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

View file

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

View file

@ -19,7 +19,10 @@ const store = createStore({
users: [], users: [],
groups: [], groups: [],
state_options: [], state_options: [],
messageTemplates: [],
messageTemplateVariables: [],
shippingVouchers: [], shippingVouchers: [],
userNotificationChannels: [],
lastEvent: '37C3', lastEvent: '37C3',
lastUsed: {}, lastUsed: {},
@ -42,7 +45,9 @@ const store = createStore({
users: 0, users: 0,
groups: 0, groups: 0,
states: 0, states: 0,
messageTemplates: 0,
shippingVouchers: 0, shippingVouchers: 0,
userNotificationChannels: 0,
}, },
persistent_loaded: false, persistent_loaded: false,
shared_loaded: false, shared_loaded: false,
@ -50,6 +55,7 @@ const store = createStore({
showAddBoxModal: false, showAddBoxModal: false,
showAddEventModal: false, showAddEventModal: false,
test: ['foo', 'bar', 'baz'],
shippingVoucherTypes: { shippingVoucherTypes: {
'2kg-de': '2kg Paket (DE)', '2kg-de': '2kg Paket (DE)',
@ -219,13 +225,27 @@ const store = createStore({
user.permissions = null; user.permissions = null;
state.user = user; state.user = user;
}, },
setTest(state, test) {
state.test = test;
},
setThumbnail(state, {url, data}) { setThumbnail(state, {url, data}) {
state.thumbnailCache[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) { setShippingVouchers(state, codes) {
state.shippingVouchers = codes; state.shippingVouchers = codes;
state.fetchedData = {...state.fetchedData, shippingVouchers: Date.now()}; state.fetchedData = {...state.fetchedData, shippingVouchers: Date.now()};
}, },
setUserNotificationChannels(state, channels) {
state.userNotificationChannels = channels;
state.fetchedData = {...state.fetchedData, userNotificationChannels: Date.now()};
},
}, },
actions: { actions: {
async login({commit}, {username, password, remember}) { async login({commit}, {username, password, remember}) {
@ -320,6 +340,13 @@ const store = createStore({
commit('replaceEvents', [...state.events.filter(e => e.eid !== event_id)]) 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}) { async fetchTicketStates({commit, state}) {
if (!state.user.token) return; if (!state.user.token) return;
if (state.fetchedData.states > Date.now() - 1000 * 60 * 60 * 24) return; if (state.fetchedData.states > Date.now() - 1000 * 60 * 60 * 24) return;
@ -459,6 +486,39 @@ const store = createStore({
const {data, success} = await http.patch(`/2/tickets/${id}/`, ticket, state.user.token); const {data, success} = await http.patch(`/2/tickets/${id}/`, ticket, state.user.token);
commit('updateTicket', data); commit('updateTicket', data);
}, },
async fetchMessageTemplates({commit, state}) {
if (!state.user.token) return;
if (state.messageTemplates.length > 0) return;
const {data, success} = await http.get('/2/message_templates/', state.user.token);
if (data && success) {
commit('setMessageTemplates', data);
}
},
async updateMessageTemplate({dispatch, state}, template) {
const {data, success} = await http.patch(`/2/message_templates/${template.id}/`,
{'message': template.message}, state.user.token);
if (data && success) {
state.fetchedData.messageTemplates = 0;
dispatch('fetchMessageTemplates');
}
},
async fetchMessageTemplateVariables({commit, state}) {
if (!state.user.token) return;
if (state.messageTemplateVariables.length > 0) return;
const {data, success} = await http.get('/2/message_template_variables/', state.user.token);
if (data && success) {
commit('setMessageTemplateVariables', data);
}
},
async createMessageTemplate({dispatch, state}, template_name) {
const {data, success} = await http.post('/2/message_templates/', {
name: template_name,
message: '-'
}, state.user.token);
if (data && success) {
dispatch('fetchMessageTemplates');
}
},
async fetchShippingVouchers({commit, state}) { async fetchShippingVouchers({commit, state}) {
if (!state.user.token) return; if (!state.user.token) return;
if (state.fetchedData.shippingVouchers > Date.now() - 1000 * 60 * 60 * 24) return; if (state.fetchedData.shippingVouchers > Date.now() - 1000 * 60 * 60 * 24) return;
@ -485,7 +545,15 @@ const store = createStore({
state.fetchedData.tickets = 0; state.fetchedData.tickets = 0;
await Promise.all([dispatch('loadTickets'), dispatch('fetchShippingVouchers')]); 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: [ plugins: [
persistentStatePlugin({ // TODO change remember to some kind of enable field persistentStatePlugin({ // TODO change remember to some kind of enable field
@ -513,6 +581,8 @@ const store = createStore({
"groups", "groups",
"loadedBoxes", "loadedBoxes",
"loadedItems", "loadedItems",
"messageTemplates",
"messageTemplatesVariables",
"shippingVouchers", "shippingVouchers",
], ],
watch: [ watch: [
@ -524,6 +594,8 @@ const store = createStore({
"groups", "groups",
"loadedBoxes", "loadedBoxes",
"loadedItems", "loadedItems",
"messageTemplates",
"messageTemplatesVariables",
"shippingVouchers", "shippingVouchers",
], ],
mutations: [ mutations: [

View file

@ -3,7 +3,7 @@
<div class="container-fluid px-xl-5 mt-3"> <div class="container-fluid px-xl-5 mt-3">
<div class="row"> <div class="row">
<div class="col-xl-8 offset-xl-2"> <div class="col-xl-8 offset-xl-2">
<Table <SlotTable
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to', 'actions', 'actions2']" :columns="['id', 'name', 'state', 'last_activity', 'assigned_to', 'actions', 'actions2']"
:items="tickets.map(formatTicket)" :items="tickets.map(formatTicket)"
:keyName="'id'" :keyName="'id'"
@ -18,7 +18,7 @@
</a> </a>
</div> </div>
</template> </template>
</Table> </SlotTable>
</div> </div>
</div> </div>
<CollapsableCards v-if="layout === 'tasks'" :items="tickets" <CollapsableCards v-if="layout === 'tasks'" :items="tickets"
@ -56,13 +56,13 @@ import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem'; import EditItem from '@/components/EditItem';
import {mapActions, mapGetters, mapState} from 'vuex'; import {mapActions, mapGetters, mapState} from 'vuex';
import Lightbox from '../components/Lightbox'; import Lightbox from '../components/Lightbox';
import Table from '@/components/Table'; import SlotTable from "@/components/SlotTable.vue";
import CollapsableCards from "@/components/CollapsableCards.vue"; import CollapsableCards from "@/components/CollapsableCards.vue";
import AsyncLoader from "@/components/AsyncLoader.vue"; import AsyncLoader from "@/components/AsyncLoader.vue";
export default { export default {
name: 'Tickets', name: 'Tickets',
components: {AsyncLoader, Lightbox, Table, Cards, Modal, EditItem, CollapsableCards}, components: {AsyncLoader, Lightbox, SlotTable, Cards, Modal, EditItem, CollapsableCards},
computed: { computed: {
...mapState(['tickets']), ...mapState(['tickets']),
...mapGetters(['stateInfo', 'getEventSlug', 'layout']), ...mapGetters(['stateInfo', 'getEventSlug', 'layout']),

View file

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

View file

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

View file

@ -1,50 +1,102 @@
<template> <template>
<Table <AsyncLoader :loaded="events.length > 0">
:columns="['slug', 'name']" <ExpandableTable v-if="!!events" :columns="['slug', 'name']" :items="events" :keyName="'slug'">
:items="events" <template v-slot:header_actions>
:keyName="'slug'" <button class="btn btn-success" @click.prevent="openAddEventModal">
> <font-awesome-icon icon="plus"/>
<template v-slot:header_actions> Create Event
<button class="btn btn-success" @click.prevent="openAddEventModal">
<font-awesome-icon icon="plus"/>
Create Event
</button>
</template>
<template v-slot:actions="{ item }">
<div class="btn-group">
<button class="btn btn-secondary" @click.stop="changeEvent(item)">
<font-awesome-icon icon="archive"/>
use
</button> </button>
<button class="btn btn-danger" @click.stop="safeDeleteEvent(item.eid)"> </template>
<font-awesome-icon icon="trash"/> <template v-slot:actions="{ item }">
delete <div class="btn-group">
</button> <button class="btn btn-secondary" @click.stop="changeEvent(item)" style="white-space: nowrap;">
</div> <font-awesome-icon icon="archive"/>&nbsp;use
</template> </button>
</Table> <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">
<font-awesome-icon icon="trash"/>
</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>
</template> </template>
<script> <script>
import {mapActions, mapMutations, mapState} from 'vuex'; import {mapActions, mapMutations, mapState} from 'vuex';
import Table from '@/components/Table'; import ExpandableTable from "@/components/ExpandableTable.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
export default { export default {
name: 'Events', name: 'Events',
components: {Table}, components: {AsyncLoader, ExpandableTable},
computed: mapState(['events']), computed: mapState(['events']),
data() {
return {new_address: []}
},
methods: { methods: {
...mapActions(['changeEvent', 'deleteEvent']), ...mapActions(['changeEvent', 'deleteEvent', 'updateEvent']),
...mapMutations(['openAddEventModal']), ...mapMutations(['openAddEventModal']),
safeDeleteEvent(id) { safeDeleteEvent(id) {
if (confirm('do you want to completely delete this event and related data?')) { if (confirm('do you want to completely delete this event and related data?')) {
this.deleteEvent(id) 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> </script>
<style scoped> <style scoped>
</style> </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>