2e847c594cc231703ef3884f5644fe8def2c5d88
[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 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
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             try:
67                 books.append(Book.objects.get(slug=slug))
68             except Book.DoesNotExist:
69                 raise ValueError('Unknown book.')
70         try:
71             real_tags.append(Tag.objects.get(category=category, slug=slug))
72         except Tag.DoesNotExist:
73             raise ValueError('Tag not found')
74
75     if not tags:
76         return [], []
77
78     tags = tags.strip('/').split('/')
79     real_tags = []
80     books = []
81     while tags:
82         category = tags.pop(0)
83         slug = tags.pop(0)
84
85         try:
86             category = category_singular[category]
87         except KeyError:
88             raise ValueError('Unknown category.')
89
90         if category not in allowed:
91             raise ValueError('Category not allowed.')
92         process(category, slug)
93
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
101
102
103 # RESTful handlers
104
105
106 class BookMediaHandler(BaseHandler):
107     """ Responsible for representing media in Books. """
108
109     model = BookMedia
110     fields = ['name', 'type', 'url', 'artist', 'director']
111
112     @classmethod
113     def url(cls, media):
114         """ Link to media on site. """
115
116         return MEDIA_BASE + media.file.url
117
118     @classmethod
119     def artist(cls, media):
120         return media.extra_info.get('artist_name', '')
121
122     @classmethod
123     def director(cls, media):
124         return media.extra_info.get('director_name', '')
125
126
127 class BookDetails(object):
128     """Custom fields used for representing Books."""
129
130     @classmethod
131     def href(cls, book):
132         """ Returns an URI for a Book in the API. """
133         return API_BASE + reverse("api_book", args=[book.slug])
134
135     @classmethod
136     def url(cls, book):
137         """ Returns Book's URL on the site. """
138         return WL_BASE + book.get_absolute_url()
139
140     @classmethod
141     def children(cls, book):
142         """ Returns all children for a book. """
143         return book.children.order_by('parent_number', 'sort_key')
144
145     @classmethod
146     def media(cls, book):
147         """ Returns all media for a book. """
148         return book.media.all()
149
150     @classmethod
151     def cover(cls, book):
152         return MEDIA_BASE + book.cover.url if book.cover else ''
153
154     @classmethod
155     def cover_thumb(cls, book):
156         return MEDIA_BASE + default.backend.get_thumbnail(
157                     book.cover, "139x193").url if book.cover else ''
158
159     @classmethod
160     def simple_thumb(cls, book):
161         return MEDIA_BASE + book.cover_api_thumb.url if book.cover_api_thumb else ''
162
163     @classmethod
164     def simple_cover(cls, book):
165         return MEDIA_BASE + book.simple_cover.url if book.simple_cover else ''
166
167     @classmethod
168     def cover_color(cls, book):
169         return WLCover.epoch_colors.get(book.extra_info.get('epoch'), '#000000')
170
171     @classmethod
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)
174
175     @staticmethod
176     def books_after(books, after, new_api):
177         if not new_api:
178             return books.filter(slug__gt=after)
179         try:
180             author, title, book_id = after.split(SORT_KEY_SEP)
181         except ValueError:
182             return Book.objects.none()
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))))
186
187     @staticmethod
188     def order_books(books, new_api):
189         if new_api:
190             return books.order_by('sort_key_author', 'sort_key', 'id')
191         else:
192             return books.order_by('slug')
193
194
195 class BookDetailHandler(BaseHandler, BookDetails):
196     """ Main handler for Book objects.
197
198     Responsible for single Book details.
199     """
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]
205
206     @piwik_track
207     def read(self, request, book):
208         """ Returns details of a book, identified by a slug and lang. """
209         try:
210             return Book.objects.get(slug=book)
211         except Book.DoesNotExist:
212             return rc.NOT_FOUND
213
214
215 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
216     """ Main handler for Book objects.
217
218     Responsible for lists of Book objects.
219     """
220     allowed_methods = ('GET',)
221     model = Book
222     fields = book_list_fields
223
224     @classmethod
225     def genres(cls, book):
226         """ Returns all media for a book. """
227         return book.tags.filter(category='genre')
228
229     @piwik_track
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.
234
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
239              are returned.
240         """
241         if pk is not None:
242             try:
243                 return Book.objects.get(pk=pk)
244             except Book.DoesNotExist:
245                 return rc.NOT_FOUND
246
247         try:
248             tags, _ancestors = read_tags(tags, request, allowed=book_tag_categories)
249         except ValueError:
250             return rc.NOT_FOUND
251
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']
257
258         if tags:
259             if top_level:
260                 books = Book.tagged_top_level(tags)
261                 return books if books else rc.NOT_FOUND
262             else:
263                 books = Book.tagged.with_all(tags)
264         else:
265             books = books if books is not None else Book.objects.all()
266         books = self.order_books(books, new_api)
267
268         if top_level:
269             books = books.filter(parent=None)
270         if audiobooks:
271             books = books.filter(media__type='mp3').distinct()
272         if daisy:
273             books = books.filter(media__type='daisy').distinct()
274         if recommended:
275             books = books.filter(recommended=True)
276         if newest:
277             books = books.order_by('-created_at')
278
279         if after:
280             books = self.books_after(books, after, new_api)
281
282         if new_api:
283             books = books.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key', 'sort_key_author')
284         else:
285             books = books.only('slug', 'title', 'cover', 'cover_thumb')
286         for category in book_tag_categories:
287             books = prefetch_relations(books, category)
288
289         if count:
290             books = books[:count]
291
292         return books
293
294     def create(self, request, *args, **kwargs):
295         return rc.FORBIDDEN
296
297
298 class BooksHandler(BookDetailHandler):
299     allowed_methods = ('GET', 'POST')
300     model = Book
301     fields = book_list_fields + ['liked']
302     anonymous = AnonymousBooksHandler
303
304     # hack, because piston is stupid
305     @classmethod
306     def liked(cls, book):
307         return getattr(book, 'liked', None)
308
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))
312
313         new_books = [
314             BookProxy(book).set('liked', book.id in likes)
315             for book in books]
316         return QuerySetProxy(new_books)
317
318     def create(self, request, *args, **kwargs):
319         if not request.user.has_perm('catalogue.add_book'):
320             return rc.FORBIDDEN
321
322         data = json.loads(request.POST.get('data'))
323         form = BookImportForm(data)
324         if form.is_valid():
325             form.save()
326             return rc.CREATED
327         else:
328             return rc.NOT_FOUND
329
330
331 class EpubHandler(BookDetailHandler):
332     def read(self, request, slug):
333         if not is_subscribed(request.user):
334             return rc.FORBIDDEN
335         try:
336             book = Book.objects.get(slug=slug)
337         except Book.DoesNotExist:
338             return rc.NOT_FOUND
339         response = HttpResponse(book.get_media('epub'))
340         return response
341
342
343 class EBooksHandler(AnonymousBooksHandler):
344     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
345
346
347 class BookProxy(models.Model):
348     class Meta:
349         managed = False
350
351     def __init__(self, book, key=None):
352         self.book = book
353         self.key = key
354
355     def set(self, attr, value):
356         self.__setattr__(attr, value)
357         return self
358
359     def __getattr__(self, item):
360         return self.book.__getattribute__(item)
361
362
363 class QuerySetProxy(models.QuerySet):
364     def __init__(self, l):
365         self.list = l
366
367     def __iter__(self):
368         return iter(self.list)
369
370
371 class AnonFilterBooksHandler(AnonymousBooksHandler):
372     fields = book_list_fields + ['key']
373
374     def parse_bool(self, s):
375         if s in ('true', 'false'):
376             return s == 'true'
377         else:
378             return None
379
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'))
385
386         new_api = request.GET.get('new_api')
387         if new_api:
388             key_sep = ','
389         else:
390             key_sep = '$'
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             try:
613                 return Tag.objects.exclude(category='set').get(pk=pk)
614             except Book.DoesNotExist:
615                 return rc.NOT_FOUND
616
617         try:
618             category_sng = category_singular[category]
619         except KeyError:
620             return rc.NOT_FOUND
621
622         after = request.GET.get('after')
623         count = request.GET.get('count')
624
625         tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
626
627         book_only = request.GET.get('book_only') == 'true'
628         picture_only = request.GET.get('picture_only') == 'true'
629         if book_only:
630             tags = tags.filter(for_books=True)
631         if picture_only:
632             tags = tags.filter(for_pictures=True)
633
634         if after:
635             tags = tags.filter(slug__gt=after)
636
637         if count:
638             tags = tags[:count]
639
640         return tags
641
642
643 class FragmentDetails(object):
644     """Custom Fragment fields."""
645
646     @classmethod
647     def href(cls, fragment):
648         """ Returns URI in the API for the fragment. """
649
650         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
651
652     @classmethod
653     def url(cls, fragment):
654         """ Returns URL on the site for the fragment. """
655
656         return WL_BASE + fragment.get_absolute_url()
657
658     @classmethod
659     def themes(cls, fragment):
660         """ Returns a list of theme tags for the fragment. """
661
662         return fragment.tags.filter(category='theme')
663
664
665 class FragmentDetailHandler(BaseHandler, FragmentDetails):
666     fields = ['book', 'anchor', 'text', 'url', 'themes']
667
668     @piwik_track
669     def read(self, request, book, anchor):
670         """ Returns details of a fragment, identified by book slug and anchor. """
671         try:
672             return Fragment.objects.get(book__slug=book, anchor=anchor)
673         except Fragment.DoesNotExist:
674             return rc.NOT_FOUND
675
676
677 class FragmentsHandler(BaseHandler, FragmentDetails):
678     """ Main handler for Fragments.
679
680     Responsible for lists of Fragment objects
681     and fields used for representing Fragments.
682
683     """
684     model = Fragment
685     fields = ['book', 'url', 'anchor', 'href']
686     allowed_methods = ('GET',)
687
688     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
689
690     @piwik_track
691     def read(self, request, tags):
692         """ Lists all fragments with given book, tags, themes.
693
694         :param tags: should be a path of categories and slugs, i.e.:
695              books/book-slug/authors/an-author/themes/a-theme/
696
697         """
698         try:
699             tags, ancestors = read_tags(tags, request, allowed=self.categories)
700         except ValueError:
701             return rc.NOT_FOUND
702         fragments = Fragment.tagged.with_all(tags).select_related('book')
703         if fragments.exists():
704             return fragments
705         else:
706             return rc.NOT_FOUND
707
708
709 class PictureHandler(BaseHandler):
710     model = Picture
711     fields = ('slug', 'title')
712     allowed_methods = ('POST',)
713
714     def create(self, request):
715         if not request.user.has_perm('picture.add_picture'):
716             return rc.FORBIDDEN
717
718         data = json.loads(request.POST.get('data'))
719         form = PictureImportForm(data)
720         if form.is_valid():
721             form.save()
722             return rc.CREATED
723         else:
724             return rc.NOT_FOUND
725
726
727 class UserDataHandler(BaseHandler):
728     model = BookUserData
729     fields = ('state', 'username', 'premium')
730     allowed_methods = ('GET', 'POST')
731
732     def read(self, request, slug=None):
733         if not request.user.is_authenticated():
734             return rc.FORBIDDEN
735         if slug is None:
736             return {'username': request.user.username, 'premium': is_subscribed(request.user)}
737         try:
738             book = Book.objects.get(slug=slug)
739         except Book.DoesNotExist:
740             return rc.NOT_FOUND
741         try:
742             data = BookUserData.objects.get(book=book, user=request.user)
743         except BookUserData.DoesNotExist:
744             return {'state': 'not_started'}
745         return data
746
747     def create(self, request, slug, state):
748         try:
749             book = Book.objects.get(slug=slug)
750         except Book.DoesNotExist:
751             return rc.NOT_FOUND
752         if not request.user.is_authenticated():
753             return rc.FORBIDDEN
754         if state not in ('reading', 'complete'):
755             return rc.NOT_FOUND
756         data, created = BookUserData.objects.get_or_create(book=book, user=request.user)
757         data.state = state
758         data.save()
759         return data
760
761
762 class UserShelfHandler(BookDetailHandler):
763     fields = book_list_fields + ['liked']
764
765     def parse_bool(self, s):
766         if s in ('true', 'false'):
767             return s == 'true'
768         else:
769             return None
770
771     # hack, because piston is stupid
772     @classmethod
773     def liked(cls, book):
774         return getattr(book, 'liked', None)
775
776     def read(self, request, state):
777         if not request.user.is_authenticated():
778             return rc.FORBIDDEN
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'):
781             return rc.NOT_FOUND
782         new_api = request.GET.get('new_api')
783         after = request.GET.get('after')
784         count = int(request.GET.get('count', 50))
785         if state == 'likes':
786             books = Book.tagged.with_any(request.user.tag_set.all())
787         else:
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)
792         if after:
793             books = self.books_after(books, after, new_api)
794         if count:
795             books = books[:count]
796         new_books = []
797         for book in books:
798             new_books.append(BookProxy(book).set('liked', book.id in likes))
799         return QuerySetProxy(new_books)
800
801
802 class UserLikeHandler(BaseHandler):
803     fields = []
804     allowed_methods = ('GET', 'POST')
805
806     def read(self, request, slug):
807         if not request.user.is_authenticated():
808             return rc.FORBIDDEN
809         try:
810             book = Book.objects.get(slug=slug)
811         except Book.DoesNotExist:
812             return rc.NOT_FOUND
813         return {'likes': likes(request.user, book)}
814
815     def create(self, request, slug):
816         if not request.user.is_authenticated():
817             return rc.FORBIDDEN
818         try:
819             book = Book.objects.get(slug=slug)
820         except Book.DoesNotExist:
821             return rc.NOT_FOUND
822         action = request.GET.get('action', 'like')
823         if action == 'like':
824             book.like(request.user)
825         elif action == 'unlike':
826             book.unlike(request.user)
827         return {}
828
829
830 class BlogEntryHandler(BaseHandler):
831     model = Entry
832     fields = (
833         'title', 'lead', 'body', 'place', 'time', 'image_url', 'image_thumb', 'gallery_urls', 'type', 'key', 'url')
834
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')
839         if after:
840             entries = entries.filter(first_published_at__lt=after)
841         if count:
842             entries = entries[:count]
843         return entries
844
845     @classmethod
846     def image_url(cls, entry):
847         return (WL_BASE + entry.image.url) if entry.image else None
848
849     @classmethod
850     def image_thumb(cls, entry):
851         return MEDIA_BASE + default.backend.get_thumbnail(
852             entry.image, "193x193").url if entry.image else ''
853
854     @classmethod
855     def gallery_urls(cls, entry):
856         return [WL_BASE + photo.url() for photo in entry.photo_set.all()]
857
858     @classmethod
859     def key(cls, entry):
860         return entry.first_published_at
861
862     @classmethod
863     def url(cls, entry):
864         return WL_BASE + entry.get_absolute_url()