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