diff --git a/TODO.md b/TODO.md index a0eb090..68452cf 100644 --- a/TODO.md +++ b/TODO.md @@ -71,8 +71,30 @@ * [ ] concept: purge old tickets * [ ] concept: purge old items * [ ] 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 -* send mails from web frontend -* login / user management +* manual ticket creation diff --git a/core/authentication/api_v2.py b/core/authentication/api_v2.py index 019a0fd..514a697 100644 --- a/core/authentication/api_v2.py +++ b/core/authentication/api_v2.py @@ -7,6 +7,7 @@ from django.contrib.auth import login from django.urls import path from django.dispatch import receiver from django.db.models.signals import post_save +from django.contrib.auth.models import Group from knox.models import AuthToken from knox.views import LoginView as KnoxLoginView @@ -14,9 +15,16 @@ from authentication.models import ExtendedUser class UserSerializer(serializers.ModelSerializer): + permissions = serializers.SerializerMethodField() + groups = serializers.SlugRelatedField(many=True, read_only=True, slug_field='name') + class Meta: model = ExtendedUser - fields = ('id', 'username', 'email', 'first_name', 'last_name') + 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) @@ -30,7 +38,27 @@ class UserViewSet(viewsets.ModelViewSet): 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]) def selfUser(request): serializer = UserSerializer(request.user) @@ -79,6 +107,7 @@ class LoginView(KnoxLoginView): router = routers.SimpleRouter() router.register(r'users', UserViewSet, basename='users') +router.register(r'groups', GroupViewSet, basename='groups') urlpatterns = router.urls + [ path('self/', selfUser), diff --git a/core/authentication/migrations/0005_alter_eventpermission_event.py b/core/authentication/migrations/0005_alter_eventpermission_event.py deleted file mode 100644 index 486f94d..0000000 --- a/core/authentication/migrations/0005_alter_eventpermission_event.py +++ /dev/null @@ -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'), - ), - ] diff --git a/core/authentication/tests/v2/test_permissions.py b/core/authentication/tests/v2/test_permissions.py index bb184cf..0a00fcd 100644 --- a/core/authentication/tests/v2/test_permissions.py +++ b/core/authentication/tests/v2/test_permissions.py @@ -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='add_item'), event=Event.objects.get(slug='testevent1')) 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='testevent2'))) #self.assertFalse(user.has_perm('inventory.add_event', Event.objects.get(slug='testevent1'))) diff --git a/core/authentication/tests/v2/test_users.py b/core/authentication/tests/v2/test_users.py index 6838e64..125be9b 100644 --- a/core/authentication/tests/v2/test_users.py +++ b/core/authentication/tests/v2/test_users.py @@ -1,17 +1,30 @@ 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 authentication.models import ExtendedUser +from authentication.models import ExtendedUser, EventPermission from core import settings +from inventory.models import Event class UserApiTest(TestCase): 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.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.token = AuthToken.objects.create(user=self.user) 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]['last_name'], '') 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]['email'], 'test') self.assertEqual(response.json()[1]['first_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): - response = self.client.post('/api/2/self/') + response = self.client.get('/api/2/self/') self.assertEqual(response.status_code, 200) self.assertEqual(response.json()['username'], 'testuser') self.assertEqual(response.json()['email'], 'test') self.assertEqual(response.json()['first_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): anonymous = Client() @@ -112,3 +135,49 @@ class UserApiTest(TestCase): content_type='application/json') self.assertEqual(response.status_code, 200) 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') diff --git a/core/core/settings.py b/core/core/settings.py index f8d7ebb..6fe4b9c 100644 --- a/core/core/settings.py +++ b/core/core/settings.py @@ -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! 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') @@ -66,7 +66,7 @@ REST_FRAMEWORK = { 'TEST_REQUEST_DEFAULT_FORMAT': 'json', 'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',), 'DEFAULT_PERMISSION_CLASSES': ( - 'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.DjangoModelPermissions'), + 'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.DjangoModelPermissions'), } AUTH_USER_MODEL = 'authentication.ExtendedUser' diff --git a/core/inventory/api_v2.py b/core/inventory/api_v2.py index 79e25f2..f7306da 100644 --- a/core/inventory/api_v2.py +++ b/core/inventory/api_v2.py @@ -97,7 +97,7 @@ class ItemSerializer(serializers.ModelSerializer): @api_view(['GET']) @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): try: event = Event.objects.get(slug=event_slug) @@ -117,11 +117,11 @@ def item(request, event_slug): try: event = Event.objects.get(slug=event_slug) 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(ItemSerializer(Item.objects.filter(event=event), many=True).data) 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) validated_data = ItemSerializer(data=request.data) if validated_data.is_valid(): @@ -131,25 +131,34 @@ def item(request, event_slug): return Response(status=404) -@api_view(['GET', 'PUT', 'DELETE']) +@api_view(['GET', 'PUT', 'DELETE', 'PATCH']) @permission_classes([IsAuthenticated]) 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': - 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(ItemSerializer(item).data) 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) validated_data = ItemSerializer(item, data=request.data) if validated_data.is_valid(): validated_data.save() 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': - 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) item.delete() return Response(status=204) diff --git a/core/tickets/api_v2.py b/core/tickets/api_v2.py index c8eb103..695aa7f 100644 --- a/core/tickets/api_v2.py +++ b/core/tickets/api_v2.py @@ -1,16 +1,18 @@ import logging -from django.urls import path +from django.urls import re_path from django.contrib.auth.decorators import permission_required from rest_framework import routers, viewsets, serializers, status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer from core.settings import MAIL_DOMAIN from mail.models import Email from mail.protocol import send_smtp, make_reply, collect_references +from notify_sessions.models import SystemEvent from tickets.models import IssueThread @@ -80,9 +82,44 @@ def reply(request, pk): 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.register(r'tickets', IssueViewSet, basename='issues') -urlpatterns = router.urls + [ - path('tickets//reply/', reply, name='reply'), -] +urlpatterns = ([ + re_path(r'^tickets/(?P\d+)/reply/$', reply, name='reply'), + re_path(r'^tickets/manual/$', manual_ticket, name='manual_ticket'), + ] + router.urls) diff --git a/core/tickets/migrations/0002_alter_issuethread_options_and_more.py b/core/tickets/migrations/0002_alter_issuethread_options_and_more.py new file mode 100644 index 0000000..3c1e4a7 --- /dev/null +++ b/core/tickets/migrations/0002_alter_issuethread_options_and_more.py @@ -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), + ), + ] diff --git a/core/tickets/models.py b/core/tickets/models.py index 44da336..7130fe5 100644 --- a/core/tickets/models.py +++ b/core/tickets/models.py @@ -10,10 +10,12 @@ class IssueThread(SoftDeleteModel): state = models.CharField(max_length=255, default='new') assigned_to = models.CharField(max_length=255, null=True) last_activity = models.DateTimeField(auto_now=True) + manually_created = models.BooleanField(default=False) class Meta: permissions = [ ('send_mail', 'Can send mail'), + ('add_issuethread_manual', 'Can add issue thread manually'), ] diff --git a/core/tickets/tests/v2/test_tickets.py b/core/tickets/tests/v2/test_tickets.py index 753c0b6..1f29963 100644 --- a/core/tickets/tests/v2/test_tickets.py +++ b/core/tickets/tests/v2/test_tickets.py @@ -5,7 +5,7 @@ from django.test import TestCase, Client from authentication.models import ExtendedUser from mail.models import Email 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 @@ -14,6 +14,7 @@ class IssueApiTest(TestCase): def setUp(self): super().setUp() self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) self.user.save() self.token = AuthToken.objects.create(user=self.user) 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]['timestamp'], 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') + diff --git a/web/src/App.vue b/web/src/App.vue index 068e7ef..d741a99 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,12 +1,13 @@ @@ -99,14 +109,21 @@ export default { ] }), computed: { - ...mapState(['events', 'activeEvent', 'layout']), - ...mapGetters(['getEventSlug', 'getActiveView', "checkRole"]), + ...mapState(['events', 'layout']), + ...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions"]), }, methods: { ...mapActions(['changeEvent', 'changeView', 'searchEventItems']), - ...mapMutations(['setLayout']), + ...mapMutations(['setLayout', 'logout']), 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'; } } }; diff --git a/web/src/components/Timeline.vue b/web/src/components/Timeline.vue index 71b8bd9..d56ae3f 100644 --- a/web/src/components/Timeline.vue +++ b/web/src/components/Timeline.vue @@ -25,8 +25,8 @@
- -
diff --git a/web/src/components/TimelineMail.vue b/web/src/components/TimelineMail.vue index 8a92a8c..df780d3 100644 --- a/web/src/components/TimelineMail.vue +++ b/web/src/components/TimelineMail.vue @@ -13,13 +13,13 @@

- +
-