Added some convenient methods to event querysets.
[philo.git] / contrib / julian / models.py
1 from django.conf import settings
2 from django.conf.urls.defaults import url, patterns, include
3 from django.contrib.auth.models import User
4 from django.contrib.contenttypes.generic import GenericForeignKey
5 from django.contrib.contenttypes.models import ContentType
6 from django.core.exceptions import ValidationError, ObjectDoesNotExist
7 from django.core.validators import RegexValidator
8 from django.db import models
9 from django.db.models.query import QuerySet
10 from django.http import HttpResponse, Http404
11 from django.utils.encoding import force_unicode
12 from philo.contrib.julian.feedgenerator import ICalendarFeed
13 from philo.contrib.penfield.models import FeedView, FEEDS
14 from philo.exceptions import ViewCanNotProvideSubpath
15 from philo.models import Tag, Entity, Page, TemplateField
16 from philo.utils import ContentTypeRegistryLimiter
17 import re, datetime, calendar
18
19
20 # TODO: Could this regex more closely match the Formal Public Identifier spec?
21 # http://xml.coverpages.org/tauber-fpi.html
22 FPI_REGEX = re.compile(r"(|\+//|-//)[^/]+//[^/]+//[A-Z]{2}")
23
24
25 ICALENDAR = ICalendarFeed.mime_type
26 FEEDS[ICALENDAR] = ICalendarFeed
27
28
29 location_content_type_limiter = ContentTypeRegistryLimiter()
30
31
32 def register_location_model(model):
33         location_content_type_limiter.register_class(model)
34
35
36 def unregister_location_model(model):
37         location_content_type_limiter.unregister_class(model)
38
39
40 class Location(Entity):
41         name = models.CharField(max_length=255)
42         slug = models.SlugField(max_length=255, unique=True)
43         
44         def __unicode__(self):
45                 return self.name
46
47
48 register_location_model(Location)
49
50
51 class TimedModel(models.Model):
52         start_date = models.DateField(help_text="YYYY-MM-DD")
53         start_time = models.TimeField(blank=True, null=True, help_text="HH:MM:SS - 24 hour clock")
54         end_date = models.DateField()
55         end_time = models.TimeField(blank=True, null=True)
56         
57         def is_all_day(self):
58                 return self.start_time is None and self.end_time is None
59         
60         def clean(self):
61                 if bool(self.start_time) != bool(self.end_time):
62                         raise ValidationError("A %s must have either a start time and an end time or neither.")
63                 
64                 if self.start_date > self.end_date or self.start_date == self.end_date and self.start_time > self.end_time:
65                         raise ValidationError("A %s cannot end before it starts." % self.__class__.__name__)
66         
67         def get_start(self):
68                 return self.start_date
69         
70         def get_end(self):
71                 return self.end_date
72         
73         class Meta:
74                 abstract = True
75
76
77 class EventManager(models.Manager):
78         def get_query_set(self):
79                 return self.model.QuerySet(self.model)
80
81 class Event(Entity, TimedModel):
82         name = models.CharField(max_length=255)
83         slug = models.SlugField(max_length=255, unique_for_date='start_date')
84         
85         location_content_type = models.ForeignKey(ContentType, limit_choices_to=location_content_type_limiter, blank=True, null=True)
86         location_pk = models.TextField(blank=True)
87         location = GenericForeignKey('location_content_type', 'location_pk')
88         
89         description = TemplateField()
90         
91         tags = models.ManyToManyField(Tag, related_name='events', blank=True, null=True)
92         
93         parent_event = models.ForeignKey('self', blank=True, null=True)
94         
95         # TODO: "User module"
96         owner = models.ForeignKey(User, related_name='owned_events')
97         
98         created = models.DateTimeField(auto_now_add=True)
99         last_modified = models.DateTimeField(auto_now=True)
100         uuid = models.TextField() # Format?
101         
102         objects = EventManager()
103         
104         class QuerySet(QuerySet):
105                         
106                 def upcoming(self):
107                         return self.filter(start_date__gte=datetime.date.today())
108                         
109                 def current(self):
110                         return self.filter(start_date__lte=datetime.date.today(), end_date__gte=datetime.date.today())
111                         
112                 def single_day(self):
113                         return self.filter(start_date__exact=models.F('end_date'))
114         
115                 def multiday(self):
116                         return self.exclude(start_date__exact=models.F('end_date'))
117         
118         def __unicode__(self):
119                 return self.name
120
121
122 class Calendar(Entity):
123         name = models.CharField(max_length=100)
124         slug = models.SlugField(max_length=100)
125         description = models.TextField(blank=True)
126         events = models.ManyToManyField(Event, related_name='calendars')
127         
128         # TODO: Can we auto-generate this on save based on site id and calendar name and settings language?
129         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)])
130         
131         def __unicode__(self):
132                 return self.name
133
134
135 class CalendarView(FeedView):
136         calendar = models.ForeignKey(Calendar)
137         index_page = models.ForeignKey(Page, related_name="calendar_index_related")
138         event_detail_page = models.ForeignKey(Page, related_name="calendar_detail_related")
139         
140         timespan_page = models.ForeignKey(Page, related_name="calendar_timespan_related", blank=True, null=True)
141         tag_page = models.ForeignKey(Page, related_name="calendar_tag_related", blank=True, null=True)
142         location_page = models.ForeignKey(Page, related_name="calendar_location_related", blank=True, null=True)
143         owner_page = models.ForeignKey(Page, related_name="calendar_owner_related", blank=True, null=True)
144         
145         tag_archive_page = models.ForeignKey(Page, related_name="calendar_tag_archive_related", blank=True, null=True)
146         location_archive_page = models.ForeignKey(Page, related_name="calendar_location_archive_related", blank=True, null=True)
147         owner_archive_page = models.ForeignKey(Page, related_name="calendar_owner_archive_related", blank=True, null=True)
148         
149         tag_permalink_base = models.CharField(max_length=30, default='tags')
150         owner_permalink_base = models.CharField(max_length=30, default='owners')
151         location_permalink_base = models.CharField(max_length=30, default='locations')
152         events_per_page = models.PositiveIntegerField(blank=True, null=True)
153         
154         item_context_var = "events"
155         object_attr = "calendar"
156         
157         def get_reverse_params(self, obj):
158                 if isinstance(obj, User):
159                         return 'events_for_user', [], {'username': obj.username}
160                 elif isinstance(obj, Event):
161                         return 'event_detail', [], {
162                                 'year': str(obj.start_date.year).zfill(4),
163                                 'month': str(obj.start_date.month).zfill(2),
164                                 'day': str(obj.start_date.day).zfill(2),
165                                 'slug': obj.slug
166                         }
167                 elif isinstance(obj, Tag) or isinstance(obj, models.query.QuerySet) and obj.model == Tag:
168                         if isinstance(obj, Tag):
169                                 obj = [obj]
170                         return 'entries_by_tag', [], {'tag_slugs': '/'.join(obj)}
171                 raise ViewCanNotProvideSubpath
172         
173         def timespan_patterns(self, pattern, timespan_name):
174                 return self.feed_patterns(pattern, 'get_events_by_timespan', 'timespan_page', "events_by_%s" % timespan_name)
175         
176         @property
177         def urlpatterns(self):
178                 # Perhaps timespans should be done with GET parameters? Or two /-separated
179                 # date slugs? (e.g. 2010-02-1/2010-02-2) or a start and duration?
180                 # (e.g. 2010-02-01/week/ or ?d=2010-02-01&l=week)
181                 urlpatterns = self.feed_patterns(r'^', 'get_all_events', 'index_page', 'index') + \
182                         self.timespan_patterns(r'^(?P<year>\d{4})', 'year') + \
183                         self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})', 'month') + \
184                         self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})', 'day') + \
185                         self.feed_patterns(r'^%s/(?P<username>[^/]+)' % self.owner_permalink_base, 'get_events_by_owner', 'owner_page', 'events_by_user') + \
186                         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') + \
187                         self.feed_patterns(r'^%s/(?P<tag_slugs>[-\w]+[-+/\w]*)' % self.tag_permalink_base, 'get_events_by_tag', 'tag_page', 'events_by_tag') + \
188                         patterns('',
189                                 url(r'(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\w-]+)$', self.event_detail_view, name="event_detail"),
190                         )
191                         
192                         # Some sort of shortcut for a location would be useful. This could be on a per-calendar
193                         # or per-calendar-view basis.
194                         #url(r'^%s/(?P<slug>[\w-]+)' % self.location_permalink_base, ...)
195                 
196                 if self.tag_archive_page:
197                         urlpatterns += patterns('',
198                                 url(r'^%s$' % self.tag_permalink_base, self.tag_archive_view, name='tag_archive')
199                         )
200                 
201                 if self.owner_archive_page:
202                         urlpatterns += patterns('',
203                                 url(r'^%s$' % self.owner_permalink_base, self.owner_archive_view, name='owner_archive')
204                         )
205                 
206                 if self.location_archive_page:
207                         urlpatterns += patterns('',
208                                 url(r'^%s$' % self.location_permalink_base, self.location_archive_view, name='location_archive')
209                         )
210                 return urlpatterns
211         
212         # Basic QuerySet fetchers.
213         def get_event_queryset(self):
214                 return self.calendar.events.all()
215         
216         def get_timespan_queryset(self, year, month=None, day=None):
217                 qs = self.get_event_queryset()
218                 # See python documentation for the min/max values.
219                 if year and month and day:
220                         year, month, day = int(year), int(month), int(day)
221                         start_datetime = datetime.datetime(year, month, day, 0, 0)
222                         end_datetime = datetime.datetime(year, month, day, 23, 59)
223                 elif year and month:
224                         year, month = int(year), int(month)
225                         start_datetime = datetime.datetime(year, month, 1, 0, 0)
226                         end_datetime = datetime.datetime(year, month, calendar.monthrange(year, month)[1], 23, 59)
227                 else:
228                         year = int(year)
229                         start_datetime = datetime.datetime(year, 1, 1, 0, 0)
230                         end_datetime = datetime.datetime(year, 12, 31, 23, 59)
231                 
232                 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)
233         
234         def get_tag_queryset(self):
235                 return Tag.objects.filter(events__calendars=self.calendar).distinct()
236         
237         def get_location_querysets(self):
238                 # Potential bottleneck?
239                 location_map = {}
240                 locations = Event.objects.values_list('location_content_type', 'location_pk')
241                 
242                 for ct, pk in locations:
243                         location_map.setdefault(ct, []).append(pk)
244                 
245                 location_cts = ContentType.objects.in_bulk(location_map.keys())
246                 location_querysets = {}
247                 
248                 for ct_pk, pks in location_map.items():
249                         ct = location_cts[ct_pk]
250                         location_querysets[ct] = ct.model_class()._default_manager.filter(pk__in=pks)
251                 
252                 return location_querysets
253         
254         def get_owner_queryset(self):
255                 return User.objects.filter(owned_events__calendars=self.calendar).distinct()
256         
257         # Event QuerySet parsers for a request/args/kwargs
258         def get_all_events(self, request, extra_context=None):
259                 return self.get_event_queryset(), extra_context
260         
261         def get_events_by_timespan(self, request, year, month=None, day=None, extra_context=None):
262                 context = extra_context or {}
263                 context.update({
264                         'year': year,
265                         'month': month,
266                         'day': day
267                 })
268                 return self.get_timespan_queryset(year, month, day), context
269         
270         def get_events_by_owner(self, request, username, extra_context=None):
271                 try:
272                         owner = self.get_owner_queryset().get(username=username)
273                 except User.DoesNotExist:
274                         raise Http404
275                 
276                 qs = self.get_event_queryset().filter(owner=owner)
277                 context = extra_context or {}
278                 context.update({
279                         'owner': owner
280                 })
281                 return qs, context
282         
283         def get_events_by_tag(self, request, tag_slugs, extra_context=None):
284                 tag_slugs = tag_slugs.replace('+', '/').split('/')
285                 tags = self.get_tag_queryset().filter(slug__in=tag_slugs)
286                 
287                 if not tags:
288                         raise Http404
289                 
290                 # Raise a 404 on an incorrect slug.
291                 found_slugs = [tag.slug for tag in tags]
292                 for slug in tag_slugs:
293                         if slug and slug not in found_slugs:
294                                 raise Http404
295
296                 events = self.get_event_queryset()
297                 for tag in tags:
298                         events = events.filter(tags=tag)
299                 
300                 context = extra_context or {}
301                 context.update({'tags': tags})
302                 
303                 return events, context
304         
305         def get_events_by_location(self, request, app_label, model, pk, extra_context=None):
306                 try:
307                         ct = ContentType.objects.get(app_label=app_label, model=model)
308                         location = ct.model_class()._default_manager.get(pk=pk)
309                 except ObjectDoesNotExist:
310                         raise Http404
311                 
312                 events = self.get_event_queryset().filter(location_content_type=ct, location_pk=location.pk)
313                 
314                 context = extra_context or {}
315                 context.update({
316                         'location': location
317                 })
318                 return events, context
319         
320         # Detail View.
321         def event_detail_view(self, request, year, month, day, slug, extra_context=None):
322                 try:
323                         event = Event.objects.select_related('parent_event').get(start_date__year=year, start_date__month=month, start_date__day=day, slug=slug)
324                 except Event.DoesNotExist:
325                         raise Http404
326                 
327                 context = self.get_context()
328                 context.update(extra_context or {})
329                 context.update({
330                         'event': event
331                 })
332                 return self.event_detail_page.render_to_response(request, extra_context=context)
333         
334         # Archive Views.
335         def tag_archive_view(self, request, extra_context=None):
336                 tags = self.get_tag_queryset()
337                 context = self.get_context()
338                 context.update(extra_context or {})
339                 context.update({
340                         'tags': tags
341                 })
342                 return self.tag_archive_page.render_to_response(request, extra_context=context)
343         
344         def location_archive_view(self, request, extra_context=None):
345                 # What datastructure should locations be?
346                 locations = self.get_location_querysets()
347                 context = self.get_context()
348                 context.update(extra_context or {})
349                 context.update({
350                         'locations': locations
351                 })
352                 return self.location_archive_page.render_to_response(request, extra_context=context)
353         
354         def owner_archive_view(self, request, extra_context=None):
355                 owners = self.get_owner_queryset()
356                 context = self.get_context()
357                 context.update(extra_context or {})
358                 context.update({
359                         'owners': owners
360                 })
361                 return self.owner_archive_page.render_to_response(request, extra_context=context)
362         
363         # Process page items
364         def process_page_items(self, request, items):
365                 if self.events_per_page:
366                         page_num = request.GET.get('page', 1)
367                         paginator, paginated_page, items = paginate(items, self.events_per_page, page_num)
368                         item_context = {
369                                 'paginator': paginator,
370                                 'paginated_page': paginated_page,
371                                 self.item_context_var: items
372                         }
373                 else:
374                         item_context = {
375                                 self.item_context_var: items
376                         }
377                 return items, item_context
378         
379         # Feed information hooks
380         def title(self, obj):
381                 return obj.name
382         
383         def link(self, obj):
384                 # Link is ignored anyway...
385                 return ""
386         
387         def feed_guid(self, obj):
388                 # Is this correct? Should I have a different id for different subfeeds?
389                 return obj.uuid
390         
391         def description(self, obj):
392                 return obj.description
393         
394         def feed_extra_kwargs(self, obj):
395                 return {'filename': "%s.ics" % obj.slug}
396         
397         def item_title(self, item):
398                 return item.name
399         
400         def item_description(self, item):
401                 return item.description
402         
403         def item_link(self, item):
404                 return self.reverse(item)
405         
406         def item_guid(self, item):
407                 return item.uuid
408         
409         def item_author_name(self, item):
410                 if item.owner:
411                         return item.owner.get_full_name()
412         
413         def item_author_email(self, item):
414                 return getattr(item.owner, 'email', None) or None
415         
416         def item_pubdate(self, item):
417                 return item.created
418         
419         def item_categories(self, item):
420                 return [tag.name for tag in item.tags.all()]
421         
422         def item_extra_kwargs(self, item):
423                 return {
424                         'start': item.get_start(),
425                         'end': item.get_end(),
426                         'last_modified': item.last_modified,
427                         # Is forcing unicode enough, or should we look for a "custom method"?
428                         'location': force_unicode(item.location),
429                 }
430         
431         def __unicode__(self):
432                 return u"%s for %s" % (self.__class__.__name__, self.calendar)
433
434 field = CalendarView._meta.get_field('feed_type')
435 field._choices += ((ICALENDAR, 'iCalendar'),)
436 field.default = ICALENDAR