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 newtagging import managers
24 from catalogue import constants
25 from catalogue import fields
26 from catalogue.models import Tag, Fragment, BookMedia
27 from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags, get_random_hash
28 from catalogue.models.tag import prefetched_relations
29 from catalogue import app_settings
30 from wolnelektury.utils import makedirs, cached_render, clear_cached_renders
32 bofh_storage = BofhFileSystemStorage()
35 class Book(models.Model):
36 """Represents a book imported from WL-XML."""
37 title = models.CharField(_('title'), max_length=32767)
38 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
39 sort_key_author = models.CharField(
40 _('sort key by author'), max_length=120, db_index=True, editable=False, default='')
41 slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
42 common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
43 language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
44 description = models.TextField(_('description'), blank=True)
45 abstract = models.TextField(_('abstract'), blank=True)
46 toc = models.TextField(_('toc'), blank=True)
47 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
48 changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
49 parent_number = models.IntegerField(_('parent number'), default=0)
50 extra_info = models.TextField(_('extra information'), default='{}')
51 gazeta_link = models.CharField(blank=True, max_length=240)
52 wiki_link = models.CharField(blank=True, max_length=240)
53 print_on_demand = models.BooleanField(_('print on demand'), default=False)
54 recommended = models.BooleanField(_('recommended'), default=False)
55 audio_length = models.CharField(_('audio length'), blank=True, max_length=8)
56 preview = models.BooleanField(_('preview'), default=False)
57 preview_until = models.DateField(_('preview until'), blank=True, null=True)
58 preview_key = models.CharField(max_length=32, blank=True, null=True)
59 findable = models.BooleanField(_('findable'), default=True, db_index=True)
61 # files generated during publication
62 xml_file = fields.XmlField(storage=bofh_storage, with_etag=False)
63 html_file = fields.HtmlField(storage=bofh_storage)
64 fb2_file = fields.Fb2Field(storage=bofh_storage)
65 txt_file = fields.TxtField(storage=bofh_storage)
66 epub_file = fields.EpubField(storage=bofh_storage)
67 mobi_file = fields.MobiField(storage=bofh_storage)
68 pdf_file = fields.PdfField(storage=bofh_storage)
70 cover = fields.CoverField(_('cover'), storage=bofh_storage)
71 # Cleaner version of cover for thumbs
72 cover_clean = fields.CoverCleanField(_('clean cover'))
73 cover_thumb = fields.CoverThumbField(_('cover thumbnail'))
74 cover_api_thumb = fields.CoverApiThumbField(
75 _('cover thumbnail for mobile app'))
76 simple_cover = fields.SimpleCoverField(_('cover for mobile app'))
77 cover_ebookpoint = fields.CoverEbookpointField(
78 _('cover for Ebookpoint'))
80 ebook_formats = constants.EBOOK_FORMATS
81 formats = ebook_formats + ['html', 'xml']
83 parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
84 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
86 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
87 has_audience = models.BooleanField(default=False)
89 objects = models.Manager()
90 tagged = managers.ModelTaggedItemManager(Tag)
91 tags = managers.TagDescriptor(Tag)
92 tag_relations = GenericRelation(Tag.intermediary_table_model)
94 html_built = django.dispatch.Signal()
95 published = django.dispatch.Signal()
101 class AlreadyExists(Exception):
105 ordering = ('sort_key_author', 'sort_key')
106 verbose_name = _('book')
107 verbose_name_plural = _('books')
108 app_label = 'catalogue'
113 def get_extra_info_json(self):
114 return json.loads(self.extra_info or '{}')
116 def get_initial(self):
118 return re.search(r'\w', self.title, re.U).group(0)
119 except AttributeError:
123 return self.tags.filter(category='author')
126 return self.tags.filter(category='epoch')
129 return self.tags.filter(category='genre')
132 return self.tags.filter(category='kind')
134 def tag_unicode(self, category):
135 relations = prefetched_relations(self, category)
137 return ', '.join(rel.tag.name for rel in relations)
139 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
141 def tags_by_category(self):
142 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
144 def author_unicode(self):
145 return self.cached_author
147 def kind_unicode(self):
148 return self.tag_unicode('kind')
150 def epoch_unicode(self):
151 return self.tag_unicode('epoch')
153 def genre_unicode(self):
154 return self.tag_unicode('genre')
156 def translators(self):
157 translators = self.get_extra_info_json().get('translators') or []
159 '\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators
162 def translator(self):
163 translators = self.get_extra_info_json().get('translators')
166 if len(translators) > 3:
167 translators = translators[:2]
171 return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
173 def cover_source(self):
174 return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
178 return self.get_extra_info_json().get('isbn_pdf')
182 return self.get_extra_info_json().get('isbn_epub')
186 return self.get_extra_info_json().get('isbn_mobi')
188 def is_accessible_to(self, user):
191 if not user.is_authenticated:
193 Membership = apps.get_model('club', 'Membership')
194 if Membership.is_active_for(user):
196 Funding = apps.get_model('funding', 'Funding')
197 if Funding.objects.filter(user=user, offer__book=self):
201 def save(self, force_insert=False, force_update=False, **kwargs):
202 from sortify import sortify
204 self.sort_key = sortify(self.title)[:120]
205 self.title = str(self.title) # ???
208 author = self.authors().first().sort_key
209 except AttributeError:
211 self.sort_key_author = author
213 self.cached_author = self.tag_unicode('author')
214 self.has_audience = 'audience' in self.get_extra_info_json()
216 if self.preview and not self.preview_key:
217 self.preview_key = get_random_hash(self.slug)[:32]
219 ret = super(Book, self).save(force_insert, force_update, **kwargs)
223 def get_absolute_url(self):
224 return reverse('book_detail', args=[self.slug])
226 def gallery_path(self):
227 return gallery_path(self.slug)
229 def gallery_url(self):
230 return gallery_url(self.slug)
232 def get_first_text(self):
235 child = self.children.all().order_by('parent_number').first()
236 if child is not None:
237 return child.get_first_text()
239 def get_last_text(self):
242 child = self.children.all().order_by('parent_number').last()
243 if child is not None:
244 return child.get_last_text()
246 def get_prev_text(self):
249 sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
250 if sibling is not None:
251 return sibling.get_last_text()
253 if self.parent.html_file:
256 return self.parent.get_prev_text()
258 def get_next_text(self, inside=True):
260 child = self.children.order_by('parent_number').first()
261 if child is not None:
262 return child.get_first_text()
266 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
267 if sibling is not None:
268 return sibling.get_first_text()
269 return self.parent.get_next_text(inside=False)
271 def get_child_audiobook(self):
272 BookMedia = apps.get_model('catalogue', 'BookMedia')
273 if not BookMedia.objects.filter(book__ancestor=self).exists():
275 for child in self.children.order_by('parent_number').all():
276 if child.has_mp3_file():
278 child_sub = child.get_child_audiobook()
279 if child_sub is not None:
282 def get_siblings(self):
285 return self.parent.children.all().order_by('parent_number')
287 def get_children(self):
288 return self.children.all().order_by('parent_number')
294 def language_code(self):
295 return constants.LANGUAGES_3TO2.get(self.language, self.language)
297 def language_name(self):
298 return dict(settings.LANGUAGES).get(self.language_code(), "")
300 def is_foreign(self):
301 return self.language_code() != settings.LANGUAGE_CODE
303 def set_audio_length(self):
304 length = self.get_audio_length()
306 self.audio_length = self.format_audio_length(length)
310 def format_audio_length(seconds):
312 >>> Book.format_audio_length(1)
314 >>> Book.format_audio_length(3661)
318 minutes = seconds // 60
319 seconds = seconds % 60
320 return '%d:%02d' % (minutes, seconds)
322 hours = seconds // 3600
323 minutes = seconds % 3600 // 60
324 seconds = seconds % 60
325 return '%d:%02d:%02d' % (hours, minutes, seconds)
327 def get_audio_length(self):
329 for media in self.get_mp3() or ():
330 total += app_settings.GET_MP3_LENGTH(media.file.path)
333 def has_media(self, type_):
334 if type_ in Book.formats:
335 return bool(getattr(self, "%s_file" % type_))
337 return self.media.filter(type=type_).exists()
340 return self.has_media('mp3')
342 def get_media(self, type_):
343 if self.has_media(type_):
344 if type_ in Book.formats:
345 return getattr(self, "%s_file" % type_)
347 return self.media.filter(type=type_)
352 return self.get_media("mp3")
355 return self.get_media("odt")
358 return self.get_media("ogg")
361 return self.get_media("daisy")
363 def get_audio_epub(self):
364 return self.get_media("audio.epub")
366 def media_url(self, format_):
367 media = self.get_media(format_)
370 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
377 return self.media_url('html')
380 return self.media_url('pdf')
383 return self.media_url('epub')
386 return self.media_url('mobi')
389 return self.media_url('txt')
392 return self.media_url('fb2')
395 return self.media_url('xml')
397 def has_description(self):
398 return len(self.description) > 0
399 has_description.short_description = _('description')
400 has_description.boolean = True
402 def has_mp3_file(self):
403 return self.has_media("mp3")
404 has_mp3_file.short_description = 'MP3'
405 has_mp3_file.boolean = True
407 def has_ogg_file(self):
408 return self.has_media("ogg")
409 has_ogg_file.short_description = 'OGG'
410 has_ogg_file.boolean = True
412 def has_daisy_file(self):
413 return self.has_media("daisy")
414 has_daisy_file.short_description = 'DAISY'
415 has_daisy_file.boolean = True
417 def has_audio_epub_file(self):
418 return self.has_media("audio.epub")
421 def media_daisy(self):
422 return self.get_media('daisy')
425 def media_audio_epub(self):
426 return self.get_media('audio.epub')
428 def get_audiobooks(self):
430 for m in self.media.filter(type='ogg').order_by().iterator():
431 ogg_files[m.name] = m
436 for mp3 in self.media.filter(type='mp3').iterator():
437 # ogg files are always from the same project
438 meta = mp3.get_extra_info_json()
439 project = meta.get('project')
442 project = 'CzytamySłuchając'
444 projects.add((project, meta.get('funded_by', '')))
445 total_duration += mp3.duration or 0
449 ogg = ogg_files.get(mp3.name)
452 audiobooks.append(media)
454 projects = sorted(projects)
455 total_duration = '%d:%02d' % (
456 total_duration // 60,
459 return audiobooks, projects, total_duration
461 def wldocument(self, parse_dublincore=True, inherit=True):
462 from catalogue.import_utils import ORMDocProvider
463 from librarian.parser import WLDocument
465 if inherit and self.parent:
466 meta_fallbacks = self.parent.cover_info()
468 meta_fallbacks = None
470 return WLDocument.from_file(
472 provider=ORMDocProvider(self),
473 parse_dublincore=parse_dublincore,
474 meta_fallbacks=meta_fallbacks)
476 def wldocument2(self):
477 from catalogue.import_utils import ORMDocProvider
478 from librarian.document import WLDocument
481 provider=ORMDocProvider(self)
483 doc.meta.update(self.cover_info())
488 def zip_format(format_):
489 def pretty_file_name(book):
490 return "%s/%s.%s" % (
491 book.get_extra_info_json()['author'],
495 field_name = "%s_file" % format_
496 field = getattr(Book, field_name)
497 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
498 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
499 return create_zip(paths, field.ZIP)
501 def zip_audiobooks(self, format_):
502 bm = BookMedia.objects.filter(book=self, type=format_)
503 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
506 license = constants.LICENSES.get(
507 m.get_extra_info_json().get('license'), {}
510 licenses.add(license)
511 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
512 'licenses': licenses,
514 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
516 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
517 if not self.findable:
520 from search.index import Index
523 index.index_book(self, book_info)
528 except Exception as e:
529 index.index.rollback()
532 # will make problems in conjunction with paid previews
533 def download_pictures(self, remote_gallery_url):
534 # This is only needed for legacy relative image paths.
535 gallery_path = self.gallery_path()
536 # delete previous files, so we don't include old files in ebooks
537 if os.path.isdir(gallery_path):
538 for filename in os.listdir(gallery_path):
539 file_path = os.path.join(gallery_path, filename)
541 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
543 makedirs(gallery_path)
544 for ilustr in ilustr_elements:
545 ilustr_src = ilustr.get('src')
546 if '/' in ilustr_src:
548 ilustr_path = os.path.join(gallery_path, ilustr_src)
549 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
551 def load_abstract(self):
552 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
553 if abstract is not None:
554 self.abstract = transform_abstrakt(abstract)
561 parser = html.HTMLParser(encoding='utf-8')
562 tree = html.parse(self.html_file.path, parser=parser)
563 toc = tree.find('//div[@id="toc"]/ol')
564 if toc is None or not len(toc):
566 html_link = reverse('book_text', args=[self.slug])
567 for a in toc.findall('.//a'):
568 a.attrib['href'] = html_link + a.attrib['href']
569 self.toc = html.tostring(toc, encoding='unicode')
573 def from_xml_file(cls, xml_file, **kwargs):
574 from django.core.files import File
575 from librarian import dcparser
577 # use librarian to parse meta-data
578 book_info = dcparser.parse(xml_file)
580 if not isinstance(xml_file, File):
581 xml_file = File(open(xml_file))
584 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
589 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
590 search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
591 from catalogue import tasks
593 if dont_build is None:
595 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
597 # check for parts before we do anything
599 if hasattr(book_info, 'parts'):
600 for part_url in book_info.parts:
602 children.append(Book.objects.get(slug=part_url.slug))
603 except Book.DoesNotExist:
604 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
607 book_slug = book_info.url.slug
608 if re.search(r'[^a-z0-9-]', book_slug):
609 raise ValueError('Invalid characters in slug')
610 book, created = Book.objects.get_or_create(slug=book_slug)
615 book.preview = bool(days)
617 book.preview_until = date.today() + timedelta(days)
620 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
621 # Save shelves for this book
622 book_shelves = list(book.tags.filter(category='set'))
623 old_cover = book.cover_info()
626 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
628 book.xml_file.set_readable(False)
630 book.findable = findable
631 book.language = book_info.language
632 book.title = book_info.title
633 if book_info.variant_of:
634 book.common_slug = book_info.variant_of.slug
636 book.common_slug = book.slug
637 book.extra_info = json.dumps(book_info.to_dict())
642 meta_tags = Tag.tags_from_info(book_info)
644 for tag in meta_tags:
645 if not tag.for_books:
649 book.tags = set(meta_tags + book_shelves)
650 book.save() # update sort_key_author
652 cover_changed = old_cover != book.cover_info()
653 obsolete_children = set(b for b in book.children.all()
654 if b not in children)
655 notify_cover_changed = []
656 for n, child_book in enumerate(children):
657 new_child = child_book.parent != book
658 child_book.parent = book
659 child_book.parent_number = n
661 if new_child or cover_changed:
662 notify_cover_changed.append(child_book)
663 # Disown unfaithful children and let them cope on their own.
664 for child in obsolete_children:
666 child.parent_number = 0
669 notify_cover_changed.append(child)
671 cls.repopulate_ancestors()
672 tasks.update_counters.delay()
674 if remote_gallery_url:
675 book.download_pictures(remote_gallery_url)
677 # No saves beyond this point.
680 if 'cover' not in dont_build:
681 book.cover.build_delay()
682 book.cover_clean.build_delay()
683 book.cover_thumb.build_delay()
684 book.cover_api_thumb.build_delay()
685 book.simple_cover.build_delay()
686 book.cover_ebookpoint.build_delay()
688 # Build HTML and ebooks.
689 book.html_file.build_delay()
691 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
692 if format_ not in dont_build:
693 getattr(book, '%s_file' % format_).build_delay()
694 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
695 if format_ not in dont_build:
696 getattr(book, '%s_file' % format_).build_delay()
698 if not settings.NO_SEARCH_INDEX and search_index and findable:
699 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
701 for child in notify_cover_changed:
702 child.parent_cover_changed()
704 book.update_popularity()
705 tasks.update_references.delay(book.id)
707 cls.published.send(sender=cls, instance=book)
710 def get_master(self):
714 'dramat_wierszowany_l',
715 'dramat_wierszowany_lp',
716 'dramat_wspolczesny', 'liryka_l', 'liryka_lp',
719 from librarian.parser import WLDocument
720 wld = WLDocument.from_file(self.xml_file.path, parse_dublincore=False)
721 root = wld.edoc.getroot()
722 for master in root.iter():
723 if master.tag in master_tags:
726 def update_references(self):
727 from references.models import Entity, Reference
728 master = self.get_master()
732 for i, sec in enumerate(master):
733 for ref in sec.findall('.//ref'):
734 href = ref.attrib.get('href', '')
735 if not href or href in found:
738 entity, created = Entity.objects.get_or_create(
741 ref, created = Reference.objects.get_or_create(
745 ref.first_section = 'sec%d' % (i + 1)
748 Reference.objects.filter(book=self).exclude(entity__uri__in=found).delete()
751 def references(self):
752 return self.reference_set.all().select_related('entity')
756 def repopulate_ancestors(cls):
757 """Fixes the ancestry cache."""
759 cursor = connection.cursor()
760 if connection.vendor == 'postgres':
761 cursor.execute("TRUNCATE catalogue_book_ancestor")
763 WITH RECURSIVE ancestry AS (
764 SELECT book.id, book.parent_id
765 FROM catalogue_book AS book
766 WHERE book.parent_id IS NOT NULL
768 SELECT ancestor.id, book.parent_id
769 FROM ancestry AS ancestor, catalogue_book AS book
770 WHERE ancestor.parent_id = book.id
771 AND book.parent_id IS NOT NULL
773 INSERT INTO catalogue_book_ancestor
774 (from_book_id, to_book_id)
780 cursor.execute("DELETE FROM catalogue_book_ancestor")
781 for b in cls.objects.exclude(parent=None):
783 while parent is not None:
784 b.ancestor.add(parent)
785 parent = parent.parent
790 for anc in self.parent.ancestors:
796 def clear_cache(self):
797 clear_cached_renders(self.mini_box)
798 clear_cached_renders(self.mini_box_nolink)
800 def cover_info(self, inherit=True):
801 """Returns a dictionary to serve as fallback for BookInfo.
803 For now, the only thing inherited is the cover image.
807 for field in ('cover_url', 'cover_by', 'cover_source'):
808 val = self.get_extra_info_json().get(field)
813 if inherit and need and self.parent is not None:
814 parent_info = self.parent.cover_info()
815 parent_info.update(info)
819 def related_themes(self):
820 return Tag.objects.usage_for_queryset(
821 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
822 counts=True).filter(category='theme').order_by('-count')
824 def parent_cover_changed(self):
825 """Called when parent book's cover image is changed."""
826 if not self.cover_info(inherit=False):
827 if 'cover' not in app_settings.DONT_BUILD:
828 self.cover.build_delay()
829 self.cover_clean.build_delay()
830 self.cover_thumb.build_delay()
831 self.cover_api_thumb.build_delay()
832 self.simple_cover.build_delay()
833 self.cover_ebookpoint.build_delay()
834 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
835 if format_ not in app_settings.DONT_BUILD:
836 getattr(self, '%s_file' % format_).build_delay()
837 for child in self.children.all():
838 child.parent_cover_changed()
840 def other_versions(self):
841 """Find other versions (i.e. in other languages) of the book."""
842 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
847 while parent is not None:
848 books.insert(0, parent)
849 parent = parent.parent
852 def pretty_title(self, html_links=False):
853 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
854 books = self.parents() + [self]
855 names.extend([(b.title, b.get_absolute_url()) for b in books])
858 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
860 names = [tag[0] for tag in names]
861 return ', '.join(names)
864 publisher = self.get_extra_info_json()['publisher']
865 if isinstance(publisher, str):
867 elif isinstance(publisher, list):
868 return ', '.join(publisher)
871 def tagged_top_level(cls, tags):
872 """ Returns top-level books tagged with `tags`.
874 It only returns those books which don't have ancestors which are
875 also tagged with those tags.
878 objects = cls.tagged.with_all(tags)
879 return objects.filter(findable=True).exclude(ancestor__in=objects)
882 def book_list(cls, book_filter=None):
883 """Generates a hierarchical listing of all books.
885 Books are optionally filtered with a test function.
890 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
892 books = books.filter(book_filter).distinct()
894 book_ids = set(b['pk'] for b in books.values("pk").iterator())
895 for book in books.iterator():
896 parent = book.parent_id
897 if parent not in book_ids:
899 books_by_parent.setdefault(parent, []).append(book)
901 for book in books.iterator():
902 books_by_parent.setdefault(book.parent_id, []).append(book)
905 books_by_author = OrderedDict()
906 for tag in Tag.objects.filter(category='author').iterator():
907 books_by_author[tag] = []
909 for book in books_by_parent.get(None, ()):
910 authors = list(book.authors().only('pk'))
912 for author in authors:
913 books_by_author[author].append(book)
917 return books_by_author, orphans, books_by_parent
920 "SP": (1, "szkoła podstawowa"),
921 "SP1": (1, "szkoła podstawowa"),
922 "SP2": (1, "szkoła podstawowa"),
923 "SP3": (1, "szkoła podstawowa"),
924 "P": (1, "szkoła podstawowa"),
925 "G": (2, "gimnazjum"),
930 def audiences_pl(self):
931 audiences = self.get_extra_info_json().get('audiences', [])
932 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
933 return [a[1] for a in audiences]
935 def stage_note(self):
936 stage = self.get_extra_info_json().get('stage')
937 if stage and stage < '0.4':
938 return (_('This work needs modernisation'),
939 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
943 def choose_fragments(self, number):
944 fragments = self.fragments.order_by()
945 fragments_count = fragments.count()
946 if not fragments_count and self.children.exists():
947 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
948 fragments_count = fragments.count()
950 if fragments_count > number:
951 offset = randint(0, fragments_count - number)
954 return fragments[offset : offset + number]
956 return self.parent.choose_fragments(number)
960 def choose_fragment(self):
961 fragments = self.choose_fragments(1)
967 def fragment_data(self):
968 fragment = self.choose_fragment()
971 'title': fragment.book.pretty_title(),
972 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
977 def update_popularity(self):
978 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
980 pop = self.popularity
983 except BookPopularity.DoesNotExist:
984 BookPopularity.objects.create(book=self, count=count)
986 def ridero_link(self):
987 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
989 def like(self, user):
990 from social.utils import likes, get_set, set_sets
991 if not likes(user, self):
992 tag = get_set(user, '')
993 set_sets(user, self, [tag])
995 def unlike(self, user):
996 from social.utils import likes, set_sets
997 if likes(user, self):
998 set_sets(user, self, [])
1000 def full_sort_key(self):
1001 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1003 def cover_color(self):
1004 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1006 @cached_render('catalogue/book_mini_box.html')
1012 @cached_render('catalogue/book_mini_box.html')
1013 def mini_box_nolink(self):
1020 class BookPopularity(models.Model):
1021 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1022 count = models.IntegerField(default=0, db_index=True)