Search engine now should be db change change-proof
[wolnelektury.git] / apps / catalogue / models.py
index 43a9496..ea09c04 100644 (file)
@@ -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,13 +216,16 @@ 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))
-
+    h = customizations_hash(customizations)
     pdf_name = '%s-custom-%s' % (book.slug, h)
     pdf_file = get_dynamic_path(None, pdf_name, ext='pdf')
 
@@ -369,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)
@@ -447,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'<a href="%s">%s</a>' % (
-                        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')
@@ -610,7 +568,6 @@ class Book(models.Model):
 
 
     def build_html(self):
-        from markupstring import MarkupString
         from django.core.files.base import ContentFile
         from slughifi import slughifi
         from librarian import html
@@ -654,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)
 
@@ -689,6 +646,20 @@ class Book(models.Model):
         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
@@ -707,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
 
@@ -778,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
@@ -791,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()
 
@@ -802,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()
 
@@ -815,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
 
@@ -828,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):
@@ -836,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()
 
@@ -844,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
 
@@ -855,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):
@@ -944,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))
@@ -979,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:
@@ -987,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
 
@@ -1002,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
@@ -1017,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 \
@@ -1038,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()