v3: User lists
authorRadek Czajka <rczajka@rczajka.pl>
Fri, 13 Mar 2026 11:33:48 +0000 (12:33 +0100)
committerRadek Czajka <rczajka@rczajka.pl>
Fri, 13 Mar 2026 11:33:48 +0000 (12:33 +0100)
src/api/urls.py
src/api/views.py
src/social/api/serializers.py
src/social/api/urls2.py
src/social/api/views.py

index 1a1b4f3..867e1fd 100644 (file)
@@ -33,6 +33,8 @@ urlpatterns1 = [
 
 urlpatterns = [
     path('2/', include((urlpatterns1, 'api'), namespace="v2")),
+    path('3/', include((urlpatterns1, 'api'), namespace="v3")),
+    path('1/<path:path>', views.Unsupported.as_view()),
 
     path('oauth/request_token/', csrf_exempt(views.OAuth1RequestTokenView.as_view())),
     path('oauth/authorize/', views.oauth_user_auth, name='oauth_user_auth'),
index 5d52678..290dc93 100644 (file)
@@ -426,3 +426,9 @@ class ConsumeSessionTransferTokenView(View):
         login(request, ott.user)
 
         return redirect(next_url)
+
+
+class Unsupported(APIView):
+    get = post = put = delete = lambda self, request, path: Response({
+        "error": "unsupported-api",
+    }, status=410)
index 7a596d2..dadf249 100644 (file)
@@ -12,7 +12,7 @@ class SettingsSerializer(serializers.ModelSerializer):
         fields = ['notifications']
 
 
-class UserListItemsField(serializers.Field):
+class UserListBooksField(serializers.Field):
     def to_representation(self, value):
         return value.userlistitem_set.exclude(deleted=True).exclude(book=None).values_list('book__slug', flat=True)
 
@@ -20,9 +20,9 @@ class UserListItemsField(serializers.Field):
         return {'books': catalogue.models.Book.objects.filter(slug__in=value)}
 
 
-class UserListSerializer(serializers.ModelSerializer):
+class UserListSerializerV2(serializers.ModelSerializer):
     client_id = serializers.CharField(write_only=True, required=False)
-    books = UserListItemsField(source='*', required=False)
+    books = UserListBooksField(source='*', required=False)
     timestamp = serializers.IntegerField(required=False)
 
     class Meta:
@@ -67,7 +67,7 @@ class UserListSerializer(serializers.ModelSerializer):
         return instance
 
 
-class UserListBooksSerializer(UserListSerializer):
+class UserListBooksSerializer(UserListSerializerV2):
     class Meta:
         model = models.UserList
         fields = ['books']
@@ -114,6 +114,39 @@ class UserListItemSerializer(serializers.ModelSerializer):
         }
 
 
+class UserListSerializerV3(serializers.ModelSerializer):
+    client_id = serializers.CharField(write_only=True, required=False)
+    timestamp = serializers.IntegerField(required=False)
+
+    class Meta:
+        model = models.UserList
+        fields = [
+            'timestamp',
+            'client_id',
+            'name',
+            'slug',
+            'favorites',
+            'deleted',
+        ]
+        read_only_fields = [
+            'favorites',
+            'slug',
+        ]
+        extra_kwargs = {
+            'slug': {
+                'required': False
+            }
+        }
+
+    def create(self, validated_data):
+        instance = models.UserList.get_by_name(
+            validated_data['user'],
+            validated_data['name'],
+            create=True
+        )
+        return instance
+
+
 class ProgressSerializer(serializers.ModelSerializer):
     book = serializers.HyperlinkedRelatedField(
         read_only=True,
index 3863ec6..7f6ab65 100644 (file)
@@ -17,7 +17,9 @@ urlpatterns = [
 
     path('lists/', views.ListsView.as_view()),
     path('lists/<slug:slug>/', views.ListView.as_view()),
-    path('lists/<slug:slug>/<slug:book>/', views.ListItemView.as_view()),
+    path('lists/<slug:slug>/items/', views.ListItemListViewV3.as_view()),
+    path('list-items/<uuid:uuid>/', views.ListItemViewV3.as_view()),
+    path('lists/<slug:slug>/<slug:book>/', views.ListItemViewV2.as_view()),
 
     path('progress/', views.ProgressListView.as_view()),
     path('progress/<slug:slug>/', views.ProgressView.as_view()),
index a43bec2..4126808 100644 (file)
@@ -5,6 +5,7 @@ from datetime import datetime
 from django.db.models import Q
 from django.http import Http404
 from django.utils.timezone import now, utc
+from rest_framework.exceptions import MethodNotAllowed
 from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveAPIView, RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView, get_object_or_404
 from rest_framework.permissions import SAFE_METHODS, IsAuthenticated, IsAuthenticatedOrReadOnly
 from rest_framework.response import Response
@@ -101,7 +102,11 @@ class MyLikesView(APIView):
 class ListsView(ListCreateAPIView):
     permission_classes = [IsAuthenticated]
     #pagination_class = None
-    serializer_class = serializers.UserListSerializer
+
+    def get_serializer_class(self):
+        if self.request.version == 'v2':
+            return serializers.UserListSerializerV2
+        return serializers.UserListSerializerV3
 
     def get_queryset(self):
         return models.UserList.objects.filter(
@@ -114,38 +119,51 @@ class ListsView(ListCreateAPIView):
         serializer.save(user=self.request.user)
 
 
+def get_userlist(slug, request):
+    if request.method in SAFE_METHODS:
+        q = Q(deleted=False)
+        if request.user.is_authenticated:
+            q |= Q(user=request.user)
+        return get_object_or_404(
+            models.UserList,
+            q,
+            slug=slug,
+        )
+    else:
+        return get_object_or_404(
+            models.UserList.all_objects.all(),
+            slug=slug,
+            user=request.user
+        )
+
+
 @never_cache
 class ListView(RetrieveUpdateDestroyAPIView):
     # TODO: check if can modify
     permission_classes = [IsAuthenticatedOrReadOnly]
-    serializer_class = serializers.UserListSerializer
+
+    def get_serializer_class(self):
+        if self.request.version == 'v2':
+            return serializers.UserListSerializerV2
+        return serializers.UserListSerializerV3
 
     def get_object(self):
-        if self.request.method in SAFE_METHODS:
-            q = Q(deleted=False)
-            if self.request.user.is_authenticated:
-                q |= Q(user=self.request.user)
-            return get_object_or_404(
-                models.UserList,
-                q,
-                slug=self.kwargs['slug'],
-            )
-        else:
-            return get_object_or_404(
-                models.UserList.all_objects.all(),
-                slug=self.kwargs['slug'],
-                user=self.request.user)
+        return get_userlist(self.kwargs['slug'], self.request)
 
     def perform_update(self, serializer):
         serializer.save(user=self.request.user)
 
     def post(self, request, slug):
-        serializer = serializers.UserListBooksSerializer(data=request.data)
-        serializer.is_valid(raise_exception=True)
-        instance = self.get_object()
-        for book in serializer.validated_data['books']:
-            instance.append(book)
-        return Response(self.get_serializer(instance).data)
+        if request.version == 'v2':
+            # Accept posting a list of books here.
+            serializer = serializers.UserListBooksSerializer(data=request.data)
+            serializer.is_valid(raise_exception=True)
+            instance = self.get_object()
+            for book in serializer.validated_data['books']:
+                instance.append(book)
+            return Response(self.get_serializer(instance).data)
+        else:
+            raise MethodNotAllowed(method=request.method)
 
     def perform_destroy(self, instance):
         instance.deleted = True
@@ -154,7 +172,8 @@ class ListView(RetrieveUpdateDestroyAPIView):
 
 
 @never_cache
-class ListItemView(APIView):
+class ListItemViewV2(APIView):
+    """v2 only"""
     permission_classes = [IsAuthenticated]
 
     def delete(self, request, slug, book):
@@ -162,7 +181,29 @@ class ListItemView(APIView):
             models.UserList, slug=slug, user=self.request.user)
         book = get_object_or_404(catalogue.models.Book, slug=book)
         instance.remove(book=book)
-        return Response(UserListSerializer(instance).data)
+        return Response(serializers.UserListSerializerV2(instance).data)
+
+
+@never_cache
+class ListItemListViewV3(ListCreateAPIView):
+    permission_classes = [IsAuthenticatedOrReadOnly]
+    serializer_class = serializers.UserListItemSerializer
+
+    def get_queryset(self):
+        lst = get_userlist(self.kwargs['slug'], self.request)
+        return lst.userlistitem_set.all()
+
+
+@never_cache
+class ListItemViewV3(RetrieveUpdateDestroyAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = serializers.UserListItemSerializer
+    lookup_field = 'uuid'
+
+    def get_queryset(self):
+        return models.UserListItem.objects.filter(
+            list__user=self.request.user
+        )
 
 
 @vary_on_auth
@@ -309,7 +350,11 @@ class ProgressSyncView(SyncView):
 
 class UserListSyncView(SyncView):
     model = models.UserList
-    serializer_class = serializers.UserListSerializer
+
+    def get_serializer_class(self):
+        if self.request.version == 'v2':
+            return serializers.UserListSerializerV2
+        return serializers.UserListSerializerV3
 
 
 class UserListItemSyncView(SyncView):