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, pattern, timespan_name):
153                 return self.feed_patterns(pattern, 'get_events_by_timespan', 'timespan_page', "events_by_%s" % timespan_name)
154         
155         @property
156         def urlpatterns(self):
157                 # Perhaps timespans should be done with GET parameters? Or two /-separated
158                 # date slugs? (e.g. 2010-02-1/2010-02-2) or a start and duration?
159                 # (e.g. 2010-02-01/week/ or ?d=2010-02-01&l=week)
160                 urlpatterns = self.feed_patterns(r'^', 'get_all_events', 'index_page', 'index') + \
161                         self.timespan_patterns(r'^(?P<year>\d{4})', 'year') + \
162                         self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})', 'month') + \
163                         self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})', 'day') + \
164                         self.feed_patterns(r'^%s/(?P<username>[^/]+)' % self.owner_permalink_base, 'get_events_by_owner', 'owner_page', 'events_by_user') + \
165                         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') + \
166                         self.feed_patterns(r'^%s/(?P<tag_slugs>[-\w]+[-+/\w]*)' % self.tag_permalink_base, 'get_events_by_tag', 'tag_page', 'events_by_tag') + \
167                         patterns('',
168                                 url(r'(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\w-]+)$', self.event_detail_view, name="event_detail"),
169                         )
170                         
171                         # Some sort of shortcut for a location would be useful. This could be on a per-calendar
172                         # or per-calendar-view basis.
173                         #url(r'^%s/(?P<slug>[\w-]+)' % self.location_permalink_base, ...)
174                 
175                 if self.tag_archive_page:
176                         urlpatterns += patterns('',
177                                 url(r'^%s$' % self.tag_permalink_base, self.tag_archive_view, name='tag_archive')
178                         )
179                 
180                 if self.owner_archive_page:
181                         urlpatterns += patterns('',
182                                 url(r'^%s$' % self.owner_permalink_base, self.owner_archive_view, name='owner_archive')
183                         )
184                 
185                 if self.location_archive_page:
186                         urlpatterns += patterns('',
187                                 url(r'^%s$' % self.location_permalink_base, self.location_archive_view, name='location_archive')
188                         )
189                 return urlpatterns
190         
191         # Basic QuerySet fetchers.
192         def get_event_queryset(self):
193                 return self.calendar.events.all()
194         
195         def get_timespan_queryset(self, year, month=None, day=None):
196                 qs = self.get_event_queryset()
197                 # See python documentation for the min/max values.
198                 if year and month and day:
199                         year, month, day = int(year), int(month), int(day)
200                         start_datetime = datetime.datetime(year, month, day, 0, 0)
201                         end_datetime = datetime.datetime(year, month, day, 23, 59)
202                 elif year and month:
203                         year, month = int(year), int(month)
204                         start_datetime = datetime.datetime(year, month, 1, 0, 0)
205                         end_datetime = datetime.datetime(year, month, calendar.monthrange(year, month)[1], 23, 59)
206                 else:
207                         year = int(year)
208                         start_datetime = datetime.datetime(year, 1, 1, 0, 0)
209                         end_datetime = datetime.datetime(year, 12, 31, 23, 59)
210                 
211                 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)
212         
213         def get_tag_queryset(self):
214                 return Tag.objects.filter(events__calendars=self.calendar).distinct()
215         
216         def get_location_querysets(self):
217                 # Potential bottleneck?
218                 location_map = {}
219                 locations = Event.objects.values_list('location_content_type', 'location_pk')
220                 
221                 for ct, pk in locations:
222                         location_map.setdefault(ct, []).append(pk)
223                 
224                 location_cts = ContentType.objects.in_bulk(location_map.keys())
225                 location_querysets = {}
226                 
227                 for ct_pk, pks in location_map.items():
228                         ct = location_cts[ct_pk]
229                         location_querysets[ct] = ct.model_class()._default_manager.filter(pk__in=pks)
230                 
231                 return location_querysets
232         
233         def get_owner_queryset(self):
234                 return User.objects.filter(owned_events__calendars=self.calendar).distinct()
235         
236         # Event QuerySet parsers for a request/args/kwargs
237         def get_all_events(self, request, extra_context=None):
238                 return self.get_event_queryset(), extra_context
239         
240         def get_events_by_timespan(self, request, year, month=None, day=None, extra_context=None):
241                 context = extra_context or {}
242                 context.update({
243                         'year': year,
244                         'month': month,
245                         'day': day
246                 })
247                 return self.get_timespan_queryset(year, month, day), context
248         
249         def get_events_by_owner(self, request, username, extra_context=None):
250                 try:
251                         owner = self.get_owner_queryset().get(username=username)
252                 except User.DoesNotExist:
253                         raise Http404
254                 
255                 qs = self.get_event_queryset().filter(owner=owner)
256                 context = extra_context or {}
257                 context.update({
258                         'owner': owner
259                 })
260                 return qs, context
261         
262         def get_events_by_tag(self, request, tag_slugs, extra_context=None):
263                 tag_slugs = tag_slugs.replace('+', '/').split('/')
264                 tags = self.get_tag_queryset().filter(slug__in=tag_slugs)
265                 
266                 if not tags:
267                         raise Http404
268                 
269                 # Raise a 404 on an incorrect slug.
270                 found_slugs = [tag.slug for tag in tags]
271                 for slug in tag_slugs:
272                         if slug and slug not in found_slugs:
273                                 raise Http404
274
275                 events = self.get_event_queryset()
276                 for tag in tags:
277                         events = events.filter(tags=tag)
278                 
279                 context = extra_context or {}
280                 context.update({'tags': tags})
281                 
282                 return events, context
283         
284         def get_events_by_location(self, request, app_label, model, pk, extra_context=None):
285                 try:
286                         ct = ContentType.objects.get(app_label=app_label, model=model)
287                         location = ct.model_class()._default_manager.get(pk=pk)
288                 except ObjectDoesNotExist:
289                         raise Http404
290                 
291                 events = self.get_event_queryset().filter(location_content_type=ct, location_pk=location.pk)
292                 
293                 context = extra_context or {}
294                 context.update({
295                         'location': location
296                 })
297                 return events, context
298         
299         # Detail View.
300         def event_detail_view(self, request, year, month, day, slug, extra_context=None):
301                 try:
302                         event = Event.objects.select_related('parent_event').get(start_date__year=year, start_date__month=month, start_date__day=day, slug=slug)
303                 except Event.DoesNotExist:
304                         raise Http404
305                 
306                 context = self.get_context()
307                 context.update(extra_context or {})
308                 context.update({
309                         'event': event
310                 })
311                 return self.event_detail_page.render_to_response(request, extra_context=context)
312         
313         # Archive Views.
314         def tag_archive_view(self, request, extra_context=None):
315                 tags = self.get_tag_queryset()
316                 context = self.get_context()
317                 context.update(extra_context or {})
318                 context.update({
319                         'tags': tags
320                 })
321                 return self.tag_archive_page.render_to_response(request, extra_context=context)
322         
323         def location_archive_view(self, request, extra_context=None):
324                 # What datastructure should locations be?
325                 locations = self.get_location_querysets()
326                 context = self.get_context()
327                 context.update(extra_context or {})
328                 context.update({
329                         'locations': locations
330                 })
331                 return self.location_archive_page.render_to_response(request, extra_context=context)
332         
333         def owner_archive_view(self, request, extra_context=None):
334                 owners = self.get_owner_queryset()
335                 context = self.get_context()
336                 context.update(extra_context or {})
337                 context.update({
338                         'owners': owners
339                 })
340                 return self.owner_archive_page.render_to_response(request, extra_context=context)
341         
342         # Process page items
343         def process_page_items(self, request, items):
344                 if self.events_per_page:
345                         page_num = request.GET.get('page', 1)
346                         paginator, paginated_page, items = paginate(items, self.events_per_page, page_num)
347                         item_context = {
348                                 'paginator': paginator,
349                                 'paginated_page': paginated_page,
350                                 self.item_context_var: items
351                         }
352                 else:
353                         item_context = {
354                                 self.item_context_var: items
355                         }
356                 return items, item_context
357         
358         # Feed information hooks
359         def title(self, obj):
360                 return obj.name
361         
362         def link(self, obj):
363                 # Link is ignored anyway...
364                 return ""
365         
366         def feed_guid(self, obj):
367                 # Is this correct? Should I have a different id for different subfeeds?
368                 return obj.uuid
369         
370         def description(self, obj):
371                 return obj.description
372         
373         def feed_extra_kwargs(self, obj):
374                 return {'filename': "%s.ics" % obj.slug}
375         
376         def item_title(self, item):
377                 return item.name
378         
379         def item_description(self, item):
380                 return item.description
381         
382         def item_link(self, item):
383                 return self.reverse(item)
384         
385         def item_guid(self, item):
386                 return item.uuid
387         
388         def item_author_name(self, item):
389                 if item.owner:
390                         return item.owner.get_full_name()
391         
392         def item_author_email(self, item):
393                 return getattr(item.owner, 'email', None) or None
394         
395         def item_pubdate(self, item):
396                 return item.created
397         
398         def item_categories(self, item):
399                 return [tag.name for tag in item.tags.all()]
400         
401         def item_extra_kwargs(self, item):
402                 return {
403                         'start': item.get_start(),
404                         'end': item.get_end(),
405                         'last_modified': item.last_modified,
406                         # Is forcing unicode enough, or should we look for a "custom method"?
407                         'location': force_unicode(item.location),
408                 }
409         
410         def __unicode__(self):
411                 return u"%s for %s" % (self.__class__.__name__, self.calendar)
412
413 field = CalendarView._meta.get_field('feed_type')
414 field._choices += ((ICALENDAR, 'iCalendar'),)
415 field.default = ICALENDAR