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()
271 return self.parent.get_prev_text()
273 def get_next_text(self):
276 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
277 if sibling is not None:
278 return sibling.get_first_text()
279 return self.parent.get_next_text()
281 def get_siblings(self):
284 return self.parent.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)
329 def has_media(self, type_):
330 if type_ in Book.formats:
331 return bool(getattr(self, "%s_file" % type_))
333 return self.media.filter(type=type_).exists()
336 return self.has_media('mp3')
338 def get_media(self, type_):
339 if self.has_media(type_):
340 if type_ in Book.formats:
341 return getattr(self, "%s_file" % type_)
343 return self.media.filter(type=type_)
348 return self.get_media("mp3")
351 return self.get_media("odt")
354 return self.get_media("ogg")
357 return self.get_media("daisy")
359 def media_url(self, format_):
360 media = self.get_media(format_)
363 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
370 return self.media_url('html')
373 return self.media_url('pdf')
376 return self.media_url('epub')
379 return self.media_url('mobi')
382 return self.media_url('txt')
385 return self.media_url('fb2')
388 return self.media_url('xml')
390 def has_description(self):
391 return len(self.description) > 0
392 has_description.short_description = _('description')
393 has_description.boolean = True
395 def has_mp3_file(self):
396 return self.has_media("mp3")
397 has_mp3_file.short_description = 'MP3'
398 has_mp3_file.boolean = True
400 def has_ogg_file(self):
401 return self.has_media("ogg")
402 has_ogg_file.short_description = 'OGG'
403 has_ogg_file.boolean = True
405 def has_daisy_file(self):
406 return self.has_media("daisy")
407 has_daisy_file.short_description = 'DAISY'
408 has_daisy_file.boolean = True
410 def get_audiobooks(self):
412 for m in self.media.filter(type='ogg').order_by().iterator():
413 ogg_files[m.name] = m
417 for mp3 in self.media.filter(type='mp3').iterator():
418 # ogg files are always from the same project
419 meta = mp3.get_extra_info_json()
420 project = meta.get('project')
423 project = 'CzytamySłuchając'
425 projects.add((project, meta.get('funded_by', '')))
429 ogg = ogg_files.get(mp3.name)
432 audiobooks.append(media)
434 projects = sorted(projects)
435 return audiobooks, projects
437 def wldocument(self, parse_dublincore=True, inherit=True):
438 from catalogue.import_utils import ORMDocProvider
439 from librarian.parser import WLDocument
441 if inherit and self.parent:
442 meta_fallbacks = self.parent.cover_info()
444 meta_fallbacks = None
446 return WLDocument.from_file(
448 provider=ORMDocProvider(self),
449 parse_dublincore=parse_dublincore,
450 meta_fallbacks=meta_fallbacks)
453 def zip_format(format_):
454 def pretty_file_name(book):
455 return "%s/%s.%s" % (
456 book.get_extra_info_json()['author'],
460 field_name = "%s_file" % format_
461 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
462 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
463 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
465 def zip_audiobooks(self, format_):
466 bm = BookMedia.objects.filter(book=self, type=format_)
467 paths = map(lambda bm: (None, bm.file.path), bm)
470 license = constants.LICENSES.get(
471 m.get_extra_info_json().get('license'), {}
474 licenses.add(license)
475 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
476 'licenses': licenses,
478 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
480 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
481 if not self.findable:
484 from search.index import Index
487 index.index_book(self, book_info)
492 except Exception as e:
493 index.index.rollback()
496 # will make problems in conjunction with paid previews
497 def download_pictures(self, remote_gallery_url):
498 gallery_path = self.gallery_path()
499 # delete previous files, so we don't include old files in ebooks
500 if os.path.isdir(gallery_path):
501 for filename in os.listdir(gallery_path):
502 file_path = os.path.join(gallery_path, filename)
504 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
506 makedirs(gallery_path)
507 for ilustr in ilustr_elements:
508 ilustr_src = ilustr.get('src')
509 ilustr_path = os.path.join(gallery_path, ilustr_src)
510 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
512 def load_abstract(self):
513 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
514 if abstract is not None:
515 self.abstract = transform_abstrakt(abstract)
520 def from_xml_file(cls, xml_file, **kwargs):
521 from django.core.files import File
522 from librarian import dcparser
524 # use librarian to parse meta-data
525 book_info = dcparser.parse(xml_file)
527 if not isinstance(xml_file, File):
528 xml_file = File(open(xml_file))
531 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
536 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
537 search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
538 if dont_build is None:
540 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
542 # check for parts before we do anything
544 if hasattr(book_info, 'parts'):
545 for part_url in book_info.parts:
547 children.append(Book.objects.get(slug=part_url.slug))
548 except Book.DoesNotExist:
549 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
552 book_slug = book_info.url.slug
553 if re.search(r'[^a-z0-9-]', book_slug):
554 raise ValueError('Invalid characters in slug')
555 book, created = Book.objects.get_or_create(slug=book_slug)
560 book.preview = bool(days)
562 book.preview_until = date.today() + timedelta(days)
565 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
566 # Save shelves for this book
567 book_shelves = list(book.tags.filter(category='set'))
568 old_cover = book.cover_info()
571 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
573 book.xml_file.set_readable(False)
575 book.findable = findable
576 book.language = book_info.language
577 book.title = book_info.title
578 if book_info.variant_of:
579 book.common_slug = book_info.variant_of.slug
581 book.common_slug = book.slug
582 book.extra_info = json.dumps(book_info.to_dict())
586 meta_tags = Tag.tags_from_info(book_info)
588 for tag in meta_tags:
589 if not tag.for_books:
593 book.tags = set(meta_tags + book_shelves)
594 book.save() # update sort_key_author
596 cover_changed = old_cover != book.cover_info()
597 obsolete_children = set(b for b in book.children.all()
598 if b not in children)
599 notify_cover_changed = []
600 for n, child_book in enumerate(children):
601 new_child = child_book.parent != book
602 child_book.parent = book
603 child_book.parent_number = n
605 if new_child or cover_changed:
606 notify_cover_changed.append(child_book)
607 # Disown unfaithful children and let them cope on their own.
608 for child in obsolete_children:
610 child.parent_number = 0
613 notify_cover_changed.append(child)
615 cls.repopulate_ancestors()
616 tasks.update_counters.delay()
618 if remote_gallery_url:
619 book.download_pictures(remote_gallery_url)
621 # No saves beyond this point.
624 if 'cover' not in dont_build:
625 book.cover.build_delay()
626 book.cover_thumb.build_delay()
627 book.cover_api_thumb.build_delay()
628 book.simple_cover.build_delay()
629 book.cover_ebookpoint.build_delay()
631 # Build HTML and ebooks.
632 book.html_file.build_delay()
634 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
635 if format_ not in dont_build:
636 getattr(book, '%s_file' % format_).build_delay()
637 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
638 if format_ not in dont_build:
639 getattr(book, '%s_file' % format_).build_delay()
641 if not settings.NO_SEARCH_INDEX and search_index and findable:
642 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
644 for child in notify_cover_changed:
645 child.parent_cover_changed()
647 book.update_popularity()
648 tasks.update_references.delay(book.id)
650 cls.published.send(sender=cls, instance=book)
653 def get_master(self):
657 'dramat_wierszowany_l',
658 'dramat_wierszowany_lp',
659 'dramat_wspolczesny', 'liryka_l', 'liryka_lp',
662 from librarian.parser import WLDocument
663 wld = WLDocument.from_file(self.xml_file.path, parse_dublincore=False)
664 root = wld.edoc.getroot()
665 for master in root.iter():
666 if master.tag in master_tags:
669 def update_references(self):
670 from references.models import Entity, Reference
671 master = self.get_master()
673 for i, sec in enumerate(master):
674 for ref in sec.findall('.//ref'):
675 href = ref.attrib.get('href', '')
676 if not href or href in found:
679 entity, created = Entity.objects.get_or_create(
682 ref, created = Reference.objects.get_or_create(
686 ref.first_section = 'sec%d' % (i + 1)
689 Reference.objects.filter(book=self).exclude(entity__uri__in=found).delete()
692 def references(self):
693 return self.reference_set.all().select_related('entity')
697 def repopulate_ancestors(cls):
698 """Fixes the ancestry cache."""
700 cursor = connection.cursor()
701 if connection.vendor == 'postgres':
702 cursor.execute("TRUNCATE catalogue_book_ancestor")
704 WITH RECURSIVE ancestry AS (
705 SELECT book.id, book.parent_id
706 FROM catalogue_book AS book
707 WHERE book.parent_id IS NOT NULL
709 SELECT ancestor.id, book.parent_id
710 FROM ancestry AS ancestor, catalogue_book AS book
711 WHERE ancestor.parent_id = book.id
712 AND book.parent_id IS NOT NULL
714 INSERT INTO catalogue_book_ancestor
715 (from_book_id, to_book_id)
721 cursor.execute("DELETE FROM catalogue_book_ancestor")
722 for b in cls.objects.exclude(parent=None):
724 while parent is not None:
725 b.ancestor.add(parent)
726 parent = parent.parent
728 def clear_cache(self):
729 clear_cached_renders(self.mini_box)
730 clear_cached_renders(self.mini_box_nolink)
732 def cover_info(self, inherit=True):
733 """Returns a dictionary to serve as fallback for BookInfo.
735 For now, the only thing inherited is the cover image.
739 for field in ('cover_url', 'cover_by', 'cover_source'):
740 val = self.get_extra_info_json().get(field)
745 if inherit and need and self.parent is not None:
746 parent_info = self.parent.cover_info()
747 parent_info.update(info)
751 def related_themes(self):
752 return Tag.objects.usage_for_queryset(
753 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
754 counts=True).filter(category='theme')
756 def parent_cover_changed(self):
757 """Called when parent book's cover image is changed."""
758 if not self.cover_info(inherit=False):
759 if 'cover' not in app_settings.DONT_BUILD:
760 self.cover.build_delay()
761 self.cover_thumb.build_delay()
762 self.cover_api_thumb.build_delay()
763 self.simple_cover.build_delay()
764 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
765 if format_ not in app_settings.DONT_BUILD:
766 getattr(self, '%s_file' % format_).build_delay()
767 for child in self.children.all():
768 child.parent_cover_changed()
770 def other_versions(self):
771 """Find other versions (i.e. in other languages) of the book."""
772 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
777 while parent is not None:
778 books.insert(0, parent)
779 parent = parent.parent
782 def pretty_title(self, html_links=False):
783 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
784 books = self.parents() + [self]
785 names.extend([(b.title, b.get_absolute_url()) for b in books])
788 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
790 names = [tag[0] for tag in names]
791 return ', '.join(names)
794 publisher = self.get_extra_info_json()['publisher']
795 if isinstance(publisher, str):
797 elif isinstance(publisher, list):
798 return ', '.join(publisher)
801 def tagged_top_level(cls, tags):
802 """ Returns top-level books tagged with `tags`.
804 It only returns those books which don't have ancestors which are
805 also tagged with those tags.
808 objects = cls.tagged.with_all(tags)
809 return objects.filter(findable=True).exclude(ancestor__in=objects)
812 def book_list(cls, book_filter=None):
813 """Generates a hierarchical listing of all books.
815 Books are optionally filtered with a test function.
820 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
822 books = books.filter(book_filter).distinct()
824 book_ids = set(b['pk'] for b in books.values("pk").iterator())
825 for book in books.iterator():
826 parent = book.parent_id
827 if parent not in book_ids:
829 books_by_parent.setdefault(parent, []).append(book)
831 for book in books.iterator():
832 books_by_parent.setdefault(book.parent_id, []).append(book)
835 books_by_author = OrderedDict()
836 for tag in Tag.objects.filter(category='author').iterator():
837 books_by_author[tag] = []
839 for book in books_by_parent.get(None, ()):
840 authors = list(book.authors().only('pk'))
842 for author in authors:
843 books_by_author[author].append(book)
847 return books_by_author, orphans, books_by_parent
850 "SP": (1, "szkoła podstawowa"),
851 "SP1": (1, "szkoła podstawowa"),
852 "SP2": (1, "szkoła podstawowa"),
853 "SP3": (1, "szkoła podstawowa"),
854 "P": (1, "szkoła podstawowa"),
855 "G": (2, "gimnazjum"),
860 def audiences_pl(self):
861 audiences = self.get_extra_info_json().get('audiences', [])
862 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
863 return [a[1] for a in audiences]
865 def stage_note(self):
866 stage = self.get_extra_info_json().get('stage')
867 if stage and stage < '0.4':
868 return (_('This work needs modernisation'),
869 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
873 def choose_fragment(self):
874 fragments = self.fragments.order_by()
875 fragments_count = fragments.count()
876 if not fragments_count and self.children.exists():
877 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
878 fragments_count = fragments.count()
880 return fragments[randint(0, fragments_count - 1)]
882 return self.parent.choose_fragment()
886 def fragment_data(self):
887 fragment = self.choose_fragment()
890 'title': fragment.book.pretty_title(),
891 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
896 def update_popularity(self):
897 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
899 pop = self.popularity
902 except BookPopularity.DoesNotExist:
903 BookPopularity.objects.create(book=self, count=count)
905 def ridero_link(self):
906 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
908 def like(self, user):
909 from social.utils import likes, get_set, set_sets
910 if not likes(user, self):
911 tag = get_set(user, '')
912 set_sets(user, self, [tag])
914 def unlike(self, user):
915 from social.utils import likes, set_sets
916 if likes(user, self):
917 set_sets(user, self, [])
919 def full_sort_key(self):
920 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
922 def cover_color(self):
923 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
925 @cached_render('catalogue/book_mini_box.html')
931 @cached_render('catalogue/book_mini_box.html')
932 def mini_box_nolink(self):
938 def add_file_fields():
939 for format_ in Book.formats:
940 field_name = "%s_file" % format_
941 # This weird globals() assignment makes Django migrations comfortable.
942 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
943 _upload_to.__name__ = '_%s_upload_to' % format_
944 globals()[_upload_to.__name__] = _upload_to
947 format_, _("%s file" % format_.upper()),
948 upload_to=_upload_to,
949 storage=bofh_storage,
953 ).contribute_to_class(Book, field_name)
955 models.CharField(max_length=255, editable=False, default='', db_index=True).contribute_to_class(Book, f'{field_name}_etag')
961 class BookPopularity(models.Model):
962 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
963 count = models.IntegerField(default=0, db_index=True)