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 collections import OrderedDict
 
   6 from random import randint
 
   8 from django.conf import settings
 
   9 from django.db import connection, models, transaction
 
  10 from django.db.models import permalink
 
  11 import django.dispatch
 
  12 from django.contrib.contenttypes.fields import GenericRelation
 
  13 from django.core.urlresolvers import reverse
 
  14 from django.utils.translation import ugettext_lazy as _
 
  16 from fnpdjango.storage import BofhFileSystemStorage
 
  17 from ssify import flush_ssi_includes
 
  18 from newtagging import managers
 
  19 from catalogue import constants
 
  20 from catalogue.fields import EbookField
 
  21 from catalogue.models import Tag, Fragment, BookMedia
 
  22 from catalogue.utils import create_zip
 
  23 from catalogue import app_settings
 
  24 from catalogue import tasks
 
  26 bofh_storage = BofhFileSystemStorage()
 
  29 def _cover_upload_to(i, n):
 
  30     return 'book/cover/%s.jpg' % i.slug
 
  32 def _cover_thumb_upload_to(i, n):
 
  33     return 'book/cover_thumb/%s.jpg' % i.slug
 
  35 def _ebook_upload_to(upload_path):
 
  37         return upload_path % i.slug
 
  41 class Book(models.Model):
 
  42     """Represents a book imported from WL-XML."""
 
  43     title         = models.CharField(_('title'), max_length=120)
 
  44     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
 
  45     sort_key_author = models.CharField(_('sort key by author'), max_length=120, db_index=True, editable=False, default=u'')
 
  46     slug = models.SlugField(_('slug'), max_length=120, db_index=True,
 
  48     common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
 
  49     language = models.CharField(_('language code'), max_length=3, db_index=True,
 
  50                     default=app_settings.DEFAULT_LANGUAGE)
 
  51     description   = models.TextField(_('description'), blank=True)
 
  52     created_at    = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
 
  53     changed_at    = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
 
  54     parent_number = models.IntegerField(_('parent number'), default=0)
 
  55     extra_info    = jsonfield.JSONField(_('extra information'), default={})
 
  56     gazeta_link   = models.CharField(blank=True, max_length=240)
 
  57     wiki_link     = models.CharField(blank=True, max_length=240)
 
  58     # files generated during publication
 
  60     cover = EbookField('cover', _('cover'),
 
  61             null=True, blank=True,
 
  62             upload_to=_cover_upload_to,
 
  63             storage=bofh_storage, max_length=255)
 
  64     # Cleaner version of cover for thumbs
 
  65     cover_thumb = EbookField('cover_thumb', _('cover thumbnail'),
 
  66             null=True, blank=True,
 
  67             upload_to=_cover_thumb_upload_to,
 
  69     ebook_formats = constants.EBOOK_FORMATS
 
  70     formats = ebook_formats + ['html', 'xml']
 
  72     parent = models.ForeignKey('self', blank=True, null=True,
 
  73         related_name='children')
 
  74     ancestor = models.ManyToManyField('self', blank=True, null=True,
 
  75         editable=False, related_name='descendant', symmetrical=False)
 
  77     objects  = models.Manager()
 
  78     tagged   = managers.ModelTaggedItemManager(Tag)
 
  79     tags     = managers.TagDescriptor(Tag)
 
  80     tag_relations = GenericRelation(Tag.intermediary_table_model)
 
  82     html_built = django.dispatch.Signal()
 
  83     published = django.dispatch.Signal()
 
  85     short_html_url_name = 'catalogue_book_short'
 
  87     class AlreadyExists(Exception):
 
  91         ordering = ('sort_key',)
 
  92         verbose_name = _('book')
 
  93         verbose_name_plural = _('books')
 
  94         app_label = 'catalogue'
 
  96     def __unicode__(self):
 
  99     def save(self, force_insert=False, force_update=False, **kwargs):
 
 100         from sortify import sortify
 
 102         self.sort_key = sortify(self.title)
 
 103         self.title = unicode(self.title) # ???
 
 106             author = self.tags.filter(category='author')[0].sort_key
 
 109         self.sort_key_author = author
 
 111         ret = super(Book, self).save(force_insert, force_update, **kwargs)
 
 116     def get_absolute_url(self):
 
 117         return ('catalogue.views.book_detail', [self.slug])
 
 121     def create_url(slug):
 
 122         return ('catalogue.views.book_detail', [slug])
 
 128     def language_code(self):
 
 129         return constants.LANGUAGES_3TO2.get(self.language, self.language)
 
 131     def language_name(self):
 
 132         return dict(settings.LANGUAGES).get(self.language_code(), "")
 
 134     def has_media(self, type_):
 
 135         if type_ in Book.formats:
 
 136             return bool(getattr(self, "%s_file" % type_))
 
 138             return self.media.filter(type=type_).exists()
 
 140     def get_media(self, type_):
 
 141         if self.has_media(type_):
 
 142             if type_ in Book.formats:
 
 143                 return getattr(self, "%s_file" % type_)
 
 145                 return self.media.filter(type=type_)
 
 150         return self.get_media("mp3")
 
 152         return self.get_media("odt")
 
 154         return self.get_media("ogg")
 
 156         return self.get_media("daisy")
 
 158     def has_description(self):
 
 159         return len(self.description) > 0
 
 160     has_description.short_description = _('description')
 
 161     has_description.boolean = True
 
 164     def has_mp3_file(self):
 
 165         return bool(self.has_media("mp3"))
 
 166     has_mp3_file.short_description = 'MP3'
 
 167     has_mp3_file.boolean = True
 
 169     def has_ogg_file(self):
 
 170         return bool(self.has_media("ogg"))
 
 171     has_ogg_file.short_description = 'OGG'
 
 172     has_ogg_file.boolean = True
 
 174     def has_daisy_file(self):
 
 175         return bool(self.has_media("daisy"))
 
 176     has_daisy_file.short_description = 'DAISY'
 
 177     has_daisy_file.boolean = True
 
 179     def wldocument(self, parse_dublincore=True, inherit=True):
 
 180         from catalogue.import_utils import ORMDocProvider
 
 181         from librarian.parser import WLDocument
 
 183         if inherit and self.parent:
 
 184             meta_fallbacks = self.parent.cover_info()
 
 186             meta_fallbacks = None
 
 188         return WLDocument.from_file(self.xml_file.path,
 
 189                 provider=ORMDocProvider(self),
 
 190                 parse_dublincore=parse_dublincore,
 
 191                 meta_fallbacks=meta_fallbacks)
 
 194     def zip_format(format_):
 
 195         def pretty_file_name(book):
 
 196             return "%s/%s.%s" % (
 
 197                 book.extra_info['author'],
 
 201         field_name = "%s_file" % format_
 
 202         books = Book.objects.filter(parent=None).exclude(**{field_name: ""})
 
 203         paths = [(pretty_file_name(b), getattr(b, field_name).path)
 
 204                     for b in books.iterator()]
 
 205         return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
 
 207     def zip_audiobooks(self, format_):
 
 208         bm = BookMedia.objects.filter(book=self, type=format_)
 
 209         paths = map(lambda bm: (None, bm.file.path), bm)
 
 210         return create_zip(paths, "%s_%s" % (self.slug, format_))
 
 212     def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
 
 214             from search.index import Index
 
 217             index.index_book(self, book_info)
 
 223             index.index.rollback()
 
 228     def from_xml_file(cls, xml_file, **kwargs):
 
 229         from django.core.files import File
 
 230         from librarian import dcparser
 
 232         # use librarian to parse meta-data
 
 233         book_info = dcparser.parse(xml_file)
 
 235         if not isinstance(xml_file, File):
 
 236             xml_file = File(open(xml_file))
 
 239             return cls.from_text_and_meta(xml_file, book_info, **kwargs)
 
 244     def from_text_and_meta(cls, raw_file, book_info, overwrite=False,
 
 245             dont_build=None, search_index=True,
 
 246             search_index_tags=True):
 
 247         if dont_build is None:
 
 249         dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
 
 251         # check for parts before we do anything
 
 253         if hasattr(book_info, 'parts'):
 
 254             for part_url in book_info.parts:
 
 256                     children.append(Book.objects.get(slug=part_url.slug))
 
 257                 except Book.DoesNotExist:
 
 258                     raise Book.DoesNotExist(_('Book "%s" does not exist.') %
 
 262         book_slug = book_info.url.slug
 
 263         if re.search(r'[^a-z0-9-]', book_slug):
 
 264             raise ValueError('Invalid characters in slug')
 
 265         book, created = Book.objects.get_or_create(slug=book_slug)
 
 272                 raise Book.AlreadyExists(_('Book %s already exists') % (
 
 274             # Save shelves for this book
 
 275             book_shelves = list(book.tags.filter(category='set'))
 
 276             old_cover = book.cover_info()
 
 279         book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
 
 281         book.language = book_info.language
 
 282         book.title = book_info.title
 
 283         if book_info.variant_of:
 
 284             book.common_slug = book_info.variant_of.slug
 
 286             book.common_slug = book.slug
 
 287         book.extra_info = book_info.to_dict()
 
 290         meta_tags = Tag.tags_from_info(book_info)
 
 292         book.tags = set(meta_tags + book_shelves)
 
 294         cover_changed = old_cover != book.cover_info()
 
 295         obsolete_children = set(b for b in book.children.all()
 
 296                                 if b not in children)
 
 297         notify_cover_changed = []
 
 298         for n, child_book in enumerate(children):
 
 299             new_child = child_book.parent != book
 
 300             child_book.parent = book
 
 301             child_book.parent_number = n
 
 303             if new_child or cover_changed:
 
 304                 notify_cover_changed.append(child_book)
 
 305         # Disown unfaithful children and let them cope on their own.
 
 306         for child in obsolete_children:
 
 308             child.parent_number = 0
 
 311                 notify_cover_changed.append(child)
 
 313         cls.repopulate_ancestors()
 
 315         # No saves beyond this point.
 
 318         if 'cover' not in dont_build:
 
 319             book.cover.build_delay()
 
 320             book.cover_thumb.build_delay()
 
 322         # Build HTML and ebooks.
 
 323         book.html_file.build_delay()
 
 325             for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
 
 326                 if format_ not in dont_build:
 
 327                     getattr(book, '%s_file' % format_).build_delay()
 
 328         for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
 
 329             if format_ not in dont_build:
 
 330                 getattr(book, '%s_file' % format_).build_delay()
 
 332         if not settings.NO_SEARCH_INDEX and search_index:
 
 333             tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
 
 335         for child in notify_cover_changed:
 
 336             child.parent_cover_changed()
 
 338         cls.published.send(sender=cls, instance=book)
 
 342     def repopulate_ancestors(cls):
 
 343         """Fixes the ancestry cache."""
 
 345         with transaction.atomic():
 
 346             cursor = connection.cursor()
 
 347             if connection.vendor == 'postgres':
 
 348                 cursor.execute("TRUNCATE catalogue_book_ancestor")
 
 350                     WITH RECURSIVE ancestry AS (
 
 351                         SELECT book.id, book.parent_id
 
 352                         FROM catalogue_book AS book
 
 353                         WHERE book.parent_id IS NOT NULL
 
 355                         SELECT ancestor.id, book.parent_id
 
 356                         FROM ancestry AS ancestor, catalogue_book AS book
 
 357                         WHERE ancestor.parent_id = book.id
 
 358                             AND book.parent_id IS NOT NULL
 
 360                     INSERT INTO catalogue_book_ancestor
 
 361                         (from_book_id, to_book_id)
 
 367                 cursor.execute("DELETE FROM catalogue_book_ancestor")
 
 368                 for b in cls.objects.exclude(parent=None):
 
 370                     while parent is not None:
 
 371                         b.ancestor.add(parent)
 
 372                         parent = parent.parent
 
 374     def flush_includes(self, languages=True):
 
 377         if languages is True:
 
 378             languages = [lc for (lc, _ln) in settings.LANGUAGES]
 
 380             template % (self.pk, lang)
 
 382                 '/katalog/b/%d/mini.%s.html',
 
 383                 '/katalog/b/%d/mini_nolink.%s.html',
 
 384                 '/katalog/b/%d/short.%s.html',
 
 385                 '/katalog/b/%d/wide.%s.html',
 
 386                 '/api/include/book/%d.%s.json',
 
 387                 '/api/include/book/%d.%s.xml',
 
 389             for lang in languages
 
 392     def cover_info(self, inherit=True):
 
 393         """Returns a dictionary to serve as fallback for BookInfo.
 
 395         For now, the only thing inherited is the cover image.
 
 399         for field in ('cover_url', 'cover_by', 'cover_source'):
 
 400             val = self.extra_info.get(field)
 
 405         if inherit and need and self.parent is not None:
 
 406             parent_info = self.parent.cover_info()
 
 407             parent_info.update(info)
 
 411     def related_themes(self):
 
 412         return Tag.objects.usage_for_queryset(
 
 413             Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
 
 414             counts=True).filter(category='theme')
 
 416     def parent_cover_changed(self):
 
 417         """Called when parent book's cover image is changed."""
 
 418         if not self.cover_info(inherit=False):
 
 419             if 'cover' not in app_settings.DONT_BUILD:
 
 420                 self.cover.build_delay()
 
 421                 self.cover_thumb.build_delay()
 
 422             for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
 
 423                 if format_ not in app_settings.DONT_BUILD:
 
 424                     getattr(self, '%s_file' % format_).build_delay()
 
 425             for child in self.children.all():
 
 426                 child.parent_cover_changed()
 
 428     def other_versions(self):
 
 429         """Find other versions (i.e. in other languages) of the book."""
 
 430         return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
 
 435         while parent is not None:
 
 436             books.insert(0, parent)
 
 437             parent = parent.parent
 
 440     def pretty_title(self, html_links=False):
 
 441         names = [(tag.name, tag.get_absolute_url())
 
 442             for tag in self.tags.filter(category='author')]
 
 443         books = self.parents() + [self]
 
 444         names.extend([(b.title, b.get_absolute_url()) for b in books])
 
 447             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
 
 449             names = [tag[0] for tag in names]
 
 450         return ', '.join(names)
 
 453     def tagged_top_level(cls, tags):
 
 454         """ Returns top-level books tagged with `tags`.
 
 456         It only returns those books which don't have ancestors which are
 
 457         also tagged with those tags.
 
 460         objects = cls.tagged.with_all(tags)
 
 461         return objects.exclude(ancestor__in=objects)
 
 464     def book_list(cls, filter=None):
 
 465         """Generates a hierarchical listing of all books.
 
 467         Books are optionally filtered with a test function.
 
 472         books = cls.objects.all().order_by('parent_number', 'sort_key').only(
 
 473                 'title', 'parent', 'slug')
 
 475             books = books.filter(filter).distinct()
 
 477             book_ids = set(b['pk'] for b in books.values("pk").iterator())
 
 478             for book in books.iterator():
 
 479                 parent = book.parent_id
 
 480                 if parent not in book_ids:
 
 482                 books_by_parent.setdefault(parent, []).append(book)
 
 484             for book in books.iterator():
 
 485                 books_by_parent.setdefault(book.parent_id, []).append(book)
 
 488         books_by_author = OrderedDict()
 
 489         for tag in Tag.objects.filter(category='author').iterator():
 
 490             books_by_author[tag] = []
 
 492         for book in books_by_parent.get(None, ()):
 
 493             authors = list(book.tags.filter(category='author'))
 
 495                 for author in authors:
 
 496                     books_by_author[author].append(book)
 
 500         return books_by_author, orphans, books_by_parent
 
 503         "SP": (1, u"szkoła podstawowa"),
 
 504         "SP1": (1, u"szkoła podstawowa"),
 
 505         "SP2": (1, u"szkoła podstawowa"),
 
 506         "P": (1, u"szkoła podstawowa"),
 
 507         "G": (2, u"gimnazjum"),
 
 509         "LP": (3, u"liceum"),
 
 511     def audiences_pl(self):
 
 512         audiences = self.extra_info.get('audiences', [])
 
 513         audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
 
 514         return [a[1] for a in audiences]
 
 516     def stage_note(self):
 
 517         stage = self.extra_info.get('stage')
 
 518         if stage and stage < '0.4':
 
 519             return (_('This work needs modernisation'),
 
 520                     reverse('infopage', args=['wymagajace-uwspolczesnienia']))
 
 524     def choose_fragment(self):
 
 525         fragments = self.fragments.order_by()
 
 526         fragments_count = fragments.count()
 
 527         if not fragments_count and self.children.exists():
 
 528             fragments = Fragment.objects.filter(book__ancestor=self).order_by()
 
 529             fragments_count = fragments.count()
 
 531             return fragments[randint(0, fragments_count - 1)]
 
 533             return self.parent.choose_fragment()
 
 538 # add the file fields
 
 539 for format_ in Book.formats:
 
 540     field_name = "%s_file" % format_
 
 541     # This weird globals() assignment makes Django migrations comfortable.
 
 542     _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
 
 543     _upload_to.__name__ = '_%s_upload_to' % format_
 
 544     globals()[_upload_to.__name__] = _upload_to
 
 546     EbookField(format_, _("%s file" % format_.upper()),
 
 547         upload_to=_upload_to,
 
 548         storage=bofh_storage,
 
 552     ).contribute_to_class(Book, field_name)