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 abstract = models.TextField('abstrakt', blank=True)
47 toc = models.TextField('spis treści', blank=True)
48 created_at = models.DateTimeField('data utworzenia', auto_now_add=True, db_index=True)
49 changed_at = models.DateTimeField('data motyfikacji', auto_now=True, db_index=True)
50 parent_number = models.IntegerField('numer w ramach rodzica', default=0)
51 extra_info = models.TextField('dodatkowe informacje', default='{}')
52 gazeta_link = models.CharField(blank=True, max_length=240)
53 wiki_link = models.CharField(blank=True, max_length=240)
54 print_on_demand = models.BooleanField('druk na żądanie', default=False)
55 recommended = models.BooleanField('polecane', default=False)
56 audio_length = models.CharField('długość audio', blank=True, max_length=8)
57 preview = models.BooleanField('prapremiera', default=False)
58 preview_until = models.DateField('prapremiera do', blank=True, null=True)
59 preview_key = models.CharField(max_length=32, blank=True, null=True)
60 findable = models.BooleanField('wyszukiwalna', default=True, db_index=True)
62 # files generated during publication
63 xml_file = fields.XmlField(storage=bofh_storage, with_etag=False)
64 html_file = fields.HtmlField(storage=bofh_storage)
65 fb2_file = fields.Fb2Field(storage=bofh_storage)
66 txt_file = fields.TxtField(storage=bofh_storage)
67 epub_file = fields.EpubField(storage=bofh_storage)
68 mobi_file = fields.MobiField(storage=bofh_storage)
69 pdf_file = fields.PdfField(storage=bofh_storage)
71 cover = fields.CoverField('okładka', storage=bofh_storage)
72 # Cleaner version of cover for thumbs
73 cover_clean = fields.CoverCleanField('czysta okładka')
74 cover_thumb = fields.CoverThumbField('miniatura okładki')
75 cover_api_thumb = fields.CoverApiThumbField(
76 'mniaturka okładki dla aplikacji')
77 simple_cover = fields.SimpleCoverField('okładka dla aplikacji')
78 cover_ebookpoint = fields.CoverEbookpointField(
79 'okładka dla Ebookpoint')
81 ebook_formats = constants.EBOOK_FORMATS
82 formats = ebook_formats + ['html', 'xml']
84 parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
85 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
87 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
88 has_audience = models.BooleanField(default=False)
90 objects = models.Manager()
91 tagged = managers.ModelTaggedItemManager(Tag)
92 tags = managers.TagDescriptor(Tag)
93 tag_relations = GenericRelation(Tag.intermediary_table_model)
95 html_built = django.dispatch.Signal()
96 published = django.dispatch.Signal()
102 class AlreadyExists(Exception):
106 ordering = ('sort_key_author', 'sort_key')
107 verbose_name = 'książka'
108 verbose_name_plural = 'książki'
109 app_label = 'catalogue'
114 def get_extra_info_json(self):
115 return json.loads(self.extra_info or '{}')
117 def get_initial(self):
119 return re.search(r'\w', self.title, re.U).group(0)
120 except AttributeError:
124 return self.tags.filter(category='author')
127 return self.tags.filter(category='epoch')
130 return self.tags.filter(category='genre')
133 return self.tags.filter(category='kind')
135 def tag_unicode(self, category):
136 relations = prefetched_relations(self, category)
138 return ', '.join(rel.tag.name for rel in relations)
140 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
142 def tags_by_category(self):
143 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
145 def author_unicode(self):
146 return self.cached_author
148 def kind_unicode(self):
149 return self.tag_unicode('kind')
151 def epoch_unicode(self):
152 return self.tag_unicode('epoch')
154 def genre_unicode(self):
155 return self.tag_unicode('genre')
157 def translators(self):
158 translators = self.get_extra_info_json().get('translators') or []
160 '\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators
163 def translator(self):
164 translators = self.get_extra_info_json().get('translators')
167 if len(translators) > 3:
168 translators = translators[:2]
172 return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
174 def cover_source(self):
175 return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
179 return self.get_extra_info_json().get('isbn_pdf')
183 return self.get_extra_info_json().get('isbn_epub')
187 return self.get_extra_info_json().get('isbn_mobi')
189 def is_accessible_to(self, user):
192 if not user.is_authenticated:
194 Membership = apps.get_model('club', 'Membership')
195 if Membership.is_active_for(user):
197 Funding = apps.get_model('funding', 'Funding')
198 if Funding.objects.filter(user=user, offer__book=self):
202 def save(self, force_insert=False, force_update=False, **kwargs):
203 from sortify import sortify
205 self.sort_key = sortify(self.title)[:120]
206 self.title = str(self.title) # ???
209 author = self.authors().first().sort_key
210 except AttributeError:
212 self.sort_key_author = author
214 self.cached_author = self.tag_unicode('author')
215 self.has_audience = 'audience' in self.get_extra_info_json()
217 if self.preview and not self.preview_key:
218 self.preview_key = get_random_hash(self.slug)[:32]
220 ret = super(Book, self).save(force_insert, force_update, **kwargs)
224 def get_absolute_url(self):
225 return reverse('book_detail', args=[self.slug])
227 def gallery_path(self):
228 return gallery_path(self.slug)
230 def gallery_url(self):
231 return gallery_url(self.slug)
233 def get_first_text(self):
236 child = self.children.all().order_by('parent_number').first()
237 if child is not None:
238 return child.get_first_text()
240 def get_last_text(self):
243 child = self.children.all().order_by('parent_number').last()
244 if child is not None:
245 return child.get_last_text()
247 def get_prev_text(self):
250 sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
251 if sibling is not None:
252 return sibling.get_last_text()
254 if self.parent.html_file:
257 return self.parent.get_prev_text()
259 def get_next_text(self, inside=True):
261 child = self.children.order_by('parent_number').first()
262 if child is not None:
263 return child.get_first_text()
267 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
268 if sibling is not None:
269 return sibling.get_first_text()
270 return self.parent.get_next_text(inside=False)
272 def get_child_audiobook(self):
273 BookMedia = apps.get_model('catalogue', 'BookMedia')
274 if not BookMedia.objects.filter(book__ancestor=self).exists():
276 for child in self.children.order_by('parent_number').all():
277 if child.has_mp3_file():
279 child_sub = child.get_child_audiobook()
280 if child_sub is not None:
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)
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')
381 return self.media_url('pdf')
384 return self.media_url('epub')
387 return self.media_url('mobi')
390 return self.media_url('txt')
393 return self.media_url('fb2')
396 return self.media_url('xml')
398 def has_description(self):
399 return len(self.description) > 0
400 has_description.short_description = 'opis'
401 has_description.boolean = True
403 def has_mp3_file(self):
404 return self.has_media("mp3")
405 has_mp3_file.short_description = 'MP3'
406 has_mp3_file.boolean = True
408 def has_ogg_file(self):
409 return self.has_media("ogg")
410 has_ogg_file.short_description = 'OGG'
411 has_ogg_file.boolean = True
413 def has_daisy_file(self):
414 return self.has_media("daisy")
415 has_daisy_file.short_description = 'DAISY'
416 has_daisy_file.boolean = True
418 def has_sync_file(self):
419 return self.has_media("sync")
422 with self.get_media('sync').first().file.open('r') as f:
423 sync = f.read().split('\n')
424 offset = float(sync[0])
426 for line in sync[1:]:
429 start, end, elid = line.split()
430 items.append([elid, float(start) + offset])
431 return json.dumps(items)
433 def has_audio_epub_file(self):
434 return self.has_media("audio.epub")
437 def media_daisy(self):
438 return self.get_media('daisy')
441 def media_audio_epub(self):
442 return self.get_media('audio.epub')
444 def get_audiobooks(self):
446 for m in self.media.filter(type='ogg').order_by().iterator():
447 ogg_files[m.name] = m
452 for mp3 in self.media.filter(type='mp3').iterator():
453 # ogg files are always from the same project
454 meta = mp3.get_extra_info_json()
455 project = meta.get('project')
458 project = 'CzytamySłuchając'
460 projects.add((project, meta.get('funded_by', '')))
461 total_duration += mp3.duration or 0
465 ogg = ogg_files.get(mp3.name)
468 audiobooks.append(media)
470 projects = sorted(projects)
471 total_duration = '%d:%02d' % (
472 total_duration // 60,
475 return audiobooks, projects, total_duration
477 def wldocument(self, parse_dublincore=True, inherit=True):
478 from catalogue.import_utils import ORMDocProvider
479 from librarian.parser import WLDocument
481 if inherit and self.parent:
482 meta_fallbacks = self.parent.cover_info()
484 meta_fallbacks = None
486 return WLDocument.from_file(
488 provider=ORMDocProvider(self),
489 parse_dublincore=parse_dublincore,
490 meta_fallbacks=meta_fallbacks)
492 def wldocument2(self):
493 from catalogue.import_utils import ORMDocProvider
494 from librarian.document import WLDocument
497 provider=ORMDocProvider(self)
499 doc.meta.update(self.cover_info())
504 def zip_format(format_):
505 def pretty_file_name(book):
506 return "%s/%s.%s" % (
507 book.get_extra_info_json()['author'],
511 field_name = "%s_file" % format_
512 field = getattr(Book, field_name)
513 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
514 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
515 return create_zip(paths, field.ZIP)
517 def zip_audiobooks(self, format_):
518 bm = BookMedia.objects.filter(book=self, type=format_)
519 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
522 license = constants.LICENSES.get(
523 m.get_extra_info_json().get('license'), {}
526 licenses.add(license)
527 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
528 'licenses': licenses,
529 'meta': self.wldocument2().meta,
531 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
533 def search_index(self, index=None):
534 if not self.findable:
536 from search.index import Index
537 Index.index_book(self)
539 # will make problems in conjunction with paid previews
540 def download_pictures(self, remote_gallery_url):
541 # This is only needed for legacy relative image paths.
542 gallery_path = self.gallery_path()
543 # delete previous files, so we don't include old files in ebooks
544 if os.path.isdir(gallery_path):
545 for filename in os.listdir(gallery_path):
546 file_path = os.path.join(gallery_path, filename)
548 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
550 makedirs(gallery_path)
551 for ilustr in ilustr_elements:
552 ilustr_src = ilustr.get('src')
553 if '/' in ilustr_src:
555 ilustr_path = os.path.join(gallery_path, ilustr_src)
556 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
558 def load_abstract(self):
559 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
560 if abstract is not None:
561 self.abstract = transform_abstrakt(abstract)
568 parser = html.HTMLParser(encoding='utf-8')
569 tree = html.parse(self.html_file.path, parser=parser)
570 toc = tree.find('//div[@id="toc"]/ol')
571 if toc is None or not len(toc):
573 html_link = reverse('book_text', args=[self.slug])
574 for a in toc.findall('.//a'):
575 a.attrib['href'] = html_link + a.attrib['href']
576 self.toc = html.tostring(toc, encoding='unicode')
580 def from_xml_file(cls, xml_file, **kwargs):
581 from django.core.files import File
582 from librarian import dcparser
584 # use librarian to parse meta-data
585 book_info = dcparser.parse(xml_file)
587 if not isinstance(xml_file, File):
588 xml_file = File(open(xml_file))
591 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
596 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
597 remote_gallery_url=None, days=0, findable=True, logo=None, logo_mono=None):
598 from catalogue import tasks
600 if dont_build is None:
602 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
604 # check for parts before we do anything
606 if hasattr(book_info, 'parts'):
607 for part_url in book_info.parts:
609 children.append(Book.objects.get(slug=part_url.slug))
610 except Book.DoesNotExist:
611 raise Book.DoesNotExist('Książka "%s" nie istnieje.' % part_url.slug)
614 book_slug = book_info.url.slug
615 if re.search(r'[^a-z0-9-]', book_slug):
616 raise ValueError('Invalid characters in slug')
617 book, created = Book.objects.get_or_create(slug=book_slug)
622 book.preview = bool(days)
624 book.preview_until = date.today() + timedelta(days)
627 raise Book.AlreadyExists('Książka %s już istnieje' % book_slug)
628 # Save shelves for this book
629 book_shelves = list(book.tags.filter(category='set'))
630 old_cover = book.cover_info()
633 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
635 book.xml_file.set_readable(False)
637 book.findable = findable
638 book.language = book_info.language
639 book.title = book_info.title
640 if book_info.variant_of:
641 book.common_slug = book_info.variant_of.slug
643 book.common_slug = book.slug
644 extra = book_info.to_dict()
648 extra['logo_mono'] = logo_mono
649 book.extra_info = json.dumps(extra)
654 meta_tags = Tag.tags_from_info(book_info)
656 for tag in meta_tags:
657 if not tag.for_books:
661 book.tags = set(meta_tags + book_shelves)
662 book.save() # update sort_key_author
664 cover_changed = old_cover != book.cover_info()
665 obsolete_children = set(b for b in book.children.all()
666 if b not in children)
667 notify_cover_changed = []
668 for n, child_book in enumerate(children):
669 new_child = child_book.parent != book
670 child_book.parent = book
671 child_book.parent_number = n
673 if new_child or cover_changed:
674 notify_cover_changed.append(child_book)
675 # Disown unfaithful children and let them cope on their own.
676 for child in obsolete_children:
678 child.parent_number = 0
681 notify_cover_changed.append(child)
683 cls.repopulate_ancestors()
684 tasks.update_counters.delay()
686 if remote_gallery_url:
687 book.download_pictures(remote_gallery_url)
689 # No saves beyond this point.
692 if 'cover' not in dont_build:
693 book.cover.build_delay()
694 book.cover_clean.build_delay()
695 book.cover_thumb.build_delay()
696 book.cover_api_thumb.build_delay()
697 book.simple_cover.build_delay()
698 book.cover_ebookpoint.build_delay()
700 # Build HTML and ebooks.
701 book.html_file.build_delay()
703 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
704 if format_ not in dont_build:
705 getattr(book, '%s_file' % format_).build_delay()
706 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
707 if format_ not in dont_build:
708 getattr(book, '%s_file' % format_).build_delay()
710 if not settings.NO_SEARCH_INDEX and search_index and findable:
711 tasks.index_book.delay(book.id)
713 for child in notify_cover_changed:
714 child.parent_cover_changed()
716 book.update_popularity()
717 tasks.update_references.delay(book.id)
719 cls.published.send(sender=cls, instance=book)
722 def update_references(self):
723 Entity = apps.get_model('references', 'Entity')
724 doc = self.wldocument2()
725 doc._compat_assign_section_ids()
726 doc._compat_assign_ordered_ids()
728 for ref_elem in doc.references():
729 uri = ref_elem.attrib.get('href', '')
735 entity, entity_created = Entity.objects.get_or_create(uri=uri)
743 ref, ref_created = entity.reference_set.get_or_create(book=self)
746 ref.occurence_set.all().delete()
747 sec = ref_elem.get_link()
748 m = re.match(r'sec(\d+)', sec)
750 sec = int(m.group(1))
751 snippet = ref_elem.get_snippet()
752 b = builders['html-snippet']()
755 html = b.output().get_bytes().decode('utf-8')
757 ref.occurence_set.create(
761 self.reference_set.exclude(entity__uri__in=refs).delete()
764 def references(self):
765 return self.reference_set.all().select_related('entity')
769 def repopulate_ancestors(cls):
770 """Fixes the ancestry cache."""
772 cursor = connection.cursor()
773 if connection.vendor == 'postgres':
774 cursor.execute("TRUNCATE catalogue_book_ancestor")
776 WITH RECURSIVE ancestry AS (
777 SELECT book.id, book.parent_id
778 FROM catalogue_book AS book
779 WHERE book.parent_id IS NOT NULL
781 SELECT ancestor.id, book.parent_id
782 FROM ancestry AS ancestor, catalogue_book AS book
783 WHERE ancestor.parent_id = book.id
784 AND book.parent_id IS NOT NULL
786 INSERT INTO catalogue_book_ancestor
787 (from_book_id, to_book_id)
793 cursor.execute("DELETE FROM catalogue_book_ancestor")
794 for b in cls.objects.exclude(parent=None):
796 while parent is not None:
797 b.ancestor.add(parent)
798 parent = parent.parent
803 for anc in self.parent.ancestors:
809 def clear_cache(self):
810 clear_cached_renders(self.mini_box)
811 clear_cached_renders(self.mini_box_nolink)
813 def cover_info(self, inherit=True):
814 """Returns a dictionary to serve as fallback for BookInfo.
816 For now, the only thing inherited is the cover image.
820 for field in ('cover_url', 'cover_by', 'cover_source'):
821 val = self.get_extra_info_json().get(field)
826 if inherit and need and self.parent is not None:
827 parent_info = self.parent.cover_info()
828 parent_info.update(info)
832 def related_themes(self):
833 return Tag.objects.usage_for_queryset(
834 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
835 counts=True).filter(category='theme').order_by('-count')
837 def parent_cover_changed(self):
838 """Called when parent book's cover image is changed."""
839 if not self.cover_info(inherit=False):
840 if 'cover' not in app_settings.DONT_BUILD:
841 self.cover.build_delay()
842 self.cover_clean.build_delay()
843 self.cover_thumb.build_delay()
844 self.cover_api_thumb.build_delay()
845 self.simple_cover.build_delay()
846 self.cover_ebookpoint.build_delay()
847 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
848 if format_ not in app_settings.DONT_BUILD:
849 getattr(self, '%s_file' % format_).build_delay()
850 for child in self.children.all():
851 child.parent_cover_changed()
853 def other_versions(self):
854 """Find other versions (i.e. in other languages) of the book."""
855 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
860 while parent is not None:
861 books.insert(0, parent)
862 parent = parent.parent
865 def pretty_title(self, html_links=False):
866 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
867 books = self.parents() + [self]
868 names.extend([(b.title, b.get_absolute_url()) for b in books])
871 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
873 names = [tag[0] for tag in names]
874 return ', '.join(names)
877 publisher = self.get_extra_info_json()['publisher']
878 if isinstance(publisher, str):
880 elif isinstance(publisher, list):
881 return ', '.join(publisher)
884 def tagged_top_level(cls, tags):
885 """ Returns top-level books tagged with `tags`.
887 It only returns those books which don't have ancestors which are
888 also tagged with those tags.
891 objects = cls.tagged.with_all(tags)
892 return objects.filter(findable=True).exclude(ancestor__in=objects)
895 def book_list(cls, book_filter=None):
896 """Generates a hierarchical listing of all books.
898 Books are optionally filtered with a test function.
903 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
905 books = books.filter(book_filter).distinct()
907 book_ids = set(b['pk'] for b in books.values("pk").iterator())
908 for book in books.iterator():
909 parent = book.parent_id
910 if parent not in book_ids:
912 books_by_parent.setdefault(parent, []).append(book)
914 for book in books.iterator():
915 books_by_parent.setdefault(book.parent_id, []).append(book)
918 books_by_author = OrderedDict()
919 for tag in Tag.objects.filter(category='author').iterator():
920 books_by_author[tag] = []
922 for book in books_by_parent.get(None, ()):
923 authors = list(book.authors().only('pk'))
925 for author in authors:
926 books_by_author[author].append(book)
930 return books_by_author, orphans, books_by_parent
933 "SP": (1, "szkoła podstawowa"),
934 "SP1": (1, "szkoła podstawowa"),
935 "SP2": (1, "szkoła podstawowa"),
936 "SP3": (1, "szkoła podstawowa"),
937 "P": (1, "szkoła podstawowa"),
938 "G": (2, "gimnazjum"),
943 def audiences_pl(self):
944 audiences = self.get_extra_info_json().get('audiences', [])
945 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
946 return [a[1] for a in audiences]
948 def stage_note(self):
949 stage = self.get_extra_info_json().get('stage')
950 if stage and stage < '0.4':
951 return (_('Ten utwór wymaga uwspółcześnienia'),
952 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
956 def choose_fragments(self, number):
957 fragments = self.fragments.order_by()
958 fragments_count = fragments.count()
959 if not fragments_count and self.children.exists():
960 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
961 fragments_count = fragments.count()
963 if fragments_count > number:
964 offset = randint(0, fragments_count - number)
967 return fragments[offset : offset + number]
969 return self.parent.choose_fragments(number)
973 def choose_fragment(self):
974 fragments = self.choose_fragments(1)
980 def fragment_data(self):
981 fragment = self.choose_fragment()
984 'title': fragment.book.pretty_title(),
985 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
990 def update_popularity(self):
991 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
993 pop = self.popularity
996 except BookPopularity.DoesNotExist:
997 BookPopularity.objects.create(book=self, count=count)
999 def ridero_link(self):
1000 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
1002 def like(self, user):
1003 from social.utils import likes, get_set, set_sets
1004 if not likes(user, self):
1005 tag = get_set(user, '')
1006 set_sets(user, self, [tag])
1008 def unlike(self, user):
1009 from social.utils import likes, set_sets
1010 if likes(user, self):
1011 set_sets(user, self, [])
1013 def full_sort_key(self):
1014 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1016 def cover_color(self):
1017 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1019 @cached_render('catalogue/book_mini_box.html')
1025 @cached_render('catalogue/book_mini_box.html')
1026 def mini_box_nolink(self):
1033 class BookPopularity(models.Model):
1034 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1035 count = models.IntegerField(default=0, db_index=True)