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 _
18 from fnpdjango.storage import BofhFileSystemStorage
19 from ssify import flush_ssi_includes
20 from newtagging import managers
21 from catalogue import constants
22 from catalogue.fields import EbookField
23 from catalogue.models import Tag, Fragment, BookMedia
24 from catalogue.utils import create_zip, gallery_url, gallery_path
25 from catalogue import app_settings
26 from catalogue import tasks
27 from wolnelektury.utils import makedirs
29 bofh_storage = BofhFileSystemStorage()
32 def _make_upload_to(path):
38 _cover_upload_to = _make_upload_to('book/cover/%s.jpg')
39 _cover_thumb_upload_to = _make_upload_to('book/cover_thumb/%s.jpg')
42 def _ebook_upload_to(upload_path):
43 return _make_upload_to(upload_path)
46 class Book(models.Model):
47 """Represents a book imported from WL-XML."""
48 title = models.CharField(_('title'), max_length=32767)
49 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
50 sort_key_author = models.CharField(
51 _('sort key by author'), max_length=120, db_index=True, editable=False, default=u'')
52 slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
53 common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
54 language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
55 description = models.TextField(_('description'), blank=True)
56 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
57 changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
58 parent_number = models.IntegerField(_('parent number'), default=0)
59 extra_info = jsonfield.JSONField(_('extra information'), default={})
60 gazeta_link = models.CharField(blank=True, max_length=240)
61 wiki_link = models.CharField(blank=True, max_length=240)
63 # files generated during publication
66 null=True, blank=True,
67 upload_to=_cover_upload_to,
68 storage=bofh_storage, max_length=255)
69 # Cleaner version of cover for thumbs
70 cover_thumb = EbookField(
71 'cover_thumb', _('cover thumbnail'),
72 null=True, blank=True,
73 upload_to=_cover_thumb_upload_to,
75 ebook_formats = constants.EBOOK_FORMATS
76 formats = ebook_formats + ['html', 'xml']
78 parent = models.ForeignKey('self', blank=True, null=True, related_name='children')
79 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
81 objects = models.Manager()
82 tagged = managers.ModelTaggedItemManager(Tag)
83 tags = managers.TagDescriptor(Tag)
84 tag_relations = GenericRelation(Tag.intermediary_table_model)
86 html_built = django.dispatch.Signal()
87 published = django.dispatch.Signal()
89 short_html_url_name = 'catalogue_book_short'
91 class AlreadyExists(Exception):
95 ordering = ('sort_key',)
96 verbose_name = _('book')
97 verbose_name_plural = _('books')
98 app_label = 'catalogue'
100 def __unicode__(self):
103 def get_initial(self):
105 return re.search(r'\w', self.title, re.U).group(0)
106 except AttributeError:
109 def author_str(self):
110 return ", ".join(str(t) for t in self.tags.filter(category='author'))
112 def save(self, force_insert=False, force_update=False, **kwargs):
113 from sortify import sortify
115 self.sort_key = sortify(self.title)[:120]
116 self.title = unicode(self.title) # ???
119 author = self.tags.filter(category='author')[0].sort_key
122 self.sort_key_author = author
124 ret = super(Book, self).save(force_insert, force_update, **kwargs)
129 def get_absolute_url(self):
130 return 'catalogue.views.book_detail', [self.slug]
134 def create_url(slug):
135 return 'catalogue.views.book_detail', [slug]
137 def gallery_path(self):
138 return gallery_path(self.slug)
140 def gallery_url(self):
141 return gallery_url(self.slug)
147 def language_code(self):
148 return constants.LANGUAGES_3TO2.get(self.language, self.language)
150 def language_name(self):
151 return dict(settings.LANGUAGES).get(self.language_code(), "")
153 def has_media(self, type_):
154 if type_ in Book.formats:
155 return bool(getattr(self, "%s_file" % type_))
157 return self.media.filter(type=type_).exists()
159 def get_media(self, type_):
160 if self.has_media(type_):
161 if type_ in Book.formats:
162 return getattr(self, "%s_file" % type_)
164 return self.media.filter(type=type_)
169 return self.get_media("mp3")
172 return self.get_media("odt")
175 return self.get_media("ogg")
178 return self.get_media("daisy")
180 def has_description(self):
181 return len(self.description) > 0
182 has_description.short_description = _('description')
183 has_description.boolean = True
186 def has_mp3_file(self):
187 return bool(self.has_media("mp3"))
188 has_mp3_file.short_description = 'MP3'
189 has_mp3_file.boolean = True
191 def has_ogg_file(self):
192 return bool(self.has_media("ogg"))
193 has_ogg_file.short_description = 'OGG'
194 has_ogg_file.boolean = True
196 def has_daisy_file(self):
197 return bool(self.has_media("daisy"))
198 has_daisy_file.short_description = 'DAISY'
199 has_daisy_file.boolean = True
201 def wldocument(self, parse_dublincore=True, inherit=True):
202 from catalogue.import_utils import ORMDocProvider
203 from librarian.parser import WLDocument
205 if inherit and self.parent:
206 meta_fallbacks = self.parent.cover_info()
208 meta_fallbacks = None
210 return WLDocument.from_file(
212 provider=ORMDocProvider(self),
213 parse_dublincore=parse_dublincore,
214 meta_fallbacks=meta_fallbacks)
217 def zip_format(format_):
218 def pretty_file_name(book):
219 return "%s/%s.%s" % (
220 book.extra_info['author'],
224 field_name = "%s_file" % format_
225 books = Book.objects.filter(parent=None).exclude(**{field_name: ""})
226 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
227 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
229 def zip_audiobooks(self, format_):
230 bm = BookMedia.objects.filter(book=self, type=format_)
231 paths = map(lambda bm: (None, bm.file.path), bm)
232 return create_zip(paths, "%s_%s" % (self.slug, format_))
234 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
236 from search.index import Index
239 index.index_book(self, book_info)
245 index.index.rollback()
248 def download_pictures(self, remote_gallery_url):
249 gallery_path = self.gallery_path()
250 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
252 makedirs(gallery_path)
253 for ilustr in ilustr_elements:
254 ilustr_src = ilustr.get('src')
255 ilustr_path = os.path.join(gallery_path, ilustr_src)
256 urllib.urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
259 def from_xml_file(cls, xml_file, **kwargs):
260 from django.core.files import File
261 from librarian import dcparser
263 # use librarian to parse meta-data
264 book_info = dcparser.parse(xml_file)
266 if not isinstance(xml_file, File):
267 xml_file = File(open(xml_file))
270 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
275 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
276 search_index_tags=True, remote_gallery_url=None):
277 if dont_build is None:
279 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
281 # check for parts before we do anything
283 if hasattr(book_info, 'parts'):
284 for part_url in book_info.parts:
286 children.append(Book.objects.get(slug=part_url.slug))
287 except Book.DoesNotExist:
288 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
291 book_slug = book_info.url.slug
292 if re.search(r'[^a-z0-9-]', book_slug):
293 raise ValueError('Invalid characters in slug')
294 book, created = Book.objects.get_or_create(slug=book_slug)
301 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
302 # Save shelves for this book
303 book_shelves = list(book.tags.filter(category='set'))
304 old_cover = book.cover_info()
307 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
309 book.language = book_info.language
310 book.title = book_info.title
311 if book_info.variant_of:
312 book.common_slug = book_info.variant_of.slug
314 book.common_slug = book.slug
315 book.extra_info = book_info.to_dict()
318 meta_tags = Tag.tags_from_info(book_info)
320 book.tags = set(meta_tags + book_shelves)
322 cover_changed = old_cover != book.cover_info()
323 obsolete_children = set(b for b in book.children.all()
324 if b not in children)
325 notify_cover_changed = []
326 for n, child_book in enumerate(children):
327 new_child = child_book.parent != book
328 child_book.parent = book
329 child_book.parent_number = n
331 if new_child or cover_changed:
332 notify_cover_changed.append(child_book)
333 # Disown unfaithful children and let them cope on their own.
334 for child in obsolete_children:
336 child.parent_number = 0
339 notify_cover_changed.append(child)
341 cls.repopulate_ancestors()
342 tasks.update_counters.delay()
344 if remote_gallery_url:
345 book.download_pictures(remote_gallery_url)
347 # No saves beyond this point.
350 if 'cover' not in dont_build:
351 book.cover.build_delay()
352 book.cover_thumb.build_delay()
354 # Build HTML and ebooks.
355 book.html_file.build_delay()
357 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
358 if format_ not in dont_build:
359 getattr(book, '%s_file' % format_).build_delay()
360 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
361 if format_ not in dont_build:
362 getattr(book, '%s_file' % format_).build_delay()
364 if not settings.NO_SEARCH_INDEX and search_index:
365 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
367 for child in notify_cover_changed:
368 child.parent_cover_changed()
370 cls.published.send(sender=cls, instance=book)
375 def repopulate_ancestors(cls):
376 """Fixes the ancestry cache."""
378 cursor = connection.cursor()
379 if connection.vendor == 'postgres':
380 cursor.execute("TRUNCATE catalogue_book_ancestor")
382 WITH RECURSIVE ancestry AS (
383 SELECT book.id, book.parent_id
384 FROM catalogue_book AS book
385 WHERE book.parent_id IS NOT NULL
387 SELECT ancestor.id, book.parent_id
388 FROM ancestry AS ancestor, catalogue_book AS book
389 WHERE ancestor.parent_id = book.id
390 AND book.parent_id IS NOT NULL
392 INSERT INTO catalogue_book_ancestor
393 (from_book_id, to_book_id)
399 cursor.execute("DELETE FROM catalogue_book_ancestor")
400 for b in cls.objects.exclude(parent=None):
402 while parent is not None:
403 b.ancestor.add(parent)
404 parent = parent.parent
406 def flush_includes(self, languages=True):
409 if languages is True:
410 languages = [lc for (lc, _ln) in settings.LANGUAGES]
412 template % (self.pk, lang)
414 '/katalog/b/%d/mini.%s.html',
415 '/katalog/b/%d/mini_nolink.%s.html',
416 '/katalog/b/%d/short.%s.html',
417 '/katalog/b/%d/wide.%s.html',
418 '/api/include/book/%d.%s.json',
419 '/api/include/book/%d.%s.xml',
421 for lang in languages
424 def cover_info(self, inherit=True):
425 """Returns a dictionary to serve as fallback for BookInfo.
427 For now, the only thing inherited is the cover image.
431 for field in ('cover_url', 'cover_by', 'cover_source'):
432 val = self.extra_info.get(field)
437 if inherit and need and self.parent is not None:
438 parent_info = self.parent.cover_info()
439 parent_info.update(info)
443 def related_themes(self):
444 return Tag.objects.usage_for_queryset(
445 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
446 counts=True).filter(category='theme')
448 def parent_cover_changed(self):
449 """Called when parent book's cover image is changed."""
450 if not self.cover_info(inherit=False):
451 if 'cover' not in app_settings.DONT_BUILD:
452 self.cover.build_delay()
453 self.cover_thumb.build_delay()
454 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
455 if format_ not in app_settings.DONT_BUILD:
456 getattr(self, '%s_file' % format_).build_delay()
457 for child in self.children.all():
458 child.parent_cover_changed()
460 def other_versions(self):
461 """Find other versions (i.e. in other languages) of the book."""
462 return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
467 while parent is not None:
468 books.insert(0, parent)
469 parent = parent.parent
472 def pretty_title(self, html_links=False):
473 names = [(tag.name, tag.get_absolute_url()) for tag in self.tags.filter(category='author')]
474 books = self.parents() + [self]
475 names.extend([(b.title, b.get_absolute_url()) for b in books])
478 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
480 names = [tag[0] for tag in names]
481 return ', '.join(names)
484 def tagged_top_level(cls, tags):
485 """ Returns top-level books tagged with `tags`.
487 It only returns those books which don't have ancestors which are
488 also tagged with those tags.
491 objects = cls.tagged.with_all(tags)
492 return objects.exclude(ancestor__in=objects)
495 def book_list(cls, book_filter=None):
496 """Generates a hierarchical listing of all books.
498 Books are optionally filtered with a test function.
503 books = cls.objects.all().order_by('parent_number', 'sort_key').only(
504 'title', 'parent', 'slug')
506 books = books.filter(book_filter).distinct()
508 book_ids = set(b['pk'] for b in books.values("pk").iterator())
509 for book in books.iterator():
510 parent = book.parent_id
511 if parent not in book_ids:
513 books_by_parent.setdefault(parent, []).append(book)
515 for book in books.iterator():
516 books_by_parent.setdefault(book.parent_id, []).append(book)
519 books_by_author = OrderedDict()
520 for tag in Tag.objects.filter(category='author').iterator():
521 books_by_author[tag] = []
523 for book in books_by_parent.get(None, ()):
524 authors = list(book.tags.filter(category='author'))
526 for author in authors:
527 books_by_author[author].append(book)
531 return books_by_author, orphans, books_by_parent
534 "SP": (1, u"szkoła podstawowa"),
535 "SP1": (1, u"szkoła podstawowa"),
536 "SP2": (1, u"szkoła podstawowa"),
537 "P": (1, u"szkoła podstawowa"),
538 "G": (2, u"gimnazjum"),
540 "LP": (3, u"liceum"),
543 def audiences_pl(self):
544 audiences = self.extra_info.get('audiences', [])
545 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
546 return [a[1] for a in audiences]
548 def stage_note(self):
549 stage = self.extra_info.get('stage')
550 if stage and stage < '0.4':
551 return (_('This work needs modernisation'),
552 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
556 def choose_fragment(self):
557 fragments = self.fragments.order_by()
558 fragments_count = fragments.count()
559 if not fragments_count and self.children.exists():
560 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
561 fragments_count = fragments.count()
563 return fragments[randint(0, fragments_count - 1)]
565 return self.parent.choose_fragment()
570 def add_file_fields():
571 for format_ in Book.formats:
572 field_name = "%s_file" % format_
573 # This weird globals() assignment makes Django migrations comfortable.
574 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
575 _upload_to.__name__ = '_%s_upload_to' % format_
576 globals()[_upload_to.__name__] = _upload_to
579 format_, _("%s file" % format_.upper()),
580 upload_to=_upload_to,
581 storage=bofh_storage,
585 ).contribute_to_class(Book, field_name)