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.urls import reverse
16 from django.utils.translation import ugettext_lazy as _, get_language
17 from django.utils.deconstruct import deconstructible
18 from fnpdjango.storage import BofhFileSystemStorage
20 from librarian.cover import WLCover
21 from librarian.html import transform_abstrakt
22 from newtagging import managers
23 from catalogue import constants
24 from catalogue.fields import EbookField
25 from catalogue.models import Tag, Fragment, BookMedia
26 from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags, get_random_hash
27 from catalogue.models.tag import prefetched_relations
28 from catalogue import app_settings
29 from catalogue import tasks
30 from wolnelektury.utils import makedirs, cached_render, clear_cached_renders
32 bofh_storage = BofhFileSystemStorage()
36 class UploadToPath(object):
37 def __init__(self, path):
40 def __call__(self, instance, filename):
41 return self.path % instance.slug
44 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
45 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
46 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
47 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
48 _cover_ebookpoint_upload_to = UploadToPath('book/cover_ebookpoint/%s.jpg')
51 def _ebook_upload_to(upload_path):
52 return UploadToPath(upload_path)
55 class Book(models.Model):
56 """Represents a book imported from WL-XML."""
57 title = models.CharField(_('title'), max_length=32767)
58 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
59 sort_key_author = models.CharField(
60 _('sort key by author'), max_length=120, db_index=True, editable=False, default='')
61 slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
62 common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
63 language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
64 description = models.TextField(_('description'), blank=True)
65 abstract = models.TextField(_('abstract'), blank=True)
66 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
67 changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
68 parent_number = models.IntegerField(_('parent number'), default=0)
69 extra_info = models.TextField(_('extra information'), default='{}')
70 gazeta_link = models.CharField(blank=True, max_length=240)
71 wiki_link = models.CharField(blank=True, max_length=240)
72 print_on_demand = models.BooleanField(_('print on demand'), default=False)
73 recommended = models.BooleanField(_('recommended'), default=False)
74 audio_length = models.CharField(_('audio length'), blank=True, max_length=8)
75 preview = models.BooleanField(_('preview'), default=False)
76 preview_until = models.DateField(_('preview until'), blank=True, null=True)
77 preview_key = models.CharField(max_length=32, blank=True, null=True)
78 findable = models.BooleanField(_('findable'), default=True, db_index=True)
80 # files generated during publication
83 null=True, blank=True,
84 upload_to=_cover_upload_to,
85 storage=bofh_storage, max_length=255)
86 cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
87 # Cleaner version of cover for thumbs
88 cover_thumb = EbookField(
89 'cover_thumb', _('cover thumbnail'),
90 null=True, blank=True,
91 upload_to=_cover_thumb_upload_to,
93 cover_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
94 cover_api_thumb = EbookField(
95 'cover_api_thumb', _('cover thumbnail for mobile app'),
96 null=True, blank=True,
97 upload_to=_cover_api_thumb_upload_to,
99 cover_api_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
100 simple_cover = EbookField(
101 'simple_cover', _('cover for mobile app'),
102 null=True, blank=True,
103 upload_to=_simple_cover_upload_to,
105 simple_cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
106 cover_ebookpoint = EbookField(
107 'cover_ebookpoint', _('cover for Ebookpoint'),
108 null=True, blank=True,
109 upload_to=_cover_ebookpoint_upload_to,
111 cover_ebookpoint_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
112 ebook_formats = constants.EBOOK_FORMATS
113 formats = ebook_formats + ['html', 'xml']
115 parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
116 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
118 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
119 has_audience = models.BooleanField(default=False)
121 objects = models.Manager()
122 tagged = managers.ModelTaggedItemManager(Tag)
123 tags = managers.TagDescriptor(Tag)
124 tag_relations = GenericRelation(Tag.intermediary_table_model)
126 html_built = django.dispatch.Signal()
127 published = django.dispatch.Signal()
131 class AlreadyExists(Exception):
135 ordering = ('sort_key_author', 'sort_key')
136 verbose_name = _('book')
137 verbose_name_plural = _('books')
138 app_label = 'catalogue'
143 def get_extra_info_json(self):
144 return json.loads(self.extra_info or '{}')
146 def get_initial(self):
148 return re.search(r'\w', self.title, re.U).group(0)
149 except AttributeError:
153 return self.tags.filter(category='author')
156 return self.tags.filter(category='epoch')
159 return self.tags.filter(category='genre')
162 return self.tags.filter(category='kind')
164 def tag_unicode(self, category):
165 relations = prefetched_relations(self, category)
167 return ', '.join(rel.tag.name for rel in relations)
169 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
171 def tags_by_category(self):
172 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
174 def author_unicode(self):
175 return self.cached_author
177 def kind_unicode(self):
178 return self.tag_unicode('kind')
180 def epoch_unicode(self):
181 return self.tag_unicode('epoch')
183 def genre_unicode(self):
184 return self.tag_unicode('genre')
186 def translators(self):
187 translators = self.get_extra_info_json().get('translators') or []
189 '\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators
192 def translator(self):
193 translators = self.get_extra_info_json().get('translators')
196 if len(translators) > 3:
197 translators = translators[:2]
201 return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
203 def cover_source(self):
204 return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
208 return self.get_extra_info_json().get('isbn_pdf')
212 return self.get_extra_info_json().get('isbn_epub')
216 return self.get_extra_info_json().get('isbn_mobi')
219 def save(self, force_insert=False, force_update=False, **kwargs):
220 from sortify import sortify
222 self.sort_key = sortify(self.title)[:120]
223 self.title = str(self.title) # ???
226 author = self.authors().first().sort_key
227 except AttributeError:
229 self.sort_key_author = author
231 self.cached_author = self.tag_unicode('author')
232 self.has_audience = 'audience' in self.get_extra_info_json()
234 if self.preview and not self.preview_key:
235 self.preview_key = get_random_hash(self.slug)[:32]
237 ret = super(Book, self).save(force_insert, force_update, **kwargs)
241 def get_absolute_url(self):
242 return reverse('book_detail', args=[self.slug])
244 def gallery_path(self):
245 return gallery_path(self.slug)
247 def gallery_url(self):
248 return gallery_url(self.slug)
250 def get_first_text(self):
253 child = self.children.all().order_by('parent_number').first()
254 if child is not None:
255 return child.get_first_text()
257 def get_last_text(self):
260 child = self.children.all().order_by('parent_number').last()
261 if child is not None:
262 return child.get_last_text()
264 def get_prev_text(self):
267 sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
268 if sibling is not None:
269 return sibling.get_last_text()
270 return self.parent.get_prev_text()
272 def get_next_text(self):
275 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
276 if sibling is not None:
277 return sibling.get_first_text()
278 return self.parent.get_next_text()
280 def get_siblings(self):
283 return self.parent.children.all().order_by('parent_number')
289 def language_code(self):
290 return constants.LANGUAGES_3TO2.get(self.language, self.language)
292 def language_name(self):
293 return dict(settings.LANGUAGES).get(self.language_code(), "")
295 def is_foreign(self):
296 return self.language_code() != settings.LANGUAGE_CODE
298 def set_audio_length(self):
299 length = self.get_audio_length()
301 self.audio_length = self.format_audio_length(length)
305 def format_audio_length(seconds):
307 >>> Book.format_audio_length(1)
309 >>> Book.format_audio_length(3661)
313 minutes = seconds // 60
314 seconds = seconds % 60
315 return '%d:%02d' % (minutes, seconds)
317 hours = seconds // 3600
318 minutes = seconds % 3600 // 60
319 seconds = seconds % 60
320 return '%d:%02d:%02d' % (hours, minutes, seconds)
322 def get_audio_length(self):
324 for media in self.get_mp3() or ():
325 total += app_settings.GET_MP3_LENGTH(media.file.path)
328 def has_media(self, type_):
329 if type_ in Book.formats:
330 return bool(getattr(self, "%s_file" % type_))
332 return self.media.filter(type=type_).exists()
335 return self.has_media('mp3')
337 def get_media(self, type_):
338 if self.has_media(type_):
339 if type_ in Book.formats:
340 return getattr(self, "%s_file" % type_)
342 return self.media.filter(type=type_)
347 return self.get_media("mp3")
350 return self.get_media("odt")
353 return self.get_media("ogg")
356 return self.get_media("daisy")
358 def media_url(self, format_):
359 media = self.get_media(format_)
362 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
369 return self.media_url('html')
372 return self.media_url('pdf')
375 return self.media_url('epub')
378 return self.media_url('mobi')
381 return self.media_url('txt')
384 return self.media_url('fb2')
387 return self.media_url('xml')
389 def has_description(self):
390 return len(self.description) > 0
391 has_description.short_description = _('description')
392 has_description.boolean = True
394 def has_mp3_file(self):
395 return self.has_media("mp3")
396 has_mp3_file.short_description = 'MP3'
397 has_mp3_file.boolean = True
399 def has_ogg_file(self):
400 return self.has_media("ogg")
401 has_ogg_file.short_description = 'OGG'
402 has_ogg_file.boolean = True
404 def has_daisy_file(self):
405 return self.has_media("daisy")
406 has_daisy_file.short_description = 'DAISY'
407 has_daisy_file.boolean = True
409 def get_audiobooks(self):
411 for m in self.media.filter(type='ogg').order_by().iterator():
412 ogg_files[m.name] = m
416 for mp3 in self.media.filter(type='mp3').iterator():
417 # ogg files are always from the same project
418 meta = mp3.get_extra_info_json()
419 project = meta.get('project')
422 project = 'CzytamySłuchając'
424 projects.add((project, meta.get('funded_by', '')))
428 ogg = ogg_files.get(mp3.name)
431 audiobooks.append(media)
433 projects = sorted(projects)
434 return audiobooks, projects
436 def wldocument(self, parse_dublincore=True, inherit=True):
437 from catalogue.import_utils import ORMDocProvider
438 from librarian.parser import WLDocument
440 if inherit and self.parent:
441 meta_fallbacks = self.parent.cover_info()
443 meta_fallbacks = None
445 return WLDocument.from_file(
447 provider=ORMDocProvider(self),
448 parse_dublincore=parse_dublincore,
449 meta_fallbacks=meta_fallbacks)
452 def zip_format(format_):
453 def pretty_file_name(book):
454 return "%s/%s.%s" % (
455 book.get_extra_info_json()['author'],
459 field_name = "%s_file" % format_
460 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
461 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
462 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
464 def zip_audiobooks(self, format_):
465 bm = BookMedia.objects.filter(book=self, type=format_)
466 paths = map(lambda bm: (None, bm.file.path), bm)
467 return create_zip(paths, "%s_%s" % (self.slug, format_))
469 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
470 if not self.findable:
473 from search.index import Index
476 index.index_book(self, book_info)
481 except Exception as e:
482 index.index.rollback()
485 # will make problems in conjunction with paid previews
486 def download_pictures(self, remote_gallery_url):
487 gallery_path = self.gallery_path()
488 # delete previous files, so we don't include old files in ebooks
489 if os.path.isdir(gallery_path):
490 for filename in os.listdir(gallery_path):
491 file_path = os.path.join(gallery_path, filename)
493 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
495 makedirs(gallery_path)
496 for ilustr in ilustr_elements:
497 ilustr_src = ilustr.get('src')
498 ilustr_path = os.path.join(gallery_path, ilustr_src)
499 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
501 def load_abstract(self):
502 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
503 if abstract is not None:
504 self.abstract = transform_abstrakt(abstract)
509 def from_xml_file(cls, xml_file, **kwargs):
510 from django.core.files import File
511 from librarian import dcparser
513 # use librarian to parse meta-data
514 book_info = dcparser.parse(xml_file)
516 if not isinstance(xml_file, File):
517 xml_file = File(open(xml_file))
520 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
525 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
526 search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
527 if dont_build is None:
529 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
531 # check for parts before we do anything
533 if hasattr(book_info, 'parts'):
534 for part_url in book_info.parts:
536 children.append(Book.objects.get(slug=part_url.slug))
537 except Book.DoesNotExist:
538 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
541 book_slug = book_info.url.slug
542 if re.search(r'[^a-z0-9-]', book_slug):
543 raise ValueError('Invalid characters in slug')
544 book, created = Book.objects.get_or_create(slug=book_slug)
549 book.preview = bool(days)
551 book.preview_until = date.today() + timedelta(days)
554 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
555 # Save shelves for this book
556 book_shelves = list(book.tags.filter(category='set'))
557 old_cover = book.cover_info()
560 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
562 book.xml_file.set_readable(False)
564 book.findable = findable
565 book.language = book_info.language
566 book.title = book_info.title
567 if book_info.variant_of:
568 book.common_slug = book_info.variant_of.slug
570 book.common_slug = book.slug
571 book.extra_info = json.dumps(book_info.to_dict())
575 meta_tags = Tag.tags_from_info(book_info)
577 for tag in meta_tags:
578 if not tag.for_books:
582 book.tags = set(meta_tags + book_shelves)
583 book.save() # update sort_key_author
585 cover_changed = old_cover != book.cover_info()
586 obsolete_children = set(b for b in book.children.all()
587 if b not in children)
588 notify_cover_changed = []
589 for n, child_book in enumerate(children):
590 new_child = child_book.parent != book
591 child_book.parent = book
592 child_book.parent_number = n
594 if new_child or cover_changed:
595 notify_cover_changed.append(child_book)
596 # Disown unfaithful children and let them cope on their own.
597 for child in obsolete_children:
599 child.parent_number = 0
602 notify_cover_changed.append(child)
604 cls.repopulate_ancestors()
605 tasks.update_counters.delay()
607 if remote_gallery_url:
608 book.download_pictures(remote_gallery_url)
610 # No saves beyond this point.
613 if 'cover' not in dont_build:
614 book.cover.build_delay()
615 book.cover_thumb.build_delay()
616 book.cover_api_thumb.build_delay()
617 book.simple_cover.build_delay()
618 book.cover_ebookpoint.build_delay()
620 # Build HTML and ebooks.
621 book.html_file.build_delay()
623 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
624 if format_ not in dont_build:
625 getattr(book, '%s_file' % format_).build_delay()
626 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
627 if format_ not in dont_build:
628 getattr(book, '%s_file' % format_).build_delay()
630 if not settings.NO_SEARCH_INDEX and search_index and findable:
631 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
633 for child in notify_cover_changed:
634 child.parent_cover_changed()
636 book.update_popularity()
637 cls.published.send(sender=cls, instance=book)
642 def repopulate_ancestors(cls):
643 """Fixes the ancestry cache."""
645 cursor = connection.cursor()
646 if connection.vendor == 'postgres':
647 cursor.execute("TRUNCATE catalogue_book_ancestor")
649 WITH RECURSIVE ancestry AS (
650 SELECT book.id, book.parent_id
651 FROM catalogue_book AS book
652 WHERE book.parent_id IS NOT NULL
654 SELECT ancestor.id, book.parent_id
655 FROM ancestry AS ancestor, catalogue_book AS book
656 WHERE ancestor.parent_id = book.id
657 AND book.parent_id IS NOT NULL
659 INSERT INTO catalogue_book_ancestor
660 (from_book_id, to_book_id)
666 cursor.execute("DELETE FROM catalogue_book_ancestor")
667 for b in cls.objects.exclude(parent=None):
669 while parent is not None:
670 b.ancestor.add(parent)
671 parent = parent.parent
673 def clear_cache(self):
674 clear_cached_renders(self.mini_box)
675 clear_cached_renders(self.mini_box_nolink)
677 def cover_info(self, inherit=True):
678 """Returns a dictionary to serve as fallback for BookInfo.
680 For now, the only thing inherited is the cover image.
684 for field in ('cover_url', 'cover_by', 'cover_source'):
685 val = self.get_extra_info_json().get(field)
690 if inherit and need and self.parent is not None:
691 parent_info = self.parent.cover_info()
692 parent_info.update(info)
696 def related_themes(self):
697 return Tag.objects.usage_for_queryset(
698 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
699 counts=True).filter(category='theme')
701 def parent_cover_changed(self):
702 """Called when parent book's cover image is changed."""
703 if not self.cover_info(inherit=False):
704 if 'cover' not in app_settings.DONT_BUILD:
705 self.cover.build_delay()
706 self.cover_thumb.build_delay()
707 self.cover_api_thumb.build_delay()
708 self.simple_cover.build_delay()
709 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
710 if format_ not in app_settings.DONT_BUILD:
711 getattr(self, '%s_file' % format_).build_delay()
712 for child in self.children.all():
713 child.parent_cover_changed()
715 def other_versions(self):
716 """Find other versions (i.e. in other languages) of the book."""
717 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
722 while parent is not None:
723 books.insert(0, parent)
724 parent = parent.parent
727 def pretty_title(self, html_links=False):
728 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
729 books = self.parents() + [self]
730 names.extend([(b.title, b.get_absolute_url()) for b in books])
733 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
735 names = [tag[0] for tag in names]
736 return ', '.join(names)
739 publisher = self.get_extra_info_json()['publisher']
740 if isinstance(publisher, str):
742 elif isinstance(publisher, list):
743 return ', '.join(publisher)
746 def tagged_top_level(cls, tags):
747 """ Returns top-level books tagged with `tags`.
749 It only returns those books which don't have ancestors which are
750 also tagged with those tags.
753 objects = cls.tagged.with_all(tags)
754 return objects.filter(findable=True).exclude(ancestor__in=objects)
757 def book_list(cls, book_filter=None):
758 """Generates a hierarchical listing of all books.
760 Books are optionally filtered with a test function.
765 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
767 books = books.filter(book_filter).distinct()
769 book_ids = set(b['pk'] for b in books.values("pk").iterator())
770 for book in books.iterator():
771 parent = book.parent_id
772 if parent not in book_ids:
774 books_by_parent.setdefault(parent, []).append(book)
776 for book in books.iterator():
777 books_by_parent.setdefault(book.parent_id, []).append(book)
780 books_by_author = OrderedDict()
781 for tag in Tag.objects.filter(category='author').iterator():
782 books_by_author[tag] = []
784 for book in books_by_parent.get(None, ()):
785 authors = list(book.authors().only('pk'))
787 for author in authors:
788 books_by_author[author].append(book)
792 return books_by_author, orphans, books_by_parent
795 "SP": (1, "szkoła podstawowa"),
796 "SP1": (1, "szkoła podstawowa"),
797 "SP2": (1, "szkoła podstawowa"),
798 "SP3": (1, "szkoła podstawowa"),
799 "P": (1, "szkoła podstawowa"),
800 "G": (2, "gimnazjum"),
805 def audiences_pl(self):
806 audiences = self.get_extra_info_json().get('audiences', [])
807 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
808 return [a[1] for a in audiences]
810 def stage_note(self):
811 stage = self.get_extra_info_json().get('stage')
812 if stage and stage < '0.4':
813 return (_('This work needs modernisation'),
814 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
818 def choose_fragment(self):
819 fragments = self.fragments.order_by()
820 fragments_count = fragments.count()
821 if not fragments_count and self.children.exists():
822 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
823 fragments_count = fragments.count()
825 return fragments[randint(0, fragments_count - 1)]
827 return self.parent.choose_fragment()
831 def fragment_data(self):
832 fragment = self.choose_fragment()
835 'title': fragment.book.pretty_title(),
836 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
841 def update_popularity(self):
842 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
844 pop = self.popularity
847 except BookPopularity.DoesNotExist:
848 BookPopularity.objects.create(book=self, count=count)
850 def ridero_link(self):
851 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
853 def like(self, user):
854 from social.utils import likes, get_set, set_sets
855 if not likes(user, self):
856 tag = get_set(user, '')
857 set_sets(user, self, [tag])
859 def unlike(self, user):
860 from social.utils import likes, set_sets
861 if likes(user, self):
862 set_sets(user, self, [])
864 def full_sort_key(self):
865 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
867 def cover_color(self):
868 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
870 @cached_render('catalogue/book_mini_box.html')
876 @cached_render('catalogue/book_mini_box.html')
877 def mini_box_nolink(self):
883 def add_file_fields():
884 for format_ in Book.formats:
885 field_name = "%s_file" % format_
886 # This weird globals() assignment makes Django migrations comfortable.
887 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
888 _upload_to.__name__ = '_%s_upload_to' % format_
889 globals()[_upload_to.__name__] = _upload_to
892 format_, _("%s file" % format_.upper()),
893 upload_to=_upload_to,
894 storage=bofh_storage,
898 ).contribute_to_class(Book, field_name)
900 models.CharField(max_length=255, editable=False, default='', db_index=True).contribute_to_class(Book, f'{field_name}_etag')
906 class BookPopularity(models.Model):
907 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
908 count = models.IntegerField(default=0, db_index=True)