wip
authorRadek Czajka <rczajka@rczajka.pl>
Tue, 8 Jul 2025 14:13:12 +0000 (16:13 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Tue, 8 Jul 2025 14:13:12 +0000 (16:13 +0200)
12 files changed:
src/api/urls.py
src/api/utils.py
src/catalogue/api/serializers.py
src/catalogue/api/urls2.py
src/catalogue/api/views.py
src/catalogue/models/book.py
src/catalogue/models/bookmedia.py
src/catalogue/models/tag.py
src/social/api/urls2.py
src/social/api/views.py
src/social/migrations/0018_progress.py [new file with mode: 0644]
src/social/models.py

index 62d4fa7..38f6192 100644 (file)
@@ -17,6 +17,7 @@ urlpatterns1 = [
     path('me/', views.UserView.as_view()),
     path('', include('catalogue.api.urls2')),
     path('', include('social.api.urls2')),
+    path('', include('bookmarks.api.urls'))
 ]
 
 
index 3b23246..26e0778 100644 (file)
@@ -5,6 +5,7 @@ from django.http import HttpResponse, HttpResponseRedirect
 from django.utils.decorators import method_decorator
 from django.utils.encoding import iri_to_uri
 from django.views.decorators.vary import vary_on_headers
+import django.views.decorators.cache
 
 
 def oauthlib_request(request):
@@ -36,6 +37,7 @@ def oauthlib_response(response_tuple):
 
 
 vary_on_auth = method_decorator(vary_on_headers('Authorization'), 'dispatch')
+never_cache = method_decorator(django.views.decorators.cache.never_cache, 'dispatch')
 
 
 class HttpResponseAppRedirect(HttpResponseRedirect):
index a90d2ef..8968bf6 100644 (file)
@@ -50,7 +50,7 @@ class AuthorItemSerializer(serializers.ModelSerializer):
     class Meta:
         model = Tag
         fields = [
-            'url', 'href', 'name'
+            'url', 'href', 'name', 'slug'
         ]
 
 class AuthorSerializer(AuthorItemSerializer):
@@ -59,7 +59,7 @@ class AuthorSerializer(AuthorItemSerializer):
     class Meta:
         model = Tag
         fields = [
-            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'id', 'url', 'href', 'name', 'slug', 'sort_key', 'description',
             'genitive', 'photo', 'photo_thumb', 'photo_attribution',
         ]
 
@@ -71,7 +71,7 @@ class EpochItemSerializer(serializers.ModelSerializer):
     )
     class Meta:
         model = Tag
-        fields = ['url', 'href', 'name']
+        fields = ['url', 'href', 'name', 'slug']
 
 class EpochSerializer(EpochItemSerializer):
     class Meta:
@@ -89,7 +89,7 @@ class GenreItemSerializer(serializers.ModelSerializer):
     )
     class Meta:
         model = Tag
-        fields = ['url', 'href', 'name']
+        fields = ['url', 'href', 'name', 'slug']
 
 class GenreSerializer(GenreItemSerializer):
     class Meta:
@@ -107,7 +107,7 @@ class KindItemSerializer(serializers.ModelSerializer):
     )
     class Meta:
         model = Tag
-        fields = ['url', 'href', 'name']
+        fields = ['url', 'href', 'name', 'slug']
 
 class KindSerializer(KindItemSerializer):
     class Meta:
@@ -157,6 +157,7 @@ class BookSerializer2(serializers.ModelSerializer):
             'cover_thumb', 'cover',
             'isbn_pdf', 'isbn_epub', 'isbn_mobi',
             'abstract',
+            'has_mp3_file',
         ]
 
 class BookSerializer11Labs(serializers.ModelSerializer):
@@ -238,6 +239,16 @@ class MediaSerializer(LegacyMixin, serializers.ModelSerializer):
         legacy_non_null_fields = ['director', 'artist']
 
 
+class MediaSerializer2(MediaSerializer):
+    size = serializers.SerializerMethodField()
+
+    class Meta:
+        model = BookMedia
+        fields = ['url', 'director', 'type', 'name', 'part_name', 'artist', 'duration', 'size']
+
+    def get_size(self, obj):
+        return obj.file.size
+
 class BookDetailSerializer(LegacyMixin, serializers.ModelSerializer):
     url = AbsoluteURLField()
 
@@ -365,4 +376,4 @@ class FragmentSerializer2(serializers.ModelSerializer):
 class FilterTagSerializer(serializers.ModelSerializer):
     class Meta:
         model = Tag
-        fields = ['id', 'category', 'name']
+        fields = ['id', 'category', 'name', 'slug']
index 7dc131d..f06ecdf 100644 (file)
@@ -23,6 +23,9 @@ urlpatterns = [
          piwik_track_view(views.BookFragmentView.as_view()),
          name='catalogue_api_book_fragment'
          ),
+    path('books/<slug:slug>/media/<slug:type>/', views.BookMediaView.as_view()),
+    path('books/<slug:slug>.json',
+        views.BookJsonView.as_view()),
 
     path('suggested-tags/',
          piwik_track_view(views.SuggestedTags.as_view()),
index e45f80e..b2734d9 100644 (file)
@@ -6,7 +6,7 @@ import os.path
 from urllib.request import urlopen
 from django.conf import settings
 from django.core.files.base import ContentFile
-from django.http import Http404, HttpResponse
+from django.http import Http404, HttpResponse, JsonResponse
 from django.utils.decorators import method_decorator
 from django.views.decorators.cache import never_cache
 from django_filters import rest_framework as dfilters
@@ -198,6 +198,11 @@ class BookFilter(dfilters.FilterSet):
         queryset=Tag.objects.filter(category__in=('author', 'epoch', 'genre', 'kind')),
         conjoined=True,
     )
+    translator = dfilters.ModelMultipleChoiceFilter(
+        field_name='translators',
+        queryset=Tag.objects.filter(category='author'),
+        conjoined=True,
+    )
 
 
 class BookList2(ListAPIView):
@@ -517,3 +522,21 @@ class BookFragmentView(RetrieveAPIView):
         book = get_object_or_404(Book, slug=self.kwargs['slug'])
         return book.choose_fragment()
 
+
+class BookMediaView(ListAPIView):
+    serializer_class = serializers.MediaSerializer2
+    pagination_class = None
+
+    def get_queryset(self):
+        return BookMedia.objects.filter(book__slug=self.kwargs['slug'], type=self.kwargs['type']).order_by('index')
+
+
+from .tojson import conv
+from lxml import etree
+from rest_framework.views import APIView
+class BookJsonView(APIView):
+    def get(self, request, slug):
+        book = get_object_or_404(Book, slug=slug)
+        js = conv(etree.parse(book.xml_file.path))
+        return JsonResponse(js, json_dumps_params={'ensure_ascii': False})
+
index c2b9eed..15b1d2f 100644 (file)
@@ -92,7 +92,7 @@ class Book(models.Model):
     objects = models.Manager()
     tagged = managers.ModelTaggedItemManager(Tag)
     tags = managers.TagDescriptor(Tag)
-    tag_relations = GenericRelation(Tag.intermediary_table_model)
+    tag_relations = GenericRelation(Tag.intermediary_table_model, related_query_name='tagged_book')
     translators = models.ManyToManyField(Tag, blank=True)
 
     html_built = django.dispatch.Signal()
index acb1881..fd0dd56 100644 (file)
@@ -87,7 +87,7 @@ class BookMedia(models.Model):
         except BookMedia.DoesNotExist:
             old = None
 
-        super(BookMedia, self).save(*args, **kwargs)
+        #super(BookMedia, self).save(*args, **kwargs)
         
         # remove the zip package for book with modified media
         if old:
index 13d8a64..07a43f4 100644 (file)
@@ -242,6 +242,11 @@ class Tag(models.Model):
                     meta_tags.append((tag, relationship))
         return meta_tags
 
+#    def get_books(self):
+#        """ Only useful for sets. """
+#        return 
+
+
 
 TagRelation.tag_model = Tag
 
index b150e61..d2d6245 100644 (file)
@@ -12,6 +12,15 @@ urlpatterns = [
         name='social_api_like'),
     path('likes/', views.LikesView.as_view()),
     path('my-likes/', views.MyLikesView.as_view()),
+
+    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('progress/', views.ProgressListView.as_view()),
+    path('progress/<slug:slug>/', views.ProgressView.as_view()),
+    path('progress/<slug:slug>/text/', views.TextProgressView.as_view()),
+    path('progress/<slug:slug>/audio/', views.AudioProgressView.as_view()),
 ]
 
 
index 22a0e9c..867a05a 100644 (file)
@@ -2,21 +2,23 @@
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 from django.http import Http404
-from rest_framework.generics import ListAPIView, get_object_or_404
-from rest_framework.permissions import IsAuthenticated
+from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveAPIView, RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView, DestroyAPIView, get_object_or_404
+from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
 from rest_framework.response import Response
+from rest_framework import serializers
 from rest_framework.views import APIView
 from api.models import BookUserData
-from api.utils import vary_on_auth
+from api.utils import vary_on_auth, never_cache
 from catalogue.api.helpers import order_books, books_after
 from catalogue.api.serializers import BookSerializer
 from catalogue.models import Book
 import catalogue.models
-from social.utils import likes
+from social.utils import likes, get_set
 from social.views import get_sets_for_book_ids
+from social import models
 
 
-@vary_on_auth
+@never_cache
 class LikeView(APIView):
     permission_classes = [IsAuthenticated]
 
@@ -34,7 +36,7 @@ class LikeView(APIView):
         return Response({})
 
 
-@vary_on_auth
+@never_cache
 class LikeView2(APIView):
     permission_classes = [IsAuthenticated]
 
@@ -53,7 +55,7 @@ class LikeView2(APIView):
         return Response({"likes": likes(request.user, book)})
 
 
-@vary_on_auth
+@never_cache
 class LikesView(APIView):
     permission_classes = [IsAuthenticated]
 
@@ -64,10 +66,11 @@ class LikesView(APIView):
         ids = books.keys()
         res = get_sets_for_book_ids(ids, request.user)
         res = {books[bid]: v for bid, v in res.items()}
+
         return Response(res)
 
 
-@vary_on_auth
+@never_cache
 class MyLikesView(APIView):
     permission_classes = [IsAuthenticated]
 
@@ -77,9 +80,91 @@ class MyLikesView(APIView):
         books = {b.id: b.slug for b in books}
         res = get_sets_for_book_ids(ids, request.user)
         res = {books[bid]: v for bid, v in res.items()}
+
+        res = list(books.values())
+        res.sort()
         return Response(res)
 
 
+class TaggedBooksField(serializers.Field):
+    def to_representation(self, value):
+        return catalogue.models.Book.tagged.with_all([value]).values_list('slug', flat=True)
+
+    def to_internal_value(self, value):
+        return {'books': catalogue.models.Book.objects.filter(slug__in=value)}
+
+
+class UserListSerializer(serializers.ModelSerializer):
+    books = TaggedBooksField(source='*')
+
+    class Meta:
+        model = catalogue.models.Tag
+        fields = ['name', 'slug', 'books']
+        read_only_fields = ['slug']
+
+    def create(self, validated_data):
+        instance = get_set(validated_data['user'], validated_data['name'])
+        catalogue.models.tag.TagRelation.objects.filter(tag=instance).delete()
+        for book in validated_data['books']:
+            catalogue.models.Tag.objects.add_tag(book, instance)
+        return instance
+
+    def update(self, instance, validated_data):
+        catalogue.models.tag.TagRelation.objects.filter(tag=instance).delete()
+        for book in validated_data['books']:
+            catalogue.models.Tag.objects.add_tag(book, instance)
+        return instance
+
+class UserListBooksSerializer(UserListSerializer):
+    class Meta:
+        model = catalogue.models.Tag
+        fields = ['books']
+
+
+@never_cache
+class ListsView(ListCreateAPIView):
+    permission_classes = [IsAuthenticated]
+    #pagination_class = None
+    serializer_class = UserListSerializer
+
+    def get_queryset(self):
+        return catalogue.models.Tag.objects.filter(user=self.request.user).exclude(name='')
+
+    def perform_create(self, serializer):
+        serializer.save(user=self.request.user)
+
+
+@never_cache
+class ListView(RetrieveUpdateDestroyAPIView):
+    # TODO: check if can modify
+    permission_classes = [IsAuthenticated]
+    serializer_class = UserListSerializer
+
+    def get_object(self):
+        return get_object_or_404(catalogue.models.Tag, slug=self.kwargs['slug'], user=self.request.user)
+
+    def perform_update(self, serializer):
+        serializer.save(user=self.request.user)
+
+    def post(self, request, slug):
+        serializer = UserListBooksSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        instance = self.get_object()
+        for book in serializer.validated_data['books']:
+            catalogue.models.Tag.objects.add_tag(book, instance)
+        return Response(self.get_serializer(instance).data)
+
+
+@never_cache
+class ListItemView(APIView):
+    permission_classes = [IsAuthenticated]
+
+    def delete(self, request, slug, book):
+        instance = get_object_or_404(catalogue.models.Tag, slug=slug, user=self.request.user)
+        book = get_object_or_404(catalogue.models.Book, slug=book)
+        catalogue.models.Tag.objects.remove_tag(book, instance)
+        return Response(UserListSerializer(instance).data)
+
 
 @vary_on_auth
 class ShelfView(ListAPIView):
@@ -108,3 +193,86 @@ class ShelfView(ListAPIView):
 
         return books
 
+
+
+class ProgressSerializer(serializers.ModelSerializer):
+    book = serializers.HyperlinkedRelatedField(
+        read_only=True,
+        view_name='catalogue_api_book',
+        lookup_field='slug'
+    )
+    book_slug = serializers.SlugRelatedField(source='book', read_only=True, slug_field='slug')
+
+    class Meta:
+        model = models.Progress
+        fields = ['book', 'book_slug', 'last_mode', 'text_percent',
+    'text_anchor',
+    'audio_percent',
+    'audio_timestamp',
+    'implicit_text_percent',
+    'implicit_text_anchor',
+    'implicit_audio_percent',
+    'implicit_audio_timestamp',
+    ]
+
+
+class TextProgressSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.Progress
+        fields = [
+                'text_percent',
+                'text_anchor',
+                ]
+        read_only_fields = ['text_percent']
+
+class AudioProgressSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.Progress
+        fields = ['audio_percent', 'audio_timestamp']
+        read_only_fields = ['audio_percent']
+
+
+@never_cache
+class ProgressListView(ListAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = ProgressSerializer
+
+    def get_queryset(self):
+        return models.Progress.objects.filter(user=self.request.user).order_by('-updated_at')
+
+
+class ProgressMixin:
+    def get_object(self):
+        try:
+            return models.Progress.objects.get(user=self.request.user, book__slug=self.kwargs['slug'])
+        except models.Progress.DoesNotExist:
+            book = get_object_or_404(Book, slug=self.kwargs['slug'])
+            return models.Progress(user=self.request.user, book=book)
+
+
+
+@never_cache
+class ProgressView(ProgressMixin, RetrieveAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = ProgressSerializer
+
+
+@never_cache
+class TextProgressView(ProgressMixin, RetrieveUpdateAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = TextProgressSerializer
+
+    def perform_update(self, serializer):
+        serializer.instance.last_mode = 'text'
+        serializer.save()
+
+
+@never_cache
+class AudioProgressView(ProgressMixin, RetrieveUpdateAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = AudioProgressSerializer
+
+    def perform_update(self, serializer):
+        serializer.instance.last_mode = 'audio'
+        serializer.save()
+
diff --git a/src/social/migrations/0018_progress.py b/src/social/migrations/0018_progress.py
new file mode 100644 (file)
index 0000000..ae2628c
--- /dev/null
@@ -0,0 +1,39 @@
+# Generated by Django 4.0.8 on 2025-05-07 13:21
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('social', '0017_userconfirmation'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Progress',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('updated_at', models.DateTimeField(auto_now=True)),
+                ('last_mode', models.CharField(choices=[('text', 'text'), ('audio', 'audio')], max_length=64)),
+                ('text_percent', models.FloatField(blank=True, null=True)),
+                ('text_anchor', models.CharField(blank=True, max_length=64)),
+                ('audio_percent', models.FloatField(blank=True, null=True)),
+                ('audio_timestamp', models.FloatField(blank=True, null=True)),
+                ('implicit_text_percent', models.FloatField(blank=True, null=True)),
+                ('implicit_text_anchor', models.CharField(blank=True, max_length=64)),
+                ('implicit_audio_percent', models.FloatField(blank=True, null=True)),
+                ('implicit_audio_timestamp', models.FloatField(blank=True, null=True)),
+                ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='catalogue.book')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'unique_together': {('user', 'book')},
+            },
+        ),
+    ]
index 17fe7d0..c7096dd 100644 (file)
@@ -200,3 +200,42 @@ class UserConfirmation(models.Model):
             user=user,
             key=generate_token()
         ).send()
+
+
+
+class Progress(models.Model):
+    user = models.ForeignKey(User, models.CASCADE)
+    book = models.ForeignKey('catalogue.Book', models.CASCADE)
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+    last_mode = models.CharField(max_length=64, choices=[
+        ('text', 'text'),
+        ('audio', 'audio'),
+    ])
+    text_percent = models.FloatField(null=True, blank=True)
+    text_anchor = models.CharField(max_length=64, blank=True)
+    audio_percent = models.FloatField(null=True, blank=True)
+    audio_timestamp = models.FloatField(null=True, blank=True)
+    implicit_text_percent = models.FloatField(null=True, blank=True)
+    implicit_text_anchor = models.CharField(max_length=64, blank=True)
+    implicit_audio_percent = models.FloatField(null=True, blank=True)
+    implicit_audio_timestamp = models.FloatField(null=True, blank=True)
+
+    class Meta:
+        unique_together = [('user', 'book')]
+
+    def save(self, *args, **kwargs):
+        audio_l = self.book.get_audio_length()
+        if self.text_anchor:
+            self.text_percent = 33
+            if audio_l:
+                self.implicit_audio_percent = 40
+                self.implicit_audio_timestamp = audio_l * .4
+        if self.audio_timestamp:
+            if self.audio_timestamp > audio_l:
+                self.audio_timestamp = audio_l
+            if audio_l:
+                self.audio_percent = 100 * self.audio_timestamp / audio_l
+                self.implicit_text_percent = 60
+                self.implicit_text_anchor = 'f20'
+        return super().save(*args, **kwargs)