X-Git-Url: https://git.mdrn.pl/wolnelektury.git/blobdiff_plain/87e084d47c335cd6f0b3e91d614999f55d148044..ace92112e99e3f48466e4d230087c3ab40187450:/src/catalogue/models/book.py?ds=sidebyside diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py index 1524c3456..c0d47f9e2 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -3,132 +3,215 @@ # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. # from collections import OrderedDict +from datetime import date, timedelta from random import randint +import os.path import re +import urllib from django.conf import settings from django.db import connection, models, transaction from django.db.models import permalink import django.dispatch from django.contrib.contenttypes.fields import GenericRelation from django.core.urlresolvers import reverse -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ugettext_lazy as _, get_language +from django.utils.deconstruct import deconstructible import jsonfield from fnpdjango.storage import BofhFileSystemStorage from ssify import flush_ssi_includes + +from librarian.cover import WLCover +from librarian.html import transform_abstrakt from newtagging import managers from catalogue import constants from catalogue.fields import EbookField from catalogue.models import Tag, Fragment, BookMedia -from catalogue.utils import create_zip +from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags +from catalogue.models.tag import prefetched_relations from catalogue import app_settings from catalogue import tasks +from wolnelektury.utils import makedirs bofh_storage = BofhFileSystemStorage() -def _cover_upload_to(i, n): - return 'book/cover/%s.jpg' % i.slug +@deconstructible +class UploadToPath(object): + def __init__(self, path): + self.path = path + + def __call__(self, instance, filename): + return self.path % instance.slug + + +_cover_upload_to = UploadToPath('book/cover/%s.jpg') +_cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg') +_cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg') +_simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg') -def _cover_thumb_upload_to(i, n): - return 'book/cover_thumb/%s.jpg' % i.slug def _ebook_upload_to(upload_path): - def _upload_to(i, n): - return upload_path % i.slug - return _upload_to + return UploadToPath(upload_path) class Book(models.Model): """Represents a book imported from WL-XML.""" - title = models.CharField(_('title'), max_length=32767) + title = models.CharField(_('title'), max_length=32767) sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False) - sort_key_author = models.CharField(_('sort key by author'), max_length=120, db_index=True, editable=False, default=u'') - slug = models.SlugField(_('slug'), max_length=120, db_index=True, - unique=True) + sort_key_author = models.CharField( + _('sort key by author'), max_length=120, db_index=True, editable=False, default=u'') + slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True) common_slug = models.SlugField(_('slug'), max_length=120, db_index=True) - language = models.CharField(_('language code'), max_length=3, db_index=True, - default=app_settings.DEFAULT_LANGUAGE) - description = models.TextField(_('description'), blank=True) - created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True) - changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True) + language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE) + description = models.TextField(_('description'), blank=True) + abstract = models.TextField(_('abstract'), blank=True) + created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True) + changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True) parent_number = models.IntegerField(_('parent number'), default=0) - extra_info = jsonfield.JSONField(_('extra information'), default={}) - gazeta_link = models.CharField(blank=True, max_length=240) - wiki_link = models.CharField(blank=True, max_length=240) + extra_info = jsonfield.JSONField(_('extra information'), default={}) + gazeta_link = models.CharField(blank=True, max_length=240) + wiki_link = models.CharField(blank=True, max_length=240) + print_on_demand = models.BooleanField(_('print on demand'), default=False) + recommended = models.BooleanField(_('recommended'), default=False) + audio_length = models.CharField(_('audio length'), blank=True, max_length=8) + preview = models.BooleanField(_('preview'), default=False) + preview_until = models.DateField(_('preview until'), blank=True, null=True) # files generated during publication - cover = EbookField('cover', _('cover'), - null=True, blank=True, - upload_to=_cover_upload_to, - storage=bofh_storage, max_length=255) + cover = EbookField( + 'cover', _('cover'), + null=True, blank=True, + upload_to=_cover_upload_to, + storage=bofh_storage, max_length=255) # Cleaner version of cover for thumbs - cover_thumb = EbookField('cover_thumb', _('cover thumbnail'), - null=True, blank=True, - upload_to=_cover_thumb_upload_to, - max_length=255) + cover_thumb = EbookField( + 'cover_thumb', _('cover thumbnail'), + null=True, blank=True, + upload_to=_cover_thumb_upload_to, + max_length=255) + cover_api_thumb = EbookField( + 'cover_api_thumb', _('cover thumbnail for mobile app'), + null=True, blank=True, + upload_to=_cover_api_thumb_upload_to, + max_length=255) + simple_cover = EbookField( + 'simple_cover', _('cover for mobile app'), + null=True, blank=True, + upload_to=_simple_cover_upload_to, + max_length=255) ebook_formats = constants.EBOOK_FORMATS formats = ebook_formats + ['html', 'xml'] - parent = models.ForeignKey('self', blank=True, null=True, - related_name='children') - ancestor = models.ManyToManyField('self', blank=True, - editable=False, related_name='descendant', symmetrical=False) + parent = models.ForeignKey('self', blank=True, null=True, related_name='children') + ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False) + + cached_author = models.CharField(blank=True, max_length=240, db_index=True) + has_audience = models.BooleanField(default=False) - objects = models.Manager() - tagged = managers.ModelTaggedItemManager(Tag) - tags = managers.TagDescriptor(Tag) + objects = models.Manager() + tagged = managers.ModelTaggedItemManager(Tag) + tags = managers.TagDescriptor(Tag) tag_relations = GenericRelation(Tag.intermediary_table_model) html_built = django.dispatch.Signal() published = django.dispatch.Signal() - short_html_url_name = 'catalogue_book_short' + SORT_KEY_SEP = '$' class AlreadyExists(Exception): pass class Meta: - ordering = ('sort_key',) + ordering = ('sort_key_author', 'sort_key') verbose_name = _('book') verbose_name_plural = _('books') app_label = 'catalogue' - def __unicode__(self): + def __str__(self): return self.title def get_initial(self): try: return re.search(r'\w', self.title, re.U).group(0) except AttributeError: + return '' + + def authors(self): + return self.tags.filter(category='author') + + def epochs(self): + return self.tags.filter(category='epoch') + + def genres(self): + return self.tags.filter(category='genre') + + def kinds(self): + return self.tags.filter(category='kind') + + def tag_unicode(self, category): + relations = prefetched_relations(self, category) + if relations: + return ', '.join(rel.tag.name for rel in relations) + else: + return ', '.join(self.tags.filter(category=category).values_list('name', flat=True)) + + def tags_by_category(self): + return split_tags(self.tags.exclude(category__in=('set', 'theme'))) + + def author_unicode(self): + return self.cached_author + + def kind_unicode(self): + return self.tag_unicode('kind') + + def epoch_unicode(self): + return self.tag_unicode('epoch') + + def genre_unicode(self): + return self.tag_unicode('genre') + + def translator(self): + translators = self.extra_info.get('translators') + if not translators: return None + if len(translators) > 3: + translators = translators[:2] + others = ' i inni' + else: + others = '' + return ', '.join(u'\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others - def author_str(self): - return ", ".join(str(t) for t in self.tags.filter(category='author')) + def cover_source(self): + return self.extra_info.get('cover_source', self.parent.cover_source() if self.parent else '') def save(self, force_insert=False, force_update=False, **kwargs): from sortify import sortify self.sort_key = sortify(self.title)[:120] - self.title = unicode(self.title) # ??? + self.title = str(self.title) # ??? try: - author = self.tags.filter(category='author')[0].sort_key - except IndexError: + author = self.authors().first().sort_key + except AttributeError: author = u'' self.sort_key_author = author + self.cached_author = self.tag_unicode('author') + self.has_audience = 'audience' in self.extra_info + ret = super(Book, self).save(force_insert, force_update, **kwargs) return ret @permalink def get_absolute_url(self): - return ('catalogue.views.book_detail', [self.slug]) + return 'book_detail', [self.slug] - @staticmethod - @permalink - def create_url(slug): - return ('catalogue.views.book_detail', [slug]) + def gallery_path(self): + return gallery_path(self.slug) + + def gallery_url(self): + return gallery_url(self.slug) @property def name(self): @@ -140,12 +223,42 @@ class Book(models.Model): def language_name(self): return dict(settings.LANGUAGES).get(self.language_code(), "") + def is_foreign(self): + return self.language_code() != settings.LANGUAGE_CODE + + def set_audio_length(self): + length = self.get_audio_length() + if length > 0: + self.audio_length = self.format_audio_length(length) + self.save() + + @staticmethod + def format_audio_length(seconds): + if seconds < 60*60: + minutes = seconds // 60 + seconds = seconds % 60 + return '%d:%02d' % (minutes, seconds) + else: + hours = seconds // 3600 + minutes = seconds % 3600 // 60 + seconds = seconds % 60 + return '%d:%02d:%02d' % (hours, minutes, seconds) + + def get_audio_length(self): + total = 0 + for media in self.get_mp3() or (): + total += app_settings.GET_MP3_LENGTH(media.file.path) + return int(total) + def has_media(self, type_): if type_ in Book.formats: return bool(getattr(self, "%s_file" % type_)) else: return self.media.filter(type=type_).exists() + def has_audio(self): + return self.has_media('mp3') + def get_media(self, type_): if self.has_media(type_): if type_ in Book.formats: @@ -157,34 +270,94 @@ class Book(models.Model): def get_mp3(self): return self.get_media("mp3") + def get_odt(self): return self.get_media("odt") + def get_ogg(self): return self.get_media("ogg") + def get_daisy(self): return self.get_media("daisy") + def media_url(self, format_): + media = self.get_media(format_) + if media: + if self.preview: + return reverse('embargo_link', kwargs={'slug': self.slug, 'format_': format_}) + else: + return media.url + else: + return None + + def html_url(self): + return self.media_url('html') + + def pdf_url(self): + return self.media_url('pdf') + + def epub_url(self): + return self.media_url('epub') + + def mobi_url(self): + return self.media_url('mobi') + + def txt_url(self): + return self.media_url('txt') + + def fb2_url(self): + return self.media_url('fb2') + + def xml_url(self): + return self.media_url('xml') + def has_description(self): return len(self.description) > 0 has_description.short_description = _('description') has_description.boolean = True - # ugly ugly ugly def has_mp3_file(self): - return bool(self.has_media("mp3")) + return self.has_media("mp3") has_mp3_file.short_description = 'MP3' has_mp3_file.boolean = True def has_ogg_file(self): - return bool(self.has_media("ogg")) + return self.has_media("ogg") has_ogg_file.short_description = 'OGG' has_ogg_file.boolean = True def has_daisy_file(self): - return bool(self.has_media("daisy")) + return self.has_media("daisy") has_daisy_file.short_description = 'DAISY' has_daisy_file.boolean = True + def get_audiobooks(self): + ogg_files = {} + for m in self.media.filter(type='ogg').order_by().iterator(): + ogg_files[m.name] = m + + audiobooks = [] + projects = set() + for mp3 in self.media.filter(type='mp3').iterator(): + # ogg files are always from the same project + meta = mp3.extra_info + project = meta.get('project') + if not project: + # temporary fallback + project = u'CzytamySłuchając' + + projects.add((project, meta.get('funded_by', ''))) + + media = {'mp3': mp3} + + ogg = ogg_files.get(mp3.name) + if ogg: + media['ogg'] = ogg + audiobooks.append(media) + + projects = sorted(projects) + return audiobooks, projects + def wldocument(self, parse_dublincore=True, inherit=True): from catalogue.import_utils import ORMDocProvider from librarian.parser import WLDocument @@ -194,10 +367,11 @@ class Book(models.Model): else: meta_fallbacks = None - return WLDocument.from_file(self.xml_file.path, - provider=ORMDocProvider(self), - parse_dublincore=parse_dublincore, - meta_fallbacks=meta_fallbacks) + return WLDocument.from_file( + self.xml_file.path, + provider=ORMDocProvider(self), + parse_dublincore=parse_dublincore, + meta_fallbacks=meta_fallbacks) @staticmethod def zip_format(format_): @@ -208,9 +382,8 @@ class Book(models.Model): format_) field_name = "%s_file" % format_ - books = Book.objects.filter(parent=None).exclude(**{field_name: ""}) - paths = [(pretty_file_name(b), getattr(b, field_name).path) - for b in books.iterator()] + books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True) + paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()] return create_zip(paths, app_settings.FORMAT_ZIPS[format_]) def zip_audiobooks(self, format_): @@ -228,10 +401,32 @@ class Book(models.Model): index.index_tags() if commit: index.index.commit() - except Exception, e: + except Exception as e: index.index.rollback() raise e + # will make problems in conjunction with paid previews + def download_pictures(self, remote_gallery_url): + gallery_path = self.gallery_path() + # delete previous files, so we don't include old files in ebooks + if os.path.isdir(gallery_path): + for filename in os.listdir(gallery_path): + file_path = os.path.join(gallery_path, filename) + os.unlink(file_path) + ilustr_elements = list(self.wldocument().edoc.findall('//ilustr')) + if ilustr_elements: + makedirs(gallery_path) + for ilustr in ilustr_elements: + ilustr_src = ilustr.get('src') + ilustr_path = os.path.join(gallery_path, ilustr_src) + urllib.urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path) + + def load_abstract(self): + abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt') + if abstract is not None: + self.abstract = transform_abstrakt(abstract) + else: + self.abstract = '' @classmethod def from_xml_file(cls, xml_file, **kwargs): @@ -250,9 +445,8 @@ class Book(models.Model): xml_file.close() @classmethod - def from_text_and_meta(cls, raw_file, book_info, overwrite=False, - dont_build=None, search_index=True, - search_index_tags=True): + def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True, + search_index_tags=True, remote_gallery_url=None, days=0): if dont_build is None: dont_build = set() dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD)) @@ -264,8 +458,7 @@ class Book(models.Model): try: children.append(Book.objects.get(slug=part_url.slug)) except Book.DoesNotExist: - raise Book.DoesNotExist(_('Book "%s" does not exist.') % - part_url.slug) + raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug) # Read book metadata book_slug = book_info.url.slug @@ -276,16 +469,20 @@ class Book(models.Model): if created: book_shelves = [] old_cover = None + book.preview = bool(days) + if book.preview: + book.preview_until = date.today() + timedelta(days) else: if not overwrite: - raise Book.AlreadyExists(_('Book %s already exists') % ( - book_slug)) + raise Book.AlreadyExists(_('Book %s already exists') % book_slug) # Save shelves for this book book_shelves = list(book.tags.filter(category='set')) old_cover = book.cover_info() # Save XML file book.xml_file.save('%s.xml' % book.slug, raw_file, save=False) + if book.preview: + book.xml_file.set_readable(False) book.language = book_info.language book.title = book_info.title @@ -294,10 +491,16 @@ class Book(models.Model): else: book.common_slug = book.slug book.extra_info = book_info.to_dict() + book.load_abstract() book.save() meta_tags = Tag.tags_from_info(book_info) + for tag in meta_tags: + if not tag.for_books: + tag.for_books = True + tag.save() + book.tags = set(meta_tags + book_shelves) cover_changed = old_cover != book.cover_info() @@ -322,12 +525,17 @@ class Book(models.Model): cls.repopulate_ancestors() tasks.update_counters.delay() + if remote_gallery_url: + book.download_pictures(remote_gallery_url) + # No saves beyond this point. # Build cover. if 'cover' not in dont_build: book.cover.build_delay() book.cover_thumb.build_delay() + book.cover_api_thumb.build_delay() + book.simple_cover.build_delay() # Build HTML and ebooks. book.html_file.build_delay() @@ -345,41 +553,43 @@ class Book(models.Model): for child in notify_cover_changed: child.parent_cover_changed() + book.save() # update sort_key_author + book.update_popularity() cls.published.send(sender=cls, instance=book) return book @classmethod + @transaction.atomic def repopulate_ancestors(cls): """Fixes the ancestry cache.""" # TODO: table names - with transaction.atomic(): - cursor = connection.cursor() - if connection.vendor == 'postgres': - cursor.execute("TRUNCATE catalogue_book_ancestor") - cursor.execute(""" - WITH RECURSIVE ancestry AS ( - SELECT book.id, book.parent_id - FROM catalogue_book AS book - WHERE book.parent_id IS NOT NULL - UNION - SELECT ancestor.id, book.parent_id - FROM ancestry AS ancestor, catalogue_book AS book - WHERE ancestor.parent_id = book.id - AND book.parent_id IS NOT NULL - ) - INSERT INTO catalogue_book_ancestor - (from_book_id, to_book_id) - SELECT id, parent_id - FROM ancestry - ORDER BY id; - """) - else: - cursor.execute("DELETE FROM catalogue_book_ancestor") - for b in cls.objects.exclude(parent=None): - parent = b.parent - while parent is not None: - b.ancestor.add(parent) - parent = parent.parent + cursor = connection.cursor() + if connection.vendor == 'postgres': + cursor.execute("TRUNCATE catalogue_book_ancestor") + cursor.execute(""" + WITH RECURSIVE ancestry AS ( + SELECT book.id, book.parent_id + FROM catalogue_book AS book + WHERE book.parent_id IS NOT NULL + UNION + SELECT ancestor.id, book.parent_id + FROM ancestry AS ancestor, catalogue_book AS book + WHERE ancestor.parent_id = book.id + AND book.parent_id IS NOT NULL + ) + INSERT INTO catalogue_book_ancestor + (from_book_id, to_book_id) + SELECT id, parent_id + FROM ancestry + ORDER BY id; + """) + else: + cursor.execute("DELETE FROM catalogue_book_ancestor") + for b in cls.objects.exclude(parent=None): + parent = b.parent + while parent is not None: + b.ancestor.add(parent) + parent = parent.parent def flush_includes(self, languages=True): if not languages: @@ -429,6 +639,8 @@ class Book(models.Model): if 'cover' not in app_settings.DONT_BUILD: self.cover.build_delay() self.cover_thumb.build_delay() + self.cover_api_thumb.build_delay() + self.simple_cover.build_delay() for format_ in constants.EBOOK_FORMATS_WITH_COVERS: if format_ not in app_settings.DONT_BUILD: getattr(self, '%s_file' % format_).build_delay() @@ -448,8 +660,7 @@ class Book(models.Model): return books def pretty_title(self, html_links=False): - names = [(tag.name, tag.get_absolute_url()) - for tag in self.tags.filter(category='author')] + names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')] books = self.parents() + [self] names.extend([(b.title, b.get_absolute_url()) for b in books]) @@ -459,6 +670,13 @@ class Book(models.Model): names = [tag[0] for tag in names] return ', '.join(names) + def publisher(self): + publisher = self.extra_info['publisher'] + if isinstance(publisher, str): + return publisher + elif isinstance(publisher, list): + return ', '.join(publisher) + @classmethod def tagged_top_level(cls, tags): """ Returns top-level books tagged with `tags`. @@ -471,7 +689,7 @@ class Book(models.Model): return objects.exclude(ancestor__in=objects) @classmethod - def book_list(cls, filter=None): + def book_list(cls, book_filter=None): """Generates a hierarchical listing of all books. Books are optionally filtered with a test function. @@ -479,10 +697,9 @@ class Book(models.Model): """ books_by_parent = {} - books = cls.objects.all().order_by('parent_number', 'sort_key').only( - 'title', 'parent', 'slug') - if filter: - books = books.filter(filter).distinct() + books = cls.objects.order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info') + if book_filter: + books = books.filter(book_filter).distinct() book_ids = set(b['pk'] for b in books.values("pk").iterator()) for book in books.iterator(): @@ -500,7 +717,7 @@ class Book(models.Model): books_by_author[tag] = [] for book in books_by_parent.get(None, ()): - authors = list(book.tags.filter(category='author')) + authors = list(book.authors().only('pk')) if authors: for author in authors: books_by_author[author].append(book) @@ -513,11 +730,13 @@ class Book(models.Model): "SP": (1, u"szkoła podstawowa"), "SP1": (1, u"szkoła podstawowa"), "SP2": (1, u"szkoła podstawowa"), + "SP3": (1, u"szkoła podstawowa"), "P": (1, u"szkoła podstawowa"), "G": (2, u"gimnazjum"), "L": (3, u"liceum"), "LP": (3, u"liceum"), } + def audiences_pl(self): audiences = self.extra_info.get('audiences', []) audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences])) @@ -544,19 +763,67 @@ class Book(models.Model): else: return None + def fragment_data(self): + fragment = self.choose_fragment() + if fragment: + return { + 'title': fragment.book.pretty_title(), + 'html': re.sub(']*>', '', fragment.get_short_text()), + } + else: + return None + + def update_popularity(self): + count = self.tags.filter(category='set').values('user').order_by('user').distinct().count() + try: + pop = self.popularity + pop.count = count + pop.save() + except BookPopularity.DoesNotExist: + BookPopularity.objects.create(book=self, count=count) + + def ridero_link(self): + return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_')) + + def like(self, user): + from social.utils import likes, get_set, set_sets + if not likes(user, self): + tag = get_set(user, '') + set_sets(user, self, [tag]) + + def unlike(self, user): + from social.utils import likes, set_sets + if likes(user, self): + set_sets(user, self, []) + + def full_sort_key(self): + return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id))) + + def cover_color(self): + return WLCover.epoch_colors.get(self.extra_info.get('epoch'), '#000000') + + +def add_file_fields(): + for format_ in Book.formats: + field_name = "%s_file" % format_ + # This weird globals() assignment makes Django migrations comfortable. + _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_)) + _upload_to.__name__ = '_%s_upload_to' % format_ + globals()[_upload_to.__name__] = _upload_to + + EbookField( + format_, _("%s file" % format_.upper()), + upload_to=_upload_to, + storage=bofh_storage, + max_length=255, + blank=True, + default='' + ).contribute_to_class(Book, field_name) + + +add_file_fields() + -# add the file fields -for format_ in Book.formats: - field_name = "%s_file" % format_ - # This weird globals() assignment makes Django migrations comfortable. - _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_)) - _upload_to.__name__ = '_%s_upload_to' % format_ - globals()[_upload_to.__name__] = _upload_to - - EbookField(format_, _("%s file" % format_.upper()), - upload_to=_upload_to, - storage=bofh_storage, - max_length=255, - blank=True, - default='' - ).contribute_to_class(Book, field_name) +class BookPopularity(models.Model): + book = models.OneToOneField(Book, related_name='popularity') + count = models.IntegerField(default=0, db_index=True)