From 23215e14acd09a0e193c29e6e725454c73d5450a Mon Sep 17 00:00:00 2001
From: jedi <git@m.j3d1.de>
Date: Sat, 9 Nov 2024 05:05:05 +0100
Subject: [PATCH] implement simple backend search for items and tickets

---
 core/inventory/api_v2.py              | 26 +++++++---
 core/inventory/serializers.py         | 11 ++++
 core/inventory/tests/v2/test_items.py | 73 +++++++++++++++++++++++++++
 core/tickets/api_v2.py                | 28 +++++++++-
 core/tickets/serializers.py           | 11 ++++
 core/tickets/tests/v2/test_tickets.py | 20 ++++++++
 6 files changed, 160 insertions(+), 9 deletions(-)

diff --git a/core/inventory/api_v2.py b/core/inventory/api_v2.py
index f853b47..e1f643e 100644
--- a/core/inventory/api_v2.py
+++ b/core/inventory/api_v2.py
@@ -6,7 +6,9 @@ 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
+from inventory.serializers import EventSerializer, ContainerSerializer, ItemSerializer, SearchResultSerializer
+
+from base64 import b64decode
 
 
 class EventViewSet(viewsets.ModelViewSet):
@@ -20,18 +22,26 @@ class ContainerViewSet(viewsets.ModelViewSet):
     queryset = Container.objects.all()
 
 
+def filter_items(items, query):
+    query_tokens = query.split(' ')
+    for item in items:
+        value = 0
+        for token in query_tokens:
+            if token in item.description:
+                value += 1
+        if value > 0:
+            yield {'search_score': value, 'item': item}
+
+
 @api_view(['GET'])
 @permission_classes([IsAuthenticated])
-@permission_required('view_item', raise_exception=True)
 def search_items(request, event_slug, query):
     try:
         event = Event.objects.get(slug=event_slug)
-        query_tokens = query.split(' ')
-        q = Item.objects.filter(event=event)
-        for token in query_tokens:
-            if token:
-                q = q.filter(description__icontains=token)
-        return Response(ItemSerializer(q, many=True).data)
+        if not request.user.has_event_perm(event, 'view_item'):
+            return Response(status=403)
+        items = filter_items(Item.objects.filter(event=event), b64decode(query).decode('utf-8'))
+        return Response(SearchResultSerializer(items, many=True).data)
     except Event.DoesNotExist:
         return Response(status=404)
 
diff --git a/core/inventory/serializers.py b/core/inventory/serializers.py
index 941e20f..9a611f6 100644
--- a/core/inventory/serializers.py
+++ b/core/inventory/serializers.py
@@ -83,3 +83,14 @@ class ItemSerializer(BasicItemSerializer):
             validated_data.pop('dataImage')
             instance.files.add(file)
         return super().update(instance, validated_data)
+
+
+class SearchResultSerializer(serializers.Serializer):
+    search_score = serializers.IntegerField()
+    item = ItemSerializer()
+
+    def to_representation(self, instance):
+        return {**ItemSerializer(instance['item']).data, 'search_score': instance['search_score']}
+
+    class Meta:
+        model = Item
diff --git a/core/inventory/tests/v2/test_items.py b/core/inventory/tests/v2/test_items.py
index 953f1a3..59a5a4e 100644
--- a/core/inventory/tests/v2/test_items.py
+++ b/core/inventory/tests/v2/test_items.py
@@ -7,6 +7,8 @@ from authentication.models import ExtendedUser
 from files.models import File
 from inventory.models import Event, Container, Item
 
+from base64 import b64encode
+
 
 class ItemTestCase(TestCase):
 
@@ -199,3 +201,74 @@ class ItemTestCase(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(len(response.json()), 1)
         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.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.id, response.json()[0]['id'])
+        self.assertEqual('abc def', response.json()[0]['description'])
+        self.assertEqual('BOX', response.json()[0]['box'])
+        self.assertEqual(self.box.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('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('def ghi', response.json()[1]['description'])
+        self.assertEqual('BOX', response.json()[1]['box'])
+        self.assertEqual(self.box.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('BOX', response.json()[0]['box'])
+        self.assertEqual(self.box.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('BOX', response.json()[0]['box'])
+        self.assertEqual(self.box.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('BOX', response.json()[1]['box'])
+        self.assertEqual(self.box.id, 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 b119bd9..1ed7e02 100644
--- a/core/tickets/api_v2.py
+++ b/core/tickets/api_v2.py
@@ -15,7 +15,7 @@ from mail.models import Email
 from mail.protocol import send_smtp, make_reply, collect_references
 from notify_sessions.models import SystemEvent
 from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher, ItemRelation
-from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer
+from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer, SearchResultSerializer
 from tickets.shared_serializers import RelationSerializer
 
 
@@ -136,6 +136,30 @@ def add_comment(request, pk):
     return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
 
 
+def filter_issues(issues, query):
+    query_tokens = query.split(' ')
+    for issue in issues:
+        value = 0
+        for token in query_tokens:
+            if token in issue.description:
+                value += 1
+        if value > 0:
+            yield {'search_score': value, 'issue': issue}
+
+
+@api_view(['GET'])
+@permission_classes([])
+# @permission_classes([IsAuthenticated])
+# @permission_required('view_item', raise_exception=True)
+def search_issues(request, event_slug, query):
+    try:
+        event = Event.objects.get(slug=event_slug)
+        items = filter_issues(IssueThread.objects.filter(event=event), b64decode(query).decode('utf-8'))
+        return Response(SearchResultSerializer(items, many=True).data)
+    except Event.DoesNotExist:
+        return Response(status=404)
+
+
 router = routers.SimpleRouter()
 router.register(r'tickets', IssueViewSet, basename='issues')
 router.register(r'matches', RelationViewSet, basename='matches')
@@ -146,4 +170,6 @@ urlpatterns = ([
                    re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),
                    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/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_issues,
+                            name='search_issues'),
                ] + router.urls)
diff --git a/core/tickets/serializers.py b/core/tickets/serializers.py
index 2778dd2..aae37ba 100644
--- a/core/tickets/serializers.py
+++ b/core/tickets/serializers.py
@@ -130,3 +130,14 @@ class IssueSerializer(BasicIssueSerializer):
             })
         return sorted(timeline, key=lambda x: x['timestamp'])
 
+
+
+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
diff --git a/core/tickets/tests/v2/test_tickets.py b/core/tickets/tests/v2/test_tickets.py
index e0fb004..105de93 100644
--- a/core/tickets/tests/v2/test_tickets.py
+++ b/core/tickets/tests/v2/test_tickets.py
@@ -9,6 +9,8 @@ from tickets.models import IssueThread, StateChange, Comment, ItemRelation
 from django.contrib.auth.models import Permission
 from knox.models import AuthToken
 
+from base64 import b64encode
+
 
 class IssueApiTest(TestCase):
 
@@ -365,3 +367,21 @@ class IssueApiTest(TestCase):
         self.assertEqual('pending_new', timeline[0]['state'])
         self.assertEqual('assignment', timeline[1]['type'])
         self.assertEqual(self.user.username, timeline[1]['assigned_to'])
+
+
+class IssueSearchTest(TestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.event = Event.objects.create(slug='EVENT', name='Event')
+        self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
+        self.user.user_permissions.add(*Permission.objects.all())
+        self.user.save()
+        self.token = AuthToken.objects.create(user=self.user)
+        self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
+
+    def test_search(self):
+        search_query = b64encode(b'abc').decode('utf-8')
+        response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual([], response.json())