remove the banner
[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         if len(self.ctx['tags']) == 1 and self.ctx['main_tag'].category == 'author':
257             self.ctx['translation_list'] = self.ctx['main_tag'].book_set.all()
258
259     def get_queryset(self):
260         qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True)
261         qs = qs.exclude(ancestor__in=qs)
262         if self.is_themed:
263             fqs = Fragment.tagged.with_all(self.ctx['fragment_tags'])
264             if self.ctx['work_tags']:
265                 fqs = fqs.filter(
266                     Q(book__in=qs) | Q(book__ancestor__in=qs)
267                 )
268             qs = fqs
269         return qs
270
271     def get_suggested_tags(self, queryset):
272         tag_ids = [t.id for t in self.ctx['tags']]
273         if self.is_themed:
274             related_tags = []
275             current_books = self.get_queryset().values_list('book', flat=True).distinct()
276             containing_books = Book.objects.filter(Q(id__in=current_books) | Q(children__in=current_books))
277
278             related_tags.extend(list(
279                 Tag.objects.usage_for_queryset(
280                     containing_books,
281                 ).exclude(category='set').exclude(pk__in=tag_ids)
282             ))
283             if self.request.user.is_authenticated:
284                 related_tags.extend(list(
285                     Tag.objects.usage_for_queryset(
286                         containing_books
287                     ).filter(
288                         user=self.request.user
289                     ).exclude(name='').exclude(pk__in=tag_ids)
290                 ))
291         else:
292             related_tags = list(get_top_level_related_tags(self.ctx['tags']))
293             if self.request.user.is_authenticated:
294                 qs = Book.tagged.with_all(self.ctx['tags']).filter(findable=True)
295                 related_tags.extend(list(
296                     Tag.objects.usage_for_queryset(
297                         qs
298                     ).filter(
299                         user=self.request.user
300                     ).exclude(name='').exclude(pk__in=tag_ids)
301                 ))
302
303             fragments = Fragment.objects.filter(
304                 Q(book__in=queryset) | Q(book__ancestor__in=queryset)
305             )
306             related_tags.extend(
307                 Tag.objects.usage_for_queryset(
308                     fragments, counts=True
309                 ).filter(category__in=('theme', 'object')).exclude(pk__in=tag_ids)
310             .only('name', 'sort_key', 'category', 'slug'))
311
312         return related_tags
313
314     
315     
316 def object_list(request, objects, fragments=None, related_tags=None, tags=None,
317                 list_type='books', extra=None):
318     if not tags:
319         tags = []
320     tag_ids = [tag.pk for tag in tags]
321
322     related_tag_lists = []
323     if related_tags:
324         related_tag_lists.append(related_tags)
325     else:
326         related_tag_lists.append(
327             Tag.objects.usage_for_queryset(
328                 objects, counts=True
329             ).exclude(category='set').exclude(pk__in=tag_ids))
330         if request.user.is_authenticated:
331             related_tag_lists.append(
332                 Tag.objects.usage_for_queryset(
333                     objects, counts=True
334                 ).filter(
335                     user=request.user
336                 ).exclude(name='').exclude(pk__in=tag_ids)
337             )
338     if not (extra and extra.get('theme_is_set')):
339         if fragments is None:
340             if list_type == 'gallery':
341                 fragments = PictureArea.objects.filter(picture__in=objects)
342             else:
343                 fragments = Fragment.objects.filter(book__in=objects)
344         related_tag_lists.append(
345             Tag.objects.usage_for_queryset(
346                 fragments, counts=True
347             ).filter(category='theme').exclude(pk__in=tag_ids)
348             .only('name', 'sort_key', 'category', 'slug'))
349         if isinstance(objects, QuerySet):
350             objects = prefetch_relations(objects, 'author')
351
352     
353     categories = split_tags(*related_tag_lists)
354     suggest = []
355     for c in ['set', 'author', 'epoch', 'kind', 'genre']:
356         suggest.extend(sorted(categories[c], key=lambda t: -t.count))
357
358     objects = list(objects)
359
360     if not objects and len(tags) == 1 and list_type == 'books':
361         if PictureArea.tagged.with_any(tags).exists() or Picture.tagged.with_any(tags).exists():
362             return redirect('tagged_object_list_gallery', '/'.join(tag.url_chunk for tag in tags))
363
364     if len(objects) > 3:
365         best = random.sample(objects, 3)
366     else:
367         best = objects
368
369     result = {
370         'object_list': objects,
371         'categories': categories,
372         'suggest': suggest,
373         'list_type': list_type,
374         'tags': tags,
375         'main_tag': tags[0] if tags else None,
376
377         'formats_form': forms.DownloadFormatsForm(),
378         'best': best,
379         'active_menu_item': list_type,
380     }
381     if extra:
382         result.update(extra)
383
384     has_theme = any(((theme := x).category == 'theme' for x in tags))
385     if has_theme:
386         result['main_tag'] = theme
387         template = 'catalogue/theme_detail.html'
388     else:
389         template = 'catalogue/author_detail.html'
390         
391     return render(
392         request, template, result,
393     )
394
395
396 class ResponseInstead(Exception):
397     def __init__(self, response):
398         super(ResponseInstead, self).__init__()
399         self.response = response
400
401
402 def analyse_tags(request, tag_str):
403     try:
404         tags = Tag.get_tag_list(tag_str)
405     except Tag.DoesNotExist:
406         # Perhaps the user is asking about an author in Public Domain
407         # counter (they are not represented in tags)
408         chunks = tag_str.split('/')
409         if len(chunks) == 2 and chunks[0] == 'autor':
410             raise ResponseInstead(pdcounter_views.author_detail(request, chunks[1]))
411         raise Http404
412     except Tag.MultipleObjectsReturned as e:
413         # Ask the user to disambiguate
414         raise ResponseInstead(differentiate_tags(request, e.tags, e.ambiguous_slugs))
415     except Tag.UrlDeprecationWarning as e:
416         raise ResponseInstead(HttpResponsePermanentRedirect(
417             reverse('tagged_object_list', args=['/'.join(tag.url_chunk for tag in e.tags)])))
418
419     if not tags:
420         raise Http404
421     try:
422         if len(tags) > settings.MAX_TAG_LIST:
423             raise Http404
424     except AttributeError:
425         pass
426
427     return tags
428
429
430 def theme_list(request, tags, list_type):
431     shelf_tags = [tag for tag in tags if tag.category == 'set']
432     fragment_tags = [tag for tag in tags if tag.category != 'set']
433     if list_type == 'gallery':
434         fragments = PictureArea.tagged.with_all(fragment_tags)
435     else:
436         fragments = Fragment.tagged.with_all(fragment_tags)
437
438     if shelf_tags:
439         # TODO: Pictures on shelves not supported yet.
440         books = Book.tagged.with_all(shelf_tags).order_by()
441         fragments = fragments.filter(Q(book__in=books) | Q(book__ancestor__in=books))
442     elif list_type == 'books':
443         fragments = fragments.filter(book__findable=True)
444
445     if not fragments and len(tags) == 1 and list_type == 'books':
446         if PictureArea.tagged.with_any(tags).exists() or Picture.tagged.with_any(tags).exists():
447             return redirect('tagged_object_list_gallery', '/'.join(tag.url_chunk for tag in tags))
448
449     return object_list(request, fragments, tags=tags, list_type=list_type, extra={
450         'theme_is_set': True,
451         'active_menu_item': 'theme',
452     })
453
454
455 def tagged_object_list(request, tags, list_type):
456     if list_type in ('books', 'audiobooks'):
457         return TaggedObjectList.as_view()(request, tags=tags)
458
459     try:
460         tags = analyse_tags(request, tags)
461     except ResponseInstead as e:
462         return e.response
463
464     if list_type == 'gallery' and any(tag.category == 'set' for tag in tags):
465         raise Http404
466
467     if any(tag.category in ('theme', 'thing') for tag in tags):
468         return theme_list(request, tags, list_type=list_type)
469
470     if list_type == 'books':
471         books = Book.tagged.with_all(tags)
472
473         if any(tag.category == 'set' for tag in tags):
474             params = {'objects': books}
475         else:
476             books = books.filter(findable=True)
477             params = {
478                 'objects': Book.tagged_top_level(tags).filter(findable=True),
479                 'fragments': Fragment.objects.filter(book__in=books),
480                 'related_tags': get_top_level_related_tags(tags),
481             }
482     elif list_type == 'gallery':
483         params = {'objects': Picture.tagged.with_all(tags)}
484     elif list_type == 'audiobooks':
485         audiobooks = Book.objects.filter(findable=True, media__type__in=('mp3', 'ogg')).distinct()
486         params = {
487             'objects': Book.tagged.with_all(tags, audiobooks),
488             'extra': {
489                 'daisy': Book.tagged.with_all(
490                     tags, audiobooks.filter(media__type='daisy').distinct()
491                 ),
492             }
493         }
494     else:
495         raise Http404
496
497     return object_list(request, tags=tags, list_type=list_type, **params)
498
499
500 def book_fragments(request, slug, theme_slug):
501     book = get_object_or_404(Book, slug=slug)
502     theme = get_object_or_404(Tag, slug=theme_slug, category='theme')
503     fragments = Fragment.tagged.with_all([theme]).filter(
504         Q(book=book) | Q(book__ancestor=book))
505
506     template_name = 'catalogue/book_fragments.html'
507     return render(
508         request,
509         template_name,
510         {
511             'book': book,
512             'theme': theme,
513             'fragments': fragments,
514             'active_menu_item': 'books',
515         })
516
517
518 @never_cache
519 def book_detail(request, slug):
520     try:
521         book = Book.objects.get(slug=slug)
522     except Book.DoesNotExist:
523         return pdcounter_views.book_stub_detail(request, slug)
524
525     return render(
526         request,
527         'catalogue/book_detail.html',
528         {
529             'book': book,
530             'accessible': book.is_accessible_to(request.user),
531             'book_children': book.children.all().order_by('parent_number', 'sort_key'),
532             'active_menu_item': 'books',
533             'club': Club.objects.first() if book.preview else None,
534             'donation_form': DonationStep1Form(),
535         })
536
537
538 def book_text(request, slug):
539     book = get_object_or_404(Book, slug=slug)
540
541     if not book.is_accessible_to(request.user):
542         return HttpResponseRedirect(book.get_absolute_url())
543
544     if not book.has_html_file():
545         raise Http404
546     with book.html_file.open('r') as f:
547         book_text = f.read()
548
549     return render(request, 'catalogue/book_text.html', {
550         'book': book,
551         'extra_info': book.get_extra_info_json(),
552         'book_text': book_text,
553         'inserts': DynamicTextInsert.get_all(request)
554     })
555
556
557 # =========
558 # = Admin =
559 # =========
560 @login_required
561 @staff_required
562 def import_book(request):
563     """docstring for import_book"""
564     book_import_form = forms.BookImportForm(request.POST, request.FILES)
565     if book_import_form.is_valid():
566         try:
567             book_import_form.save()
568         except:
569             import sys
570             import pprint
571             import traceback
572             info = sys.exc_info()
573             exception = pprint.pformat(info[1])
574             tb = '\n'.join(traceback.format_tb(info[2]))
575             return HttpResponse(
576                 "Błąd: %(exception)s\n\n%(tb)s" % {
577                     'exception': exception, 'tb': tb
578                 },
579                 content_type='text/plain'
580             )
581         return HttpResponse("Książka zaimportowana")
582     return HttpResponse("Błąd podczas importowania pliku: %r" % book_import_form.errors)
583
584
585 # info views for API
586
587 def book_info(request, book_id, lang='pl'):
588     book = get_object_or_404(Book, id=book_id)
589     # set language by hand
590     translation.activate(lang)
591     return render(request, 'catalogue/book_info.html', {'book': book})
592
593
594 def tag_info(request, tag_id):
595     tag = get_object_or_404(Tag, id=tag_id)
596     return HttpResponse(tag.description)
597
598
599 @never_cache
600 def embargo_link(request, key, format_, slug):
601     book = get_object_or_404(Book, slug=slug)
602     if format_ not in Book.formats:
603         raise Http404
604     if key != book.preview_key:
605         raise Http404
606     media_file = book.get_media(format_)
607     if not book.preview:
608         return HttpResponseRedirect(media_file.url)
609     return HttpResponse(media_file, content_type=constants.EBOOK_CONTENT_TYPES[format_])
610
611
612 def download_zip(request, file_format=None, media_format=None, slug=None):
613     if file_format:
614         url = Book.zip_format(file_format)
615     elif media_format and slug is not None:
616         book = get_object_or_404(Book, slug=slug)
617         url = book.zip_audiobooks(media_format)
618     else:
619         raise Http404('No format specified for zip package')
620     return HttpResponseRedirect(quote_plus(settings.MEDIA_URL + url, safe='/?='))
621
622
623 class CustomPDFFormView(AjaxableFormView):
624     form_class = forms.CustomPDFForm
625     title = gettext_lazy('Stwórz własny PDF')
626     submit = gettext_lazy('Pobierz')
627     template = 'catalogue/custom_pdf_form.html'
628     honeypot = True
629
630     def __call__(self, *args, **kwargs):
631         if settings.NO_CUSTOM_PDF:
632             raise Http404('Custom PDF is disabled')
633         return super(CustomPDFFormView, self).__call__(*args, **kwargs)
634
635     def form_args(self, request, obj):
636         """Override to parse view args and give additional args to the form."""
637         return (obj,), {}
638
639     def validate_object(self, obj, request):
640         book = obj
641         if not book.is_accessible_to(request.user):
642             return HttpResponseRedirect(book.get_absolute_url())
643         return super(CustomPDFFormView, self).validate_object(obj, request)
644
645     def get_object(self, request, slug, *args, **kwargs):
646         book = get_object_or_404(Book, slug=slug)
647         return book
648
649     def context_description(self, request, obj):
650         return obj.pretty_title()
651
652
653 def tag_catalogue(request, category):
654     if category == 'theme':
655         tags = Tag.objects.usage_for_model(
656             Fragment, counts=True).filter(category='theme')
657     else:
658         tags = list(get_top_level_related_tags((), categories=(category,)))
659
660     described_tags = [tag for tag in tags if tag.description]
661
662     if len(described_tags) > 4:
663         best = random.sample(described_tags, 4)
664     else:
665         best = described_tags
666
667     template_name = 'catalogue/tag_catalogue.html'
668     return render(request, template_name, {
669         'tags': tags,
670         'best': best,
671         'title': constants.CATEGORIES_NAME_PLURAL[category],
672         'whole_category': constants.WHOLE_CATEGORY[category],
673         'active_menu_item': 'theme' if category == 'theme' else None,
674     })
675
676
677 def collections(request):
678     objects = Collection.objects.filter(listed=True)
679
680     if len(objects) > 3:
681         best = random.sample(list(objects), 4)
682     else:
683         best = objects
684
685     template_name = 'catalogue/collections.html'
686     return render(request, template_name, {
687         'objects': objects,
688         'best': best,
689         'active_menu_item': 'collections'
690     })
691
692
693 def ridero_cover(request, slug):
694     from librarian.cover import make_cover
695     wldoc = Book.objects.get(slug=slug).wldocument()
696     cover = make_cover(wldoc.book_info, width=980, bleed=20, format='PNG')
697     response = HttpResponse(content_type="image/png")
698     cover.save(response)
699     return response
700
701
702 def get_isbn(request, book_format, slug):
703     book = Book.objects.get(slug=slug)
704     return HttpResponse(book.get_extra_info_json().get('isbn_%s' % book_format))