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