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 piston.handler import AnonymousBaseHandler, BaseHandler
13 from piston.utils import rc
15 from api.helpers import timestamp
16 from api.models import Deleted
17 from catalogue.forms import BookImportForm
18 from catalogue.models import Book, Tag, BookMedia, Fragment
19 from picture.models import Picture
20 from picture.forms import PictureImportForm
22 from stats.utils import piwik_track
24 API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain
36 for k, v in category_singular.items():
37 category_plural[v] = k
39 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
43 def read_tags(tags, allowed):
44 """ Reads a path of filtering tags.
46 :param str tags: a path of category and slug pairs, like: authors/an-author/...
47 :returns: list of Tag objects
48 :raises: ValueError when tags can't be found
53 tags = tags.strip('/').split('/')
56 category = tags.pop(0)
60 category = category_singular[category]
62 raise ValueError('Unknown category.')
64 if not category in allowed:
65 raise ValueError('Category not allowed.')
68 if category == 'book':
72 real_tags.append(Tag.objects.get(category=category, slug=slug))
73 except Tag.DoesNotExist:
74 raise ValueError('Tag not found')
81 class BookMediaHandler(BaseHandler):
82 """ Responsible for representing media in Books. """
85 fields = ['name', 'type', 'url']
89 """ Link to media on site. """
91 return MEDIA_BASE + media.file.url
94 class BookDetails(object):
95 """Custom fields used for representing Books."""
98 def author(cls, book):
99 return ", ".join(t.name for t in book.tags.filter(category='author'))
103 """ Returns an URI for a Book in the API. """
104 return API_BASE + reverse("api_book", args=[book.slug])
108 """ Returns Book's URL on the site. """
110 return WL_BASE + book.get_absolute_url()
113 def children(cls, book):
114 """ Returns all media for a book. """
116 return book.children.all()
119 def media(cls, book):
120 """ Returns all media for a book. """
122 return book.media.all()
126 class BookDetailHandler(BaseHandler, BookDetails):
127 """ Main handler for Book objects.
129 Responsible for single Book details.
131 allowed_methods = ['GET']
132 fields = ['title', 'parent', 'children'] + Book.formats + [
133 'media', 'url'] + book_tag_categories
136 def read(self, request, book):
137 """ Returns details of a book, identified by a slug and lang. """
139 return Book.objects.get(slug=book)
140 except Book.DoesNotExist:
144 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
145 """ Main handler for Book objects.
147 Responsible for lists of Book objects.
149 allowed_methods = ('GET',)
151 fields = ['author', 'href', 'title', 'url']
154 def read(self, request, tags, top_level=False):
155 """ Lists all books with given tags.
157 :param tags: filtering tags; should be a path of categories
158 and slugs, i.e.: authors/an-author/epoch/an-epoch/
159 :param top_level: if True and a book is included in the results,
160 it's children are aren't. By default all books matching the tags
164 tags = read_tags(tags, allowed=book_tag_categories)
170 books = Book.tagged_top_level(tags)
171 return books if books else rc.NOT_FOUND
173 books = Book.tagged.with_all(tags)
175 books = Book.objects.filter(parent=None)
177 books = Book.objects.all()
184 def create(self, request, tags, top_level=False):
188 class BooksHandler(BookDetailHandler):
190 fields = ['author', 'href', 'title', 'url']
191 anonymous = AnonymousBooksHandler
193 def create(self, request, tags, top_level=False):
194 if not request.user.has_perm('catalogue.add_book'):
197 data = json.loads(request.POST.get('data'))
198 form = BookImportForm(data)
206 # add categorized tags fields for Book
207 def _tags_getter(category):
209 def get_tags(cls, book):
210 return book.tags.filter(category=category)
212 for plural, singular in category_singular.items():
213 setattr(BookDetails, plural, _tags_getter(singular))
215 # add fields for files in Book
216 def _file_getter(format):
217 field = "%s_file" % format
219 def get_file(cls, book):
220 f = getattr(book, field)
222 return MEDIA_BASE + f.url
226 for format in Book.formats:
227 setattr(BookDetails, format, _file_getter(format))
230 class TagDetails(object):
231 """Custom Tag fields."""
235 """ Returns URI in the API for the tag. """
237 return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
241 """ Returns URL on the site. """
243 return WL_BASE + tag.get_absolute_url()
246 class TagDetailHandler(BaseHandler, TagDetails):
247 """ Responsible for details of a single Tag object. """
249 fields = ['name', 'url', 'sort_key', 'description']
252 def read(self, request, category, slug):
253 """ Returns details of a tag, identified by category and slug. """
256 category_sng = category_singular[category]
261 return Tag.objects.get(category=category_sng, slug=slug)
262 except Tag.DoesNotExist:
266 class TagsHandler(BaseHandler, TagDetails):
267 """ Main handler for Tag objects.
269 Responsible for lists of Tag objects
270 and fields used for representing Tags.
273 allowed_methods = ('GET',)
275 fields = ['name', 'href', 'url']
278 def read(self, request, category):
279 """ Lists all tags in the category (eg. all themes). """
282 category_sng = category_singular[category]
286 tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
293 class FragmentDetails(object):
294 """Custom Fragment fields."""
297 def href(cls, fragment):
298 """ Returns URI in the API for the fragment. """
300 return API_BASE + reverse("api_fragment",
301 args=[fragment.book.slug, fragment.anchor])
304 def url(cls, fragment):
305 """ Returns URL on the site for the fragment. """
307 return WL_BASE + fragment.get_absolute_url()
310 def themes(cls, fragment):
311 """ Returns a list of theme tags for the fragment. """
313 return fragment.tags.filter(category='theme')
316 class FragmentDetailHandler(BaseHandler, FragmentDetails):
317 fields = ['book', 'anchor', 'text', 'url', 'themes']
320 def read(self, request, book, anchor):
321 """ Returns details of a fragment, identified by book slug and anchor. """
323 return Fragment.objects.get(book__slug=book, anchor=anchor)
324 except Fragment.DoesNotExist:
328 class FragmentsHandler(BaseHandler, FragmentDetails):
329 """ Main handler for Fragments.
331 Responsible for lists of Fragment objects
332 and fields used for representing Fragments.
336 fields = ['book', 'url', 'anchor', 'href']
337 allowed_methods = ('GET',)
339 categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
342 def read(self, request, tags):
343 """ Lists all fragments with given book, tags, themes.
345 :param tags: should be a path of categories and slugs, i.e.:
346 books/book-slug/authors/an-author/themes/a-theme/
350 tags = read_tags(tags, allowed=self.categories)
353 fragments = Fragment.tagged.with_all(tags).select_related('book')
354 if fragments.exists():
363 class CatalogueHandler(BaseHandler):
366 def fields(request, name):
367 fields_str = request.GET.get(name) if request is not None else None
368 return fields_str.split(',') if fields_str is not None else None
372 """ Returns time suitable for use as upper time boundary for check.
374 Used to avoid issues with time between setting the change stamp
375 and actually saving the model in database.
376 Cuts the microsecond part to avoid issues with DBs where time has
379 :param datetime t: manually sets the upper boundary
382 # set to five minutes ago, to avoid concurrency issues
384 t = datetime.now() - timedelta(seconds=settings.API_WAIT)
385 # set to whole second in case DB supports something smaller
386 return t.replace(microsecond=0)
389 def book_dict(book, fields=None):
390 all_fields = ['url', 'title', 'description',
391 'gazeta_link', 'wiki_link',
392 ] + Book.formats + BookMedia.formats + [
393 'parent', 'parent_number',
395 'license', 'license_description', 'source_name',
396 'technical_editors', 'editors',
397 'author', 'sort_key',
400 fields = (f for f in fields if f in all_fields)
404 extra_info = book.get_extra_info_value()
409 if field in Book.formats:
410 f = getattr(book, field+'_file')
417 elif field in BookMedia.formats:
419 for m in book.media.filter(type=field):
428 obj[field] = book.get_absolute_url()
430 elif field == 'tags':
431 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set'))]
433 elif field == 'author':
434 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author'))
436 elif field == 'parent':
437 obj[field] = book.parent_id
439 elif field in ('license', 'license_description', 'source_name',
440 'technical_editors', 'editors'):
441 f = extra_info.get(field)
446 f = getattr(book, field)
454 def book_changes(cls, request=None, since=0, until=None, fields=None):
455 since = datetime.fromtimestamp(int(since))
456 until = cls.until(until)
459 'time_checked': timestamp(until)
463 fields = cls.fields(request, 'book_fields')
470 for book in Book.objects.filter(changed_at__gte=since,
471 changed_at__lt=until):
472 book_d = cls.book_dict(book, fields)
473 updated.append(book_d)
475 changes['updated'] = updated
477 for book in Deleted.objects.filter(content_type=Book,
478 deleted_at__gte=since,
479 deleted_at__lt=until,
480 created_at__lt=since):
481 deleted.append(book.id)
483 changes['deleted'] = deleted
488 def tag_dict(tag, fields=None):
489 all_fields = ('name', 'category', 'sort_key', 'description',
490 'gazeta_link', 'wiki_link',
495 fields = (f for f in fields if f in all_fields)
503 obj[field] = tag.get_absolute_url()
505 elif field == 'books':
506 obj[field] = [b.id for b in Book.tagged_top_level([tag])]
508 elif field == 'sort_key':
509 obj[field] = tag.sort_key
512 f = getattr(tag, field)
520 def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
521 since = datetime.fromtimestamp(int(since))
522 until = cls.until(until)
525 'time_checked': timestamp(until)
529 fields = cls.fields(request, 'tag_fields')
531 categories = cls.fields(request, 'tag_categories')
533 all_categories = ('author', 'epoch', 'kind', 'genre')
535 categories = (c for c in categories if c in all_categories)
537 categories = all_categories
542 for tag in Tag.objects.filter(category__in=categories,
543 changed_at__gte=since,
544 changed_at__lt=until):
545 # only serve non-empty tags
547 tag_d = cls.tag_dict(tag, fields)
548 updated.append(tag_d)
549 elif tag.created_at < since:
550 deleted.append(tag.id)
552 changes['updated'] = updated
554 for tag in Deleted.objects.filter(category__in=categories,
556 deleted_at__gte=since,
557 deleted_at__lt=until,
558 created_at__lt=since):
559 deleted.append(tag.id)
561 changes['deleted'] = deleted
566 def changes(cls, request=None, since=0, until=None, book_fields=None,
567 tag_fields=None, tag_categories=None):
568 until = cls.until(until)
572 cache = get_cache('api')
573 key = hash((book_fields, tag_fields, tag_categories,
574 tuple(sorted(request.GET.items()))
576 value = cache.get(key)
577 if value is not None:
581 'time_checked': timestamp(until)
585 'books': cls.book_changes(request, since, until, book_fields),
586 'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
589 for model in changes_by_type:
590 for field in changes_by_type[model]:
591 if field == 'time_checked':
593 changes.setdefault(field, {})[model] = changes_by_type[model][field]
596 cache.set(key, changes)
601 class BookChangesHandler(CatalogueHandler):
602 allowed_methods = ('GET',)
605 def read(self, request, since):
606 return self.book_changes(request, since)
609 class TagChangesHandler(CatalogueHandler):
610 allowed_methods = ('GET',)
613 def read(self, request, since):
614 return self.tag_changes(request, since)
617 class ChangesHandler(CatalogueHandler):
618 allowed_methods = ('GET',)
621 def read(self, request, since):
622 return self.changes(request, since)
625 class PictureHandler(BaseHandler):
627 fields = ('slug', 'title')
628 allowed_methods = ('POST',)
630 def create(self, request):
631 if not request.user.has_perm('picture.add_picture'):
634 data = json.loads(request.POST.get('data'))
635 form = PictureImportForm(data)