0bca7d81457c06175f0973b8524b02e4eee5eb2e
[wolnelektury.git] / src / catalogue / views.py
1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
3 #
4 from collections import OrderedDict
5 import random
6 import re
7 from urllib.parse import quote_plus
8
9 from django.conf import settings
10 from django.template.loader import render_to_string
11 from django.shortcuts import get_object_or_404, render, redirect
12 from django.http import HttpResponse, HttpResponseRedirect, Http404, HttpResponsePermanentRedirect
13 from django.urls import reverse
14 from django.db.models import Q, QuerySet
15 from django.contrib.auth.decorators import login_required, user_passes_test
16 from django.utils import translation
17 from django.utils.translation import gettext as _, gettext_lazy
18 from django.views.decorators.cache import never_cache
19 from django.views.generic import TemplateView
20
21 from ajaxable.utils import AjaxableFormView
22 from club.forms import ScheduleForm, DonationStep1Form
23 from club.models import Club
24 from annoy.models import DynamicTextInsert
25 from pdcounter import views as pdcounter_views
26 from picture.models import Picture, PictureArea
27 from wolnelektury.utils import is_ajax
28 from catalogue import constants
29 from catalogue import forms
30 from catalogue.helpers import get_top_level_related_tags
31 from catalogue.models import Book, Collection, Tag, Fragment
32 from catalogue.models.tag import TagRelation
33 from catalogue.utils import split_tags
34 from catalogue.models.tag import prefetch_relations
35
36 staff_required = user_passes_test(lambda user: user.is_staff)
37
38
39 def catalogue(request):
40     return render(request, 'catalogue/catalogue.html', {
41         'books': Book.objects.filter(findable=True, parent=None),
42         'pictures': Picture.objects.all(),
43         'collections': Collection.objects.filter(listed=True),
44         'active_menu_item': 'all_works',
45     })
46
47
48 def book_list(request, filters=None, template_name='catalogue/book_list.html',
49               nav_template_name='catalogue/snippets/book_list_nav.html',
50               list_template_name='catalogue/snippets/book_list.html'):
51     """ generates a listing of all books, optionally filtered """
52     books_by_author, orphans, books_by_parent = Book.book_list(filters)
53     books_nav = OrderedDict()
54     for tag in books_by_author:
55         if books_by_author[tag]:
56             books_nav.setdefault(tag.sort_key[0], []).append(tag)
57     return render(request, template_name, {
58         'rendered_nav': render_to_string(nav_template_name, {'books_nav': books_nav}),
59         'rendered_book_list': render_to_string(list_template_name, {
60             'books_by_author': books_by_author,
61             'orphans': orphans,
62             'books_by_parent': books_by_parent,
63         })
64     })
65
66
67 def daisy_list(request):
68     if request.EXPERIMENTS['layout'].value:
69         return object_list(request, Book.objects.filter(media__type='daisy'))
70     return book_list(request, Q(media__type='daisy'), template_name='catalogue/daisy_list.html')
71
72
73 def collection(request, slug):
74     coll = get_object_or_404(Collection, slug=slug)
75     if request.EXPERIMENTS['layout'].value:
76         template_name = 'catalogue/2022/collection.html'
77     else:
78         template_name = 'catalogue/collection.html'
79     return render(request, template_name, {
80         'collection': coll,
81         'active_menu_item': 'collections',
82     })
83
84
85 def differentiate_tags(request, tags, ambiguous_slugs):
86     beginning = '/'.join(tag.url_chunk for tag in tags)
87     unparsed = '/'.join(ambiguous_slugs[1:])
88     options = []
89     for tag in Tag.objects.filter(slug=ambiguous_slugs[0]):
90         options.append({
91             'url_args': '/'.join((beginning, tag.url_chunk, unparsed)).strip('/'),
92             'tags': [tag]
93         })
94     return render(
95         request,
96         'catalogue/differentiate_tags.html',
97         {'tags': tags, 'options': options, 'unparsed': ambiguous_slugs[1:]}
98     )
99
100
101 from django.db.models import FilteredRelation, Q
102 from django.views.decorators.cache import cache_control
103 from django.views.decorators.vary import vary_on_headers
104 from django.utils.decorators import method_decorator
105
106
107 @method_decorator([
108     vary_on_headers('X-Requested-With'),
109     cache_control(max_age=1),
110 ], 'dispatch')
111 class ObjectListView(TemplateView):
112     page_size = 100
113     themed_page_size = 10
114     item_template_name = ''
115     orderings = {}
116     default_ordering = None
117
118     def analyse(self):
119         self.is_themed = False
120         self.ctx = ctx = {}
121         ctx['tags'] = []        
122
123     def dispatch(self, *args, **kwargs):
124         try:
125             self.analyse()
126         except ResponseInstead as e:
127             return e.response
128         return super().dispatch(*args, **kwargs)
129         
130     def get_orderings(self):
131         order = self.get_order()
132         return [
133             {
134                 "slug": k,
135                 "name": v[1],
136                 "active": k == order,
137                 "default": v[0] is None,
138             }
139             for k, v in self.orderings.items()
140         ]
141
142     def get_order(self):
143         order = self.request.GET.get('order')
144         if order not in self.orderings:
145             order = self.default_ordering
146         return order
147
148     def order(self, qs):
149         order_tag = self.get_order()
150         if order_tag:
151             order = self.orderings[order_tag]
152             order_by = order[0]
153             if order_by:
154                 qs = qs.order_by(order_by)
155         return qs
156
157     def search(self, qs):
158         return qs
159
160     def get_template_names(self):
161         if is_ajax(self.request) or self.request.GET.get('dyn'):
162             if self.is_themed:
163                 return self.dynamic_themed_template_name
164             else:
165                 return self.dynamic_template_name
166         else:
167             if self.is_themed:
168                 return self.themed_template_name
169             else:
170                 return self.template_name
171
172     def get_context_data(self, **kwargs):
173         ctx = super().get_context_data()
174         ctx.update(self.ctx)
175         qs = self.get_queryset()
176         qs = self.search(qs)
177         qs = self.order(qs)
178
179         ctx['object_list'] = qs
180         ctx['suggested_tags'] = self.get_suggested_tags(qs)
181         ctx['suggested_tags_by_category'] = split_tags(ctx['suggested_tags'])
182         return ctx
183
184
185 class BookList(ObjectListView):
186     title = gettext_lazy('Literature')
187     list_type = 'books'
188     template_name = 'catalogue/2022/book_list.html'
189     dynamic_template_name = 'catalogue/2022/dynamic_book_list.html'
190     themed_template_name = 'catalogue/2022/themed_book_list.html'
191     dynamic_themed_template_name = 'catalogue/2022/dynamic_themed_book_list.html'
192
193     orderings = {
194         'pop': ('-popularity__count', 'najpopularniejsze'),
195         'alpha': (None, 'alfabetycznie'),
196     }
197     default_ordering = 'alpha'
198
199     def get_queryset(self):
200         return Book.objects.filter(parent=None, findable=True)
201
202     def search(self, qs):
203         term = self.request.GET.get('search')
204         if term:
205             meta_rels = TagRelation.objects.exclude(tag__category='set')
206             # TODO: search tags in currently displaying language
207             if self.is_themed:
208                 #qs = qs.annotate(
209                 #    meta=FilteredRelation('book__tag_relations', condition=Q(tag_relations__in=meta_rels))
210                 #)
211                 qs = qs.filter(
212                     Q(book__title__icontains=term) |
213                     #Q(meta__tag_relations__tag__name_pl__icontains=term) |
214                     Q(text__icontains=term)
215                 ).distinct()
216             else:
217                 qs = qs.annotate(
218                     meta=FilteredRelation('tag_relations', condition=Q(tag_relations__in=meta_rels))
219                 )
220                 qs = qs.filter(Q(title__icontains=term) | Q(meta__tag__name_pl__icontains=term)).distinct()
221         return qs
222
223
224 class ArtList(ObjectListView):
225     template_name = 'catalogue/2022/book_list.html'
226     dynamic_template_name = 'catalogue/2022/dynamic_book_list.html'
227     title = gettext_lazy('Art')
228     list_type = 'gallery'
229
230     def get_queryset(self):
231         return Picture.objects.all()
232
233     def search(self, qs):
234         term = self.request.GET.get('search')
235         if term:
236             qs = qs.filter(Q(title__icontains=term) | Q(tag_relations__tag__name_pl__icontains=term)).distinct()
237         return qs
238     
239
240 class LiteratureView(BookList):
241     def get_suggested_tags(self, queryset):
242         tags = list(get_top_level_related_tags([]))
243         tags.sort(key=lambda t: -t.count)
244         if self.request.user.is_authenticated:
245             tags.extend(list(Tag.objects.filter(user=self.request.user).exclude(name='')))
246         return tags
247
248
249 class AudiobooksView(LiteratureView):
250     title = gettext_lazy('Audiobooks')
251     list_type = 'audiobooks'
252
253     def get_queryset(self):
254         return Book.objects.filter(findable=True, media__type='mp3').distinct()
255
256
257 class GalleryView(ArtList):
258     def get_suggested_tags(self, queryset):
259         return Tag.objects.usage_for_queryset(
260             queryset,
261             counts=True
262         ).exclude(pk__in=[t.id for t in self.ctx['tags']]).order_by('-count')
263     
264
265 class TaggedObjectList(BookList):
266     def analyse(self):
267         super().analyse()
268         self.ctx['tags'] = analyse_tags(self.request, self.kwargs['tags'])
269         self.ctx['fragment_tags'] = [t for t in self.ctx['tags'] if t.category in ('theme', 'object')]
270         self.ctx['work_tags'] = [t for t in self.ctx['tags'] if t not in self.ctx['fragment_tags']]
271         self.is_themed = self.ctx['has_theme'] = bool(self.ctx['fragment_tags'])
272         if self.is_themed:
273             self.ctx['main_tag'] = self.ctx['fragment_tags'][0]
274         elif self.ctx['tags']:
275             self.ctx['main_tag'] = self.ctx['tags'][0]
276         else:
277             self.ctx['main_tag'] = None
278         self.ctx['filtering_tags'] = [
279             t for t in self.ctx['tags']
280             if t is not self.ctx['main_tag']
281         ]
282
283     def get_queryset(self):
284         qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True)
285         qs = qs.exclude(ancestor__in=qs)
286         if self.is_themed:
287             qs = Fragment.tagged.with_all(self.ctx['fragment_tags']).filter(
288                 Q(book__in=qs) | Q(book__ancestor__in=qs)
289             )
290         return qs
291
292     def get_suggested_tags(self, queryset):
293         tag_ids = [t.id for t in self.ctx['tags']]
294         related_tags = list(get_top_level_related_tags(self.ctx['tags']))
295         if not self.is_themed:
296             if self.request.user.is_authenticated:
297                 qs = Book.tagged.with_all(self.ctx['tags']).filter(findable=True)
298                 related_tags.extend(list(
299                     Tag.objects.usage_for_queryset(
300                         qs
301                     ).filter(
302                         user=self.request.user
303                     ).exclude(name='').exclude(pk__in=tag_ids)
304                 ))
305
306             fragments = Fragment.objects.filter(
307                 Q(book__in=queryset) | Q(book__ancestor__in=queryset)
308             )
309             related_tags.extend(
310                 Tag.objects.usage_for_queryset(
311                     fragments, counts=True
312                 ).filter(category__in=('theme', 'object')).exclude(pk__in=tag_ids)
313             .only('name', 'sort_key', 'category', 'slug'))
314
315         return related_tags
316
317     
318     
319 def object_list(request, objects, fragments=None, related_tags=None, tags=None,
320                 list_type='books', extra=None):
321     if not tags:
322         tags = []
323     tag_ids = [tag.pk for tag in tags]
324
325     related_tag_lists = []
326     if related_tags:
327         related_tag_lists.append(related_tags)
328     else:
329         related_tag_lists.append(
330             Tag.objects.usage_for_queryset(
331                 objects, counts=True
332             ).exclude(category='set').exclude(pk__in=tag_ids))
333         if request.user.is_authenticated:
334             related_tag_lists.append(
335                 Tag.objects.usage_for_queryset(
336                     objects, counts=True
337                 ).filter(
338                     user=request.user
339                 ).exclude(name='').exclude(pk__in=tag_ids)
340             )
341     if not (extra and extra.get('theme_is_set')):
342         if fragments is None:
343             if list_type == 'gallery':
344                 fragments = PictureArea.objects.filter(picture__in=objects)
345             else:
346                 fragments = Fragment.objects.filter(book__in=objects)
347         related_tag_lists.append(
348             Tag.objects.usage_for_queryset(
349                 fragments, counts=True
350             ).filter(category='theme').exclude(pk__in=tag_ids)
351             .only('name', 'sort_key', 'category', 'slug'))
352         if isinstance(objects, QuerySet):
353             objects = prefetch_relations(objects, 'author')
354
355     
356     categories = split_tags(*related_tag_lists)
357     suggest = []
358     for c in ['set', 'author', 'epoch', 'kind', 'genre']:
359         suggest.extend(sorted(categories[c], key=lambda t: -t.count))
360
361     objects = list(objects)
362
363     if not objects and len(tags) == 1 and list_type == 'books':
364         if PictureArea.tagged.with_any(tags).exists() or Picture.tagged.with_any(tags).exists():
365             return redirect('tagged_object_list_gallery', '/'.join(tag.url_chunk for tag in tags))
366
367     if len(objects) > 3:
368         best = random.sample(objects, 3)
369     else:
370         best = objects
371
372     result = {
373         'object_list': objects,
374         'categories': categories,
375         'suggest': suggest,
376         'list_type': list_type,
377         'tags': tags,
378         'main_tag': tags[0] if tags else None,
379
380         'formats_form': forms.DownloadFormatsForm(),
381         'best': best,
382         'active_menu_item': list_type,
383     }
384     if extra:
385         result.update(extra)
386
387     if request.EXPERIMENTS['layout'].value:
388         has_theme = any(((theme := x).category == 'theme' for x in tags))
389         if has_theme:
390             result['main_tag'] = theme
391             template = 'catalogue/2022/theme_detail.html'
392         else:
393             template = 'catalogue/2022/author_detail.html'
394     else:
395         template = 'catalogue/tagged_object_list.html'
396         
397     return render(
398         request, template, result,
399     )
400
401
402 def literature(request):
403     if request.EXPERIMENTS['layout'].value:
404         return LiteratureView.as_view()(request)
405     books = Book.objects.filter(parent=None, findable=True)
406     return object_list(request, books, related_tags=get_top_level_related_tags([]))
407
408
409 def gallery(request):
410     if request.EXPERIMENTS['layout'].value:
411         return GalleryView.as_view()(request)
412     return object_list(request, Picture.objects.all(), list_type='gallery')
413
414
415 def audiobooks(request):
416     if request.EXPERIMENTS['layout'].value:
417         return AudiobooksView.as_view()(request)
418     audiobooks = Book.objects.filter(findable=True, media__type__in=('mp3', 'ogg')).distinct()
419     return object_list(request, audiobooks, list_type='audiobooks', extra={
420         'daisy': Book.objects.filter(findable=True, media__type='daisy').distinct(),
421     })
422
423
424 class ResponseInstead(Exception):
425     def __init__(self, response):
426         super(ResponseInstead, self).__init__()
427         self.response = response
428
429
430 def analyse_tags(request, tag_str):
431     try:
432         tags = Tag.get_tag_list(tag_str)
433     except Tag.DoesNotExist:
434         # Perhaps the user is asking about an author in Public Domain
435         # counter (they are not represented in tags)
436         chunks = tag_str.split('/')
437         if len(chunks) == 2 and chunks[0] == 'autor':
438             raise ResponseInstead(pdcounter_views.author_detail(request, chunks[1]))
439         raise Http404
440     except Tag.MultipleObjectsReturned as e:
441         # Ask the user to disambiguate
442         raise ResponseInstead(differentiate_tags(request, e.tags, e.ambiguous_slugs))
443     except Tag.UrlDeprecationWarning as e:
444         raise ResponseInstead(HttpResponsePermanentRedirect(
445             reverse('tagged_object_list', args=['/'.join(tag.url_chunk for tag in e.tags)])))
446
447     if not tags:
448         raise Http404
449     try:
450         if len(tags) > settings.MAX_TAG_LIST:
451             raise Http404
452     except AttributeError:
453         pass
454
455     return tags
456
457
458 def theme_list(request, tags, list_type):
459     shelf_tags = [tag for tag in tags if tag.category == 'set']
460     fragment_tags = [tag for tag in tags if tag.category != 'set']
461     if list_type == 'gallery':
462         fragments = PictureArea.tagged.with_all(fragment_tags)
463     else:
464         fragments = Fragment.tagged.with_all(fragment_tags)
465
466     if shelf_tags:
467         # TODO: Pictures on shelves not supported yet.
468         books = Book.tagged.with_all(shelf_tags).order_by()
469         fragments = fragments.filter(Q(book__in=books) | Q(book__ancestor__in=books))
470     elif list_type == 'books':
471         fragments = fragments.filter(book__findable=True)
472
473     if not fragments and len(tags) == 1 and list_type == 'books':
474         if PictureArea.tagged.with_any(tags).exists() or Picture.tagged.with_any(tags).exists():
475             return redirect('tagged_object_list_gallery', '/'.join(tag.url_chunk for tag in tags))
476
477     return object_list(request, fragments, tags=tags, list_type=list_type, extra={
478         'theme_is_set': True,
479         'active_menu_item': 'theme',
480     })
481
482
483 def tagged_object_list(request, tags, list_type):
484     if request.EXPERIMENTS['layout'].value and list_type in ('books', 'audiobooks'):
485         return TaggedObjectList.as_view()(request, tags=tags)
486
487     try:
488         tags = analyse_tags(request, tags)
489     except ResponseInstead as e:
490         return e.response
491
492     if list_type == 'gallery' and any(tag.category == 'set' for tag in tags):
493         raise Http404
494
495     if any(tag.category in ('theme', 'thing') for tag in tags):
496         return theme_list(request, tags, list_type=list_type)
497
498     if list_type == 'books':
499         books = Book.tagged.with_all(tags)
500
501         if any(tag.category == 'set' for tag in tags):
502             params = {'objects': books}
503         else:
504             books = books.filter(findable=True)
505             params = {
506                 'objects': Book.tagged_top_level(tags).filter(findable=True),
507                 'fragments': Fragment.objects.filter(book__in=books),
508                 'related_tags': get_top_level_related_tags(tags),
509             }
510     elif list_type == 'gallery':
511         params = {'objects': Picture.tagged.with_all(tags)}
512     elif list_type == 'audiobooks':
513         audiobooks = Book.objects.filter(findable=True, media__type__in=('mp3', 'ogg')).distinct()
514         params = {
515             'objects': Book.tagged.with_all(tags, audiobooks),
516             'extra': {
517                 'daisy': Book.tagged.with_all(
518                     tags, audiobooks.filter(media__type='daisy').distinct()
519                 ),
520             }
521         }
522     else:
523         raise Http404
524
525     return object_list(request, tags=tags, list_type=list_type, **params)
526
527
528 def book_fragments(request, slug, theme_slug):
529     book = get_object_or_404(Book, slug=slug)
530     theme = get_object_or_404(Tag, slug=theme_slug, category='theme')
531     fragments = Fragment.tagged.with_all([theme]).filter(
532         Q(book=book) | Q(book__ancestor=book))
533
534     if request.EXPERIMENTS['layout'].value:
535         template_name = 'catalogue/2022/book_fragments.html'
536     else:
537         template_name = 'catalogue/book_fragments.html'
538     
539     return render(
540         request,
541         template_name,
542         {
543             'book': book,
544             'theme': theme,
545             'fragments': fragments,
546             'active_menu_item': 'books',
547         })
548
549
550 @never_cache
551 def book_detail(request, slug):
552     try:
553         book = Book.objects.get(slug=slug)
554     except Book.DoesNotExist:
555         return pdcounter_views.book_stub_detail(request, slug)
556
557     new_layout = request.EXPERIMENTS['layout']
558     
559     return render(
560         request,
561         'catalogue/2022/book_detail.html' if new_layout.value else 'catalogue/book_detail.html',
562         {
563             'book': book,
564             'accessible': book.is_accessible_to(request.user),
565             'book_children': book.children.all().order_by('parent_number', 'sort_key'),
566             'active_menu_item': 'books',
567             'club_form': ScheduleForm() if book.preview else None,
568             'club': Club.objects.first() if book.preview else None,
569             'donation_form': DonationStep1Form(),
570         })
571
572
573 # używane w publicznym interfejsie
574 def player(request, slug):
575     book = get_object_or_404(Book, slug=slug)
576     if not book.has_media('mp3'):
577         raise Http404
578
579     audiobooks, projects, total_duration = book.get_audiobooks()
580
581     return render(
582         request,
583         'catalogue/player.html',
584         {
585             'book': book,
586             'audiobook': '',
587             'audiobooks': audiobooks,
588             'projects': projects,
589         })
590
591
592 def book_text(request, slug):
593     book = get_object_or_404(Book, slug=slug)
594
595     if not book.is_accessible_to(request.user):
596         return HttpResponseRedirect(book.get_absolute_url())
597
598     if not book.has_html_file():
599         raise Http404
600     with book.html_file.open('r') as f:
601         book_text = f.read()
602
603     return render(request, 'catalogue/book_text.html', {
604         'book': book,
605         'book_text': book_text,
606         'inserts': DynamicTextInsert.get_all(request)
607     })
608
609
610 # =========
611 # = Admin =
612 # =========
613 @login_required
614 @staff_required
615 def import_book(request):
616     """docstring for import_book"""
617     book_import_form = forms.BookImportForm(request.POST, request.FILES)
618     if book_import_form.is_valid():
619         try:
620             book_import_form.save()
621         except:
622             import sys
623             import pprint
624             import traceback
625             info = sys.exc_info()
626             exception = pprint.pformat(info[1])
627             tb = '\n'.join(traceback.format_tb(info[2]))
628             return HttpResponse(
629                 _("An error occurred: %(exception)s\n\n%(tb)s") % {
630                     'exception': exception, 'tb': tb
631                 },
632                 content_type='text/plain'
633             )
634         return HttpResponse(_("Book imported successfully"))
635     return HttpResponse(_("Error importing file: %r") % book_import_form.errors)
636
637
638 # info views for API
639
640 def book_info(request, book_id, lang='pl'):
641     book = get_object_or_404(Book, id=book_id)
642     # set language by hand
643     translation.activate(lang)
644     return render(request, 'catalogue/book_info.html', {'book': book})
645
646
647 def tag_info(request, tag_id):
648     tag = get_object_or_404(Tag, id=tag_id)
649     return HttpResponse(tag.description)
650
651
652 @never_cache
653 def embargo_link(request, key, format_, slug):
654     book = get_object_or_404(Book, slug=slug)
655     if format_ not in Book.formats:
656         raise Http404
657     if key != book.preview_key:
658         raise Http404
659     media_file = book.get_media(format_)
660     if not book.preview:
661         return HttpResponseRedirect(media_file.url)
662     return HttpResponse(media_file, content_type=constants.EBOOK_CONTENT_TYPES[format_])
663
664
665 def download_zip(request, file_format=None, media_format=None, slug=None):
666     if file_format:
667         url = Book.zip_format(file_format)
668     elif media_format and slug is not None:
669         book = get_object_or_404(Book, slug=slug)
670         url = book.zip_audiobooks(media_format)
671     else:
672         raise Http404('No format specified for zip package')
673     return HttpResponseRedirect(quote_plus(settings.MEDIA_URL + url, safe='/?='))
674
675
676 class CustomPDFFormView(AjaxableFormView):
677     form_class = forms.CustomPDFForm
678     title = gettext_lazy('Download custom PDF')
679     submit = gettext_lazy('Download')
680     template = 'catalogue/custom_pdf_form.html'
681     honeypot = True
682
683     def __call__(self, *args, **kwargs):
684         if settings.NO_CUSTOM_PDF:
685             raise Http404('Custom PDF is disabled')
686         return super(CustomPDFFormView, self).__call__(*args, **kwargs)
687
688     def form_args(self, request, obj):
689         """Override to parse view args and give additional args to the form."""
690         return (obj,), {}
691
692     def validate_object(self, obj, request):
693         book = obj
694         if not book.is_accessible_to(request.user):
695             return HttpResponseRedirect(book.get_absolute_url())
696         return super(CustomPDFFormView, self).validate_object(obj, request)
697
698     def get_object(self, request, slug, *args, **kwargs):
699         book = get_object_or_404(Book, slug=slug)
700         return book
701
702     def context_description(self, request, obj):
703         return obj.pretty_title()
704
705
706 def tag_catalogue(request, category):
707     if category == 'theme':
708         tags = Tag.objects.usage_for_model(
709             Fragment, counts=True).filter(category='theme')
710     else:
711         tags = list(get_top_level_related_tags((), categories=(category,)))
712
713     described_tags = [tag for tag in tags if tag.description]
714
715     if len(described_tags) > 4:
716         best = random.sample(described_tags, 4)
717     else:
718         best = described_tags
719
720     if request.EXPERIMENTS['layout'].value:
721         template_name = 'catalogue/2022/tag_catalogue.html'
722     else:
723         template_name = 'catalogue/tag_catalogue.html'
724
725     return render(request, template_name, {
726         'tags': tags,
727         'best': best,
728         'title': constants.CATEGORIES_NAME_PLURAL[category],
729         'whole_category': constants.WHOLE_CATEGORY[category],
730         'active_menu_item': 'theme' if category == 'theme' else None,
731     })
732
733
734 def collections(request):
735     objects = Collection.objects.filter(listed=True)
736
737     if len(objects) > 3:
738         best = random.sample(list(objects), 4)
739     else:
740         best = objects
741
742     if request.EXPERIMENTS['layout'].value:
743         template_name = 'catalogue/2022/collections.html'
744     else:
745         template_name = 'catalogue/collections.html'
746
747     return render(request, template_name, {
748         'objects': objects,
749         'best': best,
750         'active_menu_item': 'collections'
751     })
752
753
754 def ridero_cover(request, slug):
755     from librarian.cover import make_cover
756     wldoc = Book.objects.get(slug=slug).wldocument()
757     cover = make_cover(wldoc.book_info, width=980, bleed=20, format='PNG')
758     response = HttpResponse(content_type="image/png")
759     cover.save(response)
760     return response
761
762
763 def get_isbn(request, book_format, slug):
764     book = Book.objects.get(slug=slug)
765     return HttpResponse(book.get_extra_info_json().get('isbn_%s' % book_format))