API is now pretty much tested.
[wolnelektury.git] / src / api / handlers.py
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.
4 #
5 import json
6
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
17
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 librarian.cover import WLCover
23 from paypal.rest import user_is_subscribed
24 from picture.models import Picture
25 from picture.forms import PictureImportForm
26 from social.utils import likes
27
28 from stats.utils import piwik_track
29 from wolnelektury.utils import re_escape
30
31 from . import emitters  # Register our emitters
32
33 API_BASE = WL_BASE = MEDIA_BASE = lazy(
34     lambda: u'https://' + Site.objects.get_current().domain, unicode)()
35
36 SORT_KEY_SEP = '$'
37
38 category_singular = {
39     'authors': 'author',
40     'kinds': 'kind',
41     'genres': 'genre',
42     'epochs': 'epoch',
43     'themes': 'theme',
44     'books': 'book',
45 }
46 category_plural = {}
47 for k, v in category_singular.items():
48     category_plural[v] = k
49
50 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
51
52 book_list_fields = book_tag_categories + [
53     'href', 'title', 'url', 'cover', 'cover_thumb', 'slug', 'simple_thumb', 'has_audio', 'cover_color', 'full_sort_key']
54
55
56 def read_tags(tags, request, allowed):
57     """ Reads a path of filtering tags.
58
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
62     """
63
64     def process(category, slug):
65         if category == 'book':
66             # FIXME: Unused?
67             try:
68                 books.append(Book.objects.get(slug=slug))
69             except Book.DoesNotExist:
70                 raise ValueError('Unknown book.')
71         try:
72             real_tags.append(Tag.objects.get(category=category, slug=slug))
73         except Tag.DoesNotExist:
74             raise ValueError('Tag not found')
75
76     if not tags:
77         return [], []
78
79     tags = tags.strip('/').split('/')
80     real_tags = []
81     books = []
82     while tags:
83         category = tags.pop(0)
84         slug = tags.pop(0)
85
86         try:
87             category = category_singular[category]
88         except KeyError:
89             raise ValueError('Unknown category.')
90
91         if category not in allowed:
92             raise ValueError('Category not allowed.')
93         process(category, slug)
94
95     for key in request.GET:
96         if key in category_singular:
97             category = category_singular[key]
98             if category in allowed:
99                 for slug in request.GET.getlist(key):
100                     process(category, slug)
101     return real_tags, books
102
103
104 # RESTful handlers
105
106
107 class BookMediaHandler(BaseHandler):
108     """ Responsible for representing media in Books. """
109
110     model = BookMedia
111     fields = ['name', 'type', 'url', 'artist', 'director']
112
113     @classmethod
114     def url(cls, media):
115         """ Link to media on site. """
116
117         return MEDIA_BASE + media.file.url
118
119     @classmethod
120     def artist(cls, media):
121         return media.extra_info.get('artist_name', '')
122
123     @classmethod
124     def director(cls, media):
125         return media.extra_info.get('director_name', '')
126
127
128 class BookDetails(object):
129     """Custom fields used for representing Books."""
130
131     @classmethod
132     def href(cls, book):
133         """ Returns an URI for a Book in the API. """
134         return API_BASE + reverse("api_book", args=[book.slug])
135
136     @classmethod
137     def url(cls, book):
138         """ Returns Book's URL on the site. """
139         return WL_BASE + book.get_absolute_url()
140
141     @classmethod
142     def children(cls, book):
143         """ Returns all children for a book. """
144         return book.children.order_by('parent_number', 'sort_key')
145
146     @classmethod
147     def media(cls, book):
148         """ Returns all media for a book. """
149         return book.media.all()
150
151     @classmethod
152     def cover(cls, book):
153         return MEDIA_BASE + book.cover.url if book.cover else ''
154
155     @classmethod
156     def cover_thumb(cls, book):
157         return MEDIA_BASE + default.backend.get_thumbnail(
158                     book.cover, "139x193").url if book.cover else ''
159
160     @classmethod
161     def simple_thumb(cls, book):
162         return MEDIA_BASE + book.cover_api_thumb.url if book.cover_api_thumb else ''
163
164     @classmethod
165     def simple_cover(cls, book):
166         return MEDIA_BASE + book.simple_cover.url if book.simple_cover else ''
167
168     @classmethod
169     def cover_color(cls, book):
170         return WLCover.epoch_colors.get(book.extra_info.get('epoch'), '#000000')
171
172     @classmethod
173     def full_sort_key(cls, book):
174         return '%s%s%s%s%s' % (book.sort_key_author, SORT_KEY_SEP, book.sort_key, SORT_KEY_SEP, book.id)
175
176     @staticmethod
177     def books_after(books, after, new_api):
178         if not new_api:
179             return books.filter(slug__gt=after)
180         try:
181             author, title, book_id = after.split(SORT_KEY_SEP)
182         except ValueError:
183             return Book.objects.none()
184         return books.filter(Q(sort_key_author__gt=author)
185                             | (Q(sort_key_author=author) & Q(sort_key__gt=title))
186                             | (Q(sort_key_author=author) & Q(sort_key=title) & Q(id__gt=int(book_id))))
187
188     @staticmethod
189     def order_books(books, new_api):
190         if new_api:
191             return books.order_by('sort_key_author', 'sort_key', 'id')
192         else:
193             return books.order_by('slug')
194
195
196 class BookDetailHandler(BaseHandler, BookDetails):
197     """ Main handler for Book objects.
198
199     Responsible for single Book details.
200     """
201     allowed_methods = ['GET']
202     fields = ['title', 'parent', 'children'] + Book.formats + [
203         'media', 'url', 'cover', 'cover_thumb', 'simple_thumb', 'simple_cover', 'fragment_data', 'audio_length',
204         'preview', 'cover_color'] + [
205             category_plural[c] for c in book_tag_categories]
206
207     @piwik_track
208     def read(self, request, book):
209         """ Returns details of a book, identified by a slug and lang. """
210         try:
211             return Book.objects.get(slug=book)
212         except Book.DoesNotExist:
213             return rc.NOT_FOUND
214
215
216 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
217     """ Main handler for Book objects.
218
219     Responsible for lists of Book objects.
220     """
221     allowed_methods = ('GET',)
222     model = Book
223     fields = book_list_fields
224
225     # FIXME: Unused?
226     @classmethod
227     def genres(cls, book):
228         """ Returns all media for a book. """
229         return book.tags.filter(category='genre')
230
231     @piwik_track
232     def read(self, request, tags=None, top_level=False, audiobooks=False, daisy=False, pk=None,
233              recommended=False, newest=False, books=None,
234              after=None, count=None):
235         """ Lists all books with given tags.
236
237         :param tags: filtering tags; should be a path of categories
238              and slugs, i.e.: authors/an-author/epoch/an-epoch/
239         :param top_level: if True and a book is included in the results,
240              it's children are aren't. By default all books matching the tags
241              are returned.
242         """
243         if pk is not None:
244             # FIXME: Unused?
245             try:
246                 return Book.objects.get(pk=pk)
247             except Book.DoesNotExist:
248                 return rc.NOT_FOUND
249
250         try:
251             tags, _ancestors = read_tags(tags, request, allowed=book_tag_categories)
252         except ValueError:
253             return rc.NOT_FOUND
254
255         new_api = request.GET.get('new_api')
256         if 'after' in request.GET:
257             after = request.GET['after']
258         if 'count' in request.GET:
259             count = request.GET['count']
260
261         if tags:
262             if top_level:
263                 books = Book.tagged_top_level(tags)
264                 return books if books else rc.NOT_FOUND
265             else:
266                 books = Book.tagged.with_all(tags)
267         else:
268             books = books if books is not None else Book.objects.all()
269         books = self.order_books(books, new_api)
270
271         if top_level:
272             books = books.filter(parent=None)
273         if audiobooks:
274             books = books.filter(media__type='mp3').distinct()
275         if daisy:
276             books = books.filter(media__type='daisy').distinct()
277         if recommended:
278             books = books.filter(recommended=True)
279         if newest:
280             books = books.order_by('-created_at')
281
282         if after:
283             books = self.books_after(books, after, new_api)
284
285         if new_api:
286             books = books.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key', 'sort_key_author')
287         else:
288             books = books.only('slug', 'title', 'cover', 'cover_thumb')
289         for category in book_tag_categories:
290             books = prefetch_relations(books, category)
291
292         if count:
293             books = books[:count]
294
295         return books
296
297     def create(self, request, *args, **kwargs):
298         return rc.FORBIDDEN
299
300
301 class BooksHandler(BookDetailHandler):
302     allowed_methods = ('GET', 'POST')
303     model = Book
304     fields = book_list_fields + ['liked']
305     anonymous = AnonymousBooksHandler
306
307     # hack, because piston is stupid
308     @classmethod
309     def liked(cls, book):
310         return getattr(book, 'liked', None)
311
312     def read(self, request, **kwargs):
313         books = AnonymousBooksHandler().read(request, **kwargs)
314         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
315
316         new_books = [
317             BookProxy(book).set('liked', book.id in likes)
318             for book in books]
319         return QuerySetProxy(new_books)
320
321     def create(self, request, *args, **kwargs):
322         if not request.user.has_perm('catalogue.add_book'):
323             return rc.FORBIDDEN
324
325         data = json.loads(request.POST.get('data'))
326         form = BookImportForm(data)
327         if form.is_valid():
328             form.save()
329             return rc.CREATED
330         else:
331             return rc.NOT_FOUND
332
333
334 class EpubHandler(BookDetailHandler):
335     def read(self, request, slug):
336         if not user_is_subscribed(request.user):
337             return rc.FORBIDDEN
338         try:
339             book = Book.objects.get(slug=slug)
340         except Book.DoesNotExist:
341             return rc.NOT_FOUND
342         response = HttpResponse(book.get_media('epub'))
343         return response
344
345
346 class EBooksHandler(AnonymousBooksHandler):
347     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
348
349
350 class BookProxy(models.Model):
351     class Meta:
352         managed = False
353
354     def __init__(self, book, key=None):
355         self.book = book
356         self.key = key
357
358     def set(self, attr, value):
359         self.__setattr__(attr, value)
360         return self
361
362     def __getattr__(self, item):
363         return self.book.__getattribute__(item)
364
365
366 class QuerySetProxy(models.QuerySet):
367     def __init__(self, l):
368         self.list = l
369
370     def __iter__(self):
371         return iter(self.list)
372
373
374 class AnonFilterBooksHandler(AnonymousBooksHandler):
375     fields = book_list_fields + ['key']
376
377     def parse_bool(self, s):
378         if s in ('true', 'false'):
379             return s == 'true'
380         else:
381             return None
382
383     def read(self, request):
384         key_sep = '$'
385         search_string = request.GET.get('search')
386         is_lektura = self.parse_bool(request.GET.get('lektura'))
387         is_audiobook = self.parse_bool(request.GET.get('audiobook'))
388         preview = self.parse_bool(request.GET.get('preview'))
389
390         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:
397             if is_audiobook:
398                 books = books.filter(media__type='mp3')
399             else:
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:
411             search_string = None
412         if search_string:
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)
419                 if which == 'title':
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')]
423             else:
424                 book_lists = [(books_author, 'author'), (books_title, 'title')]
425         else:
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')]
430
431         filtered_books = []
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)
437             new_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:
443                 break
444
445         return QuerySetProxy(filtered_books)
446
447
448 class FilterBooksHandler(BooksHandler):
449     anonymous = AnonFilterBooksHandler
450     fields = book_list_fields + ['key', 'liked']
451
452     # hack, because piston is stupid
453     @classmethod
454     def liked(cls, book):
455         return getattr(book, 'liked', None)
456
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)
462         return qsp
463
464
465 class BookPreviewHandler(BookDetailHandler):
466     fields = BookDetailHandler.fields + ['slug']
467
468     def read(self, request):
469         return Book.objects.filter(preview=True)
470
471
472 # add categorized tags fields for Book
473 def _tags_getter(category):
474     @classmethod
475     def get_tags(cls, book):
476         return book.tags.filter(category=category)
477     return get_tags
478
479
480 def _tag_getter(category):
481     @classmethod
482     def get_tag(cls, book):
483         return book.tag_unicode(category)
484     return get_tag
485
486
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))
491
492
493 add_tag_getters()
494
495
496 # add fields for files in Book
497 def _file_getter(book_format):
498
499     @staticmethod
500     def get_file(book):
501         f_url = book.media_url(book_format)
502         if f_url:
503             return MEDIA_BASE + f_url
504         else:
505             return ''
506     return get_file
507
508
509 def add_file_getters():
510     for book_format in Book.formats:
511         setattr(BookDetails, book_format, _file_getter(book_format))
512
513
514 add_file_getters()
515
516
517 class CollectionDetails(object):
518     """Custom Collection fields."""
519
520     @classmethod
521     def href(cls, collection):
522         """ Returns URI in the API for the collection. """
523
524         return API_BASE + reverse("api_collection", args=[collection.slug])
525
526     @classmethod
527     def url(cls, collection):
528         """ Returns URL on the site. """
529
530         return WL_BASE + collection.get_absolute_url()
531
532     @classmethod
533     def books(cls, collection):
534         return Book.objects.filter(collection.get_query())
535
536
537 class CollectionDetailHandler(BaseHandler, CollectionDetails):
538     allowed_methods = ('GET',)
539     fields = ['url', 'title', 'description', 'books']
540
541     @piwik_track
542     def read(self, request, slug):
543         """ Returns details of a collection, identified by slug. """
544         try:
545             return Collection.objects.get(slug=slug)
546         except Collection.DoesNotExist:
547             return rc.NOT_FOUND
548
549
550 class CollectionsHandler(BaseHandler, CollectionDetails):
551     allowed_methods = ('GET',)
552     model = Collection
553     fields = ['url', 'href', 'title']
554
555     @piwik_track
556     def read(self, request):
557         """ Returns all collections. """
558         return Collection.objects.all()
559
560
561 class TagDetails(object):
562     """Custom Tag fields."""
563
564     @classmethod
565     def href(cls, tag):
566         """ Returns URI in the API for the tag. """
567
568         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
569
570     @classmethod
571     def url(cls, tag):
572         """ Returns URL on the site. """
573
574         return WL_BASE + tag.get_absolute_url()
575
576
577 class TagDetailHandler(BaseHandler, TagDetails):
578     """ Responsible for details of a single Tag object. """
579
580     fields = ['name', 'url', 'sort_key', 'description']
581
582     @piwik_track
583     def read(self, request, category, slug):
584         """ Returns details of a tag, identified by category and slug. """
585
586         try:
587             category_sng = category_singular[category]
588         except KeyError:
589             return rc.NOT_FOUND
590
591         try:
592             return Tag.objects.get(category=category_sng, slug=slug)
593         except Tag.DoesNotExist:
594             return rc.NOT_FOUND
595
596
597 class TagsHandler(BaseHandler, TagDetails):
598     """ Main handler for Tag objects.
599
600     Responsible for lists of Tag objects
601     and fields used for representing Tags.
602
603     """
604     allowed_methods = ('GET',)
605     model = Tag
606     fields = ['name', 'href', 'url', 'slug']
607
608     @piwik_track
609     def read(self, request, category=None, pk=None):
610         """ Lists all tags in the category (eg. all themes). """
611         if pk is not None:
612             # FIXME: Unused?
613             try:
614                 return Tag.objects.exclude(category='set').get(pk=pk)
615             except Book.DoesNotExist:
616                 return rc.NOT_FOUND
617
618         try:
619             category_sng = category_singular[category]
620         except KeyError:
621             return rc.NOT_FOUND
622
623         after = request.GET.get('after')
624         count = request.GET.get('count')
625
626         tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
627
628         book_only = request.GET.get('book_only') == 'true'
629         picture_only = request.GET.get('picture_only') == 'true'
630         if book_only:
631             tags = tags.filter(for_books=True)
632         if picture_only:
633             tags = tags.filter(for_pictures=True)
634
635         if after:
636             tags = tags.filter(slug__gt=after)
637
638         if count:
639             tags = tags[:count]
640
641         return tags
642
643
644 class FragmentDetails(object):
645     """Custom Fragment fields."""
646
647     @classmethod
648     def href(cls, fragment):
649         """ Returns URI in the API for the fragment. """
650
651         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
652
653     @classmethod
654     def url(cls, fragment):
655         """ Returns URL on the site for the fragment. """
656
657         return WL_BASE + fragment.get_absolute_url()
658
659     @classmethod
660     def themes(cls, fragment):
661         """ Returns a list of theme tags for the fragment. """
662
663         return fragment.tags.filter(category='theme')
664
665
666 class FragmentDetailHandler(BaseHandler, FragmentDetails):
667     fields = ['book', 'anchor', 'text', 'url', 'themes']
668
669     @piwik_track
670     def read(self, request, book, anchor):
671         """ Returns details of a fragment, identified by book slug and anchor. """
672         try:
673             return Fragment.objects.get(book__slug=book, anchor=anchor)
674         except Fragment.DoesNotExist:
675             return rc.NOT_FOUND
676
677
678 class FragmentsHandler(BaseHandler, FragmentDetails):
679     """ Main handler for Fragments.
680
681     Responsible for lists of Fragment objects
682     and fields used for representing Fragments.
683
684     """
685     model = Fragment
686     fields = ['book', 'url', 'anchor', 'href']
687     allowed_methods = ('GET',)
688
689     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
690
691     @piwik_track
692     def read(self, request, tags):
693         """ Lists all fragments with given book, tags, themes.
694
695         :param tags: should be a path of categories and slugs, i.e.:
696              books/book-slug/authors/an-author/themes/a-theme/
697
698         """
699         try:
700             tags, ancestors = read_tags(tags, request, allowed=self.categories)
701         except ValueError:
702             return rc.NOT_FOUND
703         fragments = Fragment.tagged.with_all(tags).select_related('book')
704         if fragments.exists():
705             return fragments
706         else:
707             return rc.NOT_FOUND
708
709
710 class PictureHandler(BaseHandler):
711     model = Picture
712     fields = ('slug', 'title')
713     allowed_methods = ('POST',)
714
715     def create(self, request):
716         if not request.user.has_perm('picture.add_picture'):
717             return rc.FORBIDDEN
718
719         data = json.loads(request.POST.get('data'))
720         form = PictureImportForm(data)
721         if form.is_valid():
722             form.save()
723             return rc.CREATED
724         else:
725             return rc.NOT_FOUND
726
727
728 class UserDataHandler(BaseHandler):
729     model = BookUserData
730     fields = ('state', 'username', 'premium')
731     allowed_methods = ('GET', 'POST')
732
733     def read(self, request, slug=None):
734         if not request.user.is_authenticated():
735             return rc.FORBIDDEN
736         if slug is None:
737             return {'username': request.user.username, 'premium': user_is_subscribed(request.user)}
738         try:
739             book = Book.objects.get(slug=slug)
740         except Book.DoesNotExist:
741             return rc.NOT_FOUND
742         try:
743             data = BookUserData.objects.get(book=book, user=request.user)
744         except BookUserData.DoesNotExist:
745             return {'state': 'not_started'}
746         return data
747
748     def create(self, request, slug, state):
749         try:
750             book = Book.objects.get(slug=slug)
751         except Book.DoesNotExist:
752             return rc.NOT_FOUND
753         if not request.user.is_authenticated():
754             return rc.FORBIDDEN
755         if state not in ('reading', 'complete'):
756             return rc.NOT_FOUND
757         data, created = BookUserData.objects.get_or_create(book=book, user=request.user)
758         data.state = state
759         data.save()
760         return data
761
762
763 class UserShelfHandler(BookDetailHandler):
764     fields = book_list_fields + ['liked']
765
766     # FIXME: Unused?
767     def parse_bool(self, s):
768         if s in ('true', 'false'):
769             return s == 'true'
770         else:
771             return None
772
773     # hack, because piston is stupid
774     @classmethod
775     def liked(cls, book):
776         return getattr(book, 'liked', None)
777
778     def read(self, request, state):
779         if not request.user.is_authenticated():
780             return rc.FORBIDDEN
781         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
782         if state not in ('reading', 'complete', 'likes'):
783             return rc.NOT_FOUND
784         new_api = request.GET.get('new_api')
785         after = request.GET.get('after')
786         count = int(request.GET.get('count', 50))
787         if state == 'likes':
788             books = Book.tagged.with_any(request.user.tag_set.all())
789         else:
790             ids = BookUserData.objects.filter(user=request.user, complete=state == 'complete')\
791                 .values_list('book_id', flat=True)
792             books = Book.objects.filter(id__in=list(ids)).distinct()
793             books = self.order_books(books, new_api)
794         if after:
795             books = self.books_after(books, after, new_api)
796         if count:
797             books = books[:count]
798         new_books = []
799         for book in books:
800             new_books.append(BookProxy(book).set('liked', book.id in likes))
801         return QuerySetProxy(new_books)
802
803
804 class UserLikeHandler(BaseHandler):
805     fields = []
806     allowed_methods = ('GET', 'POST')
807
808     def read(self, request, slug):
809         if not request.user.is_authenticated():
810             return rc.FORBIDDEN
811         try:
812             book = Book.objects.get(slug=slug)
813         except Book.DoesNotExist:
814             return rc.NOT_FOUND
815         return {'likes': likes(request.user, book)}
816
817     def create(self, request, slug):
818         if not request.user.is_authenticated():
819             return rc.FORBIDDEN
820         try:
821             book = Book.objects.get(slug=slug)
822         except Book.DoesNotExist:
823             return rc.NOT_FOUND
824         action = request.GET.get('action', 'like')
825         if action == 'like':
826             book.like(request.user)
827         elif action == 'unlike':
828             book.unlike(request.user)
829         return {}
830
831
832 class BlogEntryHandler(BaseHandler):
833     model = Entry
834     fields = (
835         'title', 'lead', 'body', 'place', 'time', 'image_url', 'image_thumb', 'gallery_urls', 'type', 'key', 'url')
836
837     def read(self, request):
838         after = request.GET.get('after')
839         count = int(request.GET.get('count', 20))
840         entries = Entry.published_objects.filter(in_stream=True).order_by('-first_published_at')
841         if after:
842             entries = entries.filter(first_published_at__lt=after)
843         if count:
844             entries = entries[:count]
845         return entries
846
847     @classmethod
848     def image_url(cls, entry):
849         return (WL_BASE + entry.image.url) if entry.image else None
850
851     @classmethod
852     def image_thumb(cls, entry):
853         return MEDIA_BASE + default.backend.get_thumbnail(
854             entry.image, "193x193").url if entry.image else ''
855
856     @classmethod
857     def gallery_urls(cls, entry):
858         return [WL_BASE + photo.url() for photo in entry.photo_set.all()]
859
860     @classmethod
861     def key(cls, entry):
862         return entry.first_published_at
863
864     @classmethod
865     def url(cls, entry):
866         return WL_BASE + entry.get_absolute_url()