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
11 from slugify import slugify
12 from sortify import sortify
13 from urllib.request import urlretrieve
14 from django.apps import apps
15 from django.conf import settings
16 from django.db import connection, models, transaction
17 import django.dispatch
18 from django.contrib.contenttypes.fields import GenericRelation
19 from django.template.loader import render_to_string
20 from django.urls import reverse
21 from django.utils.translation import gettext_lazy as _, get_language
22 from fnpdjango.storage import BofhFileSystemStorage
24 from librarian.cover import WLCover
25 from librarian.html import transform_abstrakt
26 from librarian.builders import builders
27 from newtagging import managers
28 from catalogue import constants
29 from catalogue import fields
30 from catalogue.models import Tag, Fragment, BookMedia
31 from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags, get_random_hash
32 from catalogue.models.tag import prefetched_relations
33 from catalogue import app_settings
34 from wolnelektury.utils import makedirs, cached_render, clear_cached_renders
36 bofh_storage = BofhFileSystemStorage()
39 class Book(models.Model):
40 """Represents a book imported from WL-XML."""
41 title = models.CharField('tytuł', max_length=32767)
42 sort_key = models.CharField('klucz sortowania', max_length=120, db_index=True, db_collation='C', editable=False)
43 sort_key_author = models.CharField(
44 'klucz sortowania wg autora', max_length=120, db_index=True, db_collation='C', editable=False, default='')
45 slug = models.SlugField('slug', max_length=120, db_index=True, unique=True)
46 common_slug = models.SlugField('wspólny slug', max_length=120, db_index=True)
47 language = models.CharField('kod języka', max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
48 description = models.TextField('opis', blank=True)
49 license = models.CharField('licencja', max_length=255, blank=True, db_index=True)
50 abstract = models.TextField('abstrakt', blank=True)
51 toc = models.TextField('spis treści', blank=True)
52 created_at = models.DateTimeField('data utworzenia', auto_now_add=True, db_index=True)
53 changed_at = models.DateTimeField('data motyfikacji', auto_now=True, db_index=True)
54 parent_number = models.IntegerField('numer w ramach rodzica', default=0)
55 extra_info = models.TextField('dodatkowe informacje', default='{}')
56 gazeta_link = models.CharField(blank=True, max_length=240)
57 wiki_link = models.CharField(blank=True, max_length=240)
58 print_on_demand = models.BooleanField('druk na żądanie', default=False)
59 recommended = models.BooleanField('polecane', default=False)
60 audio_length = models.CharField('długość audio', blank=True, max_length=8)
61 preview = models.BooleanField('prapremiera', default=False)
62 preview_until = models.DateField('prapremiera do', blank=True, null=True)
63 preview_key = models.CharField(max_length=32, blank=True, null=True)
64 findable = models.BooleanField('wyszukiwalna', default=True, db_index=True)
65 can_sell = models.BooleanField('do sprzedaży', default=True)
66 can_sell_mp3 = models.BooleanField('do sprzedaży mp3', default=False)
67 isbn_mp3 = models.CharField('ISBN audiobooka', max_length=32, blank=True)
69 # files generated during publication
70 xml_file = fields.XmlField(storage=bofh_storage, with_etag=False)
71 html_file = fields.HtmlField(storage=bofh_storage)
72 html_nonotes_file = fields.HtmlNonotesField(storage=bofh_storage)
73 fb2_file = fields.Fb2Field(storage=bofh_storage)
74 txt_file = fields.TxtField(storage=bofh_storage)
75 epub_file = fields.EpubField(storage=bofh_storage)
76 mobi_file = fields.MobiField(storage=bofh_storage)
77 pdf_file = fields.PdfField(storage=bofh_storage)
79 cover = fields.CoverField('okładka', storage=bofh_storage)
80 # Cleaner version of cover for thumbs
81 cover_clean = fields.CoverCleanField('czysta okładka')
82 cover_thumb = fields.CoverThumbField('miniatura okładki')
83 cover_api_thumb = fields.CoverApiThumbField(
84 'mniaturka okładki dla aplikacji')
85 simple_cover = fields.SimpleCoverField('okładka dla aplikacji')
86 cover_ebookpoint = fields.CoverEbookpointField(
87 'okładka dla Ebookpoint')
89 ebook_formats = constants.EBOOK_FORMATS
90 formats = ebook_formats + ['html', 'xml', 'html_nonotes']
92 parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
93 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
95 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
96 has_audience = models.BooleanField(default=False)
98 objects = models.Manager()
99 tagged = managers.ModelTaggedItemManager(Tag)
100 tags = managers.TagDescriptor(Tag)
101 tag_relations = GenericRelation(Tag.intermediary_table_model, related_query_name='tagged_book')
102 translators = models.ManyToManyField(Tag, blank=True)
103 narrators = models.ManyToManyField(Tag, blank=True, related_name='narrated')
104 has_audio = models.BooleanField(default=False)
105 read_time = models.IntegerField(blank=True, null=True)
106 pages = models.IntegerField(blank=True, null=True)
108 html_built = django.dispatch.Signal()
109 published = django.dispatch.Signal()
115 class AlreadyExists(Exception):
119 ordering = ('sort_key_author', 'sort_key')
120 verbose_name = 'książka'
121 verbose_name_plural = 'książki'
122 app_label = 'catalogue'
127 def get_extra_info_json(self):
128 return json.loads(self.extra_info or '{}')
130 def get_initial(self):
132 return re.search(r'\w', self.title, re.U).group(0)
133 except AttributeError:
137 return self.tags.filter(category='author')
140 return self.tags.filter(category='epoch')
143 return self.tags.filter(category='genre')
146 return self.tags.filter(category='kind')
148 def tag_unicode(self, category):
149 relations = prefetched_relations(self, category)
151 return ', '.join(rel.tag.name for rel in relations)
153 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
155 def tags_by_category(self):
156 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
158 def author_unicode(self):
159 return self.cached_author
161 def kind_unicode(self):
162 return self.tag_unicode('kind')
164 def epoch_unicode(self):
165 return self.tag_unicode('epoch')
167 def genre_unicode(self):
168 return self.tag_unicode('genre')
170 def translator(self):
171 translators = self.get_extra_info_json().get('translators')
174 if len(translators) > 3:
175 translators = translators[:2]
179 return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
181 def cover_source(self):
182 return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
186 return self.get_extra_info_json().get('isbn_pdf')
190 return self.get_extra_info_json().get('isbn_epub')
194 return self.get_extra_info_json().get('isbn_mobi')
198 return self.get_extra_info_json().get('about')
200 def is_accessible_to(self, user):
203 if not user.is_authenticated:
205 Membership = apps.get_model('club', 'Membership')
206 if Membership.is_active_for(user):
208 Funding = apps.get_model('funding', 'Funding')
209 if Funding.objects.filter(user=user, offer__book=self):
213 def save(self, force_insert=False, force_update=False, **kwargs):
214 from sortify import sortify
216 self.sort_key = sortify(self.title)[:120]
217 self.title = str(self.title) # ???
220 author = self.authors().first().sort_key
221 except AttributeError:
223 self.sort_key_author = author
225 self.cached_author = self.tag_unicode('author')
226 self.has_audience = 'audience' in self.get_extra_info_json()
228 if self.preview and not self.preview_key:
229 self.preview_key = get_random_hash(self.slug)[:32]
231 ret = super(Book, self).save(force_insert, force_update, **kwargs)
235 def get_absolute_url(self):
236 return reverse('book_detail', args=[self.slug])
238 def gallery_path(self):
239 return gallery_path(self.slug)
241 def gallery_url(self):
242 return gallery_url(self.slug)
244 def get_first_text(self):
247 child = self.children.all().order_by('parent_number').first()
248 if child is not None:
249 return child.get_first_text()
251 def get_last_text(self):
254 child = self.children.all().order_by('parent_number').last()
255 if child is not None:
256 return child.get_last_text()
258 def get_prev_text(self):
261 sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
262 if sibling is not None:
263 return sibling.get_last_text()
265 if self.parent.html_file:
268 return self.parent.get_prev_text()
270 def get_next_text(self, inside=True):
272 child = self.children.order_by('parent_number').first()
273 if child is not None:
274 return child.get_first_text()
278 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
279 if sibling is not None:
280 return sibling.get_first_text()
281 return self.parent.get_next_text(inside=False)
283 def get_siblings(self):
286 return self.parent.children.all().order_by('parent_number')
288 def get_children(self):
289 return self.children.all().order_by('parent_number')
295 def language_code(self):
296 return constants.LANGUAGES_3TO2.get(self.language, self.language)
298 def language_name(self):
299 return dict(settings.LANGUAGES).get(self.language_code(), "")
301 def is_foreign(self):
302 return self.language_code() != settings.LANGUAGE_CODE
304 def set_audio_length(self):
305 length = self.get_audio_length()
307 self.audio_length = self.format_audio_length(length)
311 def format_audio_length(seconds):
313 >>> Book.format_audio_length(1)
315 >>> Book.format_audio_length(3661)
319 minutes = seconds // 60
320 seconds = seconds % 60
321 return '%d:%02d' % (minutes, seconds)
323 hours = seconds // 3600
324 minutes = seconds % 3600 // 60
325 seconds = seconds % 60
326 return '%d:%02d:%02d' % (hours, minutes, seconds)
328 def get_audio_length(self):
330 for media in self.get_mp3() or ():
331 total += app_settings.GET_MP3_LENGTH(media.file.path)
335 return round(self.xml_file.size / 1000 * 40)
337 def has_media(self, type_):
338 if type_ in Book.formats:
339 return bool(getattr(self, "%s_file" % type_))
341 return self.media.filter(type=type_).exists()
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)
485 if not self.has_sync_file():
487 with self.get_media('sync').first().file.open('r') as f:
488 sync = f.read().split('\n')
489 offset = float(sync[0])
491 for line in sync[1:]:
494 start, end, elid = line.split()
495 items.append([elid, float(start) + offset])
498 def sync_ts(self, ts):
500 for cur_id, t in self.get_sync():
507 def sync_elid(self, elid):
508 for cur_id, t in self.get_sync():
512 def has_audio_epub_file(self):
513 return self.has_media("audio.epub")
516 def media_daisy(self):
517 return self.get_media('daisy')
520 def media_audio_epub(self):
521 return self.get_media('audio.epub')
523 def get_audiobooks(self, with_children=False, processing=False):
525 for m in self.media.filter(type='ogg').order_by().iterator():
526 ogg_files[m.name] = m
531 for mp3 in self.media.filter(type='mp3').iterator():
532 # ogg files are always from the same project
533 meta = mp3.get_extra_info_json()
534 project = meta.get('project')
537 project = 'CzytamySłuchając'
539 projects.add((project, meta.get('funded_by', '')))
540 total_duration += mp3.duration or 0
544 ogg = ogg_files.get(mp3.name)
547 audiobooks.append(media)
550 for child in self.get_children():
551 ch_audiobooks, ch_projects, ch_duration = child.get_audiobooks(
552 with_children=True, processing=True)
553 audiobooks.append({'part': child})
554 audiobooks += ch_audiobooks
555 projects.update(ch_projects)
556 total_duration += ch_duration
559 projects = sorted(projects)
560 total_duration = '%d:%02d' % (
561 total_duration // 60,
565 return audiobooks, projects, total_duration
567 def get_audiobooks_with_children(self):
568 return self.get_audiobooks(with_children=True)
570 def wldocument(self, parse_dublincore=True, inherit=True):
571 from catalogue.import_utils import ORMDocProvider
572 from librarian.parser import WLDocument
574 if inherit and self.parent:
575 meta_fallbacks = self.parent.cover_info()
577 meta_fallbacks = None
579 return WLDocument.from_file(
581 provider=ORMDocProvider(self),
582 parse_dublincore=parse_dublincore,
583 meta_fallbacks=meta_fallbacks)
585 def wldocument2(self):
586 from catalogue.import_utils import ORMDocProvider
587 from librarian.document import WLDocument
590 provider=ORMDocProvider(self)
592 doc.meta.update(self.cover_info())
597 def zip_format(format_):
598 def pretty_file_name(book):
599 return "%s/%s.%s" % (
600 book.get_extra_info_json()['author'],
604 field_name = "%s_file" % format_
605 field = getattr(Book, field_name)
606 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
607 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
608 return create_zip(paths, field.ZIP)
610 def zip_audiobooks(self, format_):
611 bm = BookMedia.objects.filter(book=self, type=format_)
612 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
615 license = constants.LICENSES.get(
616 m.get_extra_info_json().get('license'), {}
619 licenses.add(license)
620 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
621 'licenses': licenses,
622 'meta': self.wldocument2().meta,
624 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
626 def search_index(self, index=None):
627 if not self.findable:
629 from search.index import Index
630 Index.index_book(self)
632 # will make problems in conjunction with paid previews
633 def download_pictures(self, remote_gallery_url):
634 # This is only needed for legacy relative image paths.
635 gallery_path = self.gallery_path()
636 # delete previous files, so we don't include old files in ebooks
637 if os.path.isdir(gallery_path):
638 for filename in os.listdir(gallery_path):
639 file_path = os.path.join(gallery_path, filename)
641 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
643 makedirs(gallery_path)
644 for ilustr in ilustr_elements:
645 ilustr_src = ilustr.get('src')
646 if '/' in ilustr_src:
648 ilustr_path = os.path.join(gallery_path, ilustr_src)
649 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
651 def load_abstract(self):
652 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
653 if abstract is not None:
654 self.abstract = transform_abstrakt(abstract)
661 parser = html.HTMLParser(encoding='utf-8')
662 tree = html.parse(self.html_file.path, parser=parser)
663 toc = tree.find('//div[@id="toc"]/ol')
664 if toc is None or not len(toc):
666 html_link = reverse('book_text', args=[self.slug])
667 for a in toc.findall('.//a'):
668 a.attrib['href'] = html_link + a.attrib['href']
669 self.toc = html.tostring(toc, encoding='unicode')
673 def from_xml_file(cls, xml_file, **kwargs):
674 from django.core.files import File
675 from librarian import dcparser
677 # use librarian to parse meta-data
678 book_info = dcparser.parse(xml_file)
680 if not isinstance(xml_file, File):
681 xml_file = File(open(xml_file))
684 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
689 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
690 remote_gallery_url=None, days=0, findable=True, logo=None, logo_mono=None, logo_alt=None, can_sell=None, isbn_mp3=None):
691 from catalogue import tasks
693 if dont_build is None:
695 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
697 # check for parts before we do anything
699 if hasattr(book_info, 'parts'):
700 for part_url in book_info.parts:
702 children.append(Book.objects.get(slug=part_url.slug))
703 except Book.DoesNotExist:
704 raise Book.DoesNotExist('Książka "%s" nie istnieje.' % part_url.slug)
707 book_slug = book_info.url.slug
708 if re.search(r'[^a-z0-9-]', book_slug):
709 raise ValueError('Invalid characters in slug')
710 book, created = Book.objects.get_or_create(slug=book_slug)
715 book.preview = bool(days)
717 book.preview_until = date.today() + timedelta(days)
720 raise Book.AlreadyExists('Książka %s już istnieje' % book_slug)
721 # Save shelves for this book
722 book_shelves = list(book.tags.filter(category='set'))
723 old_cover = book.cover_info()
726 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
728 book.xml_file.set_readable(False)
730 book.findable = findable
731 book.language = book_info.language
732 book.title = book_info.title
733 book.license = book_info.license or ''
734 if book_info.variant_of:
735 book.common_slug = book_info.variant_of.slug
737 book.common_slug = book.slug
738 extra = book_info.to_dict()
742 extra['logo_mono'] = logo_mono
744 extra['logo_alt'] = logo_alt
745 if can_sell is not None:
746 book.can_sell = can_sell
747 if isbn_mp3 is not None:
748 book.isbn_mp3 = isbn_mp3
749 book.extra_info = json.dumps(extra)
756 meta_tags = Tag.tags_from_info(book_info)
758 just_tags = [t for (t, rel) in meta_tags if not rel]
759 book.tags = set(just_tags + book_shelves)
760 book.save() # update sort_key_author
762 book.translators.set([t for (t, rel) in meta_tags if rel == 'translator'])
764 cover_changed = old_cover != book.cover_info()
765 obsolete_children = set(b for b in book.children.all()
766 if b not in children)
767 notify_cover_changed = []
768 for n, child_book in enumerate(children):
769 new_child = child_book.parent != book
770 child_book.parent = book
771 child_book.parent_number = n
773 if new_child or cover_changed:
774 notify_cover_changed.append(child_book)
775 # Disown unfaithful children and let them cope on their own.
776 for child in obsolete_children:
778 child.parent_number = 0
781 notify_cover_changed.append(child)
783 cls.repopulate_ancestors()
784 tasks.update_counters.delay()
786 if remote_gallery_url:
787 book.download_pictures(remote_gallery_url)
789 # No saves beyond this point.
792 if 'cover' not in dont_build:
793 book.cover.build_delay()
794 book.cover_clean.build_delay()
795 book.cover_thumb.build_delay()
796 book.cover_api_thumb.build_delay()
797 book.simple_cover.build_delay()
798 book.cover_ebookpoint.build_delay()
800 # Build HTML and ebooks.
801 book.html_file.build_delay()
803 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
804 if format_ not in dont_build:
805 getattr(book, '%s_file' % format_).build_delay()
806 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
807 if format_ not in dont_build:
808 getattr(book, '%s_file' % format_).build_delay()
809 if 'html_nonotes' not in dont_build:
810 book.html_nonotes_file.build_delay()
812 if not settings.NO_SEARCH_INDEX and search_index and findable:
813 tasks.index_book.delay(book.id)
815 for child in notify_cover_changed:
816 child.parent_cover_changed()
818 book.update_popularity()
819 if 'references' not in dont_build:
820 tasks.update_references.delay(book.id)
822 cls.published.send(sender=cls, instance=book)
825 def update_stats(self):
826 stats = self.wldocument2().get_statistics()['total']
828 stats.get('verses_with_fn', 0) / 30 +
829 stats.get('chars_out_verse_with_fn', 0) / 1800)
830 self.read_time = round(self.get_time())
831 self.save(update_fields=['pages', 'read_time'])
832 if self.parent is not None:
833 self.parent.update_stats()
835 def update_references(self):
836 Entity = apps.get_model('references', 'Entity')
837 doc = self.wldocument2()
838 doc._compat_assign_section_ids()
839 doc._compat_assign_ordered_ids()
841 for ref_elem in doc.references():
842 uri = ref_elem.attrib.get('href', '')
848 entity, entity_created = Entity.objects.get_or_create(uri=uri)
856 ref, ref_created = entity.reference_set.get_or_create(book=self)
859 ref.occurence_set.all().delete()
860 sec = ref_elem.get_link()
861 m = re.match(r'sec(\d+)', sec)
863 sec = int(m.group(1))
864 snippet = ref_elem.get_snippet()
865 b = builders['html-snippet']()
868 html = b.output().get_bytes().decode('utf-8')
870 ref.occurence_set.create(
874 self.reference_set.exclude(entity__uri__in=refs).delete()
877 def references(self):
878 return self.reference_set.all().select_related('entity')
880 def update_has_audio(self):
881 self.has_audio = False
882 if self.media.filter(type='mp3').exists():
883 self.has_audio = True
884 if self.descendant.filter(has_audio=True).exists():
885 self.has_audio = True
886 self.save(update_fields=['has_audio'])
887 if self.parent is not None:
888 self.parent.update_has_audio()
890 def update_narrators(self):
891 narrator_names = set()
892 for bm in self.media.filter(type='mp3'):
893 narrator_names.update(set(
894 a.strip() for a in re.split(r',|\si\s', bm.artist)
898 for name in narrator_names:
899 if not name: continue
902 t = Tag.objects.get(category='author', slug=slug)
903 except Tag.DoesNotExist:
905 ' '.join(name.rsplit(' ', 1)[::-1]).lower()
907 t = Tag.objects.create(
914 self.narrators.set(narrators)
916 def update_can_sell_mp3(self):
918 for child in self.get_children():
919 child.update_can_sell_mp3()
920 if not child.can_sell_mp3:
922 if self.has_mp3_file():
923 audio_items = requests.get(f'https://audio.wolnelektury.pl/archive/book/{self.slug}.json').json()['items']
924 if not all(x['project']['can_sell'] for x in audio_items):
926 self.can_sell_mp3 = ret
927 self.save(update_fields=['can_sell_mp3'])
931 def repopulate_ancestors(cls):
932 """Fixes the ancestry cache."""
934 cursor = connection.cursor()
935 if connection.vendor == 'postgres':
936 cursor.execute("TRUNCATE catalogue_book_ancestor")
938 WITH RECURSIVE ancestry AS (
939 SELECT book.id, book.parent_id
940 FROM catalogue_book AS book
941 WHERE book.parent_id IS NOT NULL
943 SELECT ancestor.id, book.parent_id
944 FROM ancestry AS ancestor, catalogue_book AS book
945 WHERE ancestor.parent_id = book.id
946 AND book.parent_id IS NOT NULL
948 INSERT INTO catalogue_book_ancestor
949 (from_book_id, to_book_id)
955 cursor.execute("DELETE FROM catalogue_book_ancestor")
956 for b in cls.objects.exclude(parent=None):
958 while parent is not None:
959 b.ancestor.add(parent)
960 parent = parent.parent
965 for anc in self.parent.ancestors:
971 def clear_cache(self):
972 clear_cached_renders(self.mini_box)
973 clear_cached_renders(self.mini_box_nolink)
975 def cover_info(self, inherit=True):
976 """Returns a dictionary to serve as fallback for BookInfo.
978 For now, the only thing inherited is the cover image.
982 for field in ('cover_url', 'cover_by', 'cover_source'):
983 val = self.get_extra_info_json().get(field)
988 if inherit and need and self.parent is not None:
989 parent_info = self.parent.cover_info()
990 parent_info.update(info)
994 def related_themes(self):
995 return Tag.objects.usage_for_queryset(
996 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
997 counts=True).filter(category='theme').order_by('-count')
999 def parent_cover_changed(self):
1000 """Called when parent book's cover image is changed."""
1001 if not self.cover_info(inherit=False):
1002 if 'cover' not in app_settings.DONT_BUILD:
1003 self.cover.build_delay()
1004 self.cover_clean.build_delay()
1005 self.cover_thumb.build_delay()
1006 self.cover_api_thumb.build_delay()
1007 self.simple_cover.build_delay()
1008 self.cover_ebookpoint.build_delay()
1009 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
1010 if format_ not in app_settings.DONT_BUILD:
1011 getattr(self, '%s_file' % format_).build_delay()
1012 for child in self.children.all():
1013 child.parent_cover_changed()
1015 def other_versions(self):
1016 """Find other versions (i.e. in other languages) of the book."""
1017 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
1021 parent = self.parent
1022 while parent is not None:
1023 books.insert(0, parent)
1024 parent = parent.parent
1027 def pretty_title(self, html_links=False):
1028 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
1029 books = self.parents() + [self]
1030 names.extend([(b.title, b.get_absolute_url()) for b in books])
1033 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
1035 names = [tag[0] for tag in names]
1036 return ', '.join(names)
1038 def publisher(self):
1039 publisher = self.get_extra_info_json()['publisher']
1040 if isinstance(publisher, str):
1042 elif isinstance(publisher, list):
1043 return ', '.join(publisher)
1045 def get_recommended(self, limit=4):
1046 books_qs = type(self).objects.filter(findable=True)
1047 books_qs = books_qs.exclude(common_slug=self.common_slug).exclude(ancestor=self)
1048 books = type(self).tagged.related_to(self, books_qs)[:limit]
1052 def tagged_top_level(cls, tags):
1053 """ Returns top-level books tagged with `tags`.
1055 It only returns those books which don't have ancestors which are
1056 also tagged with those tags.
1059 objects = cls.tagged.with_all(tags)
1060 return objects.filter(findable=True).exclude(ancestor__in=objects)
1063 def book_list(cls, book_filter=None):
1064 """Generates a hierarchical listing of all books.
1066 Books are optionally filtered with a test function.
1070 books_by_parent = {}
1071 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
1073 books = books.filter(book_filter).distinct()
1075 book_ids = set(b['pk'] for b in books.values("pk").iterator())
1076 for book in books.iterator():
1077 parent = book.parent_id
1078 if parent not in book_ids:
1080 books_by_parent.setdefault(parent, []).append(book)
1082 for book in books.iterator():
1083 books_by_parent.setdefault(book.parent_id, []).append(book)
1086 books_by_author = OrderedDict()
1087 for tag in Tag.objects.filter(category='author').iterator():
1088 books_by_author[tag] = []
1090 for book in books_by_parent.get(None, ()):
1091 authors = list(book.authors().only('pk'))
1093 for author in authors:
1094 books_by_author[author].append(book)
1096 orphans.append(book)
1098 return books_by_author, orphans, books_by_parent
1101 "SP": (1, "szkoła podstawowa"),
1102 "SP1": (1, "szkoła podstawowa"),
1103 "SP2": (1, "szkoła podstawowa"),
1104 "SP3": (1, "szkoła podstawowa"),
1105 "P": (1, "szkoła podstawowa"),
1106 "G": (2, "gimnazjum"),
1108 "LP": (3, "liceum"),
1111 def audiences_pl(self):
1112 audiences = self.get_extra_info_json().get('audiences', [])
1113 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
1114 return [a[1] for a in audiences]
1116 def stage_note(self):
1117 stage = self.get_extra_info_json().get('stage')
1118 if stage and stage < '0.4':
1119 return (_('Ten utwór wymaga uwspółcześnienia'),
1120 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
1124 def choose_fragments(self, number):
1125 fragments = self.fragments.order_by()
1126 fragments_count = fragments.count()
1127 if not fragments_count and self.children.exists():
1128 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
1129 fragments_count = fragments.count()
1131 if fragments_count > number:
1132 offset = randint(0, fragments_count - number)
1135 return fragments[offset : offset + number]
1137 return self.parent.choose_fragments(number)
1141 def choose_fragment(self):
1142 fragments = self.choose_fragments(1)
1148 def fragment_data(self):
1149 fragment = self.choose_fragment()
1152 'title': fragment.book.pretty_title(),
1153 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
1158 def update_popularity(self):
1159 count = self.userlistitem_set.values('list__user').order_by('list__user').distinct().count()
1161 pop = self.popularity
1164 except BookPopularity.DoesNotExist:
1165 BookPopularity.objects.create(book=self, count=count)
1167 def ridero_link(self):
1168 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
1170 def elevenreader_link(self):
1171 first_text = self.get_first_text()
1172 if first_text is None:
1174 return 'https://elevenreader.io/audiobooks/wolnelektury:' + first_text.slug
1176 def content_warnings(self):
1178 'wulgaryzmy': _('wulgaryzmy'),
1180 warnings = self.get_extra_info_json().get('content_warnings', [])
1182 warnings_def.get(w, w)
1188 def full_sort_key(self):
1189 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1191 def cover_color(self):
1192 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1194 @cached_render('catalogue/book_mini_box.html')
1200 @cached_render('catalogue/book_mini_box.html')
1201 def mini_box_nolink(self):
1208 class BookPopularity(models.Model):
1209 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1210 count = models.IntegerField(default=0, db_index=True)