1 # -*- coding: utf-8 -*-
2 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
3 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
7 from django.contrib.sites.models import Site
8 from django.core.urlresolvers import reverse
9 from django.db.models import Q
10 from django.http.response import HttpResponse
11 from django.utils.functional import lazy
12 from django.db import models
13 from migdal.models import Entry
14 from piston.handler import AnonymousBaseHandler, BaseHandler
15 from piston.utils import rc
16 from sorl.thumbnail import default
18 from api.models import BookUserData
19 from catalogue.forms import BookImportForm
20 from catalogue.models import Book, Tag, BookMedia, Fragment, Collection
21 from catalogue.models.tag import prefetch_relations
22 from catalogue.utils import is_subscribed
23 from librarian.cover import WLCover
24 from picture.models import Picture
25 from picture.forms import PictureImportForm
26 from social.utils import likes
28 from stats.utils import piwik_track
29 from wolnelektury.utils import re_escape
31 from . import emitters # Register our emitters
33 API_BASE = WL_BASE = MEDIA_BASE = lazy(
34 lambda: u'https://' + Site.objects.get_current().domain, unicode)()
47 for k, v in category_singular.items():
48 category_plural[v] = k
50 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
52 book_list_fields = book_tag_categories + [
53 'href', 'title', 'url', 'cover', 'cover_thumb', 'slug', 'simple_thumb', 'has_audio', 'cover_color', 'full_sort_key']
56 def read_tags(tags, request, allowed):
57 """ Reads a path of filtering tags.
59 :param str tags: a path of category and slug pairs, like: authors/an-author/...
60 :returns: list of Tag objects
61 :raises: ValueError when tags can't be found
64 def process(category, slug):
65 if category == 'book':
67 books.append(Book.objects.get(slug=slug))
68 except Book.DoesNotExist:
69 raise ValueError('Unknown book.')
71 real_tags.append(Tag.objects.get(category=category, slug=slug))
72 except Tag.DoesNotExist:
73 raise ValueError('Tag not found')
78 tags = tags.strip('/').split('/')
82 category = tags.pop(0)
86 category = category_singular[category]
88 raise ValueError('Unknown category.')
90 if category not in allowed:
91 raise ValueError('Category not allowed.')
92 process(category, slug)
94 for key in request.GET:
95 if key in category_singular:
96 category = category_singular[key]
97 if category in allowed:
98 for slug in request.GET.getlist(key):
99 process(category, slug)
100 return real_tags, books
106 class BookMediaHandler(BaseHandler):
107 """ Responsible for representing media in Books. """
110 fields = ['name', 'type', 'url', 'artist', 'director']
114 """ Link to media on site. """
116 return MEDIA_BASE + media.file.url
119 def artist(cls, media):
120 return media.extra_info.get('artist_name', '')
123 def director(cls, media):
124 return media.extra_info.get('director_name', '')
127 class BookDetails(object):
128 """Custom fields used for representing Books."""
132 """ Returns an URI for a Book in the API. """
133 return API_BASE + reverse("api_book", args=[book.slug])
137 """ Returns Book's URL on the site. """
138 return WL_BASE + book.get_absolute_url()
141 def children(cls, book):
142 """ Returns all children for a book. """
143 return book.children.order_by('parent_number', 'sort_key')
146 def media(cls, book):
147 """ Returns all media for a book. """
148 return book.media.all()
151 def cover(cls, book):
152 return MEDIA_BASE + book.cover.url if book.cover else ''
155 def cover_thumb(cls, book):
156 return MEDIA_BASE + default.backend.get_thumbnail(
157 book.cover, "139x193").url if book.cover else ''
160 def simple_thumb(cls, book):
161 return MEDIA_BASE + book.cover_api_thumb.url if book.cover_api_thumb else ''
164 def simple_cover(cls, book):
165 return MEDIA_BASE + book.simple_cover.url if book.simple_cover else ''
168 def cover_color(cls, book):
169 return WLCover.epoch_colors.get(book.extra_info.get('epoch'), '#000000')
172 def full_sort_key(cls, book):
173 return '%s%s%s%s%s' % (book.sort_key_author, SORT_KEY_SEP, book.sort_key, SORT_KEY_SEP, book.id)
176 def books_after(books, after, new_api):
178 return books.filter(slug__gt=after)
180 author, title, book_id = after.split(SORT_KEY_SEP)
183 return books.filter(Q(sort_key_author__gt=author)
184 | (Q(sort_key_author=author) & Q(sort_key__gt=title))
185 | (Q(sort_key_author=author) & Q(sort_key=title) & Q(id__gt=int(book_id))))
188 def order_books(books, new_api):
190 return books.order_by('sort_key_author', 'sort_key', 'id')
192 return books.order_by('slug')
195 class BookDetailHandler(BaseHandler, BookDetails):
196 """ Main handler for Book objects.
198 Responsible for single Book details.
200 allowed_methods = ['GET']
201 fields = ['title', 'parent', 'children'] + Book.formats + [
202 'media', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'simple_cover', 'fragment_data', 'audio_length',
203 'preview', 'cover_color'] + [
204 category_plural[c] for c in book_tag_categories]
207 def read(self, request, book):
208 """ Returns details of a book, identified by a slug and lang. """
210 return Book.objects.get(slug=book)
211 except Book.DoesNotExist:
215 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
216 """ Main handler for Book objects.
218 Responsible for lists of Book objects.
220 allowed_methods = ('GET',)
222 fields = book_list_fields
225 def genres(cls, book):
226 """ Returns all media for a book. """
227 return book.tags.filter(category='genre')
230 def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None,
231 recommended=False, newest=False, books=None,
232 after=None, count=None):
233 """ Lists all books with given tags.
235 :param tags: filtering tags; should be a path of categories
236 and slugs, i.e.: authors/an-author/epoch/an-epoch/
237 :param top_level: if True and a book is included in the results,
238 it's children are aren't. By default all books matching the tags
243 return Book.objects.get(pk=pk)
244 except Book.DoesNotExist:
248 tags, _ancestors = read_tags(tags, request, allowed=book_tag_categories)
252 new_api = request.GET.get('new_api')
253 if 'after' in request.GET:
254 after = request.GET['after']
255 if 'count' in request.GET:
256 count = request.GET['count']
260 books = Book.tagged_top_level(tags)
261 return books if books else rc.NOT_FOUND
263 books = Book.tagged.with_all(tags)
265 books = books if books is not None else Book.objects.all()
266 books = self.order_books(books, new_api)
269 books = books.filter(parent=None)
271 books = books.filter(media__type='mp3').distinct()
273 books = books.filter(media__type='daisy').distinct()
275 books = books.filter(recommended=True)
277 books = books.order_by('-created_at')
280 books = self.books_after(books, after, new_api)
283 books = books.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key', 'sort_key_author')
285 books = books.only('slug', 'title', 'cover', 'cover_thumb')
286 for category in book_tag_categories:
287 books = prefetch_relations(books, category)
290 books = books[:count]
294 def create(self, request, *args, **kwargs):
298 class BooksHandler(BookDetailHandler):
299 allowed_methods = ('GET', 'POST')
301 fields = book_list_fields + ['liked']
302 anonymous = AnonymousBooksHandler
304 # hack, because piston is stupid
306 def liked(cls, book):
307 return getattr(book, 'liked', None)
309 def read(self, request, **kwargs):
310 books = AnonymousBooksHandler().read(request, **kwargs)
311 likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
314 BookProxy(book).set('liked', book.id in likes)
316 return QuerySetProxy(new_books)
318 def create(self, request, *args, **kwargs):
319 if not request.user.has_perm('catalogue.add_book'):
322 data = json.loads(request.POST.get('data'))
323 form = BookImportForm(data)
331 class EpubHandler(BookDetailHandler):
332 def read(self, request, slug):
333 if not is_subscribed(request.user):
336 book = Book.objects.get(slug=slug)
337 except Book.DoesNotExist:
339 response = HttpResponse(book.get_media('epub'))
343 class EBooksHandler(AnonymousBooksHandler):
344 fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
347 class BookProxy(models.Model):
351 def __init__(self, book, key=None):
355 def set(self, attr, value):
356 self.__setattr__(attr, value)
359 def __getattr__(self, item):
360 return self.book.__getattribute__(item)
363 class QuerySetProxy(models.QuerySet):
364 def __init__(self, l):
368 return iter(self.list)
371 class AnonFilterBooksHandler(AnonymousBooksHandler):
372 fields = book_list_fields + ['key']
374 def parse_bool(self, s):
375 if s in ('true', 'false'):
380 def read(self, request):
381 search_string = request.GET.get('search')
382 is_lektura = self.parse_bool(request.GET.get('lektura'))
383 is_audiobook = self.parse_bool(request.GET.get('audiobook'))
384 preview = self.parse_bool(request.GET.get('preview'))
386 new_api = request.GET.get('new_api')
391 after = request.GET.get('after')
392 count = int(request.GET.get('count', 50))
393 books = self.order_books(Book.objects.distinct(), new_api)
394 if is_lektura is not None:
395 books = books.filter(has_audience=is_lektura)
396 if is_audiobook is not None:
398 books = books.filter(media__type='mp3')
400 books = books.exclude(media__type='mp3')
401 if preview is not None:
402 books = books.filter(preview=preview)
403 for key in request.GET:
404 if key in category_singular:
405 category = category_singular[key]
406 if category in book_tag_categories:
407 slugs = request.GET[key].split(',')
408 tags = Tag.objects.filter(category=category, slug__in=slugs)
409 books = Book.tagged.with_any(tags, books)
410 if (search_string is not None) and len(search_string) < 3:
413 search_string = re_escape(search_string)
414 books_author = books.filter(cached_author__iregex='\m' + search_string)
415 books_title = books.filter(title__iregex='\m' + search_string)
416 books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True)))
417 if after and (key_sep in after):
418 which, key = after.split(key_sep, 1)
420 book_lists = [(self.books_after(books_title, key, new_api), 'title')]
421 else: # which == 'author'
422 book_lists = [(self.books_after(books_author, key, new_api), 'author'), (books_title, 'title')]
424 book_lists = [(books_author, 'author'), (books_title, 'title')]
426 if after and key_sep in after:
427 which, key = after.split(key_sep, 1)
428 books = self.books_after(books, key, new_api)
429 book_lists = [(books, 'book')]
432 for book_list, label in book_lists:
433 book_list = book_list.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key_author', 'sort_key')
434 for category in book_tag_categories:
435 book_list = prefetch_relations(book_list, category)
436 remaining_count = count - len(filtered_books)
438 BookProxy(book, '%s%s%s' % (
439 label, key_sep, book.slug if not new_api else self.full_sort_key(book)))
440 for book in book_list[:remaining_count]]
441 filtered_books += new_books
442 if len(filtered_books) == count:
445 return QuerySetProxy(filtered_books)
448 class FilterBooksHandler(BooksHandler):
449 anonymous = AnonFilterBooksHandler
450 fields = book_list_fields + ['key', 'liked']
452 # hack, because piston is stupid
454 def liked(cls, book):
455 return getattr(book, 'liked', None)
457 def read(self, request):
458 qsp = AnonFilterBooksHandler().read(request)
459 likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
460 for book in qsp.list:
461 book.set('liked', book.id in likes)
465 class BookPreviewHandler(BookDetailHandler):
466 fields = BookDetailHandler.fields + ['slug']
468 def read(self, request):
469 return Book.objects.filter(preview=True)
472 # add categorized tags fields for Book
473 def _tags_getter(category):
475 def get_tags(cls, book):
476 return book.tags.filter(category=category)
480 def _tag_getter(category):
482 def get_tag(cls, book):
483 return book.tag_unicode(category)
487 def add_tag_getters():
488 for plural, singular in category_singular.items():
489 setattr(BookDetails, plural, _tags_getter(singular))
490 setattr(BookDetails, singular, _tag_getter(singular))
496 # add fields for files in Book
497 def _file_getter(book_format):
501 f_url = book.media_url(book_format)
503 return MEDIA_BASE + f_url
509 def add_file_getters():
510 for book_format in Book.formats:
511 setattr(BookDetails, book_format, _file_getter(book_format))
517 class CollectionDetails(object):
518 """Custom Collection fields."""
521 def href(cls, collection):
522 """ Returns URI in the API for the collection. """
524 return API_BASE + reverse("api_collection", args=[collection.slug])
527 def url(cls, collection):
528 """ Returns URL on the site. """
530 return WL_BASE + collection.get_absolute_url()
533 def books(cls, collection):
534 return Book.objects.filter(collection.get_query())
537 class CollectionDetailHandler(BaseHandler, CollectionDetails):
538 allowed_methods = ('GET',)
539 fields = ['url', 'title', 'description', 'books']
542 def read(self, request, slug):
543 """ Returns details of a collection, identified by slug. """
545 return Collection.objects.get(slug=slug)
546 except Collection.DoesNotExist:
550 class CollectionsHandler(BaseHandler, CollectionDetails):
551 allowed_methods = ('GET',)
553 fields = ['url', 'href', 'title']
556 def read(self, request):
557 """ Returns all collections. """
558 return Collection.objects.all()
561 class TagDetails(object):
562 """Custom Tag fields."""
566 """ Returns URI in the API for the tag. """
568 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
572 """ Returns URL on the site. """
574 return WL_BASE + tag.get_absolute_url()
577 class TagDetailHandler(BaseHandler, TagDetails):
578 """ Responsible for details of a single Tag object. """
580 fields = ['name', 'url', 'sort_key', 'description']
583 def read(self, request, category, slug):
584 """ Returns details of a tag, identified by category and slug. """
587 category_sng = category_singular[category]
592 return Tag.objects.get(category=category_sng, slug=slug)
593 except Tag.DoesNotExist:
597 class TagsHandler(BaseHandler, TagDetails):
598 """ Main handler for Tag objects.
600 Responsible for lists of Tag objects
601 and fields used for representing Tags.
604 allowed_methods = ('GET',)
606 fields = ['name', 'href', 'url', 'slug']
609 def read(self, request, category=None, pk=None):
610 """ Lists all tags in the category (eg. all themes). """
613 return Tag.objects.exclude(category='set').get(pk=pk)
614 except Book.DoesNotExist:
618 category_sng = category_singular[category]
622 after = request.GET.get('after')
623 count = request.GET.get('count')
625 tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
627 book_only = request.GET.get('book_only') == 'true'
628 picture_only = request.GET.get('picture_only') == 'true'
630 tags = tags.filter(for_books=True)
632 tags = tags.filter(for_pictures=True)
635 tags = tags.filter(slug__gt=after)
643 class FragmentDetails(object):
644 """Custom Fragment fields."""
647 def href(cls, fragment):
648 """ Returns URI in the API for the fragment. """
650 return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
653 def url(cls, fragment):
654 """ Returns URL on the site for the fragment. """
656 return WL_BASE + fragment.get_absolute_url()
659 def themes(cls, fragment):
660 """ Returns a list of theme tags for the fragment. """
662 return fragment.tags.filter(category='theme')
665 class FragmentDetailHandler(BaseHandler, FragmentDetails):
666 fields = ['book', 'anchor', 'text', 'url', 'themes']
669 def read(self, request, book, anchor):
670 """ Returns details of a fragment, identified by book slug and anchor. """
672 return Fragment.objects.get(book__slug=book, anchor=anchor)
673 except Fragment.DoesNotExist:
677 class FragmentsHandler(BaseHandler, FragmentDetails):
678 """ Main handler for Fragments.
680 Responsible for lists of Fragment objects
681 and fields used for representing Fragments.
685 fields = ['book', 'url', 'anchor', 'href']
686 allowed_methods = ('GET',)
688 categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
691 def read(self, request, tags):
692 """ Lists all fragments with given book, tags, themes.
694 :param tags: should be a path of categories and slugs, i.e.:
695 books/book-slug/authors/an-author/themes/a-theme/
699 tags, ancestors = read_tags(tags, request, allowed=self.categories)
702 fragments = Fragment.tagged.with_all(tags).select_related('book')
703 if fragments.exists():
709 class PictureHandler(BaseHandler):
711 fields = ('slug', 'title')
712 allowed_methods = ('POST',)
714 def create(self, request):
715 if not request.user.has_perm('picture.add_picture'):
718 data = json.loads(request.POST.get('data'))
719 form = PictureImportForm(data)
727 class UserDataHandler(BaseHandler):
729 fields = ('state', 'username', 'premium')
730 allowed_methods = ('GET', 'POST')
732 def read(self, request, slug=None):
733 if not request.user.is_authenticated():
736 return {'username': request.user.username, 'premium': is_subscribed(request.user)}
738 book = Book.objects.get(slug=slug)
739 except Book.DoesNotExist:
742 data = BookUserData.objects.get(book=book, user=request.user)
743 except BookUserData.DoesNotExist:
744 return {'state': 'not_started'}
747 def create(self, request, slug, state):
749 book = Book.objects.get(slug=slug)
750 except Book.DoesNotExist:
752 if not request.user.is_authenticated():
754 if state not in ('reading', 'complete'):
756 data, created = BookUserData.objects.get_or_create(book=book, user=request.user)
762 class UserShelfHandler(BookDetailHandler):
763 fields = book_list_fields + ['liked']
765 def parse_bool(self, s):
766 if s in ('true', 'false'):
771 # hack, because piston is stupid
773 def liked(cls, book):
774 return getattr(book, 'liked', None)
776 def read(self, request, state):
777 if not request.user.is_authenticated():
779 likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
780 if state not in ('reading', 'complete', 'likes'):
782 new_api = request.GET.get('new_api')
783 after = request.GET.get('after')
784 count = int(request.GET.get('count', 50))
786 books = Book.tagged.with_any(request.user.tag_set.all())
788 ids = BookUserData.objects.filter(user=request.user, complete=state == 'complete')\
789 .values_list('book_id', flat=True)
790 books = Book.objects.filter(id__in=list(ids)).distinct()
791 books = self.order_books(books, new_api)
793 books = self.books_after(books, after, new_api)
795 books = books[:count]
798 new_books.append(BookProxy(book).set('liked', book.id in likes))
799 return QuerySetProxy(new_books)
802 class UserLikeHandler(BaseHandler):
804 allowed_methods = ('GET', 'POST')
806 def read(self, request, slug):
807 if not request.user.is_authenticated():
810 book = Book.objects.get(slug=slug)
811 except Book.DoesNotExist:
813 return {'likes': likes(request.user, book)}
815 def create(self, request, slug):
816 if not request.user.is_authenticated():
819 book = Book.objects.get(slug=slug)
820 except Book.DoesNotExist:
822 action = request.GET.get('action', 'like')
824 book.like(request.user)
825 elif action == 'unlike':
826 book.unlike(request.user)
830 class BlogEntryHandler(BaseHandler):
833 'title', 'lead', 'body', 'place', 'time', 'image_url', 'image_thumb', 'gallery_urls', 'type', 'key', 'url')
835 def read(self, request):
836 after = request.GET.get('after')
837 count = int(request.GET.get('count', 20))
838 entries = Entry.published_objects.filter(in_stream=True).order_by('-first_published_at')
840 entries = entries.filter(first_published_at__lt=after)
842 entries = entries[:count]
846 def image_url(cls, entry):
847 return (WL_BASE + entry.image.url) if entry.image else None
850 def image_thumb(cls, entry):
851 return MEDIA_BASE + default.backend.get_thumbnail(
852 entry.image, "193x193").url if entry.image else ''
855 def gallery_urls(cls, entry):
856 return [WL_BASE + photo.url() for photo in entry.photo_set.all()]
860 return entry.first_published_at
864 return WL_BASE + entry.get_absolute_url()