Compare commits

..

35 commits

Author SHA1 Message Date
d4ffa0228c update admin interface
All checks were successful
/ test (push) Successful in 54s
/ deploy (push) Successful in 4m40s
2024-11-23 17:43:09 +01:00
61c4beda47 add timeline information to the /items endpoint
Some checks failed
/ test (push) Successful in 53s
/ deploy (push) Failing after 3m20s
2024-11-23 16:58:55 +01:00
e8887fee8b add frontend to edit event details
All checks were successful
/ test (push) Successful in 51s
/ deploy (push) Successful in 4m37s
2024-11-23 01:12:45 +01:00
8786f4b845 ensure creation file date
All checks were successful
/ test (push) Successful in 49s
/ deploy (push) Successful in 5m5s
2024-11-23 00:59:45 +01:00
0eaff2266c fix untested error in /tickets endpoint
All checks were successful
/ test (push) Successful in 48s
/ deploy (push) Successful in 4m45s
2024-11-21 22:37:49 +01:00
81a0959547 Speed up sql
All checks were successful
/ test (pull_request) Successful in 48s
/ test (push) Successful in 49s
/ deploy (push) Successful in 5m13s
2024-11-21 21:26:16 +01:00
fdf5ab8ad1 implement simple backend search for items and tickets
All checks were successful
/ test (push) Successful in 51s
/ deploy (push) Successful in 4m36s
2024-11-21 01:45:47 +01:00
f2647f0dbd add /matches endpoint ad return match information in tickets and /item endpoints
All checks were successful
/ test (push) Successful in 52s
/ deploy (push) Successful in 4m42s
2024-11-21 00:59:03 +01:00
26255fadec drop v1 API and rename id columns
All checks were successful
/ test (push) Successful in 51s
2024-11-20 02:39:49 +01:00
2fce260ba8 test on every push
All checks were successful
/ test (push) Successful in 50s
/ deploy (push) Successful in 4m41s
2024-11-18 02:17:30 +01:00
c9d58191b3 show tickets filtered by active event 2024-11-18 02:17:30 +01:00
41b71bd51a partition tickets by event 2024-11-18 02:17:30 +01:00
d73bebd5de match incoming mail to event 2024-11-18 02:16:55 +01:00
2c609427ec add simple issue templates
All checks were successful
/ test (push) Successful in 54s
/ deploy (push) Successful in 4m55s
2024-11-13 23:13:13 +01:00
120507512d deploy: Simple protection for metrics endpoint
All checks were successful
/ test (pull_request) Successful in 52s
/ test (push) Successful in 52s
/ deploy (push) Successful in 3m45s
2024-11-13 18:15:00 +00:00
63d6b7a5a8 cicd: run on every pull request, but only deploy on testing
All checks were successful
/ test (pull_request) Successful in 52s
/ test (push) Successful in 54s
/ deploy (push) Successful in 4m45s
2024-11-12 17:05:03 +01:00
5ba4085e60 cicd: Run tests automatically
All checks were successful
/ test (push) Successful in 54s
/ deploy (push) Successful in 4m41s
2024-11-11 22:10:35 +01:00
be02a3e163 cicd: Deploy testing automatically
All checks were successful
/ deploy (push) Successful in 4m56s
2024-11-11 19:37:37 +00:00
444c2de16c change the ticket.state in the backend too 2024-11-10 19:01:45 +00:00
8831f67f00 ticket state changes to pending_open on first view 2024-11-10 19:01:45 +00: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
b9cfdf5456 fixed race contidion on ticket view loading 2024-11-06 21:26:01 +01:00
0f8462dc7c enforce startup order in docker-compose.yml 2024-11-06 21:13:44 +01:00
242066ada4 Add dev environment using docker 2024-11-06 00:51:25 +01:00
d13687a910 cleanup admin 'dashboard' 2024-11-06 00:26:56 +01:00
f44da341b4 add event mail addresses to the /events endpoints 2024-11-06 00:08:52 +01:00
55cef1128e extract the search box into its own component 2024-11-05 23:36:05 +01:00
6e38ff7ac7 use an 'AsyncButton' that waits for completion of an async onClick handler for mail replies and comments in tickets 2024-11-05 23:32:53 +01:00
3a8fa8cdcf show an animation to signify that the page is still loading 2024-11-05 23:25:44 +01:00
767d34f8b7 state button has the state pre selected 2024-11-03 21:30:08 +01:00
dffd3531fa add form to create new event 2024-11-02 22:12:00 +01:00
77 changed files with 2523 additions and 1317 deletions

View file

@ -0,0 +1,35 @@
name: Bug Report
about: File a bug report
labels:
- Kind/Bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox (Windows)
- Firefox (MacOS)
- Firefox (Linux)
- Firefox (Android)
- Firefox (iOS)
- Chrome (Windows)
- Chrome (MacOS)
- Chrome (Linux)
- Chrome (Android)
- Chrome (iOS)
- Safari
- Microsoft Edge

View file

@ -0,0 +1,27 @@
name: 'New Feature'
about: 'This template is for new features'
labels:
- Kind/Feature
body:
- type: markdown
attributes:
value: |
Before creating a Feature Ticket, please check for duplicates.
- type: markdown
attributes:
value: |
### Implementation Checklist
- [ ] concept
- [ ] frontend
- [ ] backend
- [ ] unittests
- [ ] tested on staging
visible: [ content ]
- type: textarea
id: description
attributes:
label: 'Feature Description'
description: 'Explain the the feature.'
placeholder: Description
validations:
required: true

View file

@ -0,0 +1,60 @@
on:
push:
branches:
- testing
jobs:
test:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache-dependency-path: '**/requirements.dev.txt'
- name: Install dependencies
working-directory: core
run: pip3 install -r requirements.dev.txt
- name: Run django tests
working-directory: core
run: python3 manage.py test
deploy:
needs: [test]
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Install ansible
run: |
apt update -y
apt install python3-pip -y
python3 -m pip install ansible
python3 -m pip install ansible-lint
- name: Populate relevant files
run: |
mkdir ~/.ssh
echo "${{ secrets.C3LF_SSH_TESTING }}" > ~/.ssh/id_ed25519
chmod 0600 ~/.ssh/id_ed25519
ls -lah ~/.ssh
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_ed25519
echo "andromeda.lab.or.it ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDXPoO0PE+B9PYwbGaLo98zhbmjAkp6eBtVeZe43v/+T" >> ~/.ssh/known_hosts
mkdir /etc/ansible
echo "${{ secrets.C3LF_INVENTORY_TESTING }}" > /etc/ansible/hosts
- name: Check ansible version
run: |
ansible --version
- name: List ansible hosts
run: |
ansible -m ping Andromeda
- name: Deploy testing
run: |
cd deploy/ansible
ansible-playbook playbooks/deploy-c3lf-sys3.yml

View file

@ -0,0 +1,21 @@
on:
pull_request:
push:
jobs:
test:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache-dependency-path: '**/requirements.dev.txt'
- name: Install dependencies
working-directory: core
run: pip3 install -r requirements.dev.txt
- name: Run django tests
working-directory: core
run: python3 manage.py test

14
core/.coveragerc Normal file
View file

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

View file

@ -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,11 @@ 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 +29,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 +47,8 @@ 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'))
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -50,6 +59,7 @@ 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',
@ -85,6 +95,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 +103,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 +146,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 +217,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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
from django.urls import 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([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(' ') if not request.user.has_event_perm(event, 'view_item'):
q = Item.objects.filter(event=event) return Response(status=403)
for token in query_tokens: items = filter_items(Item.objects.filter(event=event), b64decode(query).decode('utf-8'))
if token: return Response(SearchResultSerializer(items, many=True).data)
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)
@ -40,7 +50,9 @@ def search_items(request, event_slug, query):
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def item(request, event_slug): def item(request, event_slug):
try: try:
event = Event.objects.get(slug=event_slug) event = None
if event_slug != 'none':
event = Event.objects.get(slug=event_slug)
if request.method == 'GET': if request.method == 'GET':
if not request.user.has_event_perm(event, 'view_item'): if not request.user.has_event_perm(event, 'view_item'):
return Response(status=403) return Response(status=403)
@ -52,8 +64,11 @@ def item(request, event_slug):
if validated_data.is_valid(): if validated_data.is_valid():
validated_data.save(event=event) validated_data.save(event=event)
return Response(validated_data.data, status=201) return Response(validated_data.data, status=201)
return Response(status=400)
except Event.DoesNotExist: except Event.DoesNotExist:
return Response(status=404) return Response(status=404)
except KeyError:
return Response(status=400)
@api_view(['GET', 'PUT', 'DELETE', 'PATCH']) @api_view(['GET', 'PUT', 'DELETE', 'PATCH'])
@ -61,7 +76,7 @@ def item(request, event_slug):
def item_by_id(request, event_slug, id): def item_by_id(request, event_slug, id):
try: try:
event = Event.objects.get(slug=event_slug) event = Event.objects.get(slug=event_slug)
item = Item.objects.get(event=event, uid=id) item = Item.objects.get(event=event, id=id)
if request.method == 'GET': if request.method == 'GET':
if not request.user.has_event_perm(event, 'view_item'): if not request.user.has_event_perm(event, 'view_item'):
return Response(status=403) return Response(status=403)
@ -99,8 +114,8 @@ 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), re_path(r'^(?P<event_slug>[\w-]+)/items/$', item, name='item'),
path('<event_slug>/items/<query>/', search_items), re_path(r'^(?P<event_slug>[\w-]+)/items/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_items, name='search_items'),
path('<event_slug>/item/', item), re_path(r'^(?P<event_slug>[\w-]+)/item/$', item, name='item'),
path('<event_slug>/item/<id>/', item_by_id), re_path(r'^(?P<event_slug>[\w-]+)/item/(?P<id>\d+)/$', item_by_id, name='item_by_id'),
] ]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2024-11-03 18:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0004_alter_event_created_at_alter_item_created_at'),
('mail', '0004_alter_emailattachment_file'),
]
operations = [
migrations.AlterField(
model_name='eventaddress',
name='event',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='addresses', to='inventory.event'),
),
]

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

@ -3,7 +3,7 @@ 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 from core.settings import MAIL_DOMAIN, ACTIVE_SPAM_TRAINING
from files.models import AbstractFile from files.models import AbstractFile
from inventory.models import Event from inventory.models import Event
from tickets.models import IssueThread from tickets.models import IssueThread
@ -18,7 +18,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,10 +28,22 @@ 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 and self.raw_file.path:
import subprocess
path = self.raw_file.path
subprocess.run(["rspamc", "learn_spam", path])
def train_ham(self):
if ACTIVE_SPAM_TRAINING and self.raw_file.path:
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) event = models.ForeignKey(Event, models.SET_NULL, null=True, related_name='addresses')
address = models.CharField(max_length=255) address = models.CharField(max_length=255)

View file

@ -1,4 +1,5 @@
import logging import logging
from re import match
import aiosmtplib import aiosmtplib
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
@ -10,6 +11,10 @@ from notify_sessions.models import SystemEvent
from tickets.models import IssueThread from tickets.models import IssueThread
class SpecialMailException(Exception):
pass
def find_quoted_printable(s, marker): def find_quoted_printable(s, marker):
positions = [i for i in range(len(s)) if s.lower().startswith('=?utf-8?' + marker + '?', i)] positions = [i for i in range(len(s)) if s.lower().startswith('=?utf-8?' + marker + '?', i)]
for pos in positions: for pos in positions:
@ -86,7 +91,7 @@ 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)
def find_active_issue_thread(in_reply_to, address, subject): def find_active_issue_thread(in_reply_to, address, subject, event):
from re import match from re import match
uuid_match = match(r'^ticket\+([a-f0-9-]{36})@', address) uuid_match = match(r'^ticket\+([a-f0-9-]{36})@', address)
if uuid_match: if uuid_match:
@ -97,7 +102,7 @@ def find_active_issue_thread(in_reply_to, address, subject):
if reply_to.exists(): if reply_to.exists():
return reply_to.first().issue_thread, False return reply_to.first().issue_thread, False
else: else:
issue = IssueThread.objects.create(name=subject) issue = IssueThread.objects.create(name=subject, event=event)
return issue, True return issue, True
@ -180,13 +185,13 @@ 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: if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "":
log.warning("Header from does not match envelope from") log.warning("Ignoring mailer daemon")
log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}") raise SpecialMailException("Ignoring mailer daemon")
if header_to != envelope.rcpt_tos[0]: if Email.objects.filter(reference=header_message_id).exists(): # break before issue thread is created
log.warning("Header to does not match envelope to") log.warning("Email already exists")
log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}") raise Exception("Email already exists")
recipient = envelope.rcpt_tos[0].lower() if envelope.rcpt_tos else header_to.lower() recipient = envelope.rcpt_tos[0].lower() if envelope.rcpt_tos else header_to.lower()
sender = envelope.mail_from if envelope.mail_from else header_from sender = envelope.mail_from if envelope.mail_from else header_from
@ -197,11 +202,14 @@ def receive_email(envelope, log=None):
subject = unescape_and_decode_base64(subject) subject = unescape_and_decode_base64(subject)
target_event = find_target_event(recipient) target_event = find_target_event(recipient)
active_issue_thread, new = find_active_issue_thread(header_in_reply_to, recipient, subject) active_issue_thread, new = find_active_issue_thread(header_in_reply_to, recipient, subject, target_event)
from hashlib import sha256
random_filename = 'mail-' + sha256(envelope.content).hexdigest()
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, name=random_filename), 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)
@ -233,7 +241,7 @@ Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid
active_issue_thread.state = 'pending_open' active_issue_thread.state = 'pending_open'
active_issue_thread.save() active_issue_thread.save()
return email, new, reply return email, new, reply, active_issue_thread
class LMTPHandler: class LMTPHandler:
@ -255,7 +263,7 @@ class LMTPHandler:
content = None content = None
try: try:
content = envelope.content content = envelope.content
email, new, reply = await receive_email(envelope, log) email, new, reply, thread = await receive_email(envelope, log)
log.info(f"Created email {email.id}") log.info(f"Created email {email.id}")
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received', systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
reference=email.id) reference=email.id)
@ -263,14 +271,20 @@ class LMTPHandler:
channel_layer = get_channel_layer() channel_layer = get_channel_layer()
await channel_layer.group_send( await channel_layer.group_send(
'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")
return '250 Message accepted for delivery'
except SpecialMailException as e:
import uuid
random_filename = 'special-' + str(uuid.uuid4())
with open(random_filename, 'wb') as f:
f.write(content)
log.warning(f"Special mail exception: {e} saved to {random_filename}")
return '250 Message accepted for delivery' return '250 Message accepted for delivery'
except Exception as e: except Exception as e:
from hashlib import sha256 from hashlib import sha256

View file

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

View file

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

View file

@ -1,3 +1,6 @@
aiodns==3.2.0
aiohttp==3.9.5
aiosignal==1.3.1
aiosmtpd==1.4.4.post2 aiosmtpd==1.4.4.post2
aiosmtplib==3.0.1 aiosmtplib==3.0.1
asgiref==3.7.2 asgiref==3.7.2
@ -38,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

View file

@ -1,4 +1,4 @@
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
@ -10,16 +10,28 @@ from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from core.settings import MAIL_DOMAIN from core.settings import MAIL_DOMAIN
from inventory.models import Event
from mail.models import Email 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, ItemRelation
from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer, SearchResultSerializer
from tickets.shared_serializers import RelationSerializer
class IssueViewSet(viewsets.ModelViewSet): class IssueViewSet(viewsets.ModelViewSet):
serializer_class = IssueSerializer serializer_class = IssueSerializer
queryset = IssueThread.objects.all() queryset = IssueThread.objects.all().prefetch_related('state_changes', 'comments', 'emails', 'emails__attachments', 'assignments', 'item_relation_changes', 'shipping_vouchers')
class RelationViewSet(viewsets.ModelViewSet):
serializer_class = RelationSerializer
queryset = ItemRelation.objects.all()
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = CommentSerializer
queryset = Comment.objects.all()
class ShippingVoucherViewSet(viewsets.ModelViewSet): class ShippingVoucherViewSet(viewsets.ModelViewSet):
@ -55,7 +67,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:
@ -65,8 +77,16 @@ def manual_ticket(request):
if 'body' not in request.data: if 'body' not in request.data:
return Response({'status': 'error', 'message': 'missing body'}, status=status.HTTP_400_BAD_REQUEST) return Response({'status': 'error', 'message': 'missing body'}, status=status.HTTP_400_BAD_REQUEST)
event = None
if event_slug != 'none':
try:
event = Event.objects.get(slug=event_slug)
except:
return Response({'status': 'error', 'message': 'invalid event'}, status=status.HTTP_400_BAD_REQUEST)
issue = IssueThread.objects.create( issue = IssueThread.objects.create(
name=request.data['name'], name=request.data['name'],
event=event,
manually_created=True, manually_created=True,
) )
email = Email.objects.create( email = Email.objects.create(
@ -116,13 +136,40 @@ 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([IsAuthenticated])
def search_issues(request, event_slug, query):
try:
event = Event.objects.get(slug=event_slug)
if not request.user.has_event_perm(event, 'view_issuethread'):
return Response(status=403)
items = filter_issues(IssueThread.objects.filter(event=event), b64decode(query).decode('utf-8'))
return Response(SearchResultSerializer(items, many=True).data)
except Event.DoesNotExist:
return Response(status=404)
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register(r'tickets', IssueViewSet, basename='issues') router.register(r'tickets', IssueViewSet, basename='issues')
router.register(r'matches', RelationViewSet, basename='matches')
router.register(r'shipping_vouchers', ShippingVoucherViewSet, basename='shipping_vouchers') router.register(r'shipping_vouchers', ShippingVoucherViewSet, basename='shipping_vouchers')
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

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

View file

@ -1,5 +1,7 @@
from django.db import models from itertools import groupby
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
@ -43,7 +45,6 @@ class IssueThread(SoftDeleteModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
event = models.ForeignKey(Event, null=True, on_delete=models.SET_NULL, related_name='issue_threads') event = models.ForeignKey(Event, null=True, on_delete=models.SET_NULL, related_name='issue_threads')
manually_created = models.BooleanField(default=False) manually_created = models.BooleanField(default=False)
related_items = models.ManyToManyField(Item, through='ItemRelation')
def short_uuid(self): def short_uuid(self):
return self.uuid[:8] return self.uuid[:8]
@ -51,7 +52,11 @@ class IssueThread(SoftDeleteModel):
@property @property
def state(self): def state(self):
try: try:
return self.state_changes.order_by('-timestamp').first().state state_changes = sorted(self.state_changes.all(), key=lambda x: x.timestamp, reverse=True)
if state_changes:
return state_changes[0].state
else:
return None
except AttributeError: except AttributeError:
return 'none' return 'none'
@ -60,11 +65,17 @@ 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):
try: try:
return self.assignments.order_by('-timestamp').first().assigned_to assignments = sorted(self.assignments.all(), key=lambda x: x.timestamp, reverse=True)
if assignments:
return assignments[0].assigned_to
else:
return None
except AttributeError: except AttributeError:
return None return None
@ -74,6 +85,11 @@ class IssueThread(SoftDeleteModel):
return return
self.assignments.create(assigned_to=value) self.assignments.create(assigned_to=value)
@property
def related_items(self):
groups = groupby(self.item_relation_changes.all(), lambda rel: rel.item.id)
return [sorted(v, key=lambda r: r.timestamp)[0].item for k, v in groups]
def __str__(self): def __str__(self):
return '[' + str(self.id) + '][' + self.short_uuid() + '] ' + self.name return '[' + str(self.id) + '][' + self.short_uuid() + '] ' + self.name
@ -130,8 +146,8 @@ class Assignment(models.Model):
class ItemRelation(models.Model): class ItemRelation(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='item_relations') issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='item_relation_changes')
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='issues') item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='issue_relation_changes')
timestamp = models.DateTimeField(auto_now_add=True) timestamp = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=255, choices=RELATION_STATUS_CHOICES, default='possible') status = models.CharField(max_length=255, choices=RELATION_STATUS_CHOICES, default='possible')

View file

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

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

View file

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

View file

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

@ -11,4 +11,6 @@ c3lf-nodes:
mail_domain: <mail_domain> mail_domain: <mail_domain>
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,6 @@ 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 }}

View file

@ -70,6 +70,13 @@ server {
alias /var/www/c3lf-sys3/staticfiles/; alias /var/www/c3lf-sys3/staticfiles/;
} }
location /metrics {
allow 95.156.226.90;
allow 127.0.0.1;
allow ::1;
deny all;
}
listen 443 ssl http2; # managed by Certbot listen 443 ssl http2; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/{{ web_domain }}/fullchain.pem; # managed by Certbot ssl_certificate /etc/letsencrypt/live/{{ web_domain }}/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/{{ web_domain }}/privkey.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/{{ web_domain }}/privkey.pem; # managed by Certbot

View file

@ -10,4 +10,4 @@ RUN apt update && apt install -y mariadb-client
RUN pip install -r requirements.dev.txt RUN pip install -r requirements.dev.txt
RUN pip install -r requirements.prod.txt RUN pip install -r requirements.prod.txt
RUN pip install mysqlclient RUN pip install mysqlclient
COPY . /code/ COPY .. /code/

View file

@ -0,0 +1,48 @@
services:
core:
build:
context: ../../core
dockerfile: ../deploy/dev/Dockerfile.backend
command: bash -c 'python manage.py migrate && python manage.py runserver 0.0.0.0:8000'
environment:
- HTTP_HOST=core
- DB_HOST=db
- DB_PORT=3306
- DB_NAME=system3
- DB_USER=system3
- DB_PASSWORD=system3
volumes:
- ../../core:/code
ports:
- "8000:8000"
depends_on:
- db
frontend:
build:
context: ../../web
dockerfile: ../deploy/dev/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
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"
volumes:
mariadb_data:

27
deploy/dev/vue.config.js Normal file
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

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

@ -1,31 +0,0 @@
services:
core:
build:
context: ./core
dockerfile: Dockerfile.dev
command: python manage.py runserver 0.0.0.0:8000
#environment:
# - DATABASE_URL
volumes:
- ./core:/code
ports:
- "8000:8000"
frontend:
build:
context: ./web
dockerfile: Dockerfile.dev
command: npm run serve
volumes:
- ./web:/web:ro
- /web/node_modules
ports:
- "8080:8080"
db:
image: mariadb
environment:
MARIADB_RANDOM_ROOT_PASSWORD: true
MARIADB_DATABASE: system3
MARIADB_USER: system3
MARIADB_PASSWORD: system3

View file

@ -1,10 +1,11 @@
<template> <template>
<div id="app"> <div style="min-height: 100vh; display: flex; flex-direction: column;">
<AddItemModal v-if="addItemModalOpen && isLoggedIn" @close="closeAddItemModal()" isModal="true"/> <AddItemModal v-if="addItemModalOpen && isLoggedIn" @close="closeAddItemModal()" isModal="true"/>
<AddTicketModal v-if="addTicketModalOpen && isLoggedIn" @close="closeAddTicketModal()" isModal="true"/> <AddTicketModal v-if="addTicketModalOpen && isLoggedIn" @close="closeAddTicketModal()" isModal="true"/>
<AddBoxModal v-if="showAddBoxModal && isLoggedIn" @close="closeAddBoxModal()" isModal="true"/> <AddBoxModal v-if="showAddBoxModal && isLoggedIn" @close="closeAddBoxModal()" isModal="true"/>
<AddEventModal v-if="showAddEventModal && isLoggedIn" @close="closeAddEventModal()" isModal="true"/>
<Navbar v-if="isLoggedIn" @addItemClicked="openAddItemModal()" @addTicketClicked="openAddTicketModal()"/> <Navbar v-if="isLoggedIn" @addItemClicked="openAddItemModal()" @addTicketClicked="openAddTicketModal()"/>
<router-view/> <router-view style="flex: 1 1;"/>
</div> </div>
</template> </template>
@ -14,12 +15,13 @@ import AddItemModal from '@/components/AddItemModal';
import {mapState, mapMutations, mapActions, mapGetters} from 'vuex'; import {mapState, mapMutations, mapActions, mapGetters} from 'vuex';
import AddTicketModal from "@/components/AddTicketModal.vue"; import AddTicketModal from "@/components/AddTicketModal.vue";
import AddBoxModal from "@/components/AddBoxModal.vue"; import AddBoxModal from "@/components/AddBoxModal.vue";
import AddEventModal from "@/components/AddEventModal.vue";
export default { export default {
name: 'app', name: 'app',
components: {AddBoxModal, Navbar, AddItemModal, AddTicketModal}, components: {AddBoxModal, AddEventModal, Navbar, AddItemModal, AddTicketModal},
computed: { computed: {
...mapState(['loadedItems', 'layout', 'toasts', 'showAddBoxModal']), ...mapState(['loadedItems', 'layout', 'toasts', 'showAddBoxModal', 'showAddEventModal']),
...mapGetters(['isLoggedIn']), ...mapGetters(['isLoggedIn']),
}, },
data: () => ({ data: () => ({
@ -27,7 +29,7 @@ export default {
addTicketModalOpen: false addTicketModalOpen: false
}), }),
methods: { methods: {
...mapMutations(['removeToast', 'createToast', 'closeAddBoxModal', 'openAddBoxModal']), ...mapMutations(['removeToast', 'createToast', 'closeAddBoxModal', 'openAddBoxModal', 'closeAddEventModal']),
...mapActions(['loadEvents', 'scheduleAfterInit']), ...mapActions(['loadEvents', 'scheduleAfterInit']),
openAddItemModal() { openAddItemModal() {
this.addItemModalOpen = true; this.addItemModalOpen = true;

View file

@ -0,0 +1,86 @@
<template>
<div>
<Modal v-if="isModal" title="Add Event" @close="$emit('close')">
<template #body>
<div>
<div class="row mb-3">
<div class="col">
<input type="text" class="form-control" placeholder="Event Title"
v-model="data.name">
</div>
<div class="col">
<input type="text" class="form-control" placeholder="Event Slug" v-model="data.slug">
</div>
</div>
<div class="row">
<div class="col">
<input type="date" class="form-control" title="Buildup Start" v-model="data.pre_start"
:max="data.start" @focus="prepare_date_field">
</div>
<div class="col">
<input type="date" class="form-control" title="Official Event Start" v-model="data.start"
:min="data.pre_start" :max="data.end" @focus="prepare_date_field">
</div>
<div class="col">
<input type="date" class="form-control" title="Official Event End" v-model="data.end"
:min="data.start" :max="data.post_end" @focus="prepare_date_field">
</div>
<div class="col">
<input type="date" class="form-control" title="Teardown End" v-model="data.post_end"
:min="data.end" @focus="prepare_date_field">
</div>
</div>
<small>The buildup and teardown dates only relate to the timeframe in which the c3lf team is
present.</small>
</div>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="$emit('close')">Cancel</button>
<button type="button" class="btn btn-success" @click="createEvent(data).then(()=>$emit('close'))">
Create
</button>
</template>
</Modal>
</div>
</template>
<script>
import Modal from '@/components/Modal';
import {mapActions} from "vuex";
export default {
name: 'AddEventModal',
components: {Modal},
props: ['isModal'],
data: () => ({
data: {
name: '',
slug: '',
start: null,
end: null,
pre_start: null,
post_end: null,
},
errors: {},
}),
methods: {
...mapActions(['createEvent']),
validate_data() {
return this.pre_start <= this.start && this.start <= this.end && this.end <= this.post_end && !!this.slug && !!this.name;
},
prepare_date_field(e) {
if (!e.target.value) {
if (e.target.min) {
e.target.value = e.target.min
} else if (e.target.max) {
e.target.value = e.target.max
}
}
}
},
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,133 @@
<template>
<div class="async-wrapper" :class="{ 'loaded': loaded }">
<div class="deferred">
<slot></slot>
</div>
<div class="loader-wrapper">
<div class="loader-ellipsis">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AsyncLoader',
props: {
loaded: {
type: Boolean,
default: false
}
}
};
</script>
<style>
.async-wrapper {
position: relative;
}
.async-wrapper > .deferred {
width: 100%;
height: 100%;
display: none;
}
.async-wrapper.loaded > .deferred {
display: block;
}
.async-wrapper > .loader-wrapper {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis {
color: #17a2b8;
}
.async-wrapper.loaded > .loader-wrapper {
display: none;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis,
.async-wrapper > .loader-wrapper > .loader-ellipsis div {
box-sizing: border-box;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis div {
position: absolute;
top: 33.33333px;
width: 13.33333px;
height: 13.33333px;
border-radius: 50%;
background: currentColor;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(1) {
left: 8px;
animation: loader-ellipsis1 0.6s infinite;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(2) {
left: 8px;
animation: loader-ellipsis2 0.6s infinite;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(3) {
left: 32px;
animation: loader-ellipsis2 0.6s infinite;
}
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(4) {
left: 56px;
animation: loader-ellipsis3 0.6s infinite;
}
@keyframes loader-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes loader-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes loader-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
</style>

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

@ -6,7 +6,8 @@
{{ getEventSlug }} {{ getEventSlug }}
</button> </button>
<div class="dropdown-menu bg-dark" aria-labelledby="dropdownMenuButton"> <div class="dropdown-menu bg-dark" aria-labelledby="dropdownMenuButton">
<a class="dropdown-item text-light" href="#" v-for="(event, index) in events" v-bind:key="index" <a class="dropdown-item text-light" href="#" v-for="(event, index) in selectableEvents"
v-bind:key="index"
:class="{ active: event.slug === getEventSlug }" @click="changeEvent(event)">{{ event.slug }}</a> :class="{ active: event.slug === getEventSlug }" @click="changeEvent(event)">{{ event.slug }}</a>
</div> </div>
</div> </div>
@ -29,16 +30,7 @@
</router-link> </router-link>
</li> </li>
</ul> </ul>
<form class="form-inline mt-1 my-lg-auto my-xl-auto w-100 d-inline mr-1" v-if="hasPermissions"> <SearchBox v-if="hasPermissions" class="mt-1 my-lg-auto my-xl-auto w-100 d-inline mr-1"/>
<input
class="form-control w-100"
type="search"
placeholder="Search"
aria-label="Search"
@input="searchEventItems($event.target.value)"
disabled
>
</form>
<div class="custom-control-inline mr-1" v-if="hasPermissions"> <div class="custom-control-inline mr-1" v-if="hasPermissions">
<div class="btn-group btn-group-toggle mr-1" v-if="isItemView()"> <div class="btn-group btn-group-toggle mr-1" v-if="isItemView()">
<button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')"> <button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')">
@ -57,12 +49,12 @@
</button> </button>
</div> </div>
<button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addItemClicked')" <button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addItemClicked')"
v-if="isItemView()"> v-if="isItemView() && getEventSlug !== 'all'">
<font-awesome-icon icon="plus"/> <font-awesome-icon icon="plus"/>
<span class="d-none d-md-inline">&nbsp;Add Item</span> <span class="d-none d-md-inline">&nbsp;Add Item</span>
</button> </button>
<button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addTicketClicked')" <button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addTicketClicked')"
v-if="isTicketView()"> v-if="isTicketView() && getEventSlug !== 'all'">
<font-awesome-icon icon="plus"/> <font-awesome-icon icon="plus"/>
<span class="d-none d-md-inline">&nbsp;Add Ticket</span> <span class="d-none d-md-inline">&nbsp;Add Ticket</span>
</button> </button>
@ -73,19 +65,6 @@
</button> </button>
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto"> <ul class="navbar-nav ml-auto">
<!--li class="nav-item dropdown">
<button class="btn nav-link dropdown-toggle" type="button" id="dropdownMenuButton2"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ getActiveView }}
</button>
<ul class="dropdown-menu bg-dark" aria-labelledby="dropdownMenuButton2">
<li class="" v-for="(link, index) in views" v-bind:key="index"
:class="{ active: link.path === getActiveView }">
<a class="nav-link text-nowrap" href="#" @click="changeView(link)">{{ link.title }}</a>
</li>
</ul>
</li-->
<li class="nav-item" v-for="(link, index) in links" v-bind:key="index"> <li class="nav-item" v-for="(link, index) in links" v-bind:key="index">
<a class="nav-link text-nowrap" :href="link.path" @click.prevent="navigateTo(link.path)"> <a class="nav-link text-nowrap" :href="link.path" @click.prevent="navigateTo(link.path)">
{{ link.title }} {{ link.title }}
@ -103,9 +82,13 @@
<script> <script>
import {mapState, mapActions, mapMutations, mapGetters} from 'vuex'; import {mapState, mapActions, mapMutations, mapGetters} from 'vuex';
import SearchBox from "@/components/inputs/SearchBox.vue";
export default { export default {
name: 'Navbar', name: 'Navbar',
components: {
SearchBox
},
data: () => ({ data: () => ({
views: [ views: [
{'title': 'items', 'path': 'items'}, {'title': 'items', 'path': 'items'},
@ -120,9 +103,12 @@ export default {
computed: { computed: {
...mapState(['events']), ...mapState(['events']),
...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions", "layout", "route"]), ...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions", "layout", "route"]),
selectableEvents() {
return [{slug: 'all'}, ...this.events, {slug: 'none'}];
}
}, },
methods: { methods: {
...mapActions(['changeEvent', 'changeView', 'searchEventItems']), ...mapActions(['changeEvent', 'changeView']),
...mapMutations(['logout']), ...mapMutations(['logout']),
navigateTo(link) { navigateTo(link) {
if (this.route.path !== link) if (this.route.path !== link)

View file

@ -40,10 +40,10 @@
<div class=""> <div class="">
<textarea placeholder="add comment..." v-model="newComment" class="form-control"> <textarea placeholder="add comment..." v-model="newComment" class="form-control">
</textarea> </textarea>
<button class="btn btn-primary float-right" @click="addCommentAndClear"> <AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
<font-awesome-icon icon="comment"/> <font-awesome-icon icon="comment"/>
Save Comment Save Comment
</button> </AsyncButton>
</div> </div>
</div> </div>
</li> </li>
@ -58,10 +58,10 @@
<div> <div>
<textarea placeholder="reply mail..." v-model="newMail" class="form-control"> <textarea placeholder="reply mail..." v-model="newMail" class="form-control">
</textarea> </textarea>
<button class="btn btn-primary float-right" @click="sendMailAndClear"> <AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear">
<font-awesome-icon icon="envelope"/> <font-awesome-icon icon="envelope"/>
Send Mail Send Mail
</button> </AsyncButton>
</div> </div>
</div> </div>
</li> </li>
@ -77,11 +77,12 @@ import {mapActions, mapGetters} from "vuex";
import TimelineAssignment from "@/components/TimelineAssignment.vue"; import TimelineAssignment from "@/components/TimelineAssignment.vue";
import TimelineRelatedItem from "@/components/TimelineRelatedItem.vue"; import TimelineRelatedItem from "@/components/TimelineRelatedItem.vue";
import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue"; import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue";
export default { export default {
name: 'Timeline', name: 'Timeline',
components: { components: {
TimelineShippingVoucher, TimelineShippingVoucher, AsyncButton,
TimelineRelatedItem, TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail TimelineRelatedItem, TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail
}, },
props: { props: {
@ -103,18 +104,15 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['fetchShippingVouchers']), ...mapActions(['sendMail', 'postComment']),
sendMailAndClear: function () { sendMailAndClear: async function () {
this.$emit('sendMail', this.newMail); await this.sendMail(this.newMail);
this.newMail = ""; this.newMail = "";
}, },
addCommentAndClear: function () { addCommentAndClear: async function () {
this.$emit('addComment', this.newComment); await this.postComment(this.newComment);
this.newComment = ""; this.newComment = "";
} }
},
mounted() {
this.fetchShippingVouchers();
} }
}; };
</script> </script>

View file

@ -0,0 +1,47 @@
<template>
<button @click.stop="handleClick" :disabled="disabled">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"
:class="{'d-none': !disabled}"></span>
<span class="ml-2" :class="{'d-none': !disabled}">In Progress...</span>
<span :class="{'d-none': disabled}"><slot></slot></span>
</button>
</template>
<script>
export default {
name: 'AsyncButton',
data() {
return {
disabled: false,
};
},
props: {
task: {
type: Function,
required: true,
},
},
methods: {
async handleClick() {
console.log("AsyncButton.handleClick() called");
if (this.task && typeof this.task === 'function') {
this.disabled = true;
try {
await this.task();
} catch (e) {
console.error(e);
} finally {
this.disabled = false;
}
}
},
}
};
</script>
<style scoped>
.spinner-border {
vertical-align: -0.125em;
}
</style>

View file

@ -0,0 +1,43 @@
<template>
<input
class="form-control w-100"
type="search"
placeholder="Search"
aria-label="Search"
v-model="search_query"
@keyup.enter="dispatchSearch"
>
</template>
<script>
import {mapActions, mapGetters} from "vuex";
export default {
name: 'SearchBox',
data() {
return {
search_query: ''
}
},
computed: {
...mapGetters(['getActiveView'])
},
methods: {
...mapActions(['searchEventItems', 'searchEventTickets']),
isItemView() {
return this.getActiveView === 'items' || this.getActiveView === 'item';
},
isTicketView() {
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket';
},
dispatchSearch() {
if (this.isItemView()) {
this.searchEventItems(this.search_query);
} else if (this.isTicketView()) {
this.searchEventTickets(this.search_query);
}
}
}
};
</script>

View file

@ -7,7 +7,7 @@ import Files from './views/Files';
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';
import Debug from "@/views/admin/Debug.vue"; import Dashboard from "@/views/admin/Dashboard.vue";
import Tickets from "@/views/Tickets.vue"; import Tickets from "@/views/Tickets.vue";
import Ticket from "@/views/Ticket.vue"; import Ticket from "@/views/Ticket.vue";
import Admin from "@/views/admin/Admin.vue"; import Admin from "@/views/admin/Admin.vue";
@ -59,7 +59,7 @@ const routes = [
{requiresAuth: true, requiresPermission: 'delete_event'} {requiresAuth: true, requiresPermission: 'delete_event'}
}, },
{ {
path: '', name: 'admin', component: Debug, meta: path: '', name: 'admin', component: Dashboard, meta:
{requiresAuth: true, requiresPermission: 'delete_event'} {requiresAuth: true, requiresPermission: 'delete_event'}
}, },
{ {

View file

@ -3,7 +3,7 @@ import router from './router';
import * as base64 from 'base-64'; import * as base64 from 'base-64';
import * as utf8 from 'utf8'; import * as utf8 from 'utf8';
import {ticketStateColorLookup, ticketStateIconLookup, http} from "@/utils"; import {ticketStateColorLookup, ticketStateIconLookup, http, http_session} from "@/utils";
import sharedStatePlugin from "@/shared-state-plugin"; import sharedStatePlugin from "@/shared-state-plugin";
import persistentStatePlugin from "@/persistent-state-plugin"; import persistentStatePlugin from "@/persistent-state-plugin";
@ -11,18 +11,20 @@ const store = createStore({
state: { state: {
keyIncrement: 0, keyIncrement: 0,
events: [], events: [],
loadedItems: [], items: [],
itemCache: {},
loadedBoxes: [], loadedBoxes: [],
toasts: [], toasts: [],
tickets: [],
users: [], users: [],
groups: [], groups: [],
state_options: [], state_options: [],
shippingVouchers: [], shippingVouchers: [],
lastEvent: '37C3', loadedItems: {},
loadedTickets: {},
lastEvent: 'all',
lastUsed: {}, lastUsed: {},
searchQuery: '',
remember: false, remember: false,
user: { user: {
username: null, username: null,
@ -48,6 +50,7 @@ const store = createStore({
afterInitHandlers: [], afterInitHandlers: [],
showAddBoxModal: false, showAddBoxModal: false,
showAddEventModal: false,
shippingVoucherTypes: { shippingVoucherTypes: {
'2kg-de': '2kg Paket (DE)', '2kg-de': '2kg Paket (DE)',
@ -60,7 +63,14 @@ const store = createStore({
}, },
getters: { getters: {
route: state => router.currentRoute.value, route: state => router.currentRoute.value,
session: state => http_session(state.user.token),
getEventSlug: state => router.currentRoute.value.params.event ? router.currentRoute.value.params.event : state.lastEvent, getEventSlug: state => router.currentRoute.value.params.event ? router.currentRoute.value.params.event : state.lastEvent,
getAllItems: state => Object.values(state.loadedItems).flat(),
getAllTickets: state => Object.values(state.loadedTickets).flat(),
getEventItems: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllItems : getters.getAllItems.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
getEventTickets: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllTickets : getters.getAllTickets.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
isItemsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedItems : Object.keys(state.loadedItems).includes(getters.getEventSlug),
isTicketsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedTickets : Object.keys(state.loadedTickets).includes(getters.getEventSlug),
getActiveView: state => router.currentRoute.value.name || 'items', getActiveView: state => router.currentRoute.value.name || 'items',
getFilters: state => router.currentRoute.value.query, getFilters: state => router.currentRoute.value.query,
getBoxes: state => state.loadedBoxes, getBoxes: state => state.loadedBoxes,
@ -128,35 +138,48 @@ const store = createStore({
changeView(state, {view, slug}) { changeView(state, {view, slug}) {
router.push({path: `/${slug}/${view}`}); router.push({path: `/${slug}/${view}`});
}, },
replaceLoadedItems(state, newItems) {
state.loadedItems = newItems;
state.fetchedData = {...state.fetchedData, items: Date.now()}; // TODO: manage caching items for different events and search results correctly
},
setItemCache(state, {slug, items}) {
state.itemCache[slug] = items;
},
replaceBoxes(state, loadedBoxes) { replaceBoxes(state, loadedBoxes) {
state.loadedBoxes = loadedBoxes; state.loadedBoxes = loadedBoxes;
state.fetchedData = {...state.fetchedData, boxes: Date.now()}; state.fetchedData = {...state.fetchedData, boxes: Date.now()};
}, },
setItems(state, {slug, items}) {
state.loadedItems[slug] = items;
state.loadedItems = {...state.loadedItems};
console.log(state.loadedItems)
},
replaceItems(state, items) {
const groups = Object.groupBy(items, i => i.event ? i.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedItems[key] = value;
state.loadedItems = {...state.loadedItems};
console.log(state.loadedItems)
},
updateItem(state, updatedItem) { updateItem(state, updatedItem) {
const item = state.loadedItems.filter(({uid}) => uid === updatedItem.uid)[0]; const item = state.loadedItems[updatedItem.event?updatedItem.event:'none'].filter(
({uid}) => uid === updatedItem.uid)[0];
Object.assign(item, updatedItem); Object.assign(item, updatedItem);
}, },
removeItem(state, item) { removeItem(state, item) {
state.loadedItems = state.loadedItems.filter(it => it !== item); state.loadedItems[item.event?item.event:'none'] = state.loadedItems[item.event].filter(it => it !== item);
}, },
appendItem(state, item) { appendItem(state, item) {
state.loadedItems.push(item); state.loadedItems[item.event?item.event:'none'].push(item);
},
setTickets(state, {slug, tickets}) {
state.loadedTickets[slug] = tickets;
state.loadedTickets = {...state.loadedTickets};
console.log(state.loadedTickets)
}, },
replaceTickets(state, tickets) { replaceTickets(state, tickets) {
state.tickets = tickets; const groups = Object.groupBy(tickets, t => t.event ? t.event : 'none')
state.fetchedData = {...state.fetchedData, tickets: Date.now()}; for (const [key, value] of Object.entries(groups)) state.loadedTickets[key] = value;
state.loadedTickets = {...state.loadedTickets};
console.log(state.loadedTickets)
}, },
updateTicket(state, updatedTicket) { updateTicket(state, updatedTicket) {
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0]; const ticket = state.loadedTickets[updatedTicket.event?updatedTicket.event:'none'].filter(
({id}) => id === updatedTicket.id)[0];
Object.assign(ticket, updatedTicket); Object.assign(ticket, updatedTicket);
state.tickets = [...state.tickets]; state.loadedTickets = {...state.loadedTickets};
}, },
replaceUsers(state, users) { replaceUsers(state, users) {
state.users = users; state.users = users;
@ -172,6 +195,12 @@ const store = createStore({
closeAddBoxModal(state) { closeAddBoxModal(state) {
state.showAddBoxModal = false; state.showAddBoxModal = false;
}, },
openAddEventModal(state) {
state.showAddEventModal = true;
},
closeAddEventModal(state) {
state.showAddEventModal = false;
},
createToast(state, {title, message, color}) { createToast(state, {title, message, color}) {
var toast = {title, message, color, key: state.keyIncrement} var toast = {title, message, color, key: state.keyIncrement}
state.toasts.push(toast); state.toasts.push(toast);
@ -241,7 +270,7 @@ const store = createStore({
return false; return false;
} }
}, },
async reloadToken({commit, state, getters}) { async reloadToken({commit, state}) {
try { try {
if (state.user.username && state.user.password) { if (state.user.username && state.user.password) {
const data = await fetch('/api/2/login/', { const data = await fetch('/api/2/login/', {
@ -289,171 +318,183 @@ const store = createStore({
async fetchImage({state}, url) { async fetchImage({state}, url) {
return await fetch(url, {headers: {'Authorization': `Token ${state.user.token}`}}); return await fetch(url, {headers: {'Authorization': `Token ${state.user.token}`}});
}, },
async loadUserInfo({commit, state}) { async loadUserInfo({commit, getters}) {
const {data, success} = await http.get('/2/self/', state.user.token); const {data, success} = await getters.session.get('/2/self/');
commit('setPermissions', data.permissions); commit('setPermissions', data.permissions);
}, },
async loadEvents({commit, state}) { async loadEvents({commit, state, getters}) {
if (!state.user.token) return; if (!state.user.token) return;
if (state.fetchedData.events > Date.now() - 1000 * 60 * 60 * 24) return; if (state.fetchedData.events > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/events/', state.user.token); const {data, success} = await getters.session.get('/2/events/');
if (data && success) if (data && success) commit('replaceEvents', data);
commit('replaceEvents', data);
}, },
async fetchTicketStates({commit, state}) { async createEvent({commit, dispatch, state, getters}, event) {
const {data, success} = await getters.session.post('/2/events/', event);
if (data && success) commit('replaceEvents', [...state.events, data]);
},
async deleteEvent({commit, dispatch, state, getters}, event_id) {
const {data, success} = await getters.session.delete(`/2/events/${event_id}/`);
if (success) {
await dispatch('loadEvents')
commit('replaceEvents', [...state.events.filter(e => e.eid !== event_id)])
}
},
async updateEvent({commit, dispatch, state}, {id, partial_event}){
console.log(id, partial_event);
const {data, success} = await http.patch(`/2/events/${id}/`, partial_event, state.user.token);
if (success) {
commit('replaceEvents', [...state.events.filter(e => e.eid !== id), data])
}
},
async fetchTicketStates({commit, state, getters}) {
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;
const {data, success} = await http.get('/2/tickets/states/', state.user.token); const {data, success} = await getters.session.get('/2/tickets/states/');
if (data && success) if (data && success) commit('replaceTicketStates', data);
commit('replaceTicketStates', data);
}, },
changeEvent({dispatch, getters, commit}, eventName) { async changeEvent({dispatch, getters, commit}, eventName) {
router.push({path: `/${eventName.slug}/${getters.getActiveView}/`}); await router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
dispatch('loadEventItems'); //dispatch('loadEventItems');
}, },
changeView({getters}, link) { async changeView({getters}, link) {
router.push({path: `/${getters.getEventSlug}/${link.path}/`}); await router.push({path: `/${getters.getEventSlug}/${link.path}/`});
}, },
showBoxContent({getters}, box) { async showBoxContent({getters}, box) {
router.push({path: `/${getters.getEventSlug}/items/`, query: {box}}); await router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
}, },
async loadEventItems({commit, getters, state}) { async loadEventItems({commit, getters, state}) {
if (!state.user.token) return; if (!state.user.token) return;
if (state.fetchedData.items > Date.now() - 1000 * 60 * 60 * 24) return; if (state.fetchedData.items > Date.now() - 1000 * 60 * 60 * 24) return;
try { try {
commit('replaceLoadedItems', []);
const slug = getters.getEventSlug; const slug = getters.getEventSlug;
if (slug in state.itemCache) { const {data, success} = await getters.session.get(`/2/${slug}/items/`);
commit('replaceLoadedItems', state.itemCache[slug]);
}
const {data, success} = await http.get(`/2/${slug}/items/`, state.user.token);
if (data && success) { if (data && success) {
commit('replaceLoadedItems', data); commit('setItems', {slug, items: data});
commit('setItemCache', {slug, items: data});
} }
} catch (e) { } catch (e) {
console.error("Error loading items"); console.error("Error loading items");
} }
}, },
async searchEventItems({commit, getters, state}, query) { async searchEventItems({commit, getters, state}, query) {
const foo = utf8.encode(query); const encoded_query = base64.encode(utf8.encode(query));
const bar = base64.encode(foo); const slug = getters.getEventSlug;
const {
const {data, success} = await http.get(`/2/${getters.getEventSlug}/items/${bar}/`, state.user.token); data, success
if (data && success) } = await getters.session.get(`/2/${slug}/items/${encoded_query}/`);
commit('replaceLoadedItems', data); if (data && success) {
commit('setItems', {slug, items: data});
}
}, },
async loadBoxes({commit, state}) { async loadBoxes({commit, state, getters}) {
if (!state.user.token) return; if (!state.user.token) return;
if (state.fetchedData.boxes > Date.now() - 1000 * 60 * 60 * 24) return; if (state.fetchedData.boxes > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/boxes/', state.user.token); const {data, success} = await getters.session.get('/2/boxes/');
if (data && success) if (data && success) commit('replaceBoxes', data);
commit('replaceBoxes', data);
}, },
async createBox({commit, dispatch, state}, box) { async createBox({commit, dispatch, state, getters}, box) {
const {data, success} = await http.post('/2/boxes/', box, state.user.token); const {data, success} = await getters.session.post('/2/boxes/', box);
commit('replaceBoxes', data); commit('replaceBoxes', data);
dispatch('loadBoxes').then(() => { dispatch('loadBoxes').then(() => {
commit('closeAddBoxModal'); commit('closeAddBoxModal');
}); });
}, },
async deleteBox({commit, dispatch, state}, box_id) { async deleteBox({commit, dispatch, state, getters}, box_id) {
await http.delete(`/2/boxes/${box_id}/`, state.user.token); await getters.session.delete(`/2/boxes/${box_id}/`);
dispatch('loadBoxes'); dispatch('loadBoxes');
}, },
async updateItem({commit, getters, state}, item) { async updateItem({commit, getters, state}, item) {
const { const {
data, data, success
success } = await getters.session.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
} = await http.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, item, state.user.token);
commit('updateItem', data); commit('updateItem', data);
}, },
async markItemReturned({commit, getters, state}, item) { async markItemReturned({commit, getters, state}, item) {
await http.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true}, state.user.token); await getters.session.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true},
state.user.token);
commit('removeItem', item); commit('removeItem', item);
}, },
async deleteItem({commit, getters, state}, item) { async deleteItem({commit, getters, state}, item) {
await http.delete(`/2/${getters.getEventSlug}/item/${item.uid}/`, item, state.user.token); await getters.session.delete(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
commit('removeItem', item); commit('removeItem', item);
}, },
async postItem({commit, getters, state}, item) { async postItem({commit, getters, state}, item) {
commit('updateLastUsed', {box: item.box, cid: item.cid}); commit('updateLastUsed', {box: item.box, cid: item.cid});
const {data, success} = await http.post(`/2/${getters.getEventSlug}/item/`, item, state.user.token); const {data, success} = await getters.session.post(`/2/${getters.getEventSlug}/item/`, item);
commit('appendItem', data); commit('appendItem', data);
}, },
async loadTickets({commit, state}) { async loadTickets({commit, state, getters}) {
if (!state.user.token) return; if (!state.user.token) return;
if (state.fetchedData.tickets > Date.now() - 1000 * 60 * 60 * 24) return; //if (state.fetchedData.tickets > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/tickets/', state.user.token); const {data, success} = await getters.session.get('/2/tickets/');
if (data && success) if (data && success) commit('replaceTickets', data);
commit('replaceTickets', data);
}, },
async sendMail({commit, dispatch, state}, {id, message}) { async searchEventTickets({commit, getters, state}, query) {
const {data, success} = await http.post(`/2/tickets/${id}/reply/`, {message}, state.user.token); const encoded_query = base64.encode(utf8.encode(query));
const {
data, success
} = await getters.session.get(`/2/${getters.getEventSlug}/tickets/${encoded_query}/`);
if (data && success) commit('replaceTickets', data);
},
async sendMail({commit, dispatch, state, getters}, {id, message}) {
const {data, success} = await getters.session.post(`/2/tickets/${id}/reply/`, {message},
state.user.token);
if (data && success) { if (data && success) {
state.fetchedData.tickets = 0; state.fetchedData.tickets = 0;
await dispatch('loadTickets'); await dispatch('loadTickets');
} }
}, },
async postManualTicket({commit, dispatch, state}, {sender, message, title,}) { async postManualTicket({commit, dispatch, state, getters}, {sender, message, title,}) {
const {data, success} = await http.post(`/2/tickets/manual/`, { const {data, success} = await getters.session.post(`/2/tickets/manual/`, {
name: title, name: title, sender, body: message, recipient: 'mail@c3lf.de'
sender, });
body: message,
recipient: 'mail@c3lf.de'
}, state.user.token);
await dispatch('loadTickets'); await dispatch('loadTickets');
}, },
async postComment({commit, dispatch, state}, {id, message}) { async postComment({commit, dispatch, state, getters}, {id, message}) {
const {data, success} = await http.post(`/2/tickets/${id}/comment/`, {comment: message}, state.user.token); const {data, success} = await getters.session.post(`/2/tickets/${id}/comment/`, {comment: message});
if (data && success) { if (data && success) {
state.fetchedData.tickets = 0; state.fetchedData.tickets = 0;
await dispatch('loadTickets'); await dispatch('loadTickets');
} }
}, },
async loadUsers({commit, state}) { async loadUsers({commit, state, getters}) {
if (!state.user.token) return; if (!state.user.token) return;
if (state.fetchedData.users > Date.now() - 1000 * 60 * 60 * 24) return; if (state.fetchedData.users > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/users/', state.user.token); const {data, success} = await getters.session.get('/2/users/');
if (data && success) if (data && success) commit('replaceUsers', data);
commit('replaceUsers', data);
}, },
async loadGroups({commit, state}) { async loadGroups({commit, state, getters}) {
if (!state.user.token) return; if (!state.user.token) return;
if (state.fetchedData.groups > Date.now() - 1000 * 60 * 60 * 24) return; if (state.fetchedData.groups > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/groups/', state.user.token); const {data, success} = await getters.session.get('/2/groups/');
if (data && success) if (data && success) commit('replaceGroups', data);
commit('replaceGroups', data);
}, },
async updateTicket({commit, state}, ticket) { async updateTicket({commit, state, getters}, ticket) {
const {data, success} = await http.put(`/2/tickets/${ticket.id}/`, ticket, state.user.token); const {data, success} = await getters.session.put(`/2/tickets/${ticket.id}/`, ticket);
commit('updateTicket', data); commit('updateTicket', data);
}, },
async updateTicketPartial({commit, state}, {id, ...ticket}) { async updateTicketPartial({commit, state, getters}, {id, ...ticket}) {
const {data, success} = await http.patch(`/2/tickets/${id}/`, ticket, state.user.token); const {data, success} = await getters.session.patch(`/2/tickets/${id}/`, ticket);
commit('updateTicket', data); commit('updateTicket', data);
}, },
async fetchShippingVouchers({commit, state}) { async fetchShippingVouchers({commit, state, getters}) {
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;
const {data, success} = await http.get('/2/shipping_vouchers/', state.user.token); const {data, success} = await getters.session.get('/2/shipping_vouchers/');
if (data && success) { if (data && success) {
commit('setShippingVouchers', data); commit('setShippingVouchers', data);
} }
}, },
async createShippingVoucher({dispatch, state}, code) { async createShippingVoucher({dispatch, state, getters}, code) {
const {data, success} = await http.post('/2/shipping_vouchers/', code, state.user.token); const {data, success} = await getters.session.post('/2/shipping_vouchers/', code);
if (data && success) { if (data && success) {
state.fetchedData.shippingVouchers = 0; state.fetchedData.shippingVouchers = 0;
dispatch('fetchShippingVouchers'); dispatch('fetchShippingVouchers');
} }
}, },
async claimShippingVoucher({dispatch, state}, {ticket, shipping_voucher_type}) { async claimShippingVoucher({dispatch, state, getters}, {ticket, shipping_voucher_type}) {
const id = state.shippingVouchers.filter(voucher => voucher.type === shipping_voucher_type && voucher.issue_thread === null)[0].id; const id = state.shippingVouchers.filter(voucher => voucher.type === shipping_voucher_type && voucher.issue_thread === null)[0].id;
const { const {data, success} = await getters.session.patch(`/2/shipping_vouchers/${id}/`, {issue_thread: ticket});
data,
success
} = await http.patch(`/2/shipping_vouchers/${id}/`, {issue_thread: ticket}, state.user.token);
if (data && success) { if (data && success) {
state.fetchedData.shippingVouchers = 0; state.fetchedData.shippingVouchers = 0;
state.fetchedData.tickets = 0; state.fetchedData.tickets = 0;
@ -461,58 +502,30 @@ const store = createStore({
} }
} }
}, },
plugins: [ plugins: [persistentStatePlugin({ // TODO change remember to some kind of enable field
persistentStatePlugin({ // TODO change remember to some kind of enable field prefix: "lf_",
prefix: "lf_", debug: false,
debug: false, isLoadedKey: "persistent_loaded",
isLoadedKey: "persistent_loaded", state: ["remember", "user", "events", "lastUsed",]
state: [ }), sharedStatePlugin({
"remember", debug: false,
"user", isLoadedKey: "shared_loaded",
"events", clearingMutation: "logout",
"lastUsed", afterInit: "afterSharedInit",
] state: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes", "loadedTickets", "shippingVouchers",],
}), watch: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes", "loadedTickets", "shippingVouchers",],
sharedStatePlugin({ mutations: [//"replaceTickets",
debug: false, ],
isLoadedKey: "shared_loaded", }),],
clearingMutation: "logout",
afterInit: "afterSharedInit",
state: [
"test",
"state_options",
"fetchedData",
"tickets",
"users",
"groups",
"loadedBoxes",
"loadedItems",
"shippingVouchers",
],
watch: [
"test",
"state_options",
"fetchedData",
"tickets",
"users",
"groups",
"loadedBoxes",
"loadedItems",
"shippingVouchers",
],
mutations: [
//"replaceTickets",
],
}),
],
}); });
store.watch((state) => state.user, (user) => { store.watch((state) => state.user, (user) => {
if (store.getters.isLoggedIn) { if (store.getters.isLoggedIn) {
if (router.currentRoute.value.name === 'login' && router.currentRoute.value.query.redirect) if (router.currentRoute.value.name === 'login' && router.currentRoute.value.query.redirect) {
router.push(router.currentRoute.value.query.redirect); router.push(router.currentRoute.value.query.redirect);
else if (router.currentRoute.value.name === 'login') } else if (router.currentRoute.value.name === 'login') {
router.push('/'); router.push('/');
}
} else { } else {
if (router.currentRoute.value.name !== 'login') { if (router.currentRoute.value.name !== 'login') {
router.push({ router.push({

View file

@ -95,9 +95,17 @@ const http = {
"Authorization": `Token ${token}`, "Authorization": `Token ${token}`,
}, },
}); });
const success = response.status === 200 || response.status === 201; const success = response.status === 204;
return {data: await response.json() || {}, success}; return {data: await response.text() || {}, success};
} }
} }
export {ticketStateColorLookup, ticketStateIconLookup, http}; const http_session = token => ({
get: async (url) => await http.get(url, token),
post: async (url, data) => await http.post(url, data, token),
put: async (url, data) => await http.put(url, data, token),
patch: async (url, data) => await http.patch(url, data, token),
delete: async (url) => await http.delete(url, token),
});
export {ticketStateColorLookup, ticketStateIconLookup, http, http_session};

View file

@ -1,77 +1,82 @@
<template> <template>
<div class="container-fluid px-xl-5 mt-3"> <AsyncLoader :loaded="isItemsLoaded">
<Modal title="Edit Item" v-if="editingItem" @close="closeEditingModal()"> <div class="container-fluid px-xl-5 mt-3">
<template #body> <Modal title="Edit Item" v-if="editingItem" @close="closeEditingModal()">
<EditItem <template #body>
:item="editingItem" <EditItem
badge="uid" :item="editingItem"
badge="uid"
/>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="closeEditingModal()">Cancel</button>
<button type="button" class="btn btn-success" @click="saveEditingItem()">Save Changes</button>
</template>
</Modal>
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="closeLightboxModal()"/>
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['uid', 'description', 'box']"
:items="getEventItems"
:keyName="'uid'"
@itemActivated="openLightboxModalWith($event)"
>
<template #actions="{ item }">
<div class="btn-group">
<button class="btn btn-success"
@click.stop="confirm('return Item?') && markItemReturned(item)"
title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-secondary" @click.stop="openEditingModalWith(item)" title="edit">
<font-awesome-icon icon="edit"/>
</button>
<button class="btn btn-danger" @click.stop="confirm('delete Item?') && deleteItem(item)"
title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div>
</template>
</Table>
</div>
</div>
<Cards
v-if="layout === 'cards'"
:columns="['uid', 'description', 'box']"
:items="getEventItems"
:keyName="'uid'"
v-slot="{ item }"
@itemActivated="openLightboxModalWith($event)"
>
<AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`"
class="card-img-top img-fluid"
/> />
</template> <div class="card-body">
<template #buttons> <h6 class="card-title">{{ item.description }}</h6>
<button type="button" class="btn btn-secondary" @click="closeEditingModal()">Cancel</button> <h6 class="card-subtitle text-secondary">uid: {{ item.uid }} box: {{ item.box }}</h6>
<button type="button" class="btn btn-success" @click="saveEditingItem()">Save Changes</button> <div class="row mx-auto mt-2">
</template>
</Modal>
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="closeLightboxModal()"/>
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['uid', 'description', 'box']"
:items="loadedItems"
:keyName="'uid'"
@itemActivated="openLightboxModalWith($event)"
>
<template #actions="{ item }">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-success" <button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturned(item)" title="returned"> @click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
<font-awesome-icon icon="check"/> <font-awesome-icon icon="check"/>
</button> </button>
<button class="btn btn-secondary" @click.stop="openEditingModalWith(item)" title="edit"> <button class="btn btn-outline-secondary" @click.stop="openEditingModalWith(item)"
title="edit">
<font-awesome-icon icon="edit"/> <font-awesome-icon icon="edit"/>
</button> </button>
<button class="btn btn-danger" @click.stop="confirm('delete Item?') && deleteItem(item)" <button class="btn btn-outline-danger"
@click.stop="confirm('delete Item?') && deleteItem(item)"
title="delete"> title="delete">
<font-awesome-icon icon="trash"/> <font-awesome-icon icon="trash"/>
</button> </button>
</div> </div>
</template>
</Table>
</div>
</div>
<Cards
v-if="layout === 'cards'"
:columns="['uid', 'description', 'box']"
:items="loadedItems"
:keyName="'uid'"
v-slot="{ item }"
@itemActivated="openLightboxModalWith($event)"
>
<AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`"
class="card-img-top img-fluid"
/>
<div class="card-body">
<h6 class="card-title">{{ item.description }}</h6>
<h6 class="card-subtitle text-secondary">uid: {{ item.uid }} box: {{ item.box }}</h6>
<div class="row mx-auto mt-2">
<div class="btn-group">
<button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-outline-secondary" @click.stop="openEditingModalWith(item)" title="edit">
<font-awesome-icon icon="edit"/>
</button>
<button class="btn btn-outline-danger" @click.stop="confirm('delete Item?') && deleteItem(item)"
title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div> </div>
</div> </div>
</div> </Cards>
</Cards> </div>
</div> </AsyncLoader>
</template> </template>
<script> <script>
@ -82,6 +87,7 @@ 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 AuthenticatedImage from "@/components/AuthenticatedImage.vue"; import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
export default { export default {
name: 'Items', name: 'Items',
@ -89,10 +95,10 @@ export default {
lightboxHash: null, lightboxHash: null,
editingItem: null, editingItem: null,
}), }),
components: {AuthenticatedImage, Lightbox, Table, Cards, Modal, EditItem}, components: {AsyncLoader, AuthenticatedImage, Lightbox, Table, Cards, Modal, EditItem},
computed: { computed: {
...mapState(['loadedItems']), ...mapState([]),
...mapGetters(['layout']), ...mapGetters(['getEventItems', 'isItemsLoaded', 'layout']),
}, },
methods: { methods: {
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']), ...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']),

View file

@ -1,78 +1,90 @@
<template> <template>
<div class="container-fluid px-xl-5 mt-3"> <AsyncLoader :loaded="ticket.id">
<div class="row"> <div class="container-fluid px-xl-5 mt-3">
<div class="col-xl-8 offset-xl-2"> <div class="row">
<div class="card bg-dark text-light mb-2" id="filters"> <div class="col-xl-8 offset-xl-2">
<div class="card-header"> <div class="card bg-dark text-light mb-2" id="filters">
<h3>Ticket #{{ ticket.id }} - {{ ticket.name }}</h3> <div class="card-header">
</div> <h3>Ticket #{{ ticket.id }} - {{ ticket.name }}</h3>
<Timeline :timeline="ticket.timeline" @sendMail="handleMail" @addComment="handleComment"/>
<div class="card-footer d-flex justify-content-between">
<button class="btn btn-secondary mr-2" @click="$router.go(-1)">Back</button>
<!--button class="btn btn-danger" @click="deleteItem({type: 'tickets', id: ticket.id})">
<font-awesome-icon icon="trash"/>
Delete
</button-->
<div class="btn-group">
<select class="form-control" v-model="ticket.assigned_to">
<option v-for="user in users" :value="user.username">{{ user.username }}</option>
</select>
<button class="form-control btn btn-success" @click="assigTicket(ticket)">
Assign&nbsp;Ticket
</button>
</div> </div>
<div class="btn-group"> <Timeline :timeline="ticket.timeline" @sendMail="handleMail" @addComment="handleComment"/>
<select class="form-control" v-model="ticket.state"> <div class="card-footer d-flex justify-content-between">
<option v-for="status in state_options" :value="status.value">{{ status.text }}</option> <button class="btn btn-secondary mr-2" @click="$router.go(-1)">Back</button>
</select> <!--button class="btn btn-danger" @click="deleteItem({type: 'tickets', id: ticket.id})">
<button class="form-control btn btn-success" @click="changeTicketStatus(ticket)"> <font-awesome-icon icon="trash"/>
Change&nbsp;Status Delete
</button> </button-->
<div class="btn-group">
<select class="form-control" v-model="selected_assignee">
<option v-for="user in users" :value="user.username">{{ user.username }}</option>
</select>
<button class="form-control btn btn-success"
@click="assignTicket(ticket)"
:disabled="!selected_assignee || (selected_assignee == ticket.assigned_to)">
Assign&nbsp;Ticket
</button>
</div>
<div class="btn-group">
<select class="form-control" v-model="selected_state">
<option v-for="status in state_options" :value="status.value">{{
status.text
}}
</option>
</select>
<button class="form-control btn btn-success"
@click="changeTicketStatus(ticket)"
:disabled="(selected_state == ticket.state)">
Change&nbsp;Status
</button>
</div>
</div> </div>
</div> <div class="card-footer d-flex justify-content-between">
<div class="card-footer d-flex justify-content-between"> <ClipboardButton :payload="shippingEmail" class="btn btn-primary">
<ClipboardButton :payload="shippingEmail" class="btn btn-primary"> <font-awesome-icon icon="clipboard"/>
<font-awesome-icon icon="clipboard"/> Copy&nbsp;DHL&nbsp;contact&nbsp;to&nbsp;clipboard
Copy&nbsp;DHL&nbsp;contact&nbsp;to&nbsp;clipboard </ClipboardButton>
</ClipboardButton> <div class="btn-group">
<div class="btn-group"> <select class="form-control" v-model="shipping_voucher_type">
<select class="form-control" v-model="shipping_voucher_type"> <option v-for="type in availableShippingVoucherTypes.filter(t=>t.count>0)"
<option v-for="type in availableShippingVoucherTypes.filter(t=>t.count>0)" :value="type.id">{{ type.name }}
:value="type.id">{{ type.name }} </option>
</option> </select>
</select> <button class="form-control btn btn-success"
<button class="form-control btn btn-success" @click="claimShippingVoucher({ticket: ticket.id, shipping_voucher_type}).then(()=>shipping_voucher_type=null)"
@click="claimShippingVoucher({ticket: ticket.id, shipping_voucher_type}).then(()=>shipping_voucher_type=null)" :disabled="!shipping_voucher_type">
:disabled="!shipping_voucher_type"> Claim&nbsp;Shipping&nbsp;Voucher
Claim&nbsp;Shipping&nbsp;Voucher </button>
</button> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </AsyncLoader>
</template> </template>
<script> <script>
import {mapActions, mapGetters, mapState} from 'vuex'; import {mapActions, mapGetters, mapState} from 'vuex';
import Timeline from "@/components/Timeline.vue"; import Timeline from "@/components/Timeline.vue";
import ClipboardButton from "@/components/inputs/ClipboardButton.vue"; import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
export default { export default {
name: 'Ticket', name: 'Ticket',
components: {ClipboardButton, Timeline}, components: {AsyncLoader, ClipboardButton, Timeline},
data() { data() {
return { return {
shipping_voucher_type: null selected_state: null,
selected_assignee: null,
shipping_voucher_type: null,
} }
}, },
computed: { computed: {
...mapState(['tickets', 'state_options', 'users']), ...mapState(['state_options', 'users']),
...mapGetters(['availableShippingVoucherTypes']), ...mapGetters(['availableShippingVoucherTypes', 'getAllTickets', 'route']),
ticket() { ticket() {
const id = parseInt(this.$route.params.id) const id = parseInt(this.route.params.id)
const ret = this.tickets.find(ticket => ticket.id === id); const ret = this.getAllTickets.find(ticket => ticket.id === id);
return ret ? ret : {}; return ret ? ret : {};
}, },
shippingEmail() { shippingEmail() {
@ -83,7 +95,7 @@ export default {
methods: { methods: {
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']), ...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']), ...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
...mapActions(['claimShippingVoucher']), ...mapActions(['claimShippingVoucher', 'fetchShippingVouchers']),
handleMail(mail) { handleMail(mail) {
this.sendMail({ this.sendMail({
id: this.ticket.id, id: this.ticket.id,
@ -97,24 +109,34 @@ export default {
}) })
}, },
changeTicketStatus(ticket) { changeTicketStatus(ticket) {
ticket.state = this.selected_state;
this.updateTicketPartial({ this.updateTicketPartial({
id: ticket.id, id: ticket.id,
state: ticket.state state: this.selected_state,
}) })
}, },
assigTicket(ticket) { assignTicket(ticket) {
ticket.assigned_to = this.selected_assignee;
this.updateTicketPartial({ this.updateTicketPartial({
id: ticket.id, id: ticket.id,
assigned_to: ticket.assigned_to assigned_to: this.selected_assignee
}) })
}, },
}, },
mounted() { mounted() {
this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets(), this.loadUsers()]); this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(() => {
if (this.ticket.state == "pending_new") {
this.selected_state = "pending_open";
this.changeTicketStatus(this.ticket)
}
;
this.selected_state = this.ticket.state;
this.selected_assignee = this.ticket.assigned_to
})]);
} }
}; };
</script> </script>
<style scoped> <style scoped>
</style> </style>

View file

@ -1,51 +1,56 @@
<template> <template>
<div class="container-fluid px-xl-5 mt-3"> <AsyncLoader :loaded="isTicketsLoaded">
<div class="row"> <div class="container-fluid px-xl-5 mt-3">
<div class="col-xl-8 offset-xl-2"> <div class="row">
<Table <div class="col-xl-8 offset-xl-2">
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to']" <Table
:items="tickets" :columns="['id', 'name', 'state', 'last_activity', 'assigned_to',
:keyName="'id'" ...(getEventSlug==='all'?['event']:[])]"
v-if="layout === 'table'" :items="getEventTickets.map(formatTicket)"
> :keyName="'id'"
<template #actions="{ item }"> v-if="layout === 'table'"
<div class="btn-group"> >
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view" <template v-slot:actions="{item}">
@click.prevent="gotoDetail(item)"> <div class="btn-group">
<font-awesome-icon icon="eye"/> <a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
View @click.prevent="gotoDetail(item)">
</a> <font-awesome-icon icon="eye"/>
</div> View
</template> </a>
</Table> </div>
</template>
</Table>
</div>
</div> </div>
</div> <CollapsableCards v-if="layout === 'tasks'" :items="getEventTickets"
<CollapsableCards v-if="layout === 'tasks'" :items="tickets" :columns="['id', 'name', 'last_activity', 'assigned_to',
:columns="['id', 'name', 'last_activity', 'assigned_to']" ...(getEventSlug==='all'?['event']:[])]"
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping', :keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
'pending_physical_confirmation','pending_return','pending_postponed'].map(stateInfo)"> 'pending_physical_confirmation','pending_return','pending_postponed'].map(stateInfo)">
<template #section_header="{index, section, count}"> <template #section_header="{index, section, count}">
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span> {{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
</template> </template>
<template #section_body="{item}"> <template #section_body="{item}">
<tr> <tr>
<td>{{ item.id }}</td> <td>{{ item.id }}</td>
<td>{{ item.name }}</td> <td>{{ item.name }}</td>
<td>{{ item.last_activity }}</td> <td>{{ item.last_activity }}</td>
<td>{{ item.assigned_to }}</td> <td>{{ item.assigned_to }}</td>
<td> <td v-if="getEventSlug==='all'">{{ item.event }}</td>
<div class="btn-group"> <td>
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view" <div class="btn-group">
@click.prevent="gotoDetail(item)"> <a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
<font-awesome-icon icon="eye"/> @click.prevent="gotoDetail(item)">
View <font-awesome-icon icon="eye"/>
</a> View
</div> </a>
</td> </div>
</tr> </td>
</template> </tr>
</CollapsableCards> </template>
</div> </CollapsableCards>
</div>
</AsyncLoader>
</template> </template>
<script> <script>
@ -56,13 +61,13 @@ import {mapActions, mapGetters, mapState} from 'vuex';
import Lightbox from '../components/Lightbox'; import Lightbox from '../components/Lightbox';
import Table from '@/components/Table'; import Table from '@/components/Table';
import CollapsableCards from "@/components/CollapsableCards.vue"; import CollapsableCards from "@/components/CollapsableCards.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
export default { export default {
name: 'Tickets', name: 'Tickets',
components: {Lightbox, Table, Cards, Modal, EditItem, CollapsableCards}, components: {AsyncLoader, Lightbox, Table, Cards, Modal, EditItem, CollapsableCards},
computed: { computed: {
...mapState(['tickets']), ...mapGetters(['getEventTickets', 'isTicketsLoaded', 'stateInfo', 'getEventSlug', 'layout']),
...mapGetters(['stateInfo', 'getEventSlug', 'layout']),
}, },
methods: { methods: {
...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']), ...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']),
@ -76,7 +81,8 @@ export default {
state: this.stateInfo(ticket.state).text, state: this.stateInfo(ticket.state).text,
stateColor: this.stateInfo(ticket.state).color, stateColor: this.stateInfo(ticket.state).color,
last_activity: ticket.last_activity, last_activity: ticket.last_activity,
assigned_to: ticket.assigned_to assigned_to: ticket.assigned_to,
event: ticket.event
}; };
} }
}, },

View file

@ -0,0 +1,33 @@
<template>
<div>
<h3 class="text-center">Events</h3>
<ul>
<li v-for="event in events" :key="event.id">
{{ event.slug }}
</li>
</ul>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
export default {
name: 'Dashboard',
components: {},
computed: {
...mapState(['events']),
},
methods: {
...mapActions(['loadEvents']),
},
mounted() {
this.loadEvents();
}
};
</script>
<style>
</style>

View file

@ -1,62 +0,0 @@
<template>
<div>
<!--qr-code :text="qr_url" color="#000" bg-color="#fff" error-level="H" class="qr-code"></qr-code-->
<h3 class="text-center">Events</h3>
<!--p>{{ events }}</p-->
<ul>
<li v-for="event in events" :key="event.id">
{{ event.slug }}
</li>
</ul>
<h3 class="text-center">Items</h3>
<!--p>{{ loadedItems }}</p-->
<ul>
<li v-for="item in loadedItems" :key="item.id">
{{ item.description }}
</li>
</ul>
<h3 class="text-center">Boxes</h3>
<!--p>{{ loadedBoxes }}</p-->
<ul>
<li v-for="box in loadedBoxes" :key="box.id">
{{ box.name }}
</li>
</ul>
<h3 class="text-center">Issues</h3>
<!--p>{{ issues }}</p-->
<ul>
<li v-for="issue in tickets" :key="issue.id">
{{ issue.id }}
</li>
</ul>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
export default {
name: 'Debug',
components: {Table},
computed: {
...mapState(['events', 'loadedItems', 'loadedBoxes', 'tickets']),
qr_url() {
return window.location.href;
}
},
methods: {
...mapActions(['changeEvent', 'loadTickets']),
},
mounted() {
this.loadTickets();
}
};
</script>
<style>
.qr-code img {
border: #fff solid 7px
}
</style>

View file

@ -1,36 +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 #actions="{ item }"> Create Event
<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=""> </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, 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']),
methods: mapActions(['changeEvent']), data() {
return {new_address: []}
},
methods: {
...mapActions(['changeEvent', 'deleteEvent', 'updateEvent']),
...mapMutations(['openAddEventModal']),
safeDeleteEvent(id) {
if (confirm('do you want to completely delete this event and related data?')) {
this.deleteEvent(id)
}
},
addAddress(id) {
const a = this.new_address[id];
if (!this.events[id].addresses.includes(a))
this.events[id].addresses.push(a)
this.new_address[id] = ""
this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}});
},
deleteAddress(id, a_id) {
this.events[id].addresses = this.events[id].addresses.filter((e, i) => i !== a_id);
this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}});
}
},
}; };
</script> </script>
<style scoped> <style scoped>
</style> </style>

View file

@ -7,25 +7,25 @@ module.exports = {
"Access-Control-Allow-Headers": "*", "Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Methods": "*" "Access-Control-Allow-Methods": "*"
}, },
//proxy: { proxy: {
// '^/media/2': { '^/media/2': {
// target: 'https://staging.c3lf.de/', target: 'https://staging.c3lf.de/',
// changeOrigin: true changeOrigin: true
// }, },
// '^/api/2': { '^/api/2': {
// target: 'https://staging.c3lf.de/', target: 'https://staging.c3lf.de/',
// changeOrigin: true, changeOrigin: true,
// }, },
// '^/api/1': { '^/api/1': {
// target: 'https://staging.c3lf.de/', target: 'https://staging.c3lf.de/',
// changeOrigin: true, changeOrigin: true,
// }, },
// '^/ws/2': { '^/ws/2': {
// target: 'http://127.0.0.1:8082/', target: 'http://127.0.0.1:8082/',
// //changeOrigin: true, //changeOrigin: true,
// ws: true, ws: true,
// logLevel: 'debug', logLevel: 'debug',
// }, },
//} }
} }
} }