Merge branch 'melinath'
authorJoseph Spiros <joseph.spiros@ithinksw.com>
Tue, 24 Aug 2010 13:10:33 +0000 (09:10 -0400)
committerJoseph Spiros <joseph.spiros@ithinksw.com>
Tue, 24 Aug 2010 13:10:33 +0000 (09:10 -0400)
* melinath:
  Corrected get_subpath on blogs and newsletters to fit the new paradigm.
  Implemented feeds for NewsletterView. Corrected BlogView/NewsletterView urlpatterns.
  Moved feed-related methods to a generic mixin.
  Added feeds to blogs.
  Implemented object deletion on null values. Added ModelChoiceWidget to ContentReferences.
  Display of current values functional.
  Display of ContainerFormSets functional; display of their values is not; saving of values, however, is.
  Initial new page admin commit. Working on faking the admin out through inlines instead of ModelAdmin methods.
  Implemented email change confirmation.
  Implemented EmailTokenGenerator and added password changing to the LoginMultiView.
  Implemented password resetting. Improved redirection on login.
  Implemented one-time login on account confirm.
  Token-based User registration functional
  WIP: Implementation of pended user creation using tokens. Needs some testing.
  Initial waldo commit. Implements abstract LoginMultiView and AccountMultiView to generically handle login situations for philo.

admin/pages.py
contrib/penfield/models.py
contrib/penfield/utils.py [new file with mode: 0644]
contrib/waldo/__init__.py [new file with mode: 0644]
contrib/waldo/forms.py [new file with mode: 0644]
contrib/waldo/models.py [new file with mode: 0644]
contrib/waldo/tokens.py [new file with mode: 0644]
forms.py
models/pages.py
templates/admin/philo/edit_inline/tabular_container.html [new file with mode: 0644]

index 52238da..03b943f 100644 (file)
@@ -5,7 +5,27 @@ from philo.admin import widgets
 from philo.admin.base import COLLAPSE_CLASSES
 from philo.admin.nodes import ViewAdmin
 from philo.models.pages import Page, Template, Contentlet, ContentReference
-from philo.forms import TemplateForm
+from philo.forms import TemplateForm, ContentletInlineFormSet, ContentReferenceInlineFormSet, ContentletForm, ContentReferenceForm
+
+
+class ContentletInline(admin.StackedInline):
+       model = Contentlet
+       extra = 0
+       max_num = 0
+       formset = ContentletInlineFormSet
+       form = ContentletForm
+       can_delete = False
+       template = 'admin/philo/edit_inline/tabular_container.html'
+
+
+class ContentReferenceInline(admin.StackedInline):
+       model = ContentReference
+       extra = 0
+       max_num = 0
+       formset = ContentReferenceInlineFormSet
+       form = ContentReferenceForm
+       can_delete = False
+       template = 'admin/philo/edit_inline/tabular_container.html'
 
 
 class PageAdmin(ViewAdmin):
@@ -18,80 +38,7 @@ class PageAdmin(ViewAdmin):
        list_display = ('title', 'template')
        list_filter = ('template',)
        search_fields = ['title', 'contentlets__content']
-       
-       def get_fieldsets(self, request, obj=None, **kwargs):
-               fieldsets = list(self.fieldsets)
-               if obj: # if no obj, creating a new page, thus no template set, thus no containers
-                       template = obj.template
-                       if template.documentation:
-                               fieldsets.append(('Template Documentation', {
-                                       'description': template.documentation
-                               }))
-                       contentlet_containers, contentreference_containers = template.containers
-                       for container_name in contentlet_containers:
-                               fieldsets.append((('Container: %s' % container_name), {
-                                       'fields': (('contentlet_container_content_%s' % container_name), ('contentlet_container_dynamic_%s' % container_name)),
-                                       'classes': ['monospace']
-                               }))
-                       for container_name, container_content_type in contentreference_containers:
-                               fieldsets.append((('Container: %s' % container_name), {
-                                       'fields': (('contentreference_container_%s' % container_name),)
-                               }))
-               return fieldsets
-       
-       def get_form(self, request, obj=None, **kwargs):
-               form = super(PageAdmin, self).get_form(request, obj, **kwargs)
-               if obj: # if no obj, creating a new page, thus no template set, thus no containers
-                       page = obj
-                       template = page.template
-                       contentlet_containers, contentreference_containers = template.containers
-                       for container_name in contentlet_containers:
-                               initial_content = None
-                               initial_dynamic = False
-                               try:
-                                       contentlet = page.contentlets.get(name__exact=container_name)
-                                       initial_content = contentlet.content
-                                       initial_dynamic = contentlet.dynamic
-                               except Contentlet.DoesNotExist:
-                                       pass
-                               form.base_fields[('contentlet_container_content_%s' % container_name)] = forms.CharField(label='Content', widget=forms.Textarea(), initial=initial_content, required=False)
-                               form.base_fields[('contentlet_container_dynamic_%s' % container_name)] = forms.BooleanField(label='Dynamic', help_text='Specify whether this content contains dynamic template code', initial=initial_dynamic, required=False)
-                       for container_name, container_content_type in contentreference_containers:
-                               initial_content = None
-                               try:
-                                       initial_content = page.contentreferences.get(name__exact=container_name, content_type=container_content_type).content.pk
-                               except (ContentReference.DoesNotExist, AttributeError):
-                                       pass
-                               form.base_fields[('contentreference_container_%s' % container_name)] = forms.ModelChoiceField(label='References', widget=widgets.ModelLookupWidget(container_content_type), initial=initial_content, required=False, queryset=container_content_type.model_class().objects.all())
-               return form
-       
-       def save_model(self, request, page, form, change):
-               page.save()
-               template = page.template
-               contentlet_containers, contentreference_containers = template.containers
-               for container_name in contentlet_containers:
-                       if (('contentlet_container_content_%s' % container_name) in form.cleaned_data) and (('contentlet_container_dynamic_%s' % container_name) in form.cleaned_data):
-                               content = form.cleaned_data[('contentlet_container_content_%s' % container_name)]
-                               dynamic = form.cleaned_data[('contentlet_container_dynamic_%s' % container_name)]
-                               contentlet, created = page.contentlets.get_or_create(name=container_name, defaults={'content': content, 'dynamic': dynamic})
-                               if not created:
-                                       contentlet.content = content
-                                       contentlet.dynamic = dynamic
-                                       contentlet.save()
-               for container_name, container_content_type in contentreference_containers:
-                       if ('contentreference_container_%s' % container_name) in form.cleaned_data:
-                               content = form.cleaned_data[('contentreference_container_%s' % container_name)]
-                               try:
-                                       contentreference = page.contentreferences.get(name=container_name)
-                               except ContentReference.DoesNotExist:
-                                       contentreference = ContentReference(name=container_name, page=page, content_type=container_content_type)
-                               else:
-                                       if content == None:
-                                               contentreference.delete()
-                               
-                               if content is not None:
-                                       contentreference.content_id = content.id
-                                       contentreference.save()
+       inlines = [ContentletInline, ContentReferenceInline] + ViewAdmin.inlines
 
 
 class TemplateAdmin(admin.ModelAdmin):
index bf5178e..e433b84 100644 (file)
@@ -2,12 +2,14 @@ from django.db import models
 from django.conf import settings
 from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model
 from philo.exceptions import ViewCanNotProvideSubpath
-from django.conf.urls.defaults import url, patterns
+from django.conf.urls.defaults import url, patterns, include
 from django.core.urlresolvers import reverse
-from django.http import Http404, HttpResponse
+from django.http import Http404
 from datetime import datetime
 from philo.utils import paginate
 from philo.contrib.penfield.validators import validate_pagination_count
+from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed
+from philo.contrib.penfield.utils import FeedMultiViewMixin
 
 
 class Blog(Entity, Titled):
@@ -41,7 +43,7 @@ class BlogEntry(Entity, Titled):
 register_value_model(BlogEntry)
 
 
-class BlogView(MultiView):
+class BlogView(MultiView, FeedMultiViewMixin):
        ENTRY_PERMALINK_STYLE_CHOICES = (
                ('D', 'Year, month, and day'),
                ('M', 'Year and month'),
@@ -62,98 +64,105 @@ class BlogView(MultiView):
        entry_permalink_style = models.CharField(max_length=1, choices=ENTRY_PERMALINK_STYLE_CHOICES)
        entry_permalink_base = models.CharField(max_length=255, blank=False, default='entries')
        tag_permalink_base = models.CharField(max_length=255, blank=False, default='tags')
+       feed_suffix = models.CharField(max_length=255, blank=False, default=FeedMultiViewMixin.feed_suffix)
+       feeds_enabled = models.BooleanField() 
        
        def __unicode__(self):
                return u'BlogView for %s' % self.blog.title
        
+       @property
+       def per_page(self):
+               return self.entries_per_page
+       
+       @property
+       def feed_title(self):
+               return self.blog.title
+       
        def get_subpath(self, obj):
                if isinstance(obj, BlogEntry):
                        if obj.blog == self.blog:
-                               entry_view_args = {'slug': obj.slug}
+                               kwargs = {'slug': obj.slug}
                                if self.entry_permalink_style in 'DMY':
-                                       entry_view_args.update({'year': str(obj.date.year).zfill(4)})
+                                       kwargs.update({'year': str(obj.date.year).zfill(4)})
                                        if self.entry_permalink_style in 'DM':
-                                               entry_view_args.update({'month': str(obj.date.month).zfill(2)})
+                                               kwargs.update({'month': str(obj.date.month).zfill(2)})
                                                if self.entry_permalink_style == 'D':
-                                                       entry_view_args.update({'day': str(obj.date.day).zfill(2)})
-                               return reverse(self.entry_view, urlconf=self, kwargs=entry_view_args)
+                                                       kwargs.update({'day': str(obj.date.day).zfill(2)})
+                               return reverse(self.entry_view, urlconf=self, kwargs=kwargs)
                elif isinstance(obj, Tag):
                        if obj in self.blog.entry_tags:
-                               return reverse(self.tag_view, urlconf=self, kwargs={'tag_slugs': obj.slug})
+                               return reverse('entries_by_tag', urlconf=self, kwargs={'tag_slugs': obj.slug})
                elif isinstance(obj, (str, unicode)):
                        split_obj = obj.split(':')
                        if len(split_obj) > 1:
-                               entry_archive_view_args = {}
-                               if split_obj[0].lower() == 'archives':
-                                       entry_archive_view_args.update({'year': str(int(split_obj[1])).zfill(4)})
+                               kwargs = {}
+                               try:
+                                       kwargs.update({'year': str(int(split_obj[1])).zfill(4)})
                                        if len(split_obj) > 2:
-                                               entry_archive_view_args.update({'month': str(int(split_obj[2])).zfill(2)})
+                                               kwargs.update({'month': str(int(split_obj[2])).zfill(2)})
                                                if len(split_obj) > 3:
-                                                       entry_archive_view_args.update({'day': str(int(split_obj[3])).zfill(2)})
-                                       return reverse(self.entry_archive_view, urlconf=self, kwargs=entry_archive_view_args)
+                                                       kwargs.update({'day': str(int(split_obj[3])).zfill(2)})
+                                                       return reverse('entries_by_day', urlconf=self, kwargs=kwargs)
+                                               return reverse('entries_by_month', urlconf=self, kwargs=kwargs)
+                                       return reverse('entries_by_year', urlconf=self, kwargs=kwargs)
+                               except:
+                                       pass
                raise ViewCanNotProvideSubpath
        
+       def get_context(self):
+               return {'blog': self.blog}
+       
        @property
        def urlpatterns(self):
-               base_patterns = patterns('',
-                       url(r'^$', self.index_view),
-                       url((r'^(?:%s)/?$' % self.tag_permalink_base), self.tag_archive_view),
-                       url((r'^(?:%s)/(?P<tag_slugs>[-\w]+[-+/\w]*)/?$' % self.tag_permalink_base), self.tag_view)
+               urlpatterns = patterns('',
+                       url(r'^', include(self.feed_patterns(self.get_all_entries, self.index_page, 'index'))),
+                       url((r'^(?:%s)/(?P<tag_slugs>[-\w]+[-+/\w]*)/' % self.tag_permalink_base), include(self.feed_patterns(self.get_entries_by_tag, self.tag_page, 'entries_by_tag')))
                )
+               if self.tag_archive_page:
+                       urlpatterns += patterns('',
+                               url((r'^(?:%s)/?$' % self.tag_permalink_base), self.tag_archive_view)
+                       )
+               
+               if self.entry_archive_page:
+                       if self.entry_permalink_style in 'DMY':
+                               urlpatterns += patterns('',
+                                       url(r'^(?P<year>\d{4})/', include(self.feed_patterns(self.get_entries_by_ymd, self.entry_archive_page, 'entries_by_year')))
+                               )
+                               if self.entry_permalink_style in 'DM':
+                                       urlpatterns += patterns('',
+                                               url(r'^(?P<year>\d{4})/(?P<month>\d{2})/?$', include(self.feed_patterns(self.get_entries_by_ymd, self.entry_archive_page, 'entries_by_month'))),
+                                       )
+                                       if self.entry_permalink_style == 'D':
+                                               urlpatterns += patterns('',
+                                                       url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/?$', include(self.feed_patterns(self.get_entries_by_ymd, self.entry_archive_page, 'entries_by_day')))
+                                               )
+               
                if self.entry_permalink_style == 'D':
-                       entry_patterns = patterns('',
-                               url(r'^(?P<year>\d{4})/?$', self.entry_archive_view),
-                               url(r'^(?P<year>\d{4})/(?P<month>\d{2})/?$', self.entry_archive_view),
-                               url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/?$', self.entry_archive_view),
+                       urlpatterns += patterns('',
                                url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[-\w]+)/?$', self.entry_view)
                        )
                elif self.entry_permalink_style == 'M':
-                       entry_patterns = patterns('',
-                               url(r'^(?P<year>\d{4})/?$', self.entry_archive_view),
-                               url(r'^(?P<year>\d{4})/(?P<month>\d{2})/?$', self.entry_archive_view),
+                       urlpatterns += patterns('',
                                url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<slug>[-\w]+)/?$', self.entry_view)
                        )
                elif self.entry_permalink_style == 'Y':
-                       entry_patterns = patterns('',
-                               url(r'^(?P<year>\d{4})/?$', self.entry_archive_view),
+                       urlpatterns += patterns('',
                                url(r'^(?P<year>\d{4})/(?P<slug>[-\w]+)/?$', self.entry_view)
                        )
                elif self.entry_permalink_style == 'B':
-                       entry_patterns = patterns('',
-                               url((r'^(?:%s)/?$' % self.entry_permalink_base), self.entry_archive_view),
+                       urlpatterns += patterns('',
                                url((r'^(?:%s)/(?P<slug>[-\w]+)/?$' % self.entry_permalink_base), self.entry_view)
                        )
                else:
-                       entry_patterns = patterns('',
+                       urlpatterns = patterns('',
                                url(r'^(?P<slug>[-\w]+)/?$', self.entry_view)
                        )
-               return base_patterns + entry_patterns
-       
-       def index_view(self, request, node=None, extra_context=None):
-               paginator, page, entries = paginate(self.blog.entries.all(), self.entries_per_page, request.GET.get('page', 1))
-               context = {}
-               context.update(extra_context or {})
-               context.update({'blog': self.blog, 'paginator': paginator, 'paginated_page': page, 'entries': entries})
-               return self.index_page.render_to_response(node, request, extra_context=context)
+               return urlpatterns
        
-       def entry_view(self, request, slug, year=None, month=None, day=None, node=None, extra_context=None):
-               entries = self.blog.entries.all()
-               if year:
-                       entries = entries.filter(date__year=year)
-               if month:
-                       entries = entries.filter(date__month=month)
-               if day:
-                       entries = entries.filter(date__day=day)
-               try:
-                       entry = entries.get(slug=slug)
-               except:
-                       raise Http404
-               context = {}
-               context.update(extra_context or {})
-               context.update({'blog': self.blog, 'entry': entry})
-               return self.entry_page.render_to_response(node, request, extra_context=context)
+       def get_all_entries(self, request, node=None, extra_context=None):
+               return self.blog.entries.all(), extra_context
        
-       def entry_archive_view(self, request, year=None, month=None, day=None, node=None, extra_context=None):
+       def get_entries_by_ymd(self, request, year=None, month=None, day=None, node=None, extra_context=None):
                if not self.entry_archive_page:
                        raise Http404
                entries = self.blog.entries.all()
@@ -164,13 +173,11 @@ class BlogView(MultiView):
                if day:
                        entries = entries.filter(date__day=day)
                
-               paginator, page, entries = paginate(entries, self.entries_per_page, request.GET.get('page', 1))
-               context = {}
-               context.update(extra_context or {})
-               context.update({'blog': self.blog, 'year': year, 'month': month, 'day': day, 'paginator': paginator, 'paginated_page': page, 'entries': entries})
-               return self.entry_archive_page.render_to_response(node, request, extra_context=context)
+               context = extra_context or {}
+               context.update({'year': year, 'month': month, 'day': day})
+               return entries, context
        
-       def tag_view(self, request, tag_slugs, node=None, extra_context=None):
+       def get_entries_by_tag(self, request, node=None, extra_context=None):
                tags = []
                for tag_slug in tag_slugs.replace('+', '/').split('/'):
                        if tag_slug: # ignore blank slugs, handles for multiple consecutive separators (+ or /)
@@ -181,18 +188,34 @@ class BlogView(MultiView):
                                tags.append(tag)
                if len(tags) <= 0:
                        raise Http404
-               
+
                entries = self.blog.entries.all()
                for tag in tags:
                        entries = entries.filter(tags=tag)
                if entries.count() <= 0:
                        raise Http404
                
-               paginator, page, entries = paginate(entries, self.entries_per_page, request.GET.get('page', 1))
-               context = {}
+               return entries, extra_context
+       
+       def get_obj_description(self, obj):
+               return obj.excerpt
+       
+       def entry_view(self, request, slug, year=None, month=None, day=None, node=None, extra_context=None):
+               entries = self.blog.entries.all()
+               if year:
+                       entries = entries.filter(date__year=year)
+               if month:
+                       entries = entries.filter(date__month=month)
+               if day:
+                       entries = entries.filter(date__day=day)
+               try:
+                       entry = entries.get(slug=slug)
+               except:
+                       raise Http404
+               context = self.get_context()
                context.update(extra_context or {})
-               context.update({'blog': self.blog, 'tags': tags, 'paginator': paginator, 'paginated_page': page, 'entries': entries})
-               return self.tag_page.render_to_response(node, request, extra_context=context)
+               context.update({'entry': entry})
+               return self.entry_page.render_to_response(node, request, extra_context=context)
        
        def tag_archive_view(self, request, node=None, extra_context=None):
                if not self.tag_archive_page:
@@ -239,7 +262,7 @@ class NewsletterIssue(Entity, Titled):
 register_value_model(NewsletterIssue)
 
 
-class NewsletterView(MultiView):
+class NewsletterView(MultiView, FeedMultiViewMixin):
        ARTICLE_PERMALINK_STYLE_CHOICES = (
                ('D', 'Year, month, and day'),
                ('M', 'Year and month'),
@@ -259,85 +282,99 @@ class NewsletterView(MultiView):
        article_permalink_base = models.CharField(max_length=255, blank=False, default='articles')
        issue_permalink_base = models.CharField(max_length=255, blank=False, default='issues')
        
+       feed_suffix = models.CharField(max_length=255, blank=False, default=FeedMultiViewMixin.feed_suffix)
+       feeds_enabled = models.BooleanField()
+       
+       @property
+       def feed_title(self):
+               return self.newsletter.title
+       
        def get_subpath(self, obj):
                if isinstance(obj, NewsletterArticle):
                        if obj.newsletter == self.newsletter:
-                               article_view_args = {'slug': obj.slug}
+                               kwargs = {'slug': obj.slug}
                                if self.article_permalink_style in 'DMY':
-                                       article_view_args.update({'year': str(obj.date.year).zfill(4)})
+                                       kwargs.update({'year': str(obj.date.year).zfill(4)})
                                        if self.article_permalink_style in 'DM':
-                                               article_view_args.update({'month': str(obj.date.month).zfill(2)})
+                                               kwargs.update({'month': str(obj.date.month).zfill(2)})
                                                if self.article_permalink_style == 'D':
-                                                       article_view_args.update({'day': str(obj.date.day).zfill(2)})
-                               return reverse(self.article_view, urlconf=self, kwargs=article_view_args)
+                                                       kwargs.update({'day': str(obj.date.day).zfill(2)})
+                               return reverse(self.article_view, urlconf=self, kwargs=kwargs)
                elif isinstance(obj, NewsletterIssue):
                        if obj.newsletter == self.newsletter:
-                               return reverse(self.issue_view, urlconf=self, kwargs={'number': str(obj.number)})
+                               return reverse('issue', urlconf=self, kwargs={'number': str(obj.number)})
                raise ViewCanNotProvideSubpath
        
        @property
        def urlpatterns(self):
-               base_patterns = patterns('',
-                       url(r'^$', self.index_view),
-                       url((r'^(?:%s)/?$' % self.issue_permalink_base), self.issue_archive_view),
-                       url((r'^(?:%s)/(?P<number>\d+)/?$' % self.issue_permalink_base), self.issue_view)
+               urlpatterns = patterns('',
+                       url(r'^', include(self.feed_patterns(self.get_all_articles, self.index_page, 'index'))),
+                       url(r'^(?:%s)/(?P<number>\d+)/' % self.issue_permalink_base, include(self.feed_patterns(self.get_articles_by_issue, self.issue_page, 'issue')))
                )
-               article_patterns = patterns('',
-                       url((r'^(?:%s)/?$' % self.article_permalink_base), self.article_archive_view)
-               )
-               if self.article_permalink_style in 'DMY':
-                       article_patterns += patterns('',
-                               url((r'^(?:%s)/(?P<year>\d{4})/?$' % self.article_permalink_base), self.article_archive_view)
+               if self.issue_archive_page:
+                       urlpatterns += patterns('',
+                               url(r'^(?:%s)/$' % self.issue_permalink_base, self.issue_archive_view)
+                       )
+               if self.article_archive_page:
+                       urlpatterns += patterns('',
+                               url(r'^(?:%s)/' % self.article_permalink_base, include(self.feed_patterns(self.get_all_articles, self.article_archive_page, 'articles')))
                        )
-                       if self.article_permalink_style in 'DM':
-                               article_patterns += patterns('',
-                                       url((r'^(?:%s)/(?P<year>\d{4})/(?P<month>\d{2})/?$' % self.article_permalink_base), self.article_archive_view)
+                       if self.article_permalink_style in 'DMY':
+                               urlpatterns += patterns('',
+                                       url(r'^(?:%s)/(?P<year>\d{4})/' % self.article_permalink_base, include(self.feed_patterns(self.get_articles_by_ymd, self.article_archive_page, 'articles_by_year')))
                                )
-                               if self.article_permalink_style == 'D':
-                                       article_patterns += patterns('',
-                                               url((r'^(?:%s)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/?$' % self.article_permalink_base), self.article_archive_view),
-                                               url((r'^(?:%s)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[-\w]+)/?$' % self.article_permalink_base), self.article_view)
+                               if self.article_permalink_style in 'DM':
+                                       urlpatterns += patterns('',
+                                               url(r'^(?:%s)/(?P<year>\d{4})/(?P<month>\d{2})/' % self.article_permalink_base, include(self.feed_patterns(self.get_articles_by_ymd, self.article_archive_page, 'articles_by_month')))
                                        )
-                               else:
-                                       article_patterns += patterns('',
-                                               url((r'^(?:%s)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<slug>[-\w]+)/?$' % self.article_permalink_base), self.article_view)
-                                       )
-                       else:
-                               article_patterns += patterns('',
-                                       url((r'^(?:%s)/(?P<year>\d{4})/(?P<slug>[-\w]+)/?$' % self.article_permalink_base), self.article_view)
-                               )
-               else:
-                       article_patterns += patterns('',
-                               url((r'^(?:%s)/(?P<slug>[-\w]+)/?$' % self.article_permalink_base), self.article_view)
+                                       if self.article_permalink_style == 'D':
+                                               urlpatterns += patterns('',
+                                                       url(r'^(?:%s)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/' % self.article_permalink_base, include(self.feed_patterns(self.get_articles_by_ymd, self.article_archive_page, 'articles_by_day')))
+                                               )
+               
+               if self.article_permalink_style == 'Y':
+                       urlpatterns += patterns('',
+                               url(r'^(?:%s)/(?P<year>\d{4})/(?P<slug>[\w-]+)/$' % self.article_permalink_base, self.article_view)
+                       )
+               elif self.article_permalink_style == 'M':
+                       urlpatterns += patterns('',
+                               url(r'^(?:%s)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<slug>[\w-]+)/$' % self.article_permalink_base, self.article_view)
+                       )
+               elif self.article_permalink_style == 'D':
+                       urlpatterns += patterns('',
+                               url(r'^(?:%s)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\w-]+)/$' % self.article_permalink_base, self.article_view)
                        )
-               return base_patterns + article_patterns
+               else:   
+                       urlpatterns += patterns('',
+                               url(r'^(?:%s)/(?P<slug>[-\w]+)/?$' % self.article_permalink_base, self.article_view)
+                       )
+               
+               return urlpatterns
        
-       def index_view(self, request, node=None, extra_context=None):
-               context = {}
-               context.update(extra_context or {})
-               context.update({'newsletter': self.newsletter})
-               return self.index_page.render_to_response(node, request, extra_context=context)
+       def get_context(self):
+               return {'newsletter': self.newsletter}
        
-       def article_view(self, request, slug, year=None, month=None, day=None, node=None, extra_context=None):
-               articles = self.newsletter.articles.all()
-               if year:
-                       articles = articles.filter(date__year=year)
+       def get_all_articles(self, request, node, extra_context=None):
+               return self.newsletter.articles.all(), extra_context
+       
+       def get_articles_by_ymd(self, request, year, month=None, day=None, node=None, extra_context=None):
+               articles = self.newsletter.articles.filter(dat__year=year)
                if month:
                        articles = articles.filter(date__month=month)
                if day:
                        articles = articles.filter(date__day=day)
+               return articles
+       
+       def get_articles_by_issue(self, request, number, node=None, extra_context=None):
                try:
-                       article = articles.get(slug=slug)
+                       issue = self.newsletter.issues.get(number=number)
                except:
                        raise Http404
-               context = {}
-               context.update(extra_context or {})
-               context.update({'newsletter': self.newsletter, 'article': article})
-               return self.article_page.render_to_response(node, request, extra_context=context)
+               context = extra_context or {}
+               context.update({'issue': issue})
+               return issue.articles.all(), context
        
-       def article_archive_view(self, request, year=None, month=None, day=None, node=None, extra_context=None):
-               if not self.article_archive_page:
-                       raise Http404
+       def article_view(self, request, slug, year=None, month=None, day=None, node=None, extra_context=None):
                articles = self.newsletter.articles.all()
                if year:
                        articles = articles.filter(date__year=year)
@@ -345,20 +382,14 @@ class NewsletterView(MultiView):
                        articles = articles.filter(date__month=month)
                if day:
                        articles = articles.filter(date__day=day)
-               context = {}
-               context.update(extra_context or {})
-               context.update({'newsletter': self.newsletter, 'year': year, 'month': month, 'day': day, 'articles': articles})
-               return self.article_archive_page.render_to_response(node, request, extra_context=context)
-       
-       def issue_view(self, request, number, node=None, extra_context=None):
                try:
-                       issue = self.newsletter.issues.get(number=number)
+                       article = articles.get(slug=slug)
                except:
                        raise Http404
                context = {}
                context.update(extra_context or {})
-               context.update({'newsletter': self.newsletter, 'issue': issue})
-               return self.issue_page.render_to_response(node, request, extra_context=context)
+               context.update({'newsletter': self.newsletter, 'article': article})
+               return self.article_page.render_to_response(node, request, extra_context=context)
        
        def issue_archive_view(self, request, node=None, extra_context=None):
                if not self.issue_archive_page:
@@ -366,4 +397,7 @@ class NewsletterView(MultiView):
                context = {}
                context.update(extra_context or {})
                context.update({'newsletter': self.newsletter})
-               return self.issue_archive_page.render_to_response(node, request, extra_context=context)
\ No newline at end of file
+               return self.issue_archive_page.render_to_response(node, request, extra_context=context)
+       
+       def get_obj_description(self, obj):
+               return obj.lede or obj.full_text
\ No newline at end of file
diff --git a/contrib/penfield/utils.py b/contrib/penfield/utils.py
new file mode 100644 (file)
index 0000000..7b5e946
--- /dev/null
@@ -0,0 +1,81 @@
+from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed
+from django.conf.urls.defaults import url, patterns
+from django.core.urlresolvers import reverse
+from django.http import HttpResponse
+
+
+class FeedMultiViewMixin(object):
+       """
+       This mixin provides common methods for adding feeds to multiviews. In order to use this mixin,
+       the multiview must define feed_title (probably as properties that return values
+       on related objects.) feed_description may also be defined; it defaults to an empty string.
+       """
+       feed_suffix = 'feed'
+       feeds_enabled = True
+       atom_feed = Atom1Feed
+       rss_feed = Rss201rev2Feed
+       feed_description = ''
+       
+       def page_view(self, func, page, list_var='entries'):
+               """
+               Wraps an object-fetching function and renders the results as a page.
+               """
+               def inner(request, node=None, extra_context=None, **kwargs):
+                       objects, extra_context = func(request=request, node=node, extra_context=extra_context, **kwargs)
+
+                       context = self.get_context()
+                       context.update(extra_context or {})
+
+                       if 'page' in kwargs or 'page' in request.GET:
+                               page_num = kwargs.get('page', request.GET.get('page', 1))
+                               paginator, paginated_page, objects = paginate(objects, self.per_page, page_num)
+                               context.update({'paginator': paginator, 'paginated_page': paginated_page, list_var: objects})
+                       else:
+                               context.update({list_var: objects})
+
+                       return page.render_to_response(node, request, extra_context=context)
+
+               return inner
+       
+       def feed_view(self, func, reverse_name):
+               """
+               Wraps an object-fetching function and renders the results as a rss or atom feed.
+               """
+               def inner(request, node=None, extra_context=None, **kwargs):
+                       objects, extra_context = func(request=request, node=node, extra_context=extra_context, **kwargs)
+       
+                       if 'HTTP_ACCEPT' in request.META and 'rss' in request.META['HTTP_ACCEPT'] and 'atom' not in request.META['HTTP_ACCEPT']:
+                               feed_type = 'rss'
+                       else:
+                               feed_type = 'atom'
+                       
+                       feed = self.get_feed(feed_type, request, node, kwargs, reverse_name)
+                       
+                       for obj in objects:
+                               feed.add_item(obj.title, '/%s/%s/' % (node.get_absolute_url().strip('/'), self.get_subpath(obj).strip('/')), description=self.get_obj_description(obj))
+       
+                       response = HttpResponse(mimetype=feed.mime_type)
+                       feed.write(response, 'utf-8')
+                       return response
+
+               return inner
+       
+       def get_feed(self, feed_type, request, node, kwargs, reverse_name):
+               title = self.feed_title
+               link = '/%s/%s/' % (node.get_absolute_url().strip('/'), reverse(reverse_name, urlconf=self, kwargs=kwargs).strip('/'))
+               description = self.feed_description
+               if feed_type == 'rss':
+                       return self.rss_feed(title, link, description)
+               
+               return self.atom_feed(title, link, description, subtitle=description)
+       
+       def feed_patterns(self, object_fetcher, page, base_name):
+               feed_name = '%s_feed' % base_name
+               urlpatterns = patterns('',
+                       url(r'^%s/$' % self.feed_suffix, self.feed_view(object_fetcher, feed_name), name=feed_name),
+                       url(r'^$', self.page_view(object_fetcher, page), name=base_name)
+               )
+               return urlpatterns
+       
+       def get_obj_description(self, obj):
+               raise NotImplementedError
\ No newline at end of file
diff --git a/contrib/waldo/__init__.py b/contrib/waldo/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/contrib/waldo/forms.py b/contrib/waldo/forms.py
new file mode 100644 (file)
index 0000000..4465a99
--- /dev/null
@@ -0,0 +1,51 @@
+from datetime import date
+from django import forms
+from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
+from django.contrib.auth.models import User
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext_lazy as _
+from philo.contrib.waldo.tokens import REGISTRATION_TIMEOUT_DAYS
+
+
+LOGIN_FORM_KEY = 'this_is_the_login_form'
+LoginForm = type('LoginForm', (AuthenticationForm,), {
+       LOGIN_FORM_KEY: forms.BooleanField(widget=forms.HiddenInput, initial=True)
+})
+
+
+class EmailInput(forms.TextInput):
+       input_type = 'email'
+
+
+class RegistrationForm(UserCreationForm):
+       email = forms.EmailField(widget=EmailInput)
+       
+       def clean_username(self):
+               username = self.cleaned_data['username']
+               
+               # Trivial case: if the username doesn't exist, go for it!
+               try:
+                       user = User.objects.get(username=username)
+               except User.DoesNotExist:
+                       return username
+               
+               if not user.is_active and (date.today() - user.date_joined.date()).days > REGISTRATION_TIMEOUT_DAYS and user.last_login == user.date_joined:
+                       # Then this is a user who has not confirmed their registration and whose time is up. Delete the old user and return the username.
+                       user.delete()
+                       return username
+               
+               raise ValidationError(_("A user with that username already exists."))
+       
+       def clean_email(self):
+               if User.objects.filter(email__iexact=self.cleaned_data['email']):
+                       raise ValidationError(_('This email is already in use. Please supply a different email address'))
+               return self.cleaned_data['email']
+       
+       def save(self):
+               username = self.cleaned_data['username']
+               email = self.cleaned_data['email']
+               password = self.cleaned_data['password1']
+               new_user = User.objects.create_user(username, email, password)
+               new_user.is_active = False
+               new_user.save()
+               return new_user
\ No newline at end of file
diff --git a/contrib/waldo/models.py b/contrib/waldo/models.py
new file mode 100644 (file)
index 0000000..93edab1
--- /dev/null
@@ -0,0 +1,449 @@
+from django import forms
+from django.conf.urls.defaults import url, patterns, include
+from django.contrib import messages
+from django.contrib.auth import authenticate, login, views as auth_views
+from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm, PasswordChangeForm
+from django.contrib.auth.models import User
+from django.contrib.auth.tokens import default_token_generator as password_token_generator
+from django.contrib.sites.models import Site
+from django.core.mail import send_mail
+from django.core.urlresolvers import reverse
+from django.db import models
+from django.http import Http404, HttpResponseRedirect
+from django.shortcuts import render_to_response, get_object_or_404
+from django.utils.http import int_to_base36, base36_to_int
+from django.utils.translation import ugettext_lazy, ugettext as _
+from django.views.decorators.cache import never_cache
+from django.views.decorators.csrf import csrf_protect
+from philo.models import MultiView, Page
+from philo.contrib.waldo.forms import LOGIN_FORM_KEY, LoginForm, RegistrationForm
+from philo.contrib.waldo.tokens import registration_token_generator, email_token_generator
+import urlparse
+
+
+ERROR_MESSAGE = ugettext_lazy("Please enter a correct username and password. Note that both fields are case-sensitive.")
+
+
+def get_field_data(obj, fields):
+       if fields == None:
+               fields = [field.name for field in obj._meta.fields if field.editable]
+       
+       return dict([(field.name, field.value_from_object(obj)) for field in obj._meta.fields if field.name in fields])
+
+
+class LoginMultiView(MultiView):
+       """
+       Handles login, registration, and forgotten passwords. In other words, this
+       multiview provides exclusively view and methods related to usernames and
+       passwords.
+       """
+       login_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_login_related')
+       password_reset_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_related')
+       password_reset_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_confirmation_email_related')
+       password_set_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_set_related')
+       password_change_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_change_related', blank=True, null=True)
+       register_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_related')
+       register_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_confirmation_email_related')
+       
+       @property
+       def urlpatterns(self):
+               urlpatterns = patterns('',
+                       url(r'^login/$', self.login, name='login'),
+                       url(r'^logout/$', self.logout, name='logout'),
+                       
+                       url(r'^password/reset/$', csrf_protect(self.password_reset), name='password_reset'),
+                       url(r'^password/reset/(?P<uidb36>\w+)/(?P<token>[^/]+)/$', self.password_reset_confirm, name='password_reset_confirm'),
+                       
+                       url(r'^register/$', csrf_protect(self.register), name='register'),
+                       url(r'^register/(?P<uidb36>\w+)/(?P<token>[^/]+)/$', self.register_confirm, name='register_confirm')
+               )
+               
+               if self.password_change_page:
+                       urlpatterns += patterns('',
+                               url(r'^password/change/$', csrf_protect(self.login_required(self.password_change)), name='password_change'),
+                       )
+               
+               return urlpatterns
+       
+       def get_context(self, extra_dict=None):
+               context = {}
+               context.update(extra_dict or {})
+               return context
+       
+       def display_login_page(self, request, message, node=None, extra_context=None):
+               request.session.set_test_cookie()
+               
+               referrer = request.META.get('HTTP_REFERER', None)
+               
+               if referrer is not None:
+                       referrer = urlparse.urlparse(referrer)
+                       host = referrer[1]
+                       if host != request.get_host():
+                               referrer = None
+                       else:
+                               redirect = ''.join(referrer[2:])
+               
+               if referrer is None:
+                       redirect = node.get_absolute_url()
+               
+               path = request.get_full_path()
+               if redirect != path:
+                       if redirect is None:
+                               redirect = '/'.join(path.split('/')[:-2])
+                       request.session['redirect'] = redirect
+               
+               if request.POST:
+                       form = LoginForm(request.POST)
+               else:
+                       form = LoginForm()
+               context = self.get_context({
+                       'message': message,
+                       'form': form
+               })
+               context.update(extra_context or {})
+               return self.login_page.render_to_response(node, request, extra_context=context)
+       
+       def login(self, request, node=None, extra_context=None):
+               """
+               Displays the login form for the given HttpRequest.
+               """
+               context = self.get_context(extra_context)
+               
+               from django.contrib.auth.models import User
+               
+               # If this isn't already the login page, display it.
+               if not request.POST.has_key(LOGIN_FORM_KEY):
+                       if request.POST:
+                               message = _("Please log in again, because your session has expired.")
+                       else:
+                               message = ""
+                       return self.display_login_page(request, message, node, context)
+
+               # Check that the user accepts cookies.
+               if not request.session.test_cookie_worked():
+                       message = _("Looks like your browser isn't configured to accept cookies. Please enable cookies, reload this page, and try again.")
+                       return self.display_login_page(request, message, node, context)
+               else:
+                       request.session.delete_test_cookie()
+               
+               # Check the password.
+               username = request.POST.get('username', None)
+               password = request.POST.get('password', None)
+               user = authenticate(username=username, password=password)
+               if user is None:
+                       message = ERROR_MESSAGE
+                       if username is not None and u'@' in username:
+                               # Mistakenly entered e-mail address instead of username? Look it up.
+                               try:
+                                       user = User.objects.get(email=username)
+                               except (User.DoesNotExist, User.MultipleObjectsReturned):
+                                       message = _("Usernames cannot contain the '@' character.")
+                               else:
+                                       if user.check_password(password):
+                                               message = _("Your e-mail address is not your username."
+                                                                       " Try '%s' instead.") % user.username
+                                       else:
+                                               message = _("Usernames cannot contain the '@' character.")
+                       return self.display_login_page(request, message, node, context)
+
+               # The user data is correct; log in the user in and continue.
+               else:
+                       if user.is_active:
+                               login(request, user)
+                               try:
+                                       redirect = request.session.pop('redirect')
+                               except KeyError:
+                                       redirect = node.get_absolute_url()
+                               return HttpResponseRedirect(redirect)
+                       else:
+                               return self.display_login_page(request, ERROR_MESSAGE, node, context)
+       login = never_cache(login)
+       
+       def logout(self, request):
+               return auth_views.logout(request, request.META['HTTP_REFERER'])
+       
+       def login_required(self, view):
+               def inner(request, node=None, *args, **kwargs):
+                       if not request.user.is_authenticated():
+                               login_url = reverse('login', urlconf=self).strip('/')
+                               return HttpResponseRedirect('%s%s/' % (node.get_absolute_url(), login_url))
+                       return view(request, node=node, *args, **kwargs)
+               
+               return inner
+       
+       def send_confirmation_email(self, subject, email, page, extra_context):
+               message = page.render_to_string(extra_context=extra_context)
+               from_email = 'noreply@%s' % Site.objects.get_current().domain
+               send_mail(subject, message, from_email, [email])
+       
+       def password_reset(self, request, node=None, extra_context=None, token_generator=password_token_generator):
+               if request.method == 'POST':
+                       form = PasswordResetForm(request.POST)
+                       if form.is_valid():
+                               current_site = Site.objects.get_current()
+                               for user in form.users_cache:
+                                       token = token_generator.make_token(user)
+                                       link = 'http://%s/%s/%s/' % (current_site.domain, node.get_absolute_url().strip('/'), reverse('password_reset_confirm', urlconf=self, kwargs={'uidb36': int_to_base36(user.id), 'token': token}).strip('/'))
+                                       context = {
+                                               'link': link,
+                                               'username': user.username
+                                       }
+                                       self.send_confirmation_email('Confirm password reset for account at %s' % current_site.domain, user.email, self.password_reset_confirmation_email, context)
+                                       messages.add_message(request, messages.SUCCESS, "An email has been sent to the address you provided with details on resetting your password.", fail_silently=True)
+                               return HttpResponseRedirect('')
+               else:
+                       form = PasswordResetForm()
+               
+               context = self.get_context({'form': form})
+               context.update(extra_context or {})
+               return self.password_reset_page.render_to_response(node, request, extra_context=context)
+       
+       def password_reset_confirm(self, request, node=None, extra_context=None, uidb36=None, token=None, token_generator=password_token_generator):
+               """
+               Checks that a given hash in a password reset link is valid. If so,
+               displays the password set form.
+               """
+               assert uidb36 is not None and token is not None
+               try:
+                       uid_int = base36_to_int(uidb36)
+               except:
+                       raise Http404
+               
+               user = get_object_or_404(User, id=uid_int)
+               
+               if token_generator.check_token(user, token):
+                       if request.method == 'POST':
+                               form = SetPasswordForm(user, request.POST)
+                               
+                               if form.is_valid():
+                                       form.save()
+                                       messages.add_message(request, messages.SUCCESS, "Password reset successful.")
+                                       return HttpResponseRedirect('/%s/%s/' % (node.get_absolute_url().strip('/'), reverse('login', urlconf=self).strip('/')))
+                       else:
+                               form = SetPasswordForm(user)
+                       
+                       context = self.get_context({'form': form})
+                       return self.password_set_page.render_to_response(node, request, extra_context=context)
+               
+               raise Http404
+       
+       def password_change(self, request, node=None, extra_context=None):
+               if request.method == 'POST':
+                       form = PasswordChangeForm(request.user, request.POST)
+                       if form.is_valid():
+                               form.save()
+                               messages.add_message(request, messages.SUCCESS, 'Password changed successfully.', fail_silently=True)
+                               return HttpResponseRedirect('')
+               else:
+                       form = PasswordChangeForm(request.user)
+               
+               context = self.get_context({'form': form})
+               context.update(extra_context or {})
+               return self.password_change_page.render_to_response(node, request, extra_context=context)
+       
+       def register(self, request, node=None, extra_context=None, token_generator=registration_token_generator):
+               if request.user.is_authenticated():
+                       return HttpResponseRedirect(node.get_absolute_url())
+               
+               if request.method == 'POST':
+                       form = RegistrationForm(request.POST)
+                       if form.is_valid():
+                               user = form.save()
+                               current_site = Site.objects.get_current()
+                               token = token_generator.make_token(user)
+                               link = 'http://%s/%s/%s/' % (current_site.domain, node.get_absolute_url().strip('/'), reverse('register_confirm', urlconf=self, kwargs={'uidb36': int_to_base36(user.id), 'token': token}).strip('/'))
+                               context = {
+                                       'link': link
+                               }
+                               self.send_confirmation_email('Confirm account creation at %s' % current_site.name, user.email, self.register_confirmation_email, context)
+                               messages.add_message(request, messages.SUCCESS, 'An email has been sent to %s with details on activating your account.' % user.email, fail_silently=True)
+                               return HttpResponseRedirect('')
+               else:
+                       form = RegistrationForm()
+               
+               context = self.get_context({'form': form})
+               context.update(extra_context or {})
+               return self.register_page.render_to_response(node, request, extra_context=context)
+       
+       def register_confirm(self, request, node=None, extra_context=None, uidb36=None, token=None, token_generator=registration_token_generator):
+               """
+               Checks that a given hash in a registration link is valid and activates
+               the given account. If so, log them in and redirect to
+               self.post_register_confirm_redirect.
+               """
+               assert uidb36 is not None and token is not None
+               try:
+                       uid_int = base36_to_int(uidb36)
+               except:
+                       raise Http404
+               
+               user = get_object_or_404(User, id=uid_int)
+               if token_generator.check_token(user, token):
+                       user.is_active = True
+                       true_password = user.password
+                       try:
+                               user.set_password('temp_password')
+                               user.save()
+                               authenticated_user = authenticate(username=user.username, password='temp_password')
+                               login(request, authenticated_user)
+                       finally:
+                               # if anything goes wrong, ABSOLUTELY make sure that the true password is restored.
+                               user.password = true_password
+                               user.save()
+                       return self.post_register_confirm_redirect(request, node)
+               
+               raise Http404
+       
+       def post_register_confirm_redirect(self, request, node):
+               return HttpResponseRedirect(node.get_absolute_url())
+       
+       class Meta:
+               abstract = True
+
+
+class AccountMultiView(LoginMultiView):
+       """
+       Subclasses may define an account_profile model, fields from the User model
+       to include in the account, and fields from the account profile to use in
+       the account.
+       """
+       manage_account_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_manage_account_related')
+       email_change_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_email_change_confirmation_email_related')
+       user_fields = ['first_name', 'last_name', 'email']
+       required_user_fields = user_fields
+       account_profile = None
+       account_profile_fields = None
+       
+       @property
+       def urlpatterns(self):
+               urlpatterns = super(AccountMultiView, self).urlpatterns
+               urlpatterns += patterns('',
+                       url(r'^account/$', self.login_required(self.account_view), name='account'),
+                       url(r'^account/email/(?P<uidb36>\w+)/(?P<email>[\w.]+[+][\w.]+)/(?P<token>[^/]+)/$', self.email_change_confirm, name='email_change_confirm')
+               )
+               return urlpatterns
+       
+       def get_account_forms(self):
+               user_form = forms.models.modelform_factory(User, fields=self.user_fields)
+               
+               if self.account_profile is None:
+                       profile_form = None
+               else:
+                       profile_form = forms.models.modelform_factory(self.account_profile, fields=self.account_profile_fields or [field.name for field in self.account_profile._meta.fields if field.editable and field.name != 'user'])
+               
+               for field_name, field in user_form.base_fields.items():
+                       if field_name in self.required_user_fields:
+                               field.required = True
+               return user_form, profile_form
+       
+       def get_account_form_instances(self, user, data=None):
+               form_instances = []
+               user_form, profile_form = self.get_account_forms()
+               if data is None:
+                       form_instances.append(user_form(instance=user))
+                       if profile_form:
+                               form_instances.append(profile_form(instance=self.account_profile._default_manager.get_or_create(user=user)[0]))
+               else:
+                       form_instances.append(user_form(data, instance=user))
+                       if profile_form:
+                               form_instances.append(profile_form(data, instance=self.account_profile._default_manager.get_or_create(user=user)[0]))
+               
+               return form_instances
+       
+       def account_view(self, request, node=None, extra_context=None, token_generator=email_token_generator):
+               if request.method == 'POST':
+                       form_instances = self.get_account_form_instances(request.user, request.POST)
+                       current_email = request.user.email
+                       
+                       for form in form_instances:
+                               if not form.is_valid():
+                                       break
+                       else:
+                               # When the user_form is validated, it changes the model instance, i.e. request.user, in place.
+                               email = request.user.email
+                               if current_email != email:
+                                       
+                                       request.user.email = current_email
+                                       
+                                       for form in form_instances:
+                                               form.cleaned_data.pop('email', None)
+                                       
+                                       current_site = Site.objects.get_current()
+                                       token = token_generator.make_token(request.user, email)
+                                       link = 'http://%s/%s/%s/' % (current_site.domain, node.get_absolute_url().strip('/'), reverse('email_change_confirm', urlconf=self, kwargs={'uidb36': int_to_base36(request.user.id), 'email': email.replace('@', '+'), 'token': token}).strip('/'))
+                                       context = {
+                                               'link': link
+                                       }
+                                       self.send_confirmation_email('Confirm account email change at %s' % current_site.domain, email, self.email_change_confirmation_email, context)
+                                       messages.add_message(request, messages.SUCCESS, "An email has be sent to %s to confirm the email change." % email)
+                                       
+                               for form in form_instances:
+                                       form.save()
+                               messages.add_message(request, messages.SUCCESS, "Account information saved.", fail_silently=True)
+                               return HttpResponseRedirect('')
+               else:
+                       form_instances = self.get_account_form_instances(request.user)
+               
+               context = self.get_context({
+                       'forms': form_instances
+               })
+               context.update(extra_context or {})
+               return self.manage_account_page.render_to_response(node, request, extra_context=context)
+       
+       def has_valid_account(self, user):
+               user_form, profile_form = self.get_account_forms()
+               forms = []
+               forms.append(user_form(data=get_field_data(user, self.user_fields)))
+               
+               if profile_form is not None:
+                       profile = self.account_profile._default_manager.get_or_create(user=user)[0]
+                       forms.append(profile_form(data=get_field_data(profile, self.account_profile_fields)))
+               
+               for form in forms:
+                       if not form.is_valid():
+                               return False
+               return True
+       
+       def account_required(self, view):
+               def inner(request, *args, **kwargs):
+                       if not self.has_valid_account(request.user):
+                               messages.add_message(request, messages.ERROR, "You need to add some account information before you can post listings.", fail_silently=True)
+                               return self.account_view(request, *args, **kwargs)
+                       return view(request, *args, **kwargs)
+               
+               inner = self.login_required(inner)
+               return inner
+       
+       def post_register_confirm_redirect(self, request, node):
+               messages.add_message(request, messages.INFO, 'Welcome! Please fill in some more information.', fail_silently=True)
+               return HttpResponseRedirect('/%s/%s/' % (node.get_absolute_url().strip('/'), reverse('account', urlconf=self).strip('/')))
+       
+       def email_change_confirm(self, request, node=None, extra_context=None, uidb36=None, token=None, email=None, token_generator=email_token_generator):
+               """
+               Checks that a given hash in an email change link is valid. If so, changes the email and redirects to the account page.
+               """
+               assert uidb36 is not None and token is not None and email is not None
+               
+               try:
+                       uid_int = base36_to_int(uidb36)
+               except:
+                       raise Http404
+               
+               user = get_object_or_404(User, id=uid_int)
+               
+               email = email.replace('+', '@')
+               
+               if email == user.email:
+                       # Then short-circuit.
+                       raise Http404
+               
+               if token_generator.check_token(user, email, token):
+                       user.email = email
+                       user.save()
+                       messages.add_message(request, messages.SUCCESS, 'Email changed successfully.')
+                       return HttpResponseRedirect('/%s/%s/' % (node.get_absolute_url().strip('/'), reverse('account', urlconf=self).strip('/')))
+               
+               raise Http404
+       
+       class Meta:
+               abstract = True
\ No newline at end of file
diff --git a/contrib/waldo/tokens.py b/contrib/waldo/tokens.py
new file mode 100644 (file)
index 0000000..95ce0c0
--- /dev/null
@@ -0,0 +1,106 @@
+"""
+Based on django.contrib.auth.tokens
+"""
+
+
+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
+
+
+REGISTRATION_TIMEOUT_DAYS = getattr(settings, 'WALDO_REGISTRATION_TIMEOUT_DAYS', 1)
+EMAIL_TIMEOUT_DAYS = getattr(settings, 'WALDO_EMAIL_TIMEOUT_DAYS', 1)
+
+
+class RegistrationTokenGenerator(PasswordResetTokenGenerator):
+       """
+       Strategy object used to generate and check tokens for the user registration mechanism.
+       """
+       def check_token(self, user, token):
+               """
+               Check that a registration token is correct for a given user.
+               """
+               # If the user is active, the hash can't be valid.
+               if user.is_active:
+                       return False
+               
+               # Parse the token
+               try:
+                       ts_b36, hash = token.split('-')
+               except ValueError:
+                       return False
+               
+               try:
+                       ts = base36_to_int(ts_b36)
+               except ValueError:
+                       return False
+               
+               # Check that the timestamp and uid have not been tampered with.
+               if self._make_token_with_timestamp(user, ts) != token:
+                       return False
+               
+               # Check that the timestamp is within limit
+               if (self._num_days(self._today()) - ts) > REGISTRATION_TIMEOUT_DAYS:
+                       return False
+               
+               return True
+       
+       def _make_token_with_timestamp(self, user, timestamp):
+               ts_b36 = int_to_base36(timestamp)
+               
+               # 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]
+               return '%s-%s' % (ts_b36, hash)
+
+
+registration_token_generator = RegistrationTokenGenerator()
+
+
+class EmailTokenGenerator(PasswordResetTokenGenerator):
+       """
+       Strategy object used to generate and check tokens for a user email change mechanism.
+       """
+       def make_token(self, user, email):
+               """
+               Returns a token that can be used once to do an email change for the given user and email.
+               """
+               return self._make_token_with_timestamp(user, email, self._num_days(self._today()))
+       
+       def check_token(self, user, email, token):
+               if email == user.email:
+                       return False
+               
+               # Parse the token
+               try:
+                       ts_b36, hash = token.split('-')
+               except ValueError:
+                       return False
+               
+               try:
+                       ts = base36_to_int(ts_b36)
+               except ValueError:
+                       return False
+               
+               # Check that the timestamp and uid have not been tampered with.
+               if self._make_token_with_timestamp(user, email, ts) != token:
+                       return False
+               
+               # Check that the timestamp is within limit
+               if (self._num_days(self._today()) - ts) > EMAIL_TIMEOUT_DAYS:
+                       return False
+               
+               return True
+       
+       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]
+               return '%s-%s' % (ts_b36, hash)
+
+
+email_token_generator = EmailTokenGenerator()
\ No newline at end of file
index 8050d39..57e772d 100644 (file)
--- a/forms.py
+++ b/forms.py
@@ -1,8 +1,12 @@
-from django.core.exceptions import ValidationError
-from django.forms.models import model_to_dict, fields_for_model, ModelFormMetaclass, ModelForm
+from django import forms
+from django.contrib.admin.widgets import AdminTextareaWidget
+from django.core.exceptions import ValidationError, ObjectDoesNotExist
+from django.forms.models import model_to_dict, fields_for_model, ModelFormMetaclass, ModelForm, BaseInlineFormSet
+from django.forms.formsets import TOTAL_FORM_COUNT
 from django.template import loader, loader_tags, TemplateDoesNotExist, Context, Template as DjangoTemplate
 from django.utils.datastructures import SortedDict
-from philo.models import Entity, Template
+from philo.admin.widgets import ModelLookupWidget
+from philo.models import Entity, Template, Contentlet, ContentReference
 from philo.models.fields import RelationshipField
 from philo.utils import fattr
 
@@ -94,7 +98,7 @@ class EntityForm(EntityFormBase): # Would inherit from ModelForm directly if it
 def validate_template(template):
        """
        Makes sure that the template and all included or extended templates are valid.
-       """     
+       """ 
        for node in template.nodelist:
                try:
                        if isinstance(node, loader_tags.ExtendsNode):
@@ -119,4 +123,173 @@ class TemplateForm(ModelForm):
                return code
 
        class Meta:
-               model = Template
\ No newline at end of file
+               model = Template
+
+
+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)
+       
+       def should_delete(self):
+               return not bool(self.cleaned_data['content'])
+       
+       class Meta:
+               model = Contentlet
+               fields = ['name', 'content', 'dynamic']
+
+
+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):
+               qs = qs.filter(name__in=[c[0] for c in containers])
+               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
index 7476f18..4260761 100644 (file)
@@ -98,6 +98,12 @@ class Page(View):
        template = models.ForeignKey(Template, related_name='pages')
        title = models.CharField(max_length=255)
        
+       def get_containers(self):
+               if not hasattr(self, '_containers'):
+                       self._containers = self.template.containers
+               return self._containers
+       containers = property(get_containers)
+       
        def render_to_string(self, node=None, request=None, path=None, subpath=None, extra_context=None):
                context = {}
                context.update(extra_context or {})
diff --git a/templates/admin/philo/edit_inline/tabular_container.html b/templates/admin/philo/edit_inline/tabular_container.html
new file mode 100644 (file)
index 0000000..f93e52f
--- /dev/null
@@ -0,0 +1,125 @@
+{% load i18n adminmedia %}
+{{ inline_admin_formset.formset.management_form }}
+{% if inline_admin_formset.formset.forms %}
+<div class="inline-group" id="{{ inline_admin_formset.formset.prefix }}-group">
+  <div class="tabular inline-related {% if forloop.last %}last-related{% endif %}">
+<fieldset class="module{% if inline_admin_formset.classes %} {{ inline_admin_formset.classes|join:' ' }}{% endif %}">
+   <h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
+   {{ inline_admin_formset.formset.non_form_errors }}
+   <table>
+        <thead><tr>
+        {% for field in inline_admin_formset.fields %}
+          {% if not field.widget.is_hidden %}
+                <th{% if field.required %} class="required"{% endif %}>{{ field.label|capfirst }}</th>
+          {% endif %}
+        {% endfor %}
+        {% if inline_admin_formset.formset.can_delete %}<th>{% trans "Delete?" %}</th>{% endif %}
+        </tr></thead>
+
+        <tbody>
+        {% 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 %}
+               {{ inline_admin_form.form.name.as_hidden }}
+               {% endspaceless %}
+               {% if inline_admin_form.form.non_field_errors %}
+               <tr><td colspan="{{ inline_admin_form.field_count }}">{{ inline_admin_form.form.non_field_errors }}</td></tr>
+               {% endif %}
+               <tr class="{% cycle "row1" "row2" %} {% if forloop.last %} empty-form{% endif %}"
+                        id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
+                       <th>{{ inline_admin_form.form.verbose_name|capfirst }}:</th>
+               {% for fieldset in inline_admin_form %}
+                 {% for line in fieldset %}
+                       {% for field in line %}
+                         {% if field.field.name != 'name' %}
+                         <td class="{{ field.field.name }}">
+                         {% if field.is_readonly %}
+                                 <p>{{ field.contents }}</p>
+                         {% else %}
+                                 {{ field.field.errors.as_ul }}
+                                 {{ field.field }}
+                         {% endif %}
+                         </td>
+                         {% endif %}
+                       {% endfor %}
+                 {% endfor %}
+               {% endfor %}
+               {% if inline_admin_formset.formset.can_delete %}
+                 <td class="delete">{% if inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }}{% endif %}</td>
+               {% endif %}
+               </tr>
+        {% endfor %}
+        </tbody>
+   </table>
+</fieldset>
+  </div>
+</div>
+
+<script type="text/javascript">
+(function($) {
+       $(document).ready(function($) {
+               var rows = "#{{ inline_admin_formset.formset.prefix }}-group .tabular.inline-related tbody tr";
+               var alternatingRows = function(row) {
+                       $(rows).not(".add-row").removeClass("row1 row2")
+                               .filter(":even").addClass("row1").end()
+                               .filter(rows + ":odd").addClass("row2");
+               }
+               var reinitDateTimeShortCuts = function() {
+                       // Reinitialize the calendar and clock widgets by force
+                       if (typeof DateTimeShortcuts != "undefined") {
+                               $(".datetimeshortcuts").remove();
+                               DateTimeShortcuts.init();
+                       }
+               }
+               var updateSelectFilter = function() {
+                       // If any SelectFilter widgets are a part of the new form,
+                       // instantiate a new SelectFilter instance for it.
+                       if (typeof SelectFilter != "undefined"){
+                               $(".selectfilter").each(function(index, value){
+                                 var namearr = value.name.split('-');
+                                 SelectFilter.init(value.id, namearr[namearr.length-1], false, "{% admin_media_prefix %}");
+                               })
+                               $(".selectfilterstacked").each(function(index, value){
+                                 var namearr = value.name.split('-');
+                                 SelectFilter.init(value.id, namearr[namearr.length-1], true, "{% admin_media_prefix %}");
+                               })
+                       }
+               }
+               var initPrepopulatedFields = function(row) {
+                       row.find('.prepopulated_field').each(function() {
+                               var field = $(this);
+                               var input = field.find('input, select, textarea');
+                               var dependency_list = input.data('dependency_list') || [];
+                               var dependencies = row.find(dependency_list.join(',')).find('input, select, textarea');
+                               if (dependencies.length) {
+                                       input.prepopulate(dependencies, input.attr('maxlength'));
+                               }
+                       });
+               }
+               $(rows).formset({
+                       prefix: "{{ inline_admin_formset.formset.prefix }}",
+                       addText: "{% blocktrans with inline_admin_formset.opts.verbose_name|title as verbose_name %}Add another {{ verbose_name }}{% endblocktrans %}",
+                       formCssClass: "dynamic-{{ inline_admin_formset.formset.prefix }}",
+                       deleteCssClass: "inline-deletelink",
+                       deleteText: "{% trans "Remove" %}",
+                       emptyCssClass: "empty-form",
+                       removed: alternatingRows,
+                       added: (function(row) {
+                               initPrepopulatedFields(row);
+                               reinitDateTimeShortCuts();
+                               updateSelectFilter();
+                               alternatingRows(row);
+                       })
+               });
+       });
+})(django.jQuery);
+</script>
+{% endif %}