#1011: short_html refresh after bookmedia delete
[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()), save=False)
457             self.save()
458             FileRecord(slug=self.slug, type='epub', sha1=sha1(epub_file.getvalue()).hexdigest()).save()
459         except NoDublinCore:
460             pass
461
462         book_descendants = list(self.children.all())
463         while len(book_descendants) > 0:
464             child_book = book_descendants.pop(0)
465             if remove_descendants and child_book.has_epub_file():
466                 child_book.epub_file.delete()
467             # save anyway, to refresh short_html
468             child_book.save()
469             book_descendants += list(child_book.children.all())
470
471
472     @classmethod
473     def from_xml_file(cls, xml_file, overwrite=False, build_epub=True):
474         # use librarian to parse meta-data
475         book_info = dcparser.parse(xml_file)
476
477         if not isinstance(xml_file, File):
478             xml_file = File(open(xml_file))
479
480         try:
481             return cls.from_text_and_meta(xml_file, book_info, overwrite, build_epub=build_epub)
482         finally:
483             xml_file.close()
484
485     @classmethod
486     def from_text_and_meta(cls, raw_file, book_info, overwrite=False, build_epub=True):
487         from tempfile import NamedTemporaryFile
488         from slughifi import slughifi
489         from markupstring import MarkupString
490         from django.core.files.storage import default_storage
491
492         # check for parts before we do anything
493         children = []
494         if hasattr(book_info, 'parts'):
495             for part_url in book_info.parts:
496                 base, slug = part_url.rsplit('/', 1)
497                 try:
498                     children.append(Book.objects.get(slug=slug))
499                 except Book.DoesNotExist, e:
500                     raise Book.DoesNotExist(_('Book with slug = "%s" does not exist.') % slug)
501
502
503         # Read book metadata
504         book_base, book_slug = book_info.url.rsplit('/', 1)
505         book, created = Book.objects.get_or_create(slug=book_slug)
506
507         if created:
508             book_shelves = []
509         else:
510             if not overwrite:
511                 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
512             # Save shelves for this book
513             book_shelves = list(book.tags.filter(category='set'))
514
515         book.title = book_info.title
516         book.set_extra_info_value(book_info.to_dict())
517         book._short_html = ''
518         book.save()
519
520         book_tags = []
521         categories = (('kinds', 'kind'), ('genres', 'genre'), ('authors', 'author'), ('epochs', 'epoch'))
522         for field_name, category in categories:
523             try:
524                 tag_names = getattr(book_info, field_name)
525             except:
526                 tag_names = [getattr(book_info, category)]
527             for tag_name in tag_names:
528                 tag_sort_key = tag_name
529                 if category == 'author':
530                     tag_sort_key = tag_name.last_name
531                     tag_name = ' '.join(tag_name.first_names) + ' ' + tag_name.last_name
532                 tag, created = Tag.objects.get_or_create(slug=slughifi(tag_name), category=category)
533                 if created:
534                     tag.name = tag_name
535                     tag.sort_key = tag_sort_key.lower()
536                     tag.save()
537                 book_tags.append(tag)
538
539         book.tags = book_tags + book_shelves
540
541         book_tag = book.book_tag()
542
543         for n, child_book in enumerate(children):
544             child_book.parent = book
545             child_book.parent_number = n
546             child_book.save()
547
548         # Save XML and HTML files
549         book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
550
551         # delete old fragments when overwriting
552         book.fragments.all().delete()
553
554         html_file = NamedTemporaryFile()
555         if html.transform(book.xml_file.path, html_file, parse_dublincore=False):
556             book.html_file.save('%s.html' % book.slug, File(html_file), save=False)
557
558             # get ancestor l-tags for adding to new fragments
559             ancestor_tags = []
560             p = book.parent
561             while p:
562                 ancestor_tags.append(p.book_tag())
563                 p = p.parent
564
565             # Extract fragments
566             closed_fragments, open_fragments = html.extract_fragments(book.html_file.path)
567             for fragment in closed_fragments.values():
568                 try:
569                     theme_names = [s.strip() for s in fragment.themes.split(',')]
570                 except AttributeError:
571                     continue
572                 themes = []
573                 for theme_name in theme_names:
574                     if not theme_name:
575                         continue
576                     tag, created = Tag.objects.get_or_create(slug=slughifi(theme_name), category='theme')
577                     if created:
578                         tag.name = theme_name
579                         tag.sort_key = theme_name.lower()
580                         tag.save()
581                     themes.append(tag)
582                 if not themes:
583                     continue
584
585                 text = fragment.to_string()
586                 short_text = ''
587                 if (len(MarkupString(text)) > 240):
588                     short_text = unicode(MarkupString(text)[:160])
589                 new_fragment, created = Fragment.objects.get_or_create(anchor=fragment.id, book=book,
590                     defaults={'text': text, 'short_text': short_text})
591
592                 new_fragment.save()
593                 new_fragment.tags = set(book_tags + themes + [book_tag] + ancestor_tags)
594
595         if not settings.NO_BUILD_EPUB and build_epub:
596             book.root_ancestor.build_epub()
597
598         book_descendants = list(book.children.all())
599         # add l-tag to descendants and their fragments
600         # delete unnecessary EPUB files
601         while len(book_descendants) > 0:
602             child_book = book_descendants.pop(0)
603             child_book.tags = list(child_book.tags) + [book_tag]
604             child_book.save()
605             for fragment in child_book.fragments.all():
606                 fragment.tags = set(list(fragment.tags) + [book_tag])
607             book_descendants += list(child_book.children.all())
608
609         # refresh cache
610         book.reset_tag_counter()
611         book.reset_theme_counter()
612
613         book.save()
614         return book
615
616
617     def refresh_tag_counter(self):
618         tags = {}
619         for child in self.children.all().order_by():
620             for tag_pk, value in child.tag_counter.iteritems():
621                 tags[tag_pk] = tags.get(tag_pk, 0) + value
622         for tag in self.tags.exclude(category__in=('book', 'theme', 'set')).order_by():
623             tags[tag.pk] = 1
624         self.set__tag_counter_value(tags)
625         self.save(reset_short_html=False)
626         return tags
627
628     def reset_tag_counter(self):
629         self._tag_counter = None
630         self.save(reset_short_html=False)
631         if self.parent:
632             self.parent.reset_tag_counter()
633
634     @property
635     def tag_counter(self):
636         if self._tag_counter is None:
637             return self.refresh_tag_counter()
638         return dict((int(k), v) for k, v in self.get__tag_counter_value().iteritems())
639
640     def refresh_theme_counter(self):
641         tags = {}
642         for fragment in Fragment.tagged.with_any([self.book_tag()]).order_by():
643             for tag in fragment.tags.filter(category='theme').order_by():
644                 tags[tag.pk] = tags.get(tag.pk, 0) + 1
645         self.set__theme_counter_value(tags)
646         self.save(reset_short_html=False)
647         return tags
648
649     def reset_theme_counter(self):
650         self._theme_counter = None
651         self.save(reset_short_html=False)
652         if self.parent:
653             self.parent.reset_theme_counter()
654
655     @property
656     def theme_counter(self):
657         if self._theme_counter is None:
658             return self.refresh_theme_counter()
659         return dict((int(k), v) for k, v in self.get__theme_counter_value().iteritems())
660
661     def pretty_title(self, html_links=False):
662         book = self
663         names = list(book.tags.filter(category='author'))
664
665         books = []
666         while book:
667             books.append(book)
668             book = book.parent
669         names.extend(reversed(books))
670
671         if html_links:
672             names = ['<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name) for tag in names]
673         else:
674             names = [tag.name for tag in names]
675
676         return ', '.join(names)
677
678
679 class Fragment(models.Model):
680     text = models.TextField()
681     short_text = models.TextField(editable=False)
682     _short_html = models.TextField(editable=False)
683     anchor = models.CharField(max_length=120)
684     book = models.ForeignKey(Book, related_name='fragments')
685
686     objects = models.Manager()
687     tagged = managers.ModelTaggedItemManager(Tag)
688     tags = managers.TagDescriptor(Tag)
689
690     class Meta:
691         ordering = ('book', 'anchor',)
692         verbose_name = _('fragment')
693         verbose_name_plural = _('fragments')
694
695     def get_absolute_url(self):
696         return '%s#m%s' % (reverse('book_text', kwargs={'slug': self.book.slug}), self.anchor)
697
698     def short_html(self):
699         key = '_short_html_%s' % get_language()
700         short_html = getattr(self, key)
701         if short_html and len(short_html):
702             return mark_safe(short_html)
703         else:
704             setattr(self, key, unicode(render_to_string('catalogue/fragment_short.html',
705                 {'fragment': self})))
706             self.save()
707             return mark_safe(getattr(self, key))
708
709
710 class FileRecord(models.Model):
711     slug = models.SlugField(_('slug'), max_length=120, db_index=True)
712     type = models.CharField(_('type'), max_length=20, db_index=True)
713     sha1 = models.CharField(_('sha-1 hash'), max_length=40)
714     time = models.DateTimeField(_('time'), auto_now_add=True)
715
716     class Meta:
717         ordering = ('-time','-slug', '-type')
718         verbose_name = _('file record')
719         verbose_name_plural = _('file records')
720
721     def __unicode__(self):
722         return "%s %s.%s" % (self.sha1,  self.slug, self.type)
723
724 ###########
725 #
726 # SIGNALS
727 #
728 ###########
729
730
731 def _tags_updated_handler(sender, affected_tags, **kwargs):
732     # reset tag global counter
733     Tag.objects.filter(pk__in=[tag.pk for tag in affected_tags]).update(book_count=None)
734
735     # if book tags changed, reset book tag counter
736     if isinstance(sender, Book) and \
737                 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
738                     exclude(category__in=('book', 'theme', 'set')).count():
739         sender.reset_tag_counter()
740     # if fragment theme changed, reset book theme counter
741     elif isinstance(sender, Fragment) and \
742                 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
743                     filter(category='theme').count():
744         sender.book.reset_theme_counter()
745 tags_updated.connect(_tags_updated_handler)
746
747
748 def _m2m_changed_handler(sender, instance, action, reverse, pk_set, **kwargs):
749     """ refresh all the short_html stuff on BookMedia delete """
750     if sender == Book.medias.through and reverse and action == 'pre_clear':
751         for book in instance.book_set.all():
752             book.save()
753 m2m_changed.connect(_m2m_changed_handler)
754
755 def _pre_delete_handler(sender, instance, **kwargs):
756     """ explicitly clear m2m, so that Books can be refreshed """
757     if sender == BookMedia:
758         instance.book_set.clear()
759 pre_delete.connect(_pre_delete_handler)
760
761 def _post_save_handler(sender, instance, **kwargs):
762     """ refresh all the short_html stuff on BookMedia update """
763     if sender == BookMedia:
764         for book in instance.book_set.all():
765             book.save()
766 post_save.connect(_post_save_handler)
767