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