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.apps import apps
 
  12 from django.conf import settings
 
  13 from django.db import connection, models, transaction
 
  14 import django.dispatch
 
  15 from django.contrib.contenttypes.fields import GenericRelation
 
  16 from django.template.loader import render_to_string
 
  17 from django.urls import reverse
 
  18 from django.utils.translation import ugettext_lazy as _, get_language
 
  19 from django.utils.deconstruct import deconstructible
 
  20 from fnpdjango.storage import BofhFileSystemStorage
 
  22 from librarian.cover import WLCover
 
  23 from librarian.html import transform_abstrakt
 
  24 from newtagging import managers
 
  25 from catalogue import constants
 
  26 from catalogue.fields import EbookField
 
  27 from catalogue.models import Tag, Fragment, BookMedia
 
  28 from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags, get_random_hash
 
  29 from catalogue.models.tag import prefetched_relations
 
  30 from catalogue import app_settings
 
  31 from catalogue import tasks
 
  32 from wolnelektury.utils import makedirs, cached_render, clear_cached_renders
 
  34 bofh_storage = BofhFileSystemStorage()
 
  38 class UploadToPath(object):
 
  39     def __init__(self, path):
 
  42     def __call__(self, instance, filename):
 
  43         return self.path % instance.slug
 
  46 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
 
  47 _cover_clean_upload_to = UploadToPath('book/cover_clean/%s.jpg')
 
  48 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
 
  49 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
 
  50 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
 
  51 _cover_ebookpoint_upload_to = UploadToPath('book/cover_ebookpoint/%s.jpg')
 
  54 def _ebook_upload_to(upload_path):
 
  55     return UploadToPath(upload_path)
 
  58 class Book(models.Model):
 
  59     """Represents a book imported from WL-XML."""
 
  60     title = models.CharField(_('title'), max_length=32767)
 
  61     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
 
  62     sort_key_author = models.CharField(
 
  63         _('sort key by author'), max_length=120, db_index=True, editable=False, default='')
 
  64     slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
 
  65     common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
 
  66     language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
 
  67     description = models.TextField(_('description'), blank=True)
 
  68     abstract = models.TextField(_('abstract'), blank=True)
 
  69     toc = models.TextField(_('toc'), blank=True)
 
  70     created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
 
  71     changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
 
  72     parent_number = models.IntegerField(_('parent number'), default=0)
 
  73     extra_info = models.TextField(_('extra information'), default='{}')
 
  74     gazeta_link = models.CharField(blank=True, max_length=240)
 
  75     wiki_link = models.CharField(blank=True, max_length=240)
 
  76     print_on_demand = models.BooleanField(_('print on demand'), default=False)
 
  77     recommended = models.BooleanField(_('recommended'), default=False)
 
  78     audio_length = models.CharField(_('audio length'), blank=True, max_length=8)
 
  79     preview = models.BooleanField(_('preview'), default=False)
 
  80     preview_until = models.DateField(_('preview until'), blank=True, null=True)
 
  81     preview_key = models.CharField(max_length=32, blank=True, null=True)
 
  82     findable = models.BooleanField(_('findable'), default=True, db_index=True)
 
  84     # files generated during publication
 
  87         null=True, blank=True,
 
  88         upload_to=_cover_upload_to,
 
  89         storage=bofh_storage, max_length=255)
 
  90     cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
  91     # Cleaner version of cover for thumbs
 
  92     cover_clean = EbookField(
 
  93         'cover_clean', _('clean cover'),
 
  94         null=True, blank=True,
 
  95         upload_to=_cover_clean_upload_to,
 
  98     cover_clean_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
  99     cover_thumb = EbookField(
 
 100         'cover_thumb', _('cover thumbnail'),
 
 101         null=True, blank=True,
 
 102         upload_to=_cover_thumb_upload_to,
 
 104     cover_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
 105     cover_api_thumb = EbookField(
 
 106         'cover_api_thumb', _('cover thumbnail for mobile app'),
 
 107         null=True, blank=True,
 
 108         upload_to=_cover_api_thumb_upload_to,
 
 110     cover_api_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
 111     simple_cover = EbookField(
 
 112         'simple_cover', _('cover for mobile app'),
 
 113         null=True, blank=True,
 
 114         upload_to=_simple_cover_upload_to,
 
 116     simple_cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
 117     cover_ebookpoint = EbookField(
 
 118         'cover_ebookpoint', _('cover for Ebookpoint'),
 
 119         null=True, blank=True,
 
 120         upload_to=_cover_ebookpoint_upload_to,
 
 122     cover_ebookpoint_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
 
 123     ebook_formats = constants.EBOOK_FORMATS
 
 124     formats = ebook_formats + ['html', 'xml']
 
 126     parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
 
 127     ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
 
 129     cached_author = models.CharField(blank=True, max_length=240, db_index=True)
 
 130     has_audience = models.BooleanField(default=False)
 
 132     objects = models.Manager()
 
 133     tagged = managers.ModelTaggedItemManager(Tag)
 
 134     tags = managers.TagDescriptor(Tag)
 
 135     tag_relations = GenericRelation(Tag.intermediary_table_model)
 
 137     html_built = django.dispatch.Signal()
 
 138     published = django.dispatch.Signal()
 
 144     class AlreadyExists(Exception):
 
 148         ordering = ('sort_key_author', 'sort_key')
 
 149         verbose_name = _('book')
 
 150         verbose_name_plural = _('books')
 
 151         app_label = 'catalogue'
 
 156     def get_extra_info_json(self):
 
 157         return json.loads(self.extra_info or '{}')
 
 159     def get_initial(self):
 
 161             return re.search(r'\w', self.title, re.U).group(0)
 
 162         except AttributeError:
 
 166         return self.tags.filter(category='author')
 
 169         return self.tags.filter(category='epoch')
 
 172         return self.tags.filter(category='genre')
 
 175         return self.tags.filter(category='kind')
 
 177     def tag_unicode(self, category):
 
 178         relations = prefetched_relations(self, category)
 
 180             return ', '.join(rel.tag.name for rel in relations)
 
 182             return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
 
 184     def tags_by_category(self):
 
 185         return split_tags(self.tags.exclude(category__in=('set', 'theme')))
 
 187     def author_unicode(self):
 
 188         return self.cached_author
 
 190     def kind_unicode(self):
 
 191         return self.tag_unicode('kind')
 
 193     def epoch_unicode(self):
 
 194         return self.tag_unicode('epoch')
 
 196     def genre_unicode(self):
 
 197         return self.tag_unicode('genre')
 
 199     def translators(self):
 
 200         translators = self.get_extra_info_json().get('translators') or []
 
 202             '\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators
 
 205     def translator(self):
 
 206         translators = self.get_extra_info_json().get('translators')
 
 209         if len(translators) > 3:
 
 210             translators = translators[:2]
 
 214         return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
 
 216     def cover_source(self):
 
 217         return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
 
 221         return self.get_extra_info_json().get('isbn_pdf')
 
 225         return self.get_extra_info_json().get('isbn_epub')
 
 229         return self.get_extra_info_json().get('isbn_mobi')
 
 231     def is_accessible_to(self, user):
 
 234         if not user.is_authenticated:
 
 236         Membership = apps.get_model('club', 'Membership')
 
 237         if Membership.is_active_for(user):
 
 239         Funding = apps.get_model('funding', 'Funding')
 
 240         if Funding.objects.filter(user=user, offer__book=self):
 
 244     def save(self, force_insert=False, force_update=False, **kwargs):
 
 245         from sortify import sortify
 
 247         self.sort_key = sortify(self.title)[:120]
 
 248         self.title = str(self.title)  # ???
 
 251             author = self.authors().first().sort_key
 
 252         except AttributeError:
 
 254         self.sort_key_author = author
 
 256         self.cached_author = self.tag_unicode('author')
 
 257         self.has_audience = 'audience' in self.get_extra_info_json()
 
 259         if self.preview and not self.preview_key:
 
 260             self.preview_key = get_random_hash(self.slug)[:32]
 
 262         ret = super(Book, self).save(force_insert, force_update, **kwargs)
 
 266     def get_absolute_url(self):
 
 267         return reverse('book_detail', args=[self.slug])
 
 269     def gallery_path(self):
 
 270         return gallery_path(self.slug)
 
 272     def gallery_url(self):
 
 273         return gallery_url(self.slug)
 
 275     def get_first_text(self):
 
 278         child = self.children.all().order_by('parent_number').first()
 
 279         if child is not None:
 
 280             return child.get_first_text()
 
 282     def get_last_text(self):
 
 285         child = self.children.all().order_by('parent_number').last()
 
 286         if child is not None:
 
 287             return child.get_last_text()
 
 289     def get_prev_text(self):
 
 292         sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
 
 293         if sibling is not None:
 
 294             return sibling.get_last_text()
 
 296         if self.parent.html_file:
 
 299         return self.parent.get_prev_text()
 
 301     def get_next_text(self, inside=True):
 
 303             child = self.children.order_by('parent_number').first()
 
 304             if child is not None:
 
 305                 return child.get_first_text()
 
 309         sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
 
 310         if sibling is not None:
 
 311             return sibling.get_first_text()
 
 312         return self.parent.get_next_text(inside=False)
 
 314     def get_child_audiobook(self):
 
 315         BookMedia = apps.get_model('catalogue', 'BookMedia')
 
 316         if not BookMedia.objects.filter(book__ancestor=self).exists():
 
 318         for child in self.children.order_by('parent_number').all():
 
 319             if child.has_mp3_file():
 
 321             child_sub = child.get_child_audiobook()
 
 322             if child_sub is not None:
 
 325     def get_siblings(self):
 
 328         return self.parent.children.all().order_by('parent_number')
 
 330     def get_children(self):
 
 331         return self.children.all().order_by('parent_number')
 
 337     def language_code(self):
 
 338         return constants.LANGUAGES_3TO2.get(self.language, self.language)
 
 340     def language_name(self):
 
 341         return dict(settings.LANGUAGES).get(self.language_code(), "")
 
 343     def is_foreign(self):
 
 344         return self.language_code() != settings.LANGUAGE_CODE
 
 346     def set_audio_length(self):
 
 347         length = self.get_audio_length()
 
 349             self.audio_length = self.format_audio_length(length)
 
 353     def format_audio_length(seconds):
 
 355         >>> Book.format_audio_length(1)
 
 357         >>> Book.format_audio_length(3661)
 
 361             minutes = seconds // 60
 
 362             seconds = seconds % 60
 
 363             return '%d:%02d' % (minutes, seconds)
 
 365             hours = seconds // 3600
 
 366             minutes = seconds % 3600 // 60
 
 367             seconds = seconds % 60
 
 368             return '%d:%02d:%02d' % (hours, minutes, seconds)
 
 370     def get_audio_length(self):
 
 372         for media in self.get_mp3() or ():
 
 373             total += app_settings.GET_MP3_LENGTH(media.file.path)
 
 376     def has_media(self, type_):
 
 377         if type_ in Book.formats:
 
 378             return bool(getattr(self, "%s_file" % type_))
 
 380             return self.media.filter(type=type_).exists()
 
 383         return self.has_media('mp3')
 
 385     def get_media(self, type_):
 
 386         if self.has_media(type_):
 
 387             if type_ in Book.formats:
 
 388                 return getattr(self, "%s_file" % type_)
 
 390                 return self.media.filter(type=type_)
 
 395         return self.get_media("mp3")
 
 398         return self.get_media("odt")
 
 401         return self.get_media("ogg")
 
 404         return self.get_media("daisy")
 
 406     def media_url(self, format_):
 
 407         media = self.get_media(format_)
 
 410                 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
 
 417         return self.media_url('html')
 
 420         return self.media_url('pdf')
 
 423         return self.media_url('epub')
 
 426         return self.media_url('mobi')
 
 429         return self.media_url('txt')
 
 432         return self.media_url('fb2')
 
 435         return self.media_url('xml')
 
 437     def has_description(self):
 
 438         return len(self.description) > 0
 
 439     has_description.short_description = _('description')
 
 440     has_description.boolean = True
 
 442     def has_mp3_file(self):
 
 443         return self.has_media("mp3")
 
 444     has_mp3_file.short_description = 'MP3'
 
 445     has_mp3_file.boolean = True
 
 447     def has_ogg_file(self):
 
 448         return self.has_media("ogg")
 
 449     has_ogg_file.short_description = 'OGG'
 
 450     has_ogg_file.boolean = True
 
 452     def has_daisy_file(self):
 
 453         return self.has_media("daisy")
 
 454     has_daisy_file.short_description = 'DAISY'
 
 455     has_daisy_file.boolean = True
 
 458     def media_daisy(self):
 
 459         return self.get_media('daisy')
 
 461     def get_audiobooks(self):
 
 463         for m in self.media.filter(type='ogg').order_by().iterator():
 
 464             ogg_files[m.name] = m
 
 469         for mp3 in self.media.filter(type='mp3').iterator():
 
 470             # ogg files are always from the same project
 
 471             meta = mp3.get_extra_info_json()
 
 472             project = meta.get('project')
 
 475                 project = 'CzytamySłuchając'
 
 477             projects.add((project, meta.get('funded_by', '')))
 
 478             total_duration += mp3.duration or 0
 
 482             ogg = ogg_files.get(mp3.name)
 
 485             audiobooks.append(media)
 
 487         projects = sorted(projects)
 
 488         total_duration = '%d:%02d' % (
 
 489             total_duration // 60,
 
 492         return audiobooks, projects, total_duration
 
 494     def wldocument(self, parse_dublincore=True, inherit=True):
 
 495         from catalogue.import_utils import ORMDocProvider
 
 496         from librarian.parser import WLDocument
 
 498         if inherit and self.parent:
 
 499             meta_fallbacks = self.parent.cover_info()
 
 501             meta_fallbacks = None
 
 503         return WLDocument.from_file(
 
 505             provider=ORMDocProvider(self),
 
 506             parse_dublincore=parse_dublincore,
 
 507             meta_fallbacks=meta_fallbacks)
 
 509     def wldocument2(self):
 
 510         from catalogue.import_utils import ORMDocProvider
 
 511         from librarian.document import WLDocument
 
 514             provider=ORMDocProvider(self)
 
 516         doc.meta.update(self.cover_info())
 
 521     def zip_format(format_):
 
 522         def pretty_file_name(book):
 
 523             return "%s/%s.%s" % (
 
 524                 book.get_extra_info_json()['author'],
 
 528         field_name = "%s_file" % format_
 
 529         books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
 
 530         paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
 
 531         return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
 
 533     def zip_audiobooks(self, format_):
 
 534         bm = BookMedia.objects.filter(book=self, type=format_)
 
 535         paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
 
 538             license = constants.LICENSES.get(
 
 539                 m.get_extra_info_json().get('license'), {}
 
 542                 licenses.add(license)
 
 543         readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
 
 544             'licenses': licenses,
 
 546         return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
 
 548     def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
 
 549         if not self.findable:
 
 552             from search.index import Index
 
 555             index.index_book(self, book_info)
 
 560         except Exception as e:
 
 561             index.index.rollback()
 
 564     # will make problems in conjunction with paid previews
 
 565     def download_pictures(self, remote_gallery_url):
 
 566         # This is only needed for legacy relative image paths.
 
 567         gallery_path = self.gallery_path()
 
 568         # delete previous files, so we don't include old files in ebooks
 
 569         if os.path.isdir(gallery_path):
 
 570             for filename in os.listdir(gallery_path):
 
 571                 file_path = os.path.join(gallery_path, filename)
 
 573         ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
 
 575             makedirs(gallery_path)
 
 576             for ilustr in ilustr_elements:
 
 577                 ilustr_src = ilustr.get('src')
 
 578                 if '/' in ilustr_src:
 
 580                 ilustr_path = os.path.join(gallery_path, ilustr_src)
 
 581                 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
 
 583     def load_abstract(self):
 
 584         abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
 
 585         if abstract is not None:
 
 586             self.abstract = transform_abstrakt(abstract)
 
 593             parser = html.HTMLParser(encoding='utf-8')
 
 594             tree = html.parse(self.html_file.path, parser=parser)
 
 595             toc = tree.find('//div[@id="toc"]/ol')
 
 596             if toc is None or not len(toc):
 
 598             html_link = reverse('book_text', args=[self.slug])
 
 599             for a in toc.findall('.//a'):
 
 600                 a.attrib['href'] = html_link + a.attrib['href']
 
 601             self.toc = html.tostring(toc, encoding='unicode')
 
 605     def from_xml_file(cls, xml_file, **kwargs):
 
 606         from django.core.files import File
 
 607         from librarian import dcparser
 
 609         # use librarian to parse meta-data
 
 610         book_info = dcparser.parse(xml_file)
 
 612         if not isinstance(xml_file, File):
 
 613             xml_file = File(open(xml_file))
 
 616             return cls.from_text_and_meta(xml_file, book_info, **kwargs)
 
 621     def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
 
 622                            search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
 
 623         if dont_build is None:
 
 625         dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
 
 627         # check for parts before we do anything
 
 629         if hasattr(book_info, 'parts'):
 
 630             for part_url in book_info.parts:
 
 632                     children.append(Book.objects.get(slug=part_url.slug))
 
 633                 except Book.DoesNotExist:
 
 634                     raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
 
 637         book_slug = book_info.url.slug
 
 638         if re.search(r'[^a-z0-9-]', book_slug):
 
 639             raise ValueError('Invalid characters in slug')
 
 640         book, created = Book.objects.get_or_create(slug=book_slug)
 
 645             book.preview = bool(days)
 
 647                 book.preview_until = date.today() + timedelta(days)
 
 650                 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
 
 651             # Save shelves for this book
 
 652             book_shelves = list(book.tags.filter(category='set'))
 
 653             old_cover = book.cover_info()
 
 656         book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
 
 658             book.xml_file.set_readable(False)
 
 660         book.findable = findable
 
 661         book.language = book_info.language
 
 662         book.title = book_info.title
 
 663         if book_info.variant_of:
 
 664             book.common_slug = book_info.variant_of.slug
 
 666             book.common_slug = book.slug
 
 667         book.extra_info = json.dumps(book_info.to_dict())
 
 672         meta_tags = Tag.tags_from_info(book_info)
 
 674         for tag in meta_tags:
 
 675             if not tag.for_books:
 
 679         book.tags = set(meta_tags + book_shelves)
 
 680         book.save()  # update sort_key_author
 
 682         cover_changed = old_cover != book.cover_info()
 
 683         obsolete_children = set(b for b in book.children.all()
 
 684                                 if b not in children)
 
 685         notify_cover_changed = []
 
 686         for n, child_book in enumerate(children):
 
 687             new_child = child_book.parent != book
 
 688             child_book.parent = book
 
 689             child_book.parent_number = n
 
 691             if new_child or cover_changed:
 
 692                 notify_cover_changed.append(child_book)
 
 693         # Disown unfaithful children and let them cope on their own.
 
 694         for child in obsolete_children:
 
 696             child.parent_number = 0
 
 699                 notify_cover_changed.append(child)
 
 701         cls.repopulate_ancestors()
 
 702         tasks.update_counters.delay()
 
 704         if remote_gallery_url:
 
 705             book.download_pictures(remote_gallery_url)
 
 707         # No saves beyond this point.
 
 710         if 'cover' not in dont_build:
 
 711             book.cover.build_delay()
 
 712             book.cover_clean.build_delay()
 
 713             book.cover_thumb.build_delay()
 
 714             book.cover_api_thumb.build_delay()
 
 715             book.simple_cover.build_delay()
 
 716             book.cover_ebookpoint.build_delay()
 
 718         # Build HTML and ebooks.
 
 719         book.html_file.build_delay()
 
 721             for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
 
 722                 if format_ not in dont_build:
 
 723                     getattr(book, '%s_file' % format_).build_delay()
 
 724         for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
 
 725             if format_ not in dont_build:
 
 726                 getattr(book, '%s_file' % format_).build_delay()
 
 728         if not settings.NO_SEARCH_INDEX and search_index and findable:
 
 729             tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
 
 731         for child in notify_cover_changed:
 
 732             child.parent_cover_changed()
 
 734         book.update_popularity()
 
 735         tasks.update_references.delay(book.id)
 
 737         cls.published.send(sender=cls, instance=book)
 
 740     def get_master(self):
 
 744             'dramat_wierszowany_l',
 
 745             'dramat_wierszowany_lp',
 
 746             'dramat_wspolczesny', 'liryka_l', 'liryka_lp',
 
 749         from librarian.parser import WLDocument
 
 750         wld = WLDocument.from_file(self.xml_file.path, parse_dublincore=False)
 
 751         root = wld.edoc.getroot()
 
 752         for master in root.iter():
 
 753             if master.tag in master_tags:
 
 756     def update_references(self):
 
 757         from references.models import Entity, Reference
 
 758         master = self.get_master()
 
 762         for i, sec in enumerate(master):
 
 763             for ref in sec.findall('.//ref'):
 
 764                 href = ref.attrib.get('href', '')
 
 765                 if not href or href in found:
 
 768                 entity, created = Entity.objects.get_or_create(
 
 771                 ref, created = Reference.objects.get_or_create(
 
 775                 ref.first_section = 'sec%d' % (i + 1)
 
 778         Reference.objects.filter(book=self).exclude(entity__uri__in=found).delete()
 
 781     def references(self):
 
 782         return self.reference_set.all().select_related('entity')
 
 786     def repopulate_ancestors(cls):
 
 787         """Fixes the ancestry cache."""
 
 789         cursor = connection.cursor()
 
 790         if connection.vendor == 'postgres':
 
 791             cursor.execute("TRUNCATE catalogue_book_ancestor")
 
 793                 WITH RECURSIVE ancestry AS (
 
 794                     SELECT book.id, book.parent_id
 
 795                     FROM catalogue_book AS book
 
 796                     WHERE book.parent_id IS NOT NULL
 
 798                     SELECT ancestor.id, book.parent_id
 
 799                     FROM ancestry AS ancestor, catalogue_book AS book
 
 800                     WHERE ancestor.parent_id = book.id
 
 801                         AND book.parent_id IS NOT NULL
 
 803                 INSERT INTO catalogue_book_ancestor
 
 804                     (from_book_id, to_book_id)
 
 810             cursor.execute("DELETE FROM catalogue_book_ancestor")
 
 811             for b in cls.objects.exclude(parent=None):
 
 813                 while parent is not None:
 
 814                     b.ancestor.add(parent)
 
 815                     parent = parent.parent
 
 820             for anc in self.parent.ancestors:
 
 826     def clear_cache(self):
 
 827         clear_cached_renders(self.mini_box)
 
 828         clear_cached_renders(self.mini_box_nolink)
 
 830     def cover_info(self, inherit=True):
 
 831         """Returns a dictionary to serve as fallback for BookInfo.
 
 833         For now, the only thing inherited is the cover image.
 
 837         for field in ('cover_url', 'cover_by', 'cover_source'):
 
 838             val = self.get_extra_info_json().get(field)
 
 843         if inherit and need and self.parent is not None:
 
 844             parent_info = self.parent.cover_info()
 
 845             parent_info.update(info)
 
 849     def related_themes(self):
 
 850         return Tag.objects.usage_for_queryset(
 
 851             Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
 
 852             counts=True).filter(category='theme').order_by('-count')
 
 854     def parent_cover_changed(self):
 
 855         """Called when parent book's cover image is changed."""
 
 856         if not self.cover_info(inherit=False):
 
 857             if 'cover' not in app_settings.DONT_BUILD:
 
 858                 self.cover.build_delay()
 
 859                 self.cover_clean.build_delay()
 
 860                 self.cover_thumb.build_delay()
 
 861                 self.cover_api_thumb.build_delay()
 
 862                 self.simple_cover.build_delay()
 
 863                 self.cover_ebookpoint.build_delay()
 
 864             for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
 
 865                 if format_ not in app_settings.DONT_BUILD:
 
 866                     getattr(self, '%s_file' % format_).build_delay()
 
 867             for child in self.children.all():
 
 868                 child.parent_cover_changed()
 
 870     def other_versions(self):
 
 871         """Find other versions (i.e. in other languages) of the book."""
 
 872         return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
 
 877         while parent is not None:
 
 878             books.insert(0, parent)
 
 879             parent = parent.parent
 
 882     def pretty_title(self, html_links=False):
 
 883         names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
 
 884         books = self.parents() + [self]
 
 885         names.extend([(b.title, b.get_absolute_url()) for b in books])
 
 888             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
 
 890             names = [tag[0] for tag in names]
 
 891         return ', '.join(names)
 
 894         publisher = self.get_extra_info_json()['publisher']
 
 895         if isinstance(publisher, str):
 
 897         elif isinstance(publisher, list):
 
 898             return ', '.join(publisher)
 
 901     def tagged_top_level(cls, tags):
 
 902         """ Returns top-level books tagged with `tags`.
 
 904         It only returns those books which don't have ancestors which are
 
 905         also tagged with those tags.
 
 908         objects = cls.tagged.with_all(tags)
 
 909         return objects.filter(findable=True).exclude(ancestor__in=objects)
 
 912     def book_list(cls, book_filter=None):
 
 913         """Generates a hierarchical listing of all books.
 
 915         Books are optionally filtered with a test function.
 
 920         books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
 
 922             books = books.filter(book_filter).distinct()
 
 924             book_ids = set(b['pk'] for b in books.values("pk").iterator())
 
 925             for book in books.iterator():
 
 926                 parent = book.parent_id
 
 927                 if parent not in book_ids:
 
 929                 books_by_parent.setdefault(parent, []).append(book)
 
 931             for book in books.iterator():
 
 932                 books_by_parent.setdefault(book.parent_id, []).append(book)
 
 935         books_by_author = OrderedDict()
 
 936         for tag in Tag.objects.filter(category='author').iterator():
 
 937             books_by_author[tag] = []
 
 939         for book in books_by_parent.get(None, ()):
 
 940             authors = list(book.authors().only('pk'))
 
 942                 for author in authors:
 
 943                     books_by_author[author].append(book)
 
 947         return books_by_author, orphans, books_by_parent
 
 950         "SP": (1, "szkoła podstawowa"),
 
 951         "SP1": (1, "szkoła podstawowa"),
 
 952         "SP2": (1, "szkoła podstawowa"),
 
 953         "SP3": (1, "szkoła podstawowa"),
 
 954         "P": (1, "szkoła podstawowa"),
 
 955         "G": (2, "gimnazjum"),
 
 960     def audiences_pl(self):
 
 961         audiences = self.get_extra_info_json().get('audiences', [])
 
 962         audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
 
 963         return [a[1] for a in audiences]
 
 965     def stage_note(self):
 
 966         stage = self.get_extra_info_json().get('stage')
 
 967         if stage and stage < '0.4':
 
 968             return (_('This work needs modernisation'),
 
 969                     reverse('infopage', args=['wymagajace-uwspolczesnienia']))
 
 973     def choose_fragments(self, number):
 
 974         fragments = self.fragments.order_by()
 
 975         fragments_count = fragments.count()
 
 976         if not fragments_count and self.children.exists():
 
 977             fragments = Fragment.objects.filter(book__ancestor=self).order_by()
 
 978             fragments_count = fragments.count()
 
 980             if fragments_count > number:
 
 981                 offset = randint(0, fragments_count - number)
 
 984             return fragments[offset : offset + number]
 
 986             return self.parent.choose_fragments(number)
 
 990     def choose_fragment(self):
 
 991         fragments = self.choose_fragments(1)
 
 997     def fragment_data(self):
 
 998         fragment = self.choose_fragment()
 
1001                 'title': fragment.book.pretty_title(),
 
1002                 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
 
1007     def update_popularity(self):
 
1008         count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
 
1010             pop = self.popularity
 
1013         except BookPopularity.DoesNotExist:
 
1014             BookPopularity.objects.create(book=self, count=count)
 
1016     def ridero_link(self):
 
1017         return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
 
1019     def like(self, user):
 
1020         from social.utils import likes, get_set, set_sets
 
1021         if not likes(user, self):
 
1022             tag = get_set(user, '')
 
1023             set_sets(user, self, [tag])
 
1025     def unlike(self, user):
 
1026         from social.utils import likes, set_sets
 
1027         if likes(user, self):
 
1028             set_sets(user, self, [])
 
1030     def full_sort_key(self):
 
1031         return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
 
1033     def cover_color(self):
 
1034         return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
 
1036     @cached_render('catalogue/book_mini_box.html')
 
1042     @cached_render('catalogue/book_mini_box.html')
 
1043     def mini_box_nolink(self):
 
1049 def add_file_fields():
 
1050     for format_ in Book.formats:
 
1051         field_name = "%s_file" % format_
 
1052         # This weird globals() assignment makes Django migrations comfortable.
 
1053         _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
 
1054         _upload_to.__name__ = '_%s_upload_to' % format_
 
1055         globals()[_upload_to.__name__] = _upload_to
 
1058             format_, _("%s file" % format_.upper()),
 
1059             upload_to=_upload_to,
 
1060             storage=bofh_storage,
 
1064         ).contribute_to_class(Book, field_name)
 
1065         if format_ != 'xml':
 
1066             models.CharField(max_length=255, editable=False, default='', db_index=True).contribute_to_class(Book, f'{field_name}_etag')
 
1072 class BookPopularity(models.Model):
 
1073     book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
 
1074     count = models.IntegerField(default=0, db_index=True)