Compare commits

...

10 commits

Author SHA1 Message Date
f7fda52fd0 fix 500 error if files does not exist on disk
All checks were successful
/ deploy (push) Successful in 4m48s
/ test (pull_request) Successful in 50s
/ test (push) Successful in 2m45s
2024-12-05 00:50:52 +01:00
385620717c add basic view for item history 2024-12-05 00:50:48 +01:00
ec6e872163 update prefetched fields 2024-12-05 00:50:12 +01:00
c03d298a58 add /item/comment endpoint and prefetch related models 2024-12-05 00:50:12 +01:00
52cf00ef9c deploy: Fix metrics in nginx 2024-12-05 00:50:12 +01:00
0cb9b6cd95 update admin interface 2024-12-05 00:50:12 +01:00
a51036d6f2 add timeline information to the /items endpoint 2024-12-05 00:50:12 +01:00
01b022128d add frontend to edit event details 2024-12-05 00:50:12 +01:00
690a9c954e ensure creation file date 2024-12-05 00:50:12 +01:00
f57f0796eb fix untested error in /tickets endpoint 2024-12-05 00:50:12 +01:00
33 changed files with 1297 additions and 446 deletions

View file

@ -38,6 +38,8 @@ def media_urls(request, hash):
'Age': 0, 'Age': 0,
'ETag': file.hash, 'ETag': file.hash,
}) })
except FileNotFoundError:
return Response(status=status.HTTP_404_NOT_FOUND)
except File.DoesNotExist: except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
except EmailAttachment.DoesNotExist: except EmailAttachment.DoesNotExist:
@ -80,6 +82,8 @@ def thumbnail_urls(request, size, hash):
'ETag': file.hash + "_" + str(size), 'ETag': file.hash + "_" + str(size),
}) })
except FileNotFoundError:
return Response(status=status.HTTP_404_NOT_FOUND)
except File.DoesNotExist: except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
except EmailAttachment.DoesNotExist: except EmailAttachment.DoesNotExist:

View file

@ -0,0 +1,24 @@
# Generated by Django 4.2.7 on 2024-11-21 22:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('files', '0002_alter_file_file'),
]
def set_creation_date(apps, schema_editor):
File = apps.get_model('files', 'File')
for file in File.objects.all():
if file.created_at is None:
if not file.item.created_at is None:
file.created_at = file.item.created_at
else:
file.created_at = max(File.objects.filter(
id__lt=file.id, created_at__isnull=False).values_list('created_at', flat=True))
file.save()
operations = [
migrations.RunPython(set_creation_date),
]

View file

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from inventory.models import Item, Container, Event from inventory.models import Item, Container, ItemPlacement, Comment, Event
class ItemAdmin(admin.ModelAdmin): class ItemAdmin(admin.ModelAdmin):
@ -22,3 +22,17 @@ class EventAdmin(admin.ModelAdmin):
admin.site.register(Event, EventAdmin) admin.site.register(Event, EventAdmin)
class ItemPlacementAdmin(admin.ModelAdmin):
pass
admin.site.register(ItemPlacement, ItemPlacementAdmin)
class CommentAdmin(admin.ModelAdmin):
pass
admin.site.register(Comment, CommentAdmin)

View file

@ -1,12 +1,13 @@
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 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.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from inventory.models import Event, Container, Item from inventory.models import Event, Container, Item, Comment
from inventory.serializers import EventSerializer, ContainerSerializer, ItemSerializer, SearchResultSerializer from inventory.serializers import EventSerializer, ContainerSerializer, CommentSerializer, ItemSerializer, \
SearchResultSerializer
from base64 import b64decode from base64 import b64decode
@ -22,6 +23,20 @@ class ContainerViewSet(viewsets.ModelViewSet):
queryset = Container.objects.all() queryset = Container.objects.all()
class ItemViewSet(viewsets.ModelViewSet):
serializer_class = ItemSerializer
def prefetch_queryset(self, queryset):
serializer = self.get_serializer_class()
if hasattr(serializer, 'Meta') and hasattr(serializer.Meta, 'prefetch_related_fields'):
queryset = queryset.prefetch_related(*serializer.Meta.prefetch_related_fields)
return queryset
def get_queryset(self):
queryset = Item.objects.all()
return self.prefetch_queryset(queryset)
def filter_items(items, query): def filter_items(items, query):
query_tokens = query.split(' ') query_tokens = query.split(' ')
for item in items: for item in items:
@ -49,6 +64,7 @@ def search_items(request, event_slug, query):
@api_view(['GET', 'POST']) @api_view(['GET', 'POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def item(request, event_slug): def item(request, event_slug):
vs = ItemViewSet()
try: try:
event = None event = None
if event_slug != 'none': if event_slug != 'none':
@ -56,7 +72,7 @@ def item(request, event_slug):
if request.method == 'GET': if request.method == 'GET':
if not request.user.has_event_perm(event, '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(vs.prefetch_queryset(Item.objects.filter(event=event)), many=True).data)
elif request.method == 'POST': elif request.method == 'POST':
if not request.user.has_event_perm(event, 'add_item'): if not request.user.has_event_perm(event, 'add_item'):
return Response(status=403) return Response(status=403)
@ -67,6 +83,27 @@ def item(request, event_slug):
return Response(status=400) return Response(status=400)
except Event.DoesNotExist: except Event.DoesNotExist:
return Response(status=404) return Response(status=404)
except KeyError:
return Response(status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@permission_required('tickets.add_comment', raise_exception=True)
def add_comment(request, event_slug, id):
event = None
if event_slug != 'none':
event = Event.objects.get(slug=event_slug)
item = Item.objects.get(event=event, id=id)
if not request.user.has_event_perm(event, 'view_item'):
return Response(status=403)
if 'comment' not in request.data or request.data['comment'] == '':
return Response({'status': 'error', 'message': 'missing comment'}, status=status.HTTP_400_BAD_REQUEST)
comment = Comment.objects.create(
item=item,
comment=request.data['comment'],
)
return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
@api_view(['GET', 'PUT', 'DELETE', 'PATCH']) @api_view(['GET', 'PUT', 'DELETE', 'PATCH'])
@ -115,5 +152,6 @@ urlpatterns = router.urls + [
re_path(r'^(?P<event_slug>[\w-]+)/items/$', item, name='item'), re_path(r'^(?P<event_slug>[\w-]+)/items/$', item, name='item'),
re_path(r'^(?P<event_slug>[\w-]+)/items/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_items, name='search_items'), re_path(r'^(?P<event_slug>[\w-]+)/items/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_items, name='search_items'),
re_path(r'^(?P<event_slug>[\w-]+)/item/$', item, name='item'), re_path(r'^(?P<event_slug>[\w-]+)/item/$', item, name='item'),
re_path(r'^(?P<event_slug>[\w-]+)/item/(?P<id>\d+)/comment/$', add_comment, name='add_comment'),
re_path(r'^(?P<event_slug>[\w-]+)/item/(?P<id>\d+)/$', item_by_id, name='item_by_id'), re_path(r'^(?P<event_slug>[\w-]+)/item/(?P<id>\d+)/$', item_by_id, name='item_by_id'),
] ]

View file

@ -0,0 +1,52 @@
# Generated by Django 4.2.7 on 2024-11-23 15:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0006_alter_event_table'),
]
def set_initial_container(apps, schema_editor):
Item = apps.get_model('inventory', 'Item')
for item in Item.objects.all():
item.container_history.get_or_create(container=item.container_old)
item.save()
operations = [
migrations.RenameField(
model_name='item',
old_name='container',
new_name='container_old',
),
migrations.CreateModel(
name='ItemPlacement',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('container',
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_history',
to='inventory.container')),
('item',
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='container_history',
to='inventory.item')),
],
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('comment', models.TextField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments',
to='inventory.item')),
],
),
migrations.RunPython(set_initial_container),
migrations.RemoveField(
model_name='item',
name='container_old',
),
]

View file

@ -7,11 +7,14 @@ from django_softdelete.models import SoftDeleteModel, SoftDeleteManager
class ItemManager(SoftDeleteManager): class ItemManager(SoftDeleteManager):
def create(self, **kwargs): def create(self, **kwargs):
container = kwargs.pop('container')
if 'uid_deprecated' in kwargs: if 'uid_deprecated' in kwargs:
raise ValueError('uid_deprecated must not be set manually') raise ValueError('uid_deprecated must not be set manually')
uid_deprecated = Item.all_objects.filter(event=kwargs['event']).count() + 1 uid_deprecated = Item.all_objects.filter(event=kwargs['event']).count() + 1
kwargs['uid_deprecated'] = uid_deprecated kwargs['uid_deprecated'] = uid_deprecated
return super().create(**kwargs) item = super().create(**kwargs)
item.container = container
return item
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(returned_at__isnull=True) return super().get_queryset().filter(returned_at__isnull=True)
@ -22,18 +25,32 @@ class Item(SoftDeleteModel):
uid_deprecated = models.IntegerField() uid_deprecated = models.IntegerField()
description = models.TextField() description = models.TextField()
event = models.ForeignKey('Event', models.CASCADE) event = models.ForeignKey('Event', models.CASCADE)
container = models.ForeignKey('Container', models.CASCADE)
returned_at = models.DateTimeField(blank=True, null=True) returned_at = models.DateTimeField(blank=True, null=True)
created_at = models.DateTimeField(null=True, auto_now_add=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)
@property
def container(self):
try:
history = sorted(self.container_history.all(), key=lambda x: x.timestamp, reverse=True)
if history:
return history[0].container
else:
return None
except AttributeError:
return None
@container.setter
def container(self, value):
if self.container == value:
return
self.container_history.create(container=value)
@property @property
def related_issues(self): def related_issues(self):
groups = groupby(self.issue_relation_changes.all(), lambda rel: rel.issue_thread.id) groups = groupby(self.issue_relation_changes.all(), lambda rel: rel.issue_thread.id)
return [sorted(v, key=lambda r: r.timestamp)[0].issue_thread for k, v in groups] return [sorted(v, key=lambda r: r.timestamp)[0].issue_thread for k, v in groups]
objects = ItemManager() objects = ItemManager()
all_objects = models.Manager() all_objects = models.Manager()
@ -53,10 +70,35 @@ class Container(SoftDeleteModel):
created_at = models.DateTimeField(blank=True, null=True) created_at = models.DateTimeField(blank=True, null=True)
updated_at = models.DateTimeField(blank=True, null=True) updated_at = models.DateTimeField(blank=True, null=True)
@property
def items(self):
try:
history = self.item_history.order_by('-timestamp').all()
return [v for k, v in groupby(history, key=lambda item: item.item.id)]
except AttributeError:
return []
def __str__(self): def __str__(self):
return '[' + str(self.id) + ']' + self.name return '[' + str(self.id) + ']' + self.name
class ItemPlacement(models.Model):
id = models.AutoField(primary_key=True)
item = models.ForeignKey('Item', models.CASCADE, related_name='container_history')
container = models.ForeignKey('Container', models.CASCADE, related_name='item_history')
timestamp = models.DateTimeField(auto_now_add=True)
class Comment(models.Model):
id = models.AutoField(primary_key=True)
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='comments')
comment = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
def __str__(self):
return str(self.item) + ' comment #' + str(self.id)
class Event(models.Model): class Event(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
@ -72,4 +114,4 @@ class Event(models.Model):
return '[' + str(self.slug) + ']' + self.name return '[' + str(self.slug) + ']' + self.name
class Meta: class Meta:
db_table = 'common_event' db_table = 'common_event'

View file

@ -3,26 +3,28 @@ from rest_framework import serializers
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
from files.models import File from files.models import File
from inventory.models import Event, Container, Item from inventory.models import Event, Container, Item, Comment
from inventory.shared_serializers import BasicItemSerializer from inventory.shared_serializers import BasicItemSerializer
from mail.models import EventAddress from mail.models import EventAddress
from tickets.shared_serializers import BasicIssueSerializer from tickets.shared_serializers import BasicIssueSerializer
class EventAdressSerializer(serializers.ModelSerializer):
class Meta:
model = EventAddress
fields = ['address']
class EventSerializer(serializers.ModelSerializer): class EventSerializer(serializers.ModelSerializer):
addresses = EventAdressSerializer(many=True, required=False) addresses = SlugRelatedField(many=True, slug_field='address', queryset=EventAddress.objects.all())
class Meta: class Meta:
model = Event model = Event
fields = ['id', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end', 'addresses'] fields = ['id', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end', 'addresses']
read_only_fields = ['id'] read_only_fields = ['id']
def to_internal_value(self, data):
data = data.copy()
addresses = data.pop('addresses', None)
dict = super().to_internal_value(data)
if addresses:
dict['addresses'] = [EventAddress.objects.get_or_create(address=x)[0] for x in addresses]
return dict
class ContainerSerializer(serializers.ModelSerializer): class ContainerSerializer(serializers.ModelSerializer):
itemCount = serializers.SerializerMethodField() itemCount = serializers.SerializerMethodField()
@ -33,17 +35,37 @@ class ContainerSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'itemCount'] read_only_fields = ['id', 'itemCount']
def get_itemCount(self, instance): def get_itemCount(self, instance):
return Item.objects.filter(container=instance.id).count() return len(instance.items)
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', 'item')
class ItemSerializer(BasicItemSerializer): class ItemSerializer(BasicItemSerializer):
timeline = serializers.SerializerMethodField()
dataImage = serializers.CharField(write_only=True, required=False) dataImage = serializers.CharField(write_only=True, required=False)
related_issues = BasicIssueSerializer(many=True, read_only=True) related_issues = BasicIssueSerializer(many=True, read_only=True)
class Meta: class Meta:
model = Item model = Item
fields = ['cid', 'box', 'id', 'description', 'file', 'dataImage', 'returned', 'event', 'related_issues'] fields = ['cid', 'box', 'id', 'description', 'file', 'dataImage', 'returned', 'event', 'related_issues',
'timeline']
read_only_fields = ['id'] read_only_fields = ['id']
prefetch_related_fields = ['comments', 'issue_relation_changes', 'container_history',
'container_history__container', 'files', 'event',
'issue_relation_changes__issue_thread',
'issue_relation_changes__issue_thread__event',
'issue_relation_changes__issue_thread__state_changes',
'issue_relation_changes__issue_thread__assignments']
def to_internal_value(self, data): def to_internal_value(self, data):
container = None container = None
@ -84,6 +106,34 @@ class ItemSerializer(BasicItemSerializer):
instance.files.add(file) instance.files.add(file)
return super().update(instance, validated_data) return super().update(instance, validated_data)
@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 relation in (obj.issue_relation_changes.all()):
timeline.append({
'type': 'issue_relation',
'id': relation.id,
'status': relation.status,
'timestamp': relation.timestamp,
'issue_thread': BasicIssueSerializer(relation.issue_thread).data,
})
for placement in (obj.container_history.all()):
timeline.append({
'type': 'placement',
'id': placement.id,
'timestamp': placement.timestamp,
'cid': placement.container.id,
'box': placement.container.name
})
return sorted(timeline, key=lambda x: x['timestamp'])
class SearchResultSerializer(serializers.Serializer): class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField() search_score = serializers.IntegerField()

View file

@ -47,6 +47,20 @@ class EventTestCase(TestCase):
self.assertEqual(Event.objects.all()[0].slug, 'EVENT2') self.assertEqual(Event.objects.all()[0].slug, 'EVENT2')
self.assertEqual(Event.objects.all()[0].name, 'Event 2 new') self.assertEqual(Event.objects.all()[0].name, 'Event 2 new')
def test_update_event(self):
from rest_framework.test import APIClient
event = Event.objects.create(slug='EVENT1', name='Event 1')
response = APIClient().patch(f'/api/2/events/{event.id}/', {'addresses': ['foo@bar.baz', 'foo1@bar.baz']})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['slug'], 'EVENT1')
self.assertEqual(response.json()['name'], 'Event 1')
self.assertEqual(2, len(response.json()['addresses']))
self.assertEqual('foo@bar.baz', response.json()['addresses'][0])
self.assertEqual('foo1@bar.baz', response.json()['addresses'][1])
self.assertEqual(len(Event.objects.all()), 1)
self.assertEqual(Event.objects.all()[0].slug, 'EVENT1')
self.assertEqual(Event.objects.all()[0].name, 'Event 1')
def test_remove_event(self): def test_remove_event(self):
event = Event.objects.create(slug='EVENT1', name='Event 1') event = Event.objects.create(slug='EVENT1', name='Event 1')
Event.objects.create(slug='EVENT2', name='Event 2') Event.objects.create(slug='EVENT2', name='Event 2')
@ -55,10 +69,10 @@ class EventTestCase(TestCase):
self.assertEqual(response.status_code, 204) self.assertEqual(response.status_code, 204)
self.assertEqual(len(Event.objects.all()), 1) self.assertEqual(len(Event.objects.all()), 1)
def test_items2(self): def test_event_with_address(self):
from mail.models import EventAddress from mail.models import EventAddress
event1 = Event.objects.create(slug='TEST1', name='Event') event1 = Event.objects.create(slug='TEST1', name='Event')
EventAddress.objects.create(event=Event.objects.get(slug='TEST1'), address='foo@bar.baz') EventAddress.objects.create(event=event1, address='foo@bar.baz')
response = self.client.get('/api/2/events/') response = self.client.get('/api/2/events/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(1, len(response.json())) self.assertEqual(1, len(response.json()))
@ -66,3 +80,15 @@ class EventTestCase(TestCase):
self.assertEqual('Event', response.json()[0]['name']) self.assertEqual('Event', response.json()[0]['name'])
self.assertEqual(1, len(response.json()[0]['addresses'])) self.assertEqual(1, len(response.json()[0]['addresses']))
def test_items_remove_addresss(self):
from mail.models import EventAddress
from rest_framework.test import APIClient
event1 = Event.objects.create(slug='TEST1', name='Event')
EventAddress.objects.create(event=event1, address='foo@bar.baz')
EventAddress.objects.create(event=event1, address='fo1o@bar.baz')
response = APIClient().patch(f'/api/2/events/{event1.id}/', {'addresses': ['foo1@bar.baz']})
self.assertEqual(response.status_code, 200)
self.assertEqual('TEST1', response.json()['slug'])
self.assertEqual('Event', response.json()['name'])
self.assertEqual(1, len(response.json()['addresses']))
self.assertEqual('foo1@bar.baz', response.json()['addresses'][0])

View file

@ -1,3 +1,5 @@
from datetime import datetime, timedelta
from django.utils import timezone from django.utils import timezone
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
@ -5,72 +7,140 @@ from knox.models import AuthToken
from authentication.models import ExtendedUser from authentication.models import ExtendedUser
from files.models import File from files.models import File
from inventory.models import Event, Container, Item from inventory.models import Event, Container, Item, Comment, ItemPlacement
from base64 import b64encode from base64 import b64encode
from tickets.models import IssueThread, ItemRelation
class ItemTestCase(TestCase): class ItemTestCase(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event') self.event = Event.objects.create(slug='EVENT', name='Event')
self.box = Container.objects.create(name='BOX') self.box1 = Container.objects.create(name='BOX1')
self.box2 = Container.objects.create(name='BOX2')
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.all())
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]})
self.issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
def test_empty(self): def test_empty(self):
response = self.client.get(f'/api/2/{self.event.slug}/item/') response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'[]') self.assertEqual(response.content, b'[]')
def test_members(self): def test_members_and_timeline(self):
item = Item.objects.create(container=self.box, event=self.event, description='1') now = datetime.now()
item = Item.objects.create(container=self.box1, event=self.event, description='1')
comment = Comment.objects.create(
item=item,
comment="test",
timestamp=now + timedelta(seconds=3),
)
match = ItemRelation.objects.create(
issue_thread=self.issue,
item=item,
timestamp=now + timedelta(seconds=4),
)
placement = ItemPlacement.objects.create(
container=self.box2,
item=item,
timestamp=now + timedelta(seconds=5),
)
response = self.client.get(f'/api/2/{self.event.slug}/item/') response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1) self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['id'], item.id) self.assertEqual(response.json()[0]['id'], item.id)
self.assertEqual(response.json()[0]['description'], '1') self.assertEqual(response.json()[0]['description'], '1')
self.assertEqual(response.json()[0]['box'], 'BOX') self.assertEqual(response.json()[0]['box'], 'BOX2')
self.assertEqual(response.json()[0]['cid'], self.box.id) self.assertEqual(response.json()[0]['cid'], self.box2.id)
self.assertEqual(response.json()[0]['file'], None) self.assertEqual(response.json()[0]['file'], None)
self.assertEqual(response.json()[0]['returned'], False) self.assertEqual(response.json()[0]['returned'], False)
self.assertEqual(response.json()[0]['event'], self.event.slug) self.assertEqual(response.json()[0]['event'], self.event.slug)
self.assertEqual(len(response.json()[0]['related_issues']), 0) self.assertEqual(len(response.json()[0]['timeline']), 4)
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'placement')
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'issue_relation')
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'placement')
self.assertEqual(response.json()[0]['timeline'][1]['id'], comment.id)
self.assertEqual(response.json()[0]['timeline'][2]['id'], match.id)
self.assertEqual(response.json()[0]['timeline'][3]['id'], placement.id)
self.assertEqual(response.json()[0]['timeline'][0]['box'], 'BOX1')
self.assertEqual(response.json()[0]['timeline'][0]['cid'], self.box1.id)
self.assertEqual(response.json()[0]['timeline'][1]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][1]['timestamp'],
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][2]['status'], 'possible')
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'],
match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['name'], "test issue")
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['event'], "EVENT")
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['state'], "pending_new")
self.assertEqual(response.json()[0]['timeline'][3]['box'], 'BOX2')
self.assertEqual(response.json()[0]['timeline'][3]['cid'], self.box2.id)
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
placement.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(len(response.json()[0]['related_issues']), 1)
self.assertEqual(response.json()[0]['related_issues'][0]['name'], "test issue")
self.assertEqual(response.json()[0]['related_issues'][0]['event'], "EVENT")
self.assertEqual(response.json()[0]['related_issues'][0]['state'], "pending_new")
def test_members_with_file(self): def test_members_with_file(self):
import base64 import base64
item = Item.objects.create(container=self.box, event=self.event, description='1') item = Item.objects.create(container=self.box1, event=self.event, description='1')
file = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')) file = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8'))
response = self.client.get(f'/api/2/{self.event.slug}/item/') response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1) self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['id'], item.id) self.assertEqual(response.json()[0]['id'], item.id)
self.assertEqual(response.json()[0]['description'], '1') self.assertEqual(response.json()[0]['description'], '1')
self.assertEqual(response.json()[0]['box'], 'BOX') self.assertEqual(response.json()[0]['box'], 'BOX1')
self.assertEqual(response.json()[0]['cid'], self.box.id) self.assertEqual(response.json()[0]['cid'], self.box1.id)
self.assertEqual(response.json()[0]['file'], file.hash) self.assertEqual(response.json()[0]['file'], file.hash)
self.assertEqual(response.json()[0]['returned'], False) self.assertEqual(response.json()[0]['returned'], False)
self.assertEqual(response.json()[0]['event'], self.event.slug) self.assertEqual(response.json()[0]['event'], self.event.slug)
self.assertEqual(len(response.json()[0]['related_issues']), 0) self.assertEqual(len(response.json()[0]['related_issues']), 0)
def test_members_with_two_file(self):
import base64
item = Item.objects.create(container=self.box1, event=self.event, description='1')
file1 = File.objects.create(item=item,
data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8'))
file2 = File.objects.create(item=item,
data="data:text/plain;base64," + base64.b64encode(b"bar").decode('utf-8'))
response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['id'], item.id)
self.assertEqual(response.json()[0]['description'], '1')
self.assertEqual(response.json()[0]['box'], 'BOX1')
self.assertEqual(response.json()[0]['cid'], self.box1.id)
self.assertEqual(response.json()[0]['file'], file2.hash)
self.assertEqual(response.json()[0]['returned'], False)
self.assertEqual(response.json()[0]['event'], self.event.slug)
self.assertEqual(len(response.json()[0]['related_issues']), 0)
def test_multi_members(self): def test_multi_members(self):
Item.objects.create(container=self.box, event=self.event, description='1') Item.objects.create(container=self.box1, event=self.event, description='1')
Item.objects.create(container=self.box, event=self.event, description='2') Item.objects.create(container=self.box1, event=self.event, description='2')
Item.objects.create(container=self.box, event=self.event, description='3') Item.objects.create(container=self.box1, event=self.event, description='3')
response = self.client.get(f'/api/2/{self.event.slug}/item/') response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 3) self.assertEqual(len(response.json()), 3)
def test_create_item(self): def test_create_item(self):
response = self.client.post(f'/api/2/{self.event.slug}/item/', {'cid': self.box.id, 'description': '1'}) response = self.client.post(f'/api/2/{self.event.slug}/item/', {'cid': self.box1.id, 'description': '1'})
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['id'], 1) self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['description'], '1') self.assertEqual(response.json()['description'], '1')
self.assertEqual(response.json()['box'], 'BOX') self.assertEqual(response.json()['box'], 'BOX1')
self.assertEqual(response.json()['cid'], self.box.id) self.assertEqual(response.json()['cid'], self.box1.id)
self.assertEqual(response.json()['file'], None) self.assertEqual(response.json()['file'], None)
self.assertEqual(response.json()['returned'], False) self.assertEqual(response.json()['returned'], False)
self.assertEqual(response.json()['event'], self.event.slug) self.assertEqual(response.json()['event'], self.event.slug)
@ -78,43 +148,43 @@ class ItemTestCase(TestCase):
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1) self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].description, '1') self.assertEqual(Item.objects.all()[0].description, '1')
self.assertEqual(Item.objects.all()[0].container.id, self.box.id) self.assertEqual(Item.objects.all()[0].container.id, self.box1.id)
def test_create_item_without_container(self): def test_create_item_without_container(self):
response = self.client.post(f'/api/2/{self.event.slug}/item/', {'description': '1'}) response = self.client.post(f'/api/2/{self.event.slug}/item/', {'description': '1'})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_create_item_without_description(self): def test_create_item_without_description(self):
response = self.client.post(f'/api/2/{self.event.slug}/item/', {'cid': self.box.id}) response = self.client.post(f'/api/2/{self.event.slug}/item/', {'cid': self.box1.id})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_create_item_with_file(self): def test_create_item_with_file(self):
import base64 import base64
response = self.client.post(f'/api/2/{self.event.slug}/item/', response = self.client.post(f'/api/2/{self.event.slug}/item/',
{'cid': self.box.id, 'description': '1', {'cid': self.box1.id, 'description': '1',
'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode( 'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode(
'utf-8')}, content_type='application/json') 'utf-8')}, content_type='application/json')
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['id'], 1) self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['description'], '1') self.assertEqual(response.json()['description'], '1')
self.assertEqual(response.json()['box'], 'BOX') self.assertEqual(response.json()['box'], 'BOX1')
self.assertEqual(response.json()['id'], self.box.id) self.assertEqual(response.json()['id'], self.box1.id)
self.assertEqual(len(response.json()['file']), 64) self.assertEqual(len(response.json()['file']), 64)
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1) self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].description, '1') self.assertEqual(Item.objects.all()[0].description, '1')
self.assertEqual(Item.objects.all()[0].container.id, self.box.id) self.assertEqual(Item.objects.all()[0].container.id, self.box1.id)
self.assertEqual(len(File.objects.all()), 1) self.assertEqual(len(File.objects.all()), 1)
def test_update_item(self): def test_update_item(self):
item = Item.objects.create(container=self.box, event=self.event, description='1') item = Item.objects.create(container=self.box1, event=self.event, description='1')
response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/', {'description': '2'}, response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/', {'description': '2'},
content_type='application/json') content_type='application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['id'], item.id) self.assertEqual(response.json()['id'], item.id)
self.assertEqual(response.json()['description'], '2') self.assertEqual(response.json()['description'], '2')
self.assertEqual(response.json()['box'], 'BOX') self.assertEqual(response.json()['box'], 'BOX1')
self.assertEqual(response.json()['cid'], self.box.id) self.assertEqual(response.json()['cid'], self.box1.id)
self.assertEqual(response.json()['file'], None) self.assertEqual(response.json()['file'], None)
self.assertEqual(response.json()['returned'], False) self.assertEqual(response.json()['returned'], False)
self.assertEqual(response.json()['event'], self.event.slug) self.assertEqual(response.json()['event'], self.event.slug)
@ -122,60 +192,62 @@ class ItemTestCase(TestCase):
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1) self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].description, '2') self.assertEqual(Item.objects.all()[0].description, '2')
self.assertEqual(Item.objects.all()[0].container.id, self.box.id) self.assertEqual(Item.objects.all()[0].container.id, self.box1.id)
def test_update_item_with_file(self): def test_update_item_with_file(self):
import base64 import base64
item = Item.objects.create(container=self.box, event=self.event, description='1') item = Item.objects.create(container=self.box1, event=self.event, description='1')
response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/', response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/',
{'description': '2', {'description': '2',
'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')}, 'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode(
content_type='application/json') 'utf-8')},
content_type='application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['id'], 1) self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['description'], '2') self.assertEqual(response.json()['description'], '2')
self.assertEqual(response.json()['box'], 'BOX') self.assertEqual(response.json()['box'], 'BOX1')
self.assertEqual(response.json()['id'], self.box.id) self.assertEqual(response.json()['id'], self.box1.id)
self.assertEqual(len(response.json()['file']), 64) self.assertEqual(len(response.json()['file']), 64)
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1) self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].description, '2') self.assertEqual(Item.objects.all()[0].description, '2')
self.assertEqual(Item.objects.all()[0].container.id, self.box.id) self.assertEqual(Item.objects.all()[0].container.id, self.box1.id)
self.assertEqual(len(File.objects.all()), 1) self.assertEqual(len(File.objects.all()), 1)
def test_delete_item(self): def test_delete_item(self):
item = Item.objects.create(container=self.box, event=self.event, description='1') item = Item.objects.create(container=self.box1, event=self.event, description='1')
Item.objects.create(container=self.box, event=self.event, description='2') Item.objects.create(container=self.box1, event=self.event, description='2')
self.assertEqual(len(Item.objects.all()), 2) self.assertEqual(len(Item.objects.all()), 2)
response = self.client.delete(f'/api/2/{self.event.slug}/item/{item.id}/') response = self.client.delete(f'/api/2/{self.event.slug}/item/{item.id}/')
self.assertEqual(response.status_code, 204) self.assertEqual(response.status_code, 204)
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
def test_delete_item2(self): def test_delete_item2(self):
Item.objects.create(container=self.box, event=self.event, description='1') Item.objects.create(container=self.box1, event=self.event, description='1')
item2 = Item.objects.create(container=self.box, event=self.event, description='2') item2 = Item.objects.create(container=self.box1, event=self.event, description='2')
self.assertEqual(len(Item.objects.all()), 2) self.assertEqual(len(Item.objects.all()), 2)
response = self.client.delete(f'/api/2/{self.event.slug}/item/{item2.id}/') response = self.client.delete(f'/api/2/{self.event.slug}/item/{item2.id}/')
self.assertEqual(response.status_code, 204) self.assertEqual(response.status_code, 204)
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
item3 = Item.objects.create(container=self.box, event=self.event, description='3') item3 = Item.objects.create(container=self.box1, event=self.event, description='3')
self.assertEqual(item3.id, 3) self.assertEqual(item3.id, 3)
self.assertEqual(len(Item.objects.all()), 2) self.assertEqual(len(Item.objects.all()), 2)
def test_item_count(self): def test_item_count(self):
Item.objects.create(container=self.box, event=self.event, description='1') Item.objects.create(container=self.box1, event=self.event, description='1')
Item.objects.create(container=self.box, event=self.event, description='2') Item.objects.create(container=self.box1, event=self.event, description='2')
response = self.client.get('/api/2/boxes/') response = self.client.get('/api/2/boxes/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1) self.assertEqual(len(response.json()), 2)
self.assertEqual(response.json()[0]['itemCount'], 2) self.assertEqual(response.json()[0]['itemCount'], 2)
self.assertEqual(response.json()[1]['itemCount'], 0)
def test_item_nonexistent(self): def test_item_nonexistent(self):
response = self.client.get(f'/api/2/NOEVENT/item/') response = self.client.get(f'/api/2/NOEVENT/item/')
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_item_return(self): def test_item_return(self):
item = Item.objects.create(container=self.box, event=self.event, description='1') item = Item.objects.create(container=self.box1, event=self.event, description='1')
self.assertEqual(item.returned_at, None) self.assertEqual(item.returned_at, None)
response = self.client.get(f'/api/2/{self.event.slug}/item/') response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -190,8 +262,8 @@ class ItemTestCase(TestCase):
self.assertEqual(len(response.json()), 0) self.assertEqual(len(response.json()), 0)
def test_item_show_not_returned(self): def test_item_show_not_returned(self):
item1 = Item.objects.create(container=self.box, event=self.event, description='1') item1 = Item.objects.create(container=self.box1, event=self.event, description='1')
item2 = Item.objects.create(container=self.box, event=self.event, description='2') item2 = Item.objects.create(container=self.box1, event=self.event, description='2')
response = self.client.get(f'/api/2/{self.event.slug}/item/') response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 2) self.assertEqual(len(response.json()), 2)
@ -208,15 +280,15 @@ class ItemSearchTestCase(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event') self.event = Event.objects.create(slug='EVENT', name='Event')
self.box = Container.objects.create(name='BOX') self.box1 = Container.objects.create(name='BOX1')
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.all())
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]})
self.item1 = Item.objects.create(container=self.box, event=self.event, description='abc def') self.item1 = Item.objects.create(container=self.box1, event=self.event, description='abc def')
self.item2 = Item.objects.create(container=self.box, event=self.event, description='def ghi') self.item2 = Item.objects.create(container=self.box1, event=self.event, description='def ghi')
self.item3 = Item.objects.create(container=self.box, event=self.event, description='jkl mno pqr') self.item3 = Item.objects.create(container=self.box1, event=self.event, description='jkl mno pqr')
self.item4 = Item.objects.create(container=self.box, event=self.event, description='stu vwx') self.item4 = Item.objects.create(container=self.box1, event=self.event, description='stu vwx')
def test_search(self): def test_search(self):
search_query = b64encode(b'abc').decode('utf-8') search_query = b64encode(b'abc').decode('utf-8')
@ -225,8 +297,8 @@ class ItemSearchTestCase(TestCase):
self.assertEqual(1, len(response.json())) self.assertEqual(1, len(response.json()))
self.assertEqual(self.item1.id, response.json()[0]['id']) self.assertEqual(self.item1.id, response.json()[0]['id'])
self.assertEqual('abc def', response.json()[0]['description']) self.assertEqual('abc def', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box']) self.assertEqual('BOX1', response.json()[0]['box'])
self.assertEqual(self.box.id, response.json()[0]['cid']) self.assertEqual(self.box1.id, response.json()[0]['cid'])
self.assertEqual(1, response.json()[0]['search_score']) self.assertEqual(1, response.json()[0]['search_score'])
def test_search2(self): def test_search2(self):
@ -236,13 +308,13 @@ class ItemSearchTestCase(TestCase):
self.assertEqual(2, len(response.json())) self.assertEqual(2, len(response.json()))
self.assertEqual(self.item1.id, response.json()[0]['id']) self.assertEqual(self.item1.id, response.json()[0]['id'])
self.assertEqual('abc def', response.json()[0]['description']) self.assertEqual('abc def', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box']) self.assertEqual('BOX1', response.json()[0]['box'])
self.assertEqual(self.box.id, response.json()[0]['cid']) self.assertEqual(self.box1.id, response.json()[0]['cid'])
self.assertEqual(1, response.json()[0]['search_score']) self.assertEqual(1, response.json()[0]['search_score'])
self.assertEqual(self.box.id, response.json()[1]['cid']) self.assertEqual(self.item2.id, response.json()[1]['id'])
self.assertEqual('def ghi', response.json()[1]['description']) self.assertEqual('def ghi', response.json()[1]['description'])
self.assertEqual('BOX', response.json()[1]['box']) self.assertEqual('BOX1', response.json()[1]['box'])
self.assertEqual(self.box.id, response.json()[1]['cid']) self.assertEqual(self.box1.id, response.json()[1]['cid'])
self.assertEqual(1, response.json()[0]['search_score']) self.assertEqual(1, response.json()[0]['search_score'])
def test_search3(self): def test_search3(self):
@ -252,8 +324,8 @@ class ItemSearchTestCase(TestCase):
self.assertEqual(1, len(response.json())) self.assertEqual(1, len(response.json()))
self.assertEqual(self.item3.id, response.json()[0]['id']) self.assertEqual(self.item3.id, response.json()[0]['id'])
self.assertEqual('jkl mno pqr', response.json()[0]['description']) self.assertEqual('jkl mno pqr', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box']) self.assertEqual('BOX1', response.json()[0]['box'])
self.assertEqual(self.box.id, response.json()[0]['cid']) self.assertEqual(self.box1.id, response.json()[0]['cid'])
self.assertEqual(1, response.json()[0]['search_score']) self.assertEqual(1, response.json()[0]['search_score'])
def test_search4(self): def test_search4(self):
@ -263,12 +335,11 @@ class ItemSearchTestCase(TestCase):
self.assertEqual(2, len(response.json())) self.assertEqual(2, len(response.json()))
self.assertEqual(self.item1.id, response.json()[0]['id']) self.assertEqual(self.item1.id, response.json()[0]['id'])
self.assertEqual('abc def', response.json()[0]['description']) self.assertEqual('abc def', response.json()[0]['description'])
self.assertEqual('BOX', response.json()[0]['box']) self.assertEqual('BOX1', response.json()[0]['box'])
self.assertEqual(self.box.id, response.json()[0]['cid']) self.assertEqual(self.box1.id, response.json()[0]['cid'])
self.assertEqual(2, response.json()[0]['search_score']) self.assertEqual(2, response.json()[0]['search_score'])
self.assertEqual(self.item2.id, response.json()[1]['id']) self.assertEqual(self.item2.id, response.json()[1]['id'])
self.assertEqual('def ghi', response.json()[1]['description']) self.assertEqual('def ghi', response.json()[1]['description'])
self.assertEqual('BOX', response.json()[1]['box']) self.assertEqual('BOX1', response.json()[1]['box'])
self.assertEqual(self.box.id, response.json()[1]['cid']) self.assertEqual(self.box1.id, response.json()[1]['cid'])
self.assertEqual(1, response.json()[1]['search_score']) self.assertEqual(1, response.json()[1]['search_score'])

View file

@ -21,7 +21,13 @@ from tickets.shared_serializers import RelationSerializer
class IssueViewSet(viewsets.ModelViewSet): class IssueViewSet(viewsets.ModelViewSet):
serializer_class = IssueSerializer serializer_class = IssueSerializer
queryset = IssueThread.objects.all().prefetch_related('state_changes', 'comments', 'emails', 'emails__attachments', 'assignments', 'assignments__assigned_to__username', 'item_relation_changes', 'shipping_vouchers')
def get_queryset(self):
queryset = IssueThread.objects.all()
serializer = self.get_serializer_class()
if hasattr(serializer, 'Meta') and hasattr(serializer.Meta, 'prefetch_related_fields'):
queryset = queryset.prefetch_related(*serializer.Meta.prefetch_related_fields)
return queryset
class RelationViewSet(viewsets.ModelViewSet): class RelationViewSet(viewsets.ModelViewSet):
@ -148,12 +154,12 @@ def filter_issues(issues, query):
@api_view(['GET']) @api_view(['GET'])
@permission_classes([]) @permission_classes([IsAuthenticated])
# @permission_classes([IsAuthenticated])
# @permission_required('view_item', raise_exception=True)
def search_issues(request, event_slug, query): def search_issues(request, event_slug, query):
try: try:
event = Event.objects.get(slug=event_slug) event = Event.objects.get(slug=event_slug)
if not request.user.has_event_perm(event, 'view_issuethread'):
return Response(status=403)
items = filter_issues(IssueThread.objects.filter(event=event), b64decode(query).decode('utf-8')) items = filter_issues(IssueThread.objects.filter(event=event), b64decode(query).decode('utf-8'))
return Response(SearchResultSerializer(items, many=True).data) return Response(SearchResultSerializer(items, many=True).data)
except Event.DoesNotExist: except Event.DoesNotExist:
@ -171,5 +177,5 @@ urlpatterns = ([
re_path(r'^tickets/(?P<pk>\d+)/comment/$', add_comment, name='add_comment'), re_path(r'^tickets/(?P<pk>\d+)/comment/$', add_comment, name='add_comment'),
re_path(r'^(?P<event_slug>[\w-]+)/tickets/manual/$', manual_ticket, name='manual_ticket'), re_path(r'^(?P<event_slug>[\w-]+)/tickets/manual/$', manual_ticket, name='manual_ticket'),
re_path(r'^(?P<event_slug>[\w-]+)/tickets/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_issues, re_path(r'^(?P<event_slug>[\w-]+)/tickets/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_issues,
name='search_issues'), name='search_issues'),
] + router.urls) ] + router.urls)

View file

@ -47,6 +47,12 @@ class IssueSerializer(BasicIssueSerializer):
model = IssueThread model = IssueThread
fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items', 'event') fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items', 'event')
read_only_fields = ('id', 'timeline', 'last_activity', 'uuid', 'related_items') read_only_fields = ('id', 'timeline', 'last_activity', 'uuid', 'related_items')
prefetch_related_fields = ['state_changes', 'comments', 'emails', 'emails__attachments', 'assignments',
'item_relation_changes', 'shipping_vouchers', 'item_relation_changes__item',
'item_relation_changes__item__container_history', 'event',
'item_relation_changes__item__container_history__container',
'item_relation_changes__item__files', 'item_relation_changes__item__event',
'item_relation_changes__item__event']
def to_internal_value(self, data): def to_internal_value(self, data):
ret = super().to_internal_value(data) ret = super().to_internal_value(data)
@ -63,12 +69,14 @@ class IssueSerializer(BasicIssueSerializer):
@staticmethod @staticmethod
def get_last_activity(self): def get_last_activity(self):
try: try:
last_state_change = max([t.timestamp for t in self.state_changes.all()]) if self.state_changes.exists() else None last_state_change = max(
[t.timestamp for t in self.state_changes.all()]) if self.state_changes.exists() else None
last_comment = max([t.timestamp for t in self.comments.all()]) if self.comments.exists() else None last_comment = max([t.timestamp for t in self.comments.all()]) if self.comments.exists() else None
last_mail = max([t.timestamp for t in self.emails.all()]) if self.emails.exists() else None last_mail = max([t.timestamp for t in self.emails.all()]) if self.emails.exists() else None
last_assignment = max([t.timestamp for t in self.assignments.all()]) if self.assignments.exists() else None last_assignment = max([t.timestamp for t in self.assignments.all()]) if self.assignments.exists() else None
last_relation = max([t.timestamp for t in self.item_relation_changes.all()]) if self.item_relation_changes.exists() else None last_relation = max([t.timestamp for t in
self.item_relation_changes.all()]) if self.item_relation_changes.exists() else None
args = [x for x in [last_state_change, last_comment, last_mail, last_assignment, last_relation] if args = [x for x in [last_state_change, last_comment, last_mail, last_assignment, last_relation] if
x is not None] x is not None]
return max(args) return max(args)
@ -129,7 +137,6 @@ class IssueSerializer(BasicIssueSerializer):
return sorted(timeline, key=lambda x: x['timestamp']) return sorted(timeline, key=lambda x: x['timestamp'])
class SearchResultSerializer(serializers.Serializer): class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField() search_score = serializers.IntegerField()
item = IssueSerializer() item = IssueSerializer()

View file

@ -5,7 +5,7 @@ from django.test import TestCase, Client
from authentication.models import ExtendedUser from authentication.models import ExtendedUser
from inventory.models import Event, Container, Item from inventory.models import Event, Container, Item
from mail.models import Email, EmailAttachment from mail.models import Email, EmailAttachment
from tickets.models import IssueThread, StateChange, Comment, ItemRelation from tickets.models import IssueThread, StateChange, Comment, ItemRelation, Assignment
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from knox.models import AuthToken from knox.models import AuthToken
@ -53,19 +53,24 @@ class IssueApiTest(TestCase):
in_reply_to=mail1.reference, in_reply_to=mail1.reference,
timestamp=now + timedelta(seconds=2), timestamp=now + timedelta(seconds=2),
) )
assignment = Assignment.objects.create(
issue_thread=issue,
assigned_to=self.user,
timestamp=now + timedelta(seconds=3),
)
comment = Comment.objects.create( comment = Comment.objects.create(
issue_thread=issue, issue_thread=issue,
comment="test", comment="test",
timestamp=now + timedelta(seconds=3), timestamp=now + timedelta(seconds=4),
) )
match = ItemRelation.objects.create( match = ItemRelation.objects.create(
issue_thread=issue, issue_thread=issue,
item = self.item, item=self.item,
timestamp=now + timedelta(seconds=5), timestamp=now + timedelta(seconds=5),
) )
self.assertEqual('pending_new', issue.state) self.assertEqual('pending_new', issue.state)
self.assertEqual('test issue', issue.name) self.assertEqual('test issue', issue.name)
self.assertEqual(None, issue.assigned_to) self.assertEqual(self.user, issue.assigned_to)
self.assertEqual(36, len(issue.uuid)) self.assertEqual(36, len(issue.uuid))
response = self.client.get('/api/2/tickets/') response = self.client.get('/api/2/tickets/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -74,14 +79,16 @@ class IssueApiTest(TestCase):
self.assertEqual(response.json()[0]['name'], "test issue") self.assertEqual(response.json()[0]['name'], "test issue")
self.assertEqual(response.json()[0]['state'], "pending_new") self.assertEqual(response.json()[0]['state'], "pending_new")
self.assertEqual(response.json()[0]['event'], "evt") self.assertEqual(response.json()[0]['event'], "evt")
self.assertEqual(response.json()[0]['assigned_to'], None) self.assertEqual(response.json()[0]['assigned_to'], self.user.username)
self.assertEqual(response.json()[0]['uuid'], issue.uuid) self.assertEqual(response.json()[0]['uuid'], issue.uuid)
self.assertEqual(response.json()[0]['last_activity'], match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) self.assertEqual(response.json()[0]['last_activity'], match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(len(response.json()[0]['timeline']), 5) self.assertEqual(len(response.json()[0]['timeline']), 6)
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'state') self.assertEqual(response.json()[0]['timeline'][0]['type'], 'state')
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'mail') self.assertEqual(response.json()[0]['timeline'][1]['type'], 'mail')
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'mail') self.assertEqual(response.json()[0]['timeline'][2]['type'], 'mail')
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'comment') self.assertEqual(response.json()[0]['timeline'][3]['type'], 'assignment')
self.assertEqual(response.json()[0]['timeline'][4]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][5]['type'], 'item_relation')
self.assertEqual(response.json()[0]['timeline'][1]['id'], mail1.id) self.assertEqual(response.json()[0]['timeline'][1]['id'], mail1.id)
self.assertEqual(response.json()[0]['timeline'][2]['id'], mail2.id) self.assertEqual(response.json()[0]['timeline'][2]['id'], mail2.id)
self.assertEqual(response.json()[0]['timeline'][3]['id'], comment.id) self.assertEqual(response.json()[0]['timeline'][3]['id'], comment.id)
@ -98,15 +105,18 @@ class IssueApiTest(TestCase):
self.assertEqual(response.json()[0]['timeline'][2]['body'], 'test') self.assertEqual(response.json()[0]['timeline'][2]['body'], 'test')
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'], self.assertEqual(response.json()[0]['timeline'][2]['timestamp'],
mail2.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) mail2.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][3]['comment'], 'test') self.assertEqual(response.json()[0]['timeline'][3]['assigned_to'], self.user.username)
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')) assignment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][4]['status'], 'possible') self.assertEqual(response.json()[0]['timeline'][4]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][4]['timestamp'], self.assertEqual(response.json()[0]['timeline'][4]['timestamp'],
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][5]['status'], 'possible')
self.assertEqual(response.json()[0]['timeline'][5]['timestamp'],
match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][4]['item']['description'], "foo") self.assertEqual(response.json()[0]['timeline'][5]['item']['description'], "foo")
self.assertEqual(response.json()[0]['timeline'][4]['item']['event'], "evt") self.assertEqual(response.json()[0]['timeline'][5]['item']['event'], "evt")
self.assertEqual(response.json()[0]['timeline'][4]['item']['box'], "box1") self.assertEqual(response.json()[0]['timeline'][5]['item']['box'], "box1")
self.assertEqual(response.json()[0]['related_items'][0]['description'], "foo") self.assertEqual(response.json()[0]['related_items'][0]['description'], "foo")
self.assertEqual(response.json()[0]['related_items'][0]['event'], "evt") self.assertEqual(response.json()[0]['related_items'][0]['event'], "evt")
self.assertEqual(response.json()[0]['related_items'][0]['box'], "box1") self.assertEqual(response.json()[0]['related_items'][0]['box'], "box1")

View file

@ -75,6 +75,14 @@ server {
allow 127.0.0.1; allow 127.0.0.1;
allow ::1; allow ::1;
deny all; deny all;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_redirect off;
proxy_buffering off;
proxy_pass http://c3lf-sys3;
} }
listen 443 ssl http2; # managed by Certbot listen 443 ssl http2; # managed by Certbot

View file

@ -1,5 +1,6 @@
<template> <template>
<div style="min-height: 100vh; display: flex; flex-direction: column;"> <div style="min-height: 100vh; display: flex; flex-direction: column;">
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="openLightboxModalWith(null)"/>
<AddItemModal v-if="addItemModalOpen && isLoggedIn" @close="closeAddItemModal()" isModal="true"/> <AddItemModal v-if="addItemModalOpen && isLoggedIn" @close="closeAddItemModal()" isModal="true"/>
<AddTicketModal v-if="addTicketModalOpen && isLoggedIn" @close="closeAddTicketModal()" isModal="true"/> <AddTicketModal v-if="addTicketModalOpen && isLoggedIn" @close="closeAddTicketModal()" isModal="true"/>
<AddBoxModal v-if="showAddBoxModal && isLoggedIn" @close="closeAddBoxModal()" isModal="true"/> <AddBoxModal v-if="showAddBoxModal && isLoggedIn" @close="closeAddBoxModal()" isModal="true"/>
@ -16,20 +17,22 @@ import {mapState, mapMutations, mapActions, mapGetters} from 'vuex';
import AddTicketModal from "@/components/AddTicketModal.vue"; import AddTicketModal from "@/components/AddTicketModal.vue";
import AddBoxModal from "@/components/AddBoxModal.vue"; import AddBoxModal from "@/components/AddBoxModal.vue";
import AddEventModal from "@/components/AddEventModal.vue"; import AddEventModal from "@/components/AddEventModal.vue";
import Lightbox from "@/components/Lightbox.vue";
export default { export default {
name: 'app', name: 'app',
components: {AddBoxModal, AddEventModal, Navbar, AddItemModal, AddTicketModal}, components: {Lightbox, AddBoxModal, AddEventModal, Navbar, AddItemModal, AddTicketModal},
computed: { computed: {
...mapState(['loadedItems', 'layout', 'toasts', 'showAddBoxModal', 'showAddEventModal']), ...mapState(['loadedItems', 'layout', 'toasts', 'showAddBoxModal', 'showAddEventModal', 'lightboxHash']),
...mapGetters(['isLoggedIn']), ...mapGetters(['isLoggedIn']),
}, },
data: () => ({ data: () => ({
addItemModalOpen: false, addItemModalOpen: false,
addTicketModalOpen: false addTicketModalOpen: false,
}), }),
methods: { methods: {
...mapMutations(['removeToast', 'createToast', 'closeAddBoxModal', 'openAddBoxModal', 'closeAddEventModal']), ...mapMutations(['removeToast', 'createToast', 'closeAddBoxModal', 'openAddBoxModal', 'closeAddEventModal',
'openLightboxModalWith']),
...mapActions(['loadEvents', 'scheduleAfterInit']), ...mapActions(['loadEvents', 'scheduleAfterInit']),
openAddItemModal() { openAddItemModal() {
this.addItemModalOpen = true; this.addItemModalOpen = true;
@ -42,7 +45,7 @@ export default {
}, },
closeAddTicketModal() { closeAddTicketModal() {
this.addTicketModalOpen = false; this.addTicketModalOpen = false;
} },
}, },
created: function () { created: function () {
document.title = document.location.hostname; document.title = document.location.hostname;

View file

@ -42,19 +42,27 @@ export default {
url: this.src, url: this.src,
data: this.image_data data: this.image_data
}); });
},
deferImage() {
setTimeout(() => {
if (this.cached) {
const c = this.getThumbnail(this.src);
if (c) {
this.image_data = c;
return;
}
}
this.loadImage();
}, 0);
}
},
watch: {
src: function (newVal, oldVal) {
this.deferImage()
} }
}, },
mounted() { mounted() {
setTimeout(() => { this.deferImage();
if (this.cached) {
const c = this.getThumbnail(this.src);
if (c) {
this.image_data = c;
return;
}
}
this.loadImage();
}, 0);
} }
} }
</script> </script>

View file

@ -52,7 +52,7 @@ export default {
}; };
}, },
created() { created() {
const query = this.$router.currentRoute ? (this.$router.currentRoute.query ? this.$router.currentRoute.query.collapsed : null) : null; const query = this.route ? (this.route.query ? this.route.query.collapsed : null) : null;
if (query !== null && query !== undefined) { if (query !== null && query !== undefined) {
this.collapsed = this.unpackInt(parseInt(query), this.sections.length); this.collapsed = this.unpackInt(parseInt(query), this.sections.length);
} else { } else {
@ -84,8 +84,8 @@ export default {
const encoded = this.packInt(this.collapsed).toString() const encoded = this.packInt(this.collapsed).toString()
if (this.route.query.collapsed !== encoded) if (this.route.query.collapsed !== encoded)
this.$router.push({ this.$router.push({
...this.$router.currentRoute, ...this.route,
query: {...this.$router.currentRoute.query, collapsed: encoded} query: {...this.route.query, collapsed: encoded}
}); });
}, },
deep: true, deep: true,

View file

@ -12,13 +12,16 @@
field="description" field="description"
:validation-fn="str => str && str.length > 0" :validation-fn="str => str && str.length > 0"
/> />
<InputCombo <div class="form-group">
label="box" <label for="box">box</label>
:model="item" <InputCombo
nameKey="box" label="box"
uniqueKey="cid" :model="item"
:options="boxes" nameKey="box"
/> uniqueKey="cid"
:options="boxes"
/>
</div>
</div> </div>
</template> </template>

View file

@ -0,0 +1,124 @@
<template>
<table class="table table-striped table-dark">
<thead>
<tr>
<th>
</th>
<th scope="col" v-for="(column, index) in columns" :key="index">
<div class="input-group">
<div class="input-group-prepend">
<button
:class="[ 'btn', column === sortBy ? 'btn-outline-info' : 'btn-outline-secondary' ]"
@click="toggleSort(column)"
>
{{ column }}
<span :class="{ 'text-info': column === sortBy }">
<font-awesome-icon :icon="getSortIcon(column)"/>
</span>
</button>
</div>
<input
type="text"
class="form-control"
placeholder="filter"
:value="filters[column]"
@input="changeFilter(column, $event.target.value)"
>
</div>
</th>
<th>
<slot name="header_actions"/>
</th>
</tr>
</thead>
<tbody v-for="(item, index) in internalItems" :key="item[keyName]" style="border-top: none">
<tr @click="toggle(index)">
<td>
<font-awesome-icon icon="angle-right" style="width: 1em;" v-if="collapsed[index]"/>
<font-awesome-icon icon="angle-down" style="width: 1em;" v-else/>
</td>
<td v-for="(column, index) in columns" :key="index">{{ item[column] }}</td>
<td>
<slot v-bind:id="index" v-bind:item="item" name="actions"/>
</td>
</tr>
<tr v-if="!collapsed[index]">
<td :colspan="columns.length + 2">
<slot v-bind:id="index" v-bind:item="item" name="detail"/>
</td>
</tr>
</tbody>
</table>
</template>
<script>
import DataContainer from '@/mixins/data-container';
import router from '../router';
import {mapGetters} from "vuex";
export default {
name: 'ExpandableTable',
mixins: [DataContainer],
created() {
this.columns.map(e => ({
k: e,
v: this.$store.getters.getFilters[e]
})).filter(e => e.v).forEach(e => this.setFilter(e.k, e.v));
const query = this.route ? (this.route.query ? this.route.query.collapsed : null) : null;
if (query !== null && query !== undefined) {
this.collapsed = this.unpackInt(parseInt(query), this.internalItems.length);
} else {
this.collapsed = this.internalItems.map(() => true);
}
},
data() {
return {
collapsed: [],
};
},
computed: {
...mapGetters(['route']),
},
methods: {
changeFilter(col, val) {
this.setFilter(col, val);
let newquery = Object.entries({
...this.$store.getters.getFilters,
[col]: val
}).reduce((a, [k, v]) => (v ? {...a, [k]: v} : a), {});
router.push({query: newquery});
},
packInt(arr) {
return arr.reduce((a, e, i) => a + (e ? 0 : 2 ** i), 0);
},
unpackInt(n, l) {
return [...Array(l)].map((e, i) => (n & 2 ** i) === 0);
},
toggle(index) {
const collapsed = [...this.collapsed]
collapsed[index] = !collapsed[index];
this.collapsed = collapsed;
},
},
watch: {
collapsed: {
handler() {
const encoded = this.packInt(this.collapsed).toString()
if (this.route.query.collapsed !== encoded)
this.$router.push({
...this.route,
query: {...this.route.query, collapsed: encoded}
});
},
deep: true,
},
},
};
</script>
<style>
.table-body-move {
transition: transform 1s;
}
</style>

View file

@ -21,6 +21,9 @@
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'shipping_voucher'"> <span class="timeline-item-icon faded-icon" v-else-if="item.type === 'shipping_voucher'">
<font-awesome-icon icon="truck"/> <font-awesome-icon icon="truck"/>
</span> </span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'placement'">
<font-awesome-icon icon="archive"/>
</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>
@ -30,40 +33,15 @@
<TimelineAssignment v-else-if="item.type === 'assignment'" :item="item"/> <TimelineAssignment v-else-if="item.type === 'assignment'" :item="item"/>
<TimelineRelatedItem v-else-if="item.type === 'item_relation'" :item="item"/> <TimelineRelatedItem v-else-if="item.type === 'item_relation'" :item="item"/>
<TimelineShippingVoucher v-else-if="item.type === 'shipping_voucher'" :item="item"/> <TimelineShippingVoucher v-else-if="item.type === 'shipping_voucher'" :item="item"/>
<TimelinePlacement v-else-if="item.type === 'placement'" :item="item"/>
<TimelineRelatedTicket v-else-if="item.type === 'issue_relation'" :item="item"/>
<p v-else>{{ item }}</p> <p v-else>{{ item }}</p>
</li> </li>
<li class="timeline-item"> <li class="timeline-item">
<span class="timeline-item-icon | faded-icon"> <slot name="timeline_action1"/>
<font-awesome-icon icon="comment"/>
</span>
<div class="new-comment card bg-dark">
<div class="">
<textarea placeholder="add comment..." v-model="newComment" class="form-control">
</textarea>
<AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
<font-awesome-icon icon="comment"/>
Save Comment
</AsyncButton>
</div>
</div>
</li> </li>
<li class="timeline-item"> <li class="timeline-item">
<span class="timeline-item-icon | faded-icon"> <slot name="timeline_action2"/>
<font-awesome-icon icon="envelope"/>
</span>
<div class="new-mail card bg-dark">
<div class="card-header">
{{ newestMailSubject }}
</div>
<div>
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
</textarea>
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear">
<font-awesome-icon icon="envelope"/>
Send Mail
</AsyncButton>
</div>
</div>
</li> </li>
</ol> </ol>
</template> </template>
@ -78,12 +56,20 @@ import TimelineAssignment from "@/components/TimelineAssignment.vue";
import TimelineRelatedItem from "@/components/TimelineRelatedItem.vue"; import TimelineRelatedItem from "@/components/TimelineRelatedItem.vue";
import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue"; import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue"; import AsyncButton from "@/components/inputs/AsyncButton.vue";
import TimelinePlacement from "@/components/TimelinePlacement.vue";
import TimelineRelatedTicket from "@/components/TimelineRelatedTicket.vue";
export default { export default {
name: 'Timeline', name: 'Timeline',
components: { components: {
TimelineShippingVoucher, AsyncButton, TimelineRelatedTicket,
TimelineRelatedItem, TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail TimelinePlacement,
TimelineShippingVoucher,
TimelineRelatedItem,
TimelineAssignment,
TimelineStateChange,
TimelineComment,
TimelineMail
}, },
props: { props: {
timeline: { timeline: {
@ -91,36 +77,13 @@ export default {
default: () => [] default: () => []
} }
}, },
emits: ['sendMail', 'addComment'],
data: () => ({
newMail: "",
newComment: ""
}),
computed: { computed: {
...mapGetters(['stateInfo']), ...mapGetters(['stateInfo'])
newestMailSubject() {
const mail = this.timeline.filter(item => item.type === 'mail').pop();
return mail ? mail.subject : "";
},
}, },
methods: {
...mapActions(['fetchShippingVouchers']),
sendMailAndClear: function () {
this.$emit('sendMail', this.newMail);
this.newMail = "";
},
addCommentAndClear: function () {
this.$emit('addComment', this.newComment);
this.newComment = "";
}
},
mounted() {
this.fetchShippingVouchers();
}
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss">
*, *,
*:before, *:before,
@ -139,10 +102,10 @@ a {
color: inherit; color: inherit;
} }
img { /*img {
display: block; display: block;
max-width: 100%; max-width: 100%;
} }*/
/* End basic CSS override */ /* End basic CSS override */

View file

@ -1,6 +1,5 @@
<template> <template>
<div class="timeline-item-wrapper"> <div class="timeline-item-wrapper">
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="closeLightboxModal()"/>
<div class="timeline-item-description"> <div class="timeline-item-description">
<i class="avatar | small"> <i class="avatar | small">
<font-awesome-icon icon="user"/> <font-awesome-icon icon="user"/>
@ -23,7 +22,7 @@
</div> </div>
<div class="card-footer" v-if="item.attachments.length"> <div class="card-footer" v-if="item.attachments.length">
<ul> <ul>
<li v-for="attachment in item.attachments" @click="openLightboxModalWith(attachment)"> <li v-for="attachment in item.attachments" @click="openLightboxModalWith(attachment.hash)">
<AuthenticatedImage :src="`/media/2/256/${attachment.hash}/`" :alt="attachment.name" <AuthenticatedImage :src="`/media/2/256/${attachment.hash}/`" :alt="attachment.name"
v-if="attachment.mime_type.startsWith('image/')" cached/> v-if="attachment.mime_type.startsWith('image/')" cached/>
<AuthenticatedDataLink :href="`/media/2/${attachment.hash}/`" :download="attachment.name" <AuthenticatedDataLink :href="`/media/2/${attachment.hash}/`" :download="attachment.name"
@ -32,26 +31,6 @@
</ul> </ul>
</div> </div>
</div> </div>
<!--button class="show-replies">
<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"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 11l4 4l-4 4m4 -4h-11a4 4 0 0 1 0 -8h1"/>
</svg>
Show 3 replies
<span class="avatar-list">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
</span>
</button-->
</div> </div>
</template> </template>
@ -59,16 +38,11 @@
import AuthenticatedImage from "@/components/AuthenticatedImage.vue"; import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AuthenticatedDataLink from "@/components/AuthenticatedDataLink.vue"; import AuthenticatedDataLink from "@/components/AuthenticatedDataLink.vue";
import Lightbox from "@/components/Lightbox.vue"; import {mapMutations} from "vuex";
export default { export default {
name: 'TimelineMail', name: 'TimelineMail',
components: {Lightbox, AuthenticatedImage, AuthenticatedDataLink}, components: {AuthenticatedImage, AuthenticatedDataLink},
data() {
return {
lightboxHash: null,
}
},
props: { props: {
'item': { 'item': {
type: Object, type: Object,
@ -85,12 +59,7 @@ export default {
}, },
methods: { methods: {
openLightboxModalWith(attachment) { ...mapMutations(['openLightboxModalWith'])
this.lightboxHash = attachment.hash;
},
closeLightboxModal() { // Closes the editing modal and discards the edited copy of the item.
this.lightboxHash = null;
},
}, },
}; };
</script> </script>

View file

@ -0,0 +1,85 @@
<template>
<div class="timeline-item-description">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<span><a href="#">$USER</a> has placed item in '{{item.box}}' (#{{item.cid}}) at <time
:datetime="timestamp">{{ timestamp }}</time>
</span>
</div>
</template>
<script>
export default {
name: 'TimelinePlacement',
props: {
'item': {
type: Object,
required: true
}
},
computed: {
'timestamp': function () {
return new Date(this.item.timestamp).toLocaleString();
},
}
};
</script>
<style scoped>
a {
color: inherit;
}
/* End basic CSS override */
.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>

View file

@ -1,12 +1,15 @@
<template> <template>
<div class="timeline-item-wrapper"> <div class="timeline-item-wrapper">
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="closeLightboxModal()"/>
<div class="timeline-item-description"> <div class="timeline-item-description">
<i class="avatar | small"> <i class="avatar | small">
<font-awesome-icon icon="user"/> <font-awesome-icon icon="user"/>
</i> </i>
<span><!--a href="#">$USER</a--> linked item <span class="badge badge-secondary">#{{ item.item.uid }} </span> on <time <span><!--a href="#">$USER</a--> linked item <span class="badge badge-secondary">#{{
:datetime="timestamp">{{ timestamp }}</time> as <span class="badge badge-primary">{{ item.status }}</span> item.item.id
}} </span> on <time
:datetime="timestamp">{{ timestamp }}</time> as <span class="badge badge-primary">{{
item.status
}}</span>
</span> </span>
</div> </div>
<div class="card bg-dark"> <div class="card bg-dark">
@ -15,56 +18,19 @@
<AuthenticatedImage v-if="item.item.file" cached <AuthenticatedImage v-if="item.item.file" cached
:src="`/media/2/256/${item.item.file}/`" :src="`/media/2/256/${item.item.file}/`"
class="d-block card-img-left" class="d-block card-img-left"
@click="openLightboxModalWith(item.item)" @click="openLightboxModalWith(item.item.file)"
/> />
</div> </div>
<div class="col"> <div class="col">
<div class="card-body"> <div class="card-body">
<h6 class="card-subtitle text-secondary">uid: {{ item.item.uid }} box: {{ item.item.box }}</h6> <h6 class="card-subtitle text-secondary">id: {{ item.item.id }} box: {{ item.item.box }}</h6>
<h6 class="card-title">{{ item.item.description }}</h6> <router-link :to="{name: 'item', params: {id: item.item.id}}">
<!--div class="row mx-auto mt-2"> <h6 class="card-title">{{ item.item.description }}</h6>
<div class="btn-group"> </router-link>
<button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturned(item.item)"
title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-outline-secondary" @click.stop="openEditingModalWith(item.item)"
title="edit">
<font-awesome-icon icon="edit"/>
</button>
<button class="btn btn-outline-danger"
@click.stop="confirm('delete Item?') && deleteItem(item.item)"
title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div>
</div>
<p>{{ item }}</p-->
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!--button class="show-replies">
<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"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 11l4 4l-4 4m4 -4h-11a4 4 0 0 1 0 -8h1"/>
</svg>
Show 3 replies
<span class="avatar-list">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
</span>
</button-->
</div> </div>
</template> </template>
@ -72,16 +38,11 @@
import AuthenticatedImage from "@/components/AuthenticatedImage.vue"; import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AuthenticatedDataLink from "@/components/AuthenticatedDataLink.vue"; import AuthenticatedDataLink from "@/components/AuthenticatedDataLink.vue";
import Lightbox from "@/components/Lightbox.vue"; import {mapMutations} from "vuex";
export default { export default {
name: 'TimelineRelatedItem', name: 'TimelineRelatedItem',
components: {Lightbox, AuthenticatedImage, AuthenticatedDataLink}, components: {AuthenticatedImage, AuthenticatedDataLink},
data() {
return {
lightboxHash: null,
}
},
props: { props: {
'item': { 'item': {
type: Object, type: Object,
@ -98,13 +59,8 @@ export default {
}, },
methods: { methods: {
openLightboxModalWith(attachment) { ...mapMutations(['openLightboxModalWith'])
this.lightboxHash = attachment.hash; }
},
closeLightboxModal() { // Closes the editing modal and discards the edited copy of the item.
this.lightboxHash = null;
},
},
}; };
</script> </script>

View file

@ -0,0 +1,94 @@
<template>
<div class="timeline-item-wrapper">
<div class="timeline-item-description">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<span> linked ticket <span class="badge badge-secondary">#{{ item.issue_thread.id }} </span> on
<time :datetime="timestamp">{{ timestamp }}</time> as
<span class="badge badge-primary">{{ item.status }}</span>
</span>
</div>
<div class="timeline-item-description">
<router-link :to="{name: 'ticket', params: {id: item.issue_thread.id}}">
<h6 class="card-title">Ticket #{{ item.issue_thread.id }} - {{ item.issue_thread.name }}</h6>
</router-link>
</div>
</div>
</template>
<script>
export default {
name: 'TimelineRelatedTicket',
components: {},
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);
}
}
}
.card {
border: 1px solid var(--gray);
}
.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>

View file

@ -24,7 +24,6 @@ export default {
}, },
methods: { methods: {
async handleClick() { async handleClick() {
console.log("AsyncButton.handleClick() called");
if (this.task && typeof this.task === 'function') { if (this.task && typeof this.task === 'function') {
this.disabled = true; this.disabled = true;
try { try {

View file

@ -1,39 +1,36 @@
<template> <template>
<div class="form-group"> <div class="input-group">
<label :for="label">{{ label }}</label> <div class="input-group-prepend">
<div class="input-group"> <button
<div class="input-group-prepend"> class="btn btn-outline-secondary dropdown-toggle"
<button type="button"
class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown"
type="button" >Search
data-toggle="dropdown" </button>
>Search <div class="dropdown-menu">
</button> <a
<div class="dropdown-menu"> v-for="(option, index) in sortedOptions"
<a :key="index"
v-for="(option, index) in sortedOptions" class="dropdown-item"
:key="index" @click="setInternalValue(option)"
class="dropdown-item" :class="{ active: option == selectedOption }"
@click="setInternalValue(option)"
:class="{ active: option == selectedOption }"
>
{{ option[nameKey] }}
</a>
</div>
</div>
<input type="text" class="form-control" :id="label" v-model="internalName">
<div class="input-group-append">
<button
class="btn"
:class="{ 'btn-info disabled': isValid, 'btn-success': !isValid }"
v-if="!isValid"
@click="addOption()"
> >
<font-awesome-icon icon="plus"/> {{ option[nameKey] }}
</button> </a>
</div> </div>
<Addon type="Combo Box" :is-valid="isValid"/>
</div> </div>
<input type="text" class="form-control" :id="label" v-model="internalName">
<div class="input-group-append">
<button
class="btn"
:class="{ 'btn-info disabled': isValid, 'btn-success': !isValid }"
v-if="!isValid"
@click="addOption()"
>
<font-awesome-icon icon="plus"/>
</button>
</div>
<Addon type="Combo Box" :is-valid="isValid"/>
</div> </div>
</template> </template>

View file

@ -2,9 +2,10 @@
<div> <div>
<img <img
v-if="!capturing" v-if="!capturing"
class="img-fluid rounded mx-auto d-block mb-3 img-preview" :class="imgClass || ['img-fluid', 'rounded', 'mx-auto', 'd-block', 'mb-3', 'img-preview']"
:src="dataImage" :src="dataImage"
alt="Image not available." alt="Image not available."
@click="e=>$emit('detail', e)"
/> />
<video <video
v-if="capturing" v-if="capturing"
@ -44,19 +45,21 @@
</template> </template>
<script> <script>
import {mapMutations} from 'vuex'; import {mapActions, mapMutations} from 'vuex';
export default { export default {
name: 'InputPhoto', name: 'InputPhoto',
props: ['model', 'field', 'onCapture'], props: ['model', 'field', 'onCapture', 'imgClass'],
data: () => ({ data: () => ({
capturing: false, capturing: false,
streaming: false, streaming: false,
stream: undefined, stream: undefined,
dataImage: undefined dataImage: undefined
}), }),
emits: ['detail'],
methods: { methods: {
...mapMutations(['createToast']), ...mapMutations(['createToast']),
...mapActions(['fetchImage']),
openStream() { openStream() {
if (!this.capturing) { if (!this.capturing) {
this.capturing = true; this.capturing = true;

View file

@ -1,10 +1,11 @@
import {createRouter, createWebHistory} from 'vue-router' import {createRouter, createWebHistory} from 'vue-router'
import store from '@/store'; import store from '@/store';
import Items from './views/Items'; import Item from "@/views/Item.vue";
import Boxes from './views/Boxes'; import Items from '@/views/Items';
import Files from './views/Files'; import Boxes from '@/views/Boxes';
import HowTo from './views/HowTo'; import Files from '@/views/Files';
import HowTo from '@/views/HowTo';
import Login from '@/views/Login.vue'; import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue'; import Register from '@/views/Register.vue';
import Dashboard from "@/views/admin/Dashboard.vue"; import Dashboard from "@/views/admin/Dashboard.vue";
@ -27,7 +28,7 @@ const routes = [
{requiresAuth: true, requiresPermission: 'view_item'} {requiresAuth: true, requiresPermission: 'view_item'}
}, },
{ {
path: '/:event/item/:uid/', name: 'item', component: Items, meta: path: '/:event/item/:id/', name: 'item', component: Item, meta:
{requiresAuth: true, requiresPermission: 'view_item'} {requiresAuth: true, requiresPermission: 'view_item'}
}, },
{ {

View file

@ -34,6 +34,7 @@ const store = createStore({
expiry: null, expiry: null,
}, },
lightboxHash: null,
thumbnailCache: {}, thumbnailCache: {},
fetchedData: { fetchedData: {
events: 0, events: 0,
@ -145,38 +146,34 @@ const store = createStore({
setItems(state, {slug, items}) { setItems(state, {slug, items}) {
state.loadedItems[slug] = items; state.loadedItems[slug] = items;
state.loadedItems = {...state.loadedItems}; state.loadedItems = {...state.loadedItems};
console.log(state.loadedItems)
}, },
replaceItems(state, items) { replaceItems(state, items) {
const groups = Object.groupBy(items, i => i.event ? i.event : 'none') const groups = Object.groupBy(items, i => i.event ? i.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedItems[key] = value; for (const [key, value] of Object.entries(groups)) state.loadedItems[key] = value;
state.loadedItems = {...state.loadedItems}; state.loadedItems = {...state.loadedItems};
console.log(state.loadedItems)
}, },
updateItem(state, updatedItem) { updateItem(state, updatedItem) {
const item = state.loadedItems[updatedItem.event?updatedItem.event:'none'].filter( const item = state.loadedItems[updatedItem.event ? updatedItem.event : 'none'].filter(
({uid}) => uid === updatedItem.uid)[0]; ({id}) => id === updatedItem.id)[0];
Object.assign(item, updatedItem); Object.assign(item, updatedItem);
}, },
removeItem(state, item) { removeItem(state, item) {
state.loadedItems[item.event?item.event:'none'] = state.loadedItems[item.event].filter(it => it !== item); state.loadedItems[item.event ? item.event : 'none'] = state.loadedItems[item.event].filter(it => it !== item);
}, },
appendItem(state, item) { appendItem(state, item) {
state.loadedItems[item.event?item.event:'none'].push(item); state.loadedItems[item.event ? item.event : 'none'].push(item);
}, },
setTickets(state, {slug, tickets}) { setTickets(state, {slug, tickets}) {
state.loadedTickets[slug] = tickets; state.loadedTickets[slug] = tickets;
state.loadedTickets = {...state.loadedTickets}; state.loadedTickets = {...state.loadedTickets};
console.log(state.loadedTickets)
}, },
replaceTickets(state, tickets) { replaceTickets(state, tickets) {
const groups = Object.groupBy(tickets, t => t.event ? t.event : 'none') const groups = Object.groupBy(tickets, t => t.event ? t.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedTickets[key] = value; for (const [key, value] of Object.entries(groups)) state.loadedTickets[key] = value;
state.loadedTickets = {...state.loadedTickets}; state.loadedTickets = {...state.loadedTickets};
console.log(state.loadedTickets)
}, },
updateTicket(state, updatedTicket) { updateTicket(state, updatedTicket) {
const ticket = state.loadedTickets[updatedTicket.event?updatedTicket.event:'none'].filter( const ticket = state.loadedTickets[updatedTicket.event ? updatedTicket.event : 'none'].filter(
({id}) => id === updatedTicket.id)[0]; ({id}) => id === updatedTicket.id)[0];
Object.assign(ticket, updatedTicket); Object.assign(ticket, updatedTicket);
state.loadedTickets = {...state.loadedTickets}; state.loadedTickets = {...state.loadedTickets};
@ -189,6 +186,9 @@ const store = createStore({
state.groups = groups; state.groups = groups;
state.fetchedData = {...state.fetchedData, groups: Date.now()}; state.fetchedData = {...state.fetchedData, groups: Date.now()};
}, },
openLightboxModalWith(state, hash) {
state.lightboxHash = hash;
},
openAddBoxModal(state) { openAddBoxModal(state) {
state.showAddBoxModal = true; state.showAddBoxModal = true;
}, },
@ -336,7 +336,13 @@ const store = createStore({
const {data, success} = await getters.session.delete(`/2/events/${event_id}/`); const {data, success} = await getters.session.delete(`/2/events/${event_id}/`);
if (success) { if (success) {
await dispatch('loadEvents') await dispatch('loadEvents')
commit('replaceEvents', [...state.events.filter(e => e.eid !== event_id)]) commit('replaceEvents', [...state.events.filter(e => e.id !== event_id)])
}
},
async updateEvent({commit, dispatch, state}, {id, partial_event}){
const {data, success} = await http.patch(`/2/events/${id}/`, partial_event, state.user.token);
if (success) {
commit('replaceEvents', [...state.events.filter(e => e.id !== id), data])
} }
}, },
async fetchTicketStates({commit, state, getters}) { async fetchTicketStates({commit, state, getters}) {
@ -347,7 +353,6 @@ const store = createStore({
}, },
async changeEvent({dispatch, getters, commit}, eventName) { async changeEvent({dispatch, getters, commit}, eventName) {
await router.push({path: `/${eventName.slug}/${getters.getActiveView}/`}); await router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
//dispatch('loadEventItems');
}, },
async changeView({getters}, link) { async changeView({getters}, link) {
await router.push({path: `/${getters.getEventSlug}/${link.path}/`}); await router.push({path: `/${getters.getEventSlug}/${link.path}/`});
@ -398,16 +403,16 @@ const store = createStore({
async updateItem({commit, getters, state}, item) { async updateItem({commit, getters, state}, item) {
const { const {
data, success data, success
} = await getters.session.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, item); } = await getters.session.put(`/2/${getters.getEventSlug}/item/${item.id}/`, item);
commit('updateItem', data); commit('updateItem', data);
}, },
async markItemReturned({commit, getters, state}, item) { async markItemReturned({commit, getters, state}, item) {
await getters.session.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true}, await getters.session.patch(`/2/${getters.getEventSlug}/item/${item.id}/`, {returned: true},
state.user.token); state.user.token);
commit('removeItem', item); commit('removeItem', item);
}, },
async deleteItem({commit, getters, state}, item) { async deleteItem({commit, getters, state}, item) {
await getters.session.delete(`/2/${getters.getEventSlug}/item/${item.uid}/`, item); await getters.session.delete(`/2/${getters.getEventSlug}/item/${item.id}/`, item);
commit('removeItem', item); commit('removeItem', item);
}, },
async postItem({commit, getters, state}, item) { async postItem({commit, getters, state}, item) {
@ -450,6 +455,13 @@ const store = createStore({
await dispatch('loadTickets'); await dispatch('loadTickets');
} }
}, },
async postItemComment({commit, dispatch, state, getters}, {id, message}) {
const {data, success} = await getters.session.post(`/2/${getters.getEventSlug}/item/${id}/comment/`, {comment: message});
if (data && success) {
state.fetchedData.items = 0;
await dispatch('loadEventItems');
}
},
async loadUsers({commit, state, getters}) { async loadUsers({commit, state, getters}) {
if (!state.user.token) return; if (!state.user.token) return;
if (state.fetchedData.users > Date.now() - 1000 * 60 * 60 * 24) return; if (state.fetchedData.users > Date.now() - 1000 * 60 * 60 * 24) return;

186
web/src/views/Item.vue Normal file
View file

@ -0,0 +1,186 @@
<template>
<AsyncLoader :loaded="!!item.id">
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-lg-3 col-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card bg-dark">
<InputPhoto
v-if="!!editingItem"
:model="editingItem"
field="file"
:on-capture="storeImage"
imgClass="d-block card-img"
/>
<div class="card-body">
<h6 class="card-subtitle text-secondary">id: {{ item.id }} box: {{ item.box }}</h6>
<h6 class="card-title">{{ item.description }}</h6>
</div>
<div class="card-footer">
<InputString
v-if="!!editingItem"
label="description"
:model="editingItem"
field="description"
:validation-fn="str => str && str.length > 0"
/>
</div>
</div>
</div>
</div>
<div class="col-xl-8">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-header">
<h3>Item #{{ item.id }} - {{ item.description }}</h3>
</div>
<Timeline :timeline="item.timeline">
<template v-slot:timeline_action1>
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="comment"/>
</span>
<div class="new-comment card bg-dark">
<div class="">
<textarea placeholder="add comment..." v-model="newComment"
class="form-control">
</textarea>
<AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
<font-awesome-icon icon="comment"/>
Save Comment
</AsyncButton>
</div>
</div>
</template>
</Timeline>
<div class="card-footer d-flex justify-content-between">
<div class="btn-group">
<button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturnedAndClose(item)"
title="returned">
<font-awesome-icon icon="check"/>&nbsp;mark&nbsp;returned
</button>
<button class="btn btn-outline-danger"
@click.stop="confirm('delete Item?') && deleteItemAndClose(item)"
title="delete">
<font-awesome-icon icon="trash"/>&nbsp;delete
</button>
</div>
<InputCombo
v-if="!!editingItem"
label="box"
:model="editingItem"
nameKey="box"
uniqueKey="cid"
:options="boxes"
style="width: auto;"
/>
<button type="button" class="btn btn-success" @click="saveEditingItem()">Save Changes
</button>
</div>
</div>
</div>
<div class="col-lg-3 col-xl-2" v-if="item.related_issues && item.related_issues.length">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-body">
<h5 class="card-title text-info">Related</h5>
</div>
<div class="card bg-dark" v-for="issue in item.related_issues" v-bind:key="item.id">
<div class="card-body">
<router-link :to="{name: 'ticket', params: {id: issue.id}}">
<h6 class="card-title">Ticket #{{ issue.id }} - {{ issue.name }}</h6>
</router-link>
<h6 class="card-subtitle text-secondary">state: {{ issue.state }}</h6>
</div>
</div>
</div>
</div>
</div>
</div>
</AsyncLoader>
</template>
<script>
import {mapActions, mapGetters, mapMutations, mapState} from 'vuex';
import router from "@/router";
import Timeline from "@/components/Timeline.vue";
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import InputCombo from "@/components/inputs/InputCombo.vue";
import InputString from "@/components/inputs/InputString.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import InputPhoto from "@/components/inputs/InputPhoto.vue";
import Modal from "@/components/Modal.vue";
import EditItem from "@/components/EditItem.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue";
export default {
name: 'Item',
components: {
AsyncButton,
EditItem,
Modal, InputPhoto, AuthenticatedImage, InputString, InputCombo, AsyncLoader, ClipboardButton, Timeline
},
data() {
return {
newComment: "",
editingItem: null,
}
},
computed: {
...mapState(['state_options', 'users']),
...mapGetters(['availableShippingVoucherTypes', 'getAllItems', 'route', 'getBoxes']),
item() {
const id = parseInt(this.route.params.id)
const ret = this.getAllItems.find(item => item.id === id);
return ret ? ret : {};
},
boxes() {
return this.getBoxes.map(obj => ({cid: obj.id, box: obj.name}));
}
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'updateTicketPartial', 'postItemComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit', 'updateItem']),
...mapActions(['claimShippingVoucher', 'fetchShippingVouchers', 'loadEventItems', 'loadBoxes']),
...mapMutations(['openLightboxModalWith']),
async addCommentAndClear() {
await this.postItemComment({
id: this.item.id,
message: this.newComment
})
this.newComment = "";
},
async saveEditingItem() { // Saves the edited copy of the item.
await this.updateItem(this.editingItem);
this.editingItem = {...this.item}
},
storeImage(image) {
this.editingItem.dataImage = image;
},
confirm(message) {
return window.confirm(message);
},
async markItemReturnedAndClose(item) {
await this.markItemReturned(item);
router.back();
},
async deleteItemAndClose(item) {
await this.deleteItem(item);
router.back();
}
},
mounted() {
this.scheduleAfterInit(() => [Promise.all([this.loadEventItems(), this.loadBoxes()]).then(() => {
this.selected_state = this.item.state;
this.selected_assignee = this.item.assigned_to
this.editingItem = {...this.item}
})]);
}
};
</script>
<style scoped>
</style>

View file

@ -1,26 +1,13 @@
<template> <template>
<AsyncLoader :loaded="isItemsLoaded"> <AsyncLoader :loaded="isItemsLoaded">
<div class="container-fluid px-xl-5 mt-3"> <div class="container-fluid px-xl-5 mt-3">
<Modal title="Edit Item" v-if="editingItem" @close="closeEditingModal()">
<template #body>
<EditItem
:item="editingItem"
badge="uid"
/>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="closeEditingModal()">Cancel</button>
<button type="button" class="btn btn-success" @click="saveEditingItem()">Save Changes</button>
</template>
</Modal>
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="closeLightboxModal()"/>
<div class="row" v-if="layout === 'table'"> <div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2"> <div class="col-xl-8 offset-xl-2">
<Table <Table
:columns="['uid', 'description', 'box']" :columns="['id', 'description', 'box']"
:items="getEventItems" :items="getEventItems"
:keyName="'uid'" :keyName="'id'"
@itemActivated="openLightboxModalWith($event)" @itemActivated="showItemDetail"
> >
<template #actions="{ item }"> <template #actions="{ item }">
<div class="btn-group"> <div class="btn-group">
@ -43,11 +30,11 @@
</div> </div>
<Cards <Cards
v-if="layout === 'cards'" v-if="layout === 'cards'"
:columns="['uid', 'description', 'box']" :columns="['id', 'description', 'box']"
:items="getEventItems" :items="getEventItems"
:keyName="'uid'" :keyName="'id'"
v-slot="{ item }" v-slot="{ item }"
@itemActivated="openLightboxModalWith($event)" @itemActivated="item => openLightboxModalWith(item.file)"
> >
<AuthenticatedImage v-if="item.file" cached <AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`" :src="`/media/2/256/${item.file}/`"
@ -55,14 +42,14 @@
/> />
<div class="card-body"> <div class="card-body">
<h6 class="card-title">{{ item.description }}</h6> <h6 class="card-title">{{ item.description }}</h6>
<h6 class="card-subtitle text-secondary">uid: {{ item.uid }} box: {{ item.box }}</h6> <h6 class="card-subtitle text-secondary">id: {{ item.id }} box: {{ item.box }}</h6>
<div class="row mx-auto mt-2"> <div class="row mx-auto mt-2">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-outline-success" <button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturned(item)" title="returned"> @click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
<font-awesome-icon icon="check"/> <font-awesome-icon icon="check"/>
</button> </button>
<button class="btn btn-outline-secondary" @click.stop="openEditingModalWith(item)" <button class="btn btn-outline-secondary" @click.stop="showItemDetail(item)"
title="edit"> title="edit">
<font-awesome-icon icon="edit"/> <font-awesome-icon icon="edit"/>
</button> </button>
@ -84,10 +71,10 @@ import Table from '@/components/Table';
import Cards from '@/components/Cards'; import Cards from '@/components/Cards';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem'; import EditItem from '@/components/EditItem';
import {mapActions, mapGetters, mapState} from 'vuex'; import {mapActions, mapGetters, mapMutations} from 'vuex';
import Lightbox from '../components/Lightbox';
import AuthenticatedImage from "@/components/AuthenticatedImage.vue"; import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncLoader from "@/components/AsyncLoader.vue"; import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default { export default {
name: 'Items', name: 'Items',
@ -95,28 +82,15 @@ export default {
lightboxHash: null, lightboxHash: null,
editingItem: null, editingItem: null,
}), }),
components: {AsyncLoader, AuthenticatedImage, Lightbox, Table, Cards, Modal, EditItem}, components: {AsyncLoader, AuthenticatedImage, Table, Cards, Modal, EditItem},
computed: { computed: {
...mapState([]),
...mapGetters(['getEventItems', 'isItemsLoaded', 'layout']), ...mapGetters(['getEventItems', 'isItemsLoaded', 'layout']),
}, },
methods: { methods: {
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']), ...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']),
openLightboxModalWith(item) { ...mapMutations(['openLightboxModalWith']),
this.lightboxHash = item.file; showItemDetail(item) {
}, router.push({name: 'item', params: {id: item.id}});
closeLightboxModal() { // Closes the editing modal and discards the edited copy of the item.
this.lightboxHash = null;
},
openEditingModalWith(item) { // Opens the editing modal with a copy of the selected item.
this.editingItem = item;
},
closeEditingModal() {
this.editingItem = null;
},
saveEditingItem() { // Saves the edited copy of the item.
this.updateItem(this.editingItem);
this.closeEditingModal();
}, },
confirm(message) { confirm(message) {
return window.confirm(message); return window.confirm(message);

View file

@ -1,5 +1,5 @@
<template> <template>
<AsyncLoader :loaded="ticket.id"> <AsyncLoader :loaded="!!ticket.id">
<div class="container-fluid px-xl-5 mt-3"> <div class="container-fluid px-xl-5 mt-3">
<div class="row"> <div class="row">
<div class="col-xl-8 offset-xl-2"> <div class="col-xl-8 offset-xl-2">
@ -7,7 +7,42 @@
<div class="card-header"> <div class="card-header">
<h3>Ticket #{{ ticket.id }} - {{ ticket.name }}</h3> <h3>Ticket #{{ ticket.id }} - {{ ticket.name }}</h3>
</div> </div>
<Timeline :timeline="ticket.timeline" @sendMail="handleMail" @addComment="handleComment"/> <Timeline :timeline="ticket.timeline">
<template v-slot:timeline_action1>
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="comment"/>
</span>
<div class="new-comment card bg-dark">
<div>
<textarea placeholder="add comment..." v-model="newComment"
class="form-control">
</textarea>
<AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
<font-awesome-icon icon="comment"/>
Save Comment
</AsyncButton>
</div>
</div>
</template>
<template v-slot:timeline_action2>
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="envelope"/>
</span>
<div class="new-mail card bg-dark">
<div class="card-header">
{{ newestMailSubject }}
</div>
<div>
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
</textarea>
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear">
<font-awesome-icon icon="envelope"/>
Send Mail
</AsyncButton>
</div>
</div>
</template>
</Timeline>
<div class="card-footer d-flex justify-content-between"> <div class="card-footer d-flex justify-content-between">
<button class="btn btn-secondary mr-2" @click="$router.go(-1)">Back</button> <button class="btn btn-secondary mr-2" @click="$router.go(-1)">Back</button>
<!--button class="btn btn-danger" @click="deleteItem({type: 'tickets', id: ticket.id})"> <!--button class="btn btn-danger" @click="deleteItem({type: 'tickets', id: ticket.id})">
@ -16,11 +51,14 @@
</button--> </button-->
<div class="btn-group"> <div class="btn-group">
<select class="form-control" v-model="selected_assignee"> <select class="form-control" v-model="selected_assignee">
<option v-for="user in users" :value="user.username">{{ user.username }}</option> <option v-for="user in users" :value="user.username">{{
user.username
}}
</option>
</select> </select>
<button class="form-control btn btn-success" <button class="form-control btn btn-success"
@click="assignTicket(ticket)" @click="assignTicket(ticket)"
:disabled="!selected_assignee || (selected_assignee == ticket.assigned_to)"> :disabled="!selected_assignee || (selected_assignee === ticket.assigned_to)">
Assign&nbsp;Ticket Assign&nbsp;Ticket
</button> </button>
</div> </div>
@ -33,7 +71,7 @@
</select> </select>
<button class="form-control btn btn-success" <button class="form-control btn btn-success"
@click="changeTicketStatus(ticket)" @click="changeTicketStatus(ticket)"
:disabled="(selected_state == ticket.state)"> :disabled="(selected_state === ticket.state)">
Change&nbsp;Status Change&nbsp;Status
</button> </button>
</div> </div>
@ -58,25 +96,53 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-3 col-xl-2 d-lg-none d-xl-block"
v-if="ticket.related_items && ticket.related_items.length">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-body">
<h5 class="card-title text-info">Related</h5>
<div class="card bg-dark" v-for="item in ticket.related_items" v-bind:key="item.id">
<AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`"
class="d-block card-img"
@click="openLightboxModalWith(item.file)"
/>
<div class="card-body">
<!--h6 class="card-title text-info"><span class="badge badge-primary">{{ item.relation_status }}</span></--h6-->
<h6 class="card-subtitle text-secondary">id: {{ item.id }} box: {{
item.box
}}</h6>
<router-link :to="{name: 'item', params: {id: item.id}}">
<h6 class="card-title">{{ item.description }}</h6>
</router-link>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</AsyncLoader> </AsyncLoader>
</template> </template>
<script> <script>
import {mapActions, mapGetters, mapState} from 'vuex'; import {mapActions, mapGetters, mapMutations, mapState} from 'vuex';
import Timeline from "@/components/Timeline.vue"; import Timeline from "@/components/Timeline.vue";
import ClipboardButton from "@/components/inputs/ClipboardButton.vue"; import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
import AsyncLoader from "@/components/AsyncLoader.vue"; import AsyncLoader from "@/components/AsyncLoader.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue";
export default { export default {
name: 'Ticket', name: 'Ticket',
components: {AsyncLoader, ClipboardButton, Timeline}, components: {AsyncButton, AuthenticatedImage, AsyncLoader, ClipboardButton, Timeline},
data() { data() {
return { return {
selected_state: null, selected_state: null,
selected_assignee: null, selected_assignee: null,
shipping_voucher_type: null, shipping_voucher_type: null,
newMail: "",
newComment: ""
} }
}, },
computed: { computed: {
@ -90,46 +156,52 @@ export default {
shippingEmail() { shippingEmail() {
const domain = document.location.hostname; const domain = document.location.hostname;
return `ticket+${this.ticket.uuid}@${domain}`; return `ticket+${this.ticket.uuid}@${domain}`;
} },
newestMailSubject() {
const mail = this.ticket.timeline ? this.ticket.timeline.filter(item => item.type === 'mail').pop() : null;
return mail ? mail.subject : "";
},
}, },
methods: { methods: {
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']), ...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']), ...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
...mapActions(['claimShippingVoucher', 'fetchShippingVouchers']), ...mapActions(['claimShippingVoucher', 'fetchShippingVouchers']),
handleMail(mail) { ...mapMutations(['openLightboxModalWith']),
this.sendMail({ changeTicketStatus() {
id: this.ticket.id, this.ticket.state = this.selected_state;
message: mail
})
},
handleComment(comment) {
this.postComment({
id: this.ticket.id,
message: comment
})
},
changeTicketStatus(ticket) {
ticket.state = this.selected_state;
this.updateTicketPartial({ this.updateTicketPartial({
id: ticket.id, id: this.ticket.id,
state: this.selected_state, state: this.selected_state,
}) })
}, },
assignTicket(ticket) { assignTicket() {
ticket.assigned_to = this.selected_assignee; this.ticket.assigned_to = this.selected_assignee;
this.updateTicketPartial({ this.updateTicketPartial({
id: ticket.id, id: this.ticket.id,
assigned_to: this.selected_assignee assigned_to: this.selected_assignee
}) })
}, },
sendMailAndClear: async function () {
await this.sendMail({
id: this.ticket.id,
message: this.newMail,
})
this.newMail = "";
},
addCommentAndClear: async function () {
await this.postComment({
id: this.ticket.id,
message: this.newComment
})
this.newComment = "";
}
}, },
mounted() { mounted() {
this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(() => { this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(() => {
if (this.ticket.state == "pending_new") { if (this.ticket.state === "pending_new") {
this.selected_state = "pending_open"; this.selected_state = "pending_open";
this.changeTicketStatus(this.ticket) this.changeTicketStatus()
} }
;
this.selected_state = this.ticket.state; this.selected_state = this.ticket.state;
this.selected_assignee = this.ticket.assigned_to this.selected_assignee = this.ticket.assigned_to
})]); })]);

View file

@ -12,11 +12,11 @@
> >
<template v-slot:actions="{item}"> <template v-slot:actions="{item}">
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view" <router-link :to="{name: 'ticket', params: {id: item.id}}" class="btn btn-primary"
@click.prevent="gotoDetail(item)"> title="view">
<font-awesome-icon icon="eye"/> <font-awesome-icon icon="eye"/>
View View
</a> </router-link>
</div> </div>
</template> </template>
</Table> </Table>
@ -39,11 +39,11 @@
<td v-if="getEventSlug==='all'">{{ item.event }}</td> <td v-if="getEventSlug==='all'">{{ item.event }}</td>
<td> <td>
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view" <router-link :to="{name: 'ticket', params: {id: item.id}}" class="btn btn-primary"
@click.prevent="gotoDetail(item)"> title="view">
<font-awesome-icon icon="eye"/> <font-awesome-icon icon="eye"/>
View View
</a> </router-link>
</div> </div>
</td> </td>
</tr> </tr>
@ -55,24 +55,22 @@
<script> <script>
import Cards from '@/components/Cards'; import Cards from '@/components/Cards';
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
import {mapActions, mapGetters, mapState} from 'vuex'; import {mapActions, mapGetters, mapState} from 'vuex';
import Lightbox from '../components/Lightbox';
import Table from '@/components/Table'; import Table from '@/components/Table';
import CollapsableCards from "@/components/CollapsableCards.vue"; import CollapsableCards from "@/components/CollapsableCards.vue";
import AsyncLoader from "@/components/AsyncLoader.vue"; import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default { export default {
name: 'Tickets', name: 'Tickets',
components: {AsyncLoader, Lightbox, Table, Cards, Modal, EditItem, CollapsableCards}, components: {AsyncLoader, Table, Cards, CollapsableCards},
computed: { computed: {
...mapGetters(['getEventTickets', 'isTicketsLoaded', 'stateInfo', 'getEventSlug', 'layout']), ...mapGetters(['getEventTickets', 'isTicketsLoaded', 'stateInfo', 'getEventSlug', 'layout']),
}, },
methods: { methods: {
...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']), ...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']),
gotoDetail(ticket) { gotoDetail(ticket) {
this.$router.push({name: 'ticket', params: {id: ticket.id}}); router.push({name: 'ticket', params: {id: ticket.id}});
}, },
formatTicket(ticket) { formatTicket(ticket) {
return { return {

View file

@ -1,50 +1,102 @@
<template> <template>
<Table <AsyncLoader :loaded="events.length > 0">
:columns="['slug', 'name']" <ExpandableTable v-if="!!events" :columns="['slug', 'name']" :items="events" :keyName="'slug'">
:items="events" <template v-slot:header_actions>
:keyName="'slug'" <button class="btn btn-success" @click.prevent="openAddEventModal">
> <font-awesome-icon icon="plus"/>
<template v-slot:header_actions> Create Event
<button class="btn btn-success" @click.prevent="openAddEventModal">
<font-awesome-icon icon="plus"/>
Create Event
</button>
</template>
<template v-slot:actions="{ item }">
<div class="btn-group">
<button class="btn btn-secondary" @click.stop="changeEvent(item)">
<font-awesome-icon icon="archive"/>
use
</button> </button>
<button class="btn btn-danger" @click.stop="safeDeleteEvent(item.eid)"> </template>
<font-awesome-icon icon="trash"/> <template v-slot:actions="{ item }">
delete <div class="btn-group">
</button> <button class="btn btn-secondary" @click.stop="changeEvent(item)" style="white-space: nowrap;">
</div> <font-awesome-icon icon="archive"/>&nbsp;use
</template> </button>
</Table> <button class="btn btn-danger" @click.stop="safeDeleteEvent(item.eid)" style="white-space: nowrap;">
<font-awesome-icon icon="trash"/>&nbsp;delete
</button>
</div>
</template>
<template v-slot:detail="{id, item }">
<div class="row">
<div class="col">
<input type="date" class="form-control form-control-sm" title="Buildup Start"
v-model="item.pre_start" disabled style="opacity: 1"
:max="item.start" @focus="prepare_date_field">
</div>
<div class="col">
<input type="date" class="form-control form-control-sm" title="Official Event Start"
v-model="item.start" disabled style="opacity: 1"
:min="item.pre_start" :max="item.end" @focus="prepare_date_field">
</div>
<div class="col">
<input type="date" class="form-control form-control-sm" title="Official Event End"
v-model="item.end" disabled style="opacity: 1"
:min="item.start" :max="item.post_end" @focus="prepare_date_field">
</div>
<div class="col">
<input type="date" class="form-control form-control-sm" title="Teardown End"
v-model="item.post_end" disabled style="opacity: 1"
:min="item.end" @focus="prepare_date_field">
</div>
</div>
<div class="mt-3">
<label class="mr-3">Addresses: </label>
<div v-for="(address, a_id) in item.addresses" class="btn-group btn-group-sm mr-3"
@click.stop="deleteAddress(id, a_id)">
<button class="btn btn-secondary" disabled style="opacity: 1">
{{ address }}
</button>
<button class="btn btn-danger">
<font-awesome-icon icon="trash"/>
</button>
</div>
<div class="btn-group btn-group-sm">
<input type="text" v-model="new_address[id]">
<button class="btn btn-secondary" @click.stop="addAddress(id)" style="white-space: nowrap;">
<font-awesome-icon icon="envelope"/>&nbsp;add
</button>
</div>
</div>
</template>
</ExpandableTable>
</AsyncLoader>
</template> </template>
<script> <script>
import {mapActions, mapMutations, mapState} from 'vuex'; import {mapActions, mapMutations, mapState} from 'vuex';
import Table from '@/components/Table'; import ExpandableTable from "@/components/ExpandableTable.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
export default { export default {
name: 'Events', name: 'Events',
components: {Table}, components: {AsyncLoader, ExpandableTable},
computed: mapState(['events']), computed: mapState(['events']),
data() {
return {new_address: []}
},
methods: { methods: {
...mapActions(['changeEvent', 'deleteEvent']), ...mapActions(['changeEvent', 'deleteEvent', 'updateEvent']),
...mapMutations(['openAddEventModal']), ...mapMutations(['openAddEventModal']),
safeDeleteEvent(id) { safeDeleteEvent(id) {
if (confirm('do you want to completely delete this event and related data?')) { if (confirm('do you want to completely delete this event and related data?')) {
this.deleteEvent(id) this.deleteEvent(id)
} }
}, },
addAddress(id) {
const a = this.new_address[id];
if (!this.events[id].addresses.includes(a))
this.events[id].addresses.push(a)
this.new_address[id] = ""
this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}});
},
deleteAddress(id, a_id) {
this.events[id].addresses = this.events[id].addresses.filter((e, i) => i !== a_id);
this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}});
}
}, },
}; };
</script> </script>
<style scoped> <style scoped>
</style> </style>