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