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
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('', include('catalogue.api.urls2')),
path('', include('social.api.urls2')),
+ path('', include('bookmarks.api.urls')),
+ path('', include('search.api.urls')),
+ path('', include('push.api.urls')),
]
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):
vary_on_auth = method_decorator(vary_on_headers('Authorization'), 'dispatch')
+never_cache = method_decorator(django.views.decorators.cache.never_cache, 'dispatch')
class HttpResponseAppRedirect(HttpResponseRedirect):
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({})
--- /dev/null
+from django.urls import path
+from . import views
+
+
+urlpatterns = [
+ path('bookmarks/', views.BookmarksView.as_view()),
+ path('bookmarks/book/<slug:book>/', views.BookBookmarksView.as_view()),
+ path('bookmarks/<uuid:uuid>/', views.BookmarkView.as_view(), name='api_bookmark'),
+]
--- /dev/null
+from api.utils import never_cache
+
+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 IsAuthenticated
+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 = [IsAuthenticated]
+ serializer_class = BookmarkSerializer
+ lookup_field = 'uuid'
+
+ def get_queryset(self):
+ return self.request.user.bookmark_set.all()
--- /dev/null
+# 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),
+ ),
+ ]
--- /dev/null
+# 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),
+ ),
+ ]
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'
+ if audio_l:
+ self.audio_timestamp = audio_l * .4
+ if self.audio_timestamp:
+ self.mode = 'audio'
+ if self.audio_timestamp > audio_l:
+ self.audio_timestamp = audio_l
+ if audio_l:
+ self.anchor = 'f20'
+ 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 {
class Meta:
model = Tag
fields = [
- 'url', 'href', 'name'
+ 'url', 'href', 'name', 'slug'
]
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',
]
)
class Meta:
model = Tag
- fields = ['url', 'href', 'name']
+ fields = ['url', 'href', 'name', 'slug']
class EpochSerializer(EpochItemSerializer):
class Meta:
)
class Meta:
model = Tag
- fields = ['url', 'href', 'name']
+ fields = ['url', 'href', 'name', 'slug']
class GenreSerializer(GenreItemSerializer):
class Meta:
)
class Meta:
model = Tag
- fields = ['url', 'href', 'name']
+ fields = ['url', 'href', 'name', 'slug']
class KindSerializer(KindItemSerializer):
class Meta:
'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='*')
class FilterTagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
- fields = ['id', 'category', 'name']
+ fields = ['id', 'category', 'name', 'slug']
--- /dev/null
+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': ('a', 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),
+}
+
+
+#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('"', '”')
+ 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']
+ 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,
+ '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))
path('books/<slug:slug>/media/<slug:type>/',
views.BookMediaView.as_view()
),
+ path('books/<slug:slug>.json',
+ views.BookJsonView.as_view()),
path('suggested-tags/',
piwik_track_view(views.SuggestedTags.as_view()),
path('genres/<slug:slug>/',
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/<slug:slug>/',
+ piwik_track_view(views.ThemeView.as_view()),
+ name='catalogue_api_theme'),
]
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
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):
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]
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})
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)
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
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 unlike(self, user):
- from social.utils import likes, set_sets
- if likes(user, self):
- set_sets(user, self, [])
-
def full_sort_key(self):
return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
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:
@staticmethod
def get_tag_list(tag_str):
+ from social.models import UserList
+
if not tag_str:
return []
tags = []
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]
meta_tags.append((tag, relationship))
return meta_tags
+# def get_books(self):
+# """ Only useful for sets. """
+# return
+
+
TagRelation.tag_model = Tag
+++ /dev/null
-{% 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 %}
- <div class="{% block box-class %}book-box{% if audiobooks %} audiobook-box{% endif %}{% endblock %}">
- <div class="book-box-inner">
-
- {% with book.tags_by_category as tags %}
- <div class="book-left-column">
- <div class="book-box-body">
- {% block book-box-body-pre %}
- {% endblock %}
-
- <div class="cover-area">
- {% if book.cover_clean %}
- <a href="{% block cover-link %}{{ book.get_absolute_url }}{% endblock %}">
- <img src="{% thumbnail book.cover_clean '139x193' as th %}{{ th.url }}{% endthumbnail %}" alt="Cover" class="cover" />
- </a>
- {% endif %}
- {% block cover-area-extra %}{% endblock %}
- </div>
-
- {% get_current_language as LANGUAGE_CODE %}
- {% cache 86400 book_box_head_tags book.pk LANGUAGE_CODE %}
- <div class="book-box-head">
- <div class="author">
- {% for tag in tags.author %}
- <a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a>{% if not forloop.last %},
- {% endif %}{% endfor %}{% for parent in book.parents %},
- <a href="{{ parent.get_absolute_url }}">{{ parent.title }}</a>{% endfor %}
- </div>
- <div class="title">
- <a href="{{ book.get_absolute_url }}">{{ book.title }}</a>
- </div>
- {% if book.translator %}
- <div class="author">
- tłum. {{ book.translator }}
- </div>
- {% endif %}
- </div>
-
- <div class="tags">
- <span class="category">
- <span class="mono"> {% trans "Epoka" %}:</span> <span class="book-box-tag">
- {% for tag in tags.epoch %}
- <a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a>
- {% if not forloop.last %}<span>, </span>{% endif %}
- {% endfor %}
- </span></span>
-
- <span class="category">
- <span class="mono"> {% trans "Rodzaj" %}:</span> <span class="book-box-tag">
- {% for tag in tags.kind %}
- <a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a>
- {% if not forloop.last %}<span>, </span>{% endif %}
- {% endfor %}
- </span></span>
-
- <span class="category">
- <span class="mono"> {% trans "Gatunek" %}:</span> <span class="book-box-tag">
- {% for tag in tags.genre %}
- <a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a>
- {% if not forloop.last %}<span>, </span>{% endif %}
- {% endfor %}
- </span></span>
-
- {% with extra_info=book.get_extra_info_json %}
- {% if extra_info.location %}
- <span class="category">
- <span class="mono"> {% trans "Region" %}:</span> <span class="book-box-tag">
- {{ extra_info.location }}
- </span></span>
- {% endif %}
- {% endwith %}
-
- {% if book.is_foreign %}
- <span class="category">
- <span class="mono"> {% trans "Język" %}:</span> <span class="book-box-tag">
- <a>{{ book.language_name }}</a>
- </span>
- </span>
- {% endif %}
-
- {% with stage_note=book.stage_note %}
- {% if stage_note.0 %}
- <br>
- <span class="category">
- <a{% if stage_note.1 %} href="{{ stage_note.1 }}"{% endif %}>{{ stage_note.0 }}</a>
- </span>
- {% endif %}
- {% endwith %}
- </div>
- {% endcache %}
- </div>
- {% book_shelf_tags book.pk %}
-
- {% cache 86400 book_box_tools book.pk book|status:request.user LANGUAGE_CODE %}
- {% if book|status:request.user != 'closed' %}
- <ul class="book-box-tools">
- <li class="book-box-read">
- {% if book.html_file %}
- <div>{% content_warning book %}</div>
- <a href="{% url 'book_text' book.slug %}" class="downarrow">{% trans "Czytaj online" %}</a>
- {% endif %}
- {% if book.print_on_demand %}
- <a href="{{ book.ridero_link }}" class="downarrow print tlite-tooltip" title="{% trans "Cena książki w druku cyfrowym jest zależna od liczby stron.<br>Przed zakupem upewnij się, że cena druku na żądanie jest dla Ciebie odpowiednia.<br>Wszystkie nasze zasoby w wersji elektronicznej są zawsze dostępne bezpłatnie." %}">{% trans "Druk na żądanie z" %}
- <img src="{% static 'img/ridero.png' %}" style="height: 0.8em;"/></a>
- {% endif %}
- </li>
- <li class="book-box-download">
- <div class="book-box-formats">
- {% trans "Pobierz ebook" %}:<br>
- {% if book.pdf_file %}
- <a href="{{ book.pdf_url}}">PDF</a>
- {% endif %}
- {% if book.epub_file %}
- <a href="{{ book.epub_url}}">EPUB</a>
- {% endif %}
- {% if book.mobi_file %}
- <a href="{{ book.mobi_url}}">MOBI</a>
- {% endif %}
- {% if book.fb2_file %}
- <a href="{{ book.fb2_url}}">FB2</a>
- {% endif %}
- {% if book.txt_file %}
- <a href="{{ book.txt_url}}">TXT</a>
- {% endif %}
- </div>
- {% if book.has_mp3_file %}
- <div class="book-box-formats">
- {% trans "Pobierz audiobook" %}:<br>
- {% download_audio book %}
- </div>
- {% endif %}
- <div class="book-box-formats">
- {% custom_pdf_link_li book %}
- </div>
- </li>
- </ul>
- {% else %}
- {% block preview-info %}
- <p class="book-box-tools book-box-tools-warn">
- Ten utwór jest na razie dostępny wyłącznie dla naszych Darczyńców.
- <a href="{% url 'club_join' %}">Wspieraj Wolne Lektury</a>
- </p>
- <div>{% content_warning book %}</div>
- {% endblock %}
- {% endif %}
- {% endcache %}
- {% block book-box-extra-info %}{% endblock %}
- {% block box-append %}{% endblock %}
- </div>
- {% endwith %}
-
- {% if book.abstract %}
- <div class="abstract more-expand">
- {{ book.abstract|safe }}
- </div>
- {% endif %}
-
- <div class="clearboth"></div>
- </div>
- </div>
- {% endwith %}
- {% endwith %}
-{% endspaceless %}
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()
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)
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
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'])
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 = []
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(
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(
.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']:
}
template = 'catalogue/author_detail.html'
-
+
return render(
request, template, result,
)
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):
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:
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')
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,
@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()
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
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
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
--- /dev/null
+from django.urls import path
+from . import views
+
+
+urlpatterns = [
+ path('deviceTokens/', views.DeviceTokensView.as_view()),
+]
--- /dev/null
+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)
--- /dev/null
+# 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)),
+ ],
+ ),
+ ]
--- /dev/null
+# 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),
+ ),
+ ]
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',)
]
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,
--- /dev/null
+# 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()),
+]
--- /dev/null
+# 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 []
+
import catalogue.models
import infopages.models
+import social.models
from .forms import SearchFilters
import re
import json
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(
'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)]
])
'type': tag.category,
'label': tag.name,
'url': tag.get_absolute_url(),
+ 'slug': tag.slug,
+ 'id': tag.id,
}
for tag in tags[:limit - len(data)]
])
'type': 'collection',
'label': collection.title,
'url': collection.get_absolute_url(),
+ 'slug': collection.slug,
}
for collection in collections[:limit - len(data)]
])
'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:
'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 = [
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:
admin.site.register(Carousel, CarouselAdmin)
+
+admin.site.register(models.UserList)
urlpatterns = [
+ path('settings/', views.SettingsView.as_view()),
+
path('like/<slug:slug>/',
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/<slug:slug>/', views.ListView.as_view()),
+ path('lists/<slug:slug>/<slug:book>/', views.ListItemView.as_view()),
+
+ path('progress/', views.ProgressListView.as_view()),
+ path('progress/<slug:slug>/', views.ProgressView.as_view()),
+ path('progress/<slug:slug>/text/', views.TextProgressView.as_view()),
+ path('progress/<slug:slug>/audio/', views.AudioProgressView.as_view()),
+
+ 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()),
]
# 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 pytz import utc
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
+from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveAPIView, RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView, get_object_or_404
+from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from rest_framework.response import Response
+from rest_framework import serializers
from rest_framework.views import APIView
from api.models import BookUserData
-from api.utils import vary_on_auth
+from api.utils import vary_on_auth, never_cache
from catalogue.api.helpers import order_books, books_after
from catalogue.api.serializers import BookSerializer
from catalogue.models import Book
import catalogue.models
-from social.utils import likes
from social.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]
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]
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]
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 = [IsAuthenticated]
+ serializer_class = UserListSerializer
+
+ def get_object(self):
+ 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.update(
+ deleted=True,
+ updated_at=now()
+ )
+
+
+@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
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)
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
#
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):
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):
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'])
return
try:
- book.tag_relations.filter(tag=tag).delete()
+ ul.userlistitem_set.filter(book=book).delete()
except:
pass
- return book, tag
+ return book, ul
--- /dev/null
+# 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')},
+ },
+ ),
+ ]
--- /dev/null
+# 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),
+ ),
+ ]
--- /dev/null
+# 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')),
+ ],
+ ),
+ ]
--- /dev/null
+# 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
+ )
+ ]
--- /dev/null
+# 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),
+ ),
+ ]
--- /dev/null
+# 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),
+ ]
--- /dev/null
+# 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),
+ ),
+ ]
--- /dev/null
+# 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),
+ ),
+ ]
--- /dev/null
+# 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)),
+ ],
+ ),
+ ]
# 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 pytz import utc
from random import randint
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from django.urls import reverse
+from django.utils.timezone import now
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):
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)
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):
+ # TODO: check for duplicates?
+ n = now()
+ 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
--- /dev/null
+from datetime import datetime
+from django.utils.timezone import now
+from pytz import 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
+++ /dev/null
-{% spaceless %}
- <ul class='social-shelf-tags'>
- {% for tag in tags %}
- <li><a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a></li>
- {% endfor %}
- </ul>
-{% endspaceless %}
\ No newline at end of file
#
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
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()
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):
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
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)
+ 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))
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
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})
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})
@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:
@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
)