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.functional import lazy
14 from django.utils.timezone import utc
15 from piston.handler import AnonymousBaseHandler, BaseHandler
16 from piston.utils import rc
17 from sorl.thumbnail import default
19 from api.helpers import timestamp
20 from api.models import Deleted
21 from catalogue.forms import BookImportForm
22 from catalogue.models import Book, Tag, BookMedia, Fragment, Collection
23 from catalogue.utils import related_tag_name
24 from picture.models import Picture
25 from picture.forms import PictureImportForm
26 from wolnelektury.utils import tz
28 from stats.utils import piwik_track
30 API_BASE = WL_BASE = MEDIA_BASE = lazy(
31 lambda: u'http://' + Site.objects.get_current().domain, unicode)()
43 for k, v in category_singular.items():
44 category_plural[v] = k
46 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
50 def read_tags(tags, allowed):
51 """ Reads a path of filtering tags.
53 :param str tags: a path of category and slug pairs, like: authors/an-author/...
54 :returns: list of Tag objects
55 :raises: ValueError when tags can't be found
60 tags = tags.strip('/').split('/')
63 category = tags.pop(0)
67 category = category_singular[category]
69 raise ValueError('Unknown category.')
71 if not category in allowed:
72 raise ValueError('Category not allowed.')
75 if category == 'book':
79 real_tags.append(Tag.objects.get(category=category, slug=slug))
80 except Tag.DoesNotExist:
81 raise ValueError('Tag not found')
88 class BookMediaHandler(BaseHandler):
89 """ Responsible for representing media in Books. """
92 fields = ['name', 'type', 'url', 'artist', 'director']
96 """ Link to media on site. """
98 return MEDIA_BASE + media.file.url
101 def artist(cls, media):
102 return media.extra_info.get('artist_name', '')
105 def director(cls, media):
106 return media.extra_info.get('director_name', '')
110 class BookDetails(object):
111 """Custom fields used for representing Books."""
115 """ Returns an URI for a Book in the API. """
116 return API_BASE + reverse("api_book", args=[book.slug])
120 """ Returns Book's URL on the site. """
122 return WL_BASE + book.get_absolute_url()
125 def children(cls, book):
126 """ Returns all children for a book. """
128 return book.children.all()
131 def media(cls, book):
132 """ Returns all media for a book. """
133 return book.media.all()
136 def cover(cls, book):
137 return MEDIA_BASE + book.cover.url if book.cover else ''
140 def cover_thumb(cls, book):
141 return MEDIA_BASE + default.backend.get_thumbnail(
142 book.cover, "139x193").url if book.cover else ''
146 class BookDetailHandler(BaseHandler, BookDetails):
147 """ Main handler for Book objects.
149 Responsible for single Book details.
151 allowed_methods = ['GET']
152 fields = ['title', 'parent', 'children'] + Book.formats + [
153 'media', 'url', 'cover', 'cover_thumb'] + [
154 category_plural[c] for c in book_tag_categories]
157 def read(self, request, book):
158 """ Returns details of a book, identified by a slug and lang. """
160 return Book.objects.get(slug=book)
161 except Book.DoesNotExist:
165 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
166 """ Main handler for Book objects.
168 Responsible for lists of Book objects.
170 allowed_methods = ('GET',)
172 fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
175 def genres(cls, book):
176 """ Returns all media for a book. """
177 return book.tags.filter(category='genre')
180 def read(self, request, tags, top_level=False,
181 audiobooks=False, daisy=False):
182 """ Lists all books with given tags.
184 :param tags: filtering tags; should be a path of categories
185 and slugs, i.e.: authors/an-author/epoch/an-epoch/
186 :param top_level: if True and a book is included in the results,
187 it's children are aren't. By default all books matching the tags
191 tags = read_tags(tags, allowed=book_tag_categories)
197 books = Book.tagged_top_level(tags)
198 return books if books else rc.NOT_FOUND
200 books = Book.tagged.with_all(tags)
202 books = Book.objects.all()
205 books = books.filter(parent=None)
207 books = books.filter(media__type='mp3').distinct()
209 books = books.filter(media__type='daisy').distinct()
216 def create(self, request, *args, **kwargs):
220 class BooksHandler(BookDetailHandler):
221 allowed_methods = ('GET', 'POST')
223 fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
224 anonymous = AnonymousBooksHandler
226 def create(self, request, *args, **kwargs):
227 if not request.user.has_perm('catalogue.add_book'):
230 data = json.loads(request.POST.get('data'))
231 form = BookImportForm(data)
239 class EBooksHandler(AnonymousBooksHandler):
240 fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats)
243 # add categorized tags fields for Book
244 def _tags_getter(category):
246 def get_tags(cls, book):
247 return book.tags.filter(category=category)
249 def _tag_getter(category):
251 def get_tag(cls, book):
252 return ", ".join(related_tag_name(t) for t in book.related_info()['tags'].get(category, []))
254 for plural, singular in category_singular.items():
255 setattr(BookDetails, plural, _tags_getter(singular))
256 setattr(BookDetails, singular, _tag_getter(singular))
258 # add fields for files in Book
259 def _file_getter(format):
260 field = "%s_file" % format
262 def get_file(cls, book):
263 f = getattr(book, field)
265 return MEDIA_BASE + f.url
269 for format in Book.formats:
270 setattr(BookDetails, format, _file_getter(format))
273 class CollectionDetails(object):
274 """Custom Collection fields."""
277 def href(cls, collection):
278 """ Returns URI in the API for the collection. """
280 return API_BASE + reverse("api_collection", args=[collection.slug])
283 def url(cls, collection):
284 """ Returns URL on the site. """
286 return WL_BASE + collection.get_absolute_url()
289 def books(cls, collection):
290 return Book.objects.filter(collection.get_query())
294 class CollectionDetailHandler(BaseHandler, CollectionDetails):
295 allowed_methods = ('GET',)
296 fields = ['url', 'title', 'description', 'books']
299 def read(self, request, slug):
301 """ Returns details of a collection, identified by slug. """
303 return Collection.objects.get(slug=slug)
304 except Collection.DoesNotExist:
308 class CollectionsHandler(BaseHandler, CollectionDetails):
309 allowed_methods = ('GET',)
311 fields = ['url', 'href', 'title']
314 def read(self, request):
315 """ Returns all collections. """
316 return Collection.objects.all()
319 class TagDetails(object):
320 """Custom Tag fields."""
324 """ Returns URI in the API for the tag. """
326 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
330 """ Returns URL on the site. """
332 return WL_BASE + tag.get_absolute_url()
335 class TagDetailHandler(BaseHandler, TagDetails):
336 """ Responsible for details of a single Tag object. """
338 fields = ['name', 'url', 'sort_key', 'description']
341 def read(self, request, category, slug):
342 """ Returns details of a tag, identified by category and slug. """
345 category_sng = category_singular[category]
350 return Tag.objects.get(category=category_sng, slug=slug)
351 except Tag.DoesNotExist:
355 class TagsHandler(BaseHandler, TagDetails):
356 """ Main handler for Tag objects.
358 Responsible for lists of Tag objects
359 and fields used for representing Tags.
362 allowed_methods = ('GET',)
364 fields = ['name', 'href', 'url']
367 def read(self, request, category):
368 """ Lists all tags in the category (eg. all themes). """
371 category_sng = category_singular[category]
375 tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
382 class FragmentDetails(object):
383 """Custom Fragment fields."""
386 def href(cls, fragment):
387 """ Returns URI in the API for the fragment. """
389 return API_BASE + reverse("api_fragment",
390 args=[fragment.book.slug, fragment.anchor])
393 def url(cls, fragment):
394 """ Returns URL on the site for the fragment. """
396 return WL_BASE + fragment.get_absolute_url()
399 def themes(cls, fragment):
400 """ Returns a list of theme tags for the fragment. """
402 return fragment.tags.filter(category='theme')
405 class FragmentDetailHandler(BaseHandler, FragmentDetails):
406 fields = ['book', 'anchor', 'text', 'url', 'themes']
409 def read(self, request, book, anchor):
410 """ Returns details of a fragment, identified by book slug and anchor. """
412 return Fragment.objects.get(book__slug=book, anchor=anchor)
413 except Fragment.DoesNotExist:
417 class FragmentsHandler(BaseHandler, FragmentDetails):
418 """ Main handler for Fragments.
420 Responsible for lists of Fragment objects
421 and fields used for representing Fragments.
425 fields = ['book', 'url', 'anchor', 'href']
426 allowed_methods = ('GET',)
428 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
431 def read(self, request, tags):
432 """ Lists all fragments with given book, tags, themes.
434 :param tags: should be a path of categories and slugs, i.e.:
435 books/book-slug/authors/an-author/themes/a-theme/
439 tags = read_tags(tags, allowed=self.categories)
442 fragments = Fragment.tagged.with_all(tags).select_related('book')
443 if fragments.exists():
452 class CatalogueHandler(BaseHandler):
455 def fields(request, name):
456 fields_str = request.GET.get(name) if request is not None else None
457 return fields_str.split(',') if fields_str is not None else None
461 """ Returns time suitable for use as upper time boundary for check.
463 Used to avoid issues with time between setting the change stamp
464 and actually saving the model in database.
465 Cuts the microsecond part to avoid issues with DBs where time has
468 :param datetime t: manually sets the upper boundary
471 # set to five minutes ago, to avoid concurrency issues
473 t = datetime.utcnow().replace(tzinfo=utc) - timedelta(seconds=settings.API_WAIT)
474 # set to whole second in case DB supports something smaller
475 return t.replace(microsecond=0)
478 def book_dict(book, fields=None):
479 all_fields = ['url', 'title', 'description',
480 'gazeta_link', 'wiki_link',
481 ] + Book.formats + BookMedia.formats.keys() + [
482 'parent', 'parent_number',
484 'license', 'license_description', 'source_name',
485 'technical_editors', 'editors',
486 'author', 'sort_key',
489 fields = (f for f in fields if f in all_fields)
493 extra_info = book.extra_info
498 if field in Book.formats:
499 f = getattr(book, field+'_file')
506 elif field in BookMedia.formats:
508 for m in book.media.filter(type=field).iterator():
517 obj[field] = book.get_absolute_url()
519 elif field == 'tags':
520 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
522 elif field == 'author':
523 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
525 elif field == 'parent':
526 obj[field] = book.parent_id
528 elif field in ('license', 'license_description', 'source_name',
529 'technical_editors', 'editors'):
530 f = extra_info.get(field)
535 f = getattr(book, field)
543 def book_changes(cls, request=None, since=0, until=None, fields=None):
544 since = datetime.fromtimestamp(int(since), tz)
545 until = cls.until(until)
548 'time_checked': timestamp(until)
552 fields = cls.fields(request, 'book_fields')
559 for book in Book.objects.filter(changed_at__gte=since,
560 changed_at__lt=until).iterator():
561 book_d = cls.book_dict(book, fields)
562 updated.append(book_d)
564 changes['updated'] = updated
566 for book in Deleted.objects.filter(content_type=Book,
567 deleted_at__gte=since,
568 deleted_at__lt=until,
569 created_at__lt=since).iterator():
570 deleted.append(book.id)
572 changes['deleted'] = deleted
577 def tag_dict(tag, fields=None):
578 all_fields = ('name', 'category', 'sort_key', 'description',
579 'gazeta_link', 'wiki_link',
584 fields = (f for f in fields if f in all_fields)
592 obj[field] = tag.get_absolute_url()
594 elif field == 'books':
595 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
597 elif field == 'sort_key':
598 obj[field] = tag.sort_key
601 f = getattr(tag, field)
609 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
610 since = datetime.fromtimestamp(int(since), tz)
611 until = cls.until(until)
614 'time_checked': timestamp(until)
618 fields = cls.fields(request, 'tag_fields')
620 categories = cls.fields(request, 'tag_categories')
622 all_categories = ('author', 'epoch', 'kind', 'genre')
624 categories = (c for c in categories if c in all_categories)
626 categories = all_categories
631 for tag in Tag.objects.filter(category__in=categories,
632 changed_at__gte=since,
633 changed_at__lt=until).iterator():
634 # only serve non-empty tags
636 tag_d = cls.tag_dict(tag, fields)
637 updated.append(tag_d)
638 elif tag.created_at < since:
639 deleted.append(tag.id)
641 changes['updated'] = updated
643 for tag in Deleted.objects.filter(category__in=categories,
645 deleted_at__gte=since,
646 deleted_at__lt=until,
647 created_at__lt=since).iterator():
648 deleted.append(tag.id)
650 changes['deleted'] = deleted
655 def changes(cls, request=None, since=0, until=None, book_fields=None,
656 tag_fields=None, tag_categories=None):
657 until = cls.until(until)
661 cache = get_cache('api')
662 key = hash((book_fields, tag_fields, tag_categories,
663 tuple(sorted(request.GET.items()))
665 value = cache.get(key)
666 if value is not None:
670 'time_checked': timestamp(until)
674 'books': cls.book_changes(request, since, until, book_fields),
675 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
678 for model in changes_by_type:
679 for field in changes_by_type[model]:
680 if field == 'time_checked':
682 changes.setdefault(field, {})[model] = changes_by_type[model][field]
685 cache.set(key, changes)
690 class BookChangesHandler(CatalogueHandler):
691 allowed_methods = ('GET',)
694 def read(self, request, since):
695 return self.book_changes(request, since)
698 class TagChangesHandler(CatalogueHandler):
699 allowed_methods = ('GET',)
702 def read(self, request, since):
703 return self.tag_changes(request, since)
706 class ChangesHandler(CatalogueHandler):
707 allowed_methods = ('GET',)
710 def read(self, request, since):
711 return self.changes(request, since)
714 class PictureHandler(BaseHandler):
716 fields = ('slug', 'title')
717 allowed_methods = ('POST',)
719 def create(self, request):
720 if not request.user.has_perm('picture.add_picture'):
723 data = json.loads(request.POST.get('data'))
724 form = PictureImportForm(data)