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)
94 translators = models.ManyToManyField(Tag, blank=True)
96 html_built = django.dispatch.Signal()
97 published = django.dispatch.Signal()
103 class AlreadyExists(Exception):
107 ordering = ('sort_key_author', 'sort_key')
108 verbose_name = 'książka'
109 verbose_name_plural = 'książki'
110 app_label = 'catalogue'
115 def get_extra_info_json(self):
116 return json.loads(self.extra_info or '{}')
118 def get_initial(self):
120 return re.search(r'\w', self.title, re.U).group(0)
121 except AttributeError:
125 return self.tags.filter(category='author')
128 return self.tags.filter(category='epoch')
131 return self.tags.filter(category='genre')
134 return self.tags.filter(category='kind')
136 def tag_unicode(self, category):
137 relations = prefetched_relations(self, category)
139 return ', '.join(rel.tag.name for rel in relations)
141 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
143 def tags_by_category(self):
144 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
146 def author_unicode(self):
147 return self.cached_author
149 def kind_unicode(self):
150 return self.tag_unicode('kind')
152 def epoch_unicode(self):
153 return self.tag_unicode('epoch')
155 def genre_unicode(self):
156 return self.tag_unicode('genre')
158 def translator(self):
159 translators = self.get_extra_info_json().get('translators')
162 if len(translators) > 3:
163 translators = translators[:2]
167 return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
169 def cover_source(self):
170 return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
174 return self.get_extra_info_json().get('isbn_pdf')
178 return self.get_extra_info_json().get('isbn_epub')
182 return self.get_extra_info_json().get('isbn_mobi')
184 def is_accessible_to(self, user):
187 if not user.is_authenticated:
189 Membership = apps.get_model('club', 'Membership')
190 if Membership.is_active_for(user):
192 Funding = apps.get_model('funding', 'Funding')
193 if Funding.objects.filter(user=user, offer__book=self):
197 def save(self, force_insert=False, force_update=False, **kwargs):
198 from sortify import sortify
200 self.sort_key = sortify(self.title)[:120]
201 self.title = str(self.title) # ???
204 author = self.authors().first().sort_key
205 except AttributeError:
207 self.sort_key_author = author
209 self.cached_author = self.tag_unicode('author')
210 self.has_audience = 'audience' in self.get_extra_info_json()
212 if self.preview and not self.preview_key:
213 self.preview_key = get_random_hash(self.slug)[:32]
215 ret = super(Book, self).save(force_insert, force_update, **kwargs)
219 def get_absolute_url(self):
220 return reverse('book_detail', args=[self.slug])
222 def gallery_path(self):
223 return gallery_path(self.slug)
225 def gallery_url(self):
226 return gallery_url(self.slug)
228 def get_first_text(self):
231 child = self.children.all().order_by('parent_number').first()
232 if child is not None:
233 return child.get_first_text()
235 def get_last_text(self):
238 child = self.children.all().order_by('parent_number').last()
239 if child is not None:
240 return child.get_last_text()
242 def get_prev_text(self):
245 sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
246 if sibling is not None:
247 return sibling.get_last_text()
249 if self.parent.html_file:
252 return self.parent.get_prev_text()
254 def get_next_text(self, inside=True):
256 child = self.children.order_by('parent_number').first()
257 if child is not None:
258 return child.get_first_text()
262 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
263 if sibling is not None:
264 return sibling.get_first_text()
265 return self.parent.get_next_text(inside=False)
267 def get_child_audiobook(self):
268 BookMedia = apps.get_model('catalogue', 'BookMedia')
269 if not BookMedia.objects.filter(book__ancestor=self).exists():
271 for child in self.children.order_by('parent_number').all():
272 if child.has_mp3_file():
274 child_sub = child.get_child_audiobook()
275 if child_sub is not None:
278 def get_siblings(self):
281 return self.parent.children.all().order_by('parent_number')
283 def get_children(self):
284 return self.children.all().order_by('parent_number')
290 def language_code(self):
291 return constants.LANGUAGES_3TO2.get(self.language, self.language)
293 def language_name(self):
294 return dict(settings.LANGUAGES).get(self.language_code(), "")
296 def is_foreign(self):
297 return self.language_code() != settings.LANGUAGE_CODE
299 def set_audio_length(self):
300 length = self.get_audio_length()
302 self.audio_length = self.format_audio_length(length)
306 def format_audio_length(seconds):
308 >>> Book.format_audio_length(1)
310 >>> Book.format_audio_length(3661)
314 minutes = seconds // 60
315 seconds = seconds % 60
316 return '%d:%02d' % (minutes, seconds)
318 hours = seconds // 3600
319 minutes = seconds % 3600 // 60
320 seconds = seconds % 60
321 return '%d:%02d:%02d' % (hours, minutes, seconds)
323 def get_audio_length(self):
325 for media in self.get_mp3() or ():
326 total += app_settings.GET_MP3_LENGTH(media.file.path)
330 return round(self.xml_file.size / 1000 * 40)
332 def has_media(self, type_):
333 if type_ in Book.formats:
334 return bool(getattr(self, "%s_file" % type_))
336 return self.media.filter(type=type_).exists()
339 return self.has_media('mp3')
341 def get_media(self, type_):
342 if self.has_media(type_):
343 if type_ in Book.formats:
344 return getattr(self, "%s_file" % type_)
346 return self.media.filter(type=type_)
351 return self.get_media("mp3")
354 return self.get_media("odt")
357 return self.get_media("ogg")
360 return self.get_media("daisy")
362 def get_audio_epub(self):
363 return self.get_media("audio.epub")
365 def media_url(self, format_):
366 media = self.get_media(format_)
369 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
376 return self.media_url('html')
379 return self.media_url('pdf')
382 return self.media_url('epub')
385 return self.media_url('mobi')
388 return self.media_url('txt')
391 return self.media_url('fb2')
394 return self.media_url('xml')
396 def has_description(self):
397 return len(self.description) > 0
398 has_description.short_description = 'opis'
399 has_description.boolean = True
401 def has_mp3_file(self):
402 return self.has_media("mp3")
403 has_mp3_file.short_description = 'MP3'
404 has_mp3_file.boolean = True
406 def has_ogg_file(self):
407 return self.has_media("ogg")
408 has_ogg_file.short_description = 'OGG'
409 has_ogg_file.boolean = True
411 def has_daisy_file(self):
412 return self.has_media("daisy")
413 has_daisy_file.short_description = 'DAISY'
414 has_daisy_file.boolean = True
416 def has_sync_file(self):
417 return settings.FEATURE_SYNCHRO and self.has_media("sync")
420 with self.get_media('sync').first().file.open('r') as f:
421 sync = f.read().split('\n')
422 offset = float(sync[0])
424 for line in sync[1:]:
427 start, end, elid = line.split()
428 items.append([elid, float(start) + offset])
429 return json.dumps(items)
431 def has_audio_epub_file(self):
432 return self.has_media("audio.epub")
435 def media_daisy(self):
436 return self.get_media('daisy')
439 def media_audio_epub(self):
440 return self.get_media('audio.epub')
442 def get_audiobooks(self):
444 for m in self.media.filter(type='ogg').order_by().iterator():
445 ogg_files[m.name] = m
450 for mp3 in self.media.filter(type='mp3').iterator():
451 # ogg files are always from the same project
452 meta = mp3.get_extra_info_json()
453 project = meta.get('project')
456 project = 'CzytamySłuchając'
458 projects.add((project, meta.get('funded_by', '')))
459 total_duration += mp3.duration or 0
463 ogg = ogg_files.get(mp3.name)
466 audiobooks.append(media)
468 projects = sorted(projects)
469 total_duration = '%d:%02d' % (
470 total_duration // 60,
473 return audiobooks, projects, total_duration
475 def wldocument(self, parse_dublincore=True, inherit=True):
476 from catalogue.import_utils import ORMDocProvider
477 from librarian.parser import WLDocument
479 if inherit and self.parent:
480 meta_fallbacks = self.parent.cover_info()
482 meta_fallbacks = None
484 return WLDocument.from_file(
486 provider=ORMDocProvider(self),
487 parse_dublincore=parse_dublincore,
488 meta_fallbacks=meta_fallbacks)
490 def wldocument2(self):
491 from catalogue.import_utils import ORMDocProvider
492 from librarian.document import WLDocument
495 provider=ORMDocProvider(self)
497 doc.meta.update(self.cover_info())
502 def zip_format(format_):
503 def pretty_file_name(book):
504 return "%s/%s.%s" % (
505 book.get_extra_info_json()['author'],
509 field_name = "%s_file" % format_
510 field = getattr(Book, field_name)
511 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
512 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
513 return create_zip(paths, field.ZIP)
515 def zip_audiobooks(self, format_):
516 bm = BookMedia.objects.filter(book=self, type=format_)
517 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
520 license = constants.LICENSES.get(
521 m.get_extra_info_json().get('license'), {}
524 licenses.add(license)
525 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
526 'licenses': licenses,
527 'meta': self.wldocument2().meta,
529 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
531 def search_index(self, index=None):
532 if not self.findable:
534 from search.index import Index
535 Index.index_book(self)
537 # will make problems in conjunction with paid previews
538 def download_pictures(self, remote_gallery_url):
539 # This is only needed for legacy relative image paths.
540 gallery_path = self.gallery_path()
541 # delete previous files, so we don't include old files in ebooks
542 if os.path.isdir(gallery_path):
543 for filename in os.listdir(gallery_path):
544 file_path = os.path.join(gallery_path, filename)
546 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
548 makedirs(gallery_path)
549 for ilustr in ilustr_elements:
550 ilustr_src = ilustr.get('src')
551 if '/' in ilustr_src:
553 ilustr_path = os.path.join(gallery_path, ilustr_src)
554 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
556 def load_abstract(self):
557 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
558 if abstract is not None:
559 self.abstract = transform_abstrakt(abstract)
566 parser = html.HTMLParser(encoding='utf-8')
567 tree = html.parse(self.html_file.path, parser=parser)
568 toc = tree.find('//div[@id="toc"]/ol')
569 if toc is None or not len(toc):
571 html_link = reverse('book_text', args=[self.slug])
572 for a in toc.findall('.//a'):
573 a.attrib['href'] = html_link + a.attrib['href']
574 self.toc = html.tostring(toc, encoding='unicode')
578 def from_xml_file(cls, xml_file, **kwargs):
579 from django.core.files import File
580 from librarian import dcparser
582 # use librarian to parse meta-data
583 book_info = dcparser.parse(xml_file)
585 if not isinstance(xml_file, File):
586 xml_file = File(open(xml_file))
589 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
594 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
595 remote_gallery_url=None, days=0, findable=True, logo=None, logo_mono=None, logo_alt=None):
596 from catalogue import tasks
598 if dont_build is None:
600 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
602 # check for parts before we do anything
604 if hasattr(book_info, 'parts'):
605 for part_url in book_info.parts:
607 children.append(Book.objects.get(slug=part_url.slug))
608 except Book.DoesNotExist:
609 raise Book.DoesNotExist('Książka "%s" nie istnieje.' % part_url.slug)
612 book_slug = book_info.url.slug
613 if re.search(r'[^a-z0-9-]', book_slug):
614 raise ValueError('Invalid characters in slug')
615 book, created = Book.objects.get_or_create(slug=book_slug)
620 book.preview = bool(days)
622 book.preview_until = date.today() + timedelta(days)
625 raise Book.AlreadyExists('Książka %s już istnieje' % book_slug)
626 # Save shelves for this book
627 book_shelves = list(book.tags.filter(category='set'))
628 old_cover = book.cover_info()
631 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
633 book.xml_file.set_readable(False)
635 book.findable = findable
636 book.language = book_info.language
637 book.title = book_info.title
638 if book_info.variant_of:
639 book.common_slug = book_info.variant_of.slug
641 book.common_slug = book.slug
642 extra = book_info.to_dict()
646 extra['logo_mono'] = logo_mono
648 extra['logo_alt'] = logo_alt
649 book.extra_info = json.dumps(extra)
654 meta_tags = Tag.tags_from_info(book_info)
656 for tag, relationship in meta_tags:
657 if not tag.for_books:
661 just_tags = [t for (t, rel) in meta_tags if not rel]
662 book.tags = set(just_tags + book_shelves)
663 book.save() # update sort_key_author
665 book.translators.set([t for (t, rel) in meta_tags if rel == 'translator'])
667 cover_changed = old_cover != book.cover_info()
668 obsolete_children = set(b for b in book.children.all()
669 if b not in children)
670 notify_cover_changed = []
671 for n, child_book in enumerate(children):
672 new_child = child_book.parent != book
673 child_book.parent = book
674 child_book.parent_number = n
676 if new_child or cover_changed:
677 notify_cover_changed.append(child_book)
678 # Disown unfaithful children and let them cope on their own.
679 for child in obsolete_children:
681 child.parent_number = 0
684 notify_cover_changed.append(child)
686 cls.repopulate_ancestors()
687 tasks.update_counters.delay()
689 if remote_gallery_url:
690 book.download_pictures(remote_gallery_url)
692 # No saves beyond this point.
695 if 'cover' not in dont_build:
696 book.cover.build_delay()
697 book.cover_clean.build_delay()
698 book.cover_thumb.build_delay()
699 book.cover_api_thumb.build_delay()
700 book.simple_cover.build_delay()
701 book.cover_ebookpoint.build_delay()
703 # Build HTML and ebooks.
704 book.html_file.build_delay()
706 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
707 if format_ not in dont_build:
708 getattr(book, '%s_file' % format_).build_delay()
709 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
710 if format_ not in dont_build:
711 getattr(book, '%s_file' % format_).build_delay()
713 if not settings.NO_SEARCH_INDEX and search_index and findable:
714 tasks.index_book.delay(book.id)
716 for child in notify_cover_changed:
717 child.parent_cover_changed()
719 book.update_popularity()
720 tasks.update_references.delay(book.id)
722 cls.published.send(sender=cls, instance=book)
725 def update_references(self):
726 Entity = apps.get_model('references', 'Entity')
727 doc = self.wldocument2()
728 doc._compat_assign_section_ids()
729 doc._compat_assign_ordered_ids()
731 for ref_elem in doc.references():
732 uri = ref_elem.attrib.get('href', '')
738 entity, entity_created = Entity.objects.get_or_create(uri=uri)
746 ref, ref_created = entity.reference_set.get_or_create(book=self)
749 ref.occurence_set.all().delete()
750 sec = ref_elem.get_link()
751 m = re.match(r'sec(\d+)', sec)
753 sec = int(m.group(1))
754 snippet = ref_elem.get_snippet()
755 b = builders['html-snippet']()
758 html = b.output().get_bytes().decode('utf-8')
760 ref.occurence_set.create(
764 self.reference_set.exclude(entity__uri__in=refs).delete()
767 def references(self):
768 return self.reference_set.all().select_related('entity')
772 def repopulate_ancestors(cls):
773 """Fixes the ancestry cache."""
775 cursor = connection.cursor()
776 if connection.vendor == 'postgres':
777 cursor.execute("TRUNCATE catalogue_book_ancestor")
779 WITH RECURSIVE ancestry AS (
780 SELECT book.id, book.parent_id
781 FROM catalogue_book AS book
782 WHERE book.parent_id IS NOT NULL
784 SELECT ancestor.id, book.parent_id
785 FROM ancestry AS ancestor, catalogue_book AS book
786 WHERE ancestor.parent_id = book.id
787 AND book.parent_id IS NOT NULL
789 INSERT INTO catalogue_book_ancestor
790 (from_book_id, to_book_id)
796 cursor.execute("DELETE FROM catalogue_book_ancestor")
797 for b in cls.objects.exclude(parent=None):
799 while parent is not None:
800 b.ancestor.add(parent)
801 parent = parent.parent
806 for anc in self.parent.ancestors:
812 def clear_cache(self):
813 clear_cached_renders(self.mini_box)
814 clear_cached_renders(self.mini_box_nolink)
816 def cover_info(self, inherit=True):
817 """Returns a dictionary to serve as fallback for BookInfo.
819 For now, the only thing inherited is the cover image.
823 for field in ('cover_url', 'cover_by', 'cover_source'):
824 val = self.get_extra_info_json().get(field)
829 if inherit and need and self.parent is not None:
830 parent_info = self.parent.cover_info()
831 parent_info.update(info)
835 def related_themes(self):
836 return Tag.objects.usage_for_queryset(
837 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
838 counts=True).filter(category='theme').order_by('-count')
840 def parent_cover_changed(self):
841 """Called when parent book's cover image is changed."""
842 if not self.cover_info(inherit=False):
843 if 'cover' not in app_settings.DONT_BUILD:
844 self.cover.build_delay()
845 self.cover_clean.build_delay()
846 self.cover_thumb.build_delay()
847 self.cover_api_thumb.build_delay()
848 self.simple_cover.build_delay()
849 self.cover_ebookpoint.build_delay()
850 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
851 if format_ not in app_settings.DONT_BUILD:
852 getattr(self, '%s_file' % format_).build_delay()
853 for child in self.children.all():
854 child.parent_cover_changed()
856 def other_versions(self):
857 """Find other versions (i.e. in other languages) of the book."""
858 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
863 while parent is not None:
864 books.insert(0, parent)
865 parent = parent.parent
868 def pretty_title(self, html_links=False):
869 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
870 books = self.parents() + [self]
871 names.extend([(b.title, b.get_absolute_url()) for b in books])
874 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
876 names = [tag[0] for tag in names]
877 return ', '.join(names)
880 publisher = self.get_extra_info_json()['publisher']
881 if isinstance(publisher, str):
883 elif isinstance(publisher, list):
884 return ', '.join(publisher)
887 def tagged_top_level(cls, tags):
888 """ Returns top-level books tagged with `tags`.
890 It only returns those books which don't have ancestors which are
891 also tagged with those tags.
894 objects = cls.tagged.with_all(tags)
895 return objects.filter(findable=True).exclude(ancestor__in=objects)
898 def book_list(cls, book_filter=None):
899 """Generates a hierarchical listing of all books.
901 Books are optionally filtered with a test function.
906 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
908 books = books.filter(book_filter).distinct()
910 book_ids = set(b['pk'] for b in books.values("pk").iterator())
911 for book in books.iterator():
912 parent = book.parent_id
913 if parent not in book_ids:
915 books_by_parent.setdefault(parent, []).append(book)
917 for book in books.iterator():
918 books_by_parent.setdefault(book.parent_id, []).append(book)
921 books_by_author = OrderedDict()
922 for tag in Tag.objects.filter(category='author').iterator():
923 books_by_author[tag] = []
925 for book in books_by_parent.get(None, ()):
926 authors = list(book.authors().only('pk'))
928 for author in authors:
929 books_by_author[author].append(book)
933 return books_by_author, orphans, books_by_parent
936 "SP": (1, "szkoła podstawowa"),
937 "SP1": (1, "szkoła podstawowa"),
938 "SP2": (1, "szkoła podstawowa"),
939 "SP3": (1, "szkoła podstawowa"),
940 "P": (1, "szkoła podstawowa"),
941 "G": (2, "gimnazjum"),
946 def audiences_pl(self):
947 audiences = self.get_extra_info_json().get('audiences', [])
948 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
949 return [a[1] for a in audiences]
951 def stage_note(self):
952 stage = self.get_extra_info_json().get('stage')
953 if stage and stage < '0.4':
954 return (_('Ten utwór wymaga uwspółcześnienia'),
955 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
959 def choose_fragments(self, number):
960 fragments = self.fragments.order_by()
961 fragments_count = fragments.count()
962 if not fragments_count and self.children.exists():
963 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
964 fragments_count = fragments.count()
966 if fragments_count > number:
967 offset = randint(0, fragments_count - number)
970 return fragments[offset : offset + number]
972 return self.parent.choose_fragments(number)
976 def choose_fragment(self):
977 fragments = self.choose_fragments(1)
983 def fragment_data(self):
984 fragment = self.choose_fragment()
987 'title': fragment.book.pretty_title(),
988 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
993 def update_popularity(self):
994 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
996 pop = self.popularity
999 except BookPopularity.DoesNotExist:
1000 BookPopularity.objects.create(book=self, count=count)
1002 def ridero_link(self):
1003 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
1005 def like(self, user):
1006 from social.utils import likes, get_set, set_sets
1007 if not likes(user, self):
1008 tag = get_set(user, '')
1009 set_sets(user, self, [tag])
1011 def unlike(self, user):
1012 from social.utils import likes, set_sets
1013 if likes(user, self):
1014 set_sets(user, self, [])
1016 def full_sort_key(self):
1017 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1019 def cover_color(self):
1020 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1022 @cached_render('catalogue/book_mini_box.html')
1028 @cached_render('catalogue/book_mini_box.html')
1029 def mini_box_nolink(self):
1036 class BookPopularity(models.Model):
1037 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1038 count = models.IntegerField(default=0, db_index=True)