From e605292bf0758d6d66afabdf8c008aba52c66e6d Mon Sep 17 00:00:00 2001 From: jedi Date: Mon, 22 Jan 2024 17:21:22 +0100 Subject: [PATCH] make tickets assignable to users --- ..._event_created_at_alter_item_created_at.py | 23 ++++ core/inventory/models.py | 6 +- core/tickets/api_v2.py | 97 +--------------- ...0008_alter_issuethread_options_and_more.py | 33 ++++++ core/tickets/models.py | 29 +++-- core/tickets/serializers.py | 106 ++++++++++++++++++ core/tickets/tests/v2/test_tickets.py | 19 +++- web/src/components/Timeline.vue | 7 +- web/src/components/TimelineAssignment.vue | 84 ++++++++++++++ web/src/components/TimelineStateChange.vue | 3 - web/src/views/Ticket.vue | 20 +++- 11 files changed, 317 insertions(+), 110 deletions(-) create mode 100644 core/inventory/migrations/0004_alter_event_created_at_alter_item_created_at.py create mode 100644 core/tickets/migrations/0008_alter_issuethread_options_and_more.py create mode 100644 core/tickets/serializers.py create mode 100644 web/src/components/TimelineAssignment.vue diff --git a/core/inventory/migrations/0004_alter_event_created_at_alter_item_created_at.py b/core/inventory/migrations/0004_alter_event_created_at_alter_item_created_at.py new file mode 100644 index 0000000..b5fd81a --- /dev/null +++ b/core/inventory/migrations/0004_alter_event_created_at_alter_item_created_at.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-01-22 16:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0003_alter_item_options'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='item', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/core/inventory/models.py b/core/inventory/models.py index 73f4582..ca0aeb7 100644 --- a/core/inventory/models.py +++ b/core/inventory/models.py @@ -8,7 +8,6 @@ class ItemManager(SoftDeleteManager): def create(self, **kwargs): if 'uid' in kwargs: raise ValueError('uid must not be set manually') - #uid = Item.objects.filter(event=kwargs['event']).count() + 1 uid = Item.all_objects.filter(event=kwargs['event']).count() + 1 kwargs['uid'] = uid return super().create(**kwargs) @@ -24,7 +23,7 @@ class Item(SoftDeleteModel): event = models.ForeignKey('Event', models.CASCADE, db_column='eid') container = models.ForeignKey('Container', models.CASCADE, db_column='cid') returned_at = models.DateTimeField(blank=True, null=True) - created_at = models.DateTimeField(blank=True, null=True) + created_at = models.DateTimeField(null=True, auto_now_add=True) updated_at = models.DateTimeField(blank=True, null=True) objects = ItemManager() @@ -36,6 +35,7 @@ class Item(SoftDeleteModel): ('match_item', 'Can match item') ] + class Container(SoftDeleteModel): cid = models.AutoField(primary_key=True) name = models.CharField(max_length=255) @@ -51,5 +51,5 @@ class Event(models.Model): end = models.DateTimeField(blank=True, null=True) pre_start = models.DateTimeField(blank=True, null=True) post_end = models.DateTimeField(blank=True, null=True) - created_at = models.DateTimeField(blank=True, null=True) + created_at = models.DateTimeField(null=True, auto_now_add=True) updated_at = models.DateTimeField(blank=True, null=True) diff --git a/core/tickets/api_v2.py b/core/tickets/api_v2.py index 2763444..9b9855d 100644 --- a/core/tickets/api_v2.py +++ b/core/tickets/api_v2.py @@ -2,7 +2,7 @@ import logging 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 import routers, viewsets, status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -10,78 +10,11 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from core.settings import MAIL_DOMAIN -from mail.api_v2 import AttachmentSerializer 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, Comment, STATE_CHOICES - - -class IssueSerializer(serializers.ModelSerializer): - timeline = serializers.SerializerMethodField() - last_activity = serializers.SerializerMethodField() - - class Meta: - model = IssueThread - fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid') - read_only_fields = ('id', 'timeline', 'last_activity', 'uuid') - - def to_internal_value(self, data): - ret = super().to_internal_value(data) - if 'state' in data: - ret['state'] = data['state'] - return ret - - def validate(self, attrs): - if 'state' in attrs: - if attrs['state'] not in [x[0] for x in STATE_CHOICES]: - raise serializers.ValidationError('invalid state') - return attrs - - @staticmethod - def get_last_activity(self): - try: - last_state_change = self.state_changes.order_by('-timestamp').first().timestamp \ - if self.state_changes.count() > 0 else None - last_comment = self.comments.order_by('-timestamp').first().timestamp if self.comments.count() > 0 else None - last_mail = self.emails.order_by('-timestamp').first().timestamp if self.emails.count() > 0 else None - args = [x for x in [last_state_change, last_comment, last_mail] if x is not None] - return max(args) - except AttributeError: - return None - - @staticmethod - def get_timeline(obj): - timeline = [] - for comment in obj.comments.all(): - timeline.append({ - 'type': 'comment', - 'id': comment.id, - 'timestamp': comment.timestamp, - 'comment': comment.comment, - }) - for state_change in obj.state_changes.all(): - timeline.append({ - 'type': 'state', - 'id': state_change.id, - 'timestamp': state_change.timestamp, - 'state': state_change.state, - }) - for email in obj.emails.all(): - timeline.append({ - 'type': 'mail', - 'id': email.id, - 'timestamp': email.timestamp, - 'sender': email.sender, - 'recipient': email.recipient, - 'subject': email.subject, - 'body': email.body, - 'attachments': AttachmentSerializer(email.attachments.all(), many=True).data, - }) - return sorted(timeline, key=lambda x: x['timestamp']) - - def get_queryset(self): - return IssueThread.objects.all().order_by('-last_activity') +from tickets.serializers import IssueSerializer, CommentSerializer class IssueViewSet(viewsets.ModelViewSet): @@ -89,18 +22,6 @@ class IssueViewSet(viewsets.ModelViewSet): queryset = IssueThread.objects.all() -class CommentSerializer(serializers.ModelSerializer): - - def validate(self, attrs): - if 'comment' not in attrs or attrs['comment'] == '': - raise serializers.ValidationError('comment cannot be empty') - return attrs - - class Meta: - model = Comment - fields = ('id', 'comment', 'timestamp', 'issue_thread') - - class CommentViewSet(viewsets.ModelViewSet): serializer_class = CommentSerializer queryset = Comment.objects.all() @@ -116,7 +37,8 @@ def reply(request, pk): first_mail = Email.objects.filter(issue_thread=issue, recipient__endswith='@' + MAIL_DOMAIN).order_by( 'timestamp').first() if not first_mail: - return Response({'status': 'error', 'message': 'no previous mail found, reply would not make sense.'}, status=status.HTTP_400_BAD_REQUEST) + return Response({'status': 'error', 'message': 'no previous mail found, reply would not make sense.'}, + status=status.HTTP_400_BAD_REQUEST) mail = Email.objects.create( issue_thread=issue, sender=first_mail.recipient, @@ -165,17 +87,6 @@ def manual_ticket(request): return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) -class StateSerializer(serializers.Serializer): - text = serializers.SerializerMethodField() - value = serializers.SerializerMethodField() - - def get_text(self, obj): - return obj['text'] - - def get_value(self, obj): - return obj['value'] - - @api_view(['GET']) @permission_classes([IsAuthenticated]) def get_available_states(request): diff --git a/core/tickets/migrations/0008_alter_issuethread_options_and_more.py b/core/tickets/migrations/0008_alter_issuethread_options_and_more.py new file mode 100644 index 0000000..788f9f6 --- /dev/null +++ b/core/tickets/migrations/0008_alter_issuethread_options_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.7 on 2024-01-22 16:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0007_alter_statechange_state'), + ] + + operations = [ + migrations.AlterModelOptions( + name='issuethread', + options={'permissions': [('send_mail', 'Can send mail'), ('add_issuethread_manual', 'Can add issue thread manually'), ('assign_issuethread', 'Can assign issue thread')]}, + ), + migrations.RemoveField( + model_name='issuethread', + name='assigned_to', + ), + migrations.CreateModel( + name='Assignment', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('assigned_to', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL)), + ('issue_thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='tickets.issuethread')), + ], + ), + ] diff --git a/core/tickets/models.py b/core/tickets/models.py index 4667b0f..7d90052 100644 --- a/core/tickets/models.py +++ b/core/tickets/models.py @@ -1,6 +1,7 @@ from django.db import models from django_softdelete.models import SoftDeleteModel +from authentication.models import ExtendedUser from inventory.models import Event from django.db.models.signals import post_save, pre_save from django.dispatch import receiver @@ -32,7 +33,6 @@ class IssueThread(SoftDeleteModel): id = models.AutoField(primary_key=True) uuid = models.CharField(max_length=255, unique=True, null=False, blank=False) name = models.CharField(max_length=255) - assigned_to = models.CharField(max_length=255, null=True) manually_created = models.BooleanField(default=False) def short_uuid(self): @@ -51,10 +51,24 @@ class IssueThread(SoftDeleteModel): return self.state_changes.create(state=value) + @property + def assigned_to(self): + try: + return self.assignments.order_by('-timestamp').first().assigned_to + except AttributeError: + return None + + @assigned_to.setter + def assigned_to(self, value): + if self.assigned_to == value: + return + self.assignments.create(assigned_to=value) + class Meta: permissions = [ ('send_mail', 'Can send mail'), ('add_issuethread_manual', 'Can add issue thread manually'), + ('assign_issuethread', 'Can assign issue thread'), ] @@ -70,12 +84,6 @@ def create_issue_thread(sender, instance, created, **kwargs): if created: StateChange.objects.create(issue_thread=instance, state='pending_new') - class Meta: - permissions = [ - ('send_mail', 'Can send mail'), - ('add_issuethread_manual', 'Can add issue thread manually'), - ] - class Comment(models.Model): id = models.AutoField(primary_key=True) @@ -89,3 +97,10 @@ class StateChange(models.Model): issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='state_changes') state = models.CharField(max_length=255, choices=STATE_CHOICES, default='pending_new') timestamp = models.DateTimeField(auto_now_add=True) + + +class Assignment(models.Model): + id = models.AutoField(primary_key=True) + issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='assignments') + assigned_to = models.ForeignKey(ExtendedUser, on_delete=models.CASCADE, related_name='assigned_tickets') + timestamp = models.DateTimeField(auto_now_add=True) diff --git a/core/tickets/serializers.py b/core/tickets/serializers.py new file mode 100644 index 0000000..fe3972e --- /dev/null +++ b/core/tickets/serializers.py @@ -0,0 +1,106 @@ +from rest_framework import serializers + +from authentication.models import ExtendedUser +from mail.api_v2 import AttachmentSerializer +from tickets.models import IssueThread, Comment, STATE_CHOICES + + +class CommentSerializer(serializers.ModelSerializer): + + def validate(self, attrs): + if 'comment' not in attrs or attrs['comment'] == '': + raise serializers.ValidationError('comment cannot be empty') + return attrs + + class Meta: + model = Comment + fields = ('id', 'comment', 'timestamp', 'issue_thread') + + +class StateSerializer(serializers.Serializer): + text = serializers.SerializerMethodField() + value = serializers.SerializerMethodField() + + def get_text(self, obj): + return obj['text'] + + def get_value(self, obj): + return obj['value'] + + +class IssueSerializer(serializers.ModelSerializer): + timeline = serializers.SerializerMethodField() + last_activity = serializers.SerializerMethodField() + assigned_to = serializers.SlugRelatedField(slug_field='username', queryset=ExtendedUser.objects.all(), + allow_null=True, required=False) + + class Meta: + model = IssueThread + fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid') + read_only_fields = ('id', 'timeline', 'last_activity', 'uuid') + + def to_internal_value(self, data): + ret = super().to_internal_value(data) + if 'state' in data: + ret['state'] = data['state'] +# if 'assigned_to' in data: +# ret['assigned_to'] = data['assigned_to'] + return ret + + def validate(self, attrs): + if 'state' in attrs: + if attrs['state'] not in [x[0] for x in STATE_CHOICES]: + raise serializers.ValidationError('invalid state') + return attrs + + @staticmethod + def get_last_activity(self): + try: + last_state_change = self.state_changes.order_by('-timestamp').first().timestamp \ + if self.state_changes.count() > 0 else None + last_comment = self.comments.order_by('-timestamp').first().timestamp if self.comments.count() > 0 else None + last_mail = self.emails.order_by('-timestamp').first().timestamp if self.emails.count() > 0 else None + args = [x for x in [last_state_change, last_comment, last_mail] if x is not None] + return max(args) + except AttributeError: + return None + + @staticmethod + def get_timeline(obj): + timeline = [] + for comment in obj.comments.all(): + timeline.append({ + 'type': 'comment', + 'id': comment.id, + 'timestamp': comment.timestamp, + 'comment': comment.comment, + }) + for state_change in obj.state_changes.all(): + timeline.append({ + 'type': 'state', + 'id': state_change.id, + 'timestamp': state_change.timestamp, + 'state': state_change.state, + }) + for email in obj.emails.all(): + timeline.append({ + 'type': 'mail', + 'id': email.id, + 'timestamp': email.timestamp, + 'sender': email.sender, + 'recipient': email.recipient, + 'subject': email.subject, + 'body': email.body, + 'attachments': AttachmentSerializer(email.attachments.all(), many=True).data, + }) + for assignment in obj.assignments.all(): + timeline.append({ + 'type': 'assignment', + 'id': assignment.id, + 'timestamp': assignment.timestamp, + 'assigned_to': assignment.assigned_to.username, + }) + return sorted(timeline, key=lambda x: x['timestamp']) + + def get_queryset(self): + return IssueThread.objects.all().order_by('-last_activity') diff --git a/core/tickets/tests/v2/test_tickets.py b/core/tickets/tests/v2/test_tickets.py index 961a8f4..6b7a048 100644 --- a/core/tickets/tests/v2/test_tickets.py +++ b/core/tickets/tests/v2/test_tickets.py @@ -297,4 +297,21 @@ class IssueApiTest(TestCase): ) response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'invalid'}, content_type='application/json') - self.assertEqual(response.status_code, 400) + self.assertEqual(400, response.status_code) + + def test_assign_user(self): + issue = IssueThread.objects.create( + name="test issue", + ) + response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'assigned_to': self.user.username}, + content_type='application/json') + self.assertEqual(200, response.status_code) + self.assertEqual('pending_new', response.json()['state']) + self.assertEqual('test issue', response.json()['name']) + self.assertEqual(self.user.username, response.json()['assigned_to']) + timeline = response.json()['timeline'] + self.assertEqual(2, len(timeline)) + self.assertEqual('state', timeline[0]['type']) + self.assertEqual('pending_new', timeline[0]['state']) + self.assertEqual('assignment', timeline[1]['type']) + self.assertEqual(self.user.username, timeline[1]['assigned_to']) diff --git a/web/src/components/Timeline.vue b/web/src/components/Timeline.vue index 1c05cd9..b53128c 100644 --- a/web/src/components/Timeline.vue +++ b/web/src/components/Timeline.vue @@ -11,12 +11,16 @@ + + + +

{{ item }}

  • @@ -61,10 +65,11 @@ import TimelineMail from "@/components/TimelineMail.vue"; import TimelineComment from "@/components/TimelineComment.vue"; import TimelineStateChange from "@/components/TimelineStateChange.vue"; import {mapGetters} from "vuex"; +import TimelineAssignment from "@/components/TimelineAssignment.vue"; export default { name: 'Timeline', - components: {TimelineStateChange, TimelineComment, TimelineMail}, + components: {TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail}, props: { timeline: { type: Array, diff --git a/web/src/components/TimelineAssignment.vue b/web/src/components/TimelineAssignment.vue new file mode 100644 index 0000000..d384825 --- /dev/null +++ b/web/src/components/TimelineAssignment.vue @@ -0,0 +1,84 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineStateChange.vue b/web/src/components/TimelineStateChange.vue index 481807b..eab7c41 100644 --- a/web/src/components/TimelineStateChange.vue +++ b/web/src/components/TimelineStateChange.vue @@ -41,9 +41,6 @@ export default { 'timestamp': function () { return new Date(this.item.timestamp).toLocaleString(); }, - 'body': function () { - return this.item.body.replace(//g, '>').replace(/\n/g, '
    '); - } } }; diff --git a/web/src/views/Ticket.vue b/web/src/views/Ticket.vue index 76e3b5d..b8c0cf9 100644 --- a/web/src/views/Ticket.vue +++ b/web/src/views/Ticket.vue @@ -17,6 +17,14 @@ Copy DHL contact to clipboard +
    + + +