From 93e3e1f84811affff7f6ba0ad808c813904da42b Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Fri, 13 Mar 2026 12:33:48 +0100 Subject: [PATCH] v3: User lists --- src/api/urls.py | 2 + src/api/views.py | 6 +++ src/social/api/serializers.py | 41 +++++++++++++-- src/social/api/urls2.py | 4 +- src/social/api/views.py | 95 ++++++++++++++++++++++++++--------- 5 files changed, 118 insertions(+), 30 deletions(-) diff --git a/src/api/urls.py b/src/api/urls.py index 1a1b4f337..867e1fd09 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -33,6 +33,8 @@ urlpatterns1 = [ urlpatterns = [ path('2/', include((urlpatterns1, 'api'), namespace="v2")), + path('3/', include((urlpatterns1, 'api'), namespace="v3")), + path('1/', 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'), diff --git a/src/api/views.py b/src/api/views.py index 5d526780a..290dc93fe 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -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) diff --git a/src/social/api/serializers.py b/src/social/api/serializers.py index 7a596d261..dadf24935 100644 --- a/src/social/api/serializers.py +++ b/src/social/api/serializers.py @@ -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, diff --git a/src/social/api/urls2.py b/src/social/api/urls2.py index 3863ec622..7f6ab6542 100644 --- a/src/social/api/urls2.py +++ b/src/social/api/urls2.py @@ -17,7 +17,9 @@ urlpatterns = [ path('lists/', views.ListsView.as_view()), path('lists//', views.ListView.as_view()), - path('lists///', views.ListItemView.as_view()), + path('lists//items/', views.ListItemListViewV3.as_view()), + path('list-items//', views.ListItemViewV3.as_view()), + path('lists///', views.ListItemViewV2.as_view()), path('progress/', views.ProgressListView.as_view()), path('progress//', views.ProgressView.as_view()), diff --git a/src/social/api/views.py b/src/social/api/views.py index a43bec277..41268084e 100644 --- a/src/social/api/views.py +++ b/src/social/api/views.py @@ -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): -- 2.20.1