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.conf import settings
12 from django.db import connection, models, transaction
13 import django.dispatch
14 from django.contrib.contenttypes.fields import GenericRelation
15 from django.template.loader import render_to_string
16 from django.urls import reverse
17 from django.utils.translation import ugettext_lazy as _, get_language
18 from django.utils.deconstruct import deconstructible
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.fields import EbookField
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 catalogue import tasks
31 from wolnelektury.utils import makedirs, cached_render, clear_cached_renders
33 bofh_storage = BofhFileSystemStorage()
37 class UploadToPath(object):
38 def __init__(self, path):
41 def __call__(self, instance, filename):
42 return self.path % instance.slug
45 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
46 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
47 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
48 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
49 _cover_ebookpoint_upload_to = UploadToPath('book/cover_ebookpoint/%s.jpg')
52 def _ebook_upload_to(upload_path):
53 return UploadToPath(upload_path)
56 class Book(models.Model):
57 """Represents a book imported from WL-XML."""
58 title = models.CharField(_('title'), max_length=32767)
59 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
60 sort_key_author = models.CharField(
61 _('sort key by author'), max_length=120, db_index=True, editable=False, default='')
62 slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
63 common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
64 language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
65 description = models.TextField(_('description'), blank=True)
66 abstract = models.TextField(_('abstract'), blank=True)
67 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
68 changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
69 parent_number = models.IntegerField(_('parent number'), default=0)
70 extra_info = models.TextField(_('extra information'), default='{}')
71 gazeta_link = models.CharField(blank=True, max_length=240)
72 wiki_link = models.CharField(blank=True, max_length=240)
73 print_on_demand = models.BooleanField(_('print on demand'), default=False)
74 recommended = models.BooleanField(_('recommended'), default=False)
75 audio_length = models.CharField(_('audio length'), blank=True, max_length=8)
76 preview = models.BooleanField(_('preview'), default=False)
77 preview_until = models.DateField(_('preview until'), blank=True, null=True)
78 preview_key = models.CharField(max_length=32, blank=True, null=True)
79 findable = models.BooleanField(_('findable'), default=True, db_index=True)
81 # files generated during publication
84 null=True, blank=True,
85 upload_to=_cover_upload_to,
86 storage=bofh_storage, max_length=255)
87 cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
88 # Cleaner version of cover for thumbs
89 cover_thumb = EbookField(
90 'cover_thumb', _('cover thumbnail'),
91 null=True, blank=True,
92 upload_to=_cover_thumb_upload_to,
94 cover_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
95 cover_api_thumb = EbookField(
96 'cover_api_thumb', _('cover thumbnail for mobile app'),
97 null=True, blank=True,
98 upload_to=_cover_api_thumb_upload_to,
100 cover_api_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
101 simple_cover = EbookField(
102 'simple_cover', _('cover for mobile app'),
103 null=True, blank=True,
104 upload_to=_simple_cover_upload_to,
106 simple_cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
107 cover_ebookpoint = EbookField(
108 'cover_ebookpoint', _('cover for Ebookpoint'),
109 null=True, blank=True,
110 upload_to=_cover_ebookpoint_upload_to,
112 cover_ebookpoint_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
113 ebook_formats = constants.EBOOK_FORMATS
114 formats = ebook_formats + ['html', 'xml']
116 parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
117 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
119 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
120 has_audience = models.BooleanField(default=False)
122 objects = models.Manager()
123 tagged = managers.ModelTaggedItemManager(Tag)
124 tags = managers.TagDescriptor(Tag)
125 tag_relations = GenericRelation(Tag.intermediary_table_model)
127 html_built = django.dispatch.Signal()
128 published = django.dispatch.Signal()
132 class AlreadyExists(Exception):
136 ordering = ('sort_key_author', 'sort_key')
137 verbose_name = _('book')
138 verbose_name_plural = _('books')
139 app_label = 'catalogue'
144 def get_extra_info_json(self):
145 return json.loads(self.extra_info or '{}')
147 def get_initial(self):
149 return re.search(r'\w', self.title, re.U).group(0)
150 except AttributeError:
154 return self.tags.filter(category='author')
157 return self.tags.filter(category='epoch')
160 return self.tags.filter(category='genre')
163 return self.tags.filter(category='kind')
165 def tag_unicode(self, category):
166 relations = prefetched_relations(self, category)
168 return ', '.join(rel.tag.name for rel in relations)
170 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
172 def tags_by_category(self):
173 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
175 def author_unicode(self):
176 return self.cached_author
178 def kind_unicode(self):
179 return self.tag_unicode('kind')
181 def epoch_unicode(self):
182 return self.tag_unicode('epoch')
184 def genre_unicode(self):
185 return self.tag_unicode('genre')
187 def translators(self):
188 translators = self.get_extra_info_json().get('translators') or []
190 '\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators
193 def translator(self):
194 translators = self.get_extra_info_json().get('translators')
197 if len(translators) > 3:
198 translators = translators[:2]
202 return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
204 def cover_source(self):
205 return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
209 return self.get_extra_info_json().get('isbn_pdf')
213 return self.get_extra_info_json().get('isbn_epub')
217 return self.get_extra_info_json().get('isbn_mobi')
220 def save(self, force_insert=False, force_update=False, **kwargs):
221 from sortify import sortify
223 self.sort_key = sortify(self.title)[:120]
224 self.title = str(self.title) # ???
227 author = self.authors().first().sort_key
228 except AttributeError:
230 self.sort_key_author = author
232 self.cached_author = self.tag_unicode('author')
233 self.has_audience = 'audience' in self.get_extra_info_json()
235 if self.preview and not self.preview_key:
236 self.preview_key = get_random_hash(self.slug)[:32]
238 ret = super(Book, self).save(force_insert, force_update, **kwargs)
242 def get_absolute_url(self):
243 return reverse('book_detail', args=[self.slug])
245 def gallery_path(self):
246 return gallery_path(self.slug)
248 def gallery_url(self):
249 return gallery_url(self.slug)
251 def get_first_text(self):
254 child = self.children.all().order_by('parent_number').first()
255 if child is not None:
256 return child.get_first_text()
258 def get_last_text(self):
261 child = self.children.all().order_by('parent_number').last()
262 if child is not None:
263 return child.get_last_text()
265 def get_prev_text(self):
268 sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
269 if sibling is not None:
270 return sibling.get_last_text()
272 if self.parent.html_file:
275 return self.parent.get_prev_text()
277 def get_next_text(self):
278 child = self.children.order_by('parent_number').first()
279 if child is not None:
280 return child.get_first_text()
284 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
285 if sibling is not None:
286 return sibling.get_first_text()
287 return self.parent.get_next_text()
289 def get_siblings(self):
292 return self.parent.children.all().order_by('parent_number')
294 def get_children(self):
295 return self.children.all().order_by('parent_number')
301 def language_code(self):
302 return constants.LANGUAGES_3TO2.get(self.language, self.language)
304 def language_name(self):
305 return dict(settings.LANGUAGES).get(self.language_code(), "")
307 def is_foreign(self):
308 return self.language_code() != settings.LANGUAGE_CODE
310 def set_audio_length(self):
311 length = self.get_audio_length()
313 self.audio_length = self.format_audio_length(length)
317 def format_audio_length(seconds):
319 >>> Book.format_audio_length(1)
321 >>> Book.format_audio_length(3661)
325 minutes = seconds // 60
326 seconds = seconds % 60
327 return '%d:%02d' % (minutes, seconds)
329 hours = seconds // 3600
330 minutes = seconds % 3600 // 60
331 seconds = seconds % 60
332 return '%d:%02d:%02d' % (hours, minutes, seconds)
334 def get_audio_length(self):
336 for media in self.get_mp3() or ():
337 total += app_settings.GET_MP3_LENGTH(media.file.path)
340 def has_media(self, type_):
341 if type_ in Book.formats:
342 return bool(getattr(self, "%s_file" % type_))
344 return self.media.filter(type=type_).exists()
347 return self.has_media('mp3')
349 def get_media(self, type_):
350 if self.has_media(type_):
351 if type_ in Book.formats:
352 return getattr(self, "%s_file" % type_)
354 return self.media.filter(type=type_)
359 return self.get_media("mp3")
362 return self.get_media("odt")
365 return self.get_media("ogg")
368 return self.get_media("daisy")
370 def media_url(self, format_):
371 media = self.get_media(format_)
374 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
381 return self.media_url('html')
384 return self.media_url('pdf')
387 return self.media_url('epub')
390 return self.media_url('mobi')
393 return self.media_url('txt')
396 return self.media_url('fb2')
399 return self.media_url('xml')
401 def has_description(self):
402 return len(self.description) > 0
403 has_description.short_description = _('description')
404 has_description.boolean = True
406 def has_mp3_file(self):
407 return self.has_media("mp3")
408 has_mp3_file.short_description = 'MP3'
409 has_mp3_file.boolean = True
411 def has_ogg_file(self):
412 return self.has_media("ogg")
413 has_ogg_file.short_description = 'OGG'
414 has_ogg_file.boolean = True
416 def has_daisy_file(self):
417 return self.has_media("daisy")
418 has_daisy_file.short_description = 'DAISY'
419 has_daisy_file.boolean = True
422 def media_daisy(self):
423 return self.get_media('daisy')
425 def get_audiobooks(self):
427 for m in self.media.filter(type='ogg').order_by().iterator():
428 ogg_files[m.name] = m
432 for mp3 in self.media.filter(type='mp3').iterator():
433 # ogg files are always from the same project
434 meta = mp3.get_extra_info_json()
435 project = meta.get('project')
438 project = 'CzytamySłuchając'
440 projects.add((project, meta.get('funded_by', '')))
444 ogg = ogg_files.get(mp3.name)
447 audiobooks.append(media)
449 projects = sorted(projects)
450 return audiobooks, projects
452 def wldocument(self, parse_dublincore=True, inherit=True):
453 from catalogue.import_utils import ORMDocProvider
454 from librarian.parser import WLDocument
456 if inherit and self.parent:
457 meta_fallbacks = self.parent.cover_info()
459 meta_fallbacks = None
461 return WLDocument.from_file(
463 provider=ORMDocProvider(self),
464 parse_dublincore=parse_dublincore,
465 meta_fallbacks=meta_fallbacks)
467 def wldocument2(self):
468 from catalogue.import_utils import ORMDocProvider
469 from librarian.document import WLDocument
472 provider=ORMDocProvider(self)
474 doc.meta.update(self.cover_info())
479 def zip_format(format_):
480 def pretty_file_name(book):
481 return "%s/%s.%s" % (
482 book.get_extra_info_json()['author'],
486 field_name = "%s_file" % format_
487 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
488 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
489 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
491 def zip_audiobooks(self, format_):
492 bm = BookMedia.objects.filter(book=self, type=format_)
493 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
496 license = constants.LICENSES.get(
497 m.get_extra_info_json().get('license'), {}
500 licenses.add(license)
501 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
502 'licenses': licenses,
504 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
506 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
507 if not self.findable:
510 from search.index import Index
513 index.index_book(self, book_info)
518 except Exception as e:
519 index.index.rollback()
522 # will make problems in conjunction with paid previews
523 def download_pictures(self, remote_gallery_url):
524 gallery_path = self.gallery_path()
525 # delete previous files, so we don't include old files in ebooks
526 if os.path.isdir(gallery_path):
527 for filename in os.listdir(gallery_path):
528 file_path = os.path.join(gallery_path, filename)
530 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
532 makedirs(gallery_path)
533 for ilustr in ilustr_elements:
534 ilustr_src = ilustr.get('src')
535 ilustr_path = os.path.join(gallery_path, ilustr_src)
536 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
538 def load_abstract(self):
539 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
540 if abstract is not None:
541 self.abstract = transform_abstrakt(abstract)
546 def from_xml_file(cls, xml_file, **kwargs):
547 from django.core.files import File
548 from librarian import dcparser
550 # use librarian to parse meta-data
551 book_info = dcparser.parse(xml_file)
553 if not isinstance(xml_file, File):
554 xml_file = File(open(xml_file))
557 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
562 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
563 search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
564 if dont_build is None:
566 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
568 # check for parts before we do anything
570 if hasattr(book_info, 'parts'):
571 for part_url in book_info.parts:
573 children.append(Book.objects.get(slug=part_url.slug))
574 except Book.DoesNotExist:
575 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
578 book_slug = book_info.url.slug
579 if re.search(r'[^a-z0-9-]', book_slug):
580 raise ValueError('Invalid characters in slug')
581 book, created = Book.objects.get_or_create(slug=book_slug)
586 book.preview = bool(days)
588 book.preview_until = date.today() + timedelta(days)
591 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
592 # Save shelves for this book
593 book_shelves = list(book.tags.filter(category='set'))
594 old_cover = book.cover_info()
597 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
599 book.xml_file.set_readable(False)
601 book.findable = findable
602 book.language = book_info.language
603 book.title = book_info.title
604 if book_info.variant_of:
605 book.common_slug = book_info.variant_of.slug
607 book.common_slug = book.slug
608 book.extra_info = json.dumps(book_info.to_dict())
612 meta_tags = Tag.tags_from_info(book_info)
614 for tag in meta_tags:
615 if not tag.for_books:
619 book.tags = set(meta_tags + book_shelves)
620 book.save() # update sort_key_author
622 cover_changed = old_cover != book.cover_info()
623 obsolete_children = set(b for b in book.children.all()
624 if b not in children)
625 notify_cover_changed = []
626 for n, child_book in enumerate(children):
627 new_child = child_book.parent != book
628 child_book.parent = book
629 child_book.parent_number = n
631 if new_child or cover_changed:
632 notify_cover_changed.append(child_book)
633 # Disown unfaithful children and let them cope on their own.
634 for child in obsolete_children:
636 child.parent_number = 0
639 notify_cover_changed.append(child)
641 cls.repopulate_ancestors()
642 tasks.update_counters.delay()
644 if remote_gallery_url:
645 book.download_pictures(remote_gallery_url)
647 # No saves beyond this point.
650 if 'cover' not in dont_build:
651 book.cover.build_delay()
652 book.cover_thumb.build_delay()
653 book.cover_api_thumb.build_delay()
654 book.simple_cover.build_delay()
655 book.cover_ebookpoint.build_delay()
657 # Build HTML and ebooks.
658 book.html_file.build_delay()
660 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
661 if format_ not in dont_build:
662 getattr(book, '%s_file' % format_).build_delay()
663 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
664 if format_ not in dont_build:
665 getattr(book, '%s_file' % format_).build_delay()
667 if not settings.NO_SEARCH_INDEX and search_index and findable:
668 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
670 for child in notify_cover_changed:
671 child.parent_cover_changed()
673 book.update_popularity()
674 tasks.update_references.delay(book.id)
676 cls.published.send(sender=cls, instance=book)
679 def get_master(self):
683 'dramat_wierszowany_l',
684 'dramat_wierszowany_lp',
685 'dramat_wspolczesny', 'liryka_l', 'liryka_lp',
688 from librarian.parser import WLDocument
689 wld = WLDocument.from_file(self.xml_file.path, parse_dublincore=False)
690 root = wld.edoc.getroot()
691 for master in root.iter():
692 if master.tag in master_tags:
695 def update_references(self):
696 from references.models import Entity, Reference
697 master = self.get_master()
699 for i, sec in enumerate(master):
700 for ref in sec.findall('.//ref'):
701 href = ref.attrib.get('href', '')
702 if not href or href in found:
705 entity, created = Entity.objects.get_or_create(
708 ref, created = Reference.objects.get_or_create(
712 ref.first_section = 'sec%d' % (i + 1)
715 Reference.objects.filter(book=self).exclude(entity__uri__in=found).delete()
718 def references(self):
719 return self.reference_set.all().select_related('entity')
723 def repopulate_ancestors(cls):
724 """Fixes the ancestry cache."""
726 cursor = connection.cursor()
727 if connection.vendor == 'postgres':
728 cursor.execute("TRUNCATE catalogue_book_ancestor")
730 WITH RECURSIVE ancestry AS (
731 SELECT book.id, book.parent_id
732 FROM catalogue_book AS book
733 WHERE book.parent_id IS NOT NULL
735 SELECT ancestor.id, book.parent_id
736 FROM ancestry AS ancestor, catalogue_book AS book
737 WHERE ancestor.parent_id = book.id
738 AND book.parent_id IS NOT NULL
740 INSERT INTO catalogue_book_ancestor
741 (from_book_id, to_book_id)
747 cursor.execute("DELETE FROM catalogue_book_ancestor")
748 for b in cls.objects.exclude(parent=None):
750 while parent is not None:
751 b.ancestor.add(parent)
752 parent = parent.parent
757 for anc in self.parent.ancestors:
763 def clear_cache(self):
764 clear_cached_renders(self.mini_box)
765 clear_cached_renders(self.mini_box_nolink)
767 def cover_info(self, inherit=True):
768 """Returns a dictionary to serve as fallback for BookInfo.
770 For now, the only thing inherited is the cover image.
774 for field in ('cover_url', 'cover_by', 'cover_source'):
775 val = self.get_extra_info_json().get(field)
780 if inherit and need and self.parent is not None:
781 parent_info = self.parent.cover_info()
782 parent_info.update(info)
786 def related_themes(self):
787 return Tag.objects.usage_for_queryset(
788 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
789 counts=True).filter(category='theme')
791 def parent_cover_changed(self):
792 """Called when parent book's cover image is changed."""
793 if not self.cover_info(inherit=False):
794 if 'cover' not in app_settings.DONT_BUILD:
795 self.cover.build_delay()
796 self.cover_thumb.build_delay()
797 self.cover_api_thumb.build_delay()
798 self.simple_cover.build_delay()
799 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
800 if format_ not in app_settings.DONT_BUILD:
801 getattr(self, '%s_file' % format_).build_delay()
802 for child in self.children.all():
803 child.parent_cover_changed()
805 def other_versions(self):
806 """Find other versions (i.e. in other languages) of the book."""
807 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
812 while parent is not None:
813 books.insert(0, parent)
814 parent = parent.parent
817 def pretty_title(self, html_links=False):
818 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
819 books = self.parents() + [self]
820 names.extend([(b.title, b.get_absolute_url()) for b in books])
823 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
825 names = [tag[0] for tag in names]
826 return ', '.join(names)
829 publisher = self.get_extra_info_json()['publisher']
830 if isinstance(publisher, str):
832 elif isinstance(publisher, list):
833 return ', '.join(publisher)
836 def tagged_top_level(cls, tags):
837 """ Returns top-level books tagged with `tags`.
839 It only returns those books which don't have ancestors which are
840 also tagged with those tags.
843 objects = cls.tagged.with_all(tags)
844 return objects.filter(findable=True).exclude(ancestor__in=objects)
847 def book_list(cls, book_filter=None):
848 """Generates a hierarchical listing of all books.
850 Books are optionally filtered with a test function.
855 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
857 books = books.filter(book_filter).distinct()
859 book_ids = set(b['pk'] for b in books.values("pk").iterator())
860 for book in books.iterator():
861 parent = book.parent_id
862 if parent not in book_ids:
864 books_by_parent.setdefault(parent, []).append(book)
866 for book in books.iterator():
867 books_by_parent.setdefault(book.parent_id, []).append(book)
870 books_by_author = OrderedDict()
871 for tag in Tag.objects.filter(category='author').iterator():
872 books_by_author[tag] = []
874 for book in books_by_parent.get(None, ()):
875 authors = list(book.authors().only('pk'))
877 for author in authors:
878 books_by_author[author].append(book)
882 return books_by_author, orphans, books_by_parent
885 "SP": (1, "szkoła podstawowa"),
886 "SP1": (1, "szkoła podstawowa"),
887 "SP2": (1, "szkoła podstawowa"),
888 "SP3": (1, "szkoła podstawowa"),
889 "P": (1, "szkoła podstawowa"),
890 "G": (2, "gimnazjum"),
895 def audiences_pl(self):
896 audiences = self.get_extra_info_json().get('audiences', [])
897 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
898 return [a[1] for a in audiences]
900 def stage_note(self):
901 stage = self.get_extra_info_json().get('stage')
902 if stage and stage < '0.4':
903 return (_('This work needs modernisation'),
904 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
908 def choose_fragments(self, number):
909 fragments = self.fragments.order_by()
910 fragments_count = fragments.count()
911 if not fragments_count and self.children.exists():
912 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
913 fragments_count = fragments.count()
915 offset = randint(0, fragments_count - number)
916 return fragments[offset : offset + number]
918 return self.parent.choose_fragments(number)
922 def choose_fragment(self):
923 fragments = self.choose_fragments(1)
929 def fragment_data(self):
930 fragment = self.choose_fragment()
933 'title': fragment.book.pretty_title(),
934 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
939 def update_popularity(self):
940 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
942 pop = self.popularity
945 except BookPopularity.DoesNotExist:
946 BookPopularity.objects.create(book=self, count=count)
948 def ridero_link(self):
949 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
951 def like(self, user):
952 from social.utils import likes, get_set, set_sets
953 if not likes(user, self):
954 tag = get_set(user, '')
955 set_sets(user, self, [tag])
957 def unlike(self, user):
958 from social.utils import likes, set_sets
959 if likes(user, self):
960 set_sets(user, self, [])
962 def full_sort_key(self):
963 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
965 def cover_color(self):
966 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
968 @cached_render('catalogue/book_mini_box.html')
974 @cached_render('catalogue/book_mini_box.html')
975 def mini_box_nolink(self):
981 def add_file_fields():
982 for format_ in Book.formats:
983 field_name = "%s_file" % format_
984 # This weird globals() assignment makes Django migrations comfortable.
985 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
986 _upload_to.__name__ = '_%s_upload_to' % format_
987 globals()[_upload_to.__name__] = _upload_to
990 format_, _("%s file" % format_.upper()),
991 upload_to=_upload_to,
992 storage=bofh_storage,
996 ).contribute_to_class(Book, field_name)
998 models.CharField(max_length=255, editable=False, default='', db_index=True).contribute_to_class(Book, f'{field_name}_etag')
1004 class BookPopularity(models.Model):
1005 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1006 count = models.IntegerField(default=0, db_index=True)