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