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