From b109e5995ee376c7b43b48e738072c537f31ec64 Mon Sep 17 00:00:00 2001 From: jedi Date: Thu, 28 Nov 2024 21:58:26 +0100 Subject: [PATCH 01/10] add /item/comment endpoint and prefetch related models --- core/inventory/api_v2.py | 43 +++++++++++++++++++++++++++++++---- core/inventory/serializers.py | 15 +++++++++++- core/tickets/api_v2.py | 10 ++++++-- core/tickets/serializers.py | 9 +++++--- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/core/inventory/api_v2.py b/core/inventory/api_v2.py index 60f0292..64f9b27 100644 --- a/core/inventory/api_v2.py +++ b/core/inventory/api_v2.py @@ -1,12 +1,13 @@ from django.urls import re_path 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.response import Response from rest_framework.permissions import IsAuthenticated -from inventory.models import Event, Container, Item -from inventory.serializers import EventSerializer, ContainerSerializer, ItemSerializer, SearchResultSerializer +from inventory.models import Event, Container, Item, Comment +from inventory.serializers import EventSerializer, ContainerSerializer, CommentSerializer, ItemSerializer, \ + SearchResultSerializer from base64 import b64decode @@ -22,6 +23,17 @@ class ContainerViewSet(viewsets.ModelViewSet): queryset = Container.objects.all() +class ItemViewSet(viewsets.ModelViewSet): + serializer_class = ItemSerializer + + def get_queryset(self): + queryset = Item.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 + + def filter_items(items, query): query_tokens = query.split(' ') for item in items: @@ -49,6 +61,7 @@ def search_items(request, event_slug, query): @api_view(['GET', 'POST']) @permission_classes([IsAuthenticated]) def item(request, event_slug): + vs = ItemViewSet() try: event = None if event_slug != 'none': @@ -56,7 +69,7 @@ def item(request, event_slug): if request.method == 'GET': if not request.user.has_event_perm(event, 'view_item'): return Response(status=403) - return Response(ItemSerializer(Item.objects.filter(event=event), many=True).data) + return Response(ItemSerializer(vs.get_queryset().filter(event=event), many=True).data) elif request.method == 'POST': if not request.user.has_event_perm(event, 'add_item'): return Response(status=403) @@ -71,12 +84,31 @@ def item(request, event_slug): 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']) @permission_classes([IsAuthenticated]) def item_by_id(request, event_slug, id): try: event = Event.objects.get(slug=event_slug) - item = Item.objects.get(event=event, id=id) + item = ItemViewSet().get_queryset().get(event=event, id=id) if request.method == 'GET': if not request.user.has_event_perm(event, 'view_item'): return Response(status=403) @@ -117,5 +149,6 @@ urlpatterns = router.urls + [ re_path(r'^(?P[\w-]+)/items/$', item, name='item'), re_path(r'^(?P[\w-]+)/items/(?P[-A-Za-z0-9+/]*={0,3})/$', search_items, name='search_items'), re_path(r'^(?P[\w-]+)/item/$', item, name='item'), + re_path(r'^(?P[\w-]+)/item/(?P\d+)/comment/$', add_comment, name='add_comment'), re_path(r'^(?P[\w-]+)/item/(?P\d+)/$', item_by_id, name='item_by_id'), ] diff --git a/core/inventory/serializers.py b/core/inventory/serializers.py index 63f479b..90a75aa 100644 --- a/core/inventory/serializers.py +++ b/core/inventory/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from rest_framework.relations import SlugRelatedField 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 mail.models import EventAddress from tickets.shared_serializers import BasicIssueSerializer @@ -38,6 +38,18 @@ class ContainerSerializer(serializers.ModelSerializer): 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): timeline = serializers.SerializerMethodField() dataImage = serializers.CharField(write_only=True, required=False) @@ -48,6 +60,7 @@ class ItemSerializer(BasicItemSerializer): fields = ['cid', 'box', 'id', 'description', 'file', 'dataImage', 'returned', 'event', 'related_issues', 'timeline'] read_only_fields = ['id'] + prefetch_related_fields = ['comments', 'issue_relation_changes', 'container_history'] def to_internal_value(self, data): container = None diff --git a/core/tickets/api_v2.py b/core/tickets/api_v2.py index 415e045..99dc008 100644 --- a/core/tickets/api_v2.py +++ b/core/tickets/api_v2.py @@ -21,7 +21,13 @@ from tickets.shared_serializers import RelationSerializer class IssueViewSet(viewsets.ModelViewSet): 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): @@ -171,5 +177,5 @@ urlpatterns = ([ re_path(r'^tickets/(?P\d+)/comment/$', add_comment, name='add_comment'), re_path(r'^(?P[\w-]+)/tickets/manual/$', manual_ticket, name='manual_ticket'), re_path(r'^(?P[\w-]+)/tickets/(?P[-A-Za-z0-9+/]*={0,3})/$', search_issues, - name='search_issues'), + name='search_issues'), ] + router.urls) diff --git a/core/tickets/serializers.py b/core/tickets/serializers.py index b7d8b28..b382b38 100644 --- a/core/tickets/serializers.py +++ b/core/tickets/serializers.py @@ -47,6 +47,8 @@ class IssueSerializer(BasicIssueSerializer): model = IssueThread fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items', 'event') 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'] def to_internal_value(self, data): ret = super().to_internal_value(data) @@ -63,12 +65,14 @@ class IssueSerializer(BasicIssueSerializer): @staticmethod def get_last_activity(self): 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_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_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 x is not None] return max(args) @@ -129,7 +133,6 @@ class IssueSerializer(BasicIssueSerializer): return sorted(timeline, key=lambda x: x['timestamp']) - class SearchResultSerializer(serializers.Serializer): search_score = serializers.IntegerField() item = IssueSerializer() From eb9e9088ca1106f819bcb0d397ed07b4a7084c56 Mon Sep 17 00:00:00 2001 From: jedi Date: Fri, 29 Nov 2024 01:32:54 +0100 Subject: [PATCH 02/10] update prefetched fields --- core/inventory/api_v2.py | 11 +++++++---- core/inventory/models.py | 6 +++++- core/inventory/serializers.py | 7 ++++++- core/tickets/serializers.py | 6 +++++- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/core/inventory/api_v2.py b/core/inventory/api_v2.py index 64f9b27..326c049 100644 --- a/core/inventory/api_v2.py +++ b/core/inventory/api_v2.py @@ -26,13 +26,16 @@ class ContainerViewSet(viewsets.ModelViewSet): class ItemViewSet(viewsets.ModelViewSet): serializer_class = ItemSerializer - def get_queryset(self): - queryset = Item.objects.all() + 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): query_tokens = query.split(' ') @@ -69,7 +72,7 @@ def item(request, event_slug): if request.method == 'GET': if not request.user.has_event_perm(event, 'view_item'): return Response(status=403) - return Response(ItemSerializer(vs.get_queryset().filter(event=event), many=True).data) + return Response(ItemSerializer(vs.prefetch_queryset(Item.objects.filter(event=event)), many=True).data) elif request.method == 'POST': if not request.user.has_event_perm(event, 'add_item'): return Response(status=403) @@ -108,7 +111,7 @@ def add_comment(request, event_slug, id): def item_by_id(request, event_slug, id): try: event = Event.objects.get(slug=event_slug) - item = ItemViewSet().get_queryset().get(event=event, id=id) + item = Item.objects.get(event=event, id=id) if request.method == 'GET': if not request.user.has_event_perm(event, 'view_item'): return Response(status=403) diff --git a/core/inventory/models.py b/core/inventory/models.py index 10796e1..231094e 100644 --- a/core/inventory/models.py +++ b/core/inventory/models.py @@ -32,7 +32,11 @@ class Item(SoftDeleteModel): @property def container(self): try: - return self.container_history.order_by('-timestamp').first().container + 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 diff --git a/core/inventory/serializers.py b/core/inventory/serializers.py index 90a75aa..26a5be4 100644 --- a/core/inventory/serializers.py +++ b/core/inventory/serializers.py @@ -60,7 +60,12 @@ class ItemSerializer(BasicItemSerializer): fields = ['cid', 'box', 'id', 'description', 'file', 'dataImage', 'returned', 'event', 'related_issues', 'timeline'] read_only_fields = ['id'] - prefetch_related_fields = ['comments', 'issue_relation_changes', 'container_history'] + 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): container = None diff --git a/core/tickets/serializers.py b/core/tickets/serializers.py index b382b38..50cdb72 100644 --- a/core/tickets/serializers.py +++ b/core/tickets/serializers.py @@ -48,7 +48,11 @@ class IssueSerializer(BasicIssueSerializer): fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items', 'event') 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', '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): ret = super().to_internal_value(data) From 2a2ef61fc412486ba46e52dbe10b184a657ef922 Mon Sep 17 00:00:00 2001 From: jedi Date: Wed, 27 Nov 2024 19:39:58 +0100 Subject: [PATCH 03/10] add basic view for item history --- web/src/App.vue | 13 +- web/src/components/AuthenticatedImage.vue | 28 ++- web/src/components/CollapsableCards.vue | 6 +- web/src/components/EditItem.vue | 17 +- web/src/components/Timeline.vue | 76 +++----- web/src/components/TimelineMail.vue | 39 +--- web/src/components/TimelinePlacement.vue | 85 +++++++++ web/src/components/TimelineRelatedItem.vue | 74 ++------ web/src/components/TimelineRelatedTicket.vue | 94 ++++++++++ web/src/components/inputs/AsyncButton.vue | 1 - web/src/components/inputs/InputCombo.vue | 61 +++--- web/src/components/inputs/InputPhoto.vue | 9 +- web/src/router.js | 11 +- web/src/store.js | 37 ++-- web/src/views/Item.vue | 186 +++++++++++++++++++ web/src/views/Items.vue | 54 ++---- web/src/views/Ticket.vue | 130 ++++++++++--- web/src/views/Tickets.vue | 20 +- 18 files changed, 630 insertions(+), 311 deletions(-) create mode 100644 web/src/components/TimelinePlacement.vue create mode 100644 web/src/components/TimelineRelatedTicket.vue create mode 100644 web/src/views/Item.vue diff --git a/web/src/App.vue b/web/src/App.vue index bd4956f..d2c9f7d 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,5 +1,6 @@ diff --git a/web/src/components/Timeline.vue b/web/src/components/Timeline.vue index 88bfa94..41e1274 100644 --- a/web/src/components/Timeline.vue +++ b/web/src/components/Timeline.vue @@ -21,6 +21,9 @@ + + + @@ -30,40 +33,15 @@ + +

{{ item }}

  • - - - -
    -
    - - - - Save Comment - -
    -
    +
  • - - - -
    -
    - {{ newestMailSubject }} -
    -
    - - - - Send Mail - -
    -
    +
  • @@ -78,12 +56,20 @@ import TimelineAssignment from "@/components/TimelineAssignment.vue"; import TimelineRelatedItem from "@/components/TimelineRelatedItem.vue"; import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue"; import AsyncButton from "@/components/inputs/AsyncButton.vue"; +import TimelinePlacement from "@/components/TimelinePlacement.vue"; +import TimelineRelatedTicket from "@/components/TimelineRelatedTicket.vue"; export default { name: 'Timeline', components: { - TimelineShippingVoucher, AsyncButton, - TimelineRelatedItem, TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail + TimelineRelatedTicket, + TimelinePlacement, + TimelineShippingVoucher, + TimelineRelatedItem, + TimelineAssignment, + TimelineStateChange, + TimelineComment, + TimelineMail }, props: { timeline: { @@ -91,33 +77,13 @@ export default { default: () => [] } }, - emits: ['sendMail', 'addComment'], - data: () => ({ - newMail: "", - newComment: "" - }), computed: { - ...mapGetters(['stateInfo']), - newestMailSubject() { - const mail = this.timeline.filter(item => item.type === 'mail').pop(); - return mail ? mail.subject : ""; - }, + ...mapGetters(['stateInfo']) }, - methods: { - ...mapActions(['sendMail', 'postComment']), - sendMailAndClear: async function () { - await this.sendMail(this.newMail); - this.newMail = ""; - }, - addCommentAndClear: async function () { - await this.postComment(this.newComment); - this.newComment = ""; - } - } }; - \ No newline at end of file diff --git a/web/src/components/TimelineRelatedItem.vue b/web/src/components/TimelineRelatedItem.vue index c17a3a8..2670215 100644 --- a/web/src/components/TimelineRelatedItem.vue +++ b/web/src/components/TimelineRelatedItem.vue @@ -1,12 +1,15 @@ @@ -72,16 +38,11 @@ import AuthenticatedImage from "@/components/AuthenticatedImage.vue"; import AuthenticatedDataLink from "@/components/AuthenticatedDataLink.vue"; -import Lightbox from "@/components/Lightbox.vue"; +import {mapMutations} from "vuex"; export default { name: 'TimelineRelatedItem', - components: {Lightbox, AuthenticatedImage, AuthenticatedDataLink}, - data() { - return { - lightboxHash: null, - } - }, + components: {AuthenticatedImage, AuthenticatedDataLink}, props: { 'item': { type: Object, @@ -98,13 +59,8 @@ export default { }, methods: { - openLightboxModalWith(attachment) { - this.lightboxHash = attachment.hash; - }, - closeLightboxModal() { // Closes the editing modal and discards the edited copy of the item. - this.lightboxHash = null; - }, - }, + ...mapMutations(['openLightboxModalWith']) + } }; diff --git a/web/src/components/TimelineRelatedTicket.vue b/web/src/components/TimelineRelatedTicket.vue new file mode 100644 index 0000000..694a4e0 --- /dev/null +++ b/web/src/components/TimelineRelatedTicket.vue @@ -0,0 +1,94 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/inputs/AsyncButton.vue b/web/src/components/inputs/AsyncButton.vue index a3c1712..833f964 100644 --- a/web/src/components/inputs/AsyncButton.vue +++ b/web/src/components/inputs/AsyncButton.vue @@ -24,7 +24,6 @@ export default { }, methods: { async handleClick() { - console.log("AsyncButton.handleClick() called"); if (this.task && typeof this.task === 'function') { this.disabled = true; try { diff --git a/web/src/components/inputs/InputCombo.vue b/web/src/components/inputs/InputCombo.vue index 50b5459..fc64d42 100644 --- a/web/src/components/inputs/InputCombo.vue +++ b/web/src/components/inputs/InputCombo.vue @@ -1,39 +1,36 @@ diff --git a/web/src/components/inputs/InputPhoto.vue b/web/src/components/inputs/InputPhoto.vue index e50033b..90745a4 100644 --- a/web/src/components/inputs/InputPhoto.vue +++ b/web/src/components/inputs/InputPhoto.vue @@ -2,9 +2,10 @@
    Image not available.