Added some convenient methods to event querysets.
[philo.git] / contrib / julian / models.py
index c9bd6da..44b51f6 100644 (file)
@@ -1,16 +1,20 @@
 from django.conf import settings
-from django.contrib.localflavor.us.models import USStateField
-from django.contrib.contenttypes.generic import GenericForeignKey, GenericRelation
+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.core.exceptions import ValidationError
-from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator
+from django.core.exceptions import ValidationError, ObjectDoesNotExist
+from django.core.validators import RegexValidator
 from django.db import models
-from philo.contrib.julian.fields import USZipCodeField
-from philo.models.base import Tag, Entity, Titled
-from philo.models.fields import TemplateField
-from philo.utils import ContentTypeSubclassLimiter
-import datetime
-import re
+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 re, datetime, calendar
 
 
 # TODO: Could this regex more closely match the Formal Public Identifier spec?
@@ -18,53 +22,30 @@ import re
 FPI_REGEX = re.compile(r"(|\+//|-//)[^/]+//[^/]+//[A-Z]{2}")
 
 
-class Location(Entity):
-       name = models.CharField(max_length=150, blank=True)
-       description = models.TextField(blank=True)
-       
-       longitude = models.FloatField(blank=True, validators=[MinValueValidator(-180), MaxValueValidator(180)])
-       latitude = models.FloatField(blank=True, validators=[MinValueValidator(-90), MaxValueValidator(90)])
-       
-       events = GenericRelation('Event')
-       
-       def clean(self):
-               if not (self.name or self.description) or (self.longitude is None and self.latitude is None):
-                       raise ValidationError("Either a name and description or a latitude and longitude must be defined.")
-       
-       class Meta:
-               abstract = True
+ICALENDAR = ICalendarFeed.mime_type
+FEEDS[ICALENDAR] = ICalendarFeed
 
 
-_location_content_type_limiter = ContentTypeSubclassLimiter(Location)
+location_content_type_limiter = ContentTypeRegistryLimiter()
 
 
-# TODO: Can we track when a building is open? Hmm...
-class Building(Location):
-       """A building is a location with a street address."""
-       address = models.CharField(max_length=255)
-       city = models.CharField(max_length=150)
-       
-       class Meta:
-               abstract = True
+def register_location_model(model):
+       location_content_type_limiter.register_class(model)
 
 
-_building_content_type_limiter = ContentTypeSubclassLimiter(Building)
+def unregister_location_model(model):
+       location_content_type_limiter.unregister_class(model)
 
 
-class USBuilding(Building):
-       state = USStateField()
-       zipcode = USZipCodeField()
+class Location(Entity):
+       name = models.CharField(max_length=255)
+       slug = models.SlugField(max_length=255, unique=True)
        
-       class Meta:
-               verbose_name = "Building (US)"
-               verbose_name_plural = "Buildings (US)"
+       def __unicode__(self):
+               return self.name
 
 
-class Venue(Location):
-       """A venue is a location inside a building"""
-       building_content_type = models.ForeignKey(ContentType, limit_choices_to=_building_content_type_limiter)
-       building_pk = models.TextField()
-       building = GenericForeignKey('building_content_type', 'building_pk')
+register_location_model(Location)
 
 
 class TimedModel(models.Model):
@@ -76,28 +57,380 @@ class TimedModel(models.Model):
        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 Event(Entity, Titled, TimedModel):
-       location_content_type = models.ForeignKey(ContentType, limit_choices_to=_location_content_type_limiter)
-       location_pk = models.TextField()
+class EventManager(models.Manager):
+       def get_query_set(self):
+               return self.model.QuerySet(self.model)
+
+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, blank=True, null=True)
+       tags = models.ManyToManyField(Tag, related_name='events', blank=True, null=True)
        
        parent_event = models.ForeignKey('self', blank=True, null=True)
        
-       owner = models.ForeignKey(getattr(settings, 'PHILO_PERSON_MODULE', 'auth.User'))
+       # 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)
+       uuid = models.TextField() # Format?
+       
+       objects = EventManager()
+       
+       class QuerySet(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'))
+       
+       def __unicode__(self):
+               return self.name
 
 
 class Calendar(Entity):
        name = models.CharField(max_length=100)
-       #slug = models.SlugField(max_length=255, unique=True)
+       slug = models.SlugField(max_length=100)
+       description = models.TextField(blank=True)
        events = models.ManyToManyField(Event, related_name='calendars')
        
        # TODO: Can we auto-generate this on save based on site id and calendar name and settings language?
-       uuid = models.CharField("Calendar UUID", max_length=100, unique=True, help_text="Should conform to Formal Public Identifier format. See <http://en.wikipedia.org/wiki/Formal_Public_Identifier>", validators=[RegexValidator(FPI_REGEX)])
\ No newline at end of file
+       uuid = models.TextField("Calendar UUID", unique=True, help_text="Should conform to Formal Public Identifier format. See <a href='http://en.wikipedia.org/wiki/Formal_Public_Identifier'>Wikipedia</a>.", validators=[RegexValidator(FPI_REGEX)])
+       
+       def __unicode__(self):
+               return self.name
+
+
+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):
+               # Is this correct? Should I have a different id for different subfeeds?
+               return obj.uuid
+       
+       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