1 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Wolne Lektury. 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 gettext_lazy as _, get_language
19 from fnpdjango.storage import BofhFileSystemStorage
21 from librarian.cover import WLCover
22 from librarian.html import transform_abstrakt
23 from librarian.builders import builders
24 from newtagging import managers
25 from catalogue import constants
26 from catalogue import fields
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 wolnelektury.utils import makedirs, cached_render, clear_cached_renders
33 bofh_storage = BofhFileSystemStorage()
36 class Book(models.Model):
37 """Represents a book imported from WL-XML."""
38 title = models.CharField('tytuł', max_length=32767)
39 sort_key = models.CharField('klucz sortowania', max_length=120, db_index=True, editable=False)
40 sort_key_author = models.CharField(
41 'klucz sortowania wg autora', max_length=120, db_index=True, editable=False, default='')
42 slug = models.SlugField('slug', max_length=120, db_index=True, unique=True)
43 common_slug = models.SlugField('wspólny slug', max_length=120, db_index=True)
44 language = models.CharField('kod języka', max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
45 description = models.TextField('opis', blank=True)
46 license = models.CharField('licencja', max_length=255, blank=True, db_index=True)
47 abstract = models.TextField('abstrakt', blank=True)
48 toc = models.TextField('spis treści', blank=True)
49 created_at = models.DateTimeField('data utworzenia', auto_now_add=True, db_index=True)
50 changed_at = models.DateTimeField('data motyfikacji', auto_now=True, db_index=True)
51 parent_number = models.IntegerField('numer w ramach rodzica', default=0)
52 extra_info = models.TextField('dodatkowe informacje', default='{}')
53 gazeta_link = models.CharField(blank=True, max_length=240)
54 wiki_link = models.CharField(blank=True, max_length=240)
55 print_on_demand = models.BooleanField('druk na żądanie', default=False)
56 recommended = models.BooleanField('polecane', default=False)
57 audio_length = models.CharField('długość audio', blank=True, max_length=8)
58 preview = models.BooleanField('prapremiera', default=False)
59 preview_until = models.DateField('prapremiera do', blank=True, null=True)
60 preview_key = models.CharField(max_length=32, blank=True, null=True)
61 findable = models.BooleanField('wyszukiwalna', default=True, db_index=True)
63 # files generated during publication
64 xml_file = fields.XmlField(storage=bofh_storage, with_etag=False)
65 html_file = fields.HtmlField(storage=bofh_storage)
66 html_nonotes_file = fields.HtmlNonotesField(storage=bofh_storage)
67 fb2_file = fields.Fb2Field(storage=bofh_storage)
68 txt_file = fields.TxtField(storage=bofh_storage)
69 epub_file = fields.EpubField(storage=bofh_storage)
70 mobi_file = fields.MobiField(storage=bofh_storage)
71 pdf_file = fields.PdfField(storage=bofh_storage)
73 cover = fields.CoverField('okładka', storage=bofh_storage)
74 # Cleaner version of cover for thumbs
75 cover_clean = fields.CoverCleanField('czysta okładka')
76 cover_thumb = fields.CoverThumbField('miniatura okładki')
77 cover_api_thumb = fields.CoverApiThumbField(
78 'mniaturka okładki dla aplikacji')
79 simple_cover = fields.SimpleCoverField('okładka dla aplikacji')
80 cover_ebookpoint = fields.CoverEbookpointField(
81 'okładka dla Ebookpoint')
83 ebook_formats = constants.EBOOK_FORMATS
84 formats = ebook_formats + ['html', 'xml', 'html_nonotes']
86 parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
87 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
89 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
90 has_audience = models.BooleanField(default=False)
92 objects = models.Manager()
93 tagged = managers.ModelTaggedItemManager(Tag)
94 tags = managers.TagDescriptor(Tag)
95 tag_relations = GenericRelation(Tag.intermediary_table_model)
96 translators = models.ManyToManyField(Tag, blank=True)
98 html_built = django.dispatch.Signal()
99 published = django.dispatch.Signal()
105 class AlreadyExists(Exception):
109 ordering = ('sort_key_author', 'sort_key')
110 verbose_name = 'książka'
111 verbose_name_plural = 'książki'
112 app_label = 'catalogue'
117 def get_extra_info_json(self):
118 return json.loads(self.extra_info or '{}')
120 def get_initial(self):
122 return re.search(r'\w', self.title, re.U).group(0)
123 except AttributeError:
127 return self.tags.filter(category='author')
130 return self.tags.filter(category='epoch')
133 return self.tags.filter(category='genre')
136 return self.tags.filter(category='kind')
138 def tag_unicode(self, category):
139 relations = prefetched_relations(self, category)
141 return ', '.join(rel.tag.name for rel in relations)
143 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
145 def tags_by_category(self):
146 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
148 def author_unicode(self):
149 return self.cached_author
151 def kind_unicode(self):
152 return self.tag_unicode('kind')
154 def epoch_unicode(self):
155 return self.tag_unicode('epoch')
157 def genre_unicode(self):
158 return self.tag_unicode('genre')
160 def translator(self):
161 translators = self.get_extra_info_json().get('translators')
164 if len(translators) > 3:
165 translators = translators[:2]
169 return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
171 def cover_source(self):
172 return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
176 return self.get_extra_info_json().get('isbn_pdf')
180 return self.get_extra_info_json().get('isbn_epub')
184 return self.get_extra_info_json().get('isbn_mobi')
186 def is_accessible_to(self, user):
189 if not user.is_authenticated:
191 Membership = apps.get_model('club', 'Membership')
192 if Membership.is_active_for(user):
194 Funding = apps.get_model('funding', 'Funding')
195 if Funding.objects.filter(user=user, offer__book=self):
199 def save(self, force_insert=False, force_update=False, **kwargs):
200 from sortify import sortify
202 self.sort_key = sortify(self.title)[:120]
203 self.title = str(self.title) # ???
206 author = self.authors().first().sort_key
207 except AttributeError:
209 self.sort_key_author = author
211 self.cached_author = self.tag_unicode('author')
212 self.has_audience = 'audience' in self.get_extra_info_json()
214 if self.preview and not self.preview_key:
215 self.preview_key = get_random_hash(self.slug)[:32]
217 ret = super(Book, self).save(force_insert, force_update, **kwargs)
221 def get_absolute_url(self):
222 return reverse('book_detail', args=[self.slug])
224 def gallery_path(self):
225 return gallery_path(self.slug)
227 def gallery_url(self):
228 return gallery_url(self.slug)
230 def get_first_text(self):
233 child = self.children.all().order_by('parent_number').first()
234 if child is not None:
235 return child.get_first_text()
237 def get_last_text(self):
240 child = self.children.all().order_by('parent_number').last()
241 if child is not None:
242 return child.get_last_text()
244 def get_prev_text(self):
247 sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
248 if sibling is not None:
249 return sibling.get_last_text()
251 if self.parent.html_file:
254 return self.parent.get_prev_text()
256 def get_next_text(self, inside=True):
258 child = self.children.order_by('parent_number').first()
259 if child is not None:
260 return child.get_first_text()
264 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
265 if sibling is not None:
266 return sibling.get_first_text()
267 return self.parent.get_next_text(inside=False)
269 def get_child_audiobook(self):
270 BookMedia = apps.get_model('catalogue', 'BookMedia')
271 if not BookMedia.objects.filter(book__ancestor=self).exists():
273 for child in self.children.order_by('parent_number').all():
274 if child.has_mp3_file():
276 child_sub = child.get_child_audiobook()
277 if child_sub is not None:
280 def get_siblings(self):
283 return self.parent.children.all().order_by('parent_number')
285 def get_children(self):
286 return self.children.all().order_by('parent_number')
292 def language_code(self):
293 return constants.LANGUAGES_3TO2.get(self.language, self.language)
295 def language_name(self):
296 return dict(settings.LANGUAGES).get(self.language_code(), "")
298 def is_foreign(self):
299 return self.language_code() != settings.LANGUAGE_CODE
301 def set_audio_length(self):
302 length = self.get_audio_length()
304 self.audio_length = self.format_audio_length(length)
308 def format_audio_length(seconds):
310 >>> Book.format_audio_length(1)
312 >>> Book.format_audio_length(3661)
316 minutes = seconds // 60
317 seconds = seconds % 60
318 return '%d:%02d' % (minutes, seconds)
320 hours = seconds // 3600
321 minutes = seconds % 3600 // 60
322 seconds = seconds % 60
323 return '%d:%02d:%02d' % (hours, minutes, seconds)
325 def get_audio_length(self):
327 for media in self.get_mp3() or ():
328 total += app_settings.GET_MP3_LENGTH(media.file.path)
332 return round(self.xml_file.size / 1000 * 40)
334 def has_media(self, type_):
335 if type_ in Book.formats:
336 return bool(getattr(self, "%s_file" % type_))
338 return self.media.filter(type=type_).exists()
341 return self.has_media('mp3')
343 def get_media(self, type_):
344 if self.has_media(type_):
345 if type_ in Book.formats:
346 return getattr(self, "%s_file" % type_)
348 return self.media.filter(type=type_)
353 return self.get_media("mp3")
356 return self.get_media("odt")
359 return self.get_media("ogg")
362 return self.get_media("daisy")
364 def get_audio_epub(self):
365 return self.get_media("audio.epub")
367 def media_url(self, format_):
368 media = self.get_media(format_)
371 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
378 return self.media_url('html')
380 def html_nonotes_url(self):
381 return self.media_url('html_nonotes')
384 return self.media_url('pdf')
387 return self.media_url('epub')
390 return self.media_url('mobi')
393 return self.media_url('txt')
396 return self.media_url('fb2')
399 return self.media_url('xml')
401 def has_description(self):
402 return len(self.description) > 0
403 has_description.short_description = 'opis'
404 has_description.boolean = True
406 def has_mp3_file(self):
407 return self.has_media("mp3")
408 has_mp3_file.short_description = 'MP3'
409 has_mp3_file.boolean = True
411 def has_ogg_file(self):
412 return self.has_media("ogg")
413 has_ogg_file.short_description = 'OGG'
414 has_ogg_file.boolean = True
416 def has_daisy_file(self):
417 return self.has_media("daisy")
418 has_daisy_file.short_description = 'DAISY'
419 has_daisy_file.boolean = True
421 def has_sync_file(self):
422 return settings.FEATURE_SYNCHRO and self.has_media("sync")
424 def build_sync_file(self):
425 from lxml import html
426 from django.core.files.base import ContentFile
427 with self.html_file.open('rb') as f:
428 h = html.fragment_fromstring(f.read().decode('utf-8'))
432 for m in self.get_audiobooks()[0]
434 if settings.MOCK_DURATIONS:
435 durations = settings.MOCK_DURATIONS
441 for elem in h.iter():
442 if elem.get('data-audio-ts'):
443 part, ts = int(elem.get('data-audio-part')), float(elem.get('data-audio-ts'))
444 ts = str(round(sum(durations[:part - 1]) + ts, 3))
445 # check if inside verse
448 # Workaround for missing ids.
449 if 'verse' in p.get('class', ''):
451 p.set('id', f'syn{sid}')
454 sync.append((ts, p.get('id')))
459 cls = elem.get('class', '')
460 # Workaround for missing ids.
461 if 'paragraph' in cls or 'verse' in cls or elem.tag in ('h1', 'h2', 'h3', 'h4'):
462 if not elem.get('id'):
463 elem.set('id', f'syn{sid}')
466 sync.append((ts, elem.get('id')))
469 htext = html.tostring(h, encoding='utf-8')
470 with open(self.html_file.path, 'wb') as f:
473 bm = self.media.get(type='sync')
475 bm = BookMedia(book=self, type='sync')
478 f'{s[0]}\t{sync[i+1][0]}\t{s[1]}' for i, s in enumerate(sync[:-1])
481 None, ContentFile(sync)
486 with self.get_media('sync').first().file.open('r') as f:
487 sync = f.read().split('\n')
488 offset = float(sync[0])
490 for line in sync[1:]:
493 start, end, elid = line.split()
494 items.append([elid, float(start) + offset])
495 return json.dumps(items)
497 def has_audio_epub_file(self):
498 return self.has_media("audio.epub")
501 def media_daisy(self):
502 return self.get_media('daisy')
505 def media_audio_epub(self):
506 return self.get_media('audio.epub')
508 def get_audiobooks(self):
510 for m in self.media.filter(type='ogg').order_by().iterator():
511 ogg_files[m.name] = m
516 for mp3 in self.media.filter(type='mp3').iterator():
517 # ogg files are always from the same project
518 meta = mp3.get_extra_info_json()
519 project = meta.get('project')
522 project = 'CzytamySłuchając'
524 projects.add((project, meta.get('funded_by', '')))
525 total_duration += mp3.duration or 0
529 ogg = ogg_files.get(mp3.name)
532 audiobooks.append(media)
534 projects = sorted(projects)
535 total_duration = '%d:%02d' % (
536 total_duration // 60,
539 return audiobooks, projects, total_duration
541 def wldocument(self, parse_dublincore=True, inherit=True):
542 from catalogue.import_utils import ORMDocProvider
543 from librarian.parser import WLDocument
545 if inherit and self.parent:
546 meta_fallbacks = self.parent.cover_info()
548 meta_fallbacks = None
550 return WLDocument.from_file(
552 provider=ORMDocProvider(self),
553 parse_dublincore=parse_dublincore,
554 meta_fallbacks=meta_fallbacks)
556 def wldocument2(self):
557 from catalogue.import_utils import ORMDocProvider
558 from librarian.document import WLDocument
561 provider=ORMDocProvider(self)
563 doc.meta.update(self.cover_info())
568 def zip_format(format_):
569 def pretty_file_name(book):
570 return "%s/%s.%s" % (
571 book.get_extra_info_json()['author'],
575 field_name = "%s_file" % format_
576 field = getattr(Book, field_name)
577 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
578 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
579 return create_zip(paths, field.ZIP)
581 def zip_audiobooks(self, format_):
582 bm = BookMedia.objects.filter(book=self, type=format_)
583 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
586 license = constants.LICENSES.get(
587 m.get_extra_info_json().get('license'), {}
590 licenses.add(license)
591 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
592 'licenses': licenses,
593 'meta': self.wldocument2().meta,
595 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
597 def search_index(self, index=None):
598 if not self.findable:
600 from search.index import Index
601 Index.index_book(self)
603 # will make problems in conjunction with paid previews
604 def download_pictures(self, remote_gallery_url):
605 # This is only needed for legacy relative image paths.
606 gallery_path = self.gallery_path()
607 # delete previous files, so we don't include old files in ebooks
608 if os.path.isdir(gallery_path):
609 for filename in os.listdir(gallery_path):
610 file_path = os.path.join(gallery_path, filename)
612 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
614 makedirs(gallery_path)
615 for ilustr in ilustr_elements:
616 ilustr_src = ilustr.get('src')
617 if '/' in ilustr_src:
619 ilustr_path = os.path.join(gallery_path, ilustr_src)
620 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
622 def load_abstract(self):
623 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
624 if abstract is not None:
625 self.abstract = transform_abstrakt(abstract)
632 parser = html.HTMLParser(encoding='utf-8')
633 tree = html.parse(self.html_file.path, parser=parser)
634 toc = tree.find('//div[@id="toc"]/ol')
635 if toc is None or not len(toc):
637 html_link = reverse('book_text', args=[self.slug])
638 for a in toc.findall('.//a'):
639 a.attrib['href'] = html_link + a.attrib['href']
640 self.toc = html.tostring(toc, encoding='unicode')
644 def from_xml_file(cls, xml_file, **kwargs):
645 from django.core.files import File
646 from librarian import dcparser
648 # use librarian to parse meta-data
649 book_info = dcparser.parse(xml_file)
651 if not isinstance(xml_file, File):
652 xml_file = File(open(xml_file))
655 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
660 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
661 remote_gallery_url=None, days=0, findable=True, logo=None, logo_mono=None, logo_alt=None):
662 from catalogue import tasks
664 if dont_build is None:
666 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
668 # check for parts before we do anything
670 if hasattr(book_info, 'parts'):
671 for part_url in book_info.parts:
673 children.append(Book.objects.get(slug=part_url.slug))
674 except Book.DoesNotExist:
675 raise Book.DoesNotExist('Książka "%s" nie istnieje.' % part_url.slug)
678 book_slug = book_info.url.slug
679 if re.search(r'[^a-z0-9-]', book_slug):
680 raise ValueError('Invalid characters in slug')
681 book, created = Book.objects.get_or_create(slug=book_slug)
686 book.preview = bool(days)
688 book.preview_until = date.today() + timedelta(days)
691 raise Book.AlreadyExists('Książka %s już istnieje' % book_slug)
692 # Save shelves for this book
693 book_shelves = list(book.tags.filter(category='set'))
694 old_cover = book.cover_info()
697 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
699 book.xml_file.set_readable(False)
701 book.findable = findable
702 book.language = book_info.language
703 book.title = book_info.title
704 book.license = book_info.license or ''
705 if book_info.variant_of:
706 book.common_slug = book_info.variant_of.slug
708 book.common_slug = book.slug
709 extra = book_info.to_dict()
713 extra['logo_mono'] = logo_mono
715 extra['logo_alt'] = logo_alt
716 book.extra_info = json.dumps(extra)
721 meta_tags = Tag.tags_from_info(book_info)
723 just_tags = [t for (t, rel) in meta_tags if not rel]
724 book.tags = set(just_tags + book_shelves)
725 book.save() # update sort_key_author
727 book.translators.set([t for (t, rel) in meta_tags if rel == 'translator'])
729 cover_changed = old_cover != book.cover_info()
730 obsolete_children = set(b for b in book.children.all()
731 if b not in children)
732 notify_cover_changed = []
733 for n, child_book in enumerate(children):
734 new_child = child_book.parent != book
735 child_book.parent = book
736 child_book.parent_number = n
738 if new_child or cover_changed:
739 notify_cover_changed.append(child_book)
740 # Disown unfaithful children and let them cope on their own.
741 for child in obsolete_children:
743 child.parent_number = 0
746 notify_cover_changed.append(child)
748 cls.repopulate_ancestors()
749 tasks.update_counters.delay()
751 if remote_gallery_url:
752 book.download_pictures(remote_gallery_url)
754 # No saves beyond this point.
757 if 'cover' not in dont_build:
758 book.cover.build_delay()
759 book.cover_clean.build_delay()
760 book.cover_thumb.build_delay()
761 book.cover_api_thumb.build_delay()
762 book.simple_cover.build_delay()
763 book.cover_ebookpoint.build_delay()
765 # Build HTML and ebooks.
766 book.html_file.build_delay()
768 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
769 if format_ not in dont_build:
770 getattr(book, '%s_file' % format_).build_delay()
771 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
772 if format_ not in dont_build:
773 getattr(book, '%s_file' % format_).build_delay()
774 book.html_nonotes_file.build_delay()
776 if not settings.NO_SEARCH_INDEX and search_index and findable:
777 tasks.index_book.delay(book.id)
779 for child in notify_cover_changed:
780 child.parent_cover_changed()
782 book.update_popularity()
783 tasks.update_references.delay(book.id)
785 cls.published.send(sender=cls, instance=book)
788 def update_references(self):
789 Entity = apps.get_model('references', 'Entity')
790 doc = self.wldocument2()
791 doc._compat_assign_section_ids()
792 doc._compat_assign_ordered_ids()
794 for ref_elem in doc.references():
795 uri = ref_elem.attrib.get('href', '')
801 entity, entity_created = Entity.objects.get_or_create(uri=uri)
809 ref, ref_created = entity.reference_set.get_or_create(book=self)
812 ref.occurence_set.all().delete()
813 sec = ref_elem.get_link()
814 m = re.match(r'sec(\d+)', sec)
816 sec = int(m.group(1))
817 snippet = ref_elem.get_snippet()
818 b = builders['html-snippet']()
821 html = b.output().get_bytes().decode('utf-8')
823 ref.occurence_set.create(
827 self.reference_set.exclude(entity__uri__in=refs).delete()
830 def references(self):
831 return self.reference_set.all().select_related('entity')
835 def repopulate_ancestors(cls):
836 """Fixes the ancestry cache."""
838 cursor = connection.cursor()
839 if connection.vendor == 'postgres':
840 cursor.execute("TRUNCATE catalogue_book_ancestor")
842 WITH RECURSIVE ancestry AS (
843 SELECT book.id, book.parent_id
844 FROM catalogue_book AS book
845 WHERE book.parent_id IS NOT NULL
847 SELECT ancestor.id, book.parent_id
848 FROM ancestry AS ancestor, catalogue_book AS book
849 WHERE ancestor.parent_id = book.id
850 AND book.parent_id IS NOT NULL
852 INSERT INTO catalogue_book_ancestor
853 (from_book_id, to_book_id)
859 cursor.execute("DELETE FROM catalogue_book_ancestor")
860 for b in cls.objects.exclude(parent=None):
862 while parent is not None:
863 b.ancestor.add(parent)
864 parent = parent.parent
869 for anc in self.parent.ancestors:
875 def clear_cache(self):
876 clear_cached_renders(self.mini_box)
877 clear_cached_renders(self.mini_box_nolink)
879 def cover_info(self, inherit=True):
880 """Returns a dictionary to serve as fallback for BookInfo.
882 For now, the only thing inherited is the cover image.
886 for field in ('cover_url', 'cover_by', 'cover_source'):
887 val = self.get_extra_info_json().get(field)
892 if inherit and need and self.parent is not None:
893 parent_info = self.parent.cover_info()
894 parent_info.update(info)
898 def related_themes(self):
899 return Tag.objects.usage_for_queryset(
900 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
901 counts=True).filter(category='theme').order_by('-count')
903 def parent_cover_changed(self):
904 """Called when parent book's cover image is changed."""
905 if not self.cover_info(inherit=False):
906 if 'cover' not in app_settings.DONT_BUILD:
907 self.cover.build_delay()
908 self.cover_clean.build_delay()
909 self.cover_thumb.build_delay()
910 self.cover_api_thumb.build_delay()
911 self.simple_cover.build_delay()
912 self.cover_ebookpoint.build_delay()
913 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
914 if format_ not in app_settings.DONT_BUILD:
915 getattr(self, '%s_file' % format_).build_delay()
916 for child in self.children.all():
917 child.parent_cover_changed()
919 def other_versions(self):
920 """Find other versions (i.e. in other languages) of the book."""
921 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
926 while parent is not None:
927 books.insert(0, parent)
928 parent = parent.parent
931 def pretty_title(self, html_links=False):
932 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
933 books = self.parents() + [self]
934 names.extend([(b.title, b.get_absolute_url()) for b in books])
937 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
939 names = [tag[0] for tag in names]
940 return ', '.join(names)
943 publisher = self.get_extra_info_json()['publisher']
944 if isinstance(publisher, str):
946 elif isinstance(publisher, list):
947 return ', '.join(publisher)
950 def tagged_top_level(cls, tags):
951 """ Returns top-level books tagged with `tags`.
953 It only returns those books which don't have ancestors which are
954 also tagged with those tags.
957 objects = cls.tagged.with_all(tags)
958 return objects.filter(findable=True).exclude(ancestor__in=objects)
961 def book_list(cls, book_filter=None):
962 """Generates a hierarchical listing of all books.
964 Books are optionally filtered with a test function.
969 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
971 books = books.filter(book_filter).distinct()
973 book_ids = set(b['pk'] for b in books.values("pk").iterator())
974 for book in books.iterator():
975 parent = book.parent_id
976 if parent not in book_ids:
978 books_by_parent.setdefault(parent, []).append(book)
980 for book in books.iterator():
981 books_by_parent.setdefault(book.parent_id, []).append(book)
984 books_by_author = OrderedDict()
985 for tag in Tag.objects.filter(category='author').iterator():
986 books_by_author[tag] = []
988 for book in books_by_parent.get(None, ()):
989 authors = list(book.authors().only('pk'))
991 for author in authors:
992 books_by_author[author].append(book)
996 return books_by_author, orphans, books_by_parent
999 "SP": (1, "szkoła podstawowa"),
1000 "SP1": (1, "szkoła podstawowa"),
1001 "SP2": (1, "szkoła podstawowa"),
1002 "SP3": (1, "szkoła podstawowa"),
1003 "P": (1, "szkoła podstawowa"),
1004 "G": (2, "gimnazjum"),
1006 "LP": (3, "liceum"),
1009 def audiences_pl(self):
1010 audiences = self.get_extra_info_json().get('audiences', [])
1011 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
1012 return [a[1] for a in audiences]
1014 def stage_note(self):
1015 stage = self.get_extra_info_json().get('stage')
1016 if stage and stage < '0.4':
1017 return (_('Ten utwór wymaga uwspółcześnienia'),
1018 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
1022 def choose_fragments(self, number):
1023 fragments = self.fragments.order_by()
1024 fragments_count = fragments.count()
1025 if not fragments_count and self.children.exists():
1026 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
1027 fragments_count = fragments.count()
1029 if fragments_count > number:
1030 offset = randint(0, fragments_count - number)
1033 return fragments[offset : offset + number]
1035 return self.parent.choose_fragments(number)
1039 def choose_fragment(self):
1040 fragments = self.choose_fragments(1)
1046 def fragment_data(self):
1047 fragment = self.choose_fragment()
1050 'title': fragment.book.pretty_title(),
1051 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
1056 def update_popularity(self):
1057 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
1059 pop = self.popularity
1062 except BookPopularity.DoesNotExist:
1063 BookPopularity.objects.create(book=self, count=count)
1065 def ridero_link(self):
1066 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
1068 def like(self, user):
1069 from social.utils import likes, get_set, set_sets
1070 if not likes(user, self):
1071 tag = get_set(user, '')
1072 set_sets(user, self, [tag])
1074 def unlike(self, user):
1075 from social.utils import likes, set_sets
1076 if likes(user, self):
1077 set_sets(user, self, [])
1079 def full_sort_key(self):
1080 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1082 def cover_color(self):
1083 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1085 @cached_render('catalogue/book_mini_box.html')
1091 @cached_render('catalogue/book_mini_box.html')
1092 def mini_box_nolink(self):
1099 class BookPopularity(models.Model):
1100 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1101 count = models.IntegerField(default=0, db_index=True)