#961: translator, infobox in online book view
[wolnelektury.git] / apps / catalogue / models.py
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.
4 #
5 from django.db import models
6 from django.db.models import permalink, Q
7 from django.utils.translation import ugettext_lazy as _
8 from django.contrib.auth.models import User
9 from django.core.files import File
10 from django.template.loader import render_to_string
11 from django.template.defaultfilters import slugify
12 from django.utils.safestring import mark_safe
13 from django.utils.translation import get_language
14 from django.core.urlresolvers import reverse
15 from django.db.models.signals import post_save, m2m_changed, pre_delete
16
17 from django.conf import settings
18
19 from newtagging.models import TagBase, tags_updated
20 from newtagging import managers
21 from catalogue.fields import JSONField
22
23 from librarian import dcparser, html, epub, NoDublinCore
24 from mutagen import id3
25
26
27 TAG_CATEGORIES = (
28     ('author', _('author')),
29     ('epoch', _('epoch')),
30     ('kind', _('kind')),
31     ('genre', _('genre')),
32     ('theme', _('theme')),
33     ('set', _('set')),
34     ('book', _('book')),
35 )
36
37 MEDIA_FORMATS = (
38     ('odt', _('ODT file')),
39     ('mp3', _('MP3 file')),
40     ('ogg', _('OGG file')),
41     ('daisy', _('DAISY file')), 
42 )
43
44 class TagSubcategoryManager(models.Manager):
45     def __init__(self, subcategory):
46         super(TagSubcategoryManager, self).__init__()
47         self.subcategory = subcategory
48
49     def get_query_set(self):
50         return super(TagSubcategoryManager, self).get_query_set().filter(category=self.subcategory)
51
52
53 class Tag(TagBase):
54     name = models.CharField(_('name'), max_length=50, db_index=True)
55     slug = models.SlugField(_('slug'), max_length=120, db_index=True)
56     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True)
57     category = models.CharField(_('category'), max_length=50, blank=False, null=False,
58         db_index=True, choices=TAG_CATEGORIES)
59     description = models.TextField(_('description'), blank=True)
60     main_page = models.BooleanField(_('main page'), default=False, db_index=True, help_text=_('Show tag on main page'))
61
62     user = models.ForeignKey(User, blank=True, null=True)
63     book_count = models.IntegerField(_('book count'), blank=True, null=True)
64     gazeta_link = models.CharField(blank=True, max_length=240)
65     wiki_link = models.CharField(blank=True, max_length=240)
66
67     categories_rev = {
68         'autor': 'author',
69         'epoka': 'epoch',
70         'rodzaj': 'kind',
71         'gatunek': 'genre',
72         'motyw': 'theme',
73         'polka': 'set',
74     }
75     categories_dict = dict((item[::-1] for item in categories_rev.iteritems()))
76
77     class Meta:
78         ordering = ('sort_key',)
79         verbose_name = _('tag')
80         verbose_name_plural = _('tags')
81         unique_together = (("slug", "category"),)
82
83     def __unicode__(self):
84         return self.name
85
86     def __repr__(self):
87         return "Tag(slug=%r)" % self.slug
88
89     @permalink
90     def get_absolute_url(self):
91         return ('catalogue.views.tagged_object_list', [self.url_chunk])
92
93     def has_description(self):
94         return len(self.description) > 0
95     has_description.short_description = _('description')
96     has_description.boolean = True
97
98     def get_count(self):
99         """ returns global book count for book tags, fragment count for themes """
100
101         if self.book_count is None:
102             if self.category == 'book':
103                 # never used
104                 objects = Book.objects.none()
105             elif self.category == 'theme':
106                 objects = Fragment.tagged.with_all((self,))
107             else:
108                 objects = Book.tagged.with_all((self,)).order_by()
109                 if self.category != 'set':
110                     # eliminate descendants
111                     l_tags = Tag.objects.filter(slug__in=[book.book_tag_slug() for book in objects])
112                     descendants_keys = [book.pk for book in Book.tagged.with_any(l_tags)]
113                     if descendants_keys:
114                         objects = objects.exclude(pk__in=descendants_keys)
115             self.book_count = objects.count()
116             self.save()
117         return self.book_count
118
119     @staticmethod
120     def get_tag_list(tags):
121         if isinstance(tags, basestring):
122             real_tags = []
123             ambiguous_slugs = []
124             category = None
125             tags_splitted = tags.split('/')
126             for index, name in enumerate(tags_splitted):
127                 if name in Tag.categories_rev:
128                     category = Tag.categories_rev[name]
129                 else:
130                     if category:
131                         real_tags.append(Tag.objects.get(slug=name, category=category))
132                         category = None
133                     else:
134                         try:
135                             real_tags.append(Tag.objects.exclude(category='book').get(slug=name))
136                         except Tag.MultipleObjectsReturned, e:
137                             ambiguous_slugs.append(name)
138
139             if category:
140                 # something strange left off
141                 raise Tag.DoesNotExist()
142             if ambiguous_slugs:
143                 # some tags should be qualified
144                 e = Tag.MultipleObjectsReturned()
145                 e.tags = real_tags
146                 e.ambiguous_slugs = ambiguous_slugs
147                 raise e
148             else:
149                 return real_tags
150         else:
151             return TagBase.get_tag_list(tags)
152
153     @property
154     def url_chunk(self):
155         return '/'.join((Tag.categories_dict[self.category], self.slug))
156
157
158 # TODO: why is this hard-coded ?
159 def book_upload_path(ext=None):
160     def get_dynamic_path(media, filename, ext=ext):
161         # how to put related book's slug here?
162         if not ext:
163             ext = media.type
164         if not media.name:
165             name = slugify(filename.split(".")[0])
166         else:
167             name = slugify(media.name)
168         return 'lektura/%s.%s' % (name, ext)
169     return get_dynamic_path
170
171
172 class BookMedia(models.Model):
173     type        = models.CharField(_('type'), choices=MEDIA_FORMATS, max_length="100")
174     name        = models.CharField(_('name'), max_length="100", blank=True)
175     file        = models.FileField(_('file'), upload_to=book_upload_path(), blank=True)
176     uploaded_at = models.DateTimeField(_('creation date'), auto_now_add=True, editable=False)
177     extra_info  = JSONField(_('extra information'), default='{}')
178
179     def __unicode__(self):
180         return "%s (%s)" % (self.name, self.file.name.split("/")[-1])
181
182     class Meta:
183         ordering            = ('type', 'name')
184         verbose_name        = _('book media')
185         verbose_name_plural = _('book media')
186
187     def save(self, force_insert=False, force_update=False):
188         media = super(BookMedia, self).save(force_insert, force_update)
189         if self.type == 'mp3':
190             file = self.file
191             extra_info = self.get_extra_info_value()
192             extra_info.update(self.get_mp3_info())
193             self.set_extra_info_value(extra_info)
194             media = super(BookMedia, self).save(force_insert, force_update)
195         return media
196
197     def get_mp3_info(self):
198         """Retrieves artist and director names from audio ID3 tags."""
199         try:
200             audio = id3.ID3(self.file.path)
201             artist_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE1'))
202             director_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE3'))
203         except:
204             artist_name = director_name = ''
205         return {'artist_name': artist_name, 'director_name': director_name}
206
207
208 class Book(models.Model):
209     title         = models.CharField(_('title'), max_length=120)
210     slug          = models.SlugField(_('slug'), max_length=120, unique=True, db_index=True)
211     description   = models.TextField(_('description'), blank=True)
212     created_at    = models.DateTimeField(_('creation date'), auto_now_add=True)
213     _short_html   = models.TextField(_('short HTML'), editable=False)
214     parent_number = models.IntegerField(_('parent number'), default=0)
215     extra_info    = JSONField(_('extra information'))
216     gazeta_link   = models.CharField(blank=True, max_length=240)
217     wiki_link     = models.CharField(blank=True, max_length=240)
218     # files generated during publication
219     xml_file      = models.FileField(_('XML file'), upload_to=book_upload_path('xml'), blank=True)
220     html_file     = models.FileField(_('HTML file'), upload_to=book_upload_path('html'), blank=True)
221     pdf_file      = models.FileField(_('PDF file'), upload_to=book_upload_path('pdf'), blank=True)
222     epub_file     = models.FileField(_('EPUB file'), upload_to=book_upload_path('epub'), blank=True)    
223     txt_file      = models.FileField(_('TXT file'), upload_to=book_upload_path('txt'), blank=True)        
224     # other files
225     medias        = models.ManyToManyField(BookMedia, blank=True)
226     
227     parent        = models.ForeignKey('self', blank=True, null=True, related_name='children')
228     objects  = models.Manager()
229     tagged   = managers.ModelTaggedItemManager(Tag)
230     tags     = managers.TagDescriptor(Tag)
231
232     _tag_counter = JSONField(null=True, editable=False)
233     _theme_counter = JSONField(null=True, editable=False)
234
235     class AlreadyExists(Exception):
236         pass
237
238     class Meta:
239         ordering = ('title',)
240         verbose_name = _('book')
241         verbose_name_plural = _('books')
242
243     def __unicode__(self):
244         return self.title
245
246     def save(self, force_insert=False, force_update=False, reset_short_html=True, **kwargs):
247         if reset_short_html:
248             # Reset _short_html during save
249             update = {}
250             for key in filter(lambda x: x.startswith('_short_html'), self.__dict__):
251                 update[key] = ''
252                 self.__setattr__(key, '')
253             # Fragment.short_html relies on book's tags, so reset it here too
254             self.fragments.all().update(**update)
255
256         return super(Book, self).save(force_insert, force_update)
257
258     @permalink
259     def get_absolute_url(self):
260         return ('catalogue.views.book_detail', [self.slug])
261
262     @property
263     def name(self):
264         return self.title
265
266     def book_tag_slug(self):
267         return ('l-' + self.slug)[:120]
268
269     def book_tag(self):
270         slug = self.book_tag_slug()
271         book_tag, created = Tag.objects.get_or_create(slug=slug, category='book')
272         if created:
273             book_tag.name = self.title[:50]
274             book_tag.sort_key = self.title.lower()
275             book_tag.save()
276         return book_tag
277
278     def has_media(self, type):
279         if   type == 'xml':
280             if self.xml_file:
281                 return True
282             else:
283                 return False
284         elif type == 'html':
285             if self.html_file:
286                 return True
287             else:
288                 return False        
289         elif type == 'txt':
290             if self.txt_file:
291                 return True
292             else:
293                 return False        
294         elif type == 'pdf':
295             if self.pdf_file:
296                 return True
297             else:
298                 return False  
299         elif type == 'epub':
300             if self.epub_file:
301                 return True
302             else:
303                 return False                          
304         else:
305             if self.medias.filter(book=self, type=type).count() > 0:
306                 return True
307             else:
308                 return False
309
310     def get_media(self, type):
311         if self.has_media(type):
312             if   type == "xml":
313                 return self.xml_file
314             elif type == "html":
315                 return self.html_file
316             elif type == "epub":
317                 return self.epub_file
318             elif type == "txt":
319                 return self.txt_file
320             elif type == "pdf":
321                 return self.pdf_file
322             else:                                             
323                 return self.medias.filter(book=self, type=type)
324         else:
325             return None
326
327     def get_mp3(self):
328         return self.get_media("mp3")
329     def get_odt(self):
330         return self.get_media("odt")
331     def get_ogg(self):
332         return self.get_media("ogg")
333     def get_daisy(self):
334         return self.get_media("daisy")                       
335
336     def short_html(self):
337         key = '_short_html_%s' % get_language()
338         short_html = getattr(self, key)
339
340         if short_html and len(short_html):
341             return mark_safe(short_html)
342         else:
343             tags = self.tags.filter(~Q(category__in=('set', 'theme', 'book')))
344             tags = [mark_safe(u'<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name)) for tag in tags]
345
346             formats = []
347             # files generated during publication               
348             if self.has_media("html"):
349                 formats.append(u'<a href="%s">%s</a>' % (reverse('book_text', kwargs={'slug': self.slug}), _('Read online')))
350             if self.has_media("pdf"):
351                 formats.append(u'<a href="%s">PDF</a>' % self.get_media('pdf').url)
352             if self.root_ancestor.has_media("epub"):
353                 formats.append(u'<a href="%s">EPUB</a>' % self.root_ancestor.get_media('epub').url)
354             if self.has_media("txt"):
355                 formats.append(u'<a href="%s">TXT</a>' % self.get_media('txt').url)
356             # other files
357             for m in self.medias.order_by('type'):
358                 formats.append(u'<a href="%s">%s</a>' % (m.file.url, m.type.upper()))
359
360             formats = [mark_safe(format) for format in formats]
361
362             setattr(self, key, unicode(render_to_string('catalogue/book_short.html',
363                 {'book': self, 'tags': tags, 'formats': formats})))
364             self.save(reset_short_html=False)
365             return mark_safe(getattr(self, key))
366
367
368     @property
369     def root_ancestor(self):
370         """ returns the oldest ancestor """
371
372         if not hasattr(self, '_root_ancestor'):
373             book = self
374             while book.parent:
375                 book = book.parent
376             self._root_ancestor = book
377         return self._root_ancestor
378
379
380     def has_description(self):
381         return len(self.description) > 0
382     has_description.short_description = _('description')
383     has_description.boolean = True
384
385     # ugly ugly ugly
386     def has_pdf_file(self):
387         return bool(self.pdf_file)
388     has_pdf_file.short_description = 'PDF'
389     has_pdf_file.boolean = True
390
391     def has_epub_file(self):
392         return bool(self.epub_file)
393     has_epub_file.short_description = 'EPUB'
394     has_epub_file.boolean = True
395
396     def has_txt_file(self):
397         return bool(self.txt_file)
398     has_txt_file.short_description = 'HTML'
399     has_txt_file.boolean = True
400
401     def has_html_file(self):
402         return bool(self.html_file)
403     has_html_file.short_description = 'HTML'
404     has_html_file.boolean = True
405
406     def has_odt_file(self):
407         return bool(self.has_media("odt"))
408     has_odt_file.short_description = 'ODT'
409     has_odt_file.boolean = True
410
411     def has_mp3_file(self):
412         return bool(self.has_media("mp3"))
413     has_mp3_file.short_description = 'MP3'
414     has_mp3_file.boolean = True
415
416     def has_ogg_file(self):
417         return bool(self.has_media("ogg"))
418     has_ogg_file.short_description = 'OGG'
419     has_ogg_file.boolean = True
420     
421     def has_daisy_file(self):
422         return bool(self.has_media("daisy"))
423     has_daisy_file.short_description = 'DAISY'
424     has_daisy_file.boolean = True    
425     
426     def build_epub(self, remove_descendants=True):
427         """ (Re)builds the epub file.
428             If book has a parent, does nothing.
429             Unless remove_descendants is False, descendants' epubs are removed.
430         """
431     
432         from StringIO import StringIO
433         from hashlib import sha1
434         from django.core.files.base import ContentFile
435         from librarian import DocProvider
436
437         class BookImportDocProvider(DocProvider):
438             """ used for joined EPUBs """
439
440             def __init__(self, book):
441                 self.book = book
442
443             def by_slug(self, slug):
444                 if slug == self.book.slug:
445                     return self.book.xml_file
446                 else:
447                     return Book.objects.get(slug=slug).xml_file
448
449         if self.parent:
450             # don't need an epub
451             return
452
453         epub_file = StringIO()
454         try:
455             epub.transform(BookImportDocProvider(self), self.slug, output_file=epub_file)
456             self.epub_file.save('%s.epub' % self.slug, ContentFile(epub_file.getvalue()))
457             FileRecord(slug=self.slug, type='epub', sha1=sha1(epub_file.getvalue()).hexdigest()).save()
458         except NoDublinCore:
459             pass
460
461         book_descendants = list(self.children.all())
462         while len(book_descendants) > 0:
463             child_book = book_descendants.pop(0)
464             if remove_descendants and child_book.has_epub_file():
465                 child_book.epub_file.delete()
466             # save anyway, to refresh short_html
467             child_book.save()
468             book_descendants += list(child_book.children.all())
469
470     def build_txt(self):
471         from StringIO import StringIO
472         from django.core.files.base import ContentFile
473         from librarian import text
474
475         out = StringIO()
476         text.transform(open(self.xml_file.path), out)
477         self.txt_file.save('%s.txt' % self.slug, ContentFile(out.getvalue()))
478         self.save()
479
480
481     @classmethod
482     def from_xml_file(cls, xml_file, **kwargs):
483         # use librarian to parse meta-data
484         book_info = dcparser.parse(xml_file)
485
486         if not isinstance(xml_file, File):
487             xml_file = File(open(xml_file))
488
489         try:
490             return cls.from_text_and_meta(xml_file, book_info, **kwargs)
491         finally:
492             xml_file.close()
493
494     @classmethod
495     def from_text_and_meta(cls, raw_file, book_info, overwrite=False, build_epub=True, build_txt=True):
496         from tempfile import NamedTemporaryFile
497         from slughifi import slughifi
498         from markupstring import MarkupString
499         from django.core.files.storage import default_storage
500
501         # check for parts before we do anything
502         children = []
503         if hasattr(book_info, 'parts'):
504             for part_url in book_info.parts:
505                 base, slug = part_url.rsplit('/', 1)
506                 try:
507                     children.append(Book.objects.get(slug=slug))
508                 except Book.DoesNotExist, e:
509                     raise Book.DoesNotExist(_('Book with slug = "%s" does not exist.') % slug)
510
511
512         # Read book metadata
513         book_base, book_slug = book_info.url.rsplit('/', 1)
514         book, created = Book.objects.get_or_create(slug=book_slug)
515
516         if created:
517             book_shelves = []
518         else:
519             if not overwrite:
520                 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
521             # Save shelves for this book
522             book_shelves = list(book.tags.filter(category='set'))
523
524         book.title = book_info.title
525         book.set_extra_info_value(book_info.to_dict())
526         book._short_html = ''
527         book.save()
528
529         book_tags = []
530         categories = (('kinds', 'kind'), ('genres', 'genre'), ('authors', 'author'), ('epochs', 'epoch'))
531         for field_name, category in categories:
532             try:
533                 tag_names = getattr(book_info, field_name)
534             except:
535                 tag_names = [getattr(book_info, category)]
536             for tag_name in tag_names:
537                 tag_sort_key = tag_name
538                 if category == 'author':
539                     tag_sort_key = tag_name.last_name
540                     tag_name = ' '.join(tag_name.first_names) + ' ' + tag_name.last_name
541                 tag, created = Tag.objects.get_or_create(slug=slughifi(tag_name), category=category)
542                 if created:
543                     tag.name = tag_name
544                     tag.sort_key = tag_sort_key.lower()
545                     tag.save()
546                 book_tags.append(tag)
547
548         book.tags = book_tags + book_shelves
549
550         book_tag = book.book_tag()
551
552         for n, child_book in enumerate(children):
553             child_book.parent = book
554             child_book.parent_number = n
555             child_book.save()
556
557         # Save XML and HTML files
558         book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
559
560         # delete old fragments when overwriting
561         book.fragments.all().delete()
562
563         html_file = NamedTemporaryFile()
564         if html.transform(book.xml_file.path, html_file, parse_dublincore=False):
565             book.html_file.save('%s.html' % book.slug, File(html_file), save=False)
566
567             # get ancestor l-tags for adding to new fragments
568             ancestor_tags = []
569             p = book.parent
570             while p:
571                 ancestor_tags.append(p.book_tag())
572                 p = p.parent
573
574             # Extract fragments
575             closed_fragments, open_fragments = html.extract_fragments(book.html_file.path)
576             for fragment in closed_fragments.values():
577                 try:
578                     theme_names = [s.strip() for s in fragment.themes.split(',')]
579                 except AttributeError:
580                     continue
581                 themes = []
582                 for theme_name in theme_names:
583                     if not theme_name:
584                         continue
585                     tag, created = Tag.objects.get_or_create(slug=slughifi(theme_name), category='theme')
586                     if created:
587                         tag.name = theme_name
588                         tag.sort_key = theme_name.lower()
589                         tag.save()
590                     themes.append(tag)
591                 if not themes:
592                     continue
593
594                 text = fragment.to_string()
595                 short_text = ''
596                 if (len(MarkupString(text)) > 240):
597                     short_text = unicode(MarkupString(text)[:160])
598                 new_fragment, created = Fragment.objects.get_or_create(anchor=fragment.id, book=book,
599                     defaults={'text': text, 'short_text': short_text})
600
601                 new_fragment.save()
602                 new_fragment.tags = set(book_tags + themes + [book_tag] + ancestor_tags)
603
604         if not settings.NO_BUILD_TXT and build_txt:
605             book.build_txt()
606
607         if not settings.NO_BUILD_EPUB and build_epub:
608             book.root_ancestor.build_epub()
609
610         book_descendants = list(book.children.all())
611         # add l-tag to descendants and their fragments
612         # delete unnecessary EPUB files
613         while len(book_descendants) > 0:
614             child_book = book_descendants.pop(0)
615             child_book.tags = list(child_book.tags) + [book_tag]
616             child_book.save()
617             for fragment in child_book.fragments.all():
618                 fragment.tags = set(list(fragment.tags) + [book_tag])
619             book_descendants += list(child_book.children.all())
620
621         # refresh cache
622         book.reset_tag_counter()
623         book.reset_theme_counter()
624
625         book.save()
626         return book
627
628
629     def refresh_tag_counter(self):
630         tags = {}
631         for child in self.children.all().order_by():
632             for tag_pk, value in child.tag_counter.iteritems():
633                 tags[tag_pk] = tags.get(tag_pk, 0) + value
634         for tag in self.tags.exclude(category__in=('book', 'theme', 'set')).order_by():
635             tags[tag.pk] = 1
636         self.set__tag_counter_value(tags)
637         self.save(reset_short_html=False)
638         return tags
639
640     def reset_tag_counter(self):
641         self._tag_counter = None
642         self.save(reset_short_html=False)
643         if self.parent:
644             self.parent.reset_tag_counter()
645
646     @property
647     def tag_counter(self):
648         if self._tag_counter is None:
649             return self.refresh_tag_counter()
650         return dict((int(k), v) for k, v in self.get__tag_counter_value().iteritems())
651
652     def refresh_theme_counter(self):
653         tags = {}
654         for fragment in Fragment.tagged.with_any([self.book_tag()]).order_by():
655             for tag in fragment.tags.filter(category='theme').order_by():
656                 tags[tag.pk] = tags.get(tag.pk, 0) + 1
657         self.set__theme_counter_value(tags)
658         self.save(reset_short_html=False)
659         return tags
660
661     def reset_theme_counter(self):
662         self._theme_counter = None
663         self.save(reset_short_html=False)
664         if self.parent:
665             self.parent.reset_theme_counter()
666
667     @property
668     def theme_counter(self):
669         if self._theme_counter is None:
670             return self.refresh_theme_counter()
671         return dict((int(k), v) for k, v in self.get__theme_counter_value().iteritems())
672
673     def pretty_title(self, html_links=False):
674         book = self
675         names = list(book.tags.filter(category='author'))
676
677         books = []
678         while book:
679             books.append(book)
680             book = book.parent
681         names.extend(reversed(books))
682
683         if html_links:
684             names = ['<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name) for tag in names]
685         else:
686             names = [tag.name for tag in names]
687
688         return ', '.join(names)
689
690
691 class Fragment(models.Model):
692     text = models.TextField()
693     short_text = models.TextField(editable=False)
694     _short_html = models.TextField(editable=False)
695     anchor = models.CharField(max_length=120)
696     book = models.ForeignKey(Book, related_name='fragments')
697
698     objects = models.Manager()
699     tagged = managers.ModelTaggedItemManager(Tag)
700     tags = managers.TagDescriptor(Tag)
701
702     class Meta:
703         ordering = ('book', 'anchor',)
704         verbose_name = _('fragment')
705         verbose_name_plural = _('fragments')
706
707     def get_absolute_url(self):
708         return '%s#m%s' % (reverse('book_text', kwargs={'slug': self.book.slug}), self.anchor)
709
710     def short_html(self):
711         key = '_short_html_%s' % get_language()
712         short_html = getattr(self, key)
713         if short_html and len(short_html):
714             return mark_safe(short_html)
715         else:
716             setattr(self, key, unicode(render_to_string('catalogue/fragment_short.html',
717                 {'fragment': self})))
718             self.save()
719             return mark_safe(getattr(self, key))
720
721
722 class FileRecord(models.Model):
723     slug = models.SlugField(_('slug'), max_length=120, db_index=True)
724     type = models.CharField(_('type'), max_length=20, db_index=True)
725     sha1 = models.CharField(_('sha-1 hash'), max_length=40)
726     time = models.DateTimeField(_('time'), auto_now_add=True)
727
728     class Meta:
729         ordering = ('-time','-slug', '-type')
730         verbose_name = _('file record')
731         verbose_name_plural = _('file records')
732
733     def __unicode__(self):
734         return "%s %s.%s" % (self.sha1,  self.slug, self.type)
735
736 ###########
737 #
738 # SIGNALS
739 #
740 ###########
741
742
743 def _tags_updated_handler(sender, affected_tags, **kwargs):
744     # reset tag global counter
745     Tag.objects.filter(pk__in=[tag.pk for tag in affected_tags]).update(book_count=None)
746
747     # if book tags changed, reset book tag counter
748     if isinstance(sender, Book) and \
749                 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
750                     exclude(category__in=('book', 'theme', 'set')).count():
751         sender.reset_tag_counter()
752     # if fragment theme changed, reset book theme counter
753     elif isinstance(sender, Fragment) and \
754                 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
755                     filter(category='theme').count():
756         sender.book.reset_theme_counter()
757 tags_updated.connect(_tags_updated_handler)
758
759
760 def _m2m_changed_handler(sender, instance, action, reverse, pk_set, **kwargs):
761     """ refresh all the short_html stuff on BookMedia delete """
762     if sender == Book.medias.through and reverse and action == 'pre_clear':
763         for book in instance.book_set.all():
764             book.save()
765 m2m_changed.connect(_m2m_changed_handler)
766
767 def _pre_delete_handler(sender, instance, **kwargs):
768     """ explicitly clear m2m, so that Books can be refreshed """
769     if sender == BookMedia:
770         instance.book_set.clear()
771 pre_delete.connect(_pre_delete_handler)
772
773 def _post_save_handler(sender, instance, **kwargs):
774     """ refresh all the short_html stuff on BookMedia update """
775     if sender == BookMedia:
776         for book in instance.book_set.all():
777             book.save()
778 post_save.connect(_post_save_handler)
779