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 # delete previous files, so we don't include old files in ebooks
251 for filename in os.listdir(gallery_path):
252 file_path = os.path.join(gallery_path, filename)
254 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
256 makedirs(gallery_path)
257 for ilustr in ilustr_elements:
258 ilustr_src = ilustr.get('src')
259 ilustr_path = os.path.join(gallery_path, ilustr_src)
260 urllib.urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
263 def from_xml_file(cls, xml_file, **kwargs):
264 from django.core.files import File
265 from librarian import dcparser
267 # use librarian to parse meta-data
268 book_info = dcparser.parse(xml_file)
270 if not isinstance(xml_file, File):
271 xml_file = File(open(xml_file))
274 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
279 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
280 search_index_tags=True, remote_gallery_url=None):
281 if dont_build is None:
283 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
285 # check for parts before we do anything
287 if hasattr(book_info, 'parts'):
288 for part_url in book_info.parts:
290 children.append(Book.objects.get(slug=part_url.slug))
291 except Book.DoesNotExist:
292 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
295 book_slug = book_info.url.slug
296 if re.search(r'[^a-z0-9-]', book_slug):
297 raise ValueError('Invalid characters in slug')
298 book, created = Book.objects.get_or_create(slug=book_slug)
305 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
306 # Save shelves for this book
307 book_shelves = list(book.tags.filter(category='set'))
308 old_cover = book.cover_info()
311 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
313 book.language = book_info.language
314 book.title = book_info.title
315 if book_info.variant_of:
316 book.common_slug = book_info.variant_of.slug
318 book.common_slug = book.slug
319 book.extra_info = book_info.to_dict()
322 meta_tags = Tag.tags_from_info(book_info)
324 book.tags = set(meta_tags + book_shelves)
326 cover_changed = old_cover != book.cover_info()
327 obsolete_children = set(b for b in book.children.all()
328 if b not in children)
329 notify_cover_changed = []
330 for n, child_book in enumerate(children):
331 new_child = child_book.parent != book
332 child_book.parent = book
333 child_book.parent_number = n
335 if new_child or cover_changed:
336 notify_cover_changed.append(child_book)
337 # Disown unfaithful children and let them cope on their own.
338 for child in obsolete_children:
340 child.parent_number = 0
343 notify_cover_changed.append(child)
345 cls.repopulate_ancestors()
346 tasks.update_counters.delay()
348 if remote_gallery_url:
349 book.download_pictures(remote_gallery_url)
351 # No saves beyond this point.
354 if 'cover' not in dont_build:
355 book.cover.build_delay()
356 book.cover_thumb.build_delay()
358 # Build HTML and ebooks.
359 book.html_file.build_delay()
361 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
362 if format_ not in dont_build:
363 getattr(book, '%s_file' % format_).build_delay()
364 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
365 if format_ not in dont_build:
366 getattr(book, '%s_file' % format_).build_delay()
368 if not settings.NO_SEARCH_INDEX and search_index:
369 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
371 for child in notify_cover_changed:
372 child.parent_cover_changed()
374 cls.published.send(sender=cls, instance=book)
379 def repopulate_ancestors(cls):
380 """Fixes the ancestry cache."""
382 cursor = connection.cursor()
383 if connection.vendor == 'postgres':
384 cursor.execute("TRUNCATE catalogue_book_ancestor")
386 WITH RECURSIVE ancestry AS (
387 SELECT book.id, book.parent_id
388 FROM catalogue_book AS book
389 WHERE book.parent_id IS NOT NULL
391 SELECT ancestor.id, book.parent_id
392 FROM ancestry AS ancestor, catalogue_book AS book
393 WHERE ancestor.parent_id = book.id
394 AND book.parent_id IS NOT NULL
396 INSERT INTO catalogue_book_ancestor
397 (from_book_id, to_book_id)
403 cursor.execute("DELETE FROM catalogue_book_ancestor")
404 for b in cls.objects.exclude(parent=None):
406 while parent is not None:
407 b.ancestor.add(parent)
408 parent = parent.parent
410 def flush_includes(self, languages=True):
413 if languages is True:
414 languages = [lc for (lc, _ln) in settings.LANGUAGES]
416 template % (self.pk, lang)
418 '/katalog/b/%d/mini.%s.html',
419 '/katalog/b/%d/mini_nolink.%s.html',
420 '/katalog/b/%d/short.%s.html',
421 '/katalog/b/%d/wide.%s.html',
422 '/api/include/book/%d.%s.json',
423 '/api/include/book/%d.%s.xml',
425 for lang in languages
428 def cover_info(self, inherit=True):
429 """Returns a dictionary to serve as fallback for BookInfo.
431 For now, the only thing inherited is the cover image.
435 for field in ('cover_url', 'cover_by', 'cover_source'):
436 val = self.extra_info.get(field)
441 if inherit and need and self.parent is not None:
442 parent_info = self.parent.cover_info()
443 parent_info.update(info)
447 def related_themes(self):
448 return Tag.objects.usage_for_queryset(
449 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
450 counts=True).filter(category='theme')
452 def parent_cover_changed(self):
453 """Called when parent book's cover image is changed."""
454 if not self.cover_info(inherit=False):
455 if 'cover' not in app_settings.DONT_BUILD:
456 self.cover.build_delay()
457 self.cover_thumb.build_delay()
458 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
459 if format_ not in app_settings.DONT_BUILD:
460 getattr(self, '%s_file' % format_).build_delay()
461 for child in self.children.all():
462 child.parent_cover_changed()
464 def other_versions(self):
465 """Find other versions (i.e. in other languages) of the book."""
466 return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
471 while parent is not None:
472 books.insert(0, parent)
473 parent = parent.parent
476 def pretty_title(self, html_links=False):
477 names = [(tag.name, tag.get_absolute_url()) for tag in self.tags.filter(category='author')]
478 books = self.parents() + [self]
479 names.extend([(b.title, b.get_absolute_url()) for b in books])
482 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
484 names = [tag[0] for tag in names]
485 return ', '.join(names)
488 def tagged_top_level(cls, tags):
489 """ Returns top-level books tagged with `tags`.
491 It only returns those books which don't have ancestors which are
492 also tagged with those tags.
495 objects = cls.tagged.with_all(tags)
496 return objects.exclude(ancestor__in=objects)
499 def book_list(cls, book_filter=None):
500 """Generates a hierarchical listing of all books.
502 Books are optionally filtered with a test function.
507 books = cls.objects.all().order_by('parent_number', 'sort_key').only(
508 'title', 'parent', 'slug')
510 books = books.filter(book_filter).distinct()
512 book_ids = set(b['pk'] for b in books.values("pk").iterator())
513 for book in books.iterator():
514 parent = book.parent_id
515 if parent not in book_ids:
517 books_by_parent.setdefault(parent, []).append(book)
519 for book in books.iterator():
520 books_by_parent.setdefault(book.parent_id, []).append(book)
523 books_by_author = OrderedDict()
524 for tag in Tag.objects.filter(category='author').iterator():
525 books_by_author[tag] = []
527 for book in books_by_parent.get(None, ()):
528 authors = list(book.tags.filter(category='author'))
530 for author in authors:
531 books_by_author[author].append(book)
535 return books_by_author, orphans, books_by_parent
538 "SP": (1, u"szkoła podstawowa"),
539 "SP1": (1, u"szkoła podstawowa"),
540 "SP2": (1, u"szkoła podstawowa"),
541 "P": (1, u"szkoła podstawowa"),
542 "G": (2, u"gimnazjum"),
544 "LP": (3, u"liceum"),
547 def audiences_pl(self):
548 audiences = self.extra_info.get('audiences', [])
549 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
550 return [a[1] for a in audiences]
552 def stage_note(self):
553 stage = self.extra_info.get('stage')
554 if stage and stage < '0.4':
555 return (_('This work needs modernisation'),
556 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
560 def choose_fragment(self):
561 fragments = self.fragments.order_by()
562 fragments_count = fragments.count()
563 if not fragments_count and self.children.exists():
564 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
565 fragments_count = fragments.count()
567 return fragments[randint(0, fragments_count - 1)]
569 return self.parent.choose_fragment()
574 def add_file_fields():
575 for format_ in Book.formats:
576 field_name = "%s_file" % format_
577 # This weird globals() assignment makes Django migrations comfortable.
578 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
579 _upload_to.__name__ = '_%s_upload_to' % format_
580 globals()[_upload_to.__name__] = _upload_to
583 format_, _("%s file" % format_.upper()),
584 upload_to=_upload_to,
585 storage=bofh_storage,
589 ).contribute_to_class(Book, field_name)