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