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