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
 
  15 from sorl.thumbnail import default
 
  17 from api.helpers import timestamp
 
  18 from api.models import Deleted
 
  19 from catalogue.forms import BookImportForm
 
  20 from catalogue.models import Book, Tag, BookMedia, Fragment, Collection
 
  21 from catalogue.utils import related_tag_name
 
  22 from picture.models import Picture
 
  23 from picture.forms import PictureImportForm
 
  25 from stats.utils import piwik_track
 
  27 API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain
 
  39 for k, v in category_singular.items():
 
  40     category_plural[v] = k
 
  42 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
 
  46 def read_tags(tags, allowed):
 
  47     """ Reads a path of filtering tags.
 
  49     :param str tags: a path of category and slug pairs, like: authors/an-author/...
 
  50     :returns: list of Tag objects
 
  51     :raises: ValueError when tags can't be found
 
  56     tags = tags.strip('/').split('/')
 
  59         category = tags.pop(0)
 
  63             category = category_singular[category]
 
  65             raise ValueError('Unknown category.')
 
  67         if not category in allowed:
 
  68             raise ValueError('Category not allowed.')
 
  71         if category == 'book':
 
  75             real_tags.append(Tag.objects.get(category=category, slug=slug))
 
  76         except Tag.DoesNotExist:
 
  77             raise ValueError('Tag not found')
 
  84 class BookMediaHandler(BaseHandler):
 
  85     """ Responsible for representing media in Books. """
 
  88     fields = ['name', 'type', 'url', 'artist', 'director']
 
  92         """ Link to media on site. """
 
  94         return MEDIA_BASE + media.file.url
 
  97     def artist(cls, media):
 
  98         return media.extra_info.get('artist_name', '')
 
 101     def director(cls, media):
 
 102         return media.extra_info.get('director_name', '')
 
 106 class BookDetails(object):
 
 107     """Custom fields used for representing Books."""
 
 111         """ Returns an URI for a Book in the API. """
 
 112         return API_BASE + reverse("api_book", args=[book.slug])
 
 116         """ Returns Book's URL on the site. """
 
 118         return WL_BASE + book.get_absolute_url()
 
 121     def children(cls, book):
 
 122         """ Returns all children for a book. """
 
 124         return book.children.all()
 
 127     def media(cls, book):
 
 128         """ Returns all media for a book. """
 
 129         return book.media.all()
 
 132     def cover(cls, book):
 
 133         return MEDIA_BASE + book.cover.url if book.cover else ''
 
 136     def cover_thumb(cls, book):
 
 137         return MEDIA_BASE + default.backend.get_thumbnail(
 
 138                     book.cover, "139x193").url if book.cover else ''
 
 142 class BookDetailHandler(BaseHandler, BookDetails):
 
 143     """ Main handler for Book objects.
 
 145     Responsible for single Book details.
 
 147     allowed_methods = ['GET']
 
 148     fields = ['title', 'parent', 'children'] + Book.formats + [
 
 149         'media', 'url', 'cover', 'cover_thumb'] + [
 
 150             category_plural[c] for c in book_tag_categories]
 
 153     def read(self, request, book):
 
 154         """ Returns details of a book, identified by a slug and lang. """
 
 156             return Book.objects.get(slug=book)
 
 157         except Book.DoesNotExist:
 
 161 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
 
 162     """ Main handler for Book objects.
 
 164     Responsible for lists of Book objects.
 
 166     allowed_methods = ('GET',)
 
 168     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
 
 171     def genres(cls, book):
 
 172         """ Returns all media for a book. """
 
 173         return book.tags.filter(category='genre')
 
 176     def read(self, request, tags, top_level=False,
 
 177                 audiobooks=False, daisy=False):
 
 178         """ Lists all books with given tags.
 
 180         :param tags: filtering tags; should be a path of categories
 
 181              and slugs, i.e.: authors/an-author/epoch/an-epoch/
 
 182         :param top_level: if True and a book is included in the results,
 
 183              it's children are aren't. By default all books matching the tags
 
 187             tags = read_tags(tags, allowed=book_tag_categories)
 
 193                 books = Book.tagged_top_level(tags)
 
 194                 return books if books else rc.NOT_FOUND
 
 196                 books = Book.tagged.with_all(tags)
 
 198             books = Book.objects.all()
 
 201             books = books.filter(parent=None)
 
 203             books = books.filter(media__type='mp3').distinct()
 
 205             books = books.filter(media__type='daisy').distinct()
 
 212     def create(self, request, *args, **kwargs):
 
 216 class BooksHandler(BookDetailHandler):
 
 217     allowed_methods = ('GET', 'POST')
 
 219     fields = book_tag_categories + ['href', 'title', 'url', 'cover', 'cover_thumb']
 
 220     anonymous = AnonymousBooksHandler
 
 222     def create(self, request, *args, **kwargs):
 
 223         if not request.user.has_perm('catalogue.add_book'):
 
 226         data = json.loads(request.POST.get('data'))
 
 227         form = BookImportForm(data)
 
 235 class EBooksHandler(AnonymousBooksHandler):
 
 236     fields = ('author', 'href', 'title', 'cover') + tuple(Book.ebook_formats)
 
 239 # add categorized tags fields for Book
 
 240 def _tags_getter(category):
 
 242     def get_tags(cls, book):
 
 243         return book.tags.filter(category=category)
 
 245 def _tag_getter(category):
 
 247     def get_tag(cls, book):
 
 248         return ", ".join(related_tag_name(t) for t in book.related_info()['tags'].get(category, []))
 
 250 for plural, singular in category_singular.items():
 
 251     setattr(BookDetails, plural, _tags_getter(singular))
 
 252     setattr(BookDetails, singular, _tag_getter(singular))
 
 254 # add fields for files in Book
 
 255 def _file_getter(format):
 
 256     field = "%s_file" % format
 
 258     def get_file(cls, book):
 
 259         f = getattr(book, field)
 
 261             return MEDIA_BASE + f.url
 
 265 for format in Book.formats:
 
 266     setattr(BookDetails, format, _file_getter(format))
 
 269 class CollectionDetails(object):
 
 270     """Custom Collection fields."""
 
 273     def href(cls, collection):
 
 274         """ Returns URI in the API for the collection. """
 
 276         return API_BASE + reverse("api_collection", args=[collection.slug])
 
 279     def url(cls, collection):
 
 280         """ Returns URL on the site. """
 
 282         return WL_BASE + collection.get_absolute_url()
 
 285     def books(cls, collection):
 
 286         return Book.objects.filter(collection.get_query())
 
 290 class CollectionDetailHandler(BaseHandler, CollectionDetails):
 
 291     allowed_methods = ('GET',)
 
 292     fields = ['url', 'title', 'description', 'books']
 
 295     def read(self, request, slug):
 
 297         """ Returns details of a collection, identified by slug. """
 
 299             return Collection.objects.get(slug=slug)
 
 300         except Collection.DoesNotExist:
 
 304 class CollectionsHandler(BaseHandler, CollectionDetails):
 
 305     allowed_methods = ('GET',)
 
 307     fields = ['url', 'href', 'title']
 
 310     def read(self, request):
 
 311         """ Returns all collections. """
 
 312         return Collection.objects.all()
 
 315 class TagDetails(object):
 
 316     """Custom Tag fields."""
 
 320         """ Returns URI in the API for the tag. """
 
 322         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
 
 326         """ Returns URL on the site. """
 
 328         return WL_BASE + tag.get_absolute_url()
 
 331 class TagDetailHandler(BaseHandler, TagDetails):
 
 332     """ Responsible for details of a single Tag object. """
 
 334     fields = ['name', 'url', 'sort_key', 'description']
 
 337     def read(self, request, category, slug):
 
 338         """ Returns details of a tag, identified by category and slug. """
 
 341             category_sng = category_singular[category]
 
 346             return Tag.objects.get(category=category_sng, slug=slug)
 
 347         except Tag.DoesNotExist:
 
 351 class TagsHandler(BaseHandler, TagDetails):
 
 352     """ Main handler for Tag objects.
 
 354     Responsible for lists of Tag objects
 
 355     and fields used for representing Tags.
 
 358     allowed_methods = ('GET',)
 
 360     fields = ['name', 'href', 'url']
 
 363     def read(self, request, category):
 
 364         """ Lists all tags in the category (eg. all themes). """
 
 367             category_sng = category_singular[category]
 
 371         tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
 
 378 class FragmentDetails(object):
 
 379     """Custom Fragment fields."""
 
 382     def href(cls, fragment):
 
 383         """ Returns URI in the API for the fragment. """
 
 385         return API_BASE + reverse("api_fragment", 
 
 386             args=[fragment.book.slug, fragment.anchor])
 
 389     def url(cls, fragment):
 
 390         """ Returns URL on the site for the fragment. """
 
 392         return WL_BASE + fragment.get_absolute_url()
 
 395     def themes(cls, fragment):
 
 396         """ Returns a list of theme tags for the fragment. """
 
 398         return fragment.tags.filter(category='theme')
 
 401 class FragmentDetailHandler(BaseHandler, FragmentDetails):
 
 402     fields = ['book', 'anchor', 'text', 'url', 'themes']
 
 405     def read(self, request, book, anchor):
 
 406         """ Returns details of a fragment, identified by book slug and anchor. """
 
 408             return Fragment.objects.get(book__slug=book, anchor=anchor)
 
 409         except Fragment.DoesNotExist:
 
 413 class FragmentsHandler(BaseHandler, FragmentDetails):
 
 414     """ Main handler for Fragments.
 
 416     Responsible for lists of Fragment objects
 
 417     and fields used for representing Fragments.
 
 421     fields = ['book', 'url', 'anchor', 'href']
 
 422     allowed_methods = ('GET',)
 
 424     categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
 
 427     def read(self, request, tags):
 
 428         """ Lists all fragments with given book, tags, themes.
 
 430         :param tags: should be a path of categories and slugs, i.e.:
 
 431              books/book-slug/authors/an-author/themes/a-theme/
 
 435             tags = read_tags(tags, allowed=self.categories)
 
 438         fragments = Fragment.tagged.with_all(tags).select_related('book')
 
 439         if fragments.exists():
 
 448 class CatalogueHandler(BaseHandler):
 
 451     def fields(request, name):
 
 452         fields_str = request.GET.get(name) if request is not None else None
 
 453         return fields_str.split(',') if fields_str is not None else None
 
 457         """ Returns time suitable for use as upper time boundary for check.
 
 459             Used to avoid issues with time between setting the change stamp
 
 460             and actually saving the model in database.
 
 461             Cuts the microsecond part to avoid issues with DBs where time has
 
 464             :param datetime t: manually sets the upper boundary
 
 467         # set to five minutes ago, to avoid concurrency issues
 
 469             t = datetime.now() - timedelta(seconds=settings.API_WAIT)
 
 470         # set to whole second in case DB supports something smaller
 
 471         return t.replace(microsecond=0)
 
 474     def book_dict(book, fields=None):
 
 475         all_fields = ['url', 'title', 'description',
 
 476                       'gazeta_link', 'wiki_link',
 
 477                       ] + Book.formats + BookMedia.formats.keys() + [
 
 478                       'parent', 'parent_number',
 
 480                       'license', 'license_description', 'source_name',
 
 481                       'technical_editors', 'editors',
 
 482                       'author', 'sort_key',
 
 485             fields = (f for f in fields if f in all_fields)
 
 489         extra_info = book.extra_info
 
 494             if field in Book.formats:
 
 495                 f = getattr(book, field+'_file')
 
 502             elif field in BookMedia.formats:
 
 504                 for m in book.media.filter(type=field).iterator():
 
 513                 obj[field] = book.get_absolute_url()
 
 515             elif field == 'tags':
 
 516                 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
 
 518             elif field == 'author':
 
 519                 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
 
 521             elif field == 'parent':
 
 522                 obj[field] = book.parent_id
 
 524             elif field in ('license', 'license_description', 'source_name',
 
 525                       'technical_editors', 'editors'):
 
 526                 f = extra_info.get(field)
 
 531                 f = getattr(book, field)
 
 539     def book_changes(cls, request=None, since=0, until=None, fields=None):
 
 540         since = datetime.fromtimestamp(int(since))
 
 541         until = cls.until(until)
 
 544             'time_checked': timestamp(until)
 
 548             fields = cls.fields(request, 'book_fields')
 
 555         for book in Book.objects.filter(changed_at__gte=since,
 
 556                     changed_at__lt=until).iterator():
 
 557             book_d = cls.book_dict(book, fields)
 
 558             updated.append(book_d)
 
 560             changes['updated'] = updated
 
 562         for book in Deleted.objects.filter(content_type=Book, 
 
 563                     deleted_at__gte=since,
 
 564                     deleted_at__lt=until,
 
 565                     created_at__lt=since).iterator():
 
 566             deleted.append(book.id)
 
 568             changes['deleted'] = deleted
 
 573     def tag_dict(tag, fields=None):
 
 574         all_fields = ('name', 'category', 'sort_key', 'description',
 
 575                       'gazeta_link', 'wiki_link',
 
 580             fields = (f for f in fields if f in all_fields)
 
 588                 obj[field] = tag.get_absolute_url()
 
 590             elif field == 'books':
 
 591                 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
 
 593             elif field == 'sort_key':
 
 594                 obj[field] = tag.sort_key
 
 597                 f = getattr(tag, field)
 
 605     def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
 
 606         since = datetime.fromtimestamp(int(since))
 
 607         until = cls.until(until)
 
 610             'time_checked': timestamp(until)
 
 614             fields = cls.fields(request, 'tag_fields')
 
 616             categories = cls.fields(request, 'tag_categories')
 
 618         all_categories = ('author', 'epoch', 'kind', 'genre')
 
 620             categories = (c for c in categories if c in all_categories)
 
 622             categories = all_categories
 
 627         for tag in Tag.objects.filter(category__in=categories, 
 
 628                     changed_at__gte=since,
 
 629                     changed_at__lt=until).iterator():
 
 630             # only serve non-empty tags
 
 632                 tag_d = cls.tag_dict(tag, fields)
 
 633                 updated.append(tag_d)
 
 634             elif tag.created_at < since:
 
 635                 deleted.append(tag.id)
 
 637             changes['updated'] = updated
 
 639         for tag in Deleted.objects.filter(category__in=categories,
 
 641                     deleted_at__gte=since,
 
 642                     deleted_at__lt=until,
 
 643                     created_at__lt=since).iterator():
 
 644             deleted.append(tag.id)
 
 646             changes['deleted'] = deleted
 
 651     def changes(cls, request=None, since=0, until=None, book_fields=None,
 
 652                 tag_fields=None, tag_categories=None):
 
 653         until = cls.until(until)
 
 657             cache = get_cache('api')
 
 658             key = hash((book_fields, tag_fields, tag_categories,
 
 659                     tuple(sorted(request.GET.items()))
 
 661             value = cache.get(key)
 
 662             if value is not None:
 
 666             'time_checked': timestamp(until)
 
 670             'books': cls.book_changes(request, since, until, book_fields),
 
 671             'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
 
 674         for model in changes_by_type:
 
 675             for field in changes_by_type[model]:
 
 676                 if field == 'time_checked':
 
 678                 changes.setdefault(field, {})[model] = changes_by_type[model][field]
 
 681             cache.set(key, changes)
 
 686 class BookChangesHandler(CatalogueHandler):
 
 687     allowed_methods = ('GET',)
 
 690     def read(self, request, since):
 
 691         return self.book_changes(request, since)
 
 694 class TagChangesHandler(CatalogueHandler):
 
 695     allowed_methods = ('GET',)
 
 698     def read(self, request, since):
 
 699         return self.tag_changes(request, since)
 
 702 class ChangesHandler(CatalogueHandler):
 
 703     allowed_methods = ('GET',)
 
 706     def read(self, request, since):
 
 707         return self.changes(request, since)
 
 710 class PictureHandler(BaseHandler):
 
 712     fields = ('slug', 'title')
 
 713     allowed_methods = ('POST',)
 
 715     def create(self, request):
 
 716         if not request.user.has_perm('picture.add_picture'):
 
 719         data = json.loads(request.POST.get('data'))
 
 720         form = PictureImportForm(data)