1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
 
   2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
 
   4 from collections import OrderedDict
 
   6 from datetime import date, timedelta
 
   7 from random import randint
 
  10 from urllib.request import urlretrieve
 
  11 from django.conf import settings
 
  12 from django.db import connection, models, transaction
 
  13 import django.dispatch
 
  14 from django.contrib.contenttypes.fields import GenericRelation
 
  15 from django.template.loader import render_to_string
 
  16 from django.urls import reverse
 
  17 from django.utils.translation import ugettext_lazy as _, get_language
 
  18 from django.utils.deconstruct import deconstructible
 
  19 from fnpdjango.storage import BofhFileSystemStorage
 
  21 from librarian.cover import WLCover
 
  22 from librarian.html import transform_abstrakt
 
  23 from newtagging import managers
 
  24 from catalogue import constants
 
  25 from catalogue.fields import EbookField
 
  26 from catalogue.models import Tag, Fragment, BookMedia
 
  27 from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags, get_random_hash
 
  28 from catalogue.models.tag import prefetched_relations
 
  29 from catalogue import app_settings
 
  30 from catalogue import tasks
 
  31 from wolnelektury.utils import makedirs, cached_render, clear_cached_renders
 
  33 bofh_storage = BofhFileSystemStorage()
 
  37 class UploadToPath(object):
 
  38     def __init__(self, path):
 
  41     def __call__(self, instance, filename):
 
  42         return self.path % instance.slug
 
  45 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
 
  46 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
 
  47 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
 
  48 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
 
  49 _cover_ebookpoint_upload_to = UploadToPath('book/cover_ebookpoint/%s.jpg')
 
  52 def _ebook_upload_to(upload_path):
 
  53     return UploadToPath(upload_path)
 
  56 class Book(models.Model):
 
  57     """Represents a book imported from WL-XML."""
 
  58     title = models.CharField(_('title'), max_length=32767)
 
  59     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
 
  60     sort_key_author = models.CharField(
 
  61         _('sort key by author'), max_length=120, db_index=True, editable=False, default='')
 
  62     slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
 
  63     common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
 
  64     language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
 
  65     description = models.TextField(_('description'), blank=True)
 
  66     abstract = models.TextField(_('abstract'), blank=True)
 
  67     created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
 
  68     changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
 
  69     parent_number = models.IntegerField(_('parent number'), default=0)
 
  70     extra_info = models.TextField(_('extra information'), default='{}')
 
  71     gazeta_link = models.CharField(blank=True, max_length=240)
 
  72     wiki_link = models.CharField(blank=True, max_length=240)
 
  73     print_on_demand = models.BooleanField(_('print on demand'), default=False)
 
  74     recommended = models.BooleanField(_('recommended'), default=False)
 
  75     audio_length = models.CharField(_('audio length'), blank=True, max_length=8)
 
  76     preview = models.BooleanField(_('preview'), default=False)
 
  77     preview_until = models.DateField(_('preview until'), blank=True, null=True)
 
  78     preview_key = models.CharField(max_length=32, blank=True, null=True)
 
  79     findable = models.BooleanField(_('findable'), default=True, db_index=True)
 
  81     # files generated during publication
 
  84         null=True, blank=True,
 
  85         upload_to=_cover_upload_to,
 
  86         storage=bofh_storage, max_length=255)
 
  87     cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
  88     # Cleaner version of cover for thumbs
 
  89     cover_thumb = EbookField(
 
  90         'cover_thumb', _('cover thumbnail'),
 
  91         null=True, blank=True,
 
  92         upload_to=_cover_thumb_upload_to,
 
  94     cover_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
  95     cover_api_thumb = EbookField(
 
  96         'cover_api_thumb', _('cover thumbnail for mobile app'),
 
  97         null=True, blank=True,
 
  98         upload_to=_cover_api_thumb_upload_to,
 
 100     cover_api_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
 101     simple_cover = EbookField(
 
 102         'simple_cover', _('cover for mobile app'),
 
 103         null=True, blank=True,
 
 104         upload_to=_simple_cover_upload_to,
 
 106     simple_cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
 107     cover_ebookpoint = EbookField(
 
 108         'cover_ebookpoint', _('cover for Ebookpoint'),
 
 109         null=True, blank=True,
 
 110         upload_to=_cover_ebookpoint_upload_to,
 
 112     cover_ebookpoint_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
 113     ebook_formats = constants.EBOOK_FORMATS
 
 114     formats = ebook_formats + ['html', 'xml']
 
 116     parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
 
 117     ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
 
 119     cached_author = models.CharField(blank=True, max_length=240, db_index=True)
 
 120     has_audience = models.BooleanField(default=False)
 
 122     objects = models.Manager()
 
 123     tagged = managers.ModelTaggedItemManager(Tag)
 
 124     tags = managers.TagDescriptor(Tag)
 
 125     tag_relations = GenericRelation(Tag.intermediary_table_model)
 
 127     html_built = django.dispatch.Signal()
 
 128     published = django.dispatch.Signal()
 
 132     class AlreadyExists(Exception):
 
 136         ordering = ('sort_key_author', 'sort_key')
 
 137         verbose_name = _('book')
 
 138         verbose_name_plural = _('books')
 
 139         app_label = 'catalogue'
 
 144     def get_extra_info_json(self):
 
 145         return json.loads(self.extra_info or '{}')
 
 147     def get_initial(self):
 
 149             return re.search(r'\w', self.title, re.U).group(0)
 
 150         except AttributeError:
 
 154         return self.tags.filter(category='author')
 
 157         return self.tags.filter(category='epoch')
 
 160         return self.tags.filter(category='genre')
 
 163         return self.tags.filter(category='kind')
 
 165     def tag_unicode(self, category):
 
 166         relations = prefetched_relations(self, category)
 
 168             return ', '.join(rel.tag.name for rel in relations)
 
 170             return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
 
 172     def tags_by_category(self):
 
 173         return split_tags(self.tags.exclude(category__in=('set', 'theme')))
 
 175     def author_unicode(self):
 
 176         return self.cached_author
 
 178     def kind_unicode(self):
 
 179         return self.tag_unicode('kind')
 
 181     def epoch_unicode(self):
 
 182         return self.tag_unicode('epoch')
 
 184     def genre_unicode(self):
 
 185         return self.tag_unicode('genre')
 
 187     def translators(self):
 
 188         translators = self.get_extra_info_json().get('translators') or []
 
 190             '\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators
 
 193     def translator(self):
 
 194         translators = self.get_extra_info_json().get('translators')
 
 197         if len(translators) > 3:
 
 198             translators = translators[:2]
 
 202         return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
 
 204     def cover_source(self):
 
 205         return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
 
 209         return self.get_extra_info_json().get('isbn_pdf')
 
 213         return self.get_extra_info_json().get('isbn_epub')
 
 217         return self.get_extra_info_json().get('isbn_mobi')
 
 220     def save(self, force_insert=False, force_update=False, **kwargs):
 
 221         from sortify import sortify
 
 223         self.sort_key = sortify(self.title)[:120]
 
 224         self.title = str(self.title)  # ???
 
 227             author = self.authors().first().sort_key
 
 228         except AttributeError:
 
 230         self.sort_key_author = author
 
 232         self.cached_author = self.tag_unicode('author')
 
 233         self.has_audience = 'audience' in self.get_extra_info_json()
 
 235         if self.preview and not self.preview_key:
 
 236             self.preview_key = get_random_hash(self.slug)[:32]
 
 238         ret = super(Book, self).save(force_insert, force_update, **kwargs)
 
 242     def get_absolute_url(self):
 
 243         return reverse('book_detail', args=[self.slug])
 
 245     def gallery_path(self):
 
 246         return gallery_path(self.slug)
 
 248     def gallery_url(self):
 
 249         return gallery_url(self.slug)
 
 251     def get_first_text(self):
 
 254         child = self.children.all().order_by('parent_number').first()
 
 255         if child is not None:
 
 256             return child.get_first_text()
 
 258     def get_last_text(self):
 
 261         child = self.children.all().order_by('parent_number').last()
 
 262         if child is not None:
 
 263             return child.get_last_text()
 
 265     def get_prev_text(self):
 
 268         sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
 
 269         if sibling is not None:
 
 270             return sibling.get_last_text()
 
 271         return self.parent.get_prev_text()
 
 273     def get_next_text(self):
 
 276         sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
 
 277         if sibling is not None:
 
 278             return sibling.get_first_text()
 
 279         return self.parent.get_next_text()
 
 281     def get_siblings(self):
 
 284         return self.parent.children.all().order_by('parent_number')
 
 290     def language_code(self):
 
 291         return constants.LANGUAGES_3TO2.get(self.language, self.language)
 
 293     def language_name(self):
 
 294         return dict(settings.LANGUAGES).get(self.language_code(), "")
 
 296     def is_foreign(self):
 
 297         return self.language_code() != settings.LANGUAGE_CODE
 
 299     def set_audio_length(self):
 
 300         length = self.get_audio_length()
 
 302             self.audio_length = self.format_audio_length(length)
 
 306     def format_audio_length(seconds):
 
 308         >>> Book.format_audio_length(1)
 
 310         >>> Book.format_audio_length(3661)
 
 314             minutes = seconds // 60
 
 315             seconds = seconds % 60
 
 316             return '%d:%02d' % (minutes, seconds)
 
 318             hours = seconds // 3600
 
 319             minutes = seconds % 3600 // 60
 
 320             seconds = seconds % 60
 
 321             return '%d:%02d:%02d' % (hours, minutes, seconds)
 
 323     def get_audio_length(self):
 
 325         for media in self.get_mp3() or ():
 
 326             total += app_settings.GET_MP3_LENGTH(media.file.path)
 
 329     def has_media(self, type_):
 
 330         if type_ in Book.formats:
 
 331             return bool(getattr(self, "%s_file" % type_))
 
 333             return self.media.filter(type=type_).exists()
 
 336         return self.has_media('mp3')
 
 338     def get_media(self, type_):
 
 339         if self.has_media(type_):
 
 340             if type_ in Book.formats:
 
 341                 return getattr(self, "%s_file" % type_)
 
 343                 return self.media.filter(type=type_)
 
 348         return self.get_media("mp3")
 
 351         return self.get_media("odt")
 
 354         return self.get_media("ogg")
 
 357         return self.get_media("daisy")
 
 359     def media_url(self, format_):
 
 360         media = self.get_media(format_)
 
 363                 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
 
 370         return self.media_url('html')
 
 373         return self.media_url('pdf')
 
 376         return self.media_url('epub')
 
 379         return self.media_url('mobi')
 
 382         return self.media_url('txt')
 
 385         return self.media_url('fb2')
 
 388         return self.media_url('xml')
 
 390     def has_description(self):
 
 391         return len(self.description) > 0
 
 392     has_description.short_description = _('description')
 
 393     has_description.boolean = True
 
 395     def has_mp3_file(self):
 
 396         return self.has_media("mp3")
 
 397     has_mp3_file.short_description = 'MP3'
 
 398     has_mp3_file.boolean = True
 
 400     def has_ogg_file(self):
 
 401         return self.has_media("ogg")
 
 402     has_ogg_file.short_description = 'OGG'
 
 403     has_ogg_file.boolean = True
 
 405     def has_daisy_file(self):
 
 406         return self.has_media("daisy")
 
 407     has_daisy_file.short_description = 'DAISY'
 
 408     has_daisy_file.boolean = True
 
 410     def get_audiobooks(self):
 
 412         for m in self.media.filter(type='ogg').order_by().iterator():
 
 413             ogg_files[m.name] = m
 
 417         for mp3 in self.media.filter(type='mp3').iterator():
 
 418             # ogg files are always from the same project
 
 419             meta = mp3.get_extra_info_json()
 
 420             project = meta.get('project')
 
 423                 project = 'CzytamySłuchając'
 
 425             projects.add((project, meta.get('funded_by', '')))
 
 429             ogg = ogg_files.get(mp3.name)
 
 432             audiobooks.append(media)
 
 434         projects = sorted(projects)
 
 435         return audiobooks, projects
 
 437     def wldocument(self, parse_dublincore=True, inherit=True):
 
 438         from catalogue.import_utils import ORMDocProvider
 
 439         from librarian.parser import WLDocument
 
 441         if inherit and self.parent:
 
 442             meta_fallbacks = self.parent.cover_info()
 
 444             meta_fallbacks = None
 
 446         return WLDocument.from_file(
 
 448             provider=ORMDocProvider(self),
 
 449             parse_dublincore=parse_dublincore,
 
 450             meta_fallbacks=meta_fallbacks)
 
 453     def zip_format(format_):
 
 454         def pretty_file_name(book):
 
 455             return "%s/%s.%s" % (
 
 456                 book.get_extra_info_json()['author'],
 
 460         field_name = "%s_file" % format_
 
 461         books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
 
 462         paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
 
 463         return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
 
 465     def zip_audiobooks(self, format_):
 
 466         bm = BookMedia.objects.filter(book=self, type=format_)
 
 467         paths = map(lambda bm: (None, bm.file.path), bm)
 
 470             license = constants.LICENSES.get(
 
 471                 m.get_extra_info_json().get('license'), {}
 
 474                 licenses.add(license)
 
 475         readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
 
 476             'licenses': licenses,
 
 478         return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
 
 480     def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
 
 481         if not self.findable:
 
 484             from search.index import Index
 
 487             index.index_book(self, book_info)
 
 492         except Exception as e:
 
 493             index.index.rollback()
 
 496     # will make problems in conjunction with paid previews
 
 497     def download_pictures(self, remote_gallery_url):
 
 498         gallery_path = self.gallery_path()
 
 499         # delete previous files, so we don't include old files in ebooks
 
 500         if os.path.isdir(gallery_path):
 
 501             for filename in os.listdir(gallery_path):
 
 502                 file_path = os.path.join(gallery_path, filename)
 
 504         ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
 
 506             makedirs(gallery_path)
 
 507             for ilustr in ilustr_elements:
 
 508                 ilustr_src = ilustr.get('src')
 
 509                 ilustr_path = os.path.join(gallery_path, ilustr_src)
 
 510                 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
 
 512     def load_abstract(self):
 
 513         abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
 
 514         if abstract is not None:
 
 515             self.abstract = transform_abstrakt(abstract)
 
 520     def from_xml_file(cls, xml_file, **kwargs):
 
 521         from django.core.files import File
 
 522         from librarian import dcparser
 
 524         # use librarian to parse meta-data
 
 525         book_info = dcparser.parse(xml_file)
 
 527         if not isinstance(xml_file, File):
 
 528             xml_file = File(open(xml_file))
 
 531             return cls.from_text_and_meta(xml_file, book_info, **kwargs)
 
 536     def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
 
 537                            search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
 
 538         if dont_build is None:
 
 540         dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
 
 542         # check for parts before we do anything
 
 544         if hasattr(book_info, 'parts'):
 
 545             for part_url in book_info.parts:
 
 547                     children.append(Book.objects.get(slug=part_url.slug))
 
 548                 except Book.DoesNotExist:
 
 549                     raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
 
 552         book_slug = book_info.url.slug
 
 553         if re.search(r'[^a-z0-9-]', book_slug):
 
 554             raise ValueError('Invalid characters in slug')
 
 555         book, created = Book.objects.get_or_create(slug=book_slug)
 
 560             book.preview = bool(days)
 
 562                 book.preview_until = date.today() + timedelta(days)
 
 565                 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
 
 566             # Save shelves for this book
 
 567             book_shelves = list(book.tags.filter(category='set'))
 
 568             old_cover = book.cover_info()
 
 571         book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
 
 573             book.xml_file.set_readable(False)
 
 575         book.findable = findable
 
 576         book.language = book_info.language
 
 577         book.title = book_info.title
 
 578         if book_info.variant_of:
 
 579             book.common_slug = book_info.variant_of.slug
 
 581             book.common_slug = book.slug
 
 582         book.extra_info = json.dumps(book_info.to_dict())
 
 586         meta_tags = Tag.tags_from_info(book_info)
 
 588         for tag in meta_tags:
 
 589             if not tag.for_books:
 
 593         book.tags = set(meta_tags + book_shelves)
 
 594         book.save()  # update sort_key_author
 
 596         cover_changed = old_cover != book.cover_info()
 
 597         obsolete_children = set(b for b in book.children.all()
 
 598                                 if b not in children)
 
 599         notify_cover_changed = []
 
 600         for n, child_book in enumerate(children):
 
 601             new_child = child_book.parent != book
 
 602             child_book.parent = book
 
 603             child_book.parent_number = n
 
 605             if new_child or cover_changed:
 
 606                 notify_cover_changed.append(child_book)
 
 607         # Disown unfaithful children and let them cope on their own.
 
 608         for child in obsolete_children:
 
 610             child.parent_number = 0
 
 613                 notify_cover_changed.append(child)
 
 615         cls.repopulate_ancestors()
 
 616         tasks.update_counters.delay()
 
 618         if remote_gallery_url:
 
 619             book.download_pictures(remote_gallery_url)
 
 621         # No saves beyond this point.
 
 624         if 'cover' not in dont_build:
 
 625             book.cover.build_delay()
 
 626             book.cover_thumb.build_delay()
 
 627             book.cover_api_thumb.build_delay()
 
 628             book.simple_cover.build_delay()
 
 629             book.cover_ebookpoint.build_delay()
 
 631         # Build HTML and ebooks.
 
 632         book.html_file.build_delay()
 
 634             for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
 
 635                 if format_ not in dont_build:
 
 636                     getattr(book, '%s_file' % format_).build_delay()
 
 637         for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
 
 638             if format_ not in dont_build:
 
 639                 getattr(book, '%s_file' % format_).build_delay()
 
 641         if not settings.NO_SEARCH_INDEX and search_index and findable:
 
 642             tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
 
 644         for child in notify_cover_changed:
 
 645             child.parent_cover_changed()
 
 647         book.update_popularity()
 
 648         tasks.update_references.delay(book.id)
 
 650         cls.published.send(sender=cls, instance=book)
 
 653     def get_master(self):
 
 657             'dramat_wierszowany_l',
 
 658             'dramat_wierszowany_lp',
 
 659             'dramat_wspolczesny', 'liryka_l', 'liryka_lp',
 
 662         from librarian.parser import WLDocument
 
 663         wld = WLDocument.from_file(self.xml_file.path, parse_dublincore=False)
 
 664         root = wld.edoc.getroot()
 
 665         for master in root.iter():
 
 666             if master.tag in master_tags:
 
 669     def update_references(self):
 
 670         from references.models import Entity, Reference
 
 671         master = self.get_master()
 
 673         for i, sec in enumerate(master):
 
 674             for ref in sec.findall('.//ref'):
 
 675                 href = ref.attrib.get('href', '')
 
 676                 if not href or href in found:
 
 679                 entity, created = Entity.objects.get_or_create(
 
 682                 ref, created = Reference.objects.get_or_create(
 
 686                 ref.first_section = 'sec%d' % (i + 1)
 
 689         Reference.objects.filter(book=self).exclude(entity__uri__in=found).delete()
 
 692     def references(self):
 
 693         return self.reference_set.all().select_related('entity')
 
 697     def repopulate_ancestors(cls):
 
 698         """Fixes the ancestry cache."""
 
 700         cursor = connection.cursor()
 
 701         if connection.vendor == 'postgres':
 
 702             cursor.execute("TRUNCATE catalogue_book_ancestor")
 
 704                 WITH RECURSIVE ancestry AS (
 
 705                     SELECT book.id, book.parent_id
 
 706                     FROM catalogue_book AS book
 
 707                     WHERE book.parent_id IS NOT NULL
 
 709                     SELECT ancestor.id, book.parent_id
 
 710                     FROM ancestry AS ancestor, catalogue_book AS book
 
 711                     WHERE ancestor.parent_id = book.id
 
 712                         AND book.parent_id IS NOT NULL
 
 714                 INSERT INTO catalogue_book_ancestor
 
 715                     (from_book_id, to_book_id)
 
 721             cursor.execute("DELETE FROM catalogue_book_ancestor")
 
 722             for b in cls.objects.exclude(parent=None):
 
 724                 while parent is not None:
 
 725                     b.ancestor.add(parent)
 
 726                     parent = parent.parent
 
 728     def clear_cache(self):
 
 729         clear_cached_renders(self.mini_box)
 
 730         clear_cached_renders(self.mini_box_nolink)
 
 732     def cover_info(self, inherit=True):
 
 733         """Returns a dictionary to serve as fallback for BookInfo.
 
 735         For now, the only thing inherited is the cover image.
 
 739         for field in ('cover_url', 'cover_by', 'cover_source'):
 
 740             val = self.get_extra_info_json().get(field)
 
 745         if inherit and need and self.parent is not None:
 
 746             parent_info = self.parent.cover_info()
 
 747             parent_info.update(info)
 
 751     def related_themes(self):
 
 752         return Tag.objects.usage_for_queryset(
 
 753             Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
 
 754             counts=True).filter(category='theme')
 
 756     def parent_cover_changed(self):
 
 757         """Called when parent book's cover image is changed."""
 
 758         if not self.cover_info(inherit=False):
 
 759             if 'cover' not in app_settings.DONT_BUILD:
 
 760                 self.cover.build_delay()
 
 761                 self.cover_thumb.build_delay()
 
 762                 self.cover_api_thumb.build_delay()
 
 763                 self.simple_cover.build_delay()
 
 764             for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
 
 765                 if format_ not in app_settings.DONT_BUILD:
 
 766                     getattr(self, '%s_file' % format_).build_delay()
 
 767             for child in self.children.all():
 
 768                 child.parent_cover_changed()
 
 770     def other_versions(self):
 
 771         """Find other versions (i.e. in other languages) of the book."""
 
 772         return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
 
 777         while parent is not None:
 
 778             books.insert(0, parent)
 
 779             parent = parent.parent
 
 782     def pretty_title(self, html_links=False):
 
 783         names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
 
 784         books = self.parents() + [self]
 
 785         names.extend([(b.title, b.get_absolute_url()) for b in books])
 
 788             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
 
 790             names = [tag[0] for tag in names]
 
 791         return ', '.join(names)
 
 794         publisher = self.get_extra_info_json()['publisher']
 
 795         if isinstance(publisher, str):
 
 797         elif isinstance(publisher, list):
 
 798             return ', '.join(publisher)
 
 801     def tagged_top_level(cls, tags):
 
 802         """ Returns top-level books tagged with `tags`.
 
 804         It only returns those books which don't have ancestors which are
 
 805         also tagged with those tags.
 
 808         objects = cls.tagged.with_all(tags)
 
 809         return objects.filter(findable=True).exclude(ancestor__in=objects)
 
 812     def book_list(cls, book_filter=None):
 
 813         """Generates a hierarchical listing of all books.
 
 815         Books are optionally filtered with a test function.
 
 820         books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
 
 822             books = books.filter(book_filter).distinct()
 
 824             book_ids = set(b['pk'] for b in books.values("pk").iterator())
 
 825             for book in books.iterator():
 
 826                 parent = book.parent_id
 
 827                 if parent not in book_ids:
 
 829                 books_by_parent.setdefault(parent, []).append(book)
 
 831             for book in books.iterator():
 
 832                 books_by_parent.setdefault(book.parent_id, []).append(book)
 
 835         books_by_author = OrderedDict()
 
 836         for tag in Tag.objects.filter(category='author').iterator():
 
 837             books_by_author[tag] = []
 
 839         for book in books_by_parent.get(None, ()):
 
 840             authors = list(book.authors().only('pk'))
 
 842                 for author in authors:
 
 843                     books_by_author[author].append(book)
 
 847         return books_by_author, orphans, books_by_parent
 
 850         "SP": (1, "szkoła podstawowa"),
 
 851         "SP1": (1, "szkoła podstawowa"),
 
 852         "SP2": (1, "szkoła podstawowa"),
 
 853         "SP3": (1, "szkoła podstawowa"),
 
 854         "P": (1, "szkoła podstawowa"),
 
 855         "G": (2, "gimnazjum"),
 
 860     def audiences_pl(self):
 
 861         audiences = self.get_extra_info_json().get('audiences', [])
 
 862         audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
 
 863         return [a[1] for a in audiences]
 
 865     def stage_note(self):
 
 866         stage = self.get_extra_info_json().get('stage')
 
 867         if stage and stage < '0.4':
 
 868             return (_('This work needs modernisation'),
 
 869                     reverse('infopage', args=['wymagajace-uwspolczesnienia']))
 
 873     def choose_fragment(self):
 
 874         fragments = self.fragments.order_by()
 
 875         fragments_count = fragments.count()
 
 876         if not fragments_count and self.children.exists():
 
 877             fragments = Fragment.objects.filter(book__ancestor=self).order_by()
 
 878             fragments_count = fragments.count()
 
 880             return fragments[randint(0, fragments_count - 1)]
 
 882             return self.parent.choose_fragment()
 
 886     def fragment_data(self):
 
 887         fragment = self.choose_fragment()
 
 890                 'title': fragment.book.pretty_title(),
 
 891                 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
 
 896     def update_popularity(self):
 
 897         count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
 
 899             pop = self.popularity
 
 902         except BookPopularity.DoesNotExist:
 
 903             BookPopularity.objects.create(book=self, count=count)
 
 905     def ridero_link(self):
 
 906         return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
 
 908     def like(self, user):
 
 909         from social.utils import likes, get_set, set_sets
 
 910         if not likes(user, self):
 
 911             tag = get_set(user, '')
 
 912             set_sets(user, self, [tag])
 
 914     def unlike(self, user):
 
 915         from social.utils import likes, set_sets
 
 916         if likes(user, self):
 
 917             set_sets(user, self, [])
 
 919     def full_sort_key(self):
 
 920         return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
 
 922     def cover_color(self):
 
 923         return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
 
 925     @cached_render('catalogue/book_mini_box.html')
 
 931     @cached_render('catalogue/book_mini_box.html')
 
 932     def mini_box_nolink(self):
 
 938 def add_file_fields():
 
 939     for format_ in Book.formats:
 
 940         field_name = "%s_file" % format_
 
 941         # This weird globals() assignment makes Django migrations comfortable.
 
 942         _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
 
 943         _upload_to.__name__ = '_%s_upload_to' % format_
 
 944         globals()[_upload_to.__name__] = _upload_to
 
 947             format_, _("%s file" % format_.upper()),
 
 948             upload_to=_upload_to,
 
 949             storage=bofh_storage,
 
 953         ).contribute_to_class(Book, field_name)
 
 955             models.CharField(max_length=255, editable=False, default='', db_index=True).contribute_to_class(Book, f'{field_name}_etag')
 
 961 class BookPopularity(models.Model):
 
 962     book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
 
 963     count = models.IntegerField(default=0, db_index=True)