add tag ids
[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         self.ctx.update({
218             'fragment_tags': [],
219             'work_tags': [],
220             'user_lists': [],
221         })
222         for tag in self.ctx['tags']:
223             if isinstance(tag, UserList):
224                 self.ctx['user_lists'].append(tag)
225             elif tag.category == 'theme':
226                 self.ctx['fragment_tags'].append(tag)
227             else:
228                 self.ctx['work_tags'].append(tag)
229
230         self.is_themed = self.ctx['has_theme'] = bool(self.ctx['fragment_tags'])
231         if self.is_themed:
232             self.ctx['main_tag'] = self.ctx['fragment_tags'][0]
233         elif self.ctx['work_tags']:
234             self.ctx['main_tag'] = self.ctx['work_tags'][0]
235         else:
236             self.ctx['main_tag'] = None
237         self.ctx['filtering_tags'] = [
238             t for t in self.ctx['tags']
239             if t is not self.ctx['main_tag']
240         ]
241         if len(self.ctx['tags']) == 1 and self.ctx['main_tag'] is not None and self.ctx['main_tag'].category == 'author':
242             self.ctx['translation_list'] = self.ctx['main_tag'].book_set.all()
243             self.ctx['narrated'] = self.ctx['main_tag'].narrated.all()
244
245     def get_queryset(self):
246         if self.ctx['work_tags']:
247             qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True)
248         else:
249             qs = Book.objects.filter(findable=True)
250         for ul in self.ctx['user_lists']:
251             qs = qs.filter(id__in=[i.id for i in ul.get_books()])
252         qs = qs.exclude(ancestor__in=qs)
253         if self.is_themed:
254             fqs = Fragment.tagged.with_all(self.ctx['fragment_tags'])
255             if self.ctx['work_tags']:
256                 fqs = fqs.filter(
257                     Q(book__in=qs) | Q(book__ancestor__in=qs)
258                 )
259             qs = fqs
260         return qs
261
262     def get_suggested_tags(self, queryset):
263         if self.ctx['user_lists']:
264             # TODO
265             return []
266         tag_ids = [t.id for t in self.ctx['tags']]
267         if self.is_themed:
268             related_tags = []
269             current_books = self.get_queryset().values_list('book', flat=True).distinct()
270             containing_books = Book.objects.filter(Q(id__in=current_books) | Q(children__in=current_books))
271
272             related_tags.extend(list(
273                 Tag.objects.usage_for_queryset(
274                     containing_books,
275                 ).exclude(category='set').exclude(pk__in=tag_ids)
276             ))
277             ### FIXME: These won't be tags
278             if self.request.user.is_authenticated:
279                 related_tags.extend(list(
280                     Tag.objects.usage_for_queryset(
281                         containing_books
282                     ).filter(
283                         user=self.request.user
284                     ).exclude(name='').exclude(pk__in=tag_ids)
285                 ))
286         else:
287             related_tags = list(get_top_level_related_tags(self.ctx['tags']))
288             if self.request.user.is_authenticated:
289                 qs = Book.tagged.with_all(self.ctx['tags']).filter(findable=True)
290                 related_tags.extend(list(
291                     Tag.objects.usage_for_queryset(
292                         qs
293                     ).filter(
294                         user=self.request.user
295                     ).exclude(name='').exclude(pk__in=tag_ids)
296                 ))
297
298             fragments = Fragment.objects.filter(
299                 Q(book__in=queryset) | Q(book__ancestor__in=queryset)
300             )
301             related_tags.extend(
302                 Tag.objects.usage_for_queryset(
303                     fragments, counts=True
304                 ).filter(category__in=('theme', 'object')).exclude(pk__in=tag_ids)
305             .only('name', 'sort_key', 'category', 'slug'))
306
307         return related_tags
308
309     
310     
311 def object_list(request, objects, list_type='books'):
312     related_tag_lists = []
313     if True:
314         related_tag_lists.append(
315             Tag.objects.usage_for_queryset(
316                 objects, counts=True
317             ).exclude(category='set'))
318         ### FIXME: these won't be tags
319         if request.user.is_authenticated:
320             related_tag_lists.append(
321                 Tag.objects.usage_for_queryset(
322                     objects, counts=True
323                 ).filter(
324                     user=request.user
325                 ).exclude(name='')
326             )
327     if True:
328         fragments = Fragment.objects.filter(book__in=objects)
329         related_tag_lists.append(
330             Tag.objects.usage_for_queryset(
331                 fragments, counts=True
332             ).filter(category='theme')
333             .only('name', 'sort_key', 'category', 'slug'))
334         if isinstance(objects, QuerySet):
335             objects = prefetch_relations(objects, 'author')
336
337     categories = split_tags(*related_tag_lists)
338     suggest = []
339     for c in ['set', 'author', 'epoch', 'kind', 'genre']:
340         suggest.extend(sorted(categories[c], key=lambda t: -t.count))
341
342     objects = list(objects)
343
344     result = {
345         'object_list': objects,
346         'suggest': suggest,
347         'list_type': list_type,
348     }
349
350     template = 'catalogue/author_detail.html'
351
352     return render(
353         request, template, result,
354     )
355
356
357 class ResponseInstead(Exception):
358     def __init__(self, response):
359         super(ResponseInstead, self).__init__()
360         self.response = response
361
362
363 def analyse_tags(request, tag_str):
364     try:
365         tags = Tag.get_tag_list(tag_str)
366     except Tag.DoesNotExist:
367         # Perhaps the user is asking about an author in Public Domain
368         # counter (they are not represented in tags)
369         chunks = tag_str.split('/')
370         if len(chunks) == 2 and chunks[0] == 'autor':
371             raise ResponseInstead(pdcounter_views.author_detail(request, chunks[1]))
372         raise Http404
373     except Tag.MultipleObjectsReturned as e:
374         # Ask the user to disambiguate
375         raise ResponseInstead(differentiate_tags(request, e.tags, e.ambiguous_slugs))
376     except Tag.UrlDeprecationWarning as e:
377         raise ResponseInstead(HttpResponsePermanentRedirect(
378             reverse('tagged_object_list', args=['/'.join(tag.url_chunk for tag in e.tags)])))
379
380     if not tags:
381         raise Http404
382     try:
383         if len(tags) > settings.MAX_TAG_LIST:
384             raise Http404
385     except AttributeError:
386         pass
387
388     return tags
389
390
391 def tagged_object_list(request, tags, list_type):
392     return TaggedObjectList.as_view()(request, tags=tags)
393
394
395 def book_fragments(request, slug, theme_slug):
396     book = get_object_or_404(Book, slug=slug)
397     theme = get_object_or_404(Tag, slug=theme_slug, category='theme')
398     fragments = Fragment.tagged.with_all([theme]).filter(
399         Q(book=book) | Q(book__ancestor=book))
400
401     template_name = 'catalogue/book_fragments.html'
402     return render(
403         request,
404         template_name,
405         {
406             'book': book,
407             'theme': theme,
408             'fragments': fragments,
409         })
410
411
412 @never_cache
413 def book_detail(request, slug):
414     try:
415         book = Book.objects.get(slug=slug)
416     except Book.DoesNotExist:
417         return pdcounter_views.book_stub_detail(request, slug)
418
419     return render(
420         request,
421         'catalogue/book_detail.html',
422         {
423             'book': book,
424             'accessible': book.is_accessible_to(request.user),
425             'book_children': book.children.all().order_by('parent_number', 'sort_key'),
426             'club': Club.objects.first() if book.preview else None,
427             'donation_form': DonationStep1Form(),
428         })
429
430
431 def book_text(request, slug):
432     book = get_object_or_404(Book, slug=slug)
433
434     if not book.is_accessible_to(request.user):
435         return HttpResponseRedirect(book.get_absolute_url())
436
437     if not book.has_html_file():
438         raise Http404
439     with book.html_file.open('r') as f:
440         book_text = f.read()
441
442     return render(request, 'catalogue/book_text.html', {
443         'book': book,
444         'extra_info': book.get_extra_info_json(),
445         'book_text': book_text,
446         'inserts': DynamicTextInsert.get_all(request),
447
448         'club': Club.objects.first(),
449         'donation_form': DonationStep1Form(),
450     })
451
452
453 # =========
454 # = Admin =
455 # =========
456 @login_required
457 @staff_required
458 def import_book(request):
459     """docstring for import_book"""
460     book_import_form = forms.BookImportForm(request.POST, request.FILES)
461     if book_import_form.is_valid():
462         try:
463             book_import_form.save()
464         except:
465             import sys
466             import pprint
467             import traceback
468             info = sys.exc_info()
469             exception = pprint.pformat(info[1])
470             tb = '\n'.join(traceback.format_tb(info[2]))
471             return HttpResponse(
472                 "Błąd: %(exception)s\n\n%(tb)s" % {
473                     'exception': exception, 'tb': tb
474                 },
475                 content_type='text/plain'
476             )
477         return HttpResponse("Książka zaimportowana")
478     return HttpResponse("Błąd podczas importowania pliku: %r" % book_import_form.errors)
479
480
481 # info views for API
482
483 def book_info(request, book_id, lang='pl'):
484     book = get_object_or_404(Book, id=book_id)
485     # set language by hand
486     translation.activate(lang)
487     return render(request, 'catalogue/book_info.html', {'book': book})
488
489
490 def tag_info(request, tag_id):
491     tag = get_object_or_404(Tag, id=tag_id)
492     return HttpResponse(tag.description)
493
494
495 @never_cache
496 def embargo_link(request, key, format_, slug):
497     book = get_object_or_404(Book, slug=slug)
498     if format_ not in Book.formats:
499         raise Http404
500     if key != book.preview_key:
501         raise Http404
502     media_file = book.get_media(format_)
503     if not book.preview:
504         return HttpResponseRedirect(media_file.url)
505     return HttpResponse(media_file, content_type=constants.EBOOK_CONTENT_TYPES[format_])
506
507
508 def download_zip(request, file_format=None, media_format=None, slug=None):
509     if file_format:
510         url = Book.zip_format(file_format)
511     elif media_format and slug is not None:
512         book = get_object_or_404(Book, slug=slug)
513         url = book.zip_audiobooks(media_format)
514     else:
515         raise Http404('No format specified for zip package')
516     return HttpResponseRedirect(quote_plus(settings.MEDIA_URL + url, safe='/?='))
517
518
519 class CustomPDFFormView(AjaxableFormView):
520     form_class = forms.CustomPDFForm
521     title = gettext_lazy('Stwórz własny PDF')
522     submit = gettext_lazy('Pobierz')
523     template = 'catalogue/custom_pdf_form.html'
524     honeypot = True
525
526     def __call__(self, *args, **kwargs):
527         if settings.NO_CUSTOM_PDF:
528             raise Http404('Custom PDF is disabled')
529         return super(CustomPDFFormView, self).__call__(*args, **kwargs)
530
531     def form_args(self, request, obj):
532         """Override to parse view args and give additional args to the form."""
533         return (obj,), {}
534
535     def validate_object(self, obj, request):
536         book = obj
537         if not book.is_accessible_to(request.user):
538             return HttpResponseRedirect(book.get_absolute_url())
539         return super(CustomPDFFormView, self).validate_object(obj, request)
540
541     def get_object(self, request, slug, *args, **kwargs):
542         book = get_object_or_404(Book, slug=slug)
543         return book
544
545     def context_description(self, request, obj):
546         return obj.pretty_title()
547
548
549 def tag_catalogue(request, category):
550     if category == 'theme':
551         tags = Tag.objects.usage_for_model(
552             Fragment, counts=True).filter(category='theme')
553     else:
554         tags = list(get_top_level_related_tags((), categories=(category,)))
555
556     described_tags = [tag for tag in tags if tag.description]
557
558     if len(described_tags) > 4:
559         best = random.sample(described_tags, 4)
560     else:
561         best = described_tags
562
563     template_name = 'catalogue/tag_catalogue.html'
564     return render(request, template_name, {
565         'tags': tags,
566         'best': best,
567         'title': constants.CATEGORIES_NAME_PLURAL[category],
568         'whole_category': constants.WHOLE_CATEGORY[category],
569     })
570
571
572 def collections(request):
573     objects = Collection.objects.filter(listed=True)
574
575     if len(objects) > 3:
576         best = random.sample(list(objects), 4)
577     else:
578         best = objects
579
580     template_name = 'catalogue/collections.html'
581     return render(request, template_name, {
582         'objects': objects,
583         'best': best,
584     })
585
586
587 def ridero_cover(request, slug):
588     from librarian.cover import make_cover
589     wldoc = Book.objects.get(slug=slug).wldocument()
590     cover = make_cover(wldoc.book_info, width=980, bleed=20, format='PNG')
591     response = HttpResponse(content_type="image/png")
592     cover.save(response)
593     return response
594
595
596 def get_isbn(request, book_format, slug):
597     book = Book.objects.get(slug=slug)
598     return HttpResponse(book.get_extra_info_json().get('isbn_%s' % book_format))