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']
90 """ Link to media on site. """
92 return MEDIA_BASE + media.file.url
95 class BookDetails(object):
96 """Custom fields used for representing Books."""
99 def author(cls, book):
100 return ",".join(t[0] for t in book.related_info()['tags']['author'])
104 """ Returns an URI for a Book in the API. """
105 return API_BASE + reverse("api_book", args=[book.slug])
109 """ Returns Book's URL on the site. """
111 return WL_BASE + book.get_absolute_url()
114 def children(cls, book):
115 """ Returns all children for a book. """
117 return book.children.all()
120 def media(cls, book):
121 """ Returns all media for a book. """
122 return book.media.all()
125 def cover(cls, book):
126 return MEDIA_BASE + book.cover.url if book.cover else ''
130 class BookDetailHandler(BaseHandler, BookDetails):
131 """ Main handler for Book objects.
133 Responsible for single Book details.
135 allowed_methods = ['GET']
136 fields = ['title', 'parent', 'children'] + Book.formats + [
137 'media', 'url', 'cover'] + book_tag_categories
140 def read(self, request, book):
141 """ Returns details of a book, identified by a slug and lang. """
143 return Book.objects.get(slug=book)
144 except Book.DoesNotExist:
148 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
149 """ Main handler for Book objects.
151 Responsible for lists of Book objects.
153 allowed_methods = ('GET',)
155 fields = ['author', 'href', 'title', 'url', 'cover']
158 def read(self, request, tags, top_level=False):
159 """ Lists all books with given tags.
161 :param tags: filtering tags; should be a path of categories
162 and slugs, i.e.: authors/an-author/epoch/an-epoch/
163 :param top_level: if True and a book is included in the results,
164 it's children are aren't. By default all books matching the tags
168 tags = read_tags(tags, allowed=book_tag_categories)
174 books = Book.tagged_top_level(tags)
175 return books if books else rc.NOT_FOUND
177 books = Book.tagged.with_all(tags)
179 books = Book.objects.filter(parent=None)
181 books = Book.objects.all()
188 def create(self, request, tags, top_level=False):
192 class BooksHandler(BookDetailHandler):
193 allowed_methods = ('GET', 'POST')
195 fields = ['author', 'href', 'title', 'url']
196 anonymous = AnonymousBooksHandler
198 def create(self, request, tags, top_level=False):
199 if not request.user.has_perm('catalogue.add_book'):
202 data = json.loads(request.POST.get('data'))
203 form = BookImportForm(data)
211 # add categorized tags fields for Book
212 def _tags_getter(category):
214 def get_tags(cls, book):
215 return book.tags.filter(category=category)
217 for plural, singular in category_singular.items():
218 setattr(BookDetails, plural, _tags_getter(singular))
220 # add fields for files in Book
221 def _file_getter(format):
222 field = "%s_file" % format
224 def get_file(cls, book):
225 f = getattr(book, field)
227 return MEDIA_BASE + f.url
231 for format in Book.formats:
232 setattr(BookDetails, format, _file_getter(format))
235 class TagDetails(object):
236 """Custom Tag fields."""
240 """ Returns URI in the API for the tag. """
242 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
246 """ Returns URL on the site. """
248 return WL_BASE + tag.get_absolute_url()
251 class TagDetailHandler(BaseHandler, TagDetails):
252 """ Responsible for details of a single Tag object. """
254 fields = ['name', 'url', 'sort_key', 'description']
257 def read(self, request, category, slug):
258 """ Returns details of a tag, identified by category and slug. """
261 category_sng = category_singular[category]
266 return Tag.objects.get(category=category_sng, slug=slug)
267 except Tag.DoesNotExist:
271 class TagsHandler(BaseHandler, TagDetails):
272 """ Main handler for Tag objects.
274 Responsible for lists of Tag objects
275 and fields used for representing Tags.
278 allowed_methods = ('GET',)
280 fields = ['name', 'href', 'url']
283 def read(self, request, category):
284 """ Lists all tags in the category (eg. all themes). """
287 category_sng = category_singular[category]
291 tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
298 class FragmentDetails(object):
299 """Custom Fragment fields."""
302 def href(cls, fragment):
303 """ Returns URI in the API for the fragment. """
305 return API_BASE + reverse("api_fragment",
306 args=[fragment.book.slug, fragment.anchor])
309 def url(cls, fragment):
310 """ Returns URL on the site for the fragment. """
312 return WL_BASE + fragment.get_absolute_url()
315 def themes(cls, fragment):
316 """ Returns a list of theme tags for the fragment. """
318 return fragment.tags.filter(category='theme')
321 class FragmentDetailHandler(BaseHandler, FragmentDetails):
322 fields = ['book', 'anchor', 'text', 'url', 'themes']
325 def read(self, request, book, anchor):
326 """ Returns details of a fragment, identified by book slug and anchor. """
328 return Fragment.objects.get(book__slug=book, anchor=anchor)
329 except Fragment.DoesNotExist:
333 class FragmentsHandler(BaseHandler, FragmentDetails):
334 """ Main handler for Fragments.
336 Responsible for lists of Fragment objects
337 and fields used for representing Fragments.
341 fields = ['book', 'url', 'anchor', 'href']
342 allowed_methods = ('GET',)
344 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
347 def read(self, request, tags):
348 """ Lists all fragments with given book, tags, themes.
350 :param tags: should be a path of categories and slugs, i.e.:
351 books/book-slug/authors/an-author/themes/a-theme/
355 tags = read_tags(tags, allowed=self.categories)
358 fragments = Fragment.tagged.with_all(tags).select_related('book')
359 if fragments.exists():
368 class CatalogueHandler(BaseHandler):
371 def fields(request, name):
372 fields_str = request.GET.get(name) if request is not None else None
373 return fields_str.split(',') if fields_str is not None else None
377 """ Returns time suitable for use as upper time boundary for check.
379 Used to avoid issues with time between setting the change stamp
380 and actually saving the model in database.
381 Cuts the microsecond part to avoid issues with DBs where time has
384 :param datetime t: manually sets the upper boundary
387 # set to five minutes ago, to avoid concurrency issues
389 t = datetime.now() - timedelta(seconds=settings.API_WAIT)
390 # set to whole second in case DB supports something smaller
391 return t.replace(microsecond=0)
394 def book_dict(book, fields=None):
395 all_fields = ['url', 'title', 'description',
396 'gazeta_link', 'wiki_link',
397 ] + Book.formats + BookMedia.formats + [
398 'parent', 'parent_number',
400 'license', 'license_description', 'source_name',
401 'technical_editors', 'editors',
402 'author', 'sort_key',
405 fields = (f for f in fields if f in all_fields)
409 extra_info = book.get_extra_info_value()
414 if field in Book.formats:
415 f = getattr(book, field+'_file')
422 elif field in BookMedia.formats:
424 for m in book.media.filter(type=field):
433 obj[field] = book.get_absolute_url()
435 elif field == 'tags':
436 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set'))]
438 elif field == 'author':
439 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author'))
441 elif field == 'parent':
442 obj[field] = book.parent_id
444 elif field in ('license', 'license_description', 'source_name',
445 'technical_editors', 'editors'):
446 f = extra_info.get(field)
451 f = getattr(book, field)
459 def book_changes(cls, request=None, since=0, until=None, fields=None):
460 since = datetime.fromtimestamp(int(since))
461 until = cls.until(until)
464 'time_checked': timestamp(until)
468 fields = cls.fields(request, 'book_fields')
475 for book in Book.objects.filter(changed_at__gte=since,
476 changed_at__lt=until):
477 book_d = cls.book_dict(book, fields)
478 updated.append(book_d)
480 changes['updated'] = updated
482 for book in Deleted.objects.filter(content_type=Book,
483 deleted_at__gte=since,
484 deleted_at__lt=until,
485 created_at__lt=since):
486 deleted.append(book.id)
488 changes['deleted'] = deleted
493 def tag_dict(tag, fields=None):
494 all_fields = ('name', 'category', 'sort_key', 'description',
495 'gazeta_link', 'wiki_link',
500 fields = (f for f in fields if f in all_fields)
508 obj[field] = tag.get_absolute_url()
510 elif field == 'books':
511 obj[field] = [b.id for b in Book.tagged_top_level([tag])]
513 elif field == 'sort_key':
514 obj[field] = tag.sort_key
517 f = getattr(tag, field)
525 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
526 since = datetime.fromtimestamp(int(since))
527 until = cls.until(until)
530 'time_checked': timestamp(until)
534 fields = cls.fields(request, 'tag_fields')
536 categories = cls.fields(request, 'tag_categories')
538 all_categories = ('author', 'epoch', 'kind', 'genre')
540 categories = (c for c in categories if c in all_categories)
542 categories = all_categories
547 for tag in Tag.objects.filter(category__in=categories,
548 changed_at__gte=since,
549 changed_at__lt=until):
550 # only serve non-empty tags
552 tag_d = cls.tag_dict(tag, fields)
553 updated.append(tag_d)
554 elif tag.created_at < since:
555 deleted.append(tag.id)
557 changes['updated'] = updated
559 for tag in Deleted.objects.filter(category__in=categories,
561 deleted_at__gte=since,
562 deleted_at__lt=until,
563 created_at__lt=since):
564 deleted.append(tag.id)
566 changes['deleted'] = deleted
571 def changes(cls, request=None, since=0, until=None, book_fields=None,
572 tag_fields=None, tag_categories=None):
573 until = cls.until(until)
577 cache = get_cache('api')
578 key = hash((book_fields, tag_fields, tag_categories,
579 tuple(sorted(request.GET.items()))
581 value = cache.get(key)
582 if value is not None:
586 'time_checked': timestamp(until)
590 'books': cls.book_changes(request, since, until, book_fields),
591 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
594 for model in changes_by_type:
595 for field in changes_by_type[model]:
596 if field == 'time_checked':
598 changes.setdefault(field, {})[model] = changes_by_type[model][field]
601 cache.set(key, changes)
606 class BookChangesHandler(CatalogueHandler):
607 allowed_methods = ('GET',)
610 def read(self, request, since):
611 return self.book_changes(request, since)
614 class TagChangesHandler(CatalogueHandler):
615 allowed_methods = ('GET',)
618 def read(self, request, since):
619 return self.tag_changes(request, since)
622 class ChangesHandler(CatalogueHandler):
623 allowed_methods = ('GET',)
626 def read(self, request, since):
627 return self.changes(request, since)
630 class PictureHandler(BaseHandler):
632 fields = ('slug', 'title')
633 allowed_methods = ('POST',)
635 def create(self, request):
636 if not request.user.has_perm('picture.add_picture'):
639 data = json.loads(request.POST.get('data'))
640 form = PictureImportForm(data)