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
21 from catalogue.models.tag import prefetch_relations
22 from paypal.rest import user_is_subscribed
23 from picture.models import Picture
24 from picture.forms import PictureImportForm
25 from social.utils import likes
27 from stats.utils import piwik_track
28 from wolnelektury.utils import re_escape
31 API_BASE = WL_BASE = MEDIA_BASE = lazy(
32 lambda: u'https://' + Site.objects.get_current().domain, unicode)()
43 for k, v in category_singular.items():
44 category_plural[v] = k
46 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
48 book_list_fields = book_tag_categories + [
49 'href', 'title', 'url', 'cover', 'cover_thumb', 'slug', 'simple_thumb', 'has_audio', 'cover_color', 'full_sort_key']
52 def read_tags(tags, request, allowed):
53 """ Reads a path of filtering tags.
55 :param str tags: a path of category and slug pairs, like: authors/an-author/...
56 :returns: list of Tag objects
57 :raises: ValueError when tags can't be found
60 def process(category, slug):
61 if category == 'book':
64 books.append(Book.objects.get(slug=slug))
65 except Book.DoesNotExist:
66 raise ValueError('Unknown book.')
68 real_tags.append(Tag.objects.get(category=category, slug=slug))
69 except Tag.DoesNotExist:
70 raise ValueError('Tag not found')
75 tags = tags.strip('/').split('/')
79 category = tags.pop(0)
83 category = category_singular[category]
85 raise ValueError('Unknown category.')
87 if category not in allowed:
88 raise ValueError('Category not allowed.')
89 process(category, slug)
91 for key in request.GET:
92 if key in category_singular:
93 category = category_singular[key]
94 if category in allowed:
95 for slug in request.GET.getlist(key):
96 process(category, slug)
97 return real_tags, books
106 class BookDetails(object):
107 """Custom fields used for representing Books."""
111 """ Returns an URI for a Book in the API. """
112 return API_BASE + reverse("catalogue_api_book", args=[book.slug])
116 """ Returns Book's URL on the site. """
117 return WL_BASE + book.get_absolute_url()
120 def children(cls, book):
121 """ Returns all children for a book. """
122 return book.children.order_by('parent_number', 'sort_key')
125 def media(cls, book):
126 """ Returns all media for a book. """
127 return book.media.all()
130 def cover(cls, book):
131 return MEDIA_BASE + book.cover.url if book.cover else ''
134 def cover_thumb(cls, book):
135 return MEDIA_BASE + default.backend.get_thumbnail(
136 book.cover, "139x193").url if book.cover else ''
139 def simple_thumb(cls, book):
140 return MEDIA_BASE + book.cover_api_thumb.url if book.cover_api_thumb else ''
143 def simple_cover(cls, book):
144 return MEDIA_BASE + book.simple_cover.url if book.simple_cover else ''
147 def books_after(books, after, new_api):
149 return books.filter(slug__gt=after)
151 author, title, book_id = after.split(Book.SORT_KEY_SEP)
153 return Book.objects.none()
154 return books.filter(Q(sort_key_author__gt=author)
155 | (Q(sort_key_author=author) & Q(sort_key__gt=title))
156 | (Q(sort_key_author=author) & Q(sort_key=title) & Q(id__gt=int(book_id))))
159 def order_books(books, new_api):
161 return books.order_by('sort_key_author', 'sort_key', 'id')
163 return books.order_by('slug')
166 class BookDetailHandler(BaseHandler, BookDetails):
167 """ Main handler for Book objects.
169 Responsible for single Book details.
171 allowed_methods = ['GET']
172 fields = ['title', 'parent', 'children'] + Book.formats + [
173 'media', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'simple_cover', 'fragment_data', 'audio_length',
174 'preview', 'cover_color'] + [
175 category_plural[c] for c in book_tag_categories]
178 def read(self, request, book):
179 """ Returns details of a book, identified by a slug and lang. """
181 return Book.objects.get(slug=book)
182 except Book.DoesNotExist:
186 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
187 """ Main handler for Book objects.
189 Responsible for lists of Book objects.
191 allowed_methods = ('GET',)
193 fields = book_list_fields
197 def genres(cls, book):
198 """ Returns all media for a book. """
199 return book.tags.filter(category='genre')
202 def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None,
203 recommended=False, newest=False, books=None,
204 after=None, count=None):
205 """ Lists all books with given tags.
207 :param tags: filtering tags; should be a path of categories
208 and slugs, i.e.: authors/an-author/epoch/an-epoch/
209 :param top_level: if True and a book is included in the results,
210 it's children are aren't. By default all books matching the tags
216 return Book.objects.get(pk=pk)
217 except Book.DoesNotExist:
221 tags, _ancestors = read_tags(tags, request, allowed=book_tag_categories)
225 new_api = request.GET.get('new_api')
226 if 'after' in request.GET:
227 after = request.GET['after']
228 if 'count' in request.GET:
229 count = request.GET['count']
233 books = Book.tagged_top_level(tags)
234 return books if books else rc.NOT_FOUND
236 books = Book.tagged.with_all(tags)
238 books = books if books is not None else Book.objects.all()
239 books = self.order_books(books, new_api)
242 books = books.filter(parent=None)
244 books = books.filter(media__type='mp3').distinct()
246 books = books.filter(media__type='daisy').distinct()
248 books = books.filter(recommended=True)
250 books = books.order_by('-created_at')
253 books = self.books_after(books, after, new_api)
256 books = books.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key', 'sort_key_author')
258 books = books.only('slug', 'title', 'cover', 'cover_thumb')
259 for category in book_tag_categories:
260 books = prefetch_relations(books, category)
263 books = books[:count]
267 def create(self, request, *args, **kwargs):
271 class BooksHandler(BookDetailHandler):
272 allowed_methods = ('GET', 'POST')
274 fields = book_list_fields + ['liked']
275 anonymous = AnonymousBooksHandler
277 # hack, because piston is stupid
279 def liked(cls, book):
280 return getattr(book, 'liked', None)
282 def read(self, request, **kwargs):
283 books = AnonymousBooksHandler().read(request, **kwargs)
284 likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
287 BookProxy(book).set('liked', book.id in likes)
289 return QuerySetProxy(new_books)
291 def create(self, request, *args, **kwargs):
292 if not request.user.has_perm('catalogue.add_book'):
295 data = json.loads(request.POST.get('data'))
296 form = BookImportForm(data)
304 class EBooksHandler(AnonymousBooksHandler):
305 fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
308 class BookProxy(models.Model):
312 def __init__(self, book, key=None):
316 def set(self, attr, value):
317 self.__setattr__(attr, value)
320 def __getattr__(self, item):
321 return self.book.__getattribute__(item)
324 class QuerySetProxy(models.QuerySet):
325 def __init__(self, l):
329 return iter(self.list)
332 class AnonFilterBooksHandler(AnonymousBooksHandler):
333 fields = book_list_fields + ['key']
335 def parse_bool(self, s):
336 if s in ('true', 'false'):
341 def read(self, request):
343 search_string = request.GET.get('search')
344 is_lektura = self.parse_bool(request.GET.get('lektura'))
345 is_audiobook = self.parse_bool(request.GET.get('audiobook'))
346 preview = self.parse_bool(request.GET.get('preview'))
348 new_api = request.GET.get('new_api')
349 after = request.GET.get('after')
350 count = int(request.GET.get('count', 50))
351 books = self.order_books(Book.objects.distinct(), new_api)
352 if is_lektura is not None:
353 books = books.filter(has_audience=is_lektura)
354 if is_audiobook is not None:
356 books = books.filter(media__type='mp3')
358 books = books.exclude(media__type='mp3')
359 if preview is not None:
360 books = books.filter(preview=preview)
361 for key in request.GET:
362 if key in category_singular:
363 category = category_singular[key]
364 if category in book_tag_categories:
365 slugs = request.GET[key].split(',')
366 tags = Tag.objects.filter(category=category, slug__in=slugs)
367 books = Book.tagged.with_any(tags, books)
368 if (search_string is not None) and len(search_string) < 3:
371 search_string = re_escape(search_string)
372 books_author = books.filter(cached_author__iregex='\m' + search_string)
373 books_title = books.filter(title__iregex='\m' + search_string)
374 books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True)))
375 if after and (key_sep in after):
376 which, key = after.split(key_sep, 1)
378 book_lists = [(self.books_after(books_title, key, new_api), 'title')]
379 else: # which == 'author'
380 book_lists = [(self.books_after(books_author, key, new_api), 'author'), (books_title, 'title')]
382 book_lists = [(books_author, 'author'), (books_title, 'title')]
384 if after and key_sep in after:
385 which, key = after.split(key_sep, 1)
386 books = self.books_after(books, key, new_api)
387 book_lists = [(books, 'book')]
390 for book_list, label in book_lists:
391 book_list = book_list.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key_author', 'sort_key')
392 for category in book_tag_categories:
393 book_list = prefetch_relations(book_list, category)
394 remaining_count = count - len(filtered_books)
396 BookProxy(book, '%s%s%s' % (
397 label, key_sep, book.slug if not new_api else book.full_sort_key()))
398 for book in book_list[:remaining_count]]
399 filtered_books += new_books
400 if len(filtered_books) == count:
403 return QuerySetProxy(filtered_books)
406 class FilterBooksHandler(BooksHandler):
407 anonymous = AnonFilterBooksHandler
408 fields = book_list_fields + ['key', 'liked']
410 # hack, because piston is stupid
412 def liked(cls, book):
413 return getattr(book, 'liked', None)
415 def read(self, request):
416 qsp = AnonFilterBooksHandler().read(request)
417 likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
418 for book in qsp.list:
419 book.set('liked', book.id in likes)
423 class BookPreviewHandler(BookDetailHandler):
424 fields = BookDetailHandler.fields + ['slug']
426 def read(self, request):
427 return Book.objects.filter(preview=True)
430 # add categorized tags fields for Book
431 def _tags_getter(category):
433 def get_tags(cls, book):
434 return book.tags.filter(category=category)
438 def _tag_getter(category):
440 def get_tag(cls, book):
441 return book.tag_unicode(category)
445 def add_tag_getters():
446 for plural, singular in category_singular.items():
447 setattr(BookDetails, plural, _tags_getter(singular))
448 setattr(BookDetails, singular, _tag_getter(singular))
454 # add fields for files in Book
455 def _file_getter(book_format):
459 f_url = book.media_url(book_format)
461 return MEDIA_BASE + f_url
467 def add_file_getters():
468 for book_format in Book.formats:
469 setattr(BookDetails, book_format, _file_getter(book_format))
475 class PictureHandler(BaseHandler):
477 fields = ('slug', 'title')
478 allowed_methods = ('POST',)
480 def create(self, request):
481 if not request.user.has_perm('picture.add_picture'):
484 data = json.loads(request.POST.get('data'))
485 form = PictureImportForm(data)
493 class UserShelfHandler(BookDetailHandler):
494 fields = book_list_fields + ['liked']
497 def parse_bool(self, s):
498 if s in ('true', 'false'):
503 # hack, because piston is stupid
505 def liked(cls, book):
506 return getattr(book, 'liked', None)
508 def read(self, request, state):
509 if not request.user.is_authenticated():
511 likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
512 if state not in ('reading', 'complete', 'likes'):
514 new_api = request.GET.get('new_api')
515 after = request.GET.get('after')
516 count = int(request.GET.get('count', 50))
518 books = Book.tagged.with_any(request.user.tag_set.all())
520 ids = BookUserData.objects.filter(user=request.user, complete=state == 'complete')\
521 .values_list('book_id', flat=True)
522 books = Book.objects.filter(id__in=list(ids)).distinct()
523 books = self.order_books(books, new_api)
525 books = self.books_after(books, after, new_api)
527 books = books[:count]
530 new_books.append(BookProxy(book).set('liked', book.id in likes))
531 return QuerySetProxy(new_books)
534 class UserLikeHandler(BaseHandler):
536 allowed_methods = ('GET', 'POST')
538 def read(self, request, slug):
539 if not request.user.is_authenticated():
542 book = Book.objects.get(slug=slug)
543 except Book.DoesNotExist:
545 return {'likes': likes(request.user, book)}
547 def create(self, request, slug):
548 if not request.user.is_authenticated():
551 book = Book.objects.get(slug=slug)
552 except Book.DoesNotExist:
554 action = request.GET.get('action', 'like')
556 book.like(request.user)
557 elif action == 'unlike':
558 book.unlike(request.user)
562 class BlogEntryHandler(BaseHandler):
565 'title', 'lead', 'body', 'place', 'time', 'image_url', 'image_thumb', 'gallery_urls', 'type', 'key', 'url')
567 def read(self, request):
568 after = request.GET.get('after')
569 count = int(request.GET.get('count', 20))
570 entries = Entry.published_objects.filter(in_stream=True).order_by('-first_published_at')
572 entries = entries.filter(first_published_at__lt=after)
574 entries = entries[:count]
578 def image_url(cls, entry):
579 return (WL_BASE + entry.image.url) if entry.image else None
582 def image_thumb(cls, entry):
583 return MEDIA_BASE + default.backend.get_thumbnail(
584 entry.image, "193x193").url if entry.image else ''
587 def gallery_urls(cls, entry):
588 return [WL_BASE + photo.url() for photo in entry.photo_set.all()]
592 return entry.first_published_at
596 return WL_BASE + entry.get_absolute_url()