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
8 from django.conf import settings
9 from django.contrib.sites.models import Site
10 from django.core.cache import get_cache
11 from django.core.urlresolvers import reverse
12 from django.utils.functional import lazy
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 = lazy(
30 lambda: u'http://' + Site.objects.get_current().domain, unicode)()
42 for k, v in category_singular.items():
43 category_plural[v] = k
45 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
49 def read_tags(tags, allowed):
50 """ Reads a path of filtering tags.
52 :param str tags: a path of category and slug pairs, like: authors/an-author/...
53 :returns: list of Tag objects
54 :raises: ValueError when tags can't be found
59 tags = tags.strip('/').split('/')
62 category = tags.pop(0)
66 category = category_singular[category]
68 raise ValueError('Unknown category.')
70 if not category in allowed:
71 raise ValueError('Category not allowed.')
74 if category == 'book':
78 real_tags.append(Tag.objects.get(category=category, slug=slug))
79 except Tag.DoesNotExist:
80 raise ValueError('Tag not found')
87 class BookMediaHandler(BaseHandler):
88 """ Responsible for representing media in Books. """
91 fields = ['name', 'type', 'url', 'artist', 'director']
95 """ Link to media on site. """
97 return MEDIA_BASE + media.file.url
100 def artist(cls, media):
101 return media.extra_info.get('artist_name', '')
104 def director(cls, media):
105 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):
298 """ Returns details of a collection, identified by slug. """
300 return Collection.objects.get(slug=slug)
301 except Collection.DoesNotExist:
305 class CollectionsHandler(BaseHandler, CollectionDetails):
306 allowed_methods = ('GET',)
308 fields = ['url', 'href', 'title']
311 def read(self, request):
312 """ Returns all collections. """
313 return Collection.objects.all()
316 class TagDetails(object):
317 """Custom Tag fields."""
321 """ Returns URI in the API for the tag. """
323 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
327 """ Returns URL on the site. """
329 return WL_BASE + tag.get_absolute_url()
332 class TagDetailHandler(BaseHandler, TagDetails):
333 """ Responsible for details of a single Tag object. """
335 fields = ['name', 'url', 'sort_key', 'description']
338 def read(self, request, category, slug):
339 """ Returns details of a tag, identified by category and slug. """
342 category_sng = category_singular[category]
347 return Tag.objects.get(category=category_sng, slug=slug)
348 except Tag.DoesNotExist:
352 class TagsHandler(BaseHandler, TagDetails):
353 """ Main handler for Tag objects.
355 Responsible for lists of Tag objects
356 and fields used for representing Tags.
359 allowed_methods = ('GET',)
361 fields = ['name', 'href', 'url']
364 def read(self, request, category):
365 """ Lists all tags in the category (eg. all themes). """
368 category_sng = category_singular[category]
372 tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
379 class FragmentDetails(object):
380 """Custom Fragment fields."""
383 def href(cls, fragment):
384 """ Returns URI in the API for the fragment. """
386 return API_BASE + reverse("api_fragment",
387 args=[fragment.book.slug, fragment.anchor])
390 def url(cls, fragment):
391 """ Returns URL on the site for the fragment. """
393 return WL_BASE + fragment.get_absolute_url()
396 def themes(cls, fragment):
397 """ Returns a list of theme tags for the fragment. """
399 return fragment.tags.filter(category='theme')
402 class FragmentDetailHandler(BaseHandler, FragmentDetails):
403 fields = ['book', 'anchor', 'text', 'url', 'themes']
406 def read(self, request, book, anchor):
407 """ Returns details of a fragment, identified by book slug and anchor. """
409 return Fragment.objects.get(book__slug=book, anchor=anchor)
410 except Fragment.DoesNotExist:
414 class FragmentsHandler(BaseHandler, FragmentDetails):
415 """ Main handler for Fragments.
417 Responsible for lists of Fragment objects
418 and fields used for representing Fragments.
422 fields = ['book', 'url', 'anchor', 'href']
423 allowed_methods = ('GET',)
425 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
428 def read(self, request, tags):
429 """ Lists all fragments with given book, tags, themes.
431 :param tags: should be a path of categories and slugs, i.e.:
432 books/book-slug/authors/an-author/themes/a-theme/
436 tags = read_tags(tags, allowed=self.categories)
439 fragments = Fragment.tagged.with_all(tags).select_related('book')
440 if fragments.exists():
449 class CatalogueHandler(BaseHandler):
452 def fields(request, name):
453 fields_str = request.GET.get(name) if request is not None else None
454 return fields_str.split(',') if fields_str is not None else None
458 """ Returns time suitable for use as upper time boundary for check.
460 Used to avoid issues with time between setting the change stamp
461 and actually saving the model in database.
462 Cuts the microsecond part to avoid issues with DBs where time has
465 :param datetime t: manually sets the upper boundary
468 # set to five minutes ago, to avoid concurrency issues
470 t = datetime.utcnow().replace(tzinfo=utc) - timedelta(seconds=settings.API_WAIT)
471 # set to whole second in case DB supports something smaller
472 return t.replace(microsecond=0)
475 def book_dict(book, fields=None):
476 all_fields = ['url', 'title', 'description',
477 'gazeta_link', 'wiki_link',
478 ] + Book.formats + BookMedia.formats.keys() + [
479 'parent', 'parent_number',
481 'license', 'license_description', 'source_name',
482 'technical_editors', 'editors',
483 'author', 'sort_key',
486 fields = (f for f in fields if f in all_fields)
490 extra_info = book.extra_info
495 if field in Book.formats:
496 f = getattr(book, field+'_file')
503 elif field in BookMedia.formats:
505 for m in book.media.filter(type=field).iterator():
514 obj[field] = book.get_absolute_url()
516 elif field == 'tags':
517 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
519 elif field == 'author':
520 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
522 elif field == 'parent':
523 obj[field] = book.parent_id
525 elif field in ('license', 'license_description', 'source_name',
526 'technical_editors', 'editors'):
527 f = extra_info.get(field)
532 f = getattr(book, field)
540 def book_changes(cls, request=None, since=0, until=None, fields=None):
541 since = datetime.fromtimestamp(int(since), tz)
542 until = cls.until(until)
545 'time_checked': timestamp(until)
549 fields = cls.fields(request, 'book_fields')
556 for book in Book.objects.filter(changed_at__gte=since,
557 changed_at__lt=until).iterator():
558 book_d = cls.book_dict(book, fields)
559 updated.append(book_d)
561 changes['updated'] = updated
563 for book in Deleted.objects.filter(content_type=Book,
564 deleted_at__gte=since,
565 deleted_at__lt=until,
566 created_at__lt=since).iterator():
567 deleted.append(book.id)
569 changes['deleted'] = deleted
574 def tag_dict(tag, fields=None):
575 all_fields = ('name', 'category', 'sort_key', 'description',
576 'gazeta_link', 'wiki_link',
581 fields = (f for f in fields if f in all_fields)
589 obj[field] = tag.get_absolute_url()
591 elif field == 'books':
592 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
594 elif field == 'sort_key':
595 obj[field] = tag.sort_key
598 f = getattr(tag, field)
606 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
607 since = datetime.fromtimestamp(int(since), tz)
608 until = cls.until(until)
611 'time_checked': timestamp(until)
615 fields = cls.fields(request, 'tag_fields')
617 categories = cls.fields(request, 'tag_categories')
619 all_categories = ('author', 'epoch', 'kind', 'genre')
621 categories = (c for c in categories if c in all_categories)
623 categories = all_categories
628 for tag in Tag.objects.filter(category__in=categories,
629 changed_at__gte=since,
630 changed_at__lt=until).iterator():
631 # only serve non-empty tags
633 tag_d = cls.tag_dict(tag, fields)
634 updated.append(tag_d)
635 elif tag.created_at < since:
636 deleted.append(tag.id)
638 changes['updated'] = updated
640 for tag in Deleted.objects.filter(category__in=categories,
642 deleted_at__gte=since,
643 deleted_at__lt=until,
644 created_at__lt=since).iterator():
645 deleted.append(tag.id)
647 changes['deleted'] = deleted
652 def changes(cls, request=None, since=0, until=None, book_fields=None,
653 tag_fields=None, tag_categories=None):
654 until = cls.until(until)
658 cache = get_cache('api')
659 key = hash((book_fields, tag_fields, tag_categories,
660 tuple(sorted(request.GET.items()))
662 value = cache.get(key)
663 if value is not None:
667 'time_checked': timestamp(until)
671 'books': cls.book_changes(request, since, until, book_fields),
672 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
675 for model in changes_by_type:
676 for field in changes_by_type[model]:
677 if field == 'time_checked':
679 changes.setdefault(field, {})[model] = changes_by_type[model][field]
682 cache.set(key, changes)
687 class BookChangesHandler(CatalogueHandler):
688 allowed_methods = ('GET',)
691 def read(self, request, since):
692 return self.book_changes(request, since)
695 class TagChangesHandler(CatalogueHandler):
696 allowed_methods = ('GET',)
699 def read(self, request, since):
700 return self.tag_changes(request, since)
703 class ChangesHandler(CatalogueHandler):
704 allowed_methods = ('GET',)
707 def read(self, request, since):
708 return self.changes(request, since)
711 class PictureHandler(BaseHandler):
713 fields = ('slug', 'title')
714 allowed_methods = ('POST',)
716 def create(self, request):
717 if not request.user.has_perm('picture.add_picture'):
720 data = json.loads(request.POST.get('data'))
721 form = PictureImportForm(data)