Compare commits
10 commits
b575926d52
...
906adbe0db
Author | SHA1 | Date | |
---|---|---|---|
906adbe0db | |||
b7a582a86f | |||
b00976e0fa | |||
b8c3bcfa3b | |||
674106a8a5 | |||
81f1f97a6b | |||
125232d1c5 | |||
2c09540a1b | |||
b6ed492382 | |||
6c69948c44 |
30 changed files with 769 additions and 292 deletions
26
TODO.md
26
TODO.md
|
@ -71,8 +71,30 @@
|
||||||
* [ ] concept: purge old tickets
|
* [ ] concept: purge old tickets
|
||||||
* [ ] concept: purge old items
|
* [ ] concept: purge old items
|
||||||
* [ ] concept: auto email stale after x days
|
* [ ] concept: auto email stale after x days
|
||||||
|
* [ ] frontend: reply email field multiline
|
||||||
|
* [ ] concept: customisable autoreply (message, title) + ticket id
|
||||||
|
* [ ] frontend: show from address in issueThread or internal user
|
||||||
|
* [ ] manual ticket creation
|
||||||
|
* [ ] hide citation for incoming mails
|
||||||
|
* [ ] frontend: different icons for send and received mails
|
||||||
|
* [ ] frontend: change ticket status
|
||||||
|
* new mail -> new,
|
||||||
|
* op reply ->
|
||||||
|
* waiting for detail
|
||||||
|
* waiting for address / shipment information
|
||||||
|
* customer reply -> open
|
||||||
|
* needs physical confirmation
|
||||||
|
* needs to be shipped
|
||||||
|
* dhl mail -> closed: shipped
|
||||||
|
* closed: not found
|
||||||
|
* closed: not our problem
|
||||||
|
* closed: timeout (no reply)
|
||||||
|
* closed: duplicate
|
||||||
|
* closed: spam
|
||||||
|
* [ ] concept: split ticket for multiple items
|
||||||
|
* [ ] mail signature
|
||||||
|
* [ ] guru api integration
|
||||||
|
|
||||||
## Priority: TODO
|
## Priority: TODO
|
||||||
|
|
||||||
* send mails from web frontend
|
* manual ticket creation
|
||||||
* login / user management
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ from django.contrib.auth import login
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
from knox.models import AuthToken
|
from knox.models import AuthToken
|
||||||
from knox.views import LoginView as KnoxLoginView
|
from knox.views import LoginView as KnoxLoginView
|
||||||
|
|
||||||
|
@ -14,9 +15,16 @@ from authentication.models import ExtendedUser
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
permissions = serializers.SerializerMethodField()
|
||||||
|
groups = serializers.SlugRelatedField(many=True, read_only=True, slug_field='name')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ExtendedUser
|
model = ExtendedUser
|
||||||
fields = ('id', 'username', 'email', 'first_name', 'last_name')
|
fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
|
||||||
|
read_only_fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
|
||||||
|
|
||||||
|
def get_permissions(self, obj):
|
||||||
|
return list(set(obj.get_permissions()))
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=ExtendedUser)
|
@receiver(post_save, sender=ExtendedUser)
|
||||||
|
@ -30,7 +38,27 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = UserSerializer
|
serializer_class = UserSerializer
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
class GroupSerializer(serializers.ModelSerializer):
|
||||||
|
permissions = serializers.SerializerMethodField()
|
||||||
|
members = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Group
|
||||||
|
fields = ('id', 'name', 'permissions', 'members')
|
||||||
|
|
||||||
|
def get_permissions(self, obj):
|
||||||
|
return ["*:" + p.codename for p in obj.permissions.all()]
|
||||||
|
|
||||||
|
def get_members(self, obj):
|
||||||
|
return [u.username for u in obj.user_set.all()]
|
||||||
|
|
||||||
|
|
||||||
|
class GroupViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Group.objects.all()
|
||||||
|
serializer_class = GroupSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def selfUser(request):
|
def selfUser(request):
|
||||||
serializer = UserSerializer(request.user)
|
serializer = UserSerializer(request.user)
|
||||||
|
@ -79,6 +107,7 @@ class LoginView(KnoxLoginView):
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
router.register(r'users', UserViewSet, basename='users')
|
router.register(r'users', UserViewSet, basename='users')
|
||||||
|
router.register(r'groups', GroupViewSet, basename='groups')
|
||||||
|
|
||||||
urlpatterns = router.urls + [
|
urlpatterns = router.urls + [
|
||||||
path('self/', selfUser),
|
path('self/', selfUser),
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
# Generated by Django 4.2.7 on 2023-12-13 02:29
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('inventory', '0001_initial'),
|
|
||||||
('authentication', '0004_legacy_user'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='eventpermission',
|
|
||||||
name='event',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='inventory.event'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -65,7 +65,6 @@ class PermissionsTestCase(TestCase):
|
||||||
user.event_permissions.create(permission=Permission.objects.get(codename='view_item'), event=Event.objects.get(slug='testevent2'))
|
user.event_permissions.create(permission=Permission.objects.get(codename='view_item'), event=Event.objects.get(slug='testevent2'))
|
||||||
user.event_permissions.create(permission=Permission.objects.get(codename='add_item'), event=Event.objects.get(slug='testevent1'))
|
user.event_permissions.create(permission=Permission.objects.get(codename='add_item'), event=Event.objects.get(slug='testevent1'))
|
||||||
user.save()
|
user.save()
|
||||||
print(user.get_all_permissions())
|
|
||||||
#self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent1')))
|
#self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent1')))
|
||||||
#self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent2')))
|
#self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent2')))
|
||||||
#self.assertFalse(user.has_perm('inventory.add_event', Event.objects.get(slug='testevent1')))
|
#self.assertFalse(user.has_perm('inventory.add_event', Event.objects.get(slug='testevent1')))
|
||||||
|
|
|
@ -1,17 +1,30 @@
|
||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission, Group
|
||||||
|
|
||||||
from knox.models import AuthToken
|
from knox.models import AuthToken
|
||||||
|
|
||||||
from authentication.models import ExtendedUser
|
from authentication.models import ExtendedUser, EventPermission
|
||||||
from core import settings
|
from core import settings
|
||||||
|
from inventory.models import Event
|
||||||
|
|
||||||
|
|
||||||
class UserApiTest(TestCase):
|
class UserApiTest(TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
self.event = Event.objects.create(name='testevent', slug='testevent')
|
||||||
|
self.group1 = Group.objects.create(name='testgroup1')
|
||||||
|
self.group2 = Group.objects.create(name='testgroup2')
|
||||||
|
self.group1.permissions.add(Permission.objects.get(codename='add_item'))
|
||||||
|
self.group1.permissions.add(Permission.objects.get(codename='view_item'))
|
||||||
|
self.group2.permissions.add(Permission.objects.get(codename='view_event'))
|
||||||
|
self.group2.permissions.add(Permission.objects.get(codename='view_item'))
|
||||||
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
|
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
|
||||||
self.user.user_permissions.add(*Permission.objects.all())
|
self.user.user_permissions.add(Permission.objects.get(codename='add_event'))
|
||||||
|
self.user.groups.add(self.group1)
|
||||||
|
self.user.groups.add(self.group2)
|
||||||
|
self.user.save()
|
||||||
|
EventPermission.objects.create(event=self.event, user=self.user,
|
||||||
|
permission=Permission.objects.get(codename='delete_item'))
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.token = AuthToken.objects.create(user=self.user)
|
self.token = AuthToken.objects.create(user=self.user)
|
||||||
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
|
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
|
||||||
|
@ -25,18 +38,28 @@ class UserApiTest(TestCase):
|
||||||
self.assertEqual(response.json()[0]['first_name'], '')
|
self.assertEqual(response.json()[0]['first_name'], '')
|
||||||
self.assertEqual(response.json()[0]['last_name'], '')
|
self.assertEqual(response.json()[0]['last_name'], '')
|
||||||
self.assertEqual(response.json()[0]['id'], 1)
|
self.assertEqual(response.json()[0]['id'], 1)
|
||||||
|
self.assertEqual(response.json()[0]['groups'], [])
|
||||||
self.assertEqual(response.json()[1]['username'], 'testuser')
|
self.assertEqual(response.json()[1]['username'], 'testuser')
|
||||||
self.assertEqual(response.json()[1]['email'], 'test')
|
self.assertEqual(response.json()[1]['email'], 'test')
|
||||||
self.assertEqual(response.json()[1]['first_name'], '')
|
self.assertEqual(response.json()[1]['first_name'], '')
|
||||||
self.assertEqual(response.json()[1]['last_name'], '')
|
self.assertEqual(response.json()[1]['last_name'], '')
|
||||||
|
self.assertEqual(response.json()[1]['id'], 2)
|
||||||
|
self.assertEqual(response.json()[1]['groups'], ['testgroup1', 'testgroup2'])
|
||||||
|
|
||||||
def test_self_user(self):
|
def test_self_user(self):
|
||||||
response = self.client.post('/api/2/self/')
|
response = self.client.get('/api/2/self/')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json()['username'], 'testuser')
|
self.assertEqual(response.json()['username'], 'testuser')
|
||||||
self.assertEqual(response.json()['email'], 'test')
|
self.assertEqual(response.json()['email'], 'test')
|
||||||
self.assertEqual(response.json()['first_name'], '')
|
self.assertEqual(response.json()['first_name'], '')
|
||||||
self.assertEqual(response.json()['last_name'], '')
|
self.assertEqual(response.json()['last_name'], '')
|
||||||
|
permissions = response.json()['permissions']
|
||||||
|
self.assertEqual(len(permissions), 5)
|
||||||
|
self.assertTrue('*:add_item' in permissions)
|
||||||
|
self.assertTrue('*:view_item' in permissions)
|
||||||
|
self.assertTrue('*:view_event' in permissions)
|
||||||
|
self.assertTrue('testevent:delete_item' in permissions)
|
||||||
|
self.assertTrue('*:add_event' in permissions)
|
||||||
|
|
||||||
def test_register_user(self):
|
def test_register_user(self):
|
||||||
anonymous = Client()
|
anonymous = Client()
|
||||||
|
@ -112,3 +135,49 @@ class UserApiTest(TestCase):
|
||||||
content_type='application/json')
|
content_type='application/json')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTrue('token' in response.json())
|
self.assertTrue('token' in response.json())
|
||||||
|
|
||||||
|
|
||||||
|
class GroupApiTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.event = Event.objects.create(name='testevent', slug='testevent')
|
||||||
|
# Admin, Orga, Team, User are created by default
|
||||||
|
self.group1 = Group.objects.create(name='testgroup1')
|
||||||
|
self.group2 = Group.objects.create(name='testgroup2')
|
||||||
|
self.group1.permissions.add(Permission.objects.get(codename='add_item'))
|
||||||
|
self.group1.permissions.add(Permission.objects.get(codename='view_item'))
|
||||||
|
self.group2.permissions.add(Permission.objects.get(codename='view_event'))
|
||||||
|
self.group2.permissions.add(Permission.objects.get(codename='view_item'))
|
||||||
|
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
|
||||||
|
self.user.user_permissions.add(Permission.objects.get(codename='add_event'))
|
||||||
|
self.user.groups.add(self.group1)
|
||||||
|
self.user.groups.add(self.group2)
|
||||||
|
self.user.save()
|
||||||
|
EventPermission.objects.create(event=self.event, user=self.user,
|
||||||
|
permission=Permission.objects.get(codename='delete_item'))
|
||||||
|
self.user.save()
|
||||||
|
self.token = AuthToken.objects.create(user=self.user)
|
||||||
|
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
|
||||||
|
|
||||||
|
def test_groups(self):
|
||||||
|
response = self.client.get('/api/2/groups/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.json()), 6)
|
||||||
|
self.assertEqual(response.json()[0]['name'], 'Admin')
|
||||||
|
self.assertEqual(response.json()[1]['name'], 'Orga')
|
||||||
|
self.assertEqual(response.json()[2]['name'], 'Team')
|
||||||
|
self.assertEqual(response.json()[3]['name'], 'User')
|
||||||
|
self.assertEqual(response.json()[4]['name'], 'testgroup1')
|
||||||
|
self.assertEqual(response.json()[5]['name'], 'testgroup2')
|
||||||
|
|
||||||
|
def test_group(self):
|
||||||
|
response = self.client.get('/api/2/groups/5/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()['name'], 'testgroup1')
|
||||||
|
permissions = response.json()['permissions']
|
||||||
|
self.assertEqual(len(permissions), 2)
|
||||||
|
self.assertTrue('*:add_item' in permissions)
|
||||||
|
self.assertTrue('*:view_item' in permissions)
|
||||||
|
members = response.json()['members']
|
||||||
|
self.assertEqual(len(members), 1)
|
||||||
|
self.assertEqual(members[0], 'testuser')
|
||||||
|
|
|
@ -29,7 +29,7 @@ SECRET_KEY = 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost')]
|
ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost'), 'c3lf.de']
|
||||||
|
|
||||||
MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost')
|
MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost')
|
||||||
|
|
||||||
|
@ -66,7 +66,7 @@ REST_FRAMEWORK = {
|
||||||
'TEST_REQUEST_DEFAULT_FORMAT': 'json',
|
'TEST_REQUEST_DEFAULT_FORMAT': 'json',
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',),
|
'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',),
|
||||||
'DEFAULT_PERMISSION_CLASSES': (
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.DjangoModelPermissions'),
|
'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.DjangoModelPermissions'),
|
||||||
}
|
}
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'authentication.ExtendedUser'
|
AUTH_USER_MODEL = 'authentication.ExtendedUser'
|
||||||
|
|
|
@ -97,7 +97,7 @@ class ItemSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@permission_required('inventory.view_item', raise_exception=True)
|
@permission_required('view_item', raise_exception=True)
|
||||||
def search_items(request, event_slug, query):
|
def search_items(request, event_slug, query):
|
||||||
try:
|
try:
|
||||||
event = Event.objects.get(slug=event_slug)
|
event = Event.objects.get(slug=event_slug)
|
||||||
|
@ -117,11 +117,11 @@ def item(request, event_slug):
|
||||||
try:
|
try:
|
||||||
event = Event.objects.get(slug=event_slug)
|
event = Event.objects.get(slug=event_slug)
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
if not request.user.has_event_perm(event, 'inventory.view_item'):
|
if not request.user.has_event_perm(event, 'view_item'):
|
||||||
return Response(status=403)
|
return Response(status=403)
|
||||||
return Response(ItemSerializer(Item.objects.filter(event=event), many=True).data)
|
return Response(ItemSerializer(Item.objects.filter(event=event), many=True).data)
|
||||||
elif request.method == 'POST':
|
elif request.method == 'POST':
|
||||||
if not request.user.has_event_perm(event, 'inventory.add_item'):
|
if not request.user.has_event_perm(event, 'add_item'):
|
||||||
return Response(status=403)
|
return Response(status=403)
|
||||||
validated_data = ItemSerializer(data=request.data)
|
validated_data = ItemSerializer(data=request.data)
|
||||||
if validated_data.is_valid():
|
if validated_data.is_valid():
|
||||||
|
@ -131,25 +131,34 @@ def item(request, event_slug):
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET', 'PUT', 'DELETE'])
|
@api_view(['GET', 'PUT', 'DELETE', 'PATCH'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def item_by_id(request, event_slug, id):
|
def item_by_id(request, event_slug, id):
|
||||||
try:
|
try:
|
||||||
event = Event.objects.get(slug=event_slug)
|
event = Event.objects.get(slug=event_slug)
|
||||||
item = Item.objects.get(event=event, uid=id)
|
item = Item.objects.get(event=event, uid=id)
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
if not request.user.has_event_perm(event, 'inventory.view_item'):
|
if not request.user.has_event_perm(event, 'view_item'):
|
||||||
return Response(status=403)
|
return Response(status=403)
|
||||||
return Response(ItemSerializer(item).data)
|
return Response(ItemSerializer(item).data)
|
||||||
elif request.method == 'PUT':
|
elif request.method == 'PUT':
|
||||||
if not request.user.has_event_perm(event, 'inventory.change_item'):
|
if not request.user.has_event_perm(event, 'change_item'):
|
||||||
return Response(status=403)
|
return Response(status=403)
|
||||||
validated_data = ItemSerializer(item, data=request.data)
|
validated_data = ItemSerializer(item, data=request.data)
|
||||||
if validated_data.is_valid():
|
if validated_data.is_valid():
|
||||||
validated_data.save()
|
validated_data.save()
|
||||||
return Response(validated_data.data)
|
return Response(validated_data.data)
|
||||||
|
return Response(validated_data.errors, status=400)
|
||||||
|
elif request.method == 'PATCH':
|
||||||
|
if not request.user.has_event_perm(event, 'change_item'):
|
||||||
|
return Response(status=403)
|
||||||
|
validated_data = ItemSerializer(item, data=request.data, partial=True)
|
||||||
|
if validated_data.is_valid():
|
||||||
|
validated_data.save()
|
||||||
|
return Response(validated_data.data)
|
||||||
|
return Response(validated_data.errors, status=400)
|
||||||
elif request.method == 'DELETE':
|
elif request.method == 'DELETE':
|
||||||
if not request.user.has_event_perm(event, 'inventory.delete_item'):
|
if not request.user.has_event_perm(event, 'delete_item'):
|
||||||
return Response(status=403)
|
return Response(status=403)
|
||||||
item.delete()
|
item.delete()
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import re_path
|
||||||
from django.contrib.auth.decorators import permission_required
|
from django.contrib.auth.decorators import permission_required
|
||||||
from rest_framework import routers, viewsets, serializers, status
|
from rest_framework import routers, viewsets, serializers, status
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
from core.settings import MAIL_DOMAIN
|
from core.settings import MAIL_DOMAIN
|
||||||
from mail.models import Email
|
from mail.models import Email
|
||||||
from mail.protocol import send_smtp, make_reply, collect_references
|
from mail.protocol import send_smtp, make_reply, collect_references
|
||||||
|
from notify_sessions.models import SystemEvent
|
||||||
from tickets.models import IssueThread
|
from tickets.models import IssueThread
|
||||||
|
|
||||||
|
|
||||||
|
@ -80,9 +82,44 @@ def reply(request, pk):
|
||||||
return Response({'status': 'ok'}, status=status.HTTP_201_CREATED)
|
return Response({'status': 'ok'}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@permission_required('tickets.add_issuethread_manual', raise_exception=True)
|
||||||
|
def manual_ticket(request):
|
||||||
|
if 'name' not in request.data:
|
||||||
|
return Response({'status': 'error', 'message': 'missing name'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if 'sender' not in request.data:
|
||||||
|
return Response({'status': 'error', 'message': 'missing sender'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if 'recipient' not in request.data:
|
||||||
|
return Response({'status': 'error', 'message': 'missing recipient'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if 'body' not in request.data:
|
||||||
|
return Response({'status': 'error', 'message': 'missing body'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
issue = IssueThread.objects.create(
|
||||||
|
name=request.data['name'],
|
||||||
|
manually_created=True,
|
||||||
|
)
|
||||||
|
email = Email.objects.create(
|
||||||
|
issue_thread=issue,
|
||||||
|
sender=request.data['sender'],
|
||||||
|
recipient=request.data['recipient'],
|
||||||
|
subject=request.data['name'],
|
||||||
|
body=request.data['body'],
|
||||||
|
)
|
||||||
|
systemevent = SystemEvent.objects.create(type='email received', reference=email.id)
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
||||||
|
"message": "email received"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
router.register(r'tickets', IssueViewSet, basename='issues')
|
router.register(r'tickets', IssueViewSet, basename='issues')
|
||||||
|
|
||||||
urlpatterns = router.urls + [
|
urlpatterns = ([
|
||||||
path('tickets/<int:pk>/reply/', reply, name='reply'),
|
re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),
|
||||||
]
|
re_path(r'^tickets/manual/$', manual_ticket, name='manual_ticket'),
|
||||||
|
] + router.urls)
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 4.2.7 on 2023-12-22 20:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tickets', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='issuethread',
|
||||||
|
options={'permissions': [('send_mail', 'Can send mail'), ('add_issuethread_manual', 'Can add issue thread manually')]},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='issuethread',
|
||||||
|
name='manually_created',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
|
@ -10,10 +10,12 @@ class IssueThread(SoftDeleteModel):
|
||||||
state = models.CharField(max_length=255, default='new')
|
state = models.CharField(max_length=255, default='new')
|
||||||
assigned_to = models.CharField(max_length=255, null=True)
|
assigned_to = models.CharField(max_length=255, null=True)
|
||||||
last_activity = models.DateTimeField(auto_now=True)
|
last_activity = models.DateTimeField(auto_now=True)
|
||||||
|
manually_created = models.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
permissions = [
|
permissions = [
|
||||||
('send_mail', 'Can send mail'),
|
('send_mail', 'Can send mail'),
|
||||||
|
('add_issuethread_manual', 'Can add issue thread manually'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ from django.test import TestCase, Client
|
||||||
from authentication.models import ExtendedUser
|
from authentication.models import ExtendedUser
|
||||||
from mail.models import Email
|
from mail.models import Email
|
||||||
from tickets.models import IssueThread, StateChange, Comment
|
from tickets.models import IssueThread, StateChange, Comment
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import Permission
|
||||||
from knox.models import AuthToken
|
from knox.models import AuthToken
|
||||||
|
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ class IssueApiTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
|
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
|
||||||
|
self.user.user_permissions.add(*Permission.objects.all())
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.token = AuthToken.objects.create(user=self.user)
|
self.token = AuthToken.objects.create(user=self.user)
|
||||||
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
|
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
|
||||||
|
@ -91,3 +92,19 @@ class IssueApiTest(TestCase):
|
||||||
self.assertEqual(response.json()[0]['timeline'][3]['comment'], 'test')
|
self.assertEqual(response.json()[0]['timeline'][3]['comment'], 'test')
|
||||||
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
|
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
|
||||||
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
||||||
|
|
||||||
|
def test_manual_creation(self):
|
||||||
|
response = self.client.post('/api/2/tickets/manual/', {'name': 'test issue', 'sender': 'test',
|
||||||
|
'recipient': 'test', 'body': 'test'})
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(response.json()['state'], 'new')
|
||||||
|
self.assertEqual(response.json()['name'], 'test issue')
|
||||||
|
self.assertEqual(response.json()['assigned_to'], None)
|
||||||
|
timeline = response.json()['timeline']
|
||||||
|
self.assertEqual(len(timeline), 1)
|
||||||
|
self.assertEqual(timeline[0]['type'], 'mail')
|
||||||
|
self.assertEqual(timeline[0]['sender'], 'test')
|
||||||
|
self.assertEqual(timeline[0]['recipient'], 'test')
|
||||||
|
self.assertEqual(timeline[0]['subject'], 'test issue')
|
||||||
|
self.assertEqual(timeline[0]['body'], 'test')
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<AddItemModal v-if="addModalOpen && isLoggedIn" @close="closeAddModal()" isModal="true"/>
|
<AddItemModal v-if="addItemModalOpen && isLoggedIn" @close="closeAddItemModal()" isModal="true"/>
|
||||||
<Navbar v-if="isLoggedIn" @addClicked="openAddModal()"/>
|
<AddTicketModal v-if="addTicketModalOpen && isLoggedIn" @close="closeAddTicketModal()" isModal="true"/>
|
||||||
|
<Navbar v-if="isLoggedIn" @addItemClicked="openAddItemModal()" @addTicketClicked="openAddTicketModal()"/>
|
||||||
<router-view/>
|
<router-view/>
|
||||||
<div aria-live="polite" aria-atomic="true" v-if="isLoggedIn"
|
<div aria-live="polite" aria-atomic="true" v-if="isLoggedIn"
|
||||||
class="d-flex justify-content-end align-items-start fixed-top mx-1 my-5 py-3"
|
class="d-flex justify-content-end align-items-start fixed-top mx-1 my-5 py-3"
|
||||||
style="min-height: 200px; z-index: 100000; pointer-events: none">
|
style="min-height: 200px; z-index: 100000; pointer-events: none">
|
||||||
<Toast v-for="toast in toasts" :key="toast" :title="toast.title" :message="toast.message"
|
<Toast v-for="(toast , index) in toasts" :key="index" :title="toast.title" :message="toast.message"
|
||||||
:color="toast.color"
|
:color="toast.color"
|
||||||
@close="removeToast(toast.key)" style="pointer-events: auto"/>
|
@close="removeToast(toast.key)" style="pointer-events: auto"/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -18,27 +19,35 @@ import Navbar from '@/components/Navbar';
|
||||||
import AddItemModal from '@/components/AddItemModal';
|
import AddItemModal from '@/components/AddItemModal';
|
||||||
import Toast from './components/Toast';
|
import Toast from './components/Toast';
|
||||||
import {mapState, mapMutations, mapActions, mapGetters} from 'vuex';
|
import {mapState, mapMutations, mapActions, mapGetters} from 'vuex';
|
||||||
|
import AddTicketModal from "@/components/AddTicketModal.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'app',
|
name: 'app',
|
||||||
components: {Toast, Navbar, AddItemModal},
|
components: {Toast, Navbar, AddItemModal, AddTicketModal},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['loadedItems', 'layout', 'toasts']),
|
...mapState(['loadedItems', 'layout', 'toasts']),
|
||||||
...mapGetters(['isLoggedIn']),
|
...mapGetters(['isLoggedIn']),
|
||||||
},
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
addModalOpen: false,
|
addItemModalOpen: false,
|
||||||
|
addTicketModalOpen: false,
|
||||||
notify_socket: null,
|
notify_socket: null,
|
||||||
socket_toast: null,
|
socket_toast: null,
|
||||||
}),
|
}),
|
||||||
methods: {
|
methods: {
|
||||||
...mapMutations(['removeToast', 'createToast']),
|
...mapMutations(['removeToast', 'createToast']),
|
||||||
...mapActions(['loadEventItems', 'loadTickets']),
|
...mapActions(['loadEventItems', 'loadTickets']),
|
||||||
openAddModal() {
|
openAddItemModal() {
|
||||||
this.addModalOpen = true;
|
this.addItemModalOpen = true;
|
||||||
},
|
},
|
||||||
closeAddModal() {
|
openAddTicketModal() {
|
||||||
this.addModalOpen = false;
|
this.addTicketModalOpen = true;
|
||||||
|
},
|
||||||
|
closeAddItemModal() {
|
||||||
|
this.addItemModalOpen = false;
|
||||||
|
},
|
||||||
|
closeAddTicketModal() {
|
||||||
|
this.addTicketModalOpen = false;
|
||||||
},
|
},
|
||||||
tryConnect() {
|
tryConnect() {
|
||||||
if (!this.notify_socket || this.notify_socket.readyState !== WebSocket.OPEN) {
|
if (!this.notify_socket || this.notify_socket.readyState !== WebSocket.OPEN) {
|
||||||
|
|
49
web/src/components/AddTicketModal.vue
Normal file
49
web/src/components/AddTicketModal.vue
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<Modal v-if="isModal" title="Add Item" @close="$emit('close')">
|
||||||
|
<template #body>
|
||||||
|
<div>
|
||||||
|
<input type="text" class="form-control" placeholder="Sender" v-model="ticket.sender">
|
||||||
|
<input type="text" class="form-control" placeholder="Title" v-model="ticket.title">
|
||||||
|
<textarea class="form-control" placeholder="Message" v-model="ticket.message"></textarea>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #buttons>
|
||||||
|
<button type="button" class="btn btn-secondary" @click="$emit('close')">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-success" @click="saveNewTicket()">Save new Item</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import EditItem from '@/components/EditItem';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AddTicketModal',
|
||||||
|
components: {Modal, EditItem},
|
||||||
|
props: ['isModal'],
|
||||||
|
data: () => ({
|
||||||
|
ticket: {
|
||||||
|
sender: '',
|
||||||
|
message: '',
|
||||||
|
title: '',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
created() {
|
||||||
|
this.ticket = {box: this.$store.state.lastUsed.box || '', cid: this.$store.state.lastUsed.cid || ''};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
saveNewTicket() {
|
||||||
|
this.$store.dispatch('postManualTicket', this.ticket).then(() => {
|
||||||
|
this.$emit('close');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
45
web/src/components/Matrix2D.vue
Normal file
45
web/src/components/Matrix2D.vue
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
<template>
|
||||||
|
<table class="table table-striped table-dark" style="table-layout: fixed">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col"></th>
|
||||||
|
<th scope="col" v-for="(column, index) in columns" :key="index">
|
||||||
|
{{ column }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, i) in rows" :key="i">
|
||||||
|
<td>{{ row }}</td>
|
||||||
|
<td v-for="(column, j) in columns" :key="j">
|
||||||
|
<font-awesome-icon v-if="items[j][i]" icon="check" class="text-success"/>
|
||||||
|
<font-awesome-icon v-else icon="times" class="text-danger"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Matrix2D',
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
61
web/src/components/Matrix3D.vue
Normal file
61
web/src/components/Matrix3D.vue
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
<template>
|
||||||
|
<table class="table table-striped table-dark" style="table-layout: fixed">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col"></th>
|
||||||
|
<th scope="col" v-for="(column, index) in columns" :key="index">
|
||||||
|
{{ column }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, i) in rows" :key="i">
|
||||||
|
<td>{{ row }}</td>
|
||||||
|
<td v-for="(column, j) in columns" :key="j">
|
||||||
|
<font-awesome-icon v-if="triState(items[j][i]) === 'on'" icon="check" class="text-success"/>
|
||||||
|
<font-awesome-icon v-else-if="triState(items[j][i]) === 'off'" icon="times" class="text-danger"/>
|
||||||
|
<font-awesome-icon v-else icon="minus" class="text-warning"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Matrix3D',
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
foldedDimension: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
triState(list) {
|
||||||
|
if(list.every(e => e === true)) {
|
||||||
|
return 'on';
|
||||||
|
} else if(list.every(e => e === false)) {
|
||||||
|
return 'off';
|
||||||
|
} else {
|
||||||
|
return 'partial';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
|
@ -11,25 +11,25 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav nav-tabs flex-nowrap">
|
<ul class="nav nav-tabs flex-nowrap">
|
||||||
<li class="nav-item">
|
<li class="nav-item" v-if="checkPermission(getEventSlug, 'view_item')">
|
||||||
<router-link :to="{name: 'items', params: {event: getEventSlug}}"
|
<router-link :to="{name: 'items', params: {event: getEventSlug}}"
|
||||||
:class="['nav-link', { active: getActiveView === 'items' || getActiveView === 'item' }]">
|
:class="['nav-link', { active: isItemView() }]">
|
||||||
Items
|
Items
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" v-if="checkRole('team')">
|
<li class="nav-item" v-if="checkPermission(getEventSlug, 'view_issuethread')">
|
||||||
<router-link :to="{name: 'tickets', params: {event: getEventSlug}}"
|
<router-link :to="{name: 'tickets', params: {event: getEventSlug}}"
|
||||||
:class="['nav-link', { active: getActiveView === 'tickets' || getActiveView === 'ticket' }]">
|
:class="['nav-link', { active: isTicketView() }]">
|
||||||
Tickets
|
Tickets
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" v-if="checkRole('admin')">
|
<li class="nav-item" v-if="checkPermission(getEventSlug, 'delete_event')">
|
||||||
<router-link :to="{name: 'admin'}" :class="['nav-link', { active: getActiveView === 'admin' }]">
|
<router-link :to="{name: 'admin'}" class="nav-link" active-class="active">
|
||||||
Admin
|
Admin
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<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 mr-1" v-if="hasPermissions">
|
||||||
<input
|
<input
|
||||||
class="form-control w-100"
|
class="form-control w-100"
|
||||||
type="search"
|
type="search"
|
||||||
|
@ -39,8 +39,8 @@
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
<div class="custom-control-inline mr-1">
|
<div class="custom-control-inline mr-1" v-if="hasPermissions">
|
||||||
<div class="btn-group btn-group-toggle mx-1">
|
<div class="btn-group btn-group-toggle mr-1" v-if="isItemView()">
|
||||||
<button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')">
|
<button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')">
|
||||||
<font-awesome-icon icon="th"/>
|
<font-awesome-icon icon="th"/>
|
||||||
</button>
|
</button>
|
||||||
|
@ -48,9 +48,15 @@
|
||||||
<font-awesome-icon icon="list"/>
|
<font-awesome-icon icon="list"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn text-nowrap btn-success" @click="$emit('addClicked')">
|
<button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addItemClicked')"
|
||||||
|
v-if="isItemView()">
|
||||||
<font-awesome-icon icon="plus"/>
|
<font-awesome-icon icon="plus"/>
|
||||||
<span class="d-none d-md-inline"> Add</span>
|
<span class="d-none d-md-inline"> Add Item</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addTicketClicked')"
|
||||||
|
v-if="isTicketView()">
|
||||||
|
<font-awesome-icon icon="plus"/>
|
||||||
|
<span class="d-none d-md-inline"> Add Ticket</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
|
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
|
||||||
|
@ -77,9 +83,13 @@
|
||||||
{{ link.title }}
|
{{ link.title }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-nowrap" href="/logout" @click.prevent="logout()">
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -99,14 +109,21 @@ export default {
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['events', 'activeEvent', 'layout']),
|
...mapState(['events', 'layout']),
|
||||||
...mapGetters(['getEventSlug', 'getActiveView', "checkRole"]),
|
...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions"]),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['changeEvent', 'changeView', 'searchEventItems']),
|
...mapActions(['changeEvent', 'changeView', 'searchEventItems']),
|
||||||
...mapMutations(['setLayout']),
|
...mapMutations(['setLayout', 'logout']),
|
||||||
navigateTo(link) {
|
navigateTo(link) {
|
||||||
this.$router.push(link);
|
if (this.$router.currentRoute.path !== link)
|
||||||
|
this.$router.push(link);
|
||||||
|
},
|
||||||
|
isItemView() {
|
||||||
|
return this.getActiveView === 'items' || this.getActiveView === 'item';
|
||||||
|
},
|
||||||
|
isTicketView() {
|
||||||
|
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,8 +25,8 @@
|
||||||
</span>
|
</span>
|
||||||
<div class="new-comment">
|
<div class="new-comment">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" placeholder="Add a comment..." v-model="newMail">
|
<input type="text" placeholder="reply mail..." v-model="newMail">
|
||||||
<button class="btn" @click="sendMail">
|
<button class="btn btn-primary" @click="sendMail">
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -13,13 +13,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p><span v-html="body"></span></p>
|
<p><span v-html="body"></span></p>
|
||||||
<button class="button">👏 2</button>
|
<!--button class="button">👏 2</button>
|
||||||
<button class="button | square">
|
<button class="button | square">
|
||||||
<font-awesome-icon icon="user"/>
|
<font-awesome-icon icon="user"/>
|
||||||
</button>
|
</button-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="show-replies">
|
<!--button class="show-replies">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-arrow-forward"
|
<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"
|
width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
<font-awesome-icon icon="user"/>
|
<font-awesome-icon icon="user"/>
|
||||||
</i>
|
</i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button-->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -34,14 +34,17 @@ import {
|
||||||
faComment,
|
faComment,
|
||||||
faEnvelope,
|
faEnvelope,
|
||||||
faUser,
|
faUser,
|
||||||
faComments
|
faComments,
|
||||||
|
faArchive,
|
||||||
|
faMinus,
|
||||||
} 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,
|
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);
|
faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave, faEye, faComment, faUser, faComments, faEnvelope,
|
||||||
|
faArchive, faMinus);
|
||||||
Vue.component('font-awesome-icon', FontAwesomeIcon);
|
Vue.component('font-awesome-icon', FontAwesomeIcon);
|
||||||
|
|
||||||
//import VueQRCodeComponent from 'vue-qrcode-component'
|
//import VueQRCodeComponent from 'vue-qrcode-component'
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import Items from './views/Items';
|
import Items from './views/Items';
|
||||||
import Boxes from './views/Boxes';
|
import Boxes from './views/Boxes';
|
||||||
import Files from './views/Files';
|
import Files from './views/Files';
|
||||||
import Events from './views/Events';
|
|
||||||
import Error from './views/Error';
|
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';
|
||||||
|
@ -13,25 +12,51 @@ import Tickets from "@/views/Tickets.vue";
|
||||||
import Ticket from "@/views/Ticket.vue";
|
import Ticket from "@/views/Ticket.vue";
|
||||||
import Admin from "@/views/admin/Admin.vue";
|
import Admin from "@/views/admin/Admin.vue";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
|
import Empty from "@/views/Empty.vue";
|
||||||
|
import Events from "@/views/admin/Events.vue";
|
||||||
|
import AccessControl from "@/views/admin/AccessControl.vue";
|
||||||
|
|
||||||
Vue.use(VueRouter);
|
Vue.use(VueRouter);
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{path: '/', redirect: '/Camp23/items', meta: {requiresAuth: false}},
|
{path: '/', redirect: '/Camp23/items', meta: {requiresAuth: false}},
|
||||||
{path: '/login', name: 'login', component: Login, meta: {requiresAuth: false}},
|
{path: '/login/', name: 'login', component: Login, meta: {requiresAuth: false}},
|
||||||
{path: '/register', name: 'register', component: Register, meta: {requiresAuth: false}},
|
{path: '/register/', name: 'register', component: Register, meta: {requiresAuth: false}},
|
||||||
{path: '/howto', name: 'howto', component: HowTo, meta: {requiresAuth: true}},
|
{path: '/howto/', name: 'howto', component: HowTo, meta: {requiresAuth: true}},
|
||||||
{path: '/:event/boxes', name: 'boxes', component: Boxes, meta: {requiresAuth: true}},
|
{path: '/:event/items/', name: 'items', component: Items, meta:
|
||||||
{path: '/:event/items', name: 'items', component: Items, meta: {requiresAuth: true}},
|
{requiresAuth: true, requiresPermission: 'view_item'}},
|
||||||
{path: '/:event/box/:uid', name: 'box', component: Boxes, meta: {requiresAuth: true}},
|
{path: '/:event/item/:uid/', name: 'item', component: Items, meta:
|
||||||
{path: '/:event/item/:uid', name: 'item', component: Items, meta: {requiresAuth: true}},
|
{requiresAuth: true, requiresPermission: 'view_item'}},
|
||||||
{path: '/:event/tickets', name: 'tickets', component: Tickets, meta: {requiresAuth: true}},
|
{path: '/:event/boxes/', name: 'boxes', component: Boxes, meta:
|
||||||
{path: '/:event/ticket/:id', name: 'ticket', component: Ticket, meta: {requiresAuth: true}},
|
{requiresAuth: true, requiresPermission: 'view_container'}},
|
||||||
{path: '/admin', name: 'admin', component: Admin, meta: {requiresAuth: true}},
|
{path: '/:event/box/:uid/', name: 'box', component: Boxes, meta:
|
||||||
{path: '/admin/files', name: 'files', component: Files, meta: {requiresAuth: true}},
|
{requiresAuth: true, requiresPermission: 'view_container'}},
|
||||||
{path: '/admin/events', name: 'events', component: Events, meta: {requiresAuth: true}},
|
{path: '/:event/tickets/', name: 'tickets', component: Tickets, meta:
|
||||||
{path: '/admin/debug', name: 'debug', component: Debug, meta: {requiresAuth: true}},
|
{requiresAuth: true, requiresPermission: 'view_issuethread'}},
|
||||||
{path: '/admin/users', name: 'users', component: Events, meta: {requiresAuth: true}},
|
{path: '/:event/ticket/:id/', name: 'ticket', component: Ticket, meta:
|
||||||
|
{requiresAuth: true, requiresPermission: 'view_issuethread'}},
|
||||||
|
{path: '/admin/', component: Admin, meta:
|
||||||
|
{requiresAuth: true, requiresPermission: 'delete_event'},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'files/', name: 'files', component: Files, meta:
|
||||||
|
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'events/', name: 'events', component: Events, meta:
|
||||||
|
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '', name: 'admin', component: Debug, meta:
|
||||||
|
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'users/', name: 'users', component: AccessControl, meta:
|
||||||
|
{requiresAuth: true, requiresPermission: 'delete_event'}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}},
|
||||||
{path: '*', component: Error},
|
{path: '*', component: Error},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -57,11 +82,16 @@ const router = new VueRouter({
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
if (to.meta.requiresAuth && !store.getters.isLoggedIn) {
|
if (to.meta.requiresAuth && !store.getters.isLoggedIn) {
|
||||||
//console.log("Not logged in, redirecting to login page")
|
console.log("Not logged in, redirecting to login page")
|
||||||
next({
|
next({
|
||||||
name: 'login',
|
name: 'login',
|
||||||
query: {redirect: to.fullPath},
|
query: {redirect: to.fullPath},
|
||||||
})
|
})
|
||||||
|
} else if (to.meta.requiresPermission && !store.getters.checkPermission(to.params.event || "*", to.meta.requiresPermission)) {
|
||||||
|
console.log("Not enough permissions, redirecting to empty page")
|
||||||
|
next({
|
||||||
|
path: '/user',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,14 +63,18 @@ const store = new Vuex.Store({
|
||||||
events: [],
|
events: [],
|
||||||
layout: 'cards',
|
layout: 'cards',
|
||||||
loadedItems: [],
|
loadedItems: [],
|
||||||
|
itemCache: {},
|
||||||
loadedBoxes: [],
|
loadedBoxes: [],
|
||||||
toasts: [],
|
toasts: [],
|
||||||
tickets: [],
|
tickets: [],
|
||||||
userRoles: ['admin', 'team', 'orga', 'user'],
|
users: [],
|
||||||
|
groups: [],
|
||||||
lastEvent: localStorage.getItem('lf_lastEvent') || '36C3',
|
lastEvent: localStorage.getItem('lf_lastEvent') || '36C3',
|
||||||
lastUsed: JSON.parse(localStorage.getItem('lf_lastUsed') || '{}'),
|
lastUsed: JSON.parse(localStorage.getItem('lf_lastUsed') || '{}'),
|
||||||
remember: false,
|
remember: false,
|
||||||
user: null,
|
user: null,
|
||||||
|
password: null,
|
||||||
|
userPermissions: [],
|
||||||
token: null,
|
token: null,
|
||||||
token_expiry: null,
|
token_expiry: null,
|
||||||
local_loaded: false,
|
local_loaded: false,
|
||||||
|
@ -80,11 +84,13 @@ const store = new Vuex.Store({
|
||||||
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),
|
checkPermission: state => (event, perm) => state.userPermissions.includes(`${event}:${perm}`) || state.userPermissions.includes(`*:${perm}`),
|
||||||
|
hasPermissions: state => state.userPermissions.length > 0,
|
||||||
isLoggedIn(state) {
|
isLoggedIn(state) {
|
||||||
if (!state.local_loaded) {
|
if (!state.local_loaded) {
|
||||||
state.remember = localStorage.getItem('remember') === 'true'
|
state.remember = localStorage.getItem('remember') === 'true'
|
||||||
state.user = localStorage.getItem('user')
|
state.user = localStorage.getItem('user')
|
||||||
|
state.userPermissions = JSON.parse(localStorage.getItem('permissions') || '[]')
|
||||||
state.token = localStorage.getItem('token')
|
state.token = localStorage.getItem('token')
|
||||||
state.token_expiry = localStorage.getItem('token_expiry')
|
state.token_expiry = localStorage.getItem('token_expiry')
|
||||||
state.local_loaded = true
|
state.local_loaded = true
|
||||||
|
@ -111,6 +117,9 @@ const store = new Vuex.Store({
|
||||||
replaceLoadedItems(state, newItems) {
|
replaceLoadedItems(state, newItems) {
|
||||||
state.loadedItems = newItems;
|
state.loadedItems = newItems;
|
||||||
},
|
},
|
||||||
|
setItemCache(state, {slug, items}) {
|
||||||
|
state.itemCache[slug] = items;
|
||||||
|
},
|
||||||
setLayout(state, layout) {
|
setLayout(state, layout) {
|
||||||
state.layout = layout;
|
state.layout = layout;
|
||||||
},
|
},
|
||||||
|
@ -130,6 +139,12 @@ const store = new Vuex.Store({
|
||||||
replaceTickets(state, tickets) {
|
replaceTickets(state, tickets) {
|
||||||
state.tickets = tickets;
|
state.tickets = tickets;
|
||||||
},
|
},
|
||||||
|
replaceUsers(state, users) {
|
||||||
|
state.users = users;
|
||||||
|
},
|
||||||
|
replaceGroups(state, groups) {
|
||||||
|
state.groups = groups;
|
||||||
|
},
|
||||||
updateTicket(state, updatedTicket) {
|
updateTicket(state, updatedTicket) {
|
||||||
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
|
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
|
||||||
Object.assign(ticket, updatedTicket);
|
Object.assign(ticket, updatedTicket);
|
||||||
|
@ -152,6 +167,14 @@ const store = new Vuex.Store({
|
||||||
if (user)
|
if (user)
|
||||||
localStorage.setItem('user', user);
|
localStorage.setItem('user', user);
|
||||||
},
|
},
|
||||||
|
setPassword(state, password) {
|
||||||
|
state.password = password;
|
||||||
|
},
|
||||||
|
setPermissions(state, permissions) {
|
||||||
|
state.userPermissions = permissions;
|
||||||
|
if (permissions)
|
||||||
|
localStorage.setItem('permissions', JSON.stringify(permissions));
|
||||||
|
},
|
||||||
setToken(state, {token, expiry}) {
|
setToken(state, {token, expiry}) {
|
||||||
state.token = token;
|
state.token = token;
|
||||||
state.token_expiry = expiry;
|
state.token_expiry = expiry;
|
||||||
|
@ -163,9 +186,11 @@ const store = new Vuex.Store({
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.token = null;
|
state.token = null;
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
|
localStorage.removeItem('permissions');
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('token_expiry');
|
localStorage.removeItem('token_expiry');
|
||||||
router.push('/login');
|
if (router.currentRoute.name !== 'login')
|
||||||
|
router.push('/login');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
@ -181,6 +206,7 @@ const store = new Vuex.Store({
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
commit('setToken', data);
|
commit('setToken', data);
|
||||||
commit('setUser', username);
|
commit('setUser', username);
|
||||||
|
commit('setPassword', password);
|
||||||
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
|
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
|
||||||
dispatch('afterLogin');
|
dispatch('afterLogin');
|
||||||
return true;
|
return true;
|
||||||
|
@ -194,39 +220,46 @@ const store = new Vuex.Store({
|
||||||
},
|
},
|
||||||
async reloadToken({commit, state}) {
|
async reloadToken({commit, state}) {
|
||||||
try {
|
try {
|
||||||
const data = await fetch('/api/2/login/', {
|
if (data.password) {
|
||||||
method: 'POST',
|
const data = await fetch('/api/2/login/', {
|
||||||
headers: {'Content-Type': 'application/json'},
|
method: 'POST',
|
||||||
body: JSON.stringify({username: state.user, password: state.token}),
|
headers: {'Content-Type': 'application/json'},
|
||||||
credentials: 'omit'
|
body: JSON.stringify({username: state.user, password: state.password}),
|
||||||
}).then(r => r.json())
|
credentials: 'omit'
|
||||||
if (data.token) {
|
}).then(r => r.json())
|
||||||
commit('setToken', data);
|
if (data.token) {
|
||||||
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
|
commit('setToken', data);
|
||||||
return true;
|
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
//credentials failed, logout
|
//credentials failed, logout
|
||||||
store.commit('logout');
|
store.commit('logout');
|
||||||
router.push('/login');
|
|
||||||
},
|
},
|
||||||
async afterLogin({dispatch}) {
|
async afterLogin({dispatch}) {
|
||||||
await dispatch('loadBoxes');
|
const boxes = dispatch('loadBoxes');
|
||||||
await dispatch('loadEventItems');
|
const items = dispatch('loadEventItems');
|
||||||
await dispatch('loadTickets');
|
const tickets = dispatch('loadTickets');
|
||||||
|
const user = dispatch('loadUserInfo');
|
||||||
|
await Promise.all([boxes, items, tickets, user]);
|
||||||
},
|
},
|
||||||
async fetchImage({state}, url) {
|
async fetchImage({state}, url) {
|
||||||
return await fetch(url, {headers: {'Authorization': `Token ${state.token}`}});
|
return await fetch(url, {headers: {'Authorization': `Token ${state.token}`}});
|
||||||
},
|
},
|
||||||
|
async loadUserInfo({commit}) {
|
||||||
|
const {data} = await axios.get('/2/self/');
|
||||||
|
commit('setUser', data.username);
|
||||||
|
commit('setPermissions', data.permissions);
|
||||||
|
},
|
||||||
async loadEvents({commit}) {
|
async loadEvents({commit}) {
|
||||||
const {data} = await axios.get('/2/events/');
|
const {data} = await axios.get('/2/events/');
|
||||||
commit('replaceEvents', data);
|
commit('replaceEvents', data);
|
||||||
},
|
},
|
||||||
changeEvent({dispatch, getters, commit}, eventName) {
|
changeEvent({dispatch, getters, commit}, eventName) {
|
||||||
router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
|
router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
|
||||||
commit('replaceLoadedItems', []);
|
|
||||||
dispatch('loadEventItems');
|
dispatch('loadEventItems');
|
||||||
},
|
},
|
||||||
changeView({getters}, link) {
|
changeView({getters}, link) {
|
||||||
|
@ -235,10 +268,16 @@ const store = new Vuex.Store({
|
||||||
showBoxContent({getters}, box) {
|
showBoxContent({getters}, box) {
|
||||||
router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
|
router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
|
||||||
},
|
},
|
||||||
async loadEventItems({commit, getters}) {
|
async loadEventItems({commit, getters, state}) {
|
||||||
try {
|
try {
|
||||||
const {data} = await axios.get(`/2/${getters.getEventSlug}/items/`);
|
commit('replaceLoadedItems', []);
|
||||||
|
const slug = getters.getEventSlug;
|
||||||
|
if (slug in state.itemCache) {
|
||||||
|
commit('replaceLoadedItems', state.itemCache[slug]);
|
||||||
|
}
|
||||||
|
const {data} = await axios.get(`/2/${slug}/items/`);
|
||||||
commit('replaceLoadedItems', data);
|
commit('replaceLoadedItems', data);
|
||||||
|
commit('setItemCache', {slug, items: data});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error loading items");
|
console.error("Error loading items");
|
||||||
}
|
}
|
||||||
|
@ -259,7 +298,7 @@ const store = new Vuex.Store({
|
||||||
commit('updateItem', data);
|
commit('updateItem', data);
|
||||||
},
|
},
|
||||||
async markItemReturned({commit, getters}, item) {
|
async markItemReturned({commit, getters}, item) {
|
||||||
await axios.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true});
|
await axios.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true});
|
||||||
commit('removeItem', item);
|
commit('removeItem', item);
|
||||||
},
|
},
|
||||||
async deleteItem({commit, getters}, item) {
|
async deleteItem({commit, getters}, item) {
|
||||||
|
@ -278,7 +317,19 @@ const store = new Vuex.Store({
|
||||||
async sendMail({commit, dispatch}, {id, message}) {
|
async sendMail({commit, dispatch}, {id, message}) {
|
||||||
const {data} = await axios.post(`/2/tickets/${id}/reply/`, {message});
|
const {data} = await axios.post(`/2/tickets/${id}/reply/`, {message});
|
||||||
await dispatch('loadTickets');
|
await dispatch('loadTickets');
|
||||||
}
|
},
|
||||||
|
async postManualTicket({commit, dispatch}, {sender, message, title,}) {
|
||||||
|
const {data} = await axios.post(`/2/tickets/manual/`, {name: title, sender, body: message, recipient: 'mail@c3lf.de'});
|
||||||
|
await dispatch('loadTickets');
|
||||||
|
},
|
||||||
|
async loadUsers({commit}) {
|
||||||
|
const {data} = await axios.get('/2/users/');
|
||||||
|
commit('replaceUsers', data);
|
||||||
|
},
|
||||||
|
async loadGroups({commit}) {
|
||||||
|
const {data} = await axios.get('/2/groups/');
|
||||||
|
commit('replaceGroups', data);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
29
web/src/views/Empty.vue
Normal file
29
web/src/views/Empty.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">
|
||||||
|
<div class="card bg-dark text-light mb-2" id="filters">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="text-center">User: {{user}}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Your Account is not yet activated. Please contact an admin.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {mapState} from "vuex";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Empty',
|
||||||
|
computed: mapState(['user']),
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -1,5 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<p>Error</p>
|
<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 class="text-center">Error</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Something went wrong. <a href="/">Go back to the start page </a>or contact an admin.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
<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>
|
|
68
web/src/views/admin/AccessControl.vue
Normal file
68
web/src/views/admin/AccessControl.vue
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-center">Users</h3>
|
||||||
|
<Table :items="users" :columns="['username']" :key-name="'id'">
|
||||||
|
<template v-slot:default="{item}">
|
||||||
|
<span>
|
||||||
|
{{item.groups.join(', ')}}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<h3 class="text-center">Groups</h3>
|
||||||
|
<Table :items="groups" :columns="['name']" :key-name="'id'">
|
||||||
|
<template v-slot:default="{item}">
|
||||||
|
<span>
|
||||||
|
{{item.members.join(', ')}}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<Matrix2D :items="groupPermissions" :columns="groupNames" :rows="permissionNames"/>
|
||||||
|
<h3 class="text-center">Permissions</h3>
|
||||||
|
<Matrix3D :items="userPermissions" :columns="userNames" :rows="permissionNames"
|
||||||
|
:folded-dimension="eventsNames"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {mapActions, mapState} from 'vuex';
|
||||||
|
import Table from "@/components/Table.vue";
|
||||||
|
import Matrix3D from "@/components/Matrix3D.vue";
|
||||||
|
import Matrix2D from "@/components/Matrix2D.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AccessControl',
|
||||||
|
components: {Matrix3D, Matrix2D, Table},
|
||||||
|
computed: {
|
||||||
|
...mapState(['users', 'groups', 'events']),
|
||||||
|
permissionNames() {
|
||||||
|
return this.groups.map(g => g.permissions).flat().map(p => p.split(":")[1])
|
||||||
|
},
|
||||||
|
groupNames() {
|
||||||
|
return this.groups.map(g => g.name)
|
||||||
|
},
|
||||||
|
eventsNames() {
|
||||||
|
return this.events.map(e => e.slug)
|
||||||
|
},
|
||||||
|
userNames() {
|
||||||
|
return this.users.map(u => u.username)
|
||||||
|
},
|
||||||
|
groupPermissions() {
|
||||||
|
return this.groups.map(g => this.permissionNames.map(p => g.permissions.includes("*:" + p)))
|
||||||
|
},
|
||||||
|
userPermissions() {
|
||||||
|
return this.users.map(u => this.permissionNames.map(p => this.events.map(e =>
|
||||||
|
u.permissions.includes("*:" + p) || u.permissions.includes(e.slug + ":" + p)
|
||||||
|
)))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: mapActions(['loadUsers', 'loadGroups']),
|
||||||
|
mounted() {
|
||||||
|
this.loadUsers();
|
||||||
|
this.loadGroups();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -3,21 +3,22 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xl-8 offset-xl-2">
|
<div class="col-xl-8 offset-xl-2">
|
||||||
<div class="card bg-dark text-light mb-2" id="filters">
|
<div class="card bg-dark text-light mb-2" id="filters">
|
||||||
<h3 class="text-center">Admin</h3>
|
<div class="card-header">
|
||||||
<ul>
|
<ul class="nav nav-tabs card-header-tabs">
|
||||||
<li>
|
<li class="nav-item">
|
||||||
<router-link :to="{name: 'debug'}">Debug</router-link>
|
<router-link class="nav-link" :to="{name: 'admin'}" active-class="active" exact>Dashboard</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li class="nav-item">
|
||||||
<router-link :to="{name: 'boxes', params: {event: getEventSlug}}">Boxes</router-link>
|
<router-link class="nav-link" :to="{name: 'events'}" active-class="active">Events</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li class="nav-item">
|
||||||
<router-link :to="{name: 'events'}">Events</router-link>
|
<router-link class="nav-link" :to="{name: 'users'}" active-class="active">Access Control</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
</ul>
|
||||||
<router-link :to="{name: 'users'}">Users</router-link>
|
</div>
|
||||||
</li>
|
<div class="card-body">
|
||||||
</ul>
|
<router-view></router-view>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -26,9 +27,11 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {mapGetters} from 'vuex';
|
import {mapGetters} from 'vuex';
|
||||||
|
import Cards from "@/components/Cards.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Admin',
|
name: 'Admin',
|
||||||
|
components: {Cards},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(['getEventSlug']),
|
...mapGetters(['getEventSlug']),
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,25 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container-fluid px-xl-5 mt-3">
|
<Table
|
||||||
<div class="row">
|
:columns="['cid', 'name','itemCount']"
|
||||||
<div class="col-xl-8 offset-xl-2">
|
:items="loadedBoxes"
|
||||||
<Table
|
:keyName="'cid'"
|
||||||
:columns="['cid', 'name','itemCount']"
|
v-slot="{ item }"
|
||||||
:items="loadedBoxes"
|
>
|
||||||
:keyName="'cid'"
|
<div class="btn-group">
|
||||||
v-slot="{ item }"
|
<button class="btn btn-secondary" @click.stop="showBoxContent(item.name)">
|
||||||
>
|
<!--font-awesome-icon icon="archive"/--> content
|
||||||
<div class="btn-group">
|
</button>
|
||||||
<button class="btn btn-secondary" @click.stop="showBoxContent(item.name)">
|
<button class="btn btn-danger" @click.stop="" title="delete">
|
||||||
<!--font-awesome-icon icon="archive"/--> content
|
<font-awesome-icon icon="trash"/>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" @click.stop="" title="delete">
|
|
||||||
<font-awesome-icon icon="trash"/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
@ -1,63 +1,58 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container-fluid px-xl-5 mt-3">
|
<div>
|
||||||
<div class="row">
|
<!--qr-code :text="qr_url" color="#000" bg-color="#fff" error-level="H" class="qr-code"></qr-code-->
|
||||||
<div class="col-xl-8 offset-xl-2">
|
<h3 class="text-center">Events</h3>
|
||||||
<!--qr-code :text="qr_url" color="#000" bg-color="#fff" error-level="H" class="qr-code"></qr-code-->
|
<!--p>{{ events }}</p-->
|
||||||
<h3 class="text-center">Events</h3>
|
<ul>
|
||||||
<!--p>{{ events }}</p-->
|
<li v-for="event in events" :key="event.id">
|
||||||
<ul>
|
{{ event.slug }}
|
||||||
<li v-for="event in events" :key="event.id">
|
</li>
|
||||||
{{ event.slug }}
|
</ul>
|
||||||
</li>
|
<h3 class="text-center">Items</h3>
|
||||||
</ul>
|
<!--p>{{ loadedItems }}</p-->
|
||||||
<h3 class="text-center">Items</h3>
|
<ul>
|
||||||
<!--p>{{ loadedItems }}</p-->
|
<li v-for="item in loadedItems" :key="item.id">
|
||||||
<ul>
|
{{ item.description }}
|
||||||
<li v-for="item in loadedItems" :key="item.id">
|
</li>
|
||||||
{{ item.description }}
|
</ul>
|
||||||
</li>
|
<h3 class="text-center">Boxes</h3>
|
||||||
</ul>
|
<!--p>{{ loadedBoxes }}</p-->
|
||||||
<h3 class="text-center">Boxes</h3>
|
<ul>
|
||||||
<!--p>{{ loadedBoxes }}</p-->
|
<li v-for="box in loadedBoxes" :key="box.id">
|
||||||
<ul>
|
{{ box.name }}
|
||||||
<li v-for="box in loadedBoxes" :key="box.id">
|
</li>
|
||||||
{{ box.name }}
|
</ul>
|
||||||
</li>
|
<h3 class="text-center">Mails</h3>
|
||||||
</ul>
|
<!--p>{{ mails }}</p-->
|
||||||
<h3 class="text-center">Mails</h3>
|
<ul>
|
||||||
<!--p>{{ mails }}</p-->
|
<li v-for="mail in mails" :key="mail.id">
|
||||||
<ul>
|
{{ mail.id }}
|
||||||
<li v-for="mail in mails" :key="mail.id">
|
</li>
|
||||||
{{ mail.id }}
|
</ul>
|
||||||
</li>
|
<h3 class="text-center">Issues</h3>
|
||||||
</ul>
|
<!--p>{{ issues }}</p-->
|
||||||
<h3 class="text-center">Issues</h3>
|
<ul>
|
||||||
<!--p>{{ issues }}</p-->
|
<li v-for="issue in issues" :key="issue.id">
|
||||||
<ul>
|
{{ issue.id }}
|
||||||
<li v-for="issue in issues" :key="issue.id">
|
</li>
|
||||||
{{ issue.id }}
|
</ul>
|
||||||
</li>
|
<h3 class="text-center">System Events</h3>
|
||||||
</ul>
|
<!--p>{{ systemEvents }}</p-->
|
||||||
<h3 class="text-center">System Events</h3>
|
<ul>
|
||||||
<!--p>{{ systemEvents }}</p-->
|
<li v-for="systemEvent in systemEvents" :key="systemEvent.id">
|
||||||
<ul>
|
{{ systemEvent.id }}
|
||||||
<li v-for="systemEvent in systemEvents" :key="systemEvent.id">
|
</li>
|
||||||
{{ systemEvent.id }}
|
</ul>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {mapActions, mapState} from 'vuex';
|
import {mapActions, mapState} from 'vuex';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Events from "@/views/Events.vue";
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Debug',
|
name: 'Debug',
|
||||||
components: {Events, Table},
|
components: {Table},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['events', 'loadedItems', 'loadedBoxes', 'mails', 'issues', 'systemEvents']),
|
...mapState(['events', 'loadedItems', 'loadedBoxes', 'mails', 'issues', 'systemEvents']),
|
||||||
qr_url() {
|
qr_url() {
|
||||||
|
|
|
@ -1,27 +1,21 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container-fluid px-xl-5 mt-3">
|
<Table
|
||||||
<div class="row">
|
:columns="['slug', 'name']"
|
||||||
<div class="col-xl-8 offset-xl-2">
|
:items="events"
|
||||||
<Table
|
:keyName="'slug'"
|
||||||
:columns="['slug', 'name']"
|
v-slot="{ item }"
|
||||||
:items="events"
|
>
|
||||||
:keyName="'slug'"
|
<div class="btn-group">
|
||||||
v-slot="{ item }"
|
<button class="btn btn-secondary" @click.stop="changeEvent(item)">
|
||||||
>
|
<font-awesome-icon icon="archive"/>
|
||||||
<div class="btn-group">
|
use
|
||||||
<button class="btn btn-secondary" @click.stop="changeEvent(item)">
|
</button>
|
||||||
<font-awesome-icon icon="archive"/>
|
<button class="btn btn-danger" @click.stop="">
|
||||||
use
|
<font-awesome-icon icon="trash"/>
|
||||||
</button>
|
delete
|
||||||
<button class="btn btn-danger" @click.stop="">
|
</button>
|
||||||
<font-awesome-icon icon="trash"/>
|
|
||||||
delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
<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>
|
|
Loading…
Reference in a new issue