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 namedtuple
 
   7 from django.db import models
 
   8 from django.db.models import permalink
 
  10 from django.core.cache import get_cache
 
  11 from django.utils.translation import ugettext_lazy as _
 
  12 from django.contrib.auth.models import User
 
  13 from django.template.loader import render_to_string
 
  14 from django.utils.datastructures import SortedDict
 
  15 from django.utils.safestring import mark_safe
 
  16 from django.utils.translation import get_language
 
  17 from django.core.urlresolvers import reverse
 
  18 from django.db.models.signals import post_save, pre_delete, post_delete
 
  21 from django.conf import settings
 
  23 from newtagging.models import TagBase, tags_updated
 
  24 from newtagging import managers
 
  25 from catalogue.fields import OverwritingFileField
 
  26 from catalogue.utils import create_zip, split_tags, truncate_html_words
 
  27 from catalogue import tasks
 
  31 # Those are hard-coded here so that makemessages sees them.
 
  33     ('author', _('author')),
 
  34     ('epoch', _('epoch')),
 
  36     ('genre', _('genre')),
 
  37     ('theme', _('theme')),
 
  43 permanent_cache = get_cache('permanent')
 
  46 class TagSubcategoryManager(models.Manager):
 
  47     def __init__(self, subcategory):
 
  48         super(TagSubcategoryManager, self).__init__()
 
  49         self.subcategory = subcategory
 
  51     def get_query_set(self):
 
  52         return super(TagSubcategoryManager, self).get_query_set().filter(category=self.subcategory)
 
  56     """A tag attachable to books and fragments (and possibly anything).
 
  58     Used to represent searchable metadata (authors, epochs, genres, kinds),
 
  59     fragment themes (motifs) and some book hierarchy related kludges."""
 
  60     name = models.CharField(_('name'), max_length=50, db_index=True)
 
  61     slug = models.SlugField(_('slug'), max_length=120, db_index=True)
 
  62     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True)
 
  63     category = models.CharField(_('category'), max_length=50, blank=False, null=False,
 
  64         db_index=True, choices=TAG_CATEGORIES)
 
  65     description = models.TextField(_('description'), blank=True)
 
  67     user = models.ForeignKey(User, blank=True, null=True)
 
  68     book_count = models.IntegerField(_('book count'), blank=True, null=True)
 
  69     gazeta_link = models.CharField(blank=True, max_length=240)
 
  70     wiki_link = models.CharField(blank=True, max_length=240)
 
  72     created_at    = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
 
  73     changed_at    = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
 
  75     class UrlDeprecationWarning(DeprecationWarning):
 
  86     categories_dict = dict((item[::-1] for item in categories_rev.iteritems()))
 
  89         ordering = ('sort_key',)
 
  90         verbose_name = _('tag')
 
  91         verbose_name_plural = _('tags')
 
  92         unique_together = (("slug", "category"),)
 
  94     def __unicode__(self):
 
  98         return "Tag(slug=%r)" % self.slug
 
 101     def get_absolute_url(self):
 
 102         return ('catalogue.views.tagged_object_list', [self.url_chunk])
 
 104     def has_description(self):
 
 105         return len(self.description) > 0
 
 106     has_description.short_description = _('description')
 
 107     has_description.boolean = True
 
 110         """Returns global book count for book tags, fragment count for themes."""
 
 112         if self.category == 'book':
 
 114             objects = Book.objects.none()
 
 115         elif self.category == 'theme':
 
 116             objects = Fragment.tagged.with_all((self,))
 
 118             objects = Book.tagged.with_all((self,)).order_by()
 
 119             if self.category != 'set':
 
 120                 # eliminate descendants
 
 121                 l_tags = Tag.objects.filter(slug__in=[book.book_tag_slug() for book in objects.iterator()])
 
 122                 descendants_keys = [book.pk for book in Book.tagged.with_any(l_tags).iterator()]
 
 124                     objects = objects.exclude(pk__in=descendants_keys)
 
 125         return objects.count()
 
 128     def get_tag_list(tags):
 
 129         if isinstance(tags, basestring):
 
 134             tags_splitted = tags.split('/')
 
 135             for name in tags_splitted:
 
 137                     real_tags.append(Tag.objects.get(slug=name, category=category))
 
 139                 elif name in Tag.categories_rev:
 
 140                     category = Tag.categories_rev[name]
 
 143                         real_tags.append(Tag.objects.exclude(category='book').get(slug=name))
 
 145                     except Tag.MultipleObjectsReturned, e:
 
 146                         ambiguous_slugs.append(name)
 
 149                 # something strange left off
 
 150                 raise Tag.DoesNotExist()
 
 152                 # some tags should be qualified
 
 153                 e = Tag.MultipleObjectsReturned()
 
 155                 e.ambiguous_slugs = ambiguous_slugs
 
 158                 e = Tag.UrlDeprecationWarning()
 
 163             return TagBase.get_tag_list(tags)
 
 167         return '/'.join((Tag.categories_dict[self.category], self.slug))
 
 170     def tags_from_info(info):
 
 171         from slughifi import slughifi
 
 172         from sortify import sortify
 
 174         categories = (('kinds', 'kind'), ('genres', 'genre'), ('authors', 'author'), ('epochs', 'epoch'))
 
 175         for field_name, category in categories:
 
 177                 tag_names = getattr(info, field_name)
 
 180                     tag_names = [getattr(info, category)]
 
 182                     # For instance, Pictures do not have 'genre' field.
 
 184             for tag_name in tag_names:
 
 185                 tag_sort_key = tag_name
 
 186                 if category == 'author':
 
 187                     tag_sort_key = tag_name.last_name
 
 188                     tag_name = tag_name.readable()
 
 189                 tag, created = Tag.objects.get_or_create(slug=slughifi(tag_name), category=category)
 
 192                     tag.sort_key = sortify(tag_sort_key.lower())
 
 194                 meta_tags.append(tag)
 
 199 def get_dynamic_path(media, filename, ext=None, maxlen=100):
 
 200     from slughifi import slughifi
 
 202     # how to put related book's slug here?
 
 205         ext = media.formats[media.type].ext
 
 206     if media is None or not media.name:
 
 207         name = slughifi(filename.split(".")[0])
 
 209         name = slughifi(media.name)
 
 210     return 'book/%s/%s.%s' % (ext, name[:maxlen-len('book/%s/.%s' % (ext, ext))-4], ext)
 
 213 # TODO: why is this hard-coded ?
 
 214 def book_upload_path(ext=None, maxlen=100):
 
 215     return lambda *args: get_dynamic_path(*args, ext=ext, maxlen=maxlen)
 
 218 class BookMedia(models.Model):
 
 219     """Represents media attached to a book."""
 
 220     FileFormat = namedtuple("FileFormat", "name ext")
 
 221     formats = SortedDict([
 
 222         ('mp3', FileFormat(name='MP3', ext='mp3')),
 
 223         ('ogg', FileFormat(name='Ogg Vorbis', ext='ogg')),
 
 224         ('daisy', FileFormat(name='DAISY', ext='daisy.zip')),
 
 226     format_choices = [(k, _('%s file') % t.name)
 
 227             for k, t in formats.items()]
 
 229     type        = models.CharField(_('type'), choices=format_choices, max_length="100")
 
 230     name        = models.CharField(_('name'), max_length="100")
 
 231     file        = OverwritingFileField(_('file'), upload_to=book_upload_path())
 
 232     uploaded_at = models.DateTimeField(_('creation date'), auto_now_add=True, editable=False)
 
 233     extra_info  = jsonfield.JSONField(_('extra information'), default='{}', editable=False)
 
 234     book = models.ForeignKey('Book', related_name='media')
 
 235     source_sha1 = models.CharField(null=True, blank=True, max_length=40, editable=False)
 
 237     def __unicode__(self):
 
 238         return "%s (%s)" % (self.name, self.file.name.split("/")[-1])
 
 241         ordering            = ('type', 'name')
 
 242         verbose_name        = _('book media')
 
 243         verbose_name_plural = _('book media')
 
 245     def save(self, *args, **kwargs):
 
 246         from slughifi import slughifi
 
 247         from catalogue.utils import ExistingFile, remove_zip
 
 250             old = BookMedia.objects.get(pk=self.pk)
 
 251         except BookMedia.DoesNotExist:
 
 254             # if name changed, change the file name, too
 
 255             if slughifi(self.name) != slughifi(old.name):
 
 256                 self.file.save(None, ExistingFile(self.file.path), save=False, leave=True)
 
 258         super(BookMedia, self).save(*args, **kwargs)
 
 260         # remove the zip package for book with modified media
 
 262             remove_zip("%s_%s" % (old.book.slug, old.type))
 
 263         remove_zip("%s_%s" % (self.book.slug, self.type))
 
 265         extra_info = self.extra_info
 
 266         extra_info.update(self.read_meta())
 
 267         self.extra_info = extra_info
 
 268         self.source_sha1 = self.read_source_sha1(self.file.path, self.type)
 
 269         return super(BookMedia, self).save(*args, **kwargs)
 
 273             Reads some metadata from the audiobook.
 
 276         from mutagen import id3
 
 278         artist_name = director_name = project = funded_by = ''
 
 279         if self.type == 'mp3':
 
 281                 audio = id3.ID3(self.file.path)
 
 282                 artist_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE1'))
 
 283                 director_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE3'))
 
 284                 project = ", ".join([t.data for t in audio.getall('PRIV') 
 
 285                         if t.owner=='wolnelektury.pl?project'])
 
 286                 funded_by = ", ".join([t.data for t in audio.getall('PRIV') 
 
 287                         if t.owner=='wolnelektury.pl?funded_by'])
 
 290         elif self.type == 'ogg':
 
 292                 audio = mutagen.File(self.file.path)
 
 293                 artist_name = ', '.join(audio.get('artist', []))
 
 294                 director_name = ', '.join(audio.get('conductor', []))
 
 295                 project = ", ".join(audio.get('project', []))
 
 296                 funded_by = ", ".join(audio.get('funded_by', []))
 
 301         return {'artist_name': artist_name, 'director_name': director_name,
 
 302                 'project': project, 'funded_by': funded_by}
 
 305     def read_source_sha1(filepath, filetype):
 
 307             Reads source file SHA1 from audiobok metadata.
 
 310         from mutagen import id3
 
 312         if filetype == 'mp3':
 
 314                 audio = id3.ID3(filepath)
 
 315                 return [t.data for t in audio.getall('PRIV') 
 
 316                         if t.owner=='wolnelektury.pl?flac_sha1'][0]
 
 319         elif filetype == 'ogg':
 
 321                 audio = mutagen.File(filepath)
 
 322                 return audio.get('flac_sha1', [None])[0] 
 
 329 class Book(models.Model):
 
 330     """Represents a book imported from WL-XML."""
 
 331     title         = models.CharField(_('title'), max_length=120)
 
 332     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
 
 333     slug = models.SlugField(_('slug'), max_length=120, db_index=True,
 
 335     common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
 
 336     language = models.CharField(_('language code'), max_length=3, db_index=True,
 
 337                     default=settings.CATALOGUE_DEFAULT_LANGUAGE)
 
 338     description   = models.TextField(_('description'), blank=True)
 
 339     created_at    = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
 
 340     changed_at    = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
 
 341     parent_number = models.IntegerField(_('parent number'), default=0)
 
 342     extra_info    = jsonfield.JSONField(_('extra information'), default='{}')
 
 343     gazeta_link   = models.CharField(blank=True, max_length=240)
 
 344     wiki_link     = models.CharField(blank=True, max_length=240)
 
 345     # files generated during publication
 
 347     cover = models.FileField(_('cover'), upload_to=book_upload_path('png'),
 
 348                 null=True, blank=True)
 
 349     ebook_formats = ['pdf', 'epub', 'mobi', 'txt']
 
 350     formats = ebook_formats + ['html', 'xml']
 
 352     parent        = models.ForeignKey('self', blank=True, null=True, related_name='children')
 
 354     _related_info = jsonfield.JSONField(blank=True, null=True, editable=False)
 
 356     objects  = models.Manager()
 
 357     tagged   = managers.ModelTaggedItemManager(Tag)
 
 358     tags     = managers.TagDescriptor(Tag)
 
 360     html_built = django.dispatch.Signal()
 
 361     published = django.dispatch.Signal()
 
 363     class AlreadyExists(Exception):
 
 367         ordering = ('sort_key',)
 
 368         verbose_name = _('book')
 
 369         verbose_name_plural = _('books')
 
 371     def __unicode__(self):
 
 374     def save(self, force_insert=False, force_update=False, reset_short_html=True, **kwargs):
 
 375         from sortify import sortify
 
 377         self.sort_key = sortify(self.title)
 
 379         ret = super(Book, self).save(force_insert, force_update)
 
 382             self.reset_short_html()
 
 387     def get_absolute_url(self):
 
 388         return ('catalogue.views.book_detail', [self.slug])
 
 394     def book_tag_slug(self):
 
 395         return ('l-' + self.slug)[:120]
 
 398         slug = self.book_tag_slug()
 
 399         book_tag, created = Tag.objects.get_or_create(slug=slug, category='book')
 
 401             book_tag.name = self.title[:50]
 
 402             book_tag.sort_key = self.title.lower()
 
 406     def has_media(self, type_):
 
 407         if type_ in Book.formats:
 
 408             return bool(getattr(self, "%s_file" % type_))
 
 410             return self.media.filter(type=type_).exists()
 
 412     def get_media(self, type_):
 
 413         if self.has_media(type_):
 
 414             if type_ in Book.formats:
 
 415                 return getattr(self, "%s_file" % type_)
 
 417                 return self.media.filter(type=type_)
 
 422         return self.get_media("mp3")
 
 424         return self.get_media("odt")
 
 426         return self.get_media("ogg")
 
 428         return self.get_media("daisy")                       
 
 430     def reset_short_html(self):
 
 434         type(self).objects.filter(pk=self.pk).update(_related_info=None)
 
 435         # Fragment.short_html relies on book's tags, so reset it here too
 
 436         for fragm in self.fragments.all().iterator():
 
 437             fragm.reset_short_html()
 
 439     def has_description(self):
 
 440         return len(self.description) > 0
 
 441     has_description.short_description = _('description')
 
 442     has_description.boolean = True
 
 445     def has_mp3_file(self):
 
 446         return bool(self.has_media("mp3"))
 
 447     has_mp3_file.short_description = 'MP3'
 
 448     has_mp3_file.boolean = True
 
 450     def has_ogg_file(self):
 
 451         return bool(self.has_media("ogg"))
 
 452     has_ogg_file.short_description = 'OGG'
 
 453     has_ogg_file.boolean = True
 
 455     def has_daisy_file(self):
 
 456         return bool(self.has_media("daisy"))
 
 457     has_daisy_file.short_description = 'DAISY'
 
 458     has_daisy_file.boolean = True
 
 460     def wldocument(self, parse_dublincore=True):
 
 461         from catalogue.import_utils import ORMDocProvider
 
 462         from librarian.parser import WLDocument
 
 464         return WLDocument.from_file(self.xml_file.path,
 
 465                 provider=ORMDocProvider(self),
 
 466                 parse_dublincore=parse_dublincore)
 
 468     def build_cover(self, book_info=None):
 
 469         """(Re)builds the cover image."""
 
 470         from StringIO import StringIO
 
 471         from django.core.files.base import ContentFile
 
 472         from librarian.cover import WLCover
 
 474         if book_info is None:
 
 475             book_info = self.wldocument().book_info
 
 477         cover = WLCover(book_info).image()
 
 479         cover.save(imgstr, 'png')
 
 480         self.cover.save(None, ContentFile(imgstr.getvalue()))
 
 482     def build_html(self):
 
 483         from django.core.files.base import ContentFile
 
 484         from slughifi import slughifi
 
 485         from librarian import html
 
 487         meta_tags = list(self.tags.filter(
 
 488             category__in=('author', 'epoch', 'genre', 'kind')))
 
 489         book_tag = self.book_tag()
 
 491         html_output = self.wldocument(parse_dublincore=False).as_html()
 
 493             self.html_file.save('%s.html' % self.slug,
 
 494                     ContentFile(html_output.get_string()))
 
 496             # get ancestor l-tags for adding to new fragments
 
 500                 ancestor_tags.append(p.book_tag())
 
 503             # Delete old fragments and create them from scratch
 
 504             self.fragments.all().delete()
 
 506             closed_fragments, open_fragments = html.extract_fragments(self.html_file.path)
 
 507             for fragment in closed_fragments.values():
 
 509                     theme_names = [s.strip() for s in fragment.themes.split(',')]
 
 510                 except AttributeError:
 
 513                 for theme_name in theme_names:
 
 516                     tag, created = Tag.objects.get_or_create(slug=slughifi(theme_name), category='theme')
 
 518                         tag.name = theme_name
 
 519                         tag.sort_key = theme_name.lower()
 
 525                 text = fragment.to_string()
 
 526                 short_text = truncate_html_words(text, 15)
 
 527                 if text == short_text:
 
 529                 new_fragment = Fragment.objects.create(anchor=fragment.id, book=self,
 
 530                     text=text, short_text=short_text)
 
 533                 new_fragment.tags = set(meta_tags + themes + [book_tag] + ancestor_tags)
 
 535             self.html_built.send(sender=self)
 
 539     # Thin wrappers for builder tasks
 
 540     def build_pdf(self, *args, **kwargs):
 
 541         """(Re)builds PDF."""
 
 542         return tasks.build_pdf.delay(self.pk, *args, **kwargs)
 
 543     def build_epub(self, *args, **kwargs):
 
 544         """(Re)builds EPUB."""
 
 545         return tasks.build_epub.delay(self.pk, *args, **kwargs)
 
 546     def build_mobi(self, *args, **kwargs):
 
 547         """(Re)builds MOBI."""
 
 548         return tasks.build_mobi.delay(self.pk, *args, **kwargs)
 
 549     def build_txt(self, *args, **kwargs):
 
 550         """(Re)builds TXT."""
 
 551         return tasks.build_txt.delay(self.pk, *args, **kwargs)
 
 554     def zip_format(format_):
 
 555         def pretty_file_name(book):
 
 556             return "%s/%s.%s" % (
 
 557                 b.extra_info['author'],
 
 561         field_name = "%s_file" % format_
 
 562         books = Book.objects.filter(parent=None).exclude(**{field_name: ""})
 
 563         paths = [(pretty_file_name(b), getattr(b, field_name).path)
 
 564                     for b in books.iterator()]
 
 565         return create_zip(paths,
 
 566                     getattr(settings, "ALL_%s_ZIP" % format_.upper()))
 
 568     def zip_audiobooks(self, format_):
 
 569         bm = BookMedia.objects.filter(book=self, type=format_)
 
 570         paths = map(lambda bm: (None, bm.file.path), bm)
 
 571         return create_zip(paths, "%s_%s" % (self.slug, format_))
 
 573     def search_index(self, book_info=None, reuse_index=False, index_tags=True):
 
 576             idx = search.ReusableIndex()
 
 582             idx.index_book(self, book_info)
 
 589     def from_xml_file(cls, xml_file, **kwargs):
 
 590         from django.core.files import File
 
 591         from librarian import dcparser
 
 593         # use librarian to parse meta-data
 
 594         book_info = dcparser.parse(xml_file)
 
 596         if not isinstance(xml_file, File):
 
 597             xml_file = File(open(xml_file))
 
 600             return cls.from_text_and_meta(xml_file, book_info, **kwargs)
 
 605     def from_text_and_meta(cls, raw_file, book_info, overwrite=False,
 
 606             build_epub=True, build_txt=True, build_pdf=True, build_mobi=True,
 
 607             search_index=True, search_index_tags=True, search_index_reuse=False):
 
 609         # check for parts before we do anything
 
 611         if hasattr(book_info, 'parts'):
 
 612             for part_url in book_info.parts:
 
 614                     children.append(Book.objects.get(slug=part_url.slug))
 
 615                 except Book.DoesNotExist:
 
 616                     raise Book.DoesNotExist(_('Book "%s" does not exist.') %
 
 621         book_slug = book_info.url.slug
 
 622         if re.search(r'[^a-z0-9-]', book_slug):
 
 623             raise ValueError('Invalid characters in slug')
 
 624         book, created = Book.objects.get_or_create(slug=book_slug)
 
 630                 raise Book.AlreadyExists(_('Book %s already exists') % (
 
 632             # Save shelves for this book
 
 633             book_shelves = list(book.tags.filter(category='set'))
 
 635         book.language = book_info.language
 
 636         book.title = book_info.title
 
 637         if book_info.variant_of:
 
 638             book.common_slug = book_info.variant_of.slug
 
 640             book.common_slug = book.slug
 
 641         book.extra_info = book_info.to_dict()
 
 644         meta_tags = Tag.tags_from_info(book_info)
 
 646         book.tags = set(meta_tags + book_shelves)
 
 648         book_tag = book.book_tag()
 
 650         for n, child_book in enumerate(children):
 
 651             child_book.parent = book
 
 652             child_book.parent_number = n
 
 655         # Save XML and HTML files
 
 656         book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
 
 658         # delete old fragments when overwriting
 
 659         book.fragments.all().delete()
 
 661         if book.build_html():
 
 662             if not settings.NO_BUILD_TXT and build_txt:
 
 665         book.build_cover(book_info)
 
 667         if not settings.NO_BUILD_EPUB and build_epub:
 
 670         if not settings.NO_BUILD_PDF and build_pdf:
 
 673         if not settings.NO_BUILD_MOBI and build_mobi:
 
 676         if not settings.NO_SEARCH_INDEX and search_index:
 
 677             book.search_index(index_tags=search_index_tags, reuse_index=search_index_reuse)
 
 678             #index_book.delay(book.id, book_info)
 
 680         book_descendants = list(book.children.all())
 
 681         descendants_tags = set()
 
 682         # add l-tag to descendants and their fragments
 
 683         while len(book_descendants) > 0:
 
 684             child_book = book_descendants.pop(0)
 
 685             descendants_tags.update(child_book.tags)
 
 686             child_book.tags = list(child_book.tags) + [book_tag]
 
 688             for fragment in child_book.fragments.all().iterator():
 
 689                 fragment.tags = set(list(fragment.tags) + [book_tag])
 
 690             book_descendants += list(child_book.children.all())
 
 692         for tag in descendants_tags:
 
 698         book.reset_tag_counter()
 
 699         book.reset_theme_counter()
 
 701         cls.published.send(sender=book)
 
 704     def related_info(self):
 
 705         """Keeps info about related objects (tags, media) in cache field."""
 
 706         if self._related_info is not None:
 
 707             return self._related_info
 
 709             rel = {'tags': {}, 'media': {}}
 
 711             tags = self.tags.filter(category__in=(
 
 712                     'author', 'kind', 'genre', 'epoch'))
 
 713             tags = split_tags(tags)
 
 714             for category in tags:
 
 715                 rel['tags'][category] = [
 
 716                         (t.name, t.slug) for t in tags[category]]
 
 718             for media_format in BookMedia.formats:
 
 719                 rel['media'][media_format] = self.has_media(media_format)
 
 724                 parents.append((book.parent.title, book.parent.slug))
 
 726             parents = parents[::-1]
 
 728                 rel['parents'] = parents
 
 731                 type(self).objects.filter(pk=self.pk).update(_related_info=rel)
 
 734     def related_themes(self):
 
 735         theme_counter = self.theme_counter
 
 736         book_themes = list(Tag.objects.filter(pk__in=theme_counter.keys()))
 
 737         for tag in book_themes:
 
 738             tag.count = theme_counter[tag.pk]
 
 741     def reset_tag_counter(self):
 
 745         cache_key = "Book.tag_counter/%d" % self.id
 
 746         permanent_cache.delete(cache_key)
 
 748             self.parent.reset_tag_counter()
 
 751     def tag_counter(self):
 
 753             cache_key = "Book.tag_counter/%d" % self.id
 
 754             tags = permanent_cache.get(cache_key)
 
 760             for child in self.children.all().order_by().iterator():
 
 761                 for tag_pk, value in child.tag_counter.iteritems():
 
 762                     tags[tag_pk] = tags.get(tag_pk, 0) + value
 
 763             for tag in self.tags.exclude(category__in=('book', 'theme', 'set')).order_by().iterator():
 
 767                 permanent_cache.set(cache_key, tags)
 
 770     def reset_theme_counter(self):
 
 774         cache_key = "Book.theme_counter/%d" % self.id
 
 775         permanent_cache.delete(cache_key)
 
 777             self.parent.reset_theme_counter()
 
 780     def theme_counter(self):
 
 782             cache_key = "Book.theme_counter/%d" % self.id
 
 783             tags = permanent_cache.get(cache_key)
 
 789             for fragment in Fragment.tagged.with_any([self.book_tag()]).order_by().iterator():
 
 790                 for tag in fragment.tags.filter(category='theme').order_by().iterator():
 
 791                     tags[tag.pk] = tags.get(tag.pk, 0) + 1
 
 794                 permanent_cache.set(cache_key, tags)
 
 797     def pretty_title(self, html_links=False):
 
 799         names = list(book.tags.filter(category='author'))
 
 805         names.extend(reversed(books))
 
 808             names = ['<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name) for tag in names]
 
 810             names = [tag.name for tag in names]
 
 812         return ', '.join(names)
 
 815     def tagged_top_level(cls, tags):
 
 816         """ Returns top-level books tagged with `tags`.
 
 818         It only returns those books which don't have ancestors which are
 
 819         also tagged with those tags.
 
 822         # get relevant books and their tags
 
 823         objects = cls.tagged.with_all(tags)
 
 824         # eliminate descendants
 
 825         l_tags = Tag.objects.filter(category='book',
 
 826             slug__in=[book.book_tag_slug() for book in objects.iterator()])
 
 827         descendants_keys = [book.pk for book in cls.tagged.with_any(l_tags).iterator()]
 
 829             objects = objects.exclude(pk__in=descendants_keys)
 
 834     def book_list(cls, filter=None):
 
 835         """Generates a hierarchical listing of all books.
 
 837         Books are optionally filtered with a test function.
 
 842         books = cls.objects.all().order_by('parent_number', 'sort_key').only(
 
 843                 'title', 'parent', 'slug')
 
 845             books = books.filter(filter).distinct()
 
 847             book_ids = set(b['pk'] for b in books.values("pk").iterator())
 
 848             for book in books.iterator():
 
 849                 parent = book.parent_id
 
 850                 if parent not in book_ids:
 
 852                 books_by_parent.setdefault(parent, []).append(book)
 
 854             for book in books.iterator():
 
 855                 books_by_parent.setdefault(book.parent_id, []).append(book)
 
 858         books_by_author = SortedDict()
 
 859         for tag in Tag.objects.filter(category='author').iterator():
 
 860             books_by_author[tag] = []
 
 862         for book in books_by_parent.get(None,()):
 
 863             authors = list(book.tags.filter(category='author'))
 
 865                 for author in authors:
 
 866                     books_by_author[author].append(book)
 
 870         return books_by_author, orphans, books_by_parent
 
 873         "SP1": (1, u"szkoła podstawowa"),
 
 874         "SP2": (1, u"szkoła podstawowa"),
 
 875         "P": (1, u"szkoła podstawowa"),
 
 876         "G": (2, u"gimnazjum"),
 
 878         "LP": (3, u"liceum"),
 
 880     def audiences_pl(self):
 
 881         audiences = self.extra_info.get('audiences', [])
 
 882         audiences = sorted(set([self._audiences_pl[a] for a in audiences]))
 
 883         return [a[1] for a in audiences]
 
 885     def choose_fragment(self):
 
 886         tag = self.book_tag()
 
 887         fragments = Fragment.tagged.with_any([tag])
 
 888         if fragments.exists():
 
 889             return fragments.order_by('?')[0]
 
 891             return self.parent.choose_fragment()
 
 896 def _has_factory(ftype):
 
 897     has = lambda self: bool(getattr(self, "%s_file" % ftype))
 
 898     has.short_description = ftype.upper()
 
 901     has.__name__ = "has_%s_file" % ftype
 
 905 # add the file fields
 
 906 for t in Book.formats:
 
 907     field_name = "%s_file" % t
 
 908     models.FileField(_("%s file" % t.upper()),
 
 909             upload_to=book_upload_path(t),
 
 910             blank=True).contribute_to_class(Book, field_name)
 
 912     setattr(Book, "has_%s_file" % t, _has_factory(t))
 
 915 class Fragment(models.Model):
 
 916     """Represents a themed fragment of a book."""
 
 917     text = models.TextField()
 
 918     short_text = models.TextField(editable=False)
 
 919     anchor = models.CharField(max_length=120)
 
 920     book = models.ForeignKey(Book, related_name='fragments')
 
 922     objects = models.Manager()
 
 923     tagged = managers.ModelTaggedItemManager(Tag)
 
 924     tags = managers.TagDescriptor(Tag)
 
 927         ordering = ('book', 'anchor',)
 
 928         verbose_name = _('fragment')
 
 929         verbose_name_plural = _('fragments')
 
 931     def get_absolute_url(self):
 
 932         return '%s#m%s' % (reverse('book_text', args=[self.book.slug]), self.anchor)
 
 934     def reset_short_html(self):
 
 938         cache_key = "Fragment.short_html/%d/%s"
 
 939         for lang, langname in settings.LANGUAGES:
 
 940             permanent_cache.delete(cache_key % (self.id, lang))
 
 942     def get_short_text(self):
 
 943         """Returns short version of the fragment."""
 
 944         return self.short_text if self.short_text else self.text
 
 946     def short_html(self):
 
 948             cache_key = "Fragment.short_html/%d/%s" % (self.id, get_language())
 
 949             short_html = permanent_cache.get(cache_key)
 
 953         if short_html is not None:
 
 954             return mark_safe(short_html)
 
 956             short_html = unicode(render_to_string('catalogue/fragment_short.html',
 
 959                 permanent_cache.set(cache_key, short_html)
 
 960             return mark_safe(short_html)
 
 963 class Collection(models.Model):
 
 964     """A collection of books, which might be defined before publishing them."""
 
 965     title = models.CharField(_('title'), max_length=120, db_index=True)
 
 966     slug = models.SlugField(_('slug'), max_length=120, primary_key=True)
 
 967     description = models.TextField(_('description'), null=True, blank=True)
 
 969     models.SlugField(_('slug'), max_length=120, unique=True, db_index=True)
 
 970     book_slugs = models.TextField(_('book slugs'))
 
 973         ordering = ('title',)
 
 974         verbose_name = _('collection')
 
 975         verbose_name_plural = _('collections')
 
 977     def __unicode__(self):
 
 988 def _tags_updated_handler(sender, affected_tags, **kwargs):
 
 989     # reset tag global counter
 
 990     # we want Tag.changed_at updated for API to know the tag was touched
 
 991     for tag in affected_tags:
 
 994     # if book tags changed, reset book tag counter
 
 995     if isinstance(sender, Book) and \
 
 996                 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
 
 997                     exclude(category__in=('book', 'theme', 'set')).count():
 
 998         sender.reset_tag_counter()
 
 999     # if fragment theme changed, reset book theme counter
 
1000     elif isinstance(sender, Fragment) and \
 
1001                 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
 
1002                     filter(category='theme').count():
 
1003         sender.book.reset_theme_counter()
 
1004 tags_updated.connect(_tags_updated_handler)
 
1007 def _pre_delete_handler(sender, instance, **kwargs):
 
1008     """ refresh Book on BookMedia delete """
 
1009     if sender == BookMedia:
 
1010         instance.book.save()
 
1011 pre_delete.connect(_pre_delete_handler)
 
1014 def _post_save_handler(sender, instance, **kwargs):
 
1015     """ refresh all the short_html stuff on BookMedia update """
 
1016     if sender == BookMedia:
 
1017         instance.book.save()
 
1018 post_save.connect(_post_save_handler)
 
1021 if not settings.NO_SEARCH_INDEX:
 
1022     @django.dispatch.receiver(post_delete, sender=Book)
 
1023     def _remove_book_from_index_handler(sender, instance, **kwargs):
 
1024         """ remove the book from search index, when it is deleted."""
 
1026         search.JVM.attachCurrentThread()
 
1027         idx = search.Index()
 
1028         idx.open(timeout=10000)  # 10 seconds timeout.
 
1030             idx.remove_book(instance)