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 django.utils.timezone import utc
14 from piston.handler import AnonymousBaseHandler, BaseHandler
15 from piston.utils import rc
16 from sorl.thumbnail import default
18 from api.helpers import timestamp
19 from api.models import Deleted
20 from catalogue.forms import BookImportForm
21 from catalogue.models import Book, Tag, BookMedia, Fragment, Collection
22 from catalogue.utils import related_tag_name
23 from picture.models import Picture
24 from picture.forms import PictureImportForm
25 from wolnelektury.utils import tz
27 from stats.utils import piwik_track
29 API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain
41 for k, v in category_singular.items():
42 category_plural[v] = k
44 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
48 def read_tags(tags, allowed):
49 """ Reads a path of filtering tags.
51 :param str tags: a path of category and slug pairs, like: authors/an-author/...
52 :returns: list of Tag objects
53 :raises: ValueError when tags can't be found
58 tags = tags.strip('/').split('/')
61 category = tags.pop(0)
65 category = category_singular[category]
67 raise ValueError('Unknown category.')
69 if not category in allowed:
70 raise ValueError('Category not allowed.')
73 if category == 'book':
77 real_tags.append(Tag.objects.get(category=category, slug=slug))
78 except Tag.DoesNotExist:
79 raise ValueError('Tag not found')
86 class BookMediaHandler(BaseHandler):
87 """ Responsible for representing media in Books. """
90 fields = ['name', 'type', 'url', 'artist', 'director']
94 """ Link to media on site. """
96 return MEDIA_BASE + media.file.url
99 def artist(cls, media):
100 return media.extra_info.get('artist_name', '')
103 def director(cls, media):
104 return media.extra_info.get('director_name', '')
108 class BookDetails(object):
109 """Custom fields used for representing Books."""
113 """ Returns an URI for a Book in the API. """
114 return API_BASE + reverse("api_book", args=[book.slug])
118 """ Returns Book's URL on the site. """
120 return WL_BASE + book.get_absolute_url()
123 def children(cls, book):
124 """ Returns all children for a book. """
126 return book.children.all()
129 def media(cls, book):
130 """ Returns all media for a book. """
131 return book.media.all()
134 def cover(cls, book):
135 return MEDIA_BASE + book.cover.url if book.cover else ''
138 def cover_thumb(cls, book):
139 return MEDIA_BASE + default.backend.get_thumbnail(
140 book.cover, "139x193").url if book.cover else ''
144 class BookDetailHandler(BaseHandler, BookDetails):
145 """ Main handler for Book objects.
147 Responsible for single Book details.
149 allowed_methods = ['GET']
150 fields = ['title', 'parent', 'children'] + Book.formats + [
151 'media', 'url', 'cover', 'cover_thumb'] + [
152 category_plural[c] for c in book_tag_categories]
155 def read(self, request, book):
156 """ Returns details of a book, identified by a slug and lang. """
158 return Book.objects.get(slug=book)
159 except Book.DoesNotExist:
163 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
164 """ Main handler for Book objects.
166 Responsible for lists of Book objects.
168 allowed_methods = ('GET',)
170 fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
173 def genres(cls, book):
174 """ Returns all media for a book. """
175 return book.tags.filter(category='genre')
178 def read(self, request, tags, top_level=False,
179 audiobooks=False, daisy=False):
180 """ Lists all books with given tags.
182 :param tags: filtering tags; should be a path of categories
183 and slugs, i.e.: authors/an-author/epoch/an-epoch/
184 :param top_level: if True and a book is included in the results,
185 it's children are aren't. By default all books matching the tags
189 tags = read_tags(tags, allowed=book_tag_categories)
195 books = Book.tagged_top_level(tags)
196 return books if books else rc.NOT_FOUND
198 books = Book.tagged.with_all(tags)
200 books = Book.objects.all()
203 books = books.filter(parent=None)
205 books = books.filter(media__type='mp3').distinct()
207 books = books.filter(media__type='daisy').distinct()
214 def create(self, request, *args, **kwargs):
218 class BooksHandler(BookDetailHandler):
219 allowed_methods = ('GET', 'POST')
221 fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
222 anonymous = AnonymousBooksHandler
224 def create(self, request, *args, **kwargs):
225 if not request.user.has_perm('catalogue.add_book'):
228 data = json.loads(request.POST.get('data'))
229 form = BookImportForm(data)
237 class EBooksHandler(AnonymousBooksHandler):
238 fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats)
241 # add categorized tags fields for Book
242 def _tags_getter(category):
244 def get_tags(cls, book):
245 return book.tags.filter(category=category)
247 def _tag_getter(category):
249 def get_tag(cls, book):
250 return ", ".join(related_tag_name(t) for t in book.related_info()['tags'].get(category, []))
252 for plural, singular in category_singular.items():
253 setattr(BookDetails, plural, _tags_getter(singular))
254 setattr(BookDetails, singular, _tag_getter(singular))
256 # add fields for files in Book
257 def _file_getter(format):
258 field = "%s_file" % format
260 def get_file(cls, book):
261 f = getattr(book, field)
263 return MEDIA_BASE + f.url
267 for format in Book.formats:
268 setattr(BookDetails, format, _file_getter(format))
271 class CollectionDetails(object):
272 """Custom Collection fields."""
275 def href(cls, collection):
276 """ Returns URI in the API for the collection. """
278 return API_BASE + reverse("api_collection", args=[collection.slug])
281 def url(cls, collection):
282 """ Returns URL on the site. """
284 return WL_BASE + collection.get_absolute_url()
287 def books(cls, collection):
288 return Book.objects.filter(collection.get_query())
292 class CollectionDetailHandler(BaseHandler, CollectionDetails):
293 allowed_methods = ('GET',)
294 fields = ['url', 'title', 'description', 'books']
297 def read(self, request, slug):
299 """ Returns details of a collection, identified by slug. """
301 return Collection.objects.get(slug=slug)
302 except Collection.DoesNotExist:
306 class CollectionsHandler(BaseHandler, CollectionDetails):
307 allowed_methods = ('GET',)
309 fields = ['url', 'href', 'title']
312 def read(self, request):
313 """ Returns all collections. """
314 return Collection.objects.all()
317 class TagDetails(object):
318 """Custom Tag fields."""
322 """ Returns URI in the API for the tag. """
324 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
328 """ Returns URL on the site. """
330 return WL_BASE + tag.get_absolute_url()
333 class TagDetailHandler(BaseHandler, TagDetails):
334 """ Responsible for details of a single Tag object. """
336 fields = ['name', 'url', 'sort_key', 'description']
339 def read(self, request, category, slug):
340 """ Returns details of a tag, identified by category and slug. """
343 category_sng = category_singular[category]
348 return Tag.objects.get(category=category_sng, slug=slug)
349 except Tag.DoesNotExist:
353 class TagsHandler(BaseHandler, TagDetails):
354 """ Main handler for Tag objects.
356 Responsible for lists of Tag objects
357 and fields used for representing Tags.
360 allowed_methods = ('GET',)
362 fields = ['name', 'href', 'url']
365 def read(self, request, category):
366 """ Lists all tags in the category (eg. all themes). """
369 category_sng = category_singular[category]
373 tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
380 class FragmentDetails(object):
381 """Custom Fragment fields."""
384 def href(cls, fragment):
385 """ Returns URI in the API for the fragment. """
387 return API_BASE + reverse("api_fragment",
388 args=[fragment.book.slug, fragment.anchor])
391 def url(cls, fragment):
392 """ Returns URL on the site for the fragment. """
394 return WL_BASE + fragment.get_absolute_url()
397 def themes(cls, fragment):
398 """ Returns a list of theme tags for the fragment. """
400 return fragment.tags.filter(category='theme')
403 class FragmentDetailHandler(BaseHandler, FragmentDetails):
404 fields = ['book', 'anchor', 'text', 'url', 'themes']
407 def read(self, request, book, anchor):
408 """ Returns details of a fragment, identified by book slug and anchor. """
410 return Fragment.objects.get(book__slug=book, anchor=anchor)
411 except Fragment.DoesNotExist:
415 class FragmentsHandler(BaseHandler, FragmentDetails):
416 """ Main handler for Fragments.
418 Responsible for lists of Fragment objects
419 and fields used for representing Fragments.
423 fields = ['book', 'url', 'anchor', 'href']
424 allowed_methods = ('GET',)
426 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
429 def read(self, request, tags):
430 """ Lists all fragments with given book, tags, themes.
432 :param tags: should be a path of categories and slugs, i.e.:
433 books/book-slug/authors/an-author/themes/a-theme/
437 tags = read_tags(tags, allowed=self.categories)
440 fragments = Fragment.tagged.with_all(tags).select_related('book')
441 if fragments.exists():
450 class CatalogueHandler(BaseHandler):
453 def fields(request, name):
454 fields_str = request.GET.get(name) if request is not None else None
455 return fields_str.split(',') if fields_str is not None else None
459 """ Returns time suitable for use as upper time boundary for check.
461 Used to avoid issues with time between setting the change stamp
462 and actually saving the model in database.
463 Cuts the microsecond part to avoid issues with DBs where time has
466 :param datetime t: manually sets the upper boundary
469 # set to five minutes ago, to avoid concurrency issues
471 t = datetime.utcnow().replace(tzinfo=utc) - timedelta(seconds=settings.API_WAIT)
472 # set to whole second in case DB supports something smaller
473 return t.replace(microsecond=0)
476 def book_dict(book, fields=None):
477 all_fields = ['url', 'title', 'description',
478 'gazeta_link', 'wiki_link',
479 ] + Book.formats + BookMedia.formats.keys() + [
480 'parent', 'parent_number',
482 'license', 'license_description', 'source_name',
483 'technical_editors', 'editors',
484 'author', 'sort_key',
487 fields = (f for f in fields if f in all_fields)
491 extra_info = book.extra_info
496 if field in Book.formats:
497 f = getattr(book, field+'_file')
504 elif field in BookMedia.formats:
506 for m in book.media.filter(type=field).iterator():
515 obj[field] = book.get_absolute_url()
517 elif field == 'tags':
518 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
520 elif field == 'author':
521 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
523 elif field == 'parent':
524 obj[field] = book.parent_id
526 elif field in ('license', 'license_description', 'source_name',
527 'technical_editors', 'editors'):
528 f = extra_info.get(field)
533 f = getattr(book, field)
541 def book_changes(cls, request=None, since=0, until=None, fields=None):
542 since = datetime.fromtimestamp(int(since), tz)
543 until = cls.until(until)
546 'time_checked': timestamp(until)
550 fields = cls.fields(request, 'book_fields')
557 for book in Book.objects.filter(changed_at__gte=since,
558 changed_at__lt=until).iterator():
559 book_d = cls.book_dict(book, fields)
560 updated.append(book_d)
562 changes['updated'] = updated
564 for book in Deleted.objects.filter(content_type=Book,
565 deleted_at__gte=since,
566 deleted_at__lt=until,
567 created_at__lt=since).iterator():
568 deleted.append(book.id)
570 changes['deleted'] = deleted
575 def tag_dict(tag, fields=None):
576 all_fields = ('name', 'category', 'sort_key', 'description',
577 'gazeta_link', 'wiki_link',
582 fields = (f for f in fields if f in all_fields)
590 obj[field] = tag.get_absolute_url()
592 elif field == 'books':
593 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
595 elif field == 'sort_key':
596 obj[field] = tag.sort_key
599 f = getattr(tag, field)
607 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
608 since = datetime.fromtimestamp(int(since), tz)
609 until = cls.until(until)
612 'time_checked': timestamp(until)
616 fields = cls.fields(request, 'tag_fields')
618 categories = cls.fields(request, 'tag_categories')
620 all_categories = ('author', 'epoch', 'kind', 'genre')
622 categories = (c for c in categories if c in all_categories)
624 categories = all_categories
629 for tag in Tag.objects.filter(category__in=categories,
630 changed_at__gte=since,
631 changed_at__lt=until).iterator():
632 # only serve non-empty tags
634 tag_d = cls.tag_dict(tag, fields)
635 updated.append(tag_d)
636 elif tag.created_at < since:
637 deleted.append(tag.id)
639 changes['updated'] = updated
641 for tag in Deleted.objects.filter(category__in=categories,
643 deleted_at__gte=since,
644 deleted_at__lt=until,
645 created_at__lt=since).iterator():
646 deleted.append(tag.id)
648 changes['deleted'] = deleted
653 def changes(cls, request=None, since=0, until=None, book_fields=None,
654 tag_fields=None, tag_categories=None):
655 until = cls.until(until)
659 cache = get_cache('api')
660 key = hash((book_fields, tag_fields, tag_categories,
661 tuple(sorted(request.GET.items()))
663 value = cache.get(key)
664 if value is not None:
668 'time_checked': timestamp(until)
672 'books': cls.book_changes(request, since, until, book_fields),
673 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
676 for model in changes_by_type:
677 for field in changes_by_type[model]:
678 if field == 'time_checked':
680 changes.setdefault(field, {})[model] = changes_by_type[model][field]
683 cache.set(key, changes)
688 class BookChangesHandler(CatalogueHandler):
689 allowed_methods = ('GET',)
692 def read(self, request, since):
693 return self.book_changes(request, since)
696 class TagChangesHandler(CatalogueHandler):
697 allowed_methods = ('GET',)
700 def read(self, request, since):
701 return self.tag_changes(request, since)
704 class ChangesHandler(CatalogueHandler):
705 allowed_methods = ('GET',)
708 def read(self, request, since):
709 return self.changes(request, since)
712 class PictureHandler(BaseHandler):
714 fields = ('slug', 'title')
715 allowed_methods = ('POST',)
717 def create(self, request):
718 if not request.user.has_perm('picture.add_picture'):
721 data = json.loads(request.POST.get('data'))
722 form = PictureImportForm(data)