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