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