From: Radek Czajka Date: Fri, 12 Dec 2025 13:31:37 +0000 (+0100) Subject: Merge branch 'master' into appdev X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/d2a9ebf1eae1ee5fa8a09a7dfea76995274f7716?hp=07cd561683d722537e51ca728c584abb7caec92f Merge branch 'master' into appdev --- diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 396184d30..5f7496ec6 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -42,7 +42,7 @@ mutagen==1.47 sorl-thumbnail==12.10.0 # home-brewed & dependencies -librarian==24.5.10 +librarian==25.12 # celery tasks celery[redis]==5.4.0 diff --git a/src/api/migrations/0009_sessiontransfertoken.py b/src/api/migrations/0009_sessiontransfertoken.py new file mode 100644 index 000000000..a277811aa --- /dev/null +++ b/src/api/migrations/0009_sessiontransfertoken.py @@ -0,0 +1,27 @@ +# Generated by Django 4.0.8 on 2025-11-19 13:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0008_alter_token_token_type'), + ] + + operations = [ + migrations.CreateModel( + name='SessionTransferToken', + fields=[ + ('token', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('used', models.BooleanField(default=False)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/api/models.py b/src/api/models.py index 4f0556561..eade03754 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -1,12 +1,14 @@ # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later. # Copyright © Fundacja Wolne Lektury. See NOTICE for more information. # +from datetime import timedelta +import uuid from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models.signals import pre_delete - +from django.utils import timezone from catalogue.models import Book, Tag @@ -121,3 +123,26 @@ class Token(models.Model): def __str__(self): return "%s Token %s for %s" % (self.get_token_type_display(), self.key, self.consumer) + + +class SessionTransferToken(models.Model): + token = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + used = models.BooleanField(default=False) + + @classmethod + def create_for_user(cls, user, lifetime_seconds=30): + return cls.objects.create( + user=user, + expires_at=timezone.now() + timedelta(seconds=lifetime_seconds) + ) + + def is_valid(self): + if self.used: + return False + if timezone.now() > self.expires_at: + return False + return True diff --git a/src/api/serializers.py b/src/api/serializers.py index 8c008926d..8d160b0e4 100644 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -46,3 +46,34 @@ class RefreshTokenSerializer(serializers.Serializer): class RequestConfirmSerializer(serializers.Serializer): email = serializers.CharField() + + +class DeleteAccountSerializer(serializers.Serializer): + password =serializers.CharField( + style={'input_type': 'password'} + ) + + def validate_password(self, value): + u = self.context['user'] + if not u.check_password(value): + raise serializers.ValidationError("Password incorrect.") + return value + + +class PasswordSerializer(serializers.Serializer): + old_password = serializers.CharField( + style={'input_type': 'password'} + ) + new_password = serializers.CharField( + style={'input_type': 'password'} + ) + + def validate_old_password(self, value): + u = self.context['user'] + if not u.check_password(value): + raise serializers.ValidationError("Password incorrect.") + return value + + +class ResetPasswordSerializer(serializers.Serializer): + email = serializers.EmailField() diff --git a/src/api/urls.py b/src/api/urls.py index 62d4fa7f7..182e0dd6f 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -15,8 +15,17 @@ urlpatterns1 = [ path('requestConfirm/', csrf_exempt(views.RequestConfirmView.as_view())), path('login/', csrf_exempt(views.Login2View.as_view())), path('me/', views.UserView.as_view()), + path('deleteAccount/', views.DeleteAccountView.as_view()), + path('password/', views.PasswordView.as_view()), + path('password-reset/', views.ResetPasswordView.as_view()), + path("session-transfer/", views.SessionTransferTokenView.as_view()), + path("session-transfer/continue/", views.ConsumeSessionTransferTokenView.as_view(), name="api-session-continue"), + path('', include('catalogue.api.urls2')), path('', include('social.api.urls2')), + path('', include('bookmarks.api.urls')), + path('', include('search.api.urls')), + path('', include('push.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/api/views.py b/src/api/views.py index 011161e52..86275aaf4 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -2,14 +2,15 @@ # Copyright © Fundacja Wolne Lektury. See NOTICE for more information. # from time import time +from allauth.account.forms import ResetPasswordForm from django.conf import settings -from django.contrib.auth import authenticate +from django.contrib.auth import authenticate, login from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django import forms -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.http import Http404 -from django.shortcuts import render +from django.shortcuts import redirect, render from django.views.generic.base import View from oauthlib.common import urlencode, generate_token from oauthlib.oauth1 import RequestTokenEndpoint, AccessTokenEndpoint @@ -19,7 +20,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.generics import GenericAPIView, RetrieveAPIView, get_object_or_404 from catalogue.models import Book -from .models import BookUserData, KEY_SIZE, SECRET_SIZE, Token +from .models import BookUserData, KEY_SIZE, SECRET_SIZE, Token, SessionTransferToken from social.models import UserConfirmation from . import serializers from .request_validator import PistonRequestValidator @@ -334,3 +335,84 @@ class RequestConfirmView(APIView): UserConfirmation.request(user) return Response({}) + +class DeleteAccountView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = serializers.DeleteAccountSerializer + + def post(self, request): + u = request.user + serializer = self.get_serializer( + data=request.data, + context={'user': u} + ) + serializer.is_valid(raise_exception=True) + d = serializer.validated_data + u.is_active = False + u.save() + return Response({}) + + +class PasswordView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = serializers.PasswordSerializer + + def post(self, request): + u = request.user + serializer = self.get_serializer( + data=request.data, + context={'user': u} + ) + serializer.is_valid(raise_exception=True) + d = serializer.validated_data + u.set_password(d['new_password']) + u.save() + return Response({}) + + +class ResetPasswordView(GenericAPIView): + serializer_class = serializers.ResetPasswordSerializer + + def post(self, request): + serializer = serializers.ResetPasswordSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + form = ResetPasswordForm({"email": serializer.validated_data['email']}) + form.is_valid() + form.save(request) + return Response({}) + + +class SessionTransferTokenView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + ott = SessionTransferToken.create_for_user(request.user) + return Response({ + "token": str(ott.token) + }) + + +class ConsumeSessionTransferTokenView(View): + def get(self, request): + token_str = request.GET.get("token") + next_url = request.GET.get("next", "/") #TODO: validate + + if not token_str: + return HttpResponseBadRequest("Missing token") + + try: + ott = SessionTransferToken.objects.get(token=token_str) + except SessionTransferToken.DoesNotExist: + return HttpResponseBadRequest("Invalid token") + + if not ott.is_valid(): + return HttpResponseForbidden("Token expired or already used") + + # Mark token as used + ott.used = True + ott.save(update_fields=["used"]) + + # Log in the user via Django session + login(request, ott.user) + + return redirect(next_url) diff --git a/src/bookmarks/api/urls.py b/src/bookmarks/api/urls.py new file mode 100644 index 000000000..f61b3263f --- /dev/null +++ b/src/bookmarks/api/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + + +urlpatterns = [ + path('bookmarks/', views.BookmarksView.as_view()), + path('bookmarks/book//', views.BookBookmarksView.as_view()), + path('bookmarks//', views.BookmarkView.as_view(), name='api_bookmark'), +] diff --git a/src/bookmarks/api/views.py b/src/bookmarks/api/views.py new file mode 100644 index 000000000..b500a6654 --- /dev/null +++ b/src/bookmarks/api/views.py @@ -0,0 +1,69 @@ +from api.utils import never_cache + +from django.db.models import Q +from django.http import Http404, JsonResponse +from django.shortcuts import render, get_object_or_404 +from django.views.decorators import cache +import catalogue.models +from wolnelektury.utils import is_ajax +from bookmarks import models +from lxml import html +import re +from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView +from rest_framework import serializers +from rest_framework.permissions import SAFE_METHODS, IsAuthenticated, IsAuthenticatedOrReadOnly +from api.fields import AbsoluteURLField + + +class BookmarkSerializer(serializers.ModelSerializer): + book = serializers.SlugRelatedField( + queryset=catalogue.models.Book.objects.all(), slug_field='slug', + required=False + ) + href = AbsoluteURLField(view_name='api_bookmark', view_args=['uuid']) + timestamp = serializers.IntegerField(required=False) + location = serializers.CharField(required=False) + + class Meta: + model = models.Bookmark + fields = ['book', 'anchor', 'audio_timestamp', 'mode', 'note', 'href', 'uuid', 'location', 'timestamp', 'deleted'] + read_only_fields = ['uuid', 'mode'] + + + +@never_cache +class BookmarksView(ListCreateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = BookmarkSerializer + + def get_queryset(self): + return self.request.user.bookmark_set.all() + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +@never_cache +class BookBookmarksView(ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = BookmarkSerializer + pagination_class = None + + def get_queryset(self): + return self.request.user.bookmark_set.filter(book__slug=self.kwargs['book']) + + +@never_cache +class BookmarkView(RetrieveUpdateDestroyAPIView): + permission_classes = [IsAuthenticatedOrReadOnly] + serializer_class = BookmarkSerializer + lookup_field = 'uuid' + + def get_queryset(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 models.Bookmark.objects.filter(q) + else: + return self.request.user.bookmark_set.all() diff --git a/src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py b/src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py new file mode 100644 index 000000000..8e9f32c3e --- /dev/null +++ b/src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.0.8 on 2025-08-01 14:35 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookmarks', '0002_quote'), + ] + + operations = [ + migrations.AddField( + model_name='bookmark', + name='deleted', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='bookmark', + name='reported_timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='bookmark', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/src/bookmarks/migrations/0004_bookmark_audio_timestamp_bookmark_mode_and_more.py b/src/bookmarks/migrations/0004_bookmark_audio_timestamp_bookmark_mode_and_more.py new file mode 100644 index 000000000..87dc168b7 --- /dev/null +++ b/src/bookmarks/migrations/0004_bookmark_audio_timestamp_bookmark_mode_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.0.8 on 2025-08-22 14:52 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookmarks', '0003_bookmark_deleted_bookmark_reported_timestamp_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='bookmark', + name='audio_timestamp', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='bookmark', + name='mode', + field=models.CharField(choices=[('text', 'text'), ('audio', 'audio')], default='text', max_length=64), + ), + migrations.AlterField( + model_name='bookmark', + name='reported_timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/src/bookmarks/models.py b/src/bookmarks/models.py index 67a4fa5e6..7325e2d32 100644 --- a/src/bookmarks/models.py +++ b/src/bookmarks/models.py @@ -1,17 +1,110 @@ import uuid +from django.apps import apps from django.db import models +from django.utils.timezone import now +from social.syncable import Syncable -class Bookmark(models.Model): +class Bookmark(Syncable, models.Model): uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) user = models.ForeignKey('auth.User', models.CASCADE) book = models.ForeignKey('catalogue.Book', models.CASCADE) anchor = models.CharField(max_length=100, blank=True) + audio_timestamp = models.IntegerField(null=True, blank=True) + mode = models.CharField(max_length=64, choices=[ + ('text', 'text'), + ('audio', 'audio'), + ], default='text') created_at = models.DateTimeField(auto_now_add=True) note = models.TextField(blank=True) + updated_at = models.DateTimeField(auto_now=True) + reported_timestamp = models.DateTimeField(default=now) + deleted = models.BooleanField(default=False) + + syncable_fields = [ + 'deleted', 'note', + ] def __str__(self): return str(self.uuid) + + def save(self, *args, **kwargs): + # TODO: placeholder. + try: + audio_l = self.book.get_audio_length() + except: + audio_l = 60 + + if self.anchor: + self.mode = 'text' + self.audio_timestamp = self.book.sync_elid(self.anchor) + if self.audio_timestamp: + self.mode = 'audio' + self.anchor = self.book.sync_ts(self.audio_timestamp) + return super().save(*args, **kwargs) + + @classmethod + def create_from_data(cls, user, data): + if data.get('location'): + return cls.get_by_location(user, data['location'], create=True) + elif data.get('book') and data.get('anchor'): + return cls.objects.create(user=user, book=data['book'], anchor=data['anchor']) + elif data.get('book') and data.get('audio_timestamp'): + return cls.objects.create(user=user, book=data['book'], audio_timestamp=data['audio_timestamp']) + + @property + def timestamp(self): + return self.updated_at.timestamp() + + def location(self): + if self.mode == 'text': + return f'{self.book.slug}/{self.anchor}' + else: + return f'{self.book.slug}/audio/{self.audio_timestamp}' + + @classmethod + def get_by_location(cls, user, location, create=False): + Book = apps.get_model('catalogue', 'Book') + try: + slug, anchor = location.split('/', 1) + except: + return None + if '/' in anchor: + try: + mode, audio_timestamp = anchor.split('/', 1) + assert mode == 'audio' + audio_timestamp = int(audio_timestamp) + except: + return None + anchor = '' + instance = cls.objects.filter( + user=user, + book__slug=slug, + mode=mode, + audio_timestamp=audio_timestamp, + ).first() + else: + mode = 'text' + audio_timestamp = None + instance = cls.objects.filter( + user=user, + book__slug=slug, + mode='text', + anchor=anchor, + ).first() + if instance is None and create: + try: + book = Book.objects.get(slug=slug) + except Book.DoesNotExist: + return None + instance = cls.objects.create( + user=user, + book=book, + mode=mode, + anchor=anchor, + audio_timestamp=audio_timestamp, + ) + return instance def get_for_json(self): return { diff --git a/src/catalogue/api/serializers.py b/src/catalogue/api/serializers.py index 406cd39aa..3d6341ed8 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' + 'id', '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 = ['id', '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 = ['id', '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 = ['id', 'url', 'href', 'name', 'slug'] class KindSerializer(KindItemSerializer): class Meta: @@ -117,6 +117,18 @@ class KindSerializer(KindItemSerializer): 'collective_noun', ] +class ThemeSerializer(serializers.ModelSerializer): + url = AbsoluteURLField() + href = AbsoluteURLField( + view_name='catalogue_api_theme', + view_args=('slug',) + ) + class Meta: + model = Tag + fields = [ + 'url', 'href', 'name', 'slug', 'sort_key', 'description', + ] + class TranslatorSerializer(serializers.Serializer): name = serializers.CharField(source='*') @@ -143,6 +155,7 @@ class BookSerializer2(serializers.ModelSerializer): view_name='catalogue_api_book', lookup_field='slug' ) + audiences = serializers.ListField(source='audiences_pl') class Meta: model = Book @@ -157,7 +170,8 @@ class BookSerializer2(serializers.ModelSerializer): 'cover_thumb', 'cover', 'isbn_pdf', 'isbn_epub', 'isbn_mobi', 'abstract', - 'has_mp3_file', + 'has_mp3_file', 'has_sync_file', + 'elevenreader_link', 'content_warnings', 'audiences', ] class BookSerializer11Labs(serializers.ModelSerializer): @@ -377,4 +391,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/tojson.py b/src/catalogue/api/tojson.py new file mode 100644 index 000000000..3d47cd995 --- /dev/null +++ b/src/catalogue/api/tojson.py @@ -0,0 +1,223 @@ +from collections import defaultdict +import json +import re +from sys import argv +from lxml import etree + +tags = { + 'utwor': ('_pass', False, None, None, None), + '{http://www.w3.org/1999/02/22-rdf-syntax-ns#}RDF': ('_ignore', False, None, None, None), + 'abstrakt': ('_ignore', False, None, None, None), + 'uwaga': ('_ignore', False, None, None, None), + 'extra': ('_ignore', False, None, None, None), + 'nota_red': ('_ignore', False, None, None, None), + 'numeracja': ('_ignore', False, None, None, None), + + 'powiesc': ('master', False, None, None, None), + 'opowiadanie': ('master', False, None, None, None), + 'liryka_lp': ('master', False, None, None, None), + 'liryka_l': ('master', False, None, None, None), + 'dramat_wspolczesny': ('master', False, None, None, None), + 'dramat_wierszowany_lp': ('master', False, None, None, None), + 'dramat_wierszowany_l': ('master', False, None, None, None), + + 'dlugi_cytat': ('blockquote', False, None, None, None), + 'poezja_cyt': ('blockquote', False, None, None, None), + 'dlugi_cyt': ('blockquote', False, None, None, None), + 'ramka': ('blockquote', False, {'class': 'ramka'}, None, None), + + 'blok': ('div', False, None, None, None), + + 'strofa': ('div', True, {'class': 'stanza'}, None, None), + 'wers': ('div', True, {'class': 'verse'}, None, None), + 'wers_wciety': ('div', True, {'class': 'wers_wciety'}, None, None), + 'wers_cd': ('div', True, {'class': 'wers_cd'}, None, None), + 'wers_akap': ('div', True, {'class': 'wers_akap'}, None, None), + 'zastepnik_wersu': ('div', True, {'class': 'zastepnik_wersu'}, None, None), + 'wers_do_prawej': ('div', True, {'class': 'wers_do_prawej'}, None, None), + 'wers_srodek': ('div', True, {'class': 'wers_srodek'}, None, None), + + 'autor_utworu': ('div', True, {'class': 'author'}, None, None), + 'dzielo_nadrzedne': ('div', True, {'class': 'dzielo_nadrzedne'}, None, None), + 'nazwa_utworu': ('div', True, {'class': 'title'}, None, None), + 'podtytul': ('div', True, {'class': 'podtytul'}, None, None), + + 'motto': ('div', False, {'class': 'motto'}, None, None), + 'motto_podpis': ('div', True, {'class': 'motto_podpis'}, None, None), + 'dedykacja': ('div', True, {'class': 'dedykacja'}, None, None), + 'miejsce_czas': ('div', True, {'class': 'miejsce_czas'}, None, None), + + 'lista_osob': ('div', False, {'class': 'lista_osob'}, None, None), + 'naglowek_listy': ('div', True, {'class': 'naglowek_listy'}, None, None), + 'lista_osoba': ('div', True, {'class': 'lista_osoba'}, None, None), + 'naglowek_osoba': ('div', True, {'class': 'naglowek_osoba'}, None, None), + 'osoba': ('em', True, {'class': 'osoba'}, None, None), + 'didaskalia': ('div', True, {'class': 'didaskalia'}, None, None), + 'kwestia': ('div', False, {'class': 'kwestia'}, None, None), + 'didask_tekst': ('em', False, {'class': 'didask_tekst'}, None, None), + + 'naglowek_czesc': ('h2', True, None, None, None), + 'naglowek_akt': ('h2', True, None, None, None), + 'naglowek_scena': ('h3', True, None, None, None), + 'naglowek_rozdzial': ('h3', True, None, None, None), + 'naglowek_podrozdzial': ('h4', True, None, None, None), + 'srodtytul': ('h5', True, None, None, None), + + 'nota': ('div', True, {'class': 'note'}, None, False), + + 'akap': ('p', True, {'class': 'paragraph'}, None, True), + 'akap_dialog': ('p', True, {'class': 'paragraph'}, None, True), + 'akap_cd': ('p', True, {'class': 'paragraph'}, None, True), + + 'sekcja_asterysk': ('p', True, {'class': 'spacer-asterisk'}, None, True), + 'sekcja_swiatlo': ('p', True, {'class': 'sekcja_swiatlo'}, None, True), + 'separator_linia': ('p', True, {'class': 'separator_linia'}, None, True), + + 'tytul_dziela': ('em', True, {'class': 'book-title'}, None, False), + 'slowo_obce': ('em', True, {'class': 'foreign-word'}, None, False), + 'wyroznienie': ('em', True, {'class': 'author-emphasis'}, None, False), + 'wieksze_odstepy': ('em', True, {'class': 'wieksze_odstepy'}, None, False), + + 'ref': ('a', True, {'class': 'reference'}, {'data-uri': 'href'}, False), + + 'begin': ('_ignore', True, {'class': 'reference'}, {'data-uri': 'href'}, False), + 'end': ('_ignore', True, {'class': 'reference'}, {'data-uri': 'href'}, False), + 'motyw': ('_ignore', True, {'class': 'theme'}, None, False), + + 'pa': ('a', True, {'class': 'footnote footnote-pa'}, None, False), + 'pe': ('a', True, {'class': 'footnote footnote-pe'}, None, False), + 'pr': ('a', True, {'class': 'footnote footnote-pr'}, None, False), + 'pt': ('a', True, {'class': 'footnote footnote-pt'}, None, False), + 'ptrad': ('a', True, {'class': 'footnote footnote-ptrad'}, None, False), +} + +id_prefixes = { + 'pa': 'fn', + 'pe': 'fn', + 'pr': 'fn', + 'pt': 'fn', + 'ptrad': 'fn', + 'wers': 'f', + 'wers_wciety': 'f', + 'wers_cd': 'f', + 'wers_akap': 'f', + 'zastepnik_wersu': 'f', + 'wers_do_prawej': 'f', + 'wers_srodek': 'f', + 'akap': 'f', + 'akap_cd': 'f', + 'akap_dialog': 'f', +} + + +#tree = etree.parse(argv[1]) + +front1 = set([ + 'dzielo_nadrzedne', + 'nazwa_utworu', + 'podtytul', + ]) +front2 = set(['autor_utworu']) + + +def norm(text): + text = text.replace('---', '—').replace('--', '–').replace('...', '…').replace(',,', '„').replace('"', '”').replace('\n', ' ') + return text + + +def toj(elem, S): + if elem.tag is etree.Comment: return [] + tag, hastext, attrs, attr_map, num = tags[elem.tag] + contents = [] + if tag == '_pass': + output = contents + elif tag == '_ignore': + return [] + else: + output = { + 'tag': tag, + } + if num: + S['index'] += 1 + output['paragraphIndex'] = S['index'] + if 'dlugi_cytat' not in S['stack'] and 'poezja_cyt' not in S['stack']: + S['vindex'] += 1 + output['visibleNumber'] = S['vindex'] + id_prefix = id_prefixes.get(elem.tag, 'i') + S['id'][id_prefix] += 1 + output['id'] = id_prefix + str(S['id'][id_prefix]) + if elem.attrib.get('id'): + output['id'] = 'wl-' + elem.attrib.get('id') + if attrs: + output['attr'] = attrs.copy() + if attr_map: + output.setdefault('attr', {}) + for k, v in attr_map.items(): + output['attr'][k] = elem.attrib[v] + output['contents'] = contents + output = [output] + if elem.tag == 'strofa': + verses = [etree.Element('wers')] + if elem.text: + vparts = re.split(r'/\s+', elem.text) + for i, v in enumerate(vparts): + if i: + verses.append(etree.Element('wers')) + verses[-1].text = (verses[-1].text or '') + v + for child in elem: + vparts = re.split(r'/\s+', child.tail or '') + child.tail = vparts[0] + verses[-1].append(child) + for v in vparts[1:]: + verses.append(etree.Element('wers')) + verses[-1].text = v + + if not(len(verses[-1]) or (verses[-1].text or '').strip()): + verses.pop() + + elem.clear(keep_tail=True) + for verse in verses: + if len(verse) == 1 and (verse[0].tag.startswith('wers') or verse[0].tag == 'zastepnik_wersu') and not (verse[0].tail or '').strip(): + elem.append(verse[0]) + else: + elem.append(verse) + + #if not len(elem): + # for v in re.split(r'/\s+', elem.text): + # etree.SubElement(elem, 'wers').text = v + # elem.text = None + + if hastext and elem.text: + contents.append(norm(elem.text)) + for c in elem: + S['stack'].append(elem.tag) + contents += toj(c, S) + if hastext and c.tail: + contents.append(norm(c.tail)) + S['stack'].pop() + + if elem.tag in front1: + S['front1'] += output + return [] + if elem.tag in front2: + S['front2'] += output + return [] + return output + +def conv(tree): + S = { + 'index': 0, + 'vindex': 0, + 'id': defaultdict(lambda: 0), + 'stack': [], + 'front1': [], + 'front2': [], + } + output = toj(tree.getroot(), S) + if not len(output): return {} + jt = output[0] + jt['front1'] = S['front1'] + jt['front2'] = S['front2'] + return jt + +#print(json.dumps(jt, indent=2, ensure_ascii=False)) diff --git a/src/catalogue/api/urls2.py b/src/catalogue/api/urls2.py index e90e13eea..46f2a36b5 100644 --- a/src/catalogue/api/urls2.py +++ b/src/catalogue/api/urls2.py @@ -26,6 +26,11 @@ urlpatterns = [ path('books//media//', views.BookMediaView.as_view() ), + path('books/.json', + views.BookJsonView.as_view()), + path('books//sync/', + views.BookSyncView.as_view() + ), path('suggested-tags/', piwik_track_view(views.SuggestedTags.as_view()), @@ -56,4 +61,10 @@ urlpatterns = [ path('genres//', piwik_track_view(views.GenreView.as_view()), name='catalogue_api_genre'), + path('themes/', + piwik_track_view(views.ThemeList.as_view()), + name="catalogue_api_theme_list"), + path('themes//', + piwik_track_view(views.ThemeView.as_view()), + name='catalogue_api_theme'), ] diff --git a/src/catalogue/api/views.py b/src/catalogue/api/views.py index 821b281c4..0f3a95fa2 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): @@ -246,6 +251,18 @@ class BookDetail2(RetrieveAPIView): serializer_class = serializers.BookSerializer2 +class BookSyncView(RetrieveAPIView): + queryset = Book.objects.all() + lookup_field = 'slug' + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + return Response([ + {"id": i, "timestamp": ts} + for (i, ts) in instance.get_sync() + ]) + + @vary_on_auth # Because of embargo links. class EbookList(BookList): serializer_class = serializers.EbookSerializer @@ -401,6 +418,15 @@ class KindView(RetrieveAPIView): queryset = Tag.objects.filter(category='kind') lookup_field = 'slug' +class ThemeList(ListAPIView): + serializer_class = serializers.ThemeSerializer + queryset = Tag.objects.filter(category='theme') + +class ThemeView(RetrieveAPIView): + serializer_class = serializers.ThemeSerializer + queryset = Tag.objects.filter(category='theme') + lookup_field = 'slug' + class TagView(RetrieveAPIView): permission_classes = [DjangoModelPermissionsOrAnonReadOnly] @@ -527,3 +553,14 @@ class BookMediaView(ListAPIView): 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 0400656e2..ee3aecb02 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -94,7 +94,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) narrators = models.ManyToManyField(Tag, blank=True, related_name='narrated') has_audio = models.BooleanField(default=False) @@ -471,8 +471,9 @@ class Book(models.Model): None, ContentFile(sync) ) - def get_sync(self): + if not self.has_sync_file(): + return [] with self.get_media('sync').first().file.open('r') as f: sync = f.read().split('\n') offset = float(sync[0]) @@ -482,8 +483,22 @@ class Book(models.Model): continue start, end, elid = line.split() items.append([elid, float(start) + offset]) - return json.dumps(items) - + return items + + def sync_ts(self, ts): + elid = None + for cur_id, t in self.get_sync(): + if ts >= t: + elid = cur_id + else: + break + return elid + + def sync_elid(self, elid): + for cur_id, t in self.get_sync(): + if cur_id == elid: + return t + def has_audio_epub_file(self): return self.has_media("audio.epub") @@ -1094,7 +1109,7 @@ class Book(models.Model): return None def update_popularity(self): - count = self.tags.filter(category='set').values('user').order_by('user').distinct().count() + count = self.userlistitem_set.values('list__user').order_by('list__user').distinct().count() try: pop = self.popularity pop.count = count @@ -1105,16 +1120,23 @@ class Book(models.Model): def ridero_link(self): return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_')) - def like(self, user): - from social.utils import likes, get_set, set_sets - if not likes(user, self): - tag = get_set(user, '') - set_sets(user, self, [tag]) + def elevenreader_link(self): + first_text = self.get_first_text() + if first_text is None: + return None + return 'https://elevenreader.io/audiobooks/wolnelektury:' + first_text.slug - def unlike(self, user): - from social.utils import likes, set_sets - if likes(user, self): - set_sets(user, self, []) + def content_warnings(self): + warnings_def = { + 'wulgaryzmy': _('wulgaryzmy'), + } + warnings = self.get_extra_info_json().get('content_warnings', []) + warnings = [ + warnings_def.get(w, w) + for w in warnings + ] + warnings.sort() + return warnings def full_sort_key(self): return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id))) diff --git a/src/catalogue/models/bookmedia.py b/src/catalogue/models/bookmedia.py index 87d7f7803..a5df65740 100644 --- a/src/catalogue/models/bookmedia.py +++ b/src/catalogue/models/bookmedia.py @@ -89,7 +89,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..fb90a7b89 100644 --- a/src/catalogue/models/tag.py +++ b/src/catalogue/models/tag.py @@ -161,6 +161,8 @@ class Tag(models.Model): @staticmethod def get_tag_list(tag_str): + from social.models import UserList + if not tag_str: return [] tags = [] @@ -170,7 +172,10 @@ class Tag(models.Model): tags_splitted = tag_str.split('/') for name in tags_splitted: if category: - tags.append(Tag.objects.get(slug=name, category=category)) + if category == 'set': + tags.append(UserList.objects.get(slug=name, deleted=False)) + else: + tags.append(Tag.objects.get(slug=name, category=category)) category = None elif name in Tag.categories_rev: category = Tag.categories_rev[name] @@ -242,6 +247,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/catalogue/templates/catalogue/book_short.html b/src/catalogue/templates/catalogue/book_short.html deleted file mode 100644 index 190bdc8f9..000000000 --- a/src/catalogue/templates/catalogue/book_short.html +++ /dev/null @@ -1,172 +0,0 @@ -{% spaceless %} - {% load i18n %} - {% load thumbnail %} - {% load cache %} - {% load catalogue_tags %} - {% load book_shelf_tags from social_tags %} - {% load static %} - - {% with ga=book.get_audiobooks %} - {% with audiobooks=ga.0 %} -
-
- - {% with book.tags_by_category as tags %} -
-
- {% block book-box-body-pre %} - {% endblock %} - -
- {% if book.cover_clean %} - - Cover - - {% endif %} - {% block cover-area-extra %}{% endblock %} -
- - {% get_current_language as LANGUAGE_CODE %} - {% cache 86400 book_box_head_tags book.pk LANGUAGE_CODE %} -
-
- {% for tag in tags.author %} - {{ tag.name }}{% if not forloop.last %}, - {% endif %}{% endfor %}{% for parent in book.parents %}, - {{ parent.title }}{% endfor %} -
- - {% if book.translator %} -
- tłum. {{ book.translator }} -
- {% endif %} -
- -
- - {% trans "Epoka" %}:  - {% for tag in tags.epoch %} - {{ tag.name }} - {% if not forloop.last %}, {% endif %} - {% endfor %} - - - - {% trans "Rodzaj" %}:  - {% for tag in tags.kind %} - {{ tag.name }} - {% if not forloop.last %}, {% endif %} - {% endfor %} - - - - {% trans "Gatunek" %}:  - {% for tag in tags.genre %} - {{ tag.name }} - {% if not forloop.last %}, {% endif %} - {% endfor %} - - - {% with extra_info=book.get_extra_info_json %} - {% if extra_info.location %} - - {% trans "Region" %}:  - {{ extra_info.location }} - - {% endif %} - {% endwith %} - - {% if book.is_foreign %} - - {% trans "Język" %}:  - {{ book.language_name }} - - - {% endif %} - - {% with stage_note=book.stage_note %} - {% if stage_note.0 %} -
- - {{ stage_note.0 }} - - {% endif %} - {% endwith %} -
- {% endcache %} -
- {% book_shelf_tags book.pk %} - - {% cache 86400 book_box_tools book.pk book|status:request.user LANGUAGE_CODE %} - {% if book|status:request.user != 'closed' %} - - {% else %} - {% block preview-info %} -

- Ten utwór jest na razie dostępny wyłącznie dla naszych Darczyńców. - Wspieraj Wolne Lektury -

-
{% content_warning book %}
- {% endblock %} - {% endif %} - {% endcache %} - {% block book-box-extra-info %}{% endblock %} - {% block box-append %}{% endblock %} -
- {% endwith %} - - {% if book.abstract %} -
- {{ book.abstract|safe }} -
- {% endif %} - -
-
-
- {% endwith %} - {% endwith %} -{% endspaceless %} diff --git a/src/catalogue/templates/catalogue/book_text.html b/src/catalogue/templates/catalogue/book_text.html index 18e4bcc43..e06f0f2a3 100644 --- a/src/catalogue/templates/catalogue/book_text.html +++ b/src/catalogue/templates/catalogue/book_text.html @@ -322,9 +322,7 @@
{% include 'catalogue/snippets/jplayer_reader.html' %}
- + {{ book.get_sync|json_script:"smil" }} {% endif %} diff --git a/src/catalogue/templatetags/catalogue_tags.py b/src/catalogue/templatetags/catalogue_tags.py index d2298d9f5..e79563cb3 100644 --- a/src/catalogue/templatetags/catalogue_tags.py +++ b/src/catalogue/templatetags/catalogue_tags.py @@ -17,6 +17,7 @@ from catalogue.helpers import get_audiobook_tags from catalogue.models import Book, BookMedia, Fragment, Tag, Source from catalogue.constants import LICENSES from club.models import Membership +from social.models import UserList register = template.Library() @@ -73,7 +74,10 @@ def nice_title_from_tags(tags, related_tags): def split_tags(tags): result = {} for tag in tags: - result.setdefault(tag.category, []).append(tag) + if isinstance(tag, UserList): + result.setdefault('userlist', []).append(tag) + else: + result.setdefault(tag.category, []).append(tag) return result self = split_tags(tags) @@ -420,16 +424,8 @@ def status(book, user): @register.inclusion_tag('catalogue/snippets/content_warning.html') def content_warning(book): - warnings_def = { - 'wulgaryzmy': _('wulgaryzmy'), - } - warnings = book.get_extra_info_json().get('content_warnings', []) - warnings = sorted( - warnings_def.get(w, w) - for w in warnings - ) return { - "warnings": warnings + "warnings": book.content_warnings(), } diff --git a/src/catalogue/views.py b/src/catalogue/views.py index d5b83ab08..1ac6c089d 100644 --- a/src/catalogue/views.py +++ b/src/catalogue/views.py @@ -23,6 +23,7 @@ from club.forms import DonationStep1Form from club.models import Club from annoy.models import DynamicTextInsert from pdcounter import views as pdcounter_views +from social.models import UserList from wolnelektury.utils import is_ajax from catalogue import constants from catalogue import forms @@ -211,26 +212,43 @@ class AudiobooksView(LiteratureView): class TaggedObjectList(BookList): def analyse(self): super().analyse() + self.ctx['tags'] = analyse_tags(self.request, self.kwargs['tags']) - self.ctx['fragment_tags'] = [t for t in self.ctx['tags'] if t.category in ('theme', 'object')] - self.ctx['work_tags'] = [t for t in self.ctx['tags'] if t not in self.ctx['fragment_tags']] + self.ctx.update({ + 'fragment_tags': [], + 'work_tags': [], + 'user_lists': [], + }) + for tag in self.ctx['tags']: + if isinstance(tag, UserList): + self.ctx['user_lists'].append(tag) + elif tag.category == 'theme': + self.ctx['fragment_tags'].append(tag) + else: + self.ctx['work_tags'].append(tag) + self.is_themed = self.ctx['has_theme'] = bool(self.ctx['fragment_tags']) if self.is_themed: self.ctx['main_tag'] = self.ctx['fragment_tags'][0] - elif self.ctx['tags']: - self.ctx['main_tag'] = self.ctx['tags'][0] + elif self.ctx['work_tags']: + self.ctx['main_tag'] = self.ctx['work_tags'][0] else: self.ctx['main_tag'] = None self.ctx['filtering_tags'] = [ t for t in self.ctx['tags'] if t is not self.ctx['main_tag'] ] - if len(self.ctx['tags']) == 1 and self.ctx['main_tag'].category == 'author': + if len(self.ctx['tags']) == 1 and self.ctx['main_tag'] is not None and self.ctx['main_tag'].category == 'author': self.ctx['translation_list'] = self.ctx['main_tag'].book_set.all() self.ctx['narrated'] = self.ctx['main_tag'].narrated.all() def get_queryset(self): - qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True) + if self.ctx['work_tags']: + qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True) + else: + qs = Book.objects.filter(findable=True) + for ul in self.ctx['user_lists']: + qs = qs.filter(id__in=[i.id for i in ul.get_books()]) qs = qs.exclude(ancestor__in=qs) if self.is_themed: fqs = Fragment.tagged.with_all(self.ctx['fragment_tags']) @@ -242,6 +260,9 @@ class TaggedObjectList(BookList): return qs def get_suggested_tags(self, queryset): + if self.ctx['user_lists']: + # TODO + return [] tag_ids = [t.id for t in self.ctx['tags']] if self.is_themed: related_tags = [] @@ -253,6 +274,7 @@ class TaggedObjectList(BookList): containing_books, ).exclude(category='set').exclude(pk__in=tag_ids) )) + ### FIXME: These won't be tags if self.request.user.is_authenticated: related_tags.extend(list( Tag.objects.usage_for_queryset( @@ -293,6 +315,7 @@ def object_list(request, objects, list_type='books'): Tag.objects.usage_for_queryset( objects, counts=True ).exclude(category='set')) + ### FIXME: these won't be tags if request.user.is_authenticated: related_tag_lists.append( Tag.objects.usage_for_queryset( @@ -310,7 +333,7 @@ def object_list(request, objects, list_type='books'): .only('name', 'sort_key', 'category', 'slug')) if isinstance(objects, QuerySet): objects = prefetch_relations(objects, 'author') - + categories = split_tags(*related_tag_lists) suggest = [] for c in ['set', 'author', 'epoch', 'kind', 'genre']: @@ -325,7 +348,7 @@ def object_list(request, objects, list_type='books'): } template = 'catalogue/author_detail.html' - + return render( request, template, result, ) diff --git a/src/lesmianator/models.py b/src/lesmianator/models.py index fbcee049c..bcf271ca2 100644 --- a/src/lesmianator/models.py +++ b/src/lesmianator/models.py @@ -16,7 +16,8 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.conf import settings from django.urls import reverse -from catalogue.models import Book, Tag +from catalogue.models import Book +from social.models import UserList class Poem(models.Model): @@ -138,17 +139,16 @@ class Continuations(models.Model): conts) @classmethod - def for_set(cls, tag): - books = Book.tagged_top_level([tag]) - cont_tabs = (cls.get(b) for b in books.iterator()) + def for_userlist(cls, ul): + cont_tabs = (cls.get(b) for b in ul.get_books()) return reduce(cls.join_conts, cont_tabs) @classmethod def get(cls, sth): object_type = ContentType.objects.get_for_model(sth) should_keys = {sth.id} - if isinstance(sth, Tag): - should_keys = set(b.pk for b in Book.tagged.with_any((sth,)).iterator()) + if isinstance(sth, UserList): + should_keys = set(b.pk for b in sth.get_books()) try: obj = cls.objects.get(content_type=object_type, object_id=sth.id) if not obj.pickle: @@ -162,8 +162,8 @@ class Continuations(models.Model): except cls.DoesNotExist: if isinstance(sth, Book): conts = cls.for_book(sth) - elif isinstance(sth, Tag): - conts = cls.for_set(sth) + elif isinstance(sth, UserList): + conts = cls.for_userlist(sth) else: raise NotImplementedError('Lesmianator continuations: only Book and Tag supported') diff --git a/src/lesmianator/views.py b/src/lesmianator/views.py index 938ba4c83..0efeba324 100644 --- a/src/lesmianator/views.py +++ b/src/lesmianator/views.py @@ -6,13 +6,14 @@ from django.shortcuts import render, get_object_or_404 from django.views.decorators import cache from catalogue.utils import get_random_hash -from catalogue.models import Book, Tag +from catalogue.models import Book +from social.models import UserList from lesmianator.models import Poem, Continuations def main_page(request): last = Poem.objects.all().order_by('-created_at')[:10] - shelves = Tag.objects.filter(user__username='lesmianator') + shelves = UserList.objects.filter(user__username='lesmianator') return render( request, @@ -50,10 +51,10 @@ def poem_from_book(request, slug): @cache.never_cache def poem_from_set(request, shelf): user = request.user if request.user.is_authenticated else None - tag = get_object_or_404(Tag, category='set', slug=shelf) + tag = get_object_or_404(UserList, slug=shelf) text = Poem.write(Continuations.get(tag)) p = Poem(slug=get_random_hash(text), text=text, created_by=user) - books = Book.tagged.with_any((tag,)) + books = tag.get_books() p.created_from = json.dumps([b.id for b in books]) p.save() diff --git a/src/opds/views.py b/src/opds/views.py index 38f34efd5..561eb9978 100644 --- a/src/opds/views.py +++ b/src/opds/views.py @@ -17,6 +17,7 @@ from django.utils.functional import lazy from basicauth import logged_in_or_basicauth, factory_decorator from catalogue.models import Book, Tag from search.utils import UnaccentSearchQuery, UnaccentSearchVector +from social.models import UserList import operator import logging @@ -318,7 +319,7 @@ class UserFeed(Feed): return "Półki użytkownika %s" % user.username def items(self, user): - return Tag.objects.filter(category='set', user=user).exclude(items=None) + return UserList.objects.filter(user=user, deleted=False) def item_title(self, item): return item.name @@ -343,10 +344,10 @@ class UserSetFeed(AcquisitionFeed): return "Spis utworów na stronie http://WolneLektury.pl" def get_object(self, request, slug): - return get_object_or_404(Tag, category='set', slug=slug, user=request.user) + return get_object_or_404(UserList, deleted=False, slug=slug, user=request.user) def items(self, tag): - return Book.tagged.with_any([tag]) + return tag.get_books() @piwik_track diff --git a/src/push/api/urls.py b/src/push/api/urls.py new file mode 100644 index 000000000..2920966b7 --- /dev/null +++ b/src/push/api/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import views + + +urlpatterns = [ + path('deviceTokens/', views.DeviceTokensView.as_view()), +] diff --git a/src/push/api/views.py b/src/push/api/views.py new file mode 100644 index 000000000..0c575b92e --- /dev/null +++ b/src/push/api/views.py @@ -0,0 +1,44 @@ +from rest_framework import serializers +from rest_framework.generics import ListCreateAPIView +from rest_framework.permissions import IsAuthenticated +from api.utils import never_cache +from api.fields import AbsoluteURLField +from push import models + + +class DeviceTokenSerializer(serializers.ModelSerializer): + deleted = serializers.BooleanField(default=False, write_only=True) + # Explicit definition to disable unique validator. + token = serializers.CharField() + + class Meta: + model = models.DeviceToken + fields = ['token', 'created_at', 'updated_at', 'deleted'] + read_only_fields = ['created_at', 'updated_at'] + + def save(self): + if self.validated_data['deleted']: + self.destroy(self.validated_data) + else: + return self.create(self.validated_data) + + def create(self, validated_data): + obj, created = models.DeviceToken.objects.get_or_create( + user=self.context['request'].user, + token=validated_data['token'], + ) + return obj + + def destroy(self, validated_data): + models.DeviceToken.objects.filter( + user=self.context['request'].user, + token=validated_data['token'] + ).delete() + +@never_cache +class DeviceTokensView(ListCreateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = DeviceTokenSerializer + + def get_queryset(self): + return models.DeviceToken.objects.filter(user=self.request.user) diff --git a/src/push/migrations/0005_devicetoken.py b/src/push/migrations/0005_devicetoken.py new file mode 100644 index 000000000..029051cf7 --- /dev/null +++ b/src/push/migrations/0005_devicetoken.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.8 on 2025-08-26 07:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('push', '0004_alter_notification_body_alter_notification_image_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='DeviceToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=1024)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/push/migrations/0006_alter_devicetoken_options_alter_devicetoken_token.py b/src/push/migrations/0006_alter_devicetoken_options_alter_devicetoken_token.py new file mode 100644 index 000000000..520404a96 --- /dev/null +++ b/src/push/migrations/0006_alter_devicetoken_options_alter_devicetoken_token.py @@ -0,0 +1,22 @@ +# Generated by Django 4.0.8 on 2025-09-03 12:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('push', '0005_devicetoken'), + ] + + operations = [ + migrations.AlterModelOptions( + name='devicetoken', + options={'ordering': ('-updated_at',)}, + ), + migrations.AlterField( + model_name='devicetoken', + name='token', + field=models.CharField(max_length=1024, unique=True), + ), + ] diff --git a/src/push/models.py b/src/push/models.py index 461845e77..3d86b9690 100644 --- a/src/push/models.py +++ b/src/push/models.py @@ -16,3 +16,13 @@ class Notification(models.Model): def __str__(self): return '%s: %s' % (self.timestamp, self.title) + + +class DeviceToken(models.Model): + user = models.ForeignKey('auth.User', models.CASCADE) + token = models.CharField(max_length=1024, unique=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ('-updated_at',) diff --git a/src/reporting/views.py b/src/reporting/views.py index 553371efe..eb647467d 100644 --- a/src/reporting/views.py +++ b/src/reporting/views.py @@ -60,7 +60,7 @@ def stats_page(request): ] etags.append(d) - unused_tags = Tag.objects.exclude(category='set').filter(items=None, book=None) + unused_tags = Tag.objects.filter(items=None, book=None) return render(request, 'reporting/main.html', { 'media_types': media_types, diff --git a/src/search/api/urls.py b/src/search/api/urls.py new file mode 100644 index 000000000..93ddf6d90 --- /dev/null +++ b/src/search/api/urls.py @@ -0,0 +1,13 @@ +# This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Wolne Lektury. See NOTICE for more information. +# +from django.urls import path +from . import views + + +urlpatterns = [ + path('search/hint/', views.HintView.as_view()), + path('search/', views.SearchView.as_view()), + path('search/books/', views.BookSearchView.as_view()), + path('search/text/', views.TextSearchView.as_view()), +] diff --git a/src/search/api/views.py b/src/search/api/views.py new file mode 100644 index 000000000..17642d135 --- /dev/null +++ b/src/search/api/views.py @@ -0,0 +1,92 @@ +# This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Wolne Lektury. See NOTICE for more information. +# +from rest_framework.generics import ListAPIView +from rest_framework.response import Response +from rest_framework import serializers +from rest_framework.views import APIView +import catalogue.models +import catalogue.api.serializers +from search.views import get_hints +from search.forms import SearchFilters + + +class HintView(APIView): + def get(self, request): + term = request.query_params.get('q') + hints = get_hints(term, request.user) + for h in hints: + if h.get('img'): + h['img'] = request.build_absolute_uri(h['img']) + return Response(hints) + + +class SearchView(APIView): + def get(self, request): + term = self.request.query_params.get('q') + f = SearchFilters({'q': term}) + if f.is_valid(): + r = f.results() + res = {} + rl = res['author'] = [] + c = {'request': request} + for item in r['author']: + rl.append( + catalogue.api.serializers.AuthorSerializer(item, context=c).data + ) + rl = res['genre'] = [] + for item in r['genre']: + rl.append( + catalogue.api.serializers.GenreSerializer(item, context=c).data + ) + rl = res['theme'] = [] + for item in r['theme']: + rl.append( + catalogue.api.serializers.ThemeSerializer(item, context=c).data + ) + + return Response(res) + + +class BookSearchView(ListAPIView): + serializer_class = catalogue.api.serializers.BookSerializer2 + + def get_queryset(self): + term = self.request.query_params.get('q') + f = SearchFilters({'q': term}) + if f.is_valid(): + r = f.results() + return r['book'] + return [] + + + +class SnippetSerializer(serializers.ModelSerializer): + anchor = serializers.CharField(source='sec') + headline = serializers.CharField() + + class Meta: + model = catalogue.models.Snippet + fields = ['anchor', 'headline'] + + +class BookSnippetsSerializer(serializers.Serializer): + book = catalogue.api.serializers.BookSerializer2() + snippets = SnippetSerializer(many=True) + + +class TextSearchView(ListAPIView): + serializer_class = BookSnippetsSerializer + + def get_queryset(self): + term = self.request.query_params.get('q') + f = SearchFilters({'q': term}) + if f.is_valid(): + r = f.results() + r = list({ + 'book': book, + 'snippets': snippets + } for (book, snippets) in r['snippet'].items()) + return r + return [] + diff --git a/src/search/views.py b/src/search/views.py index e439c4128..6066cd97a 100644 --- a/src/search/views.py +++ b/src/search/views.py @@ -9,6 +9,7 @@ from sorl.thumbnail import get_thumbnail import catalogue.models import infopages.models +import social.models from .forms import SearchFilters import re import json @@ -23,22 +24,8 @@ def remove_query_syntax_chars(query, replace=' '): return query_syntax_chars.sub(replace, query) -@cache.never_cache -def hint(request, mozhint=False, param='term'): - prefix = request.GET.get(param, '') - if len(prefix) < 2: - return JsonResponse([], safe=False) - - prefix = re_escape(' '.join(remove_query_syntax_chars(prefix).split())) - - try: - limit = int(request.GET.get('max', '')) - except ValueError: - limit = 20 - else: - if limit < 1: - limit = 20 - +def get_hints(prefix, user=None, limit=10): + if not prefix: return [] data = [] if len(data) < limit: authors = catalogue.models.Tag.objects.filter( @@ -49,17 +36,21 @@ def hint(request, mozhint=False, param='term'): 'label': author.name, 'url': author.get_absolute_url(), 'img': get_thumbnail(author.photo, '72x72', crop='top').url if author.photo else '', + 'slug': author.slug, + 'id': author.id, } for author in authors[:limit - len(data)] ]) - if request.user.is_authenticated and len(data) < limit: - tags = catalogue.models.Tag.objects.filter( - category='set', user=request.user, name_pl__iregex='\m' + prefix).only('name', 'id', 'slug', 'category') + + if user is not None and user.is_authenticated and len(data) < limit: + tags = social.models.UserList.objects.filter( + user=user, name__iregex='\m' + prefix).only('name', 'id', 'slug') data.extend([ { - 'type': 'set', + 'type': 'userlist', 'label': tag.name, 'url': tag.get_absolute_url(), + 'slug': tag.slug, } for tag in tags[:limit - len(data)] ]) @@ -71,6 +62,8 @@ def hint(request, mozhint=False, param='term'): 'type': tag.category, 'label': tag.name, 'url': tag.get_absolute_url(), + 'slug': tag.slug, + 'id': tag.id, } for tag in tags[:limit - len(data)] ]) @@ -82,6 +75,7 @@ def hint(request, mozhint=False, param='term'): 'type': 'collection', 'label': collection.title, 'url': collection.get_absolute_url(), + 'slug': collection.slug, } for collection in collections[:limit - len(data)] ]) @@ -98,6 +92,7 @@ def hint(request, mozhint=False, param='term'): 'author': author_str, 'url': b.get_absolute_url(), 'img': get_thumbnail(b.cover_clean, '72x72').url if b.cover_clean else '', + 'slug': b.slug, } ) if len(data) < limit: @@ -110,9 +105,34 @@ def hint(request, mozhint=False, param='term'): 'type': 'info', 'label': info.title, 'url': info.get_absolute_url(), + 'slug': info.slug, } for info in infos[:limit - len(data)] ]) + return data + + +@cache.never_cache +def hint(request, mozhint=False, param='term'): + prefix = request.GET.get(param, '') + if len(prefix) < 2: + return JsonResponse([], safe=False) + + prefix = re_escape(' '.join(remove_query_syntax_chars(prefix).split())) + + try: + limit = int(request.GET.get('max', '')) + except ValueError: + limit = 20 + else: + if limit < 1: + limit = 20 + + data = get_hints( + prefix, + user=request.user if request.user.is_authenticated else None, + limit=limit + ) if mozhint: data = [ diff --git a/src/social/admin.py b/src/social/admin.py index ac5fbd250..9d1ee4565 100644 --- a/src/social/admin.py +++ b/src/social/admin.py @@ -6,7 +6,7 @@ from django.forms import ModelForm from django.forms.widgets import TextInput from admin_ordering.admin import OrderableAdmin from social.models import Cite, BannerGroup, Carousel, CarouselItem - +from social import models class CiteForm(ModelForm): class Meta: @@ -79,3 +79,5 @@ class CarouselAdmin(admin.ModelAdmin): admin.site.register(Carousel, CarouselAdmin) + +admin.site.register(models.UserList) diff --git a/src/social/api/urls2.py b/src/social/api/urls2.py index b150e6183..3863ec622 100644 --- a/src/social/api/urls2.py +++ b/src/social/api/urls2.py @@ -7,11 +7,27 @@ from . import views urlpatterns = [ + path('settings/', views.SettingsView.as_view()), + path('like//', piwik_track_view(views.LikeView2.as_view()), 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()), + + path('sync/progress/', views.ProgressSyncView.as_view()), + path('sync/userlist/', views.UserListSyncView.as_view()), + path('sync/userlistitem/', views.UserListItemSyncView.as_view()), + path('sync/bookmark/', views.BookmarkSyncView.as_view()), ] diff --git a/src/social/api/views.py b/src/social/api/views.py index 22a0e9c52..9d8fd4a59 100644 --- a/src/social/api/views.py +++ b/src/social/api/views.py @@ -1,22 +1,43 @@ # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later. # Copyright © Fundacja Wolne Lektury. See NOTICE for more information. # +from datetime import datetime +from django.db.models import Q from django.http import Http404 -from rest_framework.generics import ListAPIView, get_object_or_404 -from rest_framework.permissions import IsAuthenticated +from django.utils.timezone import now, utc +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 +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.views import get_sets_for_book_ids +from social.utils import likes +from social import models +import bookmarks.models +from bookmarks.api.views import BookmarkSerializer -@vary_on_auth +class SettingsSerializer(serializers.ModelSerializer): + class Meta: + model = models.UserProfile + fields = ['notifications'] + + +class SettingsView(RetrieveUpdateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = SettingsSerializer + + def get_object(self): + return models.UserProfile.get_for(self.request.user) + + +@never_cache class LikeView(APIView): permission_classes = [IsAuthenticated] @@ -28,13 +49,13 @@ class LikeView(APIView): book = get_object_or_404(Book, slug=slug) action = request.query_params.get('action', 'like') if action == 'like': - book.like(request.user) + models.UserList.like(request.user, book) elif action == 'unlike': - book.unlike(request.user) + models.UserList.unlike(request.user, book) return Response({}) -@vary_on_auth +@never_cache class LikeView2(APIView): permission_classes = [IsAuthenticated] @@ -44,16 +65,16 @@ class LikeView2(APIView): def put(self, request, slug): book = get_object_or_404(Book, slug=slug) - book.like(request.user) + models.UserList.like(request.user, book) return Response({"likes": likes(request.user, book)}) def delete(self, request, slug): book = get_object_or_404(Book, slug=slug) - book.unlike(request.user) + models.UserList.unlike(request.user, book) return Response({"likes": likes(request.user, book)}) -@vary_on_auth +@never_cache class LikesView(APIView): permission_classes = [IsAuthenticated] @@ -64,21 +85,184 @@ 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] def get(self, request): - ids = catalogue.models.tag.TagRelation.objects.filter(tag__user=request.user).values_list('object_id', flat=True).distinct() - books = Book.objects.filter(id__in=ids) - 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()} - return Response(res) + ul = models.UserList.get_favorites_list(request.user) + if ul is None: + return Response([]) + return Response( + ul.userlistitem_set.exclude(deleted=True).exclude(book=None).values_list('book__slug', flat=True) + ) + + +class UserListItemsField(serializers.Field): + def to_representation(self, value): + return value.userlistitem_set.exclude(deleted=True).exclude(book=None).values_list('book__slug', flat=True) + + def to_internal_value(self, value): + return {'books': catalogue.models.Book.objects.filter(slug__in=value)} + + +class UserListSerializer(serializers.ModelSerializer): + client_id = serializers.CharField(write_only=True, required=False) + books = UserListItemsField(source='*', required=False) + timestamp = serializers.IntegerField(required=False) + + class Meta: + model = models.UserList + fields = [ + 'timestamp', + 'client_id', + 'name', + 'slug', + 'favorites', + 'deleted', + 'books', + ] + read_only_fields = ['favorites'] + extra_kwargs = { + 'slug': { + 'required': False + } + } + + def create(self, validated_data): + instance = models.UserList.get_by_name( + validated_data['user'], + validated_data['name'], + create=True + ) + instance.userlistitem_set.all().delete() + for book in validated_data['books']: + instance.append(book) + return instance + + def update(self, instance, validated_data): + instance.userlistitem_set.all().delete() + for book in validated_data['books']: + instance.append(instance) + return instance + +class UserListBooksSerializer(UserListSerializer): + class Meta: + model = models.UserList + fields = ['books'] + + +class UserListItemSerializer(serializers.ModelSerializer): + client_id = serializers.CharField(write_only=True, required=False) + favorites = serializers.BooleanField(required=False) + list_slug = serializers.SlugRelatedField( + queryset=models.UserList.objects.all(), + source='list', + slug_field='slug', + required=False, + ) + timestamp = serializers.IntegerField(required=False) + book_slug = serializers.SlugRelatedField( + queryset=Book.objects.all(), + source='book', + slug_field='slug', + required=False + ) + + class Meta: + model = models.UserListItem + fields = [ + 'client_id', + 'uuid', + 'order', + 'list_slug', + 'timestamp', + 'favorites', + 'deleted', + + 'book_slug', + 'fragment', + 'quote', + 'bookmark', + 'note', + ] + extra_kwargs = { + 'order': { + 'required': False + } + } + + +@never_cache +class ListsView(ListCreateAPIView): + permission_classes = [IsAuthenticated] + #pagination_class = None + serializer_class = UserListSerializer + + def get_queryset(self): + return models.UserList.objects.filter( + user=self.request.user, + favorites=False, + deleted=False + ) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +@never_cache +class ListView(RetrieveUpdateDestroyAPIView): + # TODO: check if can modify + permission_classes = [IsAuthenticatedOrReadOnly] + serializer_class = UserListSerializer + + 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, + 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']: + instance.append(book) + return Response(self.get_serializer(instance).data) + + def perform_destroy(self, instance): + instance.deleted = True + instance.updated_at = now() + instance.save() + + +@never_cache +class ListItemView(APIView): + permission_classes = [IsAuthenticated] + def delete(self, request, slug, book): + instance = get_object_or_404( + 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) @vary_on_auth @@ -95,7 +279,7 @@ class ShelfView(ListAPIView): after = self.request.query_params.get('after') count = int(self.request.query_params.get('count', 50)) if state == 'likes': - books = Book.tagged.with_any(self.request.user.tag_set.all()) + books = Book.objects.filter(userlistitem__list__user=self.request.user) else: ids = BookUserData.objects.filter(user=self.request.user, complete=state == 'complete')\ .values_list('book_id', flat=True) @@ -108,3 +292,206 @@ 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( + queryset=Book.objects.all(), + source='book', + slug_field='slug') + timestamp = serializers.IntegerField(required=False) + + class Meta: + model = models.Progress + fields = [ + 'timestamp', + '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', + ] + extra_kwargs = { + 'last_mode': { + 'required': False, + 'default': 'text', + } + } + + +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() + + + +@never_cache +class SyncView(ListAPIView): + permission_classes = [IsAuthenticated] + sync_id_field = 'slug' + sync_id_serializer_field = 'slug' + sync_user_field = 'user' + + def get_queryset(self): + try: + timestamp = int(self.request.GET.get('ts')) + except: + timestamp = 0 + + timestamp = datetime.fromtimestamp(timestamp, tz=utc) + + data = [] + return self.get_queryset_for_ts(timestamp) + + def get_queryset_for_ts(self, timestamp): + return self.model.objects.filter( + updated_at__gt=timestamp, + **{ + self.sync_user_field: self.request.user + } + ).order_by('updated_at') + + def get_instance(self, user, data): + sync_id = data.get(self.sync_id_serializer_field) + if not sync_id: + return None + return self.model.objects.filter(**{ + self.sync_user_field: user, + self.sync_id_field: sync_id + }).first() + + def post(self, request): + new_ids = [] + data = request.data + if not isinstance(data, list): + raise serializers.ValidationError('Payload should be a list') + for item in data: + instance = self.get_instance(request.user, item) + ser = self.get_serializer( + instance=instance, + data=item + ) + ser.is_valid(raise_exception=True) + synced_instance = self.model.sync( + request.user, + instance, + ser.validated_data + ) + if instance is None and 'client_id' in ser.validated_data and synced_instance is not None: + new_ids.append({ + 'client_id': ser.validated_data['client_id'], + self.sync_id_serializer_field: getattr(synced_instance, self.sync_id_field), + }) + return Response(new_ids) + + +class ProgressSyncView(SyncView): + model = models.Progress + serializer_class = ProgressSerializer + + sync_id_field = 'book__slug' + sync_id_serializer_field = 'book_slug' + + +class UserListSyncView(SyncView): + model = models.UserList + serializer_class = UserListSerializer + + +class UserListItemSyncView(SyncView): + model = models.UserListItem + serializer_class = UserListItemSerializer + + sync_id_field = 'uuid' + sync_id_serializer_field = 'uuid' + sync_user_field = 'list__user' + + def get_queryset_for_ts(self, timestamp): + qs = self.model.objects.filter( + updated_at__gt=timestamp, + **{ + self.sync_user_field: self.request.user + } + ) + if self.request.query_params.get('favorites'): + qs = qs.filter(list__favorites=True) + return qs.order_by('updated_at') + + +class BookmarkSyncView(SyncView): + model = bookmarks.models.Bookmark + serializer_class = BookmarkSerializer + + sync_id_field = 'uuid' + sync_id_serializer_field = 'uuid' + + def get_instance(self, user, data): + ret = super().get_instance(user, data) + if ret is None: + if data.get('location'): + ret = self.model.get_by_location(user, data['location']) + return ret diff --git a/src/social/forms.py b/src/social/forms.py index f82e27c43..4d39f0812 100644 --- a/src/social/forms.py +++ b/src/social/forms.py @@ -3,16 +3,8 @@ # from django import forms -from catalogue.models import Book, Tag -from social.utils import get_set - - -class UserSetsForm(forms.Form): - def __init__(self, book, user, *args, **kwargs): - super(UserSetsForm, self).__init__(*args, **kwargs) - self.fields['set_ids'] = forms.ChoiceField( - choices=[(tag.id, tag.name) for tag in Tag.objects.filter(category='set', user=user).iterator()], - ) +from catalogue.models import Book +from . import models class AddSetForm(forms.Form): @@ -22,18 +14,18 @@ class AddSetForm(forms.Form): def save(self, user): name = self.cleaned_data['name'].strip() if not name: return - tag = get_set(user, name) + ul = models.UserList.get_by_name(user, name, create=True) try: book = Book.objects.get(id=self.cleaned_data['book']) except Book.DoesNotExist: return try: - book.tag_relations.create(tag=tag) + ul.append(book=book) except: pass - return book, tag + return book, ul class RemoveSetForm(forms.Form): @@ -43,8 +35,8 @@ class RemoveSetForm(forms.Form): def save(self, user): slug = self.cleaned_data['slug'] try: - tag = Tag.objects.get(user=user, slug=slug) - except Tag.DoesNotExist: + ul = models.UserList.objects.get(user=user, slug=slug) + except models.UserList.DoesNotExist: return try: book = Book.objects.get(id=self.cleaned_data['book']) @@ -52,8 +44,8 @@ class RemoveSetForm(forms.Form): return try: - book.tag_relations.filter(tag=tag).delete() + ul.userlistitem_set.filter(book=book).delete() except: pass - return book, tag + return book, ul 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/migrations/0019_progress_deleted.py b/src/social/migrations/0019_progress_deleted.py new file mode 100644 index 000000000..71b1fab53 --- /dev/null +++ b/src/social/migrations/0019_progress_deleted.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.8 on 2025-07-08 14:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('social', '0018_progress'), + ] + + operations = [ + migrations.AddField( + model_name='progress', + name='deleted', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/social/migrations/0020_userlist_userlistitem.py b/src/social/migrations/0020_userlist_userlistitem.py new file mode 100644 index 000000000..f97f833e1 --- /dev/null +++ b/src/social/migrations/0020_userlist_userlistitem.py @@ -0,0 +1,48 @@ +# Generated by Django 4.0.8 on 2025-07-14 13:39 + +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), + ('bookmarks', '0002_quote'), + ('social', '0019_progress_deleted'), + ] + + operations = [ + migrations.CreateModel( + name='UserList', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(unique=True)), + ('name', models.CharField(max_length=1024)), + ('favorites', models.BooleanField(default=False)), + ('public', models.BooleanField(default=False)), + ('deleted', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserListItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.IntegerField()), + ('deleted', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField()), + ('note', models.TextField(blank=True)), + ('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='catalogue.book')), + ('bookmark', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='bookmarks.bookmark')), + ('fragment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='catalogue.fragment')), + ('list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='social.userlist')), + ('quote', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='bookmarks.quote')), + ], + ), + ] diff --git a/src/social/migrations/0021_move_sets.py b/src/social/migrations/0021_move_sets.py new file mode 100644 index 000000000..64e9ccc59 --- /dev/null +++ b/src/social/migrations/0021_move_sets.py @@ -0,0 +1,56 @@ +# Generated by Django 4.0.8 on 2025-07-14 13:40 + +from django.db import migrations +from django.utils.timezone import now + + +def move_sets_to_userlists(apps, schema_editor): + UserList = apps.get_model('social', 'UserList') + UserListItem = apps.get_model('social', 'UserListItem') + Tag = apps.get_model('catalogue', 'Tag') + + for tag in Tag.objects.filter(category='set'): + print() + print(tag) + ul = UserList.objects.create( + slug=tag.slug, + user=tag.user, + name=tag.name, + favorites=not tag.name, + public=not tag.name, + created_at=tag.created_at, + updated_at=tag.changed_at, + ) + + for i, item in enumerate(tag.items.all()): + #assert item.content_type_id == 12, item.content_type_id + print(item) + ul.userlistitem_set.create( + order=i + 1, + created_at=ul.updated_at, + updated_at=ul.updated_at, + book_id=item.object_id + ) + + tag.delete() + + +def rollback_userlists_to_sets(apps, schema_editor): + UserList = apps.get_model('social', 'UserList') + UserListItem = apps.get_model('social', 'UserListItem') + Tag = apps.get_model('catalogue', 'Tag') + + +class Migration(migrations.Migration): + + dependencies = [ + ('social', '0020_userlist_userlistitem'), + ('catalogue', '0001_initial'), + ] + + operations = [ + migrations.RunPython( + move_sets_to_userlists, + rollback_userlists_to_sets + ) + ] diff --git a/src/social/migrations/0022_userlist_reported_timestamp_and_more.py b/src/social/migrations/0022_userlist_reported_timestamp_and_more.py new file mode 100644 index 000000000..2e21005f9 --- /dev/null +++ b/src/social/migrations/0022_userlist_reported_timestamp_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.0.8 on 2025-07-22 13:09 + +from django.db import migrations, models +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('social', '0021_move_sets'), + ] + + operations = [ + migrations.AddField( + model_name='userlist', + name='reported_timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='userlistitem', + name='reported_timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='userlistitem', + name='uuid', + field=models.UUIDField(editable=False, null=True), + ), + migrations.AlterField( + model_name='userlist', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + migrations.AlterField( + model_name='userlistitem', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/src/social/migrations/0023_auto_20250722_1513.py b/src/social/migrations/0023_auto_20250722_1513.py new file mode 100644 index 000000000..a7120e85b --- /dev/null +++ b/src/social/migrations/0023_auto_20250722_1513.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.8 on 2025-07-22 13:13 + +from django.db import migrations, transaction +import uuid + + +def gen_uuid(apps, schema_editor): + UserListItem = apps.get_model("social", "UserListItem") + while UserListItem.objects.filter(uuid__isnull=True).exists(): + print(UserListItem.objects.filter(uuid__isnull=True).count(), 'rows left') + with transaction.atomic(): + for row in UserListItem.objects.filter(uuid__isnull=True)[:1000]: + row.uuid = uuid.uuid4() + row.save(update_fields=["uuid"]) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('social', '0022_userlist_reported_timestamp_and_more'), + ] + + operations = [ + migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), + ] diff --git a/src/social/migrations/0024_auto_20250722_1513.py b/src/social/migrations/0024_auto_20250722_1513.py new file mode 100644 index 000000000..c0a8b0c1d --- /dev/null +++ b/src/social/migrations/0024_auto_20250722_1513.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.8 on 2025-07-22 13:13 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('social', '0023_auto_20250722_1513'), + ] + + operations = [ + migrations.AlterField( + model_name='userlistitem', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/src/social/migrations/0025_progress_reported_timestamp_alter_userlistitem_uuid.py b/src/social/migrations/0025_progress_reported_timestamp_alter_userlistitem_uuid.py new file mode 100644 index 000000000..56923c0ac --- /dev/null +++ b/src/social/migrations/0025_progress_reported_timestamp_alter_userlistitem_uuid.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.8 on 2025-07-29 12:44 + +from django.db import migrations, models +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('social', '0024_auto_20250722_1513'), + ] + + operations = [ + migrations.AddField( + model_name='progress', + name='reported_timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AlterField( + model_name='userlistitem', + name='uuid', + field=models.UUIDField(blank=True, default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/src/social/migrations/0026_userprofile.py b/src/social/migrations/0026_userprofile.py new file mode 100644 index 000000000..d5e8c5a22 --- /dev/null +++ b/src/social/migrations/0026_userprofile.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0.8 on 2025-08-22 13:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('social', '0025_progress_reported_timestamp_alter_userlistitem_uuid'), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('notifications', models.BooleanField(default=False)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/social/models.py b/src/social/models.py index 17fe7d07a..862db4cdd 100644 --- a/src/social/models.py +++ b/src/social/models.py @@ -1,6 +1,8 @@ # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later. # Copyright © Fundacja Wolne Lektury. See NOTICE for more information. # +from datetime import datetime +import uuid from oauthlib.common import urlencode, generate_token from random import randint from django.db import models @@ -9,8 +11,11 @@ from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.mail import send_mail from django.urls import reverse +from django.utils.timezone import now, utc from catalogue.models import Book +from catalogue.utils import get_random_hash from wolnelektury.utils import cached_render, clear_cached_renders +from .syncable import Syncable class BannerGroup(models.Model): @@ -175,6 +180,16 @@ class CarouselItem(models.Model): return self.banner or self.banner_group.get_banner() +class UserProfile(models.Model): + user = models.OneToOneField(User, models.CASCADE) + notifications = models.BooleanField(default=False) + + @classmethod + def get_for(cls, user): + obj, created = cls.objects.get_or_create(user=user) + return obj + + class UserConfirmation(models.Model): user = models.ForeignKey(User, models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) @@ -200,3 +215,228 @@ class UserConfirmation(models.Model): user=user, key=generate_token() ).send() + + +class Progress(Syncable, 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) + reported_timestamp = models.DateTimeField() + deleted = models.BooleanField(default=False) + 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) + + syncable_fields = [ + 'deleted', + 'last_mode', 'text_anchor', 'audio_timestamp' + ] + + class Meta: + unique_together = [('user', 'book')] + + @property + def timestamp(self): + return self.updated_at.timestamp() + + @classmethod + def create_from_data(cls, user, data): + return cls.objects.create( + user=user, + book=data['book'], + reported_timestamp=now(), + ) + + def save(self, *args, **kwargs): + try: + audio_l = self.book.get_audio_length() + except: + audio_l = 60 + 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) + + +class UserList(Syncable, models.Model): + slug = models.SlugField(unique=True) + user = models.ForeignKey(User, models.CASCADE) + name = models.CharField(max_length=1024) + favorites = models.BooleanField(default=False) + public = models.BooleanField(default=False) + deleted = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + reported_timestamp = models.DateTimeField() + + syncable_fields = ['name', 'public', 'deleted'] + + def get_absolute_url(self): + return reverse( + 'tagged_object_list', + args=[f'polka/{self.slug}'] + ) + + def __str__(self): + return self.name + + @property + def url_chunk(self): + return f'polka/{self.slug}' + + @classmethod + def create_from_data(cls, user, data): + return cls.create(user, data['name']) + + @classmethod + def create(cls, user, name): + n = now() + return cls.objects.create( + user=user, + name=name, + slug=get_random_hash(name), + updated_at=n, + reported_timestamp=n, + ) + + @classmethod + def get_by_name(cls, user, name, create=False): + l = cls.objects.filter( + user=user, + name=name + ).first() + if l is None and create: + l = cls.create(user, name) + return l + + @classmethod + def get_favorites_list(cls, user, create=False): + try: + return cls.objects.get( + user=user, + favorites=True + ) + except cls.DoesNotExist: + n = now() + if create: + return cls.objects.create( + user=user, + favorites=True, + slug=get_random_hash('favorites'), + updated_at=n, + reported_timestamp=n, + ) + else: + return None + except cls.MultipleObjectsReturned: + # merge? + lists = list(cls.objects.filter(user=user, favorites=True)) + for l in lists[1:]: + t.userlistitem_set.all().update( + list=lists[0] + ) + l.delete() + return lists[0] + + @classmethod + def likes(cls, user, book): + ls = cls.get_favorites_list(user) + if ls is None: + return False + return ls.userlistitem_set.filter(deleted=False, book=book).exists() + + def append(self, book): + n = now() + items = self.userlistitem_set.filter( + book=book, + ) + if items.exists(): + items.update( + deleted=False, + reported_timestamp=n, + ) + item = items.first() + else: + item = self.userlistitem_set.create( + book=book, + order=(self.userlistitem_set.aggregate(m=models.Max('order'))['m'] or 0) + 1, + updated_at=n, + reported_timestamp=n, + ) + book.update_popularity() + return item + + def remove(self, book): + self.userlistitem_set.filter(book=book).update( + deleted=True, + updated_at=now() + ) + book.update_popularity() + + @classmethod + def like(cls, user, book): + ul = cls.get_favorites_list(user, create=True) + ul.append(book) + + @classmethod + def unlike(cls, user, book): + ul = cls.get_favorites_list(user) + if ul is not None: + ul.remove(book) + + def get_books(self): + return [item.book for item in self.userlistitem_set.exclude(deleted=True).exclude(book=None)] + + +class UserListItem(Syncable, models.Model): + list = models.ForeignKey(UserList, models.CASCADE) + uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False, blank=True) + order = models.IntegerField() + deleted = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + reported_timestamp = models.DateTimeField() + + book = models.ForeignKey('catalogue.Book', models.SET_NULL, null=True, blank=True) + fragment = models.ForeignKey('catalogue.Fragment', models.SET_NULL, null=True, blank=True) + quote = models.ForeignKey('bookmarks.Quote', models.SET_NULL, null=True, blank=True) + bookmark = models.ForeignKey('bookmarks.Bookmark', models.SET_NULL, null=True, blank=True) + + note = models.TextField(blank=True) + + syncable_fields = ['order', 'deleted', 'book', 'fragment', 'quote', 'bookmark', 'note'] + + @classmethod + def create_from_data(cls, user, data): + if data.get('favorites'): + l = UserList.get_favorites_list(user, create=True) + else: + l = data['list'] + try: + assert l.user == user + except AssertionError: + return + return l.append(book=data['book']) + + @property + def favorites(self): + return self.list.favorites diff --git a/src/social/syncable.py b/src/social/syncable.py new file mode 100644 index 000000000..6447f344b --- /dev/null +++ b/src/social/syncable.py @@ -0,0 +1,39 @@ +from datetime import datetime +from django.utils.timezone import now, utc + + +class Syncable: + @classmethod + def sync(cls, user, instance, data): + ts = data.get('timestamp') + if ts is None: + ts = now() + else: + ts = datetime.fromtimestamp(ts, tz=utc) + + if instance is not None: + if ts and ts < instance.reported_timestamp: + return + + if instance is None: + if data.get('deleted'): + return + instance = cls.create_from_data(user, data) + if instance is None: + return + + instance.reported_timestamp = ts + for f in cls.syncable_fields: + if f in data: + setattr(instance, f, data[f]) + + instance.save() + return instance + + @property + def timestamp(self): + return self.updated_at.timestamp() + + @classmethod + def create_from_data(cls, user, data): + raise NotImplementedError diff --git a/src/social/templates/social/shelf_tags.html b/src/social/templates/social/shelf_tags.html deleted file mode 100644 index 47e0b0a17..000000000 --- a/src/social/templates/social/shelf_tags.html +++ /dev/null @@ -1,7 +0,0 @@ -{% spaceless %} - -{% endspaceless %} \ No newline at end of file diff --git a/src/social/templatetags/social_tags.py b/src/social/templatetags/social_tags.py index a4b0f3ed2..ae7c5a094 100644 --- a/src/social/templatetags/social_tags.py +++ b/src/social/templatetags/social_tags.py @@ -3,7 +3,6 @@ # import re from django import template -from django.utils.functional import lazy from django.utils.cache import add_never_cache_headers from catalogue.models import Book, Fragment from social.utils import likes, get_or_choose_cite, choose_cite as cs @@ -32,25 +31,6 @@ def choose_cites(number, book=None, author=None): return Fragment.tagged.with_all([author]).order_by('?')[:number] -@register.simple_tag(takes_context=True) -def book_shelf_tags(context, book_id): - request = context['request'] - if not request.user.is_authenticated: - return '' - book = Book.objects.get(pk=book_id) - lks = likes(request.user, book, request) - - def get_value(): - if not lks: - return '' - tags = book.tags.filter(category='set', user=request.user).exclude(name='') - if not tags: - return '' - ctx = {'tags': tags} - return template.loader.render_to_string('social/shelf_tags.html', ctx) - return lazy(get_value, str)() - - @register.inclusion_tag('social/carousel.html', takes_context=True) def carousel(context, placement): banners = Carousel.get(placement).carouselitem_set.all()#first().get_banner() diff --git a/src/social/utils.py b/src/social/utils.py index 6ab303abf..eb16507c5 100644 --- a/src/social/utils.py +++ b/src/social/utils.py @@ -7,10 +7,9 @@ from random import randint from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.functional import lazy -from catalogue.models import Book, Tag -from catalogue import utils -from catalogue.tasks import touch_tag +from catalogue.models import Book from social.models import Cite +from social import models def likes(user, work, request=None): @@ -18,7 +17,7 @@ def likes(user, work, request=None): return False if request is None: - return work.tags.filter(category='set', user=user).exists() + return models.UserList.likes(user, work) if not hasattr(request, 'social_likes'): # tuple: unchecked, checked, liked @@ -35,54 +34,17 @@ def likes(user, work, request=None): if likes_t[0]: ids = tuple(likes_t[0]) likes_t[0].clear() - likes_t[2].update(Tag.intermediary_table_model.objects.filter( - content_type_id=ct.pk, tag__user_id=user.pk, - object_id__in=ids - ).distinct().values_list('object_id', flat=True)) + ls = models.UserList.get_favorites_list(user) + if ls is None: + return False + likes_t[2].update( + ls.userlistitem_set.filter(deleted=False).filter( + book_id__in=ids).values_list('book_id', flat=True)) likes_t[1].update(ids) return work.pk in likes_t[2] return lazy(_likes, bool)() -def get_set(user, name): - """Returns a tag for use by the user. Creates it, if necessary.""" - try: - tag = Tag.objects.get(category='set', user=user, name=name) - except Tag.DoesNotExist: - tag = Tag.objects.create( - category='set', user=user, name=name, slug=utils.get_random_hash(name), sort_key=name.lower()) - except Tag.MultipleObjectsReturned: - # fix duplicated noname shelf - tags = list(Tag.objects.filter(category='set', user=user, name=name)) - tag = tags[0] - for other_tag in tags[1:]: - for item in other_tag.items.all(): - Tag.objects.remove_tag(item, other_tag) - Tag.objects.add_tag(item, tag) - other_tag.delete() - return tag - - -def set_sets(user, work, sets): - """Set tags used for given work by a given user.""" - - old_sets = list(work.tags.filter(category='set', user=user)) - - work.tags = sets + list( - work.tags.filter(~Q(category='set') | ~Q(user=user))) - - for shelf in [shelf for shelf in old_sets if shelf not in sets]: - touch_tag(shelf) - for shelf in [shelf for shelf in sets if shelf not in old_sets]: - touch_tag(shelf) - - # delete empty tags - Tag.objects.filter(category='set', user=user, items=None).delete() - - if isinstance(work, Book): - work.update_popularity() - - def cites_for_tags(tags): """Returns a QuerySet with all Cites for books with given tags.""" return Cite.objects.filter(book__in=Book.tagged.with_all(tags)) diff --git a/src/social/views.py b/src/social/views.py index 8f27b8746..0ff077104 100644 --- a/src/social/views.py +++ b/src/social/views.py @@ -8,8 +8,7 @@ from django.views.decorators.cache import never_cache from django.views.decorators.http import require_POST from django.views.generic.edit import FormView -from catalogue.models import Book, Tag -import catalogue.models.tag +from catalogue.models import Book from social import forms, models from wolnelektury.utils import is_ajax @@ -26,7 +25,7 @@ def like_book(request, slug): if request.method != 'POST': return redirect(book) - book.like(request.user) + models.UserList.like(request.user, book) if is_ajax(request): return JsonResponse({"success": True, "msg": "ok", "like": True}) @@ -58,7 +57,7 @@ def unlike_book(request, slug): if request.method != 'POST': return redirect(book) - book.unlike(request.user) + models.UserList.unlike(request.user, book) if is_ajax(request): return JsonResponse({"success": True, "msg": "ok", "like": False}) @@ -69,32 +68,29 @@ def unlike_book(request, slug): @login_required def my_shelf(request): template_name = 'social/my_shelf.html' - tags = list(request.user.tag_set.all()) - suggest = [t for t in tags if t.name] - print(suggest) + ulists = list(request.user.userlist_set.all()) + suggest = [t for t in ulists if t.name] return render(request, template_name, { - 'tags': tags, - 'books': Book.tagged.with_any(tags), + 'tags': ulists, + 'books': Book.objects_filter(userlistitem__list__user=request.user), 'suggest': suggest, }) def get_sets_for_book_ids(book_ids, user): data = {} - tagged = catalogue.models.tag.TagRelation.objects.filter( - tag__user=user, - #content_type= # for books, - object_id__in=book_ids - ).order_by('tag__sort_key') + tagged = models.UserListItem.objects.filter( + list__user=user, + book_id__in=book_ids + ).order_by('list__name') for t in tagged: - # related? - item = data.setdefault(t.object_id, []) - if t.tag.name: + item = data.setdefault(t.book_id, []) + if t.list.name: item.append({ - "slug": t.tag.slug, - "url": t.tag.get_absolute_url(), - "name": t.tag.name, + "slug": t.list.slug, + "url": t.list.get_absolute_url(), + "name": t.list.name, }) for b in book_ids: if b not in data: @@ -119,12 +115,12 @@ def my_liked(request): @login_required def my_tags(request): term = request.GET.get('term', '') - tags = Tag.objects.filter(user=request.user).order_by('sort_key') + tags = models.UserList.objects.filter(user=request.user).order_by('name') if term: - tags = tags.filter(name__icontains=term) + ulists = tags.filter(name__icontains=term) return JsonResponse( [ - t.name for t in tags + ul.name for ul in ulists ], safe=False )