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