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.
5 from datetime import datetime, timedelta
7 from urlparse import urljoin
9 from django.conf import settings
10 from django.contrib.sites.models import Site
11 from django.core.cache import get_cache
12 from django.core.urlresolvers import reverse
13 from piston.handler import AnonymousBaseHandler, BaseHandler
14 from piston.utils import rc
15 from sorl.thumbnail import default
17 from api.helpers import timestamp
18 from api.models import Deleted
19 from catalogue.forms import BookImportForm
20 from catalogue.models import Book, Tag, BookMedia, Fragment
21 from picture.models import Picture
22 from picture.forms import PictureImportForm
24 from stats.utils import piwik_track
26 API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain
38 for k, v in category_singular.items():
39 category_plural[v] = k
41 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
45 def read_tags(tags, allowed):
46 """ Reads a path of filtering tags.
48 :param str tags: a path of category and slug pairs, like: authors/an-author/...
49 :returns: list of Tag objects
50 :raises: ValueError when tags can't be found
55 tags = tags.strip('/').split('/')
58 category = tags.pop(0)
62 category = category_singular[category]
64 raise ValueError('Unknown category.')
66 if not category in allowed:
67 raise ValueError('Category not allowed.')
70 if category == 'book':
74 real_tags.append(Tag.objects.get(category=category, slug=slug))
75 except Tag.DoesNotExist:
76 raise ValueError('Tag not found')
83 class BookMediaHandler(BaseHandler):
84 """ Responsible for representing media in Books. """
87 fields = ['name', 'type', 'url', 'artist', 'director']
91 """ Link to media on site. """
93 return MEDIA_BASE + media.file.url
96 def artist(cls, media):
97 return media.extra_info.get('artist_name', '')
100 def director(cls, media):
101 return media.extra_info.get('director_name', '')
105 class BookDetails(object):
106 """Custom fields used for representing Books."""
109 def author(cls, book):
110 return ",".join(t[0] for t in book.related_info()['tags'].get('author', []))
114 """ Returns an URI for a Book in the API. """
115 return API_BASE + reverse("api_book", args=[book.slug])
119 """ Returns Book's URL on the site. """
121 return WL_BASE + book.get_absolute_url()
124 def children(cls, book):
125 """ Returns all children for a book. """
127 return book.children.all()
130 def media(cls, book):
131 """ Returns all media for a book. """
132 return book.media.all()
135 def cover(cls, book):
136 return MEDIA_BASE + book.cover.url if book.cover else ''
139 def cover_thumb(cls, book):
140 return MEDIA_BASE + default.backend.get_thumbnail(
141 book.cover, "139x193").url
145 class BookDetailHandler(BaseHandler, BookDetails):
146 """ Main handler for Book objects.
148 Responsible for single Book details.
150 allowed_methods = ['GET']
151 fields = ['title', 'parent', 'children'] + Book.formats + [
152 'media', 'url', 'cover', 'cover_thumb'] + [
153 category_plural[c] for c in book_tag_categories]
156 def read(self, request, book):
157 """ Returns details of a book, identified by a slug and lang. """
159 return Book.objects.get(slug=book)
160 except Book.DoesNotExist:
164 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
165 """ Main handler for Book objects.
167 Responsible for lists of Book objects.
169 allowed_methods = ('GET',)
171 fields = ['author', 'href', 'title', 'url', 'cover']
174 def read(self, request, tags, top_level=False,
175 audiobooks=False, daisy=False):
176 """ Lists all books with given tags.
178 :param tags: filtering tags; should be a path of categories
179 and slugs, i.e.: authors/an-author/epoch/an-epoch/
180 :param top_level: if True and a book is included in the results,
181 it's children are aren't. By default all books matching the tags
185 tags = read_tags(tags, allowed=book_tag_categories)
191 books = Book.tagged_top_level(tags)
192 return books if books else rc.NOT_FOUND
194 books = Book.tagged.with_all(tags)
196 books = Book.objects.all()
199 books = books.filter(parent=None)
201 books = books.filter(media__type='mp3').distinct()
203 books = books.filter(media__type='daisy').distinct()
210 def create(self, request, *args, **kwargs):
214 class BooksHandler(BookDetailHandler):
215 allowed_methods = ('GET', 'POST')
217 fields = ['author', 'href', 'title', 'url']
218 anonymous = AnonymousBooksHandler
220 def create(self, request, *args, **kwargs):
221 if not request.user.has_perm('catalogue.add_book'):
224 data = json.loads(request.POST.get('data'))
225 form = BookImportForm(data)
233 class EBooksHandler(AnonymousBooksHandler):
234 fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats)
237 # add categorized tags fields for Book
238 def _tags_getter(category):
240 def get_tags(cls, book):
241 return book.tags.filter(category=category)
243 for plural, singular in category_singular.items():
244 setattr(BookDetails, plural, _tags_getter(singular))
246 # add fields for files in Book
247 def _file_getter(format):
248 field = "%s_file" % format
250 def get_file(cls, book):
251 f = getattr(book, field)
253 return MEDIA_BASE + f.url
257 for format in Book.formats:
258 setattr(BookDetails, format, _file_getter(format))
261 class TagDetails(object):
262 """Custom Tag fields."""
266 """ Returns URI in the API for the tag. """
268 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
272 """ Returns URL on the site. """
274 return WL_BASE + tag.get_absolute_url()
277 class TagDetailHandler(BaseHandler, TagDetails):
278 """ Responsible for details of a single Tag object. """
280 fields = ['name', 'url', 'sort_key', 'description']
283 def read(self, request, category, slug):
284 """ Returns details of a tag, identified by category and slug. """
287 category_sng = category_singular[category]
292 return Tag.objects.get(category=category_sng, slug=slug)
293 except Tag.DoesNotExist:
297 class TagsHandler(BaseHandler, TagDetails):
298 """ Main handler for Tag objects.
300 Responsible for lists of Tag objects
301 and fields used for representing Tags.
304 allowed_methods = ('GET',)
306 fields = ['name', 'href', 'url']
309 def read(self, request, category):
310 """ Lists all tags in the category (eg. all themes). """
313 category_sng = category_singular[category]
317 tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
324 class FragmentDetails(object):
325 """Custom Fragment fields."""
328 def href(cls, fragment):
329 """ Returns URI in the API for the fragment. """
331 return API_BASE + reverse("api_fragment",
332 args=[fragment.book.slug, fragment.anchor])
335 def url(cls, fragment):
336 """ Returns URL on the site for the fragment. """
338 return WL_BASE + fragment.get_absolute_url()
341 def themes(cls, fragment):
342 """ Returns a list of theme tags for the fragment. """
344 return fragment.tags.filter(category='theme')
347 class FragmentDetailHandler(BaseHandler, FragmentDetails):
348 fields = ['book', 'anchor', 'text', 'url', 'themes']
351 def read(self, request, book, anchor):
352 """ Returns details of a fragment, identified by book slug and anchor. """
354 return Fragment.objects.get(book__slug=book, anchor=anchor)
355 except Fragment.DoesNotExist:
359 class FragmentsHandler(BaseHandler, FragmentDetails):
360 """ Main handler for Fragments.
362 Responsible for lists of Fragment objects
363 and fields used for representing Fragments.
367 fields = ['book', 'url', 'anchor', 'href']
368 allowed_methods = ('GET',)
370 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
373 def read(self, request, tags):
374 """ Lists all fragments with given book, tags, themes.
376 :param tags: should be a path of categories and slugs, i.e.:
377 books/book-slug/authors/an-author/themes/a-theme/
381 tags = read_tags(tags, allowed=self.categories)
384 fragments = Fragment.tagged.with_all(tags).select_related('book')
385 if fragments.exists():
394 class CatalogueHandler(BaseHandler):
397 def fields(request, name):
398 fields_str = request.GET.get(name) if request is not None else None
399 return fields_str.split(',') if fields_str is not None else None
403 """ Returns time suitable for use as upper time boundary for check.
405 Used to avoid issues with time between setting the change stamp
406 and actually saving the model in database.
407 Cuts the microsecond part to avoid issues with DBs where time has
410 :param datetime t: manually sets the upper boundary
413 # set to five minutes ago, to avoid concurrency issues
415 t = datetime.now() - timedelta(seconds=settings.API_WAIT)
416 # set to whole second in case DB supports something smaller
417 return t.replace(microsecond=0)
420 def book_dict(book, fields=None):
421 all_fields = ['url', 'title', 'description',
422 'gazeta_link', 'wiki_link',
423 ] + Book.formats + BookMedia.formats.keys() + [
424 'parent', 'parent_number',
426 'license', 'license_description', 'source_name',
427 'technical_editors', 'editors',
428 'author', 'sort_key',
431 fields = (f for f in fields if f in all_fields)
435 extra_info = book.extra_info
440 if field in Book.formats:
441 f = getattr(book, field+'_file')
448 elif field in BookMedia.formats:
450 for m in book.media.filter(type=field).iterator():
459 obj[field] = book.get_absolute_url()
461 elif field == 'tags':
462 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
464 elif field == 'author':
465 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
467 elif field == 'parent':
468 obj[field] = book.parent_id
470 elif field in ('license', 'license_description', 'source_name',
471 'technical_editors', 'editors'):
472 f = extra_info.get(field)
477 f = getattr(book, field)
485 def book_changes(cls, request=None, since=0, until=None, fields=None):
486 since = datetime.fromtimestamp(int(since))
487 until = cls.until(until)
490 'time_checked': timestamp(until)
494 fields = cls.fields(request, 'book_fields')
501 for book in Book.objects.filter(changed_at__gte=since,
502 changed_at__lt=until).iterator():
503 book_d = cls.book_dict(book, fields)
504 updated.append(book_d)
506 changes['updated'] = updated
508 for book in Deleted.objects.filter(content_type=Book,
509 deleted_at__gte=since,
510 deleted_at__lt=until,
511 created_at__lt=since).iterator():
512 deleted.append(book.id)
514 changes['deleted'] = deleted
519 def tag_dict(tag, fields=None):
520 all_fields = ('name', 'category', 'sort_key', 'description',
521 'gazeta_link', 'wiki_link',
526 fields = (f for f in fields if f in all_fields)
534 obj[field] = tag.get_absolute_url()
536 elif field == 'books':
537 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
539 elif field == 'sort_key':
540 obj[field] = tag.sort_key
543 f = getattr(tag, field)
551 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
552 since = datetime.fromtimestamp(int(since))
553 until = cls.until(until)
556 'time_checked': timestamp(until)
560 fields = cls.fields(request, 'tag_fields')
562 categories = cls.fields(request, 'tag_categories')
564 all_categories = ('author', 'epoch', 'kind', 'genre')
566 categories = (c for c in categories if c in all_categories)
568 categories = all_categories
573 for tag in Tag.objects.filter(category__in=categories,
574 changed_at__gte=since,
575 changed_at__lt=until).iterator():
576 # only serve non-empty tags
578 tag_d = cls.tag_dict(tag, fields)
579 updated.append(tag_d)
580 elif tag.created_at < since:
581 deleted.append(tag.id)
583 changes['updated'] = updated
585 for tag in Deleted.objects.filter(category__in=categories,
587 deleted_at__gte=since,
588 deleted_at__lt=until,
589 created_at__lt=since).iterator():
590 deleted.append(tag.id)
592 changes['deleted'] = deleted
597 def changes(cls, request=None, since=0, until=None, book_fields=None,
598 tag_fields=None, tag_categories=None):
599 until = cls.until(until)
603 cache = get_cache('api')
604 key = hash((book_fields, tag_fields, tag_categories,
605 tuple(sorted(request.GET.items()))
607 value = cache.get(key)
608 if value is not None:
612 'time_checked': timestamp(until)
616 'books': cls.book_changes(request, since, until, book_fields),
617 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
620 for model in changes_by_type:
621 for field in changes_by_type[model]:
622 if field == 'time_checked':
624 changes.setdefault(field, {})[model] = changes_by_type[model][field]
627 cache.set(key, changes)
632 class BookChangesHandler(CatalogueHandler):
633 allowed_methods = ('GET',)
636 def read(self, request, since):
637 return self.book_changes(request, since)
640 class TagChangesHandler(CatalogueHandler):
641 allowed_methods = ('GET',)
644 def read(self, request, since):
645 return self.tag_changes(request, since)
648 class ChangesHandler(CatalogueHandler):
649 allowed_methods = ('GET',)
652 def read(self, request, since):
653 return self.changes(request, since)
656 class PictureHandler(BaseHandler):
658 fields = ('slug', 'title')
659 allowed_methods = ('POST',)
661 def create(self, request):
662 if not request.user.has_perm('picture.add_picture'):
665 data = json.loads(request.POST.get('data'))
666 form = PictureImportForm(data)