make tickets assignable to users #48
11 changed files with 317 additions and 110 deletions
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -8,7 +8,6 @@ class ItemManager(SoftDeleteManager):
|
||||||
def create(self, **kwargs):
|
def create(self, **kwargs):
|
||||||
if 'uid' in kwargs:
|
if 'uid' in kwargs:
|
||||||
raise ValueError('uid must not be set manually')
|
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
|
uid = Item.all_objects.filter(event=kwargs['event']).count() + 1
|
||||||
kwargs['uid'] = uid
|
kwargs['uid'] = uid
|
||||||
return super().create(**kwargs)
|
return super().create(**kwargs)
|
||||||
|
@ -24,7 +23,7 @@ class Item(SoftDeleteModel):
|
||||||
event = models.ForeignKey('Event', models.CASCADE, db_column='eid')
|
event = models.ForeignKey('Event', models.CASCADE, db_column='eid')
|
||||||
container = models.ForeignKey('Container', models.CASCADE, db_column='cid')
|
container = models.ForeignKey('Container', models.CASCADE, db_column='cid')
|
||||||
returned_at = models.DateTimeField(blank=True, null=True)
|
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)
|
updated_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
objects = ItemManager()
|
objects = ItemManager()
|
||||||
|
@ -36,6 +35,7 @@ class Item(SoftDeleteModel):
|
||||||
('match_item', 'Can match item')
|
('match_item', 'Can match item')
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class Container(SoftDeleteModel):
|
class Container(SoftDeleteModel):
|
||||||
cid = models.AutoField(primary_key=True)
|
cid = models.AutoField(primary_key=True)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
@ -51,5 +51,5 @@ class Event(models.Model):
|
||||||
end = models.DateTimeField(blank=True, null=True)
|
end = models.DateTimeField(blank=True, null=True)
|
||||||
pre_start = models.DateTimeField(blank=True, null=True)
|
pre_start = models.DateTimeField(blank=True, null=True)
|
||||||
post_end = 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)
|
updated_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
|
@ -2,7 +2,7 @@ import logging
|
||||||
|
|
||||||
from django.urls import re_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, 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
|
||||||
|
@ -10,78 +10,11 @@ from asgiref.sync import async_to_sync
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
from core.settings import MAIL_DOMAIN
|
from core.settings import MAIL_DOMAIN
|
||||||
from mail.api_v2 import AttachmentSerializer
|
|
||||||
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 notify_sessions.models import SystemEvent
|
||||||
from tickets.models import IssueThread, Comment, STATE_CHOICES
|
from tickets.models import IssueThread, Comment, STATE_CHOICES
|
||||||
|
from tickets.serializers import IssueSerializer, CommentSerializer
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
|
|
||||||
class IssueViewSet(viewsets.ModelViewSet):
|
class IssueViewSet(viewsets.ModelViewSet):
|
||||||
|
@ -89,18 +22,6 @@ class IssueViewSet(viewsets.ModelViewSet):
|
||||||
queryset = IssueThread.objects.all()
|
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):
|
class CommentViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = CommentSerializer
|
serializer_class = CommentSerializer
|
||||||
queryset = Comment.objects.all()
|
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(
|
first_mail = Email.objects.filter(issue_thread=issue, recipient__endswith='@' + MAIL_DOMAIN).order_by(
|
||||||
'timestamp').first()
|
'timestamp').first()
|
||||||
if not first_mail:
|
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(
|
mail = Email.objects.create(
|
||||||
issue_thread=issue,
|
issue_thread=issue,
|
||||||
sender=first_mail.recipient,
|
sender=first_mail.recipient,
|
||||||
|
@ -165,17 +87,6 @@ def manual_ticket(request):
|
||||||
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
|
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'])
|
@api_view(['GET'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def get_available_states(request):
|
def get_available_states(request):
|
||||||
|
|
|
@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,6 +1,7 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django_softdelete.models import SoftDeleteModel
|
from django_softdelete.models import SoftDeleteModel
|
||||||
|
|
||||||
|
from authentication.models import ExtendedUser
|
||||||
from inventory.models import Event
|
from inventory.models import Event
|
||||||
from django.db.models.signals import post_save, pre_save
|
from django.db.models.signals import post_save, pre_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
@ -32,7 +33,6 @@ class IssueThread(SoftDeleteModel):
|
||||||
id = models.AutoField(primary_key=True)
|
id = models.AutoField(primary_key=True)
|
||||||
uuid = models.CharField(max_length=255, unique=True, null=False, blank=False)
|
uuid = models.CharField(max_length=255, unique=True, null=False, blank=False)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
assigned_to = models.CharField(max_length=255, null=True)
|
|
||||||
manually_created = models.BooleanField(default=False)
|
manually_created = models.BooleanField(default=False)
|
||||||
|
|
||||||
def short_uuid(self):
|
def short_uuid(self):
|
||||||
|
@ -51,10 +51,24 @@ class IssueThread(SoftDeleteModel):
|
||||||
return
|
return
|
||||||
self.state_changes.create(state=value)
|
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:
|
class Meta:
|
||||||
permissions = [
|
permissions = [
|
||||||
('send_mail', 'Can send mail'),
|
('send_mail', 'Can send mail'),
|
||||||
('add_issuethread_manual', 'Can add issue thread manually'),
|
('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:
|
if created:
|
||||||
StateChange.objects.create(issue_thread=instance, state='pending_new')
|
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):
|
class Comment(models.Model):
|
||||||
id = models.AutoField(primary_key=True)
|
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')
|
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')
|
state = models.CharField(max_length=255, choices=STATE_CHOICES, default='pending_new')
|
||||||
timestamp = models.DateTimeField(auto_now_add=True)
|
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)
|
||||||
|
|
106
core/tickets/serializers.py
Normal file
106
core/tickets/serializers.py
Normal file
|
@ -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')
|
|
@ -297,4 +297,21 @@ class IssueApiTest(TestCase):
|
||||||
)
|
)
|
||||||
response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'invalid'},
|
response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'invalid'},
|
||||||
content_type='application/json')
|
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'])
|
||||||
|
|
|
@ -11,12 +11,16 @@
|
||||||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'state'" :class="'bg-' + stateInfo(item.state).color">
|
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'state'" :class="'bg-' + stateInfo(item.state).color">
|
||||||
<font-awesome-icon :icon="stateInfo(item.state).icon"/>
|
<font-awesome-icon :icon="stateInfo(item.state).icon"/>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'assignment'" :class="'bg-secondary'">
|
||||||
|
<font-awesome-icon icon="user"/>
|
||||||
|
</span>
|
||||||
<span class="timeline-item-icon faded-icon" v-else>
|
<span class="timeline-item-icon faded-icon" v-else>
|
||||||
<font-awesome-icon icon="pen"/>
|
<font-awesome-icon icon="pen"/>
|
||||||
</span>
|
</span>
|
||||||
<TimelineMail v-if="item.type === 'mail'" :item="item"/>
|
<TimelineMail v-if="item.type === 'mail'" :item="item"/>
|
||||||
<TimelineComment v-else-if="item.type === 'comment'" :item="item"/>
|
<TimelineComment v-else-if="item.type === 'comment'" :item="item"/>
|
||||||
<TimelineStateChange v-else-if="item.type === 'state'" :item="item"/>
|
<TimelineStateChange v-else-if="item.type === 'state'" :item="item"/>
|
||||||
|
<TimelineAssignment v-else-if="item.type === 'assignment'" :item="item"/>
|
||||||
<p v-else>{{ item }}</p>
|
<p v-else>{{ item }}</p>
|
||||||
</li>
|
</li>
|
||||||
<li class="timeline-item">
|
<li class="timeline-item">
|
||||||
|
@ -61,10 +65,11 @@ import TimelineMail from "@/components/TimelineMail.vue";
|
||||||
import TimelineComment from "@/components/TimelineComment.vue";
|
import TimelineComment from "@/components/TimelineComment.vue";
|
||||||
import TimelineStateChange from "@/components/TimelineStateChange.vue";
|
import TimelineStateChange from "@/components/TimelineStateChange.vue";
|
||||||
import {mapGetters} from "vuex";
|
import {mapGetters} from "vuex";
|
||||||
|
import TimelineAssignment from "@/components/TimelineAssignment.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Timeline',
|
name: 'Timeline',
|
||||||
components: {TimelineStateChange, TimelineComment, TimelineMail},
|
components: {TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail},
|
||||||
props: {
|
props: {
|
||||||
timeline: {
|
timeline: {
|
||||||
type: Array,
|
type: Array,
|
||||||
|
|
84
web/src/components/TimelineAssignment.vue
Normal file
84
web/src/components/TimelineAssignment.vue
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
<template>
|
||||||
|
<div class="timeline-item-description">
|
||||||
|
<i class="avatar | small">
|
||||||
|
<font-awesome-icon icon="user"/>
|
||||||
|
</i>
|
||||||
|
<span><a href="#">$USER</a> assigned ticket to <span
|
||||||
|
class="badge badge-pill badge-secondary">{{ item.assigned_to }}</span> at <time
|
||||||
|
:datetime="timestamp">{{ timestamp }}</time>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'TimelineAssignment',
|
||||||
|
props: {
|
||||||
|
'item': {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
'timestamp': function () {
|
||||||
|
return new Date(this.item.timestamp).toLocaleString();
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.timeline-item-description {
|
||||||
|
display: flex;
|
||||||
|
padding-top: 6px;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--gray);
|
||||||
|
|
||||||
|
img {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
/*color: var(--c-grey-500);*/
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
outline: 0; /* Don't actually do this */
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
|
@ -41,9 +41,6 @@ export default {
|
||||||
'timestamp': function () {
|
'timestamp': function () {
|
||||||
return new Date(this.item.timestamp).toLocaleString();
|
return new Date(this.item.timestamp).toLocaleString();
|
||||||
},
|
},
|
||||||
'body': function () {
|
|
||||||
return this.item.body.replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -17,6 +17,14 @@
|
||||||
<font-awesome-icon icon="clipboard"/>
|
<font-awesome-icon icon="clipboard"/>
|
||||||
Copy DHL contact to clipboard
|
Copy DHL contact to clipboard
|
||||||
</ClipboardButton>
|
</ClipboardButton>
|
||||||
|
<div class="btn-group">
|
||||||
|
<select class="form-control" v-model="ticket.assigned_to">
|
||||||
|
<option v-for="user in users" :value="user.username">{{ user.username }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="form-control btn btn-success" @click="assigTicket(ticket)">
|
||||||
|
Assign Ticket
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<select class="form-control" v-model="ticket.state">
|
<select class="form-control" v-model="ticket.state">
|
||||||
<option v-for="status in state_options" :value="status.value">{{ status.text }}</option>
|
<option v-for="status in state_options" :value="status.value">{{ status.text }}</option>
|
||||||
|
@ -41,7 +49,7 @@ export default {
|
||||||
name: 'Ticket',
|
name: 'Ticket',
|
||||||
components: {ClipboardButton, Timeline},
|
components: {ClipboardButton, Timeline},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['tickets', 'state_options']),
|
...mapState(['tickets', 'state_options', 'users']),
|
||||||
ticket() {
|
ticket() {
|
||||||
const id = parseInt(this.$route.params.id)
|
const id = parseInt(this.$route.params.id)
|
||||||
const ret = this.tickets.find(ticket => ticket.id === id);
|
const ret = this.tickets.find(ticket => ticket.id === id);
|
||||||
|
@ -53,7 +61,8 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['deleteItem', 'markItemReturned', 'loadTickets', 'sendMail', 'updateTicketPartial', 'fetchTicketStates', 'postComment']),
|
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
|
||||||
|
...mapActions(['loadTickets', 'loadUsers', 'fetchTicketStates']),
|
||||||
handleMail(mail) {
|
handleMail(mail) {
|
||||||
this.sendMail({
|
this.sendMail({
|
||||||
id: this.ticket.id,
|
id: this.ticket.id,
|
||||||
|
@ -71,11 +80,18 @@ export default {
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
state: ticket.state
|
state: ticket.state
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
assigTicket(ticket) {
|
||||||
|
this.updateTicketPartial({
|
||||||
|
id: ticket.id,
|
||||||
|
assigned_to: ticket.assigned_to
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.fetchTicketStates()
|
this.fetchTicketStates()
|
||||||
this.loadTickets()
|
this.loadTickets()
|
||||||
|
this.loadUsers()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
Loading…
Reference in a new issue