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