Move sets to user lists.
authorRadek Czajka <rczajka@rczajka.pl>
Tue, 15 Jul 2025 13:49:20 +0000 (15:49 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Tue, 15 Jul 2025 13:49:20 +0000 (15:49 +0200)
20 files changed:
src/catalogue/models/book.py
src/catalogue/models/tag.py
src/catalogue/templates/catalogue/book_short.html [deleted file]
src/catalogue/templatetags/catalogue_tags.py
src/catalogue/views.py
src/lesmianator/models.py
src/lesmianator/views.py
src/opds/views.py
src/reporting/views.py
src/search/views.py
src/social/admin.py
src/social/api/views.py
src/social/forms.py
src/social/migrations/0020_userlist_userlistitem.py [new file with mode: 0644]
src/social/migrations/0021_move_sets.py [new file with mode: 0644]
src/social/models.py
src/social/templates/social/shelf_tags.html [deleted file]
src/social/templatetags/social_tags.py
src/social/utils.py
src/social/views.py

index 15b1d2f..0f53baf 100644 (file)
@@ -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)))
 
index 07a43f4..fb90a7b 100644 (file)
@@ -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 (file)
index 190bdc8..0000000
+++ /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 %}
-  <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>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<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 %}
index d2298d9..ddf1a8f 100644 (file)
@@ -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)
index 3c4a55f..a56286d 100644 (file)
@@ -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,
     )
index fbcee04..bcf271c 100644 (file)
@@ -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')
 
index 938ba4c..0efeba3 100644 (file)
@@ -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()
 
index 38f34ef..561eb99 100644 (file)
@@ -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
index 553371e..eb64746 100644 (file)
@@ -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,
index e439c41..459190d 100644 (file)
@@ -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',
index ac5fbd2..9d1ee45 100644 (file)
@@ -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)
index c58efd1..4f4937b 100644 (file)
@@ -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)
index f82e27c..4d39f08 100644 (file)
@@ -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 (file)
index 0000000..f97f833
--- /dev/null
@@ -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 (file)
index 0000000..64e9ccc
--- /dev/null
@@ -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
+        )
+    ]
index c63879b..dc9a09a 100644 (file)
@@ -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 (file)
index 47e0b0a..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-{% 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
index a4b0f3e..ae7c5a0 100644 (file)
@@ -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()
index 6ab303a..f4d9445 100644 (file)
@@ -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))
index 3dfcd9e..e3d60ca 100644 (file)
@@ -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
     )