From: Radek Czajka Date: Tue, 8 Jul 2025 14:13:12 +0000 (+0200) Subject: wip X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/e7d9b3875966262ee2e9eb6c281d2677a05a4e91 wip --- diff --git a/src/api/urls.py b/src/api/urls.py index 62d4fa7f7..38f619252 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -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')) ] diff --git a/src/api/utils.py b/src/api/utils.py index 3b23246b9..26e0778d9 100644 --- a/src/api/utils.py +++ b/src/api/utils.py @@ -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): diff --git a/src/catalogue/api/serializers.py b/src/catalogue/api/serializers.py index a90d2ef9c..8968bf6b1 100644 --- a/src/catalogue/api/serializers.py +++ b/src/catalogue/api/serializers.py @@ -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'] diff --git a/src/catalogue/api/urls2.py b/src/catalogue/api/urls2.py index 7dc131d23..f06ecdf21 100644 --- a/src/catalogue/api/urls2.py +++ b/src/catalogue/api/urls2.py @@ -23,6 +23,9 @@ urlpatterns = [ piwik_track_view(views.BookFragmentView.as_view()), name='catalogue_api_book_fragment' ), + path('books//media//', views.BookMediaView.as_view()), + path('books/.json', + views.BookJsonView.as_view()), path('suggested-tags/', piwik_track_view(views.SuggestedTags.as_view()), diff --git a/src/catalogue/api/views.py b/src/catalogue/api/views.py index e45f80e75..b2734d941 100644 --- a/src/catalogue/api/views.py +++ b/src/catalogue/api/views.py @@ -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}) + diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py index c2b9eeddf..15b1d2fe0 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -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() diff --git a/src/catalogue/models/bookmedia.py b/src/catalogue/models/bookmedia.py index acb1881e5..fd0dd56a7 100644 --- a/src/catalogue/models/bookmedia.py +++ b/src/catalogue/models/bookmedia.py @@ -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: diff --git a/src/catalogue/models/tag.py b/src/catalogue/models/tag.py index 13d8a6403..07a43f45e 100644 --- a/src/catalogue/models/tag.py +++ b/src/catalogue/models/tag.py @@ -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 diff --git a/src/social/api/urls2.py b/src/social/api/urls2.py index b150e6183..d2d6245b5 100644 --- a/src/social/api/urls2.py +++ b/src/social/api/urls2.py @@ -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//', views.ListView.as_view()), + path('lists///', views.ListItemView.as_view()), + + path('progress/', views.ProgressListView.as_view()), + path('progress//', views.ProgressView.as_view()), + path('progress//text/', views.TextProgressView.as_view()), + path('progress//audio/', views.AudioProgressView.as_view()), ] diff --git a/src/social/api/views.py b/src/social/api/views.py index 22a0e9c52..867a05a54 100644 --- a/src/social/api/views.py +++ b/src/social/api/views.py @@ -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 index 000000000..ae2628c27 --- /dev/null +++ b/src/social/migrations/0018_progress.py @@ -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')}, + }, + ), + ] diff --git a/src/social/models.py b/src/social/models.py index 17fe7d07a..c7096dd55 100644 --- a/src/social/models.py +++ b/src/social/models.py @@ -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)