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'].get('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.keys() + [
 
 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)