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