817b3ada5b47a337a444433a2d2239a9cd03620b
[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 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 daisy_list(request):
49     return object_list(request, Book.objects.filter(media__type='daisy'))
50
51
52 def collection(request, slug):
53     coll = get_object_or_404(Collection, slug=slug)
54     template_name = 'catalogue/2022/collection.html'
55     return render(request, template_name, {
56         'collection': coll,
57         'active_menu_item': 'collections',
58     })
59
60
61 def differentiate_tags(request, tags, ambiguous_slugs):
62     beginning = '/'.join(tag.url_chunk for tag in tags)
63     unparsed = '/'.join(ambiguous_slugs[1:])
64     options = []
65     for tag in Tag.objects.filter(slug=ambiguous_slugs[0]):
66         options.append({
67             'url_args': '/'.join((beginning, tag.url_chunk, unparsed)).strip('/'),
68             'tags': [tag]
69         })
70     return render(
71         request,
72         'catalogue/differentiate_tags.html',
73         {'tags': tags, 'options': options, 'unparsed': ambiguous_slugs[1:]}
74     )
75
76
77 from django.db.models import FilteredRelation, Q
78 from django.views.decorators.cache import cache_control
79 from django.views.decorators.vary import vary_on_headers
80 from django.utils.decorators import method_decorator
81
82
83 @method_decorator([
84     vary_on_headers('X-Requested-With'),
85     cache_control(max_age=1),
86 ], 'dispatch')
87 class ObjectListView(TemplateView):
88     page_size = 100
89     themed_page_size = 10
90     item_template_name = ''
91     orderings = {}
92     default_ordering = None
93
94     def analyse(self):
95         self.is_themed = False
96         self.ctx = ctx = {}
97         ctx['tags'] = []        
98
99     def dispatch(self, *args, **kwargs):
100         try:
101             self.analyse()
102         except ResponseInstead as e:
103             return e.response
104         return super().dispatch(*args, **kwargs)
105         
106     def get_orderings(self):
107         order = self.get_order()
108         return [
109             {
110                 "slug": k,
111                 "name": v[1],
112                 "active": k == order,
113                 "default": v[0] is None,
114             }
115             for k, v in self.orderings.items()
116         ]
117
118     def get_order(self):
119         order = self.request.GET.get('order')
120         if order not in self.orderings:
121             order = self.default_ordering
122         return order
123
124     def order(self, qs):
125         order_tag = self.get_order()
126         if order_tag:
127             order = self.orderings[order_tag]
128             order_by = order[0]
129             if order_by:
130                 qs = qs.order_by(order_by)
131         return qs
132
133     def search(self, qs):
134         return qs
135
136     def get_template_names(self):
137         if is_ajax(self.request) or self.request.GET.get('dyn'):
138             if self.is_themed:
139                 return self.dynamic_themed_template_name
140             else:
141                 return self.dynamic_template_name
142         else:
143             if self.is_themed:
144                 return self.themed_template_name
145             else:
146                 return self.template_name
147
148     def get_context_data(self, **kwargs):
149         ctx = super().get_context_data()
150         ctx.update(self.ctx)
151         qs = self.get_queryset()
152         qs = self.search(qs)
153         qs = self.order(qs)
154
155         ctx['object_list'] = qs
156         ctx['suggested_tags'] = self.get_suggested_tags(qs)
157         ctx['suggested_tags_by_category'] = split_tags(ctx['suggested_tags'])
158         return ctx
159
160
161 class BookList(ObjectListView):
162     title = gettext_lazy('Literature')
163     list_type = 'books'
164     template_name = 'catalogue/2022/book_list.html'
165     dynamic_template_name = 'catalogue/2022/dynamic_book_list.html'
166     themed_template_name = 'catalogue/2022/themed_book_list.html'
167     dynamic_themed_template_name = 'catalogue/2022/dynamic_themed_book_list.html'
168
169     orderings = {
170         'pop': ('-popularity__count', 'najpopularniejsze'),
171         'alpha': (None, 'alfabetycznie'),
172     }
173     default_ordering = 'alpha'
174
175     def get_queryset(self):
176         return Book.objects.filter(parent=None, findable=True)
177
178     def search(self, qs):
179         term = self.request.GET.get('search')
180         if term:
181             meta_rels = TagRelation.objects.filter(tag__category='author')
182             # TODO: search tags in currently displaying language
183             if self.is_themed:
184                 rels = meta_rels.filter(tag__name_pl__icontains=term)
185                 qs = qs.filter(
186                     Q(book__title__icontains=term) |
187                     Q(tag_relations__in=rels) |
188                     Q(text__icontains=term)
189                 ).distinct()
190             else:
191                 qs = qs.annotate(
192                     meta=FilteredRelation('tag_relations', condition=Q(tag_relations__in=meta_rels))
193                 )
194                 qs = qs.filter(Q(title__icontains=term) | Q(meta__tag__name_pl__icontains=term)).distinct()
195         return qs
196
197
198 class ArtList(ObjectListView):
199     template_name = 'catalogue/2022/book_list.html'
200     dynamic_template_name = 'catalogue/2022/dynamic_book_list.html'
201     title = gettext_lazy('Art')
202     list_type = 'gallery'
203
204     def get_queryset(self):
205         return Picture.objects.all()
206
207     def search(self, qs):
208         term = self.request.GET.get('search')
209         if term:
210             qs = qs.filter(Q(title__icontains=term) | Q(tag_relations__tag__name_pl__icontains=term)).distinct()
211         return qs
212     
213
214 class LiteratureView(BookList):
215     def get_suggested_tags(self, queryset):
216         tags = list(get_top_level_related_tags([]))
217         tags.sort(key=lambda t: -t.count)
218         if self.request.user.is_authenticated:
219             tags.extend(list(Tag.objects.filter(user=self.request.user).exclude(name='')))
220         return tags
221
222
223 class AudiobooksView(LiteratureView):
224     title = gettext_lazy('Audiobooks')
225     list_type = 'audiobooks'
226
227     def get_queryset(self):
228         return Book.objects.filter(findable=True, media__type='mp3').distinct()
229
230
231 class GalleryView(ArtList):
232     def get_suggested_tags(self, queryset):
233         return Tag.objects.usage_for_queryset(
234             queryset,
235             counts=True
236         ).exclude(pk__in=[t.id for t in self.ctx['tags']]).order_by('-count')
237     
238
239 class TaggedObjectList(BookList):
240     def analyse(self):
241         super().analyse()
242         self.ctx['tags'] = analyse_tags(self.request, self.kwargs['tags'])
243         self.ctx['fragment_tags'] = [t for t in self.ctx['tags'] if t.category in ('theme', 'object')]
244         self.ctx['work_tags'] = [t for t in self.ctx['tags'] if t not in self.ctx['fragment_tags']]
245         self.is_themed = self.ctx['has_theme'] = bool(self.ctx['fragment_tags'])
246         if self.is_themed:
247             self.ctx['main_tag'] = self.ctx['fragment_tags'][0]
248         elif self.ctx['tags']:
249             self.ctx['main_tag'] = self.ctx['tags'][0]
250         else:
251             self.ctx['main_tag'] = None
252         self.ctx['filtering_tags'] = [
253             t for t in self.ctx['tags']
254             if t is not self.ctx['main_tag']
255         ]
256
257     def get_queryset(self):
258         qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True)
259         qs = qs.exclude(ancestor__in=qs)
260         if self.is_themed:
261             fqs = Fragment.tagged.with_all(self.ctx['fragment_tags'])
262             if self.ctx['work_tags']:
263                 fqs = fqs.filter(
264                     Q(book__in=qs) | Q(book__ancestor__in=qs)
265                 )
266             qs = fqs
267         return qs
268
269     def get_suggested_tags(self, queryset):
270         tag_ids = [t.id for t in self.ctx['tags']]
271         if self.is_themed:
272             related_tags = []
273             current_books = self.get_queryset().values_list('book', flat=True).distinct()
274             containing_books = Book.objects.filter(Q(id__in=current_books) | Q(children__in=current_books))
275
276             related_tags.extend(list(
277                 Tag.objects.usage_for_queryset(
278                     containing_books,
279                 ).exclude(category='set').exclude(pk__in=tag_ids)
280             ))
281             if self.request.user.is_authenticated:
282                 related_tags.extend(list(
283                     Tag.objects.usage_for_queryset(
284                         containing_books
285                     ).filter(
286                         user=self.request.user
287                     ).exclude(name='').exclude(pk__in=tag_ids)
288                 ))
289         else:
290             related_tags = list(get_top_level_related_tags(self.ctx['tags']))
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     has_theme = any(((theme := x).category == 'theme' for x in tags))
383     if has_theme:
384         result['main_tag'] = theme
385         template = 'catalogue/2022/theme_detail.html'
386     else:
387         template = 'catalogue/2022/author_detail.html'
388         
389     return render(
390         request, template, result,
391     )
392
393
394 class ResponseInstead(Exception):
395     def __init__(self, response):
396         super(ResponseInstead, self).__init__()
397         self.response = response
398
399
400 def analyse_tags(request, tag_str):
401     try:
402         tags = Tag.get_tag_list(tag_str)
403     except Tag.DoesNotExist:
404         # Perhaps the user is asking about an author in Public Domain
405         # counter (they are not represented in tags)
406         chunks = tag_str.split('/')
407         if len(chunks) == 2 and chunks[0] == 'autor':
408             raise ResponseInstead(pdcounter_views.author_detail(request, chunks[1]))
409         raise Http404
410     except Tag.MultipleObjectsReturned as e:
411         # Ask the user to disambiguate
412         raise ResponseInstead(differentiate_tags(request, e.tags, e.ambiguous_slugs))
413     except Tag.UrlDeprecationWarning as e:
414         raise ResponseInstead(HttpResponsePermanentRedirect(
415             reverse('tagged_object_list', args=['/'.join(tag.url_chunk for tag in e.tags)])))
416
417     if not tags:
418         raise Http404
419     try:
420         if len(tags) > settings.MAX_TAG_LIST:
421             raise Http404
422     except AttributeError:
423         pass
424
425     return tags
426
427
428 def theme_list(request, tags, list_type):
429     shelf_tags = [tag for tag in tags if tag.category == 'set']
430     fragment_tags = [tag for tag in tags if tag.category != 'set']
431     if list_type == 'gallery':
432         fragments = PictureArea.tagged.with_all(fragment_tags)
433     else:
434         fragments = Fragment.tagged.with_all(fragment_tags)
435
436     if shelf_tags:
437         # TODO: Pictures on shelves not supported yet.
438         books = Book.tagged.with_all(shelf_tags).order_by()
439         fragments = fragments.filter(Q(book__in=books) | Q(book__ancestor__in=books))
440     elif list_type == 'books':
441         fragments = fragments.filter(book__findable=True)
442
443     if not fragments and len(tags) == 1 and list_type == 'books':
444         if PictureArea.tagged.with_any(tags).exists() or Picture.tagged.with_any(tags).exists():
445             return redirect('tagged_object_list_gallery', '/'.join(tag.url_chunk for tag in tags))
446
447     return object_list(request, fragments, tags=tags, list_type=list_type, extra={
448         'theme_is_set': True,
449         'active_menu_item': 'theme',
450     })
451
452
453 def tagged_object_list(request, tags, list_type):
454     if list_type in ('books', 'audiobooks'):
455         return TaggedObjectList.as_view()(request, tags=tags)
456
457     try:
458         tags = analyse_tags(request, tags)
459     except ResponseInstead as e:
460         return e.response
461
462     if list_type == 'gallery' and any(tag.category == 'set' for tag in tags):
463         raise Http404
464
465     if any(tag.category in ('theme', 'thing') for tag in tags):
466         return theme_list(request, tags, list_type=list_type)
467
468     if list_type == 'books':
469         books = Book.tagged.with_all(tags)
470
471         if any(tag.category == 'set' for tag in tags):
472             params = {'objects': books}
473         else:
474             books = books.filter(findable=True)
475             params = {
476                 'objects': Book.tagged_top_level(tags).filter(findable=True),
477                 'fragments': Fragment.objects.filter(book__in=books),
478                 'related_tags': get_top_level_related_tags(tags),
479             }
480     elif list_type == 'gallery':
481         params = {'objects': Picture.tagged.with_all(tags)}
482     elif list_type == 'audiobooks':
483         audiobooks = Book.objects.filter(findable=True, media__type__in=('mp3', 'ogg')).distinct()
484         params = {
485             'objects': Book.tagged.with_all(tags, audiobooks),
486             'extra': {
487                 'daisy': Book.tagged.with_all(
488                     tags, audiobooks.filter(media__type='daisy').distinct()
489                 ),
490             }
491         }
492     else:
493         raise Http404
494
495     return object_list(request, tags=tags, list_type=list_type, **params)
496
497
498 def book_fragments(request, slug, theme_slug):
499     book = get_object_or_404(Book, slug=slug)
500     theme = get_object_or_404(Tag, slug=theme_slug, category='theme')
501     fragments = Fragment.tagged.with_all([theme]).filter(
502         Q(book=book) | Q(book__ancestor=book))
503
504     template_name = 'catalogue/2022/book_fragments.html'
505     return render(
506         request,
507         template_name,
508         {
509             'book': book,
510             'theme': theme,
511             'fragments': fragments,
512             'active_menu_item': 'books',
513         })
514
515
516 @never_cache
517 def book_detail(request, slug):
518     try:
519         book = Book.objects.get(slug=slug)
520     except Book.DoesNotExist:
521         return pdcounter_views.book_stub_detail(request, slug)
522
523     return render(
524         request,
525         'catalogue/2022/book_detail.html',
526         {
527             'book': book,
528             'accessible': book.is_accessible_to(request.user),
529             'book_children': book.children.all().order_by('parent_number', 'sort_key'),
530             'active_menu_item': 'books',
531             'club': Club.objects.first() if book.preview else None,
532             'donation_form': DonationStep1Form(),
533         })
534
535
536 def book_text(request, slug):
537     book = get_object_or_404(Book, slug=slug)
538
539     if not book.is_accessible_to(request.user):
540         return HttpResponseRedirect(book.get_absolute_url())
541
542     if not book.has_html_file():
543         raise Http404
544     with book.html_file.open('r') as f:
545         book_text = f.read()
546
547     return render(request, 'catalogue/book_text.html', {
548         'book': book,
549         'book_text': book_text,
550         'inserts': DynamicTextInsert.get_all(request)
551     })
552
553
554 # =========
555 # = Admin =
556 # =========
557 @login_required
558 @staff_required
559 def import_book(request):
560     """docstring for import_book"""
561     book_import_form = forms.BookImportForm(request.POST, request.FILES)
562     if book_import_form.is_valid():
563         try:
564             book_import_form.save()
565         except:
566             import sys
567             import pprint
568             import traceback
569             info = sys.exc_info()
570             exception = pprint.pformat(info[1])
571             tb = '\n'.join(traceback.format_tb(info[2]))
572             return HttpResponse(
573                 _("An error occurred: %(exception)s\n\n%(tb)s") % {
574                     'exception': exception, 'tb': tb
575                 },
576                 content_type='text/plain'
577             )
578         return HttpResponse(_("Book imported successfully"))
579     return HttpResponse(_("Error importing file: %r") % book_import_form.errors)
580
581
582 # info views for API
583
584 def book_info(request, book_id, lang='pl'):
585     book = get_object_or_404(Book, id=book_id)
586     # set language by hand
587     translation.activate(lang)
588     return render(request, 'catalogue/book_info.html', {'book': book})
589
590
591 def tag_info(request, tag_id):
592     tag = get_object_or_404(Tag, id=tag_id)
593     return HttpResponse(tag.description)
594
595
596 @never_cache
597 def embargo_link(request, key, format_, slug):
598     book = get_object_or_404(Book, slug=slug)
599     if format_ not in Book.formats:
600         raise Http404
601     if key != book.preview_key:
602         raise Http404
603     media_file = book.get_media(format_)
604     if not book.preview:
605         return HttpResponseRedirect(media_file.url)
606     return HttpResponse(media_file, content_type=constants.EBOOK_CONTENT_TYPES[format_])
607
608
609 def download_zip(request, file_format=None, media_format=None, slug=None):
610     if file_format:
611         url = Book.zip_format(file_format)
612     elif media_format and slug is not None:
613         book = get_object_or_404(Book, slug=slug)
614         url = book.zip_audiobooks(media_format)
615     else:
616         raise Http404('No format specified for zip package')
617     return HttpResponseRedirect(quote_plus(settings.MEDIA_URL + url, safe='/?='))
618
619
620 class CustomPDFFormView(AjaxableFormView):
621     form_class = forms.CustomPDFForm
622     title = gettext_lazy('Download custom PDF')
623     submit = gettext_lazy('Download')
624     template = 'catalogue/custom_pdf_form.html'
625     honeypot = True
626
627     def __call__(self, *args, **kwargs):
628         if settings.NO_CUSTOM_PDF:
629             raise Http404('Custom PDF is disabled')
630         return super(CustomPDFFormView, self).__call__(*args, **kwargs)
631
632     def form_args(self, request, obj):
633         """Override to parse view args and give additional args to the form."""
634         return (obj,), {}
635
636     def validate_object(self, obj, request):
637         book = obj
638         if not book.is_accessible_to(request.user):
639             return HttpResponseRedirect(book.get_absolute_url())
640         return super(CustomPDFFormView, self).validate_object(obj, request)
641
642     def get_object(self, request, slug, *args, **kwargs):
643         book = get_object_or_404(Book, slug=slug)
644         return book
645
646     def context_description(self, request, obj):
647         return obj.pretty_title()
648
649
650 def tag_catalogue(request, category):
651     if category == 'theme':
652         tags = Tag.objects.usage_for_model(
653             Fragment, counts=True).filter(category='theme')
654     else:
655         tags = list(get_top_level_related_tags((), categories=(category,)))
656
657     described_tags = [tag for tag in tags if tag.description]
658
659     if len(described_tags) > 4:
660         best = random.sample(described_tags, 4)
661     else:
662         best = described_tags
663
664     template_name = 'catalogue/2022/tag_catalogue.html'
665     return render(request, template_name, {
666         'tags': tags,
667         'best': best,
668         'title': constants.CATEGORIES_NAME_PLURAL[category],
669         'whole_category': constants.WHOLE_CATEGORY[category],
670         'active_menu_item': 'theme' if category == 'theme' else None,
671     })
672
673
674 def collections(request):
675     objects = Collection.objects.filter(listed=True)
676
677     if len(objects) > 3:
678         best = random.sample(list(objects), 4)
679     else:
680         best = objects
681
682     template_name = 'catalogue/2022/collections.html'
683     return render(request, template_name, {
684         'objects': objects,
685         'best': best,
686         'active_menu_item': 'collections'
687     })
688
689
690 def ridero_cover(request, slug):
691     from librarian.cover import make_cover
692     wldoc = Book.objects.get(slug=slug).wldocument()
693     cover = make_cover(wldoc.book_info, width=980, bleed=20, format='PNG')
694     response = HttpResponse(content_type="image/png")
695     cover.save(response)
696     return response
697
698
699 def get_isbn(request, book_format, slug):
700     book = Book.objects.get(slug=slug)
701     return HttpResponse(book.get_extra_info_json().get('isbn_%s' % book_format))