1 # -*- coding: utf-8 -*-
2 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
3 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
5 from collections import OrderedDict
6 from datetime import date
7 from random import randint
11 from django.conf import settings
12 from django.db import connection, models, transaction
13 from django.db.models import permalink
14 import django.dispatch
15 from django.contrib.contenttypes.fields import GenericRelation
16 from django.core.urlresolvers import reverse
17 from django.utils.translation import ugettext_lazy as _, get_language
18 from django.utils.deconstruct import deconstructible
20 from fnpdjango.storage import BofhFileSystemStorage
21 from ssify import flush_ssi_includes
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
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
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_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
48 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
49 _simple_cover_upload_to = UploadToPath('book/cover_simple/%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=u'')
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 = jsonfield.JSONField(_('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 preview = models.BooleanField(_('preview'), default=False)
76 preview_until = models.DateField(_('preview until'), blank=True, null=True)
78 # files generated during publication
81 null=True, blank=True,
82 upload_to=_cover_upload_to,
83 storage=bofh_storage, max_length=255)
84 # Cleaner version of cover for thumbs
85 cover_thumb = EbookField(
86 'cover_thumb', _('cover thumbnail'),
87 null=True, blank=True,
88 upload_to=_cover_thumb_upload_to,
90 cover_api_thumb = EbookField(
91 'cover_api_thumb', _('cover thumbnail for mobile app'),
92 null=True, blank=True,
93 upload_to=_cover_api_thumb_upload_to,
95 simple_cover = EbookField(
96 'simple_cover', _('cover for mobile app'),
97 null=True, blank=True,
98 upload_to=_simple_cover_upload_to,
100 ebook_formats = constants.EBOOK_FORMATS
101 formats = ebook_formats + ['html', 'xml']
103 parent = models.ForeignKey('self', blank=True, null=True, related_name='children')
104 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
106 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
107 has_audience = models.BooleanField(default=False)
109 objects = models.Manager()
110 tagged = managers.ModelTaggedItemManager(Tag)
111 tags = managers.TagDescriptor(Tag)
112 tag_relations = GenericRelation(Tag.intermediary_table_model)
114 html_built = django.dispatch.Signal()
115 published = django.dispatch.Signal()
117 short_html_url_name = 'catalogue_book_short'
119 class AlreadyExists(Exception):
123 ordering = ('sort_key_author', 'sort_key')
124 verbose_name = _('book')
125 verbose_name_plural = _('books')
126 app_label = 'catalogue'
128 def __unicode__(self):
131 def get_initial(self):
133 return re.search(r'\w', self.title, re.U).group(0)
134 except AttributeError:
138 return self.tags.filter(category='author')
140 def tag_unicode(self, category):
141 relations = prefetched_relations(self, category)
143 return ', '.join(rel.tag.name for rel in relations)
145 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
147 def tags_by_category(self):
148 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
150 def author_unicode(self):
151 return self.cached_author
153 def translator(self):
154 translators = self.extra_info.get('translators')
157 if len(translators) > 3:
158 translators = translators[:2]
162 return ', '.join(u'\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
164 def cover_source(self):
165 return self.extra_info.get('cover_source', self.parent.cover_source() if self.parent else '')
167 def save(self, force_insert=False, force_update=False, **kwargs):
168 from sortify import sortify
170 self.sort_key = sortify(self.title)[:120]
171 self.title = unicode(self.title) # ???
174 author = self.authors().first().sort_key
175 except AttributeError:
177 self.sort_key_author = author
179 self.cached_author = self.tag_unicode('author')
180 self.has_audience = 'audience' in self.extra_info
182 ret = super(Book, self).save(force_insert, force_update, **kwargs)
187 def get_absolute_url(self):
188 return 'catalogue.views.book_detail', [self.slug]
192 def create_url(slug):
193 return 'catalogue.views.book_detail', [slug]
195 def gallery_path(self):
196 return gallery_path(self.slug)
198 def gallery_url(self):
199 return gallery_url(self.slug)
205 def language_code(self):
206 return constants.LANGUAGES_3TO2.get(self.language, self.language)
208 def language_name(self):
209 return dict(settings.LANGUAGES).get(self.language_code(), "")
211 def is_foreign(self):
212 return self.language_code() != settings.LANGUAGE_CODE
214 def has_media(self, type_):
215 if type_ in Book.formats:
216 return bool(getattr(self, "%s_file" % type_))
218 return self.media.filter(type=type_).exists()
221 return self.has_media('mp3')
223 def get_media(self, type_):
224 if self.has_media(type_):
225 if type_ in Book.formats:
226 return getattr(self, "%s_file" % type_)
228 return self.media.filter(type=type_)
233 return self.get_media("mp3")
236 return self.get_media("odt")
239 return self.get_media("ogg")
242 return self.get_media("daisy")
244 def media_url(self, format_):
245 media = self.get_media(format_)
248 return reverse('embargo_link', kwargs={'slug': self.slug, 'format_': format_})
255 return self.media_url('html')
258 return self.media_url('pdf')
261 return self.media_url('epub')
264 return self.media_url('mobi')
267 return self.media_url('txt')
270 return self.media_url('fb2')
273 return self.media_url('xml')
275 def has_description(self):
276 return len(self.description) > 0
277 has_description.short_description = _('description')
278 has_description.boolean = True
281 def has_mp3_file(self):
282 return bool(self.has_media("mp3"))
283 has_mp3_file.short_description = 'MP3'
284 has_mp3_file.boolean = True
286 def has_ogg_file(self):
287 return bool(self.has_media("ogg"))
288 has_ogg_file.short_description = 'OGG'
289 has_ogg_file.boolean = True
291 def has_daisy_file(self):
292 return bool(self.has_media("daisy"))
293 has_daisy_file.short_description = 'DAISY'
294 has_daisy_file.boolean = True
296 def get_audiobooks(self):
298 for m in self.media.filter(type='ogg').order_by().iterator():
299 ogg_files[m.name] = m
303 for mp3 in self.media.filter(type='mp3').iterator():
304 # ogg files are always from the same project
305 meta = mp3.extra_info
306 project = meta.get('project')
309 project = u'CzytamySłuchając'
311 projects.add((project, meta.get('funded_by', '')))
315 ogg = ogg_files.get(mp3.name)
318 audiobooks.append(media)
320 projects = sorted(projects)
321 return audiobooks, projects
323 def wldocument(self, parse_dublincore=True, inherit=True):
324 from catalogue.import_utils import ORMDocProvider
325 from librarian.parser import WLDocument
327 if inherit and self.parent:
328 meta_fallbacks = self.parent.cover_info()
330 meta_fallbacks = None
332 return WLDocument.from_file(
334 provider=ORMDocProvider(self),
335 parse_dublincore=parse_dublincore,
336 meta_fallbacks=meta_fallbacks)
339 def zip_format(format_):
340 def pretty_file_name(book):
341 return "%s/%s.%s" % (
342 book.extra_info['author'],
346 field_name = "%s_file" % format_
347 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True)
348 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
349 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
351 def zip_audiobooks(self, format_):
352 bm = BookMedia.objects.filter(book=self, type=format_)
353 paths = map(lambda bm: (None, bm.file.path), bm)
354 return create_zip(paths, "%s_%s" % (self.slug, format_))
356 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
358 from search.index import Index
361 index.index_book(self, book_info)
367 index.index.rollback()
370 # will make problems in conjunction with paid previews
371 def download_pictures(self, remote_gallery_url):
372 gallery_path = self.gallery_path()
373 # delete previous files, so we don't include old files in ebooks
374 if os.path.isdir(gallery_path):
375 for filename in os.listdir(gallery_path):
376 file_path = os.path.join(gallery_path, filename)
378 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
380 makedirs(gallery_path)
381 for ilustr in ilustr_elements:
382 ilustr_src = ilustr.get('src')
383 ilustr_path = os.path.join(gallery_path, ilustr_src)
384 urllib.urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
386 def load_abstract(self):
387 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
388 if abstract is not None:
389 self.abstract = transform_abstrakt(abstract)
394 def from_xml_file(cls, xml_file, **kwargs):
395 from django.core.files import File
396 from librarian import dcparser
398 # use librarian to parse meta-data
399 book_info = dcparser.parse(xml_file)
401 if not isinstance(xml_file, File):
402 xml_file = File(open(xml_file))
405 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
410 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
411 search_index_tags=True, remote_gallery_url=None, days=0):
412 if dont_build is None:
414 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
416 # check for parts before we do anything
418 if hasattr(book_info, 'parts'):
419 for part_url in book_info.parts:
421 children.append(Book.objects.get(slug=part_url.slug))
422 except Book.DoesNotExist:
423 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
426 book_slug = book_info.url.slug
427 if re.search(r'[^a-z0-9-]', book_slug):
428 raise ValueError('Invalid characters in slug')
429 book, created = Book.objects.get_or_create(slug=book_slug)
434 book.preview = bool(days)
436 book.preview_until = date.today()
439 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
440 # Save shelves for this book
441 book_shelves = list(book.tags.filter(category='set'))
442 old_cover = book.cover_info()
445 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
447 book.xml_file.set_readable(False)
449 book.language = book_info.language
450 book.title = book_info.title
451 if book_info.variant_of:
452 book.common_slug = book_info.variant_of.slug
454 book.common_slug = book.slug
455 book.extra_info = book_info.to_dict()
459 meta_tags = Tag.tags_from_info(book_info)
461 for tag in meta_tags:
462 if not tag.for_books:
466 book.tags = set(meta_tags + book_shelves)
468 cover_changed = old_cover != book.cover_info()
469 obsolete_children = set(b for b in book.children.all()
470 if b not in children)
471 notify_cover_changed = []
472 for n, child_book in enumerate(children):
473 new_child = child_book.parent != book
474 child_book.parent = book
475 child_book.parent_number = n
477 if new_child or cover_changed:
478 notify_cover_changed.append(child_book)
479 # Disown unfaithful children and let them cope on their own.
480 for child in obsolete_children:
482 child.parent_number = 0
485 notify_cover_changed.append(child)
487 cls.repopulate_ancestors()
488 tasks.update_counters.delay()
490 if remote_gallery_url:
491 book.download_pictures(remote_gallery_url)
493 # No saves beyond this point.
496 if 'cover' not in dont_build:
497 book.cover.build_delay()
498 book.cover_thumb.build_delay()
499 book.cover_api_thumb.build_delay()
500 book.simple_cover.build_delay()
502 # Build HTML and ebooks.
503 book.html_file.build_delay()
505 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
506 if format_ not in dont_build:
507 getattr(book, '%s_file' % format_).build_delay()
508 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
509 if format_ not in dont_build:
510 getattr(book, '%s_file' % format_).build_delay()
512 if not settings.NO_SEARCH_INDEX and search_index:
513 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
515 for child in notify_cover_changed:
516 child.parent_cover_changed()
518 book.save() # update sort_key_author
519 book.update_popularity()
520 cls.published.send(sender=cls, instance=book)
525 def repopulate_ancestors(cls):
526 """Fixes the ancestry cache."""
528 cursor = connection.cursor()
529 if connection.vendor == 'postgres':
530 cursor.execute("TRUNCATE catalogue_book_ancestor")
532 WITH RECURSIVE ancestry AS (
533 SELECT book.id, book.parent_id
534 FROM catalogue_book AS book
535 WHERE book.parent_id IS NOT NULL
537 SELECT ancestor.id, book.parent_id
538 FROM ancestry AS ancestor, catalogue_book AS book
539 WHERE ancestor.parent_id = book.id
540 AND book.parent_id IS NOT NULL
542 INSERT INTO catalogue_book_ancestor
543 (from_book_id, to_book_id)
549 cursor.execute("DELETE FROM catalogue_book_ancestor")
550 for b in cls.objects.exclude(parent=None):
552 while parent is not None:
553 b.ancestor.add(parent)
554 parent = parent.parent
556 def flush_includes(self, languages=True):
559 if languages is True:
560 languages = [lc for (lc, _ln) in settings.LANGUAGES]
562 template % (self.pk, lang)
564 '/katalog/b/%d/mini.%s.html',
565 '/katalog/b/%d/mini_nolink.%s.html',
566 '/katalog/b/%d/short.%s.html',
567 '/katalog/b/%d/wide.%s.html',
568 '/api/include/book/%d.%s.json',
569 '/api/include/book/%d.%s.xml',
571 for lang in languages
574 def cover_info(self, inherit=True):
575 """Returns a dictionary to serve as fallback for BookInfo.
577 For now, the only thing inherited is the cover image.
581 for field in ('cover_url', 'cover_by', 'cover_source'):
582 val = self.extra_info.get(field)
587 if inherit and need and self.parent is not None:
588 parent_info = self.parent.cover_info()
589 parent_info.update(info)
593 def related_themes(self):
594 return Tag.objects.usage_for_queryset(
595 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
596 counts=True).filter(category='theme')
598 def parent_cover_changed(self):
599 """Called when parent book's cover image is changed."""
600 if not self.cover_info(inherit=False):
601 if 'cover' not in app_settings.DONT_BUILD:
602 self.cover.build_delay()
603 self.cover_thumb.build_delay()
604 self.cover_api_thumb.build_delay()
605 self.simple_cover.build_delay()
606 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
607 if format_ not in app_settings.DONT_BUILD:
608 getattr(self, '%s_file' % format_).build_delay()
609 for child in self.children.all():
610 child.parent_cover_changed()
612 def other_versions(self):
613 """Find other versions (i.e. in other languages) of the book."""
614 return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
619 while parent is not None:
620 books.insert(0, parent)
621 parent = parent.parent
624 def pretty_title(self, html_links=False):
625 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
626 books = self.parents() + [self]
627 names.extend([(b.title, b.get_absolute_url()) for b in books])
630 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
632 names = [tag[0] for tag in names]
633 return ', '.join(names)
636 publisher = self.extra_info['publisher']
637 if isinstance(publisher, basestring):
639 elif isinstance(publisher, list):
640 return ', '.join(publisher)
643 def tagged_top_level(cls, tags):
644 """ Returns top-level books tagged with `tags`.
646 It only returns those books which don't have ancestors which are
647 also tagged with those tags.
650 objects = cls.tagged.with_all(tags)
651 return objects.exclude(ancestor__in=objects)
654 def book_list(cls, book_filter=None):
655 """Generates a hierarchical listing of all books.
657 Books are optionally filtered with a test function.
662 books = cls.objects.order_by('parent_number', 'sort_key').only('title', 'parent', 'slug')
664 books = books.filter(book_filter).distinct()
666 book_ids = set(b['pk'] for b in books.values("pk").iterator())
667 for book in books.iterator():
668 parent = book.parent_id
669 if parent not in book_ids:
671 books_by_parent.setdefault(parent, []).append(book)
673 for book in books.iterator():
674 books_by_parent.setdefault(book.parent_id, []).append(book)
677 books_by_author = OrderedDict()
678 for tag in Tag.objects.filter(category='author').iterator():
679 books_by_author[tag] = []
681 for book in books_by_parent.get(None, ()):
682 authors = list(book.authors().only('pk'))
684 for author in authors:
685 books_by_author[author].append(book)
689 return books_by_author, orphans, books_by_parent
692 "SP": (1, u"szkoła podstawowa"),
693 "SP1": (1, u"szkoła podstawowa"),
694 "SP2": (1, u"szkoła podstawowa"),
695 "SP3": (1, u"szkoła podstawowa"),
696 "P": (1, u"szkoła podstawowa"),
697 "G": (2, u"gimnazjum"),
699 "LP": (3, u"liceum"),
702 def audiences_pl(self):
703 audiences = self.extra_info.get('audiences', [])
704 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
705 return [a[1] for a in audiences]
707 def stage_note(self):
708 stage = self.extra_info.get('stage')
709 if stage and stage < '0.4':
710 return (_('This work needs modernisation'),
711 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
715 def choose_fragment(self):
716 fragments = self.fragments.order_by()
717 fragments_count = fragments.count()
718 if not fragments_count and self.children.exists():
719 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
720 fragments_count = fragments.count()
722 return fragments[randint(0, fragments_count - 1)]
724 return self.parent.choose_fragment()
728 def fragment_data(self):
729 fragment = self.choose_fragment()
731 return {'title': fragment.book.pretty_title(), 'html': fragment.get_short_text()}
735 def update_popularity(self):
736 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
738 pop = self.popularity
741 except BookPopularity.DoesNotExist:
742 BookPopularity.objects.create(book=self, count=count)
744 def ridero_link(self):
745 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
748 def add_file_fields():
749 for format_ in Book.formats:
750 field_name = "%s_file" % format_
751 # This weird globals() assignment makes Django migrations comfortable.
752 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
753 _upload_to.__name__ = '_%s_upload_to' % format_
754 globals()[_upload_to.__name__] = _upload_to
757 format_, _("%s file" % format_.upper()),
758 upload_to=_upload_to,
759 storage=bofh_storage,
763 ).contribute_to_class(Book, field_name)
768 class BookPopularity(models.Model):
769 book = models.OneToOneField(Book, related_name='popularity')
770 count = models.IntegerField(default=0, db_index=True)