enlarge top menu font
[wolnelektury.git] / src / catalogue / models / book.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 collections import OrderedDict
6 from random import randint
7 import os.path
8 import re
9 import urllib
10 from django.conf import settings
11 from django.db import connection, models, transaction
12 from django.db.models import permalink
13 import django.dispatch
14 from django.contrib.contenttypes.fields import GenericRelation
15 from django.core.urlresolvers import reverse
16 from django.utils.translation import ugettext_lazy as _, get_language
17 from django.utils.deconstruct import deconstructible
18 import jsonfield
19 from fnpdjango.storage import BofhFileSystemStorage
20 from ssify import flush_ssi_includes
21
22 from librarian.html import transform_abstrakt
23 from newtagging import managers
24 from catalogue import constants
25 from catalogue.fields import EbookField
26 from catalogue.models import Tag, Fragment, BookMedia
27 from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags
28 from catalogue.models.tag import prefetched_relations
29 from catalogue import app_settings
30 from catalogue import tasks
31 from wolnelektury.utils import makedirs
32
33 bofh_storage = BofhFileSystemStorage()
34
35
36 @deconstructible
37 class UploadToPath(object):
38     def __init__(self, path):
39         self.path = path
40
41     def __call__(self, instance, filename):
42         return self.path % instance.slug
43
44
45 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
46 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
47 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
48 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
49
50
51 def _ebook_upload_to(upload_path):
52     return UploadToPath(upload_path)
53
54
55 class Book(models.Model):
56     """Represents a book imported from WL-XML."""
57     title = models.CharField(_('title'), max_length=32767)
58     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
59     sort_key_author = models.CharField(
60         _('sort key by author'), max_length=120, db_index=True, editable=False, default=u'')
61     slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
62     common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
63     language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
64     description = models.TextField(_('description'), blank=True)
65     abstract = models.TextField(_('abstract'), blank=True)
66     created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
67     changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
68     parent_number = models.IntegerField(_('parent number'), default=0)
69     extra_info = jsonfield.JSONField(_('extra information'), default={})
70     gazeta_link = models.CharField(blank=True, max_length=240)
71     wiki_link = models.CharField(blank=True, max_length=240)
72     print_on_demand = models.BooleanField(_('print on demand'), default=False)
73     recommended = models.BooleanField(_('recommended'), default=False)
74
75     # files generated during publication
76     cover = EbookField(
77         'cover', _('cover'),
78         null=True, blank=True,
79         upload_to=_cover_upload_to,
80         storage=bofh_storage, max_length=255)
81     # Cleaner version of cover for thumbs
82     cover_thumb = EbookField(
83         'cover_thumb', _('cover thumbnail'),
84         null=True, blank=True,
85         upload_to=_cover_thumb_upload_to,
86         max_length=255)
87     cover_api_thumb = EbookField(
88         'cover_api_thumb', _('cover thumbnail for mobile app'),
89         null=True, blank=True,
90         upload_to=_cover_api_thumb_upload_to,
91         max_length=255)
92     simple_cover = EbookField(
93         'simple_cover', _('cover for mobile app'),
94         null=True, blank=True,
95         upload_to=_simple_cover_upload_to,
96         max_length=255)
97     ebook_formats = constants.EBOOK_FORMATS
98     formats = ebook_formats + ['html', 'xml']
99
100     parent = models.ForeignKey('self', blank=True, null=True, related_name='children')
101     ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
102
103     cached_author = models.CharField(blank=True, max_length=240, db_index=True)
104     has_audience = models.BooleanField(default=False)
105
106     objects = models.Manager()
107     tagged = managers.ModelTaggedItemManager(Tag)
108     tags = managers.TagDescriptor(Tag)
109     tag_relations = GenericRelation(Tag.intermediary_table_model)
110
111     html_built = django.dispatch.Signal()
112     published = django.dispatch.Signal()
113
114     short_html_url_name = 'catalogue_book_short'
115
116     class AlreadyExists(Exception):
117         pass
118
119     class Meta:
120         ordering = ('sort_key_author', 'sort_key')
121         verbose_name = _('book')
122         verbose_name_plural = _('books')
123         app_label = 'catalogue'
124
125     def __unicode__(self):
126         return self.title
127
128     def get_initial(self):
129         try:
130             return re.search(r'\w', self.title, re.U).group(0)
131         except AttributeError:
132             return ''
133
134     def authors(self):
135         return self.tags.filter(category='author')
136
137     def tag_unicode(self, category):
138         relations = prefetched_relations(self, category)
139         if relations:
140             return ', '.join(rel.tag.name for rel in relations)
141         else:
142             return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
143
144     def tags_by_category(self):
145         return split_tags(self.tags.exclude(category__in=('set', 'theme')))
146
147     def author_unicode(self):
148         return self.cached_author
149
150     def translator(self):
151         translators = self.extra_info.get('translators')
152         if not translators:
153             return None
154         if len(translators) > 3:
155             translators = translators[:2]
156             others = ' i inni'
157         else:
158             others = ''
159         return ', '.join(u'\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
160
161     def cover_source(self):
162         return self.extra_info.get('cover_source', self.parent.cover_source() if self.parent else '')
163
164     def save(self, force_insert=False, force_update=False, **kwargs):
165         from sortify import sortify
166
167         self.sort_key = sortify(self.title)[:120]
168         self.title = unicode(self.title)  # ???
169
170         try:
171             author = self.authors().first().sort_key
172         except AttributeError:
173             author = u''
174         self.sort_key_author = author
175
176         self.cached_author = self.tag_unicode('author')
177         self.has_audience = 'audience' in self.extra_info
178
179         ret = super(Book, self).save(force_insert, force_update, **kwargs)
180
181         return ret
182
183     @permalink
184     def get_absolute_url(self):
185         return 'catalogue.views.book_detail', [self.slug]
186
187     @staticmethod
188     @permalink
189     def create_url(slug):
190         return 'catalogue.views.book_detail', [slug]
191
192     def gallery_path(self):
193         return gallery_path(self.slug)
194
195     def gallery_url(self):
196         return gallery_url(self.slug)
197
198     @property
199     def name(self):
200         return self.title
201
202     def language_code(self):
203         return constants.LANGUAGES_3TO2.get(self.language, self.language)
204
205     def language_name(self):
206         return dict(settings.LANGUAGES).get(self.language_code(), "")
207
208     def is_foreign(self):
209         return self.language_code() != settings.LANGUAGE_CODE
210
211     def has_media(self, type_):
212         if type_ in Book.formats:
213             return bool(getattr(self, "%s_file" % type_))
214         else:
215             return self.media.filter(type=type_).exists()
216
217     def has_audio(self):
218         return self.has_media('mp3')
219
220     def get_media(self, type_):
221         if self.has_media(type_):
222             if type_ in Book.formats:
223                 return getattr(self, "%s_file" % type_)
224             else:
225                 return self.media.filter(type=type_)
226         else:
227             return None
228
229     def get_mp3(self):
230         return self.get_media("mp3")
231
232     def get_odt(self):
233         return self.get_media("odt")
234
235     def get_ogg(self):
236         return self.get_media("ogg")
237
238     def get_daisy(self):
239         return self.get_media("daisy")
240
241     def has_description(self):
242         return len(self.description) > 0
243     has_description.short_description = _('description')
244     has_description.boolean = True
245
246     # ugly ugly ugly
247     def has_mp3_file(self):
248         return bool(self.has_media("mp3"))
249     has_mp3_file.short_description = 'MP3'
250     has_mp3_file.boolean = True
251
252     def has_ogg_file(self):
253         return bool(self.has_media("ogg"))
254     has_ogg_file.short_description = 'OGG'
255     has_ogg_file.boolean = True
256
257     def has_daisy_file(self):
258         return bool(self.has_media("daisy"))
259     has_daisy_file.short_description = 'DAISY'
260     has_daisy_file.boolean = True
261
262     def get_audiobooks(self):
263         ogg_files = {}
264         for m in self.media.filter(type='ogg').order_by().iterator():
265             ogg_files[m.name] = m
266
267         audiobooks = []
268         projects = set()
269         for mp3 in self.media.filter(type='mp3').iterator():
270             # ogg files are always from the same project
271             meta = mp3.extra_info
272             project = meta.get('project')
273             if not project:
274                 # temporary fallback
275                 project = u'CzytamySłuchając'
276
277             projects.add((project, meta.get('funded_by', '')))
278
279             media = {'mp3': mp3}
280
281             ogg = ogg_files.get(mp3.name)
282             if ogg:
283                 media['ogg'] = ogg
284             audiobooks.append(media)
285
286         projects = sorted(projects)
287         return audiobooks, projects
288
289     def wldocument(self, parse_dublincore=True, inherit=True):
290         from catalogue.import_utils import ORMDocProvider
291         from librarian.parser import WLDocument
292
293         if inherit and self.parent:
294             meta_fallbacks = self.parent.cover_info()
295         else:
296             meta_fallbacks = None
297
298         return WLDocument.from_file(
299             self.xml_file.path,
300             provider=ORMDocProvider(self),
301             parse_dublincore=parse_dublincore,
302             meta_fallbacks=meta_fallbacks)
303
304     @staticmethod
305     def zip_format(format_):
306         def pretty_file_name(book):
307             return "%s/%s.%s" % (
308                 book.extra_info['author'],
309                 book.slug,
310                 format_)
311
312         field_name = "%s_file" % format_
313         books = Book.objects.filter(parent=None).exclude(**{field_name: ""})
314         paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
315         return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
316
317     def zip_audiobooks(self, format_):
318         bm = BookMedia.objects.filter(book=self, type=format_)
319         paths = map(lambda bm: (None, bm.file.path), bm)
320         return create_zip(paths, "%s_%s" % (self.slug, format_))
321
322     def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
323         if index is None:
324             from search.index import Index
325             index = Index()
326         try:
327             index.index_book(self, book_info)
328             if index_tags:
329                 index.index_tags()
330             if commit:
331                 index.index.commit()
332         except Exception, e:
333             index.index.rollback()
334             raise e
335
336     def download_pictures(self, remote_gallery_url):
337         gallery_path = self.gallery_path()
338         # delete previous files, so we don't include old files in ebooks
339         if os.path.isdir(gallery_path):
340             for filename in os.listdir(gallery_path):
341                 file_path = os.path.join(gallery_path, filename)
342                 os.unlink(file_path)
343         ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
344         if ilustr_elements:
345             makedirs(gallery_path)
346             for ilustr in ilustr_elements:
347                 ilustr_src = ilustr.get('src')
348                 ilustr_path = os.path.join(gallery_path, ilustr_src)
349                 urllib.urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
350
351     def load_abstract(self):
352         abstract = self.wldocument().edoc.getroot().find('.//abstrakt')
353         if abstract is not None:
354             self.abstract = transform_abstrakt(abstract)
355         else:
356             self.abstract = ''
357
358     @classmethod
359     def from_xml_file(cls, xml_file, **kwargs):
360         from django.core.files import File
361         from librarian import dcparser
362
363         # use librarian to parse meta-data
364         book_info = dcparser.parse(xml_file)
365
366         if not isinstance(xml_file, File):
367             xml_file = File(open(xml_file))
368
369         try:
370             return cls.from_text_and_meta(xml_file, book_info, **kwargs)
371         finally:
372             xml_file.close()
373
374     @classmethod
375     def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
376                            search_index_tags=True, remote_gallery_url=None):
377         if dont_build is None:
378             dont_build = set()
379         dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
380
381         # check for parts before we do anything
382         children = []
383         if hasattr(book_info, 'parts'):
384             for part_url in book_info.parts:
385                 try:
386                     children.append(Book.objects.get(slug=part_url.slug))
387                 except Book.DoesNotExist:
388                     raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
389
390         # Read book metadata
391         book_slug = book_info.url.slug
392         if re.search(r'[^a-z0-9-]', book_slug):
393             raise ValueError('Invalid characters in slug')
394         book, created = Book.objects.get_or_create(slug=book_slug)
395
396         if created:
397             book_shelves = []
398             old_cover = None
399         else:
400             if not overwrite:
401                 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
402             # Save shelves for this book
403             book_shelves = list(book.tags.filter(category='set'))
404             old_cover = book.cover_info()
405
406         # Save XML file
407         book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
408
409         book.language = book_info.language
410         book.title = book_info.title
411         if book_info.variant_of:
412             book.common_slug = book_info.variant_of.slug
413         else:
414             book.common_slug = book.slug
415         book.extra_info = book_info.to_dict()
416         book.load_abstract()
417         book.save()
418
419         meta_tags = Tag.tags_from_info(book_info)
420
421         for tag in meta_tags:
422             if not tag.for_books:
423                 tag.for_books = True
424                 tag.save()
425
426         book.tags = set(meta_tags + book_shelves)
427
428         cover_changed = old_cover != book.cover_info()
429         obsolete_children = set(b for b in book.children.all()
430                                 if b not in children)
431         notify_cover_changed = []
432         for n, child_book in enumerate(children):
433             new_child = child_book.parent != book
434             child_book.parent = book
435             child_book.parent_number = n
436             child_book.save()
437             if new_child or cover_changed:
438                 notify_cover_changed.append(child_book)
439         # Disown unfaithful children and let them cope on their own.
440         for child in obsolete_children:
441             child.parent = None
442             child.parent_number = 0
443             child.save()
444             if old_cover:
445                 notify_cover_changed.append(child)
446
447         cls.repopulate_ancestors()
448         tasks.update_counters.delay()
449
450         if remote_gallery_url:
451             book.download_pictures(remote_gallery_url)
452
453         # No saves beyond this point.
454
455         # Build cover.
456         if 'cover' not in dont_build:
457             book.cover.build_delay()
458             book.cover_thumb.build_delay()
459             book.cover_api_thumb.build_delay()
460             book.simple_cover.build_delay()
461
462         # Build HTML and ebooks.
463         book.html_file.build_delay()
464         if not children:
465             for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
466                 if format_ not in dont_build:
467                     getattr(book, '%s_file' % format_).build_delay()
468         for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
469             if format_ not in dont_build:
470                 getattr(book, '%s_file' % format_).build_delay()
471
472         if not settings.NO_SEARCH_INDEX and search_index:
473             tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
474
475         for child in notify_cover_changed:
476             child.parent_cover_changed()
477
478         book.save()  # update sort_key_author
479         book.update_popularity()
480         cls.published.send(sender=cls, instance=book)
481         return book
482
483     @classmethod
484     @transaction.atomic
485     def repopulate_ancestors(cls):
486         """Fixes the ancestry cache."""
487         # TODO: table names
488         cursor = connection.cursor()
489         if connection.vendor == 'postgres':
490             cursor.execute("TRUNCATE catalogue_book_ancestor")
491             cursor.execute("""
492                 WITH RECURSIVE ancestry AS (
493                     SELECT book.id, book.parent_id
494                     FROM catalogue_book AS book
495                     WHERE book.parent_id IS NOT NULL
496                     UNION
497                     SELECT ancestor.id, book.parent_id
498                     FROM ancestry AS ancestor, catalogue_book AS book
499                     WHERE ancestor.parent_id = book.id
500                         AND book.parent_id IS NOT NULL
501                     )
502                 INSERT INTO catalogue_book_ancestor
503                     (from_book_id, to_book_id)
504                     SELECT id, parent_id
505                     FROM ancestry
506                     ORDER BY id;
507                 """)
508         else:
509             cursor.execute("DELETE FROM catalogue_book_ancestor")
510             for b in cls.objects.exclude(parent=None):
511                 parent = b.parent
512                 while parent is not None:
513                     b.ancestor.add(parent)
514                     parent = parent.parent
515
516     def flush_includes(self, languages=True):
517         if not languages:
518             return
519         if languages is True:
520             languages = [lc for (lc, _ln) in settings.LANGUAGES]
521         flush_ssi_includes([
522             template % (self.pk, lang)
523             for template in [
524                 '/katalog/b/%d/mini.%s.html',
525                 '/katalog/b/%d/mini_nolink.%s.html',
526                 '/katalog/b/%d/short.%s.html',
527                 '/katalog/b/%d/wide.%s.html',
528                 '/api/include/book/%d.%s.json',
529                 '/api/include/book/%d.%s.xml',
530                 ]
531             for lang in languages
532             ])
533
534     def cover_info(self, inherit=True):
535         """Returns a dictionary to serve as fallback for BookInfo.
536
537         For now, the only thing inherited is the cover image.
538         """
539         need = False
540         info = {}
541         for field in ('cover_url', 'cover_by', 'cover_source'):
542             val = self.extra_info.get(field)
543             if val:
544                 info[field] = val
545             else:
546                 need = True
547         if inherit and need and self.parent is not None:
548             parent_info = self.parent.cover_info()
549             parent_info.update(info)
550             info = parent_info
551         return info
552
553     def related_themes(self):
554         return Tag.objects.usage_for_queryset(
555             Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
556             counts=True).filter(category='theme')
557
558     def parent_cover_changed(self):
559         """Called when parent book's cover image is changed."""
560         if not self.cover_info(inherit=False):
561             if 'cover' not in app_settings.DONT_BUILD:
562                 self.cover.build_delay()
563                 self.cover_thumb.build_delay()
564                 self.cover_api_thumb.build_delay()
565                 self.simple_cover.build_delay()
566             for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
567                 if format_ not in app_settings.DONT_BUILD:
568                     getattr(self, '%s_file' % format_).build_delay()
569             for child in self.children.all():
570                 child.parent_cover_changed()
571
572     def other_versions(self):
573         """Find other versions (i.e. in other languages) of the book."""
574         return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
575
576     def parents(self):
577         books = []
578         parent = self.parent
579         while parent is not None:
580             books.insert(0, parent)
581             parent = parent.parent
582         return books
583
584     def pretty_title(self, html_links=False):
585         names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
586         books = self.parents() + [self]
587         names.extend([(b.title, b.get_absolute_url()) for b in books])
588
589         if html_links:
590             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
591         else:
592             names = [tag[0] for tag in names]
593         return ', '.join(names)
594
595     def publisher(self):
596         publisher = self.extra_info['publisher']
597         if isinstance(publisher, basestring):
598             return publisher
599         elif isinstance(publisher, list):
600             return ', '.join(publisher)
601
602     @classmethod
603     def tagged_top_level(cls, tags):
604         """ Returns top-level books tagged with `tags`.
605
606         It only returns those books which don't have ancestors which are
607         also tagged with those tags.
608
609         """
610         objects = cls.tagged.with_all(tags)
611         return objects.exclude(ancestor__in=objects)
612
613     @classmethod
614     def book_list(cls, book_filter=None):
615         """Generates a hierarchical listing of all books.
616
617         Books are optionally filtered with a test function.
618
619         """
620
621         books_by_parent = {}
622         books = cls.objects.order_by('parent_number', 'sort_key').only('title', 'parent', 'slug')
623         if book_filter:
624             books = books.filter(book_filter).distinct()
625
626             book_ids = set(b['pk'] for b in books.values("pk").iterator())
627             for book in books.iterator():
628                 parent = book.parent_id
629                 if parent not in book_ids:
630                     parent = None
631                 books_by_parent.setdefault(parent, []).append(book)
632         else:
633             for book in books.iterator():
634                 books_by_parent.setdefault(book.parent_id, []).append(book)
635
636         orphans = []
637         books_by_author = OrderedDict()
638         for tag in Tag.objects.filter(category='author').iterator():
639             books_by_author[tag] = []
640
641         for book in books_by_parent.get(None, ()):
642             authors = list(book.authors().only('pk'))
643             if authors:
644                 for author in authors:
645                     books_by_author[author].append(book)
646             else:
647                 orphans.append(book)
648
649         return books_by_author, orphans, books_by_parent
650
651     _audiences_pl = {
652         "SP": (1, u"szkoła podstawowa"),
653         "SP1": (1, u"szkoła podstawowa"),
654         "SP2": (1, u"szkoła podstawowa"),
655         "SP3": (1, u"szkoła podstawowa"),
656         "P": (1, u"szkoła podstawowa"),
657         "G": (2, u"gimnazjum"),
658         "L": (3, u"liceum"),
659         "LP": (3, u"liceum"),
660     }
661
662     def audiences_pl(self):
663         audiences = self.extra_info.get('audiences', [])
664         audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
665         return [a[1] for a in audiences]
666
667     def stage_note(self):
668         stage = self.extra_info.get('stage')
669         if stage and stage < '0.4':
670             return (_('This work needs modernisation'),
671                     reverse('infopage', args=['wymagajace-uwspolczesnienia']))
672         else:
673             return None, None
674
675     def choose_fragment(self):
676         fragments = self.fragments.order_by()
677         fragments_count = fragments.count()
678         if not fragments_count and self.children.exists():
679             fragments = Fragment.objects.filter(book__ancestor=self).order_by()
680             fragments_count = fragments.count()
681         if fragments_count:
682             return fragments[randint(0, fragments_count - 1)]
683         elif self.parent:
684             return self.parent.choose_fragment()
685         else:
686             return None
687
688     def fragment_data(self):
689         fragment = self.choose_fragment()
690         if fragment:
691             return {'title': fragment.book.pretty_title(), 'html': fragment.get_short_text()}
692         else:
693             return None
694
695     def update_popularity(self):
696         count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
697         try:
698             pop = self.popularity
699             pop.count = count
700             pop.save()
701         except BookPopularity.DoesNotExist:
702             BookPopularity.objects.create(book=self, count=count)
703
704     def ridero_link(self):
705         return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
706
707
708 def add_file_fields():
709     for format_ in Book.formats:
710         field_name = "%s_file" % format_
711         # This weird globals() assignment makes Django migrations comfortable.
712         _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
713         _upload_to.__name__ = '_%s_upload_to' % format_
714         globals()[_upload_to.__name__] = _upload_to
715
716         EbookField(
717             format_, _("%s file" % format_.upper()),
718             upload_to=_upload_to,
719             storage=bofh_storage,
720             max_length=255,
721             blank=True,
722             default=''
723         ).contribute_to_class(Book, field_name)
724
725 add_file_fields()
726
727
728 class BookPopularity(models.Model):
729     book = models.OneToOneField(Book, related_name='popularity')
730     count = models.IntegerField(default=0, db_index=True)