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)
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()
344 return self.has_media('mp3')
346 def get_media(self, type_):
347 if self.has_media(type_):
348 if type_ in Book.formats:
349 return getattr(self, "%s_file" % type_)
351 return self.media.filter(type=type_)
356 return self.get_media("mp3")
359 return self.get_media("odt")
362 return self.get_media("ogg")
365 return self.get_media("daisy")
367 def get_audio_epub(self):
368 return self.get_media("audio.epub")
370 def media_url(self, format_):
371 media = self.get_media(format_)
374 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
381 return self.media_url('html')
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 self.has_media("sync")
425 with self.get_media('sync').first().file.open('r') as f:
426 sync = f.read().split('\n')
427 offset = float(sync[0])
429 for line in sync[1:]:
432 start, end, elid = line.split()
433 items.append([elid, float(start) + offset])
434 return json.dumps(items)
436 def has_audio_epub_file(self):
437 return self.has_media("audio.epub")
440 def media_daisy(self):
441 return self.get_media('daisy')
444 def media_audio_epub(self):
445 return self.get_media('audio.epub')
447 def get_audiobooks(self):
449 for m in self.media.filter(type='ogg').order_by().iterator():
450 ogg_files[m.name] = m
455 for mp3 in self.media.filter(type='mp3').iterator():
456 # ogg files are always from the same project
457 meta = mp3.get_extra_info_json()
458 project = meta.get('project')
461 project = 'CzytamySłuchając'
463 projects.add((project, meta.get('funded_by', '')))
464 total_duration += mp3.duration or 0
468 ogg = ogg_files.get(mp3.name)
471 audiobooks.append(media)
473 projects = sorted(projects)
474 total_duration = '%d:%02d' % (
475 total_duration // 60,
478 return audiobooks, projects, total_duration
480 def wldocument(self, parse_dublincore=True, inherit=True):
481 from catalogue.import_utils import ORMDocProvider
482 from librarian.parser import WLDocument
484 if inherit and self.parent:
485 meta_fallbacks = self.parent.cover_info()
487 meta_fallbacks = None
489 return WLDocument.from_file(
491 provider=ORMDocProvider(self),
492 parse_dublincore=parse_dublincore,
493 meta_fallbacks=meta_fallbacks)
495 def wldocument2(self):
496 from catalogue.import_utils import ORMDocProvider
497 from librarian.document import WLDocument
500 provider=ORMDocProvider(self)
502 doc.meta.update(self.cover_info())
507 def zip_format(format_):
508 def pretty_file_name(book):
509 return "%s/%s.%s" % (
510 book.get_extra_info_json()['author'],
514 field_name = "%s_file" % format_
515 field = getattr(Book, field_name)
516 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
517 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
518 return create_zip(paths, field.ZIP)
520 def zip_audiobooks(self, format_):
521 bm = BookMedia.objects.filter(book=self, type=format_)
522 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
525 license = constants.LICENSES.get(
526 m.get_extra_info_json().get('license'), {}
529 licenses.add(license)
530 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
531 'licenses': licenses,
532 'meta': self.wldocument2().meta,
534 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
536 def search_index(self, index=None):
537 if not self.findable:
539 from search.index import Index
540 Index.index_book(self)
542 # will make problems in conjunction with paid previews
543 def download_pictures(self, remote_gallery_url):
544 # This is only needed for legacy relative image paths.
545 gallery_path = self.gallery_path()
546 # delete previous files, so we don't include old files in ebooks
547 if os.path.isdir(gallery_path):
548 for filename in os.listdir(gallery_path):
549 file_path = os.path.join(gallery_path, filename)
551 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
553 makedirs(gallery_path)
554 for ilustr in ilustr_elements:
555 ilustr_src = ilustr.get('src')
556 if '/' in ilustr_src:
558 ilustr_path = os.path.join(gallery_path, ilustr_src)
559 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
561 def load_abstract(self):
562 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
563 if abstract is not None:
564 self.abstract = transform_abstrakt(abstract)
571 parser = html.HTMLParser(encoding='utf-8')
572 tree = html.parse(self.html_file.path, parser=parser)
573 toc = tree.find('//div[@id="toc"]/ol')
574 if toc is None or not len(toc):
576 html_link = reverse('book_text', args=[self.slug])
577 for a in toc.findall('.//a'):
578 a.attrib['href'] = html_link + a.attrib['href']
579 self.toc = html.tostring(toc, encoding='unicode')
583 def from_xml_file(cls, xml_file, **kwargs):
584 from django.core.files import File
585 from librarian import dcparser
587 # use librarian to parse meta-data
588 book_info = dcparser.parse(xml_file)
590 if not isinstance(xml_file, File):
591 xml_file = File(open(xml_file))
594 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
599 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
600 remote_gallery_url=None, days=0, findable=True, logo=None, logo_mono=None, logo_alt=None):
601 from catalogue import tasks
603 if dont_build is None:
605 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
607 # check for parts before we do anything
609 if hasattr(book_info, 'parts'):
610 for part_url in book_info.parts:
612 children.append(Book.objects.get(slug=part_url.slug))
613 except Book.DoesNotExist:
614 raise Book.DoesNotExist('Książka "%s" nie istnieje.' % part_url.slug)
617 book_slug = book_info.url.slug
618 if re.search(r'[^a-z0-9-]', book_slug):
619 raise ValueError('Invalid characters in slug')
620 book, created = Book.objects.get_or_create(slug=book_slug)
625 book.preview = bool(days)
627 book.preview_until = date.today() + timedelta(days)
630 raise Book.AlreadyExists('Książka %s już istnieje' % book_slug)
631 # Save shelves for this book
632 book_shelves = list(book.tags.filter(category='set'))
633 old_cover = book.cover_info()
636 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
638 book.xml_file.set_readable(False)
640 book.findable = findable
641 book.language = book_info.language
642 book.title = book_info.title
643 if book_info.variant_of:
644 book.common_slug = book_info.variant_of.slug
646 book.common_slug = book.slug
647 extra = book_info.to_dict()
651 extra['logo_mono'] = logo_mono
653 extra['logo_alt'] = logo_alt
654 book.extra_info = json.dumps(extra)
659 meta_tags = Tag.tags_from_info(book_info)
661 for tag in meta_tags:
662 if not tag.for_books:
666 book.tags = set(meta_tags + book_shelves)
667 book.save() # update sort_key_author
669 cover_changed = old_cover != book.cover_info()
670 obsolete_children = set(b for b in book.children.all()
671 if b not in children)
672 notify_cover_changed = []
673 for n, child_book in enumerate(children):
674 new_child = child_book.parent != book
675 child_book.parent = book
676 child_book.parent_number = n
678 if new_child or cover_changed:
679 notify_cover_changed.append(child_book)
680 # Disown unfaithful children and let them cope on their own.
681 for child in obsolete_children:
683 child.parent_number = 0
686 notify_cover_changed.append(child)
688 cls.repopulate_ancestors()
689 tasks.update_counters.delay()
691 if remote_gallery_url:
692 book.download_pictures(remote_gallery_url)
694 # No saves beyond this point.
697 if 'cover' not in dont_build:
698 book.cover.build_delay()
699 book.cover_clean.build_delay()
700 book.cover_thumb.build_delay()
701 book.cover_api_thumb.build_delay()
702 book.simple_cover.build_delay()
703 book.cover_ebookpoint.build_delay()
705 # Build HTML and ebooks.
706 book.html_file.build_delay()
708 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
709 if format_ not in dont_build:
710 getattr(book, '%s_file' % format_).build_delay()
711 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
712 if format_ not in dont_build:
713 getattr(book, '%s_file' % format_).build_delay()
715 if not settings.NO_SEARCH_INDEX and search_index and findable:
716 tasks.index_book.delay(book.id)
718 for child in notify_cover_changed:
719 child.parent_cover_changed()
721 book.update_popularity()
722 tasks.update_references.delay(book.id)
724 cls.published.send(sender=cls, instance=book)
727 def update_references(self):
728 Entity = apps.get_model('references', 'Entity')
729 doc = self.wldocument2()
730 doc._compat_assign_section_ids()
731 doc._compat_assign_ordered_ids()
733 for ref_elem in doc.references():
734 uri = ref_elem.attrib.get('href', '')
740 entity, entity_created = Entity.objects.get_or_create(uri=uri)
748 ref, ref_created = entity.reference_set.get_or_create(book=self)
751 ref.occurence_set.all().delete()
752 sec = ref_elem.get_link()
753 m = re.match(r'sec(\d+)', sec)
755 sec = int(m.group(1))
756 snippet = ref_elem.get_snippet()
757 b = builders['html-snippet']()
760 html = b.output().get_bytes().decode('utf-8')
762 ref.occurence_set.create(
766 self.reference_set.exclude(entity__uri__in=refs).delete()
769 def references(self):
770 return self.reference_set.all().select_related('entity')
774 def repopulate_ancestors(cls):
775 """Fixes the ancestry cache."""
777 cursor = connection.cursor()
778 if connection.vendor == 'postgres':
779 cursor.execute("TRUNCATE catalogue_book_ancestor")
781 WITH RECURSIVE ancestry AS (
782 SELECT book.id, book.parent_id
783 FROM catalogue_book AS book
784 WHERE book.parent_id IS NOT NULL
786 SELECT ancestor.id, book.parent_id
787 FROM ancestry AS ancestor, catalogue_book AS book
788 WHERE ancestor.parent_id = book.id
789 AND book.parent_id IS NOT NULL
791 INSERT INTO catalogue_book_ancestor
792 (from_book_id, to_book_id)
798 cursor.execute("DELETE FROM catalogue_book_ancestor")
799 for b in cls.objects.exclude(parent=None):
801 while parent is not None:
802 b.ancestor.add(parent)
803 parent = parent.parent
808 for anc in self.parent.ancestors:
814 def clear_cache(self):
815 clear_cached_renders(self.mini_box)
816 clear_cached_renders(self.mini_box_nolink)
818 def cover_info(self, inherit=True):
819 """Returns a dictionary to serve as fallback for BookInfo.
821 For now, the only thing inherited is the cover image.
825 for field in ('cover_url', 'cover_by', 'cover_source'):
826 val = self.get_extra_info_json().get(field)
831 if inherit and need and self.parent is not None:
832 parent_info = self.parent.cover_info()
833 parent_info.update(info)
837 def related_themes(self):
838 return Tag.objects.usage_for_queryset(
839 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
840 counts=True).filter(category='theme').order_by('-count')
842 def parent_cover_changed(self):
843 """Called when parent book's cover image is changed."""
844 if not self.cover_info(inherit=False):
845 if 'cover' not in app_settings.DONT_BUILD:
846 self.cover.build_delay()
847 self.cover_clean.build_delay()
848 self.cover_thumb.build_delay()
849 self.cover_api_thumb.build_delay()
850 self.simple_cover.build_delay()
851 self.cover_ebookpoint.build_delay()
852 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
853 if format_ not in app_settings.DONT_BUILD:
854 getattr(self, '%s_file' % format_).build_delay()
855 for child in self.children.all():
856 child.parent_cover_changed()
858 def other_versions(self):
859 """Find other versions (i.e. in other languages) of the book."""
860 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
865 while parent is not None:
866 books.insert(0, parent)
867 parent = parent.parent
870 def pretty_title(self, html_links=False):
871 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
872 books = self.parents() + [self]
873 names.extend([(b.title, b.get_absolute_url()) for b in books])
876 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
878 names = [tag[0] for tag in names]
879 return ', '.join(names)
882 publisher = self.get_extra_info_json()['publisher']
883 if isinstance(publisher, str):
885 elif isinstance(publisher, list):
886 return ', '.join(publisher)
889 def tagged_top_level(cls, tags):
890 """ Returns top-level books tagged with `tags`.
892 It only returns those books which don't have ancestors which are
893 also tagged with those tags.
896 objects = cls.tagged.with_all(tags)
897 return objects.filter(findable=True).exclude(ancestor__in=objects)
900 def book_list(cls, book_filter=None):
901 """Generates a hierarchical listing of all books.
903 Books are optionally filtered with a test function.
908 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
910 books = books.filter(book_filter).distinct()
912 book_ids = set(b['pk'] for b in books.values("pk").iterator())
913 for book in books.iterator():
914 parent = book.parent_id
915 if parent not in book_ids:
917 books_by_parent.setdefault(parent, []).append(book)
919 for book in books.iterator():
920 books_by_parent.setdefault(book.parent_id, []).append(book)
923 books_by_author = OrderedDict()
924 for tag in Tag.objects.filter(category='author').iterator():
925 books_by_author[tag] = []
927 for book in books_by_parent.get(None, ()):
928 authors = list(book.authors().only('pk'))
930 for author in authors:
931 books_by_author[author].append(book)
935 return books_by_author, orphans, books_by_parent
938 "SP": (1, "szkoła podstawowa"),
939 "SP1": (1, "szkoła podstawowa"),
940 "SP2": (1, "szkoła podstawowa"),
941 "SP3": (1, "szkoła podstawowa"),
942 "P": (1, "szkoła podstawowa"),
943 "G": (2, "gimnazjum"),
948 def audiences_pl(self):
949 audiences = self.get_extra_info_json().get('audiences', [])
950 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
951 return [a[1] for a in audiences]
953 def stage_note(self):
954 stage = self.get_extra_info_json().get('stage')
955 if stage and stage < '0.4':
956 return (_('Ten utwór wymaga uwspółcześnienia'),
957 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
961 def choose_fragments(self, number):
962 fragments = self.fragments.order_by()
963 fragments_count = fragments.count()
964 if not fragments_count and self.children.exists():
965 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
966 fragments_count = fragments.count()
968 if fragments_count > number:
969 offset = randint(0, fragments_count - number)
972 return fragments[offset : offset + number]
974 return self.parent.choose_fragments(number)
978 def choose_fragment(self):
979 fragments = self.choose_fragments(1)
985 def fragment_data(self):
986 fragment = self.choose_fragment()
989 'title': fragment.book.pretty_title(),
990 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
995 def update_popularity(self):
996 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
998 pop = self.popularity
1001 except BookPopularity.DoesNotExist:
1002 BookPopularity.objects.create(book=self, count=count)
1004 def ridero_link(self):
1005 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
1007 def like(self, user):
1008 from social.utils import likes, get_set, set_sets
1009 if not likes(user, self):
1010 tag = get_set(user, '')
1011 set_sets(user, self, [tag])
1013 def unlike(self, user):
1014 from social.utils import likes, set_sets
1015 if likes(user, self):
1016 set_sets(user, self, [])
1018 def full_sort_key(self):
1019 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1021 def cover_color(self):
1022 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1024 @cached_render('catalogue/book_mini_box.html')
1030 @cached_render('catalogue/book_mini_box.html')
1031 def mini_box_nolink(self):
1038 class BookPopularity(models.Model):
1039 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1040 count = models.IntegerField(default=0, db_index=True)