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