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):
"""
--- /dev/null
+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
--- /dev/null
+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 <http://tools.ietf.org/html/rfc5545#section-3.8.4.3> and
+ # construct something based on that.
+ 'pubdate': 'created',
+ 'last_modified': 'last-modified',
+ #'comments' require special handling as well <http://tools.ietf.org/html/rfc5545#section-3.8.1.4>
+ '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
+ # <http://blog.thescoop.org/archives/2007/07/31/django-ical-and-vobject/>
+ 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
--- /dev/null
+# 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']
--- /dev/null
+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 self.start_date
+
+ def get_end(self):
+ return 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<year>\d{4})', 'year') + \
+ self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})', 'month') + \
+ self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})', 'day') + \
+ self.feed_patterns(r'^%s/(?P<username>[^/]+)' % self.owner_permalink_base, 'get_events_by_owner', 'owner_page', 'events_by_user') + \
+ self.feed_patterns(r'^%s/(?P<app_label>\w+)/(?P<model>\w+)/(?P<pk>[^/]+)' % self.location_permalink_base, 'get_events_by_location', 'location_page', 'events_by_location') + \
+ self.feed_patterns(r'^%s/(?P<tag_slugs>[-\w]+[-+/\w]*)' % self.tag_permalink_base, 'get_events_by_tag', 'tag_page', 'events_by_tag') + \
+ patterns('',
+ url(r'(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\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<slug>[\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
'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
})
)
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
--- /dev/null
+from philo.contrib.sobol.search import *
\ No newline at end of file
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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)
+ 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()]))
+
+ def get_reverse_params(self, obj):
+ raise ViewCanNotProvideSubpath
+
+ @property
+ def urlpatterns(self):
+ urlpatterns = patterns('',
+ url(r'^$', self.results_view, name='results'),
+ )
+ if self.enable_ajax_api:
+ urlpatterns += patterns('',
+ url(r'^(?P<slug>[\w-]+)$', self.ajax_api_view, name='ajax_api_view')
+ )
+ return urlpatterns
+
+ def get_search_instance(self, slug, search_string):
+ return registry[slug](search_string.lower())
+
+ def results_view(self, request, extra_context=None):
+ results = None
+
+ context = self.get_context()
+ context.update(extra_context or {})
+
+ if SEARCH_ARG_GET_KEY in request.GET:
+ form = self.search_form(request.GET)
+
+ if form.is_valid():
+ search_string = request.GET[SEARCH_ARG_GET_KEY].lower()
+ url = request.GET.get(URL_REDIRECT_GET_KEY)
+ hash = request.GET.get(HASH_REDIRECT_GET_KEY)
+
+ if url and hash:
+ if check_redirect_hash(hash, search_string, url):
+ # Create the necessary models
+ search = Search.objects.get_or_create(string=search_string)[0]
+ result_url = search.result_urls.get_or_create(url=url)[0]
+ result_url.clicks.create(datetime=datetime.datetime.now())
+ return HttpResponseRedirect(url)
+ else:
+ messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
+ # TODO: Should search_string be escaped here?
+ return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
+ if not self.enable_ajax_api:
+ search_instances = []
+ if eventlet:
+ pool = eventlet.GreenPool()
+ for slug in self.searches:
+ search_instance = self.get_search_instance(slug, search_string)
+ search_instances.append(search_instance)
+ if eventlet:
+ pool.spawn_n(self.make_result_cache, search_instance)
+ else:
+ self.make_result_cache(search_instance)
+ if eventlet:
+ pool.waitall()
+ context.update({
+ 'searches': search_instances
+ })
+ else:
+ context.update({
+ 'searches': [{'verbose_name': verbose_name, 'url': self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node)} for slug, verbose_name in registry.iterchoices()]
+ })
+ 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
--- /dev/null
+#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 %}<a href='{{ url }}'>{% endif %}{{ title }}{% if url %}</a>{% endif %}"
+
+# Determines the timeout on the entire result cache.
+MAX_CACHE_TIMEOUT = 60*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:
+ if self._registry[slug] != search:
+ raise RegistrationError("A different search is already registered as `%s`")
+ 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):
+ return "?%s" % self.search.get_result_querydict(self.result).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 <timeout> 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(self.result_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 <limit> results. The
+ get_result_title, get_result_url, get_result_template, and
+ get_result_extra_context methods will be used to interpret the
+ individual items that this function returns, so the result can
+ be an object with attributes as easily as a dictionary
+ with keys. The only restriction is that the objects be
+ pickleable so that they can be used with django's cache system.
+ """
+ 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):
+ return make_tracking_querydict(self.search_arg, self.get_result_url(result))
+
+ 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 = Template(DEFAULT_RESULT_TEMPLATE_STRING)
+ 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 has_more_results(self):
+ return self.get_queryset().count() > self.result_limit
+
+ 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"
+ query_format_str = "?v=1.0&q=%s"
+ # TODO: Change this template to reflect the app's actual name.
+ result_template = 'search/googlesearch.html'
+ timeout = 60
+
+ 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[: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(page, selfClosingTags=self._self_closing_tags, parseOnlyThese=strainer)
+ return self.parse_results(soup[:limit])
\ No newline at end of file
--- /dev/null
+{% extends "admin/base_site.html" %}
+
+<!-- LOADING -->
+{% load i18n %}
+
+<!-- EXTRASTYLES -->
+{% block extrastyle %}<style type="text/css">.favored{font-weight:bold;}</style>{% endblock %}
+
+<!-- BREADCRUMBS -->
+{% block breadcrumbs %}
+ <div id="breadcrumbs">
+ {% if queryset|length > 1 %}
+ <a href="../../">{% trans "Home" %}</a> ›
+ <a href="../">{{ app_label|capfirst }}</a> ›
+ <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> ›
+ {% trans 'Search results for multiple objects' %}
+ {% else %}
+ <a href="../../../../">{% trans "Home" %}</a> ›
+ <a href="../../../">{{ app_label|capfirst }}</a> ›
+ <a href="../../">{{ opts.verbose_name_plural|capfirst }}</a> ›
+ <a href="../">{{ queryset|first|truncatewords:"18" }}</a> ›
+ {% trans 'Results' %}
+ {% endif %}
+ </div>
+{% endblock %}
+
+<!-- CONTENT -->
+{% block content %}
+ <div class="container-grid delete-confirmation">
+ {% for search in queryset %}
+ {% if not forloop.first and not forloop.last %}<h1>{{ search.string }}</h1>{% endif %}
+ <div class="group tabular">
+ <h2>{% blocktrans %}Results{% endblocktrans %}</h2>{% comment %}For the favored results, add a class?{% endcomment %}
+ <div class="module table">
+ <div class="module thead">
+ <div class="tr">
+ <div class="th">Weight</div>
+ <div class="th">URL</div>
+ </div>
+ </div>
+ <div class="module tbody">
+ {% for result in search.get_weighted_results %}
+ <div class="tr{% if result in search.get_favored_results %} favored{% endif %}">
+ <div class="td">{{ result.weight }}</div>
+ <div class="td">{{ result.url }}</div>
+ </div>
+ {% endfor %}
+ </div>
+ </div>
+ </div>
+ {% endfor %}
+ </div>
+{% endblock %}
\ No newline at end of file
--- /dev/null
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block extrastyle %}<style type="text/css">.favored{font-weight:bold;}</style>{% endblock %}
+
+{% block breadcrumbs %}
+<div class="breadcrumbs">
+ {% if queryset|length > 1 %}
+ <a href="../../">{% trans "Home" %}</a> ›
+ <a href="../">{{ app_label|capfirst }}</a> ›
+ <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> ›
+ {% trans 'Search results for multiple objects' %}
+ {% else %}
+ <a href="../../../../">{% trans "Home" %}</a> ›
+ <a href="../../../">{{ app_label|capfirst }}</a> ›
+ <a href="../../">{{ opts.verbose_name_plural|capfirst }}</a> ›
+ <a href="../">{{ queryset|first|truncatewords:"18" }}</a> ›
+ {% trans 'Results' %}
+ {% endif %}
+</div>
+{% endblock %}
+
+
+{% block content %}
+ {% for search in queryset %}
+ {% if not forloop.first and not forloop.last %}<h1>{{ search.string }}</h1>{% endif %}
+ <fieldset class="module">
+ <h2>{% blocktrans %}Results{% endblocktrans %}</h2>{% comment %}For the favored results, add a class?{% endcomment %}
+ <table>
+ <thead>
+ <tr>
+ <th>Weight</th>
+ <th>URL</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for result in search.get_weighted_results %}
+ <tr{% if result in search.favored_results %} class="favored"{% endif %}>
+ <td>{{ result.weight }}</td>
+ <td>{{ result.url }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </fieldset>
+ {% endfor %}
+{% endblock %}
\ No newline at end of file
--- /dev/null
+<article>
+ <h1><a href="{{ url }}">{{ title|safe }}</a></h1>
+ <p>{{ content|safe }}</p>
+</article>
\ No newline at end of file
--- /dev/null
+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
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)
# 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)
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)
-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
__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
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:
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)
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
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:
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
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 *
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:
- kwargs[self.attname] = json.dumps(kwargs.pop(self.name))
+ 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
class LazyContainerFinder(object):
- def __init__(self, nodes):
+ 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)
self.contentreference_specs[node.name] = node.references
continue
- if isinstance(node, BlockNode):
- self.blocks[node.name] = block = LazyContainerFinder(node.nodelist)
- block.initialize()
- self.blocks.update(block.blocks)
- continue
-
- if isinstance(node, ExtendsNode):
- continue
-
if isinstance(node, VariableNode):
if node.filter_expression.var.lookups == (u'block', u'super'):
self.block_super = True
if extends:
if extends.nodelist:
- nodelists.append(LazyContainerFinder(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:
return contentlet_specs, contentreference_specs
def __unicode__(self):
- return self.get_path(pathsep=u' › ', field='name')
+ return self.name
class Meta:
app_label = 'philo'
urlpatterns = patterns('',
- url(r'^$', node_view, name='philo-root'),
+ url(r'^$', node_view, kwargs={'path': '/'}, name='philo-root'),
url(r'^(?P<path>.*)$', node_view, name='philo-node-by-path')
)
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
raise ValidationError('Tag "%s" is not permitted here.' % command)
+def linebreak_iter(template_source):
+ # Cribbed from django/views/debug.py
+ 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
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).<br />%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):
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):