1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
4 from collections import OrderedDict
7 from urllib.parse import quote_plus
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 as _, gettext_lazy
18 from django.views.decorators.cache import never_cache
19 from django.views.generic import TemplateView
21 from ajaxable.utils import AjaxableFormView
22 from club.forms import ScheduleForm, 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
36 staff_required = user_passes_test(lambda user: user.is_staff)
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',
48 def book_list(request, filters=None, template_name='catalogue/book_list.html',
49 nav_template_name='catalogue/snippets/book_list_nav.html',
50 list_template_name='catalogue/snippets/book_list.html'):
51 """ generates a listing of all books, optionally filtered """
52 books_by_author, orphans, books_by_parent = Book.book_list(filters)
53 books_nav = OrderedDict()
54 for tag in books_by_author:
55 if books_by_author[tag]:
56 books_nav.setdefault(tag.sort_key[0], []).append(tag)
57 return render(request, template_name, {
58 'rendered_nav': render_to_string(nav_template_name, {'books_nav': books_nav}),
59 'rendered_book_list': render_to_string(list_template_name, {
60 'books_by_author': books_by_author,
62 'books_by_parent': books_by_parent,
67 def daisy_list(request):
68 if request.EXPERIMENTS['layout'].value:
69 return object_list(request, Book.objects.filter(media__type='daisy'))
70 return book_list(request, Q(media__type='daisy'), template_name='catalogue/daisy_list.html')
73 def collection(request, slug):
74 coll = get_object_or_404(Collection, slug=slug)
75 if request.EXPERIMENTS['layout'].value:
76 template_name = 'catalogue/2022/collection.html'
78 template_name = 'catalogue/collection.html'
79 return render(request, template_name, {
81 'active_menu_item': 'collections',
85 def differentiate_tags(request, tags, ambiguous_slugs):
86 beginning = '/'.join(tag.url_chunk for tag in tags)
87 unparsed = '/'.join(ambiguous_slugs[1:])
89 for tag in Tag.objects.filter(slug=ambiguous_slugs[0]):
91 'url_args': '/'.join((beginning, tag.url_chunk, unparsed)).strip('/'),
96 'catalogue/differentiate_tags.html',
97 {'tags': tags, 'options': options, 'unparsed': ambiguous_slugs[1:]}
101 from django.db.models import FilteredRelation, Q
102 from django.views.decorators.cache import cache_control
103 from django.views.decorators.vary import vary_on_headers
104 from django.utils.decorators import method_decorator
108 vary_on_headers('X-Requested-With'),
109 cache_control(max_age=1),
111 class ObjectListView(TemplateView):
113 themed_page_size = 10
114 item_template_name = ''
116 default_ordering = None
119 self.is_themed = False
123 def dispatch(self, *args, **kwargs):
126 except ResponseInstead as e:
128 return super().dispatch(*args, **kwargs)
130 def get_orderings(self):
131 order = self.get_order()
136 "active": k == order,
137 "default": v[0] is None,
139 for k, v in self.orderings.items()
143 order = self.request.GET.get('order')
144 if order not in self.orderings:
145 order = self.default_ordering
149 order_tag = self.get_order()
151 order = self.orderings[order_tag]
154 qs = qs.order_by(order_by)
157 def search(self, qs):
160 def get_template_names(self):
161 if is_ajax(self.request) or self.request.GET.get('dyn'):
163 return self.dynamic_themed_template_name
165 return self.dynamic_template_name
168 return self.themed_template_name
170 return self.template_name
172 def get_context_data(self, **kwargs):
173 ctx = super().get_context_data()
175 qs = self.get_queryset()
179 ctx['object_list'] = qs
180 ctx['suggested_tags'] = self.get_suggested_tags(qs)
181 ctx['suggested_tags_by_category'] = split_tags(ctx['suggested_tags'])
185 class BookList(ObjectListView):
186 title = gettext_lazy('Literature')
188 template_name = 'catalogue/2022/book_list.html'
189 dynamic_template_name = 'catalogue/2022/dynamic_book_list.html'
190 themed_template_name = 'catalogue/2022/themed_book_list.html'
191 dynamic_themed_template_name = 'catalogue/2022/dynamic_themed_book_list.html'
194 'pop': ('-popularity__count', 'najpopularniejsze'),
195 'alpha': (None, 'alfabetycznie'),
197 default_ordering = 'alpha'
199 def get_queryset(self):
200 return Book.objects.filter(parent=None, findable=True)
202 def search(self, qs):
203 term = self.request.GET.get('search')
205 meta_rels = TagRelation.objects.filter(tag__category='author')
206 # TODO: search tags in currently displaying language
208 rels = meta_rels.filter(tag__name_pl__icontains=term)
210 Q(book__title__icontains=term) |
211 Q(tag_relations__in=rels) |
212 Q(text__icontains=term)
216 meta=FilteredRelation('tag_relations', condition=Q(tag_relations__in=meta_rels))
218 qs = qs.filter(Q(title__icontains=term) | Q(meta__tag__name_pl__icontains=term)).distinct()
222 class ArtList(ObjectListView):
223 template_name = 'catalogue/2022/book_list.html'
224 dynamic_template_name = 'catalogue/2022/dynamic_book_list.html'
225 title = gettext_lazy('Art')
226 list_type = 'gallery'
228 def get_queryset(self):
229 return Picture.objects.all()
231 def search(self, qs):
232 term = self.request.GET.get('search')
234 qs = qs.filter(Q(title__icontains=term) | Q(tag_relations__tag__name_pl__icontains=term)).distinct()
238 class LiteratureView(BookList):
239 def get_suggested_tags(self, queryset):
240 tags = list(get_top_level_related_tags([]))
241 tags.sort(key=lambda t: -t.count)
242 if self.request.user.is_authenticated:
243 tags.extend(list(Tag.objects.filter(user=self.request.user).exclude(name='')))
247 class AudiobooksView(LiteratureView):
248 title = gettext_lazy('Audiobooks')
249 list_type = 'audiobooks'
251 def get_queryset(self):
252 return Book.objects.filter(findable=True, media__type='mp3').distinct()
255 class GalleryView(ArtList):
256 def get_suggested_tags(self, queryset):
257 return Tag.objects.usage_for_queryset(
260 ).exclude(pk__in=[t.id for t in self.ctx['tags']]).order_by('-count')
263 class TaggedObjectList(BookList):
266 self.ctx['tags'] = analyse_tags(self.request, self.kwargs['tags'])
267 self.ctx['fragment_tags'] = [t for t in self.ctx['tags'] if t.category in ('theme', 'object')]
268 self.ctx['work_tags'] = [t for t in self.ctx['tags'] if t not in self.ctx['fragment_tags']]
269 self.is_themed = self.ctx['has_theme'] = bool(self.ctx['fragment_tags'])
271 self.ctx['main_tag'] = self.ctx['fragment_tags'][0]
272 elif self.ctx['tags']:
273 self.ctx['main_tag'] = self.ctx['tags'][0]
275 self.ctx['main_tag'] = None
276 self.ctx['filtering_tags'] = [
277 t for t in self.ctx['tags']
278 if t is not self.ctx['main_tag']
281 def get_queryset(self):
282 qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True)
283 qs = qs.exclude(ancestor__in=qs)
285 fqs = Fragment.tagged.with_all(self.ctx['fragment_tags'])
286 if self.ctx['work_tags']:
288 Q(book__in=qs) | Q(book__ancestor__in=qs)
293 def get_suggested_tags(self, queryset):
294 tag_ids = [t.id for t in self.ctx['tags']]
297 current_books = self.get_queryset().values_list('book', flat=True).distinct()
298 containing_books = Book.objects.filter(Q(id__in=current_books) | Q(children__in=current_books))
300 related_tags.extend(list(
301 Tag.objects.usage_for_queryset(
303 ).exclude(category='set').exclude(pk__in=tag_ids)
305 if self.request.user.is_authenticated:
306 related_tags.extend(list(
307 Tag.objects.usage_for_queryset(
310 user=self.request.user
311 ).exclude(name='').exclude(pk__in=tag_ids)
314 related_tags = list(get_top_level_related_tags(self.ctx['tags']))
315 if self.request.user.is_authenticated:
316 qs = Book.tagged.with_all(self.ctx['tags']).filter(findable=True)
317 related_tags.extend(list(
318 Tag.objects.usage_for_queryset(
321 user=self.request.user
322 ).exclude(name='').exclude(pk__in=tag_ids)
325 fragments = Fragment.objects.filter(
326 Q(book__in=queryset) | Q(book__ancestor__in=queryset)
329 Tag.objects.usage_for_queryset(
330 fragments, counts=True
331 ).filter(category__in=('theme', 'object')).exclude(pk__in=tag_ids)
332 .only('name', 'sort_key', 'category', 'slug'))
338 def object_list(request, objects, fragments=None, related_tags=None, tags=None,
339 list_type='books', extra=None):
342 tag_ids = [tag.pk for tag in tags]
344 related_tag_lists = []
346 related_tag_lists.append(related_tags)
348 related_tag_lists.append(
349 Tag.objects.usage_for_queryset(
351 ).exclude(category='set').exclude(pk__in=tag_ids))
352 if request.user.is_authenticated:
353 related_tag_lists.append(
354 Tag.objects.usage_for_queryset(
358 ).exclude(name='').exclude(pk__in=tag_ids)
360 if not (extra and extra.get('theme_is_set')):
361 if fragments is None:
362 if list_type == 'gallery':
363 fragments = PictureArea.objects.filter(picture__in=objects)
365 fragments = Fragment.objects.filter(book__in=objects)
366 related_tag_lists.append(
367 Tag.objects.usage_for_queryset(
368 fragments, counts=True
369 ).filter(category='theme').exclude(pk__in=tag_ids)
370 .only('name', 'sort_key', 'category', 'slug'))
371 if isinstance(objects, QuerySet):
372 objects = prefetch_relations(objects, 'author')
375 categories = split_tags(*related_tag_lists)
377 for c in ['set', 'author', 'epoch', 'kind', 'genre']:
378 suggest.extend(sorted(categories[c], key=lambda t: -t.count))
380 objects = list(objects)
382 if not objects and len(tags) == 1 and list_type == 'books':
383 if PictureArea.tagged.with_any(tags).exists() or Picture.tagged.with_any(tags).exists():
384 return redirect('tagged_object_list_gallery', '/'.join(tag.url_chunk for tag in tags))
387 best = random.sample(objects, 3)
392 'object_list': objects,
393 'categories': categories,
395 'list_type': list_type,
397 'main_tag': tags[0] if tags else None,
399 'formats_form': forms.DownloadFormatsForm(),
401 'active_menu_item': list_type,
406 if request.EXPERIMENTS['layout'].value:
407 has_theme = any(((theme := x).category == 'theme' for x in tags))
409 result['main_tag'] = theme
410 template = 'catalogue/2022/theme_detail.html'
412 template = 'catalogue/2022/author_detail.html'
414 template = 'catalogue/tagged_object_list.html'
417 request, template, result,
421 def literature(request):
422 if request.EXPERIMENTS['layout'].value:
423 return LiteratureView.as_view()(request)
424 books = Book.objects.filter(parent=None, findable=True)
425 return object_list(request, books, related_tags=get_top_level_related_tags([]))
428 def gallery(request):
429 if request.EXPERIMENTS['layout'].value:
430 return GalleryView.as_view()(request)
431 return object_list(request, Picture.objects.all(), list_type='gallery')
434 def audiobooks(request):
435 if request.EXPERIMENTS['layout'].value:
436 return AudiobooksView.as_view()(request)
437 audiobooks = Book.objects.filter(findable=True, media__type__in=('mp3', 'ogg')).distinct()
438 return object_list(request, audiobooks, list_type='audiobooks', extra={
439 'daisy': Book.objects.filter(findable=True, media__type='daisy').distinct(),
443 class ResponseInstead(Exception):
444 def __init__(self, response):
445 super(ResponseInstead, self).__init__()
446 self.response = response
449 def analyse_tags(request, tag_str):
451 tags = Tag.get_tag_list(tag_str)
452 except Tag.DoesNotExist:
453 # Perhaps the user is asking about an author in Public Domain
454 # counter (they are not represented in tags)
455 chunks = tag_str.split('/')
456 if len(chunks) == 2 and chunks[0] == 'autor':
457 raise ResponseInstead(pdcounter_views.author_detail(request, chunks[1]))
459 except Tag.MultipleObjectsReturned as e:
460 # Ask the user to disambiguate
461 raise ResponseInstead(differentiate_tags(request, e.tags, e.ambiguous_slugs))
462 except Tag.UrlDeprecationWarning as e:
463 raise ResponseInstead(HttpResponsePermanentRedirect(
464 reverse('tagged_object_list', args=['/'.join(tag.url_chunk for tag in e.tags)])))
469 if len(tags) > settings.MAX_TAG_LIST:
471 except AttributeError:
477 def theme_list(request, tags, list_type):
478 shelf_tags = [tag for tag in tags if tag.category == 'set']
479 fragment_tags = [tag for tag in tags if tag.category != 'set']
480 if list_type == 'gallery':
481 fragments = PictureArea.tagged.with_all(fragment_tags)
483 fragments = Fragment.tagged.with_all(fragment_tags)
486 # TODO: Pictures on shelves not supported yet.
487 books = Book.tagged.with_all(shelf_tags).order_by()
488 fragments = fragments.filter(Q(book__in=books) | Q(book__ancestor__in=books))
489 elif list_type == 'books':
490 fragments = fragments.filter(book__findable=True)
492 if not fragments and len(tags) == 1 and list_type == 'books':
493 if PictureArea.tagged.with_any(tags).exists() or Picture.tagged.with_any(tags).exists():
494 return redirect('tagged_object_list_gallery', '/'.join(tag.url_chunk for tag in tags))
496 return object_list(request, fragments, tags=tags, list_type=list_type, extra={
497 'theme_is_set': True,
498 'active_menu_item': 'theme',
502 def tagged_object_list(request, tags, list_type):
503 if request.EXPERIMENTS['layout'].value and list_type in ('books', 'audiobooks'):
504 return TaggedObjectList.as_view()(request, tags=tags)
507 tags = analyse_tags(request, tags)
508 except ResponseInstead as e:
511 if list_type == 'gallery' and any(tag.category == 'set' for tag in tags):
514 if any(tag.category in ('theme', 'thing') for tag in tags):
515 return theme_list(request, tags, list_type=list_type)
517 if list_type == 'books':
518 books = Book.tagged.with_all(tags)
520 if any(tag.category == 'set' for tag in tags):
521 params = {'objects': books}
523 books = books.filter(findable=True)
525 'objects': Book.tagged_top_level(tags).filter(findable=True),
526 'fragments': Fragment.objects.filter(book__in=books),
527 'related_tags': get_top_level_related_tags(tags),
529 elif list_type == 'gallery':
530 params = {'objects': Picture.tagged.with_all(tags)}
531 elif list_type == 'audiobooks':
532 audiobooks = Book.objects.filter(findable=True, media__type__in=('mp3', 'ogg')).distinct()
534 'objects': Book.tagged.with_all(tags, audiobooks),
536 'daisy': Book.tagged.with_all(
537 tags, audiobooks.filter(media__type='daisy').distinct()
544 return object_list(request, tags=tags, list_type=list_type, **params)
547 def book_fragments(request, slug, theme_slug):
548 book = get_object_or_404(Book, slug=slug)
549 theme = get_object_or_404(Tag, slug=theme_slug, category='theme')
550 fragments = Fragment.tagged.with_all([theme]).filter(
551 Q(book=book) | Q(book__ancestor=book))
553 if request.EXPERIMENTS['layout'].value:
554 template_name = 'catalogue/2022/book_fragments.html'
556 template_name = 'catalogue/book_fragments.html'
564 'fragments': fragments,
565 'active_menu_item': 'books',
570 def book_detail(request, slug):
572 book = Book.objects.get(slug=slug)
573 except Book.DoesNotExist:
574 return pdcounter_views.book_stub_detail(request, slug)
576 new_layout = request.EXPERIMENTS['layout']
580 'catalogue/2022/book_detail.html' if new_layout.value else 'catalogue/book_detail.html',
583 'accessible': book.is_accessible_to(request.user),
584 'book_children': book.children.all().order_by('parent_number', 'sort_key'),
585 'active_menu_item': 'books',
586 'club_form': ScheduleForm() if book.preview else None,
587 'club': Club.objects.first() if book.preview else None,
588 'donation_form': DonationStep1Form(),
592 # używane w publicznym interfejsie
593 def player(request, slug):
594 book = get_object_or_404(Book, slug=slug)
595 if not book.has_media('mp3'):
598 audiobooks, projects, total_duration = book.get_audiobooks()
602 'catalogue/player.html',
606 'audiobooks': audiobooks,
607 'projects': projects,
611 def book_text(request, slug):
612 book = get_object_or_404(Book, slug=slug)
614 if not book.is_accessible_to(request.user):
615 return HttpResponseRedirect(book.get_absolute_url())
617 if not book.has_html_file():
619 with book.html_file.open('r') as f:
622 return render(request, 'catalogue/book_text.html', {
624 'book_text': book_text,
625 'inserts': DynamicTextInsert.get_all(request)
634 def import_book(request):
635 """docstring for import_book"""
636 book_import_form = forms.BookImportForm(request.POST, request.FILES)
637 if book_import_form.is_valid():
639 book_import_form.save()
644 info = sys.exc_info()
645 exception = pprint.pformat(info[1])
646 tb = '\n'.join(traceback.format_tb(info[2]))
648 _("An error occurred: %(exception)s\n\n%(tb)s") % {
649 'exception': exception, 'tb': tb
651 content_type='text/plain'
653 return HttpResponse(_("Book imported successfully"))
654 return HttpResponse(_("Error importing file: %r") % book_import_form.errors)
659 def book_info(request, book_id, lang='pl'):
660 book = get_object_or_404(Book, id=book_id)
661 # set language by hand
662 translation.activate(lang)
663 return render(request, 'catalogue/book_info.html', {'book': book})
666 def tag_info(request, tag_id):
667 tag = get_object_or_404(Tag, id=tag_id)
668 return HttpResponse(tag.description)
672 def embargo_link(request, key, format_, slug):
673 book = get_object_or_404(Book, slug=slug)
674 if format_ not in Book.formats:
676 if key != book.preview_key:
678 media_file = book.get_media(format_)
680 return HttpResponseRedirect(media_file.url)
681 return HttpResponse(media_file, content_type=constants.EBOOK_CONTENT_TYPES[format_])
684 def download_zip(request, file_format=None, media_format=None, slug=None):
686 url = Book.zip_format(file_format)
687 elif media_format and slug is not None:
688 book = get_object_or_404(Book, slug=slug)
689 url = book.zip_audiobooks(media_format)
691 raise Http404('No format specified for zip package')
692 return HttpResponseRedirect(quote_plus(settings.MEDIA_URL + url, safe='/?='))
695 class CustomPDFFormView(AjaxableFormView):
696 form_class = forms.CustomPDFForm
697 title = gettext_lazy('Download custom PDF')
698 submit = gettext_lazy('Download')
699 template = 'catalogue/custom_pdf_form.html'
702 def __call__(self, *args, **kwargs):
703 if settings.NO_CUSTOM_PDF:
704 raise Http404('Custom PDF is disabled')
705 return super(CustomPDFFormView, self).__call__(*args, **kwargs)
707 def form_args(self, request, obj):
708 """Override to parse view args and give additional args to the form."""
711 def validate_object(self, obj, request):
713 if not book.is_accessible_to(request.user):
714 return HttpResponseRedirect(book.get_absolute_url())
715 return super(CustomPDFFormView, self).validate_object(obj, request)
717 def get_object(self, request, slug, *args, **kwargs):
718 book = get_object_or_404(Book, slug=slug)
721 def context_description(self, request, obj):
722 return obj.pretty_title()
725 def tag_catalogue(request, category):
726 if category == 'theme':
727 tags = Tag.objects.usage_for_model(
728 Fragment, counts=True).filter(category='theme')
730 tags = list(get_top_level_related_tags((), categories=(category,)))
732 described_tags = [tag for tag in tags if tag.description]
734 if len(described_tags) > 4:
735 best = random.sample(described_tags, 4)
737 best = described_tags
739 if request.EXPERIMENTS['layout'].value:
740 template_name = 'catalogue/2022/tag_catalogue.html'
742 template_name = 'catalogue/tag_catalogue.html'
744 return render(request, template_name, {
747 'title': constants.CATEGORIES_NAME_PLURAL[category],
748 'whole_category': constants.WHOLE_CATEGORY[category],
749 'active_menu_item': 'theme' if category == 'theme' else None,
753 def collections(request):
754 objects = Collection.objects.filter(listed=True)
757 best = random.sample(list(objects), 4)
761 if request.EXPERIMENTS['layout'].value:
762 template_name = 'catalogue/2022/collections.html'
764 template_name = 'catalogue/collections.html'
766 return render(request, template_name, {
769 'active_menu_item': 'collections'
773 def ridero_cover(request, slug):
774 from librarian.cover import make_cover
775 wldoc = Book.objects.get(slug=slug).wldocument()
776 cover = make_cover(wldoc.book_info, width=980, bleed=20, format='PNG')
777 response = HttpResponse(content_type="image/png")
782 def get_isbn(request, book_format, slug):
783 book = Book.objects.get(slug=slug)
784 return HttpResponse(book.get_extra_info_json().get('isbn_%s' % book_format))