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 random import randint
10 from django.conf import settings
11 from django.db import connection, models, transaction
12 from django.db.models import permalink
13 import django.dispatch
14 from django.contrib.contenttypes.fields import GenericRelation
15 from django.core.urlresolvers import reverse
16 from django.utils.translation import ugettext_lazy as _, get_language
17 from django.utils.deconstruct import deconstructible
19 from fnpdjango.storage import BofhFileSystemStorage
20 from ssify import flush_ssi_includes
22 from librarian.html import transform_abstrakt
23 from newtagging import managers
24 from catalogue import constants
25 from catalogue.fields import EbookField
26 from catalogue.models import Tag, Fragment, BookMedia
27 from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags
28 from catalogue.models.tag import prefetched_relations
29 from catalogue import app_settings
30 from catalogue import tasks
31 from wolnelektury.utils import makedirs
33 bofh_storage = BofhFileSystemStorage()
37 class UploadToPath(object):
38 def __init__(self, path):
41 def __call__(self, instance, filename):
42 return self.path % instance.slug
45 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
46 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
47 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
48 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
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=u'')
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 = jsonfield.JSONField(_('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)
75 # files generated during publication
78 null=True, blank=True,
79 upload_to=_cover_upload_to,
80 storage=bofh_storage, max_length=255)
81 # Cleaner version of cover for thumbs
82 cover_thumb = EbookField(
83 'cover_thumb', _('cover thumbnail'),
84 null=True, blank=True,
85 upload_to=_cover_thumb_upload_to,
87 cover_api_thumb = EbookField(
88 'cover_api_thumb', _('cover thumbnail for mobile app'),
89 null=True, blank=True,
90 upload_to=_cover_api_thumb_upload_to,
92 simple_cover = EbookField(
93 'simple_cover', _('cover for mobile app'),
94 null=True, blank=True,
95 upload_to=_simple_cover_upload_to,
97 ebook_formats = constants.EBOOK_FORMATS
98 formats = ebook_formats + ['html', 'xml']
100 parent = models.ForeignKey('self', blank=True, null=True, related_name='children')
101 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
103 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
104 has_audience = models.BooleanField(default=False)
106 objects = models.Manager()
107 tagged = managers.ModelTaggedItemManager(Tag)
108 tags = managers.TagDescriptor(Tag)
109 tag_relations = GenericRelation(Tag.intermediary_table_model)
111 html_built = django.dispatch.Signal()
112 published = django.dispatch.Signal()
114 short_html_url_name = 'catalogue_book_short'
116 class AlreadyExists(Exception):
120 ordering = ('sort_key_author', 'sort_key')
121 verbose_name = _('book')
122 verbose_name_plural = _('books')
123 app_label = 'catalogue'
125 def __unicode__(self):
128 def get_initial(self):
130 return re.search(r'\w', self.title, re.U).group(0)
131 except AttributeError:
135 return self.tags.filter(category='author')
137 def tag_unicode(self, category):
138 relations = prefetched_relations(self, category)
140 return ', '.join(rel.tag.name for rel in relations)
142 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
144 def tags_by_category(self):
145 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
147 def author_unicode(self):
148 return self.cached_author
150 def translator(self):
151 translators = self.extra_info.get('translators')
154 if len(translators) > 3:
155 translators = translators[:2]
159 return ', '.join(u'\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
161 def cover_source(self):
162 return self.extra_info.get('cover_source', self.parent.cover_source() if self.parent else '')
164 def save(self, force_insert=False, force_update=False, **kwargs):
165 from sortify import sortify
167 self.sort_key = sortify(self.title)[:120]
168 self.title = unicode(self.title) # ???
171 author = self.authors().first().sort_key
172 except AttributeError:
174 self.sort_key_author = author
176 self.cached_author = self.tag_unicode('author')
177 self.has_audience = 'audience' in self.extra_info
179 ret = super(Book, self).save(force_insert, force_update, **kwargs)
184 def get_absolute_url(self):
185 return 'catalogue.views.book_detail', [self.slug]
189 def create_url(slug):
190 return 'catalogue.views.book_detail', [slug]
192 def gallery_path(self):
193 return gallery_path(self.slug)
195 def gallery_url(self):
196 return gallery_url(self.slug)
202 def language_code(self):
203 return constants.LANGUAGES_3TO2.get(self.language, self.language)
205 def language_name(self):
206 return dict(settings.LANGUAGES).get(self.language_code(), "")
208 def is_foreign(self):
209 return self.language_code() != settings.LANGUAGE_CODE
211 def has_media(self, type_):
212 if type_ in Book.formats:
213 return bool(getattr(self, "%s_file" % type_))
215 return self.media.filter(type=type_).exists()
218 return self.has_media('mp3')
220 def get_media(self, type_):
221 if self.has_media(type_):
222 if type_ in Book.formats:
223 return getattr(self, "%s_file" % type_)
225 return self.media.filter(type=type_)
230 return self.get_media("mp3")
233 return self.get_media("odt")
236 return self.get_media("ogg")
239 return self.get_media("daisy")
241 def has_description(self):
242 return len(self.description) > 0
243 has_description.short_description = _('description')
244 has_description.boolean = True
247 def has_mp3_file(self):
248 return bool(self.has_media("mp3"))
249 has_mp3_file.short_description = 'MP3'
250 has_mp3_file.boolean = True
252 def has_ogg_file(self):
253 return bool(self.has_media("ogg"))
254 has_ogg_file.short_description = 'OGG'
255 has_ogg_file.boolean = True
257 def has_daisy_file(self):
258 return bool(self.has_media("daisy"))
259 has_daisy_file.short_description = 'DAISY'
260 has_daisy_file.boolean = True
262 def get_audiobooks(self):
264 for m in self.media.filter(type='ogg').order_by().iterator():
265 ogg_files[m.name] = m
269 for mp3 in self.media.filter(type='mp3').iterator():
270 # ogg files are always from the same project
271 meta = mp3.extra_info
272 project = meta.get('project')
275 project = u'CzytamySłuchając'
277 projects.add((project, meta.get('funded_by', '')))
281 ogg = ogg_files.get(mp3.name)
284 audiobooks.append(media)
286 projects = sorted(projects)
287 return audiobooks, projects
289 def wldocument(self, parse_dublincore=True, inherit=True):
290 from catalogue.import_utils import ORMDocProvider
291 from librarian.parser import WLDocument
293 if inherit and self.parent:
294 meta_fallbacks = self.parent.cover_info()
296 meta_fallbacks = None
298 return WLDocument.from_file(
300 provider=ORMDocProvider(self),
301 parse_dublincore=parse_dublincore,
302 meta_fallbacks=meta_fallbacks)
305 def zip_format(format_):
306 def pretty_file_name(book):
307 return "%s/%s.%s" % (
308 book.extra_info['author'],
312 field_name = "%s_file" % format_
313 books = Book.objects.filter(parent=None).exclude(**{field_name: ""})
314 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
315 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
317 def zip_audiobooks(self, format_):
318 bm = BookMedia.objects.filter(book=self, type=format_)
319 paths = map(lambda bm: (None, bm.file.path), bm)
320 return create_zip(paths, "%s_%s" % (self.slug, format_))
322 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
324 from search.index import Index
327 index.index_book(self, book_info)
333 index.index.rollback()
336 def download_pictures(self, remote_gallery_url):
337 gallery_path = self.gallery_path()
338 # delete previous files, so we don't include old files in ebooks
339 if os.path.isdir(gallery_path):
340 for filename in os.listdir(gallery_path):
341 file_path = os.path.join(gallery_path, filename)
343 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
345 makedirs(gallery_path)
346 for ilustr in ilustr_elements:
347 ilustr_src = ilustr.get('src')
348 ilustr_path = os.path.join(gallery_path, ilustr_src)
349 urllib.urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
351 def load_abstract(self):
352 abstract = self.wldocument().edoc.getroot().find('.//abstrakt')
353 if abstract is not None:
354 self.abstract = transform_abstrakt(abstract)
359 def from_xml_file(cls, xml_file, **kwargs):
360 from django.core.files import File
361 from librarian import dcparser
363 # use librarian to parse meta-data
364 book_info = dcparser.parse(xml_file)
366 if not isinstance(xml_file, File):
367 xml_file = File(open(xml_file))
370 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
375 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
376 search_index_tags=True, remote_gallery_url=None):
377 if dont_build is None:
379 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
381 # check for parts before we do anything
383 if hasattr(book_info, 'parts'):
384 for part_url in book_info.parts:
386 children.append(Book.objects.get(slug=part_url.slug))
387 except Book.DoesNotExist:
388 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
391 book_slug = book_info.url.slug
392 if re.search(r'[^a-z0-9-]', book_slug):
393 raise ValueError('Invalid characters in slug')
394 book, created = Book.objects.get_or_create(slug=book_slug)
401 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
402 # Save shelves for this book
403 book_shelves = list(book.tags.filter(category='set'))
404 old_cover = book.cover_info()
407 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
409 book.language = book_info.language
410 book.title = book_info.title
411 if book_info.variant_of:
412 book.common_slug = book_info.variant_of.slug
414 book.common_slug = book.slug
415 book.extra_info = book_info.to_dict()
419 meta_tags = Tag.tags_from_info(book_info)
421 for tag in meta_tags:
422 if not tag.for_books:
426 book.tags = set(meta_tags + book_shelves)
428 cover_changed = old_cover != book.cover_info()
429 obsolete_children = set(b for b in book.children.all()
430 if b not in children)
431 notify_cover_changed = []
432 for n, child_book in enumerate(children):
433 new_child = child_book.parent != book
434 child_book.parent = book
435 child_book.parent_number = n
437 if new_child or cover_changed:
438 notify_cover_changed.append(child_book)
439 # Disown unfaithful children and let them cope on their own.
440 for child in obsolete_children:
442 child.parent_number = 0
445 notify_cover_changed.append(child)
447 cls.repopulate_ancestors()
448 tasks.update_counters.delay()
450 if remote_gallery_url:
451 book.download_pictures(remote_gallery_url)
453 # No saves beyond this point.
456 if 'cover' not in dont_build:
457 book.cover.build_delay()
458 book.cover_thumb.build_delay()
459 book.cover_api_thumb.build_delay()
460 book.simple_cover.build_delay()
462 # Build HTML and ebooks.
463 book.html_file.build_delay()
465 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
466 if format_ not in dont_build:
467 getattr(book, '%s_file' % format_).build_delay()
468 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
469 if format_ not in dont_build:
470 getattr(book, '%s_file' % format_).build_delay()
472 if not settings.NO_SEARCH_INDEX and search_index:
473 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
475 for child in notify_cover_changed:
476 child.parent_cover_changed()
478 book.save() # update sort_key_author
479 book.update_popularity()
480 cls.published.send(sender=cls, instance=book)
485 def repopulate_ancestors(cls):
486 """Fixes the ancestry cache."""
488 cursor = connection.cursor()
489 if connection.vendor == 'postgres':
490 cursor.execute("TRUNCATE catalogue_book_ancestor")
492 WITH RECURSIVE ancestry AS (
493 SELECT book.id, book.parent_id
494 FROM catalogue_book AS book
495 WHERE book.parent_id IS NOT NULL
497 SELECT ancestor.id, book.parent_id
498 FROM ancestry AS ancestor, catalogue_book AS book
499 WHERE ancestor.parent_id = book.id
500 AND book.parent_id IS NOT NULL
502 INSERT INTO catalogue_book_ancestor
503 (from_book_id, to_book_id)
509 cursor.execute("DELETE FROM catalogue_book_ancestor")
510 for b in cls.objects.exclude(parent=None):
512 while parent is not None:
513 b.ancestor.add(parent)
514 parent = parent.parent
516 def flush_includes(self, languages=True):
519 if languages is True:
520 languages = [lc for (lc, _ln) in settings.LANGUAGES]
522 template % (self.pk, lang)
524 '/katalog/b/%d/mini.%s.html',
525 '/katalog/b/%d/mini_nolink.%s.html',
526 '/katalog/b/%d/short.%s.html',
527 '/katalog/b/%d/wide.%s.html',
528 '/api/include/book/%d.%s.json',
529 '/api/include/book/%d.%s.xml',
531 for lang in languages
534 def cover_info(self, inherit=True):
535 """Returns a dictionary to serve as fallback for BookInfo.
537 For now, the only thing inherited is the cover image.
541 for field in ('cover_url', 'cover_by', 'cover_source'):
542 val = self.extra_info.get(field)
547 if inherit and need and self.parent is not None:
548 parent_info = self.parent.cover_info()
549 parent_info.update(info)
553 def related_themes(self):
554 return Tag.objects.usage_for_queryset(
555 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
556 counts=True).filter(category='theme')
558 def parent_cover_changed(self):
559 """Called when parent book's cover image is changed."""
560 if not self.cover_info(inherit=False):
561 if 'cover' not in app_settings.DONT_BUILD:
562 self.cover.build_delay()
563 self.cover_thumb.build_delay()
564 self.cover_api_thumb.build_delay()
565 self.simple_cover.build_delay()
566 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
567 if format_ not in app_settings.DONT_BUILD:
568 getattr(self, '%s_file' % format_).build_delay()
569 for child in self.children.all():
570 child.parent_cover_changed()
572 def other_versions(self):
573 """Find other versions (i.e. in other languages) of the book."""
574 return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
579 while parent is not None:
580 books.insert(0, parent)
581 parent = parent.parent
584 def pretty_title(self, html_links=False):
585 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
586 books = self.parents() + [self]
587 names.extend([(b.title, b.get_absolute_url()) for b in books])
590 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
592 names = [tag[0] for tag in names]
593 return ', '.join(names)
596 publisher = self.extra_info['publisher']
597 if isinstance(publisher, basestring):
599 elif isinstance(publisher, list):
600 return ', '.join(publisher)
603 def tagged_top_level(cls, tags):
604 """ Returns top-level books tagged with `tags`.
606 It only returns those books which don't have ancestors which are
607 also tagged with those tags.
610 objects = cls.tagged.with_all(tags)
611 return objects.exclude(ancestor__in=objects)
614 def book_list(cls, book_filter=None):
615 """Generates a hierarchical listing of all books.
617 Books are optionally filtered with a test function.
622 books = cls.objects.order_by('parent_number', 'sort_key').only('title', 'parent', 'slug')
624 books = books.filter(book_filter).distinct()
626 book_ids = set(b['pk'] for b in books.values("pk").iterator())
627 for book in books.iterator():
628 parent = book.parent_id
629 if parent not in book_ids:
631 books_by_parent.setdefault(parent, []).append(book)
633 for book in books.iterator():
634 books_by_parent.setdefault(book.parent_id, []).append(book)
637 books_by_author = OrderedDict()
638 for tag in Tag.objects.filter(category='author').iterator():
639 books_by_author[tag] = []
641 for book in books_by_parent.get(None, ()):
642 authors = list(book.authors().only('pk'))
644 for author in authors:
645 books_by_author[author].append(book)
649 return books_by_author, orphans, books_by_parent
652 "SP": (1, u"szkoła podstawowa"),
653 "SP1": (1, u"szkoła podstawowa"),
654 "SP2": (1, u"szkoła podstawowa"),
655 "SP3": (1, u"szkoła podstawowa"),
656 "P": (1, u"szkoła podstawowa"),
657 "G": (2, u"gimnazjum"),
659 "LP": (3, u"liceum"),
662 def audiences_pl(self):
663 audiences = self.extra_info.get('audiences', [])
664 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
665 return [a[1] for a in audiences]
667 def stage_note(self):
668 stage = self.extra_info.get('stage')
669 if stage and stage < '0.4':
670 return (_('This work needs modernisation'),
671 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
675 def choose_fragment(self):
676 fragments = self.fragments.order_by()
677 fragments_count = fragments.count()
678 if not fragments_count and self.children.exists():
679 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
680 fragments_count = fragments.count()
682 return fragments[randint(0, fragments_count - 1)]
684 return self.parent.choose_fragment()
688 def fragment_data(self):
689 fragment = self.choose_fragment()
691 return {'title': fragment.book.pretty_title(), 'html': fragment.get_short_text()}
695 def update_popularity(self):
696 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
698 pop = self.popularity
701 except BookPopularity.DoesNotExist:
702 BookPopularity.objects.create(book=self, count=count)
704 def ridero_link(self):
705 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
708 def add_file_fields():
709 for format_ in Book.formats:
710 field_name = "%s_file" % format_
711 # This weird globals() assignment makes Django migrations comfortable.
712 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
713 _upload_to.__name__ = '_%s_upload_to' % format_
714 globals()[_upload_to.__name__] = _upload_to
717 format_, _("%s file" % format_.upper()),
718 upload_to=_upload_to,
719 storage=bofh_storage,
723 ).contribute_to_class(Book, field_name)
728 class BookPopularity(models.Model):
729 book = models.OneToOneField(Book, related_name='popularity')
730 count = models.IntegerField(default=0, db_index=True)