minor admin fix
[wolnelektury.git] / apps / catalogue / models.py
1 # -*- coding: utf-8 -*-
2 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
3 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
4 #
5 from django.db import models
6 from django.db.models import permalink, Q
7 from django.utils.translation import ugettext_lazy as _
8 from django.contrib.auth.models import User
9 from django.core.files import File
10 from django.template.loader import render_to_string
11 from django.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
16 from django.conf import settings
17
18 from newtagging.models import TagBase, tags_updated
19 from newtagging import managers
20 from catalogue.fields import JSONField
21
22 from librarian import dcparser, html, epub, NoDublinCore
23 from mutagen import id3
24
25
26 TAG_CATEGORIES = (
27     ('author', _('author')),
28     ('epoch', _('epoch')),
29     ('kind', _('kind')),
30     ('genre', _('genre')),
31     ('theme', _('theme')),
32     ('set', _('set')),
33     ('book', _('book')),
34 )
35
36 MEDIA_FORMATS = (
37     ('odt', _('ODT file')),
38     ('mp3', _('MP3 file')),
39     ('ogg', _('OGG file')),
40     ('daisy', _('DAISY file')), 
41 )
42
43 class TagSubcategoryManager(models.Manager):
44     def __init__(self, subcategory):
45         super(TagSubcategoryManager, self).__init__()
46         self.subcategory = subcategory
47
48     def get_query_set(self):
49         return super(TagSubcategoryManager, self).get_query_set().filter(category=self.subcategory)
50
51
52 class Tag(TagBase):
53     name = models.CharField(_('name'), max_length=50, db_index=True)
54     slug = models.SlugField(_('slug'), max_length=120, db_index=True)
55     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True)
56     category = models.CharField(_('category'), max_length=50, blank=False, null=False,
57         db_index=True, choices=TAG_CATEGORIES)
58     description = models.TextField(_('description'), blank=True)
59     main_page = models.BooleanField(_('main page'), default=False, db_index=True, help_text=_('Show tag on main page'))
60
61     user = models.ForeignKey(User, blank=True, null=True)
62     book_count = models.IntegerField(_('book count'), blank=True, null=True)
63     gazeta_link = models.CharField(blank=True, max_length=240)
64     wiki_link = models.CharField(blank=True, max_length=240)
65
66     categories_rev = {
67         'autor': 'author',
68         'epoka': 'epoch',
69         'rodzaj': 'kind',
70         'gatunek': 'genre',
71         'motyw': 'theme',
72         'polka': 'set',
73     }
74     categories_dict = dict((item[::-1] for item in categories_rev.iteritems()))
75
76     class Meta:
77         ordering = ('sort_key',)
78         verbose_name = _('tag')
79         verbose_name_plural = _('tags')
80         unique_together = (("slug", "category"),)
81
82     def __unicode__(self):
83         return self.name
84
85     def __repr__(self):
86         return "Tag(slug=%r)" % self.slug
87
88     @permalink
89     def get_absolute_url(self):
90         return ('catalogue.views.tagged_object_list', [self.url_chunk])
91
92     def has_description(self):
93         return len(self.description) > 0
94     has_description.short_description = _('description')
95     has_description.boolean = True
96
97     def get_count(self):
98         """ returns global book count for book tags, fragment count for themes """
99
100         if self.book_count is None:
101             if self.category == 'book':
102                 # never used
103                 objects = Book.objects.none()
104             elif self.category == 'theme':
105                 objects = Fragment.tagged.with_all((self,))
106             else:
107                 objects = Book.tagged.with_all((self,)).order_by()
108                 if self.category != 'set':
109                     # eliminate descendants
110                     l_tags = Tag.objects.filter(slug__in=[book.book_tag_slug() for book in objects])
111                     descendants_keys = [book.pk for book in Book.tagged.with_any(l_tags)]
112                     if descendants_keys:
113                         objects = objects.exclude(pk__in=descendants_keys)
114             self.book_count = objects.count()
115             self.save()
116         return self.book_count
117
118     @staticmethod
119     def get_tag_list(tags):
120         if isinstance(tags, basestring):
121             real_tags = []
122             ambiguous_slugs = []
123             category = None
124             tags_splitted = tags.split('/')
125             for index, name in enumerate(tags_splitted):
126                 if name in Tag.categories_rev:
127                     category = Tag.categories_rev[name]
128                 else:
129                     if category:
130                         real_tags.append(Tag.objects.get(slug=name, category=category))
131                         category = None
132                     else:
133                         try:
134                             real_tags.append(Tag.objects.exclude(category='book').get(slug=name))
135                         except Tag.MultipleObjectsReturned, e:
136                             ambiguous_slugs.append(name)
137
138             if category:
139                 # something strange left off
140                 raise Tag.DoesNotExist()
141             if ambiguous_slugs:
142                 # some tags should be qualified
143                 e = Tag.MultipleObjectsReturned()
144                 e.tags = real_tags
145                 e.ambiguous_slugs = ambiguous_slugs
146                 raise e
147             else:
148                 return real_tags
149         else:
150             return TagBase.get_tag_list(tags)
151
152     @property
153     def url_chunk(self):
154         return '/'.join((Tag.categories_dict[self.category], self.slug))
155
156
157 # TODO: why is this hard-coded ?
158 def book_upload_path(ext=None):
159     def get_dynamic_path(media, filename, ext=ext):
160         # how to put related book's slug here?
161         if not ext:
162             ext = media.type
163         if not media.name:
164             name = slugify(filename.split(".")[0])
165         else:
166             name = slugify(media.name)
167         return 'lektura/%s.%s' % (name, ext)
168     return get_dynamic_path
169
170
171 class BookMedia(models.Model):
172     type        = models.CharField(_('type'), choices=MEDIA_FORMATS, max_length="100")
173     name        = models.CharField(_('name'), max_length="100", blank=True)
174     file        = models.FileField(_('file'), upload_to=book_upload_path(), blank=True)
175     uploaded_at = models.DateTimeField(_('creation date'), auto_now_add=True, editable=False)
176     extra_info  = JSONField(_('extra information'), default='{}')
177
178     def __unicode__(self):
179         return "%s (%s)" % (self.name, self.file.name.split("/")[-1])
180
181     class Meta:
182         ordering            = ('type', 'name')
183         verbose_name        = _('book media')
184         verbose_name_plural = _('book media')
185
186     def save(self, force_insert=False, force_update=False):
187         media = super(BookMedia, self).save(force_insert, force_update)
188         if self.type == 'mp3':
189             file = self.file
190             extra_info = self.get_extra_info_value()
191             extra_info.update(self.get_mp3_info())
192             self.set_extra_info_value(extra_info)
193             media = super(BookMedia, self).save(force_insert, force_update)
194         return media
195
196     def get_mp3_info(self):
197         """Retrieves artist and director names from audio ID3 tags."""
198         try:
199             audio = id3.ID3(self.file.path)
200             artist_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE1'))
201             director_name = ', '.join(', '.join(tag.text) for tag in audio.getall('TPE3'))
202         except:
203             artist_name = director_name = ''
204         return {'artist_name': artist_name, 'director_name': director_name}
205
206
207 class Book(models.Model):
208     title         = models.CharField(_('title'), max_length=120)
209     slug          = models.SlugField(_('slug'), max_length=120, unique=True, db_index=True)
210     description   = models.TextField(_('description'), blank=True)
211     created_at    = models.DateTimeField(_('creation date'), auto_now_add=True)
212     _short_html   = models.TextField(_('short HTML'), editable=False)
213     parent_number = models.IntegerField(_('parent number'), default=0)
214     extra_info    = JSONField(_('extra information'))
215     gazeta_link   = models.CharField(blank=True, max_length=240)
216     wiki_link     = models.CharField(blank=True, max_length=240)
217     # files generated during publication
218     xml_file      = models.FileField(_('XML file'), upload_to=book_upload_path('xml'), blank=True)
219     html_file     = models.FileField(_('HTML file'), upload_to=book_upload_path('html'), blank=True)
220     pdf_file      = models.FileField(_('PDF file'), upload_to=book_upload_path('pdf'), blank=True)
221     epub_file     = models.FileField(_('EPUB file'), upload_to=book_upload_path('epub'), blank=True)    
222     txt_file      = models.FileField(_('TXT file'), upload_to=book_upload_path('txt'), blank=True)        
223     # other files
224     medias        = models.ManyToManyField(BookMedia, blank=True)
225     
226     parent        = models.ForeignKey('self', blank=True, null=True, related_name='children')
227     objects  = models.Manager()
228     tagged   = managers.ModelTaggedItemManager(Tag)
229     tags     = managers.TagDescriptor(Tag)
230
231     _tag_counter = JSONField(null=True, editable=False)
232     _theme_counter = JSONField(null=True, editable=False)
233
234     class AlreadyExists(Exception):
235         pass
236
237     class Meta:
238         ordering = ('title',)
239         verbose_name = _('book')
240         verbose_name_plural = _('books')
241
242     def __unicode__(self):
243         return self.title
244
245     def save(self, force_insert=False, force_update=False, reset_short_html=True, **kwargs):
246         if reset_short_html:
247             # Reset _short_html during save
248             update = {}
249             for key in filter(lambda x: x.startswith('_short_html'), self.__dict__):
250                 update[key] = ''
251                 self.__setattr__(key, '')
252             # Fragment.short_html relies on book's tags, so reset it here too
253             self.fragments.all().update(**update)
254
255         return super(Book, self).save(force_insert, force_update)
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("txt"):
354                 formats.append(u'<a href="%s">TXT</a>' % self.get_media('txt').url)
355             # other files
356             for m in self.medias.order_by('type'):
357                 formats.append(u'<a href="%s">%s</a>' % (m.file.url, m.type.upper()))
358
359             formats = [mark_safe(format) for format in formats]
360
361             setattr(self, key, unicode(render_to_string('catalogue/book_short.html',
362                 {'book': self, 'tags': tags, 'formats': formats})))
363             self.save(reset_short_html=False)
364             return mark_safe(getattr(self, key))
365
366
367     @property
368     def root_ancestor(self):
369         """ returns the oldest ancestor """
370
371         if not hasattr(self, '_root_ancestor'):
372             book = self
373             while book.parent:
374                 book = book.parent
375             self._root_ancestor = book
376         return self._root_ancestor
377
378
379     def has_description(self):
380         return len(self.description) > 0
381     has_description.short_description = _('description')
382     has_description.boolean = True
383
384     # ugly ugly ugly
385     def has_pdf_file(self):
386         return bool(self.pdf_file)
387     has_pdf_file.short_description = 'PDF'
388     has_pdf_file.boolean = True
389
390     def has_epub_file(self):
391         return bool(self.epub_file)
392     has_epub_file.short_description = 'EPUB'
393     has_epub_file.boolean = True
394
395     def has_txt_file(self):
396         return bool(self.txt_file)
397     has_txt_file.short_description = 'HTML'
398     has_txt_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, output_file=epub_file)
455             self.epub_file.save('%s.epub' % self.slug, ContentFile(epub_file.getvalue()), save=False)
456             self.save()
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()
468             book_descendants += list(child_book.children.all())
469
470
471     @classmethod
472     def from_xml_file(cls, xml_file, overwrite=False, build_epub=True):
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, build_epub=build_epub)
481         finally:
482             xml_file.close()
483
484     @classmethod
485     def from_text_and_meta(cls, raw_file, book_info, overwrite=False, build_epub=True):
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 and 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)
625         return tags
626
627     def reset_tag_counter(self):
628         self._tag_counter = None
629         self.save(reset_short_html=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)
646         return tags
647
648     def reset_theme_counter(self):
649         self._theme_counter = None
650         self.save(reset_short_html=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     def pretty_title(self, html_links=False):
661         book = self
662         names = list(book.tags.filter(category='author'))
663
664         books = []
665         while book:
666             books.append(book)
667             book = book.parent
668         names.extend(reversed(books))
669
670         if html_links:
671             names = ['<a href="%s">%s</a>' % (tag.get_absolute_url(), tag.name) for tag in names]
672         else:
673             names = [tag.name for tag in names]
674
675         return ', '.join(names)
676
677
678 class Fragment(models.Model):
679     text = models.TextField()
680     short_text = models.TextField(editable=False)
681     _short_html = models.TextField(editable=False)
682     anchor = models.CharField(max_length=120)
683     book = models.ForeignKey(Book, related_name='fragments')
684
685     objects = models.Manager()
686     tagged = managers.ModelTaggedItemManager(Tag)
687     tags = managers.TagDescriptor(Tag)
688
689     class Meta:
690         ordering = ('book', 'anchor',)
691         verbose_name = _('fragment')
692         verbose_name_plural = _('fragments')
693
694     def get_absolute_url(self):
695         return '%s#m%s' % (reverse('book_text', kwargs={'slug': self.book.slug}), self.anchor)
696
697     def short_html(self):
698         key = '_short_html_%s' % get_language()
699         short_html = getattr(self, key)
700         if short_html and len(short_html):
701             return mark_safe(short_html)
702         else:
703             setattr(self, key, unicode(render_to_string('catalogue/fragment_short.html',
704                 {'fragment': self})))
705             self.save()
706             return mark_safe(getattr(self, key))
707
708
709 class FileRecord(models.Model):
710     slug = models.SlugField(_('slug'), max_length=120, db_index=True)
711     type = models.CharField(_('type'), max_length=20, db_index=True)
712     sha1 = models.CharField(_('sha-1 hash'), max_length=40)
713     time = models.DateTimeField(_('time'), auto_now_add=True)
714
715     class Meta:
716         ordering = ('-time','-slug', '-type')
717         verbose_name = _('file record')
718         verbose_name_plural = _('file records')
719
720     def __unicode__(self):
721         return "%s %s.%s" % (self.sha1,  self.slug, self.type)
722
723
724 def _tags_updated_handler(sender, affected_tags, **kwargs):
725     # reset tag global counter
726     Tag.objects.filter(pk__in=[tag.pk for tag in affected_tags]).update(book_count=None)
727
728     # if book tags changed, reset book tag counter
729     if isinstance(sender, Book) and \
730                 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
731                     exclude(category__in=('book', 'theme', 'set')).count():
732         sender.reset_tag_counter()
733     # if fragment theme changed, reset book theme counter
734     elif isinstance(sender, Fragment) and \
735                 Tag.objects.filter(pk__in=(tag.pk for tag in affected_tags)).\
736                     filter(category='theme').count():
737         sender.book.reset_theme_counter()
738 tags_updated.connect(_tags_updated_handler)
739