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 }}