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 save(self, force_insert=False, force_update=False, **kwargs):
125 from sortify import sortify
127 self.sort_key = sortify(self.title)[:120]
128 self.title = unicode(self.title) # ???
131 author = self.authors().first().sort_key
132 except AttributeError:
134 self.sort_key_author = author
136 ret = super(Book, self).save(force_insert, force_update, **kwargs)
141 def get_absolute_url(self):
142 return 'catalogue.views.book_detail', [self.slug]
146 def create_url(slug):
147 return 'catalogue.views.book_detail', [slug]
149 def gallery_path(self):
150 return gallery_path(self.slug)
152 def gallery_url(self):
153 return gallery_url(self.slug)
159 def language_code(self):
160 return constants.LANGUAGES_3TO2.get(self.language, self.language)
162 def language_name(self):
163 return dict(settings.LANGUAGES).get(self.language_code(), "")
165 def is_foreign(self):
166 return self.language_code() != settings.LANGUAGE_CODE
168 def has_media(self, type_):
169 if type_ in Book.formats:
170 return bool(getattr(self, "%s_file" % type_))
172 return self.media.filter(type=type_).exists()
174 def get_media(self, type_):
175 if self.has_media(type_):
176 if type_ in Book.formats:
177 return getattr(self, "%s_file" % type_)
179 return self.media.filter(type=type_)
184 return self.get_media("mp3")
187 return self.get_media("odt")
190 return self.get_media("ogg")
193 return self.get_media("daisy")
195 def has_description(self):
196 return len(self.description) > 0
197 has_description.short_description = _('description')
198 has_description.boolean = True
201 def has_mp3_file(self):
202 return bool(self.has_media("mp3"))
203 has_mp3_file.short_description = 'MP3'
204 has_mp3_file.boolean = True
206 def has_ogg_file(self):
207 return bool(self.has_media("ogg"))
208 has_ogg_file.short_description = 'OGG'
209 has_ogg_file.boolean = True
211 def has_daisy_file(self):
212 return bool(self.has_media("daisy"))
213 has_daisy_file.short_description = 'DAISY'
214 has_daisy_file.boolean = True
216 def wldocument(self, parse_dublincore=True, inherit=True):
217 from catalogue.import_utils import ORMDocProvider
218 from librarian.parser import WLDocument
220 if inherit and self.parent:
221 meta_fallbacks = self.parent.cover_info()
223 meta_fallbacks = None
225 return WLDocument.from_file(
227 provider=ORMDocProvider(self),
228 parse_dublincore=parse_dublincore,
229 meta_fallbacks=meta_fallbacks)
232 def zip_format(format_):
233 def pretty_file_name(book):
234 return "%s/%s.%s" % (
235 book.extra_info['author'],
239 field_name = "%s_file" % format_
240 books = Book.objects.filter(parent=None).exclude(**{field_name: ""})
241 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
242 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
244 def zip_audiobooks(self, format_):
245 bm = BookMedia.objects.filter(book=self, type=format_)
246 paths = map(lambda bm: (None, bm.file.path), bm)
247 return create_zip(paths, "%s_%s" % (self.slug, format_))
249 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
251 from search.index import Index
254 index.index_book(self, book_info)
260 index.index.rollback()
263 def download_pictures(self, remote_gallery_url):
264 gallery_path = self.gallery_path()
265 # delete previous files, so we don't include old files in ebooks
266 if os.path.isdir(gallery_path):
267 for filename in os.listdir(gallery_path):
268 file_path = os.path.join(gallery_path, filename)
270 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
272 makedirs(gallery_path)
273 for ilustr in ilustr_elements:
274 ilustr_src = ilustr.get('src')
275 ilustr_path = os.path.join(gallery_path, ilustr_src)
276 urllib.urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
279 def from_xml_file(cls, xml_file, **kwargs):
280 from django.core.files import File
281 from librarian import dcparser
283 # use librarian to parse meta-data
284 book_info = dcparser.parse(xml_file)
286 if not isinstance(xml_file, File):
287 xml_file = File(open(xml_file))
290 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
295 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
296 search_index_tags=True, remote_gallery_url=None):
297 if dont_build is None:
299 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
301 # check for parts before we do anything
303 if hasattr(book_info, 'parts'):
304 for part_url in book_info.parts:
306 children.append(Book.objects.get(slug=part_url.slug))
307 except Book.DoesNotExist:
308 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
311 book_slug = book_info.url.slug
312 if re.search(r'[^a-z0-9-]', book_slug):
313 raise ValueError('Invalid characters in slug')
314 book, created = Book.objects.get_or_create(slug=book_slug)
321 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
322 # Save shelves for this book
323 book_shelves = list(book.tags.filter(category='set'))
324 old_cover = book.cover_info()
327 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
329 book.language = book_info.language
330 book.title = book_info.title
331 if book_info.variant_of:
332 book.common_slug = book_info.variant_of.slug
334 book.common_slug = book.slug
335 book.extra_info = book_info.to_dict()
338 meta_tags = Tag.tags_from_info(book_info)
340 book.tags = set(meta_tags + book_shelves)
342 cover_changed = old_cover != book.cover_info()
343 obsolete_children = set(b for b in book.children.all()
344 if b not in children)
345 notify_cover_changed = []
346 for n, child_book in enumerate(children):
347 new_child = child_book.parent != book
348 child_book.parent = book
349 child_book.parent_number = n
351 if new_child or cover_changed:
352 notify_cover_changed.append(child_book)
353 # Disown unfaithful children and let them cope on their own.
354 for child in obsolete_children:
356 child.parent_number = 0
359 notify_cover_changed.append(child)
361 cls.repopulate_ancestors()
362 tasks.update_counters.delay()
364 if remote_gallery_url:
365 book.download_pictures(remote_gallery_url)
367 # No saves beyond this point.
370 if 'cover' not in dont_build:
371 book.cover.build_delay()
372 book.cover_thumb.build_delay()
374 # Build HTML and ebooks.
375 book.html_file.build_delay()
377 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
378 if format_ not in dont_build:
379 getattr(book, '%s_file' % format_).build_delay()
380 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
381 if format_ not in dont_build:
382 getattr(book, '%s_file' % format_).build_delay()
384 if not settings.NO_SEARCH_INDEX and search_index:
385 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
387 for child in notify_cover_changed:
388 child.parent_cover_changed()
390 book.save() # update sort_key_author
391 cls.published.send(sender=cls, instance=book)
396 def repopulate_ancestors(cls):
397 """Fixes the ancestry cache."""
399 cursor = connection.cursor()
400 if connection.vendor == 'postgres':
401 cursor.execute("TRUNCATE catalogue_book_ancestor")
403 WITH RECURSIVE ancestry AS (
404 SELECT book.id, book.parent_id
405 FROM catalogue_book AS book
406 WHERE book.parent_id IS NOT NULL
408 SELECT ancestor.id, book.parent_id
409 FROM ancestry AS ancestor, catalogue_book AS book
410 WHERE ancestor.parent_id = book.id
411 AND book.parent_id IS NOT NULL
413 INSERT INTO catalogue_book_ancestor
414 (from_book_id, to_book_id)
420 cursor.execute("DELETE FROM catalogue_book_ancestor")
421 for b in cls.objects.exclude(parent=None):
423 while parent is not None:
424 b.ancestor.add(parent)
425 parent = parent.parent
427 def flush_includes(self, languages=True):
430 if languages is True:
431 languages = [lc for (lc, _ln) in settings.LANGUAGES]
433 template % (self.pk, lang)
435 '/katalog/b/%d/mini.%s.html',
436 '/katalog/b/%d/mini_nolink.%s.html',
437 '/katalog/b/%d/short.%s.html',
438 '/katalog/b/%d/wide.%s.html',
439 '/api/include/book/%d.%s.json',
440 '/api/include/book/%d.%s.xml',
442 for lang in languages
445 def cover_info(self, inherit=True):
446 """Returns a dictionary to serve as fallback for BookInfo.
448 For now, the only thing inherited is the cover image.
452 for field in ('cover_url', 'cover_by', 'cover_source'):
453 val = self.extra_info.get(field)
458 if inherit and need and self.parent is not None:
459 parent_info = self.parent.cover_info()
460 parent_info.update(info)
464 def related_themes(self):
465 return Tag.objects.usage_for_queryset(
466 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
467 counts=True).filter(category='theme')
469 def parent_cover_changed(self):
470 """Called when parent book's cover image is changed."""
471 if not self.cover_info(inherit=False):
472 if 'cover' not in app_settings.DONT_BUILD:
473 self.cover.build_delay()
474 self.cover_thumb.build_delay()
475 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
476 if format_ not in app_settings.DONT_BUILD:
477 getattr(self, '%s_file' % format_).build_delay()
478 for child in self.children.all():
479 child.parent_cover_changed()
481 def other_versions(self):
482 """Find other versions (i.e. in other languages) of the book."""
483 return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
488 while parent is not None:
489 books.insert(0, parent)
490 parent = parent.parent
493 def pretty_title(self, html_links=False):
494 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
495 books = self.parents() + [self]
496 names.extend([(b.title, b.get_absolute_url()) for b in books])
499 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
501 names = [tag[0] for tag in names]
502 return ', '.join(names)
505 publisher = self.extra_info['publisher']
506 if isinstance(publisher, basestring):
508 elif isinstance(publisher, list):
509 return ', '.join(publisher)
512 def tagged_top_level(cls, tags):
513 """ Returns top-level books tagged with `tags`.
515 It only returns those books which don't have ancestors which are
516 also tagged with those tags.
519 objects = cls.tagged.with_all(tags)
520 return objects.exclude(ancestor__in=objects)
523 def book_list(cls, book_filter=None):
524 """Generates a hierarchical listing of all books.
526 Books are optionally filtered with a test function.
531 books = cls.objects.order_by('parent_number', 'sort_key').only('title', 'parent', 'slug')
533 books = books.filter(book_filter).distinct()
535 book_ids = set(b['pk'] for b in books.values("pk").iterator())
536 for book in books.iterator():
537 parent = book.parent_id
538 if parent not in book_ids:
540 books_by_parent.setdefault(parent, []).append(book)
542 for book in books.iterator():
543 books_by_parent.setdefault(book.parent_id, []).append(book)
546 books_by_author = OrderedDict()
547 for tag in Tag.objects.filter(category='author').iterator():
548 books_by_author[tag] = []
550 for book in books_by_parent.get(None, ()):
551 authors = list(book.authors().only('pk'))
553 for author in authors:
554 books_by_author[author].append(book)
558 return books_by_author, orphans, books_by_parent
561 "SP": (1, u"szkoła podstawowa"),
562 "SP1": (1, u"szkoła podstawowa"),
563 "SP2": (1, u"szkoła podstawowa"),
564 "P": (1, u"szkoła podstawowa"),
565 "G": (2, u"gimnazjum"),
567 "LP": (3, u"liceum"),
570 def audiences_pl(self):
571 audiences = self.extra_info.get('audiences', [])
572 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
573 return [a[1] for a in audiences]
575 def stage_note(self):
576 stage = self.extra_info.get('stage')
577 if stage and stage < '0.4':
578 return (_('This work needs modernisation'),
579 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
583 def choose_fragment(self):
584 fragments = self.fragments.order_by()
585 fragments_count = fragments.count()
586 if not fragments_count and self.children.exists():
587 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
588 fragments_count = fragments.count()
590 return fragments[randint(0, fragments_count - 1)]
592 return self.parent.choose_fragment()
596 def update_popularity(self):
597 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
599 pop = self.popularity
602 except BookPopularity.DoesNotExist:
603 BookPopularity.objects.create(book=self, count=count)
605 def ridero_link(self):
606 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
609 def add_file_fields():
610 for format_ in Book.formats:
611 field_name = "%s_file" % format_
612 # This weird globals() assignment makes Django migrations comfortable.
613 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
614 _upload_to.__name__ = '_%s_upload_to' % format_
615 globals()[_upload_to.__name__] = _upload_to
618 format_, _("%s file" % format_.upper()),
619 upload_to=_upload_to,
620 storage=bofh_storage,
624 ).contribute_to_class(Book, field_name)
629 class BookPopularity(models.Model):
630 book = models.OneToOneField(Book, related_name='popularity')
631 count = models.IntegerField(default=0)