From 0d51e8dc2b15ca84b8979b94a3224b4a33f7f25e Mon Sep 17 00:00:00 2001 From: Joseph Spiros Date: Mon, 1 Feb 2010 06:28:24 -0500 Subject: [PATCH] Initial commit. Philo is released under the ISC License. --- .gitignore | 1 + LICENSE | 5 + README | 10 ++ __init__.py | 4 + admin.py | 130 ++++++++++++++++ models.py | 308 +++++++++++++++++++++++++++++++++++++ templatetags/__init__.py | 0 templatetags/containers.py | 52 +++++++ urls.py | 8 + utils.py | 6 + views.py | 18 +++ 11 files changed, 542 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README create mode 100644 __init__.py create mode 100644 admin.py create mode 100644 models.py create mode 100644 templatetags/__init__.py create mode 100644 templatetags/containers.py create mode 100644 urls.py create mode 100644 utils.py create mode 100644 views.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..61eafbd --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +Copyright (c) 2009-2010, 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. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README b/README new file mode 100644 index 0000000..81a59cd --- /dev/null +++ b/README @@ -0,0 +1,10 @@ +Philo is a foundation for developing web content management systems. + +Prerequisites: + * Python 2.5.4+ + * simplejson (Not required with Python 2.6+) + * Django 1.1.1+ + * django-mptt 0.2+ + * (Optional) django-grappelli 2.0+ + +To contribute, please visit the project website . diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..8ad7212 --- /dev/null +++ b/__init__.py @@ -0,0 +1,4 @@ +from models import Template + + +load_template_source = Template.loader diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..229e16d --- /dev/null +++ b/admin.py @@ -0,0 +1,130 @@ +from django.contrib import admin +from django.contrib.contenttypes import generic +from django.contrib.contenttypes.models import ContentType +from django import forms +from models import * + + +class AttributeInline(generic.GenericTabularInline): + ct_field = 'entity_content_type' + ct_fk_field = 'entity_object_id' + model = Attribute + extra = 1 + classes = ('collapse-closed',) + allow_add = True + + +class RelationshipInline(generic.GenericTabularInline): + ct_field = 'entity_content_type' + ct_fk_field = 'entity_object_id' + model = Relationship + extra = 1 + classes = ('collapse-closed',) + allow_add = True + + +class EntityAdmin(admin.ModelAdmin): + inlines = [AttributeInline, RelationshipInline] + save_on_top = True + + +class CollectionMemberInline(admin.TabularInline): + fk_name = 'collection' + model = CollectionMember + extra = 1 + classes = ('collapse-closed',) + allow_add = True + + +class CollectionAdmin(admin.ModelAdmin): + inlines = [CollectionMemberInline] + + +class TemplateAdmin(admin.ModelAdmin): + prepopulated_fields = {'slug': ('name',)} + fieldsets = ( + (None, { + 'fields': ('parent', 'name', 'slug') + }), + ('Documentation', { + 'classes': ('collapse', 'collapse-closed'), + 'fields': ('documentation',) + }), + (None, { + 'fields': ('code',) + }), + ('Advanced', { + 'classes': ('collapse','collapse-closed'), + 'fields': ('mimetype',) + }), + ) + save_on_top = True + save_as = True + + +class PageAdmin(EntityAdmin): + prepopulated_fields = {'slug': ('title',)} + fieldsets = ( + (None, { + 'fields': ('title', 'template') + }), + ('URL/Tree/Hierarchy', { + 'classes': ('collapse', 'collapse-closed'), + 'fields': ('parent', 'slug') + }), + ) + list_display = ('title', 'path', 'template') + list_filter = ('template',) + search_fields = ['title', 'slug', 'contentlets__content'] + + def get_fieldsets(self, request, obj=None, **kwargs): + fieldsets = list(self.fieldsets) + if obj: # if no obj, creating a new page, thus no template set, thus no containers + page = obj + template = page.template + containers = template.containers + if len(containers) > 0: + for container in containers: + fieldsets.append((('Container: %s' % container), { + 'fields': (('container_content_%s' % container), ('container_dynamic_%s' % container)) + })) + return fieldsets + + def get_form(self, request, obj=None, **kwargs): + form = super(PageAdmin, self).get_form(request, obj, **kwargs) + if obj: # if no obj, creating a new page, thus no template set, thus no containers + page = obj + template = page.template + containers = template.containers + for container in containers: + initial_content = None + initial_dynamic = False + try: + contentlet = page.contentlets.get(name__exact=container) + initial_content = contentlet.content + initial_dynamic = contentlet.dynamic + except Contentlet.DoesNotExist: + pass + form.base_fields[('container_content_%s' % container)] = forms.CharField(label='Content', widget=forms.Textarea(), initial=initial_content, required=False) + form.base_fields[('container_dynamic_%s' % container)] = forms.BooleanField(label='Dynamic', help_text='Specify whether this content contains dynamic template code', initial=initial_dynamic, required=False) + return form + + def save_model(self, request, page, form, change): + page.save() + + template = page.template + containers = template.containers + for container in containers: + if (("container_content_%s" % container) in form.cleaned_data) and (("container_dynamic_%s" % container) in form.cleaned_data): + content = form.cleaned_data[('container_content_%s' % container)] + dynamic = form.cleaned_data[('container_dynamic_%s' % container)] + contentlet, created = page.contentlets.get_or_create(name=container, defaults={'content': content, 'dynamic': dynamic}) + if not created: + contentlet.content = content + contentlet.dynamic = dynamic + contentlet.save() + + +admin.site.register(Collection, CollectionAdmin) +admin.site.register(Page, PageAdmin) +admin.site.register(Template, TemplateAdmin) diff --git a/models.py b/models.py new file mode 100644 index 0000000..cb8ed07 --- /dev/null +++ b/models.py @@ -0,0 +1,308 @@ +# encoding: utf-8 +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth.models import User, Group +from django.contrib.contenttypes import generic +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.contrib.sites.models import Site +import mptt +from utils import fattr +from django.template import add_to_builtins as register_templatetags +from django.template import Template as DjangoTemplate +from django.template import TemplateDoesNotExist +from django.template import Context +from django.core.exceptions import ObjectDoesNotExist +try: + import json +except ImportError: + import simplejson as json +from UserDict import DictMixin +from templatetags.containers import ContainerNode +from django.template.loader_tags import ExtendsNode, ConstantIncludeNode, IncludeNode, BlockNode +from django.template.loader import get_template + + +def _ct_model_name(model): + opts = model._meta + while opts.proxy: + model = opts.proxy_for_model + opts = model._meta + return opts.object_name.lower() + + +class Attribute(models.Model): + entity_content_type = models.ForeignKey(ContentType, verbose_name='Entity type') + entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID') + entity = generic.GenericForeignKey('entity_content_type', 'entity_object_id') + key = models.CharField(max_length=255) + json_value = models.TextField(verbose_name='Value (JSON)', help_text='This value must be valid JSON.') + + @property + def value(self): + return json.loads(self.json_value) + + def __unicode__(self): + return u'"%s": %s' % (self.key, self.value) + + +class Relationship(models.Model): + _value_models = [] + + @staticmethod + def register_value_model(model): + if issubclass(model, models.Model): + model_name = _ct_model_name(model) + if model_name not in Relationship._value_models: + Relationship._value_models.append(model_name) + else: + raise TypeError('Relationship.register_value_model only accepts subclasses of django.db.models.Model') + + @staticmethod + def unregister_value_model(model): + if issubclass(model, models.Model): + model_name = _ct_model_name(model) + if model_name in Relationship._value_models: + Relationship._value_models.remove(model_name) + else: + raise TypeError('Relationship.unregister_value_model only accepts subclasses of django.db.models.Model') + + entity_content_type = models.ForeignKey(ContentType, related_name='relationship_entity_set', verbose_name='Entity type') + entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID') + entity = generic.GenericForeignKey('entity_content_type', 'entity_object_id') + key = models.CharField(max_length=255) + value_content_type = models.ForeignKey(ContentType, related_name='relationship_value_set', limit_choices_to={'model__in':_value_models}, verbose_name='Value type') + value_object_id = models.PositiveIntegerField(verbose_name='Value ID') + value = generic.GenericForeignKey('value_content_type', 'value_object_id') + + def __unicode__(self): + return u'"%s": %s' % (self.key, self.value) + + +class QuerySetMapper(object, DictMixin): + def __init__(self, queryset, passthrough=None): + self.queryset = queryset + self.passthrough = passthrough + def __getitem__(self, key): + try: + return queryset.get(key__exact=key) + except ObjectDoesNotExist: + if self.passthrough: + return self.passthrough.__getitem__(key) + raise KeyError + def keys(self): + keys = set(self.queryset.values_list('key', flat=True).distinct()) + if self.passthrough: + keys += set(self.passthrough.keys()) + return list(keys) + + +class Entity(models.Model): + attribute_set = generic.GenericRelation(Attribute, content_type_field='entity_content_type', object_id_field='entity_object_id') + relationship_set = generic.GenericRelation(Relationship, content_type_field='entity_content_type', object_id_field='entity_object_id') + + @property + def attributes(self): + return QuerySetMapper(self.attribute_set) + + @property + def relationships(self): + return QuerySetMapper(self.relationship_set) + + class Meta: + abstract = True + + +class Collection(models.Model): + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + + +class CollectionMember(models.Model): + _value_models = [] + + @staticmethod + def register_value_model(model): + if issubclass(model, models.Model): + model_name = _ct_model_name(model) + if model_name not in CollectionMember._value_models: + CollectionMember._value_models.append(model_name) + else: + raise TypeError('CollectionMember.register_value_model only accepts subclasses of django.db.models.Model') + + @staticmethod + def unregister_value_model(model): + if issubclass(model, models.Model): + model_name = _ct_model_name(model) + if model_name in CollectionMember._value_models: + CollectionMember._value_models.remove(model_name) + else: + raise TypeError('CollectionMember.unregister_value_model only accepts subclasses of django.db.models.Model') + + collection = models.ForeignKey(Collection, related_name='members') + 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={'model__in':_value_models}, verbose_name='Member type') + member_object_id = models.PositiveIntegerField(verbose_name='Member ID') + member = generic.GenericForeignKey('member_content_type', 'member_object_id') + + +def register_value_model(model): + Relationship.register_value_model(model) + CollectionMember.register_value_model(model) + + +def unregister_value_model(model): + Relationship.unregister_value_model(model) + CollectionMember.unregister_value_model(model) + + +class TreeManager(models.Manager): + use_for_related_fields = True + + def roots(self): + return self.filter(parent__isnull=True) + + def get_with_path(self, path, root=None, pathsep='/'): + slugs = path.split(pathsep) + obj = root + for slug in slugs: + if slug: # ignore blank slugs, handles for multiple consecutive pathseps + try: + obj = self.get(slug__exact=slug, parent__exact=obj) + except self.model.DoesNotExist: + obj = None + break + if obj: + return obj + raise self.model.DoesNotExist('%s matching query does not exist.' % self.model._meta.object_name) + + +class TreeModel(models.Model): + objects = TreeManager() + parent = models.ForeignKey('self', related_name='children', null=True, blank=True) + slug = models.SlugField() + + def get_path(self, pathsep='/', field='slug'): + path = getattr(self, field) + parent = self.parent + while parent: + path = getattr(parent, field) + pathsep + path + parent = parent.parent + return path + path = property(get_path) + + def __unicode__(self): + return self.path + + class Meta: + abstract = True + + +class TreeEntity(TreeModel, Entity): + @property + def attributes(self): + if self.parent: + return QuerySetMapper(self.attribute_set, passthrough=self.parent.attributes) + return super(Entity, self).attributes() + + @property + def relationships(self): + if self.parent: + return QuerySetMapper(self.relationship_set, passthrough=self.parent.relationships) + return super(Entity, self).relationships() + + class Meta: + abstract = True + + +class Template(TreeModel): + name = models.CharField(max_length=255) + documentation = models.TextField(null=True, blank=True) + mimetype = models.CharField(max_length=255, null=True, blank=True) + code = models.TextField() + + @property + def origin(self): + return 'philo.models.Template: ' + self.path + + @property + def django_template(self): + return DjangoTemplate(self.code) + + @property + def containers(self): + """ + Returns a list of names of contentlets 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. + """ + def container_node_names(template): + def nodelist_container_node_names(nodelist): + names = [] + for node in nodelist: + try: + if isinstance(node, ContainerNode): + names.append(node.name) + elif isinstance(node, ExtendsNode): + names.extend(nodelist_container_node_names(node.nodelist)) + extended_template = node.get_parent(Context()) + if extended_template: + names.extend(container_node_names(extended_template)) + elif isinstance(node, ConstantIncludeNode): + included_template = node.template + if included_template: + names.extend(container_node_names(included_template)) + elif isinstance(node, IncludeNode): + included_template = get_template(node.template_name.resolve(Context())) + if included_template: + names.extend(container_node_names(included_template)) + elif isinstance(node, BlockNode): + names.extend(nodelist_container_node_names(node.nodelist)) + except: + pass # fail for this node + return names + return nodelist_container_node_names(template.nodelist) + return set(container_node_names(self.django_template)) + + def __unicode__(self): + return self.get_path(u' › ', 'name') + + @staticmethod + @fattr(is_usable=True) + def loader(template_name, template_dirs=None): # load_template_source + try: + template = Template.objects.get_with_path(template_name) + except Template.DoesNotExist: + raise TemplateDoesNotExist(template_name) + return (template.code, template.origin) +mptt.register(Template) + + +class Page(TreeEntity): + template = models.ForeignKey(Template, related_name='pages') + title = models.CharField(max_length=255) + + def __unicode__(self): + return self.get_path(u' › ', 'title') +mptt.register(Page) + + +# the following line enables the selection of a page as the root for a given django.contrib.sites Site object +models.ForeignKey(Page, related_name='sites', null=True, blank=True).contribute_to_class(Site, 'root_page') + + +class Contentlet(models.Model): + page = models.ForeignKey(Page, related_name='contentlets') + name = models.CharField(max_length=255) + content = models.TextField() + dynamic = models.BooleanField(default=False) + + +register_templatetags('philo.templatetags.containers') + + +register_value_model(User) +register_value_model(Group) +register_value_model(Site) +register_value_model(Collection) +register_value_model(Template) +register_value_model(Page) diff --git a/templatetags/__init__.py b/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/templatetags/containers.py b/templatetags/containers.py new file mode 100644 index 0000000..9bf056f --- /dev/null +++ b/templatetags/containers.py @@ -0,0 +1,52 @@ +from django import template +from django.conf import settings +from django.utils.safestring import SafeUnicode, mark_safe +from django.core.exceptions import ObjectDoesNotExist + +register = template.Library() + + +class ContainerNode(template.Node): + def __init__(self, name, as_var=None): + self.name = name + self.as_var = as_var + def render(self, context): + page = None + if 'page' in context: + page = context['page'] + if page: + contentlet = None + try: + contentlet = page.contentlets.get(name__exact=self.name) + except ObjectDoesNotExist: + pass + if contentlet: + content = contentlet.content + if contentlet.dynamic: + try: + content = mark_safe(template.Template(content, name=contentlet.name).render(context)) + except template.TemplateSyntaxError, error: + content = '' + if settings.DEBUG: + content = ('[Error parsing contentlet \'%s\': %s]' % self.name, error) + if self.as_var: + context[self.as_var] = content + content = '' + return content + return '' + + +def do_container(parser, token): + """ + {% container [as ] %} + """ + params = token.split_contents() + if len(params) >= 2: # without as_var + name = params[1].strip('"') + as_var = None + if len(params) == 4: + as_var = params[3] + return ContainerNode(name, as_var) + else: # error + raise template.TemplateSyntaxError('do_container template tag provided with invalid arguments') +register.tag('container', do_container) diff --git a/urls.py b/urls.py new file mode 100644 index 0000000..77ec968 --- /dev/null +++ b/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls.defaults import url, include, patterns, handler404, handler500 +from philo.views import page_view + + +urlpatterns = patterns('', + url(r'^$', page_view, name='philo-root'), + url(r'^(?P.*)$', page_view, name='philo-page-by-path') +) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..e3d1124 --- /dev/null +++ b/utils.py @@ -0,0 +1,6 @@ +def fattr(*args, **kwargs): + def wrapper(function): + for key in kwargs: + setattr(function, key, kwargs[key]) + return function + return wrapper diff --git a/views.py b/views.py new file mode 100644 index 0000000..65acb52 --- /dev/null +++ b/views.py @@ -0,0 +1,18 @@ +from django.http import Http404, HttpResponse +from django.template import RequestContext +from django.contrib.sites.models import Site +from models import Page + +def page_view(request, path=None, **kwargs): + page = None + if path is None: + path = '/' + try: + current_site = Site.objects.get_current() + if current_site: + page = Page.objects.get_with_path(path, root=current_site.root_page) + except Page.DoesNotExist: + raise Http404 + if not page: + raise Http404 + return HttpResponse(page.template.django_template.render(RequestContext(request, {'page': page})), mimetype=page.template.mimetype) -- 2.20.1