af4d1e3b531c2fee2f973392771e98cdad6e79a7
[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 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/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('Literatura')
163     list_type = 'books'
164     template_name = 'catalogue/book_list.html'
165     dynamic_template_name = 'catalogue/dynamic_book_list.html'
166     themed_template_name = 'catalogue/themed_book_list.html'
167     dynamic_themed_template_name = 'catalogue/dynamic_themed_book_list.html'
168
169     orderings = {
170         'pop': ('-popularity__count', gettext_lazy('najpopularniejsze')),
171         'alpha': (None, gettext_lazy('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/book_list.html'
200     dynamic_template_name = 'catalogue/dynamic_book_list.html'
201     title = gettext_lazy('Sztuka')
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('Audiobooki')
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/theme_detail.html'
386     else:
387         template = 'catalogue/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/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/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         'extra_info': book.get_extra_info_json(),
550         'book_text': book_text,
551         'inserts': DynamicTextInsert.get_all(request)
552     })
553
554
555 # =========
556 # = Admin =
557 # =========
558 @login_required
559 @staff_required
560 def import_book(request):
561     """docstring for import_book"""
562     book_import_form = forms.BookImportForm(request.POST, request.FILES)
563     if book_import_form.is_valid():
564         try:
565             book_import_form.save()
566         except:
567             import sys
568             import pprint
569             import traceback
570             info = sys.exc_info()
571             exception = pprint.pformat(info[1])
572             tb = '\n'.join(traceback.format_tb(info[2]))
573             return HttpResponse(
574                 "Błąd: %(exception)s\n\n%(tb)s" % {
575                     'exception': exception, 'tb': tb
576                 },
577                 content_type='text/plain'
578             )
579         return HttpResponse("Książka zaimportowana")
580     return HttpResponse("Błąd podczas importowania pliku: %r" % book_import_form.errors)
581
582
583 # info views for API
584
585 def book_info(request, book_id, lang='pl'):
586     book = get_object_or_404(Book, id=book_id)
587     # set language by hand
588     translation.activate(lang)
589     return render(request, 'catalogue/book_info.html', {'book': book})
590
591
592 def tag_info(request, tag_id):
593     tag = get_object_or_404(Tag, id=tag_id)
594     return HttpResponse(tag.description)
595
596
597 @never_cache
598 def embargo_link(request, key, format_, slug):
599     book = get_object_or_404(Book, slug=slug)
600     if format_ not in Book.formats:
601         raise Http404
602     if key != book.preview_key:
603         raise Http404
604     media_file = book.get_media(format_)
605     if not book.preview:
606         return HttpResponseRedirect(media_file.url)
607     return HttpResponse(media_file, content_type=constants.EBOOK_CONTENT_TYPES[format_])
608
609
610 def download_zip(request, file_format=None, media_format=None, slug=None):
611     if file_format:
612         url = Book.zip_format(file_format)
613     elif media_format and slug is not None:
614         book = get_object_or_404(Book, slug=slug)
615         url = book.zip_audiobooks(media_format)
616     else:
617         raise Http404('No format specified for zip package')
618     return HttpResponseRedirect(quote_plus(settings.MEDIA_URL + url, safe='/?='))
619
620
621 class CustomPDFFormView(AjaxableFormView):
622     form_class = forms.CustomPDFForm
623     title = gettext_lazy('Stwórz własny PDF')
624     submit = gettext_lazy('Pobierz')
625     template = 'catalogue/custom_pdf_form.html'
626     honeypot = True
627
628     def __call__(self, *args, **kwargs):
629         if settings.NO_CUSTOM_PDF:
630             raise Http404('Custom PDF is disabled')
631         return super(CustomPDFFormView, self).__call__(*args, **kwargs)
632
633     def form_args(self, request, obj):
634         """Override to parse view args and give additional args to the form."""
635         return (obj,), {}
636
637     def validate_object(self, obj, request):
638         book = obj
639         if not book.is_accessible_to(request.user):
640             return HttpResponseRedirect(book.get_absolute_url())
641         return super(CustomPDFFormView, self).validate_object(obj, request)
642
643     def get_object(self, request, slug, *args, **kwargs):
644         book = get_object_or_404(Book, slug=slug)
645         return book
646
647     def context_description(self, request, obj):
648         return obj.pretty_title()
649
650
651 def tag_catalogue(request, category):
652     if category == 'theme':
653         tags = Tag.objects.usage_for_model(
654             Fragment, counts=True).filter(category='theme')
655     else:
656         tags = list(get_top_level_related_tags((), categories=(category,)))
657
658     described_tags = [tag for tag in tags if tag.description]
659
660     if len(described_tags) > 4:
661         best = random.sample(described_tags, 4)
662     else:
663         best = described_tags
664
665     template_name = 'catalogue/tag_catalogue.html'
666     return render(request, template_name, {
667         'tags': tags,
668         'best': best,
669         'title': constants.CATEGORIES_NAME_PLURAL[category],
670         'whole_category': constants.WHOLE_CATEGORY[category],
671         'active_menu_item': 'theme' if category == 'theme' else None,
672     })
673
674
675 def collections(request):
676     objects = Collection.objects.filter(listed=True)
677
678     if len(objects) > 3:
679         best = random.sample(list(objects), 4)
680     else:
681         best = objects
682
683     template_name = 'catalogue/collections.html'
684     return render(request, template_name, {
685         'objects': objects,
686         'best': best,
687         'active_menu_item': 'collections'
688     })
689
690
691 def ridero_cover(request, slug):
692     from librarian.cover import make_cover
693     wldoc = Book.objects.get(slug=slug).wldocument()
694     cover = make_cover(wldoc.book_info, width=980, bleed=20, format='PNG')
695     response = HttpResponse(content_type="image/png")
696     cover.save(response)
697     return response
698
699
700 def get_isbn(request, book_format, slug):
701     book = Book.objects.get(slug=slug)
702     return HttpResponse(book.get_extra_info_json().get('isbn_%s' % book_format))