Merge branch 'release' of git://github.com/melinath/philo
authorJoseph Spiros <joseph.spiros@ithinksw.com>
Tue, 24 May 2011 21:52:18 +0000 (17:52 -0400)
committerJoseph Spiros <joseph.spiros@ithinksw.com>
Tue, 24 May 2011 21:52:18 +0000 (17:52 -0400)
* 'release' of git://github.com/melinath/philo: (44 commits)
  Hacked around the descriptor issues, since there were only three.
  Added docs for waldo.
  Converted, corrected, improved, and added to documentation for sobol's models and search API.
  Cleaned up SearchView.results_view. Added documentation for sobol.models.
  Implemented more robust delayed registry iteration. Modules declaring new searches can now be imported after sobol without issue.
  Tweaks to sobol to make its use simpler: added a prerendered version of the results to the ajax response, added result to result context by default, and preconfigured GET params on the reversed ajax_api urls that are passed to the template context. Added initial Sobol migration.
  Added directives and autodocumenters for template tags and filters in a custom extension. Switched previous template tag/filter docs to use the new directives. Renamed NavigationManager.get_queryset method to the correct get_query_set. Documented shipherd.
  Added missing Attribute import to philo.utils.entities.
  Added note about Julian's development status.
  Added some automatic skips to autodocs. Added skeletal docs for contrib and complete docs for penfield. Removed philo validators.
  Corrected various imports after adding limiting __all__ entries to philo.models.*
  Removed penfield's reliance on the Titled model and marked it as deprecated.
  Added/corrected docstrings for custom model fields and EntityProxyFields (now renamed to AttributeProxyFields and combined with former AttributeField). Added docs for philo.forms.
  Moved templatetag docs into the respective modules and fleshed them out. Centralized adding of template tags to builtins. Added __all__ declarations to philo.models.*. Added a bit of documentation to Page.render_to_string about context variables.
  Added docs for the db template loader and skeleton docs for templatetags.  Updated template tag references to actually point to templatetags. Made some minor improvements to references.
  Swapped references to specific AttributeMappers in for vague references to dictionary-like objects. Corrected links in a few places. Combined middleware docs with new node_view docs.
  Standardized AttributeMapper.get_attribute behavior. Corrected LazyTreeAttributeMapper behavior. Added documentation for AttributeMappers.
  Split utils into entities (i.e. AttributeMappers) and other utils. Added documentation for the other utils and prepped for documenting the AttributeMappers.
  Removed the various philo.validators URL validators since they served no real purpose. (The only strings that would not validate actually were valid.) Added docstrings to the validators and added a validators doc file.
  Added/tweaked docs for exceptions, middleware, and signals. Minor formatting tweaks to View and TreeManager docstrings.
  ...

143 files changed:
.gitignore
README
README.markdown
__init__.py [deleted file]
contrib/penfield/templatetags/penfield.py [deleted file]
contrib/penfield/validators.py [deleted file]
contrib/sobol/__init__.py [deleted file]
contrib/sobol/models.py [deleted file]
contrib/sobol/utils.py [deleted file]
docs/Makefile [new file with mode: 0644]
docs/_ext/djangodocs.py [new file with mode: 0644]
docs/_ext/philodocs.py [new file with mode: 0644]
docs/conf.py [new file with mode: 0644]
docs/contrib/intro.rst [new file with mode: 0644]
docs/contrib/penfield.rst [new file with mode: 0644]
docs/contrib/shipherd.rst [new file with mode: 0644]
docs/contrib/sobol.rst [new file with mode: 0644]
docs/contrib/waldo.rst [new file with mode: 0644]
docs/dummy-settings.py [new file with mode: 0644]
docs/exceptions.rst [new file with mode: 0644]
docs/forms.rst [new file with mode: 0644]
docs/handling_requests.rst [new file with mode: 0644]
docs/index.rst [new file with mode: 0644]
docs/intro.rst [new file with mode: 0644]
docs/loaders.rst [new file with mode: 0644]
docs/make.bat [new file with mode: 0644]
docs/models/collections.rst [new file with mode: 0644]
docs/models/entities.rst [new file with mode: 0644]
docs/models/fields.rst [new file with mode: 0644]
docs/models/intro.rst [new file with mode: 0644]
docs/models/miscellaneous.rst [new file with mode: 0644]
docs/models/nodes-and-views.rst [new file with mode: 0644]
docs/signals.rst [new file with mode: 0644]
docs/templatetags.rst [new file with mode: 0644]
docs/utilities.rst [new file with mode: 0644]
docs/validators.rst [new file with mode: 0644]
exceptions.py [deleted file]
philo/LICENSE [moved from LICENSE with 100% similarity]
philo/__init__.py [new file with mode: 0644]
philo/admin/__init__.py [moved from admin/__init__.py with 100% similarity]
philo/admin/base.py [moved from admin/base.py with 99% similarity]
philo/admin/collections.py [moved from admin/collections.py with 91% similarity]
philo/admin/forms/__init__.py [moved from admin/forms/__init__.py with 100% similarity]
philo/admin/forms/attributes.py [moved from admin/forms/attributes.py with 99% similarity]
philo/admin/forms/containers.py [moved from admin/forms/containers.py with 99% similarity]
philo/admin/nodes.py [moved from admin/nodes.py with 79% similarity]
philo/admin/pages.py [moved from admin/pages.py with 84% similarity]
philo/admin/widgets.py [moved from admin/widgets.py with 86% similarity]
philo/contrib/__init__.py [new file with mode: 0644]
philo/contrib/julian/__init__.py [new file with mode: 0644]
philo/contrib/julian/admin.py [moved from contrib/julian/admin.py with 97% similarity]
philo/contrib/julian/feedgenerator.py [moved from contrib/julian/feedgenerator.py with 100% similarity]
philo/contrib/julian/migrations/0001_initial.py [moved from contrib/julian/migrations/0001_initial.py with 100% similarity]
philo/contrib/julian/migrations/__init__.py [moved from contrib/__init__.py with 100% similarity]
philo/contrib/julian/models.py [moved from contrib/julian/models.py with 99% similarity]
philo/contrib/penfield/__init__.py [moved from contrib/julian/__init__.py with 100% similarity]
philo/contrib/penfield/admin.py [moved from contrib/penfield/admin.py with 88% similarity]
philo/contrib/penfield/exceptions.py [moved from contrib/penfield/exceptions.py with 100% similarity]
philo/contrib/penfield/middleware.py [moved from contrib/penfield/middleware.py with 69% similarity]
philo/contrib/penfield/migrations/0001_initial.py [moved from contrib/penfield/migrations/0001_initial.py with 100% similarity]
philo/contrib/penfield/migrations/0002_auto.py [moved from contrib/penfield/migrations/0002_auto.py with 100% similarity]
philo/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py [moved from contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py with 100% similarity]
philo/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py [moved from contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py with 100% similarity]
philo/contrib/penfield/migrations/__init__.py [moved from contrib/julian/migrations/__init__.py with 100% similarity]
philo/contrib/penfield/models.py [moved from contrib/penfield/models.py with 66% similarity]
philo/contrib/penfield/templatetags/__init__.py [moved from contrib/penfield/__init__.py with 100% similarity]
philo/contrib/penfield/templatetags/penfield.py [new file with mode: 0644]
philo/contrib/shipherd/__init__.py [moved from contrib/penfield/migrations/__init__.py with 100% similarity]
philo/contrib/shipherd/admin.py [moved from contrib/shipherd/admin.py with 97% similarity]
philo/contrib/shipherd/migrations/0001_initial.py [moved from contrib/shipherd/migrations/0001_initial.py with 100% similarity]
philo/contrib/shipherd/migrations/0002_auto.py [moved from contrib/shipherd/migrations/0002_auto.py with 100% similarity]
philo/contrib/shipherd/migrations/__init__.py [moved from contrib/penfield/templatetags/__init__.py with 100% similarity]
philo/contrib/shipherd/models.py [moved from contrib/shipherd/models.py with 70% similarity]
philo/contrib/shipherd/templatetags/__init__.py [moved from contrib/shipherd/__init__.py with 100% similarity]
philo/contrib/shipherd/templatetags/shipherd.py [moved from contrib/shipherd/templatetags/shipherd.py with 84% similarity]
philo/contrib/sobol/__init__.py [new file with mode: 0644]
philo/contrib/sobol/admin.py [moved from contrib/sobol/admin.py with 98% similarity]
philo/contrib/sobol/forms.py [moved from contrib/sobol/forms.py with 97% similarity]
philo/contrib/sobol/migrations/0001_initial.py [new file with mode: 0644]
philo/contrib/sobol/migrations/__init__.py [moved from contrib/shipherd/migrations/__init__.py with 100% similarity]
philo/contrib/sobol/models.py [new file with mode: 0644]
philo/contrib/sobol/search.py [moved from contrib/sobol/search.py with 51% similarity]
philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html [moved from contrib/sobol/templates/admin/sobol/search/grappelli_results.html with 100% similarity]
philo/contrib/sobol/templates/admin/sobol/search/results.html [moved from contrib/sobol/templates/admin/sobol/search/results.html with 100% similarity]
philo/contrib/sobol/templates/search/googlesearch.html [moved from contrib/sobol/templates/search/googlesearch.html with 100% similarity]
philo/contrib/sobol/utils.py [new file with mode: 0644]
philo/contrib/waldo/__init__.py [moved from contrib/shipherd/templatetags/__init__.py with 100% similarity]
philo/contrib/waldo/forms.py [moved from contrib/waldo/forms.py with 78% similarity]
philo/contrib/waldo/models.py [moved from contrib/waldo/models.py with 70% similarity]
philo/contrib/waldo/tokens.py [moved from contrib/waldo/tokens.py with 77% similarity]
philo/exceptions.py [new file with mode: 0644]
philo/fixtures/test_fixtures.json [moved from fixtures/test_fixtures.json with 100% similarity]
philo/forms/__init__.py [moved from forms/__init__.py with 100% similarity]
philo/forms/entities.py [moved from forms/entities.py with 94% similarity]
philo/forms/fields.py [moved from forms/fields.py with 74% similarity]
philo/loaders/__init__.py [moved from contrib/waldo/__init__.py with 100% similarity]
philo/loaders/database.py [moved from loaders/database.py with 57% similarity]
philo/middleware.py [moved from middleware.py with 88% similarity]
philo/migrations/0001_initial.py [moved from migrations/0001_initial.py with 100% similarity]
philo/migrations/0002_auto__add_field_attribute_value.py [moved from migrations/0002_auto__add_field_attribute_value.py with 100% similarity]
philo/migrations/0003_move_json.py [moved from migrations/0003_move_json.py with 100% similarity]
philo/migrations/0004_auto__del_field_attribute_json_value.py [moved from migrations/0004_auto__del_field_attribute_json_value.py with 100% similarity]
philo/migrations/0005_add_attribute_values.py [moved from migrations/0005_add_attribute_values.py with 100% similarity]
philo/migrations/0006_move_attribute_and_relationship_values.py [moved from migrations/0006_move_attribute_and_relationship_values.py with 100% similarity]
philo/migrations/0007_auto__del_relationship__del_field_attribute_value.py [moved from migrations/0007_auto__del_relationship__del_field_attribute_value.py with 100% similarity]
philo/migrations/0008_auto__del_field_manytomanyvalue_object_ids.py [moved from migrations/0008_auto__del_field_manytomanyvalue_object_ids.py with 100% similarity]
philo/migrations/0009_auto__add_field_node_lft__add_field_node_rght__add_field_node_tree_id_.py [moved from migrations/0009_auto__add_field_node_lft__add_field_node_rght__add_field_node_tree_id_.py with 100% similarity]
philo/migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py [moved from migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py with 100% similarity]
philo/migrations/0011_move_target_url.py [moved from migrations/0011_move_target_url.py with 100% similarity]
philo/migrations/0012_auto__del_field_redirect_target.py [moved from migrations/0012_auto__del_field_redirect_target.py with 100% similarity]
philo/migrations/0013_auto.py [moved from migrations/0013_auto.py with 100% similarity]
philo/migrations/0014_auto.py [moved from migrations/0014_auto.py with 100% similarity]
philo/migrations/__init__.py [moved from migrations/__init__.py with 100% similarity]
philo/models/__init__.py [moved from models/__init__.py with 50% similarity]
philo/models/base.py [moved from models/base.py with 66% similarity]
philo/models/collections.py [moved from models/collections.py with 55% similarity]
philo/models/fields/__init__.py [moved from models/fields/__init__.py with 88% similarity]
philo/models/fields/entities.py [moved from models/fields/entities.py with 63% similarity]
philo/models/nodes.py [moved from models/nodes.py with 52% similarity]
philo/models/pages.py [moved from models/pages.py with 62% similarity]
philo/signals.py [new file with mode: 0644]
philo/static/admin/js/TagCreation.js [moved from media/admin/js/TagCreation.js with 100% similarity]
philo/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html [moved from templates/admin/philo/edit_inline/grappelli_tabular_attribute.html with 56% similarity]
philo/templates/admin/philo/edit_inline/grappelli_tabular_container.html [moved from templates/admin/philo/edit_inline/grappelli_tabular_container.html with 81% similarity]
philo/templates/admin/philo/edit_inline/tabular_attribute.html [moved from templates/admin/philo/edit_inline/tabular_attribute.html with 100% similarity]
philo/templates/admin/philo/edit_inline/tabular_container.html [moved from templates/admin/philo/edit_inline/tabular_container.html with 100% similarity]
philo/templates/admin/philo/page/add_form.html [new file with mode: 0644]
philo/templatetags/__init__.py [moved from loaders/__init__.py with 100% similarity]
philo/templatetags/collections.py [moved from templatetags/collections.py with 78% similarity]
philo/templatetags/containers.py [moved from templatetags/containers.py with 86% similarity]
philo/templatetags/embed.py [moved from templatetags/embed.py with 91% similarity]
philo/templatetags/include_string.py [moved from templatetags/include_string.py with 67% similarity]
philo/templatetags/nodes.py [moved from templatetags/nodes.py with 80% similarity]
philo/tests.py [moved from tests.py with 94% similarity]
philo/urls.py [moved from urls.py with 99% similarity]
philo/utils/__init__.py [moved from utils.py with 63% similarity]
philo/utils/entities.py [new file with mode: 0644]
philo/validators.py [moved from validators.py with 77% similarity]
philo/views.py [moved from views.py with 71% similarity]
setup.py [new file with mode: 0644]
signals.py [deleted file]
templates/admin/philo/page/add_form.html [deleted file]
templatetags/__init__.py [deleted file]

index 0d20b64..073067c 100644 (file)
@@ -1 +1,2 @@
 *.pyc
+docs/_build/
diff --git a/README b/README
index 4b1a6f7..6e47860 100644 (file)
--- a/README
+++ b/README
@@ -2,7 +2,7 @@ Philo is a foundation for developing web content management systems.
 
 Prerequisites:
        * Python 2.5.4+ <http://www.python.org/>
-       * Django 1.2+ <http://www.djangoproject.com/>
+       * 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/>
index 8060db8..349a727 100644 (file)
@@ -3,7 +3,7 @@ Philo is a foundation for developing web content management systems.
 Prerequisites:
 
  * [Python 2.5.4+ &lt;http://www.python.org&gt;](http://www.python.org/)
- * [Django 1.2+ &lt;http://www.djangoproject.com/&gt;](http://www.djangoproject.com/)
+ * [Django 1.3+ &lt;http://www.djangoproject.com/&gt;](http://www.djangoproject.com/)
  * [django-mptt e734079+ &lt;https://github.com/django-mptt/django-mptt/&gt;](https://github.com/django-mptt/django-mptt/)
  * (Optional) [django-grappelli 2.0+ &lt;http://code.google.com/p/django-grappelli/&gt;](http://code.google.com/p/django-grappelli/)
  * (Optional) [south 0.7.2+ &lt;http://south.aeracode.org/)](http://south.aeracode.org/)
diff --git a/__init__.py b/__init__.py
deleted file mode 100644 (file)
index ba78dda..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-from philo.loaders.database import Loader
-
-
-_loader = Loader()
-
-
-def load_template_source(template_name, template_dirs=None):
-    # For backwards compatibility
-    import warnings
-    warnings.warn(
-        "'philo.load_template_source' is deprecated; use 'philo.loaders.database.Loader' instead.",
-        PendingDeprecationWarning
-    )
-    return _loader.load_template_source(template_name, template_dirs)
-load_template_source.is_usable = True
diff --git a/contrib/penfield/templatetags/penfield.py b/contrib/penfield/templatetags/penfield.py
deleted file mode 100644 (file)
index 99e358c..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-from django import template
-from django.utils.dates import MONTHS, MONTHS_AP
-
-register = template.Library()
-
-def monthname(value):
-       monthnum = int(value)
-       if 1 <= monthnum <= 12:
-               return MONTHS[monthnum]
-       else:
-               return value
-
-register.filter('monthname', monthname)
-
-def apmonthname(value):
-       monthnum = int(value)
-       if 1 <= monthnum <= 12:
-               return MONTHS_AP[monthnum]
-       else:
-               return value
-
-register.filter('apmonthname', apmonthname)
diff --git a/contrib/penfield/validators.py b/contrib/penfield/validators.py
deleted file mode 100644 (file)
index 48eae06..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-from django.core.exceptions import ValidationError
-
-
-def validate_pagination_count(x):
-       if x not in range(1, 10000):
-               raise ValidationError('Please enter an integer between 1 and 9999.')
\ No newline at end of file
diff --git a/contrib/sobol/__init__.py b/contrib/sobol/__init__.py
deleted file mode 100644 (file)
index 90eaf18..0000000
+++ /dev/null
@@ -1 +0,0 @@
-from philo.contrib.sobol.search import *
\ No newline at end of file
diff --git a/contrib/sobol/models.py b/contrib/sobol/models.py
deleted file mode 100644 (file)
index ee8187d..0000000
+++ /dev/null
@@ -1,224 +0,0 @@
-from django.conf.urls.defaults import patterns, url
-from django.contrib import messages
-from django.core.exceptions import ValidationError
-from django.db import models
-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.forms import SearchForm
-from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash
-from philo.exceptions import ViewCanNotProvideSubpath
-from philo.models import MultiView, Page
-from philo.models.fields import SlugMultipleChoiceField
-from philo.validators import RedirectValidator
-import datetime
-try:
-       import eventlet
-except:
-       eventlet = False
-
-
-class Search(models.Model):
-       string = models.TextField()
-       
-       def __unicode__(self):
-               return self.string
-       
-       def get_weighted_results(self, threshhold=None):
-               "Returns this search's results ordered by decreasing weight."
-               if not hasattr(self, '_weighted_results'):
-                       result_qs = self.result_urls.all()
-                       
-                       if threshhold is not None:
-                               result_qs = result_qs.filter(counts__datetime__gte=threshhold)
-                       
-                       results = [result for result in result_qs]
-                       
-                       results.sort(cmp=lambda x,y: cmp(y.weight, x.weight))
-                       
-                       self._weighted_results = results
-               
-               return self._weighted_results
-       
-       def get_favored_results(self, error=5, threshhold=None):
-               """
-               Calculate the set of most-favored results. A higher error
-               will cause this method to be more reticent about adding new
-               items.
-               
-               The thought is to see whether there are any results which
-               vastly outstrip the other options. As such, evenly-weighted
-               results should be grouped together and either added or
-               excluded as a group.
-               """
-               if not hasattr(self, '_favored_results'):
-                       results = self.get_weighted_results(threshhold)
-                       
-                       grouped_results = SortedDict()
-                       
-                       for result in results:
-                               grouped_results.setdefault(result.weight, []).append(result)
-                       
-                       self._favored_results = []
-                       
-                       for value, subresults in grouped_results.items():
-                               cost = error * sum([(value - result.weight)**2 for result in self._favored_results])
-                               if value > cost:
-                                       self._favored_results += subresults
-                               else:
-                                       break
-               return self._favored_results
-       
-       class Meta:
-               ordering = ['string']
-               verbose_name_plural = 'searches'
-
-
-class ResultURL(models.Model):
-       search = models.ForeignKey(Search, related_name='result_urls')
-       url = models.TextField(validators=[RedirectValidator()])
-       
-       def __unicode__(self):
-               return self.url
-       
-       def get_weight(self, threshhold=None):
-               if not hasattr(self, '_weight'):
-                       clicks = self.clicks.all()
-                       
-                       if threshhold is not None:
-                               clicks = clicks.filter(datetime__gte=threshhold)
-                       
-                       self._weight = sum([click.weight for click in clicks])
-               
-               return self._weight
-       weight = property(get_weight)
-       
-       class Meta:
-               ordering = ['url']
-
-
-class Click(models.Model):
-       result = models.ForeignKey(ResultURL, related_name='clicks')
-       datetime = models.DateTimeField()
-       
-       def __unicode__(self):
-               return self.datetime.strftime('%B %d, %Y %H:%M:%S')
-       
-       def get_weight(self, default=1, weighted=lambda value, days: value/days**2):
-               if not hasattr(self, '_weight'):
-                       days = (datetime.datetime.now() - self.datetime).days
-                       if days < 0:
-                               raise ValueError("Click dates must be in the past.")
-                       default = float(default)
-                       if days == 0:
-                               self._weight = float(default)
-                       else:
-                               self._weight = weighted(default, days)
-               return self._weight
-       weight = property(get_weight)
-       
-       def clean(self):
-               if self.datetime > datetime.datetime.now():
-                       raise ValidationError("Click dates must be in the past.")
-       
-       class Meta:
-               ordering = ['datetime']
-               get_latest_by = 'datetime'
-
-
-class SearchView(MultiView):
-       results_page = models.ForeignKey(Page, related_name='search_results_related')
-       searches = SlugMultipleChoiceField(choices=registry.iterchoices())
-       enable_ajax_api = models.BooleanField("Enable AJAX API", default=True, help_text="Search results will be available <i>only</i> by AJAX, not as template variables.")
-       placeholder_text = models.CharField(max_length=75, default="Search")
-       
-       search_form = SearchForm
-       
-       def __unicode__(self):
-               return u"%s (%s)" % (self.placeholder_text, u", ".join([display for slug, display in registry.iterchoices() if slug in self.searches]))
-       
-       def get_reverse_params(self, obj):
-               raise ViewCanNotProvideSubpath
-       
-       @property
-       def urlpatterns(self):
-               urlpatterns = patterns('',
-                       url(r'^$', self.results_view, name='results'),
-               )
-               if self.enable_ajax_api:
-                       urlpatterns += patterns('',
-                               url(r'^(?P<slug>[\w-]+)$', self.ajax_api_view, name='ajax_api_view')
-                       )
-               return urlpatterns
-       
-       def get_search_instance(self, slug, search_string):
-               return registry[slug](search_string.lower())
-       
-       def results_view(self, request, extra_context=None):
-               results = None
-               
-               context = self.get_context()
-               context.update(extra_context or {})
-               
-               if SEARCH_ARG_GET_KEY in request.GET:
-                       form = self.search_form(request.GET)
-                       
-                       if form.is_valid():
-                               search_string = request.GET[SEARCH_ARG_GET_KEY].lower()
-                               url = request.GET.get(URL_REDIRECT_GET_KEY)
-                               hash = request.GET.get(HASH_REDIRECT_GET_KEY)
-                               
-                               if url and hash:
-                                       if check_redirect_hash(hash, search_string, url):
-                                               # Create the necessary models
-                                               search = Search.objects.get_or_create(string=search_string)[0]
-                                               result_url = search.result_urls.get_or_create(url=url)[0]
-                                               result_url.clicks.create(datetime=datetime.datetime.now())
-                                               return HttpResponseRedirect(url)
-                                       else:
-                                               messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
-                                               # TODO: Should search_string be escaped here?
-                                               return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
-                               if not self.enable_ajax_api:
-                                       search_instances = []
-                                       if eventlet:
-                                               pool = eventlet.GreenPool()
-                                       for slug in self.searches:
-                                               search_instance = self.get_search_instance(slug, search_string)
-                                               search_instances.append(search_instance)
-                                               if eventlet:
-                                                       pool.spawn_n(self.make_result_cache, search_instance)
-                                               else:
-                                                       self.make_result_cache(search_instance)
-                                       if eventlet:
-                                               pool.waitall()
-                                       context.update({
-                                               'searches': search_instances
-                                       })
-                               else:
-                                       context.update({
-                                               'searches': [{'verbose_name': verbose_name, 'slug': slug, 'url': self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node), 'result_template': registry[slug].result_template} for slug, verbose_name in registry.iterchoices() if slug in self.searches]
-                                       })
-               else:
-                       form = SearchForm()
-               
-               context.update({
-                       'form': form
-               })
-               return self.results_page.render_to_response(request, extra_context=context)
-       
-       def make_result_cache(self, search_instance):
-               search_instance.results
-       
-       def ajax_api_view(self, request, slug, extra_context=None):
-               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:
-                       raise Http404
-               
-               search_instance = self.get_search_instance(slug, search_string)
-               response = HttpResponse(json.dumps({
-                       'results': [result.get_context() for result in search_instance.results],
-               }))
-               return response
\ No newline at end of file
diff --git a/contrib/sobol/utils.py b/contrib/sobol/utils.py
deleted file mode 100644 (file)
index 3c5e537..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-from django.conf import settings
-from django.http import QueryDict
-from django.utils.encoding import smart_str
-from django.utils.http import urlquote_plus, urlquote
-from hashlib import sha1
-
-
-SEARCH_ARG_GET_KEY = 'q'
-URL_REDIRECT_GET_KEY = 'url'
-HASH_REDIRECT_GET_KEY = 's'
-
-
-def make_redirect_hash(search_arg, url):
-       return sha1(smart_str(search_arg + url + settings.SECRET_KEY)).hexdigest()[::2]
-
-
-def check_redirect_hash(hash, search_arg, url):
-       return hash == make_redirect_hash(search_arg, url)
-
-
-def make_tracking_querydict(search_arg, url):
-       """
-       Returns a QueryDict instance containing the information necessary
-       for tracking clicks of this url.
-       
-       NOTE: will this kind of initialization handle quoting correctly?
-       """
-       return QueryDict("%s=%s&%s=%s&%s=%s" % (
-               SEARCH_ARG_GET_KEY, urlquote_plus(search_arg),
-               URL_REDIRECT_GET_KEY, urlquote(url),
-               HASH_REDIRECT_GET_KEY, make_redirect_hash(search_arg, url))
-       )
\ No newline at end of file
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644 (file)
index 0000000..16c56a5
--- /dev/null
@@ -0,0 +1,130 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+PAPER         =
+BUILDDIR      = _build
+
+# Internal variables.
+PAPEROPT_a4     = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest
+
+help:
+       @echo "Please use \`make <target>' where <target> is one of"
+       @echo "  html       to make standalone HTML files"
+       @echo "  dirhtml    to make HTML files named index.html in directories"
+       @echo "  singlehtml to make a single large HTML file"
+       @echo "  pickle     to make pickle files"
+       @echo "  json       to make JSON files"
+       @echo "  htmlhelp   to make HTML files and a HTML help project"
+       @echo "  qthelp     to make HTML files and a qthelp project"
+       @echo "  devhelp    to make HTML files and a Devhelp project"
+       @echo "  epub       to make an epub"
+       @echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+       @echo "  latexpdf   to make LaTeX files and run them through pdflatex"
+       @echo "  text       to make text files"
+       @echo "  man        to make manual pages"
+       @echo "  changes    to make an overview of all changed/added/deprecated items"
+       @echo "  linkcheck  to check all external links for integrity"
+       @echo "  doctest    to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+       -rm -rf $(BUILDDIR)/*
+
+html:
+       $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+       @echo
+       @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+       $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+       @echo
+       @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+       $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+       @echo
+       @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+       $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+       @echo
+       @echo "Build finished; now you can process the pickle files."
+
+json:
+       $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+       @echo
+       @echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+       $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+       @echo
+       @echo "Build finished; now you can run HTML Help Workshop with the" \
+             ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+       $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+       @echo
+       @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+             ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+       @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Philo.qhcp"
+       @echo "To view the help file:"
+       @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Philo.qhc"
+
+devhelp:
+       $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+       @echo
+       @echo "Build finished."
+       @echo "To view the help file:"
+       @echo "# mkdir -p $$HOME/.local/share/devhelp/Philo"
+       @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Philo"
+       @echo "# devhelp"
+
+epub:
+       $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+       @echo
+       @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+       $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+       @echo
+       @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+       @echo "Run \`make' in that directory to run these through (pdf)latex" \
+             "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+       $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+       @echo "Running LaTeX files through pdflatex..."
+       make -C $(BUILDDIR)/latex all-pdf
+       @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+       $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+       @echo
+       @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+       $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+       @echo
+       @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+changes:
+       $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+       @echo
+       @echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+       $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+       @echo
+       @echo "Link check complete; look for any errors in the above output " \
+             "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+       $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+       @echo "Testing of doctests in the sources finished, look at the " \
+             "results in $(BUILDDIR)/doctest/output.txt."
diff --git a/docs/_ext/djangodocs.py b/docs/_ext/djangodocs.py
new file mode 100644 (file)
index 0000000..0d433de
--- /dev/null
@@ -0,0 +1,248 @@
+"""
+Sphinx plugins for Django documentation.
+"""
+import os
+import re
+
+from docutils import nodes, transforms
+try:
+    import json
+except ImportError:
+    try:
+        import simplejson as json
+    except ImportError:
+        try:
+            from django.utils import simplejson as json
+        except ImportError:
+            json = None
+
+from sphinx import addnodes, roles
+from sphinx.builders.html import StandaloneHTMLBuilder
+from sphinx.writers.html import SmartyPantsHTMLTranslator
+from sphinx.util.console import bold
+from sphinx.util.compat import Directive
+
+# RE for option descriptions without a '--' prefix
+simple_option_desc_re = re.compile(
+    r'([-_a-zA-Z0-9]+)(\s*.*?)(?=,\s+(?:/|-|--)|$)')
+
+def setup(app):
+    app.add_crossref_type(
+        directivename = "setting",
+        rolename      = "setting",
+        indextemplate = "pair: %s; setting",
+    )
+    #app.add_crossref_type(
+    #    directivename = "templatetag",
+    #    rolename      = "ttag",
+    #    indextemplate = "pair: %s; template tag"
+    #)
+    #app.add_crossref_type(
+    #    directivename = "templatefilter",
+    #    rolename      = "tfilter",
+    #    indextemplate = "pair: %s; template filter"
+    #)
+    app.add_crossref_type(
+        directivename = "fieldlookup",
+        rolename      = "lookup",
+        indextemplate = "pair: %s; field lookup type",
+    )
+    app.add_description_unit(
+        directivename = "django-admin",
+        rolename      = "djadmin",
+        indextemplate = "pair: %s; django-admin command",
+        parse_node    = parse_django_admin_node,
+    )
+    app.add_description_unit(
+        directivename = "django-admin-option",
+        rolename      = "djadminopt",
+        indextemplate = "pair: %s; django-admin command-line option",
+        parse_node    = parse_django_adminopt_node,
+    )
+    app.add_config_value('django_next_version', '0.0', True)
+    app.add_directive('versionadded', VersionDirective)
+    app.add_directive('versionchanged', VersionDirective)
+    app.add_transform(SuppressBlockquotes)
+    app.add_builder(DjangoStandaloneHTMLBuilder)
+
+
+class VersionDirective(Directive):
+    has_content = True
+    required_arguments = 1
+    optional_arguments = 1
+    final_argument_whitespace = True
+    option_spec = {}
+
+    def run(self):
+        env = self.state.document.settings.env
+        arg0 = self.arguments[0]
+        is_nextversion = env.config.django_next_version == arg0
+        ret = []
+        node = addnodes.versionmodified()
+        ret.append(node)
+        if not is_nextversion:
+            if len(self.arguments) == 1:
+                linktext = 'Please, see the release notes </releases/%s>' % (arg0)
+                xrefs = roles.XRefRole()('doc', linktext, linktext, self.lineno, self.state)
+                node.extend(xrefs[0])
+            node['version'] = arg0
+        else:
+            node['version'] = "Development version"
+        node['type'] = self.name
+        if len(self.arguments) == 2:
+            inodes, messages = self.state.inline_text(self.arguments[1], self.lineno+1)
+            node.extend(inodes)
+            if self.content:
+                self.state.nested_parse(self.content, self.content_offset, node)
+            ret = ret + messages
+        env.note_versionchange(node['type'], node['version'], node, self.lineno)
+        return ret
+
+
+class SuppressBlockquotes(transforms.Transform):
+    """
+    Remove the default blockquotes that encase indented list, tables, etc.
+    """
+    default_priority = 300
+
+    suppress_blockquote_child_nodes = (
+        nodes.bullet_list,
+        nodes.enumerated_list,
+        nodes.definition_list,
+        nodes.literal_block,
+        nodes.doctest_block,
+        nodes.line_block,
+        nodes.table
+    )
+
+    def apply(self):
+        for node in self.document.traverse(nodes.block_quote):
+            if len(node.children) == 1 and isinstance(node.children[0], self.suppress_blockquote_child_nodes):
+                node.replace_self(node.children[0])
+
+class DjangoHTMLTranslator(SmartyPantsHTMLTranslator):
+    """
+    Django-specific reST to HTML tweaks.
+    """
+
+    # Don't use border=1, which docutils does by default.
+    def visit_table(self, node):
+        self.body.append(self.starttag(node, 'table', CLASS='docutils'))
+
+    # <big>? Really?
+    def visit_desc_parameterlist(self, node):
+        self.body.append('(')
+        self.first_param = 1
+
+    def depart_desc_parameterlist(self, node):
+        self.body.append(')')
+
+    #
+    # Don't apply smartypants to literal blocks
+    #
+    def visit_literal_block(self, node):
+        self.no_smarty += 1
+        SmartyPantsHTMLTranslator.visit_literal_block(self, node)
+
+    def depart_literal_block(self, node):
+        SmartyPantsHTMLTranslator.depart_literal_block(self, node)
+        self.no_smarty -= 1
+
+    #
+    # Turn the "new in version" stuff (versionadded/versionchanged) into a
+    # better callout -- the Sphinx default is just a little span,
+    # which is a bit less obvious that I'd like.
+    #
+    # FIXME: these messages are all hardcoded in English. We need to change
+    # that to accomodate other language docs, but I can't work out how to make
+    # that work.
+    #
+    version_text = {
+        'deprecated':       'Deprecated in Django %s',
+        'versionchanged':   'Changed in Django %s',
+        'versionadded':     'New in Django %s',
+    }
+
+    def visit_versionmodified(self, node):
+        self.body.append(
+            self.starttag(node, 'div', CLASS=node['type'])
+        )
+        title = "%s%s" % (
+            self.version_text[node['type']] % node['version'],
+            len(node) and ":" or "."
+        )
+        self.body.append('<span class="title">%s</span> ' % title)
+
+    def depart_versionmodified(self, node):
+        self.body.append("</div>\n")
+
+    # Give each section a unique ID -- nice for custom CSS hooks
+    def visit_section(self, node):
+        old_ids = node.get('ids', [])
+        node['ids'] = ['s-' + i for i in old_ids]
+        node['ids'].extend(old_ids)
+        SmartyPantsHTMLTranslator.visit_section(self, node)
+        node['ids'] = old_ids
+
+def parse_django_admin_node(env, sig, signode):
+    command = sig.split(' ')[0]
+    env._django_curr_admin_command = command
+    title = "django-admin.py %s" % sig
+    signode += addnodes.desc_name(title, title)
+    return sig
+
+def parse_django_adminopt_node(env, sig, signode):
+    """A copy of sphinx.directives.CmdoptionDesc.parse_signature()"""
+    from sphinx.domains.std import option_desc_re
+    count = 0
+    firstname = ''
+    for m in option_desc_re.finditer(sig):
+        optname, args = m.groups()
+        if count:
+            signode += addnodes.desc_addname(', ', ', ')
+        signode += addnodes.desc_name(optname, optname)
+        signode += addnodes.desc_addname(args, args)
+        if not count:
+            firstname = optname
+        count += 1
+    if not count:
+        for m in simple_option_desc_re.finditer(sig):
+            optname, args = m.groups()
+            if count:
+                signode += addnodes.desc_addname(', ', ', ')
+            signode += addnodes.desc_name(optname, optname)
+            signode += addnodes.desc_addname(args, args)
+            if not count:
+                firstname = optname
+            count += 1
+    if not firstname:
+        raise ValueError
+    return firstname
+
+
+class DjangoStandaloneHTMLBuilder(StandaloneHTMLBuilder):
+    """
+    Subclass to add some extra things we need.
+    """
+
+    name = 'djangohtml'
+
+    def finish(self):
+        super(DjangoStandaloneHTMLBuilder, self).finish()
+        if json is None:
+            self.warn("cannot create templatebuiltins.js due to missing simplejson dependency")
+            return
+        self.info(bold("writing templatebuiltins.js..."))
+        xrefs = self.env.domaindata["std"]["objects"]
+        templatebuiltins = {
+            "ttags": [n for ((t, n), (l, a)) in xrefs.items()
+                        if t == "templatetag" and l == "ref/templates/builtins"],
+            "tfilters": [n for ((t, n), (l, a)) in xrefs.items()
+                        if t == "templatefilter" and l == "ref/templates/builtins"],
+        }
+        outfilename = os.path.join(self.outdir, "templatebuiltins.js")
+        f = open(outfilename, 'wb')
+        f.write('var django_template_builtins = ')
+        json.dump(templatebuiltins, f)
+        f.write(';\n')
+        f.close();
diff --git a/docs/_ext/philodocs.py b/docs/_ext/philodocs.py
new file mode 100644 (file)
index 0000000..6c1ecf7
--- /dev/null
@@ -0,0 +1,56 @@
+import inspect
+
+from sphinx.addnodes import desc_addname
+from sphinx.domains.python import PyModulelevel, PyXRefRole
+from sphinx.ext import autodoc
+
+
+DOMAIN = 'py'
+
+
+class TemplateTag(PyModulelevel):
+       indextemplate = "pair: %s; template tag"
+       
+       def get_signature_prefix(self, sig):
+               return self.objtype + ' '
+       
+       def handle_signature(self, sig, signode):
+               fullname, name_prefix = PyModulelevel.handle_signature(self, sig, signode)
+               
+               for i, node in enumerate(signode):
+                       if isinstance(node, desc_addname):
+                               lib = '.'.join(node[0].split('.')[-2:])
+                               new_node = desc_addname(lib, lib)
+                               signode[i] = new_node
+               
+               return fullname, name_prefix
+
+
+class TemplateTagDocumenter(autodoc.FunctionDocumenter):
+       objtype = 'templatetag'
+       domain = DOMAIN
+       
+       @classmethod
+       def can_document_member(cls, member, membername, isattr, parent):
+               # Only document explicitly.
+               return False
+       
+       def format_args(self):
+               return None
+
+class TemplateFilterDocumenter(autodoc.FunctionDocumenter):
+       objtype = 'templatefilter'
+       domain = DOMAIN
+       
+       @classmethod
+       def can_document_member(cls, member, membername, isattr, parent):
+               # Only document explicitly.
+               return False
+
+def setup(app):
+       app.add_directive_to_domain(DOMAIN, 'templatetag', TemplateTag)
+       app.add_role_to_domain(DOMAIN, 'ttag', PyXRefRole())
+       app.add_directive_to_domain(DOMAIN, 'templatefilter', TemplateTag)
+       app.add_role_to_domain(DOMAIN, 'tfilter', PyXRefRole())
+       app.add_autodocumenter(TemplateTagDocumenter)
+       app.add_autodocumenter(TemplateFilterDocumenter)
\ No newline at end of file
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644 (file)
index 0000000..c888d0e
--- /dev/null
@@ -0,0 +1,243 @@
+# -*- coding: utf-8 -*-
+#
+# Philo documentation build configuration file, created by
+# sphinx-quickstart on Fri Jan 28 14:04:16 2011.
+#
+# This file is execfile()d with the current directory set to its containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import sys, os
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "_ext")))
+sys.path.append(os.path.abspath(os.path.dirname(os.path.dirname(__file__))))
+
+os.environ['DJANGO_SETTINGS_MODULE'] = 'dummy-settings'
+
+# Import loader so that loader_tags will be correctly added to builtins. Weird import situations... this is necessary for doc build to work.
+from django.template import loader
+
+# HACK to override descriptors that would cause AttributeErrors to be raised otherwise (which would keep them from being documented.)
+from philo.contrib.sobol.models import SearchView
+SearchView.searches = 5
+from philo.models.nodes import TargetURLModel, File
+TargetURLModel.reversing_parameters = 5
+File.file = 5
+
+# -- General configuration -----------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be extensions
+# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+extensions = ['djangodocs', 'sphinx.ext.autodoc', 'philodocs']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+#source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'Philo'
+copyright = u'2011, Joseph Spiros'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+from philo import VERSION
+version = '%s.%s' % (VERSION[0], VERSION[1])
+# The full version, including alpha/beta/rc tags.
+release = version
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+#today = ''
+# Else, today_fmt is used as the format for a strftime call.
+#today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ['_build']
+
+# The reST default role (used for this markup: `text`) to use for all documents.
+#default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+#add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+#add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+#show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+#modindex_common_prefix = []
+
+
+# Autodoc config
+autodoc_member_order = "bysource"
+
+
+# -- Options for HTML output ---------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+html_theme = 'default'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+#html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+#html_theme_path = []
+
+# The name for this set of Sphinx documents.  If None, it defaults to
+# "<project> v<release> documentation".
+#html_title = None
+
+# A shorter title for the navigation bar.  Default is the same as html_title.
+#html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+#html_logo = None
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+#html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
+# using the given strftime format.
+#html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+#html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+#html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+#html_additional_pages = {}
+
+# If false, no module index is generated.
+#html_domain_indices = True
+
+# If false, no index is generated.
+#html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+#html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+#html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+#html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+#html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it.  The value of this option must be the
+# base URL from which the finished HTML is served.
+#html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+#html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'Philodoc'
+
+
+# -- Options for LaTeX output --------------------------------------------------
+
+# The paper size ('letter' or 'a4').
+#latex_paper_size = 'letter'
+
+# The font size ('10pt', '11pt' or '12pt').
+#latex_font_size = '10pt'
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass [howto/manual]).
+latex_documents = [
+  ('index', 'Philo.tex', u'Philo Documentation',
+   u'Stephen Burrows', 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+#latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+#latex_use_parts = False
+
+# If true, show page references after internal links.
+#latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+#latex_show_urls = False
+
+# Additional stuff for the LaTeX preamble.
+#latex_preamble = ''
+
+# Documents to append as an appendix to all manuals.
+#latex_appendices = []
+
+# If false, no module index is generated.
+#latex_domain_indices = True
+
+
+# -- Options for manual page output --------------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    ('index', 'philo', u'Philo Documentation',
+     [u'Stephen Burrows'], 1)
+]
+
+def skip_attribute_attrs(app, what, name, obj, skip, options):
+       if name in ("attribute_set", "get_attribute_mapper", "nodes"):
+               return True
+       return skip
+
+def setup(app):
+       app.connect('autodoc-skip-member', skip_attribute_attrs)
+       #app.connect('autodoc-process-signature', )
diff --git a/docs/contrib/intro.rst b/docs/contrib/intro.rst
new file mode 100644 (file)
index 0000000..3b97ecd
--- /dev/null
@@ -0,0 +1,13 @@
+Contrib apps
+============
+
+.. toctree::
+       :maxdepth: 2
+       :hidden:
+       
+       penfield
+       shipherd
+       sobol
+       waldo
+
+.. automodule:: philo.contrib
diff --git a/docs/contrib/penfield.rst b/docs/contrib/penfield.rst
new file mode 100644 (file)
index 0000000..d774dcb
--- /dev/null
@@ -0,0 +1,49 @@
+Penfield
+========
+
+.. automodule:: philo.contrib.penfield
+
+.. automodule:: philo.contrib.penfield.models
+
+Blogs
++++++
+.. autoclass:: philo.contrib.penfield.models.Blog
+       :members:
+
+.. autoclass:: philo.contrib.penfield.models.BlogEntry
+       :members:
+
+.. autoclass:: philo.contrib.penfield.models.BlogView
+       :members:
+
+Newsletters
++++++++++++
+.. autoclass:: philo.contrib.penfield.models.Newsletter
+       :members:
+
+.. autoclass:: philo.contrib.penfield.models.NewsletterArticle
+       :members:
+
+.. autoclass:: philo.contrib.penfield.models.NewsletterView
+       :members:
+
+Abstract Syndication
+++++++++++++++++++++
+
+.. autoclass:: philo.contrib.penfield.models.FeedView
+       :members:
+
+.. automodule:: philo.contrib.penfield.exceptions
+       :members:
+
+.. automodule:: philo.contrib.penfield.middleware
+       :members:
+
+Template filters
+++++++++++++++++
+
+.. automodule:: philo.contrib.penfield.templatetags.penfield
+
+.. autotemplatefilter:: monthname
+
+.. autotemplatefilter:: apmonthname
diff --git a/docs/contrib/shipherd.rst b/docs/contrib/shipherd.rst
new file mode 100644 (file)
index 0000000..0f3b59d
--- /dev/null
@@ -0,0 +1,30 @@
+Shipherd
+========
+
+.. automodule:: philo.contrib.shipherd
+       :members:
+
+Models
+++++++
+
+.. automodule:: philo.contrib.shipherd.models
+       :members: Navigation, NavigationItem, NavigationMapper
+
+Navigation caching
+------------------
+
+.. autoclass:: NavigationManager
+       :members:
+
+.. autoclass:: NavigationItemManager
+       :members:
+
+.. autoclass:: NavigationCacheQuerySet
+       :members:
+
+Template tags
++++++++++++++
+
+.. automodule:: philo.contrib.shipherd.templatetags.shipherd
+
+.. autotemplatetag:: recursenavigation
diff --git a/docs/contrib/sobol.rst b/docs/contrib/sobol.rst
new file mode 100644 (file)
index 0000000..353b547
--- /dev/null
@@ -0,0 +1,17 @@
+Sobol
+=====
+
+.. automodule:: philo.contrib.sobol
+       :members:
+
+Models
+++++++
+
+.. automodule:: philo.contrib.sobol.models
+       :members:
+
+Search API
+++++++++++
+
+.. automodule:: philo.contrib.sobol.search
+       :members:
diff --git a/docs/contrib/waldo.rst b/docs/contrib/waldo.rst
new file mode 100644 (file)
index 0000000..89045d0
--- /dev/null
@@ -0,0 +1,27 @@
+Waldo
+=====
+
+.. automodule:: philo.contrib.waldo
+       :members:
+
+Models
+++++++
+
+.. automodule:: philo.contrib.waldo.models
+       :members:
+
+Forms
++++++
+
+.. automodule:: philo.contrib.waldo.forms
+       :members:
+
+Token generators
+++++++++++++++++
+
+.. automodule:: philo.contrib.waldo.tokens
+
+
+.. autodata:: registration_token_generator
+
+.. autodata:: email_token_generator
diff --git a/docs/dummy-settings.py b/docs/dummy-settings.py
new file mode 100644 (file)
index 0000000..7e424ab
--- /dev/null
@@ -0,0 +1,6 @@
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': 'db.sl3'
+    }
+}
\ No newline at end of file
diff --git a/docs/exceptions.rst b/docs/exceptions.rst
new file mode 100644 (file)
index 0000000..679ac77
--- /dev/null
@@ -0,0 +1,5 @@
+Exceptions
+==========
+
+.. automodule:: philo.exceptions
+       :members: MIDDLEWARE_NOT_CONFIGURED, AncestorDoesNotExist, ViewCanNotProvideSubpath, ViewDoesNotProvideSubpaths
\ No newline at end of file
diff --git a/docs/forms.rst b/docs/forms.rst
new file mode 100644 (file)
index 0000000..b2dfbb4
--- /dev/null
@@ -0,0 +1,12 @@
+Forms
+=====
+
+.. automodule:: philo.forms.entities
+       :members:
+
+
+Fields
+++++++
+
+.. automodule:: philo.forms.fields
+       :members:
diff --git a/docs/handling_requests.rst b/docs/handling_requests.rst
new file mode 100644 (file)
index 0000000..940d541
--- /dev/null
@@ -0,0 +1,10 @@
+Handling Requests
+=================
+
+.. automodule:: philo.middleware
+       :members:
+
+.. automodule:: philo.views
+
+
+.. autofunction:: node_view(request[, path=None, **kwargs])
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644 (file)
index 0000000..d387fa8
--- /dev/null
@@ -0,0 +1,49 @@
+.. Philo documentation master file, created by
+   sphinx-quickstart on Fri Jan 28 14:04:16 2011.
+   You can adapt this file completely to your liking, but it should at least
+   contain the root `toctree` directive.
+
+.. module:: philo
+
+Welcome to Philo's documentation!
+=================================
+
+Contents:
+
+.. toctree::
+       :maxdepth: 2
+       
+       intro
+       models/intro
+       exceptions
+       handling_requests
+       signals
+       validators
+       utilities
+       templatetags
+       forms
+       loaders
+       contrib/intro
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
+What is Philo?
+==============
+
+Philo is a foundation for developing web content management systems.
+
+Prerequisites:
+
+* `Python 2.5.4+ <http://www.python.org>`_
+* `Django 1.2+ <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) `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://philo.ithinksw.org/>`_ or make a fork of the `git repository <http://github.com/ithinksw/philo/>`_. Feel free to join us on IRC at `irc://irc.oftc.net/#philo <irc://irc.oftc.net/#philo>`_.
diff --git a/docs/intro.rst b/docs/intro.rst
new file mode 100644 (file)
index 0000000..33d1a98
--- /dev/null
@@ -0,0 +1,35 @@
+How to get started with philo
+=============================
+
+After installing `philo`_ and `mptt`_ on your python path, make sure to complete the following steps:
+
+1. add :mod:`philo` and :mod:`mptt` to :setting:`settings.INSTALLED_APPS`::
+       
+       INSTALLED_APPS = (
+               ...
+               'philo',
+               'mptt',
+               ...
+       )
+       
+2. add :class:`philo.middleware.RequestNodeMiddleware` to :setting:`settings.MIDDLEWARE_CLASSES`::
+       
+       MIDDLEWARE_CLASSES = (
+               ...
+               'philo.middleware.RequestNodeMiddleware',
+               ...
+       )
+       
+3. include :mod:`philo.urls` somewhere in your urls.py file. For example::
+       
+       from django.conf.urls.defaults import patterns, include, url
+       urlpatterns = patterns('',
+               url(r'^', include('philo.urls')),
+       )
+       
+4. Optionally add a root :class:`node <philo.models.Node>` to your current :class:`Site` in the admin interface.
+
+Philo should be ready to go!
+
+.. _philo: http://github.com/ithinksw/philo
+.. _mptt: http://github.com/django-mptt/django-mptt
\ No newline at end of file
diff --git a/docs/loaders.rst b/docs/loaders.rst
new file mode 100644 (file)
index 0000000..41c4cd9
--- /dev/null
@@ -0,0 +1,5 @@
+Database Template Loader
+========================
+
+.. automodule:: philo.loaders.database
+       :members:
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644 (file)
index 0000000..25f0d2a
--- /dev/null
@@ -0,0 +1,170 @@
+@ECHO OFF
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+       set SPHINXBUILD=sphinx-build
+)
+set BUILDDIR=_build
+set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
+if NOT "%PAPER%" == "" (
+       set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
+)
+
+if "%1" == "" goto help
+
+if "%1" == "help" (
+       :help
+       echo.Please use `make ^<target^>` where ^<target^> is one of
+       echo.  html       to make standalone HTML files
+       echo.  dirhtml    to make HTML files named index.html in directories
+       echo.  singlehtml to make a single large HTML file
+       echo.  pickle     to make pickle files
+       echo.  json       to make JSON files
+       echo.  htmlhelp   to make HTML files and a HTML help project
+       echo.  qthelp     to make HTML files and a qthelp project
+       echo.  devhelp    to make HTML files and a Devhelp project
+       echo.  epub       to make an epub
+       echo.  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter
+       echo.  text       to make text files
+       echo.  man        to make manual pages
+       echo.  changes    to make an overview over all changed/added/deprecated items
+       echo.  linkcheck  to check all external links for integrity
+       echo.  doctest    to run all doctests embedded in the documentation if enabled
+       goto end
+)
+
+if "%1" == "clean" (
+       for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
+       del /q /s %BUILDDIR%\*
+       goto end
+)
+
+if "%1" == "html" (
+       %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The HTML pages are in %BUILDDIR%/html.
+       goto end
+)
+
+if "%1" == "dirhtml" (
+       %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
+       goto end
+)
+
+if "%1" == "singlehtml" (
+       %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
+       goto end
+)
+
+if "%1" == "pickle" (
+       %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished; now you can process the pickle files.
+       goto end
+)
+
+if "%1" == "json" (
+       %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished; now you can process the JSON files.
+       goto end
+)
+
+if "%1" == "htmlhelp" (
+       %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished; now you can run HTML Help Workshop with the ^
+.hhp project file in %BUILDDIR%/htmlhelp.
+       goto end
+)
+
+if "%1" == "qthelp" (
+       %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished; now you can run "qcollectiongenerator" with the ^
+.qhcp project file in %BUILDDIR%/qthelp, like this:
+       echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Philo.qhcp
+       echo.To view the help file:
+       echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Philo.ghc
+       goto end
+)
+
+if "%1" == "devhelp" (
+       %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished.
+       goto end
+)
+
+if "%1" == "epub" (
+       %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The epub file is in %BUILDDIR%/epub.
+       goto end
+)
+
+if "%1" == "latex" (
+       %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
+       goto end
+)
+
+if "%1" == "text" (
+       %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The text files are in %BUILDDIR%/text.
+       goto end
+)
+
+if "%1" == "man" (
+       %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The manual pages are in %BUILDDIR%/man.
+       goto end
+)
+
+if "%1" == "changes" (
+       %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.The overview file is in %BUILDDIR%/changes.
+       goto end
+)
+
+if "%1" == "linkcheck" (
+       %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Link check complete; look for any errors in the above output ^
+or in %BUILDDIR%/linkcheck/output.txt.
+       goto end
+)
+
+if "%1" == "doctest" (
+       %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Testing of doctests in the sources finished, look at the ^
+results in %BUILDDIR%/doctest/output.txt.
+       goto end
+)
+
+:end
diff --git a/docs/models/collections.rst b/docs/models/collections.rst
new file mode 100644 (file)
index 0000000..0519494
--- /dev/null
@@ -0,0 +1,8 @@
+Collections
+===========
+
+.. automodule:: philo.models.collections
+       :members: Collection, CollectionMember, CollectionMemberManager
+
+.. autoclass:: CollectionMemberManager
+       :members:
\ No newline at end of file
diff --git a/docs/models/entities.rst b/docs/models/entities.rst
new file mode 100644 (file)
index 0000000..a394c6f
--- /dev/null
@@ -0,0 +1,55 @@
+Entities and Attributes
+=======================
+
+.. module:: philo.models.base
+
+One of the core concepts in Philo is the relationship between the :class:`Entity` and :class:`Attribute` classes. :class:`Attribute`\ s represent an arbitrary key/value pair by having one :class:`GenericForeignKey` to an :class:`Entity` and another to an :class:`AttributeValue`.
+
+
+Attributes
+----------
+
+.. autoclass:: Attribute
+       :members:
+
+.. autoclass:: AttributeValue
+       :members:
+
+.. automodule:: philo.models.base
+       :noindex:
+       :members: attribute_value_limiter
+
+.. autoclass:: JSONValue
+       :show-inheritance:
+
+.. autoclass:: ForeignKeyValue
+       :show-inheritance:
+
+.. autoclass:: ManyToManyValue
+       :show-inheritance:
+
+.. automodule:: philo.models.base
+       :noindex:
+       :members: value_content_type_limiter
+
+.. autofunction:: register_value_model(model)
+.. autofunction:: unregister_value_model(model)
+
+Entities
+--------
+
+.. autoclass:: Entity
+       :members:
+
+.. autoclass:: TreeManager
+       :members:
+
+.. autoclass:: TreeEntity
+       :show-inheritance:
+       :members:
+
+       .. attribute:: objects
+
+               An instance of :class:`TreeManager`.
+       
+       .. automethod:: get_path
\ No newline at end of file
diff --git a/docs/models/fields.rst b/docs/models/fields.rst
new file mode 100644 (file)
index 0000000..3092fa4
--- /dev/null
@@ -0,0 +1,21 @@
+Custom Fields
+=============
+
+.. automodule:: philo.models.fields
+       :members:
+       :exclude-members: JSONField, SlugMultipleChoiceField
+       
+       .. autoclass:: JSONField()
+               :members:
+       
+       .. autoclass:: SlugMultipleChoiceField()
+               :members:
+
+AttributeProxyFields
+--------------------
+
+.. automodule:: philo.models.fields.entities
+       :members:
+       
+       .. autoclass:: AttributeProxyField(attribute_key=None, verbose_name=None, help_text=None, default=NOT_PROVIDED, editable=True, choices=None, *args, **kwargs)
+               :members:
\ No newline at end of file
diff --git a/docs/models/intro.rst b/docs/models/intro.rst
new file mode 100644 (file)
index 0000000..4f65585
--- /dev/null
@@ -0,0 +1,16 @@
+Philo's models
+==============
+
+Contents:
+
+.. toctree::
+   :maxdepth: 2
+   
+   entities
+   nodes-and-views
+   collections
+   miscellaneous
+   fields
+
+
+.. automodule:: philo.models
diff --git a/docs/models/miscellaneous.rst b/docs/models/miscellaneous.rst
new file mode 100644 (file)
index 0000000..80b654b
--- /dev/null
@@ -0,0 +1,8 @@
+Miscellaneous Models
+=============================
+.. autoclass:: philo.models.nodes.TargetURLModel
+       :members:
+       :exclude-members: get_target_url
+
+.. autoclass:: philo.models.base.Tag
+       :members:
\ No newline at end of file
diff --git a/docs/models/nodes-and-views.rst b/docs/models/nodes-and-views.rst
new file mode 100644 (file)
index 0000000..442509d
--- /dev/null
@@ -0,0 +1,56 @@
+Nodes and Views: Building Website structure
+===========================================
+.. automodule:: philo.models.nodes
+
+Nodes
+-----
+
+.. autoclass:: Node
+       :show-inheritance:
+       :members:
+
+Views
+-----
+
+Abstract View Models
+++++++++++++++++++++
+
+.. autoclass:: View
+       :show-inheritance:
+       :members:
+
+.. autoclass:: MultiView
+       :show-inheritance:
+       :members:
+
+Concrete View Subclasses
+++++++++++++++++++++++++
+
+.. autoclass:: Redirect
+       :show-inheritance:
+       :members:
+
+.. autoclass:: File
+       :show-inheritance:
+       :members:
+
+Pages
+*****
+
+.. automodule:: philo.models.pages
+
+.. autoclass:: Page
+       :members:
+       :show-inheritance:
+
+.. autoclass:: Template
+       :members:
+       :show-inheritance:
+       
+       .. seealso:: :mod:`philo.loaders.database`
+
+.. autoclass:: Contentlet
+       :members:
+
+.. autoclass:: ContentReference
+       :members:
\ No newline at end of file
diff --git a/docs/signals.rst b/docs/signals.rst
new file mode 100644 (file)
index 0000000..8b3da3c
--- /dev/null
@@ -0,0 +1,5 @@
+Signals
+=======
+
+.. automodule:: philo.signals
+       :members:
diff --git a/docs/templatetags.rst b/docs/templatetags.rst
new file mode 100644 (file)
index 0000000..41d30d5
--- /dev/null
@@ -0,0 +1,42 @@
+Template Tags
+=============
+
+.. automodule:: philo.templatetags
+
+Collections
++++++++++++
+
+.. automodule:: philo.templatetags.collections
+       
+.. autotemplatetag:: membersof
+
+Containers
+++++++++++
+
+.. automodule:: philo.templatetags.containers
+
+
+.. autotemplatetag:: container
+
+
+Embedding
++++++++++
+
+.. automodule:: philo.templatetags.embed
+
+.. autotemplatetag:: embed
+
+
+Nodes
++++++
+
+.. automodule:: philo.templatetags.nodes
+
+.. autotemplatetag:: node_url
+
+String inclusion
+++++++++++++++++
+
+.. automodule:: philo.templatetags.include_string
+
+.. autotemplatetag:: include_string
diff --git a/docs/utilities.rst b/docs/utilities.rst
new file mode 100644 (file)
index 0000000..d1386b1
--- /dev/null
@@ -0,0 +1,39 @@
+Utilities
+=========
+
+.. automodule:: philo.utils
+       :members:
+
+AttributeMappers
+++++++++++++++++
+
+.. module:: philo.utils.entities
+
+.. autoclass:: AttributeMapper
+       :members:
+
+.. autoclass:: TreeAttributeMapper
+       :members:
+       :show-inheritance:
+
+.. autoclass:: PassthroughAttributeMapper
+       :members:
+       :show-inheritance:
+
+LazyAttributeMappers
+--------------------
+
+.. autoclass:: LazyAttributeMapperMixin
+       :members:
+
+.. autoclass:: LazyAttributeMapper
+       :members:
+       :show-inheritance:
+
+.. autoclass:: LazyTreeAttributeMapper
+       :members:
+       :show-inheritance:
+
+.. autoclass:: LazyPassthroughAttributeMapper
+       :members:
+       :show-inheritance:
diff --git a/docs/validators.rst b/docs/validators.rst
new file mode 100644 (file)
index 0000000..f91818b
--- /dev/null
@@ -0,0 +1,5 @@
+Validators
+==========
+
+.. automodule:: philo.validators
+       :members:
diff --git a/exceptions.py b/exceptions.py
deleted file mode 100644 (file)
index f53083d..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-from django.core.exceptions import ImproperlyConfigured
-
-
-MIDDLEWARE_NOT_CONFIGURED = ImproperlyConfigured("""Philo requires the RequestNode middleware to be installed. Edit your MIDDLEWARE_CLASSES setting to insert 'philo.middleware.RequestNodeMiddleware'.""")
-
-
-class ViewDoesNotProvideSubpaths(Exception):
-       """ Raised by View.reverse when the View does not provide subpaths (the default). """
-       silent_variable_failure = True
-
-
-class ViewCanNotProvideSubpath(Exception):
-       """ Raised by View.reverse when the View can not provide a subpath for the supplied arguments. """
-       silent_variable_failure = True
-
-
-class AncestorDoesNotExist(Exception):
-       """ Raised by get_path if the root model is not an ancestor of the current model """
-       pass
\ No newline at end of file
similarity index 100%
rename from LICENSE
rename to philo/LICENSE
diff --git a/philo/__init__.py b/philo/__init__.py
new file mode 100644 (file)
index 0000000..32297e0
--- /dev/null
@@ -0,0 +1 @@
+VERSION = (0, 0)
similarity index 100%
rename from admin/__init__.py
rename to philo/admin/__init__.py
similarity index 99%
rename from admin/base.py
rename to philo/admin/base.py
index 75fa336..3a9458e 100644 (file)
@@ -4,12 +4,13 @@ from django.contrib.contenttypes import generic
 from django.http import HttpResponse
 from django.utils import simplejson as json
 from django.utils.html import escape
+from mptt.admin import MPTTModelAdmin
+
 from philo.models import Tag, Attribute
 from philo.models.fields.entities import ForeignKeyAttribute, ManyToManyAttribute
 from philo.admin.forms.attributes import AttributeForm, AttributeInlineFormSet
 from philo.admin.widgets import TagFilteredSelectMultiple
 from philo.forms.entities import EntityForm, proxy_fields_for_entity_model
-from mptt.admin import MPTTModelAdmin
 
 
 COLLAPSE_CLASSES = ('collapse', 'collapse-closed', 'closed',)
similarity index 91%
rename from admin/collections.py
rename to philo/admin/collections.py
index d422b74..c2a9034 100644 (file)
@@ -1,4 +1,5 @@
 from django.contrib import admin
+
 from philo.admin.base import COLLAPSE_CLASSES
 from philo.models import CollectionMember, Collection
 
similarity index 99%
rename from admin/forms/attributes.py
rename to philo/admin/forms/attributes.py
index fc77d0f..5372ab3 100644 (file)
@@ -1,6 +1,7 @@
 from django.contrib.contenttypes.generic import BaseGenericInlineFormSet
 from django.contrib.contenttypes.models import ContentType
 from django.forms.models import ModelForm
+
 from philo.models import Attribute
 
 
similarity index 99%
rename from admin/forms/containers.py
rename to philo/admin/forms/containers.py
index 420ba17..246a954 100644 (file)
@@ -5,6 +5,7 @@ from django.db.models import Q
 from django.forms.models import ModelForm, BaseInlineFormSet, BaseModelFormSet
 from django.forms.formsets import TOTAL_FORM_COUNT
 from django.utils.datastructures import SortedDict
+
 from philo.admin.widgets import ModelLookupWidget
 from philo.models import Contentlet, ContentReference
 
similarity index 79%
rename from admin/nodes.py
rename to philo/admin/nodes.py
index 66be107..853ba25 100644 (file)
@@ -1,12 +1,15 @@
 from django.contrib import admin
+from mptt.admin import MPTTModelAdmin
+
 from philo.admin.base import EntityAdmin, TreeEntityAdmin, COLLAPSE_CLASSES
 from philo.models import Node, Redirect, File
 
 
 class NodeAdmin(TreeEntityAdmin):
        list_display = ('slug', 'view', 'accepts_subpath')
+       raw_id_fields = ('parent',)
        related_lookup_fields = {
-               'fk': [],
+               'fk': raw_id_fields,
                'm2m': [],
                'generic': [['view_content_type', 'view_object_id']]
        }
@@ -14,6 +17,9 @@ class NodeAdmin(TreeEntityAdmin):
        def accepts_subpath(self, obj):
                return obj.accepts_subpath
        accepts_subpath.boolean = True
+       
+       def formfield_for_foreignkey(self, db_field, request, **kwargs):
+               return super(MPTTModelAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
 
 
 class ViewAdmin(EntityAdmin):
similarity index 84%
rename from admin/pages.py
rename to philo/admin/pages.py
index 13d4098..fd8665b 100644 (file)
@@ -1,10 +1,11 @@
+from django import forms
 from django.conf import settings
 from django.contrib import admin
-from django import forms
+
 from philo.admin.base import COLLAPSE_CLASSES, TreeAdmin
+from philo.admin.forms.containers import *
 from philo.admin.nodes import ViewAdmin
 from philo.models.pages import Page, Template, Contentlet, ContentReference
-from philo.admin.forms.containers import *
 
 
 class ContentletInline(admin.StackedInline):
@@ -46,6 +47,12 @@ class PageAdmin(ViewAdmin):
        list_filter = ('template',)
        search_fields = ['title', 'contentlets__content']
        inlines = [ContentletInline, ContentReferenceInline] + ViewAdmin.inlines
+       
+       def response_add(self, request, obj, post_url_continue='../%s/'):
+               # Shamelessly cribbed from django/contrib/auth/admin.py:143
+               if '_addanother' not in request.POST and '_popup' not in request.POST:
+                       request.POST['_continue'] = 1
+               return super(PageAdmin, self).response_add(request, obj, post_url_continue)
 
 
 class TemplateAdmin(TreeAdmin):
similarity index 86%
rename from admin/widgets.py
rename to philo/admin/widgets.py
index aa0aa30..62a492b 100644 (file)
@@ -1,10 +1,10 @@
 from django import forms
 from django.conf import settings
 from django.contrib.admin.widgets import FilteredSelectMultiple, url_params_from_lookup_dict
-from django.utils.translation import ugettext as _
+from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.text import truncate_words
-from django.utils.html import escape
+from django.utils.translation import ugettext as _
 
 
 class ModelLookupWidget(forms.TextInput):
@@ -48,15 +48,12 @@ class TagFilteredSelectMultiple(FilteredSelectMultiple):
        catalog has been loaded in the page
        """
        class Media:
-               js = (settings.ADMIN_MEDIA_PREFIX + "js/core.js",
-                         settings.ADMIN_MEDIA_PREFIX + "js/SelectBox.js",
-                         settings.ADMIN_MEDIA_PREFIX + "js/SelectFilter2.js")
-               
-               if 'staticmedia' in settings.INSTALLED_APPS:
-                       import staticmedia
-                       js += (staticmedia.url('admin/js/TagCreation.js'),)
-               else:
-                       js += (settings.ADMIN_MEDIA_PREFIX + "js/TagCreation.js",)
+               js = (
+                       settings.ADMIN_MEDIA_PREFIX + "js/core.js",
+                       settings.ADMIN_MEDIA_PREFIX + "js/SelectBox.js",
+                       settings.ADMIN_MEDIA_PREFIX + "js/SelectFilter2.js",
+                       settings.ADMIN_MEDIA_PREFIX + "js/TagCreation.js",
+               )
 
        def render(self, name, value, attrs=None, choices=()):
                if attrs is None: attrs = {}
diff --git a/philo/contrib/__init__.py b/philo/contrib/__init__.py
new file mode 100644 (file)
index 0000000..d6c4be4
--- /dev/null
@@ -0,0 +1,10 @@
+#encoding: utf-8
+"""
+Following Python and Django’s “batteries included” philosophy, Philo includes a number of optional packages that simplify common website structures:
+
+* :mod:`~philo.contrib.penfield` — Basic philo syndication, and blog and newsletter management.
+* :mod:`~philo.contrib.shipherd` — Powerful site navigation.
+* :mod:`~philo.contrib.sobol` — Custom web and database searches.
+* :mod:`~philo.contrib.waldo` — Custom authentication systems.
+
+"""
\ No newline at end of file
diff --git a/philo/contrib/julian/__init__.py b/philo/contrib/julian/__init__.py
new file mode 100644 (file)
index 0000000..e78b263
--- /dev/null
@@ -0,0 +1,4 @@
+"""
+This version of julian is currently in development and is not considered stable.
+
+"""
\ No newline at end of file
similarity index 97%
rename from contrib/julian/admin.py
rename to philo/contrib/julian/admin.py
index 8f104e2..cf72682 100644 (file)
@@ -1,4 +1,5 @@
 from django.contrib import admin
+
 from philo.admin import EntityAdmin, COLLAPSE_CLASSES
 from philo.contrib.julian.models import Location, Event, Calendar, CalendarView
 
similarity index 99%
rename from contrib/julian/models.py
rename to philo/contrib/julian/models.py
index 5c49c7e..62b938a 100644 (file)
@@ -1,3 +1,6 @@
+import calendar
+import datetime
+
 from django.conf import settings
 from django.conf.urls.defaults import url, patterns, include
 from django.contrib.auth.models import User
@@ -10,12 +13,13 @@ from django.db import models
 from django.db.models.query import QuerySet
 from django.http import HttpResponse, Http404
 from django.utils.encoding import force_unicode
+
 from philo.contrib.julian.feedgenerator import ICalendarFeed
 from philo.contrib.penfield.models import FeedView, FEEDS
 from philo.exceptions import ViewCanNotProvideSubpath
-from philo.models import Tag, Entity, Page, TemplateField
+from philo.models import Tag, Entity, Page
+from philo.models.fields import TemplateField
 from philo.utils import ContentTypeRegistryLimiter
-import datetime, calendar
 
 
 __all__ = ('register_location_model', 'unregister_location_model', 'Location', 'TimedModel', 'Event', 'Calendar', 'CalendarView',)
similarity index 88%
rename from contrib/penfield/admin.py
rename to philo/contrib/penfield/admin.py
index c70cf46..d350303 100644 (file)
@@ -2,6 +2,7 @@ from django import forms
 from django.contrib import admin
 from django.core.urlresolvers import reverse
 from django.http import HttpResponseRedirect, QueryDict
+
 from philo.admin import EntityAdmin, AddTagAdmin, COLLAPSE_CLASSES
 from philo.contrib.penfield.models import BlogEntry, Blog, BlogView, Newsletter, NewsletterArticle, NewsletterIssue, NewsletterView
 
@@ -14,16 +15,12 @@ class DelayedDateForm(forms.ModelForm):
                self.fields[self.date_field].required = False
 
 
-class TitledAdmin(EntityAdmin):
+class BlogAdmin(EntityAdmin):
        prepopulated_fields = {'slug': ('title',)}
        list_display = ('title', 'slug')
 
 
-class BlogAdmin(TitledAdmin):
-       pass
-
-
-class BlogEntryAdmin(TitledAdmin, AddTagAdmin):
+class BlogEntryAdmin(AddTagAdmin):
        form = DelayedDateForm
        filter_horizontal = ['tags']
        list_filter = ['author', 'blog']
@@ -44,6 +41,7 @@ class BlogEntryAdmin(TitledAdmin, AddTagAdmin):
                })
        )
        related_lookup_fields = {'fk': raw_id_fields}
+       prepopulated_fields = {'slug': ('title',)}
 
 
 class BlogViewAdmin(EntityAdmin):
@@ -70,11 +68,12 @@ class BlogViewAdmin(EntityAdmin):
        related_lookup_fields = {'fk': raw_id_fields}
 
 
-class NewsletterAdmin(TitledAdmin):
-       pass
+class NewsletterAdmin(EntityAdmin):
+       prepopulated_fields = {'slug': ('title',)}
+       list_display = ('title', 'slug')
 
 
-class NewsletterArticleAdmin(TitledAdmin, AddTagAdmin):
+class NewsletterArticleAdmin(AddTagAdmin):
        form = DelayedDateForm
        filter_horizontal = ('tags', 'authors')
        list_filter = ('newsletter',)
@@ -94,6 +93,7 @@ class NewsletterArticleAdmin(TitledAdmin, AddTagAdmin):
                })
        )
        actions = ['make_issue']
+       prepopulated_fields = {'slug': ('title',)}
        
        def author_names(self, obj):
                return ', '.join([author.get_full_name() for author in obj.authors.all()])
@@ -107,8 +107,10 @@ class NewsletterArticleAdmin(TitledAdmin, AddTagAdmin):
        make_issue.short_description = u"Create issue from selected %(verbose_name_plural)s"
 
 
-class NewsletterIssueAdmin(TitledAdmin):
-       filter_horizontal = TitledAdmin.filter_horizontal + ('articles',)
+class NewsletterIssueAdmin(EntityAdmin):
+       filter_horizontal = ('articles',)
+       prepopulated_fields = {'slug': ('title',)}
+       list_display = ('title', 'slug')
 
 
 class NewsletterViewAdmin(EntityAdmin):
similarity index 69%
rename from contrib/penfield/middleware.py
rename to philo/contrib/penfield/middleware.py
index b25a28b..a0cd649 100644 (file)
@@ -1,11 +1,11 @@
 from django.http import HttpResponse
 from django.utils.decorators import decorator_from_middleware
+
 from philo.contrib.penfield.exceptions import HttpNotAcceptable
 
 
 class HttpNotAcceptableMiddleware(object):
-       """Middleware to catch HttpNotAcceptable errors and return an Http406 response.
-       See RFC 2616."""
+       """Middleware to catch :exc:`~philo.contrib.penfield.exceptions.HttpNotAcceptable` and return an :class:`HttpResponse` with a 406 response code. See :rfc:`2616`."""
        def process_exception(self, request, exception):
                if isinstance(exception, HttpNotAcceptable):
                        return HttpResponse(status=406)
similarity index 66%
rename from contrib/penfield/models.py
rename to philo/contrib/penfield/models.py
index a03bed8..b8ca610 100644 (file)
@@ -1,3 +1,5 @@
+from datetime import date, datetime
+
 from django.conf import settings
 from django.conf.urls.defaults import url, patterns, include
 from django.contrib.sites.models import Site, RequestSite
@@ -9,13 +11,14 @@ from django.utils import feedgenerator, tzinfo
 from django.utils.datastructures import SortedDict
 from django.utils.encoding import smart_unicode, force_unicode
 from django.utils.html import escape
-from datetime import date, datetime
+
 from philo.contrib.penfield.exceptions import HttpNotAcceptable
 from philo.contrib.penfield.middleware import http_not_acceptable
-from philo.contrib.penfield.validators import validate_pagination_count
 from philo.exceptions import ViewCanNotProvideSubpath
-from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField, Template
+from philo.models import Tag, Entity, MultiView, Page, register_value_model, Template
+from philo.models.fields import TemplateField
 from philo.utils import paginate
+
 try:
        import mimeparse
 except:
@@ -36,31 +39,49 @@ FEED_CHOICES = (
 
 class FeedView(MultiView):
        """
-       The FeedView expects to handle a number of different feeds for the
-       same object - i.e. patterns for a blog to handle all entries or
-       just entries for a certain year/month/day.
+       :class:`FeedView` handles a number of pages and related feeds for a single object such as a blog or newsletter. In addition to all other methods and attributes, :class:`FeedView` supports the same generic API as `django.contrib.syndication.views.Feed <http://docs.djangoproject.com/en/dev/ref/contrib/syndication/#django.contrib.syndication.django.contrib.syndication.views.Feed>`_.
        
-       This class would subclass django.contrib.syndication.views.Feed, but
-       that would make it callable, which causes problems.
        """
+       #: The type of feed which should be served by the :class:`FeedView`.
        feed_type = models.CharField(max_length=50, choices=FEED_CHOICES, default=ATOM)
+       #: The suffix which will be appended to a page URL for a feed of its items. Default: "feed"
        feed_suffix = models.CharField(max_length=255, blank=False, default="feed")
+       #: A :class:`BooleanField` - whether or not feeds are enabled.
        feeds_enabled = models.BooleanField(default=True)
+       #: A :class:`PositiveIntegerField` - the maximum number of items to return for this feed. All items will be returned if this field is blank. Default: 15.
        feed_length = models.PositiveIntegerField(blank=True, null=True, default=15, help_text="The maximum number of items to return for this feed. All items will be returned if this field is blank.")
        
+       #: A :class:`ForeignKey` to a :class:`.Template` which can be used to render the title of each item in the feed.
        item_title_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_title_related")
+       #: A :class:`ForeignKey` to a :class:`.Template` which can be used to render the description of each item in the feed.
        item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related")
        
+       #: The name of the context variable to be populated with the items managed by the :class:`FeedView`.
        item_context_var = 'items'
+       #: The attribute on a subclass of :class:`FeedView` which will contain the main object of a feed (such as a :class:`Blog`.)
        object_attr = 'object'
        
+       #: A description of the feeds served by the :class:`FeedView`. This is a required part of the :class:`django.contrib.syndication.view.Feed` API.
        description = ""
        
        def feed_patterns(self, base, get_items_attr, page_attr, reverse_name):
                """
-               Given the name to be used to reverse this view and the names of
-               the attributes for the function that fetches the objects, returns
-               patterns suitable for inclusion in urlpatterns.
+               Given the name to be used to reverse this view and the names of the attributes for the function that fetches the objects, returns patterns suitable for inclusion in urlpatterns.
+               
+               :param base: The base of the returned patterns - that is, the subpath pattern which will reference the page for the items. The :attr:`feed_suffix` will be appended to this subpath.
+               :param get_items_attr: A callable or the name of a callable on the :class:`FeedView` which will return an (``items``, ``extra_context``) tuple. This will be passed directly to :meth:`feed_view` and :meth:`page_view`.
+               :param page_attr: A :class:`.Page` instance or the name of an attribute on the :class:`FeedView` which contains a :class:`.Page` instance. This will be passed directly to :meth:`page_view` and will be rendered with the items from ``get_items_attr``.
+               :param reverse_name: The string which is considered the "name" of the view function returned by :meth:`page_view` for the given parameters.
+               :returns: Patterns suitable for use in urlpatterns.
+               
+               Example::
+               
+                       @property
+                       def urlpatterns(self):
+                               urlpatterns = self.feed_patterns(r'^', 'get_all_entries', 'index_page', 'index')
+                               urlpatterns += self.feed_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_day')
+                               return urlpatterns
+               
                """
                urlpatterns = patterns('')
                if self.feeds_enabled:
@@ -76,11 +97,18 @@ class FeedView(MultiView):
                return urlpatterns
        
        def get_object(self, request, **kwargs):
+               """By default, returns the object stored in the attribute named by :attr:`object_attr`. This can be overridden for subclasses that publish different data for different URL parameters. It is part of the :class:`django.contrib.syndication.views.Feed` API."""
                return getattr(self, self.object_attr)
        
        def feed_view(self, get_items_attr, reverse_name):
                """
                Returns a view function that renders a list of items as a feed.
+               
+               :param get_items_attr: A callable or the name of a callable on the :class:`FeedView` that will return a (items, extra_context) tuple when called with view arguments.
+               :param reverse_name: The name which can be used reverse this feed using the :class:`FeedView` as the urlconf.
+               
+               :returns: A view function that renders a list of items as a feed.
+               
                """
                get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr)
                
@@ -98,7 +126,11 @@ class FeedView(MultiView):
        
        def page_view(self, get_items_attr, page_attr):
                """
-               Returns a view function that renders a list of items as a page.
+               :param get_items_attr: A callable or the name of a callable on the :class:`FeedView` that will return a (items, extra_context) tuple when called with view arguments.
+               :param page_attr: A :class:`.Page` instance or the name of an attribute on the :class:`FeedView` which contains a :class:`.Page` instance. This will be rendered with the items from ``get_items_attr``.
+               
+               :returns: A view function that renders a list of items as an :class:`HttpResponse`.
+               
                """
                get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr)
                page = isinstance(page_attr, Page) and page_attr or getattr(self, page_attr)
@@ -116,10 +148,8 @@ class FeedView(MultiView):
        
        def process_page_items(self, request, items):
                """
-               Hook for handling any extra processing of items based on a
-               request, such as pagination or searching. This method is
-               expected to return a list of items and a dictionary to be
-               added to the page context.
+               Hook for handling any extra processing of ``items`` based on an :class:`HttpRequest`, such as pagination or searching. This method is expected to return a list of items and a dictionary to be added to the page context.
+               
                """
                item_context = {
                        self.item_context_var: items
@@ -127,6 +157,10 @@ class FeedView(MultiView):
                return items, item_context
        
        def get_feed_type(self, request):
+               """
+               Intelligently chooses a feed type for a given request. Tries to return :attr:`feed_type`, but if the Accept header does not include that mimetype, tries to return the best match from the feed types that are offered by the :class:`FeedView`. If none of the offered feed types are accepted by the :class:`HttpRequest`, then this method will raise :exc:`philo.contrib.penfield.exceptions.HttpNotAcceptable`.
+               
+               """
                feed_type = self.feed_type
                if feed_type not in FEEDS:
                        feed_type = FEEDS.keys()[0]
@@ -147,7 +181,8 @@ class FeedView(MultiView):
        
        def get_feed(self, obj, request, reverse_name):
                """
-               Returns an unpopulated feedgenerator.DefaultFeed object for this object.
+               Returns an unpopulated :class:`django.utils.feedgenerator.DefaultFeed` object for this object.
+               
                """
                try:
                        current_site = Site.objects.get_current()
@@ -181,6 +216,7 @@ class FeedView(MultiView):
                return feed
        
        def populate_feed(self, feed, items, request):
+               """Populates a :class:`django.utils.feedgenerator.DefaultFeed` instance as is returned by :meth:`get_feed` with the passed-in ``items``."""
                if self.item_title_template:
                        title_template = DjangoTemplate(self.item_title_template.code)
                else:
@@ -271,17 +307,11 @@ class FeedView(MultiView):
                return attr
        
        def feed_extra_kwargs(self, obj):
-               """
-               Returns an extra keyword arguments dictionary that is used when
-               initializing the feed generator.
-               """
+               """Returns an extra keyword arguments dictionary that is used when initializing the feed generator."""
                return {}
        
        def item_extra_kwargs(self, item):
-               """
-               Returns an extra keyword arguments dictionary that is used with
-               the `add_item` call of the feed generator.
-               """
+               """Returns an extra keyword arguments dictionary that is used with the `add_item` call of the feed generator."""
                return {}
        
        def item_title(self, item):
@@ -294,14 +324,25 @@ class FeedView(MultiView):
                abstract=True
 
 
-class Blog(Entity, Titled):
+class Blog(Entity):
+       """Represents a blog which can be posted to."""
+       #: The name of the :class:`Blog`, currently called 'title' for historical reasons.
+       title = models.CharField(max_length=255)
+       
+       #: A slug used to identify the :class:`Blog`.
+       slug = models.SlugField(max_length=255)
+       
+       def __unicode__(self):
+               return self.title
+       
        @property
        def entry_tags(self):
-               """ Returns a QuerySet of Tags that are used on any entries in this blog. """
+               """Returns a :class:`QuerySet` of :class:`.Tag`\ s that are used on any entries in this blog."""
                return Tag.objects.filter(blogentries__blog=self).distinct()
        
        @property
        def entry_dates(self):
+               """Returns a dictionary of date :class:`QuerySet`\ s for years, months, and days for which there are entries."""
                dates = {'year': self.entries.dates('date', 'year', order='DESC'), 'month': self.entries.dates('date', 'month', order='DESC'), 'day': self.entries.dates('date', 'day', order='DESC')}
                return dates
 
@@ -309,12 +350,30 @@ class Blog(Entity, Titled):
 register_value_model(Blog)
 
 
-class BlogEntry(Entity, Titled):
+class BlogEntry(Entity):
+       """Represents an entry in a :class:`Blog`."""
+       #: The title of the :class:`BlogEntry`.
+       title = models.CharField(max_length=255)
+       
+       #: A slug which identifies the :class:`BlogEntry`.
+       slug = models.SlugField(max_length=255)
+       
+       #: The :class:`Blog` which this entry has been posted to. Can be left blank to represent a "draft" status.
        blog = models.ForeignKey(Blog, related_name='entries', blank=True, null=True)
+       
+       #: A :class:`ForeignKey` to the author. The model is either :setting:`PHILO_PERSON_MODULE` or :class:`auth.User`.
        author = models.ForeignKey(getattr(settings, 'PHILO_PERSON_MODULE', 'auth.User'), related_name='blogentries')
+       
+       #: The date and time which the :class:`BlogEntry` is considered posted at.
        date = models.DateTimeField(default=None)
+       
+       #: The content of the :class:`BlogEntry`.
        content = models.TextField()
+       
+       #: An optional brief excerpt from the :class:`BlogEntry`.
        excerpt = models.TextField(blank=True, null=True)
+       
+       #: :class:`.Tag`\ s for this :class:`BlogEntry`.
        tags = models.ManyToManyField(Tag, related_name='blogentries', blank=True, null=True)
        
        def save(self, *args, **kwargs):
@@ -322,6 +381,9 @@ class BlogEntry(Entity, Titled):
                        self.date = datetime.now()
                super(BlogEntry, self).save(*args, **kwargs)
        
+       def __unicode__(self):
+               return self.title
+       
        class Meta:
                ordering = ['-date']
                verbose_name_plural = "blog entries"
@@ -332,6 +394,10 @@ register_value_model(BlogEntry)
 
 
 class BlogView(FeedView):
+       """
+       A subclass of :class:`FeedView` which handles patterns and feeds for a :class:`Blog` and its related :class:`entries <BlogEntry>`.
+       
+       """
        ENTRY_PERMALINK_STYLE_CHOICES = (
                ('D', 'Year, month, and day'),
                ('M', 'Year and month'),
@@ -340,18 +406,34 @@ class BlogView(FeedView):
                ('N', 'No base')
        )
        
+       #: The :class:`Blog` whose entries should be managed by this :class:`BlogView`
        blog = models.ForeignKey(Blog, related_name='blogviews')
        
+       #: The main page of the :class:`Blog` will be rendered with this :class:`.Page`.
        index_page = models.ForeignKey(Page, related_name='blog_index_related')
+       #: The detail view of a :class:`BlogEntry` will be rendered with this :class:`Page`.
        entry_page = models.ForeignKey(Page, related_name='blog_entry_related')
        # TODO: entry_archive is misleading. Rename to ymd_page or timespan_page.
+       #: Views of :class:`BlogEntry` archives will be rendered with this :class:`Page` (optional).
        entry_archive_page = models.ForeignKey(Page, related_name='blog_entry_archive_related', null=True, blank=True)
+       #: Views of :class:`BlogEntry` archives according to their :class:`.Tag`\ s will be rendered with this :class:`Page`.
        tag_page = models.ForeignKey(Page, related_name='blog_tag_related')
+       #: The archive of all available tags will be rendered with this :class:`Page` (optional).
        tag_archive_page = models.ForeignKey(Page, related_name='blog_tag_archive_related', null=True, blank=True)
-       entries_per_page = models.IntegerField(blank=True, validators=[validate_pagination_count], null=True)
-       
+       #: This number will be passed directly into pagination for :class:`BlogEntry` list pages. Pagination will be disabled if this is left blank.
+       entries_per_page = models.IntegerField(blank=True, null=True)
+       
+       #: Depending on the needs of the site, different permalink styles may be appropriate. Example subpaths are provided for a :class:`BlogEntry` posted on May 2nd, 2011 with a slug of "hello". The choices are:
+       #: 
+       #:      * Year, month, and day - ``2011/05/02/hello``
+       #:      * Year and month - ``2011/05/hello``
+       #:      * Year - ``2011/hello``
+       #:      * Custom base - :attr:`entry_permalink_base`\ ``/hello``
+       #:      * No base - ``hello``
        entry_permalink_style = models.CharField(max_length=1, choices=ENTRY_PERMALINK_STYLE_CHOICES)
+       #: If the :attr:`entry_permalink_style` is set to "Custom base" then the value of this field will be used as the base subpath for year/month/day entry archive pages and entry detail pages. Default: "entries"
        entry_permalink_base = models.CharField(max_length=255, blank=False, default='entries')
+       #: This will be used as the base for the views of :attr:`tag_page` and :attr:`tag_archive_page`. Default: "tags"
        tag_permalink_base = models.CharField(max_length=255, blank=False, default='tags')
        
        item_context_var = 'entries'
@@ -421,7 +503,7 @@ class BlogView(FeedView):
                                url((r'^%s/(?P<slug>[-\w]+)$' % self.entry_permalink_base), self.entry_view)
                        )
                else:
-                       urlpatterns = patterns('',
+                       urlpatterns += patterns('',
                                url(r'^(?P<slug>[-\w]+)$', self.entry_view)
                        )
                return urlpatterns
@@ -430,15 +512,19 @@ 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()
        
        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`."""
                return self.blog.entry_tags
        
        def get_all_entries(self, request, extra_context=None):
+               """Used to generate :meth:`~FeedView.feed_patterns` for all entries."""
                return self.get_entry_queryset(), extra_context
        
        def get_entries_by_ymd(self, request, year=None, month=None, day=None, extra_context=None):
+               """Used to generate :meth:`~FeedView.feed_patterns` for entries with a specific year, month, and day."""
                if not self.entry_archive_page:
                        raise Http404
                entries = self.get_entry_queryset()
@@ -454,6 +540,7 @@ class BlogView(FeedView):
                return entries, context
        
        def get_entries_by_tag(self, request, tag_slugs, extra_context=None):
+               """Used to generate :meth:`~FeedView.feed_patterns` for entries with all of the given tags."""
                tag_slugs = tag_slugs.replace('+', '/').split('/')
                tags = self.get_tag_queryset().filter(slug__in=tag_slugs)
                
@@ -476,6 +563,7 @@ class BlogView(FeedView):
                return entries, context
        
        def entry_view(self, request, slug, year=None, month=None, day=None, extra_context=None):
+               """Renders :attr:`entry_page` with the entry specified by the given parameters."""
                entries = self.get_entry_queryset()
                if year:
                        entries = entries.filter(date__year=year)
@@ -493,6 +581,7 @@ class BlogView(FeedView):
                return self.entry_page.render_to_response(request, extra_context=context)
        
        def tag_archive_view(self, request, extra_context=None):
+               """Renders :attr:`tag_archive_page` with the result of :meth:`get_tag_queryset` added to the context."""
                if not self.tag_archive_page:
                        raise Http404
                context = self.get_context()
@@ -503,6 +592,7 @@ class BlogView(FeedView):
                return self.tag_archive_page.render_to_response(request, extra_context=context)
        
        def feed_view(self, get_items_attr, reverse_name):
+               """Overrides :meth:`FeedView.feed_view` to add :class:`.Tag`\ s to the feed as categories."""
                get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr)
                
                def inner(request, extra_context=None, *args, **kwargs):
@@ -526,6 +616,7 @@ class BlogView(FeedView):
                return inner
        
        def process_page_items(self, request, items):
+               """Overrides :meth:`FeedView.process_page_items` to add pagination."""
                if self.entries_per_page:
                        page_num = request.GET.get('page', 1)
                        paginator, paginated_page, items = paginate(items, self.entries_per_page, page_num)
@@ -559,19 +650,37 @@ class BlogView(FeedView):
                return [tag.name for tag in item.tags.all()]
 
 
-class Newsletter(Entity, Titled):
-       pass
+class Newsletter(Entity):
+       """Represents a newsletter which will contain :class:`articles <NewsletterArticle>` organized into :class:`issues <NewsletterIssue>`."""
+       #: The name of the :class:`Newsletter`, currently callse 'title' for historical reasons.
+       title = models.CharField(max_length=255)
+       #: A slug used to identify the :class:`Newsletter`.
+       slug = models.SlugField(max_length=255)
+       
+       def __unicode__(self):
+               return self.title
 
 
 register_value_model(Newsletter)
 
 
-class NewsletterArticle(Entity, Titled):
+class NewsletterArticle(Entity):
+       """Represents an article in a :class:`Newsletter`"""
+       #: The title of the :class:`NewsletterArticle`.
+       title = models.CharField(max_length=255)
+       #: A slug which identifies the :class:`NewsletterArticle`.
+       slug = models.SlugField(max_length=255)
+       #: A :class:`ForeignKey` to :class:`Newsletter` representing the newsletter which this article was written for.
        newsletter = models.ForeignKey(Newsletter, related_name='articles')
+       #: A :class:`ManyToManyField` to the author(s) of the :class:`NewsletterArticle`. The model is either :setting:`PHILO_PERSON_MODULE` or :class:`auth.User`.
        authors = models.ManyToManyField(getattr(settings, 'PHILO_PERSON_MODULE', 'auth.User'), related_name='newsletterarticles')
+       #: The date and time which the :class:`NewsletterArticle` is considered published at.
        date = models.DateTimeField(default=None)
+       #: A :class:`.TemplateField` containing an optional short summary of the article, meant to grab a reader's attention and draw them in.
        lede = TemplateField(null=True, blank=True, verbose_name='Summary')
+       #: A :class:`.TemplateField` containing the full text of the article.
        full_text = TemplateField(db_index=True)
+       #: A :class:`ManyToManyField` to :class:`.Tag`\ s for the :class:`NewsletterArticle`.
        tags = models.ManyToManyField(Tag, related_name='newsletterarticles', blank=True, null=True)
        
        def save(self, *args, **kwargs):
@@ -579,6 +688,9 @@ class NewsletterArticle(Entity, Titled):
                        self.date = datetime.now()
                super(NewsletterArticle, self).save(*args, **kwargs)
        
+       def __unicode__(self):
+               return self.title
+       
        class Meta:
                get_latest_by = 'date'
                ordering = ['-date']
@@ -588,11 +700,22 @@ class NewsletterArticle(Entity, Titled):
 register_value_model(NewsletterArticle)
 
 
-class NewsletterIssue(Entity, Titled):
+class NewsletterIssue(Entity):
+       """Represents an issue of the newsletter."""
+       #: The title of the :class:`NewsletterIssue`.
+       title = models.CharField(max_length=255)
+       #: A slug which identifies the :class:`NewsletterIssue`.
+       slug = models.SlugField(max_length=255)
+       #: A :class:`ForeignKey` to the :class:`Newsletter` which this issue belongs to.
        newsletter = models.ForeignKey(Newsletter, related_name='issues')
+       #: The numbering of the issue - for example, 04.02 for volume 4, issue 2. This is an instance of :class:`CharField` to allow any arbitrary numbering system.
        numbering = models.CharField(max_length=50, help_text='For example, 04.02 for volume 4, issue 2.')
+       #: A :class:`ManyToManyField` to articles belonging to this issue.
        articles = models.ManyToManyField(NewsletterArticle, related_name='issues')
        
+       def __unicode__(self):
+               return self.title
+       
        class Meta:
                ordering = ['-numbering']
                unique_together = (('newsletter', 'numbering'),)
@@ -602,6 +725,7 @@ register_value_model(NewsletterIssue)
 
 
 class NewsletterView(FeedView):
+       """A subclass of :class:`FeedView` which handles patterns and feeds for a :class:`Newsletter` and its related :class:`articles <NewsletterArticle>`."""
        ARTICLE_PERMALINK_STYLE_CHOICES = (
                ('D', 'Year, month, and day'),
                ('M', 'Year and month'),
@@ -609,16 +733,30 @@ class NewsletterView(FeedView):
                ('S', 'Slug only')
        )
        
+       #: A :class:`ForeignKey` to the :class:`Newsletter` managed by this :class:`NewsletterView`.
        newsletter = models.ForeignKey(Newsletter, related_name='newsletterviews')
        
+       #: A :class:`ForeignKey` to the :class:`Page` used to render the main page of this :class:`NewsletterView`.
        index_page = models.ForeignKey(Page, related_name='newsletter_index_related')
+       #: A :class:`ForeignKey` to the :class:`Page` used to render the detail view of a :class:`NewsletterArticle` for this :class:`NewsletterView`.
        article_page = models.ForeignKey(Page, related_name='newsletter_article_related')
+       #: A :class:`ForeignKey` to the :class:`Page` used to render the :class:`NewsletterArticle` archive pages for this :class:`NewsletterView`.
        article_archive_page = models.ForeignKey(Page, related_name='newsletter_article_archive_related', null=True, blank=True)
+       #: A :class:`ForeignKey` to the :class:`Page` used to render the detail view of a :class:`NewsletterIssue` for this :class:`NewsletterView`.
        issue_page = models.ForeignKey(Page, related_name='newsletter_issue_related')
+       #: A :class:`ForeignKey` to the :class:`Page` used to render the :class:`NewsletterIssue` archive pages for this :class:`NewsletterView`.
        issue_archive_page = models.ForeignKey(Page, related_name='newsletter_issue_archive_related', null=True, blank=True)
        
+       #: Depending on the needs of the site, different permalink styles may be appropriate. Example subpaths are provided for a :class:`NewsletterArticle` posted on May 2nd, 2011 with a slug of "hello". The choices are:
+       #: 
+       #:      * Year, month, and day - :attr:`article_permalink_base`\ ``/2011/05/02/hello``
+       #:      * Year and month - :attr:`article_permalink_base`\ ``/2011/05/hello``
+       #:      * Year - :attr:`article_permalink_base`\ ``/2011/hello``
+       #:      * Slug only - :attr:`article_permalink_base`\ ``/hello``
        article_permalink_style = models.CharField(max_length=1, choices=ARTICLE_PERMALINK_STYLE_CHOICES)
+       #: This will be used as the base subpath for year/month/day article archive pages and article detail pages. Default: "articles"
        article_permalink_base = models.CharField(max_length=255, blank=False, default='articles')
+       #: This will be used as the base subpath for issue detail pages and the issue archive page.
        issue_permalink_base = models.CharField(max_length=255, blank=False, default='issues')
        
        item_context_var = 'articles'
@@ -693,15 +831,19 @@ 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()
        
        def get_issue_queryset(self):
+               """Returns the default :class:`QuerySet` of :class:`NewsletterIssue` instances for the :class:`NewsletterView`."""
                return self.newsletter.issues.all()
        
        def get_all_articles(self, request, extra_context=None):
+               """Used to generate :meth:`FeedView.feed_patterns` for all entries."""
                return self.get_article_queryset(), extra_context
        
        def get_articles_by_ymd(self, request, year, month=None, day=None, extra_context=None):
+               """Used to generate :meth:`FeedView.feed_patterns` for a specific year, month, and day."""
                articles = self.get_article_queryset().filter(date__year=year)
                if month:
                        articles = articles.filter(date__month=month)
@@ -710,6 +852,7 @@ class NewsletterView(FeedView):
                return articles, extra_context
        
        def get_articles_by_issue(self, request, numbering, extra_context=None):
+               """Used to generate :meth:`FeedView.feed_patterns` for articles from a certain issue."""
                try:
                        issue = self.get_issue_queryset().get(numbering=numbering)
                except NewsletterIssue.DoesNotExist:
@@ -719,6 +862,7 @@ class NewsletterView(FeedView):
                return self.get_article_queryset().filter(issues=issue), context
        
        def article_view(self, request, slug, year=None, month=None, day=None, extra_context=None):
+               """Renders :attr:`article_page` with the article specified by the given parameters."""
                articles = self.get_article_queryset()
                if year:
                        articles = articles.filter(date__year=year)
@@ -736,6 +880,7 @@ class NewsletterView(FeedView):
                return self.article_page.render_to_response(request, extra_context=context)
        
        def issue_archive_view(self, request, extra_context):
+               """Renders :attr:`issue_archive_page` with the result of :meth:`get_issue_queryset` added to the context."""
                if not self.issue_archive_page:
                        raise Http404
                context = self.get_context()
diff --git a/philo/contrib/penfield/templatetags/penfield.py b/philo/contrib/penfield/templatetags/penfield.py
new file mode 100644 (file)
index 0000000..b263a2b
--- /dev/null
@@ -0,0 +1,37 @@
+"""
+Penfield supplies two template filters to handle common use cases for blogs and newsletters.
+
+"""
+from django import template
+from django.utils.dates import MONTHS, MONTHS_AP
+
+
+register = template.Library()
+
+
+@register.filter
+def monthname(value):
+       """Returns the name of a month with the supplied numeric value."""
+       try:
+               value = int(value)
+       except:
+               pass
+       
+       try:
+               return MONTHS[value]
+       except KeyError:
+               return value
+
+
+@register.filter
+def apmonthname(value):
+       """Returns the Associated Press abbreviated month name for the supplied numeric value."""
+       try:
+               value = int(value)
+       except:
+               pass
+       
+       try:
+               return MONTHS_AP[value]
+       except KeyError:
+               return value
\ No newline at end of file
similarity index 97%
rename from contrib/shipherd/admin.py
rename to philo/contrib/shipherd/admin.py
index 93d21e5..be31a43 100644 (file)
@@ -1,4 +1,5 @@
 from django.contrib import admin
+
 from philo.admin import TreeEntityAdmin, COLLAPSE_CLASSES, NodeAdmin, EntityAdmin
 from philo.models import Node
 from philo.contrib.shipherd.models import NavigationItem, Navigation
similarity index 70%
rename from contrib/shipherd/models.py
rename to philo/contrib/shipherd/models.py
index 654f5f8..f35be3c 100644 (file)
@@ -1,19 +1,24 @@
 #encoding: utf-8
+from UserDict import DictMixin
+
 from django.core.exceptions import ValidationError
 from django.core.urlresolvers import NoReverseMatch
 from django.core.validators import RegexValidator, MinValueValidator
 from django.db import models
 from django.forms.models import model_to_dict
-from philo.models import TreeEntity, Node, TreeManager, Entity, TargetURLModel
-from philo.validators import RedirectValidator
-from UserDict import DictMixin
+
+from philo.models.base import TreeEntity, TreeManager, Entity
+from philo.models.nodes import Node, TargetURLModel
 
 
 DEFAULT_NAVIGATION_DEPTH = 3
 
 
-class NavigationQuerySetMapper(object, DictMixin):
-       """This class exists to prevent setting of items in the navigation cache through node.navigation."""
+class NavigationMapper(object, DictMixin):
+       """
+       The :class:`NavigationMapper` is a dictionary-like object which allows easy fetching of the root items of a navigation for a node according to a key. The fetching goes through the :class:`NavigationManager` and can thus take advantage of the navigation cache. A :class:`NavigationMapper` instance will be available on each node instance as :attr:`Node.navigation` if :mod:`~philo.contrib.shipherd` is in the :setting:`INSTALLED_APPS`
+       
+       """
        def __init__(self, node):
                self.node = node
        
@@ -26,7 +31,7 @@ class NavigationQuerySetMapper(object, DictMixin):
 
 def navigation(self):
        if not hasattr(self, '_navigation'):
-               self._navigation = NavigationQuerySetMapper(self)
+               self._navigation = NavigationMapper(self)
        return self._navigation
 
 
@@ -38,6 +43,7 @@ class NavigationCacheQuerySet(models.query.QuerySet):
        This subclass will trigger general cache clearing for Navigation.objects when a mass
        update or deletion is performed. As there is no convenient way to iterate over the
        changed or deleted instances, there's no way to be more precise about what gets cleared.
+       
        """
        def update(self, *args, **kwargs):
                super(NavigationCacheQuerySet, self).update(*args, **kwargs)
@@ -49,15 +55,22 @@ class NavigationCacheQuerySet(models.query.QuerySet):
 
 
 class NavigationManager(models.Manager):
-       # Since navigation is going to be hit frequently and changed
-       # relatively infrequently, cache it. Analogous to contenttypes.
+       """
+       Since navigation on a site will be hit frequently, is relatively costly to compute, and is changed relatively infrequently, the NavigationManager maintains a cache which maps nodes to navigations.
+       
+       """
        use_for_related = True
        _cache = {}
        
-       def get_queryset(self):
+       def get_query_set(self):
+               """
+               Returns a :class:`NavigationCacheQuerySet` instance.
+               
+               """
                return NavigationCacheQuerySet(self.model, using=self._db)
        
        def get_cache_for(self, node, update_targets=True):
+               """Returns the navigation cache for a given :class:`.Node`. If update_targets is ``True``, then :meth:`update_targets_for` will be run with the :class:`.Node`."""
                created = False
                if not self.has_cache_for(node):
                        self.create_cache_for(node)
@@ -69,10 +82,11 @@ class NavigationManager(models.Manager):
                return self.__class__._cache[self.db][node]
        
        def has_cache_for(self, node):
+               """Returns ``True`` if a cache exists for the :class:`.Node` and ``False`` otherwise."""
                return self.db in self.__class__._cache and node in self.__class__._cache[self.db]
        
        def create_cache_for(self, node):
-               "This method loops through the nodes ancestors and caches all unique navigation keys."
+               """This method loops through the :class:`.Node`\ s ancestors and caches all unique navigation keys."""
                ancestors = node.get_ancestors(ascending=True, include_self=True)
                
                nodes_to_cache = []
@@ -128,10 +142,7 @@ class NavigationManager(models.Manager):
                return cache
        
        def clear_cache_for(self, node):
-               # Clear the cache for this node and all its descendants. The
-               # navigation for this node has probably changed, and for now,
-               # it isn't worth it to only clear the descendants actually
-               # affected by this.
+               """Clear the cache for the :class:`.Node` and all its descendants. The navigation for this node has probably changed, and it isn't worth it to figure out which descendants were actually affected by this."""
                if not self.has_cache_for(node):
                        # Already cleared.
                        return
@@ -142,10 +153,7 @@ class NavigationManager(models.Manager):
                        cache.pop(node, None)
        
        def update_targets_for(self, node):
-               # Manually update a cache's target nodes in case something's changed there.
-               # This should be a less complex operation than reloading the models each
-               # time. Not as good as selective updates... but not much to be done
-               # about that. TODO: Benchmark it.
+               """Manually updates the target nodes for the :class:`.Node`'s cache in case something's changed there. This is a less complex operation than rebuilding the :class:`.Node`'s cache."""
                caches = self.__class__._cache[self.db][node].values()
                
                target_pks = set()
@@ -163,14 +171,23 @@ class NavigationManager(models.Manager):
                                        item.target_node = targets[targets.index(item.target_node)]
        
        def clear_cache(self):
+               """Clears the manager's entire navigation cache."""
                self.__class__._cache.pop(self.db, None)
 
 
 class Navigation(Entity):
+       """
+       :class:`Navigation` represents a group of :class:`NavigationItem`\ s that have an intrinsic relationship in terms of navigating a website. For example, a ``main`` navigation versus a ``side`` navigation, or a ``authenticated`` navigation versus an ``anonymous`` navigation.
+       
+       """
+       #: A :class:`NavigationManager` instance.
        objects = NavigationManager()
        
+       #: The :class:`.Node` which the :class:`Navigation` is attached to. The :class:`Navigation` will also be available to all the :class:`.Node`'s descendants and will override any :class:`Navigation` with the same key on any of the :class:`.Node`'s ancestors.
        node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
+       #: Each :class:`Navigation` has a ``key`` which consists of one or more word characters so that it can easily be accessed in a template as ``{{ node.navigation.this_key }}``.
        key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
+       #: There is no limit to the depth of a tree of :class:`NavigationItem`\ s, but ``depth`` will limit how much of the tree will be displayed.
        depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
        
        def __init__(self, *args, **kwargs):
@@ -201,16 +218,21 @@ class Navigation(Entity):
 class NavigationItemManager(TreeManager):
        use_for_related = True
        
-       def get_queryset(self):
+       def get_query_set(self):
+               """Returns a :class:`NavigationCacheQuerySet` instance."""
                return NavigationCacheQuerySet(self.model, using=self._db)
 
 
 class NavigationItem(TreeEntity, TargetURLModel):
+       #: A :class:`NavigationItemManager` instance
        objects = NavigationItemManager()
        
+       #: A :class:`ForeignKey` to a :class:`Navigation` instance. If this is not null, then the :class:`NavigationItem` will be a root node of the :class:`Navigation` instance.
        navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
+       #: The text which will be displayed in the navigation. This is a :class:`CharField` instance with max length 50.
        text = models.CharField(max_length=50)
        
+       #: The order in which the :class:`NavigationItem` will be displayed.
        order = models.PositiveSmallIntegerField(default=0)
        
        def __init__(self, *args, **kwargs):
@@ -227,6 +249,7 @@ class NavigationItem(TreeEntity, TargetURLModel):
                        raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
        
        def is_active(self, request):
+               """Returns ``True`` if the :class:`NavigationItem` is considered active for a given request and ``False`` otherwise."""
                if self.target_url == request.path:
                        # Handle the `default` case where the target_url and requested path
                        # are identical.
@@ -253,6 +276,7 @@ class NavigationItem(TreeEntity, TargetURLModel):
                return False
        
        def has_active_descendants(self, request):
+               """Returns ``True`` if the :class:`NavigationItem` has active descendants and ``False`` otherwise."""
                for child in self.get_children():
                        if child.is_active(request) or child.has_active_descendants(request):
                                return True
similarity index 84%
rename from contrib/shipherd/templatetags/shipherd.py
rename to philo/contrib/shipherd/templatetags/shipherd.py
index b05ff0f..508eace 100644 (file)
@@ -60,10 +60,6 @@ class LazyNavigationRecurser(object):
                        context['item'] = item
                        context['children'] = self.__class__(self.template_nodes, item.get_children(), context, request)
                        
-                       # Django 1.2.X compatibility - a lazy recurser will not be called if accessed as a template variable.
-                       if django_version < (1,3):
-                               context['children'] = context['children']()
-                       
                        # Then render the nodelist bit by bit.
                        for node in self.template_nodes:
                                bits.append(node.render(context))
@@ -105,12 +101,13 @@ class RecurseNavigationNode(template.Node):
 @register.tag
 def recursenavigation(parser, token):
        """
-       The recursenavigation templatetag takes two arguments:
-       - the node for which the navigation should be found
-       - the navigation's key.
+       The :ttag:`recursenavigation` templatetag takes two arguments:
+       
+       * the :class:`.Node` for which the :class:`.Navigation` should be found
+       * the :class:`.Navigation`'s :attr:`~.Navigation.key`.
        
-       It will then recursively loop over each item in the navigation and render the template
-       chunk within the block. recursenavigation sets the following variables in the context:
+       It will then recursively loop over each :class:`.NavigationItem` in the :class:`.Navigation` and render the template
+       chunk within the block. :ttag:`recursenavigation` sets the following variables in the context:
        
                ==============================  ================================================
                Variable                        Description
@@ -122,25 +119,26 @@ def recursenavigation(parser, token):
                ``navloop.first``               True if this is the first time through the current level
                ``navloop.last``                True if this is the last time through the current level
                ``navloop.parentloop``          This is the loop one level "above" the current one
-               ==============================  ================================================
-               ``item``                        The current item in the loop (a NavigationItem instance)
+               
+               ``item``                        The current item in the loop (a :class:`.NavigationItem` instance)
                ``children``                    If accessed, performs the next level of recursion.
                ``navloop.active``              True if the item is active for this request
                ``navloop.active_descendants``  True if the item has active descendants for this request
                ==============================  ================================================
        
-       Example:
+       Example::
+       
                <ul>
-                       {% recursenavigation node main %}
-                               <li{% if navloop.active %} class='active'{% endif %}>
-                                       {{ navloop.item.text }}
-                                       {% if item.get_children %}
-                                               <ul>
-                                                       {{ children }}
-                                               </ul>
-                                       {% endif %}
-                               </li>
-                       {% endrecursenavigation %}
+                   {% recursenavigation node "main" %}
+                       <li{% if navloop.active %} class='active'{% endif %}>
+                           {{ navloop.item.text }}
+                           {% if item.get_children %}
+                               <ul>
+                                   {{ children }}
+                               </ul>
+                           {% endif %}
+                       </li>
+                   {% endrecursenavigation %}
                </ul>
        """
        bits = token.contents.split()
diff --git a/philo/contrib/sobol/__init__.py b/philo/contrib/sobol/__init__.py
new file mode 100644 (file)
index 0000000..cd75f13
--- /dev/null
@@ -0,0 +1,6 @@
+"""
+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.
+
+"""
+
+from philo.contrib.sobol.search import *
\ No newline at end of file
similarity index 98%
rename from contrib/sobol/admin.py
rename to philo/contrib/sobol/admin.py
index 87dd39a..f4636e7 100644 (file)
@@ -1,3 +1,5 @@
+from functools import update_wrapper
+
 from django.conf import settings
 from django.conf.urls.defaults import patterns, url
 from django.contrib import admin
@@ -7,9 +9,9 @@ from django.http import HttpResponseRedirect, Http404
 from django.shortcuts import render_to_response
 from django.template import RequestContext
 from django.utils.translation import ugettext_lazy as _
+
 from philo.admin import EntityAdmin
 from philo.contrib.sobol.models import Search, ResultURL, SearchView
-from functools import update_wrapper
 
 
 class ResultURLInline(admin.TabularInline):
similarity index 97%
rename from contrib/sobol/forms.py
rename to philo/contrib/sobol/forms.py
index e79d9e7..f9994a1 100644 (file)
@@ -1,4 +1,5 @@
 from django import forms
+
 from philo.contrib.sobol.utils import SEARCH_ARG_GET_KEY
 
 
diff --git a/philo/contrib/sobol/migrations/0001_initial.py b/philo/contrib/sobol/migrations/0001_initial.py
new file mode 100644 (file)
index 0000000..c94f54c
--- /dev/null
@@ -0,0 +1,136 @@
+# 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 model 'Search'
+        db.create_table('sobol_search', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('string', self.gf('django.db.models.fields.TextField')()),
+        ))
+        db.send_create_signal('sobol', ['Search'])
+
+        # Adding model 'ResultURL'
+        db.create_table('sobol_resulturl', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('search', self.gf('django.db.models.fields.related.ForeignKey')(related_name='result_urls', to=orm['sobol.Search'])),
+            ('url', self.gf('django.db.models.fields.TextField')()),
+        ))
+        db.send_create_signal('sobol', ['ResultURL'])
+
+        # Adding model 'Click'
+        db.create_table('sobol_click', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('result', self.gf('django.db.models.fields.related.ForeignKey')(related_name='clicks', to=orm['sobol.ResultURL'])),
+            ('datetime', self.gf('django.db.models.fields.DateTimeField')()),
+        ))
+        db.send_create_signal('sobol', ['Click'])
+
+        # Adding model 'SearchView'
+        db.create_table('sobol_searchview', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('results_page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='search_results_related', to=orm['philo.Page'])),
+            ('searches', self.gf('philo.models.fields.SlugMultipleChoiceField')()),
+            ('enable_ajax_api', self.gf('django.db.models.fields.BooleanField')(default=True)),
+            ('placeholder_text', self.gf('django.db.models.fields.CharField')(default='Search', max_length=75)),
+        ))
+        db.send_create_signal('sobol', ['SearchView'])
+
+
+    def backwards(self, orm):
+        
+        # Deleting model 'Search'
+        db.delete_table('sobol_search')
+
+        # Deleting model 'ResultURL'
+        db.delete_table('sobol_resulturl')
+
+        # Deleting model 'Click'
+        db.delete_table('sobol_click')
+
+        # Deleting model 'SearchView'
+        db.delete_table('sobol_searchview')
+
+
+    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.node': {
+            'Meta': {'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', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}),
+            'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+        },
+        '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.template': {
+            'Meta': {'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'})
+        },
+        'sobol.click': {
+            'Meta': {'ordering': "['datetime']", 'object_name': 'Click'},
+            'datetime': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'result': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'clicks'", 'to': "orm['sobol.ResultURL']"})
+        },
+        'sobol.resulturl': {
+            'Meta': {'ordering': "['url']", 'object_name': 'ResultURL'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'search': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'result_urls'", 'to': "orm['sobol.Search']"}),
+            'url': ('django.db.models.fields.TextField', [], {})
+        },
+        'sobol.search': {
+            'Meta': {'ordering': "['string']", 'object_name': 'Search'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'string': ('django.db.models.fields.TextField', [], {})
+        },
+        'sobol.searchview': {
+            'Meta': {'object_name': 'SearchView'},
+            'enable_ajax_api': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'placeholder_text': ('django.db.models.fields.CharField', [], {'default': "'Search'", 'max_length': '75'}),
+            'results_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'search_results_related'", 'to': "orm['philo.Page']"}),
+            'searches': ('philo.models.fields.SlugMultipleChoiceField', [], {})
+        }
+    }
+
+    complete_apps = ['sobol']
diff --git a/philo/contrib/sobol/models.py b/philo/contrib/sobol/models.py
new file mode 100644 (file)
index 0000000..7687c09
--- /dev/null
@@ -0,0 +1,297 @@
+import datetime
+import itertools
+
+from django.conf import settings
+from django.conf.urls.defaults import patterns, url
+from django.contrib import messages
+from django.core.exceptions import ValidationError
+from django.core.validators import URLValidator
+from django.db import models
+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.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
+from philo.models import MultiView, Page
+from philo.models.fields import SlugMultipleChoiceField
+
+eventlet = None
+if getattr(settings, 'SOBOL_USE_EVENTLET', False):
+       try:
+               import eventlet
+       except:
+               pass
+
+
+class Search(models.Model):
+       """Represents all attempts to search for a unique string."""
+       #: The string which was searched for.
+       string = models.TextField()
+       
+       def __unicode__(self):
+               return self.string
+       
+       def get_weighted_results(self, threshhold=None):
+               """
+               Returns a list of :class:`ResultURL` instances related to the search and ordered by decreasing weight. This will be cached on the instance.
+               
+               :param threshhold: The earliest datetime that a :class:`Click` can have been made on a related :class:`ResultURL` in order to be included in the weighted results (or ``None`` to include all :class:`Click`\ s and :class:`ResultURL`\ s).
+               
+               """
+               if not hasattr(self, '_weighted_results'):
+                       result_qs = self.result_urls.all()
+                       
+                       if threshhold is not None:
+                               result_qs = result_qs.filter(counts__datetime__gte=threshhold)
+                       
+                       results = [result for result in result_qs]
+                       
+                       results.sort(cmp=lambda x,y: cmp(y.weight, x.weight))
+                       
+                       self._weighted_results = results
+               
+               return self._weighted_results
+       
+       def get_favored_results(self, error=5, threshhold=None):
+               """
+               Calculates the set of most-favored results based on their weight. Evenly-weighted results will be grouped together and either added or excluded as a group.
+               
+               :param error: An arbitrary number; higher values will cause this method to be more reticent about adding new items to the favored results.
+               :param threshhold: Will be passed directly into :meth:`get_weighted_results`
+               
+               """
+               if not hasattr(self, '_favored_results'):
+                       results = self.get_weighted_results(threshhold)
+                       
+                       grouped_results = SortedDict()
+                       
+                       for result in results:
+                               grouped_results.setdefault(result.weight, []).append(result)
+                       
+                       self._favored_results = []
+                       
+                       for value, subresults in grouped_results.items():
+                               cost = error * sum([(value - result.weight)**2 for result in self._favored_results])
+                               if value > cost:
+                                       self._favored_results += subresults
+                               else:
+                                       break
+               return self._favored_results
+       
+       class Meta:
+               ordering = ['string']
+               verbose_name_plural = 'searches'
+
+
+class ResultURL(models.Model):
+       """Represents a URL which has been selected one or more times for a :class:`Search`."""
+       #: A :class:`ForeignKey` to the :class:`Search` which the :class:`ResultURL` is related to.
+       search = models.ForeignKey(Search, related_name='result_urls')
+       #: The URL which was selected.
+       url = models.TextField(validators=[URLValidator()])
+       
+       def __unicode__(self):
+               return self.url
+       
+       def get_weight(self, threshhold=None):
+               """
+               Calculates, caches, and returns the weight of the :class:`ResultURL`.
+               
+               :param threshhold: The datetime limit before which :class:`Click`\ s will not contribute to the weight of the :class:`ResultURL`.
+               
+               """
+               if not hasattr(self, '_weight'):
+                       clicks = self.clicks.all()
+                       
+                       if threshhold is not None:
+                               clicks = clicks.filter(datetime__gte=threshhold)
+                       
+                       self._weight = sum([click.weight for click in clicks])
+               
+               return self._weight
+       weight = property(get_weight)
+       
+       class Meta:
+               ordering = ['url']
+
+
+class Click(models.Model):
+       """Represents a click on a :class:`ResultURL`."""
+       #: A :class:`ForeignKey` to the :class:`ResultURL` which the :class:`Click` is related to.
+       result = models.ForeignKey(ResultURL, related_name='clicks')
+       #: The datetime when the click was registered in the system.
+       datetime = models.DateTimeField()
+       
+       def __unicode__(self):
+               return self.datetime.strftime('%B %d, %Y %H:%M:%S')
+       
+       def get_weight(self, default=1, weighted=lambda value, days: value/days**2):
+               """Calculates and returns the weight of the :class:`Click`."""
+               if not hasattr(self, '_weight'):
+                       days = (datetime.datetime.now() - self.datetime).days
+                       if days < 0:
+                               raise ValueError("Click dates must be in the past.")
+                       default = float(default)
+                       if days == 0:
+                               self._weight = float(default)
+                       else:
+                               self._weight = weighted(default, days)
+               return self._weight
+       weight = property(get_weight)
+       
+       def clean(self):
+               if self.datetime > datetime.datetime.now():
+                       raise ValidationError("Click dates must be in the past.")
+       
+       class Meta:
+               ordering = ['datetime']
+               get_latest_by = 'datetime'
+
+
+class RegistryChoiceField(SlugMultipleChoiceField):
+       def _get_choices(self):
+               if isinstance(self._choices, RegistryIterator):
+                       return self._choices.copy()
+               elif hasattr(self._choices, 'next'):
+                       choices, self._choices = itertools.tee(self._choices)
+                       return choices
+               else:
+                       return self._choices
+       choices = property(_get_choices)
+
+
+try:
+       from south.modelsinspector import add_introspection_rules
+except ImportError:
+       pass
+else:
+       add_introspection_rules([], ["^philo\.contrib\.shipherd\.models\.RegistryChoiceField"])
+
+
+class SearchView(MultiView):
+       """Handles a view for the results of a search, anonymously tracks the selections made by end users, and provides an AJAX API for asynchronous search result loading. This can be particularly useful if some searches are slow."""
+       #: :class:`ForeignKey` to a :class:`.Page` which will be used to render the search results.
+       results_page = models.ForeignKey(Page, related_name='search_results_related')
+       #: A :class:`.SlugMultipleChoiceField` whose choices are the contents of the :class:`.SearchRegistry`
+       searches = RegistryChoiceField(choices=registry.iterchoices())
+       #: A :class:`BooleanField` which controls whether or not the AJAX API is enabled.
+       #:
+       #: .. note:: If the AJAX API is enabled, a ``ajax_api_url`` attribute will be added to each search instance containing the url and get parameters for an AJAX request to retrieve results for that search.
+       #:
+       #: .. note:: Be careful not to access :attr:`search_instance.results <.BaseSearch.results>` if the AJAX API is enabled - otherwise the search will be run immediately rather than on the AJAX request.
+       enable_ajax_api = models.BooleanField("Enable AJAX API", default=True)
+       #: A :class:`CharField` containing the placeholder text which is intended to be used for the search box for the :class:`SearchView`. It is the template author's responsibility to make use of this information.
+       placeholder_text = models.CharField(max_length=75, default="Search")
+       
+       #: The form which will be used to validate the input to the search box for this :class:`SearchView`.
+       search_form = SearchForm
+       
+       def __unicode__(self):
+               return u"%s (%s)" % (self.placeholder_text, u", ".join([display for slug, display in registry.iterchoices() if slug in self.searches]))
+       
+       def get_reverse_params(self, obj):
+               raise ViewCanNotProvideSubpath
+       
+       @property
+       def urlpatterns(self):
+               urlpatterns = patterns('',
+                       url(r'^$', self.results_view, name='results'),
+               )
+               if self.enable_ajax_api:
+                       urlpatterns += patterns('',
+                               url(r'^(?P<slug>[\w-]+)$', self.ajax_api_view, name='ajax_api_view')
+                       )
+               return urlpatterns
+       
+       def get_search_instance(self, slug, search_string):
+               """Returns an instance of the :class:`.BaseSearch` subclass corresponding to ``slug`` in the :class:`.SearchRegistry` and instantiated 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:
+               
+               * A search has been initiated. In this case, a list of search instances will be added to the context as ``searches``. If :attr:`enable_ajax_api` is enabled, each instance will have an ``ajax_api_url`` attribute containing the url needed to make an AJAX request for the search results.
+               * A link has been chosen. In this case, corresponding :class:`Search`, :class:`ResultURL`, and :class:`Click` instances will be created and the user will be redirected to the link's actual url.
+               
+               """
+               results = None
+               
+               context = self.get_context()
+               context.update(extra_context or {})
+               
+               if SEARCH_ARG_GET_KEY in request.GET:
+                       form = self.search_form(request.GET)
+                       
+                       if form.is_valid():
+                               search_string = request.GET[SEARCH_ARG_GET_KEY].lower()
+                               url = request.GET.get(URL_REDIRECT_GET_KEY)
+                               hash = request.GET.get(HASH_REDIRECT_GET_KEY)
+                               
+                               if url and hash:
+                                       if check_redirect_hash(hash, search_string, url):
+                                               # Create the necessary models
+                                               search = Search.objects.get_or_create(string=search_string)[0]
+                                               result_url = search.result_urls.get_or_create(url=url)[0]
+                                               result_url.clicks.create(datetime=datetime.datetime.now())
+                                               return HttpResponseRedirect(url)
+                                       else:
+                                               messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
+                                               # TODO: Should search_string be escaped here?
+                                               return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
+                               
+                               search_instances = []
+                               for slug in self.searches:
+                                       search_instance = self.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 eventlet and not self.enable_ajax_api:
+                                       pool = eventlet.GreenPool()
+                                       for instance in search_instances:
+                                               pool.spawn_n(lambda x: x.results, search_instance)
+                                       pool.waitall()
+                               
+                               context.update({
+                                       'searches': search_instances
+                               })
+               else:
+                       form = SearchForm()
+               
+               context.update({
+                       'form': form
+               })
+               return self.results_page.render_to_response(request, extra_context=context)
+       
+       def ajax_api_view(self, request, slug, extra_context=None):
+               """
+               Returns a JSON string containing two keyed lists.
+               
+               results
+                       Contains the results of :meth:`.Result.get_context` for each result.
+               rendered
+                       Contains the results of :meth:`.Result.render` for each result.
+               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.
+               
+               """
+               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:
+                       raise Http404
+               
+               search_instance = self.get_search_instance(slug, search_string)
+               
+               return HttpResponse(json.dumps({
+                       '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,
+               }), mimetype="application/json")
\ No newline at end of file
similarity index 51%
rename from contrib/sobol/search.py
rename to philo/contrib/sobol/search.py
index 39b93c7..2dbd4a7 100644 (file)
@@ -1,4 +1,5 @@
 #encoding: utf-8
+import datetime
 
 from django.conf import settings
 from django.contrib.sites.models import Site
@@ -9,17 +10,21 @@ 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
-import datetime
-from philo.contrib.sobol.utils import make_tracking_querydict
 
-try:
-       from eventlet.green import urllib2
-except:
+from philo.contrib.sobol.utils import make_tracking_querydict, RegistryIterator
+
+
+if getattr(settings, 'SOBOL_USE_EVENTLET', False):
+       try:
+               from eventlet.green import urllib2
+       except:
+               import urllib2
+else:
        import urllib2
 
 
 __all__ = (
-       'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry'
+       'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'SearchRegistry', 'registry'
 )
 
 
@@ -32,15 +37,25 @@ MAX_CACHE_TIMEOUT = 60*24*7
 
 
 class RegistrationError(Exception):
+       """Raised if there is a problem registering a search with a :class:`SearchRegistry`"""
        pass
 
 
 class SearchRegistry(object):
-       # Holds a registry of search types by slug.
+       """Holds a registry of search types by slug."""
+       
        def __init__(self):
                self._registry = {}
        
        def register(self, search, slug=None):
+               """
+               Register a search with the registry.
+               
+               :param search: The search class to register - generally a subclass of :class:`BaseSearch`
+               :param slug: The slug which will be used to register the search class. If ``slug`` is ``None``, the search's default slug will be used.
+               :raises: :class:`RegistrationError` if a different search is already registered with ``slug``.
+               
+               """
                slug = slug or search.slug
                if slug in self._registry:
                        registered = self._registry[slug]
@@ -50,6 +65,14 @@ class SearchRegistry(object):
                        self._registry[slug] = search
        
        def unregister(self, search, slug=None):
+               """
+               Unregister a search from the registry.
+               
+               :param search: The search class to unregister - generally a subclass of :class:`BaseSearch`
+               :param slug: If provided, the search will only be removed if it was registered with ``slug``. If not provided, the search class will be unregistered no matter what slug it was registered with.
+               :raises: :class:`RegistrationError` if a slug is provided but the search registered with that slug is not ``search``.
+               
+               """
                if slug is not None:
                        if slug in self._registry and self._registry[slug] == search:
                                del self._registry[slug]
@@ -60,19 +83,23 @@ class SearchRegistry(object):
                                        del self._registry[slug]
        
        def items(self):
+               """Returns a list of (slug, search) items in the registry."""
                return self._registry.items()
        
        def iteritems(self):
-               return self._registry.iteritems()
+               """Returns an iterator over the (slug, search) pairs in the registry."""
+               return RegistryIterator(self._registry, 'iteritems')
        
        def iterchoices(self):
-               for slug, search in self.iteritems():
-                       yield slug, search.verbose_name
+               """Returns an iterator over (slug, search.verbose_name) pairs for the registry."""
+               return RegistryIterator(self._registry, 'iteritems', lambda x: (x[0], x[1].verbose_name))
        
        def __getitem__(self, key):
+               """Returns the search registered with ``key``."""
                return self._registry[key]
        
        def __iter__(self):
+               """Returns an iterator over the keys in the registry."""
                return self._registry.__iter__()
 
 
@@ -81,52 +108,70 @@ registry = SearchRegistry()
 
 class Result(object):
        """
-       A result is instantiated with a configuration dictionary, a search,
-       and a template name. The configuration dictionary is expected to
-       define a `title` and optionally a `url`. Any other variables may be
-       defined; they will be made available through the result object in
-       the template, if one is defined.
+       :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.
+       
+       :param search: An instance of a :class:`BaseSearch` subclass or an object that implements the same API.
+       :param result: An arbitrary result from the ``search``.
+       
        """
        def __init__(self, search, result):
                self.search = search
                self.result = result
        
        def get_title(self):
+               """Returns the title of the result by calling :meth:`BaseSearch.get_result_title` on the raw result."""
                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()
        
        def get_template(self):
+               """Returns the template for the 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:
+               
+               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.
+               
+               """
                context = self.get_extra_context()
                context.update({
                        'title': self.get_title(),
-                       'url': self.get_url()
+                       'url': self.get_url(),
+                       'result': self.result
                })
                return context
        
        def render(self):
+               """Returns the template from :meth:`get_template` rendered with the context from :meth:`get_context`."""
                t = self.get_template()
                c = Context(self.get_context())
                return t.render(c)
        
        def __unicode__(self):
+               """Returns :meth:`render`"""
                return self.render()
 
 
 class BaseSearchMetaclass(type):
        def __new__(cls, name, bases, attrs):
                if 'verbose_name' not in attrs:
-                       attrs['verbose_name'] = capfirst(convert_camelcase(name))
+                       attrs['verbose_name'] = capfirst(' '.join(convert_camelcase(name).rsplit(' ', 1)[:-1]))
                if 'slug' not in attrs:
                        attrs['slug'] = name.lower()
                return super(BaseSearchMetaclass, cls).__new__(cls, name, bases, attrs)
@@ -134,12 +179,15 @@ class BaseSearchMetaclass(type):
 
 class BaseSearch(object):
        """
-       Defines a generic search interface. Accessing self.results will
-       attempt to retrieve cached results and, if that fails, will
-       initiate a new search and store the results in the cache.
+       Defines a generic search api. Accessing :attr:`results` will attempt to retrieve cached results and, if that fails, will initiate a new search and store the results in the cache. Each search has a ``verbose_name`` and a ``slug``. If these are not provided as attributes, they will be automatically generated based on the name of the class.
+       
+       :param search_arg: The string which is being searched for.
+       
        """
        __metaclass__ = BaseSearchMetaclass
+       #: The number of results to return from the complete list. Default: 10
        result_limit = 10
+       #: How long the items for the search should be cached (in minutes). Default: 48 hours.
        _cache_timeout = 60*48
        
        def __init__(self, search_arg):
@@ -166,6 +214,7 @@ class BaseSearch(object):
        
        @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:
@@ -189,37 +238,36 @@ class BaseSearch(object):
        
        def get_results(self, limit=None, result_class=Result):
                """
-               Calls self.search() and parses the return value into Result objects.
+               Calls :meth:`search` and parses the return value into :class:`Result` instances.
+               
+               :param limit: Passed directly to :meth:`search`.
+               :param result_class: The class used to represent the results. This will be instantiated with the :class:`BaseSearch` instance and the raw result from the search.
+               
                """
                results = self.search(limit)
                return [result_class(self, result) for result in results]
        
        def search(self, limit=None):
-               """
-               Returns an iterable of up to <limit> results. The
-               get_result_title, get_result_url, get_result_template, and
-               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. The only restriction is that the objects be
-               pickleable so that they can be used with django's cache system.
-               """
+               """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):
-               "Subclasses override this to provide the actual URL for the 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)
                if url is None:
                        return None
                return make_tracking_querydict(self.search_arg, url)
        
        def get_result_template(self, result):
+               """Returns the template to be used for rendering the ``result``."""
                if hasattr(self, 'result_template'):
                        return loader.get_template(self.result_template)
                if not hasattr(self, '_result_template'):
@@ -227,29 +275,30 @@ class BaseSearch(object):
                return self._result_template
        
        def get_result_extra_context(self, result):
+               """Returns any extra context to be used when rendering the ``result``."""
                return {}
        
        def has_more_results(self):
-               """Useful to determine whether to display a `view more results` link."""
+               """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 will be encoded
-               into a querystring for tracking purposes.
-               """
+               """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
        
        @property
        def 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)
        
        def __unicode__(self):
-               return ' '.join(self.__class__.verbose_name.rsplit(' ', 1)[:-1]) + ' results'
+               return self.verbose_name
 
 
 class DatabaseSearch(BaseSearch):
+       """Implements :meth:`~BaseSearch.search` and :meth:`get_queryset` methods to handle database queries."""
+       #: The model which should be searched by the :class:`DatabaseSearch`.
        model = None
        
        def search(self, limit=None):
@@ -261,28 +310,28 @@ class DatabaseSearch(BaseSearch):
                return self._qs
        
        def get_queryset(self):
+               """Returns a :class:`QuerySet` of all instances of :attr:`model`. This method should be overridden by subclasses to specify how the search should actually be implemented for the model."""
                return self.model._default_manager.all()
 
 
 class URLSearch(BaseSearch):
-       """
-       Defines a generic interface for searches that require accessing a
-       certain url to get search results.
-       """
+       """Defines a generic interface for searches that require accessing a certain url to get search results."""
+       #: The base URL which will be accessed to get the search results.
        search_url = ''
+       #: The url-encoded query string to be used for fetching search results from :attr:`search_url`. Must have one ``%s`` to contain the search argument.
        query_format_str = "%s"
 
        @property
        def url(self):
-               "The URL where the search gets its results."
+               """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):
-               "The URL where the users would go to get more results."
                return self.url
        
        def parse_response(self, response, limit=None):
+               """Handles the ``response`` from accessing :attr:`url` (with :func:`urllib2.urlopen`) and returns a list of up to ``limit`` results."""
                raise NotImplementedError
        
        def search(self, limit=None):
@@ -290,17 +339,14 @@ class URLSearch(BaseSearch):
 
 
 class JSONSearch(URLSearch):
-       """
-       Makes a GET request and parses the results as JSON. The default
-       behavior assumes that the return value is a list of results.
-       """
+       """Makes a GET request and parses the results as JSON. The default behavior assumes that the response contains a list of results."""
        def parse_response(self, response, limit=None):
                return json.loads(response.read())[:limit]
 
 
 class GoogleSearch(JSONSearch):
+       """An example implementation of a :class:`JSONSearch`."""
        search_url = "http://ajax.googleapis.com/ajax/services/search/web"
-       # TODO: Change this template to reflect the app's actual name.
        result_template = 'search/googlesearch.html'
        _cache_timeout = 60
        verbose_name = "Google search (current site)"
@@ -314,6 +360,7 @@ class GoogleSearch(JSONSearch):
        
        @property
        def default_args(self):
+               """Unquoted default arguments for the :class:`GoogleSearch`."""
                return "site:%s" % Site.objects.get_current().domain
        
        def parse_response(self, response, limit=None):
@@ -350,9 +397,6 @@ class GoogleSearch(JSONSearch):
        
        def get_result_url(self, result):
                return result['unescapedUrl']
-       
-       def get_result_extra_context(self, result):
-               return result
 
 
 registry.register(GoogleSearch)
@@ -365,13 +409,22 @@ except:
 else:
        __all__ += ('ScrapeSearch', 'XMLSearch',)
        class ScrapeSearch(URLSearch):
-               _strainer_args = []
-               _strainer_kwargs = {}
+               """A base class for scrape-style searching, available if :mod:`BeautifulSoup` is installed."""
+               #: Arguments to be passed into a :class:`SoupStrainer`.
+               strainer_args = []
+               #: Keyword arguments to be passed into a :class:`SoupStrainer`.
+               strainer_kwargs = {}
                
                @property
                def strainer(self):
+                       """
+                       Caches and returns a :class:`SoupStrainer` initialized with :attr:`strainer_args` and :attr:`strainer_kwargs`. This strainer will be used to parse only certain parts of the document.
+                       
+                       .. seealso:: `BeautifulSoup: Improving Performance by Parsing Only Part of the Document <http://www.crummy.com/software/BeautifulSoup/documentation.html#Improving%20Performance%20by%20Parsing%20Only%20Part%20of%20the%20Document>`_
+                       
+                       """
                        if not hasattr(self, '_strainer'):
-                               self._strainer = SoupStrainer(*self._strainer_args, **self._strainer_kwargs)
+                               self._strainer = SoupStrainer(*self.strainer_args, **self.strainer_kwargs)
                        return self._strainer
                
                def parse_response(self, response, limit=None):
@@ -381,18 +434,21 @@ else:
                
                def parse_results(self, results):
                        """
-                       Provides a hook for parsing the results of straining. This
-                       has no default behavior because the results absolutely
-                       must be parsed to properly extract the information.
-                       For more information, see http://www.crummy.com/software/BeautifulSoup/documentation.html#Improving%20Memory%20Usage%20with%20extract
+                       Provides a hook for parsing the results of straining. This has no default behavior and must be implemented by subclasses because the results absolutely must be parsed to properly extract the information.
+                       
+                       .. seealso:: `BeautifulSoup: Improving Memory Usage with extract <http://www.crummy.com/software/BeautifulSoup/documentation.html#Improving%20Memory%20Usage%20with%20extract>`_
                        """
                        raise NotImplementedError
        
        
        class XMLSearch(ScrapeSearch):
-               _self_closing_tags = []
+               """A base class for searching XML results."""
+               #: Self-closing tag names to be used when interpreting the XML document
+               #:
+               #: .. seealso:: `BeautifulSoup: Parsing XML <http://www.crummy.com/software/BeautifulSoup/documentation.html#Parsing%20XML>`_
+               self_closing_tags = []
                
                def parse_response(self, response, limit=None):
                        strainer = self.strainer
-                       soup = BeautifulStoneSoup(response, selfClosingTags=self._self_closing_tags, parseOnlyThese=strainer)
+                       soup = BeautifulStoneSoup(response, selfClosingTags=self.self_closing_tags, parseOnlyThese=strainer)
                        return self.parse_results(soup.findAll(recursive=False, limit=limit))
\ No newline at end of file
diff --git a/philo/contrib/sobol/utils.py b/philo/contrib/sobol/utils.py
new file mode 100644 (file)
index 0000000..6fd5a49
--- /dev/null
@@ -0,0 +1,51 @@
+from hashlib import sha1
+
+from django.conf import settings
+from django.http import QueryDict
+from django.utils.encoding import smart_str
+from django.utils.http import urlquote_plus, urlquote
+
+
+SEARCH_ARG_GET_KEY = 'q'
+URL_REDIRECT_GET_KEY = 'url'
+HASH_REDIRECT_GET_KEY = 's'
+
+
+def make_redirect_hash(search_arg, url):
+       """Hashes a redirect for a ``search_arg`` and ``url`` to avoid providing a simple URL spoofing service."""
+       return sha1(smart_str(search_arg + url + settings.SECRET_KEY)).hexdigest()[::2]
+
+
+def check_redirect_hash(hash, search_arg, url):
+       """Checks whether a hash is valid for a given ``search_arg`` and ``url``."""
+       return hash == make_redirect_hash(search_arg, url)
+
+
+def make_tracking_querydict(search_arg, url):
+       """Returns a :class:`QueryDict` instance containing the information necessary for tracking :class:`.Click`\ s on the ``url``."""
+       return QueryDict("%s=%s&%s=%s&%s=%s" % (
+               SEARCH_ARG_GET_KEY, urlquote_plus(search_arg),
+               URL_REDIRECT_GET_KEY, urlquote(url),
+               HASH_REDIRECT_GET_KEY, make_redirect_hash(search_arg, url))
+       )
+
+
+class RegistryIterator(object):
+       def __init__(self, registry, iterattr='__iter__', transform=lambda x:x):
+               if not hasattr(registry, iterattr):
+                       raise AttributeError("Registry has no attribute %s" % iterattr)
+               self.registry = registry
+               self.iterattr = iterattr
+               self.transform = transform
+       
+       def __iter__(self):
+               return self
+       
+       def next(self):
+               if not hasattr(self, '_iter'):
+                       self._iter = getattr(self.registry, self.iterattr)()
+               
+               return self.transform(self._iter.next())
+       
+       def copy(self):
+               return self.__class__(self.registry, self.iterattr, self.transform)
\ No newline at end of file
similarity index 78%
rename from contrib/waldo/forms.py
rename to philo/contrib/waldo/forms.py
index 2ee64d0..eb53598 100644 (file)
@@ -1,4 +1,5 @@
 from datetime import date
+
 from django import forms
 from django.conf import settings
 from django.contrib.auth import authenticate
@@ -6,14 +7,23 @@ from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
 from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext_lazy as _
+
 from philo.contrib.waldo.tokens import REGISTRATION_TIMEOUT_DAYS
 
 
 class EmailInput(forms.TextInput):
+       """Displays an HTML5 email input on browsers which support it and a normal text input on other browsers."""
        input_type = 'email'
 
 
 class RegistrationForm(UserCreationForm):
+       """
+       Handles user registration. If :mod:`recaptcha_django` is installed on the system and :class:`recaptcha_django.middleware.ReCaptchaMiddleware` is in :setting:`settings.MIDDLEWARE_CLASSES`, then a recaptcha field will automatically be added to the registration form.
+       
+       .. seealso:: `recaptcha-django <http://code.google.com/p/recaptcha-django/>`_
+       
+       """
+       #: An :class:`EmailField` using the :class:`EmailInput` widget.
        email = forms.EmailField(widget=EmailInput)
        try:
                from recaptcha_django import ReCaptchaField
@@ -55,6 +65,7 @@ class RegistrationForm(UserCreationForm):
 
 
 class UserAccountForm(forms.ModelForm):
+       """Handles a user's account - by default, :attr:`auth.User.first_name`, :attr:`auth.User.last_name`, :attr:`auth.User.email`."""
        first_name = User._meta.get_field('first_name').formfield(required=True)
        last_name = User._meta.get_field('last_name').formfield(required=True)
        email = User._meta.get_field('email').formfield(required=True, widget=EmailInput)
@@ -69,6 +80,7 @@ class UserAccountForm(forms.ModelForm):
 
 
 class WaldoAuthenticationForm(AuthenticationForm):
+       """Handles user authentication. Checks that the user has not mistakenly entered their email address (like :class:`django.contrib.admin.forms.AdminAuthenticationForm`) but does not require that the user be staff."""
        ERROR_MESSAGE = _("Please enter a correct username and password. Note that both fields are case-sensitive.")
        
        def clean(self):
@@ -92,11 +104,4 @@ class WaldoAuthenticationForm(AuthenticationForm):
                        elif not self.user_cache.is_active:
                                raise ValidationError(message)
                self.check_for_test_cookie()
-               return self.cleaned_data
-       
-       def check_for_test_cookie(self):
-               # This method duplicates the Django 1.3 AuthenticationForm method.
-               if self.request and not self.request.session.test_cookie_worked():
-                       raise forms.ValidationError(
-                               _("Your Web browser doesn't appear to have cookies enabled. "
-                                 "Cookies are required for logging in."))
\ No newline at end of file
+               return self.cleaned_data
\ No newline at end of file
similarity index 70%
rename from contrib/waldo/models.py
rename to philo/contrib/waldo/models.py
index f63cdb1..411cf8e 100644 (file)
@@ -1,3 +1,15 @@
+"""
+Waldo provides abstract :class:`.MultiView`\ s to handle several levels of common authentication:
+
+* :class:`LoginMultiView` handles the case where users only need to be able to log in and out.
+* :class:`PasswordMultiView` handles the case where users will also need to change their password.
+* :class:`RegistrationMultiView` builds on top of :class:`PasswordMultiView` to handle user registration, as well.
+* :class:`AccountMultiView` adds account-handling functionality to the :class:`RegistrationMultiView`.
+
+"""
+
+import urlparse
+
 from django import forms
 from django.conf.urls.defaults import url, patterns, include
 from django.contrib import messages
@@ -15,17 +27,17 @@ from django.utils.http import int_to_base36, base36_to_int
 from django.utils.translation import ugettext as _
 from django.views.decorators.cache import never_cache
 from django.views.decorators.csrf import csrf_protect
+
 from philo.models import MultiView, Page
 from philo.contrib.waldo.forms import WaldoAuthenticationForm, RegistrationForm, UserAccountForm
 from philo.contrib.waldo.tokens import registration_token_generator, email_token_generator
-import urlparse
 
 
 class LoginMultiView(MultiView):
-       """
-       Handles exclusively methods and views related to logging users in and out.
-       """
+       """Handles exclusively methods and views related to logging users in and out."""
+       #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the login form.
        login_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_login_related')
+       #: A django form class which will be used for the authentication process. Default: :class:`.WaldoAuthenticationForm`.
        login_form = WaldoAuthenticationForm
        
        @property
@@ -36,7 +48,7 @@ class LoginMultiView(MultiView):
                )
        
        def set_requirement_redirect(self, request, redirect=None):
-               "Figure out where someone should end up after landing on a `requirement` page like the login page."
+               """Figures out and stores where a user should end up after landing on a page (like the login page) because they have not fulfilled some kind of requirement."""
                if redirect is not None:
                        pass
                elif 'requirement_redirect' in request.session:
@@ -61,6 +73,7 @@ class LoginMultiView(MultiView):
                request.session['requirement_redirect'] = redirect
        
        def get_requirement_redirect(self, request, default=None):
+               """Returns the location which a user should be redirected to after fulfilling a requirement (like logging in)."""
                redirect = request.session.pop('requirement_redirect', None)
                # Security checks a la django.contrib.auth.views.login
                if not redirect or ' ' in redirect:
@@ -75,9 +88,7 @@ class LoginMultiView(MultiView):
        
        @never_cache
        def login(self, request, extra_context=None):
-               """
-               Displays the login form for the given HttpRequest.
-               """
+               """Renders the :attr:`login_page` with an instance of the :attr:`login_form` for the given :class:`HttpRequest`."""
                self.set_requirement_redirect(request)
                
                # Redirect already-authenticated users to the index page.
@@ -109,9 +120,11 @@ class LoginMultiView(MultiView):
        
        @never_cache
        def logout(self, request, extra_context=None):
+               """Logs the given :class:`HttpRequest` out, redirecting the user to the page they just left or to the :meth:`~.Node.get_absolute_url` for the ``request.node``."""
                return auth_views.logout(request, request.META.get('HTTP_REFERER', request.node.get_absolute_url()))
        
        def login_required(self, view):
+               """Wraps a view function to require that the user be logged in."""
                def inner(request, *args, **kwargs):
                        if not request.user.is_authenticated():
                                self.set_requirement_redirect(request, redirect=request.path)
@@ -127,14 +140,24 @@ class LoginMultiView(MultiView):
 
 
 class PasswordMultiView(LoginMultiView):
-       "Adds on views for password-related functions."
+       """
+       Adds support for password setting, resetting, and changing to the :class:`LoginMultiView`. Password reset support includes handling of a confirmation email.
+       
+       """
+       #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password reset request form.
        password_reset_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_related', blank=True, null=True)
+       #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password reset confirmation email.
        password_reset_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_confirmation_email_related', blank=True, null=True)
+       #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password setting form (i.e. the page that users will see after confirming a password reset).
        password_set_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_set_related', blank=True, null=True)
+       #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the password change form.
        password_change_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_change_related', blank=True, null=True)
        
+       #: The password change form class. Default: :class:`django.contrib.auth.forms.PasswordChangeForm`.
        password_change_form = PasswordChangeForm
+       #: The password set form class. Default: :class:`django.contrib.auth.forms.SetPasswordForm`.
        password_set_form = SetPasswordForm
+       #: The password reset request form class. Default: :class:`django.contrib.auth.forms.PasswordResetForm`.
        password_reset_form = PasswordResetForm
        
        @property
@@ -154,6 +177,18 @@ class PasswordMultiView(LoginMultiView):
                return urlpatterns
        
        def make_confirmation_link(self, confirmation_view, token_generator, user, node, token_args=None, reverse_kwargs=None, secure=False):
+               """
+               Generates a confirmation link for an arbitrary action, such as a password reset.
+               
+               :param confirmation_view: The view function which needs to be linked to.
+               :param token_generator: Generates a confirmable token for the action.
+               :param user: The user who is trying to take the action.
+               :param node: The node which is providing the basis for the confirmation URL.
+               :param token_args: A list of additional arguments (i.e. besides the user) to be used for token creation.
+               :param reverse_kwargs: A dictionary of any additional keyword arguments necessary for correctly reversing the view.
+               :param secure: Whether the link should use the https:// or http://.
+               
+               """
                token = token_generator.make_token(user, *(token_args or []))
                kwargs = {
                        'uidb36': int_to_base36(user.id),
@@ -163,6 +198,15 @@ class PasswordMultiView(LoginMultiView):
                return node.construct_url(subpath=self.reverse(confirmation_view, kwargs=kwargs), with_domain=True, secure=secure)
        
        def send_confirmation_email(self, subject, email, page, extra_context):
+               """
+               Sends a confirmation email for an arbitrary action, such as a password reset. If the ``page``'s :class:`.Template` has a mimetype of ``text/html``, then the email will be sent with an HTML alternative version.
+               
+               :param subject: The subject line of the email.
+               :param email: The recipient's address.
+               :param page: The page which will be used to render the email body.
+               :param extra_context: The context for rendering the ``page``.
+               
+               """
                text_content = page.render_to_string(extra_context=extra_context)
                from_email = 'noreply@%s' % Site.objects.get_current().domain
                
@@ -174,6 +218,21 @@ class PasswordMultiView(LoginMultiView):
                        send_mail(subject, text_content, from_email, [email])
        
        def password_reset(self, request, extra_context=None, token_generator=password_token_generator):
+               """
+               Handles the process by which users request a password reset, and generates the context for the confirmation email. That context will contain:
+               
+               link
+                       The confirmation link for the password reset.
+               user
+                       The user requesting the reset.
+               site
+                       The current :class:`Site`.
+               request
+                       The current :class:`HttpRequest` instance.
+               
+               :param token_generator: The token generator to use for the confirmation link.
+               
+               """
                if request.user.is_authenticated():
                        return HttpResponseRedirect(request.node.get_absolute_url())
                
@@ -186,10 +245,7 @@ class PasswordMultiView(LoginMultiView):
                                                'link': self.make_confirmation_link('password_reset_confirm', token_generator, user, request.node, secure=request.is_secure()),
                                                'user': user,
                                                'site': current_site,
-                                               'request': request,
-                                               
-                                               # Deprecated... leave in for backwards-compatibility
-                                               'username': user.username
+                                               'request': request
                                        }
                                        self.send_confirmation_email('Confirm password reset for account at %s' % current_site.domain, user.email, self.password_reset_confirmation_email, context)
                                        messages.add_message(request, messages.SUCCESS, "An email has been sent to the address you provided with details on resetting your password.", fail_silently=True)
@@ -206,8 +262,10 @@ class PasswordMultiView(LoginMultiView):
        
        def password_reset_confirm(self, request, extra_context=None, uidb36=None, token=None, token_generator=password_token_generator):
                """
-               Checks that a given hash in a password reset link is valid. If so,
-               displays the password set form.
+               Checks that ``token``` is valid, and if so, renders an instance of :attr:`password_set_form` with :attr:`password_set_page`.
+               
+               :param token_generator: The token generator used to check the ``token``.
+               
                """
                assert uidb36 is not None and token is not None
                try:
@@ -238,6 +296,7 @@ class PasswordMultiView(LoginMultiView):
                raise Http404
        
        def password_change(self, request, extra_context=None):
+               """Renders an instance of :attr:`password_change_form` with :attr:`password_change_page`."""
                if request.method == 'POST':
                        form = self.password_change_form(request.user, request.POST)
                        if form.is_valid():
@@ -259,9 +318,12 @@ class PasswordMultiView(LoginMultiView):
 
 
 class RegistrationMultiView(PasswordMultiView):
-       """Adds on the pages necessary for letting new users register."""
+       """Adds support for user registration to the :class:`PasswordMultiView`."""
+       #: A :class:`ForeignKey` to the :class:`.Page` which will be used to display the registration form.
        register_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_related', blank=True, null=True)
+       #: A :class:`ForeignKey` to the :class:`.Page` which will be used to render the registration confirmation email.
        register_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_confirmation_email_related', blank=True, null=True)
+       #: The registration form class. Default: :class:`.RegistrationForm`.
        registration_form = RegistrationForm
        
        @property
@@ -275,6 +337,12 @@ class RegistrationMultiView(PasswordMultiView):
                return urlpatterns
        
        def register(self, request, extra_context=None, token_generator=registration_token_generator):
+               """
+               Renders the :attr:`register_page` with an instance of :attr:`registration_form` in the context as ``form``. If the form has been submitted, sends a confirmation email using :attr:`register_confirmation_email` and the same context as :meth:`PasswordMultiView.password_reset`.
+               
+               :param token_generator: The token generator to use for the confirmation link.
+               
+               """
                if request.user.is_authenticated():
                        return HttpResponseRedirect(request.node.get_absolute_url())
                
@@ -304,9 +372,9 @@ class RegistrationMultiView(PasswordMultiView):
        
        def register_confirm(self, request, extra_context=None, uidb36=None, token=None, token_generator=registration_token_generator):
                """
-               Checks that a given hash in a registration link is valid and activates
-               the given account. If so, log them in and redirect to
-               self.post_register_confirm_redirect.
+               Checks that ``token`` is valid, and if so, logs the user in and redirects them to :meth:`post_register_confirm_redirect`.
+               
+               :param token_generator: The token generator used to check the ``token``.
                """
                assert uidb36 is not None and token is not None
                try:
@@ -333,6 +401,7 @@ class RegistrationMultiView(PasswordMultiView):
                raise Http404
        
        def post_register_confirm_redirect(self, request):
+               """Returns an :class:`HttpResponseRedirect` for post-registration-confirmation. Default: :meth:`Node.get_absolute_url` for ``request.node``."""
                return HttpResponseRedirect(request.node.get_absolute_url())
        
        class Meta:
@@ -340,14 +409,13 @@ class RegistrationMultiView(PasswordMultiView):
 
 
 class AccountMultiView(RegistrationMultiView):
-       """
-       By default, the `account` consists of the first_name, last_name, and email fields
-       of the User model. Using a different account model is as simple as writing a form that
-       accepts a User instance as the first argument.
-       """
+       """Adds support for user accounts on top of the :class:`RegistrationMultiView`. By default, the account consists of the first_name, last_name, and email fields of the User model. Using a different account model is as simple as replacing :attr:`account_form` with any form class that takes an :class:`auth.User` instance as the first argument."""
+       #: A :class:`ForeignKey` to the :class:`Page` which will be used to render the account management form.
        manage_account_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_manage_account_related', blank=True, null=True)
+       #: A :class:`ForeignKey` to a :class:`Page` which will be used to render an email change confirmation email. This is optional; if it is left blank, then email changes will be performed without confirmation.
        email_change_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_email_change_confirmation_email_related', blank=True, null=True, help_text="If this is left blank, email changes will be performed without confirmation.")
        
+       #: A django form class which will be used to manage the user's account. Default: :class:`.UserAccountForm`
        account_form = UserAccountForm
        
        @property
@@ -364,6 +432,12 @@ class AccountMultiView(RegistrationMultiView):
                return urlpatterns
        
        def account_view(self, request, extra_context=None, token_generator=email_token_generator, *args, **kwargs):
+               """
+               Renders the :attr:`manage_account_page` with an instance of :attr:`account_form` in the context as ``form``. If the form has been posted, the user's email was changed, and :attr:`email_change_confirmation_email` is not ``None``, sends a confirmation email to the new email to make sure it exists before making the change. The email will have the same context as :meth:`PasswordMultiView.password_reset`.
+               
+               :param token_generator: The token generator to use for the confirmation link. 
+               
+               """
                if request.method == 'POST':
                        form = self.account_form(request.user, request.POST, request.FILES)
                        
@@ -413,11 +487,13 @@ class AccountMultiView(RegistrationMultiView):
                return self.manage_account_page.render_to_response(request, extra_context=context)
        
        def has_valid_account(self, user):
+               """Returns ``True`` if the ``user`` has a valid account and ``False`` otherwise."""
                form = self.account_form(user, {})
                form.data = form.initial
                return form.is_valid()
        
        def account_required(self, view):
+               """Wraps a view function to allow access only to users with valid accounts and otherwise redirect them to the :meth:`account_view`."""
                def inner(request, *args, **kwargs):
                        if not self.has_valid_account(request.user):
                                messages.add_message(request, messages.ERROR, "You need to add some account information before you can access that page.", fail_silently=True)
@@ -433,6 +509,7 @@ class AccountMultiView(RegistrationMultiView):
                return inner
        
        def post_register_confirm_redirect(self, request):
+               """Automatically redirects users to the :meth:`account_view` after registration."""
                if self.manage_account_page:
                        messages.add_message(request, messages.INFO, 'Welcome! Please fill in some more information.', fail_silently=True)
                        return HttpResponseRedirect(self.reverse('account', node=request.node))
@@ -440,7 +517,10 @@ class AccountMultiView(RegistrationMultiView):
        
        def email_change_confirm(self, request, extra_context=None, uidb36=None, token=None, email=None, token_generator=email_token_generator):
                """
-               Checks that a given hash in an email change link is valid. If so, changes the email and redirects to the account page.
+               Checks that ``token`` is valid, and if so, changes the user's email.
+               
+               :param token_generator: The token generator used to check the ``token``.
+               
                """
                assert uidb36 is not None and token is not None and email is not None
                
similarity index 77%
rename from contrib/waldo/tokens.py
rename to philo/contrib/waldo/tokens.py
index 80f0b11..1a7c3a9 100644 (file)
@@ -1,13 +1,20 @@
 """
-Based on django.contrib.auth.tokens
-"""
+Based on :mod:`django.contrib.auth.tokens`. Supports the following settings:
+
+:setting:`WALDO_REGISTRATION_TIMEOUT_DAYS`
+       The number of days a registration link will be valid before expiring. Default: 1.
 
+:setting:`WALDO_EMAIL_TIMEOUT_DAYS`
+       The number of days an email change link will be valid before expiring. Default: 1.
 
+"""
+
+from hashlib import sha1
 from datetime import date
+
 from django.conf import settings
 from django.utils.http import int_to_base36, base36_to_int
 from django.contrib.auth.tokens import PasswordResetTokenGenerator
-from hashlib import sha1
 
 
 REGISTRATION_TIMEOUT_DAYS = getattr(settings, 'WALDO_REGISTRATION_TIMEOUT_DAYS', 1)
@@ -15,13 +22,10 @@ EMAIL_TIMEOUT_DAYS = getattr(settings, 'WALDO_EMAIL_TIMEOUT_DAYS', 1)
 
 
 class RegistrationTokenGenerator(PasswordResetTokenGenerator):
-       """
-       Strategy object used to generate and check tokens for the user registration mechanism.
-       """
+       """Strategy object used to generate and check tokens for the user registration mechanism."""
+       
        def check_token(self, user, token):
-               """
-               Check that a registration token is correct for a given user.
-               """
+               """Check that a registration token is correct for a given user."""
                # If the user is active, the hash can't be valid.
                if user.is_active:
                        return False
@@ -61,13 +65,10 @@ registration_token_generator = RegistrationTokenGenerator()
 
 
 class EmailTokenGenerator(PasswordResetTokenGenerator):
-       """
-       Strategy object used to generate and check tokens for a user email change mechanism.
-       """
+       """Strategy object used to generate and check tokens for a user email change mechanism."""
+       
        def make_token(self, user, email):
-               """
-               Returns a token that can be used once to do an email change for the given user and email.
-               """
+               """Returns a token that can be used once to do an email change for the given user and email."""
                return self._make_token_with_timestamp(user, email, self._num_days(self._today()))
        
        def check_token(self, user, email, token):
diff --git a/philo/exceptions.py b/philo/exceptions.py
new file mode 100644 (file)
index 0000000..9f908c0
--- /dev/null
@@ -0,0 +1,20 @@
+from django.core.exceptions import ImproperlyConfigured
+
+
+#: Raised if ``request.node`` is required but not present. For example, this can be raised by :func:`philo.views.node_view`. :data:`MIDDLEWARE_NOT_CONFIGURED` is an instance of :exc:`django.core.exceptions.ImproperlyConfigured`.
+MIDDLEWARE_NOT_CONFIGURED = ImproperlyConfigured("""Philo requires the RequestNode middleware to be installed. Edit your MIDDLEWARE_CLASSES setting to insert 'philo.middleware.RequestNodeMiddleware'.""")
+
+
+class ViewDoesNotProvideSubpaths(Exception):
+       """Raised by :meth:`.View.reverse` when the :class:`.View` does not provide subpaths (the default)."""
+       silent_variable_failure = True
+
+
+class ViewCanNotProvideSubpath(Exception):
+       """Raised by :meth:`.View.reverse` when the :class:`.View` can not provide a subpath for the supplied arguments."""
+       silent_variable_failure = True
+
+
+class AncestorDoesNotExist(Exception):
+       """Raised by :meth:`.TreeEntity.get_path` if the root instance is not an ancestor of the current instance."""
+       pass
\ No newline at end of file
similarity index 100%
rename from forms/__init__.py
rename to philo/forms/__init__.py
similarity index 94%
rename from forms/entities.py
rename to philo/forms/entities.py
index e781128..ba72d7d 100644 (file)
@@ -1,5 +1,6 @@
 from django.forms.models import ModelFormMetaclass, ModelForm, ModelFormOptions
 from django.utils.datastructures import SortedDict
+
 from philo.utils import fattr
 
 
@@ -93,6 +94,10 @@ class EntityFormMetaclass(ModelFormMetaclass):
 
 
 class EntityForm(ModelForm):
+       """
+       :class:`EntityForm` knows how to handle :class:`.Entity` instances - specifically, how to set initial values for :class:`.AttributeProxyField`\ s and save cleaned values to an instance on save.
+       
+       """
        __metaclass__ = EntityFormMetaclass
        
        def __init__(self, *args, **kwargs):
similarity index 74%
rename from forms/fields.py
rename to philo/forms/fields.py
index b148947..66b96ad 100644 (file)
@@ -1,6 +1,7 @@
 from django import forms
 from django.core.exceptions import ValidationError
 from django.utils import simplejson as json
+
 from philo.validators import json_validator
 
 
@@ -8,6 +9,7 @@ __all__ = ('JSONFormField',)
 
 
 class JSONFormField(forms.Field):
+       """A form field which is validated by :func:`philo.validators.json_validator`."""
        default_validators = [json_validator]
        
        def clean(self, value):
similarity index 57%
rename from loaders/database.py
rename to philo/loaders/database.py
index 141aedd..4c9c379 100644 (file)
@@ -1,10 +1,15 @@
 from django.template import TemplateDoesNotExist
 from django.template.loader import BaseLoader
 from django.utils.encoding import smart_unicode
+
 from philo.models import Template
 
 
 class Loader(BaseLoader):
+       """
+       :class:`philo.loaders.database.Loader` enables loading of template code from :class:`.Template`\ s. This would let :class:`.Template`\ s be used with ``{% include %}`` and ``{% extends %}`` tags, as well as any other features that use template loading.
+       
+       """
        is_usable=True
        
        def load_template_source(self, template_name, template_dirs=None):
similarity index 88%
rename from middleware.py
rename to philo/middleware.py
index 5ec3e77..b90067a 100644 (file)
@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.contrib.sites.models import Site
 from django.http import Http404
+
 from philo.models import Node, View
 
 
@@ -43,7 +44,7 @@ class LazyNode(object):
 
 
 class RequestNodeMiddleware(object):
-       """Middleware to process the request's path and attach the closest ancestor node."""
+       """Adds a ``node`` attribute, representing the currently-viewed node, to every incoming :class:`HttpRequest` object. This is required by :func:`philo.views.node_view`."""
        def process_request(self, request):
                request.__class__.node = LazyNode()
        
similarity index 50%
rename from models/__init__.py
rename to philo/models/__init__.py
index 523f789..3942b84 100644 (file)
@@ -1,12 +1,20 @@
+from django.conf import settings
+from django.contrib.auth.models import User, Group
+from django.contrib.sites.models import Site
+
 from philo.models.base import *
 from philo.models.collections import *
 from philo.models.nodes import *
 from philo.models.pages import *
-from django.contrib.auth.models import User, Group
-from django.contrib.sites.models import Site
 
 
 register_value_model(User)
 register_value_model(Group)
 register_value_model(Site)
-register_templatetags('philo.templatetags.embed')
\ No newline at end of file
+
+if 'philo' in settings.INSTALLED_APPS:
+       from django.template import add_to_builtins
+       add_to_builtins('philo.templatetags.embed')
+       add_to_builtins('philo.templatetags.containers')
+       add_to_builtins('philo.templatetags.collections')
+       add_to_builtins('philo.templatetags.nodes')
\ No newline at end of file
similarity index 66%
rename from models/base.py
rename to philo/models/base.py
index af1e880..02d8456 100644 (file)
@@ -1,25 +1,33 @@
 from django import forms
-from django.db import models
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes import generic
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.validators import RegexValidator
+from django.db import models
 from django.utils import simplejson as json
 from django.utils.encoding import force_unicode
+from mptt.models import MPTTModel, MPTTModelBase, MPTTOptions
+
 from philo.exceptions import AncestorDoesNotExist
 from philo.models.fields import JSONField
-from philo.utils import ContentTypeRegistryLimiter, ContentTypeSubclassLimiter
 from philo.signals import entity_class_prepared
+from philo.utils import ContentTypeRegistryLimiter, ContentTypeSubclassLimiter
+from philo.utils.entities import AttributeMapper, TreeAttributeMapper
 from philo.validators import json_validator
-from UserDict import DictMixin
-from mptt.models import MPTTModel, MPTTModelBase, MPTTOptions
+
+
+__all__ = ('Tag', 'value_content_type_limiter', 'register_value_model', 'unregister_value_model', 'JSONValue', 'ForeignKeyValue', 'ManyToManyValue', 'Attribute', 'Entity', 'TreeEntity')
 
 
 class Tag(models.Model):
+       """A simple, generic model for tagging."""
+       #: A CharField (max length 255) which contains the name of the tag.
        name = models.CharField(max_length=255)
+       #: A CharField (max length 255) which contains the tag's unique slug.
        slug = models.SlugField(max_length=255, unique=True)
        
        def __unicode__(self):
+               """Returns the value of the :attr:`name` field"""
                return self.name
        
        class Meta:
@@ -28,6 +36,7 @@ class Tag(models.Model):
 
 
 class Titled(models.Model):
+       # Use of this model is deprecated.
        title = models.CharField(max_length=255)
        slug = models.SlugField(max_length=255)
        
@@ -38,10 +47,12 @@ class Titled(models.Model):
                abstract = True
 
 
+#: An instance of :class:`.ContentTypeRegistryLimiter` which is used to track the content types which can be related to by :class:`ForeignKeyValue`\ s and :class:`ManyToManyValue`\ s.
 value_content_type_limiter = ContentTypeRegistryLimiter()
 
 
 def register_value_model(model):
+       """Registers a model as a valid content type for a :class:`ForeignKeyValue` or :class:`ManyToManyValue` through the :data:`value_content_type_limiter`."""
        value_content_type_limiter.register_class(model)
 
 
@@ -49,21 +60,37 @@ register_value_model(Tag)
 
 
 def unregister_value_model(model):
+       """Registers a model as a valid content type for a :class:`ForeignKeyValue` or :class:`ManyToManyValue` through the :data:`value_content_type_limiter`."""
        value_content_type_limiter.unregister_class(model)
 
 
 class AttributeValue(models.Model):
+       """
+       This is an abstract base class for models that can be used as values for :class:`Attribute`\ s.
+       
+       AttributeValue subclasses are expected to supply access to a clean version of their value through an attribute called "value".
+       
+       """
+       
+       #: :class:`GenericRelation` to :class:`Attribute`
        attribute_set = generic.GenericRelation('Attribute', content_type_field='value_content_type', object_id_field='value_object_id')
        
        def set_value(self, value):
+               """Given a ``value``, sets the appropriate fields so that it can be correctly stored in the database."""
                raise NotImplementedError
        
        def value_formfields(self, **kwargs):
-               """Define any formfields that would be used to construct an instance of this value."""
+               """
+               Returns any formfields that would be used to construct an instance of this value.
+               
+               :returns: A dictionary mapping field names to formfields.
+               
+               """
+               
                raise NotImplementedError
        
        def construct_instance(self, **kwargs):
-               """Apply cleaned data from the formfields generated by valid_formfields to oneself."""
+               """Applies cleaned data from the formfields generated by valid_formfields to oneself."""
                raise NotImplementedError
        
        def __unicode__(self):
@@ -73,10 +100,12 @@ class AttributeValue(models.Model):
                abstract = True
 
 
+#: An instance of :class:`ContentTypeSubclassLimiter` which is used to track the content types which are considered valid value models for an :class:`Attribute`.
 attribute_value_limiter = ContentTypeSubclassLimiter(AttributeValue)
 
 
 class JSONValue(AttributeValue):
+       """Stores a python object as a json string."""
        value = JSONField(verbose_name='Value (JSON)', help_text='This value must be valid JSON.', default='null', db_index=True)
        
        def __unicode__(self):
@@ -99,6 +128,7 @@ class JSONValue(AttributeValue):
 
 
 class ForeignKeyValue(AttributeValue):
+       """Stores a generic relationship to an instance of any value content type (as defined by the :data:`value_content_type_limiter`)."""
        content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
        object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
        value = generic.GenericForeignKey()
@@ -136,6 +166,7 @@ class ForeignKeyValue(AttributeValue):
 
 
 class ManyToManyValue(AttributeValue):
+       """Stores a generic relationship to many instances of any value content type (as defined by the :data:`value_content_type_limiter`)."""
        content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
        values = models.ManyToManyField(ForeignKeyValue, blank=True, null=True)
        
@@ -215,48 +246,45 @@ class ManyToManyValue(AttributeValue):
 
 
 class Attribute(models.Model):
+       """Represents an arbitrary key/value pair on an arbitrary :class:`Model` where the key consists of word characters and the value is a subclass of :class:`AttributeValue`."""
        entity_content_type = models.ForeignKey(ContentType, related_name='attribute_entity_set', verbose_name='Entity type')
        entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID', db_index=True)
+       
+       #: :class:`GenericForeignKey` to anything (generally an instance of an Entity subclass).
        entity = generic.GenericForeignKey('entity_content_type', 'entity_object_id')
        
        value_content_type = models.ForeignKey(ContentType, related_name='attribute_value_set', limit_choices_to=attribute_value_limiter, verbose_name='Value type', null=True, blank=True)
        value_object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
+       
+       #: :class:`GenericForeignKey` to an instance of a subclass of :class:`AttributeValue` as determined by the :data:`attribute_value_limiter`.
        value = generic.GenericForeignKey('value_content_type', 'value_object_id')
        
+       #: :class:`CharField` containing a key (up to 255 characters) consisting of alphanumeric characters and underscores.
        key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
        
        def __unicode__(self):
                return u'"%s": %s' % (self.key, self.value)
        
+       def set_value(self, value, value_class=JSONValue):
+               """Given a value and a value class, sets up self.value appropriately."""
+               if isinstance(self.value, value_class):
+                       val = self.value
+               else:
+                       if isinstance(self.value, models.Model):
+                               self.value.delete()
+                       val = value_class()
+               
+               val.set_value(value)
+               val.save()
+               
+               self.value = val
+               self.save()
+       
        class Meta:
                app_label = 'philo'
                unique_together = (('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))
 
 
-class QuerySetMapper(object, DictMixin):
-       def __init__(self, queryset, passthrough=None):
-               self.queryset = queryset
-               self.passthrough = passthrough
-       
-       def __getitem__(self, key):
-               try:
-                       value = self.queryset.get(key__exact=key).value
-               except ObjectDoesNotExist:
-                       if self.passthrough is not None:
-                               return self.passthrough.__getitem__(key)
-                       raise KeyError
-               else:
-                       if value is not None:
-                               return value.value
-                       return value
-       
-       def keys(self):
-               keys = set(self.queryset.values_list('key', flat=True).distinct())
-               if self.passthrough is not None:
-                       keys |= set(self.passthrough.keys())
-               return list(keys)
-
-
 class EntityOptions(object):
        def __init__(self, options):
                if options is not None:
@@ -279,13 +307,26 @@ class EntityBase(models.base.ModelBase):
 
 
 class Entity(models.Model):
+       """An abstract class that simplifies access to related attributes. Most models provided by Philo subclass Entity."""
        __metaclass__ = EntityBase
        
        attribute_set = generic.GenericRelation(Attribute, content_type_field='entity_content_type', object_id_field='entity_object_id')
        
-       @property
-       def attributes(self):
-               return QuerySetMapper(self.attribute_set.all())
+       def get_attribute_mapper(self, mapper=AttributeMapper):
+               """
+               Returns an :class:`.AttributeMapper` which can be used to retrieve related :class:`Attribute`\ s' values directly.
+
+               Example::
+
+                       >>> attr = entity.attribute_set.get(key='spam')
+                       >>> attr.value.value
+                       u'eggs'
+                       >>> entity.attributes['spam']
+                       u'eggs'
+               
+               """
+               return mapper(self)
+       attributes = property(get_attribute_mapper)
        
        class Meta:
                abstract = True
@@ -296,19 +337,22 @@ class TreeManager(models.Manager):
        
        def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='slug'):
                """
-               Returns the object with the path, unless absolute_result is set to False, in which
-               case it returns a tuple containing the deepest object found along the path, and the
-               remainder of the path after that object as a string (or None if there is no remaining
-               path). Raises a DoesNotExist exception if no object is found with the given path.
-               
-               If the path you're searching for is known to exist, it is always faster to use
-               absolute_result=True - unless the path depth is over ~40, in which case the high cost
-               of the absolute query makes a binary search (i.e. non-absolute) faster.
+               If ``absolute_result`` is ``True``, returns the object at ``path`` (starting at ``root``) or raises an :class:`~django.core.exceptions.ObjectDoesNotExist` exception. Otherwise, returns a tuple containing the deepest object found along ``path`` (or ``root`` if no deeper object is found) and the remainder of the path after that object as a string (or None if there is no remaining path).
+               
+               .. note:: If you are looking for something with an exact path, it is faster to use absolute_result=True, unless the path depth is over ~40, in which case the high cost of the absolute query may make a binary search (i.e. non-absolute) faster.
+               
+               .. note:: SQLite allows max of 64 tables in one join. That means the binary search will only work on paths with a max depth of 127 and the absolute fetch will only work to a max depth of (surprise!) 63. Larger depths could be handled, but since the common use case will not have a tree structure that deep, they are not.
+               
+               :param path: The path of the object
+               :param root: The object which will be considered the root of the search
+               :param absolute_result: Whether to return an absolute result or do a binary search
+               :param pathsep: The path separator used in ``path``
+               :param field: The field on the model which should be queried for ``path`` segment matching.
+               :returns: An instance if ``absolute_result`` is ``True`` or an (instance, remaining_path) tuple otherwise.
+               :raises django.core.exceptions.ObjectDoesNotExist: if no object can be found matching the input parameters.
+               
                """
-               # Note: SQLite allows max of 64 tables in one join. That means the binary search will
-               # only work on paths with a max depth of 127 and the absolute fetch will only work
-               # to a max depth of (surprise!) 63. Although this could be handled, chances are your
-               # tree structure won't be that deep.
+               
                segments = path.split(pathsep)
                
                # Clean out blank segments. Handles multiple consecutive pathseps.
@@ -407,6 +451,14 @@ class TreeModel(MPTTModel):
        slug = models.SlugField(max_length=255)
        
        def get_path(self, root=None, pathsep='/', field='slug'):
+               """
+               :param root: Only return the path since this object.
+               :param pathsep: The path separator to use when constructing an instance's path
+               :param field: The field to pull path information from for each ancestor.
+               :returns: A string representation of an object's path.
+               
+               """
+               
                if root == self:
                        return ''
                
@@ -438,13 +490,32 @@ class TreeEntityBase(MPTTModelBase, EntityBase):
 
 
 class TreeEntity(Entity, TreeModel):
+       """An abstract subclass of Entity which represents a tree relationship."""
+       
        __metaclass__ = TreeEntityBase
        
-       @property
-       def attributes(self):
-               if self.parent:
-                       return QuerySetMapper(self.attribute_set.all(), passthrough=self.parent.attributes)
-               return super(TreeEntity, self).attributes
+       def get_attribute_mapper(self, mapper=None):
+               """
+               Returns a :class:`.TreeAttributeMapper` or :class:`.AttributeMapper` which can be used to retrieve related :class:`Attribute`\ s' values directly. If an :class:`Attribute` with a given key is not related to the :class:`Entity`, then the mapper will check the parent's attributes.
+
+               Example::
+
+                       >>> attr = entity.attribute_set.get(key='spam')
+                       DoesNotExist: Attribute matching query does not exist.
+                       >>> attr = entity.parent.attribute_set.get(key='spam')
+                       >>> attr.value.value
+                       u'eggs'
+                       >>> entity.attributes['spam']
+                       u'eggs'
+               
+               """
+               if mapper is None:
+                       if self.parent:
+                               mapper = TreeAttributeMapper
+                       else:
+                               mapper = AttributeMapper
+               return super(TreeEntity, self).get_attribute_mapper(mapper)
+       attributes = property(get_attribute_mapper)
        
        class Meta:
                abstract = True
\ No newline at end of file
similarity index 55%
rename from models/collections.py
rename to philo/models/collections.py
index 539ecdb..be7b706 100644 (file)
@@ -1,17 +1,27 @@
-from django.db import models
-from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes import generic
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+
 from philo.models.base import value_content_type_limiter, register_value_model
 from philo.utils import fattr
-from django.template import add_to_builtins as register_templatetags
+
+
+__all__ = ('Collection', 'CollectionMember')
 
 
 class Collection(models.Model):
+       """
+       Collections are curated ordered groupings of arbitrary models.
+       
+       """
+       #: :class:`CharField` with max_length 255
        name = models.CharField(max_length=255)
+       #: Optional :class:`TextField`
        description = models.TextField(blank=True, null=True)
        
        @fattr(short_description='Members')
        def get_count(self):
+               """Returns the number of items in the collection."""
                return self.members.count()
        
        def __unicode__(self):
@@ -25,15 +35,37 @@ class CollectionMemberManager(models.Manager):
        use_for_related_fields = True
 
        def with_model(self, model):
+               """
+               Given a model class or instance, returns a queryset of all instances of that model which have collection members in this manager's scope.
+               
+               Example::
+               
+                       >>> from philo.models import Collection
+                       >>> from django.contrib.auth.models import User
+                       >>> collection = Collection.objects.get(name="Foo")
+                       >>> collection.members.all()
+                       [<CollectionMember: Foo - user1>, <CollectionMember: Foo - user2>, <CollectionMember: Foo - Spam & Eggs>]
+                       >>> collection.members.with_model(User)
+                       [<User: user1>, <User: user2>]
+               
+               """
                return model._default_manager.filter(pk__in=self.filter(member_content_type=ContentType.objects.get_for_model(model)).values_list('member_object_id', flat=True))
 
 
 class CollectionMember(models.Model):
+       """
+       The collection member model represents a generic link from a :class:`Collection` to an arbitrary model instance with an attached order.
+       
+       """
+       #: A :class:`CollectionMemberManager` instance
        objects = CollectionMemberManager()
+       #: :class:`ForeignKey` to a :class:`Collection` instance.
        collection = models.ForeignKey(Collection, related_name='members')
+       #: The numerical index of the item within the collection (optional).
        index = models.PositiveIntegerField(verbose_name='Index', help_text='This will determine the ordering of the item within the collection. (Optional)', null=True, blank=True)
        member_content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Member type')
        member_object_id = models.PositiveIntegerField(verbose_name='Member ID')
+       #: :class:`GenericForeignKey` to an arbitrary model instance.
        member = generic.GenericForeignKey('member_content_type', 'member_object_id')
        
        def __unicode__(self):
@@ -43,5 +75,4 @@ class CollectionMember(models.Model):
                app_label = 'philo'
 
 
-register_templatetags('philo.templatetags.collections')
 register_value_model(Collection)
\ No newline at end of file
similarity index 88%
rename from models/fields/__init__.py
rename to philo/models/fields/__init__.py
index d900e31..efd315f 100644 (file)
@@ -5,12 +5,14 @@ from django.db import models
 from django.utils import simplejson as json
 from django.utils.text import capfirst
 from django.utils.translation import ugettext_lazy as _
+
 from philo.forms.fields import JSONFormField
 from philo.validators import TemplateValidator, json_validator
 #from philo.models.fields.entities import *
 
 
 class TemplateField(models.TextField):
+       """A :class:`TextField` which is validated with a :class:`.TemplateValidator`. ``allow``, ``disallow``, and ``secure`` will be passed into the validator's construction."""
        def __init__(self, allow=None, disallow=None, secure=True, *args, **kwargs):
                super(TemplateField, self).__init__(*args, **kwargs)
                self.validators.append(TemplateValidator(allow, disallow, secure))
@@ -40,6 +42,7 @@ class JSONDescriptor(object):
 
 
 class JSONField(models.TextField):
+       """A :class:`TextField` which stores its value on the model instance as a python object and stores its value in the database as JSON. Validated with :func:`.json_validator`."""
        default_validators = [json_validator]
        
        def get_attname(self):
@@ -68,6 +71,7 @@ class JSONField(models.TextField):
 
 
 class SlugMultipleChoiceField(models.Field):
+       """Stores a selection of multiple items with unique slugs in the form of a comma-separated list."""
        __metaclass__ = models.SubfieldBase
        description = _("Comma-separated slug field")
        
@@ -109,8 +113,7 @@ class SlugMultipleChoiceField(models.Field):
                                del kwargs[k]
                
                defaults.update(kwargs)
-               # Django 1.2 does not supply MultipleChoiceField
-               form_class = getattr(forms, 'TypedMultipleChoiceField', forms.MultipleChoiceField)
+               form_class = forms.TypedMultipleChoiceField
                return form_class(**defaults)
        
        def validate(self, value, model_instance):
similarity index 63%
rename from models/fields/entities.py
rename to philo/models/fields/entities.py
index 6c407d0..0558d3e 100644 (file)
@@ -1,29 +1,14 @@
-"""
-The EntityProxyFields defined in this file can be assigned as fields on
-a subclass of philo.models.Entity. They act like any other model
-fields, but instead of saving their data to the database, they save it
-to attributes related to a model instance. Additionally, a new
-attribute will be created for an instance if and only if the field's
-value has been set. This is relevant i.e. for passthroughs, where the
-value of the field may be defined by some other instance's attributes.
-
-Example::
-
-       class Thing(Entity):
-               numbers = models.PositiveIntegerField()
-       
-       class ThingProxy(Thing):
-               improvised = JSONAttribute(models.BooleanField)
-"""
+import datetime
 from itertools import tee
+
 from django import forms
 from django.core.exceptions import FieldError
 from django.db import models
 from django.db.models.fields import NOT_PROVIDED
 from django.utils.text import capfirst
-from philo.signals import entity_class_prepared
+
 from philo.models import ManyToManyValue, JSONValue, ForeignKeyValue, Attribute, Entity
-import datetime
+from philo.signals import entity_class_prepared
 
 
 __all__ = ('JSONAttribute', 'ForeignKeyAttribute', 'ManyToManyAttribute')
@@ -32,8 +17,23 @@ __all__ = ('JSONAttribute', 'ForeignKeyAttribute', 'ManyToManyAttribute')
 ATTRIBUTE_REGISTRY = '_attribute_registry'
 
 
-class EntityProxyField(object):
-       def __init__(self, verbose_name=None, help_text=None, default=NOT_PROVIDED, editable=True, choices=None, *args, **kwargs):
+class AttributeProxyField(object):
+       """
+       :class:`AttributeProxyField`\ s can be assigned as fields on a subclass of :class:`philo.models.base.Entity`. They act like any other model fields, but instead of saving their data to the model's table, they save it to :class:`.Attribute`\ s related to a model instance. Additionally, a new :class:`.Attribute` will be created for an instance if and only if the field's value has been set. This is relevant i.e. for :class:`.PassthroughAttributeMapper`\ s and :class:`.TreeAttributeMapper`\ s, where even an :class:`.Attribute` with a value of ``None`` will prevent a passthrough.
+       
+       Example::
+       
+               class Thing(Entity):
+                       numbers = models.PositiveIntegerField()
+                       improvised = JSONAttribute(models.BooleanField)
+       
+       :param attribute_key: The key of the attribute that will be used to store this field's value, if it is different than the field's name.
+       
+       The remaining parameters have the same meaning as for ordinary model fields.
+       
+       """
+       def __init__(self, attribute_key=None, verbose_name=None, help_text=None, default=NOT_PROVIDED, editable=True, choices=None, *args, **kwargs):
+               self.attribute_key = attribute_key
                self.verbose_name = verbose_name
                self.help_text = help_text
                self.default = default
@@ -42,8 +42,15 @@ class EntityProxyField(object):
        
        def actually_contribute_to_class(self, sender, **kwargs):
                sender._entity_meta.add_proxy_field(self)
+               setattr(sender, self.name, AttributeFieldDescriptor(self))
+               opts = sender._entity_meta
+               if not hasattr(opts, '_has_attribute_fields'):
+                       opts._has_attribute_fields = True
+                       models.signals.post_save.connect(process_attribute_fields, sender=sender)
        
        def contribute_to_class(self, cls, name):
+               if self.attribute_key is None:
+                       self.attribute_key = name
                if issubclass(cls, Entity):
                        self.name = self.attname = name
                        self.model = cls
@@ -54,6 +61,10 @@ class EntityProxyField(object):
                        raise FieldError('%s instances can only be declared on Entity subclasses.' % self.__class__.__name__)
        
        def formfield(self, form_class=forms.CharField, **kwargs):
+               """
+               Returns a form field capable of accepting values for the :class:`AttributeProxyField`.
+               
+               """
                defaults = {
                        'required': False,
                        'label': capfirst(self.verbose_name),
@@ -65,25 +76,34 @@ class EntityProxyField(object):
                return form_class(**defaults)
        
        def value_from_object(self, obj):
-               """The return value of this method will be used by the EntityForm as
-               this field's initial value."""
+               """Returns the value of this field in the given model instance."""
                return getattr(obj, self.name)
        
        def get_storage_value(self, value):
-               """Final conversion of `value` before it gets stored on an Entity instance.
-               This step is performed by the ProxyFieldForm."""
+               """Final conversion of ``value`` before it gets stored on an :class:`.Entity` instance. This will be called during :meth:`.EntityForm.save`."""
                return value
        
+       def validate_value(self, value):
+               "Raise an appropriate exception if ``value`` is not valid for this :class:`AttributeProxyField`."
+               pass
+       
        def has_default(self):
+               """Returns ``True`` if a default value was provided and ``False`` otherwise."""
                return self.default is not NOT_PROVIDED
        
        def _get_choices(self):
+               """Returns the choices passed into the constructor."""
                if hasattr(self._choices, 'next'):
                        choices, self._choices = tee(self._choices)
                        return choices
                else:
                        return self._choices
        choices = property(_get_choices)
+       
+       @property
+       def value_class(self):
+               """Each :class:`AttributeProxyField` subclass can define a value_class to use for creation of new :class:`.AttributeValue`\ s"""
+               raise AttributeError("value_class must be defined on %s subclasses." % self.__class__.__name__)
 
 
 class AttributeFieldDescriptor(object):
@@ -124,62 +144,32 @@ class AttributeFieldDescriptor(object):
 
 
 def process_attribute_fields(sender, instance, created, **kwargs):
+       """This function is attached to each :class:`Entity` subclass's post_save signal. Any :class:`Attribute`\ s managed by :class:`AttributeProxyField`\ s which have been removed will be deleted, and any new attributes will be created."""
        if ATTRIBUTE_REGISTRY in instance.__dict__:
                registry = instance.__dict__[ATTRIBUTE_REGISTRY]
                instance.attribute_set.filter(key__in=[field.attribute_key for field in registry['removed']]).delete()
                
                for field in registry['added']:
+                       # TODO: Should this perhaps just use instance.attributes[field.attribute_key] = getattr(instance, field.name, None)?
+                       # (Would eliminate the need for field.value_class.)
                        try:
                                attribute = instance.attribute_set.get(key=field.attribute_key)
                        except Attribute.DoesNotExist:
                                attribute = Attribute()
                                attribute.entity = instance
                                attribute.key = field.attribute_key
-                       
-                       value_class = field.value_class
-                       if isinstance(attribute.value, value_class):
-                               value = attribute.value
-                       else:
-                               if isinstance(attribute.value, models.Model):
-                                       attribute.value.delete()
-                               value = value_class()
-                       
-                       value.set_value(getattr(instance, field.name, None))
-                       value.save()
-                       
-                       attribute.value = value
-                       attribute.save()
+                       attribute.set_value(value=getattr(instance, field.name, None), value_class=field.value_class)
                del instance.__dict__[ATTRIBUTE_REGISTRY]
 
 
-class AttributeField(EntityProxyField):
-       def __init__(self, attribute_key=None, **kwargs):
-               self.attribute_key = attribute_key
-               super(AttributeField, self).__init__(**kwargs)
+class JSONAttribute(AttributeProxyField):
+       """
+       Handles an :class:`.Attribute` with a :class:`.JSONValue`.
        
-       def actually_contribute_to_class(self, sender, **kwargs):
-               super(AttributeField, self).actually_contribute_to_class(sender, **kwargs)
-               setattr(sender, self.name, AttributeFieldDescriptor(self))
-               opts = sender._entity_meta
-               if not hasattr(opts, '_has_attribute_fields'):
-                       opts._has_attribute_fields = True
-                       models.signals.post_save.connect(process_attribute_fields, sender=sender)
+       :param field_template: A django form field instance that will be used to guide rendering and interpret values. For example, using :class:`django.forms.BooleanField` will make this field render as a checkbox.
        
-       def contribute_to_class(self, cls, name):
-               if self.attribute_key is None:
-                       self.attribute_key = name
-               super(AttributeField, self).contribute_to_class(cls, name)
-       
-       def validate_value(self, value):
-               "Confirm that the value is valid or raise an appropriate error."
-               pass
+       """
        
-       @property
-       def value_class(self):
-               raise AttributeError("value_class must be defined on AttributeField subclasses.")
-
-
-class JSONAttribute(AttributeField):
        value_class = JSONValue
        
        def __init__(self, field_template=None, **kwargs):
@@ -200,12 +190,14 @@ class JSONAttribute(AttributeField):
                return self.field_template.formfield(**defaults)
        
        def value_from_object(self, obj):
+               """If the field template is a :class:`DateField` or a :class:`DateTimeField`, this will convert the default return value to a datetime instance."""
                value = super(JSONAttribute, self).value_from_object(obj)
                if isinstance(self.field_template, (models.DateField, models.DateTimeField)):
                        value = self.field_template.to_python(value)
                return value
        
        def get_storage_value(self, value):
+               """If ``value`` is a :class:`datetime.datetime` instance, this will convert it to a format which can be stored as correct JSON."""
                if isinstance(value, datetime.datetime):
                        return value.strftime("%Y-%m-%d %H:%M:%S")
                if isinstance(value, datetime.date):
@@ -213,11 +205,18 @@ class JSONAttribute(AttributeField):
                return value
 
 
-class ForeignKeyAttribute(AttributeField):
+class ForeignKeyAttribute(AttributeProxyField):
+       """
+       Handles an :class:`.Attribute` with a :class:`.ForeignKeyValue`.
+       
+       :param limit_choices_to: A :class:`Q` object, dictionary, or :class:`ContentTypeLimiter <philo.utils>` to restrict the queryset for the :class:`ForeignKeyAttribute`.
+       
+       """
        value_class = ForeignKeyValue
        
        def __init__(self, model, limit_choices_to=None, **kwargs):
                super(ForeignKeyAttribute, self).__init__(**kwargs)
+               # Spoof being a rel from a ForeignKey for admin widgets.
                self.to = model
                if limit_choices_to is None:
                        limit_choices_to = {}
@@ -235,15 +234,22 @@ class ForeignKeyAttribute(AttributeField):
                return super(ForeignKeyAttribute, self).formfield(form_class=form_class, **defaults)
        
        def value_from_object(self, obj):
+               """Converts the default value type (a model instance) to a pk."""
                relobj = super(ForeignKeyAttribute, self).value_from_object(obj)
                return getattr(relobj, 'pk', None)
        
        def get_related_field(self):
-               """Spoof being a rel from a ForeignKey."""
+               # Spoof being a rel from a ForeignKey for admin widgets.
                return self.to._meta.pk
 
 
 class ManyToManyAttribute(ForeignKeyAttribute):
+       """
+       Handles an :class:`.Attribute` with a :class:`.ManyToManyValue`.
+       
+       :param limit_choices_to: A :class:`Q` object, dictionary, or :class:`ContentTypeLimiter <philo.utils>` to restrict the queryset for the :class:`ManyToManyAttribute`.
+       
+       """
        value_class = ManyToManyValue
        
        def validate_value(self, value):
@@ -254,6 +260,7 @@ class ManyToManyAttribute(ForeignKeyAttribute):
                return super(ManyToManyAttribute, self).formfield(form_class=form_class, **kwargs)
        
        def value_from_object(self, obj):
+               """Converts the default value type (a queryset) to a list of pks."""
                qs = super(ForeignKeyAttribute, self).value_from_object(obj)
                try:
                        return qs.values_list('pk', flat=True)
similarity index 52%
rename from models/nodes.py
rename to philo/models/nodes.py
index 99be196..87ccb40 100644 (file)
@@ -1,33 +1,42 @@
-from django.db import models
-from django.contrib.contenttypes.models import ContentType
+from inspect import getargspec
+
 from django.contrib.contenttypes import generic
+from django.contrib.contenttypes.models import ContentType
 from django.contrib.sites.models import Site, RequestSite
-from django.http import HttpResponse, HttpResponseServerError, HttpResponseRedirect, Http404
 from django.core.exceptions import ValidationError
 from django.core.servers.basehttp import FileWrapper
 from django.core.urlresolvers import resolve, clear_url_caches, reverse, NoReverseMatch
-from django.template import add_to_builtins as register_templatetags
+from django.db import models
+from django.http import HttpResponse, HttpResponseServerError, HttpResponseRedirect, Http404
 from django.utils.encoding import smart_str
-from inspect import getargspec
-from philo.exceptions import MIDDLEWARE_NOT_CONFIGURED
-from philo.models.base import TreeEntity, Entity, QuerySetMapper, register_value_model
+
+from philo.exceptions import MIDDLEWARE_NOT_CONFIGURED, ViewCanNotProvideSubpath, ViewDoesNotProvideSubpaths
+from philo.models.base import TreeEntity, Entity, register_value_model
 from philo.models.fields import JSONField
 from philo.utils import ContentTypeSubclassLimiter
-from philo.validators import RedirectValidator
-from philo.exceptions import ViewCanNotProvideSubpath, ViewDoesNotProvideSubpaths, AncestorDoesNotExist
+from philo.utils.entities import LazyPassthroughAttributeMapper
 from philo.signals import view_about_to_render, view_finished_rendering
 
 
+__all__ = ('Node', 'View', 'MultiView', 'Redirect', 'File')
+
+
 _view_content_type_limiter = ContentTypeSubclassLimiter(None)
 
 
 class Node(TreeEntity):
+       """
+       :class:`Node`\ s are the basic building blocks of a website using Philo. They define the URL hierarchy and connect each URL to a :class:`View` subclass instance which is used to generate an HttpResponse.
+       
+       """
        view_content_type = models.ForeignKey(ContentType, related_name='node_view_set', limit_choices_to=_view_content_type_limiter)
        view_object_id = models.PositiveIntegerField()
+       #: :class:`GenericForeignKey` to a non-abstract subclass of :class:`View`
        view = generic.GenericForeignKey('view_content_type', 'view_object_id')
        
        @property
        def accepts_subpath(self):
+               """A property shortcut for :attr:`self.view.accepts_subpath <View.accepts_subpath>`"""
                if self.view:
                        return self.view.accepts_subpath
                return False
@@ -36,21 +45,36 @@ class Node(TreeEntity):
                return self.view.handles_subpath(subpath)
        
        def render_to_response(self, request, extra_context=None):
+               """This is a shortcut method for :meth:`View.render_to_response`"""
                return self.view.render_to_response(request, extra_context)
        
        def get_absolute_url(self, request=None, with_domain=False, secure=False):
+               """
+               This is essentially a shortcut for calling :meth:`construct_url` without a subpath.
+               
+               :returns: The absolute url of the node on the current site.
+               
+               """
                return self.construct_url(request=request, with_domain=with_domain, secure=secure)
        
        def construct_url(self, subpath="/", request=None, with_domain=False, secure=False):
                """
-               This method will construct a URL based on the Node's location.
-               If a request is passed in, that will be used as a backup in case
-               the Site lookup fails. The Site lookup takes precedence because
-               it's what's used to find the root node. This will raise:
-               - NoReverseMatch if philo-root is not reverseable
-               - Site.DoesNotExist if a domain is requested but not buildable.
-               - AncestorDoesNotExist if the root node of the site isn't an
-                 ancestor of this instance.
+               This method will do its best to construct a URL based on the Node's location. If with_domain is True, that URL will include a domain and a protocol; if secure is True as well, the protocol will be https. The request will be used to construct a domain in cases where a call to :meth:`Site.objects.get_current` fails.
+               
+               Node urls will not contain a trailing slash unless a subpath is provided which ends with a trailing slash. Subpaths are expected to begin with a slash, as if returned by :func:`django.core.urlresolvers.reverse`.
+               
+               :meth:`construct_url` may raise the following exceptions:
+               
+               - :class:`NoReverseMatch` if "philo-root" is not reversable -- for example, if :mod:`philo.urls` is not included anywhere in your urlpatterns.
+               - :class:`Site.DoesNotExist <ObjectDoesNotExist>` if with_domain is True but no :class:`Site` or :class:`RequestSite` can be built.
+               - :class:`~philo.exceptions.AncestorDoesNotExist` if the root node of the site isn't an ancestor of the node constructing the URL.
+               
+               :param string subpath: The subpath to be constructed beyond beyond the node's URL.
+               :param request: :class:`HttpRequest` instance. Will be used to construct a :class:`RequestSite` if :meth:`Site.objects.get_current` fails.
+               :param with_domain: Whether the constructed URL should include a domain name and protocol.
+               :param secure: Whether the protocol, if included, should be http:// or https://.
+               :returns: A constructed url for accessing the given subpath of the current node instance.
+               
                """
                # Try reversing philo-root first, since we can't do anything if that fails.
                root_url = reverse('philo-root')
@@ -89,18 +113,38 @@ models.ForeignKey(Node, related_name='sites', null=True, blank=True).contribute_
 
 
 class View(Entity):
+       """
+       :class:`View` is an abstract model that represents an item which can be "rendered", generally in response to an :class:`HttpRequest`.
+       
+       """
+       #: A generic relation back to nodes.
        nodes = generic.GenericRelation(Node, content_type_field='view_content_type', object_id_field='view_object_id')
        
+       #: Property or attribute which defines whether this :class:`View` can handle subpaths. Default: ``False``
        accepts_subpath = False
        
        def handles_subpath(self, subpath):
+               """Returns True if the :class:`View` handles the given subpath, and False otherwise."""
                if not self.accepts_subpath and subpath != "/":
                        return False
                return True
        
        def reverse(self, view_name=None, args=None, kwargs=None, node=None, obj=None):
-               """Shortcut method to handle the common pattern of getting the
-               absolute url for a view's subpaths."""
+               """
+               If :attr:`accepts_subpath` is True, try to reverse a URL using the given parameters using ``self`` as the urlconf.
+               
+               If ``obj`` is provided, :meth:`get_reverse_params` will be called and the results will be combined with any ``view_name``, ``args``, and ``kwargs`` that may have been passed in.
+               
+               :param view_name: The name of the view to be reversed.
+               :param args: Extra args for reversing the view.
+               :param kwargs: A dictionary of arguments for reversing the view.
+               :param node: The node whose subpath this is.
+               :param obj: An object to be passed to :meth:`get_reverse_params` to generate a view_name, args, and kwargs for reversal.
+               :returns: A subpath beyond the node that reverses the view, or an absolute url that reverses the view if a node was passed in.
+               :except philo.exceptions.ViewDoesNotProvideSubpaths: if :attr:`accepts_subpath` is False
+               :except philo.exceptions.ViewCanNotProvideSubpath: if a reversal is not possible.
+               
+               """
                if not self.accepts_subpath:
                        raise ViewDoesNotProvideSubpaths
                
@@ -123,13 +167,26 @@ class View(Entity):
                return subpath
        
        def get_reverse_params(self, obj):
-               """This method should return a view_name, args, kwargs tuple suitable for reversing a url for the given obj using self as the urlconf."""
+               """
+               This method is not implemented on the base class. It should return a (``view_name``, ``args``, ``kwargs``) tuple suitable for reversing a url for the given ``obj`` using ``self`` as the urlconf. If a reversal will not be possible, this method should raise :class:`~philo.exceptions.ViewCanNotProvideSubpath`.
+               
+               """
                raise NotImplementedError("View subclasses must implement get_reverse_params to support subpaths.")
        
-       def attributes_with_node(self, node):
-               return QuerySetMapper(self.attribute_set, passthrough=node.attributes)
+       def attributes_with_node(self, node, mapper=LazyPassthroughAttributeMapper):
+               """
+               Returns a :class:`LazyPassthroughAttributeMapper` which can be used to directly retrieve the values of :class:`Attribute`\ s related to the :class:`View`, falling back on the :class:`Attribute`\ s of the passed-in :class:`Node` and its ancestors.
+               
+               """
+               return mapper((self, node))
        
        def render_to_response(self, request, extra_context=None):
+               """
+               Renders the :class:`View` as an :class:`HttpResponse`. This will raise :const:`~philo.exceptions.MIDDLEWARE_NOT_CONFIGURED` if the `request` doesn't have an attached :class:`Node`. This can happen if the :class:`~philo.middleware.RequestNodeMiddleware` is not in :setting:`settings.MIDDLEWARE_CLASSES` or if it is not functioning correctly.
+               
+               :meth:`render_to_response` will send the :data:`~philo.signals.view_about_to_render` signal, then call :meth:`actually_render_to_response`, and finally send the :data:`~philo.signals.view_finished_rendering` signal before returning the ``response``.
+
+               """
                if not hasattr(request, 'node'):
                        raise MIDDLEWARE_NOT_CONFIGURED
                
@@ -140,6 +197,7 @@ class View(Entity):
                return response
        
        def actually_render_to_response(self, request, extra_context=None):
+               """Concrete subclasses must override this method to provide the business logic for turning a ``request`` and ``extra_context`` into an :class:`HttpResponse`."""
                raise NotImplementedError('View subclasses must implement actually_render_to_response.')
        
        class Meta:
@@ -150,10 +208,16 @@ _view_content_type_limiter.cls = View
 
 
 class MultiView(View):
+       """
+       :class:`MultiView` is an abstract model which represents a section of related pages - for example, a :class:`~philo.contrib.penfield.BlogView` might have a foreign key to :class:`Page`\ s for an index, an entry detail, an entry archive by day, and so on. :class:`!MultiView` subclasses :class:`View`, and defines the following additional methods and attributes:
+       
+       """
+       #: Same as :attr:`View.accepts_subpath`. Default: ``True``
        accepts_subpath = True
        
        @property
        def urlpatterns(self):
+               """Returns urlpatterns that point to views (generally methods on the class). :class:`MultiView`\ s can be thought of as "managing" these subpaths."""
                raise NotImplementedError("MultiView subclasses must implement urlpatterns.")
        
        def handles_subpath(self, subpath):
@@ -166,6 +230,10 @@ class MultiView(View):
                return True
        
        def actually_render_to_response(self, request, extra_context=None):
+               """
+               Resolves the remaining subpath left after finding this :class:`View`'s node using :attr:`self.urlpatterns <urlpatterns>` and renders the view function (or method) found with the appropriate args and kwargs.
+               
+               """
                clear_url_caches()
                subpath = request.node.subpath
                view, args, kwargs = resolve(subpath, urlconf=self)
@@ -177,17 +245,33 @@ class MultiView(View):
                return view(request, *args, **kwargs)
        
        def get_context(self):
-               """Hook for providing instance-specific context - such as the value of a Field - to all views."""
+               """Hook for providing instance-specific context - such as the value of a Field - to any view methods on the instance."""
                return {}
        
        def basic_view(self, field_name):
                """
-               Given the name of a field on ``self``, accesses the value of
+               Given the name of a field on the class, accesses the value of
                that field and treats it as a ``View`` instance. Creates a
                basic context based on self.get_context() and any extra_context
                that was passed in, then calls the ``View`` instance's
                render_to_response() method. This method is meant to be called
                to return a view function appropriate for urlpatterns.
+               
+               :param field_name: The name of a field on the instance which contains a :class:`View` subclass instance.
+               :returns: A simple view function.
+               
+               Example::
+                       
+                       class Foo(Multiview):
+                               page = models.ForeignKey(Page)
+                               
+                               @property
+                               def urlpatterns(self):
+                                       urlpatterns = patterns('',
+                                               url(r'^$', self.basic_view('page'))
+                                       )
+                                       return urlpatterns
+               
                """
                field = self._meta.get_field(field_name)
                view = getattr(self, field.name, None)
@@ -206,8 +290,12 @@ class MultiView(View):
 
 
 class TargetURLModel(models.Model):
+       """An abstract parent class for models which deal in targeting a url."""
+       #: An optional :class:`ForeignKey` to a :class:`.Node`. If provided, that node will be used as the basis for the redirect.
        target_node = models.ForeignKey(Node, blank=True, null=True, related_name="%(app_label)s_%(class)s_related")
-       url_or_subpath = models.CharField(max_length=200, validators=[RedirectValidator()], blank=True, help_text="Point to this url or, if a node is defined and accepts subpaths, this subpath of the node.")
+       #: A :class:`CharField` which may contain an absolute or relative URL, or the name of a node's subpath.
+       url_or_subpath = models.CharField(max_length=200, blank=True, help_text="Point to this url or, if a node is defined and accepts subpaths, this subpath of the node.")
+       #: A :class:`~philo.models.fields.JSONField` instance. If the value of :attr:`reversing_parameters` is not None, the :attr:`url_or_subpath` will be treated as the name of a view to be reversed. The value of :attr:`reversing_parameters` will be passed into the reversal as args if it is a list or as kwargs if it is a dictionary. Otherwise it will be ignored.
        reversing_parameters = JSONField(blank=True, help_text="If reversing parameters are defined, url_or_subpath will instead be interpreted as the view name to be reversed.")
        
        def clean(self):
@@ -236,6 +324,7 @@ class TargetURLModel(models.Model):
                return self.url_or_subpath, args, kwargs
        
        def get_target_url(self):
+               """Calculates and returns the target url based on the :attr:`target_node`, :attr:`url_or_subpath`, and :attr:`reversing_parameters`."""
                node = self.target_node
                if node is not None and node.accepts_subpath and self.url_or_subpath:
                        if self.reversing_parameters is not None:
@@ -260,13 +349,17 @@ class TargetURLModel(models.Model):
 
 
 class Redirect(TargetURLModel, View):
+       """Represents a 301 or 302 redirect to a different url on an absolute or relative path."""
+       #: A choices tuple of redirect status codes (temporary or permanent).
        STATUS_CODES = (
                (302, 'Temporary'),
                (301, 'Permanent'),
        )
+       #: An :class:`IntegerField` which uses :attr:`STATUS_CODES` as its choices. Determines whether the redirect is considered temporary or permanent.
        status_code = models.IntegerField(choices=STATUS_CODES, default=302, verbose_name='redirect type')
        
        def actually_render_to_response(self, request, extra_context=None):
+               """Returns an :class:`HttpResponseRedirect` to :attr:`self.target_url`."""
                response = HttpResponseRedirect(self.target_url)
                response.status_code = self.status_code
                return response
@@ -276,9 +369,10 @@ class Redirect(TargetURLModel, View):
 
 
 class File(View):
-       """ For storing arbitrary files """
-       
+       """Stores an arbitrary file."""
+       #: Defines the mimetype of the uploaded file. This will not be validated.
        mimetype = models.CharField(max_length=255)
+       #: Contains the uploaded file. Files are uploaded to ``philo/files/%Y/%m/%d``.
        file = models.FileField(upload_to='philo/files/%Y/%m/%d')
        
        def actually_render_to_response(self, request, extra_context=None):
@@ -291,8 +385,8 @@ class File(View):
                app_label = 'philo'
        
        def __unicode__(self):
+               """Returns the path of the uploaded file."""
                return self.file.name
 
 
-register_templatetags('philo.templatetags.nodes')
 register_value_model(Node)
\ No newline at end of file
similarity index 62%
rename from models/pages.py
rename to philo/models/pages.py
index 2221ee4..bdd9b42 100644 (file)
@@ -1,20 +1,29 @@
 # encoding: utf-8
+"""
+:class:`Page`\ s are the most frequently used :class:`.View` subclass. They define a basic HTML page and its associated content. Each :class:`Page` renders itself according to a :class:`Template`. The :class:`Template` may contain :ttag:`container` tags, which define related :class:`Contentlet`\ s and :class:`ContentReference`\ s for any page using that :class:`Template`.
+
+"""
+
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes import generic
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.http import HttpResponse
-from django.template import TemplateDoesNotExist, Context, RequestContext, Template as DjangoTemplate, add_to_builtins as register_templatetags, TextNode, VariableNode
+from django.template import TemplateDoesNotExist, Context, RequestContext, Template as DjangoTemplate, TextNode, VariableNode
 from django.template.loader_tags import BlockNode, ExtendsNode, BlockContext
 from django.utils.datastructures import SortedDict
+
 from philo.models.base import TreeModel, register_value_model
 from philo.models.fields import TemplateField
 from philo.models.nodes import View
+from philo.signals import page_about_to_render_to_string, page_finished_rendering_to_string
 from philo.templatetags.containers import ContainerNode
 from philo.utils import fattr
 from philo.validators import LOADED_TEMPLATE_ATTR
-from philo.signals import page_about_to_render_to_string, page_finished_rendering_to_string
+
+
+__all__ = ('Template', 'Page', 'Contentlet', 'ContentReference')
 
 
 class LazyContainerFinder(object):
@@ -70,18 +79,21 @@ class LazyContainerFinder(object):
 
 
 class Template(TreeModel):
+       """Represents a database-driven django template."""
+       #: The name of the template. Used for organization and debugging.
        name = models.CharField(max_length=255)
+       #: Can be used to let users know what the template is meant to be used for.
        documentation = models.TextField(null=True, blank=True)
+       #: Defines the mimetype of the template. This is not validated. Default: ``text/html``.
        mimetype = models.CharField(max_length=255, default=getattr(settings, 'DEFAULT_CONTENT_TYPE', 'text/html'))
+       #: An insecure :class:`~philo.models.fields.TemplateField` containing the django template code for this template.
        code = TemplateField(secure=False, verbose_name='django template code')
        
        @property
        def containers(self):
                """
-               Returns a tuple where the first item is a list of names of contentlets referenced by containers,
-               and the second item is a list of tuples of names and contenttypes of contentreferences referenced by containers.
-               This will break if there is a recursive extends or includes in the template code.
-               Due to the use of an empty Context, any extends or include tags with dynamic arguments probably won't work.
+               Returns a tuple where the first item is a list of names of contentlets referenced by containers, and the second item is a list of tuples of names and contenttypes of contentreferences referenced by containers. This will break if there is a recursive extends or includes in the template code. Due to the use of an empty Context, any extends or include tags with dynamic arguments probably won't work.
+               
                """
                template = DjangoTemplate(self.code)
                
@@ -130,6 +142,7 @@ class Template(TreeModel):
                return contentlet_specs, contentreference_specs
        
        def __unicode__(self):
+               """Returns the value of the :attr:`name` field."""
                return self.name
        
        class Meta:
@@ -138,18 +151,31 @@ class Template(TreeModel):
 
 class Page(View):
        """
-       Represents a page - something which is rendered according to a template. The page will have a number of related Contentlets depending on the template selected - but these will appear only after the page has been saved with that template.
+       Represents a page - something which is rendered according to a :class:`Template`. The page will have a number of related :class:`Contentlet`\ s and :class:`ContentReference`\ s depending on the template selected - but these will appear only after the page has been saved with that template.
+       
        """
+       #: A :class:`ForeignKey` to the :class:`Template` used to render this :class:`Page`.
        template = models.ForeignKey(Template, related_name='pages')
+       #: The name of this page. Chances are this will be used for organization - i.e. finding the page in a list of pages - rather than for display.
        title = models.CharField(max_length=255)
        
        def get_containers(self):
+               """
+               Returns the results :attr:`~Template.containers` for the related template. This is a tuple containing the specs of all :ttag:`container`\ s in the :class:`Template`'s code. The value will be cached on the instance so that multiple accesses will be less expensive.
+               
+               """
                if not hasattr(self, '_containers'):
                        self._containers = self.template.containers
                return self._containers
        containers = property(get_containers)
        
        def render_to_string(self, request=None, extra_context=None):
+               """
+               In addition to rendering as an :class:`HttpResponse`, a :class:`Page` can also render as a string. This means, for example, that :class:`Page`\ s can be used to render emails or other non-HTML content with the same :ttag:`container`-based functionality as is used for HTML.
+               
+               The :class:`Page` will add itself to the context as ``page`` and its :attr:`~.Entity.attributes` as ``attributes``. If a request is provided, then :class:`request.node <.Node>` will also be added to the context as ``node`` and ``attributes`` will be set to the result of calling :meth:`~.View.attributes_with_node` with that :class:`.Node`.
+               
+               """
                context = {}
                context.update(extra_context or {})
                context.update({'page': self, 'attributes': self.attributes})
@@ -165,12 +191,18 @@ class Page(View):
                return string
        
        def actually_render_to_response(self, request, extra_context=None):
+               """Returns an :class:`HttpResponse` with the content of the :meth:`render_to_string` method and the mimetype set to the :attr:`~Template.mimetype` of the related :class:`Template`."""
                return HttpResponse(self.render_to_string(request, extra_context), mimetype=self.template.mimetype)
        
        def __unicode__(self):
+               """Returns the value of :attr:`title`"""
                return self.title
        
        def clean_fields(self, exclude=None):
+               """
+               This is an override of the default model clean_fields method. Essentially, in addition to validating the fields, this method validates the :class:`Template` instance that is used to render this :class:`Page`. This is useful for catching template errors before they show up as 500 errors on a live site.
+               
+               """
                if exclude is None:
                        exclude = []
                
@@ -196,11 +228,16 @@ class Page(View):
 
 
 class Contentlet(models.Model):
+       """Represents a piece of content on a page. This content is treated as a secure :class:`~philo.models.fields.TemplateField`."""
+       #: The page which this :class:`Contentlet` is related to.
        page = models.ForeignKey(Page, related_name='contentlets')
+       #: This represents the name of the container as defined by a :ttag:`container` tag.
        name = models.CharField(max_length=255, db_index=True)
+       #: A secure :class:`~philo.models.fields.TemplateField` holding the content for this :class:`Contentlet`. Note that actually using this field as a template requires use of the :ttag:`include_string` template tag.
        content = TemplateField()
        
        def __unicode__(self):
+               """Returns the value of the :attr:`name` field."""
                return self.name
        
        class Meta:
@@ -208,21 +245,23 @@ class Contentlet(models.Model):
 
 
 class ContentReference(models.Model):
+       """Represents a model instance related to a page."""
+       #: The page which this :class:`ContentReference` is related to.
        page = models.ForeignKey(Page, related_name='contentreferences')
+       #: This represents the name of the container as defined by a :ttag:`container` tag.
        name = models.CharField(max_length=255, db_index=True)
        content_type = models.ForeignKey(ContentType, verbose_name='Content type')
        content_id = models.PositiveIntegerField(verbose_name='Content ID', blank=True, null=True)
+       #: A :class:`GenericForeignKey` to a model instance. The content type of this instance is defined by the :ttag:`container` tag which defines this :class:`ContentReference`.
        content = generic.GenericForeignKey('content_type', 'content_id')
        
        def __unicode__(self):
+               """Returns the value of the :attr:`name` field."""
                return self.name
        
        class Meta:
                app_label = 'philo'
 
 
-register_templatetags('philo.templatetags.containers')
-
-
 register_value_model(Template)
 register_value_model(Page)
\ No newline at end of file
diff --git a/philo/signals.py b/philo/signals.py
new file mode 100644 (file)
index 0000000..13f6cd1
--- /dev/null
@@ -0,0 +1,60 @@
+from django.dispatch import Signal
+
+
+#: Sent whenever an Entity subclass has been "prepared" -- that is, after the processing necessary to make :mod:`.AttributeProxyField`\ s work has been completed. This will fire after :obj:`django.db.models.signals.class_prepared`.
+#:
+#: Arguments that are sent with this signal:
+#: 
+#: ``sender``
+#:     The model class.
+entity_class_prepared = Signal(providing_args=['class'])
+
+#: Sent when a :class:`~philo.models.nodes.View` instance is about to render. This allows you, for example, to modify the ``extra_context`` dictionary used in rendering.
+#:
+#: Arguments that are sent with this signal:
+#:
+#: ``sender``
+#:     The :class:`~philo.models.nodes.View` instance
+#:
+#: ``request``
+#:     The :class:`HttpRequest` instance which the :class:`~philo.models.nodes.View` is rendering in response to.
+#:
+#: ``extra_context``
+#:     A dictionary which will be passed into :meth:`~philo.models.nodes.View.actually_render_to_response`.
+view_about_to_render = Signal(providing_args=['request', 'extra_context'])
+
+#: Sent when a view instance has finished rendering.
+#:
+#: Arguments that are sent with this signal:
+#:
+#: ``sender``
+#:     The :class:`~philo.models.nodes.View` instance
+#:
+#: ``response``
+#:     The :class:`HttpResponse` instance which :class:`~philo.models.nodes.View` view has rendered to.
+view_finished_rendering = Signal(providing_args=['response'])
+
+#: Sent when a :class:`~philo.models.pages.Page` instance is about to render as a string. If the :class:`~philo.models.pages.Page` is rendering as a response, this signal is sent after :obj:`view_about_to_render` and serves a similar function. However, there are situations where a :class:`~philo.models.pages.Page` may be rendered as a string without being rendered as a response afterwards.
+#:
+#: Arguments that are sent with this signal:
+#:
+#: ``sender``
+#:     The :class:`~philo.models.pages.Page` instance
+#:
+#: ``request``
+#:     The :class:`HttpRequest` instance which the :class:`~philo.models.pages.Page` is rendering in response to (if any).
+#:
+#: ``extra_context``
+#:     A dictionary which will be passed into the :class:`Template` context.
+page_about_to_render_to_string = Signal(providing_args=['request', 'extra_context'])
+
+#: Sent when a :class:`~philo.models.pages.Page` instance has just finished rendering as a string. If the :class:`~philo.models.pages.Page` is rendering as a response, this signal is sent before :obj:`view_finished_rendering` and serves a similar function. However, there are situations where a :class:`~philo.models.pages.Page` may be rendered as a string without being rendered as a response afterwards.
+#:
+#: Arguments that are sent with this signal:
+#:
+#: ``sender``
+#:     The :class:`~philo.models.pages.Page` instance
+#:
+#: ``string``
+#:     The string which the :class:`~philo.models.pages.Page` has rendered to.
+page_finished_rendering_to_string = Signal(providing_args=['string'])
\ No newline at end of file
@@ -1,4 +1,4 @@
-{% load i18n adminmedia %}
+{% load i18n adminmedia grp_tags %}
 
 <!-- group -->
 <div class="group tabular{% if inline_admin_formset.opts.classes %} {{ inline_admin_formset.opts.classes|join:" " }}{% endif %}"
 
 <script type="text/javascript">
 (function($) {
-    $(document).ready(function($) {
-        
-        $("#{{ inline_admin_formset.formset.prefix }}-group").grp_inline({
-            prefix: "{{ inline_admin_formset.formset.prefix }}",
-            onBeforeAdded: function(inline) {},
-            onAfterAdded: function(form) {
-                grappelli.reinitDateTimeFields(form);
-                grappelli.updateSelectFilter(form);
-                form.find("input.vForeignKeyRawIdAdminField").grp_related_fk({lookup_url:"{% url grp_related_lookup %}"});
-                form.find("input.vManyToManyRawIdAdminField").grp_related_m2m({lookup_url:"{% url grp_m2m_lookup %}"});
-                form.find("input[name*='object_id'][name$='id']").grp_related_generic({lookup_url:"{% url grp_related_lookup %}"});
-            },
-        });
-        
-        {% if inline_admin_formset.opts.sortable_field_name %}
-        $("#{{ inline_admin_formset.formset.prefix }}-group > div.table").sortable({
-            handle: "a.drag-handler",
-            items: "div.dynamic-form",
-            axis: "y",
-            appendTo: 'body',
-            forceHelperSize: true,
-            containment: '#{{ inline_admin_formset.formset.prefix }}-group > div.table',
-            tolerance: 'pointer',
-        });
-        $("#{{ opts.module_name }}_form").bind("submit", function(){
-            var sortable_field_name = "{{ inline_admin_formset.opts.sortable_field_name }}";
-            var i = 0;
-            $("#{{ inline_admin_formset.formset.prefix }}-group").find("div.dynamic-form").each(function(){
-                var fields = $(this).find("div.td :input[value]");
-                if (fields.serialize()) {
-                    $(this).find("input[name$='"+sortable_field_name+"']").val(i);
-                    i++;
-                }
-            });
-        });
-        {% endif %}
-        
-    });
+       $(document).ready(function($) {
+               
+               var prefix = "{{ inline_admin_formset.formset.prefix }}";
+               var related_lookup_fields_fk = {% get_related_lookup_fields_fk inline_admin_formset.opts %};
+               var related_lookup_fields_m2m = {% get_related_lookup_fields_m2m inline_admin_formset.opts %};
+               var related_lookup_fields_generic = {% get_related_lookup_fields_generic inline_admin_formset.opts %};
+               $.each(related_lookup_fields_fk, function() {
+                       $("#{{ inline_admin_formset.formset.prefix }}-group > div.table")
+                       .find("input[name^='" + prefix + "'][name$='" + this + "']")
+                       .grp_related_fk({lookup_url:"{% url grp_related_lookup %}"});
+               });
+               $.each(related_lookup_fields_m2m, function() {
+                       $("#{{ inline_admin_formset.formset.prefix }}-group > div.table")
+                       .find("input[name^='" + prefix + "'][name$='" + this + "']")
+                       .grp_related_m2m({lookup_url:"{% url grp_m2m_lookup %}"});
+               });
+               $.each(related_lookup_fields_generic, function() {
+                       var content_type = this[0],
+                               object_id = this[1];
+                       $("#{{ inline_admin_formset.formset.prefix }}-group > div.table")
+                       .find("input[name^='" + prefix + "'][name$='" + this[1] + "']")
+                       .each(function() {
+                               var i = $(this).attr("id").match(/-\d+-/);
+                               if (i) {
+                                       var ct_id = "#id_" + prefix + i[0] + content_type,
+                                               obj_id = "#id_" + prefix + i[0] + object_id;
+                                       $(this).grp_related_generic({content_type:ct_id, object_id:obj_id, lookup_url:"{% url grp_related_lookup %}"});
+                               }
+                       });
+               });
+               
+               $("#{{ inline_admin_formset.formset.prefix }}-group").grp_inline({
+                       prefix: "{{ inline_admin_formset.formset.prefix }}",
+                       onBeforeAdded: function(inline) {},
+                       onAfterAdded: function(form) {
+                               grappelli.reinitDateTimeFields(form);
+                               grappelli.updateSelectFilter(form);
+                               $.each(related_lookup_fields_fk, function() {
+                                       form.find("input[name^='" + prefix + "'][name$='" + this + "']")
+                                       .grp_related_fk({lookup_url:"{% url grp_related_lookup %}"});
+                               });
+                               $.each(related_lookup_fields_m2m, function() {
+                                       form.find("input[name^='" + prefix + "'][name$='" + this + "']")
+                                       .grp_related_m2m({lookup_url:"{% url grp_m2m_lookup %}"});
+                               });
+                               $.each(related_lookup_fields_generic, function() {
+                                       var content_type = this[0],
+                                               object_id = this[1];
+                                       form.find("input[name^='" + prefix + "'][name$='" + this[1] + "']")
+                                       .each(function() {
+                                               var i = $(this).attr("id").match(/-\d+-/);
+                                               if (i) {
+                                                       var ct_id = "#id_" + prefix + i[0] + content_type,
+                                                               obj_id = "#id_" + prefix + i[0] + object_id;
+                                                       $(this).grp_related_generic({content_type:ct_id, object_id:obj_id, lookup_url:"{% url grp_related_lookup %}"});
+                                               }
+                                       });
+                               });
+                       },
+               });
+               
+               {% if inline_admin_formset.opts.sortable_field_name %}
+               $("#{{ inline_admin_formset.formset.prefix }}-group > div.table").sortable({
+                       handle: "a.drag-handler",
+                       items: "div.dynamic-form",
+                       axis: "y",
+                       appendTo: 'body',
+                       forceHelperSize: true,
+                       containment: '#{{ inline_admin_formset.formset.prefix }}-group > div.table',
+                       tolerance: 'pointer',
+               });
+               $("#{{ opts.module_name }}_form").bind("submit", function(){
+                       var sortable_field_name = "{{ inline_admin_formset.opts.sortable_field_name }}";
+                       var i = 0;
+                       $("#{{ inline_admin_formset.formset.prefix }}-group").find("div.dynamic-form").each(function(){
+                               var fields = $(this).find("div.td :input[value]");
+                               if (fields.serialize()) {
+                                       $(this).find("input[name$='"+sortable_field_name+"']").val(i);
+                                       i++;
+                               }
+                       });
+               });
+               {% endif %}
+               
+       });
 })(django.jQuery);
 </script>
                        {% endfor %}{% endspaceless %}
                {% endfor %}
                {% for form in inline_admin_formset.formset.forms %}
-                       <div class="row cells-{{ form.fields.keys|length }}{% if not form.fields.keys|length_is:"2" %} cells{% endif %}{% if form.errors %} errors{% endif %} {% for field in form %}{{ field.field.name }} {% endfor %}{% comment %} {% if forloop.last %} empty-form{% endif %}{% endcomment %}">
+                       <div class="row cells-{{ form.fields|length }} cells{% if form.errors %} errors{% endif %}{% for field in form %} {{ field.field.name }}{% endfor %}">
                                {{ form.non_field_errors }}
-                               <div{% if not form.fields.keys|length_is:"2" %} class="cell"{% endif %}>
-                                       <div class="column span-4"><label class='required' for="{{ form.content.auto_id }}{{ form.content_id.auto_id }}">{{ form.verbose_name|capfirst }}:</label></div>
+                               <div>
                                {% for field in form %}
                                        {% if not field.is_hidden %}
+                                       {% comment %}This will be true for one field: the content/content reference{% endcomment %}
+                                       <div class="column span-4"><label class='required' for="{{ form.content.auto_id }}{{ form.content_id.auto_id }}">{{ form.verbose_name|capfirst }}:</label></div>
                                        <div class="column span-flexible">
                                                {{ field }}
                                                {{ field.errors }}
diff --git a/philo/templates/admin/philo/page/add_form.html b/philo/templates/admin/philo/page/add_form.html
new file mode 100644 (file)
index 0000000..b2a6358
--- /dev/null
@@ -0,0 +1,14 @@
+{% extends "admin/change_form.html" %}
+{% load i18n %}
+
+{% block form_top %}
+       {% if not is_popup %}
+               <p>{% trans "First, choose a template. After saving, you'll be able to provide additional content for containers." %}</p>
+       {% else %}
+               <p>{% trans "Choose a template" %}</p>
+       {% endif %}
+{% endblock %}
+
+{% block after_field_sets %}
+<script type="text/javascript">document.getElementById("id_name").focus();</script>
+{% endblock %}
\ No newline at end of file
similarity index 78%
rename from templatetags/collections.py
rename to philo/templatetags/collections.py
index 38b3f91..414a742 100644 (file)
@@ -1,3 +1,8 @@
+"""
+The collection template tags are automatically included as builtins if :mod:`philo` is an installed app.
+
+"""
+
 from django import template
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
@@ -21,9 +26,15 @@ class MembersofNode(template.Node):
                return ''
 
 
-def do_membersof(parser, token):
+@register.tag
+def membersof(parser, token):
        """
-       {% membersof <collection> with <app_label>.<model_name> as <var> %}
+       Given a collection and a content type, sets the results of :meth:`collection.members.with_model <.CollectionMemberManager.with_model>` as a variable in the context.
+       
+       Usage::
+       
+               {% membersof <collection> with <app_label>.<model_name> as <var> %}
+       
        """
        params=token.split_contents()
        tag = params[0]
@@ -45,7 +56,4 @@ def do_membersof(parser, token):
        if params[4] != 'as':
                raise template.TemplateSyntaxError('"%s" template tag requires the fifth parameter to be "as"' % tag)
        
-       return MembersofNode(collection=params[1], model=ct.model_class(), as_var=params[5])
-
-
-register.tag('membersof', do_membersof)
\ No newline at end of file
+       return MembersofNode(collection=params[1], model=ct.model_class(), as_var=params[5])
\ No newline at end of file
similarity index 86%
rename from templatetags/containers.py
rename to philo/templatetags/containers.py
index c5fd445..e280e60 100644 (file)
@@ -1,8 +1,13 @@
+"""
+The container template tags are automatically included as builtins if :mod:`philo` is an installed app.
+
+"""
+
 from django import template
 from django.conf import settings
-from django.utils.safestring import SafeUnicode, mark_safe
-from django.core.exceptions import ObjectDoesNotExist
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist
+from django.utils.safestring import SafeUnicode, mark_safe
 
 
 register = template.Library()
@@ -59,9 +64,15 @@ class ContainerNode(template.Node):
                return content
 
 
-def do_container(parser, token):
+@register.tag
+def container(parser, token):
        """
-       {% container <name> [[references <type>] as <variable>] %}
+       If a template using this tag is used to render a :class:`.Page`, that :class:`.Page` will have associated content which can be set in the admin interface. If a content type is referenced, then a :class:`.ContentReference` object will be created; otherwise, a :class:`.Contentlet` object will be created.
+       
+       Usage::
+       
+               {% container <name> [[references <app_label>.<model_name>] as <variable>] %}
+       
        """
        params = token.split_contents()
        if len(params) >= 2:
@@ -94,6 +105,3 @@ def do_container(parser, token):
                
        else: # error
                raise template.TemplateSyntaxError('"%s" template tag provided without arguments (at least one required)' % tag)
-
-
-register.tag('container', do_container)
similarity index 91%
rename from templatetags/embed.py
rename to philo/templatetags/embed.py
index eb4cd68..9599240 100644 (file)
@@ -1,7 +1,12 @@
+"""
+The embed template tags are automatically included as builtins if :mod:`philo` is an installed app.
+
+"""
 from django import template
-from django.contrib.contenttypes.models import ContentType
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.template.loader_tags import ExtendsNode, BlockContext, BLOCK_CONTEXT_KEY, TextNode, BlockNode
+
 from philo.utils import LOADED_TEMPLATE_ATTR
 
 
@@ -286,11 +291,23 @@ def parse_content_type(bit, tagname):
        return ct
 
 
-def do_embed(parser, token):
+@register.tag
+def embed(parser, token):
        """
-       The {% embed %} tag can be used in two ways:
-       {% embed <app_label>.<model_name> with <template> %} :: Sets which template will be used to render a particular model.
-       {% embed (<app_label>.<model_name> <object_pk> || <instance>) [<argname>=<value> ...] %} :: Embeds the instance specified by the given parameters in the document with the previously-specified template. Any kwargs provided will be passed into the context of the template.
+       The {% embed %} tag can be used in two ways.
+       
+       First, to set which template will be used to render a particular model. This declaration can be placed in a base template and will propagate into all templates that extend that template.
+       
+       Syntax::
+       
+               {% embed <app_label>.<model_name> with <template> %}
+       
+       Second, to embed a specific model instance in the document with a template specified earlier in the template or in a parent template using the first syntax. The instance can be specified as a content type and pk or as a context variable. Any kwargs provided will be passed into the context of the template.
+       
+       Syntax::
+       
+               {% embed (<app_label>.<model_name> <object_pk> || <instance>) [<argname>=<value> ...] %}
+       
        """
        bits = token.split_contents()
        tag = bits.pop(0)
@@ -330,7 +347,4 @@ def do_embed(parser, token):
        except ValueError:
                return EmbedNode(ct, object_pk=parser.compile_filter(pk), kwargs=kwargs)
        else:
-               return ConstantEmbedNode(ct, object_pk=pk, kwargs=kwargs)
-
-
-register.tag('embed', do_embed)
\ No newline at end of file
+               return ConstantEmbedNode(ct, object_pk=pk, kwargs=kwargs)
\ No newline at end of file
similarity index 67%
rename from templatetags/include_string.py
rename to philo/templatetags/include_string.py
index 260dcff..cb0a8b5 100644 (file)
@@ -6,8 +6,6 @@ register = template.Library()
 
 
 class IncludeStringNode(template.Node):
-       """The passed variable is expected to be a string of template code to be rendered with
-       the current context."""
        def __init__(self, string):
                self.string = string
        
@@ -23,16 +21,18 @@ class IncludeStringNode(template.Node):
                        return settings.TEMPLATE_STRING_IF_INVALID
 
 
-def do_include_string(parser, token):
+@register.tag
+def include_string(parser, token):
        """
-       Include a flat string by interpreting it as a template.
-       {% include_string <template_code> %}
+       Include a flat string by interpreting it as a template. The compiled template will be rendered with the current context.
+       
+       Usage::
+       
+               {% include_string <template_code> %}
+       
        """
        bits = token.split_contents()
        if len(bits) != 2:
                raise TemplateSyntaxError("%r tag takes one argument: the template string to be included" % bits[0])
        string = parser.compile_filter(bits[1])
-       return IncludeStringNode(string)
-
-
-register.tag('include_string', do_include_string)
\ No newline at end of file
+       return IncludeStringNode(string)
\ No newline at end of file
similarity index 80%
rename from templatetags/nodes.py
rename to philo/templatetags/nodes.py
index 5ae507d..189fdd5 100644 (file)
@@ -1,9 +1,15 @@
+"""
+The node template tags are automatically included as builtins if :mod:`philo` is an installed app.
+
+"""
+
 from django import template
 from django.conf import settings
 from django.contrib.sites.models import Site
 from django.core.urlresolvers import reverse, NoReverseMatch
 from django.template.defaulttags import kwarg_re
 from django.utils.encoding import smart_str
+
 from philo.exceptions import ViewCanNotProvideSubpath
 
 
@@ -64,13 +70,18 @@ class NodeURLNode(template.Node):
                        return url
 
 
-@register.tag(name='node_url')
-def do_node_url(parser, token):
+@register.tag
+def node_url(parser, token):
        """
-       {% node_url [for <node>] [as <var>] %}
-       {% node_url with <obj> [for <node>] [as <var>] %}
-       {% node_url <view_name> [<arg1> [<arg2> ...] ] [for <node>] [as <var>] %}
-       {% node_url <view_name> [<key1>=<value1> [<key2>=<value2> ...] ] [for <node>] [as <var>]%}
+       The :ttag:`node_url` tag allows access to :meth:`.View.reverse` from a template for a :class:`.Node`. By default, the :class:`.Node` that is used for the call is pulled from the context variable ``node``; however, this can be overridden with the ``[for <node>]`` option.
+       
+       Usage::
+       
+               {% node_url [for <node>] [as <var>] %}
+               {% node_url with <obj> [for <node>] [as <var>] %}
+               {% node_url <view_name> [<arg1> [<arg2> ...] ] [for <node>] [as <var>] %}
+               {% node_url <view_name> [<key1>=<value1> [<key2>=<value2> ...] ] [for <node>] [as <var>] %}
+       
        """
        params = token.split_contents()
        tag = params[0]
similarity index 94%
rename from tests.py
rename to philo/tests.py
index 96ac7b6..a0e0184 100644 (file)
--- a/tests.py
@@ -1,13 +1,17 @@
-from django.test import TestCase
+import sys
+import traceback
+
 from django import template
 from django.conf import settings
 from django.db import connection
 from django.template import loader
 from django.template.loaders import cached
+from django.test import TestCase
+from django.test.utils import setup_test_template_loader
+
+from philo.contrib.penfield.models import Blog, BlogView, BlogEntry
 from philo.exceptions import AncestorDoesNotExist
 from philo.models import Node, Page, Template
-from philo.contrib.penfield.models import Blog, BlogView, BlogEntry
-import sys, traceback
 
 
 class TemplateTestCase(TestCase):
@@ -17,19 +21,15 @@ class TemplateTestCase(TestCase):
                "Tests to make sure that embed behaves with complex includes and extends"
                template_tests = self.get_template_tests()
                
-               # Register our custom template loader. Shamelessly cribbed from django core regressiontests.
-               def test_template_loader(template_name, template_dirs=None):
-                       "A custom template loader that loads the unit-test templates."
-                       try:
-                               return (template_tests[template_name][0] , "test:%s" % template_name)
-                       except KeyError:
-                               raise template.TemplateDoesNotExist, template_name
-               
-               cache_loader = cached.Loader(('test_template_loader',))
-               cache_loader._cached_loaders = (test_template_loader,)
+               # Register our custom template loader. Shamelessly cribbed from django/tests/regressiontests/templates/tests.py:384.
+               cache_loader = setup_test_template_loader(
+                       dict([(name, t[0]) for name, t in template_tests.iteritems()]),
+                       use_cached_loader=True,
+               )
                
-               old_template_loaders = loader.template_source_loaders
-               loader.template_source_loaders = [cache_loader]
+               failures = []
+               tests = template_tests.items()
+               tests.sort()
                
                # Turn TEMPLATE_DEBUG off, because tests assume that.
                old_td, settings.TEMPLATE_DEBUG = settings.TEMPLATE_DEBUG, False
@@ -38,9 +38,6 @@ class TemplateTestCase(TestCase):
                old_invalid = settings.TEMPLATE_STRING_IF_INVALID
                expected_invalid_str = 'INVALID'
                
-               failures = []
-               tests = template_tests.items()
-               tests.sort()
                # Run tests
                for name, vals in tests:
                        xx, context, result = vals
similarity index 99%
rename from urls.py
rename to philo/urls.py
index 0363224..d4dfc7b 100644 (file)
--- a/urls.py
@@ -1,4 +1,5 @@
 from django.conf.urls.defaults import patterns, url
+
 from philo.views import node_view
 
 
similarity index 63%
rename from utils.py
rename to philo/utils/__init__.py
index 57f949e..83436a9 100644 (file)
--- a/utils.py
@@ -5,6 +5,31 @@ from django.template import Context
 from django.template.loader_tags import ExtendsNode, ConstantIncludeNode
 
 
+def fattr(*args, **kwargs):
+       """
+       Returns a wrapper which takes a function as its only argument and sets the key/value pairs passed in with kwargs as attributes on that function. This can be used as a decorator.
+       
+       Example::
+       
+               >>> from philo.utils import fattr
+               >>> @fattr(short_description="Hello World!")
+               ... def x():
+               ...     pass
+               ... 
+               >>> x.short_description
+               'Hello World!'
+       
+       """
+       def wrapper(function):
+               for key in kwargs:
+                       setattr(function, key, kwargs[key])
+               return function
+       return wrapper
+
+
+### ContentTypeLimiters
+
+
 class ContentTypeLimiter(object):
        def q_object(self):
                return models.Q(pk__in=[])
@@ -14,13 +39,16 @@ class ContentTypeLimiter(object):
 
 
 class ContentTypeRegistryLimiter(ContentTypeLimiter):
+       """Can be used to limit the choices for a :class:`ForeignKey` or :class:`ManyToManyField` to the :class:`ContentType`\ s which have been registered with this limiter."""
        def __init__(self):
                self.classes = []
        
        def register_class(self, cls):
+               """Registers a model class with this limiter."""
                self.classes.append(cls)
        
        def unregister_class(self, cls):
+               """Unregisters a model class from this limiter."""
                self.classes.remove(cls)
        
        def q_object(self):
@@ -37,6 +65,13 @@ class ContentTypeRegistryLimiter(ContentTypeLimiter):
 
 
 class ContentTypeSubclassLimiter(ContentTypeLimiter):
+       """
+       Can be used to limit the choices for a :class:`ForeignKey` or :class:`ManyToManyField` to the :class:`ContentType`\ s for all non-abstract models which subclass the class passed in on instantiation.
+       
+       :param cls: The class whose non-abstract subclasses will be valid choices.
+       :param inclusive: Whether ``cls`` should also be considered a valid choice (if it is a non-abstract subclass of :class:`models.Model`)
+       
+       """
        def __init__(self, cls, inclusive=False):
                self.cls = cls
                self.inclusive = inclusive
@@ -59,17 +94,18 @@ class ContentTypeSubclassLimiter(ContentTypeLimiter):
                return models.Q(pk__in=contenttype_pks)
 
 
-def fattr(*args, **kwargs):
-       def wrapper(function):
-               for key in kwargs:
-                       setattr(function, key, kwargs[key])
-               return function
-       return wrapper
+### Pagination
 
 
 def paginate(objects, per_page=None, page_number=1):
        """
-       Given a list of objects, return a (paginator, page, objects) tuple.
+       Given a list of objects, return a (``paginator``, ``page``, ``objects``) tuple.
+       
+       :param objects: The list of objects to be paginated.
+       :param per_page: The number of objects per page.
+       :param page_number: The number of the current page.
+       :returns tuple: (``paginator``, ``page``, ``objects``) where ``paginator`` is a :class:`django.core.paginator.Paginator` instance, ``page`` is the result of calling :meth:`Paginator.page` with ``page_number``, and objects is ``page.objects``. Any of the return values which can't be calculated will be returned as ``None``.
+       
        """
        try:
                per_page = int(per_page)
@@ -107,6 +143,9 @@ def paginate(objects, per_page=None, page_number=1):
        return paginator, page, objects
 
 
+### Facilitating template analysis.
+
+
 LOADED_TEMPLATE_ATTR = '_philo_loaded_template'
 BLANK_CONTEXT = Context()
 
diff --git a/philo/utils/entities.py b/philo/utils/entities.py
new file mode 100644 (file)
index 0000000..05e41fc
--- /dev/null
@@ -0,0 +1,206 @@
+from UserDict import DictMixin
+
+from django.db import models
+from django.contrib.contenttypes.models import ContentType
+
+
+### AttributeMappers
+
+
+class AttributeMapper(object, DictMixin):
+       """
+       Given an :class:`~philo.models.base.Entity` subclass instance, this class allows dictionary-style access to the :class:`~philo.models.base.Entity`'s :class:`~philo.models.base.Attribute`\ s. In order to prevent unnecessary queries, the :class:`AttributeMapper` will cache all :class:`~philo.models.base.Attribute`\ s and the associated python values when it is first accessed.
+       
+       :param entity: The :class:`~philo.models.base.Entity` subclass instance whose :class:`~philo.models.base.Attribute`\ s will be made accessible.
+       
+       """
+       def __init__(self, entity):
+               self.entity = entity
+               self.clear_cache()
+       
+       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()
+               return self._cache[key]
+       
+       def __setitem__(self, key, value):
+               """Given a python value, sets the value of the :class:`~philo.models.base.Attribute` with the given ``key`` to that value."""
+               # Prevent circular import.
+               from philo.models.base import JSONValue, ForeignKeyValue, ManyToManyValue, Attribute
+               old_attr = self.get_attribute(key)
+               if old_attr and old_attr.entity_content_type == ContentType.objects.get_for_model(self.entity) and old_attr.entity_object_id == self.entity.pk:
+                       attribute = old_attr
+               else:
+                       attribute = Attribute(key=key)
+                       attribute.entity = self.entity
+                       attribute.full_clean()
+               
+               if isinstance(value, models.query.QuerySet):
+                       value_class = ManyToManyValue
+               elif isinstance(value, models.Model):
+                       value_class = ForeignKeyValue
+               else:
+                       value_class = JSONValue
+               
+               attribute.set_value(value=value, value_class=value_class)
+               self._cache[key] = attribute.value.value
+               self._attributes_cache[key] = attribute
+       
+       def get_attributes(self):
+               """Returns an iterable of all of the :class:`~philo.models.base.Entity`'s :class:`~philo.models.base.Attribute`\ s."""
+               return self.entity.attribute_set.all()
+       
+       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()
+               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()
+               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()
+               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()
+               return self._cache.values()
+       
+       def _populate_cache(self):
+               if self._cache_populated:
+                       return
+               
+               attributes = self.get_attributes()
+               value_lookups = {}
+               
+               for a in attributes:
+                       value_lookups.setdefault(a.value_content_type, []).append(a.value_object_id)
+                       self._attributes_cache[a.key] = a
+               
+               values_bulk = {}
+               
+               for ct, pks in value_lookups.items():
+                       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
+       
+       def clear_cache(self):
+               """Clears the cache."""
+               self._cache = {}
+               self._attributes_cache = {}
+               self._cache_populated = 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:
+                       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:
+                       self._add_to_cache(key)
+               return self._attributes_cache.get(key, default)
+       
+       def _raw_get_attribute(self, key):
+               return self.get_attributes().get(key=key)
+       
+       def _add_to_cache(self, key):
+               from philo.models.base import Attribute
+               try:
+                       attr = self._raw_get_attribute(key)
+               except Attribute.DoesNotExist:
+                       raise KeyError
+               else:
+                       val = getattr(attr.value, 'value', None)
+                       self._cache[key] = val
+                       self._attributes_cache[key] = attr
+
+
+class LazyAttributeMapper(LazyAttributeMapperMixin, AttributeMapper):
+       def get_attributes(self):
+               return super(LazyAttributeMapper, self).get_attributes().exclude(key__in=self._cache.keys())
+
+
+class TreeAttributeMapper(AttributeMapper):
+       """The :class:`~philo.models.base.TreeEntity` class allows the inheritance of :class:`~philo.models.base.Attribute`\ s down the tree. This mapper will return the most recently declared :class:`~philo.models.base.Attribute` among the :class:`~philo.models.base.TreeEntity`'s ancestors or set an attribute on the :class:`~philo.models.base.Entity` it is attached to."""
+       def get_attributes(self):
+               """Returns a list of :class:`~philo.models.base.Attribute`\ s sorted by increasing parent level. When used to populate the cache, this will cause :class:`~philo.models.base.Attribute`\ s on the root to be overwritten by those on its children, etc."""
+               from philo.models import Attribute
+               ancestors = dict(self.entity.get_ancestors(include_self=True).values_list('pk', 'level'))
+               ct = ContentType.objects.get_for_model(self.entity)
+               attrs = Attribute.objects.filter(entity_content_type=ct, entity_object_id__in=ancestors.keys())
+               return sorted(attrs, key=lambda x: ancestors[x.entity_object_id])
+
+
+class LazyTreeAttributeMapper(LazyAttributeMapperMixin, TreeAttributeMapper):
+       def get_attributes(self):
+               from philo.models import Attribute
+               ancestors = dict(self.entity.get_ancestors(include_self=True).values_list('pk', 'level'))
+               ct = ContentType.objects.get_for_model(self.entity)
+               attrs = Attribute.objects.filter(entity_content_type=ct, entity_object_id__in=ancestors.keys()).exclude(key__in=self._cache.keys())
+               return sorted(attrs, key=lambda x: ancestors[x.entity_object_id])
+       
+       def _raw_get_attribute(self, key):
+               from philo.models import Attribute
+               ancestors = dict(self.entity.get_ancestors(include_self=True).values_list('pk', 'level'))
+               ct = ContentType.objects.get_for_model(self.entity)
+               try:
+                       attrs = Attribute.objects.filter(entity_content_type=ct, entity_object_id__in=ancestors.keys(), key=key)
+                       sorted_attrs = sorted(attrs, key=lambda x: ancestors[x.entity_object_id], reverse=True)
+                       return sorted_attrs[0]
+               except IndexError:
+                       raise Attribute.DoesNotExist
+
+
+class PassthroughAttributeMapper(AttributeMapper):
+       """
+       Given an iterable of :class:`Entities <philo.models.base.Entity>`, this mapper will fetch an :class:`AttributeMapper` for each one. Lookups will return the value from the first :class:`AttributeMapper` which has an entry for a given key. Assignments will be made to the first :class:`.Entity` in the iterable.
+       
+       :param entities: An iterable of :class:`.Entity` subclass instances.
+       
+       """
+       def __init__(self, entities):
+               self._attributes = [e.attributes for e in entities]
+               super(PassthroughAttributeMapper, self).__init__(self._attributes[0].entity)
+       
+       def _populate_cache(self):
+               if self._cache_populated:
+                       return
+               
+               for a in reversed(self._attributes):
+                       a._populate_cache()
+                       self._attributes_cache.update(a._attributes_cache)
+                       self._cache.update(a._cache)
+               
+               self._cache_populated = True
+       
+       def get_attributes(self):
+               raise NotImplementedError
+       
+       def clear_cache(self):
+               super(PassthroughAttributeMapper, self).clear_cache()
+               for a in self._attributes:
+                       a.clear_cache()
+
+
+class LazyPassthroughAttributeMapper(LazyAttributeMapperMixin, PassthroughAttributeMapper):
+       """The :class:`LazyPassthroughAttributeMapper` is lazy in that it tries to avoid accessing the :class:`AttributeMapper`\ s that it uses for lookups. However, those :class:`AttributeMapper`\ s may or may not be lazy themselves."""
+       def _raw_get_attribute(self, key):
+               from philo.models import Attribute
+               for a in self._attributes:
+                       attr = a.get_attribute(key)
+                       if attr is not None:
+                               return attr
+               raise Attribute.DoesNotExist
\ No newline at end of file
similarity index 77%
rename from validators.py
rename to philo/validators.py
index 5ae9409..349dd56 100644 (file)
@@ -1,13 +1,15 @@
-from django.utils.translation import ugettext_lazy as _
-from django.core.validators import RegexValidator
+import re
+
 from django.core.exceptions import ValidationError
 from django.template import Template, Parser, Lexer, TOKEN_BLOCK, TOKEN_VAR, TemplateSyntaxError
 from django.utils import simplejson as json
 from django.utils.html import escape, mark_safe
-import re
+from django.utils.translation import ugettext_lazy as _
+
 from philo.utils import LOADED_TEMPLATE_ATTR
 
 
+#: Tags which are considered insecure and are therefore always disallowed by secure :class:`TemplateValidator` instances.
 INSECURE_TAGS = (
        'load',
        'extends',
@@ -16,34 +18,8 @@ INSECURE_TAGS = (
 )
 
 
-class RedirectValidator(RegexValidator):
-       """Based loosely on the URLValidator, but no option to verify_exists"""
-       regex = re.compile(
-               r'^(?:https?://' # http:// or https://
-               r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' #domain...
-               r'localhost|' #localhost...
-               r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
-               r'(?::\d+)?' # optional port
-               r'(?:/?|[/?#]?\S+)|'
-               r'[^?#\s]\S*)$',
-               re.IGNORECASE)
-       message = _(u'Enter a valid absolute or relative redirect target')
-
-
-class URLLinkValidator(RegexValidator):
-       """Based loosely on the URLValidator, but no option to verify_exists"""
-       regex = re.compile(
-               r'^(?:https?://' # http:// or https://
-               r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' #domain...
-               r'localhost|' #localhost...
-               r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
-               r'(?::\d+)?' # optional port
-               r'|)' # also allow internal links
-               r'(?:/?|[/?#]?\S+)$', re.IGNORECASE)
-       message = _(u'Enter a valid absolute or relative redirect target')
-
-
 def json_validator(value):
+       """Validates whether ``value`` is a valid json string."""
        try:
                json.loads(value)
        except Exception, e:
@@ -118,7 +94,7 @@ class TemplateValidationParser(Parser):
 
 
 def linebreak_iter(template_source):
-       # Cribbed from django/views/debug.py
+       # Cribbed from django/views/debug.py:18
        yield 0
        p = template_source.find('\n')
        while p >= 0:
@@ -128,6 +104,14 @@ def linebreak_iter(template_source):
 
 
 class TemplateValidator(object): 
+       """
+       Validates whether a string represents valid Django template code.
+       
+       :param allow: ``None`` or an iterable of tag names which are explicitly allowed. If provided, tags whose names are not in the iterable will cause a ValidationError to be raised if they are used in the template code.
+       :param disallow: ``None`` or an iterable of tag names which are explicitly allowed. If provided, tags whose names are in the iterable will cause a ValidationError to be raised if they are used in the template code. If a tag's name is in ``allow`` and ``disallow``, it will be disallowed.
+       :param secure: If the validator is set to secure, it will automatically disallow the tag names listed in :const:`INSECURE_TAGS`. Defaults to ``True``.
+       
+       """
        def __init__(self, allow=None, disallow=None, secure=True):
                self.allow = allow
                self.disallow = disallow
similarity index 71%
rename from views.py
rename to philo/views.py
index 598be36..d3054b9 100644 (file)
--- a/views.py
@@ -2,11 +2,23 @@ from django.conf import settings
 from django.core.urlresolvers import resolve
 from django.http import Http404, HttpResponseRedirect
 from django.views.decorators.vary import vary_on_headers
+
 from philo.exceptions import MIDDLEWARE_NOT_CONFIGURED
 
 
 @vary_on_headers('Accept')
 def node_view(request, path=None, **kwargs):
+       """
+       :func:`node_view` handles incoming requests by checking to make sure that:
+       
+       - the request has an attached :class:`.Node`.
+       - the attached :class:`~philo.models.nodes.Node` handles any remaining path beyond its location.
+       
+       If these conditions are not met, then :func:`node_view` will either raise :exc:`Http404` or, if it seems like the address was mistyped (for example missing a trailing slash), return an :class:`HttpResponseRedirect` to the correct address.
+       
+       Otherwise, :func:`node_view` will call the :class:`.Node`'s :meth:`~.Node.render_to_response` method, passing ``kwargs`` in as the ``extra_context``.
+       
+       """
        if "philo.middleware.RequestNodeMiddleware" not in settings.MIDDLEWARE_CLASSES:
                raise MIDDLEWARE_NOT_CONFIGURED
        
diff --git a/setup.py b/setup.py
new file mode 100644 (file)
index 0000000..3c18b16
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python
+
+from distutils.core import setup
+import os
+
+
+# Shamelessly cribbed from django's setup.py file.
+def fullsplit(path, result=None):
+       """
+       Split a pathname into components (the opposite of os.path.join) in a
+       platform-neutral way.
+       """
+       if result is None:
+               result = []
+       head, tail = os.path.split(path)
+       if head == '':
+               return [tail] + result
+       if head == path:
+               return result
+       return fullsplit(head, [tail] + result)
+
+# Compile the list of packages available, because distutils doesn't have
+# an easy way to do this. Shamelessly cribbed from django's setup.py file.
+packages, data_files = [], []
+root_dir = os.path.dirname(__file__)
+if root_dir != '':
+    os.chdir(root_dir)
+philo_dir = 'philo'
+
+for dirpath, dirnames, filenames in os.walk(philo_dir):
+       # Ignore dirnames that start with '.'
+       for i, dirname in enumerate(dirnames):
+               if dirname.startswith('.'): del dirnames[i]
+       if '__init__.py' in filenames:
+               packages.append('.'.join(fullsplit(dirpath)))
+       elif filenames:
+               data_files.append([dirpath, [os.path.join(dirpath, f) for f in filenames]])
+
+
+version = __import__('philo').VERSION
+
+setup(
+       name = 'Philo',
+       version = '%s.%s' % (version[0], version[1]),
+       packages = packages,
+       data_files = data_files,
+)
\ No newline at end of file
diff --git a/signals.py b/signals.py
deleted file mode 100644 (file)
index 3653c54..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.dispatch import Signal
-
-
-entity_class_prepared = Signal(providing_args=['class'])
-view_about_to_render = Signal(providing_args=['request', 'extra_context'])
-view_finished_rendering = Signal(providing_args=['response'])
-page_about_to_render_to_string = Signal(providing_args=['request', 'extra_context'])
-page_finished_rendering_to_string = Signal(providing_args=['string'])
\ No newline at end of file
diff --git a/templates/admin/philo/page/add_form.html b/templates/admin/philo/page/add_form.html
deleted file mode 100644 (file)
index 67f6ec4..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-{% extends "admin/change_form.html" %}
-{% load i18n %}
-
-{% block extrahead %}{{ block.super }}
-<!-- This will break if anything ever changes and may not work in all browsers. Sad face. -->
-<script type='text/javascript'>
-(function($){
-       $(function(){
-               $('#page_form input[type=submit]').click(function(e){
-                       if (e.target.name == '_addanother') {
-                               hidden = document.getElementById('page_form')._continue[0]
-                               hidden.parentNode.removeChild(hidden)
-                       }
-               })
-       })
-}(django.jQuery));
-</script>
-{% endblock %}
-
-{% block form_top %}
-       <p>{% trans "First, choose a template. After saving, you'll be able to provide additional content for containers." %}</p>
-       <input type="hidden" name="_continue" value="1" />
-{% endblock %}
-
-{% block content %}
-{% with 0 as save_on_top %}
-{{ block.super }}
-{% endwith %}
-{% endblock %}
\ No newline at end of file
diff --git a/templatetags/__init__.py b/templatetags/__init__.py
deleted file mode 100644 (file)
index e69de29..0000000