From 3b00a352a5c71747175f7c5d5eb6c0720592e9b8 Mon Sep 17 00:00:00 2001
From: Konrad Mohrfeldt <konrad.mohrfeldt@farbdev.org>
Date: Thu, 23 Jan 2025 15:52:37 +0100
Subject: [PATCH] feat: add reorder view mixin

This mixin factory makes it easy to create atomic reorder operations for
entities that have a static numeric order criteria that is supposed to
be updated by clients.
---
 program/utils.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 91 insertions(+)

diff --git a/program/utils.py b/program/utils.py
index ca5e478..b07758f 100644
--- a/program/utils.py
+++ b/program/utils.py
@@ -24,7 +24,13 @@ from datetime import date, datetime, time
 from typing import Any, Callable, Dict, Hashable, Optional, Tuple, Union
 
 import requests
+from drf_spectacular.utils import OpenApiExample, extend_schema
+from rest_framework import serializers
+from rest_framework.decorators import action
+from rest_framework.request import Request
+from rest_framework.response import Response
 
+from django import db
 from django.conf import settings
 from django.utils import timezone
 from program.models import Episode, EpisodeLink, Profile, ProfileLink, Show, ShowLink
@@ -247,3 +253,88 @@ def saves_relationships_to(*field_names):
         return wrapper
 
     return relationship_update_decorator
+
+
+def reorder_mixin_factory(
+    order_field_name: str | None = None,
+    id_field_type: type[serializers.Field] = serializers.IntegerField,
+    serializer_class: type[serializers.Serializer] | None = None,
+):
+    """
+    Factory function that creates a mixin for handling bulk reordering of objects in a ViewSet.
+
+    :param order_field_name: the field that stores the ordinal
+    :param id_field_type: the field type that stores the id
+    :param serializer_class: Optional custom response serializer for the endpoint
+    :returns: the parameterized mixin class for the ViewSet
+    """
+
+    class ReorderItemSerializer(serializers.Serializer):
+        id = id_field_type()
+        order = serializers.IntegerField(source=order_field_name)
+
+        def __init__(self, *args, **kwargs):
+            super().__init__(*args, **kwargs)
+            # Get the model from the parent's context if available
+            if self.parent and getattr(self.parent, "context", None):
+                viewset = self.parent.context.get("view")
+                if viewset:
+                    self.fields["id"].queryset = viewset.get_queryset()
+
+    class ReorderRequestSerializer(serializers.Serializer):
+        orderings = ReorderItemSerializer(many=True)
+
+    class ReorderMixin:
+        @extend_schema(
+            methods=["patch"],
+            request=ReorderRequestSerializer,
+            responses={200: serializer_class} if serializer_class else {204: None},
+            examples=[
+                OpenApiExample(
+                    "Reorder Example",
+                    value={
+                        "orderings": [
+                            {"id": 1, "order": 5},
+                            {"id": 2, "order": 2},
+                            {"id": 3, "order": 1},
+                        ]
+                    },
+                    description="Example request to reorder multiple objects",
+                )
+            ],
+            description="Reorder multiple objects in a single operation.",
+            summary="Bulk reorder objects",
+        )
+        @action(
+            detail=False,
+            methods=["patch"],
+            url_path="reorder",
+            serializer_class=ReorderRequestSerializer,
+        )
+        def reorder(self, request: Request) -> Response:
+            serializer = ReorderRequestSerializer(data=request.data)
+            serializer.is_valid(raise_exception=True)
+
+            new_orderings = serializer.validated_data["orderings"]
+            requested_obj_ids = [ordering["id"] for ordering in new_orderings]
+            model = self.get_queryset().model
+            obj_ids = model.objects.filter(id__in=requested_obj_ids).values_list("id", flat=True)
+            update_map = {
+                ordering["id"]: ordering["order"]
+                for ordering in new_orderings
+                if ordering["id"] in obj_ids
+            }
+
+            with db.transaction.atomic():
+                objects = model.objects.select_for_update().filter(id__in=update_map.keys())
+
+                for pk, order in update_map.items():
+                    objects.filter(id=pk).update(**{order_field_name or "order": order})
+
+                if serializer_class:
+                    updated_objects = self.get_queryset().filter(id__in=update_map.keys())
+                    response_serializer = serializer_class(updated_objects, many=True)
+                    return Response(response_serializer.data)
+                return Response(status=204)
+
+    return ReorderMixin
-- 
GitLab