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