Merge branch 'develop' into syndication
authorStephen Burrows <stephen.r.burrows@gmail.com>
Tue, 21 Jun 2011 22:38:28 +0000 (18:38 -0400)
committerStephen Burrows <stephen.r.burrows@gmail.com>
Tue, 21 Jun 2011 22:38:28 +0000 (18:38 -0400)
35 files changed:
README
README.markdown
docs/contrib/shipherd.rst
docs/index.rst
docs/tutorials/getting-started.rst
docs/tutorials/intro.rst
docs/tutorials/shipherd.rst [new file with mode: 0644]
docs/what.rst
philo/__init__.py
philo/admin/nodes.py
philo/contrib/penfield/models.py
philo/contrib/shipherd/templatetags/shipherd.py
philo/contrib/sobol/__init__.py
philo/contrib/sobol/admin.py
philo/contrib/sobol/models.py
philo/contrib/sobol/search.py
philo/contrib/sobol/static/sobol/ajax_search.js [new file with mode: 0644]
philo/contrib/sobol/templates/admin/sobol/search/change_form.html [new file with mode: 0644]
philo/contrib/sobol/templates/admin/sobol/search/change_list.html [new file with mode: 0644]
philo/contrib/sobol/templates/admin/sobol/search/grappelli_change_form.html [new file with mode: 0644]
philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html [deleted file]
philo/contrib/sobol/templates/admin/sobol/search/results.html [deleted file]
philo/contrib/sobol/templates/search/googlesearch.html [deleted file]
philo/contrib/sobol/templates/sobol/search/_list.html [new file with mode: 0644]
philo/contrib/sobol/templates/sobol/search/content.html [new file with mode: 0644]
philo/contrib/sobol/templates/sobol/search/result.html [new file with mode: 0644]
philo/middleware.py
philo/migrations/0015_auto__add_unique_node_slug_parent__add_unique_template_slug_parent.py [moved from philo/migrations/0016_auto__add_unique_node_slug_parent__add_unique_template_slug_parent.py with 100% similarity]
philo/migrations/0016_auto__add_field_file_name.py [new file with mode: 0644]
philo/migrations/0017_generate_filenames.py [new file with mode: 0644]
philo/migrations/0018_auto__chg_field_node_view_object_id__chg_field_node_view_content_type.py [moved from philo/migrations/0015_auto__chg_field_node_view_object_id__chg_field_node_view_content_type.py with 100% similarity]
philo/models/base.py
philo/models/nodes.py
philo/utils/entities.py
setup.py

diff --git a/README b/README
index f1ef32e..dbd9cc2 100644 (file)
--- a/README
+++ b/README
@@ -5,10 +5,12 @@ Prerequisites:
        * Django 1.3+ <http://www.djangoproject.com/>
        * django-mptt e734079+ <https://github.com/django-mptt/django-mptt/> 
        * (Optional) django-grappelli 2.0+ <http://code.google.com/p/django-grappelli/>
-       * (Optional) recaptcha-django r6 <http://code.google.com/p/recaptcha-django/>
        * (Optional) south 0.7.2+ <http://south.aeracode.org/>
+       * (Optional) recaptcha-django r6 <http://code.google.com/p/recaptcha-django/>
+
+To contribute, please visit the project website <http://project.philocms.org/> and/or make a fork of the git repository on GitHub <http://github.com/ithinksw/philo> or Gitorious <http://gitorious
+.org/ithinksw/philo>. Feel free to join us on IRC at irc://irc.oftc.net/#philo.
 
-To contribute, please visit the project website <http://philocms.org/>. Feel free to join us on IRC at irc://irc.oftc.net/#philo.
 
 ====
 Using philo
index b85cb50..91a8115 100644 (file)
@@ -9,7 +9,8 @@ Prerequisites:
  * (Optional) [south 0.7.2+ &lt;http://south.aeracode.org/)](http://south.aeracode.org/)
  * (Optional) [recaptcha-django r6 &lt;http://code.google.com/p/recaptcha-django/&gt;](http://code.google.com/p/recaptcha-django/)
 
-To contribute, please visit the [project website](http://philocms.org/). Feel free to join us on IRC at [irc://irc.oftc.net/#philo](irc://irc.oftc.net/#philo>).
+To contribute, please visit the [project website](http://project.philocms.org/) and/or make a fork of the git repository on [GitHub](http://github.com/ithinksw/philo) or [Gitorious](http://gitorious
+.org/ithinksw/philo). Feel free to join us on IRC at [irc://irc.oftc.net/#philo](irc://irc.oftc.net/#philo).
 
 Using philo
 ===========
index 915dac8..7d2eaf7 100644 (file)
@@ -29,6 +29,7 @@ Models
 
 .. automodule:: philo.contrib.shipherd.models
        :members: Navigation, NavigationItem, NavigationMapper
+       :show-inheritance:
 
 Navigation caching
 ------------------
index 079185d..7e960a0 100644 (file)
@@ -19,7 +19,7 @@ Prerequisites:
 * (Optional) `south 0.7.2+ <http://south.aeracode.org/>`_
 * (Optional) `recaptcha-django r6 <http://code.google.com/p/recaptcha-django/>`_
 
-To contribute, please visit the `project website <http://philocms.org/>`_ or make a fork of the git repository on `GitHub <http://github.com/ithinksw/philo>`_ or `Gitorious <http://gitorious.org/ithinksw/philo>`_. Feel free to join us on IRC at `irc://irc.oftc.net/#philo <irc://irc.oftc.net/#philo>`_.
+To contribute, please visit the `project website <http://project.philocms.org/>`_ and/or make a fork of the git repository on `GitHub <http://github.com/ithinksw/philo>`_ or `Gitorious <http://gitorious.org/ithinksw/philo>`_. Feel free to join us on IRC at `irc://irc.oftc.net/#philo <irc://irc.oftc.net/#philo>`_.
 
 Contents
 ++++++++
index eeb9ce8..11eb927 100644 (file)
@@ -53,7 +53,7 @@ Now that you've got everything configured, it's time to set up your first page!
 
 Next, add a philo :class:`.Page` - let's call it "Hello World Page" and use the template you just made.
 
-Now make a philo :class:`.Node`. Give it the slug ``hello-world``. Set the view content type to "Page" and use the page that you just made. If you navigate to ``/hello-world``, you will see the results of rendering the page!
+Now make a philo :class:`.Node`. Give it the slug ``hello-world``. Set the ``view_content_type`` to "Page" and the ``view_object_id`` to the id of the page that you just made - probably 1. If you navigate to ``/hello-world``, you will see the results of rendering the page!
 
 Setting the root node
 +++++++++++++++++++++
@@ -70,18 +70,20 @@ Editing page contents
 Great! We've got a page that says "Hello World". But what if we want it to say something else? Should we really have to edit the :class:`.Template` to change the content of the :class:`.Page`? And what if we want to share the :class:`.Template` but have different content? Adjust the :class:`.Template` to look like this::
        
        <html>
-               <head>
-                       <title>{% container page_title %}</title>
-               </head>
-               <body>
-                       {% container page_body as content %}
-                       {% if content %}
-                               <p>{{ content }}</p>
-                       {% endif %}
-                       <p>The time is {% now %}.</p>
-               </body>
+           <head>
+               <title>{% container page_title %}</title>
+           </head>
+           <body>
+               {% container page_body as content %}
+               {% if content %}
+                   <p>{{ content }}</p>
+               {% endif %}
+               <p>The time is {% now "jS F Y H:i" %}.</p>
+           </body>
        </html>
 
 Now go edit your :class:`.Page`. Two new fields called "Page title" and "Page body" have shown up! You can put anything you like in here and have it show up in the appropriate places when the page is rendered.
 
 .. seealso:: :ttag:`philo.templatetags.containers.container`
+
+Congrats! You've done it!
index 903dfea..c7d3e99 100644 (file)
@@ -5,3 +5,4 @@ Tutorials
        :maxdepth: 1
        
        getting-started
+       shipherd
diff --git a/docs/tutorials/shipherd.rst b/docs/tutorials/shipherd.rst
new file mode 100644 (file)
index 0000000..3454ef2
--- /dev/null
@@ -0,0 +1,63 @@
+Using Shipherd in the Admin
+===========================
+
+The navigation mechanism is fairly complex; unfortunately, there's no real way around that - without a lot of equally complex code that you are quite welcome to write and contribute! ;-)
+
+For this guide, we'll assume that you have the setup described in :doc:`getting-started`. We'll be adding a main :class:`.Navigation` to the root :class:`.Node` and making it display as part of the :class:`.Template`.
+
+Before getting started, make sure that you've added :mod:`philo.contrib.shipherd` to your :setting:`INSTALLED_APPS`. :mod:`~philo.contrib.shipherd` template tags also require the request context processor, so make sure to set :setting:`TEMPLATE_CONTEXT_PROCESSORS` appropriately::
+
+       TEMPLATE_CONTEXT_PROCESSORS = (
+               # Defaults
+               "django.contrib.auth.context_processors.auth",
+               "django.core.context_processors.debug",
+               "django.core.context_processors.i18n",
+               "django.core.context_processors.media",
+               "django.core.context_processors.static",
+               "django.contrib.messages.context_processors.messages"
+               ...
+               "django.core.context_processors.request"
+       )
+
+Creating the Navigation
++++++++++++++++++++++++
+
+Start off by adding a new :class:`.Navigation` instance with :attr:`~.Navigation.node` set to the good ole' ``root`` node and :attr:`~.Navigation.key` set to ``main``. The default :attr:`~.Navigation.depth` of 3 is fine.
+
+Now open up that first inline :class:`.NavigationItem`. Make the text ``Hello World`` and set the target :class:`.Node` to, again, ``root``. (Of course, this is a special case. If we had another node that we wanted to point to, we would choose that.)
+
+Press save and you've created your first navigation.
+
+Displaying the Navigation
++++++++++++++++++++++++++
+
+All you need to do now is show the navigation in the template! This is quite easy, using the :ttag:`~philo.contrib.shipherd.templatetags.shipherd.recursenavigation` templatetag. For now we'll keep it simple. Adjust the "Hello World Template" to look like this::
+       
+       <html>{% load shipherd %}
+           <head>
+               <title>{% container page_title %}</title>
+           </head>
+           <body>
+               <ul>
+                   {% recursenavigation node "main" %}
+                       <li{% if navloop.active %} class="active"{% endif %}>
+                           {{ item.text }}
+                       </li>
+                   {% endrecursenavigation %}
+               </ul>
+               {% container page_body as content %}
+               {% if content %}
+                   <p>{{ content }}</p>
+               {% endif %}
+               <p>The time is {% now %}.</p>
+           </body>
+       </html>
+
+Now have a look at the page - your navigation is there!
+
+Linking to google
++++++++++++++++++
+
+Edit the ``main`` :class:`.Navigation` again to add another :class:`.NavigationItem`. This time give it the :attr:`~.NavigationItem.text` ``Google`` and set the :attr:`~.TargetURLModel.url_or_subpath` field to ``http://google.com``. A navigation item will show up on the Hello World page that points to ``google.com``! Granted, your navigation probably shouldn't do that, because confusing navigation is confusing; the point is that it is possible to provide navigation to arbitrary URLs.
+
+:attr:`~.TargetURLModel.url_or_subpath` can also be used in conjuction with a :class:`.Node` to link to a subpath beyond that :class:`.Node`'s url.
index ac44619..efa8537 100644 (file)
@@ -9,3 +9,17 @@ Philo allows the creation of site structures using Django's built-in admin inter
 * :class:`.Attribute`\ s are arbitrary key/value pairs which can be attached to most of the models that Philo provides. Attributes of a :class:`.Node` will be inherited by all of the :class:`.Node`'s descendants and will be available in the template's context.
 
 The :ttag:`~philo.templatetags.containers.container` template tag that Philo provides makes it easy to mark areas in a template which need to be editable page-by-page; every :class:`.Page` will have an additional field in the admin for each :ttag:`~philo.templatetags.containers.container` in the template it uses.
+
+How's that different than other CMSes?
+++++++++++++++++++++++++++++++++++++++
+
+Philo developed according to principles that grew out of the observation of the limitations and practices of other content management systems. For example, Philo believes that:
+
+* Designers are in charge of how content is displayed, not end users. For example, users should be able to embed images in blog entries -- but the display of the image, even the presence or absence of a wrapping ``<figure>`` element, should depend on the template used to render the entry, not the HTML5 knowledge of the user.
+       .. seealso:: :ttag:`~philo.templatetags.embed.embed`
+* Interpretation of content (as a django template, as markdown, as textile, etc.) is the responsibility of the template designer, not of code developers or the framework.
+       .. seealso:: :ttag:`~philo.templatetags.include_string.include_string`
+* Page content should be simple -- not reorderable. Each piece of content should only be related to one page. Any other system will cause more trouble than it's worth.
+       .. seealso:: :class:`.Contentlet`, :class:`.ContentReference`
+* Some pieces of information may be shared by an entire site, used in disparate places, and changed frequently enough that it is far too difficult to track down every use. These pieces of information should be stored separately from the content that contains them.
+       .. seealso:: :class:`.Attribute`
index e574b70..c07c373 100644 (file)
@@ -1 +1 @@
-VERSION = (0, '1rc')
+VERSION = (0, 9)
index 853ba25..46c456a 100644 (file)
@@ -47,10 +47,12 @@ class RedirectAdmin(ViewAdmin):
 class FileAdmin(ViewAdmin):
        fieldsets = (
                (None, {
-                       'fields': ('file', 'mimetype')
+                       'fields': ('name', 'file', 'mimetype')
                }),
        )
-       list_display = ('mimetype', 'file')
+       list_display = ('name', 'mimetype', 'file')
+       search_fields = ('name',)
+       list_filter = ('mimetype',)
 
 
 admin.site.register(Node, NodeAdmin)
index 5e2fdbf..3a8e82f 100644 (file)
@@ -200,8 +200,8 @@ class BlogView(FeedView):
                return {'blog': self.blog}
        
        def get_entry_queryset(self):
-               """Returns the default :class:`QuerySet` of :class:`BlogEntry` instances for the :class:`BlogView`."""
-               return self.blog.entries.all()
+               """Returns the default :class:`QuerySet` of :class:`BlogEntry` instances for the :class:`BlogView` - all entries that are considered posted in the past. This allows for scheduled posting of entries."""
+               return self.blog.entries.filter(date__lte=datetime.now())
        
        def get_tag_queryset(self):
                """Returns the default :class:`QuerySet` of :class:`.Tag`\ s for the :class:`BlogView`'s :meth:`get_entries_by_tag` and :meth:`tag_archive_view`."""
@@ -519,8 +519,8 @@ class NewsletterView(FeedView):
                return {'newsletter': self.newsletter}
        
        def get_article_queryset(self):
-               """Returns the default :class:`QuerySet` of :class:`NewsletterArticle` instances for the :class:`NewsletterView`."""
-               return self.newsletter.articles.all()
+               """Returns the default :class:`QuerySet` of :class:`NewsletterArticle` instances for the :class:`NewsletterView` - all articles that are considered posted in the past. This allows for scheduled posting of articles."""
+               return self.newsletter.articles.filter(date__lte=datetime.now())
        
        def get_issue_queryset(self):
                """Returns the default :class:`QuerySet` of :class:`NewsletterIssue` instances for the :class:`NewsletterView`."""
index 9e572aa..1031d73 100644 (file)
@@ -131,7 +131,7 @@ def recursenavigation(parser, token):
                <ul>
                    {% recursenavigation node "main" %}
                        <li{% if navloop.active %} class='active'{% endif %}>
-                           {{ navloop.item.text }}
+                           {{ item.text }}
                            {% if item.get_children %}
                                <ul>
                                    {{ children }}
@@ -140,6 +140,22 @@ def recursenavigation(parser, token):
                        </li>
                    {% endrecursenavigation %}
                </ul>
+       
+       .. note:: {% recursenavigation %} requires that the current :class:`HttpRequest` be present in the context as ``request``. The simplest way to do this is with the `request context processor`_. If this is installed with just the default template context processors, the entry in your settings file will look like this::
+
+               TEMPLATE_CONTEXT_PROCESSORS = (
+                       # Defaults
+                       "django.contrib.auth.context_processors.auth",
+                       "django.core.context_processors.debug",
+                       "django.core.context_processors.i18n",
+                       "django.core.context_processors.media",
+                       "django.core.context_processors.static",
+                       "django.contrib.messages.context_processors.messages"
+                       ...
+                       "django.core.context_processors.request"
+               )
+       
+       .. _request context processor: https://docs.djangoproject.com/en/dev/ref/templates/api/#django-core-context-processors-request
        """
        bits = token.contents.split()
        if len(bits) != 3:
index cd75f13..0458a83 100644 (file)
@@ -1,5 +1,21 @@
 """
-Sobol implements a generic search interface, which can be used to search databases or websites. No assumptions are made about the search method, and the results are cached using django's caching.
+Sobol implements a generic search interface, which can be used to search databases or websites. No assumptions are made about the search method. If SOBOL_USE_CACHE is ``True`` (default), the results will be cached using django's cache framework. Be aware that this may use a large number of cache entries, as a unique entry will be made for each search string for each type of search.
+
+Settings
+--------
+
+:setting:`SOBOL_USE_CACHE`
+       Whether sobol will use django's cache framework. Defaults to ``True``; this may cause a lot of entries in the cache.
+
+:setting:`SOBOL_USE_EVENTLET`
+       If :mod:`eventlet` is installed and this setting is ``True``, sobol web searches will use :mod:`eventlet.green.urllib2` instead of the built-in :mod:`urllib2` module. Default: ``False``.
+
+Templates
+---------
+
+For convenience, :mod:`.sobol` provides a template at ``sobol/search/_list.html`` which can be used with an ``{% include %}`` tag inside a full search page template to list the search results. The ``_list.html`` template also uses a basic jQuery script (``static/sobol/ajax_search.js``) to handle AJAX search result loading if the AJAX API of the current :class:`.SearchView` is enabled. If you want to use ``_list.html``, but want to provide your own version of jQuery or your own AJAX loading script, or if you want to include the basic script somewhere else (like inside the ``<head>``) simply do the following::
+
+       {% include "sobol/search/_list.html" with suppress_scripts=1 %}
 
 """
 
index f4636e7..6af7e4d 100644 (file)
@@ -29,25 +29,7 @@ class SearchAdmin(admin.ModelAdmin):
        search_fields = ['string', 'result_urls__url']
        actions = ['results_action']
        if 'grappelli' in settings.INSTALLED_APPS:
-               results_template = 'admin/sobol/search/grappelli_results.html'
-       else:
-               results_template = 'admin/sobol/search/results.html'
-       
-       def get_urls(self):
-               urlpatterns = super(SearchAdmin, self).get_urls()
-               
-               def wrap(view):
-                       def wrapper(*args, **kwargs):
-                               return self.admin_site.admin_view(view)(*args, **kwargs)
-                       return update_wrapper(wrapper, view)
-               
-               info = self.model._meta.app_label, self.model._meta.module_name
-               
-               urlpatterns = patterns('',
-                       url(r'^results/$', wrap(self.results_view), name="%s_%s_selected_results" % info),
-                       url(r'^(.+)/results/$', wrap(self.results_view), name="%s_%s_results" % info)
-               ) + urlpatterns
-               return urlpatterns
+               change_form_template = 'admin/sobol/search/grappelli_change_form.html'
        
        def unique_urls(self, obj):
                return obj.unique_urls
@@ -60,41 +42,6 @@ class SearchAdmin(admin.ModelAdmin):
        def queryset(self, request):
                qs = super(SearchAdmin, self).queryset(request)
                return qs.annotate(total_clicks=Count('result_urls__clicks', distinct=True), unique_urls=Count('result_urls', distinct=True))
-       
-       def results_action(self, request, queryset):
-               info = self.model._meta.app_label, self.model._meta.module_name
-               if len(queryset) == 1:
-                       return HttpResponseRedirect(reverse("admin:%s_%s_results" % info, args=(queryset[0].pk,)))
-               else:
-                       url = reverse("admin:%s_%s_selected_results" % info)
-                       return HttpResponseRedirect("%s?ids=%s" % (url, ','.join([str(item.pk) for item in queryset])))
-       results_action.short_description = "View results for selected %(verbose_name_plural)s"
-       
-       def results_view(self, request, object_id=None, extra_context=None):
-               if object_id is not None:
-                       object_ids = [object_id]
-               else:
-                       object_ids = request.GET.get('ids').split(',')
-                       
-                       if object_ids is None:
-                               raise Http404
-               
-               qs = self.queryset(request).filter(pk__in=object_ids)
-               opts = self.model._meta
-               
-               if len(object_ids) == 1:
-                       title = _(u"Search results for %s" % qs[0])
-               else:
-                       title = _(u"Search results for multiple objects")
-               
-               context = {
-                       'title': title,
-                       'queryset': qs,
-                       'opts': opts,
-                       'root_path': self.admin_site.root_path,
-                       'app_label': opts.app_label
-               }
-               return render_to_response(self.results_template, context, context_instance=RequestContext(request))
 
 
 class SearchViewAdmin(EntityAdmin):
index ee5f62e..ffe5871 100644 (file)
@@ -11,7 +11,7 @@ from django.http import HttpResponseRedirect, Http404, HttpResponse
 from django.utils import simplejson as json
 from django.utils.datastructures import SortedDict
 
-from philo.contrib.sobol import registry
+from philo.contrib.sobol import registry, get_search_instance
 from philo.contrib.sobol.forms import SearchForm
 from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash, RegistryIterator
 from philo.exceptions import ViewCanNotProvideSubpath
@@ -79,6 +79,8 @@ class Search(models.Model):
                                        self._favored_results += subresults
                                else:
                                        break
+                       if len(self._favored_results) == len(results):
+                               self._favored_results = []
                return self._favored_results
        
        class Meta:
@@ -156,7 +158,7 @@ try:
 except ImportError:
        pass
 else:
-       add_introspection_rules([], ["^philo\.contrib\.shipherd\.models\.RegistryChoiceField"])
+       add_introspection_rules([], ["^philo\.contrib\.sobol\.models\.RegistryChoiceField"])
 
 
 class SearchView(MultiView):
@@ -194,10 +196,6 @@ class SearchView(MultiView):
                        )
                return urlpatterns
        
-       def get_search_instance(self, slug, search_string):
-               """Gets the :class:`.BaseSearch` subclass registered with :obj:`.sobol.search.registry` as ``slug`` and instantiates it with ``search_string``."""
-               return registry[slug](search_string.lower())
-       
        def results_view(self, request, extra_context=None):
                """
                Renders :attr:`results_page` with a context containing an instance of :attr:`search_form`. If the form was submitted and was valid, then one of two things has happened:
@@ -233,11 +231,12 @@ class SearchView(MultiView):
                                
                                search_instances = []
                                for slug in self.searches:
-                                       search_instance = self.get_search_instance(slug, search_string)
-                                       search_instances.append(search_instance)
+                                       if slug in registry:
+                                               search_instance = get_search_instance(slug, search_string)
+                                               search_instances.append(search_instance)
                                        
-                                       if self.enable_ajax_api:
-                                               search_instance.ajax_api_url = "%s?%s=%s" % (self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node), SEARCH_ARG_GET_KEY, search_string)
+                                               if self.enable_ajax_api:
+                                                       search_instance.ajax_api_url = "%s?%s=%s" % (self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node), SEARCH_ARG_GET_KEY, search_string)
                                
                                if eventlet and not self.enable_ajax_api:
                                        pool = eventlet.GreenPool()
@@ -246,8 +245,16 @@ class SearchView(MultiView):
                                        pool.waitall()
                                
                                context.update({
-                                       'searches': search_instances
+                                       'searches': search_instances,
+                                       'favored_results': []
                                })
+                               
+                               try:
+                                       search = Search.objects.get(string=search_string)
+                               except Search.DoesNotExist:
+                                       pass
+                               else:
+                                       context['favored_results'] = [r.url for r in search.get_favored_results()]
                else:
                        form = SearchForm()
                
@@ -258,8 +265,10 @@ class SearchView(MultiView):
        
        def ajax_api_view(self, request, slug, extra_context=None):
                """
-               Returns a JSON string containing two keyed lists.
+               Returns a JSON object containing the following variables:
                
+               search
+                       Contains the slug for the search.
                results
                        Contains the results of :meth:`.Result.get_context` for each result.
                rendered
@@ -267,19 +276,19 @@ class SearchView(MultiView):
                hasMoreResults
                        ``True`` or ``False`` whether the search has more results according to :meth:`BaseSearch.has_more_results`
                moreResultsURL
-                       Contains None or a querystring which, once accessed, will note the :class:`Click` and redirect the user to a page containing more results.
+                       Contains ``None`` or a querystring which, once accessed, will note the :class:`Click` and redirect the user to a page containing more results.
                
                """
                search_string = request.GET.get(SEARCH_ARG_GET_KEY)
                
-               if not request.is_ajax() or not self.enable_ajax_api or slug not in self.searches or search_string is None:
+               if not request.is_ajax() or not self.enable_ajax_api or slug not in registry or slug not in self.searches or search_string is None:
                        raise Http404
                
-               search_instance = self.get_search_instance(slug, search_string)
+               search_instance = get_search_instance(slug, search_string)
                
                return HttpResponse(json.dumps({
+                       'search': search_instance.slug,
                        'results': [result.get_context() for result in search_instance.results],
-                       'rendered': [result.render() for result in search_instance.results],
-                       'hasMoreResults': search.has_more_results(),
-                       'moreResultsURL': (u"?%s" % search.more_results_querydict.urlencode()) if search.more_results_querydict else None,
+                       'hasMoreResults': search_instance.has_more_results,
+                       'moreResultsURL': search_instance.more_results_url,
                }), mimetype="application/json")
\ No newline at end of file
index b0dca84..a79030a 100644 (file)
@@ -1,5 +1,6 @@
 #encoding: utf-8
 import datetime
+from hashlib import sha1
 
 from django.conf import settings
 from django.contrib.sites.models import Site
@@ -9,7 +10,7 @@ from django.utils import simplejson as json
 from django.utils.http import urlquote_plus
 from django.utils.safestring import mark_safe
 from django.utils.text import capfirst
-from django.template import loader, Context, Template
+from django.template import loader, Context, Template, TemplateDoesNotExist
 
 from philo.contrib.sobol.utils import make_tracking_querydict
 from philo.utils.registry import Registry
@@ -25,22 +26,36 @@ else:
 
 
 __all__ = (
-       'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry'
+       'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry', 'get_search_instance'
 )
 
 
-SEARCH_CACHE_KEY = 'philo_sobol_search_results'
-DEFAULT_RESULT_TEMPLATE_STRING = "{% if url %}<a href='{{ url }}'>{% endif %}{{ title }}{% if url %}</a>{% endif %}"
-DEFAULT_RESULT_TEMPLATE = Template(DEFAULT_RESULT_TEMPLATE_STRING)
-
-# Determines the timeout on the entire result cache.
-MAX_CACHE_TIMEOUT = 60*24*7
+SEARCH_CACHE_SEED = 'philo_sobol_search_results'
+USE_CACHE = getattr(settings, 'SOBOL_USE_CACHE', True)
 
 
 #: A registry for :class:`BaseSearch` subclasses that should be available in the admin.
 registry = Registry()
 
 
+def _make_cache_key(search, search_arg):
+       return sha1(SEARCH_CACHE_SEED + search.slug + search_arg).hexdigest()
+
+
+def get_search_instance(slug, search_arg):
+       """Returns a search instance for the given slug, either from the cache or newly-instantiated."""
+       search = registry[slug]
+       search_arg = search_arg.lower()
+       if USE_CACHE:
+               key = _make_cache_key(search, search_arg)
+               cached = cache.get(key)
+               if cached:
+                       return cached
+       instance = search(search_arg)
+       instance.slug = slug
+       return instance
+
+
 class Result(object):
        """
        :class:`Result` is a helper class that, given a search and a result of that search, is able to correctly render itself with a template defined by the search. Every :class:`Result` will pass a ``title``, a ``url`` (if applicable), and the raw ``result`` returned by the search into the template context when rendering.
@@ -58,39 +73,41 @@ class Result(object):
                return self.search.get_result_title(self.result)
        
        def get_url(self):
-               """Returns the url of the result or an empty string by calling :meth:`BaseSearch.get_result_querydict` on the raw result and then encoding the querydict returned."""
-               qd = self.search.get_result_querydict(self.result)
-               if qd is None:
-                       return ""
-               return "?%s" % qd.urlencode()
+               """Returns the url of the result or ``None`` by calling :meth:`BaseSearch.get_result_url` on the raw result. This url will contain a querystring which, if used, will track a :class:`.Click` for the actual url."""
+               return self.search.get_result_url(self.result)
+       
+       def get_actual_url(self):
+               """Returns the actual url of the result by calling :meth:`BaseSearch.get_actual_result_url` on the raw result."""
+               return self.search.get_actual_result_url(self.result)
+       
+       def get_content(self):
+               """Returns the content of the result by calling :meth:`BaseSearch.get_result_content` on the raw result."""
+               return self.search.get_result_content(self.result)
        
        def get_template(self):
-               """Returns the template for the result by calling :meth:`BaseSearch.get_result_template` on the raw result."""
+               """Returns the template which will be used to render the :class:`Result` by calling :meth:`BaseSearch.get_result_template` on the raw result."""
                return self.search.get_result_template(self.result)
        
-       def get_extra_context(self):
-               """Returns any extra context for the result by calling :meth:`BaseSearch.get_result_extra_context` on the raw result."""
-               return self.search.get_result_extra_context(self.result)
-       
        def get_context(self):
                """
-               Returns the context dictionary for the result. This is used both in rendering the result and in the AJAX return value for :meth:`.SearchView.ajax_api_view`. The context will contain everything from :meth:`get_extra_context` as well as the following keys:
+               Returns the context dictionary for the result. This is used both in rendering the result and in the AJAX return value for :meth:`.SearchView.ajax_api_view`. The context will contain the following keys:
                
                title
                        The result of calling :meth:`get_title`
                url
                        The result of calling :meth:`get_url`
-               result
-                       The raw result which the :class:`Result` was instantiated with.
+               content
+                       The result of calling :meth:`get_content`
                
                """
-               context = self.get_extra_context()
-               context.update({
-                       'title': self.get_title(),
-                       'url': self.get_url(),
-                       'result': self.result
-               })
-               return context
+               if not hasattr(self, '_context'):
+                       self._context = {
+                               'title': self.get_title(),
+                               'url': self.get_url(),
+                               'actual_url': self.get_actual_url(),
+                               'content': self.get_content()
+                       }
+               return self._context
        
        def render(self):
                """Returns the template from :meth:`get_template` rendered with the context from :meth:`get_context`."""
@@ -108,7 +125,7 @@ class BaseSearchMetaclass(type):
                if 'verbose_name' not in attrs:
                        attrs['verbose_name'] = capfirst(' '.join(convert_camelcase(name).rsplit(' ', 1)[:-1]))
                if 'slug' not in attrs:
-                       attrs['slug'] = name.lower()
+                       attrs['slug'] = name[:-6].lower() if name.endswith("Search") else name.lower()
                return super(BaseSearchMetaclass, cls).__new__(cls, name, bases, attrs)
 
 
@@ -120,54 +137,44 @@ class BaseSearch(object):
        
        """
        __metaclass__ = BaseSearchMetaclass
-       #: The number of results to return from the complete list. Default: 10
-       result_limit = 10
+       #: The number of results to return from the complete list. Default: 5
+       result_limit = 5
        #: How long the items for the search should be cached (in minutes). Default: 48 hours.
        _cache_timeout = 60*48
+       #: The path to the template which will be used to render the :class:`Result`\ s for this search. If this is ``None``, then the framework will try ``sobol/search/<slug>/result.html`` and ``sobol/search/result.html``.
+       result_template = None
+       #: The path to the template which will be used to generate the title of the :class:`Result`\ s for this search. If this is ``None``, then the framework will try ``sobol/search/<slug>/title.html`` and ``sobol/search/title.html``.
+       title_template = None
+       #: The path to the template which will be used to generate the content of the :class:`Result`\ s for this search. If this is ``None``, then the framework will try ``sobol/search/<slug>/content.html`` and ``sobol/search/content.html``.
+       content_template = None
        
        def __init__(self, search_arg):
                self.search_arg = search_arg
        
-       def _get_cached_results(self):
-               """Return the cached results if the results haven't timed out. Otherwise return None."""
-               result_cache = cache.get(SEARCH_CACHE_KEY)
-               if result_cache and self.__class__ in result_cache and self.search_arg.lower() in result_cache[self.__class__]:
-                       cached = result_cache[self.__class__][self.search_arg.lower()]
-                       if cached['timeout'] >= datetime.datetime.now():
-                               return cached['results']
-               return None
-       
-       def _set_cached_results(self, results, timeout):
-               """Sets the results to the cache for <timeout> minutes."""
-               result_cache = cache.get(SEARCH_CACHE_KEY) or {}
-               cached = result_cache.setdefault(self.__class__, {}).setdefault(self.search_arg.lower(), {})
-               cached.update({
-                       'results': results,
-                       'timeout': datetime.datetime.now() + datetime.timedelta(minutes=timeout)
-               })
-               cache.set(SEARCH_CACHE_KEY, result_cache, MAX_CACHE_TIMEOUT)
-       
        @property
        def results(self):
                """Retrieves cached results or initiates a new search via :meth:`get_results` and caches the results."""
                if not hasattr(self, '_results'):
-                       results = self._get_cached_results()
-                       if results is None:
-                               try:
-                                       # Cache one extra result so we can see if there are
-                                       # more results to be had.
-                                       limit = self.result_limit
-                                       if limit is not None:
-                                               limit += 1
-                                       results = self.get_results(limit)
-                               except:
-                                       if settings.DEBUG:
-                                               raise
-                                       #  On exceptions, don't set any cache; just return.
-                                       return []
+                       try:
+                               # Cache one extra result so we can see if there are
+                               # more results to be had.
+                               limit = self.result_limit
+                               if limit is not None:
+                                       limit += 1
+                               results = self.get_results(limit)
+                       except:
+                               if settings.DEBUG:
+                                       raise
+                               #  On exceptions, don't set any cache; just return.
+                               return []
                        
-                               self._set_cached_results(results, self._cache_timeout)
                        self._results = results
+                       
+                       if USE_CACHE:
+                               for result in results:
+                                       result.get_context()
+                               key = _make_cache_key(self, self.search_arg)
+                               cache.set(key, self, self._cache_timeout)
                
                return self._results
        
@@ -186,46 +193,73 @@ class BaseSearch(object):
                """Returns an iterable of up to ``limit`` results. The :meth:`get_result_title`, :meth:`get_result_url`, :meth:`get_result_template`, and :meth:`get_result_extra_context` methods will be used to interpret the individual items that this function returns, so the result can be an object with attributes as easily as a dictionary with keys. However, keep in mind that the raw results will be stored with django's caching mechanisms and will be converted to JSON."""
                raise NotImplementedError
        
-       def get_result_title(self, result):
-               """Returns the title of the ``result``. Must be implemented by subclasses."""
-               raise NotImplementedError
-       
-       def get_result_url(self, result):
+       def get_actual_result_url(self, result):
                """Returns the actual URL for the ``result`` or ``None`` if there is no URL. Must be implemented by subclasses."""
                raise NotImplementedError
        
        def get_result_querydict(self, result):
                """Returns a querydict for tracking selection of the result, or ``None`` if there is no URL for the result."""
-               url = self.get_result_url(result)
+               url = self.get_actual_result_url(result)
                if url is None:
                        return None
                return make_tracking_querydict(self.search_arg, url)
        
+       def get_result_url(self, result):
+               """Returns ``None`` or a url which, when accessed, will register a :class:`.Click` for that url."""
+               qd = self.get_result_querydict(result)
+               if qd is None:
+                       return None
+               return "?%s" % qd.urlencode()
+       
+       def get_result_title(self, result):
+               """Returns the title of the ``result``. By default, renders ``sobol/search/<slug>/title.html`` or ``sobol/search/title.html`` with the result in the context. This can be overridden by setting :attr:`title_template` or simply overriding :meth:`get_result_title`. If no template can be found, this will raise :exc:`TemplateDoesNotExist`."""
+               return loader.render_to_string(self.title_template or [
+                       'sobol/search/%s/title.html' % self.slug,
+                       'sobol/search/title.html'
+               ], {'result': result})
+       
+       def get_result_content(self, result):
+               """Returns the content for the ``result``. By default, renders ``sobol/search/<slug>/content.html`` or ``sobol/search/content.html`` with the result in the context. This can be overridden by setting :attr:`content_template` or simply overriding :meth:`get_result_content`. If no template is found, this will return an empty string."""
+               try:
+                       return loader.render_to_string(self.content_template or [
+                               'sobol/search/%s/content.html' % self.slug,
+                               'sobol/search/content.html'
+                       ], {'result': result})
+               except TemplateDoesNotExist:
+                       return ""
+       
        def get_result_template(self, result):
-               """Returns the template to be used for rendering the ``result``."""
-               if hasattr(self, 'result_template'):
+               """Returns the template to be used for rendering the ``result``. For a search with slug ``google``, this would first try ``sobol/search/google/result.html``, then fall back on ``sobol/search/result.html``. Subclasses can override this by setting :attr:`result_template` to the path of another template."""
+               if self.result_template:
                        return loader.get_template(self.result_template)
-               if not hasattr(self, '_result_template'):
-                       self._result_template = DEFAULT_RESULT_TEMPLATE
-               return self._result_template
-       
-       def get_result_extra_context(self, result):
-               """Returns any extra context to be used when rendering the ``result``."""
-               return {}
+               return loader.select_template([
+                       'sobol/search/%s/result.html' % self.slug,
+                       'sobol/search/result.html'
+               ])
        
+       @property
        def has_more_results(self):
                """Returns ``True`` if there are more results than :attr:`result_limit` and ``False`` otherwise."""
                return len(self.results) > self.result_limit
        
-       @property
-       def more_results_url(self):
-               """Returns the actual url for more results. This should be accessed through :attr:`more_results_querydict` in the template so that the click can be tracked."""
-               raise NotImplementedError
+       def get_actual_more_results_url(self):
+               """Returns the actual url for more results. By default, simply returns ``None``."""
+               return None
        
-       @property
-       def more_results_querydict(self):
+       def get_more_results_querydict(self):
                """Returns a :class:`QueryDict` for tracking whether people click on a 'more results' link."""
-               return make_tracking_querydict(self.search_arg, self.more_results_url)
+               url = self.get_actual_more_results_url()
+               if url:
+                       return make_tracking_querydict(self.search_arg, url)
+               return None
+       
+       @property
+       def more_results_url(self):
+               """Returns a URL which consists of a querystring which, when accessed, will log a :class:`.Click` for the actual URL."""
+               qd = self.get_more_results_querydict()
+               if qd is None:
+                       return None
+               return "?%s" % qd.urlencode()
        
        def __unicode__(self):
                return self.verbose_name
@@ -260,9 +294,8 @@ class URLSearch(BaseSearch):
        def url(self):
                """The URL where the search gets its results. Composed from :attr:`search_url` and :attr:`query_format_str`."""
                return self.search_url + self.query_format_str % urlquote_plus(self.search_arg)
-
-       @property
-       def more_results_url(self):
+       
+       def get_actual_more_results_url(self):
                return self.url
        
        def parse_response(self, response, limit=None):
@@ -282,9 +315,9 @@ class JSONSearch(URLSearch):
 class GoogleSearch(JSONSearch):
        """An example implementation of a :class:`JSONSearch`."""
        search_url = "http://ajax.googleapis.com/ajax/services/search/web"
-       result_template = 'search/googlesearch.html'
        _cache_timeout = 60
        verbose_name = "Google search (current site)"
+       _more_results_url = None
        
        @property
        def query_format_str(self):
@@ -323,15 +356,17 @@ class GoogleSearch(JSONSearch):
                        return True
                return False
        
-       @property
-       def more_results_url(self):
+       def get_actual_more_results_url(self):
                return self._more_results_url
        
+       def get_actual_result_url(self, result):
+               return result['unescapedUrl']
+       
        def get_result_title(self, result):
-               return result['titleNoFormatting']
+               return mark_safe(result['titleNoFormatting'])
        
-       def get_result_url(self, result):
-               return result['unescapedUrl']
+       def get_result_content(self, result):
+               return mark_safe(result['content'])
 
 
 registry.register(GoogleSearch)
diff --git a/philo/contrib/sobol/static/sobol/ajax_search.js b/philo/contrib/sobol/static/sobol/ajax_search.js
new file mode 100644 (file)
index 0000000..b2ef413
--- /dev/null
@@ -0,0 +1,81 @@
+(function($){
+       var sobol = window.sobol = {};
+       sobol.favoredResults = []
+       sobol.favoredResultSearch = null;
+       sobol.search = function(){
+               var searches = sobol.searches = $('article.search');
+               if(sobol.favoredResults.length) sobol.favoredResultSearch = searches.eq(0);
+               for (var i=sobol.favoredResults.length ? 1 : 0;i<searches.length;i++) {
+                       (function(){
+                               var s = searches[i];
+                               $.ajax({
+                                       url: s.getAttribute('data-url'),
+                                       dataType: 'json',
+                                       success: function(data){
+                                               sobol.onSuccess($(s), data);
+                                       },
+                                       error: function(data, textStatus, errorThrown){
+                                               sobol.onError($(s), textStatus, errorThrown);
+                                       }
+                               });
+                       }());
+               };
+       }
+       sobol.renderResult = function(result){
+               // Returns the result rendered as a string. Override this to provide custom rendering.
+               var url = result['url'],
+                       title = result['title'],
+                       content = result['content'],
+                       rendered = '';
+               
+               if(url){
+                       rendered += "<dt><a href='" + url + "'>" + title + "</a></dt>";
+               } else {
+                       rendered += "<dt>" + title + "</dt>";
+               }
+               if(content && content != ''){
+                       rendered += "<dd>" + content + "</dd>"
+               }
+               return rendered
+       }
+       sobol.addFavoredResult = function(result) {
+               var dl = sobol.favoredResultSearch.find('dl');
+               if(!dl.length){
+                       dl = $('<dl>');
+                       dl.appendTo(sobol.favoredResultSearch);
+                       sobol.favoredResultSearch.removeClass('loading');
+               }
+               dl[0].innerHTML += sobol.renderResult(result)
+       }
+       sobol.onSuccess = function(ele, data){
+               // hook for success!
+               ele.removeClass('loading');
+               if (data['results'].length) {
+                       ele[0].innerHTML += "<dl>";
+                       $.each(data['results'], function(i, v){
+                               ele[0].innerHTML += sobol.renderResult(v);
+                       })
+                       ele[0].innerHTML += "</dl>";
+                       if(data['hasMoreResults'] && data['moreResultsURL']) ele[0].innerHTML += "<footer><p><a href='" + data['moreResultsURL'] + "'>See more results</a></p></footer>";
+               } else {
+                       ele.addClass('empty');
+                       ele[0].innerHTML += "<p>No results found.</p>";
+                       ele.slideUp();
+               }
+               if (sobol.favoredResultSearch){
+                       for (var i=0;i<data['results'].length;i++){
+                               var r = data['results'][i];
+                               if ($.inArray(r['actual_url'], sobol.favoredResults) != -1){
+                                       sobol.addFavoredResult(r);
+                               }
+                       }
+               }
+       };
+       sobol.onError = function(ele, textStatus, errorThrown){
+               // Hook for error...
+               ele.removeClass('loading');
+               text = errorThrown ? errorThrown : textStatus ? textStatus : "Error occurred.";
+               ele[0].innerHTML += "<p>" + text + "</p>";
+       };
+       $(sobol.search);
+}(jQuery));
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/change_form.html b/philo/contrib/sobol/templates/admin/sobol/search/change_form.html
new file mode 100644 (file)
index 0000000..8dfba08
--- /dev/null
@@ -0,0 +1,43 @@
+{% extends 'admin/change_form.html' %}
+{% load i18n %}
+
+{% block javascripts %}{% endblock %}
+{% block object-tools %}{% endblock %}
+{% block title %}Results for "{{ original.string }}" | {% trans 'Django site admin' %}{% endblock %}
+{% block content_title %}<h1>Results for "{{ original.string }}"</h1>{% endblock %}
+{% block extrastyle %}
+       <style type="text/css">
+               .favored td{
+                       font-weight:bold;
+               }
+               #changelist table{
+                       width:100%;
+               }
+       </style>
+{% endblock %}
+
+{% block content %}
+       <div class="module" id="changelist">
+               <table>
+                       <thead>
+                               <tr>
+                                       <th>Weight</th>
+                                       <th>URL</th>
+                               </tr>
+                       </thead>
+                       <tbody>
+                               {% for result in original.get_weighted_results %}
+                               <tr class="{% cycle 'row1' 'row2' %}{% if result in original.get_favored_results %} favored{% endif %}">
+                                       <td>{{ result.weight }}</td>
+                                       <td>{{ result.url }}</td>
+                               </tr>
+                               {% endfor %}
+                       </tbody>
+               </table>
+       </div>
+       {% block submit_row %}
+       <div class="submit-row">
+               {% if not is_popup and has_delete_permission %}{% if change or show_delete %}<p class="deletelink-box"><a href="delete/" class="deletelink">{% trans "Delete" %}</a></p>{% endif %}{% endif %}
+       </div>
+       {% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/change_list.html b/philo/contrib/sobol/templates/admin/sobol/search/change_list.html
new file mode 100644 (file)
index 0000000..9b01661
--- /dev/null
@@ -0,0 +1,3 @@
+{% extends 'admin/change_list.html' %}
+
+{% block object-tools %}{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/grappelli_change_form.html b/philo/contrib/sobol/templates/admin/sobol/search/grappelli_change_form.html
new file mode 100644 (file)
index 0000000..c89f748
--- /dev/null
@@ -0,0 +1,23 @@
+{% extends 'admin/sobol/search/change_form.html' %}
+{% load i18n %}
+
+{% block extrastyle %}
+       <style type="text/css">
+               .favored td{
+                       font-weight:bold;
+               }
+               #changelist{
+                       border:none;
+                       background:none;
+               }
+               thead th{color:#444;font-weight:bold;}
+       </style>
+{% endblock %}
+
+{% block submit_row %}
+       <div class="module footer">
+               <ul class="submit-row">
+                       {% if not is_popup and has_delete_permission %}{% if change or show_delete %}<li class="left delete-link-container"><a href="delete/" class="delete-link">{% trans "Delete" %}</a></li>{% endif %}{% endif %}
+               </ul>
+       </div>
+{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html b/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html
deleted file mode 100644 (file)
index f01eb88..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-{% extends "admin/base_site.html" %}
-
-<!-- LOADING -->
-{% load i18n %}
-
-<!-- EXTRASTYLES -->
-{% block extrastyle %}<style type="text/css">.favored{font-weight:bold;}</style>{% endblock %}
-
-<!-- BREADCRUMBS -->
-{% block breadcrumbs %}
-       <div id="breadcrumbs">
-               {% if queryset|length > 1 %}
-               <a href="../../">{% trans "Home" %}</a> &rsaquo;
-               <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
-               <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
-               {% trans 'Search results for multiple objects' %}
-               {% else %}
-               <a href="../../../../">{% trans "Home" %}</a> &rsaquo;
-               <a href="../../../">{{ app_label|capfirst }}</a> &rsaquo;
-               <a href="../../">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
-               <a href="../">{{ queryset|first|truncatewords:"18" }}</a> &rsaquo;
-               {% trans 'Results' %}
-               {% endif %}
-       </div>
-{% endblock %}
-
-<!-- CONTENT -->
-{% block content %}
-       <div class="container-grid delete-confirmation">
-               {% for search in queryset %}
-               <div class="group tabular">
-                       <h2>{{ search_string }}</h2>
-                       <div class="module table">
-                               <div class="module thead">
-                                       <div class="tr">
-                                               <div class="th">Weight</div>
-                                               <div class="th">URL</div>
-                                       </div>
-                               </div>
-                               <div class="module tbody">
-                                       {% for result in search.get_weighted_results %}
-                                       <div class="tr{% if result in search.get_favored_results %} favored{% endif %}">
-                                               <div class="td">{{ result.weight }}</div>
-                                               <div class="td">{{ result.url }}</div>
-                                       </div>
-                                       {% endfor %}
-                               </div>
-                       </div>
-               </div>
-               {% endfor %}
-       </div>
-{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/results.html b/philo/contrib/sobol/templates/admin/sobol/search/results.html
deleted file mode 100644 (file)
index 24442c7..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-{% extends "admin/base_site.html" %}
-{% load i18n %}
-
-{% block extrastyle %}<style type="text/css">.favored{font-weight:bold;}</style>{% endblock %}
-
-{% block breadcrumbs %}
-<div class="breadcrumbs">
-       {% if queryset|length > 1 %}
-       <a href="../../">{% trans "Home" %}</a> &rsaquo;
-       <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
-       <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
-       {% trans 'Search results for multiple objects' %}
-       {% else %}
-       <a href="../../../../">{% trans "Home" %}</a> &rsaquo;
-       <a href="../../../">{{ app_label|capfirst }}</a> &rsaquo; 
-       <a href="../../">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
-       <a href="../">{{ queryset|first|truncatewords:"18" }}</a> &rsaquo;
-       {% trans 'Results' %}
-       {% endif %}
-</div>
-{% endblock %}
-
-
-{% block content %}
-               {% for search in queryset %}
-                       <fieldset class="module">
-                               <h2>{{ search.string }}</h2>
-                               <table>
-                                       <thead>
-                                               <tr>
-                                                       <th>Weight</th>
-                                                       <th>URL</th>
-                                               </tr>
-                                       </thead>
-                                       <tbody>
-                                               {% for result in search.get_weighted_results %}
-                                               <tr{% if result in search.favored_results %} class="favored"{% endif %}>
-                                                       <td>{{ result.weight }}</td>
-                                                       <td>{{ result.url }}</td>
-                                               </tr>
-                                               {% endfor %}
-                                       </tbody>
-                               </table>
-                       </fieldset>
-               {% endfor %}
-{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/search/googlesearch.html b/philo/contrib/sobol/templates/search/googlesearch.html
deleted file mode 100644 (file)
index 1b22388..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<article>
-       <h1><a href="{{ url }}">{{ title|safe }}</a></h1>
-       <p>{{ content|safe }}</p>
-</article>
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/sobol/search/_list.html b/philo/contrib/sobol/templates/sobol/search/_list.html
new file mode 100644 (file)
index 0000000..99db761
--- /dev/null
@@ -0,0 +1,56 @@
+{% with node.view.enable_ajax_api as ajax %}
+{% if ajax %}
+       {% if not suppress_scripts %}<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js"></script><script type="text/javascript" src="{{ STATIC_URL }}sobol/ajax_search.js"></script>{% endif %}
+       <script type="text/javascript">
+               (function($){
+                       var sobol = window.sobol;
+                       sobol.favoredResults = [{% for r in favored_results %}"{{ r }}"{% if not forloop.last %}, {% endif %}{% endfor %}];
+               }(jQuery));
+       </script>
+{% endif %}
+{% if favored_results %}
+       <article class="search favored{% if ajax %} loading{% endif %}">
+               <header>
+                       <h1>Favored results</h1>
+               </header>
+               {% if not ajax %}
+               <dl>
+                       {% for search in searches %}
+                       {% for result in search.results %}
+                               {% if result.get_actual_url in favored_results %}
+                               {{ result }}
+                               {% endif %}
+                       {% endfor %}
+                       {% endfor %}
+                       {% if search.get_actual_more_results_url in favored_results %}
+                               <dt><a href="{{ search.more_results_url }}">More results for {{ search }}</a></dt>
+                       {% endif %}
+               </dl>
+               {% endif %}
+       </article>
+{% endif %}
+{% for search in searches %}
+<article {% if ajax %}class="search loading {{ search.slug }}" data-url="{{ search.ajax_api_url }}"{% else %}class="search {{ search.slug }}{% if not search.results %} empty{% endif %}"{% endif %}>
+       <header>
+               <a name='{{ search.slug }}'></a>
+               <h1>{{ search }}</h1>
+       </header>
+       {% if not ajax %}
+               {% if search.results %}
+                       <dl>
+                       {% for result in search.results %}
+                               {{ result }}
+                       {% endfor %}
+                       </dl>
+                       {% if search.has_more_results and search.more_results_url %}
+                       <footer>
+                               <p><a href="{{ search.more_results_url }}">See more results</a></p>
+                       </footer>
+                       {% endif %}
+               {% else %}
+                       <p>No results found.</p>
+               {% endif %}
+       {% endif %}
+</article>
+{% endfor %}
+{% endwith %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/sobol/search/content.html b/philo/contrib/sobol/templates/sobol/search/content.html
new file mode 100644 (file)
index 0000000..82088ec
--- /dev/null
@@ -0,0 +1 @@
+{{ result.content|truncatewords_html:20 }}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/sobol/search/result.html b/philo/contrib/sobol/templates/sobol/search/result.html
new file mode 100644 (file)
index 0000000..c5a906a
--- /dev/null
@@ -0,0 +1,2 @@
+<dt>{% if url %}<a href="{{ url }}">{% endif %}{{ title }}{% if url %}</a>{% endif %}</dt>
+{% if content %}<dd>{{ content }}</dd>{% endif %}
\ No newline at end of file
index 037fdc8..f4f7e9d 100644 (file)
@@ -36,7 +36,12 @@ def get_node(path):
 
 
 class RequestNodeMiddleware(object):
-       """Adds a ``node`` attribute, representing the currently-viewed node, to every incoming :class:`HttpRequest` object. This is required by :func:`philo.views.node_view`."""
+       """
+       Adds a ``node`` attribute, representing the currently-viewed :class:`.Node`, to every incoming :class:`HttpRequest` object. This is required by :func:`philo.views.node_view`.
+       
+       :class:`RequestNodeMiddleware` also catches all exceptions raised while handling requests that have attached :class:`.Node`\ s if :setting:`settings.DEBUG` is ``True``. If a :exc:`django.http.Http404` error was caught, :class:`RequestNodeMiddleware` will look for an "Http404" :class:`.Attribute` on the request's :class:`.Node`; otherwise it will look for an "Http500" :class:`.Attribute`. If an appropriate :class:`.Attribute` is found, and the value of the attribute is a :class:`.View` instance, then the :class:`.View` will be rendered with the exception in the ``extra_context``, bypassing any later handling of exceptions.
+       
+       """
        def process_view(self, request, view_func, view_args, view_kwargs):
                try:
                        path = view_kwargs['path']
@@ -51,12 +56,16 @@ class RequestNodeMiddleware(object):
                
                if isinstance(exception, Http404):
                        error_view = request.node.attributes.get('Http404', None)
+                       status_code = 404
                else:
                        error_view = request.node.attributes.get('Http500', None)
+                       status_code = 500
                
                if error_view is None or not isinstance(error_view, View):
                        # Should this be duck-typing? Perhaps even no testing?
                        return
                
                extra_context = {'exception': exception}
-               return error_view.render_to_response(request, extra_context)
\ No newline at end of file
+               response = error_view.render_to_response(request, extra_context)
+               response.status_code = status_code
+               return response
\ No newline at end of file
diff --git a/philo/migrations/0016_auto__add_field_file_name.py b/philo/migrations/0016_auto__add_field_file_name.py
new file mode 100644 (file)
index 0000000..0d8e654
--- /dev/null
@@ -0,0 +1,139 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding field 'File.name'
+        db.add_column('philo_file', 'name', self.gf('django.db.models.fields.CharField')(default='<Generated name>', max_length=255), keep_default=False)
+
+
+    def backwards(self, orm):
+        
+        # Deleting field 'File.name'
+        db.delete_column('philo_file', 'name')
+
+
+    models = {
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'philo.attribute': {
+            'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'},
+            'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}),
+            'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}),
+            'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+        },
+        'philo.collection': {
+            'Meta': {'object_name': 'Collection'},
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.collectionmember': {
+            'Meta': {'object_name': 'CollectionMember'},
+            'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+        },
+        'philo.contentlet': {
+            'Meta': {'object_name': 'Contentlet'},
+            'content': ('philo.models.fields.TemplateField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"})
+        },
+        'philo.contentreference': {
+            'Meta': {'object_name': 'ContentReference'},
+            'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"})
+        },
+        'philo.file': {
+            'Meta': {'object_name': 'File'},
+            'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.foreignkeyvalue': {
+            'Meta': {'object_name': 'ForeignKeyValue'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+        },
+        'philo.jsonvalue': {
+            'Meta': {'object_name': 'JSONValue'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'})
+        },
+        'philo.manytomanyvalue': {
+            'Meta': {'object_name': 'ManyToManyValue'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'})
+        },
+        'philo.node': {
+            'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}),
+            'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'philo.page': {
+            'Meta': {'object_name': 'Page'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.redirect': {
+            'Meta': {'object_name': 'Redirect'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}),
+            'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}),
+            'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+        },
+        'philo.tag': {
+            'Meta': {'ordering': "('name',)", 'object_name': 'Tag'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'})
+        },
+        'philo.template': {
+            'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'},
+            'code': ('philo.models.fields.TemplateField', [], {}),
+            'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'})
+        }
+    }
+
+    complete_apps = ['philo']
diff --git a/philo/migrations/0017_generate_filenames.py b/philo/migrations/0017_generate_filenames.py
new file mode 100644 (file)
index 0000000..613ac7a
--- /dev/null
@@ -0,0 +1,139 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import DataMigration
+from django.db import models
+
+class Migration(DataMigration):
+
+       def forwards(self, orm):
+               "Write your forwards methods here."
+               for f in orm.File.objects.filter(name="<Generated name>"):
+                       f.name = f.file.name
+                       f.save()
+
+
+       def backwards(self, orm):
+               "Write your backwards methods here."
+               pass
+
+
+       models = {
+               'contenttypes.contenttype': {
+                       'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+                       'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+                       'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+               },
+               'philo.attribute': {
+                       'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'},
+                       'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}),
+                       'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+                       'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}),
+                       'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+               },
+               'philo.collection': {
+                       'Meta': {'object_name': 'Collection'},
+                       'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+               },
+               'philo.collectionmember': {
+                       'Meta': {'object_name': 'CollectionMember'},
+                       'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+                       'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+                       'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+               },
+               'philo.contentlet': {
+                       'Meta': {'object_name': 'Contentlet'},
+                       'content': ('philo.models.fields.TemplateField', [], {}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+                       'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"})
+               },
+               'philo.contentreference': {
+                       'Meta': {'object_name': 'ContentReference'},
+                       'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+                       'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+                       'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"})
+               },
+               'philo.file': {
+                       'Meta': {'object_name': 'File'},
+                       'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+                       'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+               },
+               'philo.foreignkeyvalue': {
+                       'Meta': {'object_name': 'ForeignKeyValue'},
+                       'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+               },
+               'philo.jsonvalue': {
+                       'Meta': {'object_name': 'JSONValue'},
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'})
+               },
+               'philo.manytomanyvalue': {
+                       'Meta': {'object_name': 'ManyToManyValue'},
+                       'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'})
+               },
+               'philo.node': {
+                       'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'},
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+                       'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+                       'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}),
+                       'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+                       'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+                       'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+                       'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}),
+                       'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'})
+               },
+               'philo.page': {
+                       'Meta': {'object_name': 'Page'},
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}),
+                       'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+               },
+               'philo.redirect': {
+                       'Meta': {'object_name': 'Redirect'},
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}),
+                       'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}),
+                       'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}),
+                       'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+               },
+               'philo.tag': {
+                       'Meta': {'ordering': "('name',)", 'object_name': 'Tag'},
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+                       'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'})
+               },
+               'philo.template': {
+                       'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'},
+                       'code': ('philo.models.fields.TemplateField', [], {}),
+                       'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+                       'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+                       'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}),
+                       'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+                       'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}),
+                       'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+                       'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+                       'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'})
+               }
+       }
+
+       complete_apps = ['philo']
index 86569b7..2f798ae 100644 (file)
@@ -319,7 +319,12 @@ class Entity(models.Model):
                
                """
                return mapper(self)
-       attributes = property(get_attribute_mapper)
+       
+       @property
+       def attributes(self):
+               if not hasattr(self, '_attributes'):
+                       self._attributes = self.get_attribute_mapper()
+               return self._attributes
        
        class Meta:
                abstract = True
@@ -500,7 +505,6 @@ class TreeEntity(Entity, MPTTModel):
                        else:
                                mapper = AttributeMapper
                return super(TreeEntity, self).get_attribute_mapper(mapper)
-       attributes = property(get_attribute_mapper)
        
        def __unicode__(self):
                return self.path
index ab3bca5..5b8b8ed 100644 (file)
@@ -1,4 +1,6 @@
 from inspect import getargspec
+import mimetypes
+from os.path import basename
 
 from django.contrib.contenttypes import generic
 from django.contrib.contenttypes.models import ContentType
@@ -374,23 +376,32 @@ class Redirect(TargetURLModel, View):
 
 class File(View):
        """Stores an arbitrary file."""
-       #: Defines the mimetype of the uploaded file. This will not be validated.
-       mimetype = models.CharField(max_length=255)
+       #: The name of the uploaded file. This is meant for finding the file again later, not for display.
+       name = models.CharField(max_length=255)
+       #: Defines the mimetype of the uploaded file. This will not be validated. If no mimetype is provided, it will be automatically generated based on the filename.
+       mimetype = models.CharField(max_length=255, blank=True)
        #: Contains the uploaded file. Files are uploaded to ``philo/files/%Y/%m/%d``.
        file = models.FileField(upload_to='philo/files/%Y/%m/%d')
        
+       def clean(self):
+               if not self.mimetype:
+                       self.mimetype = mimetypes.guess_type(self.file.name, strict=False)[0]
+                       if self.mimetype is None:
+                               raise ValidationError("Unknown file type.")
+       
        def actually_render_to_response(self, request, extra_context=None):
                wrapper = FileWrapper(self.file)
                response = HttpResponse(wrapper, content_type=self.mimetype)
                response['Content-Length'] = self.file.size
+               response['Content-Disposition'] = "inline; filename=%s" % basename(self.file.name)
                return response
        
        class Meta:
                app_label = 'philo'
        
        def __unicode__(self):
-               """Returns the path of the uploaded file."""
-               return self.file.name
+               """Returns the value of :attr:`File.name`."""
+               return self.name
 
 
 register_value_model(Node)
\ No newline at end of file
index 05e41fc..1ddff05 100644 (file)
@@ -20,8 +20,8 @@ class AttributeMapper(object, DictMixin):
        
        def __getitem__(self, key):
                """Returns the ultimate python value of the :class:`~philo.models.base.Attribute` with the given ``key`` from the cache, populating the cache if necessary."""
-               if not self._cache_populated:
-                       self._populate_cache()
+               if not self._cache_filled:
+                       self._fill_cache()
                return self._cache[key]
        
        def __setitem__(self, key, value):
@@ -53,30 +53,30 @@ class AttributeMapper(object, DictMixin):
        
        def get_attribute(self, key, default=None):
                """Returns the :class:`~philo.models.base.Attribute` instance with the given ``key`` from the cache, populating the cache if necessary, or ``default`` if no such attribute is found."""
-               if not self._cache_populated:
-                       self._populate_cache()
+               if not self._cache_filled:
+                       self._fill_cache()
                return self._attributes_cache.get(key, default)
        
        def keys(self):
                """Returns the keys from the cache, first populating the cache if necessary."""
-               if not self._cache_populated:
-                       self._populate_cache()
+               if not self._cache_filled:
+                       self._fill_cache()
                return self._cache.keys()
        
        def items(self):
                """Returns the items from the cache, first populating the cache if necessary."""
-               if not self._cache_populated:
-                       self._populate_cache()
+               if not self._cache_filled:
+                       self._fill_cache()
                return self._cache.items()
        
        def values(self):
                """Returns the values from the cache, first populating the cache if necessary."""
-               if not self._cache_populated:
-                       self._populate_cache()
+               if not self._cache_filled:
+                       self._fill_cache()
                return self._cache.values()
        
-       def _populate_cache(self):
-               if self._cache_populated:
+       def _fill_cache(self):
+               if self._cache_filled:
                        return
                
                attributes = self.get_attributes()
@@ -92,24 +92,24 @@ class AttributeMapper(object, DictMixin):
                        values_bulk[ct] = ct.model_class().objects.in_bulk(pks)
                
                self._cache.update(dict([(a.key, getattr(values_bulk[a.value_content_type].get(a.value_object_id), 'value', None)) for a in attributes]))
-               self._cache_populated = True
+               self._cache_filled = True
        
        def clear_cache(self):
                """Clears the cache."""
                self._cache = {}
                self._attributes_cache = {}
-               self._cache_populated = False
+               self._cache_filled = False
 
 
 class LazyAttributeMapperMixin(object):
        """In some cases, it may be that only one attribute value needs to be fetched. In this case, it is more efficient to avoid populating the cache whenever possible. This mixin overrides the :meth:`__getitem__` and :meth:`get_attribute` methods to prevent their populating the cache. If the cache has been populated (i.e. through :meth:`keys`, :meth:`values`, etc.), then the value or attribute will simply be returned from the cache."""
        def __getitem__(self, key):
-               if key not in self._cache and not self._cache_populated:
+               if key not in self._cache and not self._cache_filled:
                        self._add_to_cache(key)
                return self._cache[key]
        
        def get_attribute(self, key, default=None):
-               if key not in self._attributes_cache and not self._cache_populated:
+               if key not in self._attributes_cache and not self._cache_filled:
                        self._add_to_cache(key)
                return self._attributes_cache.get(key, default)
        
@@ -175,16 +175,16 @@ class PassthroughAttributeMapper(AttributeMapper):
                self._attributes = [e.attributes for e in entities]
                super(PassthroughAttributeMapper, self).__init__(self._attributes[0].entity)
        
-       def _populate_cache(self):
-               if self._cache_populated:
+       def _fill_cache(self):
+               if self._cache_filled:
                        return
                
                for a in reversed(self._attributes):
-                       a._populate_cache()
+                       a._fill_cache()
                        self._attributes_cache.update(a._attributes_cache)
                        self._cache.update(a._cache)
                
-               self._cache_populated = True
+               self._cache_filled = True
        
        def get_attributes(self):
                raise NotImplementedError
index f33d211..8f13ea5 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -44,7 +44,7 @@ setup(
        version = '.'.join([str(v) for v in version]),
        url = "http://philocms.org/",
        description = "A foundation for developing web content management systems.",
-       long_description = open(os.path.join(root_dir, 'README.markdown')).read(),
+       long_description = open(os.path.join(root_dir, 'README')).read(),
        maintainer = "iThink Software",
        maintainer_email = "contact@ithinksw.com",
        packages = packages,