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)
64 recommended = models.BooleanField(_('recommended'), default=False)
66 # files generated during publication
69 null=True, blank=True,
70 upload_to=_cover_upload_to,
71 storage=bofh_storage, max_length=255)
72 # Cleaner version of cover for thumbs
73 cover_thumb = EbookField(
74 'cover_thumb', _('cover thumbnail'),
75 null=True, blank=True,
76 upload_to=_cover_thumb_upload_to,
78 ebook_formats = constants.EBOOK_FORMATS
79 formats = ebook_formats + ['html', 'xml']
81 parent = models.ForeignKey('self', blank=True, null=True, related_name='children')
82 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
84 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
85 has_audience = models.BooleanField(default=False)
87 objects = models.Manager()
88 tagged = managers.ModelTaggedItemManager(Tag)
89 tags = managers.TagDescriptor(Tag)
90 tag_relations = GenericRelation(Tag.intermediary_table_model)
92 html_built = django.dispatch.Signal()
93 published = django.dispatch.Signal()
95 short_html_url_name = 'catalogue_book_short'
97 class AlreadyExists(Exception):
101 ordering = ('sort_key_author', 'sort_key')
102 verbose_name = _('book')
103 verbose_name_plural = _('books')
104 app_label = 'catalogue'
106 def __unicode__(self):
109 def get_initial(self):
111 return re.search(r'\w', self.title, re.U).group(0)
112 except AttributeError:
116 return self.tags.filter(category='author')
118 def tag_unicode(self, category):
119 relations = prefetched_relations(self, category)
121 return ', '.join(rel.tag.name for rel in relations)
123 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
125 def author_unicode(self):
126 return self.cached_author
128 def translator(self):
129 translators = self.extra_info.get('translators')
132 if len(translators) > 3:
133 translators = translators[:2]
137 return ', '.join(u'\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
139 def save(self, force_insert=False, force_update=False, **kwargs):
140 from sortify import sortify
142 self.sort_key = sortify(self.title)[:120]
143 self.title = unicode(self.title) # ???
146 author = self.authors().first().sort_key
147 except AttributeError:
149 self.sort_key_author = author
151 self.cached_author = self.tag_unicode('author')
152 self.has_audience = 'audience' in self.extra_info
154 ret = super(Book, self).save(force_insert, force_update, **kwargs)
159 def get_absolute_url(self):
160 return 'catalogue.views.book_detail', [self.slug]
164 def create_url(slug):
165 return 'catalogue.views.book_detail', [slug]
167 def gallery_path(self):
168 return gallery_path(self.slug)
170 def gallery_url(self):
171 return gallery_url(self.slug)
177 def language_code(self):
178 return constants.LANGUAGES_3TO2.get(self.language, self.language)
180 def language_name(self):
181 return dict(settings.LANGUAGES).get(self.language_code(), "")
183 def is_foreign(self):
184 return self.language_code() != settings.LANGUAGE_CODE
186 def has_media(self, type_):
187 if type_ in Book.formats:
188 return bool(getattr(self, "%s_file" % type_))
190 return self.media.filter(type=type_).exists()
192 def get_media(self, type_):
193 if self.has_media(type_):
194 if type_ in Book.formats:
195 return getattr(self, "%s_file" % type_)
197 return self.media.filter(type=type_)
202 return self.get_media("mp3")
205 return self.get_media("odt")
208 return self.get_media("ogg")
211 return self.get_media("daisy")
213 def has_description(self):
214 return len(self.description) > 0
215 has_description.short_description = _('description')
216 has_description.boolean = True
219 def has_mp3_file(self):
220 return bool(self.has_media("mp3"))
221 has_mp3_file.short_description = 'MP3'
222 has_mp3_file.boolean = True
224 def has_ogg_file(self):
225 return bool(self.has_media("ogg"))
226 has_ogg_file.short_description = 'OGG'
227 has_ogg_file.boolean = True
229 def has_daisy_file(self):
230 return bool(self.has_media("daisy"))
231 has_daisy_file.short_description = 'DAISY'
232 has_daisy_file.boolean = True
234 def wldocument(self, parse_dublincore=True, inherit=True):
235 from catalogue.import_utils import ORMDocProvider
236 from librarian.parser import WLDocument
238 if inherit and self.parent:
239 meta_fallbacks = self.parent.cover_info()
241 meta_fallbacks = None
243 return WLDocument.from_file(
245 provider=ORMDocProvider(self),
246 parse_dublincore=parse_dublincore,
247 meta_fallbacks=meta_fallbacks)
250 def zip_format(format_):
251 def pretty_file_name(book):
252 return "%s/%s.%s" % (
253 book.extra_info['author'],
257 field_name = "%s_file" % format_
258 books = Book.objects.filter(parent=None).exclude(**{field_name: ""})
259 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
260 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
262 def zip_audiobooks(self, format_):
263 bm = BookMedia.objects.filter(book=self, type=format_)
264 paths = map(lambda bm: (None, bm.file.path), bm)
265 return create_zip(paths, "%s_%s" % (self.slug, format_))
267 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
269 from search.index import Index
272 index.index_book(self, book_info)
278 index.index.rollback()
281 def download_pictures(self, remote_gallery_url):
282 gallery_path = self.gallery_path()
283 # delete previous files, so we don't include old files in ebooks
284 if os.path.isdir(gallery_path):
285 for filename in os.listdir(gallery_path):
286 file_path = os.path.join(gallery_path, filename)
288 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
290 makedirs(gallery_path)
291 for ilustr in ilustr_elements:
292 ilustr_src = ilustr.get('src')
293 ilustr_path = os.path.join(gallery_path, ilustr_src)
294 urllib.urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
297 def from_xml_file(cls, xml_file, **kwargs):
298 from django.core.files import File
299 from librarian import dcparser
301 # use librarian to parse meta-data
302 book_info = dcparser.parse(xml_file)
304 if not isinstance(xml_file, File):
305 xml_file = File(open(xml_file))
308 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
313 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
314 search_index_tags=True, remote_gallery_url=None):
315 if dont_build is None:
317 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
319 # check for parts before we do anything
321 if hasattr(book_info, 'parts'):
322 for part_url in book_info.parts:
324 children.append(Book.objects.get(slug=part_url.slug))
325 except Book.DoesNotExist:
326 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
329 book_slug = book_info.url.slug
330 if re.search(r'[^a-z0-9-]', book_slug):
331 raise ValueError('Invalid characters in slug')
332 book, created = Book.objects.get_or_create(slug=book_slug)
339 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
340 # Save shelves for this book
341 book_shelves = list(book.tags.filter(category='set'))
342 old_cover = book.cover_info()
345 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
347 book.language = book_info.language
348 book.title = book_info.title
349 if book_info.variant_of:
350 book.common_slug = book_info.variant_of.slug
352 book.common_slug = book.slug
353 book.extra_info = book_info.to_dict()
356 meta_tags = Tag.tags_from_info(book_info)
358 book.tags = set(meta_tags + book_shelves)
360 cover_changed = old_cover != book.cover_info()
361 obsolete_children = set(b for b in book.children.all()
362 if b not in children)
363 notify_cover_changed = []
364 for n, child_book in enumerate(children):
365 new_child = child_book.parent != book
366 child_book.parent = book
367 child_book.parent_number = n
369 if new_child or cover_changed:
370 notify_cover_changed.append(child_book)
371 # Disown unfaithful children and let them cope on their own.
372 for child in obsolete_children:
374 child.parent_number = 0
377 notify_cover_changed.append(child)
379 cls.repopulate_ancestors()
380 tasks.update_counters.delay()
382 if remote_gallery_url:
383 book.download_pictures(remote_gallery_url)
385 # No saves beyond this point.
388 if 'cover' not in dont_build:
389 book.cover.build_delay()
390 book.cover_thumb.build_delay()
392 # Build HTML and ebooks.
393 book.html_file.build_delay()
395 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
396 if format_ not in dont_build:
397 getattr(book, '%s_file' % format_).build_delay()
398 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
399 if format_ not in dont_build:
400 getattr(book, '%s_file' % format_).build_delay()
402 if not settings.NO_SEARCH_INDEX and search_index:
403 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
405 for child in notify_cover_changed:
406 child.parent_cover_changed()
408 book.save() # update sort_key_author
409 cls.published.send(sender=cls, instance=book)
414 def repopulate_ancestors(cls):
415 """Fixes the ancestry cache."""
417 cursor = connection.cursor()
418 if connection.vendor == 'postgres':
419 cursor.execute("TRUNCATE catalogue_book_ancestor")
421 WITH RECURSIVE ancestry AS (
422 SELECT book.id, book.parent_id
423 FROM catalogue_book AS book
424 WHERE book.parent_id IS NOT NULL
426 SELECT ancestor.id, book.parent_id
427 FROM ancestry AS ancestor, catalogue_book AS book
428 WHERE ancestor.parent_id = book.id
429 AND book.parent_id IS NOT NULL
431 INSERT INTO catalogue_book_ancestor
432 (from_book_id, to_book_id)
438 cursor.execute("DELETE FROM catalogue_book_ancestor")
439 for b in cls.objects.exclude(parent=None):
441 while parent is not None:
442 b.ancestor.add(parent)
443 parent = parent.parent
445 def flush_includes(self, languages=True):
448 if languages is True:
449 languages = [lc for (lc, _ln) in settings.LANGUAGES]
451 template % (self.pk, lang)
453 '/katalog/b/%d/mini.%s.html',
454 '/katalog/b/%d/mini_nolink.%s.html',
455 '/katalog/b/%d/short.%s.html',
456 '/katalog/b/%d/wide.%s.html',
457 '/api/include/book/%d.%s.json',
458 '/api/include/book/%d.%s.xml',
460 for lang in languages
463 def cover_info(self, inherit=True):
464 """Returns a dictionary to serve as fallback for BookInfo.
466 For now, the only thing inherited is the cover image.
470 for field in ('cover_url', 'cover_by', 'cover_source'):
471 val = self.extra_info.get(field)
476 if inherit and need and self.parent is not None:
477 parent_info = self.parent.cover_info()
478 parent_info.update(info)
482 def related_themes(self):
483 return Tag.objects.usage_for_queryset(
484 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
485 counts=True).filter(category='theme')
487 def parent_cover_changed(self):
488 """Called when parent book's cover image is changed."""
489 if not self.cover_info(inherit=False):
490 if 'cover' not in app_settings.DONT_BUILD:
491 self.cover.build_delay()
492 self.cover_thumb.build_delay()
493 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
494 if format_ not in app_settings.DONT_BUILD:
495 getattr(self, '%s_file' % format_).build_delay()
496 for child in self.children.all():
497 child.parent_cover_changed()
499 def other_versions(self):
500 """Find other versions (i.e. in other languages) of the book."""
501 return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
506 while parent is not None:
507 books.insert(0, parent)
508 parent = parent.parent
511 def pretty_title(self, html_links=False):
512 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
513 books = self.parents() + [self]
514 names.extend([(b.title, b.get_absolute_url()) for b in books])
517 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
519 names = [tag[0] for tag in names]
520 return ', '.join(names)
523 publisher = self.extra_info['publisher']
524 if isinstance(publisher, basestring):
526 elif isinstance(publisher, list):
527 return ', '.join(publisher)
530 def tagged_top_level(cls, tags):
531 """ Returns top-level books tagged with `tags`.
533 It only returns those books which don't have ancestors which are
534 also tagged with those tags.
537 objects = cls.tagged.with_all(tags)
538 return objects.exclude(ancestor__in=objects)
541 def book_list(cls, book_filter=None):
542 """Generates a hierarchical listing of all books.
544 Books are optionally filtered with a test function.
549 books = cls.objects.order_by('parent_number', 'sort_key').only('title', 'parent', 'slug')
551 books = books.filter(book_filter).distinct()
553 book_ids = set(b['pk'] for b in books.values("pk").iterator())
554 for book in books.iterator():
555 parent = book.parent_id
556 if parent not in book_ids:
558 books_by_parent.setdefault(parent, []).append(book)
560 for book in books.iterator():
561 books_by_parent.setdefault(book.parent_id, []).append(book)
564 books_by_author = OrderedDict()
565 for tag in Tag.objects.filter(category='author').iterator():
566 books_by_author[tag] = []
568 for book in books_by_parent.get(None, ()):
569 authors = list(book.authors().only('pk'))
571 for author in authors:
572 books_by_author[author].append(book)
576 return books_by_author, orphans, books_by_parent
579 "SP": (1, u"szkoła podstawowa"),
580 "SP1": (1, u"szkoła podstawowa"),
581 "SP2": (1, u"szkoła podstawowa"),
582 "SP3": (1, u"szkoła podstawowa"),
583 "P": (1, u"szkoła podstawowa"),
584 "G": (2, u"gimnazjum"),
586 "LP": (3, u"liceum"),
589 def audiences_pl(self):
590 audiences = self.extra_info.get('audiences', [])
591 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
592 return [a[1] for a in audiences]
594 def stage_note(self):
595 stage = self.extra_info.get('stage')
596 if stage and stage < '0.4':
597 return (_('This work needs modernisation'),
598 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
602 def choose_fragment(self):
603 fragments = self.fragments.order_by()
604 fragments_count = fragments.count()
605 if not fragments_count and self.children.exists():
606 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
607 fragments_count = fragments.count()
609 return fragments[randint(0, fragments_count - 1)]
611 return self.parent.choose_fragment()
615 def update_popularity(self):
616 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
618 pop = self.popularity
621 except BookPopularity.DoesNotExist:
622 BookPopularity.objects.create(book=self, count=count)
624 def ridero_link(self):
625 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
628 def add_file_fields():
629 for format_ in Book.formats:
630 field_name = "%s_file" % format_
631 # This weird globals() assignment makes Django migrations comfortable.
632 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
633 _upload_to.__name__ = '_%s_upload_to' % format_
634 globals()[_upload_to.__name__] = _upload_to
637 format_, _("%s file" % format_.upper()),
638 upload_to=_upload_to,
639 storage=bofh_storage,
643 ).contribute_to_class(Book, field_name)
648 class BookPopularity(models.Model):
649 book = models.OneToOneField(Book, related_name='popularity')
650 count = models.IntegerField(default=0)