lesmianator on main
[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, maxlen=100):
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[:(maxlen-len('lektura/.%s' % ext))], 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")
175     file        = models.FileField(_('file'), upload_to=book_upload_path())
176     uploaded_at = models.DateTimeField(_('creation date'), auto_now_add=True, editable=False)
177     extra_info  = JSONField(_('extra information'), default='{}')
178
179     def book_count(self):
180         return self.book_set.count()
181     book_count.short_description = _('book count')
182
183     def books(self):
184         return mark_safe('<br/>'.join("<a href='%s'>%s</a>" % (reverse('admin:catalogue_book_change', args=[b.id]), b.title) for b in self.book_set.all()))
185     books.short_description = _('books')
186
187     def __unicode__(self):
188         return "%s (%s)" % (self.name, self.file.name.split("/")[-1])
189
190     class Meta:
191         ordering            = ('type', 'name')
192         verbose_name        = _('book media')
193         verbose_name_plural = _('book media')
194
195     def save(self, force_insert=False, force_update=False, **kwargs):
196         media = super(BookMedia, self).save(force_insert, force_update, **kwargs)
197         if self.type == 'mp3':
198             file = self.file
199             extra_info = self.get_extra_info_value()
200             extra_info.update(self.get_mp3_info())
201             self.set_extra_info_value(extra_info)
202             media = super(BookMedia, self).save(force_insert, force_update, **kwargs)
203         return media
204
205     def get_mp3_info(self):
206         """Retrieves artist and director names from audio ID3 tags."""
207         try:
208             audio = id3.ID3(self.file.path)
209             artist_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE1'))
210             director_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE3'))
211         except:
212             artist_name = director_name = ''
213         return {'artist_name': artist_name, 'director_name': director_name}
214
215
216 class Book(models.Model):
217     title         = models.CharField(_('title'), max_length=120)
218     slug          = models.SlugField(_('slug'), max_length=120, unique=True, db_index=True)
219     description   = models.TextField(_('description'), blank=True)
220     created_at    = models.DateTimeField(_('creation date'), auto_now_add=True)
221     _short_html   = models.TextField(_('short HTML'), editable=False)
222     parent_number = models.IntegerField(_('parent number'), default=0)
223     extra_info    = JSONField(_('extra information'))
224     gazeta_link   = models.CharField(blank=True, max_length=240)
225     wiki_link     = models.CharField(blank=True, max_length=240)
226     # files generated during publication
227     xml_file      = models.FileField(_('XML file'), upload_to=book_upload_path('xml'), blank=True)
228     html_file     = models.FileField(_('HTML file'), upload_to=book_upload_path('html'), blank=True)
229     pdf_file      = models.FileField(_('PDF file'), upload_to=book_upload_path('pdf'), blank=True)
230     epub_file     = models.FileField(_('EPUB file'), upload_to=book_upload_path('epub'), blank=True)    
231     txt_file      = models.FileField(_('TXT file'), upload_to=book_upload_path('txt'), blank=True)        
232     # other files
233     medias        = models.ManyToManyField(BookMedia, blank=True)
234     
235     parent        = models.ForeignKey('self', blank=True, null=True, related_name='children')
236     objects  = models.Manager()
237     tagged   = managers.ModelTaggedItemManager(Tag)
238     tags     = managers.TagDescriptor(Tag)
239
240     _tag_counter = JSONField(null=True, editable=False)
241     _theme_counter = JSONField(null=True, editable=False)
242
243     class AlreadyExists(Exception):
244         pass
245
246     class Meta:
247         ordering = ('title',)
248         verbose_name = _('book')
249         verbose_name_plural = _('books')
250
251     def __unicode__(self):
252         return self.title
253
254     def save(self, force_insert=False, force_update=False, reset_short_html=True, **kwargs):
255         if reset_short_html:
256             # Reset _short_html during save
257             update = {}
258             for key in filter(lambda x: x.startswith('_short_html'), self.__dict__):
259                 update[key] = ''
260                 self.__setattr__(key, '')
261             # Fragment.short_html relies on book's tags, so reset it here too
262             self.fragments.all().update(**update)
263
264         return super(Book, self).save(force_insert, force_update)
265
266     @permalink
267     def get_absolute_url(self):
268         return ('catalogue.views.book_detail', [self.slug])
269
270     @property
271     def name(self):
272         return self.title
273
274     def book_tag_slug(self):
275         return ('l-' + self.slug)[:120]
276
277     def book_tag(self):
278         slug = self.book_tag_slug()
279         book_tag, created = Tag.objects.get_or_create(slug=slug, category='book')
280         if created:
281             book_tag.name = self.title[:50]
282             book_tag.sort_key = self.title.lower()
283             book_tag.save()
284         return book_tag
285
286     def has_media(self, type):
287         if   type == 'xml':
288             if self.xml_file:
289                 return True
290             else:
291                 return False
292         elif type == 'html':
293             if self.html_file:
294                 return True
295             else:
296                 return False        
297         elif type == 'txt':
298             if self.txt_file:
299                 return True
300             else:
301                 return False        
302         elif type == 'pdf':
303             if self.pdf_file:
304                 return True
305             else:
306                 return False  
307         elif type == 'epub':
308             if self.epub_file:
309                 return True
310             else:
311                 return False                          
312         else:
313             if self.medias.filter(book=self, type=type).count() > 0:
314                 return True
315             else:
316                 return False
317
318     def get_media(self, type):
319         if self.has_media(type):
320             if   type == "xml":
321                 return self.xml_file
322             elif type == "html":
323                 return self.html_file
324             elif type == "epub":
325                 return self.epub_file
326             elif type == "txt":
327                 return self.txt_file
328             elif type == "pdf":
329                 return self.pdf_file
330             else:                                             
331                 return self.medias.filter(book=self, type=type)
332         else:
333             return None
334
335     def get_mp3(self):
336         return self.get_media("mp3")
337     def get_odt(self):
338         return self.get_media("odt")
339     def get_ogg(self):
340         return self.get_media("ogg")
341     def get_daisy(self):
342         return self.get_media("daisy")                       
343
344     def short_html(self):
345         key = '_short_html_%s' % get_language()
346         short_html = getattr(self, key)
347
348         if short_html and len(short_html):
349             return mark_safe(short_html)
350         else:
351             tags = self.tags.filter(~Q(category__in=('set', 'theme', 'book')))
352             tags = [mark_safe(u'<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name)) for tag in tags]
353
354             formats = []
355             # files generated during publication               
356             if self.has_media("html"):
357                 formats.append(u'<a href="%s">%s</a>' % (reverse('book_text', kwargs={'slug': self.slug}), _('Read online')))
358             if self.has_media("pdf"):
359                 formats.append(u'<a href="%s">PDF</a>' % self.get_media('pdf').url)
360             if self.root_ancestor.has_media("epub"):
361                 formats.append(u'<a href="%s">EPUB</a>' % self.root_ancestor.get_media('epub').url)
362             if self.has_media("txt"):
363                 formats.append(u'<a href="%s">TXT</a>' % self.get_media('txt').url)
364             # other files
365             for m in self.medias.order_by('type'):
366                 formats.append(u'<a href="%s">%s</a>' % (m.file.url, m.type.upper()))
367
368             formats = [mark_safe(format) for format in formats]
369
370             setattr(self, key, unicode(render_to_string('catalogue/book_short.html',
371                 {'book': self, 'tags': tags, 'formats': formats})))
372             self.save(reset_short_html=False)
373             return mark_safe(getattr(self, key))
374
375
376     @property
377     def root_ancestor(self):
378         """ returns the oldest ancestor """
379
380         if not hasattr(self, '_root_ancestor'):
381             book = self
382             while book.parent:
383                 book = book.parent
384             self._root_ancestor = book
385         return self._root_ancestor
386
387
388     def has_description(self):
389         return len(self.description) > 0
390     has_description.short_description = _('description')
391     has_description.boolean = True
392
393     # ugly ugly ugly
394     def has_pdf_file(self):
395         return bool(self.pdf_file)
396     has_pdf_file.short_description = 'PDF'
397     has_pdf_file.boolean = True
398
399     def has_epub_file(self):
400         return bool(self.epub_file)
401     has_epub_file.short_description = 'EPUB'
402     has_epub_file.boolean = True
403
404     def has_txt_file(self):
405         return bool(self.txt_file)
406     has_txt_file.short_description = 'HTML'
407     has_txt_file.boolean = True
408
409     def has_html_file(self):
410         return bool(self.html_file)
411     has_html_file.short_description = 'HTML'
412     has_html_file.boolean = True
413
414     def has_odt_file(self):
415         return bool(self.has_media("odt"))
416     has_odt_file.short_description = 'ODT'
417     has_odt_file.boolean = True
418
419     def has_mp3_file(self):
420         return bool(self.has_media("mp3"))
421     has_mp3_file.short_description = 'MP3'
422     has_mp3_file.boolean = True
423
424     def has_ogg_file(self):
425         return bool(self.has_media("ogg"))
426     has_ogg_file.short_description = 'OGG'
427     has_ogg_file.boolean = True
428     
429     def has_daisy_file(self):
430         return bool(self.has_media("daisy"))
431     has_daisy_file.short_description = 'DAISY'
432     has_daisy_file.boolean = True    
433     
434     def build_epub(self, remove_descendants=True):
435         """ (Re)builds the epub file.
436             If book has a parent, does nothing.
437             Unless remove_descendants is False, descendants' epubs are removed.
438         """
439     
440         from StringIO import StringIO
441         from hashlib import sha1
442         from django.core.files.base import ContentFile
443         from librarian import DocProvider
444
445         class BookImportDocProvider(DocProvider):
446             """ used for joined EPUBs """
447
448             def __init__(self, book):
449                 self.book = book
450
451             def by_slug(self, slug):
452                 if slug == self.book.slug:
453                     return self.book.xml_file
454                 else:
455                     return Book.objects.get(slug=slug).xml_file
456
457         if self.parent:
458             # don't need an epub
459             return
460
461         epub_file = StringIO()
462         try:
463             epub.transform(BookImportDocProvider(self), self.slug, output_file=epub_file)
464             self.epub_file.save('%s.epub' % self.slug, ContentFile(epub_file.getvalue()))
465             FileRecord(slug=self.slug, type='epub', sha1=sha1(epub_file.getvalue()).hexdigest()).save()
466         except NoDublinCore:
467             pass
468
469         book_descendants = list(self.children.all())
470         while len(book_descendants) > 0:
471             child_book = book_descendants.pop(0)
472             if remove_descendants and child_book.has_epub_file():
473                 child_book.epub_file.delete()
474             # save anyway, to refresh short_html
475             child_book.save()
476             book_descendants += list(child_book.children.all())
477
478     def build_txt(self):
479         from StringIO import StringIO
480         from django.core.files.base import ContentFile
481         from librarian import text
482
483         out = StringIO()
484         text.transform(open(self.xml_file.path), out)
485         self.txt_file.save('%s.txt' % self.slug, ContentFile(out.getvalue()))
486         self.save()
487
488
489     @classmethod
490     def from_xml_file(cls, xml_file, **kwargs):
491         # use librarian to parse meta-data
492         book_info = dcparser.parse(xml_file)
493
494         if not isinstance(xml_file, File):
495             xml_file = File(open(xml_file))
496
497         try:
498             return cls.from_text_and_meta(xml_file, book_info, **kwargs)
499         finally:
500             xml_file.close()
501
502     @classmethod
503     def from_text_and_meta(cls, raw_file, book_info, overwrite=False, build_epub=True, build_txt=True):
504         import re
505         from tempfile import NamedTemporaryFile
506         from markupstring import MarkupString
507         from django.core.files.storage import default_storage
508
509         # check for parts before we do anything
510         children = []
511         if hasattr(book_info, 'parts'):
512             for part_url in book_info.parts:
513                 base, slug = part_url.rsplit('/', 1)
514                 try:
515                     children.append(Book.objects.get(slug=slug))
516                 except Book.DoesNotExist, e:
517                     raise Book.DoesNotExist(_('Book with slug = "%s" does not exist.') % slug)
518
519
520         # Read book metadata
521         book_base, book_slug = book_info.url.rsplit('/', 1)
522         if re.search(r'[^a-zA-Z0-9-]', book_slug):
523             raise ValueError('Invalid characters in slug')
524         book, created = Book.objects.get_or_create(slug=book_slug)
525
526         if created:
527             book_shelves = []
528         else:
529             if not overwrite:
530                 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
531             # Save shelves for this book
532             book_shelves = list(book.tags.filter(category='set'))
533
534         book.title = book_info.title
535         book.set_extra_info_value(book_info.to_dict())
536         book._short_html = ''
537         book.save()
538
539         book_tags = []
540         categories = (('kinds', 'kind'), ('genres', 'genre'), ('authors', 'author'), ('epochs', 'epoch'))
541         for field_name, category in categories:
542             try:
543                 tag_names = getattr(book_info, field_name)
544             except:
545                 tag_names = [getattr(book_info, category)]
546             for tag_name in tag_names:
547                 tag_sort_key = tag_name
548                 if category == 'author':
549                     tag_sort_key = tag_name.last_name
550                     tag_name = ' '.join(tag_name.first_names) + ' ' + tag_name.last_name
551                 tag, created = Tag.objects.get_or_create(slug=slughifi(tag_name), category=category)
552                 if created:
553                     tag.name = tag_name
554                     tag.sort_key = tag_sort_key.lower()
555                     tag.save()
556                 book_tags.append(tag)
557
558         book.tags = book_tags + book_shelves
559
560         book_tag = book.book_tag()
561
562         for n, child_book in enumerate(children):
563             child_book.parent = book
564             child_book.parent_number = n
565             child_book.save()
566
567         # Save XML and HTML files
568         book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
569
570         # delete old fragments when overwriting
571         book.fragments.all().delete()
572
573         html_file = NamedTemporaryFile()
574         if html.transform(book.xml_file.path, html_file, parse_dublincore=False):
575             book.html_file.save('%s.html' % book.slug, File(html_file), save=False)
576
577             # get ancestor l-tags for adding to new fragments
578             ancestor_tags = []
579             p = book.parent
580             while p:
581                 ancestor_tags.append(p.book_tag())
582                 p = p.parent
583
584             # Extract fragments
585             closed_fragments, open_fragments = html.extract_fragments(book.html_file.path)
586             for fragment in closed_fragments.values():
587                 try:
588                     theme_names = [s.strip() for s in fragment.themes.split(',')]
589                 except AttributeError:
590                     continue
591                 themes = []
592                 for theme_name in theme_names:
593                     if not theme_name:
594                         continue
595                     tag, created = Tag.objects.get_or_create(slug=slughifi(theme_name), category='theme')
596                     if created:
597                         tag.name = theme_name
598                         tag.sort_key = theme_name.lower()
599                         tag.save()
600                     themes.append(tag)
601                 if not themes:
602                     continue
603
604                 text = fragment.to_string()
605                 short_text = ''
606                 if (len(MarkupString(text)) > 240):
607                     short_text = unicode(MarkupString(text)[:160])
608                 new_fragment, created = Fragment.objects.get_or_create(anchor=fragment.id, book=book,
609                     defaults={'text': text, 'short_text': short_text})
610
611                 new_fragment.save()
612                 new_fragment.tags = set(book_tags + themes + [book_tag] + ancestor_tags)
613
614         if not settings.NO_BUILD_TXT and build_txt:
615             book.build_txt()
616
617         if not settings.NO_BUILD_EPUB and build_epub:
618             book.root_ancestor.build_epub()
619
620         book_descendants = list(book.children.all())
621         # add l-tag to descendants and their fragments
622         # delete unnecessary EPUB files
623         while len(book_descendants) > 0:
624             child_book = book_descendants.pop(0)
625             child_book.tags = list(child_book.tags) + [book_tag]
626             child_book.save()
627             for fragment in child_book.fragments.all():
628                 fragment.tags = set(list(fragment.tags) + [book_tag])
629             book_descendants += list(child_book.children.all())
630
631         # refresh cache
632         book.reset_tag_counter()
633         book.reset_theme_counter()
634
635         book.save()
636         return book
637
638
639     def refresh_tag_counter(self):
640         tags = {}
641         for child in self.children.all().order_by():
642             for tag_pk, value in child.tag_counter.iteritems():
643                 tags[tag_pk] = tags.get(tag_pk, 0) + value
644         for tag in self.tags.exclude(category__in=('book', 'theme', 'set')).order_by():
645             tags[tag.pk] = 1
646         self.set__tag_counter_value(tags)
647         self.save(reset_short_html=False)
648         return tags
649
650     def reset_tag_counter(self):
651         self._tag_counter = None
652         self.save(reset_short_html=False)
653         if self.parent:
654             self.parent.reset_tag_counter()
655
656     @property
657     def tag_counter(self):
658         if self._tag_counter is None:
659             return self.refresh_tag_counter()
660         return dict((int(k), v) for k, v in self.get__tag_counter_value().iteritems())
661
662     def refresh_theme_counter(self):
663         tags = {}
664         for fragment in Fragment.tagged.with_any([self.book_tag()]).order_by():
665             for tag in fragment.tags.filter(category='theme').order_by():
666                 tags[tag.pk] = tags.get(tag.pk, 0) + 1
667         self.set__theme_counter_value(tags)
668         self.save(reset_short_html=False)
669         return tags
670
671     def reset_theme_counter(self):
672         self._theme_counter = None
673         self.save(reset_short_html=False)
674         if self.parent:
675             self.parent.reset_theme_counter()
676
677     @property
678     def theme_counter(self):
679         if self._theme_counter is None:
680             return self.refresh_theme_counter()
681         return dict((int(k), v) for k, v in self.get__theme_counter_value().iteritems())
682
683     def pretty_title(self, html_links=False):
684         book = self
685         names = list(book.tags.filter(category='author'))
686
687         books = []
688         while book:
689             books.append(book)
690             book = book.parent
691         names.extend(reversed(books))
692
693         if html_links:
694             names = ['<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name) for tag in names]
695         else:
696             names = [tag.name for tag in names]
697
698         return ', '.join(names)
699
700
701 class Fragment(models.Model):
702     text = models.TextField()
703     short_text = models.TextField(editable=False)
704     _short_html = models.TextField(editable=False)
705     anchor = models.CharField(max_length=120)
706     book = models.ForeignKey(Book, related_name='fragments')
707
708     objects = models.Manager()
709     tagged = managers.ModelTaggedItemManager(Tag)
710     tags = managers.TagDescriptor(Tag)
711
712     class Meta:
713         ordering = ('book', 'anchor',)
714         verbose_name = _('fragment')
715         verbose_name_plural = _('fragments')
716
717     def get_absolute_url(self):
718         return '%s#m%s' % (reverse('book_text', kwargs={'slug': self.book.slug}), self.anchor)
719
720     def short_html(self):
721         key = '_short_html_%s' % get_language()
722         short_html = getattr(self, key)
723         if short_html and len(short_html):
724             return mark_safe(short_html)
725         else:
726             setattr(self, key, unicode(render_to_string('catalogue/fragment_short.html',
727                 {'fragment': self})))
728             self.save()
729             return mark_safe(getattr(self, key))
730
731
732 class FileRecord(models.Model):
733     slug = models.SlugField(_('slug'), max_length=120, db_index=True)
734     type = models.CharField(_('type'), max_length=20, db_index=True)
735     sha1 = models.CharField(_('sha-1 hash'), max_length=40)
736     time = models.DateTimeField(_('time'), auto_now_add=True)
737
738     class Meta:
739         ordering = ('-time','-slug', '-type')
740         verbose_name = _('file record')
741         verbose_name_plural = _('file records')
742
743     def __unicode__(self):
744         return "%s %s.%s" % (self.sha1,  self.slug, self.type)
745
746 ###########
747 #
748 # SIGNALS
749 #
750 ###########
751
752
753 def _tags_updated_handler(sender, affected_tags, **kwargs):
754     # reset tag global counter
755     Tag.objects.filter(pk__in=[tag.pk for tag in affected_tags]).update(book_count=None)
756
757     # if book tags changed, reset book tag counter
758     if isinstance(sender, Book) and \
759                 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
760                     exclude(category__in=('book', 'theme', 'set')).count():
761         sender.reset_tag_counter()
762     # if fragment theme changed, reset book theme counter
763     elif isinstance(sender, Fragment) and \
764                 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
765                     filter(category='theme').count():
766         sender.book.reset_theme_counter()
767 tags_updated.connect(_tags_updated_handler)
768
769
770 def _m2m_changed_handler(sender, instance, action, reverse, pk_set, **kwargs):
771     """ refresh all the short_html stuff on BookMedia delete """
772     if sender == Book.medias.through and reverse and action == 'pre_clear':
773         for book in instance.book_set.all():
774             book.save()
775 m2m_changed.connect(_m2m_changed_handler)
776
777 def _pre_delete_handler(sender, instance, **kwargs):
778     """ explicitly clear m2m, so that Books can be refreshed """
779     if sender == BookMedia:
780         instance.book_set.clear()
781 pre_delete.connect(_pre_delete_handler)
782
783 def _post_save_handler(sender, instance, **kwargs):
784     """ refresh all the short_html stuff on BookMedia update """
785     if sender == BookMedia:
786         for book in instance.book_set.all():
787             book.save()
788 post_save.connect(_post_save_handler)
789