From 5bdfe313de7eb79f7521d7d32218ef0f4cb8e967 Mon Sep 17 00:00:00 2001 From: jedi Date: Thu, 23 Nov 2023 23:17:20 +0100 Subject: [PATCH] wip --- Dockerfile | 15 + core/authentication/api_v2.py | 21 ++ core/authentication/tests/__init__.py | 0 core/authentication/tests/v2/__init__.py | 0 core/authentication/tests/v2/test_users.py | 18 + core/core/settings.py | 31 +- core/core/urls.py | 7 + core/files/api_v2.py | 24 ++ core/files/media_v2.py | 57 +++ core/files/tests/__init__.py | 0 core/files/tests/v1/__init__.py | 0 .../{tests.py => tests/v1/test_files.py} | 0 core/files/tests/v2/__init__.py | 0 core/files/tests/v2/test_files.py | 49 +++ core/inventory/api_v2.py | 161 +++++++++ core/inventory/tests/v1/__init__.py | 0 core/inventory/tests/{ => v1}/test_api.py | 0 .../tests/{ => v1}/test_containers.py | 0 core/inventory/tests/{ => v1}/test_events.py | 0 core/inventory/tests/{ => v1}/test_items.py | 0 core/inventory/tests/v2/__init__.py | 0 core/inventory/tests/v2/test_api.py | 34 ++ core/inventory/tests/v2/test_containers.py | 59 +++ core/inventory/tests/v2/test_events.py | 56 +++ core/inventory/tests/v2/test_items.py | 133 +++++++ core/mail/admin.py | 17 + core/mail/api_v2.py | 22 ++ core/mail/migrations/0001_initial.py | 46 +++ core/mail/models.py | 34 ++ core/mail/protocol.py | 92 ++++- core/mail/tests/v2/test_mails.py | 198 +++++++++++ core/notify_sessions/admin.py | 10 + core/notify_sessions/api_v2.py | 23 ++ core/notify_sessions/consumers.py | 27 +- .../migrations/0001_initial.py | 27 ++ core/notify_sessions/models.py | 47 +++ core/notify_sessions/tests/__init__.py | 0 .../tests/test_notify_socket.py | 66 ++++ core/tickets/__init__.py | 0 core/tickets/admin.py | 20 ++ core/tickets/api_v2.py | 22 ++ core/tickets/migrations/0001_initial.py | 44 +++ core/tickets/migrations/__init__.py | 0 core/tickets/models.py | 22 ++ core/tickets/tests/__init__.py | 0 core/tickets/tests/v2/__init__.py | 0 core/tickets/tests/v2/test_tickets.py | 13 + web/package.json | 8 +- web/src/App.vue | 5 +- web/src/components/Lightbox.vue | 6 +- web/src/components/Navbar.vue | 92 +++-- web/src/components/Timeline.vue | 336 ++++++++++++++++++ web/src/main.js | 11 +- web/src/router.js | 28 +- web/src/store/index.js | 50 ++- web/src/views/HowTo.vue | 3 + web/src/views/Items.vue | 4 +- web/src/views/Ticket.vue | 49 +++ web/src/views/Tickets.vue | 46 +++ web/src/views/admin/Admin.vue | 40 +++ web/src/views/admin/Boxes.vue | 39 ++ web/src/views/admin/Debug.vue | 75 ++++ web/src/views/admin/Events.vue | 41 +++ web/src/views/admin/Users.vue | 29 ++ web/vue.config.js | 39 ++ 65 files changed, 2219 insertions(+), 77 deletions(-) create mode 100644 Dockerfile create mode 100644 core/authentication/api_v2.py create mode 100644 core/authentication/tests/__init__.py create mode 100644 core/authentication/tests/v2/__init__.py create mode 100644 core/authentication/tests/v2/test_users.py create mode 100644 core/files/api_v2.py create mode 100644 core/files/media_v2.py create mode 100644 core/files/tests/__init__.py create mode 100644 core/files/tests/v1/__init__.py rename core/files/{tests.py => tests/v1/test_files.py} (100%) create mode 100644 core/files/tests/v2/__init__.py create mode 100644 core/files/tests/v2/test_files.py create mode 100644 core/inventory/api_v2.py create mode 100644 core/inventory/tests/v1/__init__.py rename core/inventory/tests/{ => v1}/test_api.py (100%) rename core/inventory/tests/{ => v1}/test_containers.py (100%) rename core/inventory/tests/{ => v1}/test_events.py (100%) rename core/inventory/tests/{ => v1}/test_items.py (100%) create mode 100644 core/inventory/tests/v2/__init__.py create mode 100644 core/inventory/tests/v2/test_api.py create mode 100644 core/inventory/tests/v2/test_containers.py create mode 100644 core/inventory/tests/v2/test_events.py create mode 100644 core/inventory/tests/v2/test_items.py create mode 100644 core/mail/admin.py create mode 100644 core/mail/api_v2.py create mode 100644 core/mail/migrations/0001_initial.py create mode 100644 core/mail/models.py create mode 100644 core/mail/tests/v2/test_mails.py create mode 100644 core/notify_sessions/admin.py create mode 100644 core/notify_sessions/api_v2.py create mode 100644 core/notify_sessions/migrations/0001_initial.py create mode 100644 core/notify_sessions/models.py create mode 100644 core/notify_sessions/tests/__init__.py create mode 100644 core/notify_sessions/tests/test_notify_socket.py create mode 100644 core/tickets/__init__.py create mode 100644 core/tickets/admin.py create mode 100644 core/tickets/api_v2.py create mode 100644 core/tickets/migrations/0001_initial.py create mode 100644 core/tickets/migrations/__init__.py create mode 100644 core/tickets/models.py create mode 100644 core/tickets/tests/__init__.py create mode 100644 core/tickets/tests/v2/__init__.py create mode 100644 core/tickets/tests/v2/test_tickets.py create mode 100644 web/src/components/Timeline.vue create mode 100644 web/src/views/Ticket.vue create mode 100644 web/src/views/Tickets.vue create mode 100644 web/src/views/admin/Admin.vue create mode 100644 web/src/views/admin/Boxes.vue create mode 100644 web/src/views/admin/Debug.vue create mode 100644 web/src/views/admin/Events.vue create mode 100644 web/src/views/admin/Users.vue create mode 100644 web/vue.config.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1511ac8 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/core/authentication/api_v2.py b/core/authentication/api_v2.py new file mode 100644 index 0000000..5dac2a8 --- /dev/null +++ b/core/authentication/api_v2.py @@ -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 diff --git a/core/authentication/tests/__init__.py b/core/authentication/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/authentication/tests/v2/__init__.py b/core/authentication/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/authentication/tests/v2/test_users.py b/core/authentication/tests/v2/test_users.py new file mode 100644 index 0000000..dddfc51 --- /dev/null +++ b/core/authentication/tests/v2/test_users.py @@ -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) diff --git a/core/core/settings.py b/core/core/settings.py index 615ecdc..a15fbb0 100644 --- a/core/core/settings.py +++ b/core/core/settings.py @@ -33,6 +33,11 @@ ALLOWED_HOSTS = [os.getenv('HTTP_HOST', '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" # Application definition @@ -49,8 +54,11 @@ INSTALLED_APPS = [ 'rest_framework.authtoken', 'drf_yasg', 'channels', + 'authentication', 'files', + 'tickets', 'inventory', + 'mail', 'notify_sessions', ] @@ -184,14 +192,21 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024 * 128 # 128 MB DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -CHANNEL_LAYERS = { - 'default': { - 'BACKEND': 'channels_redis.core.RedisChannelLayer', - 'CONFIG': { - 'hosts': [('localhost', 6379)], - }, +if 'test' in sys.argv: + CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer' + } + } +else: + CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': { + 'hosts': [('localhost', 6379)], + }, + } + } -} - TEST_RUNNER = 'core.test_runner.FastTestRunner' diff --git a/core/core/urls.py b/core/core/urls.py index c115026..b0161bb 100644 --- a/core/core/urls.py +++ b/core/core/urls.py @@ -24,5 +24,12 @@ urlpatterns = [ path('api/1/', include('inventory.api_v1')), path('api/1/', include('files.api_v1')), path('api/1/', include('files.media_v1')), + path('api/2/', include('inventory.api_v2')), + path('api/2/', include('files.api_v2')), + path('media/2/', include('files.media_v2')), + 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), ] diff --git a/core/files/api_v2.py b/core/files/api_v2.py new file mode 100644 index 0000000..a0962f0 --- /dev/null +++ b/core/files/api_v2.py @@ -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 diff --git a/core/files/media_v2.py b/core/files/media_v2.py new file mode 100644 index 0000000..39bca8a --- /dev/null +++ b/core/files/media_v2.py @@ -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('//', thumbnail_urls), + path('/', media_urls), +] diff --git a/core/files/tests/__init__.py b/core/files/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/files/tests/v1/__init__.py b/core/files/tests/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/files/tests.py b/core/files/tests/v1/test_files.py similarity index 100% rename from core/files/tests.py rename to core/files/tests/v1/test_files.py diff --git a/core/files/tests/v2/__init__.py b/core/files/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/files/tests/v2/test_files.py b/core/files/tests/v2/test_files.py new file mode 100644 index 0000000..81265f2 --- /dev/null +++ b/core/files/tests/v2/test_files.py @@ -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) diff --git a/core/inventory/api_v2.py b/core/inventory/api_v2.py new file mode 100644 index 0000000..0b4feea --- /dev/null +++ b/core/inventory/api_v2.py @@ -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('/items/', item), + path('/items//', search_items), + path('/item/', item), + path('/item//', item_by_id), +] diff --git a/core/inventory/tests/v1/__init__.py b/core/inventory/tests/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/inventory/tests/test_api.py b/core/inventory/tests/v1/test_api.py similarity index 100% rename from core/inventory/tests/test_api.py rename to core/inventory/tests/v1/test_api.py diff --git a/core/inventory/tests/test_containers.py b/core/inventory/tests/v1/test_containers.py similarity index 100% rename from core/inventory/tests/test_containers.py rename to core/inventory/tests/v1/test_containers.py diff --git a/core/inventory/tests/test_events.py b/core/inventory/tests/v1/test_events.py similarity index 100% rename from core/inventory/tests/test_events.py rename to core/inventory/tests/v1/test_events.py diff --git a/core/inventory/tests/test_items.py b/core/inventory/tests/v1/test_items.py similarity index 100% rename from core/inventory/tests/test_items.py rename to core/inventory/tests/v1/test_items.py diff --git a/core/inventory/tests/v2/__init__.py b/core/inventory/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/inventory/tests/v2/test_api.py b/core/inventory/tests/v2/test_api.py new file mode 100644 index 0000000..db1df0a --- /dev/null +++ b/core/inventory/tests/v2/test_api.py @@ -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(), []) diff --git a/core/inventory/tests/v2/test_containers.py b/core/inventory/tests/v2/test_containers.py new file mode 100644 index 0000000..78b82c1 --- /dev/null +++ b/core/inventory/tests/v2/test_containers.py @@ -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) diff --git a/core/inventory/tests/v2/test_events.py b/core/inventory/tests/v2/test_events.py new file mode 100644 index 0000000..a861f12 --- /dev/null +++ b/core/inventory/tests/v2/test_events.py @@ -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) diff --git a/core/inventory/tests/v2/test_items.py b/core/inventory/tests/v2/test_items.py new file mode 100644 index 0000000..e13bcba --- /dev/null +++ b/core/inventory/tests/v2/test_items.py @@ -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) diff --git a/core/mail/admin.py b/core/mail/admin.py new file mode 100644 index 0000000..55619c2 --- /dev/null +++ b/core/mail/admin.py @@ -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) diff --git a/core/mail/api_v2.py b/core/mail/api_v2.py new file mode 100644 index 0000000..3de1d3c --- /dev/null +++ b/core/mail/api_v2.py @@ -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 diff --git a/core/mail/migrations/0001_initial.py b/core/mail/migrations/0001_initial.py new file mode 100644 index 0000000..9c792fb --- /dev/null +++ b/core/mail/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/core/mail/models.py b/core/mail/models.py new file mode 100644 index 0000000..50e2367 --- /dev/null +++ b/core/mail/models.py @@ -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) diff --git a/core/mail/protocol.py b/core/mail/protocol.py index d466f51..cb4cf0a 100644 --- a/core/mail/protocol.py +++ b/core/mail/protocol.py @@ -1,16 +1,42 @@ import logging 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 core.settings import MAIL_DOMAIN reply = EmailMessage() - reply["From"] = "noreply@" + MAIL_DOMAIN - reply["To"] = to - reply["Subject"] = subject - reply.set_content(message) + reply["From"] = reply_email.sender + reply["To"] = reply_email.recipient + reply["Subject"] = reply_email.subject + 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 @@ -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) +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: async def handle_RCPT(self, server, session, envelope, address, rcpt_options): from core.settings import MAIL_DOMAIN @@ -31,6 +65,7 @@ class LMTPHandler: async def handle_DATA(self, server, session, envelope): import email log = logging.getLogger('mail.log') + log.setLevel(logging.DEBUG) log.info('Message from %s' % envelope.mail_from) log.info('Message for %s' % envelope.rcpt_tos) log.info('Message data:\n') @@ -54,15 +89,60 @@ class LMTPHandler: header_from = parsed.get('From') 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: log.warning("Header from does not match envelope from") + log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}") if header_to != envelope.rcpt_tos[0]: log.warning("Header to does not match envelope to") + log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}") - 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") + return '250 Message accepted for delivery' except Exception as e: log.error(e) diff --git a/core/mail/tests/v2/test_mails.py b/core/mail/tests/v2/test_mails.py new file mode 100644 index 0000000..c02067d --- /dev/null +++ b/core/mail/tests/v2/test_mails.py @@ -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:\nRCPT TO:\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:\n') +# handler.data_received(b'RCPT TO:\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') diff --git a/core/notify_sessions/admin.py b/core/notify_sessions/admin.py new file mode 100644 index 0000000..5f518e7 --- /dev/null +++ b/core/notify_sessions/admin.py @@ -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) diff --git a/core/notify_sessions/api_v2.py b/core/notify_sessions/api_v2.py new file mode 100644 index 0000000..2e8c2de --- /dev/null +++ b/core/notify_sessions/api_v2.py @@ -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 diff --git a/core/notify_sessions/consumers.py b/core/notify_sessions/consumers.py index ebcbc1e..06413b5 100644 --- a/core/notify_sessions/consumers.py +++ b/core/notify_sessions/consumers.py @@ -1,3 +1,4 @@ +import logging from json.decoder import JSONDecodeError from json import loads as json_loads from json import dumps as json_dumps @@ -6,18 +7,16 @@ from channels.generic.websocket import AsyncWebsocketConsumer class NotifyConsumer(AsyncWebsocketConsumer): + def __init__(self, *args, **kwargs): super().__init__(args, kwargs) + self.log = logging.getLogger("server.log") self.room_group_name = "general" - # self.event_slug = None 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 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() 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) # 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: text_data_json = json_loads(text_data) message = text_data_json["message"] # Send message to room group 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"})) + self.log.error(e) except KeyError as e: await self.send(text_data=json_dumps({"message": "error", "error": f"missing key: {str(e)}"})) + self.log.error(e) except Exception as e: await self.send(text_data=json_dumps({"message": "error", "error": "unknown error"})) + self.log.error(e) raise e # 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"] + name = event["name"] + event_id = event["event_id"] # 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})) diff --git a/core/notify_sessions/migrations/0001_initial.py b/core/notify_sessions/migrations/0001_initial.py new file mode 100644 index 0000000..0889058 --- /dev/null +++ b/core/notify_sessions/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/core/notify_sessions/models.py b/core/notify_sessions/models.py new file mode 100644 index 0000000..d3f248a --- /dev/null +++ b/core/notify_sessions/models.py @@ -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 diff --git a/core/notify_sessions/tests/__init__.py b/core/notify_sessions/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/notify_sessions/tests/test_notify_socket.py b/core/notify_sessions/tests/test_notify_socket.py new file mode 100644 index 0000000..4e971f1 --- /dev/null +++ b/core/notify_sessions/tests/test_notify_socket.py @@ -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") diff --git a/core/tickets/__init__.py b/core/tickets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tickets/admin.py b/core/tickets/admin.py new file mode 100644 index 0000000..d862811 --- /dev/null +++ b/core/tickets/admin.py @@ -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) diff --git a/core/tickets/api_v2.py b/core/tickets/api_v2.py new file mode 100644 index 0000000..60b7150 --- /dev/null +++ b/core/tickets/api_v2.py @@ -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 diff --git a/core/tickets/migrations/0001_initial.py b/core/tickets/migrations/0001_initial.py new file mode 100644 index 0000000..cdfaadc --- /dev/null +++ b/core/tickets/migrations/0001_initial.py @@ -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')), + ], + ), + ] diff --git a/core/tickets/migrations/__init__.py b/core/tickets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tickets/models.py b/core/tickets/models.py new file mode 100644 index 0000000..fb02df0 --- /dev/null +++ b/core/tickets/models.py @@ -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) diff --git a/core/tickets/tests/__init__.py b/core/tickets/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tickets/tests/v2/__init__.py b/core/tickets/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tickets/tests/v2/test_tickets.py b/core/tickets/tests/v2/test_tickets.py new file mode 100644 index 0000000..4a0a802 --- /dev/null +++ b/core/tickets/tests/v2/test_tickets.py @@ -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(), []) + + diff --git a/web/package.json b/web/package.json index 3d2e680..3ac17cf 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,6 @@ "base-64": "^0.1.0", "bootstrap": "^4.3.1", "core-js": "^3.3.2", - "dotenv-webpack": "^1.7.0", "jquery": "^3.4.1", "lodash": "^4.17.15", "luxon": "^1.21.3", @@ -28,12 +27,15 @@ "vue-debounce": "^2.2.0", "vue-router": "^3.1.3", "vuex": "^3.1.2", - "vuex-router-sync": "^5.0.0" + "vuex-router-sync": "^5.0.0", + "yarn": "^1.22.21" }, "devDependencies": { "@vue/cli-plugin-babel": "^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": { "root": true, diff --git a/web/src/App.vue b/web/src/App.vue index 99a78d8..81273aa 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -47,7 +47,8 @@ export default { message: "Connecting to websocket...", 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) => { if (this.socket_toast) { this.removeToast(this.socket_toast.key); @@ -96,7 +97,7 @@ export default { }, }, created: function () { - this.tryConnect(); + //this.tryConnect(); } }; diff --git a/web/src/components/Lightbox.vue b/web/src/components/Lightbox.vue index ac159f4..97c0c46 100644 --- a/web/src/components/Lightbox.vue +++ b/web/src/components/Lightbox.vue @@ -16,15 +16,11 @@ diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index 3aa52d1..9a9a101 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -10,27 +10,25 @@ :class="{ active: event.slug === getEventSlug }" @click="changeEvent(event)">{{ event.slug }} - -
- -
- - -
-
- - - +
- +
+
+ + +
+ +
+