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 datetime import datetime
7 from django.db import models
8 from django.db.models import permalink, Q
9 from django.core.cache import cache
10 from django.utils.translation import ugettext_lazy as _
11 from django.contrib.auth.models import User
12 from django.core.files import File
13 from django.template.loader import render_to_string
14 from django.utils.safestring import mark_safe
15 from django.utils.translation import get_language
16 from django.core.urlresolvers import reverse
17 from django.db.models.signals import post_save, m2m_changed, pre_delete
19 from django.conf import settings
21 from newtagging.models import TagBase, tags_updated
22 from newtagging import managers
23 from catalogue.fields import JSONField, OverwritingFileField
24 from catalogue.utils import ExistingFile
26 from librarian import dcparser, html, epub, NoDublinCore
28 from mutagen import id3
29 from slughifi import slughifi
30 from sortify import sortify
34 ('author', _('author')),
35 ('epoch', _('epoch')),
37 ('genre', _('genre')),
38 ('theme', _('theme')),
44 ('odt', _('ODT file')),
45 ('mp3', _('MP3 file')),
46 ('ogg', _('OGG file')),
47 ('daisy', _('DAISY file')),
50 # not quite, but Django wants you to set a timeout
51 CACHE_FOREVER = 2419200 # 28 days
53 class TagSubcategoryManager(models.Manager):
54 def __init__(self, subcategory):
55 super(TagSubcategoryManager, self).__init__()
56 self.subcategory = subcategory
58 def get_query_set(self):
59 return super(TagSubcategoryManager, self).get_query_set().filter(category=self.subcategory)
63 name = models.CharField(_('name'), max_length=50, db_index=True)
64 slug = models.SlugField(_('slug'), max_length=120, db_index=True)
65 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True)
66 category = models.CharField(_('category'), max_length=50, blank=False, null=False,
67 db_index=True, choices=TAG_CATEGORIES)
68 description = models.TextField(_('description'), blank=True)
69 main_page = models.BooleanField(_('main page'), default=False, db_index=True, help_text=_('Show tag on main page'))
71 user = models.ForeignKey(User, blank=True, null=True)
72 book_count = models.IntegerField(_('book count'), blank=True, null=True)
73 gazeta_link = models.CharField(blank=True, max_length=240)
74 wiki_link = models.CharField(blank=True, max_length=240)
76 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
77 changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
79 class UrlDeprecationWarning(DeprecationWarning):
90 categories_dict = dict((item[::-1] for item in categories_rev.iteritems()))
93 ordering = ('sort_key',)
94 verbose_name = _('tag')
95 verbose_name_plural = _('tags')
96 unique_together = (("slug", "category"),)
98 def __unicode__(self):
102 return "Tag(slug=%r)" % self.slug
105 def get_absolute_url(self):
106 return ('catalogue.views.tagged_object_list', [self.url_chunk])
108 def has_description(self):
109 return len(self.description) > 0
110 has_description.short_description = _('description')
111 has_description.boolean = True
114 """ returns global book count for book tags, fragment count for themes """
116 if self.book_count is None:
117 if self.category == 'book':
119 objects = Book.objects.none()
120 elif self.category == 'theme':
121 objects = Fragment.tagged.with_all((self,))
123 objects = Book.tagged.with_all((self,)).order_by()
124 if self.category != 'set':
125 # eliminate descendants
126 l_tags = Tag.objects.filter(slug__in=[book.book_tag_slug() for book in objects])
127 descendants_keys = [book.pk for book in Book.tagged.with_any(l_tags)]
129 objects = objects.exclude(pk__in=descendants_keys)
130 self.book_count = objects.count()
132 return self.book_count
135 def get_tag_list(tags):
136 if isinstance(tags, basestring):
141 tags_splitted = tags.split('/')
142 for name in tags_splitted:
144 real_tags.append(Tag.objects.get(slug=name, category=category))
146 elif name in Tag.categories_rev:
147 category = Tag.categories_rev[name]
150 real_tags.append(Tag.objects.exclude(category='book').get(slug=name))
152 except Tag.MultipleObjectsReturned, e:
153 ambiguous_slugs.append(name)
156 # something strange left off
157 raise Tag.DoesNotExist()
159 # some tags should be qualified
160 e = Tag.MultipleObjectsReturned()
162 e.ambiguous_slugs = ambiguous_slugs
165 e = Tag.UrlDeprecationWarning()
170 return TagBase.get_tag_list(tags)
174 return '/'.join((Tag.categories_dict[self.category], self.slug))
177 # TODO: why is this hard-coded ?
178 def book_upload_path(ext=None, maxlen=100):
179 def get_dynamic_path(media, filename, ext=ext):
180 # how to put related book's slug here?
182 if media.type == 'daisy':
187 name = slughifi(filename.split(".")[0])
189 name = slughifi(media.name)
190 return 'book/%s/%s.%s' % (ext, name[:maxlen-len('book/%s/.%s' % (ext, ext))-4], ext)
191 return get_dynamic_path
194 class BookMedia(models.Model):
195 type = models.CharField(_('type'), choices=MEDIA_FORMATS, max_length="100")
196 name = models.CharField(_('name'), max_length="100")
197 file = OverwritingFileField(_('file'), upload_to=book_upload_path())
198 uploaded_at = models.DateTimeField(_('creation date'), auto_now_add=True, editable=False)
199 extra_info = JSONField(_('extra information'), default='{}', editable=False)
200 book = models.ForeignKey('Book', related_name='media')
201 source_sha1 = models.CharField(null=True, blank=True, max_length=40, editable=False)
203 def __unicode__(self):
204 return "%s (%s)" % (self.name, self.file.name.split("/")[-1])
207 ordering = ('type', 'name')
208 verbose_name = _('book media')
209 verbose_name_plural = _('book media')
211 def save(self, *args, **kwargs):
213 old = BookMedia.objects.get(pk=self.pk)
214 except BookMedia.DoesNotExist, e:
217 # if name changed, change the file name, too
218 if slughifi(self.name) != slughifi(old.name):
219 self.file.save(None, ExistingFile(self.file.path), save=False, leave=True)
221 super(BookMedia, self).save(*args, **kwargs)
222 extra_info = self.get_extra_info_value()
223 extra_info.update(self.read_meta())
224 self.set_extra_info_value(extra_info)
225 self.source_sha1 = self.read_source_sha1(self.file.path, self.type)
226 return super(BookMedia, self).save(*args, **kwargs)
230 Reads some metadata from the audiobook.
233 artist_name = director_name = project = funded_by = ''
234 if self.type == 'mp3':
236 audio = id3.ID3(self.file.path)
237 artist_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE1'))
238 director_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE3'))
239 project = ", ".join([t.data for t in audio.getall('PRIV')
240 if t.owner=='wolnelektury.pl?project'])
241 funded_by = ", ".join([t.data for t in audio.getall('PRIV')
242 if t.owner=='wolnelektury.pl?funded_by'])
245 elif self.type == 'ogg':
247 audio = mutagen.File(self.file.path)
248 artist_name = ', '.join(audio.get('artist', []))
249 director_name = ', '.join(audio.get('conductor', []))
250 project = ", ".join(audio.get('project', []))
251 funded_by = ", ".join(audio.get('funded_by', []))
256 return {'artist_name': artist_name, 'director_name': director_name,
257 'project': project, 'funded_by': funded_by}
260 def read_source_sha1(filepath, filetype):
262 Reads source file SHA1 from audiobok metadata.
265 if filetype == 'mp3':
267 audio = id3.ID3(filepath)
268 return [t.data for t in audio.getall('PRIV')
269 if t.owner=='wolnelektury.pl?flac_sha1'][0]
272 elif filetype == 'ogg':
274 audio = mutagen.File(filepath)
275 return audio.get('flac_sha1', [None])[0]
282 class Book(models.Model):
283 title = models.CharField(_('title'), max_length=120)
284 sort_key = models.CharField(_('sort_key'), max_length=120, db_index=True, editable=False)
285 slug = models.SlugField(_('slug'), max_length=120, unique=True, db_index=True)
286 description = models.TextField(_('description'), blank=True)
287 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
288 changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
289 parent_number = models.IntegerField(_('parent number'), default=0)
290 extra_info = JSONField(_('extra information'), default='{}')
291 gazeta_link = models.CharField(blank=True, max_length=240)
292 wiki_link = models.CharField(blank=True, max_length=240)
293 # files generated during publication
294 xml_file = models.FileField(_('XML file'), upload_to=book_upload_path('xml'), blank=True)
295 html_file = models.FileField(_('HTML file'), upload_to=book_upload_path('html'), blank=True)
296 pdf_file = models.FileField(_('PDF file'), upload_to=book_upload_path('pdf'), blank=True)
297 epub_file = models.FileField(_('EPUB file'), upload_to=book_upload_path('epub'), blank=True)
298 txt_file = models.FileField(_('TXT file'), upload_to=book_upload_path('txt'), blank=True)
300 parent = models.ForeignKey('self', blank=True, null=True, related_name='children')
301 objects = models.Manager()
302 tagged = managers.ModelTaggedItemManager(Tag)
303 tags = managers.TagDescriptor(Tag)
305 class AlreadyExists(Exception):
309 ordering = ('sort_key',)
310 verbose_name = _('book')
311 verbose_name_plural = _('books')
313 def __unicode__(self):
316 def save(self, force_insert=False, force_update=False, reset_short_html=True, **kwargs):
317 self.sort_key = sortify(self.title)
319 ret = super(Book, self).save(force_insert, force_update)
322 self.reset_short_html()
327 def get_absolute_url(self):
328 return ('catalogue.views.book_detail', [self.slug])
334 def book_tag_slug(self):
335 return ('l-' + self.slug)[:120]
338 slug = self.book_tag_slug()
339 book_tag, created = Tag.objects.get_or_create(slug=slug, category='book')
341 book_tag.name = self.title[:50]
342 book_tag.sort_key = self.title.lower()
346 def has_media(self, type):
373 if self.media.filter(type=type).exists():
378 def get_media(self, type):
379 if self.has_media(type):
383 return self.html_file
385 return self.epub_file
391 return self.media.filter(type=type)
396 return self.get_media("mp3")
398 return self.get_media("odt")
400 return self.get_media("ogg")
402 return self.get_media("daisy")
404 def reset_short_html(self):
408 cache_key = "Book.short_html/%d/%s"
409 for lang, langname in settings.LANGUAGES:
410 cache.delete(cache_key % (self.id, lang))
411 # Fragment.short_html relies on book's tags, so reset it here too
412 for fragm in self.fragments.all():
413 fragm.reset_short_html()
415 def short_html(self):
417 cache_key = "Book.short_html/%d/%s" % (self.id, get_language())
418 short_html = cache.get(cache_key)
422 if short_html is not None:
423 return mark_safe(short_html)
425 tags = self.tags.filter(~Q(category__in=('set', 'theme', 'book')))
426 tags = [mark_safe(u'<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name)) for tag in tags]
429 # files generated during publication
430 if self.has_media("html"):
431 formats.append(u'<a href="%s">%s</a>' % (reverse('book_text', kwargs={'slug': self.slug}), _('Read online')))
432 if self.has_media("pdf"):
433 formats.append(u'<a href="%s">PDF</a>' % self.get_media('pdf').url)
434 if self.root_ancestor.has_media("epub"):
435 formats.append(u'<a href="%s">EPUB</a>' % self.root_ancestor.get_media('epub').url)
436 if self.has_media("txt"):
437 formats.append(u'<a href="%s">TXT</a>' % self.get_media('txt').url)
439 for m in self.media.order_by('type'):
440 formats.append(u'<a href="%s">%s</a>' % (m.file.url, m.type.upper()))
442 formats = [mark_safe(format) for format in formats]
444 short_html = unicode(render_to_string('catalogue/book_short.html',
445 {'book': self, 'tags': tags, 'formats': formats}))
448 cache.set(cache_key, short_html, CACHE_FOREVER)
449 return mark_safe(short_html)
452 def root_ancestor(self):
453 """ returns the oldest ancestor """
455 if not hasattr(self, '_root_ancestor'):
459 self._root_ancestor = book
460 return self._root_ancestor
463 def has_description(self):
464 return len(self.description) > 0
465 has_description.short_description = _('description')
466 has_description.boolean = True
469 def has_pdf_file(self):
470 return bool(self.pdf_file)
471 has_pdf_file.short_description = 'PDF'
472 has_pdf_file.boolean = True
474 def has_epub_file(self):
475 return bool(self.epub_file)
476 has_epub_file.short_description = 'EPUB'
477 has_epub_file.boolean = True
479 def has_txt_file(self):
480 return bool(self.txt_file)
481 has_txt_file.short_description = 'HTML'
482 has_txt_file.boolean = True
484 def has_html_file(self):
485 return bool(self.html_file)
486 has_html_file.short_description = 'HTML'
487 has_html_file.boolean = True
489 def has_odt_file(self):
490 return bool(self.has_media("odt"))
491 has_odt_file.short_description = 'ODT'
492 has_odt_file.boolean = True
494 def has_mp3_file(self):
495 return bool(self.has_media("mp3"))
496 has_mp3_file.short_description = 'MP3'
497 has_mp3_file.boolean = True
499 def has_ogg_file(self):
500 return bool(self.has_media("ogg"))
501 has_ogg_file.short_description = 'OGG'
502 has_ogg_file.boolean = True
504 def has_daisy_file(self):
505 return bool(self.has_media("daisy"))
506 has_daisy_file.short_description = 'DAISY'
507 has_daisy_file.boolean = True
509 def build_epub(self, remove_descendants=True):
510 """ (Re)builds the epub file.
511 If book has a parent, does nothing.
512 Unless remove_descendants is False, descendants' epubs are removed.
515 from StringIO import StringIO
516 from hashlib import sha1
517 from django.core.files.base import ContentFile
518 from librarian import DocProvider
520 class BookImportDocProvider(DocProvider):
521 """ used for joined EPUBs """
523 def __init__(self, book):
526 def by_slug(self, slug):
527 if slug == self.book.slug:
528 return self.book.xml_file
530 return Book.objects.get(slug=slug).xml_file
536 epub_file = StringIO()
538 epub.transform(BookImportDocProvider(self), self.slug, output_file=epub_file)
539 self.epub_file.save('%s.epub' % self.slug, ContentFile(epub_file.getvalue()))
540 FileRecord(slug=self.slug, type='epub', sha1=sha1(epub_file.getvalue()).hexdigest()).save()
544 book_descendants = list(self.children.all())
545 while len(book_descendants) > 0:
546 child_book = book_descendants.pop(0)
547 if remove_descendants and child_book.has_epub_file():
548 child_book.epub_file.delete()
549 # save anyway, to refresh short_html
551 book_descendants += list(child_book.children.all())
554 from StringIO import StringIO
555 from django.core.files.base import ContentFile
556 from librarian import text
559 text.transform(open(self.xml_file.path), out)
560 self.txt_file.save('%s.txt' % self.slug, ContentFile(out.getvalue()))
563 def build_html(self):
564 from tempfile import NamedTemporaryFile
565 from markupstring import MarkupString
567 meta_tags = list(self.tags.filter(
568 category__in=('author', 'epoch', 'genre', 'kind')))
569 book_tag = self.book_tag()
571 html_file = NamedTemporaryFile()
572 if html.transform(self.xml_file.path, html_file, parse_dublincore=False):
573 self.html_file.save('%s.html' % self.slug, File(html_file))
575 # get ancestor l-tags for adding to new fragments
579 ancestor_tags.append(p.book_tag())
582 # Delete old fragments and create them from scratch
583 self.fragments.all().delete()
585 closed_fragments, open_fragments = html.extract_fragments(self.html_file.path)
586 for fragment in closed_fragments.values():
588 theme_names = [s.strip() for s in fragment.themes.split(',')]
589 except AttributeError:
592 for theme_name in theme_names:
595 tag, created = Tag.objects.get_or_create(slug=slughifi(theme_name), category='theme')
597 tag.name = theme_name
598 tag.sort_key = theme_name.lower()
604 text = fragment.to_string()
606 if (len(MarkupString(text)) > 240):
607 short_text = unicode(MarkupString(text)[:160])
608 new_fragment = Fragment.objects.create(anchor=fragment.id, book=self,
609 text=text, short_text=short_text)
612 new_fragment.tags = set(meta_tags + themes + [book_tag] + ancestor_tags)
619 def from_xml_file(cls, xml_file, **kwargs):
620 # use librarian to parse meta-data
621 book_info = dcparser.parse(xml_file)
623 if not isinstance(xml_file, File):
624 xml_file = File(open(xml_file))
627 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
632 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, build_epub=True, build_txt=True):
635 # check for parts before we do anything
637 if hasattr(book_info, 'parts'):
638 for part_url in book_info.parts:
639 base, slug = part_url.rsplit('/', 1)
641 children.append(Book.objects.get(slug=slug))
642 except Book.DoesNotExist, e:
643 raise Book.DoesNotExist(_('Book with slug = "%s" does not exist.') % slug)
647 book_base, book_slug = book_info.url.rsplit('/', 1)
648 if re.search(r'[^a-zA-Z0-9-]', book_slug):
649 raise ValueError('Invalid characters in slug')
650 book, created = Book.objects.get_or_create(slug=book_slug)
656 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
657 # Save shelves for this book
658 book_shelves = list(book.tags.filter(category='set'))
660 book.title = book_info.title
661 book.set_extra_info_value(book_info.to_dict())
665 categories = (('kinds', 'kind'), ('genres', 'genre'), ('authors', 'author'), ('epochs', 'epoch'))
666 for field_name, category in categories:
668 tag_names = getattr(book_info, field_name)
670 tag_names = [getattr(book_info, category)]
671 for tag_name in tag_names:
672 tag_sort_key = tag_name
673 if category == 'author':
674 tag_sort_key = tag_name.last_name
675 tag_name = ' '.join(tag_name.first_names) + ' ' + tag_name.last_name
676 tag, created = Tag.objects.get_or_create(slug=slughifi(tag_name), category=category)
679 tag.sort_key = sortify(tag_sort_key.lower())
681 meta_tags.append(tag)
683 book.tags = set(meta_tags + book_shelves)
685 book_tag = book.book_tag()
687 for n, child_book in enumerate(children):
688 child_book.parent = book
689 child_book.parent_number = n
692 # Save XML and HTML files
693 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
695 # delete old fragments when overwriting
696 book.fragments.all().delete()
698 if book.build_html():
699 if not settings.NO_BUILD_TXT and build_txt:
702 if not settings.NO_BUILD_EPUB and build_epub:
703 book.root_ancestor.build_epub()
705 book_descendants = list(book.children.all())
706 # add l-tag to descendants and their fragments
707 # delete unnecessary EPUB files
708 while len(book_descendants) > 0:
709 child_book = book_descendants.pop(0)
710 child_book.tags = list(child_book.tags) + [book_tag]
712 for fragment in child_book.fragments.all():
713 fragment.tags = set(list(fragment.tags) + [book_tag])
714 book_descendants += list(child_book.children.all())
719 book.reset_tag_counter()
720 book.reset_theme_counter()
724 def reset_tag_counter(self):
728 cache_key = "Book.tag_counter/%d" % self.id
729 cache.delete(cache_key)
731 self.parent.reset_tag_counter()
734 def tag_counter(self):
736 cache_key = "Book.tag_counter/%d" % self.id
737 tags = cache.get(cache_key)
743 for child in self.children.all().order_by():
744 for tag_pk, value in child.tag_counter.iteritems():
745 tags[tag_pk] = tags.get(tag_pk, 0) + value
746 for tag in self.tags.exclude(category__in=('book', 'theme', 'set')).order_by():
750 cache.set(cache_key, tags, CACHE_FOREVER)
753 def reset_theme_counter(self):
757 cache_key = "Book.theme_counter/%d" % self.id
758 cache.delete(cache_key)
760 self.parent.reset_theme_counter()
763 def theme_counter(self):
765 cache_key = "Book.theme_counter/%d" % self.id
766 tags = cache.get(cache_key)
772 for fragment in Fragment.tagged.with_any([self.book_tag()]).order_by():
773 for tag in fragment.tags.filter(category='theme').order_by():
774 tags[tag.pk] = tags.get(tag.pk, 0) + 1
777 cache.set(cache_key, tags, CACHE_FOREVER)
780 def pretty_title(self, html_links=False):
782 names = list(book.tags.filter(category='author'))
788 names.extend(reversed(books))
791 names = ['<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name) for tag in names]
793 names = [tag.name for tag in names]
795 return ', '.join(names)
798 def tagged_top_level(cls, tags):
799 """ Returns top-level books tagged with `tags'.
801 It only returns those books which don't have ancestors which are
802 also tagged with those tags.
805 # get relevant books and their tags
806 objects = cls.tagged.with_all(tags)
807 # eliminate descendants
808 l_tags = Tag.objects.filter(category='book', slug__in=[book.book_tag_slug() for book in objects])
809 descendants_keys = [book.pk for book in cls.tagged.with_any(l_tags)]
811 objects = objects.exclude(pk__in=descendants_keys)
816 class Fragment(models.Model):
817 text = models.TextField()
818 short_text = models.TextField(editable=False)
819 anchor = models.CharField(max_length=120)
820 book = models.ForeignKey(Book, related_name='fragments')
822 objects = models.Manager()
823 tagged = managers.ModelTaggedItemManager(Tag)
824 tags = managers.TagDescriptor(Tag)
827 ordering = ('book', 'anchor',)
828 verbose_name = _('fragment')
829 verbose_name_plural = _('fragments')
831 def get_absolute_url(self):
832 return '%s#m%s' % (reverse('book_text', kwargs={'slug': self.book.slug}), self.anchor)
834 def reset_short_html(self):
838 cache_key = "Fragment.short_html/%d/%s"
839 for lang, langname in settings.LANGUAGES:
840 cache.delete(cache_key % (self.id, lang))
842 def short_html(self):
844 cache_key = "Fragment.short_html/%d/%s" % (self.id, get_language())
845 short_html = cache.get(cache_key)
849 if short_html is not None:
850 return mark_safe(short_html)
852 short_html = unicode(render_to_string('catalogue/fragment_short.html',
855 cache.set(cache_key, short_html, CACHE_FOREVER)
856 return mark_safe(short_html)
859 class FileRecord(models.Model):
860 slug = models.SlugField(_('slug'), max_length=120, db_index=True)
861 type = models.CharField(_('type'), max_length=20, db_index=True)
862 sha1 = models.CharField(_('sha-1 hash'), max_length=40)
863 time = models.DateTimeField(_('time'), auto_now_add=True)
866 ordering = ('-time','-slug', '-type')
867 verbose_name = _('file record')
868 verbose_name_plural = _('file records')
870 def __unicode__(self):
871 return "%s %s.%s" % (self.sha1, self.slug, self.type)
880 def _tags_updated_handler(sender, affected_tags, **kwargs):
881 # reset tag global counter
882 # we want Tag.changed_at updated for API to know the tag was touched
883 Tag.objects.filter(pk__in=[tag.pk for tag in affected_tags]).update(book_count=None, changed_at=datetime.now())
885 # if book tags changed, reset book tag counter
886 if isinstance(sender, Book) and \
887 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
888 exclude(category__in=('book', 'theme', 'set')).count():
889 sender.reset_tag_counter()
890 # if fragment theme changed, reset book theme counter
891 elif isinstance(sender, Fragment) and \
892 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
893 filter(category='theme').count():
894 sender.book.reset_theme_counter()
895 tags_updated.connect(_tags_updated_handler)
898 def _pre_delete_handler(sender, instance, **kwargs):
899 """ refresh Book on BookMedia delete """
900 if sender == BookMedia:
902 pre_delete.connect(_pre_delete_handler)
904 def _post_save_handler(sender, instance, **kwargs):
905 """ refresh all the short_html stuff on BookMedia update """
906 if sender == BookMedia:
908 post_save.connect(_post_save_handler)