wip
This commit is contained in:
parent
0f911589ca
commit
5bdfe313de
65 changed files with 2219 additions and 77 deletions
15
Dockerfile
Normal file
15
Dockerfile
Normal 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"]
|
21
core/authentication/api_v2.py
Normal file
21
core/authentication/api_v2.py
Normal 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
|
0
core/authentication/tests/__init__.py
Normal file
0
core/authentication/tests/__init__.py
Normal file
0
core/authentication/tests/v2/__init__.py
Normal file
0
core/authentication/tests/v2/__init__.py
Normal file
18
core/authentication/tests/v2/test_users.py
Normal file
18
core/authentication/tests/v2/test_users.py
Normal 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)
|
|
@ -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'
|
||||||
|
|
|
@ -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
24
core/files/api_v2.py
Normal 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
57
core/files/media_v2.py
Normal 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),
|
||||||
|
]
|
0
core/files/tests/__init__.py
Normal file
0
core/files/tests/__init__.py
Normal file
0
core/files/tests/v1/__init__.py
Normal file
0
core/files/tests/v1/__init__.py
Normal file
0
core/files/tests/v2/__init__.py
Normal file
0
core/files/tests/v2/__init__.py
Normal file
49
core/files/tests/v2/test_files.py
Normal file
49
core/files/tests/v2/test_files.py
Normal 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
161
core/inventory/api_v2.py
Normal 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),
|
||||||
|
]
|
0
core/inventory/tests/v1/__init__.py
Normal file
0
core/inventory/tests/v1/__init__.py
Normal file
0
core/inventory/tests/v2/__init__.py
Normal file
0
core/inventory/tests/v2/__init__.py
Normal file
34
core/inventory/tests/v2/test_api.py
Normal file
34
core/inventory/tests/v2/test_api.py
Normal 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(), [])
|
59
core/inventory/tests/v2/test_containers.py
Normal file
59
core/inventory/tests/v2/test_containers.py
Normal 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)
|
56
core/inventory/tests/v2/test_events.py
Normal file
56
core/inventory/tests/v2/test_events.py
Normal 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)
|
133
core/inventory/tests/v2/test_items.py
Normal file
133
core/inventory/tests/v2/test_items.py
Normal 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
17
core/mail/admin.py
Normal 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
22
core/mail/api_v2.py
Normal 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
|
46
core/mail/migrations/0001_initial.py
Normal file
46
core/mail/migrations/0001_initial.py
Normal 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
34
core/mail/models.py
Normal 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)
|
|
@ -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)
|
||||||
|
|
198
core/mail/tests/v2/test_mails.py
Normal file
198
core/mail/tests/v2/test_mails.py
Normal 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')
|
10
core/notify_sessions/admin.py
Normal file
10
core/notify_sessions/admin.py
Normal 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)
|
23
core/notify_sessions/api_v2.py
Normal file
23
core/notify_sessions/api_v2.py
Normal 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
|
|
@ -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}))
|
||||||
|
|
27
core/notify_sessions/migrations/0001_initial.py
Normal file
27
core/notify_sessions/migrations/0001_initial.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
47
core/notify_sessions/models.py
Normal file
47
core/notify_sessions/models.py
Normal 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
|
0
core/notify_sessions/tests/__init__.py
Normal file
0
core/notify_sessions/tests/__init__.py
Normal file
66
core/notify_sessions/tests/test_notify_socket.py
Normal file
66
core/notify_sessions/tests/test_notify_socket.py
Normal 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
0
core/tickets/__init__.py
Normal file
20
core/tickets/admin.py
Normal file
20
core/tickets/admin.py
Normal 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
22
core/tickets/api_v2.py
Normal 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
|
44
core/tickets/migrations/0001_initial.py
Normal file
44
core/tickets/migrations/0001_initial.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
0
core/tickets/migrations/__init__.py
Normal file
0
core/tickets/migrations/__init__.py
Normal file
22
core/tickets/models.py
Normal file
22
core/tickets/models.py
Normal 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)
|
0
core/tickets/tests/__init__.py
Normal file
0
core/tickets/tests/__init__.py
Normal file
0
core/tickets/tests/v2/__init__.py
Normal file
0
core/tickets/tests/v2/__init__.py
Normal file
13
core/tickets/tests/v2/test_tickets.py
Normal file
13
core/tickets/tests/v2/test_tickets.py
Normal 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(), [])
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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"> 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"> 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>
|
336
web/src/components/Timeline.vue
Normal file
336
web/src/components/Timeline.vue
Normal 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>
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
@ -134,5 +134,8 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
* {
|
||||||
|
color: #c8c8c8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -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
49
web/src/views/Ticket.vue
Normal 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
46
web/src/views/Tickets.vue
Normal 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>
|
40
web/src/views/admin/Admin.vue
Normal file
40
web/src/views/admin/Admin.vue
Normal 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>
|
39
web/src/views/admin/Boxes.vue
Normal file
39
web/src/views/admin/Boxes.vue
Normal 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>
|
75
web/src/views/admin/Debug.vue
Normal file
75
web/src/views/admin/Debug.vue
Normal 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>
|
41
web/src/views/admin/Events.vue
Normal file
41
web/src/views/admin/Events.vue
Normal 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>
|
29
web/src/views/admin/Users.vue
Normal file
29
web/src/views/admin/Users.vue
Normal 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
39
web/vue.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue