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