Compare commits

...

2 commits

Author SHA1 Message Date
796b687719 implement simple backend search for items and tickets
Some checks failed
/ test (push) Failing after 51s
/ test (pull_request) Failing after 50s
2024-11-23 01:52:34 +01:00
d5eadbe4b1 add timeline information to the /items endpoint
All checks were successful
/ test (push) Successful in 52s
/ deploy (push) Successful in 4m37s
2024-11-23 01:22:24 +01:00
7 changed files with 233 additions and 12 deletions

View file

@ -20,6 +20,9 @@ jobs:
- name: Run django tests - name: Run django tests
working-directory: core working-directory: core
run: python3 manage.py test run: python3 manage.py test
- name: Run django coverage
working-directory: core
run: coverage manage.py test
deploy: deploy:
needs: [ test ] needs: [ test ]

View file

@ -67,6 +67,8 @@ 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(['GET', 'PUT', 'DELETE', 'PATCH']) @api_view(['GET', 'PUT', 'DELETE', 'PATCH'])

View file

@ -0,0 +1,36 @@
# 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')),
],
),
]

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,28 @@ 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:
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 @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 +66,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.issue_thread) + ' 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)

View file

@ -35,16 +35,18 @@ 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 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']
def to_internal_value(self, data): def to_internal_value(self, data):
@ -86,6 +88,26 @@ 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,
})
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

@ -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,7 +7,11 @@ 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
from base64 import b64encode
from tickets.models import IssueThread, ItemRelation
from base64 import b64encode from base64 import b64encode
@ -20,14 +26,29 @@ class ItemTestCase(TestCase):
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):
now = datetime.now()
item = Item.objects.create(container=self.box, event=self.event, description='1') 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/') 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)
@ -38,7 +59,24 @@ class ItemTestCase(TestCase):
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']), 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): def test_members_with_file(self):
import base64 import base64
@ -256,7 +294,7 @@ class ItemSearchTestCase(TestCase):
self.assertEqual('BOX', response.json()[0]['box']) self.assertEqual('BOX', response.json()[0]['box'])
self.assertEqual(self.box.id, response.json()[0]['cid']) self.assertEqual(self.box.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('BOX', response.json()[1]['box'])
self.assertEqual(self.box.id, response.json()[1]['cid']) self.assertEqual(self.box.id, response.json()[1]['cid'])
@ -289,3 +327,74 @@ class ItemSearchTestCase(TestCase):
self.assertEqual(self.box.id, response.json()[1]['cid']) self.assertEqual(self.box.id, response.json()[1]['cid'])
self.assertEqual(1, response.json()[1]['search_score']) 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'])

View file

@ -130,6 +130,17 @@ class IssueSerializer(BasicIssueSerializer):
class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField()
item = IssueSerializer()
def to_representation(self, instance):
return {**IssueSerializer(instance['item']).data, 'search_score': instance['search_score']}
class Meta:
model = IssueThread
class SearchResultSerializer(serializers.Serializer): class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField() search_score = serializers.IntegerField()
item = IssueSerializer() item = IssueSerializer()