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
 
  21 from picture.models import Picture
 
  22 from picture.forms import PictureImportForm
 
  24 from stats.utils import piwik_track
 
  26 API_BASE = WL_BASE = MEDIA_BASE = 'http://' + Site.objects.get_current().domain
 
  38 for k, v in category_singular.items():
 
  39     category_plural[v] = k
 
  41 book_tag_categories = ['author', 'epoch', 'kind', 'genre']
 
  45 def read_tags(tags, allowed):
 
  46     """ Reads a path of filtering tags.
 
  48     :param str tags: a path of category and slug pairs, like: authors/an-author/...
 
  49     :returns: list of Tag objects
 
  50     :raises: ValueError when tags can't be found
 
  55     tags = tags.strip('/').split('/')
 
  58         category = tags.pop(0)
 
  62             category = category_singular[category]
 
  64             raise ValueError('Unknown category.')
 
  66         if not category in allowed:
 
  67             raise ValueError('Category not allowed.')
 
  70         if category == 'book':
 
  74             real_tags.append(Tag.objects.get(category=category, slug=slug))
 
  75         except Tag.DoesNotExist:
 
  76             raise ValueError('Tag not found')
 
  83 class BookMediaHandler(BaseHandler):
 
  84     """ Responsible for representing media in Books. """
 
  87     fields = ['name', 'type', 'url', 'artist', 'director']
 
  91         """ Link to media on site. """
 
  93         return MEDIA_BASE + media.file.url
 
  96     def artist(cls, media):
 
  97         return media.extra_info.get('artist_name', '')
 
 100     def director(cls, media):
 
 101         return media.extra_info.get('director_name', '')
 
 105 class BookDetails(object):
 
 106     """Custom fields used for representing Books."""
 
 109     def author(cls, book):
 
 110         return ",".join(t[0] for t in book.related_info()['tags'].get('author', []))
 
 114         """ Returns an URI for a Book in the API. """
 
 115         return API_BASE + reverse("api_book", args=[book.slug])
 
 119         """ Returns Book's URL on the site. """
 
 121         return WL_BASE + book.get_absolute_url()
 
 124     def children(cls, book):
 
 125         """ Returns all children for a book. """
 
 127         return book.children.all()
 
 130     def media(cls, book):
 
 131         """ Returns all media for a book. """
 
 132         return book.media.all()
 
 135     def cover(cls, book):
 
 136         return MEDIA_BASE + book.cover.url if book.cover else ''
 
 139     def cover_thumb(cls, book):
 
 140         return MEDIA_BASE + default.backend.get_thumbnail(
 
 141                     book.cover, "139x193").url
 
 145 class BookDetailHandler(BaseHandler, BookDetails):
 
 146     """ Main handler for Book objects.
 
 148     Responsible for single Book details.
 
 150     allowed_methods = ['GET']
 
 151     fields = ['title', 'parent', 'children'] + Book.formats + [
 
 152         'media', 'url', 'cover', 'cover_thumb'] + [
 
 153             category_plural[c] for c in book_tag_categories]
 
 156     def read(self, request, book):
 
 157         """ Returns details of a book, identified by a slug and lang. """
 
 159             return Book.objects.get(slug=book)
 
 160         except Book.DoesNotExist:
 
 164 class AnonymousBooksHandler(AnonymousBaseHandler, BookDetails):
 
 165     """ Main handler for Book objects.
 
 167     Responsible for lists of Book objects.
 
 169     allowed_methods = ('GET',)
 
 171     fields = ['author', 'href', 'title', 'url', 'cover']
 
 174     def read(self, request, tags, top_level=False,
 
 175                 audiobooks=False, daisy=False):
 
 176         """ Lists all books with given tags.
 
 178         :param tags: filtering tags; should be a path of categories
 
 179              and slugs, i.e.: authors/an-author/epoch/an-epoch/
 
 180         :param top_level: if True and a book is included in the results,
 
 181              it's children are aren't. By default all books matching the tags
 
 185             tags = read_tags(tags, allowed=book_tag_categories)
 
 191                 books = Book.tagged_top_level(tags)
 
 192                 return books if books else rc.NOT_FOUND
 
 194                 books = Book.tagged.with_all(tags)
 
 196             books = Book.objects.all()
 
 199             books = books.filter(parent=None)
 
 201             books = books.filter(media__type='mp3').distinct()
 
 203             books = books.filter(media__type='daisy').distinct()
 
 210     def create(self, request, *args, **kwargs):
 
 214 class BooksHandler(BookDetailHandler):
 
 215     allowed_methods = ('GET', 'POST')
 
 217     fields = ['author', 'href', 'title', 'url']
 
 218     anonymous = AnonymousBooksHandler
 
 220     def create(self, request, *args, **kwargs):
 
 221         if not request.user.has_perm('catalogue.add_book'):
 
 224         data = json.loads(request.POST.get('data'))
 
 225         form = BookImportForm(data)
 
 233 # add categorized tags fields for Book
 
 234 def _tags_getter(category):
 
 236     def get_tags(cls, book):
 
 237         return book.tags.filter(category=category)
 
 239 for plural, singular in category_singular.items():
 
 240     setattr(BookDetails, plural, _tags_getter(singular))
 
 242 # add fields for files in Book
 
 243 def _file_getter(format):
 
 244     field = "%s_file" % format
 
 246     def get_file(cls, book):
 
 247         f = getattr(book, field)
 
 249             return MEDIA_BASE + f.url
 
 253 for format in Book.formats:
 
 254     setattr(BookDetails, format, _file_getter(format))
 
 257 class TagDetails(object):
 
 258     """Custom Tag fields."""
 
 262         """ Returns URI in the API for the tag. """
 
 264         return API_BASE + reverse("api_tag", args=[category_plural[tag.category], tag.slug])
 
 268         """ Returns URL on the site. """
 
 270         return WL_BASE + tag.get_absolute_url()
 
 273 class TagDetailHandler(BaseHandler, TagDetails):
 
 274     """ Responsible for details of a single Tag object. """
 
 276     fields = ['name', 'url', 'sort_key', 'description']
 
 279     def read(self, request, category, slug):
 
 280         """ Returns details of a tag, identified by category and slug. """
 
 283             category_sng = category_singular[category]
 
 288             return Tag.objects.get(category=category_sng, slug=slug)
 
 289         except Tag.DoesNotExist:
 
 293 class TagsHandler(BaseHandler, TagDetails):
 
 294     """ Main handler for Tag objects.
 
 296     Responsible for lists of Tag objects
 
 297     and fields used for representing Tags.
 
 300     allowed_methods = ('GET',)
 
 302     fields = ['name', 'href', 'url']
 
 305     def read(self, request, category):
 
 306         """ Lists all tags in the category (eg. all themes). """
 
 309             category_sng = category_singular[category]
 
 313         tags = Tag.objects.filter(category=category_sng).exclude(book_count=0)
 
 320 class FragmentDetails(object):
 
 321     """Custom Fragment fields."""
 
 324     def href(cls, fragment):
 
 325         """ Returns URI in the API for the fragment. """
 
 327         return API_BASE + reverse("api_fragment", 
 
 328             args=[fragment.book.slug, fragment.anchor])
 
 331     def url(cls, fragment):
 
 332         """ Returns URL on the site for the fragment. """
 
 334         return WL_BASE + fragment.get_absolute_url()
 
 337     def themes(cls, fragment):
 
 338         """ Returns a list of theme tags for the fragment. """
 
 340         return fragment.tags.filter(category='theme')
 
 343 class FragmentDetailHandler(BaseHandler, FragmentDetails):
 
 344     fields = ['book', 'anchor', 'text', 'url', 'themes']
 
 347     def read(self, request, book, anchor):
 
 348         """ Returns details of a fragment, identified by book slug and anchor. """
 
 350             return Fragment.objects.get(book__slug=book, anchor=anchor)
 
 351         except Fragment.DoesNotExist:
 
 355 class FragmentsHandler(BaseHandler, FragmentDetails):
 
 356     """ Main handler for Fragments.
 
 358     Responsible for lists of Fragment objects
 
 359     and fields used for representing Fragments.
 
 363     fields = ['book', 'url', 'anchor', 'href']
 
 364     allowed_methods = ('GET',)
 
 366     categories = set(['author', 'epoch', 'kind', 'genre', 'book', 'theme'])
 
 369     def read(self, request, tags):
 
 370         """ Lists all fragments with given book, tags, themes.
 
 372         :param tags: should be a path of categories and slugs, i.e.:
 
 373              books/book-slug/authors/an-author/themes/a-theme/
 
 377             tags = read_tags(tags, allowed=self.categories)
 
 380         fragments = Fragment.tagged.with_all(tags).select_related('book')
 
 381         if fragments.exists():
 
 390 class CatalogueHandler(BaseHandler):
 
 393     def fields(request, name):
 
 394         fields_str = request.GET.get(name) if request is not None else None
 
 395         return fields_str.split(',') if fields_str is not None else None
 
 399         """ Returns time suitable for use as upper time boundary for check.
 
 401             Used to avoid issues with time between setting the change stamp
 
 402             and actually saving the model in database.
 
 403             Cuts the microsecond part to avoid issues with DBs where time has
 
 406             :param datetime t: manually sets the upper boundary
 
 409         # set to five minutes ago, to avoid concurrency issues
 
 411             t = datetime.now() - timedelta(seconds=settings.API_WAIT)
 
 412         # set to whole second in case DB supports something smaller
 
 413         return t.replace(microsecond=0)
 
 416     def book_dict(book, fields=None):
 
 417         all_fields = ['url', 'title', 'description',
 
 418                       'gazeta_link', 'wiki_link',
 
 419                       ] + Book.formats + BookMedia.formats.keys() + [
 
 420                       'parent', 'parent_number',
 
 422                       'license', 'license_description', 'source_name',
 
 423                       'technical_editors', 'editors',
 
 424                       'author', 'sort_key',
 
 427             fields = (f for f in fields if f in all_fields)
 
 431         extra_info = book.extra_info
 
 436             if field in Book.formats:
 
 437                 f = getattr(book, field+'_file')
 
 444             elif field in BookMedia.formats:
 
 446                 for m in book.media.filter(type=field).iterator():
 
 455                 obj[field] = book.get_absolute_url()
 
 457             elif field == 'tags':
 
 458                 obj[field] = [t.id for t in book.tags.exclude(category__in=('book', 'set')).iterator()]
 
 460             elif field == 'author':
 
 461                 obj[field] = ", ".join(t.name for t in book.tags.filter(category='author').iterator())
 
 463             elif field == 'parent':
 
 464                 obj[field] = book.parent_id
 
 466             elif field in ('license', 'license_description', 'source_name',
 
 467                       'technical_editors', 'editors'):
 
 468                 f = extra_info.get(field)
 
 473                 f = getattr(book, field)
 
 481     def book_changes(cls, request=None, since=0, until=None, fields=None):
 
 482         since = datetime.fromtimestamp(int(since))
 
 483         until = cls.until(until)
 
 486             'time_checked': timestamp(until)
 
 490             fields = cls.fields(request, 'book_fields')
 
 497         for book in Book.objects.filter(changed_at__gte=since,
 
 498                     changed_at__lt=until).iterator():
 
 499             book_d = cls.book_dict(book, fields)
 
 500             updated.append(book_d)
 
 502             changes['updated'] = updated
 
 504         for book in Deleted.objects.filter(content_type=Book, 
 
 505                     deleted_at__gte=since,
 
 506                     deleted_at__lt=until,
 
 507                     created_at__lt=since).iterator():
 
 508             deleted.append(book.id)
 
 510             changes['deleted'] = deleted
 
 515     def tag_dict(tag, fields=None):
 
 516         all_fields = ('name', 'category', 'sort_key', 'description',
 
 517                       'gazeta_link', 'wiki_link',
 
 522             fields = (f for f in fields if f in all_fields)
 
 530                 obj[field] = tag.get_absolute_url()
 
 532             elif field == 'books':
 
 533                 obj[field] = [b.id for b in Book.tagged_top_level([tag]).iterator()]
 
 535             elif field == 'sort_key':
 
 536                 obj[field] = tag.sort_key
 
 539                 f = getattr(tag, field)
 
 547     def tag_changes(cls, request=None, since=0, until=None, fields=None, categories=None):
 
 548         since = datetime.fromtimestamp(int(since))
 
 549         until = cls.until(until)
 
 552             'time_checked': timestamp(until)
 
 556             fields = cls.fields(request, 'tag_fields')
 
 558             categories = cls.fields(request, 'tag_categories')
 
 560         all_categories = ('author', 'epoch', 'kind', 'genre')
 
 562             categories = (c for c in categories if c in all_categories)
 
 564             categories = all_categories
 
 569         for tag in Tag.objects.filter(category__in=categories, 
 
 570                     changed_at__gte=since,
 
 571                     changed_at__lt=until).iterator():
 
 572             # only serve non-empty tags
 
 574                 tag_d = cls.tag_dict(tag, fields)
 
 575                 updated.append(tag_d)
 
 576             elif tag.created_at < since:
 
 577                 deleted.append(tag.id)
 
 579             changes['updated'] = updated
 
 581         for tag in Deleted.objects.filter(category__in=categories,
 
 583                     deleted_at__gte=since,
 
 584                     deleted_at__lt=until,
 
 585                     created_at__lt=since).iterator():
 
 586             deleted.append(tag.id)
 
 588             changes['deleted'] = deleted
 
 593     def changes(cls, request=None, since=0, until=None, book_fields=None,
 
 594                 tag_fields=None, tag_categories=None):
 
 595         until = cls.until(until)
 
 599             cache = get_cache('api')
 
 600             key = hash((book_fields, tag_fields, tag_categories,
 
 601                     tuple(sorted(request.GET.items()))
 
 603             value = cache.get(key)
 
 604             if value is not None:
 
 608             'time_checked': timestamp(until)
 
 612             'books': cls.book_changes(request, since, until, book_fields),
 
 613             'tags': cls.tag_changes(request, since, until, tag_fields, tag_categories),
 
 616         for model in changes_by_type:
 
 617             for field in changes_by_type[model]:
 
 618                 if field == 'time_checked':
 
 620                 changes.setdefault(field, {})[model] = changes_by_type[model][field]
 
 623             cache.set(key, changes)
 
 628 class BookChangesHandler(CatalogueHandler):
 
 629     allowed_methods = ('GET',)
 
 632     def read(self, request, since):
 
 633         return self.book_changes(request, since)
 
 636 class TagChangesHandler(CatalogueHandler):
 
 637     allowed_methods = ('GET',)
 
 640     def read(self, request, since):
 
 641         return self.tag_changes(request, since)
 
 644 class ChangesHandler(CatalogueHandler):
 
 645     allowed_methods = ('GET',)
 
 648     def read(self, request, since):
 
 649         return self.changes(request, since)
 
 652 class PictureHandler(BaseHandler):
 
 654     fields = ('slug', 'title')
 
 655     allowed_methods = ('POST',)
 
 657     def create(self, request):
 
 658         if not request.user.has_perm('picture.add_picture'):
 
 661         data = json.loads(request.POST.get('data'))
 
 662         form = PictureImportForm(data)