Compare commits

...

6 commits

Author SHA1 Message Date
016edae3eb implement simple backend search for items and tickets
All checks were successful
/ test (push) Successful in 53s
2024-11-29 17:31:55 +01:00
eb9e9088ca update prefetched fields
All checks were successful
/ test (push) Successful in 51s
/ deploy (push) Successful in 3m35s
2024-11-29 16:53:15 +01:00
b109e5995e add /item/comment endpoint and prefetch related models
All checks were successful
/ test (push) Successful in 52s
/ deploy (push) Successful in 4m48s
2024-11-28 21:58:26 +01:00
a59423780e deploy: Fix metrics in nginx
All checks were successful
/ test (push) Successful in 48s
/ deploy (push) Successful in 4m56s
2024-11-25 19:36:16 +01:00
b6b29d9fed update admin interface
All checks were successful
/ test (push) Successful in 52s
/ deploy (push) Successful in 4m37s
2024-11-24 16:28:14 +01:00
61c4beda47 add timeline information to the /items endpoint
All checks were successful
/ test (push) Successful in 55s
/ deploy (push) Successful in 3m40s
2024-11-23 16:58:55 +01:00
10 changed files with 426 additions and 69 deletions

View file

@ -20,9 +20,12 @@ 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 ]
runs-on: docker runs-on: docker
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View file

@ -1,6 +1,6 @@
from django.contrib import admin 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): class ItemAdmin(admin.ModelAdmin):
@ -22,3 +22,17 @@ class EventAdmin(admin.ModelAdmin):
admin.site.register(Event, EventAdmin) 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)

View file

@ -1,12 +1,13 @@
from django.urls import re_path from django.urls import re_path
from django.contrib.auth.decorators import permission_required 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.decorators import api_view, permission_classes
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from inventory.models import Event, Container, Item from inventory.models import Event, Container, Item, Comment
from inventory.serializers import EventSerializer, ContainerSerializer, ItemSerializer, SearchResultSerializer from inventory.serializers import EventSerializer, ContainerSerializer, CommentSerializer, ItemSerializer, \
SearchResultSerializer
from base64 import b64decode from base64 import b64decode
@ -22,6 +23,20 @@ class ContainerViewSet(viewsets.ModelViewSet):
queryset = Container.objects.all() 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): def filter_items(items, query):
query_tokens = query.split(' ') query_tokens = query.split(' ')
for item in items: for item in items:
@ -49,6 +64,7 @@ def search_items(request, event_slug, query):
@api_view(['GET', 'POST']) @api_view(['GET', 'POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def item(request, event_slug): def item(request, event_slug):
vs = ItemViewSet()
try: try:
event = None event = None
if event_slug != 'none': if event_slug != 'none':
@ -56,7 +72,7 @@ def item(request, event_slug):
if request.method == 'GET': if request.method == 'GET':
if not request.user.has_event_perm(event, 'view_item'): if not request.user.has_event_perm(event, 'view_item'):
return Response(status=403) 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': elif request.method == 'POST':
if not request.user.has_event_perm(event, 'add_item'): if not request.user.has_event_perm(event, 'add_item'):
return Response(status=403) return Response(status=403)
@ -67,6 +83,27 @@ 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(['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']) @api_view(['GET', 'PUT', 'DELETE', 'PATCH'])
@ -115,5 +152,6 @@ urlpatterns = router.urls + [
re_path(r'^(?P<event_slug>[\w-]+)/items/$', item, name='item'), re_path(r'^(?P<event_slug>[\w-]+)/items/$', item, name='item'),
re_path(r'^(?P<event_slug>[\w-]+)/items/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_items, name='search_items'), re_path(r'^(?P<event_slug>[\w-]+)/items/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_items, name='search_items'),
re_path(r'^(?P<event_slug>[\w-]+)/item/$', item, name='item'), re_path(r'^(?P<event_slug>[\w-]+)/item/$', item, name='item'),
re_path(r'^(?P<event_slug>[\w-]+)/item/(?P<id>\d+)/comment/$', add_comment, name='add_comment'),
re_path(r'^(?P<event_slug>[\w-]+)/item/(?P<id>\d+)/$', item_by_id, name='item_by_id'), re_path(r'^(?P<event_slug>[\w-]+)/item/(?P<id>\d+)/$', item_by_id, name='item_by_id'),
] ]

View file

@ -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',
),
]

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,32 @@ 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:
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
@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 +70,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.item) + ' 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

@ -3,7 +3,7 @@ from rest_framework import serializers
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
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 inventory.shared_serializers import BasicItemSerializer from inventory.shared_serializers import BasicItemSerializer
from mail.models import EventAddress from mail.models import EventAddress
from tickets.shared_serializers import BasicIssueSerializer from tickets.shared_serializers import BasicIssueSerializer
@ -35,17 +35,37 @@ 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 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): 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']
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): def to_internal_value(self, data):
container = None container = None
@ -86,6 +106,34 @@ 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,
})
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'])
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, ItemPlacement
from base64 import b64encode
from tickets.models import IssueThread, ItemRelation
from base64 import b64encode from base64 import b64encode
@ -15,42 +21,89 @@ class ItemTestCase(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event') 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 = ExtendedUser.objects.create_user('testuser', 'test', 'test')
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):
item = Item.objects.create(container=self.box, event=self.event, description='1') now = datetime.now()
item = Item.objects.create(container=self.box1, 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=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/') 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)
self.assertEqual(response.json()[0]['id'], item.id) self.assertEqual(response.json()[0]['id'], item.id)
self.assertEqual(response.json()[0]['description'], '1') self.assertEqual(response.json()[0]['description'], '1')
self.assertEqual(response.json()[0]['box'], 'BOX') self.assertEqual(response.json()[0]['box'], 'BOX2')
self.assertEqual(response.json()[0]['cid'], self.box.id) self.assertEqual(response.json()[0]['cid'], self.box2.id)
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']), 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'][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")
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
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')) 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/') 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)
self.assertEqual(response.json()[0]['id'], item.id) self.assertEqual(response.json()[0]['id'], item.id)
self.assertEqual(response.json()[0]['description'], '1') self.assertEqual(response.json()[0]['description'], '1')
self.assertEqual(response.json()[0]['box'], 'BOX') self.assertEqual(response.json()[0]['box'], 'BOX1')
self.assertEqual(response.json()[0]['cid'], self.box.id) self.assertEqual(response.json()[0]['cid'], self.box1.id)
self.assertEqual(response.json()[0]['file'], file.hash) self.assertEqual(response.json()[0]['file'], file.hash)
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)
@ -58,36 +111,38 @@ class ItemTestCase(TestCase):
def test_members_with_two_file(self): def test_members_with_two_file(self):
import base64 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')
file1 = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')) file1 = File.objects.create(item=item,
file2 = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"bar").decode('utf-8')) 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/') 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)
self.assertEqual(response.json()[0]['id'], item.id) self.assertEqual(response.json()[0]['id'], item.id)
self.assertEqual(response.json()[0]['description'], '1') self.assertEqual(response.json()[0]['description'], '1')
self.assertEqual(response.json()[0]['box'], 'BOX') self.assertEqual(response.json()[0]['box'], 'BOX1')
self.assertEqual(response.json()[0]['cid'], self.box.id) self.assertEqual(response.json()[0]['cid'], self.box1.id)
self.assertEqual(response.json()[0]['file'], file2.hash) self.assertEqual(response.json()[0]['file'], file2.hash)
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]['related_issues']), 0)
def test_multi_members(self): def test_multi_members(self):
Item.objects.create(container=self.box, event=self.event, description='1') Item.objects.create(container=self.box1, 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='2')
Item.objects.create(container=self.box, event=self.event, description='3') Item.objects.create(container=self.box1, event=self.event, description='3')
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()), 3) self.assertEqual(len(response.json()), 3)
def test_create_item(self): 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.status_code, 201)
self.assertEqual(response.json()['id'], 1) self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['description'], '1') self.assertEqual(response.json()['description'], '1')
self.assertEqual(response.json()['box'], 'BOX') self.assertEqual(response.json()['box'], 'BOX1')
self.assertEqual(response.json()['cid'], self.box.id) self.assertEqual(response.json()['cid'], self.box1.id)
self.assertEqual(response.json()['file'], None) self.assertEqual(response.json()['file'], None)
self.assertEqual(response.json()['returned'], False) self.assertEqual(response.json()['returned'], False)
self.assertEqual(response.json()['event'], self.event.slug) self.assertEqual(response.json()['event'], self.event.slug)
@ -95,43 +150,43 @@ class ItemTestCase(TestCase):
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1) self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].description, '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): def test_create_item_without_container(self):
response = self.client.post(f'/api/2/{self.event.slug}/item/', {'description': '1'}) response = self.client.post(f'/api/2/{self.event.slug}/item/', {'description': '1'})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_create_item_without_description(self): 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) self.assertEqual(response.status_code, 400)
def test_create_item_with_file(self): def test_create_item_with_file(self):
import base64 import base64
response = self.client.post(f'/api/2/{self.event.slug}/item/', 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( 'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode(
'utf-8')}, content_type='application/json') 'utf-8')}, content_type='application/json')
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['id'], 1) self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['description'], '1') self.assertEqual(response.json()['description'], '1')
self.assertEqual(response.json()['box'], 'BOX') self.assertEqual(response.json()['box'], 'BOX1')
self.assertEqual(response.json()['id'], self.box.id) self.assertEqual(response.json()['id'], self.box1.id)
self.assertEqual(len(response.json()['file']), 64) self.assertEqual(len(response.json()['file']), 64)
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1) self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].description, '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) self.assertEqual(len(File.objects.all()), 1)
def test_update_item(self): 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'}, 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.status_code, 200)
self.assertEqual(response.json()['id'], item.id) self.assertEqual(response.json()['id'], item.id)
self.assertEqual(response.json()['description'], '2') self.assertEqual(response.json()['description'], '2')
self.assertEqual(response.json()['box'], 'BOX') self.assertEqual(response.json()['box'], 'BOX1')
self.assertEqual(response.json()['cid'], self.box.id) self.assertEqual(response.json()['cid'], self.box1.id)
self.assertEqual(response.json()['file'], None) self.assertEqual(response.json()['file'], None)
self.assertEqual(response.json()['returned'], False) self.assertEqual(response.json()['returned'], False)
self.assertEqual(response.json()['event'], self.event.slug) self.assertEqual(response.json()['event'], self.event.slug)
@ -139,60 +194,62 @@ class ItemTestCase(TestCase):
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1) self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].description, '2') 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): def test_update_item_with_file(self):
import base64 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}/', response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/',
{'description': '2', {'description': '2',
'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')}, 'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode(
content_type='application/json') 'utf-8')},
content_type='application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['id'], 1) self.assertEqual(response.json()['id'], 1)
self.assertEqual(response.json()['description'], '2') self.assertEqual(response.json()['description'], '2')
self.assertEqual(response.json()['box'], 'BOX') self.assertEqual(response.json()['box'], 'BOX1')
self.assertEqual(response.json()['id'], self.box.id) self.assertEqual(response.json()['id'], self.box1.id)
self.assertEqual(len(response.json()['file']), 64) self.assertEqual(len(response.json()['file']), 64)
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].id, 1) self.assertEqual(Item.objects.all()[0].id, 1)
self.assertEqual(Item.objects.all()[0].description, '2') 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) self.assertEqual(len(File.objects.all()), 1)
def test_delete_item(self): def test_delete_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')
Item.objects.create(container=self.box, event=self.event, description='2') Item.objects.create(container=self.box1, event=self.event, description='2')
self.assertEqual(len(Item.objects.all()), 2) self.assertEqual(len(Item.objects.all()), 2)
response = self.client.delete(f'/api/2/{self.event.slug}/item/{item.id}/') response = self.client.delete(f'/api/2/{self.event.slug}/item/{item.id}/')
self.assertEqual(response.status_code, 204) self.assertEqual(response.status_code, 204)
self.assertEqual(len(Item.objects.all()), 1) self.assertEqual(len(Item.objects.all()), 1)
def test_delete_item2(self): def test_delete_item2(self):
Item.objects.create(container=self.box, event=self.event, description='1') Item.objects.create(container=self.box1, event=self.event, description='1')
item2 = Item.objects.create(container=self.box, event=self.event, description='2') item2 = Item.objects.create(container=self.box1, event=self.event, description='2')
self.assertEqual(len(Item.objects.all()), 2) self.assertEqual(len(Item.objects.all()), 2)
response = self.client.delete(f'/api/2/{self.event.slug}/item/{item2.id}/') response = self.client.delete(f'/api/2/{self.event.slug}/item/{item2.id}/')
self.assertEqual(response.status_code, 204) self.assertEqual(response.status_code, 204)
self.assertEqual(len(Item.objects.all()), 1) 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(item3.id, 3)
self.assertEqual(len(Item.objects.all()), 2) self.assertEqual(len(Item.objects.all()), 2)
def test_item_count(self): def test_item_count(self):
Item.objects.create(container=self.box, event=self.event, description='1') Item.objects.create(container=self.box1, 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='2')
response = self.client.get('/api/2/boxes/') response = self.client.get('/api/2/boxes/')
self.assertEqual(response.status_code, 200) 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()[0]['itemCount'], 2)
self.assertEqual(response.json()[1]['itemCount'], 0)
def test_item_nonexistent(self): def test_item_nonexistent(self):
response = self.client.get(f'/api/2/NOEVENT/item/') response = self.client.get(f'/api/2/NOEVENT/item/')
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_item_return(self): 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) self.assertEqual(item.returned_at, None)
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)
@ -207,8 +264,8 @@ class ItemTestCase(TestCase):
self.assertEqual(len(response.json()), 0) self.assertEqual(len(response.json()), 0)
def test_item_show_not_returned(self): def test_item_show_not_returned(self):
item1 = Item.objects.create(container=self.box, event=self.event, description='1') item1 = Item.objects.create(container=self.box1, event=self.event, description='1')
item2 = Item.objects.create(container=self.box, event=self.event, description='2') item2 = Item.objects.create(container=self.box1, event=self.event, description='2')
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()), 2) self.assertEqual(len(response.json()), 2)
@ -220,6 +277,77 @@ class ItemTestCase(TestCase):
self.assertEqual(response.json()[0]['id'], item1.id) 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): class ItemSearchTestCase(TestCase):
def setUp(self): def setUp(self):
@ -256,7 +384,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'])

View file

@ -21,7 +21,13 @@ from tickets.shared_serializers import RelationSerializer
class IssueViewSet(viewsets.ModelViewSet): class IssueViewSet(viewsets.ModelViewSet):
serializer_class = IssueSerializer 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): class RelationViewSet(viewsets.ModelViewSet):
@ -171,5 +177,5 @@ urlpatterns = ([
re_path(r'^tickets/(?P<pk>\d+)/comment/$', add_comment, name='add_comment'), 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/manual/$', manual_ticket, name='manual_ticket'),
re_path(r'^(?P<event_slug>[\w-]+)/tickets/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_issues, re_path(r'^(?P<event_slug>[\w-]+)/tickets/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_issues,
name='search_issues'), name='search_issues'),
] + router.urls) ] + router.urls)

View file

@ -47,6 +47,12 @@ class IssueSerializer(BasicIssueSerializer):
model = IssueThread model = IssueThread
fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items', 'event') fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items', 'event')
read_only_fields = ('id', 'timeline', 'last_activity', 'uuid', 'related_items') 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): def to_internal_value(self, data):
ret = super().to_internal_value(data) ret = super().to_internal_value(data)
@ -63,12 +69,14 @@ class IssueSerializer(BasicIssueSerializer):
@staticmethod @staticmethod
def get_last_activity(self): def get_last_activity(self):
try: 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_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_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_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 args = [x for x in [last_state_change, last_comment, last_mail, last_assignment, last_relation] if
x is not None] x is not None]
return max(args) return max(args)
@ -129,6 +137,16 @@ class IssueSerializer(BasicIssueSerializer):
return sorted(timeline, key=lambda x: x['timestamp']) 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
class SearchResultSerializer(serializers.Serializer): class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField() search_score = serializers.IntegerField()

View file

@ -75,6 +75,14 @@ server {
allow 127.0.0.1; allow 127.0.0.1;
allow ::1; allow ::1;
deny all; 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 listen 443 ssl http2; # managed by Certbot