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
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.models.tag import prefetched_relations
26 from catalogue import app_settings
27 from catalogue import tasks
28 from wolnelektury.utils import makedirs
30 bofh_storage = BofhFileSystemStorage()
33 def _make_upload_to(path):
39 _cover_upload_to = _make_upload_to('book/cover/%s.jpg')
40 _cover_thumb_upload_to = _make_upload_to('book/cover_thumb/%s.jpg')
43 def _ebook_upload_to(upload_path):
44 return _make_upload_to(upload_path)
47 class Book(models.Model):
48 """Represents a book imported from WL-XML."""
49 title = models.CharField(_('title'), max_length=32767)
50 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
51 sort_key_author = models.CharField(
52 _('sort key by author'), max_length=120, db_index=True, editable=False, default=u'')
53 slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
54 common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
55 language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
56 description = models.TextField(_('description'), blank=True)
57 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
58 changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
59 parent_number = models.IntegerField(_('parent number'), default=0)
60 extra_info = jsonfield.JSONField(_('extra information'), default={})
61 gazeta_link = models.CharField(blank=True, max_length=240)
62 wiki_link = models.CharField(blank=True, max_length=240)
63 print_on_demand = models.BooleanField(_('print on demand'), default=False)
65 # files generated during publication
68 null=True, blank=True,
69 upload_to=_cover_upload_to,
70 storage=bofh_storage, max_length=255)
71 # Cleaner version of cover for thumbs
72 cover_thumb = EbookField(
73 'cover_thumb', _('cover thumbnail'),
74 null=True, blank=True,
75 upload_to=_cover_thumb_upload_to,
77 ebook_formats = constants.EBOOK_FORMATS
78 formats = ebook_formats + ['html', 'xml']
80 parent = models.ForeignKey('self', blank=True, null=True, related_name='children')
81 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
83 objects = models.Manager()
84 tagged = managers.ModelTaggedItemManager(Tag)
85 tags = managers.TagDescriptor(Tag)
86 tag_relations = GenericRelation(Tag.intermediary_table_model)
88 html_built = django.dispatch.Signal()
89 published = django.dispatch.Signal()
91 short_html_url_name = 'catalogue_book_short'
93 class AlreadyExists(Exception):
97 ordering = ('sort_key_author', 'sort_key')
98 verbose_name = _('book')
99 verbose_name_plural = _('books')
100 app_label = 'catalogue'
102 def __unicode__(self):
105 def get_initial(self):
107 return re.search(r'\w', self.title, re.U).group(0)
108 except AttributeError:
112 return self.tags.filter(category='author')
114 def tag_unicode(self, category):
115 relations = prefetched_relations(self, category)
117 return ', '.join(rel.tag.name for rel in relations)
119 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
121 def author_unicode(self):
122 return self.tag_unicode('author')
124 def translator(self):
125 translators = self.extra_info.get('translators')
128 if len(translators) > 3:
129 translators = translators[:2]
133 return ', '.join(u'\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
135 def save(self, force_insert=False, force_update=False, **kwargs):
136 from sortify import sortify
138 self.sort_key = sortify(self.title)[:120]
139 self.title = unicode(self.title) # ???
142 author = self.authors().first().sort_key
143 except AttributeError:
145 self.sort_key_author = author
147 ret = super(Book, self).save(force_insert, force_update, **kwargs)
152 def get_absolute_url(self):
153 return 'catalogue.views.book_detail', [self.slug]
157 def create_url(slug):
158 return 'catalogue.views.book_detail', [slug]
160 def gallery_path(self):
161 return gallery_path(self.slug)
163 def gallery_url(self):
164 return gallery_url(self.slug)
170 def language_code(self):
171 return constants.LANGUAGES_3TO2.get(self.language, self.language)
173 def language_name(self):
174 return dict(settings.LANGUAGES).get(self.language_code(), "")
176 def is_foreign(self):
177 return self.language_code() != settings.LANGUAGE_CODE
179 def has_media(self, type_):
180 if type_ in Book.formats:
181 return bool(getattr(self, "%s_file" % type_))
183 return self.media.filter(type=type_).exists()
185 def get_media(self, type_):
186 if self.has_media(type_):
187 if type_ in Book.formats:
188 return getattr(self, "%s_file" % type_)
190 return self.media.filter(type=type_)
195 return self.get_media("mp3")
198 return self.get_media("odt")
201 return self.get_media("ogg")
204 return self.get_media("daisy")
206 def has_description(self):
207 return len(self.description) > 0
208 has_description.short_description = _('description')
209 has_description.boolean = True
212 def has_mp3_file(self):
213 return bool(self.has_media("mp3"))
214 has_mp3_file.short_description = 'MP3'
215 has_mp3_file.boolean = True
217 def has_ogg_file(self):
218 return bool(self.has_media("ogg"))
219 has_ogg_file.short_description = 'OGG'
220 has_ogg_file.boolean = True
222 def has_daisy_file(self):
223 return bool(self.has_media("daisy"))
224 has_daisy_file.short_description = 'DAISY'
225 has_daisy_file.boolean = True
227 def wldocument(self, parse_dublincore=True, inherit=True):
228 from catalogue.import_utils import ORMDocProvider
229 from librarian.parser import WLDocument
231 if inherit and self.parent:
232 meta_fallbacks = self.parent.cover_info()
234 meta_fallbacks = None
236 return WLDocument.from_file(
238 provider=ORMDocProvider(self),
239 parse_dublincore=parse_dublincore,
240 meta_fallbacks=meta_fallbacks)
243 def zip_format(format_):
244 def pretty_file_name(book):
245 return "%s/%s.%s" % (
246 book.extra_info['author'],
250 field_name = "%s_file" % format_
251 books = Book.objects.filter(parent=None).exclude(**{field_name: ""})
252 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
253 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
255 def zip_audiobooks(self, format_):
256 bm = BookMedia.objects.filter(book=self, type=format_)
257 paths = map(lambda bm: (None, bm.file.path), bm)
258 return create_zip(paths, "%s_%s" % (self.slug, format_))
260 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
262 from search.index import Index
265 index.index_book(self, book_info)
271 index.index.rollback()
274 def download_pictures(self, remote_gallery_url):
275 gallery_path = self.gallery_path()
276 # delete previous files, so we don't include old files in ebooks
277 if os.path.isdir(gallery_path):
278 for filename in os.listdir(gallery_path):
279 file_path = os.path.join(gallery_path, filename)
281 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
283 makedirs(gallery_path)
284 for ilustr in ilustr_elements:
285 ilustr_src = ilustr.get('src')
286 ilustr_path = os.path.join(gallery_path, ilustr_src)
287 urllib.urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
290 def from_xml_file(cls, xml_file, **kwargs):
291 from django.core.files import File
292 from librarian import dcparser
294 # use librarian to parse meta-data
295 book_info = dcparser.parse(xml_file)
297 if not isinstance(xml_file, File):
298 xml_file = File(open(xml_file))
301 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
306 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
307 search_index_tags=True, remote_gallery_url=None):
308 if dont_build is None:
310 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
312 # check for parts before we do anything
314 if hasattr(book_info, 'parts'):
315 for part_url in book_info.parts:
317 children.append(Book.objects.get(slug=part_url.slug))
318 except Book.DoesNotExist:
319 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
322 book_slug = book_info.url.slug
323 if re.search(r'[^a-z0-9-]', book_slug):
324 raise ValueError('Invalid characters in slug')
325 book, created = Book.objects.get_or_create(slug=book_slug)
332 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
333 # Save shelves for this book
334 book_shelves = list(book.tags.filter(category='set'))
335 old_cover = book.cover_info()
338 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
340 book.language = book_info.language
341 book.title = book_info.title
342 if book_info.variant_of:
343 book.common_slug = book_info.variant_of.slug
345 book.common_slug = book.slug
346 book.extra_info = book_info.to_dict()
349 meta_tags = Tag.tags_from_info(book_info)
351 book.tags = set(meta_tags + book_shelves)
353 cover_changed = old_cover != book.cover_info()
354 obsolete_children = set(b for b in book.children.all()
355 if b not in children)
356 notify_cover_changed = []
357 for n, child_book in enumerate(children):
358 new_child = child_book.parent != book
359 child_book.parent = book
360 child_book.parent_number = n
362 if new_child or cover_changed:
363 notify_cover_changed.append(child_book)
364 # Disown unfaithful children and let them cope on their own.
365 for child in obsolete_children:
367 child.parent_number = 0
370 notify_cover_changed.append(child)
372 cls.repopulate_ancestors()
373 tasks.update_counters.delay()
375 if remote_gallery_url:
376 book.download_pictures(remote_gallery_url)
378 # No saves beyond this point.
381 if 'cover' not in dont_build:
382 book.cover.build_delay()
383 book.cover_thumb.build_delay()
385 # Build HTML and ebooks.
386 book.html_file.build_delay()
388 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
389 if format_ not in dont_build:
390 getattr(book, '%s_file' % format_).build_delay()
391 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
392 if format_ not in dont_build:
393 getattr(book, '%s_file' % format_).build_delay()
395 if not settings.NO_SEARCH_INDEX and search_index:
396 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
398 for child in notify_cover_changed:
399 child.parent_cover_changed()
401 book.save() # update sort_key_author
402 cls.published.send(sender=cls, instance=book)
407 def repopulate_ancestors(cls):
408 """Fixes the ancestry cache."""
410 cursor = connection.cursor()
411 if connection.vendor == 'postgres':
412 cursor.execute("TRUNCATE catalogue_book_ancestor")
414 WITH RECURSIVE ancestry AS (
415 SELECT book.id, book.parent_id
416 FROM catalogue_book AS book
417 WHERE book.parent_id IS NOT NULL
419 SELECT ancestor.id, book.parent_id
420 FROM ancestry AS ancestor, catalogue_book AS book
421 WHERE ancestor.parent_id = book.id
422 AND book.parent_id IS NOT NULL
424 INSERT INTO catalogue_book_ancestor
425 (from_book_id, to_book_id)
431 cursor.execute("DELETE FROM catalogue_book_ancestor")
432 for b in cls.objects.exclude(parent=None):
434 while parent is not None:
435 b.ancestor.add(parent)
436 parent = parent.parent
438 def flush_includes(self, languages=True):
441 if languages is True:
442 languages = [lc for (lc, _ln) in settings.LANGUAGES]
444 template % (self.pk, lang)
446 '/katalog/b/%d/mini.%s.html',
447 '/katalog/b/%d/mini_nolink.%s.html',
448 '/katalog/b/%d/short.%s.html',
449 '/katalog/b/%d/wide.%s.html',
450 '/api/include/book/%d.%s.json',
451 '/api/include/book/%d.%s.xml',
453 for lang in languages
456 def cover_info(self, inherit=True):
457 """Returns a dictionary to serve as fallback for BookInfo.
459 For now, the only thing inherited is the cover image.
463 for field in ('cover_url', 'cover_by', 'cover_source'):
464 val = self.extra_info.get(field)
469 if inherit and need and self.parent is not None:
470 parent_info = self.parent.cover_info()
471 parent_info.update(info)
475 def related_themes(self):
476 return Tag.objects.usage_for_queryset(
477 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
478 counts=True).filter(category='theme')
480 def parent_cover_changed(self):
481 """Called when parent book's cover image is changed."""
482 if not self.cover_info(inherit=False):
483 if 'cover' not in app_settings.DONT_BUILD:
484 self.cover.build_delay()
485 self.cover_thumb.build_delay()
486 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
487 if format_ not in app_settings.DONT_BUILD:
488 getattr(self, '%s_file' % format_).build_delay()
489 for child in self.children.all():
490 child.parent_cover_changed()
492 def other_versions(self):
493 """Find other versions (i.e. in other languages) of the book."""
494 return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
499 while parent is not None:
500 books.insert(0, parent)
501 parent = parent.parent
504 def pretty_title(self, html_links=False):
505 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
506 books = self.parents() + [self]
507 names.extend([(b.title, b.get_absolute_url()) for b in books])
510 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
512 names = [tag[0] for tag in names]
513 return ', '.join(names)
516 publisher = self.extra_info['publisher']
517 if isinstance(publisher, basestring):
519 elif isinstance(publisher, list):
520 return ', '.join(publisher)
523 def tagged_top_level(cls, tags):
524 """ Returns top-level books tagged with `tags`.
526 It only returns those books which don't have ancestors which are
527 also tagged with those tags.
530 objects = cls.tagged.with_all(tags)
531 return objects.exclude(ancestor__in=objects)
534 def book_list(cls, book_filter=None):
535 """Generates a hierarchical listing of all books.
537 Books are optionally filtered with a test function.
542 books = cls.objects.order_by('parent_number', 'sort_key').only('title', 'parent', 'slug')
544 books = books.filter(book_filter).distinct()
546 book_ids = set(b['pk'] for b in books.values("pk").iterator())
547 for book in books.iterator():
548 parent = book.parent_id
549 if parent not in book_ids:
551 books_by_parent.setdefault(parent, []).append(book)
553 for book in books.iterator():
554 books_by_parent.setdefault(book.parent_id, []).append(book)
557 books_by_author = OrderedDict()
558 for tag in Tag.objects.filter(category='author').iterator():
559 books_by_author[tag] = []
561 for book in books_by_parent.get(None, ()):
562 authors = list(book.authors().only('pk'))
564 for author in authors:
565 books_by_author[author].append(book)
569 return books_by_author, orphans, books_by_parent
572 "SP": (1, u"szkoła podstawowa"),
573 "SP1": (1, u"szkoła podstawowa"),
574 "SP2": (1, u"szkoła podstawowa"),
575 "P": (1, u"szkoła podstawowa"),
576 "G": (2, u"gimnazjum"),
578 "LP": (3, u"liceum"),
581 def audiences_pl(self):
582 audiences = self.extra_info.get('audiences', [])
583 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
584 return [a[1] for a in audiences]
586 def stage_note(self):
587 stage = self.extra_info.get('stage')
588 if stage and stage < '0.4':
589 return (_('This work needs modernisation'),
590 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
594 def choose_fragment(self):
595 fragments = self.fragments.order_by()
596 fragments_count = fragments.count()
597 if not fragments_count and self.children.exists():
598 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
599 fragments_count = fragments.count()
601 return fragments[randint(0, fragments_count - 1)]
603 return self.parent.choose_fragment()
607 def update_popularity(self):
608 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
610 pop = self.popularity
613 except BookPopularity.DoesNotExist:
614 BookPopularity.objects.create(book=self, count=count)
616 def ridero_link(self):
617 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
620 def add_file_fields():
621 for format_ in Book.formats:
622 field_name = "%s_file" % format_
623 # This weird globals() assignment makes Django migrations comfortable.
624 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
625 _upload_to.__name__ = '_%s_upload_to' % format_
626 globals()[_upload_to.__name__] = _upload_to
629 format_, _("%s file" % format_.upper()),
630 upload_to=_upload_to,
631 storage=bofh_storage,
635 ).contribute_to_class(Book, field_name)
640 class BookPopularity(models.Model):
641 book = models.OneToOneField(Book, related_name='popularity')
642 count = models.IntegerField(default=0)