From: Joseph Spiros Date: Fri, 4 Nov 2011 23:05:56 +0000 (-0400) Subject: Merge branch 'develop' into gilbert-ext4 X-Git-Url: http://git.ithinksw.org/philo.git/commitdiff_plain/4a170a70ed8171fc66d9d139df5f7be5208d838c?hp=b1187c750167bfbdb0c50f62923d11ad77d26a34 Merge branch 'develop' into gilbert-ext4 * develop: (137 commits) Added include_package_data option to setup.py to ensure MANIFEST.in is actually heeded. Edited the READMEs and documentation to be cleaner and more consistent. Bumped version number to 0.9.1 in preparation for release. Tweaked manifest file. Removed a bunch of hackery from setup.py in favor of setuptools.find_packages, since we're using setuptools anyway. Addresses bug #177. Abstracted email resetting during the email change process onto the AccountForm. Addresses bug #173. This will include non *.py files in our distribution for setup.py, easy_install, pip, &c. purposes. Removed python requirement line from setup.py, since easy_install and pip can't do anything with it and will choke on it for e.g. pypy. Correction to waldo login view to pass the request into the login_form for GET requests as well. Added release notes for 0.9.1. Merged the contribution information from the ithinksw.org philo wiki into the documentation. Removed philo.models.Tag entry from the docs. Incremented version number to 0.9.1rc and cleaned up README a tiny bit. Tweaked AttributeMapper._fill_cache to also store values on the Attribute instance itself. Made everything about AttributeMapper._fill_cache lazier. Lazy-eval the values of AttributeValues instead of loading them all during AttributeMapper._fill_cache. Removed Node.render_to_response select_related call since it is not clearly more efficient. Delayed page evaluation in FeedView.page_view to the end of the inner function. Added a 'contributing' page to the philo docs. This is meant primarily as an initial effort, which can be expanded upon more later. Corrections to Blog.entry_tags to use taggit APIs. Tweaks to penfield migration 0005 because of South issue 428 (and some incorrect filters obscured by that issue.) Minor correction to EmbedWidget.js to handle window names with dashes. Overrides TemplateField widget on admin container forms instead of on the ModelAdmin. Reverted TemplateField parent to models.TextField and moved EmbedWidget into philo.admin.widgets. Added EmbedWidget use on the appropriate ModelAdmins. Cleaned up ContentletAdmin and ContentReferenceAdmin. ... --- diff --git a/philo/LICENSE b/LICENSE similarity index 94% rename from philo/LICENSE rename to LICENSE index 61eafbd..78171e9 100644 --- a/philo/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2009-2010, iThink Software. +Copyright (c) 2009-2011, iThink Software. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0e076d9 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,12 @@ +include README +include README.markdown +include LICENSE +include MANIFEST.in +recursive-include philo/templates *.html +recursive-include philo/contrib/sobol/templates *.html +recursive-include philo/fixtures *.json +recursive-include philo/static *.css *.js +recursive-include philo/contrib/sobol/static *.css *.js +recursive-include docs *.py *.rst *.bat *.txt Makefile +global-exclude *~ +prune docs/_build \ No newline at end of file diff --git a/README b/README index cb5f47a..abf3a35 100644 --- a/README +++ b/README @@ -1,27 +1,20 @@ -Philo is a foundation for developing web content management systems. +Philo is a foundation for developing web content management systems. Prerequisites: * Python 2.5.4+ * Django 1.3+ * django-mptt e734079+ - * (Optional) django-grappelli 2.0+ - * (Optional) south 0.7.2+ - * (Optional) recaptcha-django r6 + * (optional) django-grappelli 2.0+ + * (optional) south 0.7.2+ + * (philo.contrib.penfield) django-taggit 0.9.3+ + * (philo.contrib.waldo, optional) recaptcha-django r6+ -To contribute, please visit the project website . Feel free to join us on IRC at irc://irc.oftc.net/#philo. +After installing philo and mptt on your PYTHONPATH, make sure to complete the following steps: -==== -Using philo -==== -After installing philo and mptt on your python path, make sure to complete the following steps: - -1. add 'philo.middleware.RequestNodeMiddleware' to settings.MIDDLEWARE_CLASSES. -2. add 'philo' and 'mptt' to settings.INSTALLED_APPS. -3. include 'philo.urls' somewhere in your urls.py file. +1. Add 'philo.middleware.RequestNodeMiddleware' to settings.MIDDLEWARE_CLASSES. +2. Add 'philo' and 'mptt' to settings.INSTALLED_APPS. +3. Include 'philo.urls' somewhere in your urls.py file. 4. Optionally add a root node to your current Site. +5. (philo.contrib.gilbert) Add 'django.core.context_processors.request' to settings.TEMPLATE_CONTEXT_PROCESSORS. -Philo should be ready to go! - -If you are using philo.contrib.gilbert, you will additionally need to complete the following steps: - -1. add 'django.core.context_processors.request' to settings.TEMPLATE_CONTEXT_PROCESSORS +Philo should be ready to go! All that's left is to learn more and contribute . diff --git a/README.markdown b/README.markdown index b529ca2..30856dc 100644 --- a/README.markdown +++ b/README.markdown @@ -1,28 +1,21 @@ -Philo is a foundation for developing web content management systems. +[Philo](http://philocms.org/) is a foundation for developing web content management systems. Prerequisites: - * [Python 2.5.4+ <http://www.python.org>](http://www.python.org/) - * [Django 1.3+ <http://www.djangoproject.com/>](http://www.djangoproject.com/) - * [django-mptt e734079+ <https://github.com/django-mptt/django-mptt/>](https://github.com/django-mptt/django-mptt/) - * (Optional) [django-grappelli 2.0+ <http://code.google.com/p/django-grappelli/>](http://code.google.com/p/django-grappelli/) - * (Optional) [south 0.7.2+ <http://south.aeracode.org/)](http://south.aeracode.org/) - * (Optional) [recaptcha-django r6 <http://code.google.com/p/recaptcha-django/>](http://code.google.com/p/recaptcha-django/) + * [Python 2.5.4+](http://www.python.org/) + * [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) [south 0.7.2+](http://south.aeracode.org/) + * (philo.contrib.penfield) [django-taggit 0.9.3+](https://github.com/alex/django-taggit/) + * (philo.contrib.waldo, optional) [recaptcha-django r6+](http://code.google.com/p/recaptcha-django/) -To contribute, please visit the [project website <http://philo.ithinksw.org/<](http://philo.ithinksw.org/). Feel free to join us on IRC at [irc://irc.oftc.net/#philo](irc://irc.oftc.net/#philo). +After installing philo and mptt on your PYTHONPATH, make sure to complete the following steps: -Using philo -=========== - -After installing philo and mptt on your python path, make sure to complete the following steps: - -1. add 'philo.middleware.RequestNodeMiddleware' to settings.MIDDLEWARE_CLASSES. -2. add 'philo' and 'mptt' to settings.INSTALLED_APPS. -3. include 'philo.urls' somewhere in your urls.py file. +1. Add 'philo.middleware.RequestNodeMiddleware' to settings.MIDDLEWARE_CLASSES. +2. Add 'philo' and 'mptt' to settings.INSTALLED_APPS. +3. Include 'philo.urls' somewhere in your urls.py file. 4. Optionally add a root node to your current Site. +5. (philo.contrib.gilbert) Add 'django.core.context_processors.request' to settings.TEMPLATE_CONTEXT_PROCESSORS. -Philo should be ready to go! - -If you are using philo.contrib.gilbert, you will additionally need to complete the following steps: - -1. add 'django.core.context_processors.request' to settings.TEMPLATE_CONTEXT_PROCESSORS +Philo should be ready to go! All that's left is to [learn more](http://docs.philocms.org/) and [contribute](http://docs.philocms.org/en/latest/contribute.html). diff --git a/docs/_ext/djangodocs.py b/docs/_ext/djangodocs.py index 7710786..0d433de 100644 --- a/docs/_ext/djangodocs.py +++ b/docs/_ext/djangodocs.py @@ -32,16 +32,16 @@ def setup(app): 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 = "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", diff --git a/docs/_ext/philodocs.py b/docs/_ext/philodocs.py new file mode 100644 index 0000000..6c1ecf7 --- /dev/null +++ b/docs/_ext/philodocs.py @@ -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/cla/ithinksw-ccla.txt b/docs/cla/ithinksw-ccla.txt new file mode 100644 index 0000000..0e6b2ae --- /dev/null +++ b/docs/cla/ithinksw-ccla.txt @@ -0,0 +1,140 @@ + iThink Software + Corporate Contributor License Agreement ("Agreement") v1.0 + +Thank you for your interest in iThink Software. In order to clarify +the intellectual property license granted with Contributions from +any person or entity, iThink Software must have a Contributor +License Agreement ("CLA") on file that has been signed by each +Contributor, indicating agreement to the license terms below. This +license is for your protection as a Contributor as well as the +protection of iThink Software and its users; it does not change +your rights to use your own Contributions for any other purpose. + +This version of the Agreement allows an entity (the "Corporation") +to submit Contributions to iThink Software, to authorize Contributions +submitted by its designated employees to iThink Software, and to grant +copyright and patent licenses thereto. + +If you have not already done so, please complete and sign, then scan +and email a pdf file of this Agreement to contact@ithinksw.com. +Alternatively, you may send an original signed Agreement to +iThink Software, 261 West Lorain Street, Oberlin, OH 44074, U.S.A. +Please read this document carefully before signing and +keep a copy for your records. + + Corporation name: ______________________________________________ + + Corporation address: ______________________________________________ + + ______________________________________________ + + ______________________________________________ + + Point of Contact: ______________________________________________ + + E-Mail: ______________________________________________ + + Telephone: ____________________ Fax: ____________________ + + +You accept and agree to the following terms and conditions for Your +present and future Contributions submitted to iThink Software. Except +for the license granted herein to iThink Software and recipients of +software distributed by iThink Software, You reserve all right, +title, and interest in and to Your Contributions. + +1. Definitions. + + "You" (or "Your") shall mean the copyright owner or legal entity + authorized by the copyright owner that is making this Agreement + with iThink Software. For legal entities, the entity making a + Contribution and all other entities that control, are controlled + by, or are under common control with that entity are considered to + be a single Contributor. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "Contribution" shall mean any original work of authorship, + including any modifications or additions to an existing work, that + is intentionally submitted by You to iThink Software for inclusion + in, or documentation of, any of the products owned or managed by + iThink Software (the "Work"). For the purposes of this definition, + "submitted" means any form of electronic, verbal, or written + communication sent to iThink Software or its representatives, + including but not limited to communication on electronic mailing + lists, source code control systems, and issue tracking systems that + are managed by, or on behalf of, iThink Software for the purpose of + discussing and improving the Work, but excluding communication that + is conspicuously marked or otherwise designated in writing by You + as "Not a Contribution." + +2. Grant of Copyright License. Subject to the terms and conditions of + this Agreement, You hereby grant to iThink Software and to + recipients of software distributed by iThink Software a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare derivative works of, + publicly display, publicly perform, sublicense, and distribute Your + Contributions and such derivative works. + +3. Grant of Patent License. Subject to the terms and conditions of + this Agreement, You hereby grant to iThink Software and to + recipients of software distributed by iThink Software a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have + made, use, offer to sell, sell, import, and otherwise transfer the + Work, where such license applies only to those patent claims + licensable by You that are necessarily infringed by Your + Contribution(s) alone or by combination of Your Contribution(s) + with the Work to which such Contribution(s) were submitted. If any + entity institutes patent litigation against You or any other entity + (including a cross-claim or counterclaim in a lawsuit) alleging + that your Contribution, or the Work to which you have contributed, + constitutes direct or contributory patent infringement, then any + patent licenses granted to that entity under this Agreement for + that Contribution or Work shall terminate as of the date such + litigation is filed. + +4. You represent that You are legally entitled to grant the above + license. You represent further that each employee of the + Corporation designated on Schedule A below (or in a subsequent + written modification to that Schedule) is authorized to submit + Contributions on behalf of the Corporation. + +5. You represent that each of Your Contributions is Your original + creation (see section 7 for submissions on behalf of others). + +6. You are not expected to provide support for Your Contributions, + except to the extent You desire to provide support. You may provide + support for free, for a fee, or not at all. Unless required by + applicable law or agreed to in writing, You provide Your + Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS + OF ANY KIND, either express or implied, including, without + limitation, any warranties or conditions of TITLE, NON- + INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +7. Should You wish to submit work that is not Your original creation, + You may submit it to iThink Software separately from any + Contribution, identifying the complete details of its source and of + any license or other restriction (including, but not limited to, + related patents, trademarks, and license agreements) of which you + are personally aware, and conspicuously marking the work as + "Submitted on behalf of a third-party: [named here]". + +8. It is your responsibility to notify iThink Software when any change + is required to the list of designated employees authorized to submit + Contributions on behalf of the Corporation, or to the Corporation's + Point of Contact with iThink Software. + + +Please sign: __________________________________ Date: _______________ + +Title: __________________________________ + +Corporation: __________________________________ + + +Schedule A + + [Initial list of designated employees.] diff --git a/docs/cla/ithinksw-icla.txt b/docs/cla/ithinksw-icla.txt new file mode 100644 index 0000000..929452e --- /dev/null +++ b/docs/cla/ithinksw-icla.txt @@ -0,0 +1,130 @@ + iThink Software + Individual Contributor License Agreement ("Agreement") v1.0.1 + +Thank you for your interest in iThink Software. In order to clarify +the intellectual property license granted with Contributions from +any person or entity, iThink Software must have a Contributor +License Agreement ("CLA") on file that has been signed by each +Contributor, indicating agreement to the license terms below. This +license is for your protection as a Contributor as well as the +protection of iThink Software and its users; it does not change +your rights to use your own Contributions for any other purpose. +If you have not already done so, please complete and sign, then scan +and email a pdf file of this Agreement to contact@ithinksw.com. +Alternatively, you may send an original signed Agreement to +iThink Software, 261 West Lorain Street, Oberlin, OH 44074, U.S.A. +Please read this document carefully before signing and +keep a copy for your records. + + Full name: ______________________________________________________ + + Mailing Address: ________________________________________________ + + _________________________________________________________________ + + Country: ______________________________________________________ + + Telephone: ______________________________________________________ + + Facsimile: ______________________________________________________ + + E-Mail: ______________________________________________________ + +You accept and agree to the following terms and conditions for Your +present and future Contributions submitted to iThink Software. Except +for the license granted herein to iThink Software and recipients of +software distributed by iThink Software, You reserve all right, +title, and interest in and to Your Contributions. + +1. Definitions. + + "You" (or "Your") shall mean the copyright owner or legal entity + authorized by the copyright owner that is making this Agreement + with iThink Software. For legal entities, the entity making a + Contribution and all other entities that control, are controlled + by, or are under common control with that entity are considered to + be a single Contributor. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "Contribution" shall mean any original work of authorship, + including any modifications or additions to an existing work, that + is intentionally submitted by You to iThink Software for inclusion + in, or documentation of, any of the products owned or managed by + iThink Software (the "Work"). For the purposes of this definition, + "submitted" means any form of electronic, verbal, or written + communication sent to iThink Software or its representatives, + including but not limited to communication on electronic mailing + lists, source code control systems, and issue tracking systems that + are managed by, or on behalf of, iThink Software for the purpose of + discussing and improving the Work, but excluding communication that + is conspicuously marked or otherwise designated in writing by You + as "Not a Contribution." + +2. Grant of Copyright License. Subject to the terms and conditions of + this Agreement, You hereby grant to iThink Software and to + recipients of software distributed by iThink Software a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare derivative works of, + publicly display, publicly perform, sublicense, and distribute Your + Contributions and such derivative works. + +3. Grant of Patent License. Subject to the terms and conditions of + this Agreement, You hereby grant to iThink Software and to + recipients of software distributed by iThink Software a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have + made, use, offer to sell, sell, import, and otherwise transfer the + Work, where such license applies only to those patent claims + licensable by You that are necessarily infringed by Your + Contribution(s) alone or by combination of Your Contribution(s) + with the Work to which such Contribution(s) were submitted. If any + entity institutes patent litigation against You or any other entity + (including a cross-claim or counterclaim in a lawsuit) alleging + that your Contribution, or the Work to which you have contributed, + constitutes direct or contributory patent infringement, then any + patent licenses granted to that entity under this Agreement for + that Contribution or Work shall terminate as of the date such + litigation is filed. + +4. You represent that you are legally entitled to grant the above + license. If your employer(s) has rights to intellectual property + that you create that includes your Contributions, you represent + that you have received permission to make Contributions on behalf + of that employer, that your employer has waived such rights for + your Contributions to iThink Software, or that your employer has + executed a separate Corporate CLA with iThink Software. + +5. You represent that each of Your Contributions is Your original + creation (see section 7 for submissions on behalf of others). You + represent that Your Contribution submissions include complete + details of any third-party license or other restriction (including, + but not limited to, related patents and trademarks) of which you + are personally aware and which are associated with any part of Your + Contributions. + +6. You are not expected to provide support for Your Contributions, + except to the extent You desire to provide support. You may provide + support for free, for a fee, or not at all. Unless required by + applicable law or agreed to in writing, You provide Your + Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS + OF ANY KIND, either express or implied, including, without + limitation, any warranties or conditions of TITLE, NON- + INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +7. Should You wish to submit work that is not Your original creation, + You may submit it to iThink Software separately from any + Contribution, identifying the complete details of its source and of + any license or other restriction (including, but not limited to, + related patents, trademarks, and license agreements) of which you + are personally aware, and conspicuously marking the work as + "Submitted on behalf of a third-party: [named here]". + +8. You agree to notify iThink Software of any facts or circumstances of + which you become aware that would make these representations + inaccurate in any respect. + + +Please sign: __________________________________ Date: ________________ diff --git a/docs/conf.py b/docs/conf.py index 043219d..2e703d0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,6 +21,16 @@ 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. @@ -28,7 +38,7 @@ 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'] +extensions = ['djangodocs', 'sphinx.ext.autodoc', 'philodocs'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -44,7 +54,7 @@ master_doc = 'index' # General information about the project. project = u'Philo' -copyright = u'2011, Joseph Spiros' +copyright = u'2009-2011, iThink Software' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -52,7 +62,7 @@ copyright = u'2011, Joseph Spiros' # # The short X.Y version. from philo import VERSION -version = '%s.%s' % (VERSION[0], VERSION[1]) +version = '.'.join([str(v) for v in VERSION]) # The full version, including alpha/beta/rc tags. release = version @@ -91,6 +101,10 @@ pygments_style = 'sphinx' #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 @@ -183,7 +197,7 @@ htmlhelp_basename = 'Philodoc' # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'Philo.tex', u'Philo Documentation', - u'Stephen Burrows', 'manual'), + u'iThink Software', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -216,5 +230,14 @@ latex_documents = [ # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'philo', u'Philo Documentation', - [u'Stephen Burrows'], 1) + [u'iThink Software'], 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 index 0000000..e833317 --- /dev/null +++ b/docs/contrib/intro.rst @@ -0,0 +1,14 @@ +Contrib apps +============ + +.. toctree:: + :maxdepth: 2 + :hidden: + + penfield + shipherd + sobol + waldo + winer + +.. automodule:: philo.contrib diff --git a/docs/contrib/penfield.rst b/docs/contrib/penfield.rst new file mode 100644 index 0000000..87073b9 --- /dev/null +++ b/docs/contrib/penfield.rst @@ -0,0 +1,37 @@ +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: + +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 index 0000000..9e03f67 --- /dev/null +++ b/docs/contrib/shipherd.rst @@ -0,0 +1,46 @@ +Shipherd +======== + +.. automodule:: philo.contrib.shipherd + :members: + + :class:`.Node`\ s are useful for structuring a website; however, they are inherently unsuitable for creating site navigation. + + The most glaring problem is that a navigation tree based on :class:`.Node`\ s would have one :class:`.Node` as the root, whereas navigation usually has multiple objects at the top level. + + Additionally, navigation needs to have display text that is relevant to the current context; however, :class:`.Node`\ s do not have a field for that, and :class:`.View` subclasses with a name or title field will generally need to use it for database-searchable names. + + Finally, :class:`.Node` structures are inherently unordered, while navigation is inherently ordered. + + :mod:`~philo.contrib.shipherd` exists to resolve these issues by separating navigation structures from :class:`.Node` structures. It is instead structured around the way that site navigation works in the wild: + + * A site may have one or more independent navigation bars (Main navigation, side navigation, etc.) + * A navigation bar may be shared by sections of the website, or even by the entire site. + * A navigation bar has a certain depth that it displays to. + + The :class:`.Navigation` model supplies these features by attaching itself to a :class:`.Node` via :class:`ForeignKey` and adding a :attr:`navigation` property to :class:`.Node` which provides access to a :class:`.Node` instance's inherited :class:`.Navigation`\ s. + + Each entry in the navigation bar is then represented by a :class:`.NavigationItem`, which stores information such as the :attr:`~.NavigationItem.order` and :attr:`~.NavigationItem.text` for the entry. Given an :class:`HttpRequest`, a :class:`.NavigationItem` can also tell whether it :meth:`~.NavigationItem.is_active` or :meth:`~.NavigationItem.has_active_descendants`. + + Since the common pattern is to recurse through a navigation tree and render each part similarly, :mod:`~philo.contrib.shipherd` also ships with the :ttag:`~philo.contrib.shipherd.templatetags.shipherd.recursenavigation` template tag. + +Models +++++++ + +.. automodule:: philo.contrib.shipherd.models + :members: Navigation, NavigationItem, NavigationMapper + :show-inheritance: + +.. autoclass:: NavigationManager + :members: + +Template tags ++++++++++++++ + +.. automodule:: philo.contrib.shipherd.templatetags.shipherd + +.. autotemplatetag:: recursenavigation + +.. autotemplatefilter:: has_navigation + +.. autotemplatefilter:: navigation_host diff --git a/docs/contrib/sobol.rst b/docs/contrib/sobol.rst new file mode 100644 index 0000000..353b547 --- /dev/null +++ b/docs/contrib/sobol.rst @@ -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 index 0000000..89045d0 --- /dev/null +++ b/docs/contrib/waldo.rst @@ -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/contrib/winer.rst b/docs/contrib/winer.rst new file mode 100644 index 0000000..4b8a670 --- /dev/null +++ b/docs/contrib/winer.rst @@ -0,0 +1,15 @@ +Winer +===== + +.. automodule:: philo.contrib.winer + +.. automodule:: philo.contrib.winer.models + + .. autoclass:: FeedView + :members: + +.. automodule:: philo.contrib.winer.exceptions + :members: + +.. automodule:: philo.contrib.winer.middleware + :members: \ No newline at end of file diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..4c9fb7d --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,34 @@ +Contributing to Philo +===================== + +So you want to contribute to Philo? That's great! Here's some ways you can get started: + +* **Report bugs and request features** using the issue tracker at the `project site `_. +* **Contribute code** using `git `_. You can fork philo's repository either on `GitHub `_ or `Gitorious `_. If you are contributing to Philo, you will need to submit a :ref:`Contributor License Agreement `. +* **Join the discussion** on IRC at `irc://irc.oftc.net/#philo `_ if you have any questions or suggestions or just want to chat about the project. You can also keep in touch using the project mailing lists: `philo@ithinksw.org `_ and `philo-devel@ithinksw.org `_. + + +Branches and Code Style ++++++++++++++++++++++++ + +We use `A successful Git branching model`__ with the blessed repository. To make things easier, you probably should too. This means that you should work on and against the develop branch in most cases, and leave it to the release manager to create the commits on the master branch if and when necessary. When pulling changes into the blessed repository at your request, the release manager will usually merge them into the develop branch unless you explicitly note they be treated otherwise. + +__ http://nvie.com/posts/a-successful-git-branching-model/ + +Philo adheres to PEP8 for its code style, with two exceptions: tabs are used rather than spaces, and lines are not truncated at 79 characters. + +.. _cla: + +Licensing and Legal ++++++++++++++++++++ + +In order for the release manager to merge your changes into the blessed repository, you will need to have already submitted a signed CLA. Our CLAs are based on the Apache Software Foundation's CLAs, which is the same source as the `Django Project's CLAs`_. You might, therefore, find the `Django Project's CLA FAQ`_. helpful. + +.. _`Django Project's CLAs`: https://www.djangoproject.com/foundation/cla/ +.. _`Django Project's CLA FAQ`: https://www.djangoproject.com/foundation/cla/faq/ + +If you are an individual not doing work for an employer, then you can simply submit the :download:`Individual CLA `. + +If you are doing work for an employer, they will need to submit the :download:`Corporate CLA ` and you will need to submit the Individual CLA :download:`Individual CLA ` as well. + +Both documents include information on how to submit them. diff --git a/docs/dummy-settings.py b/docs/dummy-settings.py index e69de29..7e424ab 100644 --- a/docs/dummy-settings.py +++ b/docs/dummy-settings.py @@ -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 index 0000000..679ac77 --- /dev/null +++ b/docs/exceptions.rst @@ -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 index 0000000..b2dfbb4 --- /dev/null +++ b/docs/forms.rst @@ -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 index 0000000..940d541 --- /dev/null +++ b/docs/handling_requests.rst @@ -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 index cfc7136..05422dd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,36 +3,46 @@ 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: +Philo is a foundation for developing web content management systems. Please, read the :doc:`notes for our latest release `. + +Prerequisites: + +* `Python 2.5.4+ `_ +* `Django 1.3+ `_ +* `django-mptt e734079+ `_ +* (optional) `django-grappelli 2.0+ `_ +* (optional) `south 0.7.2+ `_ +* (:mod:`philo.contrib.penfield`) `django-taggit 0.9.3+ `_ +* (:mod:`philo.contrib.waldo`, optional) `recaptcha-django r6+ `_ + +Contents +++++++++ .. toctree:: - :maxdepth: 2 - - intro - models/intro + :maxdepth: 1 + + what + tutorials/intro + models/intro + exceptions + handling_requests + signals + validators + utilities + templatetags + forms + loaders + contrib/intro + contributing 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+ `_ -* `Django 1.2+ `_ -* `django-mptt e734079+ `_ -* (Optional) `django-grappelli 2.0+ `_ -* (Optional) `south 0.7.2+ `_ -* (Optional) `recaptcha-django r6 `_ - -To contribute, please visit the `project website `_ or make a fork of the `git repository `_. Feel free to join us on IRC at `irc://irc.oftc.net/#philo `_. diff --git a/docs/intro.rst b/docs/intro.rst deleted file mode 100644 index 33d1a98..0000000 --- a/docs/intro.rst +++ /dev/null @@ -1,35 +0,0 @@ -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 ` 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 index 0000000..41c4cd9 --- /dev/null +++ b/docs/loaders.rst @@ -0,0 +1,5 @@ +Database Template Loader +======================== + +.. automodule:: philo.loaders.database + :members: diff --git a/docs/models/collections.rst b/docs/models/collections.rst new file mode 100644 index 0000000..0519494 --- /dev/null +++ b/docs/models/collections.rst @@ -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 index a834b13..b39a253 100644 --- a/docs/models/entities.rst +++ b/docs/models/entities.rst @@ -10,25 +10,27 @@ Attributes ---------- .. autoclass:: Attribute - :members: + :members: .. autoclass:: AttributeValue - :members: + :members: .. automodule:: philo.models.base - :members: attribute_value_limiter + :noindex: + :members: attribute_value_limiter .. autoclass:: JSONValue - :show-inheritance: + :show-inheritance: .. autoclass:: ForeignKeyValue - :show-inheritance: + :show-inheritance: .. autoclass:: ManyToManyValue - :show-inheritance: + :show-inheritance: .. automodule:: philo.models.base - :members: value_content_type_limiter + :noindex: + :members: value_content_type_limiter .. autofunction:: register_value_model(model) .. autofunction:: unregister_value_model(model) @@ -37,18 +39,17 @@ Entities -------- .. autoclass:: Entity - :members: - :exclude-members: attribute_set + :members: -.. autoclass:: TreeManager - :members: +.. autoclass:: TreeEntityManager + :members: .. autoclass:: TreeEntity - :members: - :exclude-members: attribute_set + :show-inheritance: + :members: - .. attribute:: objects + .. attribute:: objects - An instance of :class:`TreeManager`. - - .. automethod:: get_path \ No newline at end of file + An instance of :class:`TreeEntityManager`. + + .. 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 index 0000000..3092fa4 --- /dev/null +++ b/docs/models/fields.rst @@ -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 index 49b2ac1..4f65585 100644 --- a/docs/models/intro.rst +++ b/docs/models/intro.rst @@ -8,6 +8,9 @@ Contents: entities nodes-and-views + collections + miscellaneous + fields -.. :module: philo.models +.. automodule:: philo.models diff --git a/docs/models/miscellaneous.rst b/docs/models/miscellaneous.rst new file mode 100644 index 0000000..005e112 --- /dev/null +++ b/docs/models/miscellaneous.rst @@ -0,0 +1,5 @@ +Miscellaneous Models +============================= +.. autoclass:: philo.models.nodes.TargetURLModel + :members: + :exclude-members: get_target_url \ No newline at end of file diff --git a/docs/models/nodes-and-views.rst b/docs/models/nodes-and-views.rst index bd31ceb..442509d 100644 --- a/docs/models/nodes-and-views.rst +++ b/docs/models/nodes-and-views.rst @@ -1,270 +1,56 @@ Nodes and Views: Building Website structure =========================================== -.. currentmodule:: philo.models +.. automodule:: philo.models.nodes Nodes ----- -: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. - -.. class:: Node - - :class:`!Node` subclasses :class:`TreeEntity`. It defines the following additional methods and attributes: - - .. attribute:: view - - :class:`GenericForeignKey` to a non-abstract subclass of :class:`View` - - .. attribute:: accepts_subpath - - A property shortcut for :attr:`self.view.accepts_subpath ` - - .. method:: render_to_response(request[, extra_context=None]) - - This is a shortcut method for :meth:`View.render_to_response` - - .. method:: get_absolute_url([request=None, with_domain=False, secure=False]) - - This is essentially a shortcut for calling :meth:`construct_url` without a subpath - which will return the URL of the Node. - - .. method:: construct_url([subpath="/", request=None, with_domain=False, secure=False]) - - 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 ` if with_domain is True but no :class:`Site` or :class:`RequestSite` can be built. - - :class:`AncestorDoesNotExist` if the root node of the site isn't an ancestor of the node constructing the URL. +.. autoclass:: Node + :show-inheritance: + :members: Views ----- Abstract View Models ++++++++++++++++++++ -.. class:: View - - :class:`!View` is an abstract model that represents an item which can be "rendered", either in response to an :class:`HttpRequest` or as a standalone. It subclasses :class:`Entity`, and defines the following additional methods and attributes: - - .. attribute:: accepts_subpath - - Defines whether this :class:`View` can handle subpaths. Default: ``False`` - - .. method:: handles_subpath(subpath) - - Returns True if the the :class:`View` handles the given subpath, and False otherwise. - - .. attribute:: nodes - - A generic relation back to nodes. - - .. method:: reverse([view_name=None, args=None, kwargs=None, node=None, obj=None]) - - 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. - - This method will raise the following exceptions: - - - :class:`ViewDoesNotProvideSubpaths` if :attr:`accepts_subpath` is False. - - :class:`ViewCanNotProvideSubpath` if a reversal is not possible. - - .. method:: get_reverse_params(obj) - - 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:`ViewCanNotProvideSubpath`. - .. method:: attributes_with_node(node) +.. autoclass:: View + :show-inheritance: + :members: - Returns a :class:`QuerySetMapper` using the :class:`node `'s attributes as a passthrough. - - .. method:: render_to_response(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 :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 :obj:`view_about_to_render ` signal, then call :meth:`actually_render_to_response`, and finally send the :obj:`view_finished_rendering ` signal before returning the ``response``. - - .. method:: actually_render_to_response(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`. - -.. class:: MultiView - - :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: - - .. attribute:: accepts_subpath - - Same as :attr:`View.accepts_subpath`. Default: ``True`` - - .. attribute:: urlpatterns - - Returns urlpatterns that point to views (generally methods on the class). :class:`!MultiView`\ s can be thought of as "managing" these subpaths. - - .. method:: actually_render_to_response(request[, extra_context=None]) - - Resolves the remaining subpath left after finding this :class:`View`'s node using :attr:`self.urlpatterns ` and renders the view function (or method) found with the appropriate args and kwargs. - - .. method:: get_context() - - Hook for providing instance-specific context - such as the value of a Field - to all views. - - .. method:: basic_view(field_name) - - Given the name of a field on ``self``, accesses the value of that field and treats it as a :class:`View` instance. Creates a basic context based on :meth:`get_context` and any extra_context that was passed in, then calls the :class:`View` instance's :meth:`~View.render_to_response` method. This method is meant to be called to return a view function appropriate for :attr:`urlpatterns`. +.. autoclass:: MultiView + :show-inheritance: + :members: Concrete View Subclasses ++++++++++++++++++++++++ -.. class:: Redirect - - A :class:`View` subclass. Defines a 301 or 302 redirect to a different url on an absolute or relative path. - - .. attribute:: STATUS_CODES - - A choices tuple of redirect status codes (temporary or permanent). - - .. attribute:: status_code - - An :class:`IntegerField` which uses :attr:`STATUS_CODES` as its choices. Determines whether the redirect is considered temporary or permanent. - - .. attribute:: target_node - - An optional :class:`ForeignKey` to a :class:`Node`. If provided, that node will be used as the basis for the redirect. - - .. attribute:: url_or_subpath - - A :class:`CharField` which may contain an absolute or relative URL. This will be validated with :class:`philo.validators.RedirectValidator`. - - .. attribute:: reversing_parameters - - 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. +.. autoclass:: Redirect + :show-inheritance: + :members: - .. attribute:: target_url - - Calculates and returns the target url based on the :attr:`target_node`, :attr:`url_or_subpath`, and :attr:`reversing_parameters`. - - .. method:: actually_render_to_response(request[, extra_context=None]) - - Returns an :class:`HttpResponseRedirect` to :attr:`self.target`. - -.. class:: File - - A :class:`View` subclass. Stores an arbitrary file. - - .. attribute:: mimetype - - Defines the mimetype of the uploaded file. This will not be validated. - - .. attribute:: file - - Contains the uploaded file. Files are uploaded to ``philo/files/%Y/%m/%d``. - - .. method:: __unicode__() - - Returns the name of :attr:`self.file `. +.. autoclass:: File + :show-inheritance: + :members: Pages ***** -: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`. - -.. class:: Page - - A :class:`View` subclass. 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. - - .. attribute:: template - - A :class:`ForeignKey` to the :class:`Template` used to render this :class:`Page`. - - .. attribute:: title - - 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. - - .. attribute:: containers - - Returns :attr:`self.template.containers ` - a tuple containing the specs of all :ttag:`container`\ s defined in the :class:`Template`. The value will be cached on the instance so that multiple accesses will be less expensive. - - .. method:: render_to_string([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-related content with the same :ttag:`container`-based functionality as is used for HTML. - - .. method:: actually_render_to_response(request[, extra_context=None]) - - Returns an :class:`HttpResponse` with the content of the :meth:`render_to_string` method and the mimetype set to :attr:`self.template.mimetype `. - - .. 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. - - .. method:: __unicode__() - - Returns :meth:`self.title ` - -.. class:: Template - - Subclasses :class:`TreeModel`. Represents a database-driven django template. Defines the following additional methods and attributes: - - .. attribute:: name - - The name of the template. Used for organization and debugging. - - .. attribute:: documentation - - Can be used to let users know what the template is meant to be used for. - - .. attribute:: mimetype - - Defines the mimetype of the template. This is not validated. Default: ``text/html``. - - .. attribute:: code - - An insecure :class:`~philo.models.fields.TemplateField` containing the django template code for this template. - - .. attribute:: containers - - 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. - - .. method:: __unicode__() - - Returns the results of the :meth:`~TreeModel.get_path` method, using the "name" field and a chevron joiner. - -.. class:: Contentlet - - Defines a piece of content on a page. This content is treated as a secure :class:`~philo.models.fields.TemplateField`. - - .. attribute:: page - - The page which this :class:`Contentlet` is related to. - - .. attribute:: name - - This represents the name of the container as defined by a :ttag:`container` tag. - - .. attribute:: content - - 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. - - .. method:: __unicode__() - - Returns :attr:`self.name <name>` - -.. class:: ContentReference - - Defines a model instance related to a page. - - .. attribute:: page - - The page which this :class:`ContentReference` is related to. - - .. attribute:: name - - This represents the name of the container as defined by a :ttag:`container` tag. +.. automodule:: philo.models.pages - .. attribute:: content +.. autoclass:: Page + :members: + :show-inheritance: - 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`. +.. autoclass:: Template + :members: + :show-inheritance: + + .. seealso:: :mod:`philo.loaders.database` - .. method:: __unicode__() +.. autoclass:: Contentlet + :members: - Returns :attr:`self.name <name>` \ No newline at end of file +.. autoclass:: ContentReference + :members: \ No newline at end of file diff --git a/docs/releases/0.9.1.rst b/docs/releases/0.9.1.rst new file mode 100644 index 0000000..2003350 --- /dev/null +++ b/docs/releases/0.9.1.rst @@ -0,0 +1,13 @@ +Philo version 0.9.1 release notes +================================= + +The primary focus of the 0.9.1 release has been streamlining and optimization. Requests in 0.9.1 are served two to three times faster than in 0.9. A number of bugs in code, documentation, and migrations have also been corrected. + +New Features and backwards-incompatible changes ++++++++++++++++++++++++++++++++++++++++++++++++ + +* :class:`.FeedView` and related syndication code has been migrated to :mod:`philo.contrib.winer` so it can be used independently of :mod:`philo.contrib.penfield`. +* :class:`.FeedView` has been refactored; the result of :meth:`.FeedView.get_object` is now passed into :meth:`.FeedView.get_items` to allow for more flexibility and for :class:`.FeedView`\ s which do not have a :class:`ForeignKey` relationship to the items that the feed is for. +* :class:`.BlogView` has been refactored to take advantage of the more flexible :meth:`~.BlogView.get_object` method. Many of its former entry-fetching methods have been removed. +* :class:`.EmbedWidget` is now used for text fields on, for example, :class:`BlogEntry`. The widget allows javascript-based generation of embed tags for model instances, using the same popup interface as raw id fields. +* :class:`philo.models.Tag` has been removed in favor of an optional requirement for ``django-taggit``. This will allow :mod:`philo` to remain more focused. Migrations are provided for :mod:`philo.contrib.penfield` which losslessly convert :mod:`philo` :class:`~philo.models.Tag`\ s to ``django-taggit`` :class:`Tags`. diff --git a/docs/signals.rst b/docs/signals.rst new file mode 100644 index 0000000..8b3da3c --- /dev/null +++ b/docs/signals.rst @@ -0,0 +1,5 @@ +Signals +======= + +.. automodule:: philo.signals + :members: diff --git a/docs/templatetags.rst b/docs/templatetags.rst new file mode 100644 index 0000000..41d30d5 --- /dev/null +++ b/docs/templatetags.rst @@ -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/tutorials/getting-started.rst b/docs/tutorials/getting-started.rst new file mode 100644 index 0000000..11eb927 --- /dev/null +++ b/docs/tutorials/getting-started.rst @@ -0,0 +1,89 @@ +Getting started with philo +========================== + +.. note:: This guide assumes that you have worked with Django's built-in administrative interface. + +Once you've installed `philo`_ and `mptt`_ to your python path, there are only a few things that you need to do to get :mod:`philo` working. + +1. Add :mod:`philo` and :mod:`mptt` to :setting:`settings.INSTALLED_APPS`:: + + INSTALLED_APPS = ( + ... + 'philo', + 'mptt', + ... + ) + +2. Syncdb or run migrations to set up your database. + +3. Add :class:`philo.middleware.RequestNodeMiddleware` to :setting:`settings.MIDDLEWARE_CLASSES`:: + + MIDDLEWARE_CLASSES = ( + ... + 'philo.middleware.RequestNodeMiddleware', + ... + ) + +4. 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')), + ) + +Philo should be ready to go! (Almost.) + +.. _philo: http://philocms.org/ +.. _mptt: http://github.com/django-mptt/django-mptt + +Hello world ++++++++++++ + +Now that you've got everything configured, it's time to set up your first page! Easy peasy. Open up the admin and add a new :class:`.Template`. Call it "Hello World Template". The code can be something like this:: + + <html> + <head> + <title>Hello world! + + +

Hello world!

+

The time is {% now %}.

+ + + +Next, add a philo :class:`.Page` - let's call it "Hello World Page" and use the template you just made. + +Now make a philo :class:`.Node`. Give it the slug ``hello-world``. Set the ``view_content_type`` to "Page" and the ``view_object_id`` to the id of the page that you just made - probably 1. If you navigate to ``/hello-world``, you will see the results of rendering the page! + +Setting the root node ++++++++++++++++++++++ + +So what's at ``/``? If you try to load it, you'll get a 404 error. This is because there's no :class:`.Node` located there - and since :attr:`.Node.slug` is a required field, getting a node there is not as simple as leaving the :attr:`.~Node.slug` blank. + +In :mod:`philo`, the node that is displayed at ``/`` is called the "root node" of the current :class:`Site`. To represent this idea cleanly in the database, :mod:`philo` adds a :class:`ForeignKey` to :class:`.Node` to the :class:`django.contrib.sites.models.Site` model. + +Since there's only one :class:`.Node` in your :class:`Site`, we probably want ``hello-world`` to be the root node. All you have to do is edit the current :class:`Site` and set its root node to ``hello-world``. Now you can see the page rendered at ``/``! + +Editing page contents ++++++++++++++++++++++ + +Great! We've got a page that says "Hello World". But what if we want it to say something else? Should we really have to edit the :class:`.Template` to change the content of the :class:`.Page`? And what if we want to share the :class:`.Template` but have different content? Adjust the :class:`.Template` to look like this:: + + + + {% container page_title %} + + + {% container page_body as content %} + {% if content %} +

{{ content }}

+ {% endif %} +

The time is {% now "jS F Y H:i" %}.

+ + + +Now go edit your :class:`.Page`. Two new fields called "Page title" and "Page body" have shown up! You can put anything you like in here and have it show up in the appropriate places when the page is rendered. + +.. seealso:: :ttag:`philo.templatetags.containers.container` + +Congrats! You've done it! diff --git a/docs/tutorials/intro.rst b/docs/tutorials/intro.rst new file mode 100644 index 0000000..c7d3e99 --- /dev/null +++ b/docs/tutorials/intro.rst @@ -0,0 +1,8 @@ +Tutorials +========= + +.. toctree:: + :maxdepth: 1 + + getting-started + shipherd diff --git a/docs/tutorials/shipherd.rst b/docs/tutorials/shipherd.rst new file mode 100644 index 0000000..914a6bb --- /dev/null +++ b/docs/tutorials/shipherd.rst @@ -0,0 +1,63 @@ +Using Shipherd in the Admin +=========================== + +The navigation mechanism is fairly complex; unfortunately, there's no real way around that - without a lot of equally complex code that you are quite welcome to write and contribute! ;-) + +For this guide, we'll assume that you have the setup described in :doc:`getting-started`. We'll be adding a main :class:`.Navigation` to the root :class:`.Node` and making it display as part of the :class:`.Template`. + +Before getting started, make sure that you've added :mod:`philo.contrib.shipherd` to your :setting:`INSTALLED_APPS`. :mod:`~philo.contrib.shipherd` template tags also require the request context processor, so make sure to set :setting:`TEMPLATE_CONTEXT_PROCESSORS` appropriately:: + + TEMPLATE_CONTEXT_PROCESSORS = ( + # Defaults + "django.contrib.auth.context_processors.auth", + "django.core.context_processors.debug", + "django.core.context_processors.i18n", + "django.core.context_processors.media", + "django.core.context_processors.static", + "django.contrib.messages.context_processors.messages" + ... + "django.core.context_processors.request" + ) + +Creating the Navigation ++++++++++++++++++++++++ + +Start off by adding a new :class:`.Navigation` instance with :attr:`~.Navigation.node` set to the good ole' ``root`` node and :attr:`~.Navigation.key` set to ``main``. The default :attr:`~.Navigation.depth` of 3 is fine. + +Now open up that first inline :class:`.NavigationItem`. Make the text ``Hello World`` and set the target :class:`.Node` to, again, ``root``. (Of course, this is a special case. If we had another node that we wanted to point to, we would choose that.) + +Press save and you've created your first navigation. + +Displaying the Navigation ++++++++++++++++++++++++++ + +All you need to do now is show the navigation in the template! This is quite easy, using the :ttag:`~philo.contrib.shipherd.templatetags.shipherd.recursenavigation` templatetag. For now we'll keep it simple. Adjust the "Hello World Template" to look like this:: + + {% load shipherd %} + + {% container page_title %} + + +
    + {% recursenavigation node "main" %} + + {{ item.text }} + + {% endrecursenavigation %} +
+ {% container page_body as content %} + {% if content %} +

{{ content }}

+ {% endif %} +

The time is {% now %}.

+ + + +Now have a look at the page - your navigation is there! + +Linking to google ++++++++++++++++++ + +Edit the ``main`` :class:`.Navigation` again to add another :class:`.NavigationItem`. This time give it the :attr:`~.NavigationItem.text` ``Google`` and set the :attr:`~.TargetURLModel.url_or_subpath` field to ``http://google.com``. A navigation item will show up on the Hello World page that points to ``google.com``! Granted, your navigation probably shouldn't do that, because confusing navigation is confusing; the point is that it is possible to provide navigation to arbitrary URLs. + +:attr:`~.TargetURLModel.url_or_subpath` can also be used in conjuction with a :class:`.Node` to link to a subpath beyond that :class:`.Node`'s url. diff --git a/docs/utilities.rst b/docs/utilities.rst new file mode 100644 index 0000000..d1386b1 --- /dev/null +++ b/docs/utilities.rst @@ -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 index 0000000..f91818b --- /dev/null +++ b/docs/validators.rst @@ -0,0 +1,5 @@ +Validators +========== + +.. automodule:: philo.validators + :members: diff --git a/docs/what.rst b/docs/what.rst new file mode 100644 index 0000000..efa8537 --- /dev/null +++ b/docs/what.rst @@ -0,0 +1,25 @@ +What is Philo, anyway? +====================== + +Philo allows the creation of site structures using Django's built-in admin interface. Like Django, Philo separates URL structure from backend code from display: + +* :class:`.Node`\ s represent the URL hierarchy of the website. +* :class:`.View`\ s contain the logic for each :class:`.Node`, as simple as a :class:`.Redirect` or as complex as a :class:`.Blog`. +* :class:`.Page`\ s (the most commonly used :class:`.View`) render whatever context they are passed using database-driven :class:`.Template`\ s written with Django's template language. +* :class:`.Attribute`\ s are arbitrary key/value pairs which can be attached to most of the models that Philo provides. Attributes of a :class:`.Node` will be inherited by all of the :class:`.Node`'s descendants and will be available in the template's context. + +The :ttag:`~philo.templatetags.containers.container` template tag that Philo provides makes it easy to mark areas in a template which need to be editable page-by-page; every :class:`.Page` will have an additional field in the admin for each :ttag:`~philo.templatetags.containers.container` in the template it uses. + +How's that different than other CMSes? +++++++++++++++++++++++++++++++++++++++ + +Philo developed according to principles that grew out of the observation of the limitations and practices of other content management systems. For example, Philo believes that: + +* Designers are in charge of how content is displayed, not end users. For example, users should be able to embed images in blog entries -- but the display of the image, even the presence or absence of a wrapping ``
`` element, should depend on the template used to render the entry, not the HTML5 knowledge of the user. + .. seealso:: :ttag:`~philo.templatetags.embed.embed` +* Interpretation of content (as a django template, as markdown, as textile, etc.) is the responsibility of the template designer, not of code developers or the framework. + .. seealso:: :ttag:`~philo.templatetags.include_string.include_string` +* Page content should be simple -- not reorderable. Each piece of content should only be related to one page. Any other system will cause more trouble than it's worth. + .. seealso:: :class:`.Contentlet`, :class:`.ContentReference` +* Some pieces of information may be shared by an entire site, used in disparate places, and changed frequently enough that it is far too difficult to track down every use. These pieces of information should be stored separately from the content that contains them. + .. seealso:: :class:`.Attribute` diff --git a/philo/__init__.py b/philo/__init__.py index 32297e0..4e4f145 100644 --- a/philo/__init__.py +++ b/philo/__init__.py @@ -1 +1 @@ -VERSION = (0, 0) +VERSION = (0, 9, 1) diff --git a/philo/admin/base.py b/philo/admin/base.py index 75fa336..d966c39 100644 --- a/philo/admin/base.py +++ b/philo/admin/base.py @@ -4,12 +4,12 @@ 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 philo.models import Tag, Attribute +from mptt.admin import MPTTModelAdmin + +from philo.models import 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',) @@ -135,43 +135,5 @@ class EntityAdmin(admin.ModelAdmin): return db_field.formfield(**kwargs) -class TreeAdmin(MPTTModelAdmin): - pass - - -class TreeEntityAdmin(EntityAdmin, TreeAdmin): - pass - - -class TagAdmin(admin.ModelAdmin): - list_display = ('name', 'slug') - prepopulated_fields = {"slug": ("name",)} - search_fields = ["name"] - - def response_add(self, request, obj, post_url_continue='../%s/'): - # If it's an ajax request, return a json response containing the necessary information. - if request.is_ajax(): - return HttpResponse(json.dumps({'pk': escape(obj._get_pk_val()), 'unicode': escape(obj)})) - return super(TagAdmin, self).response_add(request, obj, post_url_continue) - - -class AddTagAdmin(admin.ModelAdmin): - def formfield_for_manytomany(self, db_field, request=None, **kwargs): - """ - Get a form Field for a ManyToManyField. - """ - # If it uses an intermediary model that isn't auto created, don't show - # a field in admin. - if not db_field.rel.through._meta.auto_created: - return None - - if db_field.rel.to == Tag and db_field.name in (list(self.filter_vertical) + list(self.filter_horizontal)): - opts = Tag._meta - if request.user.has_perm(opts.app_label + '.' + opts.get_add_permission()): - kwargs['widget'] = TagFilteredSelectMultiple(db_field.verbose_name, (db_field.name in self.filter_vertical)) - return db_field.formfield(**kwargs) - - return super(AddTagAdmin, self).formfield_for_manytomany(db_field, request, **kwargs) - - -admin.site.register(Tag, TagAdmin) \ No newline at end of file +class TreeEntityAdmin(EntityAdmin, MPTTModelAdmin): + pass \ No newline at end of file diff --git a/philo/admin/collections.py b/philo/admin/collections.py index d422b74..c2a9034 100644 --- a/philo/admin/collections.py +++ b/philo/admin/collections.py @@ -1,4 +1,5 @@ from django.contrib import admin + from philo.admin.base import COLLAPSE_CLASSES from philo.models import CollectionMember, Collection diff --git a/philo/admin/forms/attributes.py b/philo/admin/forms/attributes.py index fc77d0f..4a6dd67 100644 --- a/philo/admin/forms/attributes.py +++ b/philo/admin/forms/attributes.py @@ -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 @@ -20,7 +21,7 @@ class AttributeForm(ModelForm): # This is necessary because model forms store changes to self.instance in their clean method. # Mutter mutter. value = self.instance.value - self._cached_value_ct = self.instance.value_content_type + self._cached_value_ct_id = self.instance.value_content_type_id self._cached_value = value # If there is a value, pull in its fields. @@ -31,7 +32,7 @@ class AttributeForm(ModelForm): def save(self, *args, **kwargs): # At this point, the cleaned_data has already been stored on self.instance. - if self.instance.value_content_type != self._cached_value_ct: + if self.instance.value_content_type_id != self._cached_value_ct_id: # The value content type has changed. Clear the old value, if there was one. if self._cached_value: self._cached_value.delete() @@ -41,8 +42,8 @@ class AttributeForm(ModelForm): # Now create a new value instance so that on next instantiation, the form will # know what fields to add. - if self.instance.value_content_type is not None: - self.instance.value = self.instance.value_content_type.model_class().objects.create() + if self.instance.value_content_type_id is not None: + self.instance.value = ContentType.objects.get_for_id(self.instance.value_content_type_id).model_class().objects.create() elif self.instance.value is not None: # The value content type is the same, but one of the value fields has changed. diff --git a/philo/admin/forms/containers.py b/philo/admin/forms/containers.py index 420ba17..0f8d117 100644 --- a/philo/admin/forms/containers.py +++ b/philo/admin/forms/containers.py @@ -1,11 +1,11 @@ from django import forms -from django.contrib.admin.widgets import AdminTextareaWidget from django.core.exceptions import ObjectDoesNotExist 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.admin.widgets import ModelLookupWidget, EmbedWidget from philo.models import Contentlet, ContentReference @@ -25,7 +25,7 @@ class ContainerForm(ModelForm): class ContentletForm(ContainerForm): - content = forms.CharField(required=False, widget=AdminTextareaWidget, label='Content') + content = forms.CharField(required=False, widget=EmbedWidget, label='Content') def should_delete(self): # Delete iff: the data has changed and is now empty. @@ -152,7 +152,7 @@ class ContainerInlineFormSet(BaseInlineFormSet): class ContentletInlineFormSet(ContainerInlineFormSet): def get_containers(self): try: - containers = list(self.instance.containers[0]) + containers = self.instance.containers[0] except ObjectDoesNotExist: containers = [] diff --git a/philo/admin/nodes.py b/philo/admin/nodes.py index e2a9c9d..46c456a 100644 --- a/philo/admin/nodes.py +++ b/philo/admin/nodes.py @@ -1,7 +1,8 @@ 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 -from mptt.admin import MPTTModelAdmin class NodeAdmin(TreeEntityAdmin): @@ -46,10 +47,12 @@ class RedirectAdmin(ViewAdmin): class FileAdmin(ViewAdmin): fieldsets = ( (None, { - 'fields': ('file', 'mimetype') + 'fields': ('name', 'file', 'mimetype') }), ) - list_display = ('mimetype', 'file') + list_display = ('name', 'mimetype', 'file') + search_fields = ('name',) + list_filter = ('mimetype',) admin.site.register(Node, NodeAdmin) diff --git a/philo/admin/pages.py b/philo/admin/pages.py index f9e96c0..4cdd37b 100644 --- a/philo/admin/pages.py +++ b/philo/admin/pages.py @@ -1,18 +1,18 @@ +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.base import COLLAPSE_CLASSES, TreeEntityAdmin +from philo.admin.forms.containers import * from philo.admin.nodes import ViewAdmin +from philo.admin.widgets import EmbedWidget +from philo.models.fields import TemplateField from philo.models.pages import Page, Template, Contentlet, ContentReference -from philo.admin.forms.containers import * -class ContentletInline(admin.StackedInline): - model = Contentlet +class ContainerInline(admin.StackedInline): extra = 0 max_num = 0 - formset = ContentletInlineFormSet - form = ContentletForm can_delete = False classes = ('collapse-open', 'collapse','open') if 'grappelli' in settings.INSTALLED_APPS: @@ -21,18 +21,16 @@ class ContentletInline(admin.StackedInline): template = 'admin/philo/edit_inline/tabular_container.html' -class ContentReferenceInline(admin.StackedInline): +class ContentletInline(ContainerInline): + model = Contentlet + formset = ContentletInlineFormSet + form = ContentletForm + + +class ContentReferenceInline(ContainerInline): model = ContentReference - extra = 0 - max_num = 0 formset = ContentReferenceInlineFormSet form = ContentReferenceForm - can_delete = False - classes = ('collapse-open', 'collapse','open') - if 'grappelli' in settings.INSTALLED_APPS: - template = 'admin/philo/edit_inline/grappelli_tabular_container.html' - else: - template = 'admin/philo/edit_inline/tabular_container.html' class PageAdmin(ViewAdmin): @@ -54,7 +52,7 @@ class PageAdmin(ViewAdmin): return super(PageAdmin, self).response_add(request, obj, post_url_continue) -class TemplateAdmin(TreeAdmin): +class TemplateAdmin(TreeEntityAdmin): prepopulated_fields = {'slug': ('name',)} fieldsets = ( (None, { @@ -72,6 +70,9 @@ class TemplateAdmin(TreeAdmin): 'fields': ('mimetype',) }), ) + formfield_overrides = { + TemplateField: {'widget': EmbedWidget} + } save_on_top = True save_as = True list_display = ('__unicode__', 'slug', 'get_path',) diff --git a/philo/admin/widgets.py b/philo/admin/widgets.py index fb13ac7..3d7d64b 100644 --- a/philo/admin/widgets.py +++ b/philo/admin/widgets.py @@ -1,10 +1,11 @@ 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.contrib.admin.widgets import url_params_from_lookup_dict +from django.utils import simplejson as json +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): @@ -40,29 +41,28 @@ class ModelLookupWidget(forms.TextInput): return mark_safe(u''.join(output)) -class TagFilteredSelectMultiple(FilteredSelectMultiple): - """ - A SelectMultiple with a JavaScript filter interface. - - Note that the resulting JavaScript assumes that the jsi18n - catalog has been loaded in the page - """ +class EmbedWidget(forms.Textarea): + """A form widget with the HTML class embedding and an embedded list of content-types.""" + def __init__(self, attrs=None): + from philo.models import value_content_type_limiter + + content_types = value_content_type_limiter.classes + data = [] + + for content_type in content_types: + data.append({'app_label': content_type._meta.app_label, 'object_name': content_type._meta.object_name.lower(), 'verbose_name': unicode(content_type._meta.verbose_name)}) + + json_ = json.dumps(data) + + default_attrs = {'class': 'embedding vLargeTextField', 'data-content-types': json_ } + + if attrs: + default_attrs.update(attrs) + + super(EmbedWidget, self).__init__(default_attrs) + class Media: - 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 = {} - attrs['class'] = 'selectfilter' - if self.is_stacked: attrs['class'] += 'stacked' - output = [super(FilteredSelectMultiple, self).render(name, value, attrs, choices)] - output.append(u'\n' % \ - (name, self.verbose_name.replace('"', '\\"'), int(self.is_stacked), settings.ADMIN_MEDIA_PREFIX, name)) - return mark_safe(u''.join(output)) \ No newline at end of file + css = { + 'all': ('philo/css/EmbedWidget.css',), + } + js = ('philo/js/EmbedWidget.js',) diff --git a/philo/contrib/__init__.py b/philo/contrib/__init__.py index e69de29..0cde6d5 100644 --- a/philo/contrib/__init__.py +++ b/philo/contrib/__init__.py @@ -0,0 +1,11 @@ +#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 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. +* :mod:`~philo.contrib.winer` — Abstract framework for Philo-based syndication. + +""" \ No newline at end of file diff --git a/philo/contrib/julian/__init__.py b/philo/contrib/julian/__init__.py index e69de29..e78b263 100644 --- a/philo/contrib/julian/__init__.py +++ b/philo/contrib/julian/__init__.py @@ -0,0 +1,4 @@ +""" +This version of julian is currently in development and is not considered stable. + +""" \ No newline at end of file diff --git a/philo/contrib/julian/admin.py b/philo/contrib/julian/admin.py index 8f104e2..cf72682 100644 --- a/philo/contrib/julian/admin.py +++ b/philo/contrib/julian/admin.py @@ -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 diff --git a/philo/contrib/julian/migrations/0001_initial.py b/philo/contrib/julian/migrations/0001_initial.py index 3236095..21e8778 100644 --- a/philo/contrib/julian/migrations/0001_initial.py +++ b/philo/contrib/julian/migrations/0001_initial.py @@ -219,14 +219,6 @@ class Migration(SchemaMigration): 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) }, - 'oberlin.locationcoordinates': { - 'Meta': {'unique_together': "(('location_ct', 'location_pk'),)", 'object_name': 'LocationCoordinates'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'latitude': ('django.db.models.fields.FloatField', [], {}), - 'location_ct': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'location_pk': ('django.db.models.fields.TextField', [], {}), - 'longitude': ('django.db.models.fields.FloatField', [], {}) - }, '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']"}), diff --git a/philo/contrib/julian/models.py b/philo/contrib/julian/models.py index 5c49c7e..df49da5 100644 --- a/philo/contrib/julian/models.py +++ b/philo/contrib/julian/models.py @@ -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,19 +13,21 @@ 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 taggit.managers import TaggableManager + from philo.contrib.julian.feedgenerator import ICalendarFeed -from philo.contrib.penfield.models import FeedView, FEEDS +from philo.contrib.winer.models import FeedView +from philo.contrib.winer.feeds import registry 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',) -ICALENDAR = ICalendarFeed.mime_type -FEEDS[ICALENDAR] = ICalendarFeed +registry.register(ICalendarFeed, verbose_name="iCalendar") try: DEFAULT_SITE = Site.objects.get_current() except: @@ -219,17 +224,17 @@ class CalendarView(FeedView): # or per-calendar-view basis. #url(r'^%s/(?P[\w-]+)' % self.location_permalink_base, ...) - if self.tag_archive_page: + if self.tag_archive_page_id: urlpatterns += patterns('', url(r'^%s$' % self.tag_permalink_base, self.tag_archive_view, name='tag_archive') ) - if self.owner_archive_page: + if self.owner_archive_page_id: urlpatterns += patterns('', url(r'^%s$' % self.owner_permalink_base, self.owner_archive_view, name='owner_archive') ) - if self.location_archive_page: + if self.location_archive_page_id: urlpatterns += patterns('', url(r'^%s$' % self.location_permalink_base, self.location_archive_view, name='location_archive') ) @@ -330,7 +335,7 @@ class CalendarView(FeedView): def get_events_by_location(self, request, app_label, model, pk, extra_context=None): try: - ct = ContentType.objects.get(app_label=app_label, model=model) + ct = ContentType.objects.get_by_natural_key(app_label, model) location = ct.model_class()._default_manager.get(pk=pk) except ObjectDoesNotExist: raise Http404 @@ -457,5 +462,4 @@ class CalendarView(FeedView): return u"%s for %s" % (self.__class__.__name__, self.calendar) field = CalendarView._meta.get_field('feed_type') -field._choices += ((ICALENDAR, 'iCalendar'),) -field.default = ICALENDAR \ No newline at end of file +field.default = registry.get_slug(ICalendarFeed, field.default) \ No newline at end of file diff --git a/philo/contrib/penfield/admin.py b/philo/contrib/penfield/admin.py index c70cf46..31aacb1 100644 --- a/philo/contrib/penfield/admin.py +++ b/philo/contrib/penfield/admin.py @@ -2,8 +2,11 @@ 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.admin import EntityAdmin, COLLAPSE_CLASSES +from philo.admin.widgets import EmbedWidget from philo.contrib.penfield.models import BlogEntry, Blog, BlogView, Newsletter, NewsletterArticle, NewsletterIssue, NewsletterView +from philo.models.fields import TemplateField class DelayedDateForm(forms.ModelForm): @@ -14,18 +17,13 @@ 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(EntityAdmin): form = DelayedDateForm - filter_horizontal = ['tags'] list_filter = ['author', 'blog'] date_hierarchy = 'date' search_fields = ('content',) @@ -44,6 +42,10 @@ class BlogEntryAdmin(TitledAdmin, AddTagAdmin): }) ) related_lookup_fields = {'fk': raw_id_fields} + prepopulated_fields = {'slug': ('title',)} + formfield_overrides = { + TemplateField: {'widget': EmbedWidget} + } class BlogViewAdmin(EntityAdmin): @@ -70,13 +72,14 @@ 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(EntityAdmin): form = DelayedDateForm - filter_horizontal = ('tags', 'authors') + filter_horizontal = ('authors',) list_filter = ('newsletter',) date_hierarchy = 'date' search_fields = ('title', 'authors__name',) @@ -94,6 +97,10 @@ class NewsletterArticleAdmin(TitledAdmin, AddTagAdmin): }) ) actions = ['make_issue'] + prepopulated_fields = {'slug': ('title',)} + formfield_overrides = { + TemplateField: {'widget': EmbedWidget} + } def author_names(self, obj): return ', '.join([author.get_full_name() for author in obj.authors.all()]) @@ -107,8 +114,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): diff --git a/philo/contrib/penfield/exceptions.py b/philo/contrib/penfield/exceptions.py deleted file mode 100644 index 96b96ed..0000000 --- a/philo/contrib/penfield/exceptions.py +++ /dev/null @@ -1,3 +0,0 @@ -class HttpNotAcceptable(Exception): - """This will be raised if an Http-Accept header will not accept the feed content types that are available.""" - pass \ No newline at end of file diff --git a/philo/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py b/philo/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py index eae496e..72df39b 100644 --- a/philo/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py +++ b/philo/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py @@ -3,6 +3,7 @@ import datetime from south.db import db from south.v2 import SchemaMigration from django.db import models +from philo.migrations import person_model, frozen_person class Migration(SchemaMigration): @@ -85,13 +86,7 @@ class Migration(SchemaMigration): 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, - 'oberlin.person': { - 'Meta': {'object_name': 'Person'}, - 'bio': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '70', 'blank': 'True'}), - 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'null': 'True', 'blank': 'True'}) - }, + person_model: frozen_person, 'penfield.blog': { 'Meta': {'object_name': 'Blog'}, 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), @@ -100,7 +95,7 @@ class Migration(SchemaMigration): }, 'penfield.blogentry': { 'Meta': {'object_name': 'BlogEntry'}, - 'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogentries'", 'to': "orm['oberlin.Person']"}), + 'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogentries'", 'to': "orm['%s']" % person_model}), 'blog': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'entries'", 'null': 'True', 'to': "orm['penfield.Blog']"}), 'content': ('django.db.models.fields.TextField', [], {}), 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), @@ -137,7 +132,7 @@ class Migration(SchemaMigration): }, 'penfield.newsletterarticle': { 'Meta': {'unique_together': "(('newsletter', 'slug'),)", 'object_name': 'NewsletterArticle'}, - 'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['oberlin.Person']"}), + 'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['%s']" % person_model}), 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), 'full_text': ('philo.models.fields.TemplateField', [], {'db_index': 'True'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), diff --git a/philo/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py b/philo/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py index 9b9ffa7..e48e0d7 100644 --- a/philo/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py +++ b/philo/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py @@ -3,6 +3,7 @@ import datetime from south.db import db from south.v2 import SchemaMigration from django.db import models +from philo.migrations import person_model, frozen_person class Migration(SchemaMigration): @@ -61,13 +62,7 @@ class Migration(SchemaMigration): 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, - 'oberlin.person': { - 'Meta': {'object_name': 'Person'}, - 'bio': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '70', 'blank': 'True'}), - 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'null': 'True', 'blank': 'True'}) - }, + person_model: frozen_person, 'penfield.blog': { 'Meta': {'object_name': 'Blog'}, 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), @@ -76,7 +71,7 @@ class Migration(SchemaMigration): }, 'penfield.blogentry': { 'Meta': {'object_name': 'BlogEntry'}, - 'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogentries'", 'to': "orm['oberlin.Person']"}), + 'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogentries'", 'to': "orm['%s']" % person_model}), 'blog': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'entries'", 'null': 'True', 'to': "orm['penfield.Blog']"}), 'content': ('django.db.models.fields.TextField', [], {}), 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), @@ -114,7 +109,7 @@ class Migration(SchemaMigration): }, 'penfield.newsletterarticle': { 'Meta': {'unique_together': "(('newsletter', 'slug'),)", 'object_name': 'NewsletterArticle'}, - 'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['oberlin.Person']"}), + 'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['%s']" % person_model}), 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), 'full_text': ('philo.models.fields.TemplateField', [], {'db_index': 'True'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), diff --git a/philo/contrib/penfield/migrations/0005_to_taggit.py b/philo/contrib/penfield/migrations/0005_to_taggit.py new file mode 100644 index 0000000..52090c6 --- /dev/null +++ b/philo/contrib/penfield/migrations/0005_to_taggit.py @@ -0,0 +1,248 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models +from philo.migrations import person_model, frozen_person + +class Migration(DataMigration): + + depends_on = ( + ("philo", "0019_to_taggit"), + ) + + needed_by = ( + ("philo", "0020_from_taggit"), + ) + + def forwards(self, orm): + "Write your forwards methods here." + BlogEntry = orm['penfield.BlogEntry'] + NewsletterArticle = orm['penfield.NewsletterArticle'] + TaggitTag = orm['taggit.Tag'] + TaggedItem = orm['taggit.TaggedItem'] + ContentType = orm['contenttypes.contenttype'] + + entry_ct = ContentType.objects.get(app_label="penfield", model="blogentry") + article_ct = ContentType.objects.get(app_label="penfield", model="newsletterarticle") + + for entry in BlogEntry.objects.all(): + for tag in entry.tags.all(): + ttag = TaggitTag.objects.get(slug=tag.slug) + TaggedItem.objects.get_or_create(tag=ttag, content_type=entry_ct, object_id=entry.pk) + + for article in NewsletterArticle.objects.all(): + for tag in article.tags.all(): + ttag = TaggitTag.objects.get(slug=tag.slug) + TaggedItem.objects.get_or_create(tag=ttag, content_type=article_ct, object_id=article.pk) + + + def backwards(self, orm): + "Write your backwards methods here." + BlogEntry = orm['penfield.BlogEntry'] + NewsletterArticle = orm['penfield.NewsletterArticle'] + Tag = orm['philo.Tag'] + TaggitTag = orm['taggit.Tag'] + TaggedItem = orm['taggit.TaggedItem'] + ContentType = orm['contenttypes.contenttype'] + + entry_ct = ContentType.objects.get(app_label="penfield", model="blogentry") + article_ct = ContentType.objects.get(app_label="penfield", model="newsletterarticle") + + for entry in BlogEntry.objects.all(): + tag_slugs = list(TaggitTag.objects.filter(taggit_taggeditem_items__content_type=entry_ct, taggit_taggeditem_items__object_id=entry.pk).values_list('slug', flat=True).distinct()) + entry.tags = Tag.objects.filter(slug__in=tag_slugs) + entry.save() + + for article in NewsletterArticle.objects.all(): + tag_slugs = list(TaggitTag.objects.filter(taggit_taggeditem_items__content_type=article_ct, taggit_taggeditem_items__object_id=article.pk).values_list('slug', flat=True).distinct()) + article.tags = Tag.objects.filter(slug__in=tag_slugs) + article.save() + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + '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'}) + }, + person_model: frozen_person, + 'penfield.blog': { + 'Meta': {'object_name': 'Blog'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.blogentry': { + 'Meta': {'ordering': "['-date']", 'object_name': 'BlogEntry'}, + 'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogentries'", 'to': "orm['%s']" % person_model}), + 'blog': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'entries'", 'null': 'True', 'to': "orm['penfield.Blog']"}), + 'content': ('django.db.models.fields.TextField', [], {}), + 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'excerpt': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'blogentries'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.blogview': { + 'Meta': {'object_name': 'BlogView'}, + 'blog': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogviews'", 'to': "orm['penfield.Blog']"}), + 'entries_per_page': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'entry_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_entry_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'entry_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_entry_related'", 'to': "orm['philo.Page']"}), + 'entry_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'entries'", 'max_length': '255'}), + 'entry_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + 'feed_length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '15', 'null': 'True', 'blank': 'True'}), + 'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}), + 'feed_type': ('django.db.models.fields.CharField', [], {'default': "'atom'", 'max_length': '50'}), + 'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_index_related'", 'to': "orm['philo.Page']"}), + 'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'tag_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_tag_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'tag_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_tag_related'", 'to': "orm['philo.Page']"}), + 'tag_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'tags'", 'max_length': '255'}) + }, + 'penfield.newsletter': { + 'Meta': {'object_name': 'Newsletter'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterarticle': { + 'Meta': {'ordering': "['-date']", 'unique_together': "(('newsletter', 'slug'),)", 'object_name': 'NewsletterArticle'}, + 'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['%s']" % person_model}), + 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'full_text': ('philo.models.fields.TemplateField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lede': ('philo.models.fields.TemplateField', [], {'null': 'True', 'blank': 'True'}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'articles'", 'to': "orm['penfield.Newsletter']"}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'newsletterarticles'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterissue': { + 'Meta': {'ordering': "['-numbering']", 'unique_together': "(('newsletter', 'numbering'),)", 'object_name': 'NewsletterIssue'}, + 'articles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'issues'", 'symmetrical': 'False', 'to': "orm['penfield.NewsletterArticle']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issues'", 'to': "orm['penfield.Newsletter']"}), + 'numbering': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterview': { + 'Meta': {'object_name': 'NewsletterView'}, + 'article_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_article_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'article_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_article_related'", 'to': "orm['philo.Page']"}), + 'article_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'articles'", 'max_length': '255'}), + 'article_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + 'feed_length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '15', 'null': 'True', 'blank': 'True'}), + 'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}), + 'feed_type': ('django.db.models.fields.CharField', [], {'default': "'atom'", 'max_length': '50'}), + 'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_index_related'", 'to': "orm['philo.Page']"}), + 'issue_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_issue_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'issue_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_issue_related'", 'to': "orm['philo.Page']"}), + 'issue_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'issues'", 'max_length': '255'}), + 'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletterviews'", 'to': "orm['penfield.Newsletter']"}) + }, + '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': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.tag': { + 'Meta': {'ordering': "('name',)", 'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + }, + 'taggit.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100', 'db_index': 'True'}) + }, + 'taggit.taggeditem': { + 'Meta': {'object_name': 'TaggedItem'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_tagged_items'", 'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_items'", 'to': "orm['taggit.Tag']"}) + } + } + + complete_apps = ['penfield', 'taggit'] + symmetrical = True diff --git a/philo/contrib/penfield/migrations/0006_delete_tag_rels.py b/philo/contrib/penfield/migrations/0006_delete_tag_rels.py new file mode 100644 index 0000000..d3bba00 --- /dev/null +++ b/philo/contrib/penfield/migrations/0006_delete_tag_rels.py @@ -0,0 +1,218 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models +from philo.migrations import person_model, frozen_person + +class Migration(SchemaMigration): + + needed_by = ( + ('philo', '0021_auto__del_tag'), + ) + + def forwards(self, orm): + + # Removing M2M table for field tags on 'BlogEntry' + db.delete_table('penfield_blogentry_tags') + + # Removing M2M table for field tags on 'NewsletterArticle' + db.delete_table('penfield_newsletterarticle_tags') + + + def backwards(self, orm): + + # Adding M2M table for field tags on 'BlogEntry' + db.create_table('penfield_blogentry_tags', ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('blogentry', models.ForeignKey(orm['penfield.blogentry'], null=False)), + ('tag', models.ForeignKey(orm['philo.tag'], null=False)) + )) + db.create_unique('penfield_blogentry_tags', ['blogentry_id', 'tag_id']) + + # Adding M2M table for field tags on 'NewsletterArticle' + db.create_table('penfield_newsletterarticle_tags', ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('newsletterarticle', models.ForeignKey(orm['penfield.newsletterarticle'], null=False)), + ('tag', models.ForeignKey(orm['philo.tag'], null=False)) + )) + db.create_unique('penfield_newsletterarticle_tags', ['newsletterarticle_id', 'tag_id']) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + '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'}) + }, + person_model: frozen_person, + 'penfield.blog': { + 'Meta': {'object_name': 'Blog'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.blogentry': { + 'Meta': {'object_name': 'BlogEntry'}, + 'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogentries'", 'to': "orm['%s']" % person_model}), + 'blog': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'entries'", 'null': 'True', 'to': "orm['penfield.Blog']"}), + 'content': ('django.db.models.fields.TextField', [], {}), + 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'excerpt': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.blogview': { + 'Meta': {'object_name': 'BlogView'}, + 'blog': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogviews'", 'to': "orm['penfield.Blog']"}), + 'entries_per_page': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'entry_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_entry_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'entry_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_entry_related'", 'to': "orm['philo.Page']"}), + 'entry_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'entries'", 'max_length': '255'}), + 'entry_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + 'feed_length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '15', 'null': 'True', 'blank': 'True'}), + 'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}), + 'feed_type': ('django.db.models.fields.CharField', [], {'default': "'atom'", 'max_length': '50'}), + 'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_index_related'", 'to': "orm['philo.Page']"}), + 'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'tag_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_tag_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'tag_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_tag_related'", 'to': "orm['philo.Page']"}), + 'tag_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'tags'", 'max_length': '255'}) + }, + 'penfield.newsletter': { + 'Meta': {'object_name': 'Newsletter'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterarticle': { + 'Meta': {'ordering': "['-date']", 'unique_together': "(('newsletter', 'slug'),)", 'object_name': 'NewsletterArticle'}, + 'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['%s']" % person_model}), + 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'full_text': ('philo.models.fields.TemplateField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lede': ('philo.models.fields.TemplateField', [], {'null': 'True', 'blank': 'True'}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'articles'", 'to': "orm['penfield.Newsletter']"}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterissue': { + 'Meta': {'ordering': "['-numbering']", 'unique_together': "(('newsletter', 'numbering'),)", 'object_name': 'NewsletterIssue'}, + 'articles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'issues'", 'symmetrical': 'False', 'to': "orm['penfield.NewsletterArticle']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issues'", 'to': "orm['penfield.Newsletter']"}), + 'numbering': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterview': { + 'Meta': {'object_name': 'NewsletterView'}, + 'article_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_article_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'article_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_article_related'", 'to': "orm['philo.Page']"}), + 'article_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'articles'", 'max_length': '255'}), + 'article_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + 'feed_length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '15', 'null': 'True', 'blank': 'True'}), + 'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}), + 'feed_type': ('django.db.models.fields.CharField', [], {'default': "'atom'", 'max_length': '50'}), + 'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_index_related'", 'to': "orm['philo.Page']"}), + 'issue_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_issue_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'issue_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_issue_related'", 'to': "orm['philo.Page']"}), + 'issue_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'issues'", 'max_length': '255'}), + 'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletterviews'", 'to': "orm['penfield.Newsletter']"}) + }, + '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': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.template': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + }, + 'taggit.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100', 'db_index': 'True'}) + }, + 'taggit.taggeditem': { + 'Meta': {'object_name': 'TaggedItem'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_tagged_items'", 'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_items'", 'to': "orm['taggit.Tag']"}) + } + } + + complete_apps = ['penfield', 'taggit'] diff --git a/philo/contrib/penfield/models.py b/philo/contrib/penfield/models.py index a03bed8..a57459c 100644 --- a/philo/contrib/penfield/models.py +++ b/philo/contrib/penfield/models.py @@ -1,307 +1,43 @@ +# encoding: utf-8 +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 -from django.contrib.syndication.views import add_domain from django.db import models from django.http import Http404, HttpResponse -from django.template import RequestContext, Template as DjangoTemplate -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 taggit.managers import TaggableManager +from taggit.models import Tag, TaggedItem + +from philo.contrib.winer.models import FeedView from philo.exceptions import ViewCanNotProvideSubpath -from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField, Template +from philo.models import Entity, Page, register_value_model +from philo.models.fields import TemplateField from philo.utils import paginate -try: - import mimeparse -except: - mimeparse = None -ATOM = feedgenerator.Atom1Feed.mime_type -RSS = feedgenerator.Rss201rev2Feed.mime_type -FEEDS = SortedDict([ - (ATOM, feedgenerator.Atom1Feed), - (RSS, feedgenerator.Rss201rev2Feed), -]) -FEED_CHOICES = ( - (ATOM, "Atom"), - (RSS, "RSS"), -) - - -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. - - This class would subclass django.contrib.syndication.views.Feed, but - that would make it callable, which causes problems. - """ - feed_type = models.CharField(max_length=50, choices=FEED_CHOICES, default=ATOM) - feed_suffix = models.CharField(max_length=255, blank=False, default="feed") - feeds_enabled = models.BooleanField(default=True) - 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.") - - item_title_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_title_related") - item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related") - - item_context_var = 'items' - object_attr = 'object' - - 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. - """ - urlpatterns = patterns('') - if self.feeds_enabled: - feed_reverse_name = "%s_feed" % reverse_name - feed_view = http_not_acceptable(self.feed_view(get_items_attr, feed_reverse_name)) - feed_pattern = r'%s%s%s$' % (base, (base and base[-1] != "^") and "/" or "", self.feed_suffix) - urlpatterns += patterns('', - url(feed_pattern, feed_view, name=feed_reverse_name), - ) - urlpatterns += patterns('', - url(r"%s$" % base, self.page_view(get_items_attr, page_attr), name=reverse_name) - ) - return urlpatterns - - def get_object(self, request, **kwargs): - return getattr(self, self.object_attr) +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) - def feed_view(self, get_items_attr, reverse_name): - """ - 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) - - def inner(request, extra_context=None, *args, **kwargs): - obj = self.get_object(request, *args, **kwargs) - feed = self.get_feed(obj, request, reverse_name) - items, xxx = get_items(request, extra_context=extra_context, *args, **kwargs) - self.populate_feed(feed, items, request) - - response = HttpResponse(mimetype=feed.mime_type) - feed.write(response, 'utf-8') - return response - - return inner - - def page_view(self, get_items_attr, page_attr): - """ - Returns a view function that renders a list of items as a page. - """ - 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) - - def inner(request, extra_context=None, *args, **kwargs): - items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs) - items, item_context = self.process_page_items(request, items) - - context = self.get_context() - context.update(extra_context or {}) - context.update(item_context or {}) - - return page.render_to_response(request, extra_context=context) - return inner - - 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. - """ - item_context = { - self.item_context_var: items - } - return items, item_context + #: A slug used to identify the :class:`Blog`. + slug = models.SlugField(max_length=255) - def get_feed_type(self, request): - feed_type = self.feed_type - if feed_type not in FEEDS: - feed_type = FEEDS.keys()[0] - accept = request.META.get('HTTP_ACCEPT') - if accept and feed_type not in accept and "*/*" not in accept and "%s/*" % feed_type.split("/")[0] not in accept: - # Wups! They aren't accepting the chosen format. Is there another format we can use? - if mimeparse: - feed_type = mimeparse.best_match(FEEDS.keys(), accept) - else: - for feed_type in FEEDS.keys(): - if feed_type in accept or "%s/*" % feed_type.split("/")[0] in accept: - break - else: - feed_type = None - if not feed_type: - raise HttpNotAcceptable - return FEEDS[feed_type] - - def get_feed(self, obj, request, reverse_name): - """ - Returns an unpopulated feedgenerator.DefaultFeed object for this object. - """ - try: - current_site = Site.objects.get_current() - except Site.DoesNotExist: - current_site = RequestSite(request) - - feed_type = self.get_feed_type(request) - node = request.node - link = node.get_absolute_url(with_domain=True, request=request, secure=request.is_secure()) - - feed = feed_type( - title = self.__get_dynamic_attr('title', obj), - subtitle = self.__get_dynamic_attr('subtitle', obj), - link = link, - description = self.__get_dynamic_attr('description', obj), - language = settings.LANGUAGE_CODE.decode(), - feed_url = add_domain( - current_site.domain, - self.__get_dynamic_attr('feed_url', obj) or node.construct_url(node.subpath, with_domain=True, request=request, secure=request.is_secure()), - request.is_secure() - ), - author_name = self.__get_dynamic_attr('author_name', obj), - author_link = self.__get_dynamic_attr('author_link', obj), - author_email = self.__get_dynamic_attr('author_email', obj), - categories = self.__get_dynamic_attr('categories', obj), - feed_copyright = self.__get_dynamic_attr('feed_copyright', obj), - feed_guid = self.__get_dynamic_attr('feed_guid', obj), - ttl = self.__get_dynamic_attr('ttl', obj), - **self.feed_extra_kwargs(obj) - ) - return feed - - def populate_feed(self, feed, items, request): - if self.item_title_template: - title_template = DjangoTemplate(self.item_title_template.code) - else: - title_template = None - if self.item_description_template: - description_template = DjangoTemplate(self.item_description_template.code) - else: - description_template = None - - node = request.node - try: - current_site = Site.objects.get_current() - except Site.DoesNotExist: - current_site = RequestSite(request) - - if self.feed_length is not None: - items = items[:self.feed_length] - - for item in items: - if title_template is not None: - title = title_template.render(RequestContext(request, {'obj': item})) - else: - title = self.__get_dynamic_attr('item_title', item) - if description_template is not None: - description = description_template.render(RequestContext(request, {'obj': item})) - else: - description = self.__get_dynamic_attr('item_description', item) - - link = node.construct_url(self.reverse(obj=item), with_domain=True, request=request, secure=request.is_secure()) - - enc = None - enc_url = self.__get_dynamic_attr('item_enclosure_url', item) - if enc_url: - enc = feedgenerator.Enclosure( - url = smart_unicode(add_domain( - current_site.domain, - enc_url, - request.is_secure() - )), - length = smart_unicode(self.__get_dynamic_attr('item_enclosure_length', item)), - mime_type = smart_unicode(self.__get_dynamic_attr('item_enclosure_mime_type', item)) - ) - author_name = self.__get_dynamic_attr('item_author_name', item) - if author_name is not None: - author_email = self.__get_dynamic_attr('item_author_email', item) - author_link = self.__get_dynamic_attr('item_author_link', item) - else: - author_email = author_link = None - - pubdate = self.__get_dynamic_attr('item_pubdate', item) - if pubdate and not pubdate.tzinfo: - ltz = tzinfo.LocalTimezone(pubdate) - pubdate = pubdate.replace(tzinfo=ltz) - - feed.add_item( - title = title, - link = link, - description = description, - unique_id = self.__get_dynamic_attr('item_guid', item, link), - enclosure = enc, - pubdate = pubdate, - author_name = author_name, - author_email = author_email, - author_link = author_link, - categories = self.__get_dynamic_attr('item_categories', item), - item_copyright = self.__get_dynamic_attr('item_copyright', item), - **self.item_extra_kwargs(item) - ) - - def __get_dynamic_attr(self, attname, obj, default=None): - try: - attr = getattr(self, attname) - except AttributeError: - return default - if callable(attr): - # Check func_code.co_argcount rather than try/excepting the - # function and catching the TypeError, because something inside - # the function may raise the TypeError. This technique is more - # accurate. - if hasattr(attr, 'func_code'): - argcount = attr.func_code.co_argcount - else: - argcount = attr.__call__.func_code.co_argcount - if argcount == 2: # one argument is 'self' - return attr(obj) - else: - return attr() - return attr - - def feed_extra_kwargs(self, obj): - """ - 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. - """ - return {} - - def item_title(self, item): - return escape(force_unicode(item)) - - def item_description(self, item): - return force_unicode(item) + def __unicode__(self): + return self.title - class Meta: - abstract=True - - -class Blog(Entity, Titled): @property def entry_tags(self): - """ Returns a QuerySet of Tags that are used on any entries in this blog. """ - return Tag.objects.filter(blogentries__blog=self).distinct() + """Returns a :class:`QuerySet` of :class:`.Tag`\ s that are used on any entries in this blog.""" + entry_pks = list(self.entries.values_list('pk', flat=True)) + kwargs = { + '%s__object_id__in' % TaggedItem.tag_relname(): entry_pks + } + return TaggedItem.tags_for(BlogEntry).filter(**kwargs) @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,19 +45,40 @@ 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) - content = models.TextField() - excerpt = models.TextField(blank=True, null=True) - tags = models.ManyToManyField(Tag, related_name='blogentries', blank=True, null=True) + + #: The content of the :class:`BlogEntry`. + content = TemplateField() + + #: An optional brief excerpt from the :class:`BlogEntry`. + excerpt = TemplateField(blank=True, null=True) + + #: A ``django-taggit`` :class:`TaggableManager`. + tags = TaggableManager() def save(self, *args, **kwargs): if self.date is None: 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 +89,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 `. + + """ ENTRY_PERMALINK_STYLE_CHOICES = ( ('D', 'Year, month, and day'), ('M', 'Year and month'), @@ -340,29 +101,44 @@ 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' - object_attr = 'blog' def __unicode__(self): return u'BlogView for %s' % self.blog.title def get_reverse_params(self, obj): if isinstance(obj, BlogEntry): - if obj.blog == self.blog: + if obj.blog_id == self.blog_id: kwargs = {'slug': obj.slug} if self.entry_permalink_style in 'DMY': kwargs.update({'year': str(obj.date.year).zfill(4)}) @@ -374,7 +150,7 @@ class BlogView(FeedView): elif isinstance(obj, Tag) or (isinstance(obj, models.query.QuerySet) and obj.model == Tag and obj): if isinstance(obj, Tag): obj = [obj] - slugs = [tag.slug for tag in obj if tag in self.get_tag_queryset()] + slugs = [tag.slug for tag in obj if tag in self.get_tag_queryset(self.blog)] if slugs: return 'entries_by_tag', [], {'tag_slugs': "/".join(slugs)} elif isinstance(obj, (date, datetime)): @@ -388,21 +164,21 @@ class BlogView(FeedView): @property def urlpatterns(self): - urlpatterns = self.feed_patterns(r'^', 'get_all_entries', 'index_page', 'index') +\ - self.feed_patterns(r'^%s/(?P[-\w]+[-+/\w]*)$' % self.tag_permalink_base, 'get_entries_by_tag', 'tag_page', 'entries_by_tag') + urlpatterns = self.feed_patterns(r'^', 'get_entries', 'index_page', 'index') +\ + self.feed_patterns(r'^%s/(?P[-\w]+[-+/\w]*)' % self.tag_permalink_base, 'get_entries', 'tag_page', 'entries_by_tag') - if self.tag_archive_page: + if self.tag_archive_page_id: urlpatterns += patterns('', url((r'^%s$' % self.tag_permalink_base), self.tag_archive_view, name='tag_archive') ) - if self.entry_archive_page: + if self.entry_archive_page_id: if self.entry_permalink_style in 'DMY': - urlpatterns += self.feed_patterns(r'^(?P\d{4})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_year') + urlpatterns += self.feed_patterns(r'^(?P\d{4})', 'get_entries', 'entry_archive_page', 'entries_by_year') if self.entry_permalink_style in 'DM': - urlpatterns += self.feed_patterns(r'^(?P\d{4})/(?P\d{2})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_month') + urlpatterns += self.feed_patterns(r'^(?P\d{4})/(?P\d{2})', 'get_entries', 'entry_archive_page', 'entries_by_month') if self.entry_permalink_style == 'D': - urlpatterns += self.feed_patterns(r'^(?P\d{4})/(?P\d{2})/(?P\d{2})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_day') + urlpatterns += self.feed_patterns(r'^(?P\d{4})/(?P\d{2})/(?P\d{2})', 'get_entries', 'entry_archive_page', 'entries_by_day') if self.entry_permalink_style == 'D': urlpatterns += patterns('', @@ -421,62 +197,81 @@ class BlogView(FeedView): url((r'^%s/(?P[-\w]+)$' % self.entry_permalink_base), self.entry_view) ) else: - urlpatterns = patterns('', + urlpatterns += patterns('', url(r'^(?P[-\w]+)$', self.entry_view) ) return urlpatterns - def get_context(self): - return {'blog': self.blog} - - def get_entry_queryset(self): - return self.blog.entries.all() + def get_entry_queryset(self, obj): + """Returns the default :class:`QuerySet` of :class:`BlogEntry` instances for the :class:`BlogView` - all entries that are considered posted in the past. This allows for scheduled posting of entries.""" + return obj.entries.filter(date__lte=datetime.now()) - def get_tag_queryset(self): - return self.blog.entry_tags + def get_tag_queryset(self, obj): + """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 obj.entry_tags - def get_all_entries(self, request, extra_context=None): - return self.get_entry_queryset(), extra_context - - def get_entries_by_ymd(self, request, year=None, month=None, day=None, extra_context=None): - if not self.entry_archive_page: - raise Http404 - entries = self.get_entry_queryset() - if year: - entries = entries.filter(date__year=year) - if month: - entries = entries.filter(date__month=month) - if day: - entries = entries.filter(date__day=day) + def get_object(self, request, year=None, month=None, day=None, tag_slugs=None): + """Returns a dictionary representing the parameters for a feed which will be exposed.""" + if tag_slugs is None: + tags = None + else: + tag_slugs = tag_slugs.replace('+', '/').split('/') + tags = self.get_tag_queryset(self.blog).filter(slug__in=tag_slugs) + if not tags: + raise Http404 + + # Raise a 404 on an incorrect slug. + found_slugs = set([tag.slug for tag in tags]) + for slug in tag_slugs: + if slug and slug not in found_slugs: + raise Http404 - context = extra_context or {} - context.update({'year': year, 'month': month, 'day': day}) - return entries, context + try: + if year and month and day: + context_date = date(int(year), int(month), int(day)) + elif year and month: + context_date = date(int(year), int(month), 1) + elif year: + context_date = date(int(year), 1, 1) + else: + context_date = None + except TypeError, ValueError: + context_date = None + + return { + 'blog': self.blog, + 'tags': tags, + 'year': year, + 'month': month, + 'day': day, + 'date': context_date + } - def get_entries_by_tag(self, request, tag_slugs, extra_context=None): - tag_slugs = tag_slugs.replace('+', '/').split('/') - tags = self.get_tag_queryset().filter(slug__in=tag_slugs) + def get_entries(self, obj, request, year=None, month=None, day=None, tag_slugs=None, extra_context=None): + """Returns the :class:`BlogEntry` objects which will be exposed for the given object, as returned from :meth:`get_object`.""" + entries = self.get_entry_queryset(obj['blog']) - if not tags: - raise Http404 + if obj['tags'] is not None: + tags = obj['tags'] + for tag in tags: + entries = entries.filter(tags=tag) - # Raise a 404 on an incorrect slug. - found_slugs = [tag.slug for tag in tags] - for slug in tag_slugs: - if slug and slug not in found_slugs: - raise Http404 - - entries = self.get_entry_queryset() - for tag in tags: - entries = entries.filter(tags=tag) + if obj['date'] is not None: + if year: + entries = entries.filter(date__year=year) + if month: + entries = entries.filter(date__month=month) + if day: + entries = entries.filter(date__day=day) context = extra_context or {} - context.update({'tags': tags}) + context.update(obj) return entries, context def entry_view(self, request, slug, year=None, month=None, day=None, extra_context=None): - entries = self.get_entry_queryset() + """Renders :attr:`entry_page` with the entry specified by the given parameters.""" + entries = self.get_entry_queryset(self.blog) if year: entries = entries.filter(date__year=year) if month: @@ -493,39 +288,18 @@ 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() context.update(extra_context or {}) context.update({ - 'tags': self.get_tag_queryset() + 'tags': self.get_tag_queryset(self.blog) }) return self.tag_archive_page.render_to_response(request, extra_context=context) - def feed_view(self, get_items_attr, reverse_name): - get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr) - - def inner(request, extra_context=None, *args, **kwargs): - obj = self.get_object(request, *args, **kwargs) - feed = self.get_feed(obj, request, reverse_name) - items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs) - self.populate_feed(feed, items, request) - - if 'tags' in extra_context: - tags = extra_context['tags'] - feed.feed['link'] = request.node.construct_url(self.reverse(obj=tags), with_domain=True, request=request, secure=request.is_secure()) - else: - tags = obj.entry_tags - - feed.feed['categories'] = [tag.name for tag in tags] - - response = HttpResponse(mimetype=feed.mime_type) - feed.write(response, 'utf-8') - return response - - 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) @@ -541,7 +315,25 @@ class BlogView(FeedView): return items, item_context def title(self, obj): - return obj.title + title = obj['blog'].title + if obj['tags']: + title += u" – %s" % u", ".join((tag.name for tag in obj['tags'])) + date = obj['date'] + if date: + if obj['day']: + datestr = date.strftime("%F %j, %Y") + elif obj['month']: + datestr = date.strftime("%F, %Y") + elif obj['year']: + datestr = date.strftime("%Y") + title += u" – %s" % datestr + return title + + def categories(self, obj): + tags = obj['tags'] + if tags: + return (tag.name for tag in tags) + return None def item_title(self, item): return item.title @@ -559,26 +351,47 @@ 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 ` organized into :class:`issues `.""" + #: 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) - tags = models.ManyToManyField(Tag, related_name='newsletterarticles', blank=True, null=True) + #: A ``django-taggit`` :class:`TaggableManager`. + tags = TaggableManager() def save(self, *args, **kwargs): if self.date is None: 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 +401,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 +426,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 `.""" ARTICLE_PERMALINK_STYLE_CHOICES = ( ('D', 'Year, month, and day'), ('M', 'Year and month'), @@ -609,16 +434,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' @@ -629,7 +468,7 @@ class NewsletterView(FeedView): def get_reverse_params(self, obj): if isinstance(obj, NewsletterArticle): - if obj.newsletter == self.newsletter: + if obj.newsletter_id == self.newsletter_id: kwargs = {'slug': obj.slug} if self.article_permalink_style in 'DMY': kwargs.update({'year': str(obj.date.year).zfill(4)}) @@ -639,7 +478,7 @@ class NewsletterView(FeedView): kwargs.update({'day': str(obj.date.day).zfill(2)}) return self.article_view, [], kwargs elif isinstance(obj, NewsletterIssue): - if obj.newsletter == self.newsletter: + if obj.newsletter_id == self.newsletter_id: return 'issue', [], {'numbering': obj.numbering} elif isinstance(obj, (date, datetime)): kwargs = { @@ -655,14 +494,12 @@ class NewsletterView(FeedView): urlpatterns = self.feed_patterns(r'^', 'get_all_articles', 'index_page', 'index') + patterns('', url(r'^%s/(?P.+)$' % self.issue_permalink_base, self.page_view('get_articles_by_issue', 'issue_page'), name='issue') ) - if self.issue_archive_page: + if self.issue_archive_page_id: urlpatterns += patterns('', url(r'^%s$' % self.issue_permalink_base, self.issue_archive_view, 'issue_archive') ) - if self.article_archive_page: - urlpatterns += patterns('', - url(r'^%s' % self.article_permalink_base, include(self.feed_patterns('get_all_articles', 'article_archive_page', 'articles'))) - ) + if self.article_archive_page_id: + urlpatterns += self.feed_patterns(r'^%s' % self.article_permalink_base, 'get_all_articles', 'article_archive_page', 'articles') if self.article_permalink_style in 'DMY': urlpatterns += self.feed_patterns(r'^%s/(?P\d{4})' % self.article_permalink_base, 'get_articles_by_ymd', 'article_archive_page', 'articles_by_year') if self.article_permalink_style in 'DM': @@ -692,34 +529,40 @@ class NewsletterView(FeedView): def get_context(self): return {'newsletter': self.newsletter} - def get_article_queryset(self): - return self.newsletter.articles.all() + def get_article_queryset(self, obj): + """Returns the default :class:`QuerySet` of :class:`NewsletterArticle` instances for the :class:`NewsletterView` - all articles that are considered posted in the past. This allows for scheduled posting of articles.""" + return obj.articles.filter(date__lte=datetime.now()) - def get_issue_queryset(self): - return self.newsletter.issues.all() + def get_issue_queryset(self, obj): + """Returns the default :class:`QuerySet` of :class:`NewsletterIssue` instances for the :class:`NewsletterView`.""" + return obj.issues.all() - def get_all_articles(self, request, extra_context=None): - return self.get_article_queryset(), extra_context + def get_all_articles(self, obj, request, extra_context=None): + """Used to generate :meth:`~.FeedView.feed_patterns` for all entries.""" + return self.get_article_queryset(obj), extra_context - def get_articles_by_ymd(self, request, year, month=None, day=None, extra_context=None): - articles = self.get_article_queryset().filter(date__year=year) + def get_articles_by_ymd(self, obj, 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(obj).filter(date__year=year) if month: articles = articles.filter(date__month=month) if day: articles = articles.filter(date__day=day) return articles, extra_context - def get_articles_by_issue(self, request, numbering, extra_context=None): + def get_articles_by_issue(self, obj, 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) + issue = self.get_issue_queryset(obj).get(numbering=numbering) except NewsletterIssue.DoesNotExist: raise Http404 context = extra_context or {} context.update({'issue': issue}) - return self.get_article_queryset().filter(issues=issue), context + return self.get_article_queryset(obj).filter(issues=issue), context def article_view(self, request, slug, year=None, month=None, day=None, extra_context=None): - articles = self.get_article_queryset() + """Renders :attr:`article_page` with the article specified by the given parameters.""" + articles = self.get_article_queryset(self.newsletter) if year: articles = articles.filter(date__year=year) if month: @@ -736,12 +579,13 @@ 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() context.update(extra_context or {}) context.update({ - 'issues': self.get_issue_queryset() + 'issues': self.get_issue_queryset(self.newsletter) }) return self.issue_archive_page.render_to_response(request, extra_context=context) diff --git a/philo/contrib/penfield/templatetags/penfield.py b/philo/contrib/penfield/templatetags/penfield.py index 99e358c..b263a2b 100644 --- a/philo/contrib/penfield/templatetags/penfield.py +++ b/philo/contrib/penfield/templatetags/penfield.py @@ -1,22 +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): - monthnum = int(value) - if 1 <= monthnum <= 12: - return MONTHS[monthnum] - else: + """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('monthname', monthname) +@register.filter def apmonthname(value): - monthnum = int(value) - if 1 <= monthnum <= 12: - return MONTHS_AP[monthnum] - else: - return value - -register.filter('apmonthname', apmonthname) + """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 diff --git a/philo/contrib/penfield/validators.py b/philo/contrib/penfield/validators.py deleted file mode 100644 index 48eae06..0000000 --- a/philo/contrib/penfield/validators.py +++ /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/philo/contrib/shipherd/admin.py b/philo/contrib/shipherd/admin.py index 93d21e5..246693e 100644 --- a/philo/contrib/shipherd/admin.py +++ b/philo/contrib/shipherd/admin.py @@ -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 @@ -10,8 +11,9 @@ NAVIGATION_RAW_ID_FIELDS = ('navigation', 'parent', 'target_node') class NavigationItemInline(admin.StackedInline): raw_id_fields = NAVIGATION_RAW_ID_FIELDS model = NavigationItem - extra = 1 + extra = 0 sortable_field_name = 'order' + ordering = ('order',) related_lookup_fields = {'fk': raw_id_fields} @@ -68,7 +70,7 @@ class NodeNavigationItemInline(NavigationItemInline): class NodeNavigationInline(admin.TabularInline): model = Navigation - extra = 1 + extra = 0 NodeAdmin.inlines = [NodeNavigationInline, NodeNavigationItemInline] + NodeAdmin.inlines diff --git a/philo/contrib/shipherd/migrations/0003_auto__del_field_navigationitem_slug.py b/philo/contrib/shipherd/migrations/0003_auto__del_field_navigationitem_slug.py new file mode 100644 index 0000000..5d7d5e3 --- /dev/null +++ b/philo/contrib/shipherd/migrations/0003_auto__del_field_navigationitem_slug.py @@ -0,0 +1,74 @@ +# 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): + + # Deleting field 'NavigationItem.slug' + db.delete_column('shipherd_navigationitem', 'slug') + + + def backwards(self, orm): + + # User chose to not deal with backwards NULL issues for 'NavigationItem.slug' + raise RuntimeError("Cannot reverse this migration. 'NavigationItem.slug' and its values cannot be restored.") + + + 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', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'shipherd.navigation': { + 'Meta': {'unique_together': "(('node', 'key'),)", 'object_name': 'Navigation'}, + 'depth': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '3'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'node': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'navigation_set'", 'to': "orm['philo.Node']"}) + }, + 'shipherd.navigationitem': { + 'Meta': {'object_name': 'NavigationItem'}, + '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'}), + 'navigation': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'roots'", 'null': 'True', 'to': "orm['shipherd.Navigation']"}), + 'order': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['shipherd.NavigationItem']"}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'shipherd_navigationitem_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'text': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + } + } + + complete_apps = ['shipherd'] diff --git a/philo/contrib/shipherd/models.py b/philo/contrib/shipherd/models.py index 654f5f8..95be501 100644 --- a/philo/contrib/shipherd/models.py +++ b/philo/contrib/shipherd/models.py @@ -1,225 +1,156 @@ #encoding: utf-8 +from UserDict import DictMixin +from hashlib import sha1 + +from django.contrib.sites.models import Site +from django.core.cache import cache 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, TreeEntityManager, 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. 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 + self._cache = {} def __getitem__(self, key): - return Navigation.objects.get_cache_for(self.node)[key]['root_items'] - - def keys(self): - return Navigation.objects.get_cache_for(self.node).keys() + if key not in self._cache: + try: + self._cache[key] = Navigation.objects.get_for_node(self.node, key) + except Navigation.DoesNotExist: + self._cache[key] = None + return self._cache[key] def navigation(self): if not hasattr(self, '_navigation'): - self._navigation = NavigationQuerySetMapper(self) + self._navigation = NavigationMapper(self) return self._navigation Node.navigation = property(navigation) -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) - Navigation.objects.clear_cache() - - def delete(self, *args, **kwargs): - super(NavigationCacheQuerySet, self).delete(*args, **kwargs) - Navigation.objects.clear_cache() - - class NavigationManager(models.Manager): - # Since navigation is going to be hit frequently and changed - # relatively infrequently, cache it. Analogous to contenttypes. use_for_related = True - _cache = {} - def get_queryset(self): - return NavigationCacheQuerySet(self.model, using=self._db) - - def get_cache_for(self, node, update_targets=True): - created = False - if not self.has_cache_for(node): - self.create_cache_for(node) - created = True - - if update_targets and not created: - self.update_targets_for(node) - - return self.__class__._cache[self.db][node] - - def has_cache_for(self, node): - 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." - ancestors = node.get_ancestors(ascending=True, include_self=True) - - nodes_to_cache = [] - - for node in ancestors: - if self.has_cache_for(node): - cache = self.get_cache_for(node).copy() - break - else: - nodes_to_cache.insert(0, node) - else: - cache = {} - - for node in nodes_to_cache: - cache = cache.copy() - cache.update(self._build_cache_for(node)) - self.__class__._cache.setdefault(self.db, {})[node] = cache - - def _build_cache_for(self, node): - cache = {} - tree_id_attr = NavigationItem._mptt_meta.tree_id_attr - level_attr = NavigationItem._mptt_meta.level_attr - - for navigation in node.navigation_set.all(): - tree_ids = navigation.roots.values_list(tree_id_attr) - items = list(NavigationItem.objects.filter(**{'%s__in' % tree_id_attr: tree_ids, '%s__lt' % level_attr: navigation.depth}).order_by('order', 'lft')) + def get_for_node(self, node, key): + cache_key = self._get_cache_key(node, key) + cached = cache.get(cache_key) + + if cached is None: + opts = Node._mptt_meta + left = getattr(node, opts.left_attr) + right = getattr(node, opts.right_attr) + tree_id = getattr(node, opts.tree_id_attr) + kwargs = { + "node__%s__lte" % opts.left_attr: left, + "node__%s__gte" % opts.right_attr: right, + "node__%s" % opts.tree_id_attr: tree_id + } + navs = self.filter(key=key, **kwargs).select_related('node').order_by('-node__%s' % opts.level_attr) + nav = navs[0] + roots = nav.roots.all().select_related('target_node').order_by('order') + item_opts = NavigationItem._mptt_meta + by_pk = {} + tree_ids = [] - root_items = [] + site_root_node = Site.objects.get_current().root_node - for item in items: - item._is_cached = True - - if not hasattr(item, '_cached_children'): - item._cached_children = [] - - if item.parent: - # alternatively, if I don't want to force it to a list, I could keep track of - # instances where the parent hasn't yet been met and do this step later for them. - # delayed action. - item.parent = items[items.index(item.parent)] - if not hasattr(item.parent, '_cached_children'): - item.parent._cached_children = [] - item.parent._cached_children.append(item) - else: - root_items.append(item) + for root in roots: + by_pk[root.pk] = root + tree_ids.append(getattr(root, item_opts.tree_id_attr)) + root._cached_children = [] + if root.target_node: + root.target_node.get_path(root=site_root_node) + root.navigation = nav - cache[navigation.key] = { - 'navigation': navigation, - 'root_items': root_items, - 'items': items + kwargs = { + '%s__in' % item_opts.tree_id_attr: tree_ids, + '%s__lt' % item_opts.level_attr: nav.depth, + '%s__gt' % item_opts.level_attr: 0 } + items = NavigationItem.objects.filter(**kwargs).select_related('target_node').order_by('level', 'order') + for item in items: + by_pk[item.pk] = item + item._cached_children = [] + parent_pk = getattr(item, '%s_id' % item_opts.parent_attr) + item.parent = by_pk[parent_pk] + item.parent._cached_children.append(item) + if item.target_node: + item.target_node.get_path(root=site_root_node) + + cached = roots + cache.set(cache_key, cached) - 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. - if not self.has_cache_for(node): - # Already cleared. - return - - descendants = node.get_descendants(include_self=True) - cache = self.__class__._cache[self.db] - for node in descendants: - cache.pop(node, None) + return cached - 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. - caches = self.__class__._cache[self.db][node].values() - - target_pks = set() + def _get_cache_key(self, node, key): + opts = Node._mptt_meta + left = getattr(node, opts.left_attr) + right = getattr(node, opts.right_attr) + tree_id = getattr(node, opts.tree_id_attr) + parent_id = getattr(node, "%s_id" % opts.parent_attr) - for cache in caches: - target_pks |= set([item.target_node_id for item in cache['items']]) - - # A distinct query is not strictly necessary. TODO: benchmark the efficiency - # with/without distinct. - targets = list(Node.objects.filter(pk__in=target_pks).distinct()) - - for cache in caches: - for item in cache['items']: - if item.target_node_id: - item.target_node = targets[targets.index(item.target_node)] - - def clear_cache(self): - self.__class__._cache.pop(self.db, None) + return sha1(unicode(left) + unicode(right) + unicode(tree_id) + unicode(parent_id) + unicode(node.pk) + unicode(key)).hexdigest() 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:`Navigation`'s :class:`NavigationItem`\ s will be accessible from its related :class:`.Node` and that :class:`.Node`'s descendants through a :class:`NavigationMapper` instance at :attr:`Node.navigation`. Example:: + + >>> node.navigation_set.all() + [] + >>> parent = node.parent + >>> items = parent.navigation_set.get(key='main').roots.all() + >>> parent.navigation["main"] == node.navigation["main"] == list(items) + True + + """ + #: 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): - super(Navigation, self).__init__(*args, **kwargs) - self._initial_data = model_to_dict(self) - def __unicode__(self): return "%s[%s]" % (self.node, self.key) - def _has_changed(self): - return self._initial_data != model_to_dict(self) - - def save(self, *args, **kwargs): - super(Navigation, self).save(*args, **kwargs) - - if self._has_changed(): - Navigation.objects.clear_cache_for(self.node) - self._initial_data = model_to_dict(self) - - def delete(self, *args, **kwargs): - super(Navigation, self).delete(*args, **kwargs) - Navigation.objects.clear_cache_for(self.node) - class Meta: unique_together = ('node', 'key') -class NavigationItemManager(TreeManager): - use_for_related = True - - def get_queryset(self): - return NavigationCacheQuerySet(self.model, using=self._db) - - class NavigationItem(TreeEntity, TargetURLModel): - 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): - super(NavigationItem, self).__init__(*args, **kwargs) - self._initial_data = model_to_dict(self) - self._is_cached = False - - def __unicode__(self): - return self.get_path(field='text', pathsep=u' › ') + def get_path(self, root=None, pathsep=u' › ', field='text'): + return super(NavigationItem, self).get_path(root, pathsep, field) + path = property(get_path) def clean(self): super(NavigationItem, self).clean() @@ -227,6 +158,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. @@ -242,41 +174,21 @@ class NavigationItem(TreeEntity, TargetURLModel): # the same as the request path, check whether the target node is an ancestor # of the requested node. If so, this is active unless the target node # is the same as the ``host node`` for this navigation structure. - try: - host_node = self.get_root().navigation.node - except AttributeError: - pass - else: - if self.target_node != host_node and self.target_node.is_ancestor_of(request.node): - return True + root = self + + # The common case will be cached items, whose parents are cached with them. + while root.parent is not None: + root = root.parent + + host_node_id = root.navigation.node_id + if self.target_node.pk != host_node_id and self.target_node.is_ancestor_of(request.node): + return True 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 - return False - - def _has_changed(self): - if model_to_dict(self) == self._initial_data: - return False - return True - - def _clear_cache(self): - try: - root = self.get_root() - if self.get_level() < root.navigation.depth: - Navigation.objects.clear_cache_for(self.get_root().navigation.node) - except AttributeError: - pass - - def save(self, *args, **kwargs): - super(NavigationItem, self).save(*args, **kwargs) - - if self._has_changed(): - self._clear_cache() - - def delete(self, *args, **kwargs): - super(NavigationItem, self).delete(*args, **kwargs) - self._clear_cache() \ No newline at end of file + return False \ No newline at end of file diff --git a/philo/contrib/shipherd/templatetags/shipherd.py b/philo/contrib/shipherd/templatetags/shipherd.py index 4723246..4fae9c4 100644 --- a/philo/contrib/shipherd/templatetags/shipherd.py +++ b/philo/contrib/shipherd/templatetags/shipherd.py @@ -101,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: - 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: + * 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 :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 @@ -118,26 +119,32 @@ 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:: +
    - {% recursenavigation node main %} - - {{ navloop.item.text }} - {% if item.get_children %} -
      - {{ children }} -
    - {% endif %} - - {% endrecursenavigation %} + {% recursenavigation node "main" %} + + {{ item.text }} + {% if item.get_children %} +
      + {{ children }} +
    + {% endif %} + + {% endrecursenavigation %}
+ + .. note:: {% recursenavigation %} requires that the current :class:`HttpRequest` be present in the context as ``request``. The simplest way to do this is with the `request context processor`_. Simply make sure that ``django.core.context_processors.request`` is included in your :setting:`TEMPLATE_CONTEXT_PROCESSORS` setting. + + .. _request context processor: https://docs.djangoproject.com/en/dev/ref/templates/api/#django-core-context-processors-request + """ bits = token.contents.split() if len(bits) != 3: @@ -153,21 +160,17 @@ def recursenavigation(parser, token): @register.filter def has_navigation(node, key=None): + """Returns ``True`` if the node has a :class:`.Navigation` with the given key and ``False`` otherwise. If ``key`` is ``None``, returns whether the node has any :class:`.Navigation`\ s at all.""" try: - nav = node.navigation - if key is not None: - if key in nav and bool(node.navigation[key]): - return True - elif key not in node.navigation: - return False - return bool(node.navigation) + return bool(node.navigation[key]) except: return False @register.filter def navigation_host(node, key): + """Returns the :class:`.Node` which hosts the :class:`.Navigation` which ``node`` has inherited for ``key``. Returns ``node`` if any exceptions are encountered.""" try: - return Navigation.objects.filter(node__in=node.get_ancestors(include_self=True), key=key).order_by('-node__level')[0].node + return node.navigation[key].node except: return node \ No newline at end of file diff --git a/philo/contrib/sobol/__init__.py b/philo/contrib/sobol/__init__.py index 90eaf18..0458a83 100644 --- a/philo/contrib/sobol/__init__.py +++ b/philo/contrib/sobol/__init__.py @@ -1 +1,22 @@ +""" +Sobol implements a generic search interface, which can be used to search databases or websites. No assumptions are made about the search method. If SOBOL_USE_CACHE is ``True`` (default), the results will be cached using django's cache framework. Be aware that this may use a large number of cache entries, as a unique entry will be made for each search string for each type of search. + +Settings +-------- + +:setting:`SOBOL_USE_CACHE` + Whether sobol will use django's cache framework. Defaults to ``True``; this may cause a lot of entries in the cache. + +:setting:`SOBOL_USE_EVENTLET` + If :mod:`eventlet` is installed and this setting is ``True``, sobol web searches will use :mod:`eventlet.green.urllib2` instead of the built-in :mod:`urllib2` module. Default: ``False``. + +Templates +--------- + +For convenience, :mod:`.sobol` provides a template at ``sobol/search/_list.html`` which can be used with an ``{% include %}`` tag inside a full search page template to list the search results. The ``_list.html`` template also uses a basic jQuery script (``static/sobol/ajax_search.js``) to handle AJAX search result loading if the AJAX API of the current :class:`.SearchView` is enabled. If you want to use ``_list.html``, but want to provide your own version of jQuery or your own AJAX loading script, or if you want to include the basic script somewhere else (like inside the ````) simply do the following:: + + {% include "sobol/search/_list.html" with suppress_scripts=1 %} + +""" + from philo.contrib.sobol.search import * \ No newline at end of file diff --git a/philo/contrib/sobol/admin.py b/philo/contrib/sobol/admin.py index 87dd39a..6af7e4d 100644 --- a/philo/contrib/sobol/admin.py +++ b/philo/contrib/sobol/admin.py @@ -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): @@ -27,25 +29,7 @@ class SearchAdmin(admin.ModelAdmin): search_fields = ['string', 'result_urls__url'] actions = ['results_action'] if 'grappelli' in settings.INSTALLED_APPS: - results_template = 'admin/sobol/search/grappelli_results.html' - else: - results_template = 'admin/sobol/search/results.html' - - def get_urls(self): - urlpatterns = super(SearchAdmin, self).get_urls() - - def wrap(view): - def wrapper(*args, **kwargs): - return self.admin_site.admin_view(view)(*args, **kwargs) - return update_wrapper(wrapper, view) - - info = self.model._meta.app_label, self.model._meta.module_name - - urlpatterns = patterns('', - url(r'^results/$', wrap(self.results_view), name="%s_%s_selected_results" % info), - url(r'^(.+)/results/$', wrap(self.results_view), name="%s_%s_results" % info) - ) + urlpatterns - return urlpatterns + change_form_template = 'admin/sobol/search/grappelli_change_form.html' def unique_urls(self, obj): return obj.unique_urls @@ -58,41 +42,6 @@ class SearchAdmin(admin.ModelAdmin): def queryset(self, request): qs = super(SearchAdmin, self).queryset(request) return qs.annotate(total_clicks=Count('result_urls__clicks', distinct=True), unique_urls=Count('result_urls', distinct=True)) - - def results_action(self, request, queryset): - info = self.model._meta.app_label, self.model._meta.module_name - if len(queryset) == 1: - return HttpResponseRedirect(reverse("admin:%s_%s_results" % info, args=(queryset[0].pk,))) - else: - url = reverse("admin:%s_%s_selected_results" % info) - return HttpResponseRedirect("%s?ids=%s" % (url, ','.join([str(item.pk) for item in queryset]))) - results_action.short_description = "View results for selected %(verbose_name_plural)s" - - def results_view(self, request, object_id=None, extra_context=None): - if object_id is not None: - object_ids = [object_id] - else: - object_ids = request.GET.get('ids').split(',') - - if object_ids is None: - raise Http404 - - qs = self.queryset(request).filter(pk__in=object_ids) - opts = self.model._meta - - if len(object_ids) == 1: - title = _(u"Search results for %s" % qs[0]) - else: - title = _(u"Search results for multiple objects") - - context = { - 'title': title, - 'queryset': qs, - 'opts': opts, - 'root_path': self.admin_site.root_path, - 'app_label': opts.app_label - } - return render_to_response(self.results_template, context, context_instance=RequestContext(request)) class SearchViewAdmin(EntityAdmin): diff --git a/philo/contrib/sobol/forms.py b/philo/contrib/sobol/forms.py index e79d9e7..f9994a1 100644 --- a/philo/contrib/sobol/forms.py +++ b/philo/contrib/sobol/forms.py @@ -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 index 0000000..c94f54c --- /dev/null +++ b/philo/contrib/sobol/migrations/0001_initial.py @@ -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/migrations/__init__.py b/philo/contrib/sobol/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/philo/contrib/sobol/models.py b/philo/contrib/sobol/models.py index ee8187d..ffe5871 100644 --- a/philo/contrib/sobol/models.py +++ b/philo/contrib/sobol/models.py @@ -1,32 +1,46 @@ +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 import registry, get_search_instance from philo.contrib.sobol.forms import SearchForm -from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash +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 -from philo.validators import RedirectValidator -import datetime -try: - import eventlet -except: - eventlet = False + +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 this search's results ordered by decreasing weight." + """ + 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() @@ -43,14 +57,11 @@ class Search(models.Model): 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. + 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) @@ -68,6 +79,8 @@ class Search(models.Model): self._favored_results += subresults else: break + if len(self._favored_results) == len(results): + self._favored_results = [] return self._favored_results class Meta: @@ -76,13 +89,22 @@ class Search(models.Model): 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') - url = models.TextField(validators=[RedirectValidator()]) + #: 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() @@ -99,13 +121,17 @@ class ResultURL(models.Model): 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: @@ -127,12 +153,30 @@ class Click(models.Model): get_latest_by = 'datetime' +try: + from south.modelsinspector import add_introspection_rules +except ImportError: + pass +else: + add_introspection_rules([], ["^philo\.contrib\.sobol\.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 :obj:`.sobol.search.registry` searches = SlugMultipleChoiceField(choices=registry.iterchoices()) - enable_ajax_api = models.BooleanField("Enable AJAX API", default=True, help_text="Search results will be available only by AJAX, not as template variables.") + #: 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): @@ -152,10 +196,14 @@ class SearchView(MultiView): ) return urlpatterns - def get_search_instance(self, slug, 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() @@ -180,26 +228,33 @@ class SearchView(MultiView): 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 = [] + for slug in self.searches: + if slug in registry: + search_instance = 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 - }) + + 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, + 'favored_results': [] + }) + + try: + search = Search.objects.get(string=search_string) + except Search.DoesNotExist: + pass 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] - }) + context['favored_results'] = [r.url for r in search.get_favored_results()] else: form = SearchForm() @@ -208,17 +263,32 @@ class SearchView(MultiView): }) 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): + """ + Returns a JSON object containing the following variables: + + search + Contains the slug for the search. + results + Contains the results of :meth:`.Result.get_context` for each result. + rendered + 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: + if not request.is_ajax() or not self.enable_ajax_api or slug not in registry or slug not in self.searches or search_string is None: raise Http404 - search_instance = self.get_search_instance(slug, search_string) - response = HttpResponse(json.dumps({ + search_instance = get_search_instance(slug, search_string) + + return HttpResponse(json.dumps({ + 'search': search_instance.slug, 'results': [result.get_context() for result in search_instance.results], - })) - return response \ No newline at end of file + 'hasMoreResults': search_instance.has_more_results, + 'moreResultsURL': search_instance.more_results_url, + }), mimetype="application/json") \ No newline at end of file diff --git a/philo/contrib/sobol/search.py b/philo/contrib/sobol/search.py index 39b93c7..a79030a 100644 --- a/philo/contrib/sobol/search.py +++ b/philo/contrib/sobol/search.py @@ -1,4 +1,6 @@ #encoding: utf-8 +import datetime +from hashlib import sha1 from django.conf import settings from django.contrib.sites.models import Site @@ -8,248 +10,264 @@ from django.utils import simplejson as json from django.utils.http import urlquote_plus from django.utils.safestring import mark_safe from django.utils.text import capfirst -from django.template import loader, Context, Template -import datetime +from django.template import loader, Context, Template, TemplateDoesNotExist + from philo.contrib.sobol.utils import make_tracking_querydict +from philo.utils.registry import Registry -try: - from eventlet.green import urllib2 -except: + +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', 'registry', 'get_search_instance' ) -SEARCH_CACHE_KEY = 'philo_sobol_search_results' -DEFAULT_RESULT_TEMPLATE_STRING = "{% if url %}{% endif %}{{ title }}{% if url %}{% endif %}" -DEFAULT_RESULT_TEMPLATE = Template(DEFAULT_RESULT_TEMPLATE_STRING) +SEARCH_CACHE_SEED = 'philo_sobol_search_results' +USE_CACHE = getattr(settings, 'SOBOL_USE_CACHE', True) -# Determines the timeout on the entire result cache. -MAX_CACHE_TIMEOUT = 60*24*7 +#: A registry for :class:`BaseSearch` subclasses that should be available in the admin. +registry = Registry() -class RegistrationError(Exception): - pass - -class SearchRegistry(object): - # Holds a registry of search types by slug. - def __init__(self): - self._registry = {} - - def register(self, search, slug=None): - slug = slug or search.slug - if slug in self._registry: - registered = self._registry[slug] - if registered.__module__ != search.__module__: - raise RegistrationError("A different search is already registered as `%s`" % slug) - else: - self._registry[slug] = search - - def unregister(self, search, slug=None): - if slug is not None: - if slug in self._registry and self._registry[slug] == search: - del self._registry[slug] - raise RegistrationError("`%s` is not registered as `%s`" % (search, slug)) - else: - for slug, search in self._registry.items(): - if search == search: - del self._registry[slug] - - def items(self): - return self._registry.items() - - def iteritems(self): - return self._registry.iteritems() - - def iterchoices(self): - for slug, search in self.iteritems(): - yield slug, search.verbose_name - - def __getitem__(self, key): - return self._registry[key] - - def __iter__(self): - return self._registry.__iter__() +def _make_cache_key(search, search_arg): + return sha1(SEARCH_CACHE_SEED + search.slug + search_arg).hexdigest() -registry = SearchRegistry() +def get_search_instance(slug, search_arg): + """Returns a search instance for the given slug, either from the cache or newly-instantiated.""" + search = registry[slug] + search_arg = search_arg.lower() + if USE_CACHE: + key = _make_cache_key(search, search_arg) + cached = cache.get(key) + if cached: + return cached + instance = search(search_arg) + instance.slug = slug + return instance class Result(object): """ - 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): - qd = self.search.get_result_querydict(self.result) - if qd is None: - return "" - return "?%s" % qd.urlencode() + """Returns the url of the result or ``None`` by calling :meth:`BaseSearch.get_result_url` on the raw result. This url will contain a querystring which, if used, will track a :class:`.Click` for the actual url.""" + return self.search.get_result_url(self.result) + + def get_actual_url(self): + """Returns the actual url of the result by calling :meth:`BaseSearch.get_actual_result_url` on the raw result.""" + return self.search.get_actual_result_url(self.result) + + def get_content(self): + """Returns the content of the result by calling :meth:`BaseSearch.get_result_content` on the raw result.""" + return self.search.get_result_content(self.result) def get_template(self): + """Returns the template which will be used to render the :class:`Result` by calling :meth:`BaseSearch.get_result_template` on the raw result.""" return self.search.get_result_template(self.result) - def get_extra_context(self): - return self.search.get_result_extra_context(self.result) - def get_context(self): - context = self.get_extra_context() - context.update({ - 'title': self.get_title(), - 'url': self.get_url() - }) - return context + """ + Returns the context dictionary for the result. This is used both in rendering the result and in the AJAX return value for :meth:`.SearchView.ajax_api_view`. The context will contain the following keys: + + title + The result of calling :meth:`get_title` + url + The result of calling :meth:`get_url` + content + The result of calling :meth:`get_content` + + """ + if not hasattr(self, '_context'): + self._context = { + 'title': self.get_title(), + 'url': self.get_url(), + 'actual_url': self.get_actual_url(), + 'content': self.get_content() + } + return self._context def render(self): + """Returns the template from :meth:`get_template` rendered with the context from :meth:`get_context`.""" 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() + attrs['slug'] = name[:-6].lower() if name.endswith("Search") else name.lower() return super(BaseSearchMetaclass, cls).__new__(cls, name, bases, attrs) 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 - result_limit = 10 + #: The number of results to return from the complete list. Default: 5 + result_limit = 5 + #: How long the items for the search should be cached (in minutes). Default: 48 hours. _cache_timeout = 60*48 + #: The path to the template which will be used to render the :class:`Result`\ s for this search. If this is ``None``, then the framework will try ``sobol/search//result.html`` and ``sobol/search/result.html``. + result_template = None + #: The path to the template which will be used to generate the title of the :class:`Result`\ s for this search. If this is ``None``, then the framework will try ``sobol/search//title.html`` and ``sobol/search/title.html``. + title_template = None + #: The path to the template which will be used to generate the content of the :class:`Result`\ s for this search. If this is ``None``, then the framework will try ``sobol/search//content.html`` and ``sobol/search/content.html``. + content_template = None def __init__(self, search_arg): self.search_arg = search_arg - def _get_cached_results(self): - """Return the cached results if the results haven't timed out. Otherwise return None.""" - result_cache = cache.get(SEARCH_CACHE_KEY) - if result_cache and self.__class__ in result_cache and self.search_arg.lower() in result_cache[self.__class__]: - cached = result_cache[self.__class__][self.search_arg.lower()] - if cached['timeout'] >= datetime.datetime.now(): - return cached['results'] - return None - - def _set_cached_results(self, results, timeout): - """Sets the results to the cache for minutes.""" - result_cache = cache.get(SEARCH_CACHE_KEY) or {} - cached = result_cache.setdefault(self.__class__, {}).setdefault(self.search_arg.lower(), {}) - cached.update({ - 'results': results, - 'timeout': datetime.datetime.now() + datetime.timedelta(minutes=timeout) - }) - cache.set(SEARCH_CACHE_KEY, result_cache, MAX_CACHE_TIMEOUT) - @property def results(self): + """Retrieves cached results or initiates a new search via :meth:`get_results` and caches the results.""" if not hasattr(self, '_results'): - results = self._get_cached_results() - if results is None: - try: - # Cache one extra result so we can see if there are - # more results to be had. - limit = self.result_limit - if limit is not None: - limit += 1 - results = self.get_results(limit) - except: - if settings.DEBUG: - raise - # On exceptions, don't set any cache; just return. - return [] + try: + # Cache one extra result so we can see if there are + # more results to be had. + limit = self.result_limit + if limit is not None: + limit += 1 + results = self.get_results(limit) + except: + if settings.DEBUG: + raise + # On exceptions, don't set any cache; just return. + return [] - self._set_cached_results(results, self._cache_timeout) self._results = results + + if USE_CACHE: + for result in results: + result.get_context() + key = _make_cache_key(self, self.search_arg) + cache.set(key, self, self._cache_timeout) return self._results 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 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): - raise NotImplementedError - - def get_result_url(self, result): - "Subclasses override this to provide the actual URL for the result." + def get_actual_result_url(self, result): + """Returns the actual URL for the ``result`` or ``None`` if there is no URL. Must be implemented by subclasses.""" raise NotImplementedError def get_result_querydict(self, result): - url = self.get_result_url(result) + """Returns a querydict for tracking selection of the result, or ``None`` if there is no URL for the result.""" + url = self.get_actual_result_url(result) if url is None: return None return make_tracking_querydict(self.search_arg, url) + def get_result_url(self, result): + """Returns ``None`` or a url which, when accessed, will register a :class:`.Click` for that url.""" + qd = self.get_result_querydict(result) + if qd is None: + return None + return "?%s" % qd.urlencode() + + def get_result_title(self, result): + """Returns the title of the ``result``. By default, renders ``sobol/search//title.html`` or ``sobol/search/title.html`` with the result in the context. This can be overridden by setting :attr:`title_template` or simply overriding :meth:`get_result_title`. If no template can be found, this will raise :exc:`TemplateDoesNotExist`.""" + return loader.render_to_string(self.title_template or [ + 'sobol/search/%s/title.html' % self.slug, + 'sobol/search/title.html' + ], {'result': result}) + + def get_result_content(self, result): + """Returns the content for the ``result``. By default, renders ``sobol/search//content.html`` or ``sobol/search/content.html`` with the result in the context. This can be overridden by setting :attr:`content_template` or simply overriding :meth:`get_result_content`. If no template is found, this will return an empty string.""" + try: + return loader.render_to_string(self.content_template or [ + 'sobol/search/%s/content.html' % self.slug, + 'sobol/search/content.html' + ], {'result': result}) + except TemplateDoesNotExist: + return "" + def get_result_template(self, result): - if hasattr(self, 'result_template'): + """Returns the template to be used for rendering the ``result``. For a search with slug ``google``, this would first try ``sobol/search/google/result.html``, then fall back on ``sobol/search/result.html``. Subclasses can override this by setting :attr:`result_template` to the path of another template.""" + if self.result_template: return loader.get_template(self.result_template) - if not hasattr(self, '_result_template'): - self._result_template = DEFAULT_RESULT_TEMPLATE - return self._result_template - - def get_result_extra_context(self, result): - return {} + return loader.select_template([ + 'sobol/search/%s/result.html' % self.slug, + 'sobol/search/result.html' + ]) + @property 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. - """ - raise NotImplementedError + def get_actual_more_results_url(self): + """Returns the actual url for more results. By default, simply returns ``None``.""" + return None + + def get_more_results_querydict(self): + """Returns a :class:`QueryDict` for tracking whether people click on a 'more results' link.""" + url = self.get_actual_more_results_url() + if url: + return make_tracking_querydict(self.search_arg, url) + return None @property - def more_results_querydict(self): - return make_tracking_querydict(self.search_arg, self.more_results_url) + def more_results_url(self): + """Returns a URL which consists of a querystring which, when accessed, will log a :class:`.Click` for the actual URL.""" + qd = self.get_more_results_querydict() + if qd is None: + return None + return "?%s" % qd.urlencode() def __unicode__(self): - return ' '.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 +279,27 @@ 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." + + def get_actual_more_results_url(self): 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,20 +307,17 @@ 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)" + _more_results_url = None @property def query_format_str(self): @@ -314,6 +328,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): @@ -341,18 +356,17 @@ class GoogleSearch(JSONSearch): return True return False - @property - def more_results_url(self): + def get_actual_more_results_url(self): return self._more_results_url - def get_result_title(self, result): - return result['titleNoFormatting'] - - def get_result_url(self, result): + def get_actual_result_url(self, result): return result['unescapedUrl'] - def get_result_extra_context(self, result): - return result + def get_result_title(self, result): + return mark_safe(result['titleNoFormatting']) + + def get_result_content(self, result): + return mark_safe(result['content']) registry.register(GoogleSearch) @@ -365,13 +379,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 `_ + + """ 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 +404,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 `_ """ 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 `_ + 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/static/sobol/ajax_search.js b/philo/contrib/sobol/static/sobol/ajax_search.js new file mode 100644 index 0000000..b2ef413 --- /dev/null +++ b/philo/contrib/sobol/static/sobol/ajax_search.js @@ -0,0 +1,81 @@ +(function($){ + var sobol = window.sobol = {}; + sobol.favoredResults = [] + sobol.favoredResultSearch = null; + sobol.search = function(){ + var searches = sobol.searches = $('article.search'); + if(sobol.favoredResults.length) sobol.favoredResultSearch = searches.eq(0); + for (var i=sobol.favoredResults.length ? 1 : 0;i" + title + ""; + } else { + rendered += "
" + title + "
"; + } + if(content && content != ''){ + rendered += "
" + content + "
" + } + return rendered + } + sobol.addFavoredResult = function(result) { + var dl = sobol.favoredResultSearch.find('dl'); + if(!dl.length){ + dl = $('
'); + dl.appendTo(sobol.favoredResultSearch); + sobol.favoredResultSearch.removeClass('loading'); + } + dl[0].innerHTML += sobol.renderResult(result) + } + sobol.onSuccess = function(ele, data){ + // hook for success! + ele.removeClass('loading'); + if (data['results'].length) { + ele[0].innerHTML += "
"; + $.each(data['results'], function(i, v){ + ele[0].innerHTML += sobol.renderResult(v); + }) + ele[0].innerHTML += "
"; + if(data['hasMoreResults'] && data['moreResultsURL']) ele[0].innerHTML += ""; + } else { + ele.addClass('empty'); + ele[0].innerHTML += "

No results found.

"; + ele.slideUp(); + } + if (sobol.favoredResultSearch){ + for (var i=0;i"; + }; + $(sobol.search); +}(jQuery)); \ No newline at end of file diff --git a/philo/contrib/sobol/templates/admin/sobol/search/change_form.html b/philo/contrib/sobol/templates/admin/sobol/search/change_form.html new file mode 100644 index 0000000..8dfba08 --- /dev/null +++ b/philo/contrib/sobol/templates/admin/sobol/search/change_form.html @@ -0,0 +1,43 @@ +{% extends 'admin/change_form.html' %} +{% load i18n %} + +{% block javascripts %}{% endblock %} +{% block object-tools %}{% endblock %} +{% block title %}Results for "{{ original.string }}" | {% trans 'Django site admin' %}{% endblock %} +{% block content_title %}

Results for "{{ original.string }}"

{% endblock %} +{% block extrastyle %} + +{% endblock %} + +{% block content %} +
+ + + + + + + + + {% for result in original.get_weighted_results %} + + + + + {% endfor %} + +
WeightURL
{{ result.weight }}{{ result.url }}
+
+ {% block submit_row %} +
+ {% if not is_popup and has_delete_permission %}{% if change or show_delete %}{% endif %}{% endif %} +
+ {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/philo/contrib/sobol/templates/admin/sobol/search/change_list.html b/philo/contrib/sobol/templates/admin/sobol/search/change_list.html new file mode 100644 index 0000000..9b01661 --- /dev/null +++ b/philo/contrib/sobol/templates/admin/sobol/search/change_list.html @@ -0,0 +1,3 @@ +{% extends 'admin/change_list.html' %} + +{% block object-tools %}{% endblock %} \ No newline at end of file diff --git a/philo/contrib/sobol/templates/admin/sobol/search/grappelli_change_form.html b/philo/contrib/sobol/templates/admin/sobol/search/grappelli_change_form.html new file mode 100644 index 0000000..c89f748 --- /dev/null +++ b/philo/contrib/sobol/templates/admin/sobol/search/grappelli_change_form.html @@ -0,0 +1,23 @@ +{% extends 'admin/sobol/search/change_form.html' %} +{% load i18n %} + +{% block extrastyle %} + +{% endblock %} + +{% block submit_row %} + +{% endblock %} \ No newline at end of file diff --git a/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html b/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html deleted file mode 100644 index f01eb88..0000000 --- a/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "admin/base_site.html" %} - - -{% load i18n %} - - -{% block extrastyle %}{% endblock %} - - -{% block breadcrumbs %} - -{% endblock %} - - -{% block content %} -
- {% for search in queryset %} -
-

{{ search_string }}

-
-
-
-
Weight
-
URL
-
-
-
- {% for result in search.get_weighted_results %} -
-
{{ result.weight }}
-
{{ result.url }}
-
- {% endfor %} -
-
-
- {% endfor %} -
-{% endblock %} \ No newline at end of file diff --git a/philo/contrib/sobol/templates/admin/sobol/search/results.html b/philo/contrib/sobol/templates/admin/sobol/search/results.html deleted file mode 100644 index 24442c7..0000000 --- a/philo/contrib/sobol/templates/admin/sobol/search/results.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load i18n %} - -{% block extrastyle %}{% endblock %} - -{% block breadcrumbs %} - -{% endblock %} - - -{% block content %} - {% for search in queryset %} -
-

{{ search.string }}

- - - - - - - - - {% for result in search.get_weighted_results %} - - - - - {% endfor %} - -
WeightURL
{{ result.weight }}{{ result.url }}
-
- {% endfor %} -{% endblock %} \ No newline at end of file diff --git a/philo/contrib/sobol/templates/search/googlesearch.html b/philo/contrib/sobol/templates/search/googlesearch.html deleted file mode 100644 index 1b22388..0000000 --- a/philo/contrib/sobol/templates/search/googlesearch.html +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/philo/contrib/sobol/templates/sobol/search/_list.html b/philo/contrib/sobol/templates/sobol/search/_list.html new file mode 100644 index 0000000..99db761 --- /dev/null +++ b/philo/contrib/sobol/templates/sobol/search/_list.html @@ -0,0 +1,56 @@ +{% with node.view.enable_ajax_api as ajax %} +{% if ajax %} + {% if not suppress_scripts %}{% endif %} + +{% endif %} +{% if favored_results %} + +{% endif %} +{% for search in searches %} +
+
+ +

{{ search }}

+
+ {% if not ajax %} + {% if search.results %} +
+ {% for result in search.results %} + {{ result }} + {% endfor %} +
+ {% if search.has_more_results and search.more_results_url %} + + {% endif %} + {% else %} +

No results found.

+ {% endif %} + {% endif %} +
+{% endfor %} +{% endwith %} \ No newline at end of file diff --git a/philo/contrib/sobol/templates/sobol/search/content.html b/philo/contrib/sobol/templates/sobol/search/content.html new file mode 100644 index 0000000..82088ec --- /dev/null +++ b/philo/contrib/sobol/templates/sobol/search/content.html @@ -0,0 +1 @@ +{{ result.content|truncatewords_html:20 }} \ No newline at end of file diff --git a/philo/contrib/sobol/templates/sobol/search/result.html b/philo/contrib/sobol/templates/sobol/search/result.html new file mode 100644 index 0000000..c5a906a --- /dev/null +++ b/philo/contrib/sobol/templates/sobol/search/result.html @@ -0,0 +1,2 @@ +
{% if url %}{% endif %}{{ title }}{% if url %}{% endif %}
+{% if content %}
{{ content }}
{% endif %} \ No newline at end of file diff --git a/philo/contrib/sobol/utils.py b/philo/contrib/sobol/utils.py index 3c5e537..6fd5a49 100644 --- a/philo/contrib/sobol/utils.py +++ b/philo/contrib/sobol/utils.py @@ -1,8 +1,9 @@ +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 -from hashlib import sha1 SEARCH_ARG_GET_KEY = 'q' @@ -11,22 +12,40 @@ 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 QueryDict instance containing the information necessary - for tracking clicks of this url. - - NOTE: will this kind of initialization handle quoting correctly? - """ + """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)) - ) \ No newline at end of file + ) + + +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 diff --git a/philo/contrib/waldo/forms.py b/philo/contrib/waldo/forms.py index 2ee64d0..8e14ba5 100644 --- a/philo/contrib/waldo/forms.py +++ b/philo/contrib/waldo/forms.py @@ -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 `_ + + """ + #: 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) @@ -63,12 +74,37 @@ class UserAccountForm(forms.ModelForm): kwargs['instance'] = user super(UserAccountForm, self).__init__(*args, **kwargs) + def email_changed(self): + """Returns ``True`` if the email field changed value and ``False`` if it did not, or if there is no email field on the form. This method must be supplied by account forms used with :mod:`~philo.contrib.waldo`.""" + return 'email' in self.changed_data + + def reset_email(self): + """ + ModelForms modify their instances in-place during :meth:`_post_clean`; this method resets the email value to its initial state and returns the altered value. This is a method on the form to allow unusual behavior such as storing email on a :class:`UserProfile`. + + """ + email = self.instance.email + self.instance.email = self.initial['email'] + self.cleaned_data.pop('email') + return email + + @classmethod + def set_email(cls, user, email): + """ + Given a valid instance and an email address, correctly set the email address for that instance and save the changes. This is a class method in order to allow unusual behavior such as storing email on a :class:`UserProfile`. + + """ + user.email = email + user.save() + + class Meta: model = User fields = ('first_name', 'last_name', 'email') 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 +128,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 diff --git a/philo/contrib/waldo/models.py b/philo/contrib/waldo/models.py index f63cdb1..cdadead 100644 --- a/philo/contrib/waldo/models.py +++ b/philo/contrib/waldo/models.py @@ -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. @@ -96,7 +107,7 @@ class LoginMultiView(MultiView): return HttpResponseRedirect(redirect) else: - form = self.login_form() + form = self.login_form(request) request.session.set_test_cookie() @@ -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,33 +140,55 @@ 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 def urlpatterns(self): urlpatterns = super(PasswordMultiView, self).urlpatterns - if self.password_reset_page and self.password_reset_confirmation_email and self.password_set_page: + if self.password_reset_page_id and self.password_reset_confirmation_email_id and self.password_set_page_id: urlpatterns += patterns('', url(r'^password/reset$', csrf_protect(self.password_reset), name='password_reset'), url(r'^password/reset/(?P\w+)/(?P[^/]+)$', self.password_reset_confirm, name='password_reset_confirm'), ) - if self.password_change_page: + if self.password_change_page_id: urlpatterns += patterns('', url(r'^password/change$', csrf_protect(self.login_required(self.password_change)), name='password_change'), ) 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,15 +318,18 @@ 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 def urlpatterns(self): urlpatterns = super(RegistrationMultiView, self).urlpatterns - if self.register_page and self.register_confirmation_email: + if self.register_page_id and self.register_confirmation_email_id: urlpatterns += patterns('', url(r'^register$', csrf_protect(self.register), name='register'), url(r'^register/(?P\w+)/(?P[^/]+)$', self.register_confirm, name='register_confirm') @@ -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,45 +409,43 @@ 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 def urlpatterns(self): urlpatterns = super(AccountMultiView, self).urlpatterns - if self.manage_account_page: + if self.manage_account_page_id: urlpatterns += patterns('', url(r'^account$', self.login_required(self.account_view), name='account'), ) - if self.email_change_confirmation_email: + if self.email_change_confirmation_email_id: urlpatterns += patterns('', url(r'^account/email/(?P\w+)/(?P[\w.]+[+][\w.]+)/(?P[^/]+)$', self.email_change_confirm, name='email_change_confirm') ) 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) if form.is_valid(): message = "Account information saved." redirect = self.get_requirement_redirect(request, default='') - if 'email' in form.changed_data and self.email_change_confirmation_email: - # ModelForms modify their instances in-place during - # validation, so reset the instance's email to its - # previous value here, then remove the new value - # from cleaned_data. We only do this if an email - # change confirmation email is available. - request.user.email = form.initial['email'] - - email = form.cleaned_data.pop('email') + if form.email_changed() and self.email_change_confirmation_email: + email = form.reset_email() current_site = Site.objects.get_current() @@ -390,7 +457,7 @@ class AccountMultiView(RegistrationMultiView): } self.send_confirmation_email('Confirm account email change at %s' % current_site.domain, email, self.email_change_confirmation_email, context) - message = "An email has be sent to %s to confirm the email%s." % (email, bool(request.user.email) and " change" or "") + message = "An email has be sent to %s to confirm the email%s." % (email, " change" if bool(request.user.email) else "") if not request.user.email: message += " You will need to confirm the email before accessing pages that require a valid account." redirect = '' @@ -413,11 +480,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) @@ -425,7 +494,7 @@ class AccountMultiView(RegistrationMultiView): self.set_requirement_redirect(request, redirect=request.path) redirect = self.reverse('account', node=request.node) else: - redirect = node.get_absolute_url() + redirect = request.node.get_absolute_url() return HttpResponseRedirect(redirect) return view(request, *args, **kwargs) @@ -433,6 +502,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 +510,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 @@ -458,8 +531,7 @@ class AccountMultiView(RegistrationMultiView): raise Http404 if token_generator.check_token(user, email, token): - user.email = email - user.save() + self.account_form.set_email(user, email) messages.add_message(request, messages.SUCCESS, 'Email changed successfully.') if self.manage_account_page: redirect = self.reverse('account', node=request.node) diff --git a/philo/contrib/waldo/tokens.py b/philo/contrib/waldo/tokens.py index 80f0b11..1a7c3a9 100644 --- a/philo/contrib/waldo/tokens.py +++ b/philo/contrib/waldo/tokens.py @@ -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/contrib/winer/__init__.py b/philo/contrib/winer/__init__.py new file mode 100644 index 0000000..83fb303 --- /dev/null +++ b/philo/contrib/winer/__init__.py @@ -0,0 +1,4 @@ +""" +Winer provides the same API as `django's syndication Feed class `_, adapted to a Philo-style :class:`~philo.models.nodes.MultiView` for easy database management. Apps that need syndication can simply subclass :class:`~philo.contrib.winer.models.FeedView`, override a few methods, and start serving RSS and Atom feeds. See :class:`~philo.contrib.penfield.models.BlogView` for a concrete implementation example. + +""" \ No newline at end of file diff --git a/philo/contrib/winer/exceptions.py b/philo/contrib/winer/exceptions.py new file mode 100644 index 0000000..e2045f9 --- /dev/null +++ b/philo/contrib/winer/exceptions.py @@ -0,0 +1,3 @@ +class HttpNotAcceptable(Exception): + """This will be raised in :meth:`.FeedView.get_feed_type` if an Http-Accept header will not accept any of the feed content types that are available.""" + pass \ No newline at end of file diff --git a/philo/contrib/winer/feeds.py b/philo/contrib/winer/feeds.py new file mode 100644 index 0000000..0554591 --- /dev/null +++ b/philo/contrib/winer/feeds.py @@ -0,0 +1,13 @@ +from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed + +from philo.utils.registry import Registry + + +DEFAULT_FEED = Atom1Feed + + +registry = Registry() + + +registry.register(Atom1Feed, verbose_name='Atom') +registry.register(Rss201rev2Feed, verbose_name='RSS') \ No newline at end of file diff --git a/philo/contrib/penfield/middleware.py b/philo/contrib/winer/middleware.py similarity index 59% rename from philo/contrib/penfield/middleware.py rename to philo/contrib/winer/middleware.py index b25a28b..89a5bd2 100644 --- a/philo/contrib/penfield/middleware.py +++ b/philo/contrib/winer/middleware.py @@ -1,11 +1,11 @@ from django.http import HttpResponse from django.utils.decorators import decorator_from_middleware -from philo.contrib.penfield.exceptions import HttpNotAcceptable + +from philo.contrib.winer.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.winer.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) diff --git a/philo/contrib/winer/models.py b/philo/contrib/winer/models.py new file mode 100644 index 0000000..4acf5d1 --- /dev/null +++ b/philo/contrib/winer/models.py @@ -0,0 +1,347 @@ +from django.conf import settings +from django.conf.urls.defaults import url, patterns, include +from django.contrib.sites.models import Site, RequestSite +from django.contrib.syndication.views import add_domain +from django.db import models +from django.http import HttpResponse +from django.template import RequestContext, Template as DjangoTemplate +from django.utils import feedgenerator, tzinfo +from django.utils.encoding import smart_unicode, force_unicode +from django.utils.html import escape + +from philo.contrib.winer.exceptions import HttpNotAcceptable +from philo.contrib.winer.feeds import registry, DEFAULT_FEED +from philo.contrib.winer.middleware import http_not_acceptable +from philo.models import Page, Template, MultiView + +try: + import mimeparse +except: + mimeparse = None + + +class FeedView(MultiView): + """ + :class:`FeedView` is an abstract model which 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 `_. + + """ + #: The type of feed which should be served by the :class:`FeedView`. + feed_type = models.CharField(max_length=50, choices=registry.choices, default=registry.get_slug(DEFAULT_FEED)) + #: The suffix which will be appended to a page URL for a :attr:`feed_type` feed of its items. Default: "feed". Note that RSS and Atom feeds will always be available at ``/rss`` and ``/atom`` regardless of the value of this setting. + #: + #: .. seealso:: :meth:`get_feed_type`, :meth:`feed_patterns` + 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 will be used to render the title of each item in the feed if provided. + 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 will be used to render the description of each item in the feed if provided. + item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related") + + #: An attribute holding the name of the context variable to be populated with the items managed by the :class:`FeedView`. Default: "items" + item_context_var = 'items' + #: An attribute holding the name of the attribute on a subclass of :class:`FeedView` which will contain the main object of a feed (such as a :class:`~philo.contrib.penfield.models.Blog`.) Default: "object" + #: + #: Example:: + #: + #: class BlogView(FeedView): + #: blog = models.ForeignKey(Blog) + #: + #: object_attr = 'blog' + #: item_context_var = 'entries' + object_attr = 'object' + + #: An attribute holding 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. In addition to ``base`` (which will serve the page at ``page_attr``) and ``base`` + :attr:`feed_suffix` (which will serve a :attr:`feed_type` feed), patterns will be provided for each registered feed type as ``base`` + ``slug``. + + :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:: + + class BlogView(FeedView): + blog = models.ForeignKey(Blog) + entry_archive_page = models.ForeignKey(Page) + + @property + def urlpatterns(self): + urlpatterns = self.feed_patterns(r'^', 'get_all_entries', 'index_page', 'index') + urlpatterns += self.feed_patterns(r'^(?P\d{4})/(?P\d{2})/(?P\d{2})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_day') + return urlpatterns + + def get_entries_by_ymd(request, year, month, day, extra_context=None): + entries = Blog.entries.all() + # filter entries based on the year, month, and day. + return entries, extra_context + + .. seealso:: :meth:`get_feed_type` + + """ + feed_patterns = () + if self.feeds_enabled: + suffixes = [(self.feed_suffix, None)] + [(slug, slug) for slug in registry] + for suffix, feed_type in suffixes: + feed_view = http_not_acceptable(self.feed_view(get_items_attr, reverse_name, feed_type)) + feed_pattern = r'%s%s%s$' % (base, "/" if base and base[-1] != "^" else "", suffix) + feed_patterns += (url(feed_pattern, feed_view, name="%s_%s" % (reverse_name, suffix)),) + feed_patterns += (url(r"%s$" % base, self.page_view(get_items_attr, page_attr), name=reverse_name),) + return patterns('', *feed_patterns) + + 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, feed_type=None): + """ + 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 the object for the feed and view arguments. + :param reverse_name: The name which can be used reverse the page for this feed using the :class:`FeedView` as the urlconf. + :param feed_type: The slug used to render the feed class which will be used by the returned view function. + + :returns: A view function that renders a list of items as a feed. + + """ + get_items = get_items_attr if callable(get_items_attr) else getattr(self, get_items_attr) + + def inner(request, extra_context=None, *args, **kwargs): + obj = self.get_object(request, *args, **kwargs) + feed = self.get_feed(obj, request, reverse_name, feed_type, *args, **kwargs) + items, xxx = get_items(obj, request, extra_context=extra_context, *args, **kwargs) + self.populate_feed(feed, items, request) + + response = HttpResponse(mimetype=feed.mime_type) + feed.write(response, 'utf-8') + return response + + return inner + + def page_view(self, get_items_attr, page_attr): + """ + :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 = get_items_attr if callable(get_items_attr) else getattr(self, get_items_attr) + + def inner(request, extra_context=None, *args, **kwargs): + obj = self.get_object(request, *args, **kwargs) + items, extra_context = get_items(obj, request, extra_context=extra_context, *args, **kwargs) + items, item_context = self.process_page_items(request, items) + + context = self.get_context() + context.update(extra_context or {}) + context.update(item_context or {}) + + page = page_attr if isinstance(page_attr, Page) else getattr(self, page_attr) + return page.render_to_response(request, extra_context=context) + return inner + + def process_page_items(self, request, items): + """ + 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 + } + return items, item_context + + def get_feed_type(self, request, feed_type=None): + """ + If ``feed_type`` is not ``None``, returns the corresponding class from the registry or raises :exc:`.HttpNotAcceptable`. + + Otherwise, 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`, raises :exc:`.HttpNotAcceptable`. + + If `mimeparse `_ is installed, it will be used to select the best matching accepted format; otherwise, the first available format that is accepted will be selected. + + """ + if feed_type is not None: + feed_type = registry[feed_type] + loose = False + else: + feed_type = registry.get(self.feed_type, DEFAULT_FEED) + loose = True + mt = feed_type.mime_type + accept = request.META.get('HTTP_ACCEPT') + if accept and mt not in accept and "*/*" not in accept and "%s/*" % mt.split("/")[0] not in accept: + # Wups! They aren't accepting the chosen format. + feed_type = None + if loose: + # Is there another format we can use? + accepted_mts = dict([(obj.mime_type, obj) for obj in registry.values()]) + if mimeparse: + mt = mimeparse.best_match(accepted_mts.keys(), accept) + if mt: + feed_type = accepted_mts[mt] + else: + for mt in accepted_mts: + if mt in accept or "%s/*" % mt.split("/")[0] in accept: + feed_type = accepted_mts[mt] + break + if not feed_type: + raise HttpNotAcceptable + return feed_type + + def get_feed(self, obj, request, reverse_name, feed_type=None, *args, **kwargs): + """ + Returns an unpopulated :class:`django.utils.feedgenerator.DefaultFeed` object for this object. + + :param obj: The object for which the feed should be generated. + :param request: The current request. + :param reverse_name: The name which can be used to reverse the URL of the page corresponding to this feed. + :param feed_type: The slug used to register the feed class that will be instantiated and returned. + + :returns: An instance of the feed class registered as ``feed_type``, falling back to :attr:`feed_type` if ``feed_type`` is ``None``. + + """ + try: + current_site = Site.objects.get_current() + except Site.DoesNotExist: + current_site = RequestSite(request) + + feed_type = self.get_feed_type(request, feed_type) + node = request.node + link = node.construct_url(self.reverse(reverse_name, args=args, kwargs=kwargs), with_domain=True, request=request, secure=request.is_secure()) + + feed = feed_type( + title = self.__get_dynamic_attr('title', obj), + subtitle = self.__get_dynamic_attr('subtitle', obj), + link = link, + description = self.__get_dynamic_attr('description', obj), + language = settings.LANGUAGE_CODE.decode(), + feed_url = add_domain( + current_site.domain, + self.__get_dynamic_attr('feed_url', obj) or node.construct_url(self.reverse("%s_%s" % (reverse_name, registry.get_slug(feed_type)), args=args, kwargs=kwargs), with_domain=True, request=request, secure=request.is_secure()), + request.is_secure() + ), + author_name = self.__get_dynamic_attr('author_name', obj), + author_link = self.__get_dynamic_attr('author_link', obj), + author_email = self.__get_dynamic_attr('author_email', obj), + categories = self.__get_dynamic_attr('categories', obj), + feed_copyright = self.__get_dynamic_attr('feed_copyright', obj), + feed_guid = self.__get_dynamic_attr('feed_guid', obj), + ttl = self.__get_dynamic_attr('ttl', obj), + **self.feed_extra_kwargs(obj) + ) + 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: + title_template = None + if self.item_description_template: + description_template = DjangoTemplate(self.item_description_template.code) + else: + description_template = None + + node = request.node + try: + current_site = Site.objects.get_current() + except Site.DoesNotExist: + current_site = RequestSite(request) + + if self.feed_length is not None: + items = items[:self.feed_length] + + for item in items: + if title_template is not None: + title = title_template.render(RequestContext(request, {'obj': item})) + else: + title = self.__get_dynamic_attr('item_title', item) + if description_template is not None: + description = description_template.render(RequestContext(request, {'obj': item})) + else: + description = self.__get_dynamic_attr('item_description', item) + + link = node.construct_url(self.reverse(obj=item), with_domain=True, request=request, secure=request.is_secure()) + + enc = None + enc_url = self.__get_dynamic_attr('item_enclosure_url', item) + if enc_url: + enc = feedgenerator.Enclosure( + url = smart_unicode(add_domain( + current_site.domain, + enc_url, + request.is_secure() + )), + length = smart_unicode(self.__get_dynamic_attr('item_enclosure_length', item)), + mime_type = smart_unicode(self.__get_dynamic_attr('item_enclosure_mime_type', item)) + ) + author_name = self.__get_dynamic_attr('item_author_name', item) + if author_name is not None: + author_email = self.__get_dynamic_attr('item_author_email', item) + author_link = self.__get_dynamic_attr('item_author_link', item) + else: + author_email = author_link = None + + pubdate = self.__get_dynamic_attr('item_pubdate', item) + if pubdate and not pubdate.tzinfo: + ltz = tzinfo.LocalTimezone(pubdate) + pubdate = pubdate.replace(tzinfo=ltz) + + feed.add_item( + title = title, + link = link, + description = description, + unique_id = self.__get_dynamic_attr('item_guid', item, link), + enclosure = enc, + pubdate = pubdate, + author_name = author_name, + author_email = author_email, + author_link = author_link, + categories = self.__get_dynamic_attr('item_categories', item), + item_copyright = self.__get_dynamic_attr('item_copyright', item), + **self.item_extra_kwargs(item) + ) + + def __get_dynamic_attr(self, attname, obj, default=None): + try: + attr = getattr(self, attname) + except AttributeError: + return default + if callable(attr): + # Check func_code.co_argcount rather than try/excepting the + # function and catching the TypeError, because something inside + # the function may raise the TypeError. This technique is more + # accurate. + if hasattr(attr, 'func_code'): + argcount = attr.func_code.co_argcount + else: + argcount = attr.__call__.func_code.co_argcount + if argcount == 2: # one argument is 'self' + return attr(obj) + else: + return attr() + return attr + + def feed_extra_kwargs(self, obj): + """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.""" + return {} + + def item_title(self, item): + return escape(force_unicode(item)) + + def item_description(self, item): + return force_unicode(item) + + class Meta: + abstract=True \ No newline at end of file diff --git a/philo/exceptions.py b/philo/exceptions.py index f53083d..9f908c0 100644 --- a/philo/exceptions.py +++ b/philo/exceptions.py @@ -1,19 +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 View.reverse when the View does not provide subpaths (the default). """ + """Raised by :meth:`.View.reverse` when the :class:`.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. """ + """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 get_path if the root model is not an ancestor of the current model """ + """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 diff --git a/philo/fixtures/test_fixtures.json b/philo/fixtures/test_fixtures.json index 4c55372..2bda0d1 100644 --- a/philo/fixtures/test_fixtures.json +++ b/philo/fixtures/test_fixtures.json @@ -91,8 +91,8 @@ "rght": 143, "view_object_id": 1, "view_content_type": [ - "penfield", - "blogview" + "philo", + "page" ], "parent": 1, "level": 1, @@ -1236,7 +1236,7 @@ "model": "philo.redirect", "fields": { "status_code": 302, - "target": "second" + "url_or_subpath": "second" } }, { @@ -1382,47 +1382,5 @@ "template": 6, "title": "Tag Archive Page" } - }, - { - "pk": 1, - "model": "penfield.blog", - "fields": { - "slug": "free-lovin", - "title": "Free lovin'" - } - }, - { - "pk": 1, - "model": "penfield.blogentry", - "fields": { - "content": "Lorem ipsum.\r\n\r\nDolor sit amet.", - "author": 1, - "title": "First Entry", - "excerpt": "", - "blog": 1, - "date": "2010-10-20 10:38:58", - "slug": "first-entry", - "tags": [ - 1 - ] - } - }, - { - "pk": 1, - "model": "penfield.blogview", - "fields": { - "entry_archive_page": 5, - "tag_page": 4, - "feed_suffix": "feed", - "entry_permalink_style": "D", - "tag_permalink_base": "tags", - "feeds_enabled": true, - "entries_per_page": null, - "tag_archive_page": 6, - "blog": 1, - "entry_permalink_base": "entries", - "index_page": 2, - "entry_page": 3 - } } ] diff --git a/philo/forms/entities.py b/philo/forms/entities.py index e781128..ba72d7d 100644 --- a/philo/forms/entities.py +++ b/philo/forms/entities.py @@ -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): diff --git a/philo/forms/fields.py b/philo/forms/fields.py index b148947..66b96ad 100644 --- a/philo/forms/fields.py +++ b/philo/forms/fields.py @@ -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): diff --git a/philo/loaders/database.py b/philo/loaders/database.py index 141aedd..4c9c379 100644 --- a/philo/loaders/database.py +++ b/philo/loaders/database.py @@ -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): diff --git a/philo/middleware.py b/philo/middleware.py index 5ec3e77..f4f7e9d 100644 --- a/philo/middleware.py +++ b/philo/middleware.py @@ -1,57 +1,54 @@ from django.conf import settings from django.contrib.sites.models import Site from django.http import Http404 + from philo.models import Node, View +from philo.utils.lazycompat import SimpleLazyObject -class LazyNode(object): - def __get__(self, request, obj_type=None): - if not hasattr(request, '_cached_node_path'): - return None - - if not hasattr(request, '_found_node'): - try: - current_site = Site.objects.get_current() - except Site.DoesNotExist: - current_site = None - - path = request._cached_node_path - trailing_slash = False - if path[-1] == '/': - trailing_slash = True - - try: - node, subpath = Node.objects.get_with_path(path, root=getattr(current_site, 'root_node', None), absolute_result=False) - except Node.DoesNotExist: - node = None - else: - if subpath is None: - subpath = "" - subpath = "/" + subpath - - if not node.handles_subpath(subpath): - node = None - else: - if trailing_slash and subpath[-1] != "/": - subpath += "/" - - node.subpath = subpath - - request._found_node = node - - return request._found_node +def get_node(path): + """Returns a :class:`Node` instance at ``path`` (relative to the current site) or ``None``.""" + try: + current_site = Site.objects.get_current() + except Site.DoesNotExist: + current_site = None + + trailing_slash = False + if path[-1] == '/': + trailing_slash = True + + try: + node, subpath = Node.objects.get_with_path(path, root=getattr(current_site, 'root_node', None), absolute_result=False) + except Node.DoesNotExist: + return None + + if subpath is None: + subpath = "" + subpath = "/" + subpath + + if trailing_slash and subpath[-1] != "/": + subpath += "/" + + node._path = path + node._subpath = subpath + + return node class RequestNodeMiddleware(object): - """Middleware to process the request's path and attach the closest ancestor node.""" - def process_request(self, request): - request.__class__.node = LazyNode() + """ + Adds a ``node`` attribute, representing the currently-viewed :class:`.Node`, to every incoming :class:`HttpRequest` object. This is required by :func:`philo.views.node_view`. + :class:`RequestNodeMiddleware` also catches all exceptions raised while handling requests that have attached :class:`.Node`\ s if :setting:`settings.DEBUG` is ``True``. If a :exc:`django.http.Http404` error was caught, :class:`RequestNodeMiddleware` will look for an "Http404" :class:`.Attribute` on the request's :class:`.Node`; otherwise it will look for an "Http500" :class:`.Attribute`. If an appropriate :class:`.Attribute` is found, and the value of the attribute is a :class:`.View` instance, then the :class:`.View` will be rendered with the exception in the ``extra_context``, bypassing any later handling of exceptions. + + """ def process_view(self, request, view_func, view_args, view_kwargs): try: - request._cached_node_path = view_kwargs['path'] + path = view_kwargs['path'] except KeyError: - pass + request.node = None + else: + request.node = SimpleLazyObject(lambda: get_node(path)) def process_exception(self, request, exception): if settings.DEBUG or not hasattr(request, 'node') or not request.node: @@ -59,12 +56,16 @@ class RequestNodeMiddleware(object): if isinstance(exception, Http404): error_view = request.node.attributes.get('Http404', None) + status_code = 404 else: error_view = request.node.attributes.get('Http500', None) + status_code = 500 if error_view is None or not isinstance(error_view, View): # Should this be duck-typing? Perhaps even no testing? return extra_context = {'exception': exception} - return error_view.render_to_response(request, extra_context) \ No newline at end of file + response = error_view.render_to_response(request, extra_context) + response.status_code = status_code + return response \ No newline at end of file diff --git a/philo/migrations/0015_auto__add_unique_node_slug_parent__add_unique_template_slug_parent.py b/philo/migrations/0015_auto__add_unique_node_slug_parent__add_unique_template_slug_parent.py new file mode 100644 index 0000000..7a79fec --- /dev/null +++ b/philo/migrations/0015_auto__add_unique_node_slug_parent__add_unique_template_slug_parent.py @@ -0,0 +1,144 @@ +# 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 unique constraint on 'Node', fields ['slug', 'parent'] + db.create_unique('philo_node', ['slug', 'parent_id']) + + # Adding unique constraint on 'Template', fields ['slug', 'parent'] + db.create_unique('philo_template', ['slug', 'parent_id']) + + + def backwards(self, orm): + + # Removing unique constraint on 'Template', fields ['slug', 'parent'] + db.delete_unique('philo_template', ['slug', 'parent_id']) + + # Removing unique constraint on 'Node', fields ['slug', 'parent'] + db.delete_unique('philo_node', ['slug', 'parent_id']) + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'ordering': "('name',)", 'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['philo'] diff --git a/philo/migrations/0016_auto__add_field_file_name.py b/philo/migrations/0016_auto__add_field_file_name.py new file mode 100644 index 0000000..0d8e654 --- /dev/null +++ b/philo/migrations/0016_auto__add_field_file_name.py @@ -0,0 +1,139 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding field 'File.name' + db.add_column('philo_file', 'name', self.gf('django.db.models.fields.CharField')(default='', max_length=255), keep_default=False) + + + def backwards(self, orm): + + # Deleting field 'File.name' + db.delete_column('philo_file', 'name') + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'ordering': "('name',)", 'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['philo'] diff --git a/philo/migrations/0017_generate_filenames.py b/philo/migrations/0017_generate_filenames.py new file mode 100644 index 0000000..613ac7a --- /dev/null +++ b/philo/migrations/0017_generate_filenames.py @@ -0,0 +1,139 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models + +class Migration(DataMigration): + + def forwards(self, orm): + "Write your forwards methods here." + for f in orm.File.objects.filter(name=""): + f.name = f.file.name + f.save() + + + def backwards(self, orm): + "Write your backwards methods here." + pass + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'ordering': "('name',)", 'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['philo'] diff --git a/philo/migrations/0018_auto__chg_field_node_view_object_id__chg_field_node_view_content_type.py b/philo/migrations/0018_auto__chg_field_node_view_object_id__chg_field_node_view_content_type.py new file mode 100644 index 0000000..75a3dee --- /dev/null +++ b/philo/migrations/0018_auto__chg_field_node_view_object_id__chg_field_node_view_content_type.py @@ -0,0 +1,145 @@ +# 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): + + # Changing field 'Node.view_object_id' + db.alter_column('philo_node', 'view_object_id', self.gf('django.db.models.fields.PositiveIntegerField')(null=True)) + + # Changing field 'Node.view_content_type' + db.alter_column('philo_node', 'view_content_type_id', self.gf('django.db.models.fields.related.ForeignKey')(null=True, to=orm['contenttypes.ContentType'])) + + + def backwards(self, orm): + + # User chose to not deal with backwards NULL issues for 'Node.view_object_id' + raise RuntimeError("Cannot reverse this migration. 'Node.view_object_id' and its values cannot be restored.") + + # User chose to not deal with backwards NULL issues for 'Node.view_content_type' + raise RuntimeError("Cannot reverse this migration. 'Node.view_content_type' and its values cannot be restored.") + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'ordering': "('name',)", 'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['philo'] diff --git a/philo/migrations/0019_to_taggit.py b/philo/migrations/0019_to_taggit.py new file mode 100644 index 0000000..fb5e8f0 --- /dev/null +++ b/philo/migrations/0019_to_taggit.py @@ -0,0 +1,155 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models + +class Migration(DataMigration): + + def forwards(self, orm): + "Write your forwards methods here." + # If any tags are longer than 100, this will result in some data loss. + PhiloTag = orm['philo.Tag'] + Tag = orm['taggit.Tag'] + + for tag in PhiloTag.objects.all(): + Tag.objects.get_or_create(name=tag.name, slug=tag.slug) + + + def backwards(self, orm): + "Write your backwards methods here." + pass + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'ordering': "('name',)", 'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + }, + 'taggit.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100', 'db_index': 'True'}) + }, + 'taggit.taggeditem': { + 'Meta': {'object_name': 'TaggedItem'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_tagged_items'", 'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_items'", 'to': "orm['taggit.Tag']"}) + } + } + + complete_apps = ['taggit', 'philo'] diff --git a/philo/migrations/0020_from_taggit.py b/philo/migrations/0020_from_taggit.py new file mode 100644 index 0000000..9a43df9 --- /dev/null +++ b/philo/migrations/0020_from_taggit.py @@ -0,0 +1,154 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models + +class Migration(DataMigration): + + def forwards(self, orm): + "Write your forwards methods here." + pass + + + def backwards(self, orm): + "Write your backwards methods here." + PhiloTag = orm['philo.Tag'] + Tag = orm['taggit.Tag'] + + for tag in Tag.objects.all(): + PhiloTag.objects.get_or_create(name=tag.name, slug=tag.slug) + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'ordering': "('name',)", 'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + }, + 'taggit.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100', 'db_index': 'True'}) + }, + 'taggit.taggeditem': { + 'Meta': {'object_name': 'TaggedItem'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_tagged_items'", 'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'taggit_taggeditem_items'", 'to': "orm['taggit.Tag']"}) + } + } + + complete_apps = ['taggit', 'philo'] diff --git a/philo/migrations/0021_auto__del_tag.py b/philo/migrations/0021_auto__del_tag.py new file mode 100644 index 0000000..f63b906 --- /dev/null +++ b/philo/migrations/0021_auto__del_tag.py @@ -0,0 +1,138 @@ +# 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): + + # Deleting model 'Tag' + db.delete_table('philo_tag') + + + def backwards(self, orm): + + # Adding model 'Tag' + db.create_table('philo_tag', ( + ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255, unique=True, db_index=True)), + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=255)), + )) + db.send_create_signal('philo', ['Tag']) + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'node_view_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.template': { + 'Meta': {'unique_together': "(('parent', 'slug'),)", 'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['philo'] diff --git a/philo/models/__init__.py b/philo/models/__init__.py index 523f789..3942b84 100644 --- a/philo/models/__init__.py +++ b/philo/models/__init__.py @@ -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 diff --git a/philo/models/base.py b/philo/models/base.py index cf420c7..e7918f5 100644 --- a/philo/models/base.py +++ b/philo/models/base.py @@ -1,44 +1,25 @@ 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.exceptions import ValidationError 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 -class Tag(models.Model): - name = models.CharField(max_length=255) - slug = models.SlugField(max_length=255, unique=True) - - def __unicode__(self): - return self.name - - class Meta: - app_label = 'philo' - ordering = ('name',) +__all__ = ('value_content_type_limiter', 'register_value_model', 'unregister_value_model', 'JSONValue', 'ForeignKeyValue', 'ManyToManyValue', 'Attribute', 'Entity', 'TreeEntity', 'SlugTreeEntity') -class Titled(models.Model): - title = models.CharField(max_length=255) - slug = models.SlugField(max_length=255) - - def __unicode__(self): - return self.title - - class Meta: - abstract = True - - -#: An instance of :class:`ContentTypeRegistryLimiter` which is used to track the content types which can be related to by ForeignKeyValues and ManyToManyValues. +#: 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() @@ -47,9 +28,6 @@ def register_value_model(model): value_content_type_limiter.register_class(model) -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) @@ -91,7 +69,7 @@ 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`. +#: 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) @@ -237,7 +215,12 @@ 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`.""" + """ + :class:`Attribute`\ s exist primarily to let arbitrary data be attached to arbitrary model instances without altering the database schema and without guaranteeing that the data will be available on every instance of that model. + + Generally, :class:`Attribute`\ s will not be accessed as models; instead, they will be accessed through the :attr:`Entity.attributes` property, which allows direct dictionary getting and setting of the value of an :class:`Attribute` with its key. + + """ 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) @@ -256,35 +239,26 @@ class Attribute(models.Model): 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: @@ -312,10 +286,9 @@ class Entity(models.Model): attribute_set = generic.GenericRelation(Attribute, content_type_field='entity_content_type', object_id_field='entity_object_id') - @property - def attributes(self): + def get_attribute_mapper(self, mapper=AttributeMapper): """ - Property that returns a dictionary-like object which can be used to retrieve related :class:`Attribute`\ s' values directly. + Returns an :class:`.AttributeMapper` which can be used to retrieve related :class:`Attribute`\ s' values directly. Example:: @@ -326,19 +299,32 @@ class Entity(models.Model): u'eggs' """ - - return QuerySetMapper(self.attribute_set.all()) + return mapper(self) + + @property + def attributes(self): + if not hasattr(self, '_attributes'): + self._attributes = self.get_attribute_mapper() + return self._attributes class Meta: abstract = True -class TreeManager(models.Manager): +class TreeEntityBase(MPTTModelBase, EntityBase): + def __new__(meta, name, bases, attrs): + attrs['_mptt_meta'] = MPTTOptions(attrs.pop('MPTTMeta', None)) + cls = EntityBase.__new__(meta, name, bases, attrs) + + return meta.register(cls) + + +class TreeEntityManager(models.Manager): use_for_related_fields = True - def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='slug'): + def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='pk'): """ - If ``absolute_result`` is ``True``, returns the object at ``path`` (starting at ``root``) or raises a :exception:`DoesNotExist` 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). + 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. @@ -349,7 +335,8 @@ class TreeManager(models.Manager): :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 (instance, remaining_path) otherwise. + :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. """ @@ -445,16 +432,19 @@ class TreeManager(models.Manager): return find_obj(segments, len(segments)/2 or len(segments)) -class TreeModel(MPTTModel): - objects = TreeManager() +class TreeEntity(Entity, MPTTModel): + """An abstract subclass of Entity which represents a tree relationship.""" + + __metaclass__ = TreeEntityBase + objects = TreeEntityManager() parent = models.ForeignKey('self', related_name='children', null=True, blank=True) - slug = models.SlugField(max_length=255) - def get_path(self, root=None, pathsep='/', field='slug'): + def get_path(self, root=None, pathsep='/', field='pk', memoize=True): """ :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. + :param memoize: Whether to use memoized results. Since, in most cases, the ancestors of a TreeEntity will not change over the course of an instance's lifetime, this defaults to ``True``. :returns: A string representation of an object's path. """ @@ -462,42 +452,38 @@ class TreeModel(MPTTModel): if root == self: return '' + parent_id = getattr(self, "%s_id" % self._mptt_meta.parent_attr) + if getattr(root, 'pk', None) == parent_id: + return getattr(self, field, '?') + if root is not None and not self.is_descendant_of(root): raise AncestorDoesNotExist(root) + if memoize: + memo_args = (parent_id, getattr(root, 'pk', None), pathsep, getattr(self, field, '?')) + try: + return self._path_memo[memo_args] + except AttributeError: + self._path_memo = {} + except KeyError: + pass + qs = self.get_ancestors(include_self=True) if root is not None: qs = qs.filter(**{'%s__gt' % self._mptt_meta.level_attr: root.get_level()}) - return pathsep.join([getattr(parent, field, '?') for parent in qs]) - path = property(get_path) - - def __unicode__(self): - return self.path - - class Meta: - unique_together = (('parent', 'slug'),) - abstract = True - - -class TreeEntityBase(MPTTModelBase, EntityBase): - def __new__(meta, name, bases, attrs): - attrs['_mptt_meta'] = MPTTOptions(attrs.pop('MPTTMeta', None)) - cls = EntityBase.__new__(meta, name, bases, attrs) + path = pathsep.join([getattr(parent, field, '?') for parent in qs]) - return meta.register(cls) - - -class TreeEntity(Entity, TreeModel): - """An abstract subclass of Entity which represents a tree relationship.""" - - __metaclass__ = TreeEntityBase + if memoize: + self._path_memo[memo_args] = path + + return path + path = property(get_path) - @property - def attributes(self): + def get_attribute_mapper(self, mapper=None): """ - Property that returns a dictionary-like object which can be used to retrieve related :class:`Attribute`\ s' values directly. If an attribute with a given key is not related to the :class:`Entity`, then the object will check the parent's attributes. + 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:: @@ -510,10 +496,42 @@ class TreeEntity(Entity, TreeModel): u'eggs' """ - - if self.parent: - return QuerySetMapper(self.attribute_set.all(), passthrough=self.parent.attributes) - return super(TreeEntity, self).attributes + if mapper is None: + if getattr(self, "%s_id" % self._mptt_meta.parent_attr): + mapper = TreeAttributeMapper + else: + mapper = AttributeMapper + return super(TreeEntity, self).get_attribute_mapper(mapper) + + def __unicode__(self): + return self.path + + class Meta: + abstract = True + + +class SlugTreeEntityManager(TreeEntityManager): + def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='slug'): + return super(SlugTreeEntityManager, self).get_with_path(path, root, absolute_result, pathsep, field) + + +class SlugTreeEntity(TreeEntity): + objects = SlugTreeEntityManager() + slug = models.SlugField(max_length=255) + + def get_path(self, root=None, pathsep='/', field='slug', memoize=True): + return super(SlugTreeEntity, self).get_path(root, pathsep, field, memoize) + path = property(get_path) + + def clean(self): + if getattr(self, "%s_id" % self._mptt_meta.parent_attr) is None: + try: + self._default_manager.exclude(pk=self.pk).get(slug=self.slug, parent__isnull=True) + except self.DoesNotExist: + pass + else: + raise ValidationError(self.unique_error_message(self.__class__, ('parent', 'slug'))) class Meta: + unique_together = ('parent', 'slug') abstract = True \ No newline at end of file diff --git a/philo/models/collections.py b/philo/models/collections.py index 539ecdb..be7b706 100644 --- a/philo/models/collections.py +++ b/philo/models/collections.py @@ -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() + [, , ] + >>> collection.members.with_model(User) + [, ] + + """ 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 diff --git a/philo/models/fields/__init__.py b/philo/models/fields/__init__.py index 1f9603e..7ab4326 100644 --- a/philo/models/fields/__init__.py +++ b/philo/models/fields/__init__.py @@ -5,12 +5,15 @@ 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.utils.registry import RegistryIterator 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 +43,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 +72,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. Also knows how to correctly handle :class:`RegistryIterator`\ s passed in as choices.""" __metaclass__ = models.SubfieldBase description = _("Comma-separated slug field") @@ -123,6 +128,16 @@ class SlugMultipleChoiceField(models.Field): if invalid_values: # should really make a custom message. raise ValidationError(self.error_messages['invalid_choice'] % invalid_values) + + 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: diff --git a/philo/models/fields/entities.py b/philo/models/fields/entities.py index 6c407d0..0558d3e 100644 --- a/philo/models/fields/entities.py +++ b/philo/models/fields/entities.py @@ -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 ` 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 ` 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) diff --git a/philo/models/nodes.py b/philo/models/nodes.py index 99be196..647ba81 100644 --- a/philo/models/nodes.py +++ b/philo/models/nodes.py @@ -1,59 +1,103 @@ -from django.db import models -from django.contrib.contenttypes.models import ContentType +from inspect import getargspec +import mimetypes +from os.path import basename + +from django.conf import settings 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.cache import cache 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 SlugTreeEntity, 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) +CACHE_PHILO_ROOT = getattr(settings, "PHILO_CACHE_PHILO_ROOT", True) -class Node(TreeEntity): - view_content_type = models.ForeignKey(ContentType, related_name='node_view_set', limit_choices_to=_view_content_type_limiter) - view_object_id = models.PositiveIntegerField() +class Node(SlugTreeEntity): + """ + :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, blank=True, null=True) + view_object_id = models.PositiveIntegerField(blank=True, null=True) + #: :class:`GenericForeignKey` to a non-abstract subclass of :class:`View` view = generic.GenericForeignKey('view_content_type', 'view_object_id') @property def accepts_subpath(self): - if self.view: - return self.view.accepts_subpath + """A property shortcut for :attr:`self.view.accepts_subpath `""" + if self.view_object_id and self.view_content_type_id: + return ContentType.objects.get_for_id(self.view_content_type_id).model_class().accepts_subpath return False def handles_subpath(self, subpath): - return self.view.handles_subpath(subpath) + if self.view_object_id and self.view_content_type_id: + return ContentType.objects.get_for_id(self.view_content_type_id).model_class().handles_subpath(subpath) + return False def render_to_response(self, request, extra_context=None): - return self.view.render_to_response(request, extra_context) + """This is a shortcut method for :meth:`View.render_to_response`""" + if self.view_object_id and self.view_content_type_id: + view_model = ContentType.objects.get_for_id(self.view_content_type_id).model_class() + self.view = view_model._default_manager.get(pk=self.view_object_id) + return self.view.render_to_response(request, extra_context) + raise Http404 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`. + + Because this method will be called frequently and will always try to reverse ``philo-root``, the results of that reversal will be cached by default. This can be disabled by setting :setting:`PHILO_CACHE_PHILO_ROOT` to ``False``. + + :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 ` 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') + if CACHE_PHILO_ROOT: + key = "CACHE_PHILO_ROOT__" + settings.ROOT_URLCONF + root_url = cache.get(key) + if root_url is None: + root_url = reverse('philo-root') + cache.set(key, root_url) + else: + root_url = reverse('philo-root') try: current_site = Site.objects.get_current() @@ -80,7 +124,7 @@ class Node(TreeEntity): return '%s%s%s%s' % (domain, root_url, path, subpath) - class Meta: + class Meta(SlugTreeEntity.Meta): app_label = 'philo' @@ -89,18 +133,39 @@ 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') + #: An attribute on the class which defines whether this :class:`View` can handle subpaths. Default: ``False`` accepts_subpath = False - def handles_subpath(self, subpath): - if not self.accepts_subpath and subpath != "/": + @classmethod + def handles_subpath(cls, subpath): + """Returns True if the :class:`View` handles the given subpath, and False otherwise.""" + if not cls.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 +188,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 +218,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,24 +229,25 @@ _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): - if not super(MultiView, self).handles_subpath(subpath): - return False - try: - resolve(subpath, urlconf=self) - except Http404: - return False - 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 ` and renders the view function (or method) found with the appropriate args and kwargs. + + """ clear_url_caches() - subpath = request.node.subpath + subpath = request.node._subpath view, args, kwargs = resolve(subpath, urlconf=self) view_args = getargspec(view) if extra_context is not None and ('extra_context' in view_args[0] or view_args[2] is not None): @@ -177,17 +257,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 +302,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): @@ -235,7 +335,17 @@ class TargetURLModel(models.Model): kwargs = dict([(smart_str(k, 'ascii'), v) for k, v in params.items()]) return self.url_or_subpath, args, kwargs - def get_target_url(self): + def get_target_url(self, memoize=True): + """Calculates and returns the target url based on the :attr:`target_node`, :attr:`url_or_subpath`, and :attr:`reversing_parameters`. The results will be memoized by default; this can be prevented by passing in ``memoize=False``.""" + if memoize: + memo_args = (self.target_node_id, self.url_or_subpath, self.reversing_parameters_json) + try: + return self._target_url_memo[memo_args] + except AttributeError: + self._target_url_memo = {} + except KeyError: + pass + 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: @@ -245,14 +355,19 @@ class TargetURLModel(models.Model): subpath = self.url_or_subpath if subpath[0] != '/': subpath = '/' + subpath - return node.construct_url(subpath) + target_url = node.construct_url(subpath) elif node is not None: - return node.get_absolute_url() + target_url = node.get_absolute_url() else: if self.reversing_parameters is not None: view_name, args, kwargs = self.get_reverse_params() - return reverse(view_name, args=args, kwargs=kwargs) - return self.url_or_subpath + target_url = reverse(view_name, args=args, kwargs=kwargs) + else: + target_url = self.url_or_subpath + + if memoize: + self._target_url_memo[memo_args] = target_url + return target_url target_url = property(get_target_url) class Meta: @@ -260,13 +375,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,23 +395,33 @@ class Redirect(TargetURLModel, View): class File(View): - """ For storing arbitrary files """ - - mimetype = models.CharField(max_length=255) + """Stores an arbitrary file.""" + #: The name of the uploaded file. This is meant for finding the file again later, not for display. + name = models.CharField(max_length=255) + #: Defines the mimetype of the uploaded file. This will not be validated. If no mimetype is provided, it will be automatically generated based on the filename. + mimetype = models.CharField(max_length=255, blank=True) + #: Contains the uploaded file. Files are uploaded to ``philo/files/%Y/%m/%d``. file = models.FileField(upload_to='philo/files/%Y/%m/%d') + def clean(self): + if not self.mimetype: + self.mimetype = mimetypes.guess_type(self.file.name, strict=False)[0] + if self.mimetype is None: + raise ValidationError("Unknown file type.") + def actually_render_to_response(self, request, extra_context=None): wrapper = FileWrapper(self.file) response = HttpResponse(wrapper, content_type=self.mimetype) response['Content-Length'] = self.file.size + response['Content-Disposition'] = "inline; filename=%s" % basename(self.file.name) return response class Meta: app_label = 'philo' def __unicode__(self): - return self.file.name + """Returns the value of :attr:`File.name`.""" + return self.name -register_templatetags('philo.templatetags.nodes') register_value_model(Node) \ No newline at end of file diff --git a/philo/models/pages.py b/philo/models/pages.py index 2221ee4..350bce5 100644 --- a/philo/models/pages.py +++ b/philo/models/pages.py @@ -1,155 +1,82 @@ # 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.loader_tags import BlockNode, ExtendsNode, BlockContext -from django.utils.datastructures import SortedDict -from philo.models.base import TreeModel, register_value_model +from django.template import Context, RequestContext, Template as DjangoTemplate + +from philo.models.base import SlugTreeEntity, register_value_model from philo.models.fields import TemplateField from philo.models.nodes import View -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 +from philo.utils import templates -class LazyContainerFinder(object): - def __init__(self, nodes, extends=False): - self.nodes = nodes - self.initialized = False - self.contentlet_specs = set() - self.contentreference_specs = SortedDict() - self.blocks = {} - self.block_super = False - self.extends = extends - - def process(self, nodelist): - for node in nodelist: - if self.extends: - if isinstance(node, BlockNode): - self.blocks[node.name] = block = LazyContainerFinder(node.nodelist) - block.initialize() - self.blocks.update(block.blocks) - continue - - if isinstance(node, ContainerNode): - if not node.references: - self.contentlet_specs.add(node.name) - else: - if node.name not in self.contentreference_specs.keys(): - self.contentreference_specs[node.name] = node.references - continue - - if isinstance(node, VariableNode): - if node.filter_expression.var.lookups == (u'block', u'super'): - self.block_super = True - - if hasattr(node, 'child_nodelists'): - for nodelist_name in node.child_nodelists: - if hasattr(node, nodelist_name): - nodelist = getattr(node, nodelist_name) - self.process(nodelist) - - # LOADED_TEMPLATE_ATTR contains the name of an attribute philo uses to declare a - # node as rendering an additional template. Philo monkeypatches the attribute onto - # the relevant default nodes and declares it on any native nodes. - if hasattr(node, LOADED_TEMPLATE_ATTR): - loaded_template = getattr(node, LOADED_TEMPLATE_ATTR) - if loaded_template: - nodelist = loaded_template.nodelist - self.process(nodelist) - - def initialize(self): - if not self.initialized: - self.process(self.nodes) - self.initialized = True +__all__ = ('Template', 'Page', 'Contentlet', 'ContentReference') -class Template(TreeModel): +class Template(SlugTreeEntity): + """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): + def get_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) - - def build_extension_tree(nodelist): - nodelists = [] - extends = None - for node in nodelist: - if not isinstance(node, TextNode): - if isinstance(node, ExtendsNode): - extends = node - break - - if extends: - if extends.nodelist: - nodelists.append(LazyContainerFinder(extends.nodelist, extends=True)) - loaded_template = getattr(extends, LOADED_TEMPLATE_ATTR) - nodelists.extend(build_extension_tree(loaded_template.nodelist)) - else: - # Base case: root. - nodelists.append(LazyContainerFinder(nodelist)) - return nodelists - - # Build a tree of the templates we're using, placing the root template first. - levels = build_extension_tree(template.nodelist)[::-1] - - contentlet_specs = set() - contentreference_specs = SortedDict() - blocks = {} - - for level in levels: - level.initialize() - contentlet_specs |= level.contentlet_specs - contentreference_specs.update(level.contentreference_specs) - for name, block in level.blocks.items(): - if block.block_super: - blocks.setdefault(name, []).append(block) - else: - blocks[name] = [block] - - for block_list in blocks.values(): - for block in block_list: - block.initialize() - contentlet_specs |= block.contentlet_specs - contentreference_specs.update(block.contentreference_specs) - - return contentlet_specs, contentreference_specs + return templates.get_containers(template) + containers = property(get_containers) def __unicode__(self): + """Returns the value of the :attr:`name` field.""" return self.name - class Meta: + class Meta(SlugTreeEntity.Meta): app_label = 'philo' 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 +92,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 +129,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 +146,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 index 3653c54..13f6cd1 100644 --- a/philo/signals.py +++ b/philo/signals.py @@ -1,8 +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 diff --git a/philo/static/admin/js/TagCreation.js b/philo/static/admin/js/TagCreation.js deleted file mode 100644 index d08d41e..0000000 --- a/philo/static/admin/js/TagCreation.js +++ /dev/null @@ -1,101 +0,0 @@ -var tagCreation = window.tagCreation; - -(function($) { - location_re = new RegExp("^https?:\/\/" + window.location.host + "/") - - $('html').ajaxSend(function(event, xhr, settings) { - function getCookie(name) { - var cookieValue = null; - if (document.cookie && document.cookie != '') { - var cookies = document.cookie.split(';'); - for (var i = 0; i < cookies.length; i++) { - var cookie = $.trim(cookies[i]); - // Does this cookie string begin with the name we want? - if (cookie.substring(0, name.length + 1) == (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; - } - if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url)) || location_re.test(settings.url)) { - // Only send the token to relative URLs i.e. locally. - xhr.setRequestHeader("X-CSRFToken", getCookie('csrftoken')); - } - }); - tagCreation = { - 'cache': {}, - 'addTagFromSlug': function(triggeringLink) { - var id = triggeringLink.id.replace(/^ajax_add_/, '') + '_input'; - var slug = document.getElementById(id).value; - - var name = slug.split(' '); - for(var i=0;i')); + EmbedWidgets = $('.embed-widget'); + EmbedWidgets.prepend($('
')); + EmbedBars = $('.embed-toolbar'); + EmbedBars.append(''); + EmbedButtons = $('.embed-button'); + EmbedSelects = $('.embed-select'); + + widget.parseContentTypes(); + EmbedSelects.each(widget.populateSelect); + + EmbedButtons.click(widget.buttonHandler); + + // overload the dismissRelatedLookupPopup function + oldDismissRelatedLookupPopup = window.dismissRelatedLookupPopup; + window.dismissRelatedLookupPopup = function (win, chosenId) { + var name = windowname_to_id(win.name), + elem = $('#'+name), val; + // if the original element was an embed widget, run our script + if (elem.parent().hasClass('embed-widget')) { + contenttype = $('select',elem.parent()).val(); + widget.appendEmbed(elem, contenttype, chosenId); + elem.focus(); + win.close(); + return; + } + // otherwise, do what you usually do + oldDismissRelatedLookupPopup.apply(this, arguments); + } + + // overload the dismissAddAnotherPopup function + oldDismissAddAnotherPopup = window.dismissAddAnotherPopup; + window.dismissAddAnotherPopup = function (win, newId, newRepr) { + var name = windowname_to_id(win.name), + elem = $('#'+win.name), val; + if (elem.parent().hasClass('embed-widget')) { + dismissRelatedLookupPopup(win, newId); + } + // otherwise, do what you usually do + oldDismissAddAnotherPopup.apply(this, arguments); + } + + // Add grappelli to the body class if the admin is grappelli. This will allow us to customize styles accordingly. + if (window.grappelli) { + $(document.body).addClass('grappelli'); + } + }, + parseContentTypes: function () { + var string = widget.EmbedFields.eq(0).attr('data-content-types'), + data = $.parseJSON(string), + i=0, + current_app_label = '', + optgroups = {}; + + // this loop relies on data being clustered by app + for(i=0; i < data.length; i++){ + item = data[i] + // run this next loop every time we encounter a new app label + if (item.app_label !== current_app_label) { + current_app_label = item.app_label; + optgroups[current_app_label] = {} + } + optgroups[current_app_label][item.verbose_name] = [item.app_label,item.object_name].join('.'); + + widget.optgroups = optgroups; + } + }, + populateSelect: function () { + var $this = $(this), + optgroups = widget.optgroups, + optgroup_els = {}, + optgroup_el, group; + + // append a title + $this.append(''); + + // for each group + for (name in optgroups){ + if(optgroups.hasOwnProperty(name)){ + // assign the group to variable group, temporarily + group = optgroups[name]; + // create an element for this group and assign it to optgroup_el, temporarily + optgroup_el = optgroup_els[name] = $(''); + // append this element to the select menu + $this.append(optgroup_el); + // for each item in the group + for (name in group) { + // append an option to the optgroup + optgroup_el.append(''); + } + } + } + }, + buttonHandler: function (e) { + var $this = $(this), + select = $this.prev('select'), + embed_widget = $this.closest('.embed-widget'), + textarea = embed_widget.children('.embedding').eq(0), + val, app_label, object_name, + href, + win; + + // prevent the button from submitting the form + e.preventDefault(); + + // handle the case that they haven't chosen a type to embed + if (select.val()==='') { + alert('Please select a media type to embed.'); + textarea.focus(); + return; + } + + // split the val into app and object + val = select.val(); + app_label = val.split('.')[0]; + object_name = val.split('.')[1]; + + // generate the url for the popup + // TODO: Find a better way to get the admin URL if possible. This will break if the URL patterns for the admin ever change. + href=['../../../', app_label, '/', object_name, '/?pop=1'].join(''); + + // open a new window + win = window.open(href, id_to_windowname(textarea.attr('id')), 'height=500,width=980,resizable=yes,scrollbars=yes'); + }, + appendEmbed: function (textarea, embed_type, embed_id) { + var $textarea = $(textarea), + textarea = $textarea[0], // make sure we're *not* working with a jQuery object + current_selection = [textarea.selectionStart, textarea.selectionEnd], + current_text = $textarea.val(), + embed_string = ['{% embed', embed_type, embed_id, '%}'].join(' '), + new_text = current_text.substring(0, current_selection[0]) + embed_string + current_text.substring(current_selection[1]), + new_cursor_pos = current_selection[0]+embed_string.length; + $textarea.val(new_text); + textarea.setSelectionRange(new_cursor_pos, new_cursor_pos); + } + } + + $(widget.init); +}(django.jQuery)); \ No newline at end of file diff --git a/philo/templatetags/collections.py b/philo/templatetags/collections.py index 38b3f91..e9db2bd 100644 --- a/philo/templatetags/collections.py +++ b/philo/templatetags/collections.py @@ -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 with . as %} + 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 with . as %} + """ params=token.split_contents() tag = params[0] @@ -36,7 +47,7 @@ def do_membersof(parser, token): try: app_label, model = params[3].strip('"').split('.') - ct = ContentType.objects.get(app_label=app_label, model=model) + ct = ContentType.objects.get_by_natural_key(app_label, model) except ValueError: raise template.TemplateSyntaxError('"%s" template tag option "with" requires an argument of the form app_label.model (see django.contrib.contenttypes)' % tag) except ContentType.DoesNotExist: @@ -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 diff --git a/philo/templatetags/containers.py b/philo/templatetags/containers.py index c5fd445..fdcd82c 100644 --- a/philo/templatetags/containers.py +++ b/philo/templatetags/containers.py @@ -1,13 +1,38 @@ +""" +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.db.models import Q +from django.utils.safestring import SafeUnicode, mark_safe register = template.Library() +CONTAINER_CONTEXT_KEY = 'philo_container_context' + + +class ContainerContext(object): + def __init__(self, page): + self.page = page + + def get_contentlets(self): + if not hasattr(self, '_contentlets'): + self._contentlets = dict(((c.name, c) for c in self.page.contentlets.all())) + return self._contentlets + + def get_references(self): + if not hasattr(self, '_references'): + references = self.page.contentreferences.all() + self._references = dict((((c.name, ContentType.objects.get_for_id(c.content_type_id)), c) for c in references)) + return self._references + + class ContainerNode(template.Node): def __init__(self, name, references=None, as_var=None): self.name = name @@ -15,53 +40,54 @@ class ContainerNode(template.Node): self.references = references def render(self, context): - content = settings.TEMPLATE_STRING_IF_INVALID - if 'page' in context: - container_content = self.get_container_content(context) - else: - container_content = None + container_content = self.get_container_content(context) if self.as_var: context[self.as_var] = container_content return '' - if not container_content: - return '' - return container_content def get_container_content(self, context): - page = context['page'] + try: + container_context = context.render_context[CONTAINER_CONTEXT_KEY] + except KeyError: + try: + page = context['page'] + except KeyError: + return settings.TEMPLATE_STRING_IF_INVALID + + container_context = ContainerContext(page) + context.render_context[CONTAINER_CONTEXT_KEY] = container_context + if self.references: # Then it's a content reference. try: - contentreference = page.contentreferences.get(name__exact=self.name, content_type=self.references) - content = contentreference.content - except ObjectDoesNotExist: + contentreference = container_context.get_references()[(self.name, self.references)] + except KeyError: content = '' + else: + content = contentreference.content else: # Otherwise it's a contentlet. try: - contentlet = page.contentlets.get(name__exact=self.name) - if '{%' in contentlet.content or '{{' in contentlet.content: - try: - content = template.Template(contentlet.content, name=contentlet.name).render(context) - except template.TemplateSyntaxError, error: - if settings.DEBUG: - content = ('[Error parsing contentlet \'%s\': %s]' % (self.name, error)) - else: - content = settings.TEMPLATE_STRING_IF_INVALID - else: - content = contentlet.content - except ObjectDoesNotExist: - content = settings.TEMPLATE_STRING_IF_INVALID - content = mark_safe(content) + contentlet = container_context.get_contentlets()[self.name] + except KeyError: + content = '' + else: + content = contentlet.content return content -def do_container(parser, token): +@register.tag +def container(parser, token): """ - {% container [[references ] as ] %} + 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 [[references .] as ] %} + """ params = token.split_contents() if len(params) >= 2: @@ -76,7 +102,7 @@ def do_container(parser, token): if option_token == 'references': try: app_label, model = remaining_tokens.pop(0).strip('"').split('.') - references = ContentType.objects.get(app_label=app_label, model=model) + references = ContentType.objects.get_by_natural_key(app_label, model) except IndexError: raise template.TemplateSyntaxError('"%s" template tag option "references" requires an argument specifying a content type' % tag) except ValueError: @@ -94,6 +120,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) diff --git a/philo/templatetags/embed.py b/philo/templatetags/embed.py index eb4cd68..b024b1b 100644 --- a/philo/templatetags/embed.py +++ b/philo/templatetags/embed.py @@ -1,8 +1,13 @@ +""" +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 + +from philo.utils.templates import LOADED_TEMPLATE_ATTR register = template.Library() @@ -280,17 +285,29 @@ def parse_content_type(bit, tagname): except ValueError: raise template.TemplateSyntaxError('"%s" template tag expects the first argument to be of the form app_label.model' % tagname) try: - ct = ContentType.objects.get(app_label=app_label, model=model) + ct = ContentType.objects.get_by_natural_key(app_label, model) except ContentType.DoesNotExist: raise template.TemplateSyntaxError('"%s" template tag requires an argument of the form app_label.model which refers to an installed content type (see django.contrib.contenttypes)' % tagname) return ct -def do_embed(parser, token): +@register.tag +def embed(parser, token): """ - The {% embed %} tag can be used in two ways: - {% embed . with