X-Git-Url: https://git.mdrn.pl/wolnelektury.git/blobdiff_plain/4b6fc6dc545a10955094bfaa9448ba070cfb33bf..a2f9ed2d400476e7da6636a7ce47ffdbbd87d64f:/apps/catalogue/models.py diff --git a/apps/catalogue/models.py b/apps/catalogue/models.py index 112ec1b99..ea09c048f 100644 --- a/apps/catalogue/models.py +++ b/apps/catalogue/models.py @@ -7,7 +7,7 @@ from collections import namedtuple from django.db import models from django.db.models import permalink, Q import django.dispatch -from django.core.cache import cache +from django.core.cache import get_cache from django.core.files.storage import DefaultStorage from django.utils.translation import ugettext_lazy as _ from django.contrib.auth.models import User @@ -16,21 +16,24 @@ from django.utils.datastructures import SortedDict from django.utils.safestring import mark_safe from django.utils.translation import get_language from django.core.urlresolvers import reverse -from django.db.models.signals import post_save, m2m_changed, pre_delete +from django.db.models.signals import post_save, m2m_changed, pre_delete, post_delete +import jsonfield from django.conf import settings from newtagging.models import TagBase, tags_updated from newtagging import managers from catalogue.fields import JSONField, OverwritingFileField -from catalogue.utils import create_zip, split_tags -from catalogue.tasks import touch_tag +from catalogue.utils import create_zip, split_tags, truncate_html_words +from catalogue.tasks import touch_tag, index_book from shutil import copy from glob import glob import re from os import path +import search +# Those are hard-coded here so that makemessages sees them. TAG_CATEGORIES = ( ('author', _('author')), ('epoch', _('epoch')), @@ -41,8 +44,8 @@ TAG_CATEGORIES = ( ('book', _('book')), ) -# not quite, but Django wants you to set a timeout -CACHE_FOREVER = 2419200 # 28 days + +permanent_cache = get_cache('permanent') class TagSubcategoryManager(models.Manager): @@ -213,14 +216,17 @@ def book_upload_path(ext=None, maxlen=100): return lambda *args: get_dynamic_path(*args, ext=ext, maxlen=maxlen) +def customizations_hash(customizations): + customizations.sort() + return hash(tuple(customizations)) + + def get_customized_pdf_path(book, customizations): """ Returns a MEDIA_ROOT relative path for a customized pdf. The name will contain a hash of customization options. """ - customizations.sort() - h = hash(tuple(customizations)) - - pdf_name = '%s-custom-%s' % (book.fileid(), h) + h = customizations_hash(customizations) + pdf_name = '%s-custom-%s' % (book.slug, h) pdf_file = get_dynamic_path(None, pdf_name, ext='pdf') return pdf_file @@ -230,7 +236,7 @@ def get_existing_customized_pdf(book): """ Returns a list of paths to generated customized pdf of a book """ - pdf_glob = '%s-custom-' % (book.fileid(),) + pdf_glob = '%s-custom-' % (book.slug,) pdf_glob = get_dynamic_path(None, pdf_glob, ext='pdf') pdf_glob = re.sub(r"[.]([a-z0-9]+)$", "*.\\1", pdf_glob) return glob(path.join(settings.MEDIA_ROOT, pdf_glob)) @@ -269,7 +275,7 @@ class BookMedia(models.Model): try: old = BookMedia.objects.get(pk=self.pk) except BookMedia.DoesNotExist, e: - pass + old = None else: # if name changed, change the file name, too if slughifi(self.name) != slughifi(old.name): @@ -278,7 +284,9 @@ class BookMedia(models.Model): super(BookMedia, self).save(*args, **kwargs) # remove the zip package for book with modified media - remove_zip(self.book.fileid()) + if old: + remove_zip("%s_%s" % (old.book.slug, old.type)) + remove_zip("%s_%s" % (self.book.slug, self.type)) extra_info = self.get_extra_info_value() extra_info.update(self.read_meta()) @@ -347,7 +355,9 @@ class BookMedia(models.Model): class Book(models.Model): title = models.CharField(_('title'), max_length=120) sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False) - slug = models.SlugField(_('slug'), max_length=120, db_index=True) + 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=settings.CATALOGUE_DEFAULT_LANGUAGE) description = models.TextField(_('description'), blank=True) @@ -365,6 +375,9 @@ class Book(models.Model): formats = ebook_formats + ['html', 'xml'] parent = models.ForeignKey('self', blank=True, null=True, related_name='children') + + _related_info = jsonfield.JSONField(blank=True, null=True, editable=False) + objects = models.Manager() tagged = managers.ModelTaggedItemManager(Tag) tags = managers.TagDescriptor(Tag) @@ -372,14 +385,10 @@ class Book(models.Model): html_built = django.dispatch.Signal() published = django.dispatch.Signal() - URLID_RE = r'[a-z0-9-]+(?:/[a-z]{3})?' - FILEID_RE = r'[a-z0-9-]+(?:_[a-z]{3})?' - class AlreadyExists(Exception): pass class Meta: - unique_together = [['slug', 'language']] ordering = ('sort_key',) verbose_name = _('book') verbose_name_plural = _('books') @@ -387,42 +396,6 @@ class Book(models.Model): def __unicode__(self): return self.title - def urlid(self, sep='/'): - stem = self.slug - if self.language != settings.CATALOGUE_DEFAULT_LANGUAGE: - stem += sep + self.language - return stem - - def fileid(self): - return self.urlid('_') - - @staticmethod - def split_urlid(urlid, sep='/', default_lang=settings.CATALOGUE_DEFAULT_LANGUAGE): - """Splits a URL book id into slug and language code. - - Returns a dictionary usable i.e. for object lookup, or None. - - >>> Book.split_urlid("a-slug/pol", default_lang="eng") - {'slug': 'a-slug', 'language': 'pol'} - >>> Book.split_urlid("a-slug", default_lang="eng") - {'slug': 'a-slug', 'language': 'eng'} - >>> Book.split_urlid("a-slug_pol", "_", default_lang="eng") - {'slug': 'a-slug', 'language': 'pol'} - >>> Book.split_urlid("a-slug/eng", default_lang="eng") - - """ - parts = urlid.rsplit(sep, 1) - if len(parts) == 2: - if parts[1] == default_lang: - return None - return {'slug': parts[0], 'language': parts[1]} - else: - return {'slug': urlid, 'language': default_lang} - - @classmethod - def split_fileid(cls, fileid): - return cls.split_urlid(fileid, '_') - def save(self, force_insert=False, force_update=False, reset_short_html=True, **kwargs): from sortify import sortify @@ -437,18 +410,14 @@ class Book(models.Model): @permalink def get_absolute_url(self): - return ('catalogue.views.book_detail', [self.urlid()]) + return ('catalogue.views.book_detail', [self.slug]) @property def name(self): return self.title def book_tag_slug(self): - stem = 'l-' + self.slug - if self.language != settings.CATALOGUE_DEFAULT_LANGUAGE: - return stem[:116] + ' ' + self.language - else: - return stem[:120] + return ('l-' + self.slug)[:120] def book_tag(self): slug = self.book_tag_slug() @@ -487,62 +456,11 @@ class Book(models.Model): if self.id is None: return - cache_key = "Book.short_html/%d/%s" - for lang, langname in settings.LANGUAGES: - cache.delete(cache_key % (self.id, lang)) - cache.delete("Book.mini_box/%d" % (self.id, )) + type(self).objects.filter(pk=self.pk).update(_related_info=None) # Fragment.short_html relies on book's tags, so reset it here too for fragm in self.fragments.all(): fragm.reset_short_html() - def short_html(self): - if self.id: - cache_key = "Book.short_html/%d/%s" % (self.id, get_language()) - short_html = cache.get(cache_key) - else: - short_html = None - - if short_html is not None: - return mark_safe(short_html) - else: - tags = self.tags.filter(category__in=('author', 'kind', 'genre', 'epoch')) - tags = split_tags(tags) - - formats = [] - # files generated during publication - for ebook_format in self.ebook_formats: - if self.has_media(ebook_format): - formats.append(u'%s' % ( - self.get_media(ebook_format).url, - ebook_format.upper() - )) - - formats = [mark_safe(format) for format in formats] - - short_html = unicode(render_to_string('catalogue/book_short.html', - {'book': self, 'tags': tags, 'formats': formats})) - - if self.id: - cache.set(cache_key, short_html, CACHE_FOREVER) - return mark_safe(short_html) - - def mini_box(self): - if self.id: - cache_key = "Book.mini_box/%d" % (self.id, ) - short_html = cache.get(cache_key) - else: - short_html = None - - if short_html is None: - authors = self.tags.filter(category='author') - - short_html = unicode(render_to_string('catalogue/book_mini_box.html', - {'book': self, 'authors': authors, 'STATIC_URL': settings.STATIC_URL})) - - if self.id: - cache.set(cache_key, short_html, CACHE_FOREVER) - return mark_safe(short_html) - def has_description(self): return len(self.description) > 0 has_description.short_description = _('description') @@ -565,7 +483,7 @@ class Book(models.Model): has_daisy_file.boolean = True def wldocument(self, parse_dublincore=True): - from catalogue.utils import ORMDocProvider + from catalogue.import_utils import ORMDocProvider from librarian.parser import WLDocument return WLDocument.from_file(self.xml_file.path, @@ -601,7 +519,7 @@ class Book(models.Model): # we'd like to be sure not to overwrite changes happening while # (timely) pdf generation is taking place (async celery scenario) current_self = Book.objects.get(id=self.id) - current_self.pdf_file.save('%s.pdf' % self.fileid(), + current_self.pdf_file.save('%s.pdf' % self.slug, File(open(pdf.get_filename()))) self.pdf_file = current_self.pdf_file @@ -624,7 +542,7 @@ class Book(models.Model): mobi = self.wldocument().as_mobi() - self.mobi_file.save('%s.mobi' % self.fileid(), File(open(mobi.get_filename()))) + self.mobi_file.save('%s.mobi' % self.slug, File(open(mobi.get_filename()))) # remove zip with all mobi files remove_zip(settings.ALL_MOBI_ZIP) @@ -636,7 +554,7 @@ class Book(models.Model): epub = self.wldocument().as_epub() - self.epub_file.save('%s.epub' % self.fileid(), + self.epub_file.save('%s.epub' % self.slug, File(open(epub.get_filename()))) # remove zip package with all epub files @@ -646,11 +564,10 @@ class Book(models.Model): from django.core.files.base import ContentFile text = self.wldocument().as_text() - self.txt_file.save('%s.txt' % self.fileid(), ContentFile(text.get_string())) + self.txt_file.save('%s.txt' % self.slug, ContentFile(text.get_string())) def build_html(self): - from markupstring import MarkupString from django.core.files.base import ContentFile from slughifi import slughifi from librarian import html @@ -661,7 +578,7 @@ class Book(models.Model): html_output = self.wldocument(parse_dublincore=False).as_html() if html_output: - self.html_file.save('%s.html' % self.fileid(), + self.html_file.save('%s.html' % self.slug, ContentFile(html_output.get_string())) # get ancestor l-tags for adding to new fragments @@ -694,9 +611,9 @@ class Book(models.Model): continue text = fragment.to_string() - short_text = '' - if (len(MarkupString(text)) > 240): - short_text = unicode(MarkupString(text)[:160]) + short_text = truncate_html_words(text, 15) + if text == short_text: + short_text = '' new_fragment = Fragment.objects.create(anchor=fragment.id, book=self, text=text, short_text=short_text) @@ -712,7 +629,7 @@ class Book(models.Model): def pretty_file_name(book): return "%s/%s.%s" % ( b.get_extra_info_value()['author'], - b.fileid(), + b.slug, format_) field_name = "%s_file" % format_ @@ -726,9 +643,23 @@ class Book(models.Model): def zip_audiobooks(self, format_): bm = BookMedia.objects.filter(book=self, type=format_) paths = map(lambda bm: (None, bm.file.path), bm) - result = create_zip.delay(paths, "%s_%s" % (self.fileid(), format_)) + result = create_zip.delay(paths, "%s_%s" % (self.slug, format_)) return result.wait() + def search_index(self, book_info=None, reuse_index=False, index_tags=True): + if reuse_index: + idx = search.ReusableIndex() + else: + idx = search.Index() + + idx.open() + try: + idx.index_book(self, book_info) + if index_tags: + idx.index_tags() + finally: + idx.close() + @classmethod def from_xml_file(cls, xml_file, **kwargs): from django.core.files import File @@ -747,7 +678,8 @@ class Book(models.Model): @classmethod def from_text_and_meta(cls, raw_file, book_info, overwrite=False, - build_epub=True, build_txt=True, build_pdf=True, build_mobi=True): + build_epub=True, build_txt=True, build_pdf=True, build_mobi=True, + search_index=True, search_index_tags=True, search_index_reuse=False): import re from sortify import sortify @@ -756,30 +688,33 @@ class Book(models.Model): if hasattr(book_info, 'parts'): for part_url in book_info.parts: try: - children.append(Book.objects.get( - slug=part_url.slug, language=part_url.language)) + children.append(Book.objects.get(slug=part_url.slug)) except Book.DoesNotExist, e: - raise Book.DoesNotExist(_('Book "%s/%s" does not exist.') % - (part_url.slug, part_url.language)) + raise Book.DoesNotExist(_('Book "%s" does not exist.') % + part_url.slug) # Read book metadata book_slug = book_info.url.slug - language = book_info.language - if re.search(r'[^a-zA-Z0-9-]', book_slug): + if re.search(r'[^a-z0-9-]', book_slug): raise ValueError('Invalid characters in slug') - book, created = Book.objects.get_or_create(slug=book_slug, language=language) + book, created = Book.objects.get_or_create(slug=book_slug) if created: book_shelves = [] else: if not overwrite: - raise Book.AlreadyExists(_('Book %s/%s already exists') % ( - book_slug, language)) + raise Book.AlreadyExists(_('Book %s already exists') % ( + book_slug)) # Save shelves for this book book_shelves = list(book.tags.filter(category='set')) + book.language = book_info.language book.title = book_info.title + if book_info.variant_of: + book.common_slug = book_info.variant_of.slug + else: + book.common_slug = book.slug book.set_extra_info_value(book_info.to_dict()) book.save() @@ -815,6 +750,10 @@ class Book(models.Model): if not settings.NO_BUILD_MOBI and build_mobi: book.build_mobi() + if not settings.NO_SEARCH_INDEX and search_index: + book.search_index(index_tags=search_index_tags, reuse_index=search_index_reuse) + #index_book.delay(book.id, book_info) + book_descendants = list(book.children.all()) descendants_tags = set() # add l-tag to descendants and their fragments @@ -828,7 +767,7 @@ class Book(models.Model): book_descendants += list(child_book.children.all()) for tag in descendants_tags: - touch_tag.delay(tag) + touch_tag(tag) book.save() @@ -839,12 +778,42 @@ class Book(models.Model): cls.published.send(sender=book) return book + def related_info(self): + """Keeps info about related objects (tags, media) in cache field.""" + if self._related_info is not None: + return self._related_info + else: + rel = {'tags': {}, 'media': {}} + + tags = self.tags.filter(category__in=( + 'author', 'kind', 'genre', 'epoch')) + tags = split_tags(tags) + for category in tags: + rel['tags'][category] = [ + (t.name, t.slug) for t in tags[category]] + + for media_format in BookMedia.formats: + rel['media'][media_format] = self.has_media(media_format) + + book = self + parents = [] + while book.parent: + parents.append((book.parent.title, book.parent.slug)) + book = book.parent + parents = parents[::-1] + if parents: + rel['parents'] = parents + + if self.pk: + type(self).objects.filter(pk=self.pk).update(_related_info=rel) + return rel + def reset_tag_counter(self): if self.id is None: return cache_key = "Book.tag_counter/%d" % self.id - cache.delete(cache_key) + permanent_cache.delete(cache_key) if self.parent: self.parent.reset_tag_counter() @@ -852,7 +821,7 @@ class Book(models.Model): def tag_counter(self): if self.id: cache_key = "Book.tag_counter/%d" % self.id - tags = cache.get(cache_key) + tags = permanent_cache.get(cache_key) else: tags = None @@ -865,7 +834,7 @@ class Book(models.Model): tags[tag.pk] = 1 if self.id: - cache.set(cache_key, tags, CACHE_FOREVER) + permanent_cache.set(cache_key, tags) return tags def reset_theme_counter(self): @@ -873,7 +842,7 @@ class Book(models.Model): return cache_key = "Book.theme_counter/%d" % self.id - cache.delete(cache_key) + permanent_cache.delete(cache_key) if self.parent: self.parent.reset_theme_counter() @@ -881,7 +850,7 @@ class Book(models.Model): def theme_counter(self): if self.id: cache_key = "Book.theme_counter/%d" % self.id - tags = cache.get(cache_key) + tags = permanent_cache.get(cache_key) else: tags = None @@ -892,7 +861,7 @@ class Book(models.Model): tags[tag.pk] = tags.get(tag.pk, 0) + 1 if self.id: - cache.set(cache_key, tags, CACHE_FOREVER) + permanent_cache.set(cache_key, tags) return tags def pretty_title(self, html_links=False): @@ -940,7 +909,7 @@ class Book(models.Model): books_by_parent = {} books = cls.objects.all().order_by('parent_number', 'sort_key').only( - 'title', 'parent', 'slug', 'language') + 'title', 'parent', 'slug') if filter: books = books.filter(filter).distinct() book_ids = set((book.pk for book in books)) @@ -981,6 +950,16 @@ class Book(models.Model): audiences = sorted(set([self._audiences_pl[a] for a in audiences])) return [a[1] for a in audiences] + def choose_fragment(self): + tag = self.book_tag() + fragments = Fragment.tagged.with_any([tag]) + if fragments.exists(): + return fragments.order_by('?')[0] + elif self.parent: + return self.parent.choose_fragment() + else: + return None + def _has_factory(ftype): has = lambda self: bool(getattr(self, "%s_file" % ftype)) @@ -1016,7 +995,7 @@ class Fragment(models.Model): verbose_name_plural = _('fragments') def get_absolute_url(self): - return '%s#m%s' % (self.book.get_html_url(), self.anchor) + return '%s#m%s' % (reverse('book_text', args=[self.book.slug]), self.anchor) def reset_short_html(self): if self.id is None: @@ -1024,12 +1003,16 @@ class Fragment(models.Model): cache_key = "Fragment.short_html/%d/%s" for lang, langname in settings.LANGUAGES: - cache.delete(cache_key % (self.id, lang)) + permanent_cache.delete(cache_key % (self.id, lang)) + + def get_short_text(self): + """Returns short version of the fragment.""" + return self.short_text if self.short_text else self.text def short_html(self): if self.id: cache_key = "Fragment.short_html/%d/%s" % (self.id, get_language()) - short_html = cache.get(cache_key) + short_html = permanent_cache.get(cache_key) else: short_html = None @@ -1039,10 +1022,28 @@ class Fragment(models.Model): short_html = unicode(render_to_string('catalogue/fragment_short.html', {'fragment': self})) if self.id: - cache.set(cache_key, short_html, CACHE_FOREVER) + permanent_cache.set(cache_key, short_html) return mark_safe(short_html) +class Collection(models.Model): + """A collection of books, which might be defined before publishing them.""" + title = models.CharField(_('title'), max_length=120, db_index=True) + slug = models.SlugField(_('slug'), max_length=120, primary_key=True) + description = models.TextField(_('description'), null=True, blank=True) + + models.SlugField(_('slug'), max_length=120, unique=True, db_index=True) + book_slugs = models.TextField(_('book slugs')) + + class Meta: + ordering = ('title',) + verbose_name = _('collection') + verbose_name_plural = _('collections') + + def __unicode__(self): + return self.title + + ########### # # SIGNALS @@ -1054,7 +1055,7 @@ def _tags_updated_handler(sender, affected_tags, **kwargs): # reset tag global counter # we want Tag.changed_at updated for API to know the tag was touched for tag in affected_tags: - touch_tag.delay(tag) + touch_tag(tag) # if book tags changed, reset book tag counter if isinstance(sender, Book) and \ @@ -1075,8 +1076,22 @@ def _pre_delete_handler(sender, instance, **kwargs): instance.book.save() pre_delete.connect(_pre_delete_handler) + def _post_save_handler(sender, instance, **kwargs): """ refresh all the short_html stuff on BookMedia update """ if sender == BookMedia: instance.book.save() post_save.connect(_post_save_handler) + + +@django.dispatch.receiver(post_delete, sender=Book) +def _remove_book_from_index_handler(sender, instance, **kwargs): + """ remove the book from search index, when it is deleted.""" + search.JVM.attachCurrentThread() + idx = search.Index() + idx.open(timeout=10000) # 10 seconds timeout. + try: + idx.remove_book(instance) + idx.index_tags() + finally: + idx.close()