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