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(
232 force_insert=force_insert, force_update=force_update, **kwargs)
236 def get_absolute_url(self):
237 return reverse('book_detail', args=[self.slug])
239 def gallery_path(self):
240 return gallery_path(self.slug)
242 def gallery_url(self):
243 return gallery_url(self.slug)
245 def get_first_text(self):
248 child = self.children.all().order_by('parent_number').first()
249 if child is not None:
250 return child.get_first_text()
252 def get_last_text(self):
255 child = self.children.all().order_by('parent_number').last()
256 if child is not None:
257 return child.get_last_text()
259 def get_prev_text(self):
262 sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
263 if sibling is not None:
264 return sibling.get_last_text()
266 if self.parent.html_file:
269 return self.parent.get_prev_text()
271 def get_next_text(self, inside=True):
273 child = self.children.order_by('parent_number').first()
274 if child is not None:
275 return child.get_first_text()
279 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
280 if sibling is not None:
281 return sibling.get_first_text()
282 return self.parent.get_next_text(inside=False)
284 def get_siblings(self):
287 return self.parent.children.all().order_by('parent_number')
289 def get_children(self):
290 return self.children.all().order_by('parent_number')
296 def language_code(self):
297 return constants.LANGUAGES_3TO2.get(self.language, self.language)
299 def language_name(self):
300 return dict(settings.LANGUAGES).get(self.language_code(), "")
302 def is_foreign(self):
303 return self.language_code() != settings.LANGUAGE_CODE
305 def set_audio_length(self):
306 length = self.get_audio_length()
308 self.audio_length = self.format_audio_length(length)
312 def format_audio_length(seconds):
314 >>> Book.format_audio_length(1)
316 >>> Book.format_audio_length(3661)
320 minutes = seconds // 60
321 seconds = seconds % 60
322 return '%d:%02d' % (minutes, seconds)
324 hours = seconds // 3600
325 minutes = seconds % 3600 // 60
326 seconds = seconds % 60
327 return '%d:%02d:%02d' % (hours, minutes, seconds)
329 def get_audio_length(self):
331 for media in self.get_mp3() or ():
332 total += app_settings.GET_MP3_LENGTH(media.file.path)
336 return round(self.xml_file.size / 1000 * 40)
338 def has_media(self, type_):
339 if type_ in Book.formats:
340 return bool(getattr(self, "%s_file" % type_))
342 return self.media.filter(type=type_).exists()
344 def get_media(self, type_):
345 if self.has_media(type_):
346 if type_ in Book.formats:
347 return getattr(self, "%s_file" % type_)
349 return self.media.filter(type=type_)
354 return self.get_media("mp3")
357 return self.get_media("odt")
360 return self.get_media("ogg")
363 return self.get_media("daisy")
365 def get_audio_epub(self):
366 return self.get_media("audio.epub")
368 def media_url(self, format_):
369 media = self.get_media(format_)
372 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
379 return self.media_url('html')
381 def html_nonotes_url(self):
382 return self.media_url('html_nonotes')
385 return self.media_url('pdf')
388 return self.media_url('epub')
391 return self.media_url('mobi')
394 return self.media_url('txt')
397 return self.media_url('fb2')
400 return self.media_url('xml')
402 def has_description(self):
403 return len(self.description) > 0
404 has_description.short_description = 'opis'
405 has_description.boolean = True
407 def has_mp3_file(self):
408 return self.has_media("mp3")
409 has_mp3_file.short_description = 'MP3'
410 has_mp3_file.boolean = True
412 def has_ogg_file(self):
413 return self.has_media("ogg")
414 has_ogg_file.short_description = 'OGG'
415 has_ogg_file.boolean = True
417 def has_daisy_file(self):
418 return self.has_media("daisy")
419 has_daisy_file.short_description = 'DAISY'
420 has_daisy_file.boolean = True
422 def has_sync_file(self):
423 return settings.FEATURE_SYNCHRO and self.has_media("sync")
425 def build_sync_file(self):
426 from lxml import html
427 from django.core.files.base import ContentFile
428 with self.html_file.open('rb') as f:
429 h = html.fragment_fromstring(f.read().decode('utf-8'))
433 for m in self.get_audiobooks()[0]
435 if settings.MOCK_DURATIONS:
436 durations = settings.MOCK_DURATIONS
442 for elem in h.iter():
443 if elem.get('data-audio-ts'):
444 part, ts = int(elem.get('data-audio-part')), float(elem.get('data-audio-ts'))
445 ts = str(round(sum(durations[:part - 1]) + ts, 3))
446 # check if inside verse
449 # Workaround for missing ids.
450 if 'verse' in p.get('class', ''):
452 p.set('id', f'syn{sid}')
455 sync.append((ts, p.get('id')))
460 cls = elem.get('class', '')
461 # Workaround for missing ids.
462 if 'paragraph' in cls or 'verse' in cls or elem.tag in ('h1', 'h2', 'h3', 'h4'):
463 if not elem.get('id'):
464 elem.set('id', f'syn{sid}')
467 sync.append((ts, elem.get('id')))
470 htext = html.tostring(h, encoding='utf-8')
471 with open(self.html_file.path, 'wb') as f:
474 bm = self.media.get(type='sync')
476 bm = BookMedia(book=self, type='sync')
479 f'{s[0]}\t{sync[i+1][0]}\t{s[1]}' for i, s in enumerate(sync[:-1])
482 None, ContentFile(sync)
486 if not self.has_sync_file():
488 with self.get_media('sync').first().file.open('r') as f:
489 sync = f.read().split('\n')
490 offset = float(sync[0])
492 for line in sync[1:]:
495 start, end, elid = line.split()
496 items.append([elid, float(start) + offset])
499 def sync_ts(self, ts):
501 for cur_id, t in self.get_sync():
508 def sync_elid(self, elid):
509 for cur_id, t in self.get_sync():
513 def has_audio_epub_file(self):
514 return self.has_media("audio.epub")
517 def media_daisy(self):
518 return self.get_media('daisy')
521 def media_audio_epub(self):
522 return self.get_media('audio.epub')
524 def get_audiobooks(self, with_children=False, processing=False):
526 for m in self.media.filter(type='ogg').order_by().iterator():
527 ogg_files[m.name] = m
532 for mp3 in self.media.filter(type='mp3').iterator():
533 # ogg files are always from the same project
534 meta = mp3.get_extra_info_json()
535 project = meta.get('project')
538 project = 'CzytamySłuchając'
540 projects.add((project, meta.get('funded_by', '')))
541 total_duration += mp3.duration or 0
545 ogg = ogg_files.get(mp3.name)
548 audiobooks.append(media)
551 for child in self.get_children():
552 ch_audiobooks, ch_projects, ch_duration = child.get_audiobooks(
553 with_children=True, processing=True)
554 audiobooks.append({'part': child})
555 audiobooks += ch_audiobooks
556 projects.update(ch_projects)
557 total_duration += ch_duration
560 projects = sorted(projects)
561 total_duration = '%d:%02d' % (
562 total_duration // 60,
566 return audiobooks, projects, total_duration
568 def get_audiobooks_with_children(self):
569 return self.get_audiobooks(with_children=True)
571 def wldocument(self, parse_dublincore=True, inherit=True):
572 from catalogue.import_utils import ORMDocProvider
573 from librarian.parser import WLDocument
575 if inherit and self.parent:
576 meta_fallbacks = self.parent.cover_info()
578 meta_fallbacks = None
580 return WLDocument.from_file(
582 provider=ORMDocProvider(self),
583 parse_dublincore=parse_dublincore,
584 meta_fallbacks=meta_fallbacks)
586 def wldocument2(self):
587 from catalogue.import_utils import ORMDocProvider
588 from librarian.document import WLDocument
591 provider=ORMDocProvider(self)
593 doc.meta.update(self.cover_info())
598 def zip_format(format_):
599 def pretty_file_name(book):
600 return "%s/%s.%s" % (
601 book.get_extra_info_json()['author'],
605 field_name = "%s_file" % format_
606 field = getattr(Book, field_name)
607 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
608 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
609 return create_zip(paths, field.ZIP)
611 def zip_audiobooks(self, format_):
612 bm = BookMedia.objects.filter(book=self, type=format_)
613 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
616 license = constants.LICENSES.get(
617 m.get_extra_info_json().get('license'), {}
620 licenses.add(license)
621 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
622 'licenses': licenses,
623 'meta': self.wldocument2().meta,
625 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
627 def search_index(self, index=None):
628 if not self.findable:
630 from search.index import Index
631 Index.index_book(self)
633 # will make problems in conjunction with paid previews
634 def download_pictures(self, remote_gallery_url):
635 # This is only needed for legacy relative image paths.
636 gallery_path = self.gallery_path()
637 # delete previous files, so we don't include old files in ebooks
638 if os.path.isdir(gallery_path):
639 for filename in os.listdir(gallery_path):
640 file_path = os.path.join(gallery_path, filename)
642 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
644 makedirs(gallery_path)
645 for ilustr in ilustr_elements:
646 ilustr_src = ilustr.get('src')
647 if '/' in ilustr_src:
649 ilustr_path = os.path.join(gallery_path, ilustr_src)
650 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
652 def load_abstract(self):
653 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
654 if abstract is not None:
655 self.abstract = transform_abstrakt(abstract)
662 parser = html.HTMLParser(encoding='utf-8')
663 tree = html.parse(self.html_file.path, parser=parser)
664 toc = tree.find('//div[@id="toc"]/ol')
665 if toc is None or not len(toc):
667 html_link = reverse('book_text', args=[self.slug])
668 for a in toc.findall('.//a'):
669 a.attrib['href'] = html_link + a.attrib['href']
670 self.toc = html.tostring(toc, encoding='unicode')
674 def from_xml_file(cls, xml_file, **kwargs):
675 from django.core.files import File
676 from librarian import dcparser
678 # use librarian to parse meta-data
679 book_info = dcparser.parse(xml_file)
681 if not isinstance(xml_file, File):
682 xml_file = File(open(xml_file))
685 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
690 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
691 remote_gallery_url=None, days=0, findable=True, logo=None, logo_mono=None, logo_alt=None, can_sell=None, isbn_mp3=None):
692 from catalogue import tasks
694 if dont_build is None:
696 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
698 # check for parts before we do anything
700 if hasattr(book_info, 'parts'):
701 for part_url in book_info.parts:
703 children.append(Book.objects.get(slug=part_url.slug))
704 except Book.DoesNotExist:
705 raise Book.DoesNotExist('Książka "%s" nie istnieje.' % part_url.slug)
708 book_slug = book_info.url.slug
709 if re.search(r'[^a-z0-9-]', book_slug):
710 raise ValueError('Invalid characters in slug')
711 book, created = Book.objects.get_or_create(slug=book_slug)
716 book.preview = bool(days)
718 book.preview_until = date.today() + timedelta(days)
721 raise Book.AlreadyExists('Książka %s już istnieje' % book_slug)
722 # Save shelves for this book
723 book_shelves = list(book.tags.filter(category='set'))
724 old_cover = book.cover_info()
727 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
729 book.xml_file.set_readable(False)
731 book.findable = findable
732 book.language = book_info.language
733 book.title = book_info.title
734 book.license = book_info.license or ''
735 if book_info.variant_of:
736 book.common_slug = book_info.variant_of.slug
738 book.common_slug = book.slug
739 extra = book_info.to_dict()
743 extra['logo_mono'] = logo_mono
745 extra['logo_alt'] = logo_alt
746 if can_sell is not None:
747 book.can_sell = can_sell
748 if isbn_mp3 is not None:
749 book.isbn_mp3 = isbn_mp3
750 book.extra_info = json.dumps(extra)
757 meta_tags = Tag.tags_from_info(book_info)
759 just_tags = [t for (t, rel) in meta_tags if not rel]
760 book.tags = set(just_tags + book_shelves)
761 book.save() # update sort_key_author
763 book.translators.set([t for (t, rel) in meta_tags if rel == 'translator'])
765 cover_changed = old_cover != book.cover_info()
766 obsolete_children = set(b for b in book.children.all()
767 if b not in children)
768 notify_cover_changed = []
769 for n, child_book in enumerate(children):
770 new_child = child_book.parent != book
771 child_book.parent = book
772 child_book.parent_number = n
774 if new_child or cover_changed:
775 notify_cover_changed.append(child_book)
776 # Disown unfaithful children and let them cope on their own.
777 for child in obsolete_children:
779 child.parent_number = 0
782 notify_cover_changed.append(child)
784 cls.repopulate_ancestors()
785 tasks.update_counters.delay()
787 if remote_gallery_url:
788 book.download_pictures(remote_gallery_url)
790 # No saves beyond this point.
793 if 'cover' not in dont_build:
794 book.cover.build_delay()
795 book.cover_clean.build_delay()
796 book.cover_thumb.build_delay()
797 book.cover_api_thumb.build_delay()
798 book.simple_cover.build_delay()
799 book.cover_ebookpoint.build_delay()
801 # Build HTML and ebooks.
802 book.html_file.build_delay()
804 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
805 if format_ not in dont_build:
806 getattr(book, '%s_file' % format_).build_delay()
807 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
808 if format_ not in dont_build:
809 getattr(book, '%s_file' % format_).build_delay()
810 if 'html_nonotes' not in dont_build:
811 book.html_nonotes_file.build_delay()
813 if not settings.NO_SEARCH_INDEX and search_index and findable:
814 tasks.index_book.delay(book.id)
816 for child in notify_cover_changed:
817 child.parent_cover_changed()
819 book.update_popularity()
820 if 'references' not in dont_build:
821 tasks.update_references.delay(book.id)
823 cls.published.send(sender=cls, instance=book)
826 def update_stats(self):
827 stats = self.wldocument2().get_statistics()['total']
829 stats.get('verses_with_fn', 0) / 30 +
830 stats.get('chars_out_verse_with_fn', 0) / 1800)
831 self.read_time = round(self.get_time())
832 self.save(update_fields=['pages', 'read_time'])
833 if self.parent is not None:
834 self.parent.update_stats()
836 def update_references(self):
837 Entity = apps.get_model('references', 'Entity')
838 doc = self.wldocument2()
839 doc._compat_assign_section_ids()
840 doc._compat_assign_ordered_ids()
842 for ref_elem in doc.references():
843 uri = ref_elem.attrib.get('href', '')
849 entity, entity_created = Entity.objects.get_or_create(uri=uri)
857 ref, ref_created = entity.reference_set.get_or_create(book=self)
860 ref.occurence_set.all().delete()
861 sec = ref_elem.get_link()
862 m = re.match(r'sec(\d+)', sec)
864 sec = int(m.group(1))
865 snippet = ref_elem.get_snippet()
866 b = builders['html-snippet']()
869 html = b.output().get_bytes().decode('utf-8')
871 ref.occurence_set.create(
875 self.reference_set.exclude(entity__uri__in=refs).delete()
878 def references(self):
879 return self.reference_set.all().select_related('entity')
881 def update_has_audio(self):
882 self.has_audio = False
883 if self.media.filter(type='mp3').exists():
884 self.has_audio = True
885 if self.descendant.filter(has_audio=True).exists():
886 self.has_audio = True
887 self.save(update_fields=['has_audio'])
888 if self.parent is not None:
889 self.parent.update_has_audio()
891 def update_narrators(self):
892 narrator_names = set()
893 for bm in self.media.filter(type='mp3'):
894 narrator_names.update(set(
895 a.strip() for a in re.split(r',|\si\s', bm.artist)
899 for name in narrator_names:
900 if not name: continue
903 t = Tag.objects.get(category='author', slug=slug)
904 except Tag.DoesNotExist:
906 ' '.join(name.rsplit(' ', 1)[::-1]).lower()
908 t = Tag.objects.create(
915 self.narrators.set(narrators)
917 def update_can_sell_mp3(self):
919 for child in self.get_children():
920 child.update_can_sell_mp3()
921 if not child.can_sell_mp3:
923 if self.has_mp3_file():
924 audio_items = requests.get(f'https://audio.wolnelektury.pl/archive/book/{self.slug}.json').json()['items']
925 if not all(x['project']['can_sell'] for x in audio_items):
927 self.can_sell_mp3 = ret
928 self.save(update_fields=['can_sell_mp3'])
932 def repopulate_ancestors(cls):
933 """Fixes the ancestry cache."""
935 cursor = connection.cursor()
936 if connection.vendor == 'postgres':
937 cursor.execute("TRUNCATE catalogue_book_ancestor")
939 WITH RECURSIVE ancestry AS (
940 SELECT book.id, book.parent_id
941 FROM catalogue_book AS book
942 WHERE book.parent_id IS NOT NULL
944 SELECT ancestor.id, book.parent_id
945 FROM ancestry AS ancestor, catalogue_book AS book
946 WHERE ancestor.parent_id = book.id
947 AND book.parent_id IS NOT NULL
949 INSERT INTO catalogue_book_ancestor
950 (from_book_id, to_book_id)
956 cursor.execute("DELETE FROM catalogue_book_ancestor")
957 for b in cls.objects.exclude(parent=None):
959 while parent is not None:
960 b.ancestor.add(parent)
961 parent = parent.parent
966 for anc in self.parent.ancestors:
972 def clear_cache(self):
973 clear_cached_renders(self.mini_box)
974 clear_cached_renders(self.mini_box_nolink)
976 def cover_info(self, inherit=True):
977 """Returns a dictionary to serve as fallback for BookInfo.
979 For now, the only thing inherited is the cover image.
983 for field in ('cover_url', 'cover_by', 'cover_source'):
984 val = self.get_extra_info_json().get(field)
989 if inherit and need and self.parent is not None:
990 parent_info = self.parent.cover_info()
991 parent_info.update(info)
995 def related_themes(self):
996 return Tag.objects.usage_for_queryset(
997 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
998 counts=True).filter(category='theme').order_by('-count')
1000 def parent_cover_changed(self):
1001 """Called when parent book's cover image is changed."""
1002 if not self.cover_info(inherit=False):
1003 if 'cover' not in app_settings.DONT_BUILD:
1004 self.cover.build_delay()
1005 self.cover_clean.build_delay()
1006 self.cover_thumb.build_delay()
1007 self.cover_api_thumb.build_delay()
1008 self.simple_cover.build_delay()
1009 self.cover_ebookpoint.build_delay()
1010 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
1011 if format_ not in app_settings.DONT_BUILD:
1012 getattr(self, '%s_file' % format_).build_delay()
1013 for child in self.children.all():
1014 child.parent_cover_changed()
1016 def other_versions(self):
1017 """Find other versions (i.e. in other languages) of the book."""
1018 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
1022 parent = self.parent
1023 while parent is not None:
1024 books.insert(0, parent)
1025 parent = parent.parent
1028 def pretty_title(self, html_links=False):
1029 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
1030 books = self.parents() + [self]
1031 names.extend([(b.title, b.get_absolute_url()) for b in books])
1034 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
1036 names = [tag[0] for tag in names]
1037 return ', '.join(names)
1039 def publisher(self):
1040 publisher = self.get_extra_info_json()['publisher']
1041 if isinstance(publisher, str):
1043 elif isinstance(publisher, list):
1044 return ', '.join(publisher)
1046 def get_recommended(self, limit=4):
1047 books_qs = type(self).objects.filter(findable=True)
1048 books_qs = books_qs.exclude(common_slug=self.common_slug).exclude(ancestor=self)
1049 books = type(self).tagged.related_to(self, books_qs)[:limit]
1053 def tagged_top_level(cls, tags):
1054 """ Returns top-level books tagged with `tags`.
1056 It only returns those books which don't have ancestors which are
1057 also tagged with those tags.
1060 objects = cls.tagged.with_all(tags)
1061 return objects.filter(findable=True).exclude(ancestor__in=objects)
1064 def book_list(cls, book_filter=None):
1065 """Generates a hierarchical listing of all books.
1067 Books are optionally filtered with a test function.
1071 books_by_parent = {}
1072 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
1074 books = books.filter(book_filter).distinct()
1076 book_ids = set(b['pk'] for b in books.values("pk").iterator())
1077 for book in books.iterator():
1078 parent = book.parent_id
1079 if parent not in book_ids:
1081 books_by_parent.setdefault(parent, []).append(book)
1083 for book in books.iterator():
1084 books_by_parent.setdefault(book.parent_id, []).append(book)
1087 books_by_author = OrderedDict()
1088 for tag in Tag.objects.filter(category='author').iterator():
1089 books_by_author[tag] = []
1091 for book in books_by_parent.get(None, ()):
1092 authors = list(book.authors().only('pk'))
1094 for author in authors:
1095 books_by_author[author].append(book)
1097 orphans.append(book)
1099 return books_by_author, orphans, books_by_parent
1102 "SP": (1, "szkoła podstawowa"),
1103 "SP1": (1, "szkoła podstawowa"),
1104 "SP2": (1, "szkoła podstawowa"),
1105 "SP3": (1, "szkoła podstawowa"),
1106 "P": (1, "szkoła podstawowa"),
1107 "G": (2, "gimnazjum"),
1109 "LP": (3, "liceum"),
1112 def audiences_pl(self):
1113 audiences = self.get_extra_info_json().get('audiences', [])
1114 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
1115 return [a[1] for a in audiences]
1117 def stage_note(self):
1118 stage = self.get_extra_info_json().get('stage')
1119 if stage and stage < '0.4':
1120 return (_('Ten utwór wymaga uwspółcześnienia'),
1121 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
1125 def choose_fragments(self, number):
1126 fragments = self.fragments.order_by()
1127 fragments_count = fragments.count()
1128 if not fragments_count and self.children.exists():
1129 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
1130 fragments_count = fragments.count()
1132 if fragments_count > number:
1133 offset = randint(0, fragments_count - number)
1136 return fragments[offset : offset + number]
1138 return self.parent.choose_fragments(number)
1142 def choose_fragment(self):
1143 fragments = self.choose_fragments(1)
1149 def fragment_data(self):
1150 fragment = self.choose_fragment()
1153 'title': fragment.book.pretty_title(),
1154 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
1159 def update_popularity(self):
1160 count = self.userlistitem_set.values('list__user').order_by('list__user').distinct().count()
1162 pop = self.popularity
1165 except BookPopularity.DoesNotExist:
1166 BookPopularity.objects.create(book=self, count=count)
1168 def ridero_link(self):
1169 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
1171 def elevenreader_link(self):
1172 first_text = self.get_first_text()
1173 if first_text is None:
1175 return 'https://elevenreader.io/audiobooks/wolnelektury:' + first_text.slug
1177 def content_warnings(self):
1179 'wulgaryzmy': _('wulgaryzmy'),
1181 warnings = self.get_extra_info_json().get('content_warnings', [])
1183 warnings_def.get(w, w)
1189 def full_sort_key(self):
1190 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1192 def cover_color(self):
1193 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1195 @cached_render('catalogue/book_mini_box.html')
1201 @cached_render('catalogue/book_mini_box.html')
1202 def mini_box_nolink(self):
1209 class BookPopularity(models.Model):
1210 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1211 count = models.IntegerField(default=0, db_index=True)