remove 'before' support in api
[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, 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 'count' in request.GET:
230             count = request.GET['count']
231
232         if tags:
233             if top_level:
234                 books = Book.tagged_top_level(tags)
235                 return books if books else rc.NOT_FOUND
236             else:
237                 books = Book.tagged.with_all(tags)
238         else:
239             books = books if books is not None else Book.objects.all()
240         books = books.order_by('slug')
241
242         if top_level:
243             books = books.filter(parent=None)
244         if audiobooks:
245             books = books.filter(media__type='mp3').distinct()
246         if daisy:
247             books = books.filter(media__type='daisy').distinct()
248         if recommended:
249             books = books.filter(recommended=True)
250         if newest:
251             books = books.order_by('-created_at')
252
253         # beznadzieja
254         if after:
255             books = books.filter(slug__gt=after)
256
257         books = books.only('slug', 'title', 'cover', 'cover_thumb')
258         for category in book_tag_categories:
259             books = prefetch_relations(books, category)
260
261         if count:
262             books = books[:count]
263
264         return books
265
266     def create(self, request, *args, **kwargs):
267         return rc.FORBIDDEN
268
269
270 class BooksHandler(BookDetailHandler):
271     allowed_methods = ('GET', 'POST')
272     model = Book
273     fields = book_list_fields + ['liked']
274     anonymous = AnonymousBooksHandler
275
276     # hack, because piston is stupid
277     @classmethod
278     def liked(cls, book):
279         return getattr(book, 'liked', None)
280
281     def read(self, request, **kwargs):
282         books = AnonymousBooksHandler().read(request, **kwargs)
283         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
284
285         new_books = [
286             BookProxy(book).set('liked', book.id in likes)
287             for book in books]
288         return QuerySetProxy(new_books)
289
290     def create(self, request, *args, **kwargs):
291         if not request.user.has_perm('catalogue.add_book'):
292             return rc.FORBIDDEN
293
294         data = json.loads(request.POST.get('data'))
295         form = BookImportForm(data)
296         if form.is_valid():
297             form.save()
298             return rc.CREATED
299         else:
300             return rc.NOT_FOUND
301
302
303 class EpubHandler(BookDetailHandler):
304     def read(self, request, slug):
305         if not is_subscribed(request.user):
306             return rc.FORBIDDEN
307         try:
308             book = Book.objects.get(slug=slug)
309         except Book.DoesNotExist:
310             return rc.NOT_FOUND
311         response = HttpResponse(book.get_media('epub'))
312         return response
313
314
315 class EBooksHandler(AnonymousBooksHandler):
316     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats) + ('slug',)
317
318
319 class BookProxy(models.Model):
320     class Meta:
321         managed = False
322
323     def __init__(self, book, key=None):
324         self.book = book
325         self.key = key
326
327     def set(self, attr, value):
328         self.__setattr__(attr, value)
329         return self
330
331     def __getattr__(self, item):
332         return self.book.__getattribute__(item)
333
334
335 class QuerySetProxy(models.QuerySet):
336     def __init__(self, l):
337         self.list = l
338
339     def __iter__(self):
340         return iter(self.list)
341
342
343 class AnonFilterBooksHandler(AnonymousBooksHandler):
344     fields = book_list_fields + ['key']
345
346     def parse_bool(self, s):
347         if s in ('true', 'false'):
348             return s == 'true'
349         else:
350             return None
351
352     def read(self, request):
353         key_sep = '$'
354         search_string = request.GET.get('search')
355         is_lektura = self.parse_bool(request.GET.get('lektura'))
356         is_audiobook = self.parse_bool(request.GET.get('audiobook'))
357         preview = self.parse_bool(request.GET.get('preview'))
358
359         after = request.GET.get('after')
360         count = int(request.GET.get('count', 50))
361         books = Book.objects.distinct().order_by('slug')
362         if is_lektura is not None:
363             books = books.filter(has_audience=is_lektura)
364         if is_audiobook is not None:
365             if is_audiobook:
366                 books = books.filter(media__type='mp3')
367             else:
368                 books = books.exclude(media__type='mp3')
369         if preview is not None:
370             books = books.filter(preview=preview)
371         for key in request.GET:
372             if key in category_singular:
373                 category = category_singular[key]
374                 if category in book_tag_categories:
375                     slugs = request.GET[key].split(',')
376                     tags = Tag.objects.filter(category=category, slug__in=slugs)
377                     books = Book.tagged.with_any(tags, books)
378         if (search_string is not None) and len(search_string) < 3:
379             search_string = None
380         if search_string:
381             search_string = re_escape(search_string)
382             books_author = books.filter(cached_author__iregex='\m' + search_string)
383             books_title = books.filter(title__iregex='\m' + search_string)
384             books_title = books_title.exclude(id__in=list(books_author.values_list('id', flat=True)))
385             if after and (key_sep in after):
386                 which, slug = after.split(key_sep, 1)
387                 if which == 'title':
388                     book_lists = [(books_title.filter(slug__gt=slug), 'title')]
389                 else:  # which == 'author'
390                     book_lists = [(books_author.filter(slug__gt=slug), 'author'), (books_title, 'title')]
391             else:
392                 book_lists = [(books_author, 'author'), (books_title, 'title')]
393         else:
394             if after and key_sep in after:
395                 which, slug = after.split(key_sep, 1)
396                 books = books.filter(slug__gt=slug)
397             book_lists = [(books, 'book')]
398
399         filtered_books = []
400         for book_list, label in book_lists:
401             book_list = book_list.only('slug', 'title', 'cover', 'cover_thumb')
402             for category in book_tag_categories:
403                 book_list = prefetch_relations(book_list, category)
404             remaining_count = count - len(filtered_books)
405             new_books = [BookProxy(book, '%s%s%s' % (label, key_sep, book.slug))
406                          for book in book_list[:remaining_count]]
407             filtered_books += new_books
408             if len(filtered_books) == count:
409                 break
410
411         return QuerySetProxy(filtered_books)
412
413
414 class FilterBooksHandler(BooksHandler):
415     anonymous = AnonFilterBooksHandler
416     fields = book_list_fields + ['key', 'liked']
417
418     # hack, because piston is stupid
419     @classmethod
420     def liked(cls, book):
421         return getattr(book, 'liked', None)
422
423     def read(self, request):
424         qsp = AnonFilterBooksHandler().read(request)
425         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
426         for book in qsp.list:
427             book.set('liked', book.id in likes)
428         return qsp
429
430
431 class BookPreviewHandler(BookDetailHandler):
432     fields = BookDetailHandler.fields + ['slug']
433
434     def read(self, request):
435         return Book.objects.filter(preview=True)
436
437
438 # add categorized tags fields for Book
439 def _tags_getter(category):
440     @classmethod
441     def get_tags(cls, book):
442         return book.tags.filter(category=category)
443     return get_tags
444
445
446 def _tag_getter(category):
447     @classmethod
448     def get_tag(cls, book):
449         return book.tag_unicode(category)
450     return get_tag
451
452
453 def add_tag_getters():
454     for plural, singular in category_singular.items():
455         setattr(BookDetails, plural, _tags_getter(singular))
456         setattr(BookDetails, singular, _tag_getter(singular))
457
458
459 add_tag_getters()
460
461
462 # add fields for files in Book
463 def _file_getter(book_format):
464
465     @staticmethod
466     def get_file(book):
467         f_url = book.media_url(book_format)
468         if f_url:
469             return MEDIA_BASE + f_url
470         else:
471             return ''
472     return get_file
473
474
475 def add_file_getters():
476     for book_format in Book.formats:
477         setattr(BookDetails, book_format, _file_getter(book_format))
478
479
480 add_file_getters()
481
482
483 class CollectionDetails(object):
484     """Custom Collection fields."""
485
486     @classmethod
487     def href(cls, collection):
488         """ Returns URI in the API for the collection. """
489
490         return API_BASE + reverse("api_collection", args=[collection.slug])
491
492     @classmethod
493     def url(cls, collection):
494         """ Returns URL on the site. """
495
496         return WL_BASE + collection.get_absolute_url()
497
498     @classmethod
499     def books(cls, collection):
500         return Book.objects.filter(collection.get_query())
501
502
503 class CollectionDetailHandler(BaseHandler, CollectionDetails):
504     allowed_methods = ('GET',)
505     fields = ['url', 'title', 'description', 'books']
506
507     @piwik_track
508     def read(self, request, slug):
509         """ Returns details of a collection, identified by slug. """
510         try:
511             return Collection.objects.get(slug=slug)
512         except Collection.DoesNotExist:
513             return rc.NOT_FOUND
514
515
516 class CollectionsHandler(BaseHandler, CollectionDetails):
517     allowed_methods = ('GET',)
518     model = Collection
519     fields = ['url', 'href', 'title']
520
521     @piwik_track
522     def read(self, request):
523         """ Returns all collections. """
524         return Collection.objects.all()
525
526
527 class TagDetails(object):
528     """Custom Tag fields."""
529
530     @classmethod
531     def href(cls, tag):
532         """ Returns URI in the API for the tag. """
533
534         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
535
536     @classmethod
537     def url(cls, tag):
538         """ Returns URL on the site. """
539
540         return WL_BASE + tag.get_absolute_url()
541
542
543 class TagDetailHandler(BaseHandler, TagDetails):
544     """ Responsible for details of a single Tag object. """
545
546     fields = ['name', 'url', 'sort_key', 'description']
547
548     @piwik_track
549     def read(self, request, category, slug):
550         """ Returns details of a tag, identified by category and slug. """
551
552         try:
553             category_sng = category_singular[category]
554         except KeyError:
555             return rc.NOT_FOUND
556
557         try:
558             return Tag.objects.get(category=category_sng, slug=slug)
559         except Tag.DoesNotExist:
560             return rc.NOT_FOUND
561
562
563 class TagsHandler(BaseHandler, TagDetails):
564     """ Main handler for Tag objects.
565
566     Responsible for lists of Tag objects
567     and fields used for representing Tags.
568
569     """
570     allowed_methods = ('GET',)
571     model = Tag
572     fields = ['name', 'href', 'url', 'slug']
573
574     @piwik_track
575     def read(self, request, category=None, pk=None):
576         """ Lists all tags in the category (eg. all themes). """
577         if pk is not None:
578             try:
579                 return Tag.objects.exclude(category='set').get(pk=pk)
580             except Book.DoesNotExist:
581                 return rc.NOT_FOUND
582
583         try:
584             category_sng = category_singular[category]
585         except KeyError:
586             return rc.NOT_FOUND
587
588         after = request.GET.get('after')
589         count = request.GET.get('count')
590
591         tags = Tag.objects.filter(category=category_sng).exclude(items=None).order_by('slug')
592
593         book_only = request.GET.get('book_only') == 'true'
594         picture_only = request.GET.get('picture_only') == 'true'
595         if book_only:
596             tags = tags.filter(for_books=True)
597         if picture_only:
598             tags = tags.filter(for_pictures=True)
599
600         if after:
601             tags = tags.filter(slug__gt=after)
602
603         if count:
604             tags = tags[:count]
605
606         return tags
607
608
609 class FragmentDetails(object):
610     """Custom Fragment fields."""
611
612     @classmethod
613     def href(cls, fragment):
614         """ Returns URI in the API for the fragment. """
615
616         return API_BASE + reverse("api_fragment", args=[fragment.book.slug, fragment.anchor])
617
618     @classmethod
619     def url(cls, fragment):
620         """ Returns URL on the site for the fragment. """
621
622         return WL_BASE + fragment.get_absolute_url()
623
624     @classmethod
625     def themes(cls, fragment):
626         """ Returns a list of theme tags for the fragment. """
627
628         return fragment.tags.filter(category='theme')
629
630
631 class FragmentDetailHandler(BaseHandler, FragmentDetails):
632     fields = ['book', 'anchor', 'text', 'url', 'themes']
633
634     @piwik_track
635     def read(self, request, book, anchor):
636         """ Returns details of a fragment, identified by book slug and anchor. """
637         try:
638             return Fragment.objects.get(book__slug=book, anchor=anchor)
639         except Fragment.DoesNotExist:
640             return rc.NOT_FOUND
641
642
643 class FragmentsHandler(BaseHandler, FragmentDetails):
644     """ Main handler for Fragments.
645
646     Responsible for lists of Fragment objects
647     and fields used for representing Fragments.
648
649     """
650     model = Fragment
651     fields = ['book', 'url', 'anchor', 'href']
652     allowed_methods = ('GET',)
653
654     categories = {'author', 'epoch', 'kind', 'genre', 'book', 'theme'}
655
656     @piwik_track
657     def read(self, request, tags):
658         """ Lists all fragments with given book, tags, themes.
659
660         :param tags: should be a path of categories and slugs, i.e.:
661              books/book-slug/authors/an-author/themes/a-theme/
662
663         """
664         try:
665             tags, ancestors = read_tags(tags, request, allowed=self.categories)
666         except ValueError:
667             return rc.NOT_FOUND
668         fragments = Fragment.tagged.with_all(tags).select_related('book')
669         if fragments.exists():
670             return fragments
671         else:
672             return rc.NOT_FOUND
673
674
675 class PictureHandler(BaseHandler):
676     model = Picture
677     fields = ('slug', 'title')
678     allowed_methods = ('POST',)
679
680     def create(self, request):
681         if not request.user.has_perm('picture.add_picture'):
682             return rc.FORBIDDEN
683
684         data = json.loads(request.POST.get('data'))
685         form = PictureImportForm(data)
686         if form.is_valid():
687             form.save()
688             return rc.CREATED
689         else:
690             return rc.NOT_FOUND
691
692
693 class UserDataHandler(BaseHandler):
694     model = BookUserData
695     fields = ('state', 'username', 'premium')
696     allowed_methods = ('GET', 'POST')
697
698     def read(self, request, slug=None):
699         if not request.user.is_authenticated():
700             return rc.FORBIDDEN
701         if slug is None:
702             return {'username': request.user.username, 'premium': is_subscribed(request.user)}
703         try:
704             book = Book.objects.get(slug=slug)
705         except Book.DoesNotExist:
706             return rc.NOT_FOUND
707         try:
708             data = BookUserData.objects.get(book=book, user=request.user)
709         except BookUserData.DoesNotExist:
710             return {'state': 'not_started'}
711         return data
712
713     def create(self, request, slug, state):
714         try:
715             book = Book.objects.get(slug=slug)
716         except Book.DoesNotExist:
717             return rc.NOT_FOUND
718         if not request.user.is_authenticated():
719             return rc.FORBIDDEN
720         if state not in ('reading', 'complete'):
721             return rc.NOT_FOUND
722         data, created = BookUserData.objects.get_or_create(book=book, user=request.user)
723         data.state = state
724         data.save()
725         return data
726
727
728 class UserShelfHandler(BookDetailHandler):
729     fields = book_list_fields + ['liked']
730
731     def parse_bool(self, s):
732         if s in ('true', 'false'):
733             return s == 'true'
734         else:
735             return None
736
737     # hack, because piston is stupid
738     @classmethod
739     def liked(cls, book):
740         return getattr(book, 'liked', None)
741
742     def read(self, request, state):
743         if not request.user.is_authenticated():
744             return rc.FORBIDDEN
745         likes = set(Book.tagged.with_any(request.user.tag_set.all()).values_list('id', flat=True))
746         if state not in ('reading', 'complete', 'likes'):
747             return rc.NOT_FOUND
748         after = request.GET.get('after')
749         count = int(request.GET.get('count', 50))
750         if state == 'likes':
751             books = Book.tagged.with_any(request.user.tag_set.all())
752         else:
753             ids = BookUserData.objects.filter(user=request.user, complete=state == 'complete')\
754                 .values_list('book_id', flat=True)
755             books = Book.objects.filter(id__in=list(ids)).distinct().order_by('slug')
756         if after:
757             books = books.filter(slug__gt=after)
758         if count:
759             books = books[:count]
760         new_books = []
761         for book in books:
762             new_books.append(BookProxy(book).set('liked', book.id in likes))
763         return QuerySetProxy(new_books)
764
765
766 class UserLikeHandler(BaseHandler):
767     fields = []
768     allowed_methods = ('GET', 'POST')
769
770     def read(self, request, slug):
771         if not request.user.is_authenticated():
772             return rc.FORBIDDEN
773         try:
774             book = Book.objects.get(slug=slug)
775         except Book.DoesNotExist:
776             return rc.NOT_FOUND
777         return {'likes': likes(request.user, book)}
778
779     def create(self, request, slug):
780         if not request.user.is_authenticated():
781             return rc.FORBIDDEN
782         try:
783             book = Book.objects.get(slug=slug)
784         except Book.DoesNotExist:
785             return rc.NOT_FOUND
786         action = request.GET.get('action', 'like')
787         if action == 'like':
788             book.like(request.user)
789         elif action == 'unlike':
790             book.unlike(request.user)
791         return {}
792
793
794 class BlogEntryHandler(BaseHandler):
795     model = Entry
796     fields = (
797         'title', 'lead', 'body', 'place', 'time', 'image_url', 'image_thumb', 'gallery_urls', 'type', 'key', 'url')
798
799     def read(self, request):
800         after = request.GET.get('after')
801         count = int(request.GET.get('count', 20))
802         entries = Entry.published_objects.filter(in_stream=True).order_by('-first_published_at')
803         if after:
804             entries = entries.filter(first_published_at__lt=after)
805         if count:
806             entries = entries[:count]
807         return entries
808
809     @classmethod
810     def image_url(cls, entry):
811         return (WL_BASE + entry.image.url) if entry.image else None
812
813     @classmethod
814     def image_thumb(cls, entry):
815         return MEDIA_BASE + default.backend.get_thumbnail(
816             entry.image, "193x193").url if entry.image else ''
817
818     @classmethod
819     def gallery_urls(cls, entry):
820         return [WL_BASE + photo.url() for photo in entry.photo_set.all()]
821
822     @classmethod
823     def key(cls, entry):
824         return entry.first_published_at
825
826     @classmethod
827     def url(cls, entry):
828         return WL_BASE + entry.get_absolute_url()