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')
229 def is_accessible_to(self, user):
232 Membership = apps.get_model('club', 'Membership')
233 return Membership.is_active_for(user)
235 def save(self, force_insert=False, force_update=False, **kwargs):
236 from sortify import sortify
238 self.sort_key = sortify(self.title)[:120]
239 self.title = str(self.title) # ???
242 author = self.authors().first().sort_key
243 except AttributeError:
245 self.sort_key_author = author
247 self.cached_author = self.tag_unicode('author')
248 self.has_audience = 'audience' in self.get_extra_info_json()
250 if self.preview and not self.preview_key:
251 self.preview_key = get_random_hash(self.slug)[:32]
253 ret = super(Book, self).save(force_insert, force_update, **kwargs)
257 def get_absolute_url(self):
258 return reverse('book_detail', args=[self.slug])
260 def gallery_path(self):
261 return gallery_path(self.slug)
263 def gallery_url(self):
264 return gallery_url(self.slug)
266 def get_first_text(self):
269 child = self.children.all().order_by('parent_number').first()
270 if child is not None:
271 return child.get_first_text()
273 def get_last_text(self):
276 child = self.children.all().order_by('parent_number').last()
277 if child is not None:
278 return child.get_last_text()
280 def get_prev_text(self):
283 sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
284 if sibling is not None:
285 return sibling.get_last_text()
287 if self.parent.html_file:
290 return self.parent.get_prev_text()
292 def get_next_text(self):
293 child = self.children.order_by('parent_number').first()
294 if child is not None:
295 return child.get_first_text()
299 sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
300 if sibling is not None:
301 return sibling.get_first_text()
302 return self.parent.get_next_text()
304 def get_child_audiobook(self):
305 BookMedia = apps.get_model('catalogue', 'BookMedia')
306 if not BookMedia.objects.filter(book__ancestor=self).exists():
308 for child in self.children.all():
309 if child.has_mp3_file():
311 child_sub = child.get_child_audiobook()
312 if child_sub is not None:
315 def get_siblings(self):
318 return self.parent.children.all().order_by('parent_number')
320 def get_children(self):
321 return self.children.all().order_by('parent_number')
327 def language_code(self):
328 return constants.LANGUAGES_3TO2.get(self.language, self.language)
330 def language_name(self):
331 return dict(settings.LANGUAGES).get(self.language_code(), "")
333 def is_foreign(self):
334 return self.language_code() != settings.LANGUAGE_CODE
336 def set_audio_length(self):
337 length = self.get_audio_length()
339 self.audio_length = self.format_audio_length(length)
343 def format_audio_length(seconds):
345 >>> Book.format_audio_length(1)
347 >>> Book.format_audio_length(3661)
351 minutes = seconds // 60
352 seconds = seconds % 60
353 return '%d:%02d' % (minutes, seconds)
355 hours = seconds // 3600
356 minutes = seconds % 3600 // 60
357 seconds = seconds % 60
358 return '%d:%02d:%02d' % (hours, minutes, seconds)
360 def get_audio_length(self):
362 for media in self.get_mp3() or ():
363 total += app_settings.GET_MP3_LENGTH(media.file.path)
366 def has_media(self, type_):
367 if type_ in Book.formats:
368 return bool(getattr(self, "%s_file" % type_))
370 return self.media.filter(type=type_).exists()
373 return self.has_media('mp3')
375 def get_media(self, type_):
376 if self.has_media(type_):
377 if type_ in Book.formats:
378 return getattr(self, "%s_file" % type_)
380 return self.media.filter(type=type_)
385 return self.get_media("mp3")
388 return self.get_media("odt")
391 return self.get_media("ogg")
394 return self.get_media("daisy")
396 def media_url(self, format_):
397 media = self.get_media(format_)
400 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
407 return self.media_url('html')
410 return self.media_url('pdf')
413 return self.media_url('epub')
416 return self.media_url('mobi')
419 return self.media_url('txt')
422 return self.media_url('fb2')
425 return self.media_url('xml')
427 def has_description(self):
428 return len(self.description) > 0
429 has_description.short_description = _('description')
430 has_description.boolean = True
432 def has_mp3_file(self):
433 return self.has_media("mp3")
434 has_mp3_file.short_description = 'MP3'
435 has_mp3_file.boolean = True
437 def has_ogg_file(self):
438 return self.has_media("ogg")
439 has_ogg_file.short_description = 'OGG'
440 has_ogg_file.boolean = True
442 def has_daisy_file(self):
443 return self.has_media("daisy")
444 has_daisy_file.short_description = 'DAISY'
445 has_daisy_file.boolean = True
448 def media_daisy(self):
449 return self.get_media('daisy')
451 def get_audiobooks(self):
453 for m in self.media.filter(type='ogg').order_by().iterator():
454 ogg_files[m.name] = m
459 for mp3 in self.media.filter(type='mp3').iterator():
460 # ogg files are always from the same project
461 meta = mp3.get_extra_info_json()
462 project = meta.get('project')
465 project = 'CzytamySłuchając'
467 projects.add((project, meta.get('funded_by', '')))
468 total_duration += mp3.duration or 0
472 ogg = ogg_files.get(mp3.name)
475 audiobooks.append(media)
477 projects = sorted(projects)
478 total_duration = '%d:%02d' % (
479 total_duration // 60,
482 return audiobooks, projects, total_duration
484 def wldocument(self, parse_dublincore=True, inherit=True):
485 from catalogue.import_utils import ORMDocProvider
486 from librarian.parser import WLDocument
488 if inherit and self.parent:
489 meta_fallbacks = self.parent.cover_info()
491 meta_fallbacks = None
493 return WLDocument.from_file(
495 provider=ORMDocProvider(self),
496 parse_dublincore=parse_dublincore,
497 meta_fallbacks=meta_fallbacks)
499 def wldocument2(self):
500 from catalogue.import_utils import ORMDocProvider
501 from librarian.document import WLDocument
504 provider=ORMDocProvider(self)
506 doc.meta.update(self.cover_info())
511 def zip_format(format_):
512 def pretty_file_name(book):
513 return "%s/%s.%s" % (
514 book.get_extra_info_json()['author'],
518 field_name = "%s_file" % format_
519 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
520 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
521 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
523 def zip_audiobooks(self, format_):
524 bm = BookMedia.objects.filter(book=self, type=format_)
525 paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
528 license = constants.LICENSES.get(
529 m.get_extra_info_json().get('license'), {}
532 licenses.add(license)
533 readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
534 'licenses': licenses,
536 return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
538 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
539 if not self.findable:
542 from search.index import Index
545 index.index_book(self, book_info)
550 except Exception as e:
551 index.index.rollback()
554 # will make problems in conjunction with paid previews
555 def download_pictures(self, remote_gallery_url):
556 gallery_path = self.gallery_path()
557 # delete previous files, so we don't include old files in ebooks
558 if os.path.isdir(gallery_path):
559 for filename in os.listdir(gallery_path):
560 file_path = os.path.join(gallery_path, filename)
562 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
564 makedirs(gallery_path)
565 for ilustr in ilustr_elements:
566 ilustr_src = ilustr.get('src')
567 ilustr_path = os.path.join(gallery_path, ilustr_src)
568 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
570 def load_abstract(self):
571 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
572 if abstract is not None:
573 self.abstract = transform_abstrakt(abstract)
580 parser = html.HTMLParser(encoding='utf-8')
581 tree = html.parse(self.html_file.path, parser=parser)
582 toc = tree.find('//div[@id="toc"]/ol')
583 if toc is None or not len(toc):
585 html_link = reverse('book_text', args=[self.slug])
586 for a in toc.findall('.//a'):
587 a.attrib['href'] = html_link + a.attrib['href']
588 self.toc = html.tostring(toc, encoding='unicode')
592 def from_xml_file(cls, xml_file, **kwargs):
593 from django.core.files import File
594 from librarian import dcparser
596 # use librarian to parse meta-data
597 book_info = dcparser.parse(xml_file)
599 if not isinstance(xml_file, File):
600 xml_file = File(open(xml_file))
603 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
608 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
609 search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
610 if dont_build is None:
612 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
614 # check for parts before we do anything
616 if hasattr(book_info, 'parts'):
617 for part_url in book_info.parts:
619 children.append(Book.objects.get(slug=part_url.slug))
620 except Book.DoesNotExist:
621 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
624 book_slug = book_info.url.slug
625 if re.search(r'[^a-z0-9-]', book_slug):
626 raise ValueError('Invalid characters in slug')
627 book, created = Book.objects.get_or_create(slug=book_slug)
632 book.preview = bool(days)
634 book.preview_until = date.today() + timedelta(days)
637 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
638 # Save shelves for this book
639 book_shelves = list(book.tags.filter(category='set'))
640 old_cover = book.cover_info()
643 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
645 book.xml_file.set_readable(False)
647 book.findable = findable
648 book.language = book_info.language
649 book.title = book_info.title
650 if book_info.variant_of:
651 book.common_slug = book_info.variant_of.slug
653 book.common_slug = book.slug
654 book.extra_info = json.dumps(book_info.to_dict())
659 meta_tags = Tag.tags_from_info(book_info)
661 for tag in meta_tags:
662 if not tag.for_books:
666 book.tags = set(meta_tags + book_shelves)
667 book.save() # update sort_key_author
669 cover_changed = old_cover != book.cover_info()
670 obsolete_children = set(b for b in book.children.all()
671 if b not in children)
672 notify_cover_changed = []
673 for n, child_book in enumerate(children):
674 new_child = child_book.parent != book
675 child_book.parent = book
676 child_book.parent_number = n
678 if new_child or cover_changed:
679 notify_cover_changed.append(child_book)
680 # Disown unfaithful children and let them cope on their own.
681 for child in obsolete_children:
683 child.parent_number = 0
686 notify_cover_changed.append(child)
688 cls.repopulate_ancestors()
689 tasks.update_counters.delay()
691 if remote_gallery_url:
692 book.download_pictures(remote_gallery_url)
694 # No saves beyond this point.
697 if 'cover' not in dont_build:
698 book.cover.build_delay()
699 book.cover_clean.build_delay()
700 book.cover_thumb.build_delay()
701 book.cover_api_thumb.build_delay()
702 book.simple_cover.build_delay()
703 book.cover_ebookpoint.build_delay()
705 # Build HTML and ebooks.
706 book.html_file.build_delay()
708 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
709 if format_ not in dont_build:
710 getattr(book, '%s_file' % format_).build_delay()
711 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
712 if format_ not in dont_build:
713 getattr(book, '%s_file' % format_).build_delay()
715 if not settings.NO_SEARCH_INDEX and search_index and findable:
716 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
718 for child in notify_cover_changed:
719 child.parent_cover_changed()
721 book.update_popularity()
722 tasks.update_references.delay(book.id)
724 cls.published.send(sender=cls, instance=book)
727 def get_master(self):
731 'dramat_wierszowany_l',
732 'dramat_wierszowany_lp',
733 'dramat_wspolczesny', 'liryka_l', 'liryka_lp',
736 from librarian.parser import WLDocument
737 wld = WLDocument.from_file(self.xml_file.path, parse_dublincore=False)
738 root = wld.edoc.getroot()
739 for master in root.iter():
740 if master.tag in master_tags:
743 def update_references(self):
744 from references.models import Entity, Reference
745 master = self.get_master()
749 for i, sec in enumerate(master):
750 for ref in sec.findall('.//ref'):
751 href = ref.attrib.get('href', '')
752 if not href or href in found:
755 entity, created = Entity.objects.get_or_create(
758 ref, created = Reference.objects.get_or_create(
762 ref.first_section = 'sec%d' % (i + 1)
765 Reference.objects.filter(book=self).exclude(entity__uri__in=found).delete()
768 def references(self):
769 return self.reference_set.all().select_related('entity')
773 def repopulate_ancestors(cls):
774 """Fixes the ancestry cache."""
776 cursor = connection.cursor()
777 if connection.vendor == 'postgres':
778 cursor.execute("TRUNCATE catalogue_book_ancestor")
780 WITH RECURSIVE ancestry AS (
781 SELECT book.id, book.parent_id
782 FROM catalogue_book AS book
783 WHERE book.parent_id IS NOT NULL
785 SELECT ancestor.id, book.parent_id
786 FROM ancestry AS ancestor, catalogue_book AS book
787 WHERE ancestor.parent_id = book.id
788 AND book.parent_id IS NOT NULL
790 INSERT INTO catalogue_book_ancestor
791 (from_book_id, to_book_id)
797 cursor.execute("DELETE FROM catalogue_book_ancestor")
798 for b in cls.objects.exclude(parent=None):
800 while parent is not None:
801 b.ancestor.add(parent)
802 parent = parent.parent
807 for anc in self.parent.ancestors:
813 def clear_cache(self):
814 clear_cached_renders(self.mini_box)
815 clear_cached_renders(self.mini_box_nolink)
817 def cover_info(self, inherit=True):
818 """Returns a dictionary to serve as fallback for BookInfo.
820 For now, the only thing inherited is the cover image.
824 for field in ('cover_url', 'cover_by', 'cover_source'):
825 val = self.get_extra_info_json().get(field)
830 if inherit and need and self.parent is not None:
831 parent_info = self.parent.cover_info()
832 parent_info.update(info)
836 def related_themes(self):
837 return Tag.objects.usage_for_queryset(
838 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
839 counts=True).filter(category='theme').order_by('-count')
841 def parent_cover_changed(self):
842 """Called when parent book's cover image is changed."""
843 if not self.cover_info(inherit=False):
844 if 'cover' not in app_settings.DONT_BUILD:
845 self.cover.build_delay()
846 self.cover_clean.build_delay()
847 self.cover_thumb.build_delay()
848 self.cover_api_thumb.build_delay()
849 self.simple_cover.build_delay()
850 self.cover_ebookpoint.build_delay()
851 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
852 if format_ not in app_settings.DONT_BUILD:
853 getattr(self, '%s_file' % format_).build_delay()
854 for child in self.children.all():
855 child.parent_cover_changed()
857 def other_versions(self):
858 """Find other versions (i.e. in other languages) of the book."""
859 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
864 while parent is not None:
865 books.insert(0, parent)
866 parent = parent.parent
869 def pretty_title(self, html_links=False):
870 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
871 books = self.parents() + [self]
872 names.extend([(b.title, b.get_absolute_url()) for b in books])
875 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
877 names = [tag[0] for tag in names]
878 return ', '.join(names)
881 publisher = self.get_extra_info_json()['publisher']
882 if isinstance(publisher, str):
884 elif isinstance(publisher, list):
885 return ', '.join(publisher)
888 def tagged_top_level(cls, tags):
889 """ Returns top-level books tagged with `tags`.
891 It only returns those books which don't have ancestors which are
892 also tagged with those tags.
895 objects = cls.tagged.with_all(tags)
896 return objects.filter(findable=True).exclude(ancestor__in=objects)
899 def book_list(cls, book_filter=None):
900 """Generates a hierarchical listing of all books.
902 Books are optionally filtered with a test function.
907 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
909 books = books.filter(book_filter).distinct()
911 book_ids = set(b['pk'] for b in books.values("pk").iterator())
912 for book in books.iterator():
913 parent = book.parent_id
914 if parent not in book_ids:
916 books_by_parent.setdefault(parent, []).append(book)
918 for book in books.iterator():
919 books_by_parent.setdefault(book.parent_id, []).append(book)
922 books_by_author = OrderedDict()
923 for tag in Tag.objects.filter(category='author').iterator():
924 books_by_author[tag] = []
926 for book in books_by_parent.get(None, ()):
927 authors = list(book.authors().only('pk'))
929 for author in authors:
930 books_by_author[author].append(book)
934 return books_by_author, orphans, books_by_parent
937 "SP": (1, "szkoła podstawowa"),
938 "SP1": (1, "szkoła podstawowa"),
939 "SP2": (1, "szkoła podstawowa"),
940 "SP3": (1, "szkoła podstawowa"),
941 "P": (1, "szkoła podstawowa"),
942 "G": (2, "gimnazjum"),
947 def audiences_pl(self):
948 audiences = self.get_extra_info_json().get('audiences', [])
949 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
950 return [a[1] for a in audiences]
952 def stage_note(self):
953 stage = self.get_extra_info_json().get('stage')
954 if stage and stage < '0.4':
955 return (_('This work needs modernisation'),
956 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
960 def choose_fragments(self, number):
961 fragments = self.fragments.order_by()
962 fragments_count = fragments.count()
963 if not fragments_count and self.children.exists():
964 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
965 fragments_count = fragments.count()
967 if fragments_count > number:
968 offset = randint(0, fragments_count - number)
971 return fragments[offset : offset + number]
973 return self.parent.choose_fragments(number)
977 def choose_fragment(self):
978 fragments = self.choose_fragments(1)
984 def fragment_data(self):
985 fragment = self.choose_fragment()
988 'title': fragment.book.pretty_title(),
989 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
994 def update_popularity(self):
995 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
997 pop = self.popularity
1000 except BookPopularity.DoesNotExist:
1001 BookPopularity.objects.create(book=self, count=count)
1003 def ridero_link(self):
1004 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
1006 def like(self, user):
1007 from social.utils import likes, get_set, set_sets
1008 if not likes(user, self):
1009 tag = get_set(user, '')
1010 set_sets(user, self, [tag])
1012 def unlike(self, user):
1013 from social.utils import likes, set_sets
1014 if likes(user, self):
1015 set_sets(user, self, [])
1017 def full_sort_key(self):
1018 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
1020 def cover_color(self):
1021 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
1023 @cached_render('catalogue/book_mini_box.html')
1029 @cached_render('catalogue/book_mini_box.html')
1030 def mini_box_nolink(self):
1036 def add_file_fields():
1037 for format_ in Book.formats:
1038 field_name = "%s_file" % format_
1039 # This weird globals() assignment makes Django migrations comfortable.
1040 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
1041 _upload_to.__name__ = '_%s_upload_to' % format_
1042 globals()[_upload_to.__name__] = _upload_to
1045 format_, _("%s file" % format_.upper()),
1046 upload_to=_upload_to,
1047 storage=bofh_storage,
1051 ).contribute_to_class(Book, field_name)
1052 if format_ != 'xml':
1053 models.CharField(max_length=255, editable=False, default='', db_index=True).contribute_to_class(Book, f'{field_name}_etag')
1059 class BookPopularity(models.Model):
1060 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
1061 count = models.IntegerField(default=0, db_index=True)