From: Stephen Burrows Date: Wed, 27 Apr 2011 21:07:41 +0000 (-0400) Subject: Merge branch 'docs' into release X-Git-Tag: philo-0.9~12^2~32 X-Git-Url: http://git.ithinksw.org/philo.git/commitdiff_plain/cec32849d48d9e36c030224e2eb9631d31ef17a2?hp=dbfa7041eadc465c1aaddac3fca41624cbed852d Merge branch 'docs' into release --- diff --git a/README b/README index 4b1a6f7..6e47860 100644 --- a/README +++ b/README @@ -2,7 +2,7 @@ Philo is a foundation for developing web content management systems. Prerequisites: * Python 2.5.4+ - * Django 1.2+ + * Django 1.3+ * django-mptt e734079+ * (Optional) django-grappelli 2.0+ * (Optional) recaptcha-django r6 diff --git a/README.markdown b/README.markdown index 8060db8..349a727 100644 --- a/README.markdown +++ b/README.markdown @@ -3,7 +3,7 @@ Philo is a foundation for developing web content management systems. Prerequisites: * [Python 2.5.4+ <http://www.python.org>](http://www.python.org/) - * [Django 1.2+ <http://www.djangoproject.com/>](http://www.djangoproject.com/) + * [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/) diff --git a/__init__.py b/__init__.py deleted file mode 100644 index ba78dda..0000000 --- a/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from philo.loaders.database import Loader - - -_loader = Loader() - - -def load_template_source(template_name, template_dirs=None): - # For backwards compatibility - import warnings - warnings.warn( - "'philo.load_template_source' is deprecated; use 'philo.loaders.database.Loader' instead.", - PendingDeprecationWarning - ) - return _loader.load_template_source(template_name, template_dirs) -load_template_source.is_usable = True diff --git a/admin/forms/containers.py b/admin/forms/containers.py deleted file mode 100644 index 5991dfa..0000000 --- a/admin/forms/containers.py +++ /dev/null @@ -1,190 +0,0 @@ -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 -from django.forms.formsets import TOTAL_FORM_COUNT -from philo.admin.widgets import ModelLookupWidget -from philo.models import Contentlet, ContentReference - - -__all__ = ( - 'ContentletForm', - 'ContentletInlineFormSet', - 'ContentReferenceForm', - 'ContentReferenceInlineFormSet' -) - - -class ContainerForm(ModelForm): - def __init__(self, *args, **kwargs): - super(ContainerForm, self).__init__(*args, **kwargs) - self.verbose_name = self.instance.name.replace('_', ' ') - - -class ContentletForm(ContainerForm): - content = forms.CharField(required=False, widget=AdminTextareaWidget, label='Content') - - def should_delete(self): - return not bool(self.cleaned_data['content']) - - class Meta: - model = Contentlet - fields = ['name', 'content'] - - -class ContentReferenceForm(ContainerForm): - def __init__(self, *args, **kwargs): - super(ContentReferenceForm, self).__init__(*args, **kwargs) - try: - self.fields['content_id'].widget = ModelLookupWidget(self.instance.content_type) - except ObjectDoesNotExist: - # This will happen when an empty form (which we will never use) gets instantiated. - pass - - def should_delete(self): - return (self.cleaned_data['content_id'] is None) - - class Meta: - model = ContentReference - fields = ['name', 'content_id'] - - -class ContainerInlineFormSet(BaseInlineFormSet): - def __init__(self, containers, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None): - # Unfortunately, I need to add some things to BaseInline between its __init__ and its - # super call, so a lot of this is repetition. - - # Start cribbed from BaseInline - from django.db.models.fields.related import RelatedObject - self.save_as_new = save_as_new - # is there a better way to get the object descriptor? - self.rel_name = RelatedObject(self.fk.rel.to, self.model, self.fk).get_accessor_name() - if self.fk.rel.field_name == self.fk.rel.to._meta.pk.name: - backlink_value = self.instance - else: - backlink_value = getattr(self.instance, self.fk.rel.field_name) - if queryset is None: - queryset = self.model._default_manager - qs = queryset.filter(**{self.fk.name: backlink_value}) - # End cribbed from BaseInline - - self.container_instances, qs = self.get_container_instances(containers, qs) - self.extra_containers = containers - self.extra = len(self.extra_containers) - super(BaseInlineFormSet, self).__init__(data, files, prefix=prefix, queryset=qs) - - def get_container_instances(self, containers, qs): - raise NotImplementedError - - def total_form_count(self): - if self.data or self.files: - return self.management_form.cleaned_data[TOTAL_FORM_COUNT] - else: - return self.initial_form_count() + self.extra - - def save_existing_objects(self, commit=True): - self.changed_objects = [] - self.deleted_objects = [] - if not self.get_queryset(): - return [] - - saved_instances = [] - for form in self.initial_forms: - pk_name = self._pk_field.name - raw_pk_value = form._raw_value(pk_name) - - # clean() for different types of PK fields can sometimes return - # the model instance, and sometimes the PK. Handle either. - pk_value = form.fields[pk_name].clean(raw_pk_value) - pk_value = getattr(pk_value, 'pk', pk_value) - - obj = self._existing_object(pk_value) - if form.should_delete(): - self.deleted_objects.append(obj) - obj.delete() - continue - if form.has_changed(): - self.changed_objects.append((obj, form.changed_data)) - saved_instances.append(self.save_existing(form, obj, commit=commit)) - if not commit: - self.saved_forms.append(form) - return saved_instances - - def save_new_objects(self, commit=True): - self.new_objects = [] - for form in self.extra_forms: - if not form.has_changed(): - continue - # If someone has marked an add form for deletion, don't save the - # object. - if form.should_delete(): - continue - self.new_objects.append(self.save_new(form, commit=commit)) - if not commit: - self.saved_forms.append(form) - return self.new_objects - - -class ContentletInlineFormSet(ContainerInlineFormSet): - def __init__(self, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None): - if instance is None: - self.instance = self.fk.rel.to() - else: - self.instance = instance - - try: - containers = list(self.instance.containers[0]) - except ObjectDoesNotExist: - containers = [] - - super(ContentletInlineFormSet, self).__init__(containers, data, files, instance, save_as_new, prefix, queryset) - - def get_container_instances(self, containers, qs): - qs = qs.filter(name__in=containers) - container_instances = [] - for container in qs: - container_instances.append(container) - containers.remove(container.name) - return container_instances, qs - - def _construct_form(self, i, **kwargs): - if i >= self.initial_form_count(): # and not kwargs.get('instance'): - kwargs['instance'] = self.model(name=self.extra_containers[i - self.initial_form_count() - 1]) - - return super(ContentletInlineFormSet, self)._construct_form(i, **kwargs) - - -class ContentReferenceInlineFormSet(ContainerInlineFormSet): - def __init__(self, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None): - if instance is None: - self.instance = self.fk.rel.to() - else: - self.instance = instance - - try: - containers = list(self.instance.containers[1]) - except ObjectDoesNotExist: - containers = [] - - super(ContentReferenceInlineFormSet, self).__init__(containers, data, files, instance, save_as_new, prefix, queryset) - - def get_container_instances(self, containers, qs): - filter = Q() - - for name, ct in containers: - filter |= Q(name=name, content_type=ct) - - qs = qs.filter(filter) - container_instances = [] - for container in qs: - container_instances.append(container) - containers.remove((container.name, container.content_type)) - return container_instances, qs - - def _construct_form(self, i, **kwargs): - if i >= self.initial_form_count(): # and not kwargs.get('instance'): - name, content_type = self.extra_containers[i - self.initial_form_count() - 1] - kwargs['instance'] = self.model(name=name, content_type=content_type) - - return super(ContentReferenceInlineFormSet, self)._construct_form(i, **kwargs) \ No newline at end of file diff --git a/contrib/shipherd/templatetags/shipherd.py b/contrib/shipherd/templatetags/shipherd.py deleted file mode 100644 index fa4ec3e..0000000 --- a/contrib/shipherd/templatetags/shipherd.py +++ /dev/null @@ -1,106 +0,0 @@ -from django import template -from django.conf import settings -from django.utils.safestring import mark_safe -from philo.contrib.shipherd.models import Navigation -from philo.models import Node -from mptt.templatetags.mptt_tags import RecurseTreeNode, cache_tree_children -from django.utils.translation import ugettext as _ - - -register = template.Library() - - -class RecurseNavigationNode(RecurseTreeNode): - def __init__(self, template_nodes, instance_var, key): - self.template_nodes = template_nodes - self.instance_var = instance_var - self.key = key - - def _render_node(self, context, item, request): - bits = [] - context.push() - for child in item.get_children(): - context['item'] = child - bits.append(self._render_node(context, child, request)) - context['item'] = item - context['children'] = mark_safe(u''.join(bits)) - context['active'] = item.is_active(request) - context['active_descendants'] = item.has_active_descendants(request) - rendered = self.template_nodes.render(context) - context.pop() - return rendered - - def render(self, context): - try: - request = context['request'] - except KeyError: - return '' - - instance = self.instance_var.resolve(context) - - try: - navigation = instance.navigation[self.key] - except: - return settings.TEMPLATE_STRING_IF_INVALID - - bits = [self._render_node(context, item, request) for item in navigation] - return ''.join(bits) - - -@register.tag -def recursenavigation(parser, token): - """ - Based on django-mptt's recursetree templatetag. In addition to {{ item }} and {{ children }}, - sets {{ active }} and {{ active_descendants }} in the context. - - Note that the tag takes one variable, which is a Node instance. - - Usage: -
    - {% recursenavigation node main %} - - {{ navigation.text }} - {% if navigation.get_children %} -
      - {{ children }} -
    - {% endif %} - - {% endrecursenavigation %} -
- """ - bits = token.contents.split() - if len(bits) != 3: - raise template.TemplateSyntaxError(_('%s tag requires two arguments: a node and a navigation section name') % bits[0]) - - instance_var = parser.compile_filter(bits[1]) - key = bits[2] - - template_nodes = parser.parse(('endrecursenavigation',)) - parser.delete_first_token() - - return RecurseNavigationNode(template_nodes, instance_var, key) - - -@register.filter -def has_navigation(node, key=None): - 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) - except: - return False - - -@register.filter -def navigation_host(node, key): - try: - return Navigation.objects.filter(node__in=node.get_ancestors(include_self=True), key=key).order_by('-node__level')[0].node - except: - if settings.TEMPLATE_DEBUG: - raise - return node \ No newline at end of file diff --git a/models/fields/__init__.py b/models/fields/__init__.py deleted file mode 100644 index d8ed839..0000000 --- a/models/fields/__init__.py +++ /dev/null @@ -1,64 +0,0 @@ -from django import forms -from django.db import models -from django.utils import simplejson as json -from philo.forms.fields import JSONFormField -from philo.validators import TemplateValidator, json_validator -#from philo.models.fields.entities import * - - -class TemplateField(models.TextField): - def __init__(self, allow=None, disallow=None, secure=True, *args, **kwargs): - super(TemplateField, self).__init__(*args, **kwargs) - self.validators.append(TemplateValidator(allow, disallow, secure)) - - -class JSONDescriptor(object): - def __init__(self, field): - self.field = field - - def __get__(self, instance, owner): - if instance is None: - raise AttributeError # ? - - if self.field.name not in instance.__dict__: - json_string = getattr(instance, self.field.attname) - instance.__dict__[self.field.name] = json.loads(json_string) - - return instance.__dict__[self.field.name] - - def __set__(self, instance, value): - instance.__dict__[self.field.name] = value - setattr(instance, self.field.attname, json.dumps(value)) - - def __delete__(self, instance): - del(instance.__dict__[self.field.name]) - setattr(instance, self.field.attname, json.dumps(None)) - - -class JSONField(models.TextField): - default_validators = [json_validator] - - def get_attname(self): - return "%s_json" % self.name - - def contribute_to_class(self, cls, name): - super(JSONField, self).contribute_to_class(cls, name) - setattr(cls, name, JSONDescriptor(self)) - models.signals.pre_init.connect(self.fix_init_kwarg, sender=cls) - - def fix_init_kwarg(self, sender, args, kwargs, **signal_kwargs): - if self.name in kwargs: - kwargs[self.attname] = json.dumps(kwargs.pop(self.name)) - - def formfield(self, *args, **kwargs): - kwargs["form_class"] = JSONFormField - return super(JSONField, self).formfield(*args, **kwargs) - - -try: - from south.modelsinspector import add_introspection_rules -except ImportError: - pass -else: - add_introspection_rules([], ["^philo\.models\.fields\.TemplateField"]) - add_introspection_rules([], ["^philo\.models\.fields\.JSONField"]) \ No newline at end of file diff --git a/LICENSE b/philo/LICENSE similarity index 100% rename from LICENSE rename to philo/LICENSE diff --git a/philo/__init__.py b/philo/__init__.py new file mode 100644 index 0000000..32297e0 --- /dev/null +++ b/philo/__init__.py @@ -0,0 +1 @@ +VERSION = (0, 0) diff --git a/admin/__init__.py b/philo/admin/__init__.py similarity index 100% rename from admin/__init__.py rename to philo/admin/__init__.py diff --git a/admin/base.py b/philo/admin/base.py similarity index 78% rename from admin/base.py rename to philo/admin/base.py index 8151461..75fa336 100644 --- a/admin/base.py +++ b/philo/admin/base.py @@ -31,54 +31,50 @@ class AttributeInline(generic.GenericTabularInline): template = 'admin/philo/edit_inline/tabular_attribute.html' -def hide_proxy_fields(cls, attname, proxy_field_set): - val_set = set(getattr(cls, attname)) - if proxy_field_set & val_set: - cls._hidden_attributes[attname] = list(val_set) - setattr(cls, attname, list(val_set - proxy_field_set)) +# HACK to bypass model validation for proxy fields +class SpoofedHiddenFields(object): + def __init__(self, proxy_fields, value): + self.value = value + self.spoofed = list(set(value) - set(proxy_fields)) + + def __get__(self, instance, owner): + if instance is None: + return self.spoofed + return self.value + + +class SpoofedAddedFields(SpoofedHiddenFields): + def __init__(self, proxy_fields, value): + self.value = value + self.spoofed = list(set(value) | set(proxy_fields)) + + +def hide_proxy_fields(cls, attname): + val = getattr(cls, attname, []) + proxy_fields = getattr(cls, 'proxy_fields') + if val: + setattr(cls, attname, SpoofedHiddenFields(proxy_fields, val)) + +def add_proxy_fields(cls, attname): + val = getattr(cls, attname, []) + proxy_fields = getattr(cls, 'proxy_fields') + setattr(cls, attname, SpoofedAddedFields(proxy_fields, val)) class EntityAdminMetaclass(admin.ModelAdmin.__metaclass__): def __new__(cls, name, bases, attrs): - # HACK to bypass model validation for proxy fields by masking them as readonly fields new_class = super(EntityAdminMetaclass, cls).__new__(cls, name, bases, attrs) - form = getattr(new_class, 'form', None) - if form: - opts = form._meta - if issubclass(form, EntityForm) and opts.model: - proxy_fields = proxy_fields_for_entity_model(opts.model).keys() - - # Store readonly fields iff they have been declared. - if 'readonly_fields' in attrs or not hasattr(new_class, '_real_readonly_fields'): - new_class._real_readonly_fields = new_class.readonly_fields - - readonly_fields = new_class.readonly_fields - new_class.readonly_fields = list(set(readonly_fields) | set(proxy_fields)) - - # Additional HACKS to handle raw_id_fields and other attributes that the admin - # uses model._meta.get_field to validate. - new_class._hidden_attributes = {} - proxy_fields = set(proxy_fields) - hide_proxy_fields(new_class, 'raw_id_fields', proxy_fields) - #END HACK + hide_proxy_fields(new_class, 'raw_id_fields') + add_proxy_fields(new_class, 'readonly_fields') return new_class - +# END HACK class EntityAdmin(admin.ModelAdmin): __metaclass__ = EntityAdminMetaclass form = EntityForm inlines = [AttributeInline] save_on_top = True - - def __init__(self, *args, **kwargs): - # HACK PART 2 restores the actual readonly fields etc. on __init__. - if hasattr(self, '_real_readonly_fields'): - self.readonly_fields = self.__class__._real_readonly_fields - if hasattr(self, '_hidden_attributes'): - for name, value in self._hidden_attributes.items(): - setattr(self, name, value) - # END HACK - super(EntityAdmin, self).__init__(*args, **kwargs) + proxy_fields = [] def formfield_for_dbfield(self, db_field, **kwargs): """ diff --git a/admin/collections.py b/philo/admin/collections.py similarity index 86% rename from admin/collections.py rename to philo/admin/collections.py index dfc4826..d422b74 100644 --- a/admin/collections.py +++ b/philo/admin/collections.py @@ -10,6 +10,7 @@ class CollectionMemberInline(admin.TabularInline): classes = COLLAPSE_CLASSES allow_add = True fields = ('member_content_type', 'member_object_id', 'index') + sortable_field_name = 'index' class CollectionAdmin(admin.ModelAdmin): diff --git a/admin/forms/__init__.py b/philo/admin/forms/__init__.py similarity index 100% rename from admin/forms/__init__.py rename to philo/admin/forms/__init__.py diff --git a/admin/forms/attributes.py b/philo/admin/forms/attributes.py similarity index 100% rename from admin/forms/attributes.py rename to philo/admin/forms/attributes.py diff --git a/philo/admin/forms/containers.py b/philo/admin/forms/containers.py new file mode 100644 index 0000000..420ba17 --- /dev/null +++ b/philo/admin/forms/containers.py @@ -0,0 +1,190 @@ +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.models import Contentlet, ContentReference + + +__all__ = ( + 'ContentletForm', + 'ContentletInlineFormSet', + 'ContentReferenceForm', + 'ContentReferenceInlineFormSet' +) + + +class ContainerForm(ModelForm): + def __init__(self, *args, **kwargs): + super(ContainerForm, self).__init__(*args, **kwargs) + self.verbose_name = self.instance.name.replace('_', ' ') + self.prefix = self.instance.name + + +class ContentletForm(ContainerForm): + content = forms.CharField(required=False, widget=AdminTextareaWidget, label='Content') + + def should_delete(self): + # Delete iff: the data has changed and is now empty. + return self.has_changed() and not bool(self.cleaned_data['content']) + + class Meta: + model = Contentlet + fields = ['content'] + + +class ContentReferenceForm(ContainerForm): + def __init__(self, *args, **kwargs): + super(ContentReferenceForm, self).__init__(*args, **kwargs) + try: + self.fields['content_id'].widget = ModelLookupWidget(self.instance.content_type) + except ObjectDoesNotExist: + # This will happen when an empty form (which we will never use) gets instantiated. + pass + + def should_delete(self): + return self.has_changed() and (self.cleaned_data['content_id'] is None) + + class Meta: + model = ContentReference + fields = ['content_id'] + + +class ContainerInlineFormSet(BaseInlineFormSet): + @property + def containers(self): + if not hasattr(self, '_containers'): + self._containers = self.get_containers() + return self._containers + + def total_form_count(self): + # This ignores the posted management form data... but that doesn't + # seem to have any ill side effects. + return len(self.containers.keys()) + + def _get_initial_forms(self): + return [form for form in self.forms if form.instance.pk is not None] + initial_forms = property(_get_initial_forms) + + def _get_extra_forms(self): + return [form for form in self.forms if form.instance.pk is None] + extra_forms = property(_get_extra_forms) + + def _construct_form(self, i, **kwargs): + if 'instance' not in kwargs: + kwargs['instance'] = self.containers.values()[i] + + # Skip over the BaseModelFormSet. We have our own way of doing things! + form = super(BaseModelFormSet, self)._construct_form(i, **kwargs) + + # Since we skipped over BaseModelFormSet, we need to duplicate what BaseInlineFormSet would do. + if self.save_as_new: + # Remove the primary key from the form's data, we are only + # creating new instances + form.data[form.add_prefix(self._pk_field.name)] = None + + # Remove the foreign key from the form's data + form.data[form.add_prefix(self.fk.name)] = None + + # Set the fk value here so that the form can do it's validation. + setattr(form.instance, self.fk.get_attname(), self.instance.pk) + return form + + def add_fields(self, form, index): + """Override the pk field's initial value with a real one.""" + super(ContainerInlineFormSet, self).add_fields(form, index) + if index is not None: + pk_value = self.containers.values()[index].pk + else: + pk_value = None + form.fields[self._pk_field.name].initial = pk_value + + def save_existing_objects(self, commit=True): + self.changed_objects = [] + self.deleted_objects = [] + if not self.get_queryset(): + return [] + + saved_instances = [] + for form in self.initial_forms: + pk_name = self._pk_field.name + raw_pk_value = form._raw_value(pk_name) + + # clean() for different types of PK fields can sometimes return + # the model instance, and sometimes the PK. Handle either. + pk_value = form.fields[pk_name].clean(raw_pk_value) + pk_value = getattr(pk_value, 'pk', pk_value) + + # if the pk_value is None, they have just switched to a + # template which didn't contain data about this container. + # Skip! + if pk_value is not None: + obj = self._existing_object(pk_value) + if form.should_delete(): + self.deleted_objects.append(obj) + obj.delete() + continue + if form.has_changed(): + self.changed_objects.append((obj, form.changed_data)) + saved_instances.append(self.save_existing(form, obj, commit=commit)) + if not commit: + self.saved_forms.append(form) + return saved_instances + + def save_new_objects(self, commit=True): + self.new_objects = [] + for form in self.extra_forms: + if not form.has_changed(): + continue + # If someone has marked an add form for deletion, don't save the + # object. + if form.should_delete(): + continue + self.new_objects.append(self.save_new(form, commit=commit)) + if not commit: + self.saved_forms.append(form) + return self.new_objects + + +class ContentletInlineFormSet(ContainerInlineFormSet): + def get_containers(self): + try: + containers = list(self.instance.containers[0]) + except ObjectDoesNotExist: + containers = [] + + qs = self.get_queryset().filter(name__in=containers) + container_dict = SortedDict([(container.name, container) for container in qs]) + for name in containers: + if name not in container_dict: + container_dict[name] = self.model(name=name) + + container_dict.keyOrder = containers + return container_dict + + +class ContentReferenceInlineFormSet(ContainerInlineFormSet): + def get_containers(self): + try: + containers = self.instance.containers[1] + except ObjectDoesNotExist: + containers = {} + + filter = Q() + for name, ct in containers.items(): + filter |= Q(name=name, content_type=ct) + qs = self.get_queryset().filter(filter) + + container_dict = SortedDict([(container.name, container) for container in qs]) + + keyOrder = [] + for name, ct in containers.items(): + keyOrder.append(name) + if name not in container_dict: + container_dict[name] = self.model(name=name, content_type=ct) + + container_dict.keyOrder = keyOrder + return container_dict \ No newline at end of file diff --git a/admin/nodes.py b/philo/admin/nodes.py similarity index 79% rename from admin/nodes.py rename to philo/admin/nodes.py index 66be107..e2a9c9d 100644 --- a/admin/nodes.py +++ b/philo/admin/nodes.py @@ -1,12 +1,14 @@ from django.contrib import admin from philo.admin.base import EntityAdmin, TreeEntityAdmin, COLLAPSE_CLASSES from philo.models import Node, Redirect, File +from mptt.admin import MPTTModelAdmin class NodeAdmin(TreeEntityAdmin): list_display = ('slug', 'view', 'accepts_subpath') + raw_id_fields = ('parent',) related_lookup_fields = { - 'fk': [], + 'fk': raw_id_fields, 'm2m': [], 'generic': [['view_content_type', 'view_object_id']] } @@ -14,6 +16,9 @@ class NodeAdmin(TreeEntityAdmin): def accepts_subpath(self, obj): return obj.accepts_subpath accepts_subpath.boolean = True + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + return super(MPTTModelAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) class ViewAdmin(EntityAdmin): diff --git a/admin/pages.py b/philo/admin/pages.py similarity index 84% rename from admin/pages.py rename to philo/admin/pages.py index 13d4098..f9e96c0 100644 --- a/admin/pages.py +++ b/philo/admin/pages.py @@ -46,6 +46,12 @@ class PageAdmin(ViewAdmin): list_filter = ('template',) search_fields = ['title', 'contentlets__content'] inlines = [ContentletInline, ContentReferenceInline] + ViewAdmin.inlines + + def response_add(self, request, obj, post_url_continue='../%s/'): + # Shamelessly cribbed from django/contrib/auth/admin.py:143 + if '_addanother' not in request.POST and '_popup' not in request.POST: + request.POST['_continue'] = 1 + return super(PageAdmin, self).response_add(request, obj, post_url_continue) class TemplateAdmin(TreeAdmin): diff --git a/admin/widgets.py b/philo/admin/widgets.py similarity index 59% rename from admin/widgets.py rename to philo/admin/widgets.py index 7a47c63..fb13ac7 100644 --- a/admin/widgets.py +++ b/philo/admin/widgets.py @@ -1,6 +1,6 @@ from django import forms from django.conf import settings -from django.contrib.admin.widgets import FilteredSelectMultiple +from django.contrib.admin.widgets import FilteredSelectMultiple, url_params_from_lookup_dict from django.utils.translation import ugettext as _ from django.utils.safestring import mark_safe from django.utils.text import truncate_words @@ -10,28 +10,34 @@ from django.utils.html import escape class ModelLookupWidget(forms.TextInput): # is_hidden = False - def __init__(self, content_type, attrs=None): + def __init__(self, content_type, attrs=None, limit_choices_to=None): self.content_type = content_type + self.limit_choices_to = limit_choices_to super(ModelLookupWidget, self).__init__(attrs) def render(self, name, value, attrs=None): related_url = '../../../%s/%s/' % (self.content_type.app_label, self.content_type.model) + params = url_params_from_lookup_dict(self.limit_choices_to) + if params: + url = u'?' + u'&'.join([u'%s=%s' % (k, v) for k, v in params.items()]) + else: + url = u'' if attrs is None: attrs = {} - if not attrs.has_key('class'): + if "class" not in attrs: attrs['class'] = 'vForeignKeyRawIdAdminField' - output = super(ModelLookupWidget, self).render(name, value, attrs) - output += '' % (related_url, name) - output += '%s' % (settings.ADMIN_MEDIA_PREFIX, _('Lookup')) - output += '' + output = [super(ModelLookupWidget, self).render(name, value, attrs)] + output.append('' % (related_url, url, name)) + output.append('%s' % (settings.ADMIN_MEDIA_PREFIX, _('Lookup'))) + output.append('') if value: value_class = self.content_type.model_class() try: value_object = value_class.objects.get(pk=value) - output += ' %s' % escape(truncate_words(value_object, 14)) + output.append(' %s' % escape(truncate_words(value_object, 14))) except value_class.DoesNotExist: pass - return mark_safe(output) + return mark_safe(u''.join(output)) class TagFilteredSelectMultiple(FilteredSelectMultiple): @@ -42,15 +48,12 @@ class TagFilteredSelectMultiple(FilteredSelectMultiple): catalog has been loaded in the page """ class Media: - js = (settings.ADMIN_MEDIA_PREFIX + "js/core.js", - settings.ADMIN_MEDIA_PREFIX + "js/SelectBox.js", - settings.ADMIN_MEDIA_PREFIX + "js/SelectFilter2.js") - - if 'staticmedia' in settings.INSTALLED_APPS: - import staticmedia - js += (staticmedia.url('admin/js/TagCreation.js'),) - else: - js += (settings.ADMIN_MEDIA_PREFIX + "js/TagCreation.js",) + js = ( + settings.ADMIN_MEDIA_PREFIX + "js/core.js", + settings.ADMIN_MEDIA_PREFIX + "js/SelectBox.js", + settings.ADMIN_MEDIA_PREFIX + "js/SelectFilter2.js", + settings.ADMIN_MEDIA_PREFIX + "js/TagCreation.js", + ) def render(self, name, value, attrs=None, choices=()): if attrs is None: attrs = {} diff --git a/contrib/__init__.py b/philo/contrib/__init__.py similarity index 100% rename from contrib/__init__.py rename to philo/contrib/__init__.py diff --git a/contrib/penfield/__init__.py b/philo/contrib/julian/__init__.py similarity index 100% rename from contrib/penfield/__init__.py rename to philo/contrib/julian/__init__.py diff --git a/philo/contrib/julian/admin.py b/philo/contrib/julian/admin.py new file mode 100644 index 0000000..8f104e2 --- /dev/null +++ b/philo/contrib/julian/admin.py @@ -0,0 +1,80 @@ +from django.contrib import admin +from philo.admin import EntityAdmin, COLLAPSE_CLASSES +from philo.contrib.julian.models import Location, Event, Calendar, CalendarView + + +class LocationAdmin(EntityAdmin): + pass + + +class EventAdmin(EntityAdmin): + fieldsets = ( + (None, { + 'fields': ('name', 'slug', 'description', 'tags', 'owner') + }), + ('Location', { + 'fields': ('location_content_type', 'location_pk') + }), + ('Time', { + 'fields': (('start_date', 'start_time'), ('end_date', 'end_time'),), + }), + ('Advanced', { + 'fields': ('parent_event', 'site',), + 'classes': COLLAPSE_CLASSES + }) + ) + filter_horizontal = ['tags'] + raw_id_fields = ['parent_event'] + related_lookup_fields = { + 'fk': raw_id_fields, + 'generic': [["location_content_type", "location_pk"]] + } + prepopulated_fields = {'slug': ('name',)} + + +class CalendarAdmin(EntityAdmin): + prepopulated_fields = {'slug': ('name',)} + filter_horizontal = ['events'] + fieldsets = ( + (None, { + 'fields': ('name', 'description', 'events') + }), + ('Advanced', { + 'fields': ('slug', 'site', 'language',), + 'classes': COLLAPSE_CLASSES + }) + ) + + +class CalendarViewAdmin(EntityAdmin): + fieldsets = ( + (None, { + 'fields': ('calendar',) + }), + ('Pages', { + 'fields': ('index_page', 'event_detail_page') + }), + ('General Settings', { + 'fields': ('tag_permalink_base', 'owner_permalink_base', 'location_permalink_base', 'events_per_page') + }), + ('Event List Pages', { + 'fields': ('timespan_page', 'tag_page', 'location_page', 'owner_page'), + 'classes': COLLAPSE_CLASSES + }), + ('Archive Pages', { + 'fields': ('location_archive_page', 'tag_archive_page', 'owner_archive_page'), + 'classes': COLLAPSE_CLASSES + }), + ('Feed Settings', { + 'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',), + 'classes': COLLAPSE_CLASSES + }) + ) + raw_id_fields = ('index_page', 'event_detail_page', 'timespan_page', 'tag_page', 'location_page', 'owner_page', 'location_archive_page', 'tag_archive_page', 'owner_archive_page', 'item_title_template', 'item_description_template',) + related_lookup_fields = {'fk': raw_id_fields} + + +admin.site.register(Location, LocationAdmin) +admin.site.register(Event, EventAdmin) +admin.site.register(Calendar, CalendarAdmin) +admin.site.register(CalendarView, CalendarViewAdmin) \ No newline at end of file diff --git a/philo/contrib/julian/feedgenerator.py b/philo/contrib/julian/feedgenerator.py new file mode 100644 index 0000000..819a273 --- /dev/null +++ b/philo/contrib/julian/feedgenerator.py @@ -0,0 +1,77 @@ +from django.http import HttpResponse +from django.utils.feedgenerator import SyndicationFeed +import vobject + + +# Map the keys in the ICalendarFeed internal dictionary to the names of iCalendar attributes. +FEED_ICAL_MAP = { + 'title': 'x-wr-calname', + 'description': 'x-wr-caldesc', + #'link': ???, + #'language': ???, + #author_email + #author_name + #author_link + #subtitle + #categories + #feed_url + #feed_copyright + 'id': 'prodid', + 'ttl': 'x-published-ttl' +} + + +ITEM_ICAL_MAP = { + 'title': 'summary', + 'description': 'description', + 'link': 'url', + # author_email, author_name, and author_link need special handling. Consider them the + # 'organizer' of the event and + # construct something based on that. + 'pubdate': 'created', + 'last_modified': 'last-modified', + #'comments' require special handling as well + 'unique_id': 'uid', + 'enclosure': 'attach', # does this need special handling? + 'categories': 'categories', # does this need special handling? + # ttl is ignored. + 'start': 'dtstart', + 'end': 'dtend', +} + + +class ICalendarFeed(SyndicationFeed): + mime_type = 'text/calendar' + + def add_item(self, *args, **kwargs): + for kwarg in ['start', 'end', 'last_modified', 'location']: + kwargs.setdefault(kwarg, None) + super(ICalendarFeed, self).add_item(*args, **kwargs) + + def write(self, outfile, encoding): + # TODO: Use encoding... how? Just convert all values when setting them should work... + cal = vobject.iCalendar() + + # IE/Outlook needs this. See + # + cal.add('method').value = 'PUBLISH' + + for key, val in self.feed.items(): + if key in FEED_ICAL_MAP and val: + cal.add(FEED_ICAL_MAP[key]).value = val + + for item in self.items: + # TODO: handle multiple types of events. + event = cal.add('vevent') + for key, val in item.items(): + #TODO: handle the non-standard items like comments and author. + if key in ITEM_ICAL_MAP and val: + event.add(ITEM_ICAL_MAP[key]).value = val + + cal.serialize(outfile) + + # Some special handling for HttpResponses. See link above. + if isinstance(outfile, HttpResponse): + filename = self.feed.get('filename', 'filename.ics') + outfile['Filename'] = filename + outfile['Content-Disposition'] = 'attachment; filename=%s' % filename \ No newline at end of file diff --git a/philo/contrib/julian/migrations/0001_initial.py b/philo/contrib/julian/migrations/0001_initial.py new file mode 100644 index 0000000..3236095 --- /dev/null +++ b/philo/contrib/julian/migrations/0001_initial.py @@ -0,0 +1,286 @@ +# 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 'Location' + db.create_table('julian_location', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=255, db_index=True)), + )) + db.send_create_signal('julian', ['Location']) + + # Adding model 'Event' + db.create_table('julian_event', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('start_date', self.gf('django.db.models.fields.DateField')()), + ('start_time', self.gf('django.db.models.fields.TimeField')(null=True, blank=True)), + ('end_date', self.gf('django.db.models.fields.DateField')()), + ('end_time', self.gf('django.db.models.fields.TimeField')(null=True, blank=True)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255, db_index=True)), + ('location_content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'], null=True, blank=True)), + ('location_pk', self.gf('django.db.models.fields.TextField')(blank=True)), + ('description', self.gf('philo.models.fields.TemplateField')()), + ('parent_event', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['julian.Event'], null=True, blank=True)), + ('owner', self.gf('django.db.models.fields.related.ForeignKey')(related_name='owned_events', to=orm['auth.User'])), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('last_modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), + ('site', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['sites.Site'])), + )) + db.send_create_signal('julian', ['Event']) + + # Adding unique constraint on 'Event', fields ['site', 'created'] + db.create_unique('julian_event', ['site_id', 'created']) + + # Adding M2M table for field tags on 'Event' + db.create_table('julian_event_tags', ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('event', models.ForeignKey(orm['julian.event'], null=False)), + ('tag', models.ForeignKey(orm['philo.tag'], null=False)) + )) + db.create_unique('julian_event_tags', ['event_id', 'tag_id']) + + # Adding model 'Calendar' + db.create_table('julian_calendar', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=100)), + ('slug', self.gf('django.db.models.fields.SlugField')(max_length=100, db_index=True)), + ('description', self.gf('django.db.models.fields.TextField')(blank=True)), + ('site', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['sites.Site'])), + ('language', self.gf('django.db.models.fields.CharField')(default='en', max_length=5)), + )) + db.send_create_signal('julian', ['Calendar']) + + # Adding unique constraint on 'Calendar', fields ['name', 'site', 'language'] + db.create_unique('julian_calendar', ['name', 'site_id', 'language']) + + # Adding M2M table for field events on 'Calendar' + db.create_table('julian_calendar_events', ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('calendar', models.ForeignKey(orm['julian.calendar'], null=False)), + ('event', models.ForeignKey(orm['julian.event'], null=False)) + )) + db.create_unique('julian_calendar_events', ['calendar_id', 'event_id']) + + # Adding model 'CalendarView' + db.create_table('julian_calendarview', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('feed_type', self.gf('django.db.models.fields.CharField')(default='text/calendar', max_length=50)), + ('feed_suffix', self.gf('django.db.models.fields.CharField')(default='feed', max_length=255)), + ('feeds_enabled', self.gf('django.db.models.fields.BooleanField')(default=True)), + ('feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True)), + ('item_title_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='julian_calendarview_title_related', null=True, to=orm['philo.Template'])), + ('item_description_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='julian_calendarview_description_related', null=True, to=orm['philo.Template'])), + ('calendar', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['julian.Calendar'])), + ('index_page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='calendar_index_related', to=orm['philo.Page'])), + ('event_detail_page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='calendar_detail_related', to=orm['philo.Page'])), + ('timespan_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_timespan_related', null=True, to=orm['philo.Page'])), + ('tag_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_tag_related', null=True, to=orm['philo.Page'])), + ('location_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_location_related', null=True, to=orm['philo.Page'])), + ('owner_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_owner_related', null=True, to=orm['philo.Page'])), + ('tag_archive_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_tag_archive_related', null=True, to=orm['philo.Page'])), + ('location_archive_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_location_archive_related', null=True, to=orm['philo.Page'])), + ('owner_archive_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_owner_archive_related', null=True, to=orm['philo.Page'])), + ('tag_permalink_base', self.gf('django.db.models.fields.CharField')(default='tags', max_length=30)), + ('owner_permalink_base', self.gf('django.db.models.fields.CharField')(default='owners', max_length=30)), + ('location_permalink_base', self.gf('django.db.models.fields.CharField')(default='locations', max_length=30)), + ('events_per_page', self.gf('django.db.models.fields.PositiveIntegerField')(null=True, blank=True)), + )) + db.send_create_signal('julian', ['CalendarView']) + + + def backwards(self, orm): + + # Removing unique constraint on 'Calendar', fields ['name', 'site', 'language'] + db.delete_unique('julian_calendar', ['name', 'site_id', 'language']) + + # Removing unique constraint on 'Event', fields ['site', 'created'] + db.delete_unique('julian_event', ['site_id', 'created']) + + # Deleting model 'Location' + db.delete_table('julian_location') + + # Deleting model 'Event' + db.delete_table('julian_event') + + # Removing M2M table for field tags on 'Event' + db.delete_table('julian_event_tags') + + # Deleting model 'Calendar' + db.delete_table('julian_calendar') + + # Removing M2M table for field events on 'Calendar' + db.delete_table('julian_calendar_events') + + # Deleting model 'CalendarView' + db.delete_table('julian_calendarview') + + + 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'}) + }, + 'julian.calendar': { + 'Meta': {'unique_together': "(('name', 'site', 'language'),)", 'object_name': 'Calendar'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'events': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'calendars'", 'blank': 'True', 'to': "orm['julian.Event']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'default': "'en'", 'max_length': '5'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['sites.Site']"}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '100', 'db_index': 'True'}) + }, + 'julian.calendarview': { + 'Meta': {'object_name': 'CalendarView'}, + 'calendar': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['julian.Calendar']"}), + 'event_detail_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'calendar_detail_related'", 'to': "orm['philo.Page']"}), + 'events_per_page': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + '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': "'text/calendar'", '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': "'calendar_index_related'", 'to': "orm['philo.Page']"}), + 'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'julian_calendarview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'julian_calendarview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'location_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_location_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'location_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_location_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'location_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'locations'", 'max_length': '30'}), + 'owner_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_owner_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'owner_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_owner_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'owner_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'owners'", 'max_length': '30'}), + 'tag_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_tag_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'tag_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_tag_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'tag_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'tags'", 'max_length': '30'}), + 'timespan_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_timespan_related'", 'null': 'True', 'to': "orm['philo.Page']"}) + }, + 'julian.event': { + 'Meta': {'unique_together': "(('site', 'created'),)", 'object_name': 'Event'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'description': ('philo.models.fields.TemplateField', [], {}), + 'end_date': ('django.db.models.fields.DateField', [], {}), + 'end_time': ('django.db.models.fields.TimeField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'location_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'location_pk': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'owned_events'", 'to': "orm['auth.User']"}), + 'parent_event': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['julian.Event']", 'null': 'True', 'blank': 'True'}), + 'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['sites.Site']"}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'start_date': ('django.db.models.fields.DateField', [], {}), + 'start_time': ('django.db.models.fields.TimeField', [], {'null': 'True', 'blank': 'True'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'events'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}) + }, + 'julian.location': { + 'Meta': {'object_name': 'Location'}, + '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'}) + }, + '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']"}), + '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.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': {'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'}) + }, + 'sites.site': { + 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"}, + 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'root_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'sites'", 'null': 'True', 'to': "orm['philo.Node']"}) + } + } + + complete_apps = ['julian'] diff --git a/contrib/penfield/migrations/__init__.py b/philo/contrib/julian/migrations/__init__.py similarity index 100% rename from contrib/penfield/migrations/__init__.py rename to philo/contrib/julian/migrations/__init__.py diff --git a/philo/contrib/julian/models.py b/philo/contrib/julian/models.py new file mode 100644 index 0000000..5c49c7e --- /dev/null +++ b/philo/contrib/julian/models.py @@ -0,0 +1,461 @@ +from django.conf import settings +from django.conf.urls.defaults import url, patterns, include +from django.contrib.auth.models import User +from django.contrib.contenttypes.generic import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.models import Site +from django.core.exceptions import ValidationError, ObjectDoesNotExist +from django.core.validators import RegexValidator +from django.db import models +from django.db.models.query import QuerySet +from django.http import HttpResponse, Http404 +from django.utils.encoding import force_unicode +from philo.contrib.julian.feedgenerator import ICalendarFeed +from philo.contrib.penfield.models import FeedView, FEEDS +from philo.exceptions import ViewCanNotProvideSubpath +from philo.models import Tag, Entity, Page, TemplateField +from philo.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 +try: + DEFAULT_SITE = Site.objects.get_current() +except: + DEFAULT_SITE = None +_languages = dict(settings.LANGUAGES) +try: + _languages[settings.LANGUAGE_CODE] + DEFAULT_LANGUAGE = settings.LANGUAGE_CODE +except KeyError: + try: + lang = settings.LANGUAGE_CODE.split('-')[0] + _languages[lang] + DEFAULT_LANGUAGE = lang + except KeyError: + DEFAULT_LANGUAGE = None + + +location_content_type_limiter = ContentTypeRegistryLimiter() + + +def register_location_model(model): + location_content_type_limiter.register_class(model) + + +def unregister_location_model(model): + location_content_type_limiter.unregister_class(model) + + +class Location(Entity): + name = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True) + + def __unicode__(self): + return self.name + + +register_location_model(Location) + + +class TimedModel(models.Model): + start_date = models.DateField(help_text="YYYY-MM-DD") + start_time = models.TimeField(blank=True, null=True, help_text="HH:MM:SS - 24 hour clock") + end_date = models.DateField() + end_time = models.TimeField(blank=True, null=True) + + def is_all_day(self): + return self.start_time is None and self.end_time is None + + def clean(self): + if bool(self.start_time) != bool(self.end_time): + raise ValidationError("A %s must have either a start time and an end time or neither.") + + if self.start_date > self.end_date or self.start_date == self.end_date and self.start_time > self.end_time: + raise ValidationError("A %s cannot end before it starts." % self.__class__.__name__) + + def get_start(self): + return datetime.datetime.combine(self.start_date, self.start_time) if self.start_time else self.start_date + + def get_end(self): + return datetime.datetime.combine(self.end_date, self.end_time) if self.end_time else self.end_date + + class Meta: + abstract = True + + +class EventManager(models.Manager): + def get_query_set(self): + return EventQuerySet(self.model) + +class EventQuerySet(QuerySet): + def upcoming(self): + return self.filter(start_date__gte=datetime.date.today()) + def current(self): + return self.filter(start_date__lte=datetime.date.today(), end_date__gte=datetime.date.today()) + def single_day(self): + return self.filter(start_date__exact=models.F('end_date')) + def multiday(self): + return self.exclude(start_date__exact=models.F('end_date')) + +class Event(Entity, TimedModel): + name = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique_for_date='start_date') + + location_content_type = models.ForeignKey(ContentType, limit_choices_to=location_content_type_limiter, blank=True, null=True) + location_pk = models.TextField(blank=True) + location = GenericForeignKey('location_content_type', 'location_pk') + + description = TemplateField() + + tags = models.ManyToManyField(Tag, related_name='events', blank=True, null=True) + + parent_event = models.ForeignKey('self', blank=True, null=True) + + # TODO: "User module" + owner = models.ForeignKey(User, related_name='owned_events') + + created = models.DateTimeField(auto_now_add=True) + last_modified = models.DateTimeField(auto_now=True) + + site = models.ForeignKey(Site, default=DEFAULT_SITE) + + @property + def uuid(self): + return "%s@%s" % (self.created.isoformat(), getattr(self.site, 'domain', 'None')) + + objects = EventManager() + + def __unicode__(self): + return self.name + + class Meta: + unique_together = ('site', 'created') + + +class Calendar(Entity): + name = models.CharField(max_length=100) + slug = models.SlugField(max_length=100) + description = models.TextField(blank=True) + events = models.ManyToManyField(Event, related_name='calendars', blank=True) + + site = models.ForeignKey(Site, default=DEFAULT_SITE) + language = models.CharField(max_length=5, choices=settings.LANGUAGES, default=DEFAULT_LANGUAGE) + + def __unicode__(self): + return self.name + + @property + def fpi(self): + # See http://xml.coverpages.org/tauber-fpi.html or ISO 9070:1991 for format information. + return "-//%s//%s//%s" % (self.site.name, self.name, self.language.split('-')[0].upper()) + + class Meta: + unique_together = ('name', 'site', 'language') + + +class CalendarView(FeedView): + calendar = models.ForeignKey(Calendar) + index_page = models.ForeignKey(Page, related_name="calendar_index_related") + event_detail_page = models.ForeignKey(Page, related_name="calendar_detail_related") + + timespan_page = models.ForeignKey(Page, related_name="calendar_timespan_related", blank=True, null=True) + tag_page = models.ForeignKey(Page, related_name="calendar_tag_related", blank=True, null=True) + location_page = models.ForeignKey(Page, related_name="calendar_location_related", blank=True, null=True) + owner_page = models.ForeignKey(Page, related_name="calendar_owner_related", blank=True, null=True) + + tag_archive_page = models.ForeignKey(Page, related_name="calendar_tag_archive_related", blank=True, null=True) + location_archive_page = models.ForeignKey(Page, related_name="calendar_location_archive_related", blank=True, null=True) + owner_archive_page = models.ForeignKey(Page, related_name="calendar_owner_archive_related", blank=True, null=True) + + tag_permalink_base = models.CharField(max_length=30, default='tags') + owner_permalink_base = models.CharField(max_length=30, default='owners') + location_permalink_base = models.CharField(max_length=30, default='locations') + events_per_page = models.PositiveIntegerField(blank=True, null=True) + + item_context_var = "events" + object_attr = "calendar" + + def get_reverse_params(self, obj): + if isinstance(obj, User): + return 'events_for_user', [], {'username': obj.username} + elif isinstance(obj, Event): + return 'event_detail', [], { + 'year': str(obj.start_date.year).zfill(4), + 'month': str(obj.start_date.month).zfill(2), + 'day': str(obj.start_date.day).zfill(2), + 'slug': obj.slug + } + elif isinstance(obj, Tag) or isinstance(obj, models.query.QuerySet) and obj.model == Tag: + if isinstance(obj, Tag): + obj = [obj] + return 'entries_by_tag', [], {'tag_slugs': '/'.join(obj)} + raise ViewCanNotProvideSubpath + + def timespan_patterns(self, pattern, timespan_name): + return self.feed_patterns(pattern, 'get_events_by_timespan', 'timespan_page', "events_by_%s" % timespan_name) + + @property + def urlpatterns(self): + # Perhaps timespans should be done with GET parameters? Or two /-separated + # date slugs? (e.g. 2010-02-1/2010-02-2) or a start and duration? + # (e.g. 2010-02-01/week/ or ?d=2010-02-01&l=week) + urlpatterns = self.feed_patterns(r'^', 'get_all_events', 'index_page', 'index') + \ + self.timespan_patterns(r'^(?P\d{4})', 'year') + \ + self.timespan_patterns(r'^(?P\d{4})/(?P\d{2})', 'month') + \ + self.timespan_patterns(r'^(?P\d{4})/(?P\d{2})/(?P\d{2})', 'day') + \ + self.feed_patterns(r'^%s/(?P[^/]+)' % self.owner_permalink_base, 'get_events_by_owner', 'owner_page', 'events_by_user') + \ + self.feed_patterns(r'^%s/(?P\w+)/(?P\w+)/(?P[^/]+)' % self.location_permalink_base, 'get_events_by_location', 'location_page', 'events_by_location') + \ + self.feed_patterns(r'^%s/(?P[-\w]+[-+/\w]*)' % self.tag_permalink_base, 'get_events_by_tag', 'tag_page', 'events_by_tag') + \ + patterns('', + url(r'(?P\d{4})/(?P\d{2})/(?P\d{2})/(?P[\w-]+)$', self.event_detail_view, name="event_detail"), + ) + + # Some sort of shortcut for a location would be useful. This could be on a per-calendar + # or per-calendar-view basis. + #url(r'^%s/(?P[\w-]+)' % self.location_permalink_base, ...) + + if self.tag_archive_page: + urlpatterns += patterns('', + url(r'^%s$' % self.tag_permalink_base, self.tag_archive_view, name='tag_archive') + ) + + if self.owner_archive_page: + urlpatterns += patterns('', + url(r'^%s$' % self.owner_permalink_base, self.owner_archive_view, name='owner_archive') + ) + + if self.location_archive_page: + urlpatterns += patterns('', + url(r'^%s$' % self.location_permalink_base, self.location_archive_view, name='location_archive') + ) + return urlpatterns + + # Basic QuerySet fetchers. + def get_event_queryset(self): + return self.calendar.events.all() + + def get_timespan_queryset(self, year, month=None, day=None): + qs = self.get_event_queryset() + # See python documentation for the min/max values. + if year and month and day: + year, month, day = int(year), int(month), int(day) + start_datetime = datetime.datetime(year, month, day, 0, 0) + end_datetime = datetime.datetime(year, month, day, 23, 59) + elif year and month: + year, month = int(year), int(month) + start_datetime = datetime.datetime(year, month, 1, 0, 0) + end_datetime = datetime.datetime(year, month, calendar.monthrange(year, month)[1], 23, 59) + else: + year = int(year) + start_datetime = datetime.datetime(year, 1, 1, 0, 0) + end_datetime = datetime.datetime(year, 12, 31, 23, 59) + + return qs.exclude(end_date__lt=start_datetime, end_time__lt=start_datetime).exclude(start_date__gt=end_datetime, start_time__gt=end_datetime, start_time__isnull=False).exclude(start_time__isnull=True, start_date__gt=end_datetime) + + def get_tag_queryset(self): + return Tag.objects.filter(events__calendars=self.calendar).distinct() + + def get_location_querysets(self): + # Potential bottleneck? + location_map = {} + locations = Event.objects.values_list('location_content_type', 'location_pk') + + for ct, pk in locations: + location_map.setdefault(ct, []).append(pk) + + location_cts = ContentType.objects.in_bulk(location_map.keys()) + location_querysets = {} + + for ct_pk, pks in location_map.items(): + ct = location_cts[ct_pk] + location_querysets[ct] = ct.model_class()._default_manager.filter(pk__in=pks) + + return location_querysets + + def get_owner_queryset(self): + return User.objects.filter(owned_events__calendars=self.calendar).distinct() + + # Event QuerySet parsers for a request/args/kwargs + def get_all_events(self, request, extra_context=None): + return self.get_event_queryset(), extra_context + + def get_events_by_timespan(self, request, year, month=None, day=None, extra_context=None): + context = extra_context or {} + context.update({ + 'year': year, + 'month': month, + 'day': day + }) + return self.get_timespan_queryset(year, month, day), context + + def get_events_by_owner(self, request, username, extra_context=None): + try: + owner = self.get_owner_queryset().get(username=username) + except User.DoesNotExist: + raise Http404 + + qs = self.get_event_queryset().filter(owner=owner) + context = extra_context or {} + context.update({ + 'owner': owner + }) + return qs, context + + def get_events_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) + + if not tags: + raise Http404 + + # 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 + + events = self.get_event_queryset() + for tag in tags: + events = events.filter(tags=tag) + + context = extra_context or {} + context.update({'tags': tags}) + + return events, context + + 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) + location = ct.model_class()._default_manager.get(pk=pk) + except ObjectDoesNotExist: + raise Http404 + + events = self.get_event_queryset().filter(location_content_type=ct, location_pk=location.pk) + + context = extra_context or {} + context.update({ + 'location': location + }) + return events, context + + # Detail View. + def event_detail_view(self, request, year, month, day, slug, extra_context=None): + try: + event = Event.objects.select_related('parent_event').get(start_date__year=year, start_date__month=month, start_date__day=day, slug=slug) + except Event.DoesNotExist: + raise Http404 + + context = self.get_context() + context.update(extra_context or {}) + context.update({ + 'event': event + }) + return self.event_detail_page.render_to_response(request, extra_context=context) + + # Archive Views. + def tag_archive_view(self, request, extra_context=None): + tags = self.get_tag_queryset() + context = self.get_context() + context.update(extra_context or {}) + context.update({ + 'tags': tags + }) + return self.tag_archive_page.render_to_response(request, extra_context=context) + + def location_archive_view(self, request, extra_context=None): + # What datastructure should locations be? + locations = self.get_location_querysets() + context = self.get_context() + context.update(extra_context or {}) + context.update({ + 'locations': locations + }) + return self.location_archive_page.render_to_response(request, extra_context=context) + + def owner_archive_view(self, request, extra_context=None): + owners = self.get_owner_queryset() + context = self.get_context() + context.update(extra_context or {}) + context.update({ + 'owners': owners + }) + return self.owner_archive_page.render_to_response(request, extra_context=context) + + # Process page items + def process_page_items(self, request, items): + if self.events_per_page: + page_num = request.GET.get('page', 1) + paginator, paginated_page, items = paginate(items, self.events_per_page, page_num) + item_context = { + 'paginator': paginator, + 'paginated_page': paginated_page, + self.item_context_var: items + } + else: + item_context = { + self.item_context_var: items + } + return items, item_context + + # Feed information hooks + def title(self, obj): + return obj.name + + def link(self, obj): + # Link is ignored anyway... + return "" + + def feed_guid(self, obj): + return obj.fpi + + def description(self, obj): + return obj.description + + def feed_extra_kwargs(self, obj): + return {'filename': "%s.ics" % obj.slug} + + def item_title(self, item): + return item.name + + def item_description(self, item): + return item.description + + def item_link(self, item): + return self.reverse(item) + + def item_guid(self, item): + return item.uuid + + def item_author_name(self, item): + if item.owner: + return item.owner.get_full_name() + + def item_author_email(self, item): + return getattr(item.owner, 'email', None) or None + + def item_pubdate(self, item): + return item.created + + def item_categories(self, item): + return [tag.name for tag in item.tags.all()] + + def item_extra_kwargs(self, item): + return { + 'start': item.get_start(), + 'end': item.get_end(), + 'last_modified': item.last_modified, + # Is forcing unicode enough, or should we look for a "custom method"? + 'location': force_unicode(item.location), + } + + def __unicode__(self): + 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 diff --git a/contrib/penfield/templatetags/__init__.py b/philo/contrib/penfield/__init__.py similarity index 100% rename from contrib/penfield/templatetags/__init__.py rename to philo/contrib/penfield/__init__.py diff --git a/contrib/penfield/admin.py b/philo/contrib/penfield/admin.py similarity index 82% rename from contrib/penfield/admin.py rename to philo/contrib/penfield/admin.py index 950539d..c70cf46 100644 --- a/contrib/penfield/admin.py +++ b/philo/contrib/penfield/admin.py @@ -1,5 +1,7 @@ -from django.contrib import admin from django import forms +from django.contrib import admin +from django.core.urlresolvers import reverse +from django.http import HttpResponseRedirect, QueryDict from philo.admin import EntityAdmin, AddTagAdmin, COLLAPSE_CLASSES from philo.contrib.penfield.models import BlogEntry, Blog, BlogView, Newsletter, NewsletterArticle, NewsletterIssue, NewsletterView @@ -60,7 +62,7 @@ class BlogViewAdmin(EntityAdmin): 'classes': COLLAPSE_CLASSES }), ('Feed Settings', { - 'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',), + 'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'feed_length', 'item_title_template', 'item_description_template',), 'classes': COLLAPSE_CLASSES }) ) @@ -91,10 +93,18 @@ class NewsletterArticleAdmin(TitledAdmin, AddTagAdmin): 'classes': COLLAPSE_CLASSES }) ) + actions = ['make_issue'] def author_names(self, obj): return ', '.join([author.get_full_name() for author in obj.authors.all()]) author_names.short_description = "Authors" + + def make_issue(self, request, queryset): + opts = NewsletterIssue._meta + info = opts.app_label, opts.module_name + url = reverse("admin:%s_%s_add" % info) + return HttpResponseRedirect("%s?articles=%s" % (url, ",".join([str(a.pk) for a in queryset]))) + make_issue.short_description = u"Create issue from selected %(verbose_name_plural)s" class NewsletterIssueAdmin(TitledAdmin): @@ -117,7 +127,7 @@ class NewsletterViewAdmin(EntityAdmin): 'classes': COLLAPSE_CLASSES }), ('Feeds', { - 'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',), + 'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'feed_length', 'item_title_template', 'item_description_template',), 'classes': COLLAPSE_CLASSES }) ) diff --git a/philo/contrib/penfield/exceptions.py b/philo/contrib/penfield/exceptions.py new file mode 100644 index 0000000..96b96ed --- /dev/null +++ b/philo/contrib/penfield/exceptions.py @@ -0,0 +1,3 @@ +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/middleware.py b/philo/contrib/penfield/middleware.py new file mode 100644 index 0000000..b25a28b --- /dev/null +++ b/philo/contrib/penfield/middleware.py @@ -0,0 +1,14 @@ +from django.http import HttpResponse +from django.utils.decorators import decorator_from_middleware +from philo.contrib.penfield.exceptions import HttpNotAcceptable + + +class HttpNotAcceptableMiddleware(object): + """Middleware to catch HttpNotAcceptable errors and return an Http406 response. + See RFC 2616.""" + def process_exception(self, request, exception): + if isinstance(exception, HttpNotAcceptable): + return HttpResponse(status=406) + + +http_not_acceptable = decorator_from_middleware(HttpNotAcceptableMiddleware) \ No newline at end of file diff --git a/contrib/penfield/migrations/0001_initial.py b/philo/contrib/penfield/migrations/0001_initial.py similarity index 100% rename from contrib/penfield/migrations/0001_initial.py rename to philo/contrib/penfield/migrations/0001_initial.py diff --git a/contrib/penfield/migrations/0002_auto.py b/philo/contrib/penfield/migrations/0002_auto.py similarity index 100% rename from contrib/penfield/migrations/0002_auto.py rename to philo/contrib/penfield/migrations/0002_auto.py diff --git a/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 similarity index 100% rename from contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py rename to philo/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py 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 new file mode 100644 index 0000000..9b9ffa7 --- /dev/null +++ b/philo/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py @@ -0,0 +1,204 @@ +# 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 'NewsletterView.feed_length' + db.add_column('penfield_newsletterview', 'feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True), keep_default=False) + + # Adding field 'BlogView.feed_length' + db.add_column('penfield_blogview', 'feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True), keep_default=False) + + + def backwards(self, orm): + + # Deleting field 'NewsletterView.feed_length' + db.delete_column('penfield_newsletterview', 'feed_length') + + # Deleting field 'BlogView.feed_length' + db.delete_column('penfield_blogview', 'feed_length') + + + 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': {'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', 'blank': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}), + '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': {'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'}) + }, + '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'}) + }, + '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['oberlin.Person']"}), + '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': "'application/atom+xml; charset=utf8'", 'max_length': '50'}), + 'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': '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': {'unique_together': "(('newsletter', 'slug'),)", 'object_name': 'NewsletterArticle'}, + 'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['oberlin.Person']"}), + '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': {'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': "'application/atom+xml; charset=utf8'", 'max_length': '50'}), + 'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': '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': {'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.tag': { + 'Meta': {'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': {'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 = ['penfield'] diff --git a/contrib/shipherd/__init__.py b/philo/contrib/penfield/migrations/__init__.py similarity index 100% rename from contrib/shipherd/__init__.py rename to philo/contrib/penfield/migrations/__init__.py diff --git a/contrib/penfield/models.py b/philo/contrib/penfield/models.py similarity index 97% rename from contrib/penfield/models.py rename to philo/contrib/penfield/models.py index 98dcdd5..a03bed8 100644 --- a/contrib/penfield/models.py +++ b/philo/contrib/penfield/models.py @@ -10,6 +10,8 @@ from django.utils.datastructures import SortedDict from django.utils.encoding import smart_unicode, force_unicode from django.utils.html import escape from datetime import date, datetime +from philo.contrib.penfield.exceptions import HttpNotAcceptable +from philo.contrib.penfield.middleware import http_not_acceptable from philo.contrib.penfield.validators import validate_pagination_count from philo.exceptions import ViewCanNotProvideSubpath from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField, Template @@ -44,6 +46,7 @@ class FeedView(MultiView): 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") @@ -62,7 +65,7 @@ class FeedView(MultiView): urlpatterns = patterns('') if self.feeds_enabled: feed_reverse_name = "%s_feed" % reverse_name - feed_view = self.feed_view(get_items_attr, 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), @@ -139,8 +142,7 @@ class FeedView(MultiView): else: feed_type = None if not feed_type: - # See RFC 2616 - return HttpResponse(status=406) + raise HttpNotAcceptable return FEEDS[feed_type] def get_feed(self, obj, request, reverse_name): @@ -194,6 +196,9 @@ class FeedView(MultiView): 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})) diff --git a/contrib/shipherd/migrations/__init__.py b/philo/contrib/penfield/templatetags/__init__.py similarity index 100% rename from contrib/shipherd/migrations/__init__.py rename to philo/contrib/penfield/templatetags/__init__.py diff --git a/contrib/penfield/templatetags/penfield.py b/philo/contrib/penfield/templatetags/penfield.py similarity index 100% rename from contrib/penfield/templatetags/penfield.py rename to philo/contrib/penfield/templatetags/penfield.py diff --git a/contrib/penfield/validators.py b/philo/contrib/penfield/validators.py similarity index 100% rename from contrib/penfield/validators.py rename to philo/contrib/penfield/validators.py diff --git a/contrib/shipherd/templatetags/__init__.py b/philo/contrib/shipherd/__init__.py similarity index 100% rename from contrib/shipherd/templatetags/__init__.py rename to philo/contrib/shipherd/__init__.py diff --git a/contrib/shipherd/admin.py b/philo/contrib/shipherd/admin.py similarity index 100% rename from contrib/shipherd/admin.py rename to philo/contrib/shipherd/admin.py diff --git a/contrib/shipherd/migrations/0001_initial.py b/philo/contrib/shipherd/migrations/0001_initial.py similarity index 100% rename from contrib/shipherd/migrations/0001_initial.py rename to philo/contrib/shipherd/migrations/0001_initial.py diff --git a/contrib/shipherd/migrations/0002_auto.py b/philo/contrib/shipherd/migrations/0002_auto.py similarity index 100% rename from contrib/shipherd/migrations/0002_auto.py rename to philo/contrib/shipherd/migrations/0002_auto.py diff --git a/contrib/waldo/__init__.py b/philo/contrib/shipherd/migrations/__init__.py similarity index 100% rename from contrib/waldo/__init__.py rename to philo/contrib/shipherd/migrations/__init__.py diff --git a/contrib/shipherd/models.py b/philo/contrib/shipherd/models.py similarity index 100% rename from contrib/shipherd/models.py rename to philo/contrib/shipherd/models.py diff --git a/loaders/__init__.py b/philo/contrib/shipherd/templatetags/__init__.py similarity index 100% rename from loaders/__init__.py rename to philo/contrib/shipherd/templatetags/__init__.py diff --git a/philo/contrib/shipherd/templatetags/shipherd.py b/philo/contrib/shipherd/templatetags/shipherd.py new file mode 100644 index 0000000..e3019e1 --- /dev/null +++ b/philo/contrib/shipherd/templatetags/shipherd.py @@ -0,0 +1,170 @@ +from django import template, VERSION as django_version +from django.conf import settings +from django.utils.safestring import mark_safe +from philo.contrib.shipherd.models import Navigation +from philo.models import Node +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext as _ + + +register = template.Library() + + +class LazyNavigationRecurser(object): + def __init__(self, template_nodes, items, context, request): + self.template_nodes = template_nodes + self.items = items + self.context = context + self.request = request + + def __call__(self): + items = self.items + context = self.context + request = self.request + + if not items: + return '' + + if 'navloop' in context: + parentloop = context['navloop'] + else: + parentloop = {} + context.push() + + depth = items[0].get_level() + len_items = len(items) + + loop_dict = context['navloop'] = { + 'parentloop': parentloop, + 'depth': depth + 1, + 'depth0': depth + } + + bits = [] + + for i, item in enumerate(items): + # First set context variables. + loop_dict['counter0'] = i + loop_dict['counter'] = i + 1 + loop_dict['revcounter'] = len_items - i + loop_dict['revcounter0'] = len_items - i - 1 + loop_dict['first'] = (i == 0) + loop_dict['last'] = (i == len_items - 1) + + # Set on loop_dict and context for backwards-compatibility. + # Eventually only allow access through the loop_dict. + loop_dict['active'] = context['active'] = item.is_active(request) + loop_dict['active_descendants'] = context['active_descendants'] = item.has_active_descendants(request) + + # Set these directly in the context for easy access. + context['item'] = item + context['children'] = self.__class__(self.template_nodes, item.get_children(), context, request) + + # Then render the nodelist bit by bit. + for node in self.template_nodes: + bits.append(node.render(context)) + context.pop() + return mark_safe(''.join(bits)) + + +class RecurseNavigationNode(template.Node): + def __init__(self, template_nodes, instance_var, key): + self.template_nodes = template_nodes + self.instance_var = instance_var + self.key = key + + def render(self, context): + try: + request = context['request'] + except KeyError: + return '' + + instance = self.instance_var.resolve(context) + + try: + items = instance.navigation[self.key] + except: + return settings.TEMPLATE_STRING_IF_INVALID + + return LazyNavigationRecurser(self.template_nodes, items, context, request)() + + +@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. + + 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: + + ============================== ================================================ + Variable Description + ============================== ================================================ + ``navloop.depth`` The current depth of the loop (1 is the top level) + ``navloop.depth0`` The current depth of the loop (0 is the top level) + ``navloop.counter`` The current iteration of the current level(1-indexed) + ``navloop.counter0`` The current iteration of the current level(0-indexed) + ``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) + ``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: +
    + {% recursenavigation node main %} + + {{ navloop.item.text }} + {% if item.get_children %} +
      + {{ children }} +
    + {% endif %} + + {% endrecursenavigation %} +
+ """ + bits = token.contents.split() + if len(bits) != 3: + raise template.TemplateSyntaxError(_('%s tag requires two arguments: a node and a navigation section name') % bits[0]) + + instance_var = parser.compile_filter(bits[1]) + key = bits[2] + + template_nodes = parser.parse(('recurse', 'endrecursenavigation',)) + + token = parser.next_token() + if token.contents == 'recurse': + template_nodes.append(RecurseNavigationMarker()) + template_nodes.extend(parser.parse(('endrecursenavigation'))) + parser.delete_first_token() + + return RecurseNavigationNode(template_nodes, instance_var, key) + + +@register.filter +def has_navigation(node, key=None): + 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) + except: + return False + + +@register.filter +def navigation_host(node, key): + try: + return Navigation.objects.filter(node__in=node.get_ancestors(include_self=True), key=key).order_by('-node__level')[0].node + except: + return node \ No newline at end of file diff --git a/philo/contrib/sobol/__init__.py b/philo/contrib/sobol/__init__.py new file mode 100644 index 0000000..90eaf18 --- /dev/null +++ b/philo/contrib/sobol/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000..87dd39a --- /dev/null +++ b/philo/contrib/sobol/admin.py @@ -0,0 +1,104 @@ +from django.conf import settings +from django.conf.urls.defaults import patterns, url +from django.contrib import admin +from django.core.urlresolvers import reverse +from django.db.models import Count +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): + model = ResultURL + readonly_fields = ('url',) + can_delete = False + extra = 0 + max_num = 0 + + +class SearchAdmin(admin.ModelAdmin): + readonly_fields = ('string',) + inlines = [ResultURLInline] + list_display = ['string', 'unique_urls', 'total_clicks'] + 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 + + def unique_urls(self, obj): + return obj.unique_urls + unique_urls.admin_order_field = 'unique_urls' + + def total_clicks(self, obj): + return obj.total_clicks + total_clicks.admin_order_field = 'total_clicks' + + 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): + raw_id_fields = ('results_page',) + related_lookup_fields = {'fk': raw_id_fields} + + +admin.site.register(Search, SearchAdmin) +admin.site.register(SearchView, SearchViewAdmin) \ No newline at end of file diff --git a/philo/contrib/sobol/forms.py b/philo/contrib/sobol/forms.py new file mode 100644 index 0000000..e79d9e7 --- /dev/null +++ b/philo/contrib/sobol/forms.py @@ -0,0 +1,12 @@ +from django import forms +from philo.contrib.sobol.utils import SEARCH_ARG_GET_KEY + + +class BaseSearchForm(forms.BaseForm): + base_fields = { + SEARCH_ARG_GET_KEY: forms.CharField() + } + + +class SearchForm(forms.Form, BaseSearchForm): + pass \ No newline at end of file diff --git a/philo/contrib/sobol/models.py b/philo/contrib/sobol/models.py new file mode 100644 index 0000000..ee8187d --- /dev/null +++ b/philo/contrib/sobol/models.py @@ -0,0 +1,224 @@ +from django.conf.urls.defaults import patterns, url +from django.contrib import messages +from django.core.exceptions import ValidationError +from django.db import models +from django.http import HttpResponseRedirect, Http404, HttpResponse +from django.utils import simplejson as json +from django.utils.datastructures import SortedDict +from philo.contrib.sobol import registry +from philo.contrib.sobol.forms import SearchForm +from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash +from philo.exceptions import ViewCanNotProvideSubpath +from philo.models import MultiView, Page +from philo.models.fields import SlugMultipleChoiceField +from philo.validators import RedirectValidator +import datetime +try: + import eventlet +except: + eventlet = False + + +class Search(models.Model): + string = models.TextField() + + def __unicode__(self): + return self.string + + def get_weighted_results(self, threshhold=None): + "Returns this search's results ordered by decreasing weight." + if not hasattr(self, '_weighted_results'): + result_qs = self.result_urls.all() + + if threshhold is not None: + result_qs = result_qs.filter(counts__datetime__gte=threshhold) + + results = [result for result in result_qs] + + results.sort(cmp=lambda x,y: cmp(y.weight, x.weight)) + + self._weighted_results = results + + return self._weighted_results + + def get_favored_results(self, error=5, threshhold=None): + """ + Calculate the set of most-favored results. A higher error + will cause this method to be more reticent about adding new + items. + + The thought is to see whether there are any results which + vastly outstrip the other options. As such, evenly-weighted + results should be grouped together and either added or + excluded as a group. + """ + if not hasattr(self, '_favored_results'): + results = self.get_weighted_results(threshhold) + + grouped_results = SortedDict() + + for result in results: + grouped_results.setdefault(result.weight, []).append(result) + + self._favored_results = [] + + for value, subresults in grouped_results.items(): + cost = error * sum([(value - result.weight)**2 for result in self._favored_results]) + if value > cost: + self._favored_results += subresults + else: + break + return self._favored_results + + class Meta: + ordering = ['string'] + verbose_name_plural = 'searches' + + +class ResultURL(models.Model): + search = models.ForeignKey(Search, related_name='result_urls') + url = models.TextField(validators=[RedirectValidator()]) + + def __unicode__(self): + return self.url + + def get_weight(self, threshhold=None): + if not hasattr(self, '_weight'): + clicks = self.clicks.all() + + if threshhold is not None: + clicks = clicks.filter(datetime__gte=threshhold) + + self._weight = sum([click.weight for click in clicks]) + + return self._weight + weight = property(get_weight) + + class Meta: + ordering = ['url'] + + +class Click(models.Model): + result = models.ForeignKey(ResultURL, related_name='clicks') + datetime = models.DateTimeField() + + def __unicode__(self): + return self.datetime.strftime('%B %d, %Y %H:%M:%S') + + def get_weight(self, default=1, weighted=lambda value, days: value/days**2): + if not hasattr(self, '_weight'): + days = (datetime.datetime.now() - self.datetime).days + if days < 0: + raise ValueError("Click dates must be in the past.") + default = float(default) + if days == 0: + self._weight = float(default) + else: + self._weight = weighted(default, days) + return self._weight + weight = property(get_weight) + + def clean(self): + if self.datetime > datetime.datetime.now(): + raise ValidationError("Click dates must be in the past.") + + class Meta: + ordering = ['datetime'] + get_latest_by = 'datetime' + + +class SearchView(MultiView): + results_page = models.ForeignKey(Page, related_name='search_results_related') + searches = SlugMultipleChoiceField(choices=registry.iterchoices()) + enable_ajax_api = models.BooleanField("Enable AJAX API", default=True, help_text="Search results will be available only by AJAX, not as template variables.") + placeholder_text = models.CharField(max_length=75, default="Search") + + search_form = SearchForm + + def __unicode__(self): + return u"%s (%s)" % (self.placeholder_text, u", ".join([display for slug, display in registry.iterchoices() if slug in self.searches])) + + def get_reverse_params(self, obj): + raise ViewCanNotProvideSubpath + + @property + def urlpatterns(self): + urlpatterns = patterns('', + url(r'^$', self.results_view, name='results'), + ) + if self.enable_ajax_api: + urlpatterns += patterns('', + url(r'^(?P[\w-]+)$', self.ajax_api_view, name='ajax_api_view') + ) + return urlpatterns + + def get_search_instance(self, slug, search_string): + return registry[slug](search_string.lower()) + + def results_view(self, request, extra_context=None): + results = None + + context = self.get_context() + context.update(extra_context or {}) + + if SEARCH_ARG_GET_KEY in request.GET: + form = self.search_form(request.GET) + + if form.is_valid(): + search_string = request.GET[SEARCH_ARG_GET_KEY].lower() + url = request.GET.get(URL_REDIRECT_GET_KEY) + hash = request.GET.get(HASH_REDIRECT_GET_KEY) + + if url and hash: + if check_redirect_hash(hash, search_string, url): + # Create the necessary models + search = Search.objects.get_or_create(string=search_string)[0] + result_url = search.result_urls.get_or_create(url=url)[0] + result_url.clicks.create(datetime=datetime.datetime.now()) + return HttpResponseRedirect(url) + else: + messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!") + # TODO: Should search_string be escaped here? + return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string)) + if not self.enable_ajax_api: + search_instances = [] + if eventlet: + pool = eventlet.GreenPool() + for slug in self.searches: + search_instance = self.get_search_instance(slug, search_string) + search_instances.append(search_instance) + if eventlet: + pool.spawn_n(self.make_result_cache, search_instance) + else: + self.make_result_cache(search_instance) + if eventlet: + pool.waitall() + context.update({ + 'searches': search_instances + }) + else: + context.update({ + 'searches': [{'verbose_name': verbose_name, 'slug': slug, 'url': self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node), 'result_template': registry[slug].result_template} for slug, verbose_name in registry.iterchoices() if slug in self.searches] + }) + else: + form = SearchForm() + + context.update({ + 'form': form + }) + return self.results_page.render_to_response(request, extra_context=context) + + def make_result_cache(self, search_instance): + search_instance.results + + def ajax_api_view(self, request, slug, extra_context=None): + search_string = request.GET.get(SEARCH_ARG_GET_KEY) + + if not request.is_ajax() or not self.enable_ajax_api or slug not in self.searches or search_string is None: + raise Http404 + + search_instance = self.get_search_instance(slug, search_string) + response = HttpResponse(json.dumps({ + 'results': [result.get_context() for result in search_instance.results], + })) + return response \ No newline at end of file diff --git a/philo/contrib/sobol/search.py b/philo/contrib/sobol/search.py new file mode 100644 index 0000000..39b93c7 --- /dev/null +++ b/philo/contrib/sobol/search.py @@ -0,0 +1,398 @@ +#encoding: utf-8 + +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.cache import cache +from django.db.models.options import get_verbose_name as convert_camelcase +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 philo.contrib.sobol.utils import make_tracking_querydict + +try: + from eventlet.green import urllib2 +except: + import urllib2 + + +__all__ = ( + 'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry' +) + + +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) + +# Determines the timeout on the entire result cache. +MAX_CACHE_TIMEOUT = 60*24*7 + + +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__() + + +registry = SearchRegistry() + + +class Result(object): + """ + A result is instantiated with a configuration dictionary, a search, + and a template name. The configuration dictionary is expected to + define a `title` and optionally a `url`. Any other variables may be + defined; they will be made available through the result object in + the template, if one is defined. + """ + def __init__(self, search, result): + self.search = search + self.result = result + + def get_title(self): + 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() + + def get_template(self): + 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 + + def render(self): + t = self.get_template() + c = Context(self.get_context()) + return t.render(c) + + def __unicode__(self): + 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)) + if 'slug' not in attrs: + attrs['slug'] = 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. + """ + __metaclass__ = BaseSearchMetaclass + result_limit = 10 + _cache_timeout = 60*48 + + 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): + 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 [] + + self._set_cached_results(results, self._cache_timeout) + self._results = results + + return self._results + + def get_results(self, limit=None, result_class=Result): + """ + Calls self.search() and parses the return value into Result objects. + """ + 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. + """ + 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." + raise NotImplementedError + + def get_result_querydict(self, result): + url = self.get_result_url(result) + if url is None: + return None + return make_tracking_querydict(self.search_arg, url) + + def get_result_template(self, result): + if hasattr(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 {} + + def has_more_results(self): + """Useful to determine whether to display a `view more results` link.""" + 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 + + @property + def more_results_querydict(self): + return make_tracking_querydict(self.search_arg, self.more_results_url) + + def __unicode__(self): + return ' '.join(self.__class__.verbose_name.rsplit(' ', 1)[:-1]) + ' results' + + +class DatabaseSearch(BaseSearch): + model = None + + def search(self, limit=None): + if not hasattr(self, '_qs'): + self._qs = self.get_queryset() + if limit is not None: + self._qs = self._qs[:limit] + + return self._qs + + def get_queryset(self): + 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. + """ + search_url = '' + query_format_str = "%s" + + @property + def url(self): + "The URL where the search gets its results." + return self.search_url + self.query_format_str % urlquote_plus(self.search_arg) + + @property + def more_results_url(self): + "The URL where the users would go to get more results." + return self.url + + def parse_response(self, response, limit=None): + raise NotImplementedError + + def search(self, limit=None): + return self.parse_response(urllib2.urlopen(self.url), limit=limit) + + +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. + """ + def parse_response(self, response, limit=None): + return json.loads(response.read())[:limit] + + +class GoogleSearch(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)" + + @property + def query_format_str(self): + default_args = self.default_args + if default_args: + default_args += " " + return "?v=1.0&q=%s%%s" % urlquote_plus(default_args).replace('%', '%%') + + @property + def default_args(self): + return "site:%s" % Site.objects.get_current().domain + + def parse_response(self, response, limit=None): + responseData = json.loads(response.read())['responseData'] + results, cursor = responseData['results'], responseData['cursor'] + + if results: + self._more_results_url = cursor['moreResultsUrl'] + self._estimated_result_count = cursor['estimatedResultCount'] + + return results[:limit] + + @property + def url(self): + # Google requires that an ajax request have a proper Referer header. + return urllib2.Request( + super(GoogleSearch, self).url, + None, + {'Referer': "http://%s" % Site.objects.get_current().domain} + ) + + @property + def has_more_results(self): + if self.results and len(self.results) < self._estimated_result_count: + return True + return False + + @property + def more_results_url(self): + return self._more_results_url + + def get_result_title(self, result): + return result['titleNoFormatting'] + + def get_result_url(self, result): + return result['unescapedUrl'] + + def get_result_extra_context(self, result): + return result + + +registry.register(GoogleSearch) + + +try: + from BeautifulSoup import BeautifulSoup, SoupStrainer, BeautifulStoneSoup +except: + pass +else: + __all__ += ('ScrapeSearch', 'XMLSearch',) + class ScrapeSearch(URLSearch): + _strainer_args = [] + _strainer_kwargs = {} + + @property + def strainer(self): + if not hasattr(self, '_strainer'): + self._strainer = SoupStrainer(*self._strainer_args, **self._strainer_kwargs) + return self._strainer + + def parse_response(self, response, limit=None): + strainer = self.strainer + soup = BeautifulSoup(response, parseOnlyThese=strainer) + return self.parse_results(soup.findAll(recursive=False, limit=limit)) + + 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 + """ + raise NotImplementedError + + + class XMLSearch(ScrapeSearch): + _self_closing_tags = [] + + def parse_response(self, response, limit=None): + strainer = self.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/templates/admin/sobol/search/grappelli_results.html b/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html new file mode 100644 index 0000000..45135ff --- /dev/null +++ b/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html @@ -0,0 +1,53 @@ +{% extends "admin/base_site.html" %} + + +{% load i18n %} + + +{% block extrastyle %}{% endblock %} + + +{% block breadcrumbs %} + +{% endblock %} + + +{% block content %} +
+ {% for search in queryset %} + {% if not forloop.first and not forloop.last %}

{{ search.string }}

{% endif %} +
+

{% blocktrans %}Results{% endblocktrans %}

{% comment %}For the favored results, add a class?{% endcomment %} +
+
+
+
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 new file mode 100644 index 0000000..44d4e7c --- /dev/null +++ b/philo/contrib/sobol/templates/admin/sobol/search/results.html @@ -0,0 +1,47 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block extrastyle %}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + + +{% block content %} + {% for search in queryset %} + {% if not forloop.first and not forloop.last %}

{{ search.string }}

{% endif %} +
+

{% blocktrans %}Results{% endblocktrans %}

{% comment %}For the favored results, add a class?{% endcomment %} + + + + + + + + + {% 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 new file mode 100644 index 0000000..1b22388 --- /dev/null +++ b/philo/contrib/sobol/templates/search/googlesearch.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/philo/contrib/sobol/utils.py b/philo/contrib/sobol/utils.py new file mode 100644 index 0000000..3c5e537 --- /dev/null +++ b/philo/contrib/sobol/utils.py @@ -0,0 +1,32 @@ +from django.conf import settings +from django.http import QueryDict +from django.utils.encoding import smart_str +from django.utils.http import urlquote_plus, urlquote +from hashlib import sha1 + + +SEARCH_ARG_GET_KEY = 'q' +URL_REDIRECT_GET_KEY = 'url' +HASH_REDIRECT_GET_KEY = 's' + + +def make_redirect_hash(search_arg, url): + return sha1(smart_str(search_arg + url + settings.SECRET_KEY)).hexdigest()[::2] + + +def check_redirect_hash(hash, search_arg, url): + return hash == make_redirect_hash(search_arg, url) + + +def make_tracking_querydict(search_arg, url): + """ + Returns a QueryDict instance containing the information necessary + for tracking clicks of this url. + + NOTE: will this kind of initialization handle quoting correctly? + """ + return QueryDict("%s=%s&%s=%s&%s=%s" % ( + SEARCH_ARG_GET_KEY, urlquote_plus(search_arg), + URL_REDIRECT_GET_KEY, urlquote(url), + HASH_REDIRECT_GET_KEY, make_redirect_hash(search_arg, url)) + ) \ No newline at end of file diff --git a/templatetags/__init__.py b/philo/contrib/waldo/__init__.py similarity index 100% rename from templatetags/__init__.py rename to philo/contrib/waldo/__init__.py diff --git a/contrib/waldo/forms.py b/philo/contrib/waldo/forms.py similarity index 100% rename from contrib/waldo/forms.py rename to philo/contrib/waldo/forms.py diff --git a/contrib/waldo/models.py b/philo/contrib/waldo/models.py similarity index 100% rename from contrib/waldo/models.py rename to philo/contrib/waldo/models.py diff --git a/contrib/waldo/tokens.py b/philo/contrib/waldo/tokens.py similarity index 85% rename from contrib/waldo/tokens.py rename to philo/contrib/waldo/tokens.py index 95ce0c0..80f0b11 100644 --- a/contrib/waldo/tokens.py +++ b/philo/contrib/waldo/tokens.py @@ -7,6 +7,7 @@ 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) @@ -52,8 +53,7 @@ class RegistrationTokenGenerator(PasswordResetTokenGenerator): # By hashing on the internal state of the user and using state that is # sure to change, we produce a hash that will be invalid as soon as it # is used. - from django.utils.hashcompat import sha_constructor - hash = sha_constructor(settings.SECRET_KEY + unicode(user.id) + unicode(user.is_active) + user.last_login.strftime('%Y-%m-%d %H:%M:%S') + unicode(timestamp)).hexdigest()[::2] + hash = sha1(settings.SECRET_KEY + unicode(user.id) + unicode(user.is_active) + user.last_login.strftime('%Y-%m-%d %H:%M:%S') + unicode(timestamp)).hexdigest()[::2] return '%s-%s' % (ts_b36, hash) @@ -98,8 +98,7 @@ class EmailTokenGenerator(PasswordResetTokenGenerator): def _make_token_with_timestamp(self, user, email, timestamp): ts_b36 = int_to_base36(timestamp) - from django.utils.hashcompat import sha_constructor - hash = sha_constructor(settings.SECRET_KEY + unicode(user.id) + user.email + email + unicode(timestamp)).hexdigest()[::2] + hash = sha1(settings.SECRET_KEY + unicode(user.id) + user.email + email + unicode(timestamp)).hexdigest()[::2] return '%s-%s' % (ts_b36, hash) diff --git a/exceptions.py b/philo/exceptions.py similarity index 100% rename from exceptions.py rename to philo/exceptions.py diff --git a/fixtures/test_fixtures.json b/philo/fixtures/test_fixtures.json similarity index 100% rename from fixtures/test_fixtures.json rename to philo/fixtures/test_fixtures.json diff --git a/forms/__init__.py b/philo/forms/__init__.py similarity index 100% rename from forms/__init__.py rename to philo/forms/__init__.py diff --git a/forms/entities.py b/philo/forms/entities.py similarity index 51% rename from forms/entities.py rename to philo/forms/entities.py index b6259a3..e781128 100644 --- a/forms/entities.py +++ b/philo/forms/entities.py @@ -1,4 +1,4 @@ -from django.forms.models import ModelFormMetaclass, ModelForm +from django.forms.models import ModelFormMetaclass, ModelForm, ModelFormOptions from django.utils.datastructures import SortedDict from philo.utils import fattr @@ -6,7 +6,7 @@ from philo.utils import fattr __all__ = ('EntityForm',) -def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widgets=None, formfield_callback=lambda f, **kwargs: f.formfield(**kwargs)): +def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widgets=None, formfield_callback=None): field_list = [] ignored = [] opts = entity_model._entity_meta @@ -21,7 +21,14 @@ def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widge kwargs = {'widget': widgets[f.name]} else: kwargs = {} - formfield = formfield_callback(f, **kwargs) + + if formfield_callback is None: + formfield = f.formfield(**kwargs) + elif not callable(formfield_callback): + raise TypeError('formfield_callback must be a function or callable') + else: + formfield = formfield_callback(f, **kwargs) + if formfield: field_list.append((f.name, formfield)) else: @@ -35,31 +42,59 @@ def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widge return field_dict -# BEGIN HACK - This will not be required after http://code.djangoproject.com/ticket/14082 has been resolved - -class EntityFormBase(ModelForm): - pass +# HACK until http://code.djangoproject.com/ticket/14082 is resolved. +_old = ModelFormMetaclass.__new__ +def _new(cls, name, bases, attrs): + if cls == ModelFormMetaclass: + m = attrs.get('__metaclass__', None) + if m is None: + parents = [b for b in bases if issubclass(b, ModelForm)] + for c in parents: + if c.__metaclass__ != ModelFormMetaclass: + m = c.__metaclass__ + break + + if m is not None: + return m(name, bases, attrs) + + return _old(cls, name, bases, attrs) +ModelFormMetaclass.__new__ = staticmethod(_new) +# END HACK -_old_metaclass_new = ModelFormMetaclass.__new__ -def _new_metaclass_new(cls, name, bases, attrs): - formfield_callback = attrs.get('formfield_callback', lambda f, **kwargs: f.formfield(**kwargs)) - new_class = _old_metaclass_new(cls, name, bases, attrs) - opts = new_class._meta - if issubclass(new_class, EntityFormBase) and opts.model: - # "override" proxy fields with declared fields by excluding them if there's a name conflict. - exclude = (list(opts.exclude or []) + new_class.declared_fields.keys()) or None - proxy_fields = proxy_fields_for_entity_model(opts.model, opts.fields, exclude, opts.widgets, formfield_callback) # don't pass in formfield_callback +class EntityFormMetaclass(ModelFormMetaclass): + def __new__(cls, name, bases, attrs): + try: + parents = [b for b in bases if issubclass(b, EntityForm)] + except NameError: + # We are defining EntityForm itself + parents = None + sup = super(EntityFormMetaclass, cls) + + if not parents: + # Then there's no business trying to use proxy fields. + return sup.__new__(cls, name, bases, attrs) + + # Fake a declaration of all proxy fields so they'll be handled correctly. + opts = ModelFormOptions(attrs.get('Meta', None)) + + if opts.model: + formfield_callback = attrs.get('formfield_callback', None) + proxy_fields = proxy_fields_for_entity_model(opts.model, opts.fields, opts.exclude, opts.widgets, formfield_callback) + else: + proxy_fields = {} + + new_attrs = proxy_fields.copy() + new_attrs.update(attrs) + + new_class = sup.__new__(cls, name, bases, new_attrs) new_class.proxy_fields = proxy_fields - new_class.base_fields.update(proxy_fields) - return new_class + return new_class -ModelFormMetaclass.__new__ = staticmethod(_new_metaclass_new) -# END HACK - - -class EntityForm(EntityFormBase): # Would inherit from ModelForm directly if it weren't for the above HACK +class EntityForm(ModelForm): + __metaclass__ = EntityFormMetaclass + def __init__(self, *args, **kwargs): initial = kwargs.pop('initial', None) instance = kwargs.get('instance', None) diff --git a/forms/fields.py b/philo/forms/fields.py similarity index 100% rename from forms/fields.py rename to philo/forms/fields.py diff --git a/philo/loaders/__init__.py b/philo/loaders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/loaders/database.py b/philo/loaders/database.py similarity index 100% rename from loaders/database.py rename to philo/loaders/database.py diff --git a/middleware.py b/philo/middleware.py similarity index 83% rename from middleware.py rename to philo/middleware.py index c0b1e9e..5ec3e77 100644 --- a/middleware.py +++ b/philo/middleware.py @@ -24,16 +24,18 @@ class LazyNode(object): node, subpath = Node.objects.get_with_path(path, root=getattr(current_site, 'root_node', None), absolute_result=False) except Node.DoesNotExist: node = None - - if node: + else: if subpath is None: subpath = "" subpath = "/" + subpath - if trailing_slash and subpath[-1] != "/": - subpath += "/" - - node.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 @@ -46,7 +48,10 @@ class RequestNodeMiddleware(object): request.__class__.node = LazyNode() def process_view(self, request, view_func, view_args, view_kwargs): - request._cached_node_path = view_kwargs.get('path', '/') + try: + request._cached_node_path = view_kwargs['path'] + except KeyError: + pass def process_exception(self, request, exception): if settings.DEBUG or not hasattr(request, 'node') or not request.node: diff --git a/migrations/0001_initial.py b/philo/migrations/0001_initial.py similarity index 100% rename from migrations/0001_initial.py rename to philo/migrations/0001_initial.py diff --git a/migrations/0002_auto__add_field_attribute_value.py b/philo/migrations/0002_auto__add_field_attribute_value.py similarity index 100% rename from migrations/0002_auto__add_field_attribute_value.py rename to philo/migrations/0002_auto__add_field_attribute_value.py diff --git a/migrations/0003_move_json.py b/philo/migrations/0003_move_json.py similarity index 100% rename from migrations/0003_move_json.py rename to philo/migrations/0003_move_json.py diff --git a/migrations/0004_auto__del_field_attribute_json_value.py b/philo/migrations/0004_auto__del_field_attribute_json_value.py similarity index 100% rename from migrations/0004_auto__del_field_attribute_json_value.py rename to philo/migrations/0004_auto__del_field_attribute_json_value.py diff --git a/migrations/0005_add_attribute_values.py b/philo/migrations/0005_add_attribute_values.py similarity index 100% rename from migrations/0005_add_attribute_values.py rename to philo/migrations/0005_add_attribute_values.py diff --git a/migrations/0006_move_attribute_and_relationship_values.py b/philo/migrations/0006_move_attribute_and_relationship_values.py similarity index 100% rename from migrations/0006_move_attribute_and_relationship_values.py rename to philo/migrations/0006_move_attribute_and_relationship_values.py diff --git a/migrations/0007_auto__del_relationship__del_field_attribute_value.py b/philo/migrations/0007_auto__del_relationship__del_field_attribute_value.py similarity index 100% rename from migrations/0007_auto__del_relationship__del_field_attribute_value.py rename to philo/migrations/0007_auto__del_relationship__del_field_attribute_value.py diff --git a/migrations/0008_auto__del_field_manytomanyvalue_object_ids.py b/philo/migrations/0008_auto__del_field_manytomanyvalue_object_ids.py similarity index 100% rename from migrations/0008_auto__del_field_manytomanyvalue_object_ids.py rename to philo/migrations/0008_auto__del_field_manytomanyvalue_object_ids.py diff --git a/migrations/0009_auto__add_field_node_lft__add_field_node_rght__add_field_node_tree_id_.py b/philo/migrations/0009_auto__add_field_node_lft__add_field_node_rght__add_field_node_tree_id_.py similarity index 100% rename from migrations/0009_auto__add_field_node_lft__add_field_node_rght__add_field_node_tree_id_.py rename to philo/migrations/0009_auto__add_field_node_lft__add_field_node_rght__add_field_node_tree_id_.py diff --git a/migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py b/philo/migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py similarity index 100% rename from migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py rename to philo/migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py diff --git a/migrations/0011_move_target_url.py b/philo/migrations/0011_move_target_url.py similarity index 100% rename from migrations/0011_move_target_url.py rename to philo/migrations/0011_move_target_url.py diff --git a/migrations/0012_auto__del_field_redirect_target.py b/philo/migrations/0012_auto__del_field_redirect_target.py similarity index 100% rename from migrations/0012_auto__del_field_redirect_target.py rename to philo/migrations/0012_auto__del_field_redirect_target.py diff --git a/migrations/0013_auto.py b/philo/migrations/0013_auto.py similarity index 100% rename from migrations/0013_auto.py rename to philo/migrations/0013_auto.py diff --git a/migrations/0014_auto.py b/philo/migrations/0014_auto.py similarity index 100% rename from migrations/0014_auto.py rename to philo/migrations/0014_auto.py diff --git a/migrations/__init__.py b/philo/migrations/__init__.py similarity index 100% rename from migrations/__init__.py rename to philo/migrations/__init__.py diff --git a/models/__init__.py b/philo/models/__init__.py similarity index 100% rename from models/__init__.py rename to philo/models/__init__.py diff --git a/models/base.py b/philo/models/base.py similarity index 98% rename from models/base.py rename to philo/models/base.py index 836fe4a..af1e880 100644 --- a/models/base.py +++ b/philo/models/base.py @@ -55,10 +55,6 @@ def unregister_value_model(model): class AttributeValue(models.Model): attribute_set = generic.GenericRelation('Attribute', content_type_field='value_content_type', object_id_field='value_object_id') - @property - def attribute(self): - return self.attribute_set.all()[0] - def set_value(self, value): raise NotImplementedError @@ -275,9 +271,9 @@ class EntityOptions(object): class EntityBase(models.base.ModelBase): def __new__(cls, name, bases, attrs): + entity_meta = attrs.pop('EntityMeta', None) new = super(EntityBase, cls).__new__(cls, name, bases, attrs) - entity_options = attrs.pop('EntityMeta', None) - setattr(new, '_entity_meta', EntityOptions(entity_options)) + new.add_to_class('_entity_meta', EntityOptions(entity_meta)) entity_class_prepared.send(sender=new) return new diff --git a/models/collections.py b/philo/models/collections.py similarity index 100% rename from models/collections.py rename to philo/models/collections.py diff --git a/philo/models/fields/__init__.py b/philo/models/fields/__init__.py new file mode 100644 index 0000000..1f9603e --- /dev/null +++ b/philo/models/fields/__init__.py @@ -0,0 +1,135 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.core.validators import validate_slug +from django.db import models +from django.utils import simplejson as json +from django.utils.text import capfirst +from django.utils.translation import ugettext_lazy as _ +from philo.forms.fields import JSONFormField +from philo.validators import TemplateValidator, json_validator +#from philo.models.fields.entities import * + + +class TemplateField(models.TextField): + def __init__(self, allow=None, disallow=None, secure=True, *args, **kwargs): + super(TemplateField, self).__init__(*args, **kwargs) + self.validators.append(TemplateValidator(allow, disallow, secure)) + + +class JSONDescriptor(object): + def __init__(self, field): + self.field = field + + def __get__(self, instance, owner): + if instance is None: + raise AttributeError # ? + + if self.field.name not in instance.__dict__: + json_string = getattr(instance, self.field.attname) + instance.__dict__[self.field.name] = json.loads(json_string) + + return instance.__dict__[self.field.name] + + def __set__(self, instance, value): + instance.__dict__[self.field.name] = value + setattr(instance, self.field.attname, json.dumps(value)) + + def __delete__(self, instance): + del(instance.__dict__[self.field.name]) + setattr(instance, self.field.attname, json.dumps(None)) + + +class JSONField(models.TextField): + default_validators = [json_validator] + + def get_attname(self): + return "%s_json" % self.name + + def contribute_to_class(self, cls, name): + super(JSONField, self).contribute_to_class(cls, name) + setattr(cls, name, JSONDescriptor(self)) + models.signals.pre_init.connect(self.fix_init_kwarg, sender=cls) + + def fix_init_kwarg(self, sender, args, kwargs, **signal_kwargs): + # Anything passed in as self.name is assumed to come from a serializer and + # will be treated as a json string. + if self.name in kwargs: + value = kwargs.pop(self.name) + + # Hack to handle the xml serializer's handling of "null" + if value is None: + value = 'null' + + kwargs[self.attname] = value + + def formfield(self, *args, **kwargs): + kwargs["form_class"] = JSONFormField + return super(JSONField, self).formfield(*args, **kwargs) + + +class SlugMultipleChoiceField(models.Field): + __metaclass__ = models.SubfieldBase + description = _("Comma-separated slug field") + + def get_internal_type(self): + return "TextField" + + def to_python(self, value): + if not value: + return [] + + if isinstance(value, list): + return value + + return value.split(',') + + def get_prep_value(self, value): + return ','.join(value) + + def formfield(self, **kwargs): + # This is necessary because django hard-codes TypedChoiceField for things with choices. + defaults = { + 'widget': forms.CheckboxSelectMultiple, + 'choices': self.get_choices(include_blank=False), + 'label': capfirst(self.verbose_name), + 'required': not self.blank, + 'help_text': self.help_text + } + if self.has_default(): + if callable(self.default): + defaults['initial'] = self.default + defaults['show_hidden_initial'] = True + else: + defaults['initial'] = self.get_default() + + for k in kwargs.keys(): + if k not in ('coerce', 'empty_value', 'choices', 'required', + 'widget', 'label', 'initial', 'help_text', + 'error_messages', 'show_hidden_initial'): + del kwargs[k] + + defaults.update(kwargs) + form_class = forms.TypedMultipleChoiceField + return form_class(**defaults) + + def validate(self, value, model_instance): + invalid_values = [] + for val in value: + try: + validate_slug(val) + except ValidationError: + invalid_values.append(val) + + if invalid_values: + # should really make a custom message. + raise ValidationError(self.error_messages['invalid_choice'] % invalid_values) + + +try: + from south.modelsinspector import add_introspection_rules +except ImportError: + pass +else: + add_introspection_rules([], ["^philo\.models\.fields\.SlugMultipleChoiceField"]) + add_introspection_rules([], ["^philo\.models\.fields\.TemplateField"]) + add_introspection_rules([], ["^philo\.models\.fields\.JSONField"]) \ No newline at end of file diff --git a/models/fields/entities.py b/philo/models/fields/entities.py similarity index 100% rename from models/fields/entities.py rename to philo/models/fields/entities.py diff --git a/models/nodes.py b/philo/models/nodes.py similarity index 100% rename from models/nodes.py rename to philo/models/nodes.py diff --git a/models/pages.py b/philo/models/pages.py similarity index 56% rename from models/pages.py rename to philo/models/pages.py index ef68b5f..2221ee4 100644 --- a/models/pages.py +++ b/philo/models/pages.py @@ -5,16 +5,70 @@ 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 +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 philo.models.fields import TemplateField from philo.models.nodes import View from philo.templatetags.containers import ContainerNode -from philo.utils import fattr, nodelist_crawl +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 +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 + + class Template(TreeModel): name = models.CharField(max_length=255) documentation = models.TextField(null=True, blank=True) @@ -29,22 +83,54 @@ class Template(TreeModel): 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 process_node(node, nodes): - if isinstance(node, ContainerNode): - nodes.append(node) + 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 - all_nodes = nodelist_crawl(DjangoTemplate(self.code).nodelist, process_node) - contentlet_node_names = set([node.name for node in all_nodes if not node.references]) - contentreference_node_names = [] - contentreference_node_specs = [] - for node in all_nodes: - if node.references and node.name not in contentreference_node_names: - contentreference_node_specs.append((node.name, node.references)) - contentreference_node_names.append(node.name) - return contentlet_node_names, contentreference_node_specs + # 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 def __unicode__(self): - return self.get_path(pathsep=u' › ', field='name') + return self.name class Meta: app_label = 'philo' @@ -85,6 +171,9 @@ class Page(View): return self.title def clean_fields(self, exclude=None): + if exclude is None: + exclude = [] + try: super(Page, self).clean_fields(exclude) except ValidationError, e: diff --git a/signals.py b/philo/signals.py similarity index 100% rename from signals.py rename to philo/signals.py diff --git a/media/admin/js/TagCreation.js b/philo/static/admin/js/TagCreation.js similarity index 75% rename from media/admin/js/TagCreation.js rename to philo/static/admin/js/TagCreation.js index 31f2910..d08d41e 100644 --- a/media/admin/js/TagCreation.js +++ b/philo/static/admin/js/TagCreation.js @@ -1,6 +1,29 @@ 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) { diff --git a/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html b/philo/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html similarity index 56% rename from templates/admin/philo/edit_inline/grappelli_tabular_attribute.html rename to philo/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html index ccead57..25c1ac4 100644 --- a/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html +++ b/philo/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html @@ -1,4 +1,4 @@ -{% load i18n adminmedia %} +{% load i18n adminmedia grp_tags %}
(function($) { - $(document).ready(function($) { - - $("#{{ inline_admin_formset.formset.prefix }}-group").grp_inline({ - prefix: "{{ inline_admin_formset.formset.prefix }}", - onBeforeAdded: function(inline) {}, - onAfterAdded: function(form) { - grappelli.reinitDateTimeFields(form); - grappelli.updateSelectFilter(form); - form.find("input.vForeignKeyRawIdAdminField").grp_related_fk({lookup_url:"{% url grp_related_lookup %}"}); - form.find("input.vManyToManyRawIdAdminField").grp_related_m2m({lookup_url:"{% url grp_m2m_lookup %}"}); - form.find("input[name*='object_id'][name$='id']").grp_related_generic({lookup_url:"{% url grp_related_lookup %}"}); - }, - }); - - {% if inline_admin_formset.opts.sortable_field_name %} - $("#{{ inline_admin_formset.formset.prefix }}-group > div.table").sortable({ - handle: "a.drag-handler", - items: "div.dynamic-form", - axis: "y", - appendTo: 'body', - forceHelperSize: true, - containment: '#{{ inline_admin_formset.formset.prefix }}-group > div.table', - tolerance: 'pointer', - }); - $("#{{ opts.module_name }}_form").bind("submit", function(){ - var sortable_field_name = "{{ inline_admin_formset.opts.sortable_field_name }}"; - var i = 0; - $("#{{ inline_admin_formset.formset.prefix }}-group").find("div.dynamic-form").each(function(){ - var fields = $(this).find("div.td :input[value]"); - if (fields.serialize()) { - $(this).find("input[name$='"+sortable_field_name+"']").val(i); - i++; - } - }); - }); - {% endif %} - - }); + $(document).ready(function($) { + + var prefix = "{{ inline_admin_formset.formset.prefix }}"; + var related_lookup_fields_fk = {% get_related_lookup_fields_fk inline_admin_formset.opts %}; + var related_lookup_fields_m2m = {% get_related_lookup_fields_m2m inline_admin_formset.opts %}; + var related_lookup_fields_generic = {% get_related_lookup_fields_generic inline_admin_formset.opts %}; + $.each(related_lookup_fields_fk, function() { + $("#{{ inline_admin_formset.formset.prefix }}-group > div.table") + .find("input[name^='" + prefix + "'][name$='" + this + "']") + .grp_related_fk({lookup_url:"{% url grp_related_lookup %}"}); + }); + $.each(related_lookup_fields_m2m, function() { + $("#{{ inline_admin_formset.formset.prefix }}-group > div.table") + .find("input[name^='" + prefix + "'][name$='" + this + "']") + .grp_related_m2m({lookup_url:"{% url grp_m2m_lookup %}"}); + }); + $.each(related_lookup_fields_generic, function() { + var content_type = this[0], + object_id = this[1]; + $("#{{ inline_admin_formset.formset.prefix }}-group > div.table") + .find("input[name^='" + prefix + "'][name$='" + this[1] + "']") + .each(function() { + var i = $(this).attr("id").match(/-\d+-/); + if (i) { + var ct_id = "#id_" + prefix + i[0] + content_type, + obj_id = "#id_" + prefix + i[0] + object_id; + $(this).grp_related_generic({content_type:ct_id, object_id:obj_id, lookup_url:"{% url grp_related_lookup %}"}); + } + }); + }); + + $("#{{ inline_admin_formset.formset.prefix }}-group").grp_inline({ + prefix: "{{ inline_admin_formset.formset.prefix }}", + onBeforeAdded: function(inline) {}, + onAfterAdded: function(form) { + grappelli.reinitDateTimeFields(form); + grappelli.updateSelectFilter(form); + $.each(related_lookup_fields_fk, function() { + form.find("input[name^='" + prefix + "'][name$='" + this + "']") + .grp_related_fk({lookup_url:"{% url grp_related_lookup %}"}); + }); + $.each(related_lookup_fields_m2m, function() { + form.find("input[name^='" + prefix + "'][name$='" + this + "']") + .grp_related_m2m({lookup_url:"{% url grp_m2m_lookup %}"}); + }); + $.each(related_lookup_fields_generic, function() { + var content_type = this[0], + object_id = this[1]; + form.find("input[name^='" + prefix + "'][name$='" + this[1] + "']") + .each(function() { + var i = $(this).attr("id").match(/-\d+-/); + if (i) { + var ct_id = "#id_" + prefix + i[0] + content_type, + obj_id = "#id_" + prefix + i[0] + object_id; + $(this).grp_related_generic({content_type:ct_id, object_id:obj_id, lookup_url:"{% url grp_related_lookup %}"}); + } + }); + }); + }, + }); + + {% if inline_admin_formset.opts.sortable_field_name %} + $("#{{ inline_admin_formset.formset.prefix }}-group > div.table").sortable({ + handle: "a.drag-handler", + items: "div.dynamic-form", + axis: "y", + appendTo: 'body', + forceHelperSize: true, + containment: '#{{ inline_admin_formset.formset.prefix }}-group > div.table', + tolerance: 'pointer', + }); + $("#{{ opts.module_name }}_form").bind("submit", function(){ + var sortable_field_name = "{{ inline_admin_formset.opts.sortable_field_name }}"; + var i = 0; + $("#{{ inline_admin_formset.formset.prefix }}-group").find("div.dynamic-form").each(function(){ + var fields = $(this).find("div.td :input[value]"); + if (fields.serialize()) { + $(this).find("input[name$='"+sortable_field_name+"']").val(i); + i++; + } + }); + }); + {% endif %} + + }); })(django.jQuery); diff --git a/philo/templates/admin/philo/edit_inline/grappelli_tabular_container.html b/philo/templates/admin/philo/edit_inline/grappelli_tabular_container.html new file mode 100644 index 0000000..621fea6 --- /dev/null +++ b/philo/templates/admin/philo/edit_inline/grappelli_tabular_container.html @@ -0,0 +1,43 @@ +{% load i18n adminmedia %} + + +{{ inline_admin_formset.formset.management_form }} +{% comment %}Don't render the formset at all if there aren't any forms.{% endcomment %} +{% if inline_admin_formset.formset.forms %} +
+ {{ inline_admin_formset.opts.verbose_name_plural|capfirst }} + {{ inline_admin_formset.formset.non_form_errors }} + {% for inline_admin_form in inline_admin_formset %} + {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %} + {{ inline_admin_form.fk_field.field }} + {% spaceless %} + {% for fieldset in inline_admin_form %} + {% for line in fieldset %} + {% for field in line %} + {% if field.is_hidden %} {{ field.field }} {% endif %} + {% endfor %} + {% endfor %} + {% endfor %}{% endspaceless %} + {% endfor %} + {% for form in inline_admin_formset.formset.forms %} +
+ {{ form.non_field_errors }} +
+ {% for field in form %} + {% if not field.is_hidden %} + {% comment %}This will be true for one field: the content/content reference{% endcomment %} +
+
+ {{ field }} + {{ field.errors }} + {% if field.field.help_text %} +

{{ field.field.help_text|safe }}

+ {% endif %} +
+ {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+{% endif %} diff --git a/templates/admin/philo/edit_inline/tabular_attribute.html b/philo/templates/admin/philo/edit_inline/tabular_attribute.html similarity index 100% rename from templates/admin/philo/edit_inline/tabular_attribute.html rename to philo/templates/admin/philo/edit_inline/tabular_attribute.html diff --git a/templates/admin/philo/edit_inline/tabular_container.html b/philo/templates/admin/philo/edit_inline/tabular_container.html similarity index 72% rename from templates/admin/philo/edit_inline/tabular_container.html rename to philo/templates/admin/philo/edit_inline/tabular_container.html index f93e52f..77d5e23 100644 --- a/templates/admin/philo/edit_inline/tabular_container.html +++ b/philo/templates/admin/philo/edit_inline/tabular_container.html @@ -7,15 +7,6 @@

{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}

{{ inline_admin_formset.formset.non_form_errors }} - - {% for field in inline_admin_formset.fields %} - {% if not field.widget.is_hidden %} - {{ field.label|capfirst }} - {% endif %} - {% endfor %} - {% if inline_admin_formset.formset.can_delete %}{% endif %} - - {% for inline_admin_form in inline_admin_formset %} {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %} @@ -28,33 +19,26 @@ {% endfor %} {% endfor %} {% endfor %} - {{ inline_admin_form.form.name.as_hidden }} {% endspaceless %} - {% if inline_admin_form.form.non_field_errors %} - + {% endfor %} + {% for form in inline_admin_formset.formset.forms %} + {% if form.non_field_errors %} + {% endif %} - - - {% for fieldset in inline_admin_form %} - {% for line in fieldset %} - {% for field in line %} - {% if field.field.name != 'name' %} + + + {% for field in form %} + {% if not field.is_hidden %} {% endif %} {% endfor %} - {% endfor %} - {% endfor %} - {% if inline_admin_formset.formset.can_delete %} - - {% endif %} {% endfor %} diff --git a/philo/templates/admin/philo/page/add_form.html b/philo/templates/admin/philo/page/add_form.html new file mode 100644 index 0000000..b2a6358 --- /dev/null +++ b/philo/templates/admin/philo/page/add_form.html @@ -0,0 +1,14 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block form_top %} + {% if not is_popup %} +

{% trans "First, choose a template. After saving, you'll be able to provide additional content for containers." %}

+ {% else %} +

{% trans "Choose a template" %}

+ {% endif %} +{% endblock %} + +{% block after_field_sets %} + +{% endblock %} \ No newline at end of file diff --git a/philo/templatetags/__init__.py b/philo/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/templatetags/collections.py b/philo/templatetags/collections.py similarity index 100% rename from templatetags/collections.py rename to philo/templatetags/collections.py diff --git a/templatetags/containers.py b/philo/templatetags/containers.py similarity index 100% rename from templatetags/containers.py rename to philo/templatetags/containers.py diff --git a/templatetags/embed.py b/philo/templatetags/embed.py similarity index 100% rename from templatetags/embed.py rename to philo/templatetags/embed.py diff --git a/templatetags/include_string.py b/philo/templatetags/include_string.py similarity index 100% rename from templatetags/include_string.py rename to philo/templatetags/include_string.py diff --git a/templatetags/nodes.py b/philo/templatetags/nodes.py similarity index 100% rename from templatetags/nodes.py rename to philo/templatetags/nodes.py diff --git a/tests.py b/philo/tests.py similarity index 94% rename from tests.py rename to philo/tests.py index 96ac7b6..a0e0184 100644 --- a/tests.py +++ b/philo/tests.py @@ -1,13 +1,17 @@ -from django.test import TestCase +import sys +import traceback + from django import template from django.conf import settings from django.db import connection from django.template import loader from django.template.loaders import cached +from django.test import TestCase +from django.test.utils import setup_test_template_loader + +from philo.contrib.penfield.models import Blog, BlogView, BlogEntry from philo.exceptions import AncestorDoesNotExist from philo.models import Node, Page, Template -from philo.contrib.penfield.models import Blog, BlogView, BlogEntry -import sys, traceback class TemplateTestCase(TestCase): @@ -17,19 +21,15 @@ class TemplateTestCase(TestCase): "Tests to make sure that embed behaves with complex includes and extends" template_tests = self.get_template_tests() - # Register our custom template loader. Shamelessly cribbed from django core regressiontests. - def test_template_loader(template_name, template_dirs=None): - "A custom template loader that loads the unit-test templates." - try: - return (template_tests[template_name][0] , "test:%s" % template_name) - except KeyError: - raise template.TemplateDoesNotExist, template_name - - cache_loader = cached.Loader(('test_template_loader',)) - cache_loader._cached_loaders = (test_template_loader,) + # Register our custom template loader. Shamelessly cribbed from django/tests/regressiontests/templates/tests.py:384. + cache_loader = setup_test_template_loader( + dict([(name, t[0]) for name, t in template_tests.iteritems()]), + use_cached_loader=True, + ) - old_template_loaders = loader.template_source_loaders - loader.template_source_loaders = [cache_loader] + failures = [] + tests = template_tests.items() + tests.sort() # Turn TEMPLATE_DEBUG off, because tests assume that. old_td, settings.TEMPLATE_DEBUG = settings.TEMPLATE_DEBUG, False @@ -38,9 +38,6 @@ class TemplateTestCase(TestCase): old_invalid = settings.TEMPLATE_STRING_IF_INVALID expected_invalid_str = 'INVALID' - failures = [] - tests = template_tests.items() - tests.sort() # Run tests for name, vals in tests: xx, context, result = vals diff --git a/urls.py b/philo/urls.py similarity index 73% rename from urls.py rename to philo/urls.py index 47be7da..0363224 100644 --- a/urls.py +++ b/philo/urls.py @@ -3,6 +3,6 @@ from philo.views import node_view urlpatterns = patterns('', - url(r'^$', node_view, name='philo-root'), + url(r'^$', node_view, kwargs={'path': '/'}, name='philo-root'), url(r'^(?P.*)$', node_view, name='philo-node-by-path') ) diff --git a/utils.py b/philo/utils.py similarity index 73% rename from utils.py rename to philo/utils.py index deb009c..57f949e 100644 --- a/utils.py +++ b/philo/utils.py @@ -121,30 +121,4 @@ def get_included(self): # We ignore the IncludeNode because it will never work in a blank context. setattr(ExtendsNode, LOADED_TEMPLATE_ATTR, property(get_extended)) -setattr(ConstantIncludeNode, LOADED_TEMPLATE_ATTR, property(get_included)) - - -def nodelist_crawl(nodelist, callback): - """This function crawls through a template's nodelist and the nodelists of any included or extended - templates, as determined by the presence and value of on a node. Each node - will also be passed to a callback function for additional processing.""" - results = [] - for node in nodelist: - try: - if hasattr(node, 'child_nodelists'): - for nodelist_name in node.child_nodelists: - if hasattr(node, nodelist_name): - results.extend(nodelist_crawl(getattr(node, nodelist_name), callback)) - - # 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: - results.extend(nodelist_crawl(loaded_template.nodelist, callback)) - - callback(node, results) - except: - raise # fail for this node - return results \ No newline at end of file +setattr(ConstantIncludeNode, LOADED_TEMPLATE_ATTR, property(get_included)) \ No newline at end of file diff --git a/validators.py b/philo/validators.py similarity index 85% rename from validators.py rename to philo/validators.py index 8b39abd..c8e5dc9 100644 --- a/validators.py +++ b/philo/validators.py @@ -3,6 +3,7 @@ from django.core.validators import RegexValidator from django.core.exceptions import ValidationError from django.template import Template, Parser, Lexer, TOKEN_BLOCK, TOKEN_VAR, TemplateSyntaxError from django.utils import simplejson as json +from django.utils.html import escape, mark_safe import re from philo.utils import LOADED_TEMPLATE_ATTR @@ -116,6 +117,16 @@ class TemplateValidationParser(Parser): raise ValidationError('Tag "%s" is not permitted here.' % command) +def linebreak_iter(template_source): + # Cribbed from django/views/debug.py:18 + yield 0 + p = template_source.find('\n') + while p >= 0: + yield p+1 + p = template_source.find('\n', p+1) + yield len(template_source) + 1 + + class TemplateValidator(object): def __init__(self, allow=None, disallow=None, secure=True): self.allow = allow @@ -128,6 +139,14 @@ class TemplateValidator(object): except ValidationError: raise except Exception, e: + if hasattr(e, 'source') and isinstance(e, TemplateSyntaxError): + origin, (start, end) = e.source + template_source = origin.reload() + upto = 0 + for num, next in enumerate(linebreak_iter(template_source)): + if start >= upto and end <= next: + raise ValidationError(mark_safe("Template code invalid: \"%s\" (%s:%d).
%s" % (escape(template_source[start:end]), origin.loadname, num, e))) + upto = next raise ValidationError("Template code invalid. Error was: %s: %s" % (e.__class__.__name__, e)) def validate_template(self, template_string): diff --git a/views.py b/philo/views.py similarity index 91% rename from views.py rename to philo/views.py index f5a2c7f..598be36 100644 --- a/views.py +++ b/philo/views.py @@ -28,7 +28,7 @@ def node_view(request, path=None, **kwargs): subpath = request.node.subpath # Explicitly disallow trailing slashes if we are otherwise at a node's url. - if request.path and request.path != "/" and request.path[-1] == "/" and subpath == "/": + if request._cached_node_path != "/" and request._cached_node_path[-1] == "/" and subpath == "/": return HttpResponseRedirect(node.get_absolute_url()) if not node.handles_subpath(subpath): diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..3c18b16 --- /dev/null +++ b/setup.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python + +from distutils.core import setup +import os + + +# Shamelessly cribbed from django's setup.py file. +def fullsplit(path, result=None): + """ + Split a pathname into components (the opposite of os.path.join) in a + platform-neutral way. + """ + if result is None: + result = [] + head, tail = os.path.split(path) + if head == '': + return [tail] + result + if head == path: + return result + return fullsplit(head, [tail] + result) + +# Compile the list of packages available, because distutils doesn't have +# an easy way to do this. Shamelessly cribbed from django's setup.py file. +packages, data_files = [], [] +root_dir = os.path.dirname(__file__) +if root_dir != '': + os.chdir(root_dir) +philo_dir = 'philo' + +for dirpath, dirnames, filenames in os.walk(philo_dir): + # Ignore dirnames that start with '.' + for i, dirname in enumerate(dirnames): + if dirname.startswith('.'): del dirnames[i] + if '__init__.py' in filenames: + packages.append('.'.join(fullsplit(dirpath))) + elif filenames: + data_files.append([dirpath, [os.path.join(dirpath, f) for f in filenames]]) + + +version = __import__('philo').VERSION + +setup( + name = 'Philo', + version = '%s.%s' % (version[0], version[1]), + packages = packages, + data_files = data_files, +) \ No newline at end of file diff --git a/templates/admin/philo/edit_inline/grappelli_tabular_container.html b/templates/admin/philo/edit_inline/grappelli_tabular_container.html deleted file mode 100644 index 5602a38..0000000 --- a/templates/admin/philo/edit_inline/grappelli_tabular_container.html +++ /dev/null @@ -1,43 +0,0 @@ -{% load i18n adminmedia %} - - -{{ inline_admin_formset.formset.management_form }} -{% comment %}Don't render the formset at all if there aren't any forms.{% endcomment %} -{% if inline_admin_formset.formset.forms %} -
-

{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}

- {{ inline_admin_formset.formset.non_form_errors }} - {% for inline_admin_form in inline_admin_formset %} - {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %} - {{ inline_admin_form.fk_field.field }} - {% spaceless %} - {% for fieldset in inline_admin_form %} - {% for line in fieldset %} - {% for field in line %} - {% if field.is_hidden %} {{ field.field }} {% endif %} - {% endfor %} - {% endfor %} - {% endfor %}{% endspaceless %} -
- -
{{ inline_admin_form.form.name.as_hidden }}
- {% for fieldset in inline_admin_form %}{% for line in fieldset %}{% for field in line %} - {% if field.field.name != 'name' %} -
- {% if field.is_readonly %} -

{{ field.contents }}

- {% else %} - {{ field.field }} - {% endif %} - {{ inline_admin_form.errors }} - {% if field.field.field.help_text %} -

{{ field.field.field.help_text|safe }}

- {% endif %} -
- {% endif %} - {% endfor %}{% endfor %}{% endfor %} -
- - {% endfor %} -
-{% endif %} diff --git a/templates/admin/philo/page/add_form.html b/templates/admin/philo/page/add_form.html deleted file mode 100644 index 67f6ec4..0000000 --- a/templates/admin/philo/page/add_form.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "admin/change_form.html" %} -{% load i18n %} - -{% block extrahead %}{{ block.super }} - - -{% endblock %} - -{% block form_top %} -

{% trans "First, choose a template. After saving, you'll be able to provide additional content for containers." %}

- -{% endblock %} - -{% block content %} -{% with 0 as save_on_top %} -{{ block.super }} -{% endwith %} -{% endblock %} \ No newline at end of file
{% trans "Delete?" %}
{{ inline_admin_form.form.non_field_errors }}
{{ form.non_field_errors }}
{{ inline_admin_form.form.verbose_name|capfirst }}:
{{ form.verbose_name|capfirst }}: - {% if field.is_readonly %} -

{{ field.contents }}

- {% else %} {{ field.field.errors.as_ul }} - {{ field.field }} - {% endif %} + {{ field }} + {% if field.field.help_text %} +

{{ field.field.help_text|safe }}

+ {% endif %}
{% if inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }}{% endif %}