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 ugettext_lazy as _, get_language
19 from django.utils.deconstruct import deconstructible
20 from fnpdjango.storage import BofhFileSystemStorage
22 from librarian.cover import WLCover
23 from librarian.html import transform_abstrakt
24 from newtagging import managers
25 from catalogue import constants
26 from catalogue.fields import EbookField
27 from catalogue.models import Tag, Fragment, BookMedia
28 from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags, get_random_hash
29 from catalogue.models.tag import prefetched_relations
30 from catalogue import app_settings
31 from catalogue import tasks
32 from wolnelektury.utils import makedirs, cached_render, clear_cached_renders
34 bofh_storage = BofhFileSystemStorage()
38 class UploadToPath(object):
39 def __init__(self, path):
42 def __call__(self, instance, filename):
43 return self.path % instance.slug
46 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
47 _cover_clean_upload_to = UploadToPath('book/cover_clean/%s.jpg')
48 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
49 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
50 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
51 _cover_ebookpoint_upload_to = UploadToPath('book/cover_ebookpoint/%s.jpg')
54 def _ebook_upload_to(upload_path):
55 return UploadToPath(upload_path)
58 class Book(models.Model):
59 """Represents a book imported from WL-XML."""
60 title = models.CharField(_('title'), max_length=32767)
61 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
62 sort_key_author = models.CharField(
63 _('sort key by author'), max_length=120, db_index=True, editable=False, default='')
64 slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
65 common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
66 language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
67 description = models.TextField(_('description'), blank=True)
68 abstract = models.TextField(_('abstract'), blank=True)
69 toc = models.TextField(_('toc'), blank=True)
70 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
71 changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
72 parent_number = models.IntegerField(_('parent number'), default=0)
73 extra_info = models.TextField(_('extra information'), default='{}')
74 gazeta_link = models.CharField(blank=True, max_length=240)
75 wiki_link = models.CharField(blank=True, max_length=240)
76 print_on_demand = models.BooleanField(_('print on demand'), default=False)
77 recommended = models.BooleanField(_('recommended'), default=False)
78 audio_length = models.CharField(_('audio length'), blank=True, max_length=8)
79 preview = models.BooleanField(_('preview'), default=False)
80 preview_until = models.DateField(_('preview until'), blank=True, null=True)
81 preview_key = models.CharField(max_length=32, blank=True, null=True)
82 findable = models.BooleanField(_('findable'), default=True, db_index=True)
84 # files generated during publication
87 null=True, blank=True,
88 upload_to=_cover_upload_to,
89 storage=bofh_storage, max_length=255)
90 cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
91 # Cleaner version of cover for thumbs
92 cover_clean = EbookField(
93 'cover_clean', _('clean cover'),
94 null=True, blank=True,
95 upload_to=_cover_clean_upload_to,
98 cover_clean_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
99 cover_thumb = EbookField(
100 'cover_thumb', _('cover thumbnail'),
101 null=True, blank=True,
102 upload_to=_cover_thumb_upload_to,
104 cover_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
105 cover_api_thumb = EbookField(
106 'cover_api_thumb', _('cover thumbnail for mobile app'),
107 null=True, blank=True,
108 upload_to=_cover_api_thumb_upload_to,
110 cover_api_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
111 simple_cover = EbookField(
112 'simple_cover', _('cover for mobile app'),
113 null=True, blank=True,
114 upload_to=_simple_cover_upload_to,
116 simple_cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
117 cover_ebookpoint = EbookField(
118 'cover_ebookpoint', _('cover for Ebookpoint'),
119 null=True, blank=True,
120 upload_to=_cover_ebookpoint_upload_to,
122 cover_ebookpoint_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
123 ebook_formats = constants.EBOOK_FORMATS
124 formats = ebook_formats + ['html', 'xml']
126 parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
127 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
129 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
130 has_audience = models.BooleanField(default=False)
132 objects = models.Manager()
133 tagged = managers.ModelTaggedItemManager(Tag)
134 tags = managers.TagDescriptor(Tag)
135 tag_relations = GenericRelation(Tag.intermediary_table_model)
137 html_built = django.dispatch.Signal()
138 published = django.dispatch.Signal()
142 class AlreadyExists(Exception):
146 ordering = ('sort_key_author', 'sort_key')
147 verbose_name = _('book')
148 verbose_name_plural = _('books')
149 app_label = 'catalogue'
154 def get_extra_info_json(self):
155 return json.loads(self.extra_info or '{}')
157 def get_initial(self):
159 return re.search(r'\w', self.title, re.U).group(0)
160 except AttributeError:
164 return self.tags.filter(category='author')
167 return self.tags.filter(category='epoch')
170 return self.tags.filter(category='genre')
173 return self.tags.filter(category='kind')
175 def tag_unicode(self, category):
176 relations = prefetched_relations(self, category)
178 return ', '.join(rel.tag.name for rel in relations)
180 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
182 def tags_by_category(self):
183 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
185 def author_unicode(self):
186 return self.cached_author
188 def kind_unicode(self):
189 return self.tag_unicode('kind')
191 def epoch_unicode(self):
192 return self.tag_unicode('epoch')
194 def genre_unicode(self):
195 return self.tag_unicode('genre')
197 def translators(self):
198 translators = self.get_extra_info_json().get('translators') or []
200 '\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators
203 def translator(self):
204 translators = self.get_extra_info_json().get('translators')
207 if len(translators) > 3:
208 translators = translators[:2]
212 return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
214 def cover_source(self):
215 return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
219 return self.get_extra_info_json().get('isbn_pdf')
223 return self.get_extra_info_json().get('isbn_epub')
227 return self.get_extra_info_json().get('isbn_mobi')
230 def save(self, force_insert=False, force_update=False, **kwargs):
231 from sortify import sortify
233 self.sort_key = sortify(self.title)[:120]
234 self.title = str(self.title) # ???
237 author = self.authors().first().sort_key
238 except AttributeError:
240 self.sort_key_author = author
242 self.cached_author = self.tag_unicode('author')
243 self.has_audience = 'audience' in self.get_extra_info_json()
245 if self.preview and not self.preview_key:
246 self.preview_key = get_random_hash(self.slug)[:32]
248 ret = super(Book, self).save(force_insert, force_update, **kwargs)
252 def get_absolute_url(self):
253 return reverse('book_detail', args=[self.slug])
255 def gallery_path(self):
256 return gallery_path(self.slug)
258 def gallery_url(self):
259 return gallery_url(self.slug)
261 def get_first_text(self):
264 child = self.children.all().order_by('parent_number').first()
265 if child is not None:
266 return child.get_first_text()
268 def get_last_text(self):
271 child = self.children.all().order_by('parent_number').last()
272 if child is not None:
273 return child.get_last_text()
275 def get_prev_text(self):
278 sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
279 if sibling is not None:
280 return sibling.get_last_text()
282 if self.parent.html_file:
285 return self.parent.get_prev_text()
287 def get_next_text(self):
288 child = self.children.order_by('parent_number').first()
289 if child is not None:
290 return child.get_first_text()
294 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
295 if sibling is not None:
296 return sibling.get_first_text()
297 return self.parent.get_next_text()
299 def get_child_audiobook(self):
300 BookMedia = apps.get_model('catalogue', 'BookMedia')
301 if not BookMedia.objects.filter(book__ancestor=self).exists():
303 for child in self.children.all():
304 if child.has_mp3_file():
306 child_sub = child.get_child_audiobook()
307 if child_sub is not None:
310 def get_siblings(self):
313 return self.parent.children.all().order_by('parent_number')
315 def get_children(self):
316 return self.children.all().order_by('parent_number')
322 def language_code(self):
323 return constants.LANGUAGES_3TO2.get(self.language, self.language)
325 def language_name(self):
326 return dict(settings.LANGUAGES).get(self.language_code(), "")
328 def is_foreign(self):
329 return self.language_code() != settings.LANGUAGE_CODE
331 def set_audio_length(self):
332 length = self.get_audio_length()
334 self.audio_length = self.format_audio_length(length)
338 def format_audio_length(seconds):
340 >>> Book.format_audio_length(1)
342 >>> Book.format_audio_length(3661)
346 minutes = seconds // 60
347 seconds = seconds % 60
348 return '%d:%02d' % (minutes, seconds)
350 hours = seconds // 3600
351 minutes = seconds % 3600 // 60
352 seconds = seconds % 60
353 return '%d:%02d:%02d' % (hours, minutes, seconds)
355 def get_audio_length(self):
357 for media in self.get_mp3() or ():
358 total += app_settings.GET_MP3_LENGTH(media.file.path)
361 def has_media(self, type_):
362 if type_ in Book.formats:
363 return bool(getattr(self, "%s_file" % type_))
365 return self.media.filter(type=type_).exists()
368 return self.has_media('mp3')
370 def get_media(self, type_):
371 if self.has_media(type_):
372 if type_ in Book.formats:
373 return getattr(self, "%s_file" % type_)
375 return self.media.filter(type=type_)
380 return self.get_media("mp3")
383 return self.get_media("odt")
386 return self.get_media("ogg")
389 return self.get_media("daisy")
391 def media_url(self, format_):
392 media = self.get_media(format_)
395 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
402 return self.media_url('html')
405 return self.media_url('pdf')
408 return self.media_url('epub')
411 return self.media_url('mobi')
414 return self.media_url('txt')
417 return self.media_url('fb2')
420 return self.media_url('xml')
422 def has_description(self):
423 return len(self.description) > 0
424 has_description.short_description = _('description')
425 has_description.boolean = True
427 def has_mp3_file(self):
428 return self.has_media("mp3")
429 has_mp3_file.short_description = 'MP3'
430 has_mp3_file.boolean = True
432 def has_ogg_file(self):
433 return self.has_media("ogg")
434 has_ogg_file.short_description = 'OGG'
435 has_ogg_file.boolean = True
437 def has_daisy_file(self):
438 return self.has_media("daisy")
439 has_daisy_file.short_description = 'DAISY'
440 has_daisy_file.boolean = True
443 def media_daisy(self):
444 return self.get_media('daisy')
446 def get_audiobooks(self):
448 for m in self.media.filter(type='ogg').order_by().iterator():
449 ogg_files[m.name] = m
454 for mp3 in self.media.filter(type='mp3').iterator():
455 # ogg files are always from the same project
456 meta = mp3.get_extra_info_json()
457 project = meta.get('project')
460 project = 'CzytamySłuchając'
462 projects.add((project, meta.get('funded_by', '')))
463 total_duration += mp3.duration or 0
467 ogg = ogg_files.get(mp3.name)
470 audiobooks.append(media)
472 projects = sorted(projects)
473 total_duration = '%d:%02d' % (
474 total_duration // 60,
477 return audiobooks, projects, total_duration
479 def wldocument(self, parse_dublincore=True, inherit=True):
480 from catalogue.import_utils import ORMDocProvider
481 from librarian.parser import WLDocument
483 if inherit and self.parent:
484 meta_fallbacks = self.parent.cover_info()
486 meta_fallbacks = None
488 return WLDocument.from_file(
490 provider=ORMDocProvider(self),
491 parse_dublincore=parse_dublincore,
492 meta_fallbacks=meta_fallbacks)
494 def wldocument2(self):
495 from catalogue.import_utils import ORMDocProvider
496 from librarian.document import WLDocument
499 provider=ORMDocProvider(self)
501 doc.meta.update(self.cover_info())
506 def zip_format(format_):
507 def pretty_file_name(book):
508 return "%s/%s.%s" % (
509 book.get_extra_info_json()['author'],
513 field_name = "%s_file" % format_
514 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
515 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
516 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
518 def zip_audiobooks(self, format_):
519 bm = BookMedia.objects.filter(book=self, type=format_)
520 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
523 license = constants.LICENSES.get(
524 m.get_extra_info_json().get('license'), {}
527 licenses.add(license)
528 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
529 'licenses': licenses,
531 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
533 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
534 if not self.findable:
537 from search.index import Index
540 index.index_book(self, book_info)
545 except Exception as e:
546 index.index.rollback()
549 # will make problems in conjunction with paid previews
550 def download_pictures(self, remote_gallery_url):
551 gallery_path = self.gallery_path()
552 # delete previous files, so we don't include old files in ebooks
553 if os.path.isdir(gallery_path):
554 for filename in os.listdir(gallery_path):
555 file_path = os.path.join(gallery_path, filename)
557 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
559 makedirs(gallery_path)
560 for ilustr in ilustr_elements:
561 ilustr_src = ilustr.get('src')
562 ilustr_path = os.path.join(gallery_path, ilustr_src)
563 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
565 def load_abstract(self):
566 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
567 if abstract is not None:
568 self.abstract = transform_abstrakt(abstract)
575 parser = html.HTMLParser(encoding='utf-8')
576 tree = html.parse(self.html_file.path, parser=parser)
577 toc = tree.find('//div[@id="toc"]/ol')
578 if toc is None or not len(toc):
580 html_link = reverse('book_text', args=[self.slug])
581 for a in toc.findall('.//a'):
582 a.attrib['href'] = html_link + a.attrib['href']
583 self.toc = html.tostring(toc, encoding='unicode')
587 def from_xml_file(cls, xml_file, **kwargs):
588 from django.core.files import File
589 from librarian import dcparser
591 # use librarian to parse meta-data
592 book_info = dcparser.parse(xml_file)
594 if not isinstance(xml_file, File):
595 xml_file = File(open(xml_file))
598 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
603 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
604 search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
605 if dont_build is None:
607 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
609 # check for parts before we do anything
611 if hasattr(book_info, 'parts'):
612 for part_url in book_info.parts:
614 children.append(Book.objects.get(slug=part_url.slug))
615 except Book.DoesNotExist:
616 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
619 book_slug = book_info.url.slug
620 if re.search(r'[^a-z0-9-]', book_slug):
621 raise ValueError('Invalid characters in slug')
622 book, created = Book.objects.get_or_create(slug=book_slug)
627 book.preview = bool(days)
629 book.preview_until = date.today() + timedelta(days)
632 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
633 # Save shelves for this book
634 book_shelves = list(book.tags.filter(category='set'))
635 old_cover = book.cover_info()
638 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
640 book.xml_file.set_readable(False)
642 book.findable = findable
643 book.language = book_info.language
644 book.title = book_info.title
645 if book_info.variant_of:
646 book.common_slug = book_info.variant_of.slug
648 book.common_slug = book.slug
649 book.extra_info = json.dumps(book_info.to_dict())
654 meta_tags = Tag.tags_from_info(book_info)
656 for tag in meta_tags:
657 if not tag.for_books:
661 book.tags = set(meta_tags + book_shelves)
662 book.save() # update sort_key_author
664 cover_changed = old_cover != book.cover_info()
665 obsolete_children = set(b for b in book.children.all()
666 if b not in children)
667 notify_cover_changed = []
668 for n, child_book in enumerate(children):
669 new_child = child_book.parent != book
670 child_book.parent = book
671 child_book.parent_number = n
673 if new_child or cover_changed:
674 notify_cover_changed.append(child_book)
675 # Disown unfaithful children and let them cope on their own.
676 for child in obsolete_children:
678 child.parent_number = 0
681 notify_cover_changed.append(child)
683 cls.repopulate_ancestors()
684 tasks.update_counters.delay()
686 if remote_gallery_url:
687 book.download_pictures(remote_gallery_url)
689 # No saves beyond this point.
692 if 'cover' not in dont_build:
693 book.cover.build_delay()
694 book.cover_clean.build_delay()
695 book.cover_thumb.build_delay()
696 book.cover_api_thumb.build_delay()
697 book.simple_cover.build_delay()
698 book.cover_ebookpoint.build_delay()
700 # Build HTML and ebooks.
701 book.html_file.build_delay()
703 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
704 if format_ not in dont_build:
705 getattr(book, '%s_file' % format_).build_delay()
706 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
707 if format_ not in dont_build:
708 getattr(book, '%s_file' % format_).build_delay()
710 if not settings.NO_SEARCH_INDEX and search_index and findable:
711 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
713 for child in notify_cover_changed:
714 child.parent_cover_changed()
716 book.update_popularity()
717 tasks.update_references.delay(book.id)
719 cls.published.send(sender=cls, instance=book)
722 def get_master(self):
726 'dramat_wierszowany_l',
727 'dramat_wierszowany_lp',
728 'dramat_wspolczesny', 'liryka_l', 'liryka_lp',
731 from librarian.parser import WLDocument
732 wld = WLDocument.from_file(self.xml_file.path, parse_dublincore=False)
733 root = wld.edoc.getroot()
734 for master in root.iter():
735 if master.tag in master_tags:
738 def update_references(self):
739 from references.models import Entity, Reference
740 master = self.get_master()
744 for i, sec in enumerate(master):
745 for ref in sec.findall('.//ref'):
746 href = ref.attrib.get('href', '')
747 if not href or href in found:
750 entity, created = Entity.objects.get_or_create(
753 ref, created = Reference.objects.get_or_create(
757 ref.first_section = 'sec%d' % (i + 1)
760 Reference.objects.filter(book=self).exclude(entity__uri__in=found).delete()
763 def references(self):
764 return self.reference_set.all().select_related('entity')
768 def repopulate_ancestors(cls):
769 """Fixes the ancestry cache."""
771 cursor = connection.cursor()
772 if connection.vendor == 'postgres':
773 cursor.execute("TRUNCATE catalogue_book_ancestor")
775 WITH RECURSIVE ancestry AS (
776 SELECT book.id, book.parent_id
777 FROM catalogue_book AS book
778 WHERE book.parent_id IS NOT NULL
780 SELECT ancestor.id, book.parent_id
781 FROM ancestry AS ancestor, catalogue_book AS book
782 WHERE ancestor.parent_id = book.id
783 AND book.parent_id IS NOT NULL
785 INSERT INTO catalogue_book_ancestor
786 (from_book_id, to_book_id)
792 cursor.execute("DELETE FROM catalogue_book_ancestor")
793 for b in cls.objects.exclude(parent=None):
795 while parent is not None:
796 b.ancestor.add(parent)
797 parent = parent.parent
802 for anc in self.parent.ancestors:
808 def clear_cache(self):
809 clear_cached_renders(self.mini_box)
810 clear_cached_renders(self.mini_box_nolink)
812 def cover_info(self, inherit=True):
813 """Returns a dictionary to serve as fallback for BookInfo.
815 For now, the only thing inherited is the cover image.
819 for field in ('cover_url', 'cover_by', 'cover_source'):
820 val = self.get_extra_info_json().get(field)
825 if inherit and need and self.parent is not None:
826 parent_info = self.parent.cover_info()
827 parent_info.update(info)
831 def related_themes(self):
832 return Tag.objects.usage_for_queryset(
833 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
834 counts=True).filter(category='theme')
836 def parent_cover_changed(self):
837 """Called when parent book's cover image is changed."""
838 if not self.cover_info(inherit=False):
839 if 'cover' not in app_settings.DONT_BUILD:
840 self.cover.build_delay()
841 self.cover_clean.build_delay()
842 self.cover_thumb.build_delay()
843 self.cover_api_thumb.build_delay()
844 self.simple_cover.build_delay()
845 self.cover_ebookpoint.build_delay()
846 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
847 if format_ not in app_settings.DONT_BUILD:
848 getattr(self, '%s_file' % format_).build_delay()
849 for child in self.children.all():
850 child.parent_cover_changed()
852 def other_versions(self):
853 """Find other versions (i.e. in other languages) of the book."""
854 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
859 while parent is not None:
860 books.insert(0, parent)
861 parent = parent.parent
864 def pretty_title(self, html_links=False):
865 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
866 books = self.parents() + [self]
867 names.extend([(b.title, b.get_absolute_url()) for b in books])
870 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
872 names = [tag[0] for tag in names]
873 return ', '.join(names)
876 publisher = self.get_extra_info_json()['publisher']
877 if isinstance(publisher, str):
879 elif isinstance(publisher, list):
880 return ', '.join(publisher)
883 def tagged_top_level(cls, tags):
884 """ Returns top-level books tagged with `tags`.
886 It only returns those books which don't have ancestors which are
887 also tagged with those tags.
890 objects = cls.tagged.with_all(tags)
891 return objects.filter(findable=True).exclude(ancestor__in=objects)
894 def book_list(cls, book_filter=None):
895 """Generates a hierarchical listing of all books.
897 Books are optionally filtered with a test function.
902 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
904 books = books.filter(book_filter).distinct()
906 book_ids = set(b['pk'] for b in books.values("pk").iterator())
907 for book in books.iterator():
908 parent = book.parent_id
909 if parent not in book_ids:
911 books_by_parent.setdefault(parent, []).append(book)
913 for book in books.iterator():
914 books_by_parent.setdefault(book.parent_id, []).append(book)
917 books_by_author = OrderedDict()
918 for tag in Tag.objects.filter(category='author').iterator():
919 books_by_author[tag] = []
921 for book in books_by_parent.get(None, ()):
922 authors = list(book.authors().only('pk'))
924 for author in authors:
925 books_by_author[author].append(book)
929 return books_by_author, orphans, books_by_parent
932 "SP": (1, "szkoła podstawowa"),
933 "SP1": (1, "szkoła podstawowa"),
934 "SP2": (1, "szkoła podstawowa"),
935 "SP3": (1, "szkoła podstawowa"),
936 "P": (1, "szkoła podstawowa"),
937 "G": (2, "gimnazjum"),
942 def audiences_pl(self):
943 audiences = self.get_extra_info_json().get('audiences', [])
944 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
945 return [a[1] for a in audiences]
947 def stage_note(self):
948 stage = self.get_extra_info_json().get('stage')
949 if stage and stage < '0.4':
950 return (_('This work needs modernisation'),
951 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
955 def choose_fragments(self, number):
956 fragments = self.fragments.order_by()
957 fragments_count = fragments.count()
958 if not fragments_count and self.children.exists():
959 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
960 fragments_count = fragments.count()
962 if fragments_count > number:
963 offset = randint(0, fragments_count - number)
966 return fragments[offset : offset + number]
968 return self.parent.choose_fragments(number)
972 def choose_fragment(self):
973 fragments = self.choose_fragments(1)
979 def fragment_data(self):
980 fragment = self.choose_fragment()
983 'title': fragment.book.pretty_title(),
984 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
989 def update_popularity(self):
990 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
992 pop = self.popularity
995 except BookPopularity.DoesNotExist:
996 BookPopularity.objects.create(book=self, count=count)
998 def ridero_link(self):
999 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
1001 def like(self, user):
1002 from social.utils import likes, get_set, set_sets
1003 if not likes(user, self):
1004 tag = get_set(user, '')
1005 set_sets(user, self, [tag])
1007 def unlike(self, user):
1008 from social.utils import likes, set_sets
1009 if likes(user, self):
1010 set_sets(user, self, [])
1012 def full_sort_key(self):
1013 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1015 def cover_color(self):
1016 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1018 @cached_render('catalogue/book_mini_box.html')
1024 @cached_render('catalogue/book_mini_box.html')
1025 def mini_box_nolink(self):
1031 def add_file_fields():
1032 for format_ in Book.formats:
1033 field_name = "%s_file" % format_
1034 # This weird globals() assignment makes Django migrations comfortable.
1035 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
1036 _upload_to.__name__ = '_%s_upload_to' % format_
1037 globals()[_upload_to.__name__] = _upload_to
1040 format_, _("%s file" % format_.upper()),
1041 upload_to=_upload_to,
1042 storage=bofh_storage,
1046 ).contribute_to_class(Book, field_name)
1047 if format_ != 'xml':
1048 models.CharField(max_length=255, editable=False, default='', db_index=True).contribute_to_class(Book, f'{field_name}_etag')
1054 class BookPopularity(models.Model):
1055 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1056 count = models.IntegerField(default=0, db_index=True)