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