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