From b19f59c689e2199c2a88347caadcc7e50a1ad514 Mon Sep 17 00:00:00 2001
From: jedi <git@m.j3d1.de>
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..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 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'])