From 9dc1452931f801d6ad00d1238531769b3887a820 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Tue, 15 Jul 2025 15:49:20 +0200 Subject: [PATCH] Move sets to user lists. --- src/catalogue/models/book.py | 13 +- src/catalogue/models/tag.py | 7 +- .../templates/catalogue/book_short.html | 172 ------------------ src/catalogue/templatetags/catalogue_tags.py | 6 +- src/catalogue/views.py | 39 +++- src/lesmianator/models.py | 16 +- src/lesmianator/views.py | 9 +- src/opds/views.py | 7 +- src/reporting/views.py | 2 +- src/search/views.py | 5 +- src/social/admin.py | 4 +- src/social/api/views.py | 77 ++++---- src/social/forms.py | 26 +-- .../migrations/0020_userlist_userlistitem.py | 48 +++++ src/social/migrations/0021_move_sets.py | 56 ++++++ src/social/models.py | 123 +++++++++++++ src/social/templates/social/shelf_tags.html | 7 - src/social/templatetags/social_tags.py | 20 -- src/social/utils.py | 54 +----- src/social/views.py | 42 ++--- 20 files changed, 375 insertions(+), 358 deletions(-) delete mode 100644 src/catalogue/templates/catalogue/book_short.html create mode 100644 src/social/migrations/0020_userlist_userlistitem.py create mode 100644 src/social/migrations/0021_move_sets.py delete mode 100644 src/social/templates/social/shelf_tags.html diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py index 15b1d2fe0..0f53baf0c 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -993,7 +993,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 @@ -1004,17 +1004,6 @@ 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 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))) diff --git a/src/catalogue/models/tag.py b/src/catalogue/models/tag.py index 07a43f45e..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] 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/templatetags/catalogue_tags.py b/src/catalogue/templatetags/catalogue_tags.py index d2298d9f5..ddf1a8fba 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) diff --git a/src/catalogue/views.py b/src/catalogue/views.py index 3c4a55fe9..a56286de7 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,25 +212,42 @@ 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() 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']) @@ -241,6 +259,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 = [] @@ -252,6 +273,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( @@ -292,6 +314,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( @@ -309,7 +332,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']: @@ -324,7 +347,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/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/views.py b/src/search/views.py index e439c4128..459190d28 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 @@ -53,8 +54,8 @@ def hint(request, mozhint=False, param='term'): 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') + tags = social.models.UserList.objects.filter( + user=request.user, name__iregex='\m' + prefix).only('name', 'id', 'slug') data.extend([ { 'type': 'set', 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/views.py b/src/social/api/views.py index c58efd1c9..4f4937b9b 100644 --- a/src/social/api/views.py +++ b/src/social/api/views.py @@ -4,7 +4,8 @@ from datetime import datetime from pytz import utc from django.http import Http404 -from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveAPIView, RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView, DestroyAPIView, get_object_or_404 +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 @@ -15,7 +16,6 @@ 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, get_set from social.views import get_sets_for_book_ids from social import models @@ -32,9 +32,9 @@ 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({}) @@ -48,12 +48,12 @@ 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)}) @@ -77,49 +77,50 @@ 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()} - - res = list(books.values()) - res.sort() - 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 TaggedBooksField(serializers.Field): +class UserListItemsField(serializers.Field): def to_representation(self, value): - return catalogue.models.Book.tagged.with_all([value]).values_list('slug', flat=True) + 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): - books = TaggedBooksField(source='*') + books = UserListItemsField(source='*') class Meta: - model = catalogue.models.Tag + model = models.UserList fields = ['name', 'slug', 'books'] read_only_fields = ['slug'] def create(self, validated_data): - instance = get_set(validated_data['user'], validated_data['name']) - catalogue.models.tag.TagRelation.objects.filter(tag=instance).delete() + 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']: - catalogue.models.Tag.objects.add_tag(book, instance) + instance.append(book) return instance def update(self, instance, validated_data): - catalogue.models.tag.TagRelation.objects.filter(tag=instance).delete() + instance.userlistitem_set.all().delete() for book in validated_data['books']: - catalogue.models.Tag.objects.add_tag(book, instance) + instance.append(instance) return instance class UserListBooksSerializer(UserListSerializer): class Meta: - model = catalogue.models.Tag + model = models.UserList fields = ['books'] @@ -130,7 +131,11 @@ class ListsView(ListCreateAPIView): serializer_class = UserListSerializer def get_queryset(self): - return catalogue.models.Tag.objects.filter(user=self.request.user).exclude(name='') + return models.UserList.objects.filter( + user=self.request.user, + favorites=False, + deleted=False + ) def perform_create(self, serializer): serializer.save(user=self.request.user) @@ -143,7 +148,10 @@ class ListView(RetrieveUpdateDestroyAPIView): serializer_class = UserListSerializer def get_object(self): - return get_object_or_404(catalogue.models.Tag, slug=self.kwargs['slug'], user=self.request.user) + 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) @@ -153,18 +161,25 @@ class ListView(RetrieveUpdateDestroyAPIView): serializer.is_valid(raise_exception=True) instance = self.get_object() for book in serializer.validated_data['books']: - catalogue.models.Tag.objects.add_tag(book, instance) + 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(catalogue.models.Tag, slug=slug, user=self.request.user) + instance = get_object_or_404( + models.UserList, slug=slug, user=self.request.user) book = get_object_or_404(catalogue.models.Book, slug=book) - catalogue.models.Tag.objects.remove_tag(book, instance) + instance.remove(book=book) return Response(UserListSerializer(instance).data) @@ -182,7 +197,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) 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/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/models.py b/src/social/models.py index c63879b5f..dc9a09a1b 100644 --- a/src/social/models.py +++ b/src/social/models.py @@ -9,6 +9,7 @@ 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 from catalogue.models import Book from wolnelektury.utils import cached_render, clear_cached_renders @@ -252,3 +253,125 @@ class Progress(models.Model): self.implicit_text_percent = 60 self.implicit_text_anchor = 'f20' return super().save(*args, **kwargs) + + +class UserList(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() + + 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(cls, user, name): + return cls.objects.create( + user=user, + name=name, + slug=get_random_hash(name), + updated_at=now() + ) + + @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: + if create: + return cls.objects.create( + user=user, + favorites=True, + slug=get_random_hash(name), + updated_at=now() + ) + 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? + self.userlistitem_set.create( + book=book, + order=self.userlistitem_set.aggregate(m=models.Max('order'))['m'] + 1, + updated_at=now(), + ) + book.update_popularity() + + 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(models.Model): + list = models.ForeignKey(UserList, models.CASCADE) + order = models.IntegerField() + deleted = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = 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) 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..f4d944501 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,15 @@ 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) + 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 3dfcd9e2b..e3d60ca93 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 @@ -25,7 +24,7 @@ def like_book(request, slug): return HttpResponseForbidden('Login required.') book = get_object_or_404(Book, slug=slug) - book.like(request.user) + models.UserList.like(request.user, book) if is_ajax(request): return JsonResponse({"success": True, "msg": "ok", "like": True}) @@ -56,7 +55,7 @@ def unlike_book(request, slug): return HttpResponseForbidden('Login required.') book = get_object_or_404(Book, slug=slug) - book.unlike(request.user) + models.UserList.unlike(request.user, book) if is_ajax(request): return JsonResponse({"success": True, "msg": "ok", "like": False}) @@ -67,32 +66,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: @@ -117,12 +113,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 ) -- 2.20.1