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