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