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