This commit is contained in:
j3d1 2023-11-23 23:17:20 +01:00
parent 0f911589ca
commit 5bdfe313de
65 changed files with 2219 additions and 77 deletions

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:13 AS build
RUN mkdir /app
WORKDIR /app
COPY /web /app/web
RUN rm /app/web/package-lock.json
RUN cd /app/web && yarn install
RUN mkdir /app/web/dist
FROM debian:bookworm-slim AS production
RUN apt-get update && apt-get install -y nginx redis python3 python3-pip python3-venv mariadb-server postfix
COPY /core /app/core
COPY --from=build /app/web/dist /app/core/web/dist
ENTRYPOINT ["top", "-b"]

View file

@ -0,0 +1,21 @@
from rest_framework import routers, viewsets, serializers
from django.contrib.auth.models import User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ('id', 'username', 'email', 'first_name', 'last_name')
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
authentication_classes = []
permission_classes = []
router = routers.SimpleRouter()
router.register(r'users', UserViewSet, basename='users')
urlpatterns = router.urls

View file

View file

View file

@ -0,0 +1,18 @@
from django.test import TestCase, Client
from core import settings
client = Client()
class IssueApiTest(TestCase):
def test_issues(self):
response = client.get('/api/2/users/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['username'], settings.LEGACY_USER_NAME)
self.assertEqual(response.json()[0]['email'], 'mail@' + settings.MAIL_DOMAIN)
self.assertEqual(response.json()[0]['first_name'], '')
self.assertEqual(response.json()[0]['last_name'], '')
self.assertEqual(response.json()[0]['id'], 1)

View file

@ -33,6 +33,11 @@ ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost')]
MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost') MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost')
CSRF_TRUSTED_ORIGINS = ["https://" + host for host in ALLOWED_HOSTS]
LEGACY_USER_NAME = os.getenv('LEGACY_API_USER', 'legacy_user')
LEGACY_USER_PASSWORD = os.getenv('LEGACY_API_PASSWORD', 'legacy_password')
SYSTEM3_VERSION = "0.0.0-dev.0" SYSTEM3_VERSION = "0.0.0-dev.0"
# Application definition # Application definition
@ -49,8 +54,11 @@ INSTALLED_APPS = [
'rest_framework.authtoken', 'rest_framework.authtoken',
'drf_yasg', 'drf_yasg',
'channels', 'channels',
'authentication',
'files', 'files',
'tickets',
'inventory', 'inventory',
'mail',
'notify_sessions', 'notify_sessions',
] ]
@ -184,7 +192,14 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024 * 128 # 128 MB
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
CHANNEL_LAYERS = { if 'test' in sys.argv:
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels.layers.InMemoryChannelLayer'
}
}
else:
CHANNEL_LAYERS = {
'default': { 'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer', 'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': { 'CONFIG': {
@ -192,6 +207,6 @@ CHANNEL_LAYERS = {
}, },
} }
} }
TEST_RUNNER = 'core.test_runner.FastTestRunner' TEST_RUNNER = 'core.test_runner.FastTestRunner'

View file

@ -24,5 +24,12 @@ urlpatterns = [
path('api/1/', include('inventory.api_v1')), path('api/1/', include('inventory.api_v1')),
path('api/1/', include('files.api_v1')), path('api/1/', include('files.api_v1')),
path('api/1/', include('files.media_v1')), path('api/1/', include('files.media_v1')),
path('api/2/', include('inventory.api_v2')),
path('api/2/', include('files.api_v2')),
path('media/2/', include('files.media_v2')),
path('api/2/', include('tickets.api_v2')),
path('api/2/', include('mail.api_v2')),
path('api/2/', include('notify_sessions.api_v2')),
path('api/2/', include('authentication.api_v2')),
path('api/', get_info), path('api/', get_info),
] ]

24
core/files/api_v2.py Normal file
View file

@ -0,0 +1,24 @@
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'
router = routers.SimpleRouter()
router.register(r'files', FileViewSet, basename='files')
urlpatterns = router.urls

57
core/files/media_v2.py Normal file
View file

@ -0,0 +1,57 @@
from coverage.annotate 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
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'])
def media_urls(request, hash_path):
try:
file = File.objects.get(file=hash_path)
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'])
def thumbnail_urls(request, size, hash_path):
if size not in [32, 64, 256]:
return Response(status=status.HTTP_404_NOT_FOUND)
try:
file = File.objects.get(file=hash_path)
if not os.path.exists(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}'):
from PIL import Image
iamge = Image.open(file.file)
iamge.thumbnail((size, size))
iamge.save(MEDIA_ROOT + f'/media/thumbnails/{size}/{hash_path}', quality=90)
return HttpResponse(status=status.HTTP_200_OK,
content_type=file.mime_type,
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('<int:size>/<path:hash_path>/', thumbnail_urls),
path('<path:hash_path>/', media_urls),
]

View file

View file

View file

View file

@ -0,0 +1,49 @@
from django.test import TestCase, Client
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_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/2/files/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()[0]['hash'], item.hash)
self.assertEqual(len(response.json()[0]['hash']), 64)
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/2/files/{item.hash}/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['hash'], item.hash)
self.assertEqual(len(response.json()['hash']), 64)
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/2/files/',
{'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)
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/2/files/{file.hash}/')
self.assertEqual(response.status_code, 204)

161
core/inventory/api_v2.py Normal file
View file

@ -0,0 +1,161 @@
from datetime import datetime
from django.urls import path, 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
class EventSerializer(serializers.ModelSerializer):
class Meta:
model = Event
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end']
read_only_fields = ['eid']
class EventViewSet(viewsets.ModelViewSet):
serializer_class = EventSerializer
queryset = Event.objects.all()
permission_classes = []
authentication_classes = []
class ContainerSerializer(serializers.ModelSerializer):
itemCount = serializers.SerializerMethodField()
class Meta:
model = Container
fields = ['cid', 'name', 'itemCount']
read_only_fields = ['cid', 'itemCount']
def get_itemCount(self, instance):
return Item.objects.filter(container=instance.cid).count()
class ContainerViewSet(viewsets.ModelViewSet):
serializer_class = ContainerSerializer
queryset = Container.objects.all()
permission_classes = []
authentication_classes = []
class ItemSerializer(serializers.ModelSerializer):
cid = serializers.SerializerMethodField()
box = serializers.SerializerMethodField()
file = serializers.SerializerMethodField()
class Meta:
model = Item
fields = ['cid', 'box', 'uid', 'description', 'file']
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):
attrs.pop('dataImage', None)
return super().validate(attrs)
def create(self, validated_data):
if 'dataImage' in validated_data:
file = File.objects.create(data=validated_data['dataImage'], iid=validated_data['iid'])
validated_data.pop('dataImage')
return Item.objects.create(**validated_data)
def update(self, instance, validated_data):
if 'returned' in validated_data:
if validated_data['returned']:
validated_data['returned_at'] = datetime.now()
validated_data.pop('returned')
if 'dataImage' in validated_data:
file = File.objects.create(data=validated_data['dataImage'], iid=instance.iid)
validated_data.pop('dataImage')
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)
router = routers.SimpleRouter()
router.register(r'events', EventViewSet, basename='events')
router.register(r'boxes', ContainerViewSet, basename='boxes')
router.register(r'box', ContainerViewSet, basename='boxes')
urlpatterns = router.urls + [
path('<event_slug>/items/', item),
path('<event_slug>/items/<query>/', search_items),
path('<event_slug>/item/', item),
path('<event_slug>/item/<id>/', item_by_id),
]

View file

View file

View file

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

View file

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

View file

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

View file

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

17
core/mail/admin.py Normal file
View file

@ -0,0 +1,17 @@
from django.contrib import admin
from mail.models import Email, EventAddress
class EmailAdmin(admin.ModelAdmin):
pass
admin.site.register(Email, EmailAdmin)
class EventAddressAdmin(admin.ModelAdmin):
pass
admin.site.register(EventAddress, EventAddressAdmin)

22
core/mail/api_v2.py Normal file
View file

@ -0,0 +1,22 @@
from rest_framework import routers, viewsets, serializers
from mail.models import Email
class EmailSerializer(serializers.ModelSerializer):
class Meta:
model = Email
fields = '__all__'
class EmailViewSet(viewsets.ModelViewSet):
serializer_class = EmailSerializer
queryset = Email.objects.all()
permission_classes = []
authentication_classes = []
router = routers.SimpleRouter()
router.register(r'mails', EmailViewSet, basename='mails')
urlpatterns = router.urls

View file

@ -0,0 +1,46 @@
# Generated by Django 4.2.7 on 2023-12-06 02:52
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('tickets', '0001_initial'),
('inventory', '0002_container_deleted_at_container_is_deleted_and_more'),
]
operations = [
migrations.CreateModel(
name='EventAddress',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('address', models.CharField(max_length=255)),
('event', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.event')),
],
),
migrations.CreateModel(
name='Email',
fields=[
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('id', models.AutoField(primary_key=True, serialize=False)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('body', models.TextField()),
('subject', models.CharField(max_length=255)),
('sender', models.CharField(max_length=255)),
('recipient', models.CharField(max_length=255)),
('reference', models.CharField(max_length=255, null=True, unique=True)),
('in_reply_to', models.CharField(max_length=255, null=True)),
('raw', models.TextField()),
('event', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.event')),
('issue_thread', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='emails', to='tickets.issuethread')),
],
options={
'abstract': False,
},
),
]

34
core/mail/models.py Normal file
View file

@ -0,0 +1,34 @@
import random
from django.db import models
from django_softdelete.models import SoftDeleteModel
from core.settings import MAIL_DOMAIN
from inventory.models import Event
from tickets.models import IssueThread
class Email(SoftDeleteModel):
id = models.AutoField(primary_key=True)
timestamp = models.DateTimeField(auto_now_add=True)
body = models.TextField()
subject = models.CharField(max_length=255)
sender = models.CharField(max_length=255)
recipient = models.CharField(max_length=255)
reference = models.CharField(max_length=255, null=True, unique=True)
in_reply_to = models.CharField(max_length=255, null=True)
raw = models.TextField()
issue_thread = models.ForeignKey(IssueThread, models.SET_NULL, null=True, related_name='emails')
event = models.ForeignKey(Event, models.SET_NULL, null=True)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if not self.reference:
self.reference = f'<{random.randint(0, 1000000000):09}@{MAIL_DOMAIN}>'
self.save()
class EventAddress(models.Model):
id = models.AutoField(primary_key=True)
event = models.ForeignKey(Event, models.SET_NULL, null=True)
address = models.CharField(max_length=255)

View file

@ -1,16 +1,42 @@
import logging import logging
import aiosmtplib import aiosmtplib
from asgiref.sync import sync_to_async
from channels.layers import get_channel_layer
from mail.models import Email, EventAddress
from notify_sessions.models import SystemEvent
from tickets.models import IssueThread
def make_reply(message, to, subject): def collect_references(issue_thread):
mails = issue_thread.emails.order_by('timestamp')
references = []
for mail in mails:
if mail.reference:
references.append(mail.reference)
return references
def make_reply(reply_email, references=None):
from email.message import EmailMessage from email.message import EmailMessage
from core.settings import MAIL_DOMAIN from core.settings import MAIL_DOMAIN
reply = EmailMessage() reply = EmailMessage()
reply["From"] = "noreply@" + MAIL_DOMAIN reply["From"] = reply_email.sender
reply["To"] = to reply["To"] = reply_email.recipient
reply["Subject"] = subject reply["Subject"] = reply_email.subject
reply.set_content(message) if reply_email.in_reply_to:
reply["In-Reply-To"] = reply_email.in_reply_to
if reply_email.reference:
reply["Message-ID"] = reply_email.reference
else:
reply["Message-ID"] = reply_email.id + "@" + MAIL_DOMAIN
reply_email.reference = reply["Message-ID"]
reply_email.save()
if references:
reply["References"] = " ".join(references)
reply.set_content(reply_email.body)
return reply return reply
@ -20,6 +46,14 @@ async def send_smtp(message, log):
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):
reply_to = Email.objects.filter(reference=in_reply_to)
if reply_to.exists():
return reply_to.first().issue_thread
else:
return IssueThread.objects.create()
class LMTPHandler: class LMTPHandler:
async def handle_RCPT(self, server, session, envelope, address, rcpt_options): async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
from core.settings import MAIL_DOMAIN from core.settings import MAIL_DOMAIN
@ -31,6 +65,7 @@ class LMTPHandler:
async def handle_DATA(self, server, session, envelope): async def handle_DATA(self, server, session, envelope):
import email import email
log = logging.getLogger('mail.log') log = logging.getLogger('mail.log')
log.setLevel(logging.DEBUG)
log.info('Message from %s' % envelope.mail_from) log.info('Message from %s' % envelope.mail_from)
log.info('Message for %s' % envelope.rcpt_tos) log.info('Message for %s' % envelope.rcpt_tos)
log.info('Message data:\n') log.info('Message data:\n')
@ -54,15 +89,60 @@ class LMTPHandler:
header_from = parsed.get('From') header_from = parsed.get('From')
header_to = parsed.get('To') header_to = parsed.get('To')
header_in_reply_to = parsed.get('In-Reply-To')
header_message_id = parsed.get('Message-ID')
if header_from != envelope.mail_from: if header_from != envelope.mail_from:
log.warning("Header from does not match envelope from") log.warning("Header from does not match envelope from")
log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}")
if header_to != envelope.rcpt_tos[0]: if header_to != envelope.rcpt_tos[0]:
log.warning("Header to does not match envelope to") log.warning("Header to does not match envelope to")
log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}")
await send_smtp(make_reply("Thank you for your message.", envelope.mail_from, 'Message received'), log) recipient = envelope.rcpt_tos[0]
sender = envelope.mail_from
target_event = None
try:
address_map = await sync_to_async(EventAddress.objects.get)(address=recipient)
if address_map.event:
target_event = address_map.event
except EventAddress.DoesNotExist:
pass
active_issue_thread = await sync_to_async(find_active_issue_thread)(header_in_reply_to)
email = await sync_to_async(Email.objects.create)(sender=sender,
recipient=recipient,
body=body.decode('utf-8'),
subject=parsed.get('Subject'),
reference=header_message_id,
in_reply_to=header_in_reply_to,
raw=envelope.content.decode('utf-8'),
event=target_event,
issue_thread=active_issue_thread)
log.info(f"Created email {email.id}")
systemevent = await sync_to_async(SystemEvent.objects.create)(type='email received', reference=email.id)
log.info(f"Created system event {systemevent.id}")
channel_layer = get_channel_layer()
await channel_layer.group_send(
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
"message": "email received"}
)
log.info(f"Sent message to frontend")
reply_email = await sync_to_async(Email.objects.create)(sender=recipient, # "noreply@" + MAIL_DOMAIN,
recipient=sender,
body="Thank you for your message.",
subject="Message received",
in_reply_to=header_message_id,
event=target_event,
issue_thread=active_issue_thread)
references = await sync_to_async(collect_references)(active_issue_thread)
await send_smtp(make_reply(reply_email, references), log)
log.info("Sent reply") log.info("Sent reply")
return '250 Message accepted for delivery' return '250 Message accepted for delivery'
except Exception as e: except Exception as e:
log.error(e) log.error(e)

View file

@ -0,0 +1,198 @@
import inspect
from unittest import mock
from django.test import TestCase, Client
from inventory.models import Event
from mail.models import Email
from mail.protocol import LMTPHandler
from tickets.models import IssueThread
client = Client()
def make_mocked_coro(return_value=mock.sentinel, raise_exception=mock.sentinel):
async def mock_coro(*args, **kwargs):
if raise_exception is not mock.sentinel:
raise raise_exception
if not inspect.isawaitable(return_value):
return return_value
await return_value
return mock.Mock(wraps=mock_coro)
class EmailsApiTest(TestCase):
def test_mails(self):
Event.objects.get_or_create(
name="Test event",
slug="test-event",
)
Email.objects.create(
subject='test',
body='test',
sender='test',
recipient='test',
)
response = client.get('/api/2/mails/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['subject'], 'test')
self.assertEqual(response.json()[0]['body'], 'test')
self.assertEqual(response.json()[0]['sender'], 'test')
self.assertEqual(response.json()[0]['recipient'], 'test')
def test_mails_empty(self):
response = client.get('/api/2/mails/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
def test_handle_client(self):
from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync
import aiosmtplib
aiosmtplib.send = make_mocked_coro()
handler = LMTPHandler()
server = mock.Mock()
session = mock.Mock()
envelope = Envelope()
envelope.mail_from = 'test1@test'
envelope.rcpt_tos = ['test2@test']
envelope.content = b'Subject: test\nFrom: test3@test\nTo: test4@test\nMessage-ID: <1@test>\n\ntest'
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
self.assertEqual(result, '250 Message accepted for delivery')
self.assertEqual(len(Email.objects.all()), 2)
self.assertEqual(len(IssueThread.objects.all()), 1)
aiosmtplib.send.assert_called_once()
self.assertEqual(Email.objects.all()[0].subject, 'test')
self.assertEqual(Email.objects.all()[0].sender, 'test1@test')
self.assertEqual(Email.objects.all()[0].recipient, 'test2@test')
self.assertEqual(Email.objects.all()[0].body, 'test')
self.assertEqual(Email.objects.all()[0].issue_thread, IssueThread.objects.all()[0])
self.assertEqual(Email.objects.all()[0].reference, '<1@test>')
self.assertEqual(Email.objects.all()[0].in_reply_to, None)
self.assertEqual(Email.objects.all()[1].subject, 'Message received')
self.assertEqual(Email.objects.all()[1].sender, 'test2@test')
self.assertEqual(Email.objects.all()[1].recipient, 'test1@test')
self.assertEqual(Email.objects.all()[1].body, 'Thank you for your message.')
self.assertEqual(Email.objects.all()[1].issue_thread, IssueThread.objects.all()[0])
self.assertTrue(Email.objects.all()[1].reference.startswith("<"))
self.assertTrue(Email.objects.all()[1].reference.endswith("@localhost>"))
self.assertEqual(Email.objects.all()[1].in_reply_to, "<1@test>")
def test_handle_client_reply(self):
issue_thread = IssueThread.objects.create()
mail1 = Email.objects.create(
subject='test',
body='test',
sender='test1@test',
recipient='test2@test',
issue_thread=issue_thread,
)
mail1_reply = Email.objects.create(
subject='Message received',
body='Thank you for your message.',
sender='test2@test',
recipient='test1@test',
in_reply_to=mail1.reference,
issue_thread=issue_thread,
)
from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync
import aiosmtplib
aiosmtplib.send = make_mocked_coro()
handler = LMTPHandler()
server = mock.Mock()
session = mock.Mock()
envelope = Envelope()
envelope.mail_from = 'test1@test'
envelope.rcpt_tos = ['test2@test']
# envelope.content = b'Subject: Re: test\nFrom: test3@test\nTo: test4@test\nMessage-ID: 3@test\nIn-Reply-To: 2@localhost\n\ntest'
envelope.content = (f'Subject: Re: test\nFrom: test3@test\nTo: test4@test\nMessage-ID: <3@test>\n'
f'In-Reply-To: {mail1_reply.reference}'.encode('utf-8') + b'\n\ntest')
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
self.assertEqual(result, '250 Message accepted for delivery')
self.assertEqual(len(Email.objects.all()), 4)
self.assertEqual(len(IssueThread.objects.all()), 1)
aiosmtplib.send.assert_called_once()
self.assertEqual(Email.objects.all()[2].subject, 'Re: test')
self.assertEqual(Email.objects.all()[2].sender, 'test1@test')
self.assertEqual(Email.objects.all()[2].recipient, 'test2@test')
self.assertEqual(Email.objects.all()[2].body, 'test')
self.assertEqual(Email.objects.all()[2].issue_thread, issue_thread)
self.assertEqual(Email.objects.all()[2].reference, '<3@test>')
self.assertEqual(Email.objects.all()[2].in_reply_to, mail1_reply.reference)
self.assertEqual(Email.objects.all()[3].subject, 'Message received')
self.assertEqual(Email.objects.all()[3].sender, 'test2@test')
self.assertEqual(Email.objects.all()[3].recipient, 'test1@test')
self.assertEqual(Email.objects.all()[3].body, 'Thank you for your message.')
self.assertEqual(Email.objects.all()[3].issue_thread, issue_thread)
self.assertTrue(Email.objects.all()[3].reference.startswith("<"))
self.assertTrue(Email.objects.all()[3].reference.endswith("@localhost>"))
self.assertEqual(Email.objects.all()[3].in_reply_to, "<3@test>")
# class AsyncLMTPTestCase(TestCase):
#
# def setUp(self):
# server = mock.Mock()
# self.create_unix_server = make_mocked_coro(server)
# self.wait_closed = make_mocked_coro()
# self.loop = asyncio.new_event_loop()
# self.loop.create_unix_server = self.create_unix_server
# asyncio.set_event_loop(self.loop)
#
# async def test_connect(self):
# server = await UnixSocketLMTPController(LMTPHandler(), unix_socket='lmtp.sock', loop=self.loop).serve()
# self.assertEqual(self.create_unix_server.call_count, 1)
# self.assertEqual(self.wait_closed.call_count, 0)
# server.close()
# # self.assertEqual(self.wait_closed.call_count, 1)
#
# def test_receive_mail(self):
# from logging import getLogger
# from aiosmtpd.lmtp import LMTP
# from asgiref.sync import async_to_sync
# log = getLogger('mail.log')
# log.addHandler(logging.StreamHandler())
# log.setLevel(logging.DEBUG)
# handler = LMTP(LMTPHandler(), loop=self.loop)
# transport = mock.Mock()
# handler.connection_made(transport)
#
# def _handle_client():
# print("Handling client")
# async_to_sync(handler._handle_client)()
# print("Client handled")
#
# thread = threading.Thread(target=_handle_client)
# thread.start()
#
# # handler.data_received(
# # b'HELO test\nMAIL FROM:<test1@test>\nRCPT TO:<test2@test>\nDATA\nSubject: test\nFrom: test1@test\nTo: '
# # b'test2@test\n\ntest\n.\nQUIT')
# handler.data_received(b'HELO test\n')
# handler.data_received(b'MAIL FROM:<test1@test>\n')
# handler.data_received(b'RCPT TO:<test2@test>\n')
# handler.data_received(b'DATA\n')
# handler.data_received(b'Subject: test\n')
# handler.data_received(b'From: test1@test\n')
# handler.data_received(b'To: test2@test\n')
# handler.data_received(b'\n')
# handler.data_received(b'test\n')
# handler.data_received(b'.\n')
# handler.data_received(b'QUIT\n')
#
# thread.join()
#
# handler.connection_lost(None)
# thread.join()
#
# # self.assertEqual(len(Email.objects.all()), 1)
# # self.assertEqual(Email.objects.all()[0].subject, 'test')
# # self.assertEqual(Email.objects.all()[0].body, 'test')
# # self.assertEqual(Email.objects.all()[0].sender, 'test')
# # self.assertEqual(Email.objects.all()[0].recipient, 'test@test')

View file

@ -0,0 +1,10 @@
from django.contrib import admin
from notify_sessions.models import SystemEvent
class SystemEventAdmin(admin.ModelAdmin):
pass
admin.site.register(SystemEvent, SystemEventAdmin)

View file

@ -0,0 +1,23 @@
from rest_framework import routers, viewsets, serializers
from tickets.models import IssueThread
from notify_sessions.models import SystemEvent
class SystemEventSerializer(serializers.ModelSerializer):
class Meta:
model = SystemEvent
fields = '__all__'
class SystemEventViewSet(viewsets.ModelViewSet):
serializer_class = SystemEventSerializer
queryset = SystemEvent.objects.all()
permission_classes = []
authentication_classes = []
router = routers.SimpleRouter()
router.register(r'systemevents', SystemEventViewSet, basename='systemevents')
urlpatterns = router.urls

View file

@ -1,3 +1,4 @@
import logging
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from json import loads as json_loads from json import loads as json_loads
from json import dumps as json_dumps from json import dumps as json_dumps
@ -6,18 +7,16 @@ from channels.generic.websocket import AsyncWebsocketConsumer
class NotifyConsumer(AsyncWebsocketConsumer): class NotifyConsumer(AsyncWebsocketConsumer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(args, kwargs) super().__init__(args, kwargs)
self.log = logging.getLogger("server.log")
self.room_group_name = "general" self.room_group_name = "general"
# self.event_slug = None
async def connect(self): async def connect(self):
# self.event_slug = self.scope["url_route"]["kwargs"]["event_slug"]
# self.room_group_name = f"chat_{self.event_slug}"
# Join room group # Join room group
await self.channel_layer.group_add(self.room_group_name, self.channel_name) await self.channel_layer.group_add(self.room_group_name, self.channel_name)
self.log.info(f"Added {self.channel_name} channel to {self.room_group_name} group")
await self.accept() await self.accept()
async def disconnect(self, close_code): async def disconnect(self, close_code):
@ -25,26 +24,34 @@ class NotifyConsumer(AsyncWebsocketConsumer):
await self.channel_layer.group_discard(self.room_group_name, self.channel_name) await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
# Receive message from WebSocket # Receive message from WebSocket
async def receive(self, text_data): async def receive(self, text_data=None, bytes_data=None):
self.log.info(f"Received message: {text_data}")
try: try:
text_data_json = json_loads(text_data) text_data_json = json_loads(text_data)
message = text_data_json["message"] message = text_data_json["message"]
# Send message to room group # Send message to room group
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.room_group_name, {"type": "chat.message", "message": message} self.room_group_name,
{"type": "generic.event", "message": message, "name": "send_message_to_frontend", "event_id": 1}
) )
except JSONDecodeError: except JSONDecodeError as e:
await self.send(text_data=json_dumps({"message": "error", "error": "malformed json"})) await self.send(text_data=json_dumps({"message": "error", "error": "malformed json"}))
self.log.error(e)
except KeyError as e: except KeyError as e:
await self.send(text_data=json_dumps({"message": "error", "error": f"missing key: {str(e)}"})) await self.send(text_data=json_dumps({"message": "error", "error": f"missing key: {str(e)}"}))
self.log.error(e)
except Exception as e: except Exception as e:
await self.send(text_data=json_dumps({"message": "error", "error": "unknown error"})) await self.send(text_data=json_dumps({"message": "error", "error": "unknown error"}))
self.log.error(e)
raise e raise e
# Receive message from room group # Receive message from room group
async def chat_message(self, event): async def generic_event(self, event):
self.log.info(f"Received event: {event}")
message = event["message"] message = event["message"]
name = event["name"]
event_id = event["event_id"]
# Send message to WebSocket # Send message to WebSocket
await self.send(text_data=json_dumps({"message": message})) await self.send(text_data=json_dumps({"message": message, "name": name, "event_id": event_id}))

View file

@ -0,0 +1,27 @@
# Generated by Django 4.2.7 on 2023-12-01 18:19
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SystemEvent',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('type', models.CharField(choices=[('ticket_created', 'ticket_created'), ('ticket_updated', 'ticket_updated'), ('ticket_deleted', 'ticket_deleted'), ('item_created', 'item_created'), ('item_updated', 'item_updated'), ('item_deleted', 'item_deleted'), ('user_created', 'user_created'), ('event_created', 'event_created'), ('event_updated', 'event_updated'), ('event_deleted', 'event_deleted')], max_length=255)),
('reference', models.IntegerField(blank=True, null=True)),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,47 @@
import logging
from django.db import models
from django.contrib.auth.models import User
from asgiref.sync import sync_to_async
from channels.layers import get_channel_layer
class SystemEvent(models.Model):
TYPE_CHOICES = [('ticket_created', 'ticket_created'),
('ticket_updated', 'ticket_updated'),
('ticket_deleted', 'ticket_deleted'),
('item_created', 'item_created'),
('item_updated', 'item_updated'),
('item_deleted', 'item_deleted'),
('user_created', 'user_created'),
('event_created', 'event_created'),
('event_updated', 'event_updated'),
('event_deleted', 'event_deleted'), ]
id = models.AutoField(primary_key=True)
timestamp = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(User, models.SET_NULL, null=True)
type = models.CharField(max_length=255, choices=TYPE_CHOICES)
reference = models.IntegerField(blank=True, null=True)
async def trigger_event(user, type, reference=None):
log = logging.getLogger('server.log')
log.info(f"Triggering event {type} for user {user} with reference {reference}")
try:
event = await sync_to_async(SystemEvent.objects.create, thread_sensitive=True)(user=user, type=type,
reference=reference)
channel_layer = get_channel_layer()
await channel_layer.group_send(
'general',
{
'type': 'generic.event',
'name': 'send_message_to_frontend',
'message': "event_trigered_from_views",
'event_id': event.id,
}
)
log.info(f"SystemEvent {event.id} triggered")
return event
except Exception as e:
log.error(e)
raise e

View file

View file

@ -0,0 +1,66 @@
from django.test import TestCase
from channels.testing import WebsocketCommunicator
from notify_sessions.consumers import NotifyConsumer
from asgiref.sync import async_to_sync
class NotifyWebsocketTestCase(TestCase):
async def test_connect(self):
communicator = WebsocketCommunicator(NotifyConsumer.as_asgi(), "/ws/2/notify/")
connected, subprotocol = await communicator.connect()
self.assertTrue(connected)
await communicator.disconnect()
async def fut_send_message(self):
communicator = WebsocketCommunicator(NotifyConsumer.as_asgi(), "/ws/2/notify/")
connected, subprotocol = await communicator.connect()
self.assertTrue(connected)
await communicator.send_json_to({
"name": "foo",
"message": "bar",
})
response = await communicator.receive_json_from()
await communicator.disconnect()
return response
def test_send_message(self):
response = async_to_sync(self.fut_send_message)()
self.assertEqual(response["message"], "bar")
self.assertEqual(response["event_id"], 1)
self.assertEqual(response["name"], "send_message_to_frontend")
# events = SystemEvent.objects.all()
# self.assertEqual(len(events), 1)
# event = events[0]
# self.assertEqual(event.event_id, 1)
# self.assertEqual(event.name, "send_message_to_frontend")
# self.assertEqual(event.message, "bar")
async def fut_send_and_receive_message(self):
communicator1 = WebsocketCommunicator(NotifyConsumer.as_asgi(), "/ws/2/notify/")
communicator2 = WebsocketCommunicator(NotifyConsumer.as_asgi(), "/ws/2/notify/")
connected1, subprotocol1 = await communicator1.connect()
connected2, subprotocol2 = await communicator2.connect()
self.assertTrue(connected1)
self.assertTrue(connected2)
await communicator1.send_json_to({
"name": "foo",
"message": "bar",
})
response = await communicator2.receive_json_from()
await communicator1.disconnect()
await communicator2.disconnect()
return response
def test_send_and_receive_message(self):
response = async_to_sync(self.fut_send_and_receive_message)()
self.assertEqual(response["message"], "bar")
self.assertEqual(response["event_id"], 1)
self.assertEqual(response["name"], "send_message_to_frontend")
# events = SystemEvent.objects.all()
# self.assertEqual(len(events), 1)
# event = events[0]
# self.assertEqual(event.event_id, 1)
# self.assertEqual(event.name, "send_message_to_frontend")
# self.assertEqual(event.message, "bar")

0
core/tickets/__init__.py Normal file
View file

20
core/tickets/admin.py Normal file
View file

@ -0,0 +1,20 @@
from django.contrib import admin
from tickets.models import IssueThread, Comment, StateChange
class IssueThreadAdmin(admin.ModelAdmin):
pass
class CommentAdmin(admin.ModelAdmin):
pass
class StateChangeAdmin(admin.ModelAdmin):
pass
admin.site.register(IssueThread, IssueThreadAdmin)
admin.site.register(Comment, CommentAdmin)
admin.site.register(StateChange, StateChangeAdmin)

22
core/tickets/api_v2.py Normal file
View file

@ -0,0 +1,22 @@
from rest_framework import routers, viewsets, serializers
from tickets.models import IssueThread
class IssueSerializer(serializers.ModelSerializer):
class Meta:
model = IssueThread
fields = '__all__'
class IssueViewSet(viewsets.ModelViewSet):
serializer_class = IssueSerializer
queryset = IssueThread.objects.all()
permission_classes = []
authentication_classes = []
router = routers.SimpleRouter()
router.register(r'tickets', IssueViewSet, basename='issues')
urlpatterns = router.urls

View file

@ -0,0 +1,44 @@
# Generated by Django 4.2.7 on 2023-12-06 02:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='IssueThread',
fields=[
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('id', models.AutoField(primary_key=True, serialize=False)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='StateChange',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('state', models.CharField(max_length=255)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('issue_thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.issuethread')),
],
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('comment', models.TextField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
('issue_thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.issuethread')),
],
),
]

View file

22
core/tickets/models.py Normal file
View file

@ -0,0 +1,22 @@
from django.db import models
from django_softdelete.models import SoftDeleteModel
from inventory.models import Event
class IssueThread(SoftDeleteModel):
id = models.AutoField(primary_key=True)
class Comment(models.Model):
id = models.AutoField(primary_key=True)
issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE)
comment = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
class StateChange(models.Model):
id = models.AutoField(primary_key=True)
issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE)
state = models.CharField(max_length=255)
timestamp = models.DateTimeField(auto_now_add=True)

View file

View file

View file

@ -0,0 +1,13 @@
from django.test import TestCase, Client
client = Client()
class IssueApiTest(TestCase):
def test_issues(self):
response = client.get('/api/2/tickets/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])

View file

@ -15,7 +15,6 @@
"base-64": "^0.1.0", "base-64": "^0.1.0",
"bootstrap": "^4.3.1", "bootstrap": "^4.3.1",
"core-js": "^3.3.2", "core-js": "^3.3.2",
"dotenv-webpack": "^1.7.0",
"jquery": "^3.4.1", "jquery": "^3.4.1",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"luxon": "^1.21.3", "luxon": "^1.21.3",
@ -28,12 +27,15 @@
"vue-debounce": "^2.2.0", "vue-debounce": "^2.2.0",
"vue-router": "^3.1.3", "vue-router": "^3.1.3",
"vuex": "^3.1.2", "vuex": "^3.1.2",
"vuex-router-sync": "^5.0.0" "vuex-router-sync": "^5.0.0",
"yarn": "^1.22.21"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-plugin-babel": "^5.0.8", "@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-service": "^5.0.8", "@vue/cli-service": "^5.0.8",
"vue-template-compiler": "^2.6.10" "express-basic-auth": "^1.2.1",
"vue-template-compiler": "^2.6.10",
"webpack": "^5"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,

View file

@ -47,7 +47,8 @@ export default {
message: "Connecting to websocket...", message: "Connecting to websocket...",
color: "warning" color: "warning"
}); });
this.notify_socket = new WebSocket('wss://' + window.location.host + '/ws/2/notify/'); const scheme = window.location.protocol === "https:" ? "wss" : "ws";
this.notify_socket = new WebSocket(scheme + '://' + window.location.host + '/ws/2/notify/');
this.notify_socket.onopen = (e) => { this.notify_socket.onopen = (e) => {
if (this.socket_toast) { if (this.socket_toast) {
this.removeToast(this.socket_toast.key); this.removeToast(this.socket_toast.key);
@ -96,7 +97,7 @@ export default {
}, },
}, },
created: function () { created: function () {
this.tryConnect(); //this.tryConnect();
} }
}; };
</script> </script>

View file

@ -16,15 +16,11 @@
<script> <script>
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import config from '../config';
export default { export default {
name: 'Lightbox', name: 'Lightbox',
components: {Modal}, components: {Modal},
props: ['file'], props: ['file']
data: () => ({
baseUrl: config.service.url,
}),
}; };
</script> </script>

View file

@ -10,27 +10,25 @@
: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>
<ul class="nav nav-tabs flex-nowrap">
<div class="custom-control-inline mr-1"> <li class="nav-item">
<button type="button" class="btn mx-1 text-nowrap btn-success" @click="$emit('addClicked')"> <router-link :to="{name: 'items', params: {event: getEventSlug}}"
<font-awesome-icon icon="plus"/> :class="['nav-link', { active: getActiveView === 'items' }]">
<span class="d-none d-md-inline">&nbsp;Add</span> Items
</button> </router-link>
<div class="btn-group btn-group-toggle"> </li>
<button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')"> <li class="nav-item" v-if="checkRole('postevent')">
<font-awesome-icon icon="th"/> <router-link :to="{name: 'tickets', params: {event: getEventSlug}}"
</button> :class="['nav-link', { active: getActiveView === 'tickets' }]">
<button :class="['btn', 'btn-info', { active: layout === 'table' }]" @click="setLayout('table')"> Tickets
<font-awesome-icon icon="list"/> </router-link>
</button> </li>
</div> <li class="nav-item" v-if="checkRole('admin')">
</div> <router-link :to="{name: 'admin'}" :class="['nav-link', { active: getActiveView === 'admin' }]">
Admin
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" </router-link>
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> </li>
<span class="navbar-toggler-icon"></span> </ul>
</button>
<form class="form-inline mt-1 my-lg-auto my-xl-auto w-100 d-inline"> <form class="form-inline mt-1 my-lg-auto my-xl-auto w-100 d-inline">
<input <input
class="form-control w-100" class="form-control w-100"
@ -41,10 +39,27 @@
disabled disabled
> >
</form> </form>
<div class="custom-control-inline mr-1">
<div class="btn-group btn-group-toggle mx-1">
<button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')">
<font-awesome-icon icon="th"/>
</button>
<button :class="['btn', 'btn-info', { active: layout === 'table' }]" @click="setLayout('table')">
<font-awesome-icon icon="list"/>
</button>
</div>
<button type="button" class="btn text-nowrap btn-success" @click="$emit('addClicked')">
<font-awesome-icon icon="plus"/>
<span class="d-none d-md-inline">&nbsp;Add</span>
</button>
</div>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</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"> <!--li class="nav-item dropdown">
<button class="btn nav-link dropdown-toggle" type="button" id="dropdownMenuButton2" <button class="btn nav-link dropdown-toggle" type="button" id="dropdownMenuButton2"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ getActiveView }} {{ getActiveView }}
@ -55,7 +70,7 @@
<a class="nav-link text-nowrap" href="#" @click="changeView(link)">{{ link.title }}</a> <a class="nav-link text-nowrap" href="#" @click="changeView(link)">{{ link.title }}</a>
</li> </li>
</ul> </ul>
</li> </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)">
@ -80,12 +95,12 @@ export default {
//{'title':'mass-edit','path':'massedit'}, //{'title':'mass-edit','path':'massedit'},
], ],
links: [ links: [
{'title': 'howto engel', 'path': '/howto/'} {'title': 'howto engel', 'path': '/howto/'},
] ]
}), }),
computed: { computed: {
...mapState(['events', 'activeEvent', 'layout']), ...mapState(['events', 'activeEvent', 'layout']),
...mapGetters(['getEventSlug', 'getActiveView']), ...mapGetters(['getEventSlug', 'getActiveView', "checkRole"]),
}, },
methods: { methods: {
...mapActions(['changeEvent', 'changeView', 'searchEventItems']), ...mapActions(['changeEvent', 'changeView', 'searchEventItems']),
@ -99,4 +114,29 @@ export default {
<style lang="scss"> <style lang="scss">
@import "../scss/navbar.scss"; @import "../scss/navbar.scss";
.nav-tabs {
margin-bottom: -0.5rem !important;
border-bottom: var(--gray) solid 1px !important;
& .nav-item {
margin-right: 0.5em;
&:first-child {
margin-left: 0.5em;
}
}
& .nav-link {
padding-bottom: 1rem !important;
border: var(--gray) solid 1px !important;
border-bottom: none !important;
&.active {
background: black !important;
border-bottom: none;
color: white !important;
}
}
}
</style> </style>

View file

@ -0,0 +1,336 @@
<template>
<ol class="timeline">
<li class="timeline-item">
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="pen"/>
</span>
<div class="timeline-item-description">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<span><a href="#">Luna Bonifacio</a> has changed <a href="#">2 attributes</a> on <time
datetime="21-01-2021">Jan 21, 2021</time></span>
</div>
</li>
<li class="timeline-item">
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="check"/>
</span>
<div class="timeline-item-description">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<span><a href="#">Yoan Almedia</a> moved <a href="#">Eric Lubin</a> to <a href="#">📚 Technical Test</a> on <time
datetime="20-01-2021">Jan 20, 2021</time></span>
</div>
</li>
<li class="timeline-item | extra-space">
<span class="timeline-item-icon | filled-icon">
<font-awesome-icon icon="envelope"/>
</span>
<div class="timeline-item-wrapper">
<div class="timeline-item-description">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<span><a href="#">Yoan Almedia</a> commented on <time
datetime="20-01-2021">Jan 20, 2021</time></span>
</div>
<div class="comment">
<p>I've sent him the assignment we discussed recently, he is coming back to us this week. Regarding
to our last call, I really enjoyed talking to him and so far he has the profile we are looking
for. Can't wait to see his technical test, I'll keep you posted and we'll debrief it all
together!😊</p>
<button class="button">👏 2</button>
<button class="button | square">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="none" d="M0 0h24v24H0z"/>
<path fill="currentColor"
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zM7 12a5 5 0 0 0 10 0h-2a3 3 0 0 1-6 0H7z"/>
</svg>
</button>
</div>
<button class="show-replies">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-arrow-forward"
width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 11l4 4l-4 4m4 -4h-11a4 4 0 0 1 0 -8h1"/>
</svg>
Show 3 replies
<span class="avatar-list">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
</span>
</button>
</div>
</li>
<li class="timeline-item">
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="comment"/>
</span>
<div class="new-comment">
<input type="text" placeholder="Add a comment..."/>
</div>
</li>
</ol>
</template>
<script>
export default {
name: 'Timeline',
props: {
timeline: {
type: Array,
default: () => []
}
},
};
</script>
<style>
*,
*:before,
*:after {
box-sizing: border-box;
}
:root {
--c-grey-100: #f4f6f8;
--c-grey-200: #e3e3e3;
--c-grey-300: #b2b2b2;
--c-grey-400: #7b7b7b;
--c-grey-500: #3d3d3d;
--c-blue-500: #688afd;
}
button,
input,
select,
textarea {
font: inherit;
}
a {
color: inherit;
}
img {
display: block;
max-width: 100%;
}
/* End basic CSS override */
.timeline {
width: 85%;
display: flex;
flex-direction: column;
padding: 32px 0 32px 32px;
border-left: 2px solid var(--c-grey-200);
font-size: 1.125rem;
margin: 0 auto;
}
.timeline-item {
display: flex;
gap: 24px;
& + * {
margin-top: 24px;
}
& + .extra-space {
margin-top: 48px;
}
}
.new-comment {
width: 100%;
input {
border: 1px solid var(--c-grey-200);
border-radius: 6px;
height: 48px;
padding: 0 16px;
width: 100%;
&::placeholder {
color: var(--c-grey-300);
}
&:focus {
border-color: var(--c-grey-300);
outline: 0; /* Don't actually do this */
box-shadow: 0 0 0 4px var(--c-grey-100);
}
}
}
.timeline-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 50%;
margin-left: -57px;
flex-shrink: 0;
overflow: hidden;
svg {
width: 20px;
height: 20px;
}
&.faded-icon {
background-color: var(--c-grey-100);
color: var(--c-grey-400);
}
&.filled-icon {
background-color: var(--c-blue-500);
color: #fff;
}
}
.timeline-item-description {
display: flex;
padding-top: 6px;
gap: 8px;
color: var(--c-grey-400);
img {
flex-shrink: 0;
}
a {
/*color: var(--c-grey-500);*/
font-weight: 500;
text-decoration: none;
&:hover,
&:focus {
outline: 0; /* Don't actually do this */
color: var(--c-blue-500);
}
}
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
aspect-ratio: 1 / 1;
flex-shrink: 0;
width: 40px;
height: 40px;
&.small {
width: 28px;
height: 28px;
}
img {
object-fit: cover;
}
}
.comment {
margin-top: 12px;
/*color: var(--c-grey-500);*/
border: 1px solid var(--c-grey-200);
background: var(--dark);
border-radius: 6px;
padding: 16px;
font-size: 1rem;
}
.button {
border: 0;
display: inline-flex;
vertical-align: middle;
margin-right: 4px;
margin-top: 12px;
align-items: center;
justify-content: center;
font-size: 1rem;
height: 32px;
padding: 0 8px;
background-color: var(--c-grey-100);
flex-shrink: 0;
cursor: pointer;
border-radius: 99em;
&:hover {
background-color: var(--c-grey-200);
}
&.square {
border-radius: 50%;
color: var(--c-grey-400);
width: 32px;
height: 32px;
padding: 0;
svg {
width: 24px;
height: 24px;
}
&:hover {
background-color: var(--c-grey-200);
color: var(--c-grey-500);
}
}
}
.show-replies {
color: var(--c-grey-300);
background-color: transparent;
border: 0;
padding: 0;
margin-top: 16px;
display: flex;
align-items: center;
gap: 6px;
font-size: 1rem;
cursor: pointer;
svg {
flex-shrink: 0;
width: 24px;
height: 24px;
}
&:hover,
&:focus {
color: var(--c-grey-200);
}
}
.avatar-list {
display: flex;
align-items: center;
& > * {
position: relative;
box-shadow: 0 0 0 2px #fff;
background: var(--dark);
margin-right: -8px;
}
}
</style>

View file

@ -29,14 +29,19 @@ import {
faPen, faPen,
faCheck, faCheck,
faTimes, faTimes,
faSave faSave,
faEye,
faComment,
faEnvelope,
faUser,
faComments
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'; import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
import vueDebounce from 'vue-debounce'; import vueDebounce from 'vue-debounce';
library.add(faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, faSortUp, faSortDown, faTh, faList, faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave); library.add(faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, faSortUp, faSortDown, faTh, faList,
faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave, faEye, faComment, faUser, faComments, faEnvelope);
Vue.component('font-awesome-icon', FontAwesomeIcon); Vue.component('font-awesome-icon', FontAwesomeIcon);
sync(store, router); sync(store, router);

View file

@ -6,25 +6,41 @@ import Error from './views/Error';
import HowTo from './views/HowTo'; import HowTo from './views/HowTo';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import Vue from 'vue'; import Vue from 'vue';
import Debug from "@/views/admin/Debug.vue";
import Tickets from "@/views/Tickets.vue";
import Ticket from "@/views/Ticket.vue";
import Admin from "@/views/admin/Admin.vue";
import store from "@/store";
Vue.use(VueRouter); Vue.use(VueRouter);
const routes = [ const routes = [
{path: '/', redirect: '/Camp23/items'}, {path: '/', redirect: '/Camp23/items'},
{path: '/howto', name: 'howto', component: HowTo}, {path: '/howto', name: 'howto', component: HowTo},
{path: '/admin/files', name: 'files', component: Files},
{path: '/admin/events', name: 'events', component: Events},
{path: '/:event/boxes', name: 'boxes', component: Boxes}, {path: '/:event/boxes', name: 'boxes', component: Boxes},
{path: '/:event/items', name: 'items', component: Items}, {path: '/:event/items', name: 'items', component: Items},
{path: '/:event/box/:uid', name: 'boxes', component: Boxes}, {path: '/:event/box/:uid', name: 'box', component: Boxes},
{path: '/:event/item/:uid', name: 'items', component: Items}, {path: '/:event/item/:uid', name: 'item', component: Items},
{path: '/:event/tickets', name: 'tickets', component: Tickets},
{path: '/:event/ticket/:id', name: 'ticket', component: Ticket},
{path: '/admin', name: 'admin', component: Admin},
{path: '/admin/files', name: 'files', component: Files},
{path: '/admin/events', name: 'events', component: Events},
{path: '/admin/debug', name: 'debug', component: Debug},
{path: '/admin/users', name: 'users', component: Events},
{path: '*', component: Error}, {path: '*', component: Error},
]; ];
const router = new VueRouter({ const router = new VueRouter({
mode: 'history', mode: 'history',
routes routes,
});
router.afterEach((to, from) => {
if (to.params.event) {
//console.log('update last event', to.params.event);
store.commit('updateLastEvent', to.params.event);
}
}); });
export default router; export default router;

View file

@ -1,7 +1,6 @@
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import AxiosBootstrap from 'axios'; import AxiosBootstrap from 'axios';
import config from '../config';
import * as _ from 'lodash/fp'; import * as _ from 'lodash/fp';
import router from '../router'; import router from '../router';
@ -10,8 +9,7 @@ import * as utf8 from 'utf8';
Vue.use(Vuex); Vue.use(Vuex);
const axios = AxiosBootstrap.create({ const axios = AxiosBootstrap.create({
baseURL: config.service.url, baseURL: '/api',
auth: config.service.auth
}); });
axios.interceptors.response.use(response => response, error => { axios.interceptors.response.use(response => response, error => {
@ -45,19 +43,61 @@ const store = new Vuex.Store({
loadedItems: [], loadedItems: [],
loadedBoxes: [], loadedBoxes: [],
toasts: [], toasts: [],
tickets: [
{
id: 1,
name: "test1",
state: "open",
assigned_to: "test",
last_activity: "2019-12-27T12:00:00+01:00",
timeline: [{name: "test1", time: "2019-12-27T12:00:00+01:00"}, {
name: "test2",
time: "2019-12-27T12:00:00+01:00"
}]
},
{
id: 2,
name: "test2",
state: "open",
assigned_to: "test",
last_activity: "2019-12-27T12:00:00+01:00",
timeline: [{name: "test1", time: "2019-12-27T12:00:00+01:00"}, {
name: "test2",
time: "2019-12-27T12:00:00+01:00"
}]
},
{
id: 3,
name: "test3",
state: "open",
assigned_to: "test",
last_activity: "2019-12-27T12:00:00+01:00",
timeline: [{name: "test1", time: "2019-12-27T12:00:00+01:00"}, {
name: "test2",
time: "2019-12-27T12:00:00+01:00"
}]
},
],
userRoles: ['admin', 'postevent', 'orga', 'user'],
lastEvent: localStorage.getItem('lf_lastEvent') || '36C3',
lastUsed: localStorage.getItem('lf_lastUsed') || {}, lastUsed: localStorage.getItem('lf_lastUsed') || {},
}, },
getters: { getters: {
getEventSlug: state => state.route && state.route.params.event ? state.route.params.event : state.events.length ? state.events[0].slug : '36C3', getEventSlug: state => state.route && state.route.params.event ? state.route.params.event : state.lastEvent,
getActiveView: state => state.route.name || 'items', getActiveView: state => state.route.name || 'items',
getFilters: state => state.route.query, getFilters: state => state.route.query,
getBoxes: state => state.loadedBoxes getBoxes: state => state.loadedBoxes,
checkRole: state => role => state.userRoles.includes(role),
}, },
mutations: { mutations: {
updateLastUsed(state, diff) { updateLastUsed(state, diff) {
state.lastUsed = _.extend(state.lastUsed, diff); state.lastUsed = _.extend(state.lastUsed, diff);
localStorage.setItem('lf_lastUsed', state.lastUsed); localStorage.setItem('lf_lastUsed', state.lastUsed);
}, },
updateLastEvent(state, slug) {
state.lastEvent = slug;
localStorage.setItem('lf_lastEvent', slug);
},
replaceEvents(state, events) { replaceEvents(state, events) {
state.events = events; state.events = events;
}, },

View file

@ -134,5 +134,8 @@ export default {
</script> </script>
<style scoped> <style scoped>
* {
color: #c8c8c8 !important;
}
</style> </style>

View file

@ -45,7 +45,7 @@
@itemActivated="openLightboxModalWith($event)" @itemActivated="openLightboxModalWith($event)"
> >
<img <img
:src="`${baseUrl}/1/thumbs/${item.file}`" :src="`/api/1/thumbs/${item.file}`"
class="card-img-top img-fluid" class="card-img-top img-fluid"
> >
<div class="card-body"> <div class="card-body">
@ -75,7 +75,6 @@ import Cards from '@/components/Cards';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem'; import EditItem from '@/components/EditItem';
import {mapActions, mapState} from 'vuex'; import {mapActions, mapState} from 'vuex';
import config from '../config';
import Lightbox from '../components/Lightbox'; import Lightbox from '../components/Lightbox';
export default { export default {
@ -83,7 +82,6 @@ export default {
data: () => ({ data: () => ({
lightboxItem: null, lightboxItem: null,
editingItem: null, editingItem: null,
baseUrl: config.service.url,
}), }),
components: {Lightbox, Table, Cards, Modal, EditItem}, components: {Lightbox, Table, Cards, Modal, EditItem},
computed: mapState(['loadedItems', 'layout']), computed: mapState(['loadedItems', 'layout']),

49
web/src/views/Ticket.vue Normal file
View file

@ -0,0 +1,49 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-header">
<h3>Ticket #{{ ticket.id }} - {{ ticket.name }}</h3>
</div>
<Timeline :timeline="ticket.timeline"/>
<div class="card-footer d-flex justify-content-between">
<router-link :to="{name: 'tickets'}" class="btn btn-secondary mr-2">Back</router-link>
<button class="btn btn-danger" @click="deleteItem({type: 'tickets', id: ticket.id})">
<font-awesome-icon icon="trash"/>
Delete
</button>
<button class="btn btn-success" @click="markItemReturned({type: 'tickets', id: ticket.id})">Mark
as returned
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Timeline from "@/components/Timeline.vue";
export default {
name: 'Ticket',
components: {Timeline},
computed: {
...mapState(['tickets']),
ticket() {
const id = parseInt(this.$route.params.id)
const ret = this.tickets.find(ticket => ticket.id === id);
return ret ? ret : {};
}
},
methods: {
...mapActions(['deleteItem', 'markItemReturned']),
}
};
</script>
<style scoped>
</style>

46
web/src/views/Tickets.vue Normal file
View file

@ -0,0 +1,46 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to']"
:items="tickets"
:keyName="'id'"
v-slot="{ item }"
>
<div class="btn-group">
<a class="btn btn-primary" :href="'/ticket/' + item.id" title="view"
@click.prevent="gotoDetail(item)">
<font-awesome-icon icon="eye"/>
View
</a>
</div>
</Table>
</div>
</div>
</div>
</template>
<script>
import Table from '@/components/Table';
import Cards from '@/components/Cards';
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
import {mapActions, mapState} from 'vuex';
import Lightbox from '../components/Lightbox';
export default {
name: 'Tickets',
components: {Lightbox, Table, Cards, Modal, EditItem},
computed: mapState(['tickets']),
methods: {
gotoDetail(ticket) {
this.$router.push({name: 'ticket', params: {id: ticket.id}});
}
}
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,40 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<h3 class="text-center">Admin</h3>
<ul>
<li>
<router-link :to="{name: 'debug'}">Debug</router-link>
</li>
<li>
<router-link :to="{name: 'boxes', params: {event: getEventSlug}}">Boxes</router-link>
</li>
<li>
<router-link :to="{name: 'events'}">Events</router-link>
</li>
<li>
<router-link :to="{name: 'users'}">Users</router-link>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
import {mapGetters} from 'vuex';
export default {
name: 'Admin',
computed: {
...mapGetters(['getEventSlug']),
},
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,39 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['cid', 'name','itemCount']"
:items="loadedBoxes"
:keyName="'cid'"
v-slot="{ item }"
>
<div class="btn-group">
<button class="btn btn-secondary" @click.stop="showBoxContent(item.name)">
<!--font-awesome-icon icon="archive"/--> content
</button>
<button class="btn btn-danger" @click.stop="" title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div>
</Table>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
export default {
name: 'Boxes',
components: {Table},
computed: mapState(['loadedBoxes', 'layout']),
methods: mapActions(['showBoxContent']),
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,75 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<h3 class="text-center">Events</h3>
<!--p>{{ events }}</p-->
<ul>
<li v-for="event in events" :key="event.id">
{{ event.slug }}
</li>
</ul>
<h3 class="text-center">Items</h3>
<!--p>{{ loadedItems }}</p-->
<ul>
<li v-for="item in loadedItems" :key="item.id">
{{ item.description }}
</li>
</ul>
<h3 class="text-center">Boxes</h3>
<!--p>{{ loadedBoxes }}</p-->
<ul>
<li v-for="box in loadedBoxes" :key="box.id">
{{ box.name }}
</li>
</ul>
<h3 class="text-center">Mails</h3>
<!--p>{{ mails }}</p-->
<ul>
<li v-for="mail in mails" :key="mail.id">
{{ mail.id }}
</li>
</ul>
<h3 class="text-center">Issues</h3>
<!--p>{{ issues }}</p-->
<ul>
<li v-for="issue in issues" :key="issue.id">
{{ issue.id }}
</li>
</ul>
<h3 class="text-center">System Events</h3>
<!--p>{{ systemEvents }}</p-->
<ul>
<li v-for="systemEvent in systemEvents" :key="systemEvent.id">
{{ systemEvent.id }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
import Events from "@/views/Events.vue";
export default {
name: 'Debug',
components: {Events, Table},
computed: mapState(['events', 'loadedItems', 'loadedBoxes', 'mails', 'issues', 'systemEvents']),
methods: {
...mapActions(['changeEvent', 'loadMails', 'loadIssues', 'loadSystemEvents']),
},
mounted() {
this.loadMails();
this.loadIssues();
this.loadSystemEvents();
}
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,41 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['slug', 'name']"
:items="events"
:keyName="'slug'"
v-slot="{ item }"
>
<div class="btn-group">
<button class="btn btn-secondary" @click.stop="changeEvent(item)">
<font-awesome-icon icon="archive"/>
use
</button>
<button class="btn btn-danger" @click.stop="">
<font-awesome-icon icon="trash"/>
delete
</button>
</div>
</Table>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
export default {
name: 'Events',
components: {Table},
computed: mapState(['events']),
methods: mapActions(['changeEvent']),
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,29 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<h3 class="text-center">Users</h3>
<p>{{ users }}</p>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
import Events from "@/views/Events.vue";
export default {
name: 'Users',
computed: mapState(['users']),
methods: mapActions(['loadUsers']),
mounted() {
this.loadUsers();
}
};
</script>
<style scoped>
</style>

39
web/vue.config.js Normal file
View file

@ -0,0 +1,39 @@
// vue.config.js
const basicAuth = require('express-basic-auth');
module.exports = {
devServer: {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Methods": "*"
},
setupMiddlewares: (middlewares, devServer) => {
devServer.app.use(basicAuth({
users: {
'c3lf': 'findetalles'
},
challenge: true
}));
return middlewares;
},
proxy: {
'/api/2': {
target: 'https://sys3.c3lf.de/',
changeOrigin: true,
},
'/api/1': {
target: 'https://sys3.c3lf.de/',
changeOrigin: true,
},
'/ws/2': {
target: 'https://sys3.c3lf.de/',
changeOrigin: true,
ws: true,
logLevel: 'debug',
xfwd: false,
},
}
}
}