bb5d43ff003c65fc7a2199e1ee077da87545af80
[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         self.ctx['main_tag'] = self.ctx['fragment_tags'][0] if self.is_themed else self.ctx['tags'][0]
273         self.ctx['filtering_tags'] = [
274             t for t in self.ctx['tags']
275             if t is not self.ctx['main_tag']
276         ]
277
278     def get_queryset(self):
279         qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True)
280         qs = qs.exclude(ancestor__in=qs)
281         if self.is_themed:
282             qs = Fragment.tagged.with_all(self.ctx['fragment_tags']).filter(
283                 Q(book__in=qs) | Q(book__ancestor__in=qs)
284             )
285         return qs
286
287     def get_suggested_tags(self, queryset):
288         tag_ids = [t.id for t in self.ctx['tags']]
289         related_tags = list(get_top_level_related_tags(self.ctx['tags']))
290         if not self.is_themed:
291             if self.request.user.is_authenticated:
292                 qs = Book.tagged.with_all(self.ctx['tags']).filter(findable=True)
293                 related_tags.extend(list(
294                     Tag.objects.usage_for_queryset(
295                         qs
296                     ).filter(
297                         user=self.request.user
298                     ).exclude(name='').exclude(pk__in=tag_ids)
299                 ))
300
301             fragments = Fragment.objects.filter(
302                 Q(book__in=queryset) | Q(book__ancestor__in=queryset)
303             )
304             related_tags.extend(
305                 Tag.objects.usage_for_queryset(
306                     fragments, counts=True
307                 ).filter(category__in=('theme', 'object')).exclude(pk__in=tag_ids)
308             .only('name', 'sort_key', 'category', 'slug'))
309
310         return related_tags
311
312     
313     
314 def object_list(request, objects, fragments=None, related_tags=None, tags=None,
315                 list_type='books', extra=None):
316     if not tags:
317         tags = []
318     tag_ids = [tag.pk for tag in tags]
319
320     related_tag_lists = []
321     if related_tags:
322         related_tag_lists.append(related_tags)
323     else:
324         related_tag_lists.append(
325             Tag.objects.usage_for_queryset(
326                 objects, counts=True
327             ).exclude(category='set').exclude(pk__in=tag_ids))
328         if request.user.is_authenticated:
329             related_tag_lists.append(
330                 Tag.objects.usage_for_queryset(
331                     objects, counts=True
332                 ).filter(
333                     user=request.user
334                 ).exclude(name='').exclude(pk__in=tag_ids)
335             )
336     if not (extra and extra.get('theme_is_set')):
337         if fragments is None:
338             if list_type == 'gallery':
339                 fragments = PictureArea.objects.filter(picture__in=objects)
340             else:
341                 fragments = Fragment.objects.filter(book__in=objects)
342         related_tag_lists.append(
343             Tag.objects.usage_for_queryset(
344                 fragments, counts=True
345             ).filter(category='theme').exclude(pk__in=tag_ids)
346             .only('name', 'sort_key', 'category', 'slug'))
347         if isinstance(objects, QuerySet):
348             objects = prefetch_relations(objects, 'author')
349
350     
351     categories = split_tags(*related_tag_lists)
352     suggest = []
353     for c in ['set', 'author', 'epoch', 'kind', 'genre']:
354         suggest.extend(sorted(categories[c], key=lambda t: -t.count))
355
356     objects = list(objects)
357
358     if not objects and len(tags) == 1 and list_type == 'books':
359         if PictureArea.tagged.with_any(tags).exists() or Picture.tagged.with_any(tags).exists():
360             return redirect('tagged_object_list_gallery', '/'.join(tag.url_chunk for tag in tags))
361
362     if len(objects) > 3:
363         best = random.sample(objects, 3)
364     else:
365         best = objects
366
367     result = {
368         'object_list': objects,
369         'categories': categories,
370         'suggest': suggest,
371         'list_type': list_type,
372         'tags': tags,
373         'main_tag': tags[0] if tags else None,
374
375         'formats_form': forms.DownloadFormatsForm(),
376         'best': best,
377         'active_menu_item': list_type,
378     }
379     if extra:
380         result.update(extra)
381
382     if request.EXPERIMENTS['layout'].value:
383         has_theme = any(((theme := x).category == 'theme' for x in tags))
384         if has_theme:
385             result['main_tag'] = theme
386             template = 'catalogue/2022/theme_detail.html'
387         else:
388             template = 'catalogue/2022/author_detail.html'
389     else:
390         template = 'catalogue/tagged_object_list.html'
391         
392     return render(
393         request, template, result,
394     )
395
396
397 def literature(request):
398     if request.EXPERIMENTS['layout'].value:
399         return LiteratureView.as_view()(request)
400     books = Book.objects.filter(parent=None, findable=True)
401     return object_list(request, books, related_tags=get_top_level_related_tags([]))
402
403
404 def gallery(request):
405     if request.EXPERIMENTS['layout'].value:
406         return GalleryView.as_view()(request)
407     return object_list(request, Picture.objects.all(), list_type='gallery')
408
409
410 def audiobooks(request):
411     if request.EXPERIMENTS['layout'].value:
412         return AudiobooksView.as_view()(request)
413     audiobooks = Book.objects.filter(findable=True, media__type__in=('mp3', 'ogg')).distinct()
414     return object_list(request, audiobooks, list_type='audiobooks', extra={
415         'daisy': Book.objects.filter(findable=True, media__type='daisy').distinct(),
416     })
417
418
419 class ResponseInstead(Exception):
420     def __init__(self, response):
421         super(ResponseInstead, self).__init__()
422         self.response = response
423
424
425 def analyse_tags(request, tag_str):
426     try:
427         tags = Tag.get_tag_list(tag_str)
428     except Tag.DoesNotExist:
429         # Perhaps the user is asking about an author in Public Domain
430         # counter (they are not represented in tags)
431         chunks = tag_str.split('/')
432         if len(chunks) == 2 and chunks[0] == 'autor':
433             raise ResponseInstead(pdcounter_views.author_detail(request, chunks[1]))
434         raise Http404
435     except Tag.MultipleObjectsReturned as e:
436         # Ask the user to disambiguate
437         raise ResponseInstead(differentiate_tags(request, e.tags, e.ambiguous_slugs))
438     except Tag.UrlDeprecationWarning as e:
439         raise ResponseInstead(HttpResponsePermanentRedirect(
440             reverse('tagged_object_list', args=['/'.join(tag.url_chunk for tag in e.tags)])))
441
442     try:
443         if len(tags) > settings.MAX_TAG_LIST:
444             raise Http404
445     except AttributeError:
446         pass
447
448     return tags
449
450
451 def theme_list(request, tags, list_type):
452     shelf_tags = [tag for tag in tags if tag.category == 'set']
453     fragment_tags = [tag for tag in tags if tag.category != 'set']
454     if list_type == 'gallery':
455         fragments = PictureArea.tagged.with_all(fragment_tags)
456     else:
457         fragments = Fragment.tagged.with_all(fragment_tags)
458
459     if shelf_tags:
460         # TODO: Pictures on shelves not supported yet.
461         books = Book.tagged.with_all(shelf_tags).order_by()
462         fragments = fragments.filter(Q(book__in=books) | Q(book__ancestor__in=books))
463     elif list_type == 'books':
464         fragments = fragments.filter(book__findable=True)
465
466     if not fragments and len(tags) == 1 and list_type == 'books':
467         if PictureArea.tagged.with_any(tags).exists() or Picture.tagged.with_any(tags).exists():
468             return redirect('tagged_object_list_gallery', '/'.join(tag.url_chunk for tag in tags))
469
470     return object_list(request, fragments, tags=tags, list_type=list_type, extra={
471         'theme_is_set': True,
472         'active_menu_item': 'theme',
473     })
474
475
476 def tagged_object_list(request, tags, list_type):
477     if request.EXPERIMENTS['layout'].value and list_type in ('books', 'audiobooks'):
478         return TaggedObjectList.as_view()(request, tags=tags)
479
480     try:
481         tags = analyse_tags(request, tags)
482     except ResponseInstead as e:
483         return e.response
484
485     if list_type == 'gallery' and any(tag.category == 'set' for tag in tags):
486         raise Http404
487
488     if any(tag.category in ('theme', 'thing') for tag in tags):
489         return theme_list(request, tags, list_type=list_type)
490
491     if list_type == 'books':
492         books = Book.tagged.with_all(tags)
493
494         if any(tag.category == 'set' for tag in tags):
495             params = {'objects': books}
496         else:
497             books = books.filter(findable=True)
498             params = {
499                 'objects': Book.tagged_top_level(tags).filter(findable=True),
500                 'fragments': Fragment.objects.filter(book__in=books),
501                 'related_tags': get_top_level_related_tags(tags),
502             }
503     elif list_type == 'gallery':
504         params = {'objects': Picture.tagged.with_all(tags)}
505     elif list_type == 'audiobooks':
506         audiobooks = Book.objects.filter(findable=True, media__type__in=('mp3', 'ogg')).distinct()
507         params = {
508             'objects': Book.tagged.with_all(tags, audiobooks),
509             'extra': {
510                 'daisy': Book.tagged.with_all(
511                     tags, audiobooks.filter(media__type='daisy').distinct()
512                 ),
513             }
514         }
515     else:
516         raise Http404
517
518     return object_list(request, tags=tags, list_type=list_type, **params)
519
520
521 def book_fragments(request, slug, theme_slug):
522     book = get_object_or_404(Book, slug=slug)
523     theme = get_object_or_404(Tag, slug=theme_slug, category='theme')
524     fragments = Fragment.tagged.with_all([theme]).filter(
525         Q(book=book) | Q(book__ancestor=book))
526
527     if request.EXPERIMENTS['layout'].value:
528         template_name = 'catalogue/2022/book_fragments.html'
529     else:
530         template_name = 'catalogue/book_fragments.html'
531     
532     return render(
533         request,
534         template_name,
535         {
536             'book': book,
537             'theme': theme,
538             'fragments': fragments,
539             'active_menu_item': 'books',
540         })
541
542
543 @never_cache
544 def book_detail(request, slug):
545     try:
546         book = Book.objects.get(slug=slug)
547     except Book.DoesNotExist:
548         return pdcounter_views.book_stub_detail(request, slug)
549
550     new_layout = request.EXPERIMENTS['layout']
551     
552     return render(
553         request,
554         'catalogue/2022/book_detail.html' if new_layout.value else 'catalogue/book_detail.html',
555         {
556             'book': book,
557             'accessible': book.is_accessible_to(request.user),
558             'book_children': book.children.all().order_by('parent_number', 'sort_key'),
559             'active_menu_item': 'books',
560             'club_form': ScheduleForm() if book.preview else None,
561             'club': Club.objects.first() if book.preview else None,
562             'donation_form': DonationStep1Form(),
563
564             'EXPERIMENTS_SWITCHABLE_layout': True,
565         })
566
567
568 # używane w publicznym interfejsie
569 def player(request, slug):
570     book = get_object_or_404(Book, slug=slug)
571     if not book.has_media('mp3'):
572         raise Http404
573
574     audiobooks, projects, total_duration = book.get_audiobooks()
575
576     return render(
577         request,
578         'catalogue/player.html',
579         {
580             'book': book,
581             'audiobook': '',
582             'audiobooks': audiobooks,
583             'projects': projects,
584         })
585
586
587 def book_text(request, slug):
588     book = get_object_or_404(Book, slug=slug)
589
590     if not book.is_accessible_to(request.user):
591         return HttpResponseRedirect(book.get_absolute_url())
592
593     if not book.has_html_file():
594         raise Http404
595     with book.html_file.open('r') as f:
596         book_text = f.read()
597
598     return render(request, 'catalogue/book_text.html', {
599         'book': book,
600         'book_text': book_text,
601         'inserts': DynamicTextInsert.get_all(request)
602     })
603
604
605 # =========
606 # = Admin =
607 # =========
608 @login_required
609 @staff_required
610 def import_book(request):
611     """docstring for import_book"""
612     book_import_form = forms.BookImportForm(request.POST, request.FILES)
613     if book_import_form.is_valid():
614         try:
615             book_import_form.save()
616         except:
617             import sys
618             import pprint
619             import traceback
620             info = sys.exc_info()
621             exception = pprint.pformat(info[1])
622             tb = '\n'.join(traceback.format_tb(info[2]))
623             return HttpResponse(
624                 _("An error occurred: %(exception)s\n\n%(tb)s") % {
625                     'exception': exception, 'tb': tb
626                 },
627                 content_type='text/plain'
628             )
629         return HttpResponse(_("Book imported successfully"))
630     return HttpResponse(_("Error importing file: %r") % book_import_form.errors)
631
632
633 # info views for API
634
635 def book_info(request, book_id, lang='pl'):
636     book = get_object_or_404(Book, id=book_id)
637     # set language by hand
638     translation.activate(lang)
639     return render(request, 'catalogue/book_info.html', {'book': book})
640
641
642 def tag_info(request, tag_id):
643     tag = get_object_or_404(Tag, id=tag_id)
644     return HttpResponse(tag.description)
645
646
647 @never_cache
648 def embargo_link(request, key, format_, slug):
649     book = get_object_or_404(Book, slug=slug)
650     if format_ not in Book.formats:
651         raise Http404
652     if key != book.preview_key:
653         raise Http404
654     media_file = book.get_media(format_)
655     if not book.preview:
656         return HttpResponseRedirect(media_file.url)
657     return HttpResponse(media_file, content_type=constants.EBOOK_CONTENT_TYPES[format_])
658
659
660 def download_zip(request, file_format=None, media_format=None, slug=None):
661     if file_format:
662         url = Book.zip_format(file_format)
663     elif media_format and slug is not None:
664         book = get_object_or_404(Book, slug=slug)
665         url = book.zip_audiobooks(media_format)
666     else:
667         raise Http404('No format specified for zip package')
668     return HttpResponseRedirect(quote_plus(settings.MEDIA_URL + url, safe='/?='))
669
670
671 class CustomPDFFormView(AjaxableFormView):
672     form_class = forms.CustomPDFForm
673     title = gettext_lazy('Download custom PDF')
674     submit = gettext_lazy('Download')
675     template = 'catalogue/custom_pdf_form.html'
676     honeypot = True
677
678     def __call__(self, *args, **kwargs):
679         if settings.NO_CUSTOM_PDF:
680             raise Http404('Custom PDF is disabled')
681         return super(CustomPDFFormView, self).__call__(*args, **kwargs)
682
683     def form_args(self, request, obj):
684         """Override to parse view args and give additional args to the form."""
685         return (obj,), {}
686
687     def validate_object(self, obj, request):
688         book = obj
689         if not book.is_accessible_to(request.user):
690             return HttpResponseRedirect(book.get_absolute_url())
691         return super(CustomPDFFormView, self).validate_object(obj, request)
692
693     def get_object(self, request, slug, *args, **kwargs):
694         book = get_object_or_404(Book, slug=slug)
695         return book
696
697     def context_description(self, request, obj):
698         return obj.pretty_title()
699
700
701 def tag_catalogue(request, category):
702     if category == 'theme':
703         tags = Tag.objects.usage_for_model(
704             Fragment, counts=True).filter(category='theme')
705     else:
706         tags = list(get_top_level_related_tags((), categories=(category,)))
707
708     described_tags = [tag for tag in tags if tag.description]
709
710     if len(described_tags) > 4:
711         best = random.sample(described_tags, 4)
712     else:
713         best = described_tags
714
715     if request.EXPERIMENTS['layout'].value:
716         template_name = 'catalogue/2022/tag_catalogue.html'
717     else:
718         template_name = 'catalogue/tag_catalogue.html'
719
720     return render(request, template_name, {
721         'tags': tags,
722         'best': best,
723         'title': constants.CATEGORIES_NAME_PLURAL[category],
724         'whole_category': constants.WHOLE_CATEGORY[category],
725         'active_menu_item': 'theme' if category == 'theme' else None,
726     })
727
728
729 def collections(request):
730     objects = Collection.objects.filter(listed=True)
731
732     if len(objects) > 3:
733         best = random.sample(list(objects), 4)
734     else:
735         best = objects
736
737     if request.EXPERIMENTS['layout'].value:
738         template_name = 'catalogue/2022/collections.html'
739     else:
740         template_name = 'catalogue/collections.html'
741
742     return render(request, template_name, {
743         'objects': objects,
744         'best': best,
745         'active_menu_item': 'collections'
746     })
747
748
749 def ridero_cover(request, slug):
750     from librarian.cover import make_cover
751     wldoc = Book.objects.get(slug=slug).wldocument()
752     cover = make_cover(wldoc.book_info, width=980, bleed=20, format='PNG')
753     response = HttpResponse(content_type="image/png")
754     cover.save(response)
755     return response
756
757
758 def get_isbn(request, book_format, slug):
759     book = Book.objects.get(slug=slug)
760     return HttpResponse(book.get_extra_info_json().get('isbn_%s' % book_format))