More API testing.
[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             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 user_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         key_sep = '$'
382         search_string = request.GET.get('search')
383         is_lektura = self.parse_bool(request.GET.get('lektura'))
384         is_audiobook = self.parse_bool(request.GET.get('audiobook'))
385         preview = self.parse_bool(request.GET.get('preview'))
386
387         new_api = request.GET.get('new_api')
388         after = request.GET.get('after')
389         count = int(request.GET.get('count', 50))
390         books = self.order_books(Book.objects.distinct(), new_api)
391         if is_lektura is not None:
392             books = books.filter(has_audience=is_lektura)
393         if is_audiobook is not None:
394             if is_audiobook:
395                 books = books.filter(media__type='mp3')
396             else:
397                 books = books.exclude(media__type='mp3')
398         if preview is not None:
399             books = books.filter(preview=preview)
400         for key in request.GET:
401             if key in category_singular:
402                 category = category_singular[key]
403                 if category in book_tag_categories:
404                     slugs = request.GET[key].split(',')
405                     tags = Tag.objects.filter(category=category, slug__in=slugs)
406                     books = Book.tagged.with_any(tags, books)
407         if (search_string is not None) and len(search_string) < 3:
408             search_string = None
409         if search_string:
410             search_string = re_escape(search_string)
411             books_author = books.filter(cached_author__iregex='\m' + search_string)
412             books_title = books.filter(title__iregex='\m' + search_string)
413             books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True)))
414             if after and (key_sep in after):
415                 which, key = after.split(key_sep, 1)
416                 if which == 'title':
417                     book_lists = [(self.books_after(books_title, key, new_api), 'title')]
418                 else:  # which == 'author'
419                     book_lists = [(self.books_after(books_author, key, new_api), 'author'), (books_title, 'title')]
420             else:
421                 book_lists = [(books_author, 'author'), (books_title, 'title')]
422         else:
423             if after and key_sep in after:
424                 which, key = after.split(key_sep, 1)
425                 books = self.books_after(books, key, new_api)
426             book_lists = [(books, 'book')]
427
428         filtered_books = []
429         for book_list, label in book_lists:
430             book_list = book_list.only('slug', 'title', 'cover', 'cover_thumb', 'sort_key_author', 'sort_key')
431             for category in book_tag_categories:
432                 book_list = prefetch_relations(book_list, category)
433             remaining_count = count - len(filtered_books)
434             new_books = [
435                 BookProxy(book, '%s%s%s' % (
436                     label, key_sep, book.slug if not new_api else self.full_sort_key(book)))
437                 for book in book_list[:remaining_count]]
438             filtered_books += new_books
439             if len(filtered_books) == count:
440                 break
441
442         return QuerySetProxy(filtered_books)
443
444
445 class FilterBooksHandler(BooksHandler):
446     anonymous = AnonFilterBooksHandler
447     fields = book_list_fields + ['key', 'liked']
448
449     # hack, because piston is stupid
450     @classmethod
451     def liked(cls, book):
452         return getattr(book, 'liked', None)
453
454     def read(self, request):
455         qsp = AnonFilterBooksHandler().read(request)
456         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
457         for book in qsp.list:
458             book.set('liked', book.id in likes)
459         return qsp
460
461
462 class BookPreviewHandler(BookDetailHandler):
463     fields = BookDetailHandler.fields + ['slug']
464
465     def read(self, request):
466         return Book.objects.filter(preview=True)
467
468
469 # add categorized tags fields for Book
470 def _tags_getter(category):
471     @classmethod
472     def get_tags(cls, book):
473         return book.tags.filter(category=category)
474     return get_tags
475
476
477 def _tag_getter(category):
478     @classmethod
479     def get_tag(cls, book):
480         return book.tag_unicode(category)
481     return get_tag
482
483
484 def add_tag_getters():
485     for plural, singular in category_singular.items():
486         setattr(BookDetails, plural, _tags_getter(singular))
487         setattr(BookDetails, singular, _tag_getter(singular))
488
489
490 add_tag_getters()
491
492
493 # add fields for files in Book
494 def _file_getter(book_format):
495
496     @staticmethod
497     def get_file(book):
498         f_url = book.media_url(book_format)
499         if f_url:
500             return MEDIA_BASE + f_url
501         else:
502             return ''
503     return get_file
504
505
506 def add_file_getters():
507     for book_format in Book.formats:
508         setattr(BookDetails, book_format, _file_getter(book_format))
509
510
511 add_file_getters()
512
513
514 class CollectionDetails(object):
515     """Custom Collection fields."""
516
517     @classmethod
518     def href(cls, collection):
519         """ Returns URI in the API for the collection. """
520
521         return API_BASE + reverse("api_collection", args=[collection.slug])
522
523     @classmethod
524     def url(cls, collection):
525         """ Returns URL on the site. """
526
527         return WL_BASE + collection.get_absolute_url()
528
529     @classmethod
530     def books(cls, collection):
531         return Book.objects.filter(collection.get_query())
532
533
534 class CollectionDetailHandler(BaseHandler, CollectionDetails):
535     allowed_methods = ('GET',)
536     fields = ['url', 'title', 'description', 'books']
537
538     @piwik_track
539     def read(self, request, slug):
540         """ Returns details of a collection, identified by slug. """
541         try:
542             return Collection.objects.get(slug=slug)
543         except Collection.DoesNotExist:
544             return rc.NOT_FOUND
545
546
547 class CollectionsHandler(BaseHandler, CollectionDetails):
548     allowed_methods = ('GET',)
549     model = Collection
550     fields = ['url', 'href', 'title']
551
552     @piwik_track
553     def read(self, request):
554         """ Returns all collections. """
555         return Collection.objects.all()
556
557
558 class TagDetails(object):
559     """Custom Tag fields."""
560
561     @classmethod
562     def href(cls, tag):
563         """ Returns URI in the API for the tag. """
564
565         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
566
567     @classmethod
568     def url(cls, tag):
569         """ Returns URL on the site. """
570
571         return WL_BASE + tag.get_absolute_url()
572
573
574 class TagDetailHandler(BaseHandler, TagDetails):
575     """ Responsible for details of a single Tag object. """
576
577     fields = ['name', 'url', 'sort_key', 'description']
578
579     @piwik_track
580     def read(self, request, category, slug):
581         """ Returns details of a tag, identified by category and slug. """
582
583         try:
584             category_sng = category_singular[category]
585         except KeyError:
586             return rc.NOT_FOUND
587
588         try:
589             return Tag.objects.get(category=category_sng, slug=slug)
590         except Tag.DoesNotExist:
591             return rc.NOT_FOUND
592
593
594 class TagsHandler(BaseHandler, TagDetails):
595     """ Main handler for Tag objects.
596
597     Responsible for lists of Tag objects
598     and fields used for representing Tags.
599
600     """
601     allowed_methods = ('GET',)
602     model = Tag
603     fields = ['name', 'href', 'url', 'slug']
604
605     @piwik_track
606     def read(self, request, category=None, pk=None):
607         """ Lists all tags in the category (eg. all themes). """
608         if pk is not None:
609             try:
610                 return Tag.objects.exclude(category='set').get(pk=pk)
611             except Book.DoesNotExist:
612                 return rc.NOT_FOUND
613
614         try:
615             category_sng = category_singular[category]
616         except KeyError:
617             return rc.NOT_FOUND
618
619         after = request.GET.get('after')
620         count = request.GET.get('count')
621
622         tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
623
624         book_only = request.GET.get('book_only') == 'true'
625         picture_only = request.GET.get('picture_only') == 'true'
626         if book_only:
627             tags = tags.filter(for_books=True)
628         if picture_only:
629             tags = tags.filter(for_pictures=True)
630
631         if after:
632             tags = tags.filter(slug__gt=after)
633
634         if count:
635             tags = tags[:count]
636
637         return tags
638
639
640 class FragmentDetails(object):
641     """Custom Fragment fields."""
642
643     @classmethod
644     def href(cls, fragment):
645         """ Returns URI in the API for the fragment. """
646
647         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
648
649     @classmethod
650     def url(cls, fragment):
651         """ Returns URL on the site for the fragment. """
652
653         return WL_BASE + fragment.get_absolute_url()
654
655     @classmethod
656     def themes(cls, fragment):
657         """ Returns a list of theme tags for the fragment. """
658
659         return fragment.tags.filter(category='theme')
660
661
662 class FragmentDetailHandler(BaseHandler, FragmentDetails):
663     fields = ['book', 'anchor', 'text', 'url', 'themes']
664
665     @piwik_track
666     def read(self, request, book, anchor):
667         """ Returns details of a fragment, identified by book slug and anchor. """
668         try:
669             return Fragment.objects.get(book__slug=book, anchor=anchor)
670         except Fragment.DoesNotExist:
671             return rc.NOT_FOUND
672
673
674 class FragmentsHandler(BaseHandler, FragmentDetails):
675     """ Main handler for Fragments.
676
677     Responsible for lists of Fragment objects
678     and fields used for representing Fragments.
679
680     """
681     model = Fragment
682     fields = ['book', 'url', 'anchor', 'href']
683     allowed_methods = ('GET',)
684
685     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
686
687     @piwik_track
688     def read(self, request, tags):
689         """ Lists all fragments with given book, tags, themes.
690
691         :param tags: should be a path of categories and slugs, i.e.:
692              books/book-slug/authors/an-author/themes/a-theme/
693
694         """
695         try:
696             tags, ancestors = read_tags(tags, request, allowed=self.categories)
697         except ValueError:
698             return rc.NOT_FOUND
699         fragments = Fragment.tagged.with_all(tags).select_related('book')
700         if fragments.exists():
701             return fragments
702         else:
703             return rc.NOT_FOUND
704
705
706 class PictureHandler(BaseHandler):
707     model = Picture
708     fields = ('slug', 'title')
709     allowed_methods = ('POST',)
710
711     def create(self, request):
712         if not request.user.has_perm('picture.add_picture'):
713             return rc.FORBIDDEN
714
715         data = json.loads(request.POST.get('data'))
716         form = PictureImportForm(data)
717         if form.is_valid():
718             form.save()
719             return rc.CREATED
720         else:
721             return rc.NOT_FOUND
722
723
724 class UserDataHandler(BaseHandler):
725     model = BookUserData
726     fields = ('state', 'username', 'premium')
727     allowed_methods = ('GET', 'POST')
728
729     def read(self, request, slug=None):
730         if not request.user.is_authenticated():
731             return rc.FORBIDDEN
732         if slug is None:
733             return {'username': request.user.username, 'premium': user_is_subscribed(request.user)}
734         try:
735             book = Book.objects.get(slug=slug)
736         except Book.DoesNotExist:
737             return rc.NOT_FOUND
738         try:
739             data = BookUserData.objects.get(book=book, user=request.user)
740         except BookUserData.DoesNotExist:
741             return {'state': 'not_started'}
742         return data
743
744     def create(self, request, slug, state):
745         try:
746             book = Book.objects.get(slug=slug)
747         except Book.DoesNotExist:
748             return rc.NOT_FOUND
749         if not request.user.is_authenticated():
750             return rc.FORBIDDEN
751         if state not in ('reading', 'complete'):
752             return rc.NOT_FOUND
753         data, created = BookUserData.objects.get_or_create(book=book, user=request.user)
754         data.state = state
755         data.save()
756         return data
757
758
759 class UserShelfHandler(BookDetailHandler):
760     fields = book_list_fields + ['liked']
761
762     def parse_bool(self, s):
763         if s in ('true', 'false'):
764             return s == 'true'
765         else:
766             return None
767
768     # hack, because piston is stupid
769     @classmethod
770     def liked(cls, book):
771         return getattr(book, 'liked', None)
772
773     def read(self, request, state):
774         if not request.user.is_authenticated():
775             return rc.FORBIDDEN
776         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
777         if state not in ('reading', 'complete', 'likes'):
778             return rc.NOT_FOUND
779         new_api = request.GET.get('new_api')
780         after = request.GET.get('after')
781         count = int(request.GET.get('count', 50))
782         if state == 'likes':
783             books = Book.tagged.with_any(request.user.tag_set.all())
784         else:
785             ids = BookUserData.objects.filter(user=request.user, complete=state == 'complete')\
786                 .values_list('book_id', flat=True)
787             books = Book.objects.filter(id__in=list(ids)).distinct()
788             books = self.order_books(books, new_api)
789         if after:
790             books = self.books_after(books, after, new_api)
791         if count:
792             books = books[:count]
793         new_books = []
794         for book in books:
795             new_books.append(BookProxy(book).set('liked', book.id in likes))
796         return QuerySetProxy(new_books)
797
798
799 class UserLikeHandler(BaseHandler):
800     fields = []
801     allowed_methods = ('GET', 'POST')
802
803     def read(self, request, slug):
804         if not request.user.is_authenticated():
805             return rc.FORBIDDEN
806         try:
807             book = Book.objects.get(slug=slug)
808         except Book.DoesNotExist:
809             return rc.NOT_FOUND
810         return {'likes': likes(request.user, book)}
811
812     def create(self, request, slug):
813         if not request.user.is_authenticated():
814             return rc.FORBIDDEN
815         try:
816             book = Book.objects.get(slug=slug)
817         except Book.DoesNotExist:
818             return rc.NOT_FOUND
819         action = request.GET.get('action', 'like')
820         if action == 'like':
821             book.like(request.user)
822         elif action == 'unlike':
823             book.unlike(request.user)
824         return {}
825
826
827 class BlogEntryHandler(BaseHandler):
828     model = Entry
829     fields = (
830         'title', 'lead', 'body', 'place', 'time', 'image_url', 'image_thumb', 'gallery_urls', 'type', 'key', 'url')
831
832     def read(self, request):
833         after = request.GET.get('after')
834         count = int(request.GET.get('count', 20))
835         entries = Entry.published_objects.filter(in_stream=True).order_by('-first_published_at')
836         if after:
837             entries = entries.filter(first_published_at__lt=after)
838         if count:
839             entries = entries[:count]
840         return entries
841
842     @classmethod
843     def image_url(cls, entry):
844         return (WL_BASE + entry.image.url) if entry.image else None
845
846     @classmethod
847     def image_thumb(cls, entry):
848         return MEDIA_BASE + default.backend.get_thumbnail(
849             entry.image, "193x193").url if entry.image else ''
850
851     @classmethod
852     def gallery_urls(cls, entry):
853         return [WL_BASE + photo.url() for photo in entry.photo_set.all()]
854
855     @classmethod
856     def key(cls, entry):
857         return entry.first_published_at
858
859     @classmethod
860     def url(cls, entry):
861         return WL_BASE + entry.get_absolute_url()