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