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
8 from django.conf import settings
9 from django.core.cache import caches
10 from django.db import connection, models, transaction
11 from django.db.models import permalink
12 import django.dispatch
13 from django.contrib.contenttypes.fields import GenericRelation
14 from django.core.urlresolvers import reverse
15 from django.utils.translation import ugettext_lazy as _
17 from fnpdjango.storage import BofhFileSystemStorage
18 from catalogue import constants
19 from catalogue.fields import EbookField
20 from catalogue.models import Tag, Fragment, BookMedia
21 from catalogue.utils import create_zip, split_tags
22 from catalogue import app_settings
23 from catalogue import tasks
24 from newtagging import managers
26 bofh_storage = BofhFileSystemStorage()
28 permanent_cache = caches['permanent']
31 def _cover_upload_to(i, n):
32 return 'book/cover/%s.jpg' % i.slug
34 def _cover_thumb_upload_to(i, n):
35 return 'book/cover_thumb/%s.jpg' % i.slug,
37 def _ebook_upload_to(upload_path):
39 return upload_path % i.slug
43 class Book(models.Model):
44 """Represents a book imported from WL-XML."""
45 title = models.CharField(_('title'), max_length=120)
46 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
47 sort_key_author = models.CharField(_('sort key by author'), max_length=120, db_index=True, editable=False, default=u'')
48 slug = models.SlugField(_('slug'), max_length=120, db_index=True,
50 common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
51 language = models.CharField(_('language code'), max_length=3, db_index=True,
52 default=app_settings.DEFAULT_LANGUAGE)
53 description = models.TextField(_('description'), blank=True)
54 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
55 changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
56 parent_number = models.IntegerField(_('parent number'), default=0)
57 extra_info = jsonfield.JSONField(_('extra information'), default={})
58 gazeta_link = models.CharField(blank=True, max_length=240)
59 wiki_link = models.CharField(blank=True, max_length=240)
60 # files generated during publication
62 cover = EbookField('cover', _('cover'),
63 null=True, blank=True,
64 upload_to=_cover_upload_to,
65 storage=bofh_storage, max_length=255)
66 # Cleaner version of cover for thumbs
67 cover_thumb = EbookField('cover_thumb', _('cover thumbnail'),
68 null=True, blank=True,
69 upload_to=_cover_thumb_upload_to,
71 ebook_formats = constants.EBOOK_FORMATS
72 formats = ebook_formats + ['html', 'xml']
74 parent = models.ForeignKey('self', blank=True, null=True,
75 related_name='children')
76 ancestor = models.ManyToManyField('self', blank=True, null=True,
77 editable=False, related_name='descendant', symmetrical=False)
79 objects = models.Manager()
80 tagged = managers.ModelTaggedItemManager(Tag)
81 tags = managers.TagDescriptor(Tag)
82 tag_relations = GenericRelation(Tag.intermediary_table_model)
84 html_built = django.dispatch.Signal()
85 published = django.dispatch.Signal()
87 class AlreadyExists(Exception):
91 ordering = ('sort_key',)
92 verbose_name = _('book')
93 verbose_name_plural = _('books')
94 app_label = 'catalogue'
96 def __unicode__(self):
99 def save(self, force_insert=False, force_update=False, reset_short_html=True, **kwargs):
100 from sortify import sortify
102 self.sort_key = sortify(self.title)
103 self.title = unicode(self.title) # ???
105 ret = super(Book, self).save(force_insert, force_update, **kwargs)
108 self.reset_short_html()
113 def get_absolute_url(self):
114 return ('catalogue.views.book_detail', [self.slug])
118 def create_url(slug):
119 return ('catalogue.views.book_detail', [slug])
125 def language_code(self):
126 return constants.LANGUAGES_3TO2.get(self.language, self.language)
128 def language_name(self):
129 return dict(settings.LANGUAGES).get(self.language_code(), "")
131 def has_media(self, type_):
132 if type_ in Book.formats:
133 return bool(getattr(self, "%s_file" % type_))
135 return self.media.filter(type=type_).exists()
137 def get_media(self, type_):
138 if self.has_media(type_):
139 if type_ in Book.formats:
140 return getattr(self, "%s_file" % type_)
142 return self.media.filter(type=type_)
147 return self.get_media("mp3")
149 return self.get_media("odt")
151 return self.get_media("ogg")
153 return self.get_media("daisy")
155 def reset_short_html(self):
159 # Fragment.short_html relies on book's tags, so reset it here too
160 for fragm in self.fragments.all().iterator():
161 fragm.reset_short_html()
164 author = self.tags.filter(category='author')[0].sort_key
167 type(self).objects.filter(pk=self.pk).update(sort_key_author=author)
171 def has_description(self):
172 return len(self.description) > 0
173 has_description.short_description = _('description')
174 has_description.boolean = True
177 def has_mp3_file(self):
178 return bool(self.has_media("mp3"))
179 has_mp3_file.short_description = 'MP3'
180 has_mp3_file.boolean = True
182 def has_ogg_file(self):
183 return bool(self.has_media("ogg"))
184 has_ogg_file.short_description = 'OGG'
185 has_ogg_file.boolean = True
187 def has_daisy_file(self):
188 return bool(self.has_media("daisy"))
189 has_daisy_file.short_description = 'DAISY'
190 has_daisy_file.boolean = True
192 def wldocument(self, parse_dublincore=True, inherit=True):
193 from catalogue.import_utils import ORMDocProvider
194 from librarian.parser import WLDocument
196 if inherit and self.parent:
197 meta_fallbacks = self.parent.cover_info()
199 meta_fallbacks = None
201 return WLDocument.from_file(self.xml_file.path,
202 provider=ORMDocProvider(self),
203 parse_dublincore=parse_dublincore,
204 meta_fallbacks=meta_fallbacks)
207 def zip_format(format_):
208 def pretty_file_name(book):
209 return "%s/%s.%s" % (
210 book.extra_info['author'],
214 field_name = "%s_file" % format_
215 books = Book.objects.filter(parent=None).exclude(**{field_name: ""})
216 paths = [(pretty_file_name(b), getattr(b, field_name).path)
217 for b in books.iterator()]
218 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
220 def zip_audiobooks(self, format_):
221 bm = BookMedia.objects.filter(book=self, type=format_)
222 paths = map(lambda bm: (None, bm.file.path), bm)
223 return create_zip(paths, "%s_%s" % (self.slug, format_))
225 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
227 from search.index import Index
230 index.index_book(self, book_info)
236 index.index.rollback()
241 def from_xml_file(cls, xml_file, **kwargs):
242 from django.core.files import File
243 from librarian import dcparser
245 # use librarian to parse meta-data
246 book_info = dcparser.parse(xml_file)
248 if not isinstance(xml_file, File):
249 xml_file = File(open(xml_file))
252 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
257 def from_text_and_meta(cls, raw_file, book_info, overwrite=False,
258 dont_build=None, search_index=True,
259 search_index_tags=True):
260 if dont_build is None:
262 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
264 # check for parts before we do anything
266 if hasattr(book_info, 'parts'):
267 for part_url in book_info.parts:
269 children.append(Book.objects.get(slug=part_url.slug))
270 except Book.DoesNotExist:
271 raise Book.DoesNotExist(_('Book "%s" does not exist.') %
275 book_slug = book_info.url.slug
276 if re.search(r'[^a-z0-9-]', book_slug):
277 raise ValueError('Invalid characters in slug')
278 book, created = Book.objects.get_or_create(slug=book_slug)
285 raise Book.AlreadyExists(_('Book %s already exists') % (
287 # Save shelves for this book
288 book_shelves = list(book.tags.filter(category='set'))
289 old_cover = book.cover_info()
292 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
294 book.language = book_info.language
295 book.title = book_info.title
296 if book_info.variant_of:
297 book.common_slug = book_info.variant_of.slug
299 book.common_slug = book.slug
300 book.extra_info = book_info.to_dict()
303 meta_tags = Tag.tags_from_info(book_info)
305 book.tags = set(meta_tags + book_shelves)
307 cover_changed = old_cover != book.cover_info()
308 obsolete_children = set(b for b in book.children.all()
309 if b not in children)
310 notify_cover_changed = []
311 for n, child_book in enumerate(children):
312 new_child = child_book.parent != book
313 child_book.parent = book
314 child_book.parent_number = n
316 if new_child or cover_changed:
317 notify_cover_changed.append(child_book)
318 # Disown unfaithful children and let them cope on their own.
319 for child in obsolete_children:
321 child.parent_number = 0
323 tasks.fix_tree_tags.delay(child)
325 notify_cover_changed.append(child)
329 # No saves beyond this point.
332 if 'cover' not in dont_build:
333 book.cover.build_delay()
334 book.cover_thumb.build_delay()
336 # Build HTML and ebooks.
337 book.html_file.build_delay()
339 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
340 if format_ not in dont_build:
341 getattr(book, '%s_file' % format_).build_delay()
342 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
343 if format_ not in dont_build:
344 getattr(book, '%s_file' % format_).build_delay()
346 if not settings.NO_SEARCH_INDEX and search_index:
347 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
349 for child in notify_cover_changed:
350 child.parent_cover_changed()
352 cls.published.send(sender=book)
356 def fix_tree_tags(cls):
357 """Fixes the ancestry cache."""
359 with transaction.atomic():
360 cursor = connection.cursor()
361 if connection.vendor == 'postgres':
362 cursor.execute("TRUNCATE catalogue_book_ancestor")
364 WITH RECURSIVE ancestry AS (
365 SELECT book.id, book.parent_id
366 FROM catalogue_book AS book
367 WHERE book.parent_id IS NOT NULL
369 SELECT ancestor.id, book.parent_id
370 FROM ancestry AS ancestor, catalogue_book AS book
371 WHERE ancestor.parent_id = book.id
372 AND book.parent_id IS NOT NULL
374 INSERT INTO catalogue_book_ancestor
375 (from_book_id, to_book_id)
381 cursor.execute("DELETE FROM catalogue_book_ancestor")
382 for b in cls.objects.exclude(parent=None):
384 while parent is not None:
385 b.ancestor.add(parent)
386 parent = parent.parent
388 def cover_info(self, inherit=True):
389 """Returns a dictionary to serve as fallback for BookInfo.
391 For now, the only thing inherited is the cover image.
395 for field in ('cover_url', 'cover_by', 'cover_source'):
396 val = self.extra_info.get(field)
401 if inherit and need and self.parent is not None:
402 parent_info = self.parent.cover_info()
403 parent_info.update(info)
407 def related_themes(self):
408 return Tag.objects.usage_for_queryset(
409 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
410 counts=True).filter(category='theme')
412 def parent_cover_changed(self):
413 """Called when parent book's cover image is changed."""
414 if not self.cover_info(inherit=False):
415 if 'cover' not in app_settings.DONT_BUILD:
416 self.cover.build_delay()
417 self.cover_thumb.build_delay()
418 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
419 if format_ not in app_settings.DONT_BUILD:
420 getattr(self, '%s_file' % format_).build_delay()
421 for child in self.children.all():
422 child.parent_cover_changed()
424 def other_versions(self):
425 """Find other versions (i.e. in other languages) of the book."""
426 return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
431 while parent is not None:
432 books.insert(0, parent)
433 parent = parent.parent
436 def pretty_title(self, html_links=False):
437 names = [(tag.name, tag.get_absolute_url())
438 for tag in self.tags.filter(category='author')]
439 books = self.parents() + [self]
440 names.extend([(b.title, b.get_absolute_url()) for b in books])
443 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
445 names = [tag[0] for tag in names]
446 return ', '.join(names)
449 def tagged_top_level(cls, tags):
450 """ Returns top-level books tagged with `tags`.
452 It only returns those books which don't have ancestors which are
453 also tagged with those tags.
456 objects = cls.tagged.with_all(tags)
457 return objects.exclude(ancestor__in=objects)
460 def book_list(cls, filter=None):
461 """Generates a hierarchical listing of all books.
463 Books are optionally filtered with a test function.
468 books = cls.objects.all().order_by('parent_number', 'sort_key').only(
469 'title', 'parent', 'slug')
471 books = books.filter(filter).distinct()
473 book_ids = set(b['pk'] for b in books.values("pk").iterator())
474 for book in books.iterator():
475 parent = book.parent_id
476 if parent not in book_ids:
478 books_by_parent.setdefault(parent, []).append(book)
480 for book in books.iterator():
481 books_by_parent.setdefault(book.parent_id, []).append(book)
484 books_by_author = OrderedDict()
485 for tag in Tag.objects.filter(category='author').iterator():
486 books_by_author[tag] = []
488 for book in books_by_parent.get(None, ()):
489 authors = list(book.tags.filter(category='author'))
491 for author in authors:
492 books_by_author[author].append(book)
496 return books_by_author, orphans, books_by_parent
499 "SP": (1, u"szkoła podstawowa"),
500 "SP1": (1, u"szkoła podstawowa"),
501 "SP2": (1, u"szkoła podstawowa"),
502 "P": (1, u"szkoła podstawowa"),
503 "G": (2, u"gimnazjum"),
505 "LP": (3, u"liceum"),
507 def audiences_pl(self):
508 audiences = self.extra_info.get('audiences', [])
509 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
510 return [a[1] for a in audiences]
512 def stage_note(self):
513 stage = self.extra_info.get('stage')
514 if stage and stage < '0.4':
515 return (_('This work needs modernisation'),
516 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
520 def choose_fragment(self):
521 fragments = self.fragments.order_by()
522 fragments_count = fragments.count()
523 if not fragments_count and self.children.exists():
524 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
525 fragments_count = fragments.count()
527 return fragments[randint(0, fragments_count - 1)]
529 return self.parent.choose_fragment()
534 # add the file fields
535 for format_ in Book.formats:
536 field_name = "%s_file" % format_
537 # This weird globals() assignment makes Django migrations comfortable.
538 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
539 _upload_to.__name__ = '_%s_upload_to' % format_
540 globals()[_upload_to.__name__] = _upload_to
542 EbookField(format_, _("%s file" % format_.upper()),
543 upload_to=_upload_to,
544 storage=bofh_storage,
548 ).contribute_to_class(Book, field_name)