Merge branch 'gilbert-ext4-murano' of git://github.com/lapilofu/philo into gilbert...
[philo.git] / philo / 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 datetime.datetime.combine(self.start_date, self.start_time) if self.start_time else self.start_date
83         
84         def get_end(self):
85                 return datetime.datetime.combine(self.end_date, self.end_time) if self.end_time else 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         
125         site = models.ForeignKey(Site, default=DEFAULT_SITE)
126         
127         @property
128         def uuid(self):
129                 return "%s@%s" % (self.created.isoformat(), getattr(self.site, 'domain', 'None'))
130         
131         objects = EventManager()
132         
133         def __unicode__(self):
134                 return self.name
135         
136         class Meta:
137                 unique_together = ('site', 'created')
138
139
140 class Calendar(Entity):
141         name = models.CharField(max_length=100)
142         slug = models.SlugField(max_length=100)
143         description = models.TextField(blank=True)
144         events = models.ManyToManyField(Event, related_name='calendars', blank=True)
145         
146         site = models.ForeignKey(Site, default=DEFAULT_SITE)
147         language = models.CharField(max_length=5, choices=settings.LANGUAGES, default=DEFAULT_LANGUAGE)
148         
149         def __unicode__(self):
150                 return self.name
151         
152         @property
153         def fpi(self):
154                 # See http://xml.coverpages.org/tauber-fpi.html or ISO 9070:1991 for format information.
155                 return "-//%s//%s//%s" % (self.site.name, self.name, self.language.split('-')[0].upper())
156         
157         class Meta:
158                 unique_together = ('name', 'site', 'language')
159
160
161 class CalendarView(FeedView):
162         calendar = models.ForeignKey(Calendar)
163         index_page = models.ForeignKey(Page, related_name="calendar_index_related")
164         event_detail_page = models.ForeignKey(Page, related_name="calendar_detail_related")
165         
166         timespan_page = models.ForeignKey(Page, related_name="calendar_timespan_related", blank=True, null=True)
167         tag_page = models.ForeignKey(Page, related_name="calendar_tag_related", blank=True, null=True)
168         location_page = models.ForeignKey(Page, related_name="calendar_location_related", blank=True, null=True)
169         owner_page = models.ForeignKey(Page, related_name="calendar_owner_related", blank=True, null=True)
170         
171         tag_archive_page = models.ForeignKey(Page, related_name="calendar_tag_archive_related", blank=True, null=True)
172         location_archive_page = models.ForeignKey(Page, related_name="calendar_location_archive_related", blank=True, null=True)
173         owner_archive_page = models.ForeignKey(Page, related_name="calendar_owner_archive_related", blank=True, null=True)
174         
175         tag_permalink_base = models.CharField(max_length=30, default='tags')
176         owner_permalink_base = models.CharField(max_length=30, default='owners')
177         location_permalink_base = models.CharField(max_length=30, default='locations')
178         events_per_page = models.PositiveIntegerField(blank=True, null=True)
179         
180         item_context_var = "events"
181         object_attr = "calendar"
182         
183         def get_reverse_params(self, obj):
184                 if isinstance(obj, User):
185                         return 'events_for_user', [], {'username': obj.username}
186                 elif isinstance(obj, Event):
187                         return 'event_detail', [], {
188                                 'year': str(obj.start_date.year).zfill(4),
189                                 'month': str(obj.start_date.month).zfill(2),
190                                 'day': str(obj.start_date.day).zfill(2),
191                                 'slug': obj.slug
192                         }
193                 elif isinstance(obj, Tag) or isinstance(obj, models.query.QuerySet) and obj.model == Tag:
194                         if isinstance(obj, Tag):
195                                 obj = [obj]
196                         return 'entries_by_tag', [], {'tag_slugs': '/'.join(obj)}
197                 raise ViewCanNotProvideSubpath
198         
199         def timespan_patterns(self, pattern, timespan_name):
200                 return self.feed_patterns(pattern, 'get_events_by_timespan', 'timespan_page', "events_by_%s" % timespan_name)
201         
202         @property
203         def urlpatterns(self):
204                 # Perhaps timespans should be done with GET parameters? Or two /-separated
205                 # date slugs? (e.g. 2010-02-1/2010-02-2) or a start and duration?
206                 # (e.g. 2010-02-01/week/ or ?d=2010-02-01&l=week)
207                 urlpatterns = self.feed_patterns(r'^', 'get_all_events', 'index_page', 'index') + \
208                         self.timespan_patterns(r'^(?P<year>\d{4})', 'year') + \
209                         self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})', 'month') + \
210                         self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})', 'day') + \
211                         self.feed_patterns(r'^%s/(?P<username>[^/]+)' % self.owner_permalink_base, 'get_events_by_owner', 'owner_page', 'events_by_user') + \
212                         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') + \
213                         self.feed_patterns(r'^%s/(?P<tag_slugs>[-\w]+[-+/\w]*)' % self.tag_permalink_base, 'get_events_by_tag', 'tag_page', 'events_by_tag') + \
214                         patterns('',
215                                 url(r'(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\w-]+)$', self.event_detail_view, name="event_detail"),
216                         )
217                         
218                         # Some sort of shortcut for a location would be useful. This could be on a per-calendar
219                         # or per-calendar-view basis.
220                         #url(r'^%s/(?P<slug>[\w-]+)' % self.location_permalink_base, ...)
221                 
222                 if self.tag_archive_page:
223                         urlpatterns += patterns('',
224                                 url(r'^%s$' % self.tag_permalink_base, self.tag_archive_view, name='tag_archive')
225                         )
226                 
227                 if self.owner_archive_page:
228                         urlpatterns += patterns('',
229                                 url(r'^%s$' % self.owner_permalink_base, self.owner_archive_view, name='owner_archive')
230                         )
231                 
232                 if self.location_archive_page:
233                         urlpatterns += patterns('',
234                                 url(r'^%s$' % self.location_permalink_base, self.location_archive_view, name='location_archive')
235                         )
236                 return urlpatterns
237         
238         # Basic QuerySet fetchers.
239         def get_event_queryset(self):
240                 return self.calendar.events.all()
241         
242         def get_timespan_queryset(self, year, month=None, day=None):
243                 qs = self.get_event_queryset()
244                 # See python documentation for the min/max values.
245                 if year and month and day:
246                         year, month, day = int(year), int(month), int(day)
247                         start_datetime = datetime.datetime(year, month, day, 0, 0)
248                         end_datetime = datetime.datetime(year, month, day, 23, 59)
249                 elif year and month:
250                         year, month = int(year), int(month)
251                         start_datetime = datetime.datetime(year, month, 1, 0, 0)
252                         end_datetime = datetime.datetime(year, month, calendar.monthrange(year, month)[1], 23, 59)
253                 else:
254                         year = int(year)
255                         start_datetime = datetime.datetime(year, 1, 1, 0, 0)
256                         end_datetime = datetime.datetime(year, 12, 31, 23, 59)
257                 
258                 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)
259         
260         def get_tag_queryset(self):
261                 return Tag.objects.filter(events__calendars=self.calendar).distinct()
262         
263         def get_location_querysets(self):
264                 # Potential bottleneck?
265                 location_map = {}
266                 locations = Event.objects.values_list('location_content_type', 'location_pk')
267                 
268                 for ct, pk in locations:
269                         location_map.setdefault(ct, []).append(pk)
270                 
271                 location_cts = ContentType.objects.in_bulk(location_map.keys())
272                 location_querysets = {}
273                 
274                 for ct_pk, pks in location_map.items():
275                         ct = location_cts[ct_pk]
276                         location_querysets[ct] = ct.model_class()._default_manager.filter(pk__in=pks)
277                 
278                 return location_querysets
279         
280         def get_owner_queryset(self):
281                 return User.objects.filter(owned_events__calendars=self.calendar).distinct()
282         
283         # Event QuerySet parsers for a request/args/kwargs
284         def get_all_events(self, request, extra_context=None):
285                 return self.get_event_queryset(), extra_context
286         
287         def get_events_by_timespan(self, request, year, month=None, day=None, extra_context=None):
288                 context = extra_context or {}
289                 context.update({
290                         'year': year,
291                         'month': month,
292                         'day': day
293                 })
294                 return self.get_timespan_queryset(year, month, day), context
295         
296         def get_events_by_owner(self, request, username, extra_context=None):
297                 try:
298                         owner = self.get_owner_queryset().get(username=username)
299                 except User.DoesNotExist:
300                         raise Http404
301                 
302                 qs = self.get_event_queryset().filter(owner=owner)
303                 context = extra_context or {}
304                 context.update({
305                         'owner': owner
306                 })
307                 return qs, context
308         
309         def get_events_by_tag(self, request, tag_slugs, extra_context=None):
310                 tag_slugs = tag_slugs.replace('+', '/').split('/')
311                 tags = self.get_tag_queryset().filter(slug__in=tag_slugs)
312                 
313                 if not tags:
314                         raise Http404
315                 
316                 # Raise a 404 on an incorrect slug.
317                 found_slugs = [tag.slug for tag in tags]
318                 for slug in tag_slugs:
319                         if slug and slug not in found_slugs:
320                                 raise Http404
321
322                 events = self.get_event_queryset()
323                 for tag in tags:
324                         events = events.filter(tags=tag)
325                 
326                 context = extra_context or {}
327                 context.update({'tags': tags})
328                 
329                 return events, context
330         
331         def get_events_by_location(self, request, app_label, model, pk, extra_context=None):
332                 try:
333                         ct = ContentType.objects.get(app_label=app_label, model=model)
334                         location = ct.model_class()._default_manager.get(pk=pk)
335                 except ObjectDoesNotExist:
336                         raise Http404
337                 
338                 events = self.get_event_queryset().filter(location_content_type=ct, location_pk=location.pk)
339                 
340                 context = extra_context or {}
341                 context.update({
342                         'location': location
343                 })
344                 return events, context
345         
346         # Detail View.
347         def event_detail_view(self, request, year, month, day, slug, extra_context=None):
348                 try:
349                         event = Event.objects.select_related('parent_event').get(start_date__year=year, start_date__month=month, start_date__day=day, slug=slug)
350                 except Event.DoesNotExist:
351                         raise Http404
352                 
353                 context = self.get_context()
354                 context.update(extra_context or {})
355                 context.update({
356                         'event': event
357                 })
358                 return self.event_detail_page.render_to_response(request, extra_context=context)
359         
360         # Archive Views.
361         def tag_archive_view(self, request, extra_context=None):
362                 tags = self.get_tag_queryset()
363                 context = self.get_context()
364                 context.update(extra_context or {})
365                 context.update({
366                         'tags': tags
367                 })
368                 return self.tag_archive_page.render_to_response(request, extra_context=context)
369         
370         def location_archive_view(self, request, extra_context=None):
371                 # What datastructure should locations be?
372                 locations = self.get_location_querysets()
373                 context = self.get_context()
374                 context.update(extra_context or {})
375                 context.update({
376                         'locations': locations
377                 })
378                 return self.location_archive_page.render_to_response(request, extra_context=context)
379         
380         def owner_archive_view(self, request, extra_context=None):
381                 owners = self.get_owner_queryset()
382                 context = self.get_context()
383                 context.update(extra_context or {})
384                 context.update({
385                         'owners': owners
386                 })
387                 return self.owner_archive_page.render_to_response(request, extra_context=context)
388         
389         # Process page items
390         def process_page_items(self, request, items):
391                 if self.events_per_page:
392                         page_num = request.GET.get('page', 1)
393                         paginator, paginated_page, items = paginate(items, self.events_per_page, page_num)
394                         item_context = {
395                                 'paginator': paginator,
396                                 'paginated_page': paginated_page,
397                                 self.item_context_var: items
398                         }
399                 else:
400                         item_context = {
401                                 self.item_context_var: items
402                         }
403                 return items, item_context
404         
405         # Feed information hooks
406         def title(self, obj):
407                 return obj.name
408         
409         def link(self, obj):
410                 # Link is ignored anyway...
411                 return ""
412         
413         def feed_guid(self, obj):
414                 return obj.fpi
415         
416         def description(self, obj):
417                 return obj.description
418         
419         def feed_extra_kwargs(self, obj):
420                 return {'filename': "%s.ics" % obj.slug}
421         
422         def item_title(self, item):
423                 return item.name
424         
425         def item_description(self, item):
426                 return item.description
427         
428         def item_link(self, item):
429                 return self.reverse(item)
430         
431         def item_guid(self, item):
432                 return item.uuid
433         
434         def item_author_name(self, item):
435                 if item.owner:
436                         return item.owner.get_full_name()
437         
438         def item_author_email(self, item):
439                 return getattr(item.owner, 'email', None) or None
440         
441         def item_pubdate(self, item):
442                 return item.created
443         
444         def item_categories(self, item):
445                 return [tag.name for tag in item.tags.all()]
446         
447         def item_extra_kwargs(self, item):
448                 return {
449                         'start': item.get_start(),
450                         'end': item.get_end(),
451                         'last_modified': item.last_modified,
452                         # Is forcing unicode enough, or should we look for a "custom method"?
453                         'location': force_unicode(item.location),
454                 }
455         
456         def __unicode__(self):
457                 return u"%s for %s" % (self.__class__.__name__, self.calendar)
458
459 field = CalendarView._meta.get_field('feed_type')
460 field._choices += ((ICALENDAR, 'iCalendar'),)
461 field.default = ICALENDAR