diff --git a/core/inventory/admin.py b/core/inventory/admin.py index 2457b5b..24cd14a 100644 --- a/core/inventory/admin.py +++ b/core/inventory/admin.py @@ -1,6 +1,6 @@ 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): @@ -22,3 +22,17 @@ class EventAdmin(admin.ModelAdmin): 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) diff --git a/core/inventory/api_v2.py b/core/inventory/api_v2.py index 60f0292..326c049 100644 --- a/core/inventory/api_v2.py +++ b/core/inventory/api_v2.py @@ -1,12 +1,13 @@ from django.urls import re_path 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.response import Response from rest_framework.permissions import IsAuthenticated -from inventory.models import Event, Container, Item -from inventory.serializers import EventSerializer, ContainerSerializer, ItemSerializer, SearchResultSerializer +from inventory.models import Event, Container, Item, Comment +from inventory.serializers import EventSerializer, ContainerSerializer, CommentSerializer, ItemSerializer, \ + SearchResultSerializer from base64 import b64decode @@ -22,6 +23,20 @@ class ContainerViewSet(viewsets.ModelViewSet): 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): query_tokens = query.split(' ') for item in items: @@ -49,6 +64,7 @@ def search_items(request, event_slug, query): @api_view(['GET', 'POST']) @permission_classes([IsAuthenticated]) def item(request, event_slug): + vs = ItemViewSet() try: event = None if event_slug != 'none': @@ -56,7 +72,7 @@ def item(request, event_slug): if request.method == 'GET': if not request.user.has_event_perm(event, 'view_item'): return Response(status=403) - return Response(ItemSerializer(Item.objects.filter(event=event), many=True).data) + return Response(ItemSerializer(vs.prefetch_queryset(Item.objects.filter(event=event)), many=True).data) elif request.method == 'POST': if not request.user.has_event_perm(event, 'add_item'): return Response(status=403) @@ -71,6 +87,25 @@ def item(request, event_slug): 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']) @permission_classes([IsAuthenticated]) def item_by_id(request, event_slug, id): @@ -117,5 +152,6 @@ urlpatterns = router.urls + [ re_path(r'^(?P[\w-]+)/items/$', item, name='item'), re_path(r'^(?P[\w-]+)/items/(?P[-A-Za-z0-9+/]*={0,3})/$', search_items, name='search_items'), re_path(r'^(?P[\w-]+)/item/$', item, name='item'), + re_path(r'^(?P[\w-]+)/item/(?P\d+)/comment/$', add_comment, name='add_comment'), re_path(r'^(?P[\w-]+)/item/(?P\d+)/$', item_by_id, name='item_by_id'), ] diff --git a/core/inventory/migrations/0007_remove_item_container_itemplacement_comment.py b/core/inventory/migrations/0007_remove_item_container_itemplacement_comment.py deleted file mode 100644 index 24c0f75..0000000 --- a/core/inventory/migrations/0007_remove_item_container_itemplacement_comment.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 4.2.7 on 2024-11-23 00:19 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0006_alter_event_table'), - ] - - operations = [ - migrations.RemoveField( - model_name='item', - name='container', - ), - 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')), - ], - ), - ] diff --git a/core/inventory/migrations/0007_rename_container_item_container_old_itemplacement_and_more.py b/core/inventory/migrations/0007_rename_container_item_container_old_itemplacement_and_more.py new file mode 100644 index 0000000..918f636 --- /dev/null +++ b/core/inventory/migrations/0007_rename_container_item_container_old_itemplacement_and_more.py @@ -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', + ), + ] diff --git a/core/inventory/models.py b/core/inventory/models.py index fbbe0a9..231094e 100644 --- a/core/inventory/models.py +++ b/core/inventory/models.py @@ -32,7 +32,11 @@ class Item(SoftDeleteModel): @property def container(self): try: - return self.container_history.order_by('-timestamp').first().container + 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 @@ -92,7 +96,7 @@ class Comment(models.Model): timestamp = models.DateTimeField(auto_now_add=True) def __str__(self): - return str(self.issue_thread) + ' comment #' + str(self.id) + return str(self.item) + ' comment #' + str(self.id) class Event(models.Model): diff --git a/core/inventory/serializers.py b/core/inventory/serializers.py index 210ac0e..26a5be4 100644 --- a/core/inventory/serializers.py +++ b/core/inventory/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from rest_framework.relations import SlugRelatedField 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 mail.models import EventAddress from tickets.shared_serializers import BasicIssueSerializer @@ -38,6 +38,18 @@ class ContainerSerializer(serializers.ModelSerializer): 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): timeline = serializers.SerializerMethodField() dataImage = serializers.CharField(write_only=True, required=False) @@ -48,6 +60,12 @@ class ItemSerializer(BasicItemSerializer): fields = ['cid', 'box', 'id', 'description', 'file', 'dataImage', 'returned', 'event', 'related_issues', 'timeline'] 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): container = None @@ -106,6 +124,14 @@ class ItemSerializer(BasicItemSerializer): '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']) diff --git a/core/inventory/tests/v2/test_items.py b/core/inventory/tests/v2/test_items.py index 44b15dd..9b25384 100644 --- a/core/inventory/tests/v2/test_items.py +++ b/core/inventory/tests/v2/test_items.py @@ -7,7 +7,7 @@ from knox.models import AuthToken from authentication.models import ExtendedUser from files.models import File -from inventory.models import Event, Container, Item, Comment +from inventory.models import Event, Container, Item, Comment, ItemPlacement from base64 import b64encode @@ -21,7 +21,8 @@ class ItemTestCase(TestCase): def setUp(self): super().setUp() 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.user_permissions.add(*Permission.objects.all()) self.token = AuthToken.objects.create(user=self.user) @@ -38,7 +39,7 @@ class ItemTestCase(TestCase): def test_members_and_timeline(self): now = datetime.now() - item = Item.objects.create(container=self.box, event=self.event, description='1') + item = Item.objects.create(container=self.box1, event=self.event, description='1') comment = Comment.objects.create( item=item, comment="test", @@ -46,7 +47,12 @@ class ItemTestCase(TestCase): ) match = ItemRelation.objects.create( issue_thread=self.issue, - item = item, + 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/') @@ -54,25 +60,34 @@ class ItemTestCase(TestCase): 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'], 'BOX') - self.assertEqual(response.json()[0]['cid'], self.box.id) + self.assertEqual(response.json()[0]['box'], 'BOX2') + self.assertEqual(response.json()[0]['cid'], self.box2.id) self.assertEqual(response.json()[0]['file'], None) self.assertEqual(response.json()[0]['returned'], False) self.assertEqual(response.json()[0]['event'], self.event.slug) - self.assertEqual(len(response.json()[0]['timeline']), 2) - self.assertEqual(response.json()[0]['timeline'][0]['type'], 'comment') - self.assertEqual(response.json()[0]['timeline'][1]['type'], 'issue_relation') - self.assertEqual(response.json()[0]['timeline'][0]['id'], comment.id) - self.assertEqual(response.json()[0]['timeline'][1]['id'], match.id) - self.assertEqual(response.json()[0]['timeline'][0]['comment'], 'test') - self.assertEqual(response.json()[0]['timeline'][0]['timestamp'], - comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) - self.assertEqual(response.json()[0]['timeline'][1]['status'], 'possible') + 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'][1]['issue_thread']['name'], "test issue") - self.assertEqual(response.json()[0]['timeline'][1]['issue_thread']['event'], "EVENT") - self.assertEqual(response.json()[0]['timeline'][1]['issue_thread']['state'], "pending_new") + 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") @@ -80,15 +95,15 @@ class ItemTestCase(TestCase): def test_members_with_file(self): 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')) 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'], 'BOX') - self.assertEqual(response.json()[0]['cid'], self.box.id) + self.assertEqual(response.json()[0]['box'], 'BOX1') + self.assertEqual(response.json()[0]['cid'], self.box1.id) self.assertEqual(response.json()[0]['file'], file.hash) self.assertEqual(response.json()[0]['returned'], False) self.assertEqual(response.json()[0]['event'], self.event.slug) @@ -96,36 +111,38 @@ class ItemTestCase(TestCase): def test_members_with_two_file(self): import base64 - item = Item.objects.create(container=self.box, 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')) + 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'], 'BOX') - self.assertEqual(response.json()[0]['cid'], self.box.id) + 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): - Item.objects.create(container=self.box, event=self.event, description='1') - Item.objects.create(container=self.box, 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='1') + Item.objects.create(container=self.box1, event=self.event, description='2') + Item.objects.create(container=self.box1, event=self.event, description='3') response = self.client.get(f'/api/2/{self.event.slug}/item/') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 3) 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.json()['id'], 1) self.assertEqual(response.json()['description'], '1') - self.assertEqual(response.json()['box'], 'BOX') - self.assertEqual(response.json()['cid'], self.box.id) + self.assertEqual(response.json()['box'], 'BOX1') + self.assertEqual(response.json()['cid'], self.box1.id) self.assertEqual(response.json()['file'], None) self.assertEqual(response.json()['returned'], False) self.assertEqual(response.json()['event'], self.event.slug) @@ -133,43 +150,43 @@ class ItemTestCase(TestCase): self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(Item.objects.all()[0].id, 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): response = self.client.post(f'/api/2/{self.event.slug}/item/', {'description': '1'}) self.assertEqual(response.status_code, 400) 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) def test_create_item_with_file(self): import base64 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( 'utf-8')}, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(response.json()['id'], 1) self.assertEqual(response.json()['description'], '1') - self.assertEqual(response.json()['box'], 'BOX') - self.assertEqual(response.json()['id'], self.box.id) + self.assertEqual(response.json()['box'], 'BOX1') + self.assertEqual(response.json()['id'], self.box1.id) self.assertEqual(len(response.json()['file']), 64) self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(Item.objects.all()[0].id, 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) 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'}, - content_type='application/json') + content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.json()['id'], item.id) self.assertEqual(response.json()['description'], '2') - self.assertEqual(response.json()['box'], 'BOX') - self.assertEqual(response.json()['cid'], self.box.id) + self.assertEqual(response.json()['box'], 'BOX1') + self.assertEqual(response.json()['cid'], self.box1.id) self.assertEqual(response.json()['file'], None) self.assertEqual(response.json()['returned'], False) self.assertEqual(response.json()['event'], self.event.slug) @@ -177,60 +194,62 @@ class ItemTestCase(TestCase): self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(Item.objects.all()[0].id, 1) 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): 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}/', - {'description': '2', - 'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')}, - content_type='application/json') + {'description': '2', + 'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode( + 'utf-8')}, + content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.json()['id'], 1) self.assertEqual(response.json()['description'], '2') - self.assertEqual(response.json()['box'], 'BOX') - self.assertEqual(response.json()['id'], self.box.id) + self.assertEqual(response.json()['box'], 'BOX1') + self.assertEqual(response.json()['id'], self.box1.id) self.assertEqual(len(response.json()['file']), 64) self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(Item.objects.all()[0].id, 1) 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) def test_delete_item(self): - item = Item.objects.create(container=self.box, event=self.event, description='1') - Item.objects.create(container=self.box, event=self.event, description='2') + item = Item.objects.create(container=self.box1, event=self.event, description='1') + Item.objects.create(container=self.box1, event=self.event, description='2') self.assertEqual(len(Item.objects.all()), 2) response = self.client.delete(f'/api/2/{self.event.slug}/item/{item.id}/') self.assertEqual(response.status_code, 204) self.assertEqual(len(Item.objects.all()), 1) def test_delete_item2(self): - Item.objects.create(container=self.box, event=self.event, description='1') - item2 = Item.objects.create(container=self.box, event=self.event, description='2') + Item.objects.create(container=self.box1, event=self.event, description='1') + item2 = Item.objects.create(container=self.box1, event=self.event, description='2') self.assertEqual(len(Item.objects.all()), 2) response = self.client.delete(f'/api/2/{self.event.slug}/item/{item2.id}/') self.assertEqual(response.status_code, 204) 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(len(Item.objects.all()), 2) def test_item_count(self): - Item.objects.create(container=self.box, 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='1') + Item.objects.create(container=self.box1, event=self.event, description='2') response = self.client.get('/api/2/boxes/') 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()[1]['itemCount'], 0) def test_item_nonexistent(self): response = self.client.get(f'/api/2/NOEVENT/item/') self.assertEqual(response.status_code, 404) 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) response = self.client.get(f'/api/2/{self.event.slug}/item/') self.assertEqual(response.status_code, 200) @@ -245,8 +264,8 @@ class ItemTestCase(TestCase): self.assertEqual(len(response.json()), 0) def test_item_show_not_returned(self): - item1 = Item.objects.create(container=self.box, event=self.event, description='1') - item2 = Item.objects.create(container=self.box, event=self.event, description='2') + item1 = Item.objects.create(container=self.box1, event=self.event, description='1') + item2 = Item.objects.create(container=self.box1, event=self.event, description='2') response = self.client.get(f'/api/2/{self.event.slug}/item/') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 2) @@ -258,6 +277,77 @@ class ItemTestCase(TestCase): self.assertEqual(response.json()[0]['id'], item1.id) +class ItemSearchTestCase(TestCase): + + def setUp(self): + super().setUp() + self.event = Event.objects.create(slug='EVENT', name='Event') + self.box1 = Container.objects.create(name='BOX1') + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + self.item1 = Item.objects.create(container=self.box1, event=self.event, description='abc def') + self.item2 = Item.objects.create(container=self.box1, event=self.event, description='def ghi') + self.item3 = Item.objects.create(container=self.box1, event=self.event, description='jkl mno pqr') + self.item4 = Item.objects.create(container=self.box1, event=self.event, description='stu vwx') + + def test_search(self): + search_query = b64encode(b'abc').decode('utf-8') + response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') + self.assertEqual(200, response.status_code) + self.assertEqual(1, len(response.json())) + self.assertEqual(self.item1.id, response.json()[0]['id']) + self.assertEqual('abc def', response.json()[0]['description']) + self.assertEqual('BOX1', response.json()[0]['box']) + self.assertEqual(self.box1.id, response.json()[0]['cid']) + self.assertEqual(1, response.json()[0]['search_score']) + + def test_search2(self): + search_query = b64encode(b'def').decode('utf-8') + response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') + self.assertEqual(200, response.status_code) + self.assertEqual(2, len(response.json())) + self.assertEqual(self.item1.id, response.json()[0]['id']) + self.assertEqual('abc def', response.json()[0]['description']) + self.assertEqual('BOX1', response.json()[0]['box']) + self.assertEqual(self.box1.id, response.json()[0]['cid']) + self.assertEqual(1, response.json()[0]['search_score']) + self.assertEqual(self.item2.id, response.json()[1]['id']) + self.assertEqual('def ghi', response.json()[1]['description']) + self.assertEqual('BOX1', response.json()[1]['box']) + self.assertEqual(self.box1.id, response.json()[1]['cid']) + self.assertEqual(1, response.json()[0]['search_score']) + + def test_search3(self): + search_query = b64encode(b'jkl').decode('utf-8') + response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') + self.assertEqual(200, response.status_code) + self.assertEqual(1, len(response.json())) + self.assertEqual(self.item3.id, response.json()[0]['id']) + self.assertEqual('jkl mno pqr', response.json()[0]['description']) + self.assertEqual('BOX1', response.json()[0]['box']) + self.assertEqual(self.box1.id, response.json()[0]['cid']) + self.assertEqual(1, response.json()[0]['search_score']) + + def test_search4(self): + search_query = b64encode(b'abc def').decode('utf-8') + response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') + self.assertEqual(200, response.status_code) + self.assertEqual(2, len(response.json())) + self.assertEqual(self.item1.id, response.json()[0]['id']) + self.assertEqual('abc def', response.json()[0]['description']) + self.assertEqual('BOX1', response.json()[0]['box']) + self.assertEqual(self.box1.id, response.json()[0]['cid']) + self.assertEqual(2, response.json()[0]['search_score']) + self.assertEqual(self.item2.id, response.json()[1]['id']) + self.assertEqual('def ghi', response.json()[1]['description']) + self.assertEqual('BOX1', response.json()[1]['box']) + self.assertEqual(self.box1.id, response.json()[1]['cid']) + self.assertEqual(1, response.json()[1]['search_score']) + + + class ItemSearchTestCase(TestCase): def setUp(self): @@ -327,74 +417,3 @@ class ItemSearchTestCase(TestCase): self.assertEqual(self.box.id, response.json()[1]['cid']) self.assertEqual(1, response.json()[1]['search_score']) - - -class ItemSearchTestCase(TestCase): - - def setUp(self): - super().setUp() - self.event = Event.objects.create(slug='EVENT', name='Event') - self.box = Container.objects.create(name='BOX') - self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') - self.user.user_permissions.add(*Permission.objects.all()) - self.token = AuthToken.objects.create(user=self.user) - self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) - self.item1 = Item.objects.create(container=self.box, event=self.event, description='abc def') - self.item2 = Item.objects.create(container=self.box, event=self.event, description='def ghi') - self.item3 = Item.objects.create(container=self.box, event=self.event, description='jkl mno pqr') - self.item4 = Item.objects.create(container=self.box, event=self.event, description='stu vwx') - - def test_search(self): - search_query = b64encode(b'abc').decode('utf-8') - response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') - self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.json())) - self.assertEqual(self.item1.uid, response.json()[0]['uid']) - self.assertEqual('abc def', response.json()[0]['description']) - self.assertEqual('BOX', response.json()[0]['box']) - self.assertEqual(self.box.cid, response.json()[0]['cid']) - self.assertEqual(1, response.json()[0]['search_score']) - - def test_search2(self): - search_query = b64encode(b'def').decode('utf-8') - response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') - self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.json())) - self.assertEqual(self.item1.uid, response.json()[0]['uid']) - self.assertEqual('abc def', response.json()[0]['description']) - self.assertEqual('BOX', response.json()[0]['box']) - self.assertEqual(self.box.cid, response.json()[0]['cid']) - self.assertEqual(1, response.json()[0]['search_score']) - self.assertEqual(self.item2.uid, response.json()[1]['uid']) - self.assertEqual('def ghi', response.json()[1]['description']) - self.assertEqual('BOX', response.json()[1]['box']) - self.assertEqual(self.box.cid, response.json()[1]['cid']) - self.assertEqual(1, response.json()[0]['search_score']) - - def test_search3(self): - search_query = b64encode(b'jkl').decode('utf-8') - response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') - self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.json())) - self.assertEqual(self.item3.uid, response.json()[0]['uid']) - self.assertEqual('jkl mno pqr', response.json()[0]['description']) - self.assertEqual('BOX', response.json()[0]['box']) - self.assertEqual(self.box.cid, response.json()[0]['cid']) - self.assertEqual(1, response.json()[0]['search_score']) - - def test_search4(self): - search_query = b64encode(b'abc def').decode('utf-8') - response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') - self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.json())) - self.assertEqual(self.item1.uid, response.json()[0]['uid']) - self.assertEqual('abc def', response.json()[0]['description']) - self.assertEqual('BOX', response.json()[0]['box']) - self.assertEqual(self.box.cid, response.json()[0]['cid']) - self.assertEqual(2, response.json()[0]['search_score']) - self.assertEqual(self.item2.uid, response.json()[1]['uid']) - self.assertEqual('def ghi', response.json()[1]['description']) - self.assertEqual('BOX', response.json()[1]['box']) - self.assertEqual(self.box.cid, response.json()[1]['cid']) - self.assertEqual(1, response.json()[1]['search_score']) - diff --git a/core/tickets/api_v2.py b/core/tickets/api_v2.py index 415e045..99dc008 100644 --- a/core/tickets/api_v2.py +++ b/core/tickets/api_v2.py @@ -21,7 +21,13 @@ from tickets.shared_serializers import RelationSerializer class IssueViewSet(viewsets.ModelViewSet): serializer_class = IssueSerializer - queryset = IssueThread.objects.all().prefetch_related('state_changes', 'comments', 'emails', 'emails__attachments', 'assignments', '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): @@ -171,5 +177,5 @@ urlpatterns = ([ re_path(r'^tickets/(?P\d+)/comment/$', add_comment, name='add_comment'), re_path(r'^(?P[\w-]+)/tickets/manual/$', manual_ticket, name='manual_ticket'), re_path(r'^(?P[\w-]+)/tickets/(?P[-A-Za-z0-9+/]*={0,3})/$', search_issues, - name='search_issues'), + name='search_issues'), ] + router.urls) diff --git a/core/tickets/serializers.py b/core/tickets/serializers.py index d25aa98..6ce73be 100644 --- a/core/tickets/serializers.py +++ b/core/tickets/serializers.py @@ -47,6 +47,12 @@ class IssueSerializer(BasicIssueSerializer): model = IssueThread fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items', 'event') 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): ret = super().to_internal_value(data) @@ -63,12 +69,14 @@ class IssueSerializer(BasicIssueSerializer): @staticmethod def get_last_activity(self): 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_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_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 x is not None] return max(args) @@ -129,7 +137,6 @@ class IssueSerializer(BasicIssueSerializer): return sorted(timeline, key=lambda x: x['timestamp']) - class SearchResultSerializer(serializers.Serializer): search_score = serializers.IntegerField() item = IssueSerializer() diff --git a/deploy/ansible/playbooks/templates/nginx.conf.j2 b/deploy/ansible/playbooks/templates/nginx.conf.j2 index 3533f37..1bde29a 100644 --- a/deploy/ansible/playbooks/templates/nginx.conf.j2 +++ b/deploy/ansible/playbooks/templates/nginx.conf.j2 @@ -75,6 +75,14 @@ server { allow 127.0.0.1; allow ::1; 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