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