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