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
16 from api.helpers import timestamp
17 from api.models import Deleted
18 from catalogue.forms import BookImportForm
19 from catalogue.models import Book, Tag, BookMedia, Fragment
20 from picture.models import Picture
21 from picture.forms import PictureImportForm
23 from stats.utils import piwik_track
25 API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain
37 for k, v in category_singular.items():
38 category_plural[v] = k
40 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
44 def read_tags(tags, allowed):
45 """ Reads a path of filtering tags.
47 :param str tags: a path of category and slug pairs, like: authors/an-author/...
48 :returns: list of Tag objects
49 :raises: ValueError when tags can't be found
54 tags = tags.strip('/').split('/')
57 category = tags.pop(0)
61 category = category_singular[category]
63 raise ValueError('Unknown category.')
65 if not category in allowed:
66 raise ValueError('Category not allowed.')
69 if category == 'book':
73 real_tags.append(Tag.objects.get(category=category, slug=slug))
74 except Tag.DoesNotExist:
75 raise ValueError('Tag not found')
82 class BookMediaHandler(BaseHandler):
83 """ Responsible for representing media in Books. """
86 fields = ['name', 'type', 'url', 'artist', 'director']
90 """ Link to media on site. """
92 return MEDIA_BASE + media.file.url
95 def artist(cls, media):
96 return media.extra_info.get('artist_name', '')
99 def director(cls, media):
100 return media.extra_info.get('director_name', '')
104 class BookDetails(object):
105 """Custom fields used for representing Books."""
108 def author(cls, book):
109 return ",".join(t[0] for t in book.related_info()['tags']['author'])
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 ''
139 class BookDetailHandler(BaseHandler, BookDetails):
140 """ Main handler for Book objects.
142 Responsible for single Book details.
144 allowed_methods = ['GET']
145 fields = ['title', 'parent', 'children'] + Book.formats + [
146 'media', 'url', 'cover'] + [
147 category_plural[c] for c in book_tag_categories]
150 def read(self, request, book):
151 """ Returns details of a book, identified by a slug and lang. """
153 return Book.objects.get(slug=book)
154 except Book.DoesNotExist:
158 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
159 """ Main handler for Book objects.
161 Responsible for lists of Book objects.
163 allowed_methods = ('GET',)
165 fields = ['author', 'href', 'title', 'url', 'cover']
168 def read(self, request, tags, top_level=False,
169 audiobooks=False, daisy=False):
170 """ Lists all books with given tags.
172 :param tags: filtering tags; should be a path of categories
173 and slugs, i.e.: authors/an-author/epoch/an-epoch/
174 :param top_level: if True and a book is included in the results,
175 it's children are aren't. By default all books matching the tags
179 tags = read_tags(tags, allowed=book_tag_categories)
185 books = Book.tagged_top_level(tags)
186 return books if books else rc.NOT_FOUND
188 books = Book.tagged.with_all(tags)
190 books = Book.objects.all()
193 books = books.filter(parent=None)
195 books = books.filter(media__type='mp3')
197 books = books.filter(media__type='daisy')
204 def create(self, request, *args, **kwargs):
208 class BooksHandler(BookDetailHandler):
209 allowed_methods = ('GET', 'POST')
211 fields = ['author', 'href', 'title', 'url']
212 anonymous = AnonymousBooksHandler
214 def create(self, request, *args, **kwargs):
215 if not request.user.has_perm('catalogue.add_book'):
218 data = json.loads(request.POST.get('data'))
219 form = BookImportForm(data)
227 # add categorized tags fields for Book
228 def _tags_getter(category):
230 def get_tags(cls, book):
231 return book.tags.filter(category=category)
233 for plural, singular in category_singular.items():
234 setattr(BookDetails, plural, _tags_getter(singular))
236 # add fields for files in Book
237 def _file_getter(format):
238 field = "%s_file" % format
240 def get_file(cls, book):
241 f = getattr(book, field)
243 return MEDIA_BASE + f.url
247 for format in Book.formats:
248 setattr(BookDetails, format, _file_getter(format))
251 class TagDetails(object):
252 """Custom Tag fields."""
256 """ Returns URI in the API for the tag. """
258 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
262 """ Returns URL on the site. """
264 return WL_BASE + tag.get_absolute_url()
267 class TagDetailHandler(BaseHandler, TagDetails):
268 """ Responsible for details of a single Tag object. """
270 fields = ['name', 'url', 'sort_key', 'description']
273 def read(self, request, category, slug):
274 """ Returns details of a tag, identified by category and slug. """
277 category_sng = category_singular[category]
282 return Tag.objects.get(category=category_sng, slug=slug)
283 except Tag.DoesNotExist:
287 class TagsHandler(BaseHandler, TagDetails):
288 """ Main handler for Tag objects.
290 Responsible for lists of Tag objects
291 and fields used for representing Tags.
294 allowed_methods = ('GET',)
296 fields = ['name', 'href', 'url']
299 def read(self, request, category):
300 """ Lists all tags in the category (eg. all themes). """
303 category_sng = category_singular[category]
307 tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
314 class FragmentDetails(object):
315 """Custom Fragment fields."""
318 def href(cls, fragment):
319 """ Returns URI in the API for the fragment. """
321 return API_BASE + reverse("api_fragment",
322 args=[fragment.book.slug, fragment.anchor])
325 def url(cls, fragment):
326 """ Returns URL on the site for the fragment. """
328 return WL_BASE + fragment.get_absolute_url()
331 def themes(cls, fragment):
332 """ Returns a list of theme tags for the fragment. """
334 return fragment.tags.filter(category='theme')
337 class FragmentDetailHandler(BaseHandler, FragmentDetails):
338 fields = ['book', 'anchor', 'text', 'url', 'themes']
341 def read(self, request, book, anchor):
342 """ Returns details of a fragment, identified by book slug and anchor. """
344 return Fragment.objects.get(book__slug=book, anchor=anchor)
345 except Fragment.DoesNotExist:
349 class FragmentsHandler(BaseHandler, FragmentDetails):
350 """ Main handler for Fragments.
352 Responsible for lists of Fragment objects
353 and fields used for representing Fragments.
357 fields = ['book', 'url', 'anchor', 'href']
358 allowed_methods = ('GET',)
360 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
363 def read(self, request, tags):
364 """ Lists all fragments with given book, tags, themes.
366 :param tags: should be a path of categories and slugs, i.e.:
367 books/book-slug/authors/an-author/themes/a-theme/
371 tags = read_tags(tags, allowed=self.categories)
374 fragments = Fragment.tagged.with_all(tags).select_related('book')
375 if fragments.exists():
384 class CatalogueHandler(BaseHandler):
387 def fields(request, name):
388 fields_str = request.GET.get(name) if request is not None else None
389 return fields_str.split(',') if fields_str is not None else None
393 """ Returns time suitable for use as upper time boundary for check.
395 Used to avoid issues with time between setting the change stamp
396 and actually saving the model in database.
397 Cuts the microsecond part to avoid issues with DBs where time has
400 :param datetime t: manually sets the upper boundary
403 # set to five minutes ago, to avoid concurrency issues
405 t = datetime.now() - timedelta(seconds=settings.API_WAIT)
406 # set to whole second in case DB supports something smaller
407 return t.replace(microsecond=0)
410 def book_dict(book, fields=None):
411 all_fields = ['url', 'title', 'description',
412 'gazeta_link', 'wiki_link',
413 ] + Book.formats + BookMedia.formats + [
414 'parent', 'parent_number',
416 'license', 'license_description', 'source_name',
417 'technical_editors', 'editors',
418 'author', 'sort_key',
421 fields = (f for f in fields if f in all_fields)
425 extra_info = book.extra_info
430 if field in Book.formats:
431 f = getattr(book, field+'_file')
438 elif field in BookMedia.formats:
440 for m in book.media.filter(type=field).iterator():
449 obj[field] = book.get_absolute_url()
451 elif field == 'tags':
452 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
454 elif field == 'author':
455 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
457 elif field == 'parent':
458 obj[field] = book.parent_id
460 elif field in ('license', 'license_description', 'source_name',
461 'technical_editors', 'editors'):
462 f = extra_info.get(field)
467 f = getattr(book, field)
475 def book_changes(cls, request=None, since=0, until=None, fields=None):
476 since = datetime.fromtimestamp(int(since))
477 until = cls.until(until)
480 'time_checked': timestamp(until)
484 fields = cls.fields(request, 'book_fields')
491 for book in Book.objects.filter(changed_at__gte=since,
492 changed_at__lt=until).iterator():
493 book_d = cls.book_dict(book, fields)
494 updated.append(book_d)
496 changes['updated'] = updated
498 for book in Deleted.objects.filter(content_type=Book,
499 deleted_at__gte=since,
500 deleted_at__lt=until,
501 created_at__lt=since).iterator():
502 deleted.append(book.id)
504 changes['deleted'] = deleted
509 def tag_dict(tag, fields=None):
510 all_fields = ('name', 'category', 'sort_key', 'description',
511 'gazeta_link', 'wiki_link',
516 fields = (f for f in fields if f in all_fields)
524 obj[field] = tag.get_absolute_url()
526 elif field == 'books':
527 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
529 elif field == 'sort_key':
530 obj[field] = tag.sort_key
533 f = getattr(tag, field)
541 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
542 since = datetime.fromtimestamp(int(since))
543 until = cls.until(until)
546 'time_checked': timestamp(until)
550 fields = cls.fields(request, 'tag_fields')
552 categories = cls.fields(request, 'tag_categories')
554 all_categories = ('author', 'epoch', 'kind', 'genre')
556 categories = (c for c in categories if c in all_categories)
558 categories = all_categories
563 for tag in Tag.objects.filter(category__in=categories,
564 changed_at__gte=since,
565 changed_at__lt=until).iterator():
566 # only serve non-empty tags
568 tag_d = cls.tag_dict(tag, fields)
569 updated.append(tag_d)
570 elif tag.created_at < since:
571 deleted.append(tag.id)
573 changes['updated'] = updated
575 for tag in Deleted.objects.filter(category__in=categories,
577 deleted_at__gte=since,
578 deleted_at__lt=until,
579 created_at__lt=since).iterator():
580 deleted.append(tag.id)
582 changes['deleted'] = deleted
587 def changes(cls, request=None, since=0, until=None, book_fields=None,
588 tag_fields=None, tag_categories=None):
589 until = cls.until(until)
593 cache = get_cache('api')
594 key = hash((book_fields, tag_fields, tag_categories,
595 tuple(sorted(request.GET.items()))
597 value = cache.get(key)
598 if value is not None:
602 'time_checked': timestamp(until)
606 'books': cls.book_changes(request, since, until, book_fields),
607 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
610 for model in changes_by_type:
611 for field in changes_by_type[model]:
612 if field == 'time_checked':
614 changes.setdefault(field, {})[model] = changes_by_type[model][field]
617 cache.set(key, changes)
622 class BookChangesHandler(CatalogueHandler):
623 allowed_methods = ('GET',)
626 def read(self, request, since):
627 return self.book_changes(request, since)
630 class TagChangesHandler(CatalogueHandler):
631 allowed_methods = ('GET',)
634 def read(self, request, since):
635 return self.tag_changes(request, since)
638 class ChangesHandler(CatalogueHandler):
639 allowed_methods = ('GET',)
642 def read(self, request, since):
643 return self.changes(request, since)
646 class PictureHandler(BaseHandler):
648 fields = ('slug', 'title')
649 allowed_methods = ('POST',)
651 def create(self, request):
652 if not request.user.has_perm('picture.add_picture'):
655 data = json.loads(request.POST.get('data'))
656 form = PictureImportForm(data)