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