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