1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. 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(_('title'), max_length=32767)
39 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
40 sort_key_author = models.CharField(
41 _('sort key by author'), 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(_('slug'), max_length=120, db_index=True)
44 language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
45 description = models.TextField(_('description'), blank=True)
46 abstract = models.TextField(_('abstract'), blank=True)
47 toc = models.TextField(_('toc'), blank=True)
48 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
49 changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
50 parent_number = models.IntegerField(_('parent number'), default=0)
51 extra_info = models.TextField(_('extra information'), 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(_('print on demand'), default=False)
55 recommended = models.BooleanField(_('recommended'), default=False)
56 audio_length = models.CharField(_('audio length'), blank=True, max_length=8)
57 preview = models.BooleanField(_('preview'), default=False)
58 preview_until = models.DateField(_('preview until'), blank=True, null=True)
59 preview_key = models.CharField(max_length=32, blank=True, null=True)
60 findable = models.BooleanField(_('findable'), 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(_('cover'), storage=bofh_storage)
72 # Cleaner version of cover for thumbs
73 cover_clean = fields.CoverCleanField(_('clean cover'))
74 cover_thumb = fields.CoverThumbField(_('cover thumbnail'))
75 cover_api_thumb = fields.CoverApiThumbField(
76 _('cover thumbnail for mobile app'))
77 simple_cover = fields.SimpleCoverField(_('cover for mobile app'))
78 cover_ebookpoint = fields.CoverEbookpointField(
79 _('cover for 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 = _('book')
108 verbose_name_plural = _('books')
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 = _('description')
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):
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(_('Book "%s" does not exist.') % 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(_('Book %s already exists') % 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 book.extra_info = json.dumps(book_info.to_dict())
649 meta_tags = Tag.tags_from_info(book_info)
651 for tag in meta_tags:
652 if not tag.for_books:
656 book.tags = set(meta_tags + book_shelves)
657 book.save() # update sort_key_author
659 cover_changed = old_cover != book.cover_info()
660 obsolete_children = set(b for b in book.children.all()
661 if b not in children)
662 notify_cover_changed = []
663 for n, child_book in enumerate(children):
664 new_child = child_book.parent != book
665 child_book.parent = book
666 child_book.parent_number = n
668 if new_child or cover_changed:
669 notify_cover_changed.append(child_book)
670 # Disown unfaithful children and let them cope on their own.
671 for child in obsolete_children:
673 child.parent_number = 0
676 notify_cover_changed.append(child)
678 cls.repopulate_ancestors()
679 tasks.update_counters.delay()
681 if remote_gallery_url:
682 book.download_pictures(remote_gallery_url)
684 # No saves beyond this point.
687 if 'cover' not in dont_build:
688 book.cover.build_delay()
689 book.cover_clean.build_delay()
690 book.cover_thumb.build_delay()
691 book.cover_api_thumb.build_delay()
692 book.simple_cover.build_delay()
693 book.cover_ebookpoint.build_delay()
695 # Build HTML and ebooks.
696 book.html_file.build_delay()
698 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
699 if format_ not in dont_build:
700 getattr(book, '%s_file' % format_).build_delay()
701 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
702 if format_ not in dont_build:
703 getattr(book, '%s_file' % format_).build_delay()
705 if not settings.NO_SEARCH_INDEX and search_index and findable:
706 tasks.index_book.delay(book.id)
708 for child in notify_cover_changed:
709 child.parent_cover_changed()
711 book.update_popularity()
712 tasks.update_references.delay(book.id)
714 cls.published.send(sender=cls, instance=book)
717 def update_references(self):
718 Entity = apps.get_model('references', 'Entity')
719 doc = self.wldocument2()
720 doc._compat_assign_section_ids()
721 doc._compat_assign_ordered_ids()
723 for ref_elem in doc.references():
724 uri = ref_elem.attrib.get('href', '')
730 entity, entity_created = Entity.objects.get_or_create(uri=uri)
734 ref, ref_created = entity.reference_set.get_or_create(book=self)
737 ref.occurence_set.all().delete()
738 sec = ref_elem.get_link()
739 m = re.match(r'sec(\d+)', sec)
741 sec = int(m.group(1))
742 snippet = ref_elem.get_snippet()
743 b = builders['html-snippet']()
746 html = b.output().get_bytes().decode('utf-8')
748 ref.occurence_set.create(
752 self.reference_set.exclude(entity__uri__in=refs).delete()
755 def references(self):
756 return self.reference_set.all().select_related('entity')
760 def repopulate_ancestors(cls):
761 """Fixes the ancestry cache."""
763 cursor = connection.cursor()
764 if connection.vendor == 'postgres':
765 cursor.execute("TRUNCATE catalogue_book_ancestor")
767 WITH RECURSIVE ancestry AS (
768 SELECT book.id, book.parent_id
769 FROM catalogue_book AS book
770 WHERE book.parent_id IS NOT NULL
772 SELECT ancestor.id, book.parent_id
773 FROM ancestry AS ancestor, catalogue_book AS book
774 WHERE ancestor.parent_id = book.id
775 AND book.parent_id IS NOT NULL
777 INSERT INTO catalogue_book_ancestor
778 (from_book_id, to_book_id)
784 cursor.execute("DELETE FROM catalogue_book_ancestor")
785 for b in cls.objects.exclude(parent=None):
787 while parent is not None:
788 b.ancestor.add(parent)
789 parent = parent.parent
794 for anc in self.parent.ancestors:
800 def clear_cache(self):
801 clear_cached_renders(self.mini_box)
802 clear_cached_renders(self.mini_box_nolink)
804 def cover_info(self, inherit=True):
805 """Returns a dictionary to serve as fallback for BookInfo.
807 For now, the only thing inherited is the cover image.
811 for field in ('cover_url', 'cover_by', 'cover_source'):
812 val = self.get_extra_info_json().get(field)
817 if inherit and need and self.parent is not None:
818 parent_info = self.parent.cover_info()
819 parent_info.update(info)
823 def related_themes(self):
824 return Tag.objects.usage_for_queryset(
825 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
826 counts=True).filter(category='theme').order_by('-count')
828 def parent_cover_changed(self):
829 """Called when parent book's cover image is changed."""
830 if not self.cover_info(inherit=False):
831 if 'cover' not in app_settings.DONT_BUILD:
832 self.cover.build_delay()
833 self.cover_clean.build_delay()
834 self.cover_thumb.build_delay()
835 self.cover_api_thumb.build_delay()
836 self.simple_cover.build_delay()
837 self.cover_ebookpoint.build_delay()
838 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
839 if format_ not in app_settings.DONT_BUILD:
840 getattr(self, '%s_file' % format_).build_delay()
841 for child in self.children.all():
842 child.parent_cover_changed()
844 def other_versions(self):
845 """Find other versions (i.e. in other languages) of the book."""
846 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
851 while parent is not None:
852 books.insert(0, parent)
853 parent = parent.parent
856 def pretty_title(self, html_links=False):
857 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
858 books = self.parents() + [self]
859 names.extend([(b.title, b.get_absolute_url()) for b in books])
862 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
864 names = [tag[0] for tag in names]
865 return ', '.join(names)
868 publisher = self.get_extra_info_json()['publisher']
869 if isinstance(publisher, str):
871 elif isinstance(publisher, list):
872 return ', '.join(publisher)
875 def tagged_top_level(cls, tags):
876 """ Returns top-level books tagged with `tags`.
878 It only returns those books which don't have ancestors which are
879 also tagged with those tags.
882 objects = cls.tagged.with_all(tags)
883 return objects.filter(findable=True).exclude(ancestor__in=objects)
886 def book_list(cls, book_filter=None):
887 """Generates a hierarchical listing of all books.
889 Books are optionally filtered with a test function.
894 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
896 books = books.filter(book_filter).distinct()
898 book_ids = set(b['pk'] for b in books.values("pk").iterator())
899 for book in books.iterator():
900 parent = book.parent_id
901 if parent not in book_ids:
903 books_by_parent.setdefault(parent, []).append(book)
905 for book in books.iterator():
906 books_by_parent.setdefault(book.parent_id, []).append(book)
909 books_by_author = OrderedDict()
910 for tag in Tag.objects.filter(category='author').iterator():
911 books_by_author[tag] = []
913 for book in books_by_parent.get(None, ()):
914 authors = list(book.authors().only('pk'))
916 for author in authors:
917 books_by_author[author].append(book)
921 return books_by_author, orphans, books_by_parent
924 "SP": (1, "szkoła podstawowa"),
925 "SP1": (1, "szkoła podstawowa"),
926 "SP2": (1, "szkoła podstawowa"),
927 "SP3": (1, "szkoła podstawowa"),
928 "P": (1, "szkoła podstawowa"),
929 "G": (2, "gimnazjum"),
934 def audiences_pl(self):
935 audiences = self.get_extra_info_json().get('audiences', [])
936 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
937 return [a[1] for a in audiences]
939 def stage_note(self):
940 stage = self.get_extra_info_json().get('stage')
941 if stage and stage < '0.4':
942 return (_('This work needs modernisation'),
943 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
947 def choose_fragments(self, number):
948 fragments = self.fragments.order_by()
949 fragments_count = fragments.count()
950 if not fragments_count and self.children.exists():
951 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
952 fragments_count = fragments.count()
954 if fragments_count > number:
955 offset = randint(0, fragments_count - number)
958 return fragments[offset : offset + number]
960 return self.parent.choose_fragments(number)
964 def choose_fragment(self):
965 fragments = self.choose_fragments(1)
971 def fragment_data(self):
972 fragment = self.choose_fragment()
975 'title': fragment.book.pretty_title(),
976 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
981 def update_popularity(self):
982 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
984 pop = self.popularity
987 except BookPopularity.DoesNotExist:
988 BookPopularity.objects.create(book=self, count=count)
990 def ridero_link(self):
991 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
993 def like(self, user):
994 from social.utils import likes, get_set, set_sets
995 if not likes(user, self):
996 tag = get_set(user, '')
997 set_sets(user, self, [tag])
999 def unlike(self, user):
1000 from social.utils import likes, set_sets
1001 if likes(user, self):
1002 set_sets(user, self, [])
1004 def full_sort_key(self):
1005 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1007 def cover_color(self):
1008 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1010 @cached_render('catalogue/book_mini_box.html')
1016 @cached_render('catalogue/book_mini_box.html')
1017 def mini_box_nolink(self):
1024 class BookPopularity(models.Model):
1025 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1026 count = models.IntegerField(default=0, db_index=True)