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.utils.translation import ugettext_lazy as _
10 from django.contrib.auth.models import User
11 from django.core.files import File
12 from django.template.loader import render_to_string
13 from django.utils.safestring import mark_safe
14 from django.utils.translation import get_language
15 from django.core.urlresolvers import reverse
16 from django.db.models.signals import post_save, m2m_changed, pre_delete
18 from django.conf import settings
20 from newtagging.models import TagBase, tags_updated
21 from newtagging import managers
22 from catalogue.fields import JSONField, OverwritingFileField
23 from catalogue.utils import ExistingFile
25 from librarian import dcparser, html, epub, NoDublinCore
27 from mutagen import id3
28 from slughifi import slughifi
29 from sortify import sortify
33 ('author', _('author')),
34 ('epoch', _('epoch')),
36 ('genre', _('genre')),
37 ('theme', _('theme')),
43 ('odt', _('ODT file')),
44 ('mp3', _('MP3 file')),
45 ('ogg', _('OGG file')),
46 ('daisy', _('DAISY file')),
49 class TagSubcategoryManager(models.Manager):
50 def __init__(self, subcategory):
51 super(TagSubcategoryManager, self).__init__()
52 self.subcategory = subcategory
54 def get_query_set(self):
55 return super(TagSubcategoryManager, self).get_query_set().filter(category=self.subcategory)
59 name = models.CharField(_('name'), max_length=50, db_index=True)
60 slug = models.SlugField(_('slug'), max_length=120, db_index=True)
61 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True)
62 category = models.CharField(_('category'), max_length=50, blank=False, null=False,
63 db_index=True, choices=TAG_CATEGORIES)
64 description = models.TextField(_('description'), blank=True)
65 main_page = models.BooleanField(_('main page'), default=False, db_index=True, help_text=_('Show tag on main page'))
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.book_count is None:
113 if self.category == 'book':
115 objects = Book.objects.none()
116 elif self.category == 'theme':
117 objects = Fragment.tagged.with_all((self,))
119 objects = Book.tagged.with_all((self,)).order_by()
120 if self.category != 'set':
121 # eliminate descendants
122 l_tags = Tag.objects.filter(slug__in=[book.book_tag_slug() for book in objects])
123 descendants_keys = [book.pk for book in Book.tagged.with_any(l_tags)]
125 objects = objects.exclude(pk__in=descendants_keys)
126 self.book_count = objects.count()
128 return self.book_count
131 def get_tag_list(tags):
132 if isinstance(tags, basestring):
137 tags_splitted = tags.split('/')
138 for name in tags_splitted:
140 real_tags.append(Tag.objects.get(slug=name, category=category))
142 elif name in Tag.categories_rev:
143 category = Tag.categories_rev[name]
146 real_tags.append(Tag.objects.exclude(category='book').get(slug=name))
148 except Tag.MultipleObjectsReturned, e:
149 ambiguous_slugs.append(name)
152 # something strange left off
153 raise Tag.DoesNotExist()
155 # some tags should be qualified
156 e = Tag.MultipleObjectsReturned()
158 e.ambiguous_slugs = ambiguous_slugs
161 e = Tag.UrlDeprecationWarning()
166 return TagBase.get_tag_list(tags)
170 return '/'.join((Tag.categories_dict[self.category], self.slug))
173 # TODO: why is this hard-coded ?
174 def book_upload_path(ext=None, maxlen=100):
175 def get_dynamic_path(media, filename, ext=ext):
176 # how to put related book's slug here?
178 if media.type == 'daisy':
183 name = slughifi(filename.split(".")[0])
185 name = slughifi(media.name)
186 return 'book/%s/%s.%s' % (ext, name[:maxlen-len('book/%s/.%s' % (ext, ext))-4], ext)
187 return get_dynamic_path
190 class BookMedia(models.Model):
191 type = models.CharField(_('type'), choices=MEDIA_FORMATS, max_length="100")
192 name = models.CharField(_('name'), max_length="100")
193 file = OverwritingFileField(_('file'), upload_to=book_upload_path())
194 uploaded_at = models.DateTimeField(_('creation date'), auto_now_add=True, editable=False)
195 extra_info = JSONField(_('extra information'), default='{}', editable=False)
196 book = models.ForeignKey('Book', related_name='media')
197 source_sha1 = models.CharField(null=True, blank=True, max_length=40, editable=False)
199 def __unicode__(self):
200 return "%s (%s)" % (self.name, self.file.name.split("/")[-1])
203 ordering = ('type', 'name')
204 verbose_name = _('book media')
205 verbose_name_plural = _('book media')
207 def save(self, *args, **kwargs):
209 old = BookMedia.objects.get(pk=self.pk)
210 except BookMedia.DoesNotExist, e:
213 # if name changed, change the file name, too
214 if slughifi(self.name) != slughifi(old.name):
215 self.file.save(None, ExistingFile(self.file.path), save=False, leave=True)
217 super(BookMedia, self).save(*args, **kwargs)
218 extra_info = self.get_extra_info_value()
219 extra_info.update(self.read_meta())
220 self.set_extra_info_value(extra_info)
221 self.source_sha1 = self.read_source_sha1(self.file.path, self.type)
222 return super(BookMedia, self).save(*args, **kwargs)
226 Reads some metadata from the audiobook.
229 artist_name = director_name = project = funded_by = ''
230 if self.type == 'mp3':
232 audio = id3.ID3(self.file.path)
233 artist_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE1'))
234 director_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE3'))
235 project = ", ".join([t.data for t in audio.getall('PRIV')
236 if t.owner=='wolnelektury.pl?project'])
237 funded_by = ", ".join([t.data for t in audio.getall('PRIV')
238 if t.owner=='wolnelektury.pl?funded_by'])
241 elif self.type == 'ogg':
243 audio = mutagen.File(self.file.path)
244 artist_name = ', '.join(audio.get('artist', []))
245 director_name = ', '.join(audio.get('conductor', []))
246 project = ", ".join(audio.get('project', []))
247 funded_by = ", ".join(audio.get('funded_by', []))
252 return {'artist_name': artist_name, 'director_name': director_name,
253 'project': project, 'funded_by': funded_by}
256 def read_source_sha1(filepath, filetype):
258 Reads source file SHA1 from audiobok metadata.
261 if filetype == 'mp3':
263 audio = id3.ID3(filepath)
264 return [t.data for t in audio.getall('PRIV')
265 if t.owner=='wolnelektury.pl?flac_sha1'][0]
268 elif filetype == 'ogg':
270 audio = mutagen.File(filepath)
271 return audio.get('flac_sha1', [None])[0]
278 class Book(models.Model):
279 title = models.CharField(_('title'), max_length=120)
280 sort_key = models.CharField(_('sort_key'), max_length=120, db_index=True, editable=False)
281 slug = models.SlugField(_('slug'), max_length=120, unique=True, db_index=True)
282 description = models.TextField(_('description'), blank=True)
283 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
284 changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
285 _short_html = models.TextField(_('short HTML'), editable=False)
286 parent_number = models.IntegerField(_('parent number'), default=0)
287 extra_info = JSONField(_('extra information'), default='{}')
288 gazeta_link = models.CharField(blank=True, max_length=240)
289 wiki_link = models.CharField(blank=True, max_length=240)
290 # files generated during publication
291 xml_file = models.FileField(_('XML file'), upload_to=book_upload_path('xml'), blank=True)
292 html_file = models.FileField(_('HTML file'), upload_to=book_upload_path('html'), blank=True)
293 pdf_file = models.FileField(_('PDF file'), upload_to=book_upload_path('pdf'), blank=True)
294 epub_file = models.FileField(_('EPUB file'), upload_to=book_upload_path('epub'), blank=True)
295 txt_file = models.FileField(_('TXT file'), upload_to=book_upload_path('txt'), blank=True)
297 parent = models.ForeignKey('self', blank=True, null=True, related_name='children')
298 objects = models.Manager()
299 tagged = managers.ModelTaggedItemManager(Tag)
300 tags = managers.TagDescriptor(Tag)
302 _tag_counter = JSONField(null=True, editable=False)
303 _theme_counter = JSONField(null=True, editable=False)
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)
320 # Reset _short_html during save
322 for key in filter(lambda x: x.startswith('_short_html'), self.__dict__):
324 self.__setattr__(key, '')
325 # Fragment.short_html relies on book's tags, so reset it here too
326 self.fragments.all().update(**update)
328 return super(Book, self).save(force_insert, force_update)
331 def get_absolute_url(self):
332 return ('catalogue.views.book_detail', [self.slug])
338 def book_tag_slug(self):
339 return ('l-' + self.slug)[:120]
342 slug = self.book_tag_slug()
343 book_tag, created = Tag.objects.get_or_create(slug=slug, category='book')
345 book_tag.name = self.title[:50]
346 book_tag.sort_key = self.title.lower()
350 def has_media(self, type):
377 if self.media.filter(type=type).exists():
382 def get_media(self, type):
383 if self.has_media(type):
387 return self.html_file
389 return self.epub_file
395 return self.media.filter(type=type)
400 return self.get_media("mp3")
402 return self.get_media("odt")
404 return self.get_media("ogg")
406 return self.get_media("daisy")
408 def short_html(self):
409 key = '_short_html_%s' % get_language()
410 short_html = getattr(self, key)
412 if short_html and len(short_html):
413 return mark_safe(short_html)
415 tags = self.tags.filter(~Q(category__in=('set', 'theme', 'book')))
416 tags = [mark_safe(u'<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name)) for tag in tags]
419 # files generated during publication
420 if self.has_media("html"):
421 formats.append(u'<a href="%s">%s</a>' % (reverse('book_text', kwargs={'slug': self.slug}), _('Read online')))
422 if self.has_media("pdf"):
423 formats.append(u'<a href="%s">PDF</a>' % self.get_media('pdf').url)
424 if self.root_ancestor.has_media("epub"):
425 formats.append(u'<a href="%s">EPUB</a>' % self.root_ancestor.get_media('epub').url)
426 if self.has_media("txt"):
427 formats.append(u'<a href="%s">TXT</a>' % self.get_media('txt').url)
429 for m in self.media.order_by('type'):
430 formats.append(u'<a href="%s">%s</a>' % (m.file.url, m.type.upper()))
432 formats = [mark_safe(format) for format in formats]
434 setattr(self, key, unicode(render_to_string('catalogue/book_short.html',
435 {'book': self, 'tags': tags, 'formats': formats})))
436 self.save(reset_short_html=False)
437 return mark_safe(getattr(self, key))
441 def root_ancestor(self):
442 """ returns the oldest ancestor """
444 if not hasattr(self, '_root_ancestor'):
448 self._root_ancestor = book
449 return self._root_ancestor
452 def has_description(self):
453 return len(self.description) > 0
454 has_description.short_description = _('description')
455 has_description.boolean = True
458 def has_pdf_file(self):
459 return bool(self.pdf_file)
460 has_pdf_file.short_description = 'PDF'
461 has_pdf_file.boolean = True
463 def has_epub_file(self):
464 return bool(self.epub_file)
465 has_epub_file.short_description = 'EPUB'
466 has_epub_file.boolean = True
468 def has_txt_file(self):
469 return bool(self.txt_file)
470 has_txt_file.short_description = 'HTML'
471 has_txt_file.boolean = True
473 def has_html_file(self):
474 return bool(self.html_file)
475 has_html_file.short_description = 'HTML'
476 has_html_file.boolean = True
478 def has_odt_file(self):
479 return bool(self.has_media("odt"))
480 has_odt_file.short_description = 'ODT'
481 has_odt_file.boolean = True
483 def has_mp3_file(self):
484 return bool(self.has_media("mp3"))
485 has_mp3_file.short_description = 'MP3'
486 has_mp3_file.boolean = True
488 def has_ogg_file(self):
489 return bool(self.has_media("ogg"))
490 has_ogg_file.short_description = 'OGG'
491 has_ogg_file.boolean = True
493 def has_daisy_file(self):
494 return bool(self.has_media("daisy"))
495 has_daisy_file.short_description = 'DAISY'
496 has_daisy_file.boolean = True
498 def build_epub(self, remove_descendants=True):
499 """ (Re)builds the epub file.
500 If book has a parent, does nothing.
501 Unless remove_descendants is False, descendants' epubs are removed.
504 from StringIO import StringIO
505 from hashlib import sha1
506 from django.core.files.base import ContentFile
507 from librarian import DocProvider
509 class BookImportDocProvider(DocProvider):
510 """ used for joined EPUBs """
512 def __init__(self, book):
515 def by_slug(self, slug):
516 if slug == self.book.slug:
517 return self.book.xml_file
519 return Book.objects.get(slug=slug).xml_file
525 epub_file = StringIO()
527 epub.transform(BookImportDocProvider(self), self.slug, output_file=epub_file)
528 self.epub_file.save('%s.epub' % self.slug, ContentFile(epub_file.getvalue()))
529 FileRecord(slug=self.slug, type='epub', sha1=sha1(epub_file.getvalue()).hexdigest()).save()
533 book_descendants = list(self.children.all())
534 while len(book_descendants) > 0:
535 child_book = book_descendants.pop(0)
536 if remove_descendants and child_book.has_epub_file():
537 child_book.epub_file.delete()
538 # save anyway, to refresh short_html
540 book_descendants += list(child_book.children.all())
543 from StringIO import StringIO
544 from django.core.files.base import ContentFile
545 from librarian import text
548 text.transform(open(self.xml_file.path), out)
549 self.txt_file.save('%s.txt' % self.slug, ContentFile(out.getvalue()))
552 def build_html(self):
553 from tempfile import NamedTemporaryFile
554 from markupstring import MarkupString
556 meta_tags = list(self.tags.filter(
557 category__in=('author', 'epoch', 'genre', 'kind')))
558 book_tag = self.book_tag()
560 html_file = NamedTemporaryFile()
561 if html.transform(self.xml_file.path, html_file, parse_dublincore=False):
562 self.html_file.save('%s.html' % self.slug, File(html_file))
564 # get ancestor l-tags for adding to new fragments
568 ancestor_tags.append(p.book_tag())
571 # Delete old fragments and create them from scratch
572 self.fragments.all().delete()
574 closed_fragments, open_fragments = html.extract_fragments(self.html_file.path)
575 for fragment in closed_fragments.values():
577 theme_names = [s.strip() for s in fragment.themes.split(',')]
578 except AttributeError:
581 for theme_name in theme_names:
584 tag, created = Tag.objects.get_or_create(slug=slughifi(theme_name), category='theme')
586 tag.name = theme_name
587 tag.sort_key = theme_name.lower()
593 text = fragment.to_string()
595 if (len(MarkupString(text)) > 240):
596 short_text = unicode(MarkupString(text)[:160])
597 new_fragment = Fragment.objects.create(anchor=fragment.id, book=self,
598 text=text, short_text=short_text)
601 new_fragment.tags = set(meta_tags + themes + [book_tag] + ancestor_tags)
608 def from_xml_file(cls, xml_file, **kwargs):
609 # use librarian to parse meta-data
610 book_info = dcparser.parse(xml_file)
612 if not isinstance(xml_file, File):
613 xml_file = File(open(xml_file))
616 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
621 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, build_epub=True, build_txt=True):
624 # check for parts before we do anything
626 if hasattr(book_info, 'parts'):
627 for part_url in book_info.parts:
628 base, slug = part_url.rsplit('/', 1)
630 children.append(Book.objects.get(slug=slug))
631 except Book.DoesNotExist, e:
632 raise Book.DoesNotExist(_('Book with slug = "%s" does not exist.') % slug)
636 book_base, book_slug = book_info.url.rsplit('/', 1)
637 if re.search(r'[^a-zA-Z0-9-]', book_slug):
638 raise ValueError('Invalid characters in slug')
639 book, created = Book.objects.get_or_create(slug=book_slug)
645 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
646 # Save shelves for this book
647 book_shelves = list(book.tags.filter(category='set'))
649 book.title = book_info.title
650 book.set_extra_info_value(book_info.to_dict())
651 book._short_html = ''
655 categories = (('kinds', 'kind'), ('genres', 'genre'), ('authors', 'author'), ('epochs', 'epoch'))
656 for field_name, category in categories:
658 tag_names = getattr(book_info, field_name)
660 tag_names = [getattr(book_info, category)]
661 for tag_name in tag_names:
662 tag_sort_key = tag_name
663 if category == 'author':
664 tag_sort_key = tag_name.last_name
665 tag_name = ' '.join(tag_name.first_names) + ' ' + tag_name.last_name
666 tag, created = Tag.objects.get_or_create(slug=slughifi(tag_name), category=category)
669 tag.sort_key = sortify(tag_sort_key.lower())
671 meta_tags.append(tag)
673 book.tags = set(meta_tags + book_shelves)
675 book_tag = book.book_tag()
677 for n, child_book in enumerate(children):
678 child_book.parent = book
679 child_book.parent_number = n
682 # Save XML and HTML files
683 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
685 # delete old fragments when overwriting
686 book.fragments.all().delete()
688 if book.build_html():
689 if not settings.NO_BUILD_TXT and build_txt:
692 if not settings.NO_BUILD_EPUB and build_epub:
693 book.root_ancestor.build_epub()
695 book_descendants = list(book.children.all())
696 # add l-tag to descendants and their fragments
697 # delete unnecessary EPUB files
698 while len(book_descendants) > 0:
699 child_book = book_descendants.pop(0)
700 child_book.tags = list(child_book.tags) + [book_tag]
702 for fragment in child_book.fragments.all():
703 fragment.tags = set(list(fragment.tags) + [book_tag])
704 book_descendants += list(child_book.children.all())
707 book.reset_tag_counter()
708 book.reset_theme_counter()
714 def refresh_tag_counter(self):
716 for child in self.children.all().order_by():
717 for tag_pk, value in child.tag_counter.iteritems():
718 tags[tag_pk] = tags.get(tag_pk, 0) + value
719 for tag in self.tags.exclude(category__in=('book', 'theme', 'set')).order_by():
721 self.set__tag_counter_value(tags)
722 self.save(reset_short_html=False)
725 def reset_tag_counter(self):
726 self._tag_counter = None
727 self.save(reset_short_html=False)
729 self.parent.reset_tag_counter()
732 def tag_counter(self):
733 if self._tag_counter is None:
734 return self.refresh_tag_counter()
735 return dict((int(k), v) for k, v in self.get__tag_counter_value().iteritems())
737 def refresh_theme_counter(self):
739 for fragment in Fragment.tagged.with_any([self.book_tag()]).order_by():
740 for tag in fragment.tags.filter(category='theme').order_by():
741 tags[tag.pk] = tags.get(tag.pk, 0) + 1
742 self.set__theme_counter_value(tags)
743 self.save(reset_short_html=False)
746 def reset_theme_counter(self):
747 self._theme_counter = None
748 self.save(reset_short_html=False)
750 self.parent.reset_theme_counter()
753 def theme_counter(self):
754 if self._theme_counter is None:
755 return self.refresh_theme_counter()
756 return dict((int(k), v) for k, v in self.get__theme_counter_value().iteritems())
758 def pretty_title(self, html_links=False):
760 names = list(book.tags.filter(category='author'))
766 names.extend(reversed(books))
769 names = ['<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name) for tag in names]
771 names = [tag.name for tag in names]
773 return ', '.join(names)
776 def tagged_top_level(cls, tags):
777 """ Returns top-level books tagged with `tags'.
779 It only returns those books which don't have ancestors which are
780 also tagged with those tags.
783 # get relevant books and their tags
784 objects = cls.tagged.with_all(tags)
785 # eliminate descendants
786 l_tags = Tag.objects.filter(category='book', slug__in=[book.book_tag_slug() for book in objects])
787 descendants_keys = [book.pk for book in cls.tagged.with_any(l_tags)]
789 objects = objects.exclude(pk__in=descendants_keys)
794 class Fragment(models.Model):
795 text = models.TextField()
796 short_text = models.TextField(editable=False)
797 _short_html = models.TextField(editable=False)
798 anchor = models.CharField(max_length=120)
799 book = models.ForeignKey(Book, related_name='fragments')
801 objects = models.Manager()
802 tagged = managers.ModelTaggedItemManager(Tag)
803 tags = managers.TagDescriptor(Tag)
806 ordering = ('book', 'anchor',)
807 verbose_name = _('fragment')
808 verbose_name_plural = _('fragments')
810 def get_absolute_url(self):
811 return '%s#m%s' % (reverse('book_text', kwargs={'slug': self.book.slug}), self.anchor)
813 def short_html(self):
814 key = '_short_html_%s' % get_language()
815 short_html = getattr(self, key)
816 if short_html and len(short_html):
817 return mark_safe(short_html)
819 setattr(self, key, unicode(render_to_string('catalogue/fragment_short.html',
820 {'fragment': self})))
822 return mark_safe(getattr(self, key))
825 class FileRecord(models.Model):
826 slug = models.SlugField(_('slug'), max_length=120, db_index=True)
827 type = models.CharField(_('type'), max_length=20, db_index=True)
828 sha1 = models.CharField(_('sha-1 hash'), max_length=40)
829 time = models.DateTimeField(_('time'), auto_now_add=True)
832 ordering = ('-time','-slug', '-type')
833 verbose_name = _('file record')
834 verbose_name_plural = _('file records')
836 def __unicode__(self):
837 return "%s %s.%s" % (self.sha1, self.slug, self.type)
846 def _tags_updated_handler(sender, affected_tags, **kwargs):
847 # reset tag global counter
848 # we want Tag.changed_at updated for API to know the tag was touched
849 Tag.objects.filter(pk__in=[tag.pk for tag in affected_tags]).update(book_count=None, changed_at=datetime.now())
851 # if book tags changed, reset book tag counter
852 if isinstance(sender, Book) and \
853 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
854 exclude(category__in=('book', 'theme', 'set')).count():
855 sender.reset_tag_counter()
856 # if fragment theme changed, reset book theme counter
857 elif isinstance(sender, Fragment) and \
858 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
859 filter(category='theme').count():
860 sender.book.reset_theme_counter()
861 tags_updated.connect(_tags_updated_handler)
864 def _pre_delete_handler(sender, instance, **kwargs):
865 """ refresh Book on BookMedia delete """
866 if sender == BookMedia:
868 pre_delete.connect(_pre_delete_handler)
870 def _post_save_handler(sender, instance, **kwargs):
871 """ refresh all the short_html stuff on BookMedia update """
872 if sender == BookMedia:
874 post_save.connect(_post_save_handler)