From e09d2fa2abe70ec85dc420ea91eb7ecfc4cc2ca6 Mon Sep 17 00:00:00 2001 From: jedi Date: Sat, 23 Nov 2024 01:22:24 +0100 Subject: [PATCH] add timeline information to the /items endpoint --- core/inventory/api_v2.py | 2 + ...em_container_old_itemplacement_and_more.py | 52 +++++++++++++++++++ core/inventory/models.py | 48 +++++++++++++++-- core/inventory/serializers.py | 26 +++++++++- core/inventory/tests/v2/test_items.py | 44 ++++++++++++++-- 5 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 core/inventory/migrations/0007_rename_container_item_container_old_itemplacement_and_more.py diff --git a/core/inventory/api_v2.py b/core/inventory/api_v2.py index e1f643e..60f0292 100644 --- a/core/inventory/api_v2.py +++ b/core/inventory/api_v2.py @@ -67,6 +67,8 @@ def item(request, event_slug): return Response(status=400) except Event.DoesNotExist: return Response(status=404) + except KeyError: + return Response(status=400) @api_view(['GET', 'PUT', 'DELETE', 'PATCH']) 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..a3a411d --- /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.self.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 3421680..fbbe0a9 100644 --- a/core/inventory/models.py +++ b/core/inventory/models.py @@ -7,11 +7,14 @@ from django_softdelete.models import SoftDeleteModel, SoftDeleteManager class ItemManager(SoftDeleteManager): def create(self, **kwargs): + container = kwargs.pop('container') if 'uid_deprecated' in kwargs: raise ValueError('uid_deprecated must not be set manually') uid_deprecated = Item.all_objects.filter(event=kwargs['event']).count() + 1 kwargs['uid_deprecated'] = uid_deprecated - return super().create(**kwargs) + item = super().create(**kwargs) + item.container = container + return item def get_queryset(self): return super().get_queryset().filter(returned_at__isnull=True) @@ -22,18 +25,28 @@ class Item(SoftDeleteModel): uid_deprecated = models.IntegerField() description = models.TextField() event = models.ForeignKey('Event', models.CASCADE) - container = models.ForeignKey('Container', models.CASCADE) returned_at = models.DateTimeField(blank=True, null=True) created_at = models.DateTimeField(null=True, auto_now_add=True) updated_at = models.DateTimeField(blank=True, null=True) + @property + def container(self): + try: + return self.container_history.order_by('-timestamp').first().container + except AttributeError: + return None + + @container.setter + def container(self, value): + if self.container == value: + return + self.container_history.create(container=value) + @property def related_issues(self): 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] - - objects = ItemManager() all_objects = models.Manager() @@ -53,10 +66,35 @@ class Container(SoftDeleteModel): created_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): 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.issue_thread) + ' comment #' + str(self.id) + + class Event(models.Model): id = models.AutoField(primary_key=True) name = models.CharField(max_length=255) @@ -72,4 +110,4 @@ class Event(models.Model): return '[' + str(self.slug) + ']' + self.name class Meta: - db_table = 'common_event' \ No newline at end of file + db_table = 'common_event' diff --git a/core/inventory/serializers.py b/core/inventory/serializers.py index 2611ce3..210ac0e 100644 --- a/core/inventory/serializers.py +++ b/core/inventory/serializers.py @@ -35,16 +35,18 @@ class ContainerSerializer(serializers.ModelSerializer): read_only_fields = ['id', 'itemCount'] def get_itemCount(self, instance): - return Item.objects.filter(container=instance.id).count() + return len(instance.items) class ItemSerializer(BasicItemSerializer): + timeline = serializers.SerializerMethodField() dataImage = serializers.CharField(write_only=True, required=False) related_issues = BasicIssueSerializer(many=True, read_only=True) class Meta: 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'] def to_internal_value(self, data): @@ -86,6 +88,26 @@ class ItemSerializer(BasicItemSerializer): instance.files.add(file) 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, + }) + return sorted(timeline, key=lambda x: x['timestamp']) + class SearchResultSerializer(serializers.Serializer): search_score = serializers.IntegerField() diff --git a/core/inventory/tests/v2/test_items.py b/core/inventory/tests/v2/test_items.py index d289a70..8a38b86 100644 --- a/core/inventory/tests/v2/test_items.py +++ b/core/inventory/tests/v2/test_items.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta + from django.utils import timezone from django.test import TestCase, Client from django.contrib.auth.models import Permission @@ -5,10 +7,12 @@ from knox.models import AuthToken from authentication.models import ExtendedUser from files.models import File -from inventory.models import Event, Container, Item +from inventory.models import Event, Container, Item, Comment from base64 import b64encode +from tickets.models import IssueThread, ItemRelation + class ItemTestCase(TestCase): @@ -20,14 +24,29 @@ class ItemTestCase(TestCase): 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.issue = IssueThread.objects.create( + name="test issue", + event=self.event, + ) def test_empty(self): response = self.client.get(f'/api/2/{self.event.slug}/item/') self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b'[]') - def test_members(self): + def test_members_and_timeline(self): + now = datetime.now() item = Item.objects.create(container=self.box, 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=5), + ) response = self.client.get(f'/api/2/{self.event.slug}/item/') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 1) @@ -38,7 +57,24 @@ class ItemTestCase(TestCase): 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]['related_issues']), 0) + 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(response.json()[0]['timeline'][1]['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(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): import base64 @@ -256,7 +292,7 @@ class ItemSearchTestCase(TestCase): self.assertEqual('BOX', response.json()[0]['box']) self.assertEqual(self.box.id, response.json()[0]['cid']) 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('BOX', response.json()[1]['box']) self.assertEqual(self.box.id, response.json()[1]['cid'])