Fixes #4021: Ebook files regeneration mechanism.
[wolnelektury.git] / src / catalogue / models / book.py
1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
3 #
4 from collections import OrderedDict
5 import json
6 from datetime import date, timedelta
7 from random import randint
8 import os.path
9 import re
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.urls import reverse
16 from django.utils.translation import ugettext_lazy as _, get_language
17 from django.utils.deconstruct import deconstructible
18 from fnpdjango.storage import BofhFileSystemStorage
19
20 from librarian.cover import WLCover
21 from librarian.html import transform_abstrakt
22 from newtagging import managers
23 from catalogue import constants
24 from catalogue.fields import EbookField
25 from catalogue.models import Tag, Fragment, BookMedia
26 from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags, get_random_hash
27 from catalogue.models.tag import prefetched_relations
28 from catalogue import app_settings
29 from catalogue import tasks
30 from wolnelektury.utils import makedirs, cached_render, clear_cached_renders
31
32 bofh_storage = BofhFileSystemStorage()
33
34
35 @deconstructible
36 class UploadToPath(object):
37     def __init__(self, path):
38         self.path = path
39
40     def __call__(self, instance, filename):
41         return self.path % instance.slug
42
43
44 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
45 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
46 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
47 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
48 _cover_ebookpoint_upload_to = UploadToPath('book/cover_ebookpoint/%s.jpg')
49
50
51 def _ebook_upload_to(upload_path):
52     return UploadToPath(upload_path)
53
54
55 class Book(models.Model):
56     """Represents a book imported from WL-XML."""
57     title = models.CharField(_('title'), max_length=32767)
58     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
59     sort_key_author = models.CharField(
60         _('sort key by author'), max_length=120, db_index=True, editable=False, default='')
61     slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
62     common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
63     language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
64     description = models.TextField(_('description'), blank=True)
65     abstract = models.TextField(_('abstract'), blank=True)
66     created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
67     changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
68     parent_number = models.IntegerField(_('parent number'), default=0)
69     extra_info = models.TextField(_('extra information'), default='{}')
70     gazeta_link = models.CharField(blank=True, max_length=240)
71     wiki_link = models.CharField(blank=True, max_length=240)
72     print_on_demand = models.BooleanField(_('print on demand'), default=False)
73     recommended = models.BooleanField(_('recommended'), default=False)
74     audio_length = models.CharField(_('audio length'), blank=True, max_length=8)
75     preview = models.BooleanField(_('preview'), default=False)
76     preview_until = models.DateField(_('preview until'), blank=True, null=True)
77     preview_key = models.CharField(max_length=32, blank=True, null=True)
78     findable = models.BooleanField(_('findable'), default=True, db_index=True)
79
80     # files generated during publication
81     cover = EbookField(
82         'cover', _('cover'),
83         null=True, blank=True,
84         upload_to=_cover_upload_to,
85         storage=bofh_storage, max_length=255)
86     cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
87     # Cleaner version of cover for thumbs
88     cover_thumb = EbookField(
89         'cover_thumb', _('cover thumbnail'),
90         null=True, blank=True,
91         upload_to=_cover_thumb_upload_to,
92         max_length=255)
93     cover_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
94     cover_api_thumb = EbookField(
95         'cover_api_thumb', _('cover thumbnail for mobile app'),
96         null=True, blank=True,
97         upload_to=_cover_api_thumb_upload_to,
98         max_length=255)
99     cover_api_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
100     simple_cover = EbookField(
101         'simple_cover', _('cover for mobile app'),
102         null=True, blank=True,
103         upload_to=_simple_cover_upload_to,
104         max_length=255)
105     simple_cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
106     cover_ebookpoint = EbookField(
107         'cover_ebookpoint', _('cover for Ebookpoint'),
108         null=True, blank=True,
109         upload_to=_cover_ebookpoint_upload_to,
110         max_length=255)
111     cover_ebookpoint_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
112     ebook_formats = constants.EBOOK_FORMATS
113     formats = ebook_formats + ['html', 'xml']
114
115     parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
116     ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
117
118     cached_author = models.CharField(blank=True, max_length=240, db_index=True)
119     has_audience = models.BooleanField(default=False)
120
121     objects = models.Manager()
122     tagged = managers.ModelTaggedItemManager(Tag)
123     tags = managers.TagDescriptor(Tag)
124     tag_relations = GenericRelation(Tag.intermediary_table_model)
125
126     html_built = django.dispatch.Signal()
127     published = django.dispatch.Signal()
128
129     SORT_KEY_SEP = '$'
130
131     class AlreadyExists(Exception):
132         pass
133
134     class Meta:
135         ordering = ('sort_key_author', 'sort_key')
136         verbose_name = _('book')
137         verbose_name_plural = _('books')
138         app_label = 'catalogue'
139
140     def __str__(self):
141         return self.title
142
143     def get_extra_info_json(self):
144         return json.loads(self.extra_info or '{}')
145
146     def get_initial(self):
147         try:
148             return re.search(r'\w', self.title, re.U).group(0)
149         except AttributeError:
150             return ''
151
152     def authors(self):
153         return self.tags.filter(category='author')
154
155     def epochs(self):
156         return self.tags.filter(category='epoch')
157
158     def genres(self):
159         return self.tags.filter(category='genre')
160
161     def kinds(self):
162         return self.tags.filter(category='kind')
163
164     def tag_unicode(self, category):
165         relations = prefetched_relations(self, category)
166         if relations:
167             return ', '.join(rel.tag.name for rel in relations)
168         else:
169             return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
170
171     def tags_by_category(self):
172         return split_tags(self.tags.exclude(category__in=('set', 'theme')))
173
174     def author_unicode(self):
175         return self.cached_author
176
177     def kind_unicode(self):
178         return self.tag_unicode('kind')
179
180     def epoch_unicode(self):
181         return self.tag_unicode('epoch')
182
183     def genre_unicode(self):
184         return self.tag_unicode('genre')
185
186     def translators(self):
187         translators = self.get_extra_info_json().get('translators') or []
188         return [
189             '\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators
190         ]
191
192     def translator(self):
193         translators = self.get_extra_info_json().get('translators')
194         if not translators:
195             return None
196         if len(translators) > 3:
197             translators = translators[:2]
198             others = ' i inni'
199         else:
200             others = ''
201         return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
202
203     def cover_source(self):
204         return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
205
206     @property
207     def isbn_pdf(self):
208         return self.get_extra_info_json().get('isbn_pdf')
209
210     @property
211     def isbn_epub(self):
212         return self.get_extra_info_json().get('isbn_epub')
213
214     @property
215     def isbn_mobi(self):
216         return self.get_extra_info_json().get('isbn_mobi')
217
218
219     def save(self, force_insert=False, force_update=False, **kwargs):
220         from sortify import sortify
221
222         self.sort_key = sortify(self.title)[:120]
223         self.title = str(self.title)  # ???
224
225         try:
226             author = self.authors().first().sort_key
227         except AttributeError:
228             author = ''
229         self.sort_key_author = author
230
231         self.cached_author = self.tag_unicode('author')
232         self.has_audience = 'audience' in self.get_extra_info_json()
233
234         if self.preview and not self.preview_key:
235             self.preview_key = get_random_hash(self.slug)[:32]
236
237         ret = super(Book, self).save(force_insert, force_update, **kwargs)
238
239         return ret
240
241     def get_absolute_url(self):
242         return reverse('book_detail', args=[self.slug])
243
244     def gallery_path(self):
245         return gallery_path(self.slug)
246
247     def gallery_url(self):
248         return gallery_url(self.slug)
249
250     @property
251     def name(self):
252         return self.title
253
254     def language_code(self):
255         return constants.LANGUAGES_3TO2.get(self.language, self.language)
256
257     def language_name(self):
258         return dict(settings.LANGUAGES).get(self.language_code(), "")
259
260     def is_foreign(self):
261         return self.language_code() != settings.LANGUAGE_CODE
262
263     def set_audio_length(self):
264         length = self.get_audio_length()
265         if length > 0:
266             self.audio_length = self.format_audio_length(length)
267             self.save()
268
269     @staticmethod
270     def format_audio_length(seconds):
271         """
272         >>> Book.format_audio_length(1)
273         '0:01'
274         >>> Book.format_audio_length(3661)
275         '1:01:01'
276         """
277         if seconds < 60*60:
278             minutes = seconds // 60
279             seconds = seconds % 60
280             return '%d:%02d' % (minutes, seconds)
281         else:
282             hours = seconds // 3600
283             minutes = seconds % 3600 // 60
284             seconds = seconds % 60
285             return '%d:%02d:%02d' % (hours, minutes, seconds)
286
287     def get_audio_length(self):
288         total = 0
289         for media in self.get_mp3() or ():
290             total += app_settings.GET_MP3_LENGTH(media.file.path)
291         return int(total)
292
293     def has_media(self, type_):
294         if type_ in Book.formats:
295             return bool(getattr(self, "%s_file" % type_))
296         else:
297             return self.media.filter(type=type_).exists()
298
299     def has_audio(self):
300         return self.has_media('mp3')
301
302     def get_media(self, type_):
303         if self.has_media(type_):
304             if type_ in Book.formats:
305                 return getattr(self, "%s_file" % type_)
306             else:
307                 return self.media.filter(type=type_)
308         else:
309             return None
310
311     def get_mp3(self):
312         return self.get_media("mp3")
313
314     def get_odt(self):
315         return self.get_media("odt")
316
317     def get_ogg(self):
318         return self.get_media("ogg")
319
320     def get_daisy(self):
321         return self.get_media("daisy")
322
323     def media_url(self, format_):
324         media = self.get_media(format_)
325         if media:
326             if self.preview:
327                 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
328             else:
329                 return media.url
330         else:
331             return None
332
333     def html_url(self):
334         return self.media_url('html')
335
336     def pdf_url(self):
337         return self.media_url('pdf')
338
339     def epub_url(self):
340         return self.media_url('epub')
341
342     def mobi_url(self):
343         return self.media_url('mobi')
344
345     def txt_url(self):
346         return self.media_url('txt')
347
348     def fb2_url(self):
349         return self.media_url('fb2')
350
351     def xml_url(self):
352         return self.media_url('xml')
353
354     def has_description(self):
355         return len(self.description) > 0
356     has_description.short_description = _('description')
357     has_description.boolean = True
358
359     def has_mp3_file(self):
360         return self.has_media("mp3")
361     has_mp3_file.short_description = 'MP3'
362     has_mp3_file.boolean = True
363
364     def has_ogg_file(self):
365         return self.has_media("ogg")
366     has_ogg_file.short_description = 'OGG'
367     has_ogg_file.boolean = True
368
369     def has_daisy_file(self):
370         return self.has_media("daisy")
371     has_daisy_file.short_description = 'DAISY'
372     has_daisy_file.boolean = True
373
374     def get_audiobooks(self):
375         ogg_files = {}
376         for m in self.media.filter(type='ogg').order_by().iterator():
377             ogg_files[m.name] = m
378
379         audiobooks = []
380         projects = set()
381         for mp3 in self.media.filter(type='mp3').iterator():
382             # ogg files are always from the same project
383             meta = mp3.get_extra_info_json()
384             project = meta.get('project')
385             if not project:
386                 # temporary fallback
387                 project = 'CzytamySłuchając'
388
389             projects.add((project, meta.get('funded_by', '')))
390
391             media = {'mp3': mp3}
392
393             ogg = ogg_files.get(mp3.name)
394             if ogg:
395                 media['ogg'] = ogg
396             audiobooks.append(media)
397
398         projects = sorted(projects)
399         return audiobooks, projects
400
401     def wldocument(self, parse_dublincore=True, inherit=True):
402         from catalogue.import_utils import ORMDocProvider
403         from librarian.parser import WLDocument
404
405         if inherit and self.parent:
406             meta_fallbacks = self.parent.cover_info()
407         else:
408             meta_fallbacks = None
409
410         return WLDocument.from_file(
411             self.xml_file.path,
412             provider=ORMDocProvider(self),
413             parse_dublincore=parse_dublincore,
414             meta_fallbacks=meta_fallbacks)
415
416     @staticmethod
417     def zip_format(format_):
418         def pretty_file_name(book):
419             return "%s/%s.%s" % (
420                 book.get_extra_info_json()['author'],
421                 book.slug,
422                 format_)
423
424         field_name = "%s_file" % format_
425         books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
426         paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
427         return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
428
429     def zip_audiobooks(self, format_):
430         bm = BookMedia.objects.filter(book=self, type=format_)
431         paths = map(lambda bm: (None, bm.file.path), bm)
432         return create_zip(paths, "%s_%s" % (self.slug, format_))
433
434     def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
435         if not self.findable:
436             return
437         if index is None:
438             from search.index import Index
439             index = Index()
440         try:
441             index.index_book(self, book_info)
442             if index_tags:
443                 index.index_tags()
444             if commit:
445                 index.index.commit()
446         except Exception as e:
447             index.index.rollback()
448             raise e
449
450     # will make problems in conjunction with paid previews
451     def download_pictures(self, remote_gallery_url):
452         gallery_path = self.gallery_path()
453         # delete previous files, so we don't include old files in ebooks
454         if os.path.isdir(gallery_path):
455             for filename in os.listdir(gallery_path):
456                 file_path = os.path.join(gallery_path, filename)
457                 os.unlink(file_path)
458         ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
459         if ilustr_elements:
460             makedirs(gallery_path)
461             for ilustr in ilustr_elements:
462                 ilustr_src = ilustr.get('src')
463                 ilustr_path = os.path.join(gallery_path, ilustr_src)
464                 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
465
466     def load_abstract(self):
467         abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
468         if abstract is not None:
469             self.abstract = transform_abstrakt(abstract)
470         else:
471             self.abstract = ''
472
473     @classmethod
474     def from_xml_file(cls, xml_file, **kwargs):
475         from django.core.files import File
476         from librarian import dcparser
477
478         # use librarian to parse meta-data
479         book_info = dcparser.parse(xml_file)
480
481         if not isinstance(xml_file, File):
482             xml_file = File(open(xml_file))
483
484         try:
485             return cls.from_text_and_meta(xml_file, book_info, **kwargs)
486         finally:
487             xml_file.close()
488
489     @classmethod
490     def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
491                            search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
492         if dont_build is None:
493             dont_build = set()
494         dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
495
496         # check for parts before we do anything
497         children = []
498         if hasattr(book_info, 'parts'):
499             for part_url in book_info.parts:
500                 try:
501                     children.append(Book.objects.get(slug=part_url.slug))
502                 except Book.DoesNotExist:
503                     raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
504
505         # Read book metadata
506         book_slug = book_info.url.slug
507         if re.search(r'[^a-z0-9-]', book_slug):
508             raise ValueError('Invalid characters in slug')
509         book, created = Book.objects.get_or_create(slug=book_slug)
510
511         if created:
512             book_shelves = []
513             old_cover = None
514             book.preview = bool(days)
515             if book.preview:
516                 book.preview_until = date.today() + timedelta(days)
517         else:
518             if not overwrite:
519                 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
520             # Save shelves for this book
521             book_shelves = list(book.tags.filter(category='set'))
522             old_cover = book.cover_info()
523
524         # Save XML file
525         book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
526         if book.preview:
527             book.xml_file.set_readable(False)
528
529         book.findable = findable
530         book.language = book_info.language
531         book.title = book_info.title
532         if book_info.variant_of:
533             book.common_slug = book_info.variant_of.slug
534         else:
535             book.common_slug = book.slug
536         book.extra_info = json.dumps(book_info.to_dict())
537         book.load_abstract()
538         book.save()
539
540         meta_tags = Tag.tags_from_info(book_info)
541
542         for tag in meta_tags:
543             if not tag.for_books:
544                 tag.for_books = True
545                 tag.save()
546
547         book.tags = set(meta_tags + book_shelves)
548         book.save()  # update sort_key_author
549
550         cover_changed = old_cover != book.cover_info()
551         obsolete_children = set(b for b in book.children.all()
552                                 if b not in children)
553         notify_cover_changed = []
554         for n, child_book in enumerate(children):
555             new_child = child_book.parent != book
556             child_book.parent = book
557             child_book.parent_number = n
558             child_book.save()
559             if new_child or cover_changed:
560                 notify_cover_changed.append(child_book)
561         # Disown unfaithful children and let them cope on their own.
562         for child in obsolete_children:
563             child.parent = None
564             child.parent_number = 0
565             child.save()
566             if old_cover:
567                 notify_cover_changed.append(child)
568
569         cls.repopulate_ancestors()
570         tasks.update_counters.delay()
571
572         if remote_gallery_url:
573             book.download_pictures(remote_gallery_url)
574
575         # No saves beyond this point.
576
577         # Build cover.
578         if 'cover' not in dont_build:
579             book.cover.build_delay()
580             book.cover_thumb.build_delay()
581             book.cover_api_thumb.build_delay()
582             book.simple_cover.build_delay()
583             book.cover_ebookpoint.build_delay()
584
585         # Build HTML and ebooks.
586         book.html_file.build_delay()
587         if not children:
588             for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
589                 if format_ not in dont_build:
590                     getattr(book, '%s_file' % format_).build_delay()
591         for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
592             if format_ not in dont_build:
593                 getattr(book, '%s_file' % format_).build_delay()
594
595         if not settings.NO_SEARCH_INDEX and search_index and findable:
596             tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
597
598         for child in notify_cover_changed:
599             child.parent_cover_changed()
600
601         book.update_popularity()
602         cls.published.send(sender=cls, instance=book)
603         return book
604
605     @classmethod
606     @transaction.atomic
607     def repopulate_ancestors(cls):
608         """Fixes the ancestry cache."""
609         # TODO: table names
610         cursor = connection.cursor()
611         if connection.vendor == 'postgres':
612             cursor.execute("TRUNCATE catalogue_book_ancestor")
613             cursor.execute("""
614                 WITH RECURSIVE ancestry AS (
615                     SELECT book.id, book.parent_id
616                     FROM catalogue_book AS book
617                     WHERE book.parent_id IS NOT NULL
618                     UNION
619                     SELECT ancestor.id, book.parent_id
620                     FROM ancestry AS ancestor, catalogue_book AS book
621                     WHERE ancestor.parent_id = book.id
622                         AND book.parent_id IS NOT NULL
623                     )
624                 INSERT INTO catalogue_book_ancestor
625                     (from_book_id, to_book_id)
626                     SELECT id, parent_id
627                     FROM ancestry
628                     ORDER BY id;
629                 """)
630         else:
631             cursor.execute("DELETE FROM catalogue_book_ancestor")
632             for b in cls.objects.exclude(parent=None):
633                 parent = b.parent
634                 while parent is not None:
635                     b.ancestor.add(parent)
636                     parent = parent.parent
637
638     def clear_cache(self):
639         clear_cached_renders(self.mini_box)
640         clear_cached_renders(self.mini_box_nolink)
641
642     def cover_info(self, inherit=True):
643         """Returns a dictionary to serve as fallback for BookInfo.
644
645         For now, the only thing inherited is the cover image.
646         """
647         need = False
648         info = {}
649         for field in ('cover_url', 'cover_by', 'cover_source'):
650             val = self.get_extra_info_json().get(field)
651             if val:
652                 info[field] = val
653             else:
654                 need = True
655         if inherit and need and self.parent is not None:
656             parent_info = self.parent.cover_info()
657             parent_info.update(info)
658             info = parent_info
659         return info
660
661     def related_themes(self):
662         return Tag.objects.usage_for_queryset(
663             Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
664             counts=True).filter(category='theme')
665
666     def parent_cover_changed(self):
667         """Called when parent book's cover image is changed."""
668         if not self.cover_info(inherit=False):
669             if 'cover' not in app_settings.DONT_BUILD:
670                 self.cover.build_delay()
671                 self.cover_thumb.build_delay()
672                 self.cover_api_thumb.build_delay()
673                 self.simple_cover.build_delay()
674             for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
675                 if format_ not in app_settings.DONT_BUILD:
676                     getattr(self, '%s_file' % format_).build_delay()
677             for child in self.children.all():
678                 child.parent_cover_changed()
679
680     def other_versions(self):
681         """Find other versions (i.e. in other languages) of the book."""
682         return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
683
684     def parents(self):
685         books = []
686         parent = self.parent
687         while parent is not None:
688             books.insert(0, parent)
689             parent = parent.parent
690         return books
691
692     def pretty_title(self, html_links=False):
693         names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
694         books = self.parents() + [self]
695         names.extend([(b.title, b.get_absolute_url()) for b in books])
696
697         if html_links:
698             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
699         else:
700             names = [tag[0] for tag in names]
701         return ', '.join(names)
702
703     def publisher(self):
704         publisher = self.get_extra_info_json()['publisher']
705         if isinstance(publisher, str):
706             return publisher
707         elif isinstance(publisher, list):
708             return ', '.join(publisher)
709
710     @classmethod
711     def tagged_top_level(cls, tags):
712         """ Returns top-level books tagged with `tags`.
713
714         It only returns those books which don't have ancestors which are
715         also tagged with those tags.
716
717         """
718         objects = cls.tagged.with_all(tags)
719         return objects.filter(findable=True).exclude(ancestor__in=objects)
720
721     @classmethod
722     def book_list(cls, book_filter=None):
723         """Generates a hierarchical listing of all books.
724
725         Books are optionally filtered with a test function.
726
727         """
728
729         books_by_parent = {}
730         books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
731         if book_filter:
732             books = books.filter(book_filter).distinct()
733
734             book_ids = set(b['pk'] for b in books.values("pk").iterator())
735             for book in books.iterator():
736                 parent = book.parent_id
737                 if parent not in book_ids:
738                     parent = None
739                 books_by_parent.setdefault(parent, []).append(book)
740         else:
741             for book in books.iterator():
742                 books_by_parent.setdefault(book.parent_id, []).append(book)
743
744         orphans = []
745         books_by_author = OrderedDict()
746         for tag in Tag.objects.filter(category='author').iterator():
747             books_by_author[tag] = []
748
749         for book in books_by_parent.get(None, ()):
750             authors = list(book.authors().only('pk'))
751             if authors:
752                 for author in authors:
753                     books_by_author[author].append(book)
754             else:
755                 orphans.append(book)
756
757         return books_by_author, orphans, books_by_parent
758
759     _audiences_pl = {
760         "SP": (1, "szkoła podstawowa"),
761         "SP1": (1, "szkoła podstawowa"),
762         "SP2": (1, "szkoła podstawowa"),
763         "SP3": (1, "szkoła podstawowa"),
764         "P": (1, "szkoła podstawowa"),
765         "G": (2, "gimnazjum"),
766         "L": (3, "liceum"),
767         "LP": (3, "liceum"),
768     }
769
770     def audiences_pl(self):
771         audiences = self.get_extra_info_json().get('audiences', [])
772         audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
773         return [a[1] for a in audiences]
774
775     def stage_note(self):
776         stage = self.get_extra_info_json().get('stage')
777         if stage and stage < '0.4':
778             return (_('This work needs modernisation'),
779                     reverse('infopage', args=['wymagajace-uwspolczesnienia']))
780         else:
781             return None, None
782
783     def choose_fragment(self):
784         fragments = self.fragments.order_by()
785         fragments_count = fragments.count()
786         if not fragments_count and self.children.exists():
787             fragments = Fragment.objects.filter(book__ancestor=self).order_by()
788             fragments_count = fragments.count()
789         if fragments_count:
790             return fragments[randint(0, fragments_count - 1)]
791         elif self.parent:
792             return self.parent.choose_fragment()
793         else:
794             return None
795
796     def fragment_data(self):
797         fragment = self.choose_fragment()
798         if fragment:
799             return {
800                 'title': fragment.book.pretty_title(),
801                 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
802             }
803         else:
804             return None
805
806     def update_popularity(self):
807         count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
808         try:
809             pop = self.popularity
810             pop.count = count
811             pop.save()
812         except BookPopularity.DoesNotExist:
813             BookPopularity.objects.create(book=self, count=count)
814
815     def ridero_link(self):
816         return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
817
818     def like(self, user):
819         from social.utils import likes, get_set, set_sets
820         if not likes(user, self):
821             tag = get_set(user, '')
822             set_sets(user, self, [tag])
823
824     def unlike(self, user):
825         from social.utils import likes, set_sets
826         if likes(user, self):
827             set_sets(user, self, [])
828
829     def full_sort_key(self):
830         return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
831
832     def cover_color(self):
833         return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
834
835     @cached_render('catalogue/book_mini_box.html')
836     def mini_box(self):
837         return {
838             'book': self
839         }
840
841     @cached_render('catalogue/book_mini_box.html')
842     def mini_box_nolink(self):
843         return {
844             'book': self,
845             'no_link': True,
846         }
847
848 def add_file_fields():
849     for format_ in Book.formats:
850         field_name = "%s_file" % format_
851         # This weird globals() assignment makes Django migrations comfortable.
852         _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
853         _upload_to.__name__ = '_%s_upload_to' % format_
854         globals()[_upload_to.__name__] = _upload_to
855
856         EbookField(
857             format_, _("%s file" % format_.upper()),
858             upload_to=_upload_to,
859             storage=bofh_storage,
860             max_length=255,
861             blank=True,
862             default=''
863         ).contribute_to_class(Book, field_name)
864         if format_ != 'xml':
865             models.CharField(max_length=255, editable=False, default='', db_index=True).contribute_to_class(Book, f'{field_name}_etag')
866
867
868 add_file_fields()
869
870
871 class BookPopularity(models.Model):
872     book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
873     count = models.IntegerField(default=0, db_index=True)