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 namedtuple
7 from django.db import models
8 from django.db.models import permalink
10 from django.core.cache import get_cache
11 from django.utils.translation import ugettext_lazy as _
12 from django.contrib.auth.models import User
13 from django.template.loader import render_to_string
14 from django.utils.datastructures import SortedDict
15 from django.utils.safestring import mark_safe
16 from django.utils.translation import get_language
17 from django.core.urlresolvers import reverse
18 from django.db.models.signals import post_save, pre_delete, post_delete
21 from django.conf import settings
23 from newtagging.models import TagBase, tags_updated
24 from newtagging import managers
25 from catalogue.fields import OverwritingFileField
26 from catalogue.utils import create_zip, split_tags, truncate_html_words
27 from catalogue import tasks
31 # Those are hard-coded here so that makemessages sees them.
33 ('author', _('author')),
34 ('epoch', _('epoch')),
36 ('genre', _('genre')),
37 ('theme', _('theme')),
43 permanent_cache = get_cache('permanent')
46 class TagSubcategoryManager(models.Manager):
47 def __init__(self, subcategory):
48 super(TagSubcategoryManager, self).__init__()
49 self.subcategory = subcategory
51 def get_query_set(self):
52 return super(TagSubcategoryManager, self).get_query_set().filter(category=self.subcategory)
56 """A tag attachable to books and fragments (and possibly anything).
58 Used to represent searchable metadata (authors, epochs, genres, kinds),
59 fragment themes (motifs) and some book hierarchy related kludges."""
60 name = models.CharField(_('name'), max_length=50, db_index=True)
61 slug = models.SlugField(_('slug'), max_length=120, db_index=True)
62 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True)
63 category = models.CharField(_('category'), max_length=50, blank=False, null=False,
64 db_index=True, choices=TAG_CATEGORIES)
65 description = models.TextField(_('description'), blank=True)
67 user = models.ForeignKey(User, blank=True, null=True)
68 book_count = models.IntegerField(_('book count'), blank=True, null=True)
69 gazeta_link = models.CharField(blank=True, max_length=240)
70 wiki_link = models.CharField(blank=True, max_length=240)
72 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
73 changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
75 class UrlDeprecationWarning(DeprecationWarning):
86 categories_dict = dict((item[::-1] for item in categories_rev.iteritems()))
89 ordering = ('sort_key',)
90 verbose_name = _('tag')
91 verbose_name_plural = _('tags')
92 unique_together = (("slug", "category"),)
94 def __unicode__(self):
98 return "Tag(slug=%r)" % self.slug
101 def get_absolute_url(self):
102 return ('catalogue.views.tagged_object_list', [self.url_chunk])
104 def has_description(self):
105 return len(self.description) > 0
106 has_description.short_description = _('description')
107 has_description.boolean = True
110 """Returns global book count for book tags, fragment count for themes."""
112 if self.category == 'book':
114 objects = Book.objects.none()
115 elif self.category == 'theme':
116 objects = Fragment.tagged.with_all((self,))
118 objects = Book.tagged.with_all((self,)).order_by()
119 if self.category != 'set':
120 # eliminate descendants
121 l_tags = Tag.objects.filter(slug__in=[book.book_tag_slug() for book in objects.iterator()])
122 descendants_keys = [book.pk for book in Book.tagged.with_any(l_tags).iterator()]
124 objects = objects.exclude(pk__in=descendants_keys)
125 return objects.count()
128 def get_tag_list(tags):
129 if isinstance(tags, basestring):
134 tags_splitted = tags.split('/')
135 for name in tags_splitted:
137 real_tags.append(Tag.objects.get(slug=name, category=category))
139 elif name in Tag.categories_rev:
140 category = Tag.categories_rev[name]
143 real_tags.append(Tag.objects.exclude(category='book').get(slug=name))
145 except Tag.MultipleObjectsReturned, e:
146 ambiguous_slugs.append(name)
149 # something strange left off
150 raise Tag.DoesNotExist()
152 # some tags should be qualified
153 e = Tag.MultipleObjectsReturned()
155 e.ambiguous_slugs = ambiguous_slugs
158 e = Tag.UrlDeprecationWarning()
163 return TagBase.get_tag_list(tags)
167 return '/'.join((Tag.categories_dict[self.category], self.slug))
170 def tags_from_info(info):
171 from slughifi import slughifi
172 from sortify import sortify
174 categories = (('kinds', 'kind'), ('genres', 'genre'), ('authors', 'author'), ('epochs', 'epoch'))
175 for field_name, category in categories:
177 tag_names = getattr(info, field_name)
180 tag_names = [getattr(info, category)]
182 # For instance, Pictures do not have 'genre' field.
184 for tag_name in tag_names:
185 tag_sort_key = tag_name
186 if category == 'author':
187 tag_sort_key = tag_name.last_name
188 tag_name = tag_name.readable()
189 tag, created = Tag.objects.get_or_create(slug=slughifi(tag_name), category=category)
192 tag.sort_key = sortify(tag_sort_key.lower())
194 meta_tags.append(tag)
199 def get_dynamic_path(media, filename, ext=None, maxlen=100):
200 from slughifi import slughifi
202 # how to put related book's slug here?
205 ext = media.formats[media.type].ext
206 if media is None or not media.name:
207 name = slughifi(filename.split(".")[0])
209 name = slughifi(media.name)
210 return 'book/%s/%s.%s' % (ext, name[:maxlen-len('book/%s/.%s' % (ext, ext))-4], ext)
213 # TODO: why is this hard-coded ?
214 def book_upload_path(ext=None, maxlen=100):
215 return lambda *args: get_dynamic_path(*args, ext=ext, maxlen=maxlen)
218 class BookMedia(models.Model):
219 """Represents media attached to a book."""
220 FileFormat = namedtuple("FileFormat", "name ext")
221 formats = SortedDict([
222 ('mp3', FileFormat(name='MP3', ext='mp3')),
223 ('ogg', FileFormat(name='Ogg Vorbis', ext='ogg')),
224 ('daisy', FileFormat(name='DAISY', ext='daisy.zip')),
226 format_choices = [(k, _('%s file') % t.name)
227 for k, t in formats.items()]
229 type = models.CharField(_('type'), choices=format_choices, max_length="100")
230 name = models.CharField(_('name'), max_length="100")
231 file = OverwritingFileField(_('file'), upload_to=book_upload_path())
232 uploaded_at = models.DateTimeField(_('creation date'), auto_now_add=True, editable=False)
233 extra_info = jsonfield.JSONField(_('extra information'), default='{}', editable=False)
234 book = models.ForeignKey('Book', related_name='media')
235 source_sha1 = models.CharField(null=True, blank=True, max_length=40, editable=False)
237 def __unicode__(self):
238 return "%s (%s)" % (self.name, self.file.name.split("/")[-1])
241 ordering = ('type', 'name')
242 verbose_name = _('book media')
243 verbose_name_plural = _('book media')
245 def save(self, *args, **kwargs):
246 from slughifi import slughifi
247 from catalogue.utils import ExistingFile, remove_zip
250 old = BookMedia.objects.get(pk=self.pk)
251 except BookMedia.DoesNotExist:
254 # if name changed, change the file name, too
255 if slughifi(self.name) != slughifi(old.name):
256 self.file.save(None, ExistingFile(self.file.path), save=False, leave=True)
258 super(BookMedia, self).save(*args, **kwargs)
260 # remove the zip package for book with modified media
262 remove_zip("%s_%s" % (old.book.slug, old.type))
263 remove_zip("%s_%s" % (self.book.slug, self.type))
265 extra_info = self.extra_info
266 extra_info.update(self.read_meta())
267 self.extra_info = extra_info
268 self.source_sha1 = self.read_source_sha1(self.file.path, self.type)
269 return super(BookMedia, self).save(*args, **kwargs)
273 Reads some metadata from the audiobook.
276 from mutagen import id3
278 artist_name = director_name = project = funded_by = ''
279 if self.type == 'mp3':
281 audio = id3.ID3(self.file.path)
282 artist_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE1'))
283 director_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE3'))
284 project = ", ".join([t.data for t in audio.getall('PRIV')
285 if t.owner=='wolnelektury.pl?project'])
286 funded_by = ", ".join([t.data for t in audio.getall('PRIV')
287 if t.owner=='wolnelektury.pl?funded_by'])
290 elif self.type == 'ogg':
292 audio = mutagen.File(self.file.path)
293 artist_name = ', '.join(audio.get('artist', []))
294 director_name = ', '.join(audio.get('conductor', []))
295 project = ", ".join(audio.get('project', []))
296 funded_by = ", ".join(audio.get('funded_by', []))
301 return {'artist_name': artist_name, 'director_name': director_name,
302 'project': project, 'funded_by': funded_by}
305 def read_source_sha1(filepath, filetype):
307 Reads source file SHA1 from audiobok metadata.
310 from mutagen import id3
312 if filetype == 'mp3':
314 audio = id3.ID3(filepath)
315 return [t.data for t in audio.getall('PRIV')
316 if t.owner=='wolnelektury.pl?flac_sha1'][0]
319 elif filetype == 'ogg':
321 audio = mutagen.File(filepath)
322 return audio.get('flac_sha1', [None])[0]
329 class Book(models.Model):
330 """Represents a book imported from WL-XML."""
331 title = models.CharField(_('title'), max_length=120)
332 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
333 slug = models.SlugField(_('slug'), max_length=120, db_index=True,
335 common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
336 language = models.CharField(_('language code'), max_length=3, db_index=True,
337 default=settings.CATALOGUE_DEFAULT_LANGUAGE)
338 description = models.TextField(_('description'), blank=True)
339 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
340 changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
341 parent_number = models.IntegerField(_('parent number'), default=0)
342 extra_info = jsonfield.JSONField(_('extra information'), default='{}')
343 gazeta_link = models.CharField(blank=True, max_length=240)
344 wiki_link = models.CharField(blank=True, max_length=240)
345 # files generated during publication
347 cover = models.FileField(_('cover'), upload_to=book_upload_path('png'),
348 null=True, blank=True)
349 ebook_formats = ['pdf', 'epub', 'mobi', 'txt']
350 formats = ebook_formats + ['html', 'xml']
352 parent = models.ForeignKey('self', blank=True, null=True, related_name='children')
354 _related_info = jsonfield.JSONField(blank=True, null=True, editable=False)
356 objects = models.Manager()
357 tagged = managers.ModelTaggedItemManager(Tag)
358 tags = managers.TagDescriptor(Tag)
360 html_built = django.dispatch.Signal()
361 published = django.dispatch.Signal()
363 class AlreadyExists(Exception):
367 ordering = ('sort_key',)
368 verbose_name = _('book')
369 verbose_name_plural = _('books')
371 def __unicode__(self):
374 def save(self, force_insert=False, force_update=False, reset_short_html=True, **kwargs):
375 from sortify import sortify
377 self.sort_key = sortify(self.title)
379 ret = super(Book, self).save(force_insert, force_update)
382 self.reset_short_html()
387 def get_absolute_url(self):
388 return ('catalogue.views.book_detail', [self.slug])
394 def book_tag_slug(self):
395 return ('l-' + self.slug)[:120]
398 slug = self.book_tag_slug()
399 book_tag, created = Tag.objects.get_or_create(slug=slug, category='book')
401 book_tag.name = self.title[:50]
402 book_tag.sort_key = self.title.lower()
406 def has_media(self, type_):
407 if type_ in Book.formats:
408 return bool(getattr(self, "%s_file" % type_))
410 return self.media.filter(type=type_).exists()
412 def get_media(self, type_):
413 if self.has_media(type_):
414 if type_ in Book.formats:
415 return getattr(self, "%s_file" % type_)
417 return self.media.filter(type=type_)
422 return self.get_media("mp3")
424 return self.get_media("odt")
426 return self.get_media("ogg")
428 return self.get_media("daisy")
430 def reset_short_html(self):
434 type(self).objects.filter(pk=self.pk).update(_related_info=None)
435 # Fragment.short_html relies on book's tags, so reset it here too
436 for fragm in self.fragments.all().iterator():
437 fragm.reset_short_html()
439 def has_description(self):
440 return len(self.description) > 0
441 has_description.short_description = _('description')
442 has_description.boolean = True
445 def has_mp3_file(self):
446 return bool(self.has_media("mp3"))
447 has_mp3_file.short_description = 'MP3'
448 has_mp3_file.boolean = True
450 def has_ogg_file(self):
451 return bool(self.has_media("ogg"))
452 has_ogg_file.short_description = 'OGG'
453 has_ogg_file.boolean = True
455 def has_daisy_file(self):
456 return bool(self.has_media("daisy"))
457 has_daisy_file.short_description = 'DAISY'
458 has_daisy_file.boolean = True
460 def wldocument(self, parse_dublincore=True):
461 from catalogue.import_utils import ORMDocProvider
462 from librarian.parser import WLDocument
464 return WLDocument.from_file(self.xml_file.path,
465 provider=ORMDocProvider(self),
466 parse_dublincore=parse_dublincore)
468 def build_cover(self, book_info=None):
469 """(Re)builds the cover image."""
470 from StringIO import StringIO
471 from django.core.files.base import ContentFile
472 from librarian.cover import WLCover
474 if book_info is None:
475 book_info = self.wldocument().book_info
477 cover = WLCover(book_info).image()
479 cover.save(imgstr, 'png')
480 self.cover.save(None, ContentFile(imgstr.getvalue()))
482 def build_html(self):
483 from django.core.files.base import ContentFile
484 from slughifi import slughifi
485 from librarian import html
487 meta_tags = list(self.tags.filter(
488 category__in=('author', 'epoch', 'genre', 'kind')))
489 book_tag = self.book_tag()
491 html_output = self.wldocument(parse_dublincore=False).as_html()
493 self.html_file.save('%s.html' % self.slug,
494 ContentFile(html_output.get_string()))
496 # get ancestor l-tags for adding to new fragments
500 ancestor_tags.append(p.book_tag())
503 # Delete old fragments and create them from scratch
504 self.fragments.all().delete()
506 closed_fragments, open_fragments = html.extract_fragments(self.html_file.path)
507 for fragment in closed_fragments.values():
509 theme_names = [s.strip() for s in fragment.themes.split(',')]
510 except AttributeError:
513 for theme_name in theme_names:
516 tag, created = Tag.objects.get_or_create(slug=slughifi(theme_name), category='theme')
518 tag.name = theme_name
519 tag.sort_key = theme_name.lower()
525 text = fragment.to_string()
526 short_text = truncate_html_words(text, 15)
527 if text == short_text:
529 new_fragment = Fragment.objects.create(anchor=fragment.id, book=self,
530 text=text, short_text=short_text)
533 new_fragment.tags = set(meta_tags + themes + [book_tag] + ancestor_tags)
535 self.html_built.send(sender=self)
539 # Thin wrappers for builder tasks
540 def build_pdf(self, *args, **kwargs):
541 """(Re)builds PDF."""
542 return tasks.build_pdf.delay(self.pk, *args, **kwargs)
543 def build_epub(self, *args, **kwargs):
544 """(Re)builds EPUB."""
545 return tasks.build_epub.delay(self.pk, *args, **kwargs)
546 def build_mobi(self, *args, **kwargs):
547 """(Re)builds MOBI."""
548 return tasks.build_mobi.delay(self.pk, *args, **kwargs)
549 def build_txt(self, *args, **kwargs):
550 """(Re)builds TXT."""
551 return tasks.build_txt.delay(self.pk, *args, **kwargs)
554 def zip_format(format_):
555 def pretty_file_name(book):
556 return "%s/%s.%s" % (
557 b.extra_info['author'],
561 field_name = "%s_file" % format_
562 books = Book.objects.filter(parent=None).exclude(**{field_name: ""})
563 paths = [(pretty_file_name(b), getattr(b, field_name).path)
564 for b in books.iterator()]
565 return create_zip(paths,
566 getattr(settings, "ALL_%s_ZIP" % format_.upper()))
568 def zip_audiobooks(self, format_):
569 bm = BookMedia.objects.filter(book=self, type=format_)
570 paths = map(lambda bm: (None, bm.file.path), bm)
571 return create_zip(paths, "%s_%s" % (self.slug, format_))
573 def search_index(self, book_info=None, reuse_index=False, index_tags=True):
576 idx = search.ReusableIndex()
582 idx.index_book(self, book_info)
589 def from_xml_file(cls, xml_file, **kwargs):
590 from django.core.files import File
591 from librarian import dcparser
593 # use librarian to parse meta-data
594 book_info = dcparser.parse(xml_file)
596 if not isinstance(xml_file, File):
597 xml_file = File(open(xml_file))
600 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
605 def from_text_and_meta(cls, raw_file, book_info, overwrite=False,
606 build_epub=True, build_txt=True, build_pdf=True, build_mobi=True,
607 search_index=True, search_index_tags=True, search_index_reuse=False):
609 # check for parts before we do anything
611 if hasattr(book_info, 'parts'):
612 for part_url in book_info.parts:
614 children.append(Book.objects.get(slug=part_url.slug))
615 except Book.DoesNotExist:
616 raise Book.DoesNotExist(_('Book "%s" does not exist.') %
621 book_slug = book_info.url.slug
622 if re.search(r'[^a-z0-9-]', book_slug):
623 raise ValueError('Invalid characters in slug')
624 book, created = Book.objects.get_or_create(slug=book_slug)
630 raise Book.AlreadyExists(_('Book %s already exists') % (
632 # Save shelves for this book
633 book_shelves = list(book.tags.filter(category='set'))
635 book.language = book_info.language
636 book.title = book_info.title
637 if book_info.variant_of:
638 book.common_slug = book_info.variant_of.slug
640 book.common_slug = book.slug
641 book.extra_info = book_info.to_dict()
644 meta_tags = Tag.tags_from_info(book_info)
646 book.tags = set(meta_tags + book_shelves)
648 book_tag = book.book_tag()
650 for n, child_book in enumerate(children):
651 child_book.parent = book
652 child_book.parent_number = n
655 # Save XML and HTML files
656 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
658 # delete old fragments when overwriting
659 book.fragments.all().delete()
661 if book.build_html():
662 if not settings.NO_BUILD_TXT and build_txt:
665 book.build_cover(book_info)
667 if not settings.NO_BUILD_EPUB and build_epub:
670 if not settings.NO_BUILD_PDF and build_pdf:
673 if not settings.NO_BUILD_MOBI and build_mobi:
676 if not settings.NO_SEARCH_INDEX and search_index:
677 book.search_index(index_tags=search_index_tags, reuse_index=search_index_reuse)
678 #index_book.delay(book.id, book_info)
680 book_descendants = list(book.children.all())
681 descendants_tags = set()
682 # add l-tag to descendants and their fragments
683 while len(book_descendants) > 0:
684 child_book = book_descendants.pop(0)
685 descendants_tags.update(child_book.tags)
686 child_book.tags = list(child_book.tags) + [book_tag]
688 for fragment in child_book.fragments.all().iterator():
689 fragment.tags = set(list(fragment.tags) + [book_tag])
690 book_descendants += list(child_book.children.all())
692 for tag in descendants_tags:
698 book.reset_tag_counter()
699 book.reset_theme_counter()
701 cls.published.send(sender=book)
704 def related_info(self):
705 """Keeps info about related objects (tags, media) in cache field."""
706 if self._related_info is not None:
707 return self._related_info
709 rel = {'tags': {}, 'media': {}}
711 tags = self.tags.filter(category__in=(
712 'author', 'kind', 'genre', 'epoch'))
713 tags = split_tags(tags)
714 for category in tags:
715 rel['tags'][category] = [
716 (t.name, t.slug) for t in tags[category]]
718 for media_format in BookMedia.formats:
719 rel['media'][media_format] = self.has_media(media_format)
724 parents.append((book.parent.title, book.parent.slug))
726 parents = parents[::-1]
728 rel['parents'] = parents
731 type(self).objects.filter(pk=self.pk).update(_related_info=rel)
734 def related_themes(self):
735 theme_counter = self.theme_counter
736 book_themes = list(Tag.objects.filter(pk__in=theme_counter.keys()))
737 for tag in book_themes:
738 tag.count = theme_counter[tag.pk]
741 def reset_tag_counter(self):
745 cache_key = "Book.tag_counter/%d" % self.id
746 permanent_cache.delete(cache_key)
748 self.parent.reset_tag_counter()
751 def tag_counter(self):
753 cache_key = "Book.tag_counter/%d" % self.id
754 tags = permanent_cache.get(cache_key)
760 for child in self.children.all().order_by().iterator():
761 for tag_pk, value in child.tag_counter.iteritems():
762 tags[tag_pk] = tags.get(tag_pk, 0) + value
763 for tag in self.tags.exclude(category__in=('book', 'theme', 'set')).order_by().iterator():
767 permanent_cache.set(cache_key, tags)
770 def reset_theme_counter(self):
774 cache_key = "Book.theme_counter/%d" % self.id
775 permanent_cache.delete(cache_key)
777 self.parent.reset_theme_counter()
780 def theme_counter(self):
782 cache_key = "Book.theme_counter/%d" % self.id
783 tags = permanent_cache.get(cache_key)
789 for fragment in Fragment.tagged.with_any([self.book_tag()]).order_by().iterator():
790 for tag in fragment.tags.filter(category='theme').order_by().iterator():
791 tags[tag.pk] = tags.get(tag.pk, 0) + 1
794 permanent_cache.set(cache_key, tags)
797 def pretty_title(self, html_links=False):
799 names = list(book.tags.filter(category='author'))
805 names.extend(reversed(books))
808 names = ['<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name) for tag in names]
810 names = [tag.name for tag in names]
812 return ', '.join(names)
815 def tagged_top_level(cls, tags):
816 """ Returns top-level books tagged with `tags`.
818 It only returns those books which don't have ancestors which are
819 also tagged with those tags.
822 # get relevant books and their tags
823 objects = cls.tagged.with_all(tags)
824 # eliminate descendants
825 l_tags = Tag.objects.filter(category='book',
826 slug__in=[book.book_tag_slug() for book in objects.iterator()])
827 descendants_keys = [book.pk for book in cls.tagged.with_any(l_tags).iterator()]
829 objects = objects.exclude(pk__in=descendants_keys)
834 def book_list(cls, filter=None):
835 """Generates a hierarchical listing of all books.
837 Books are optionally filtered with a test function.
842 books = cls.objects.all().order_by('parent_number', 'sort_key').only(
843 'title', 'parent', 'slug')
845 books = books.filter(filter).distinct()
847 book_ids = set(b['pk'] for b in books.values("pk").iterator())
848 for book in books.iterator():
849 parent = book.parent_id
850 if parent not in book_ids:
852 books_by_parent.setdefault(parent, []).append(book)
854 for book in books.iterator():
855 books_by_parent.setdefault(book.parent_id, []).append(book)
858 books_by_author = SortedDict()
859 for tag in Tag.objects.filter(category='author').iterator():
860 books_by_author[tag] = []
862 for book in books_by_parent.get(None,()):
863 authors = list(book.tags.filter(category='author'))
865 for author in authors:
866 books_by_author[author].append(book)
870 return books_by_author, orphans, books_by_parent
873 "SP1": (1, u"szkoła podstawowa"),
874 "SP2": (1, u"szkoła podstawowa"),
875 "P": (1, u"szkoła podstawowa"),
876 "G": (2, u"gimnazjum"),
878 "LP": (3, u"liceum"),
880 def audiences_pl(self):
881 audiences = self.extra_info.get('audiences', [])
882 audiences = sorted(set([self._audiences_pl[a] for a in audiences]))
883 return [a[1] for a in audiences]
885 def choose_fragment(self):
886 tag = self.book_tag()
887 fragments = Fragment.tagged.with_any([tag])
888 if fragments.exists():
889 return fragments.order_by('?')[0]
891 return self.parent.choose_fragment()
896 def _has_factory(ftype):
897 has = lambda self: bool(getattr(self, "%s_file" % ftype))
898 has.short_description = ftype.upper()
901 has.__name__ = "has_%s_file" % ftype
905 # add the file fields
906 for t in Book.formats:
907 field_name = "%s_file" % t
908 models.FileField(_("%s file" % t.upper()),
909 upload_to=book_upload_path(t),
910 blank=True).contribute_to_class(Book, field_name)
912 setattr(Book, "has_%s_file" % t, _has_factory(t))
915 class Fragment(models.Model):
916 """Represents a themed fragment of a book."""
917 text = models.TextField()
918 short_text = models.TextField(editable=False)
919 anchor = models.CharField(max_length=120)
920 book = models.ForeignKey(Book, related_name='fragments')
922 objects = models.Manager()
923 tagged = managers.ModelTaggedItemManager(Tag)
924 tags = managers.TagDescriptor(Tag)
927 ordering = ('book', 'anchor',)
928 verbose_name = _('fragment')
929 verbose_name_plural = _('fragments')
931 def get_absolute_url(self):
932 return '%s#m%s' % (reverse('book_text', args=[self.book.slug]), self.anchor)
934 def reset_short_html(self):
938 cache_key = "Fragment.short_html/%d/%s"
939 for lang, langname in settings.LANGUAGES:
940 permanent_cache.delete(cache_key % (self.id, lang))
942 def get_short_text(self):
943 """Returns short version of the fragment."""
944 return self.short_text if self.short_text else self.text
946 def short_html(self):
948 cache_key = "Fragment.short_html/%d/%s" % (self.id, get_language())
949 short_html = permanent_cache.get(cache_key)
953 if short_html is not None:
954 return mark_safe(short_html)
956 short_html = unicode(render_to_string('catalogue/fragment_short.html',
959 permanent_cache.set(cache_key, short_html)
960 return mark_safe(short_html)
963 class Collection(models.Model):
964 """A collection of books, which might be defined before publishing them."""
965 title = models.CharField(_('title'), max_length=120, db_index=True)
966 slug = models.SlugField(_('slug'), max_length=120, primary_key=True)
967 description = models.TextField(_('description'), null=True, blank=True)
969 models.SlugField(_('slug'), max_length=120, unique=True, db_index=True)
970 book_slugs = models.TextField(_('book slugs'))
973 ordering = ('title',)
974 verbose_name = _('collection')
975 verbose_name_plural = _('collections')
977 def __unicode__(self):
988 def _tags_updated_handler(sender, affected_tags, **kwargs):
989 # reset tag global counter
990 # we want Tag.changed_at updated for API to know the tag was touched
991 for tag in affected_tags:
994 # if book tags changed, reset book tag counter
995 if isinstance(sender, Book) and \
996 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
997 exclude(category__in=('book', 'theme', 'set')).count():
998 sender.reset_tag_counter()
999 # if fragment theme changed, reset book theme counter
1000 elif isinstance(sender, Fragment) and \
1001 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
1002 filter(category='theme').count():
1003 sender.book.reset_theme_counter()
1004 tags_updated.connect(_tags_updated_handler)
1007 def _pre_delete_handler(sender, instance, **kwargs):
1008 """ refresh Book on BookMedia delete """
1009 if sender == BookMedia:
1010 instance.book.save()
1011 pre_delete.connect(_pre_delete_handler)
1014 def _post_save_handler(sender, instance, **kwargs):
1015 """ refresh all the short_html stuff on BookMedia update """
1016 if sender == BookMedia:
1017 instance.book.save()
1018 post_save.connect(_post_save_handler)
1021 if not settings.NO_SEARCH_INDEX:
1022 @django.dispatch.receiver(post_delete, sender=Book)
1023 def _remove_book_from_index_handler(sender, instance, **kwargs):
1024 """ remove the book from search index, when it is deleted."""
1026 search.JVM.attachCurrentThread()
1027 idx = search.Index()
1028 idx.open(timeout=10000) # 10 seconds timeout.
1030 idx.remove_book(instance)