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.builders.html import AbstraktHtmlBuilder
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)
331 return round(self.xml_file.size / 1000 * 40)
335 def has_media(self, type_):
336 if type_ in Book.formats:
337 return bool(getattr(self, "%s_file" % type_))
339 return self.media.filter(type=type_).exists()
342 return self.has_media('mp3')
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')
382 return self.media_url('pdf')
385 return self.media_url('epub')
388 return self.media_url('mobi')
391 return self.media_url('txt')
394 return self.media_url('fb2')
397 return self.media_url('xml')
399 def has_description(self):
400 return len(self.description) > 0
401 has_description.short_description = 'opis'
402 has_description.boolean = True
404 def has_mp3_file(self):
405 return self.has_media("mp3")
406 has_mp3_file.short_description = 'MP3'
407 has_mp3_file.boolean = True
409 def has_ogg_file(self):
410 return self.has_media("ogg")
411 has_ogg_file.short_description = 'OGG'
412 has_ogg_file.boolean = True
414 def has_daisy_file(self):
415 return self.has_media("daisy")
416 has_daisy_file.short_description = 'DAISY'
417 has_daisy_file.boolean = True
419 def has_sync_file(self):
420 return settings.FEATURE_SYNCHRO and self.has_media("sync")
423 with self.get_media('sync').first().file.open('r') as f:
424 sync = f.read().split('\n')
425 offset = float(sync[0])
427 for line in sync[1:]:
430 start, end, elid = line.split()
431 items.append([elid, float(start) + offset])
432 return json.dumps(items)
434 def has_audio_epub_file(self):
435 return self.has_media("audio.epub")
438 def media_daisy(self):
439 return self.get_media('daisy')
442 def media_audio_epub(self):
443 return self.get_media('audio.epub')
445 def get_audiobooks(self):
447 for m in self.media.filter(type='ogg').order_by().iterator():
448 ogg_files[m.name] = m
453 for mp3 in self.media.filter(type='mp3').iterator():
454 # ogg files are always from the same project
455 meta = mp3.get_extra_info_json()
456 project = meta.get('project')
459 project = 'CzytamySłuchając'
461 projects.add((project, meta.get('funded_by', '')))
462 total_duration += mp3.duration or 0
466 ogg = ogg_files.get(mp3.name)
469 audiobooks.append(media)
471 projects = sorted(projects)
472 total_duration = '%d:%02d' % (
473 total_duration // 60,
476 return audiobooks, projects, total_duration
478 def wldocument(self, parse_dublincore=True, inherit=True):
479 from catalogue.import_utils import ORMDocProvider
480 from librarian.parser import WLDocument
482 if inherit and self.parent:
483 meta_fallbacks = self.parent.cover_info()
485 meta_fallbacks = None
487 return WLDocument.from_file(
489 provider=ORMDocProvider(self),
490 parse_dublincore=parse_dublincore,
491 meta_fallbacks=meta_fallbacks)
493 def wldocument2(self):
494 from catalogue.import_utils import ORMDocProvider
495 from librarian.document import WLDocument
498 provider=ORMDocProvider(self)
500 doc.meta.update(self.cover_info())
505 def zip_format(format_):
506 def pretty_file_name(book):
507 return "%s/%s.%s" % (
508 book.get_extra_info_json()['author'],
512 field_name = "%s_file" % format_
513 field = getattr(Book, field_name)
514 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
515 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
516 return create_zip(paths, field.ZIP)
518 def zip_audiobooks(self, format_):
519 bm = BookMedia.objects.filter(book=self, type=format_)
520 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
523 license = constants.LICENSES.get(
524 m.get_extra_info_json().get('license'), {}
527 licenses.add(license)
528 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
529 'licenses': licenses,
530 'meta': self.wldocument2().meta,
532 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
534 def search_index(self, index=None):
535 if not self.findable:
537 from search.index import Index
538 Index.index_book(self)
540 # will make problems in conjunction with paid previews
541 def download_pictures(self, remote_gallery_url):
542 # This is only needed for legacy relative image paths.
543 gallery_path = self.gallery_path()
544 # delete previous files, so we don't include old files in ebooks
545 if os.path.isdir(gallery_path):
546 for filename in os.listdir(gallery_path):
547 file_path = os.path.join(gallery_path, filename)
549 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
551 makedirs(gallery_path)
552 for ilustr in ilustr_elements:
553 ilustr_src = ilustr.get('src')
554 if '/' in ilustr_src:
556 ilustr_path = os.path.join(gallery_path, ilustr_src)
557 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
559 def load_abstract(self):
560 self.abstract = AbstraktHtmlBuilder().build(
561 self.wldocument2()).get_bytes().decode('utf-8')
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 just_tags = [t for (t, rel) in meta_tags if not rel]
657 book.tags = set(just_tags + book_shelves)
658 book.save() # update sort_key_author
660 book.translators.set([t for (t, rel) in meta_tags if rel == 'translator'])
662 cover_changed = old_cover != book.cover_info()
663 obsolete_children = set(b for b in book.children.all()
664 if b not in children)
665 notify_cover_changed = []
666 for n, child_book in enumerate(children):
667 new_child = child_book.parent != book
668 child_book.parent = book
669 child_book.parent_number = n
671 if new_child or cover_changed:
672 notify_cover_changed.append(child_book)
673 # Disown unfaithful children and let them cope on their own.
674 for child in obsolete_children:
676 child.parent_number = 0
679 notify_cover_changed.append(child)
681 cls.repopulate_ancestors()
682 tasks.update_counters.delay()
684 if remote_gallery_url:
685 book.download_pictures(remote_gallery_url)
687 # No saves beyond this point.
690 if 'cover' not in dont_build:
691 book.cover.build_delay()
692 book.cover_clean.build_delay()
693 book.cover_thumb.build_delay()
694 book.cover_api_thumb.build_delay()
695 book.simple_cover.build_delay()
696 book.cover_ebookpoint.build_delay()
698 # Build HTML and ebooks.
699 book.html_file.build_delay()
701 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
702 if format_ not in dont_build:
703 getattr(book, '%s_file' % format_).build_delay()
704 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
705 if format_ not in dont_build:
706 getattr(book, '%s_file' % format_).build_delay()
708 if not settings.NO_SEARCH_INDEX and search_index and findable:
709 tasks.index_book.delay(book.id)
711 for child in notify_cover_changed:
712 child.parent_cover_changed()
714 book.update_popularity()
715 tasks.update_references.delay(book.id)
717 cls.published.send(sender=cls, instance=book)
721 def update_references(self):
722 Entity = apps.get_model('references', 'Entity')
723 doc = self.wldocument2()
727 for i, ref_elem in enumerate(doc.references()):
728 uri = ref_elem.attrib.get('href', '')
734 entity, entity_created = Entity.objects.get_or_create(uri=uri)
742 ref, ref_created = entity.reference_set.get_or_create(book=self)
745 ref.occurence_set.all().delete()
746 anchor = ref_elem.get_link()
748 snippet = ref_elem.get_snippet()
749 b = builders['html-snippet']()
752 html = b.output().get_bytes().decode('utf-8')
754 ref.occurence_set.create(
759 self.reference_set.exclude(entity__uri__in=refs).delete()
762 def references(self):
763 return self.reference_set.all().select_related('entity')
767 def repopulate_ancestors(cls):
768 """Fixes the ancestry cache."""
770 cursor = connection.cursor()
771 if connection.vendor == 'postgres':
772 cursor.execute("TRUNCATE catalogue_book_ancestor")
774 WITH RECURSIVE ancestry AS (
775 SELECT book.id, book.parent_id
776 FROM catalogue_book AS book
777 WHERE book.parent_id IS NOT NULL
779 SELECT ancestor.id, book.parent_id
780 FROM ancestry AS ancestor, catalogue_book AS book
781 WHERE ancestor.parent_id = book.id
782 AND book.parent_id IS NOT NULL
784 INSERT INTO catalogue_book_ancestor
785 (from_book_id, to_book_id)
791 cursor.execute("DELETE FROM catalogue_book_ancestor")
792 for b in cls.objects.exclude(parent=None):
794 while parent is not None:
795 b.ancestor.add(parent)
796 parent = parent.parent
801 for anc in self.parent.ancestors:
807 def clear_cache(self):
808 clear_cached_renders(self.mini_box)
809 clear_cached_renders(self.mini_box_nolink)
811 def cover_info(self, inherit=True):
812 """Returns a dictionary to serve as fallback for BookInfo.
814 For now, the only thing inherited is the cover image.
818 for field in ('cover_url', 'cover_by', 'cover_source'):
819 val = self.get_extra_info_json().get(field)
824 if inherit and need and self.parent is not None:
825 parent_info = self.parent.cover_info()
826 parent_info.update(info)
830 def related_themes(self):
831 return Tag.objects.usage_for_queryset(
832 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
833 counts=True).filter(category='theme').order_by('-count')
835 def parent_cover_changed(self):
836 """Called when parent book's cover image is changed."""
837 if not self.cover_info(inherit=False):
838 if 'cover' not in app_settings.DONT_BUILD:
839 self.cover.build_delay()
840 self.cover_clean.build_delay()
841 self.cover_thumb.build_delay()
842 self.cover_api_thumb.build_delay()
843 self.simple_cover.build_delay()
844 self.cover_ebookpoint.build_delay()
845 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
846 if format_ not in app_settings.DONT_BUILD:
847 getattr(self, '%s_file' % format_).build_delay()
848 for child in self.children.all():
849 child.parent_cover_changed()
851 def other_versions(self):
852 """Find other versions (i.e. in other languages) of the book."""
853 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
858 while parent is not None:
859 books.insert(0, parent)
860 parent = parent.parent
863 def pretty_title(self, html_links=False):
864 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
865 books = self.parents() + [self]
866 names.extend([(b.title, b.get_absolute_url()) for b in books])
869 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
871 names = [tag[0] for tag in names]
872 return ', '.join(names)
875 publisher = self.get_extra_info_json()['publisher']
876 if isinstance(publisher, str):
878 elif isinstance(publisher, list):
879 return ', '.join(publisher)
882 def tagged_top_level(cls, tags):
883 """ Returns top-level books tagged with `tags`.
885 It only returns those books which don't have ancestors which are
886 also tagged with those tags.
889 objects = cls.tagged.with_all(tags)
890 return objects.filter(findable=True).exclude(ancestor__in=objects)
893 def book_list(cls, book_filter=None):
894 """Generates a hierarchical listing of all books.
896 Books are optionally filtered with a test function.
901 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
903 books = books.filter(book_filter).distinct()
905 book_ids = set(b['pk'] for b in books.values("pk").iterator())
906 for book in books.iterator():
907 parent = book.parent_id
908 if parent not in book_ids:
910 books_by_parent.setdefault(parent, []).append(book)
912 for book in books.iterator():
913 books_by_parent.setdefault(book.parent_id, []).append(book)
916 books_by_author = OrderedDict()
917 for tag in Tag.objects.filter(category='author').iterator():
918 books_by_author[tag] = []
920 for book in books_by_parent.get(None, ()):
921 authors = list(book.authors().only('pk'))
923 for author in authors:
924 books_by_author[author].append(book)
928 return books_by_author, orphans, books_by_parent
931 "SP": (1, "szkoła podstawowa"),
932 "SP1": (1, "szkoła podstawowa"),
933 "SP2": (1, "szkoła podstawowa"),
934 "SP3": (1, "szkoła podstawowa"),
935 "P": (1, "szkoła podstawowa"),
936 "G": (2, "gimnazjum"),
941 def audiences_pl(self):
942 audiences = self.get_extra_info_json().get('audiences', [])
943 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
944 return [a[1] for a in audiences]
946 def stage_note(self):
947 stage = self.get_extra_info_json().get('stage')
948 if stage and stage < '0.4':
949 return (_('Ten utwór wymaga uwspółcześnienia'),
950 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
954 def choose_fragments(self, number):
955 fragments = self.fragments.order_by()
956 fragments_count = fragments.count()
957 if not fragments_count and self.children.exists():
958 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
959 fragments_count = fragments.count()
961 if fragments_count > number:
962 offset = randint(0, fragments_count - number)
965 return fragments[offset : offset + number]
967 return self.parent.choose_fragments(number)
971 def choose_fragment(self):
972 fragments = self.choose_fragments(1)
978 def fragment_data(self):
979 fragment = self.choose_fragment()
982 'title': fragment.book.pretty_title(),
983 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
988 def update_popularity(self):
989 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
991 pop = self.popularity
994 except BookPopularity.DoesNotExist:
995 BookPopularity.objects.create(book=self, count=count)
997 def ridero_link(self):
998 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
1000 def like(self, user):
1001 from social.utils import likes, get_set, set_sets
1002 if not likes(user, self):
1003 tag = get_set(user, '')
1004 set_sets(user, self, [tag])
1006 def unlike(self, user):
1007 from social.utils import likes, set_sets
1008 if likes(user, self):
1009 set_sets(user, self, [])
1011 def full_sort_key(self):
1012 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1014 def cover_color(self):
1015 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1017 @cached_render('catalogue/book_mini_box.html')
1023 @cached_render('catalogue/book_mini_box.html')
1024 def mini_box_nolink(self):
1031 class BookPopularity(models.Model):
1032 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1033 count = models.IntegerField(default=0, db_index=True)