2
# SchoolTool - common information systems platform for school administration
3
# Copyright (c) 2005 Shuttleworth Foundation
5
# This program is free software; you can redistribute it and/or modify
6
# it under the terms of the GNU General Public License as published by
7
# the Free Software Foundation; either version 2 of the License, or
8
# (at your option) any later version.
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
# GNU General Public License for more details.
15
# You should have received a copy of the GNU General Public License
16
# along with this program; if not, write to the Free Software
17
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
20
SchoolTool application views.
28
from datetime import datetime, date, time, timedelta
31
from pytz import timezone, utc
32
from zope.component import queryMultiAdapter, adapts, getMultiAdapter
33
from zope.component import subscribers
34
from zope.event import notify
35
from zope.interface import implements, Interface
36
from zope.i18n import translate
37
from zope.publisher.interfaces.browser import IBrowserPublisher
38
from zope.publisher.interfaces import NotFound
39
from zope.security.interfaces import ForbiddenAttribute, Unauthorized
40
from zope.security.proxy import removeSecurityProxy
41
from zope.security.checker import canAccess, canWrite
42
from zope.schema import Date, TextLine, Choice, Int, Bool, List, Text
43
from zope.schema.interfaces import RequiredMissing, ConstraintNotSatisfied
44
from zope.lifecycleevent import ObjectModifiedEvent
45
from zope.app.form.browser.add import AddView
46
from zope.app.form.browser.editview import EditView
47
from zope.app.form.utility import setUpWidgets
48
from zope.app.form.interfaces import ConversionError
49
from zope.app.form.interfaces import IWidgetInputError, IInputWidget
50
from zope.app.form.interfaces import WidgetInputError, WidgetsError
51
from zope.app.form.utility import getWidgetsData
52
from zope.publisher.browser import BrowserView
53
from zope.traversing.browser.absoluteurl import absoluteURL
54
from zope.traversing.api import getParent
55
from zope.filerepresentation.interfaces import IWriteFile, IReadFile
56
from zope.session.interfaces import ISession
57
from zope.app.pagetemplate.viewpagetemplatefile import ViewPageTemplateFile
58
from zope.html.field import HtmlFragment
59
from zope.component import queryAdapter
60
from zope.viewlet.interfaces import IViewletManager
62
from zc.table.column import GetterColumn
63
from zc.table import table
65
from schooltool.common import SchoolToolMessage as _
67
from schooltool.skin.interfaces import IBreadcrumbInfo
68
from schooltool.skin import breadcrumbs
69
from schooltool.table.table import CheckboxColumn
70
from schooltool.table.interfaces import IFilterWidget
71
from schooltool.app.cal import CalendarEvent
72
from schooltool.app.browser import ViewPreferences, same
73
from schooltool.app.browser import pdfcal
74
from schooltool.app.browser.interfaces import ICalendarProvider
75
from schooltool.app.browser.interfaces import IEventForDisplay
76
from schooltool.app.browser.interfaces import IHaveEventLegend
77
from schooltool.app.interfaces import ISchoolToolCalendarEvent
78
from schooltool.app.app import getSchoolToolApplication
79
from schooltool.app.interfaces import ISchoolToolCalendar
80
from schooltool.app.interfaces import IHaveCalendar
81
from schooltool.table.batch import IterableBatch
82
from schooltool.table.table import label_cell_formatter_factory
83
from schooltool.calendar.interfaces import ICalendar
84
from schooltool.calendar.interfaces import IEditCalendar
85
from schooltool.calendar.recurrent import DailyRecurrenceRule
86
from schooltool.calendar.recurrent import YearlyRecurrenceRule
87
from schooltool.calendar.recurrent import MonthlyRecurrenceRule
88
from schooltool.calendar.recurrent import WeeklyRecurrenceRule
89
from schooltool.calendar.interfaces import IDailyRecurrenceRule
90
from schooltool.calendar.interfaces import IYearlyRecurrenceRule
91
from schooltool.calendar.interfaces import IMonthlyRecurrenceRule
92
from schooltool.calendar.interfaces import IWeeklyRecurrenceRule
93
from schooltool.calendar.utils import parse_date, parse_datetimetz
94
from schooltool.calendar.utils import parse_time, weeknum_bounds
95
from schooltool.calendar.utils import week_start, prev_month, next_month
96
from schooltool.person.interfaces import IPerson, IPersonPreferences
97
from schooltool.person.interfaces import vocabulary
98
from schooltool.term.term import getTermForDate
99
from schooltool.app.interfaces import ISchoolToolApplication
100
from schooltool.securitypolicy.crowds import Crowd
101
from schooltool.securitypolicy.interfaces import ICrowd
102
from schooltool.app.browser.interfaces import ICalendarMenuViewlet
103
from schooltool.resource.interfaces import IBaseResource
104
from schooltool.resource.interfaces import IBookingCalendar
105
from schooltool.resource.interfaces import IResourceTypeInformation
112
1: _("January"), 2: _("February"), 3: _("March"),
113
4: _("April"), 5: _("May"), 6: _("June"),
114
7: _("July"), 8: _("August"), 9: _("September"),
115
10: _("October"), 11: _("November"), 12: _("December")}
117
day_of_week_names = {
118
0: _("Monday"), 1: _("Tuesday"), 2: _("Wednesday"), 3: _("Thursday"),
119
4: _("Friday"), 5: _("Saturday"), 6: _("Sunday")}
121
short_day_of_week_names = {
122
0: _("Mon"), 1: _("Tue"), 2: _("Wed"), 3: _("Thu"),
123
4: _("Fri"), 5: _("Sat"), 6: _("Sun"),
131
class ToCalendarTraverser(object):
132
"""A traverser that allows to traverse to a calendar owner's calendar."""
134
adapts(IHaveCalendar)
135
implements(IBrowserPublisher)
137
def __init__(self, context, request):
138
self.context = context
139
self.request = request
141
def publishTraverse(self, request, name):
142
if name == 'calendar':
143
return ISchoolToolCalendar(self.context)
144
elif name in ('calendar.ics', 'calendar.vfb'):
145
calendar = ISchoolToolCalendar(self.context)
146
view = queryMultiAdapter((calendar, request), name=name)
150
raise NotFound(self.context, name, request)
152
def browserDefault(self, request):
153
return self.context, ('index.html', )
156
class CalendarTraverser(object):
157
"""A smart calendar traverser that can handle dates in the URL."""
160
implements(IBrowserPublisher)
162
queryMultiAdapter = staticmethod(queryMultiAdapter)
164
def __init__(self, context, request):
165
self.context = context
166
self.request = request
168
def browserDefault(self, request):
169
return self.context, ('daily.html', )
171
def publishTraverse(self, request, name):
172
view_name = self.getHTMLViewByDate(request, name)
174
view_name = self.getPDFViewByDate(request, name)
176
return self.queryMultiAdapter((self.context, request),
179
view = queryMultiAdapter((self.context, request), name=name)
184
event_id = base64.decodestring(name).decode("utf-8")
186
raise NotFound(self.context, name, request)
189
return self.context.find(event_id)
191
raise NotFound(self.context, event_id, request)
193
def getHTMLViewByDate(self, request, name):
194
"""Get HTML view name from URL component."""
195
return self.getViewByDate(request, name, 'html')
197
def getPDFViewByDate(self, request, name):
198
"""Get PDF view name from URL component."""
199
if not name.endswith('.pdf'):
201
name = name[:-4] # strip off the .pdf
202
view_name = self.getViewByDate(request, name, 'pdf')
203
if view_name == 'yearly.pdf':
204
return None # the yearly PDF view is not available
208
def getViewByDate(self, request, name, suffix):
209
"""Get view name from URL component."""
210
parts = name.split('-')
212
if len(parts) == 2 and parts[1].startswith('w'): # a week was given
215
week = int(parts[1][1:])
218
request.form['date'] = self.getWeek(year, week).isoformat()
219
return 'weekly.%s' % suffix
221
# a year, month or day might have been given
223
parts = [int(part) for part in parts]
230
if not (1900 < parts[0] < 2100):
234
request.form['date'] = "%d-01-01" % parts
235
return 'yearly.%s' % suffix
236
elif len(parts) == 2:
237
request.form['date'] = "%d-%02d-01" % parts
238
return 'monthly.%s' % suffix
239
elif len(parts) == 3:
240
request.form['date'] = "%d-%02d-%02d" % parts
241
return 'daily.%s' % suffix
243
def getWeek(self, year, week):
244
"""Get the start of a week by week number.
246
The Monday of the given week is returned as a datetime.date.
248
>>> traverser = CalendarTraverser(None, None)
249
>>> traverser.getWeek(2002, 11)
250
datetime.date(2002, 3, 11)
251
>>> traverser.getWeek(2005, 1)
252
datetime.date(2005, 1, 3)
253
>>> traverser.getWeek(2005, 52)
254
datetime.date(2005, 12, 26)
257
return weeknum_bounds(year, week)[0]
261
# Calendar displaying backend
264
class EventForDisplay(object):
265
"""A decorated calendar event."""
267
implements(IEventForDisplay)
269
cssClass = 'event' # at the moment no other classes are used
271
def __init__(self, event, request, color1, color2, source_calendar,
273
self.request = request
274
self.source_calendar = source_calendar
275
if canAccess(source_calendar, '__iter__'):
276
# Due to limitations in the default Zope 3 security
277
# policy, a calendar event inherits permissions from the
278
# calendar of its __parent__. However if there's an event
279
# that books a resource, and the authenticated user has
280
# schooltool.view access for the resource's calendar, she
281
# should be able to view this event when it comes from the
282
# resource's calendar. For this reason we have to remove
283
# the security proxy and check the permission manually.
284
event = removeSecurityProxy(event)
286
self.dtend = event.dtstart + event.duration
289
self.shortTitle = self.title
290
if len(self.title) > 16:
291
self.shortTitle = self.title[:15] + '...'
292
self.dtstarttz = event.dtstart.astimezone(timezone)
293
self.dtendtz = self.dtend.astimezone(timezone)
295
def __cmp__(self, other):
296
return cmp(self.context.dtstart, other.context.dtstart)
298
def __getattr__(self, name):
299
return getattr(self.context, name)
303
return self.context.title
306
"""Return the booker."""
307
event = ISchoolToolCalendarEvent(self.context, None)
311
def getBookedResources(self):
312
"""Return the list of booked resources."""
313
booker = ISchoolToolCalendarEvent(self.context, None)
315
return booker.resources
320
"""Return the URL where you can view this event.
322
Returns None if the event is not viewable (e.g. it is a timetable
325
if self.context.__parent__ is None:
328
if IEditCalendar.providedBy(self.source_calendar):
329
# display the link of the source calendar (the event is a
331
return '%s/%s' % (absoluteURL(self.source_calendar, self.request),
332
urllib.quote(self.__name__))
334
# if event is comming from an immutable (readonly) calendar,
335
# display the absolute url of the event itself
336
return absoluteURL(self, self.request)
340
"""Return the URL where you can edit this event.
342
Returns None if the event is not editable (e.g. it is a timetable
345
if self.context.__parent__ is None:
347
return '%s/edit.html?date=%s' % (
348
absoluteURL(self.context, self.request),
349
self.dtstarttz.strftime('%Y-%m-%d'))
351
def deleteLink(self):
352
"""Return the URL where you can delete this event.
354
Returns None if the event is not deletable (e.g. it is a timetable
357
if self.context.__parent__ is None:
359
return '%s/delete.html?event_id=%s&date=%s' % (
360
absoluteURL(self.source_calendar, self.request),
362
self.dtstarttz.strftime('%Y-%m-%d'))
364
def linkAllowed(self):
365
"""Return the URL where you can view/edit this event.
367
Returns the URL where can you edit this event if the user can
368
edit it, otherwise returns the URL where you can view this event.
372
if self.context.__parent__ is not None and \
373
(canWrite(self.context, 'title') or \
374
hasattr(self.context, 'original') and \
375
canWrite(self.context.original, 'title')):
376
return self.editLink()
378
return self.viewLink()
379
except ForbiddenAttribute:
380
# this exception is raised when the event does not allow
381
# us to even check if the title is editable
382
return self.viewLink()
384
def bookingLink(self):
385
"""Return the URL where you can book resources for this event.
387
Returns None if you can't do that.
389
if self.context.__parent__ is None:
391
return '%s/booking.html?date=%s' % (
392
absoluteURL(self.context, self.request),
393
self.dtstarttz.strftime('%Y-%m-%d'))
395
def renderShort(self):
396
"""Short representation of the event for the monthly view."""
397
if self.dtstarttz.date() == self.dtendtz.date():
401
return "%s (%s–%s)" % (self.shortTitle,
402
self.dtstarttz.strftime(fmt),
403
self.dtendtz.strftime(fmt))
406
class CalendarDay(object):
407
"""A single day in a calendar.
410
'date' -- date of the day (a datetime.date instance)
411
'title' -- day title, including weekday and date.
412
'events' -- list of events that took place that day, sorted by start
413
time (in ascending order).
416
def __init__(self, date, events=None):
421
day_of_week = day_of_week_names[date.weekday()]
423
def __cmp__(self, other):
424
return cmp(self.date, other.date)
427
"""Return 'today' if self.date is today, otherwise return ''."""
428
# XXX shouldn't use date.today; it depends on the server's timezone
429
# which may not match user expectations
430
return self.date == date.today() and 'today' or ''
434
# Calendar display views
437
class CalendarViewBase(BrowserView):
438
"""A base class for the calendar views.
440
This class provides functionality that is useful to several calendar views.
443
__used_for__ = ISchoolToolCalendar
445
# Which day is considered to be the first day of the week (0 = Monday,
446
# 6 = Sunday). Based on authenticated user preference, defaults to Monday
448
def __init__(self, context, request):
449
self.context = context
450
self.request = request
452
# XXX Clean this up (use self.preferences in this and subclasses)
453
prefs = ViewPreferences(request)
454
self.first_day_of_week = prefs.first_day_of_week
455
self.time_fmt = prefs.timeformat
456
self.dateformat = prefs.dateformat
457
self.timezone = prefs.timezone
459
self._days_cache = None
461
def eventAddLink(self, hour):
462
item = self.context.__parent__
463
if IBaseResource.providedBy(item):
464
rc = ISchoolToolApplication(None)['resources']
465
booking_calendar = IBookingCalendar(rc)
466
url = absoluteURL(booking_calendar, self.request)
467
url = "%s/book_one_resource.html?resource_id=%s" % (url, item.__name__)
468
url = "%s&start_date=%s&start_time=%s:00&title=%s&duration=%s" % (
469
url, self.cursor, hour['time'],
470
_("Unnamed Event"), hour['duration']*60)
472
url = "%s/add.html?field.start_date=%s&field.start_time=%s&field.duration=%s"
473
url = url % (absoluteURL(self.context, self.request),
474
self.cursor, hour['time'], hour['duration'])
481
assert self.cal_type != 'yearly'
482
url = self.calURL(self.cal_type, cursor=self.cursor)
485
def dayTitle(self, day):
486
formatter = getMultiAdapter((day, self.request), name='fullDate')
491
def calURL(self, cal_type, cursor=None):
492
"""Construct a URL to a calendar at cursor."""
494
session = ISession(self.request)['calendar']
495
dt = session.get('last_visited_day')
496
if dt and self.inCurrentPeriod(dt):
501
if self.__url is None:
502
self.__url = absoluteURL(self.context, self.request)
504
if cal_type == 'daily':
505
dt = cursor.isoformat()
506
elif cal_type == 'weekly':
507
dt = '%04d-w%02d' % cursor.isocalendar()[:2]
508
elif cal_type == 'monthly':
509
dt = cursor.strftime('%Y-%m')
510
elif cal_type == 'yearly':
511
dt = str(cursor.year)
513
raise ValueError(cal_type)
515
return '%s/%s' % (self.__url, dt)
517
def _initDaysCache(self):
518
"""Initialize the _days_cache attribute.
520
When ``update`` figures out which time period will be displayed to the
521
user, it calls ``_initDaysCache`` to give the view a chance to
522
precompute the calendar events for the time interval.
524
The base implementation designates three months around self.cursor as
525
the time interval for caching.
527
# The calendar portlet will always want three months around self.cursor
528
start_of_prev_month = prev_month(self.cursor)
529
first = week_start(start_of_prev_month, self.first_day_of_week)
530
end_of_next_month = next_month(next_month(self.cursor)) - timedelta(1)
531
last = week_start(end_of_next_month,
532
self.first_day_of_week) + timedelta(7)
533
self._days_cache = DaysCache(self._getDays, first, last)
536
"""Figure out which date we're supposed to be showing.
538
Can extract date from the request or the session. Defaults on today.
540
session = ISession(self.request)['calendar']
541
dt = session.get('last_visited_day')
543
if 'date' not in self.request:
544
# XXX shouldn't use date.today; it depends on the server's timezone
545
# which may not match user expectations
546
self.cursor = dt or date.today()
548
# TODO: It would be nice not to b0rk when the date is invalid but
549
# fall back to the current date, as if the date had not been
551
self.cursor = parse_date(self.request['date'])
553
if not (dt and self.inCurrentPeriod(dt)):
554
session['last_visited_day'] = self.cursor
556
self._initDaysCache()
558
def inCurrentPeriod(self, dt):
559
"""Return True if dt is in the period currently being shown."""
560
raise NotImplementedError("override in subclasses")
562
def pigeonhole(self, intervals, days):
563
"""Sort CalendarDay objects into date intervals.
565
Can be used to sort a list of CalendarDay objects into weeks,
566
months, quarters etc.
568
`intervals` is a list of date pairs that define half-open time
569
intervals (the start date is inclusive, and the end date is
570
exclusive). Intervals can overlap.
572
Returns a list of CalendarDay object lists -- one list for
576
for start, end in intervals:
577
results.append([day for day in days if start <= day.date < end])
580
def getWeek(self, dt):
581
"""Return the week that contains the day dt.
583
Returns a list of CalendarDay objects.
585
start = week_start(dt, self.first_day_of_week)
586
end = start + timedelta(7)
587
return self.getDays(start, end)
589
def getMonth(self, dt, days=None):
590
"""Return a nested list of days in the month that contains dt.
592
Returns a list of lists of date objects. Days in neighbouring
593
months are included if they fall into a week that contains days in
596
start_of_next_month = next_month(dt)
597
start_of_week = week_start(dt.replace(day=1), self.first_day_of_week)
598
start_of_display_month = start_of_week
601
while start_of_week < start_of_next_month:
602
start_of_next_week = start_of_week + timedelta(7)
603
week_intervals.append((start_of_week, start_of_next_week))
604
start_of_week = start_of_next_week
606
end_of_display_month = start_of_week
608
days = self.getDays(start_of_display_month, end_of_display_month)
609
# Make sure the cache contains all the days we're interested in
610
assert days[0].date <= start_of_display_month, 'not enough days'
611
assert days[-1].date >= end_of_display_month - timedelta(1), 'not enough days'
612
weeks = self.pigeonhole(week_intervals, days)
615
def getYear(self, dt):
616
"""Return the current year.
618
This returns a list of quarters, each quarter is a list of months,
619
each month is a list of weeks, and each week is a list of CalendarDays.
621
first_day_of_year = date(dt.year, 1, 1)
622
year_start_day_padded_weeks = week_start(first_day_of_year,
623
self.first_day_of_week)
624
last_day_of_year = date(dt.year, 12, 31)
625
year_end_day_padded_weeks = week_start(last_day_of_year,
626
self.first_day_of_week) + timedelta(7)
628
day_cache = self.getDays(year_start_day_padded_weeks,
629
year_end_day_padded_weeks)
633
quarter = [self.getMonth(date(dt.year, month + (q * 3), 1),
635
for month in range(1, 4)]
636
quarters.append(quarter)
639
_day_events = None # cache
641
def dayEvents(self, date):
642
"""Return events for a day sorted by start time.
644
Events spanning several days and overlapping with this day
647
if self._day_events is None:
648
self._day_events = {}
650
if date in self._day_events:
651
day = self._day_events[date]
653
day = self.getDays(date, date + timedelta(1))[0]
654
self._day_events[date] = day
657
_calendars = None # cache
659
def getCalendars(self):
660
providers = subscribers((self.context, self.request), ICalendarProvider)
662
if self._calendars is None:
664
for provider in providers:
665
result += provider.getCalendars()
666
self._calendars = result
667
return self._calendars
669
def getEvents(self, start_dt, end_dt):
670
"""Get a list of EventForDisplay objects for a selected time interval.
672
`start_dt` and `end_dt` (datetime objects) are bounds (half-open) for
675
for calendar, color1, color2 in self.getCalendars():
676
for event in calendar.expand(start_dt, end_dt):
677
if (same(event.__parent__, self.context) and
678
calendar is not self.context):
679
# Skip resource booking events (coming from
680
# overlaid calendars) if they were booked by the
681
# person whose calendar we are viewing.
682
# removeSecurityProxy(event.__parent__) and
683
# removeSecurityProxy(self.context) are needed so we
684
# could compare them.
686
yield EventForDisplay(event, self.request, color1, color2,
687
calendar, self.timezone)
689
def getDays(self, start, end):
690
"""Get a list of CalendarDay objects for a selected period of time.
692
Uses the _days_cache.
694
`start` and `end` (date objects) are bounds (half-open) for the result.
696
Events spanning more than one day get included in all days they
699
if self._days_cache is None:
700
return self._getDays(start, end)
702
return self._days_cache.getDays(start, end)
704
def _getDays(self, start, end):
705
"""Get a list of CalendarDay objects for a selected period of time.
709
`start` and `end` (date objects) are bounds (half-open) for the result.
711
Events spanning more than one day get included in all days they
720
# We have date objects, but ICalendar.expand needs datetime objects
721
start_dt = self.timezone.localize(datetime.combine(start, time()))
722
end_dt = self.timezone.localize(datetime.combine(end, time()))
723
for event in self.getEvents(start_dt, end_dt):
724
# day1 day2 day3 day4 day5
725
# |.....|.....|.....|.....|.....|
726
# | | [-- event --) | |
728
# | | `dtstart | `dtend |
732
# dtstart and dtend are datetime.datetime instances and point to
733
# time instants. first_day and last_day are datetime.date
734
# instances and point to whole days. Also note that [dtstart,
735
# dtend) is a half-open interval, therefore
736
# last_day == dtend.date() - 1 day when dtend.time() is 00:00
738
# dtend.date() otherwise
741
first_day = event.dtstart.date()
742
last_day = max(first_day, (dtend - dtend.resolution).date())
744
first_day = event.dtstart.astimezone(self.timezone).date()
745
last_day = max(first_day, (dtend.astimezone(self.timezone) -
746
dtend.resolution).date())
747
# Loop through the intersection of two day ranges:
748
# [start, end) intersect [first_day, last_day]
749
# Note that the first interval is half-open, but the second one is
750
# closed. Since we're dealing with whole days,
751
# [first_day, last_day] == [first_day, last_day + 1 day)
752
day = max(start, first_day)
753
limit = min(end, last_day + timedelta(1))
755
events[day].append(event)
762
days.append(CalendarDay(day, events[day]))
767
"""Return the first day of the previous month."""
768
return prev_month(self.cursor)
771
"""Return the first day of the next month."""
772
return next_month(self.cursor)
775
return self.cursor - timedelta(1)
778
return self.cursor + timedelta(1)
780
def getJumpToYears(self):
781
"""Return jump targets for five years centered on the current year."""
782
this_year = datetime.today().year
783
return [{'selected': year == this_year,
785
'href': self.calURL('yearly', date(year, 1, 1))}
786
for year in range(this_year - 2, this_year + 3)]
788
def getJumpToMonths(self):
789
"""Return a list of months for the drop down in the jump portlet."""
790
year = self.cursor.year
792
'href': self.calURL('monthly', date(year, k, 1))}
793
for k, v in month_names.items()]
795
def monthTitle(self, date):
796
return month_names[date.month]
798
def renderRow(self, week, month):
799
"""Do some HTML rendering in Python for performance.
801
This gains us 0.4 seconds out of 0.6 on my machine.
802
Here is the original piece of ZPT:
804
<td class="cal_yearly_day" tal:repeat="day week">
805
<a tal:condition="python:day.date.month == month[1][0].date.month"
806
tal:content="day/date/day"
807
tal:attributes="href python:view.calURL('daily', day.date);
808
class python:(len(day.events) > 0
809
and 'cal_yearly_day_busy'
811
+ (day.today() and ' today' or '')"/>
817
result.append('<td class="cal_yearly_day">')
818
if day.date.month == month:
820
cssClass = 'cal_yearly_day_busy'
822
cssClass = 'cal_yearly_day'
825
# Let us hope that URLs will not contain < > & or "
826
# This is somewhat related to
827
# http://issues.schooltool.org/issue96
828
result.append('<a href="%s" class="%s">%s</a>' %
829
(self.calURL('daily', day.date), cssClass,
831
result.append('</td>')
832
return "\n".join(result)
834
def canAddEvents(self):
835
"""Return True if current viewer can add events to this calendar."""
836
return canAccess(self.context, "addEvent")
838
def canRemoveEvents(self):
839
"""Return True if current viewer can remove events to this calendar."""
840
return canAccess(self.context, "removeEvent")
843
class DaysCache(object):
844
"""A cache of calendar days.
846
Since the expansion of recurrent calendar events, and the pigeonholing of
847
calendar events into days is an expensive task, it is better to compute
848
the calendar days of a single larger period of time, and then refer
849
to subsets of the result.
851
DaysCache provides an object that is able to do so. The goal here is that
852
any view will need perform the expensive computation only once or twice.
855
def __init__(self, expensive_getDays, cache_first, cache_last):
858
``expensive_getDays`` is a function that takes a half-open date range
859
and returns a list of CalendarDay objects.
861
``cache_first`` and ``cache_last`` provide the initial approximation
862
of the date range that will be needed in the future. You may later
863
extend the cache interval by calling ``extend``.
865
self.expensive_getDays = expensive_getDays
866
self.cache_first = cache_first
867
self.cache_last = cache_last
870
def extend(self, first, last):
873
You should call ``extend`` before any calls to ``getDays``, and not
876
self.cache_first = min(self.cache_first, first)
877
self.cache_last = max(self.cache_last, last)
879
def getDays(self, first, last):
880
"""Return a list of calendar days from ``first`` to ``last``.
882
If the interval from ``first`` to ``last`` falls into the cached
883
range, and the cache is already computed, this operation becomes
886
If the interval is not in cache, delegates to the expensive_getDays
889
assert first <= last, 'invalid date range: %s..%s' % (first, last)
890
if first >= self.cache_first and last <= self.cache_last:
891
if self._cache is None:
892
self._cache = self.expensive_getDays(self.cache_first,
894
first_idx = (first - self.cache_first).days
895
last_idx = (last - self.cache_first).days
896
return self._cache[first_idx:last_idx]
898
return self.expensive_getDays(first, last)
901
class WeeklyCalendarView(CalendarViewBase):
902
"""A view that shows one week of the calendar."""
903
implements(IHaveEventLegend)
905
__used_for__ = ISchoolToolCalendar
909
next_title = _("Next week")
910
current_title = _("Current week")
911
prev_title = _("Previous week")
913
go_to_next_title = _("Go to next week")
914
go_to_current_title = _("Go to current week")
915
go_to_prev_title = _("Go to previous week")
917
non_timetable_template = ViewPageTemplateFile("templates/cal_weekly.pt")
918
timetable_template = ViewPageTemplateFile("templates/cal_weekly_timetable.pt")
921
app = getSchoolToolApplication()
922
if app['ttschemas'].default_id is not None:
923
return self.timetable_template()
924
return self.non_timetable_template()
926
def inCurrentPeriod(self, dt):
927
# XXX wrong if week starts on Sunday.
928
return dt.isocalendar()[:2] == self.cursor.isocalendar()[:2]
931
month_name_msgid = month_names[self.cursor.month]
932
month_name = translate(month_name_msgid, context=self.request)
933
msg = _('${month}, ${year} (week ${week})',
934
mapping = {'month': month_name,
935
'year': self.cursor.year,
936
'week': self.cursor.isocalendar()[1]})
940
"""Return the link for the previous week."""
941
return self.calURL('weekly', self.cursor - timedelta(weeks=1))
944
"""Return the link for the current week."""
945
# XXX shouldn't use date.today; it depends on the server's timezone
946
# which may not match user expectations
947
return self.calURL('weekly', date.today())
950
"""Return the link for the next week."""
951
return self.calURL('weekly', self.cursor + timedelta(weeks=1))
953
def getCurrentWeek(self):
954
"""Return the current week as a list of CalendarDay objects."""
955
return self.getWeek(self.cursor)
957
def cloneEvent(self, event):
958
"""Returns a copy of an event so that it can be inserted into a list."""
959
new_event = EventForDisplay(CalendarEvent(event.dtstart,
962
self.request, event.color1,
963
event.color2, event.source_calendar,
964
event.dtendtz.tzinfo)
965
new_event.linkAllowed = event.linkAllowed
966
new_event.allday = event.allday
969
def getCurrentWeekEvents(self, eventCheck):
970
week = self.getWeek(self.cursor)
975
for event in day.events:
976
if (eventCheck(event, day) and not
977
(event.dtstart.hour, event.dtstart.minute) in start_times):
978
start_times.append((event.dtstart.hour,
979
event.dtstart.minute))
980
week_by_rows.append([])
985
for index in range(0, len(start_times)):
987
for event in week[day.date.weekday()].events:
988
if (eventCheck(event, day) and
989
(event.dtstart.hour, event.dtstart.minute) ==
990
start_times[index] and
991
event.dtstart.day == day.date.day):
992
block.append(self.cloneEvent(event))
997
events_in_day.append(block)
1000
for event in events_in_day:
1001
week_by_rows[row_num].append(event)
1004
self.formatCurrentWeekEvents(week_by_rows)
1007
def formatCurrentWeekEvents(self, week_by_rows):
1008
"""Formats a list of rows of events by deleting blank rows and extending
1009
rows to fill the entire week."""
1011
while row_num < len(week_by_rows):
1012
non_empty_row = False
1013
while (len(week_by_rows[row_num]) > 0
1014
and len(week_by_rows[row_num]) < len(day_of_week_names)):
1015
week_by_rows[row_num].append([])
1017
for block in week_by_rows[row_num]:
1019
if event is not None:
1020
non_empty_row = True
1026
del week_by_rows[row_num]
1028
def getCurrentWeekNonTimetableEvents(self):
1029
"""Return the current week's events in formatted lists."""
1030
eventCheck = lambda e, day: e is not None
1031
return self.getCurrentWeekEvents(eventCheck)
1033
def getCurrentWeekTimetableEvents(self):
1034
"""Return the current week's timetable events in formatted lists."""
1035
week = self.getWeek(self.cursor)
1037
view = getMultiAdapter((self.context, self.request),
1038
name='daily_calendar_rows')
1039
empty_begin_days = 0 #added 2008-10-18 by Daniel Hoeger
1041
periods = view.getPeriods(day.date)
1045
#added 2008-10-18 by Daniel Hoeger
1046
if periods == [] and week_by_rows == []:
1047
empty_begin_days += 1
1049
for period, tstart, duration in periods:
1050
if not tstart in start_times:
1051
start_times.append(tstart)
1052
week_by_rows.append([])
1053
if not tstart + duration in start_times:
1054
start_times.append(tstart + duration)
1055
week_by_rows.append([])
1057
for index in range(0, len(start_times)-1):
1059
for event in week[day.date.weekday()].events:
1060
if ((((start_times[index] < event.dtstart + event.duration
1061
and start_times[index] > event.dtstart) or
1062
(event.dtstart < start_times[index+1] and
1063
event.dtstart > start_times[index])) or
1064
event.dtstart == start_times[index]) and
1066
block.append(self.cloneEvent(event))
1070
events_in_day.append(block)
1073
for event in events_in_day:
1074
week_by_rows[row_num].append(event)
1077
self.formatCurrentWeekEvents(week_by_rows)
1079
# added to remove weekly view error 2008-10-18 by Daniel Hoeger
1080
if empty_begin_days > 0:
1081
old_week_by_rows = week_by_rows
1082
new_week_by_rows = []
1083
for week_by_row in old_week_by_rows:
1084
new_week_by_row = []
1085
for i in range(0,empty_begin_days):
1086
new_week_by_row.append([None])
1088
while j < len(week_by_row)-empty_begin_days:
1089
new_week_by_row.append(week_by_row[j])
1091
new_week_by_rows.append(new_week_by_row)
1092
week_by_rows = new_week_by_rows
1096
def getCurrentWeekAllDayEvents(self):
1097
"""Return the current week's all day events in formatted lists."""
1098
eventCheck = lambda e, day: e is not None and e.allday
1099
return self.getCurrentWeekEvents(eventCheck)
1101
def getCurrentWeekEventsBeforeTimetable(self):
1102
"""Return the current week's events that start before the timetable
1103
events in formatted lists."""
1104
view = getMultiAdapter((self.context, self.request),
1105
name='daily_calendar_rows')
1106
eventCheck = lambda e, day: (e is not None and not e.allday and
1107
(view.getPeriods(day.date) == [] or
1108
e.dtstart < view.getPeriods(day.date)[0][1]))
1109
return self.getCurrentWeekEvents(eventCheck)
1111
def getCurrentWeekEventsAfterTimetable(self):
1112
"""Return the current week's events that start after the timetable
1113
events in formatted lists."""
1114
view = getMultiAdapter((self.context, self.request),
1115
name='daily_calendar_rows')
1116
eventCheck = lambda e, day: (view.getPeriods(day.date) != [] and
1117
e is not None and not e.allday and
1118
e.dtstart + e.duration >
1119
view.getPeriods(day.date)[-1][1] +
1120
view.getPeriods(day.date)[-1][2])
1121
return self.getCurrentWeekEvents(eventCheck)
1124
class AtomCalendarView(WeeklyCalendarView):
1125
"""View the upcoming week's events in Atom formatted xml."""
1128
return super(WeeklyCalendarView, self).__call__()
1130
def getCurrentWeek(self):
1131
"""Return the current week as a list of CalendarDay objects."""
1132
# XXX shouldn't use date.today; it depends on the server's timezone
1133
# which may not match user expectations
1134
return self.getWeek(date.today())
1136
def w3cdtf_datetime(self, dt):
1137
# XXX: shouldn't assume the datetime is in UTC
1138
assert dt.tzname() == 'UTC'
1139
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
1141
def w3cdtf_datetime_now(self):
1142
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
1145
class MonthlyCalendarView(CalendarViewBase):
1146
"""Monthly calendar view."""
1147
implements(IHaveEventLegend)
1149
__used_for__ = ISchoolToolCalendar
1151
cal_type = 'monthly'
1153
next_title = _("Next month")
1154
current_title = _("Current month")
1155
prev_title = _("Previous month")
1157
go_to_next_title = _("Go to next month")
1158
go_to_current_title = _("Go to current month")
1159
go_to_prev_title = _("Go to previous month")
1161
def inCurrentPeriod(self, dt):
1162
return (dt.year, dt.month) == (self.cursor.year, self.cursor.month)
1165
month_name_msgid = month_names[self.cursor.month]
1166
month_name = translate(month_name_msgid, context=self.request)
1167
msg = _('${month}, ${year}',
1168
mapping={'month': month_name, 'year': self.cursor.year})
1172
"""Return the link for the previous month."""
1173
return self.calURL('monthly', self.prevMonth())
1176
"""Return the link for the current month."""
1177
# XXX shouldn't use date.today; it depends on the server's timezone
1178
# which may not match user expectations
1179
return self.calURL('monthly', date.today())
1182
"""Return the link for the next month."""
1183
return self.calURL('monthly', self.nextMonth())
1185
def dayOfWeek(self, date):
1186
return day_of_week_names[date.weekday()]
1188
def weekTitle(self, date):
1189
msg = _('Week ${week_no}',
1190
mapping={'week_no': date.isocalendar()[1]})
1193
def getCurrentMonth(self):
1194
"""Return the current month as a nested list of CalendarDays."""
1195
return self.getMonth(self.cursor)
1198
class YearlyCalendarView(CalendarViewBase):
1199
"""Yearly calendar view."""
1201
__used_for__ = ISchoolToolCalendar
1205
next_title = _("Next year")
1206
current_title = _("Current year")
1207
prev_title = _("Previous year")
1209
go_to_next_title = _("Go to next year")
1210
go_to_current_title = _("Go to current year")
1211
go_to_prev_title = _("Go to previous year")
1216
def inCurrentPeriod(self, dt):
1217
return dt.year == self.cursor.year
1220
return unicode(self.cursor.year)
1223
"""Return the link for the previous year."""
1224
return self.calURL('yearly', date(self.cursor.year - 1, 1, 1))
1227
"""Return the link for the current year."""
1228
# XXX shouldn't use date.today; it depends on the server's timezone
1229
# which may not match user expectations
1230
return self.calURL('yearly', date.today())
1233
"""Return the link for the next year."""
1234
return self.calURL('yearly', date(self.cursor.year + 1, 1, 1))
1236
def shortDayOfWeek(self, date):
1237
return short_day_of_week_names[date.weekday()]
1239
def _initDaysCache(self):
1240
"""Initialize the _days_cache attribute.
1242
When ``update`` figures out which time period will be displayed to the
1243
user, it calls ``_initDaysCache`` to give the view a chance to
1244
precompute the calendar events for the time interval.
1246
This implementation designates the year of self.cursor as the time
1247
interval for caching.
1249
CalendarViewBase._initDaysCache(self)
1250
first_day_of_year = self.cursor.replace(month=1, day=1)
1251
first = week_start(first_day_of_year, self.first_day_of_week)
1252
last_day_of_year = self.cursor.replace(month=12, day=31)
1253
last = week_start(last_day_of_year,
1254
self.first_day_of_week) + timedelta(7)
1255
self._days_cache.extend(first, last)
1258
class DailyCalendarView(CalendarViewBase):
1259
"""Daily calendar view.
1261
The events are presented as boxes on a 'sheet' with rows
1264
The challenge here is to present the events as a table, so that
1265
the overlapping events are displayed side by side, and the size of
1266
the boxes illustrate the duration of the events.
1268
implements(IHaveEventLegend)
1270
__used_for__ = ISchoolToolCalendar
1277
next_title = _("The next day")
1278
current_title = _("Today")
1279
prev_title = _("The previous day")
1281
go_to_next_title = _("Go to the next day")
1282
go_to_current_title = _("Go to today")
1283
go_to_prev_title = _("Go to the previous day")
1285
def inCurrentPeriod(self, dt):
1286
return dt == self.cursor
1289
return self.dayTitle(self.cursor)
1292
"""Return the link for the next day."""
1293
return self.calURL('daily', self.cursor - timedelta(1))
1296
"""Return the link for today."""
1297
# XXX shouldn't use date.today; it depends on the server's timezone
1298
# which may not match user expectations
1299
return self.calURL('daily', date.today())
1302
"""Return the link for the previous day."""
1303
return self.calURL('daily', self.cursor + timedelta(1))
1305
def getColumns(self):
1306
"""Return the maximum number of events that are overlapping.
1308
Extends the event so that start and end times fall on hour
1309
boundaries before calculating overlaps.
1312
daystart = datetime.combine(self.cursor, time(tzinfo=utc))
1313
events = self.dayEvents(self.cursor)
1314
for event in events:
1316
dtend = daystart + timedelta(1)
1317
for title, start, duration in self.calendarRows(events):
1318
if start <= event.dtstart < start + duration:
1320
if start < event.dtstart + event.duration <= start + duration:
1321
dtend = start + duration
1324
t += timedelta(hours=1)
1327
return max(width) or 1
1329
def _setRange(self, events):
1330
"""Set the starthour and endhour attributes according to events.
1332
The range of the hours to display is the union of the range
1333
8:00-18:00 and time spans of all the events in the events
1336
for event in events:
1337
start = self.timezone.localize(datetime.combine(self.cursor,
1338
time(self.starthour)))
1339
end = self.timezone.localize(datetime.combine(self.cursor,
1340
time()) + timedelta(hours=self.endhour)) # endhour may be 24
1341
if event.dtstart < start:
1342
newstart = max(self.timezone.localize(
1343
datetime.combine(self.cursor, time())),
1344
event.dtstart.astimezone(self.timezone))
1345
self.starthour = newstart.hour
1347
if event.dtstart + event.duration > end and \
1348
event.dtstart.astimezone(self.timezone).day <= self.cursor.day:
1349
newend = min(self.timezone.localize(
1350
datetime.combine(self.cursor,
1351
time())) + timedelta(1),
1352
event.dtstart.astimezone(self.timezone) +
1353
event.duration + timedelta(0, 3599))
1354
self.endhour = newend.hour
1355
if self.endhour == 0:
1359
__calendar_rows = None
1361
def calendarRows(self, events):
1362
"""Iterate over (title, start, duration) of time slots that make up
1365
Returns a list, caches the answer for subsequent calls.
1367
view = getMultiAdapter((self.context, self.request),
1368
name='daily_calendar_rows')
1369
return view.calendarRows(self.cursor, self.starthour, self.endhour,
1372
def _getCurrentTime(self):
1373
"""Returns current time localized to UTC timezone."""
1374
return utc.localize(datetime.utcnow())
1377
"""Return an iterator over the rows of the table.
1379
Every row is a dict with the following keys:
1381
'time' -- row label (e.g. 8:00)
1382
'cols' -- sequence of cell values for this row
1384
A cell value can be one of the following:
1385
None -- if there is no event in this cell
1386
event -- if an event starts in this cell
1387
'' -- if an event started above this cell
1390
nr_cols = self.getColumns()
1391
all_events = self.dayEvents(self.cursor)
1392
# Filter allday events
1393
simple_events = [event for event in all_events
1394
if not event.allday]
1395
self._setRange(simple_events)
1398
for title, start, duration in self.calendarRows(simple_events):
1399
end = start + duration
1402
# Remove the events that have already ended
1403
for i in range(nr_cols):
1404
ev = slots.get(i, None)
1405
if ev is not None and ev.dtstart + ev.duration <= start:
1408
# Add events that start during (or before) this hour
1409
while (simple_events and simple_events[0].dtstart < end):
1410
event = simple_events.pop(0)
1415
for i in range(nr_cols):
1416
ev = slots.get(i, None)
1418
and ev.dtstart < start
1419
and hour != self.starthour):
1420
# The event started before this hour (except first row)
1423
# Either None, or new event
1426
height = duration.seconds / 900.0
1428
# Do not display the time of the start of the period when there
1429
# is too little space as that looks rather ugly.
1432
active = start <= self._getCurrentTime() < end
1434
yield {'title': title,
1435
'cols': tuple(cols),
1436
'time': start.strftime("%H:%M"),
1440
# We can trust no period will be longer than a day
1441
'duration': duration.seconds // 60}
1445
def snapToGrid(self, dt):
1446
"""Calculate the position of a datetime on the display grid.
1448
The daily view uses a grid where a unit (currently 'em', but that
1449
can be changed in the page template) corresponds to 15 minutes, and
1450
0 represents self.starthour.
1452
Clips dt so that it is never outside today's box.
1454
base = self.timezone.localize(datetime.combine(self.cursor, time()))
1455
display_start = base + timedelta(hours=self.starthour)
1456
display_end = base + timedelta(hours=self.endhour)
1457
clipped_dt = max(display_start, min(dt, display_end))
1458
td = clipped_dt - display_start
1459
offset_in_minutes = td.seconds / 60 + td.days * 24 * 60
1460
return offset_in_minutes / 15.
1462
def eventTop(self, event):
1463
"""Calculate the position of the top of the event block in the display.
1467
return self.snapToGrid(event.dtstart.astimezone(self.timezone))
1469
def eventHeight(self, event, minheight=3):
1470
"""Calculate the height of the event block in the display.
1472
Rounds the height up to a minimum of minheight.
1476
dtend = event.dtstart + event.duration
1477
return max(minheight,
1478
self.snapToGrid(dtend) - self.snapToGrid(event.dtstart))
1480
def getAllDayEvents(self):
1481
"""Get a list of EventForDisplay objects for the all-day events at the
1482
cursors current position.
1484
for event in self.dayEvents(self.cursor):
1489
class DailyCalendarRowsView(BrowserView):
1490
"""Daily calendar rows view for SchoolTool.
1492
This view differs from the original view in SchoolBell in that it can
1493
also show day periods instead of hour numbers.
1496
__used_for__ = ISchoolToolCalendar
1498
def getPersonTimezone(self):
1499
"""Return the prefered timezone of the user."""
1500
return ViewPreferences(self.request).timezone
1502
def getPeriodsForDay(self, date):
1503
"""Return a list of timetable periods defined for `date`.
1505
This function uses the default timetable schema and the appropriate time
1508
Retuns a list of (id, dtstart, duration) tuples. The times
1509
are timezone-aware and in the timezone of the timetable.
1511
Returns an empty list if there are no periods defined for `date` (e.g.
1512
if there is no default timetable schema, or `date` falls outside all
1513
time periods, or it happens to be a holiday).
1515
schooldays = getTermForDate(date)
1516
ttcontainer = getSchoolToolApplication()['ttschemas']
1517
if ttcontainer.default_id is None or schooldays is None:
1519
ttschema = ttcontainer.getDefault()
1520
tttz = timezone(ttschema.timezone)
1521
displaytz = self.getPersonTimezone()
1523
# Find out the days in the timetable that our display date overlaps
1524
daystart = displaytz.localize(datetime.combine(date, time(0)))
1525
dayend = daystart + date.resolution
1526
day1 = daystart.astimezone(tttz).date()
1527
day2 = dayend.astimezone(tttz).date()
1529
def resolvePeriods(date):
1530
term = getTermForDate(date)
1534
periods = ttschema.model.periodsInDay(term, ttschema, date)
1536
for id, tstart, duration in periods:
1537
dtstart = datetime.combine(date, tstart)
1538
dtstart = tttz.localize(dtstart)
1539
result.append((id, dtstart, duration))
1542
periods = resolvePeriods(day1)
1544
periods += resolvePeriods(day2)
1548
# Filter out periods outside date boundaries and chop off the
1549
# ones overlapping them.
1550
for id, dtstart, duration in periods:
1551
if (dtstart + duration <= daystart) or (dayend <= dtstart):
1553
if dtstart < daystart:
1554
duration -= daystart - dtstart
1555
dtstart = daystart.astimezone(tttz)
1556
if dayend < dtstart + duration:
1557
duration = dayend - dtstart
1558
result.append((id, dtstart, duration))
1562
def getPeriods(self, cursor):
1563
"""Return the date we get from getPeriodsForDay.
1565
Checks user preferences, returns an empty list if no user is
1568
person = IPerson(self.request.principal, None)
1569
if (person is not None and
1570
IPersonPreferences(person).cal_periods):
1571
return self.getPeriodsForDay(cursor)
1575
def _addPeriodsToRows(self, rows, periods, events):
1576
"""Populate the row list with rows from periods."""
1577
tz = self.getPersonTimezone()
1579
# Put starts and ends of periods into rows
1580
for period in periods:
1581
period_id, pstart, duration = period
1582
pend = (pstart + duration).astimezone(tz)
1583
for point in rows[:]:
1584
if pstart < point < pend:
1586
if pstart not in rows:
1588
if pend not in rows:
1593
def calendarRows(self, cursor, starthour, endhour, events):
1594
"""Iterate over (title, start, duration) of time slots that make up
1597
Returns a generator.
1599
tz = self.getPersonTimezone()
1600
periods = self.getPeriods(cursor)
1602
daystart = tz.localize(datetime.combine(cursor, time()))
1603
rows = [daystart + timedelta(hours=hour)
1604
for hour in range(starthour, endhour+1)]
1607
rows = self._addPeriodsToRows(rows, periods, events)
1611
start, row_ends = rows[0], rows[1:]
1612
start = start.astimezone(tz)
1613
for end in row_ends:
1614
if periods and periods[0][1] == start:
1615
period = periods.pop(0)
1616
calendarRows.append((period[0], start, period[2]))
1618
duration = end - start
1619
calendarRows.append(('%d:%02d' % (start.hour, start.minute),
1624
def rowTitle(self, hour, minute):
1625
"""Return the row title as HH:MM or H:MM am/pm."""
1626
prefs = ViewPreferences(self.request)
1627
return time(hour, minute).strftime(prefs.timeformat)
1630
class CalendarListSubscriber(object):
1631
"""A subscriber that can tell which calendars should be displayed.
1633
This subscriber includes composite timetable calendars, overlaid
1634
calendars and the calendar you are looking at.
1637
def __init__(self, context, request):
1638
self.context = context
1639
self.request = request
1641
def getCalendars(self):
1642
"""Get a list of calendars to display.
1644
Yields tuples (calendar, color1, color2).
1647
yield (self.context, '#9db8d2', '#7590ae')
1649
parent = getParent(self.context)
1651
user = IPerson(self.request.principal, None)
1653
return # unauthenticated user
1655
unproxied_context = removeSecurityProxy(self.context)
1656
unproxied_calendar = removeSecurityProxy(ISchoolToolCalendar(user))
1657
if unproxied_context is not unproxied_calendar:
1658
return # user looking at the calendar of some other person
1660
for item in user.overlaid_calendars:
1661
if canAccess(item.calendar, '__iter__'):
1662
# overlaid calendars
1664
yield (item.calendar, item.color1, item.color2)
1668
# Calendar modification views
1672
class EventDeleteView(BrowserView):
1673
"""A view for deleting events."""
1675
__used_for__ = ISchoolToolCalendar
1677
recevent_template = ViewPageTemplateFile("templates/recevent_delete.pt")
1678
simple_event_template = ViewPageTemplateFile("templates/simple_event_delete.pt")
1681
event_id = self.request['event_id']
1682
date = parse_date(self.request['date'])
1683
self.event = self._findEvent(event_id)
1685
if self.event is None:
1686
# The event was not found.
1687
return self._redirectBack()
1689
if self.event.recurrence is None or self.event.__parent__ != self.context:
1690
return self._deleteSimpleEvent(self.event)
1692
# The event is recurrent, we might need to show a form.
1693
return self._deleteRepeatingEvent(self.event, date)
1695
def _findEvent(self, event_id):
1696
"""Find an event that has the id event_id.
1698
First the event is searched for in the current calendar and then,
1699
overlaid calendars if any.
1701
If no event with the given id is found, None is returned.
1704
return self.context.find(event_id)
1708
def _redirectBack(self):
1709
"""Redirect to the current calendar's daily view."""
1710
url = absoluteURL(self.context, self.request)
1711
self.request.response.redirect(url)
1713
def _deleteRepeatingEvent(self, event, date):
1714
"""Delete a repeating event."""
1715
if 'CANCEL' in self.request:
1716
pass # Fall through and redirect back to the calendar.
1717
elif 'ALL' in self.request:
1718
self.context.removeEvent(removeSecurityProxy(event))
1719
elif 'FUTURE' in self.request:
1720
self._modifyRecurrenceRule(event, until=(date - timedelta(1)),
1722
elif 'CURRENT' in self.request:
1723
exceptions = event.recurrence.exceptions + (date, )
1724
self._modifyRecurrenceRule(event, exceptions=exceptions)
1726
return self.recevent_template()
1728
# We did our job, redirect back to the calendar view.
1729
return self._redirectBack()
1731
def _deleteSimpleEvent(self, event):
1732
"""Delete a simple event."""
1733
if 'CANCEL' in self.request:
1734
pass # Fall through and redirect back to the calendar.
1735
elif 'DELETE' in self.request:
1736
self.context.removeEvent(removeSecurityProxy(event))
1738
return self.simple_event_template()
1740
# We did our job, redirect back to the calendar view.
1741
return self._redirectBack()
1743
def _modifyRecurrenceRule(self, event, **kwargs):
1744
"""Modify the recurrence rule of an event.
1746
If the event does not have any recurrences afterwards, it is removed
1747
from the parent calendar
1749
rrule = event.recurrence
1750
new_rrule = rrule.replace(**kwargs)
1751
# This view requires the modifyEvent permission.
1752
event.recurrence = removeSecurityProxy(new_rrule)
1753
if not event.hasOccurrences():
1754
ICalendar(event).removeEvent(removeSecurityProxy(event))
1758
"""A dict with automatic key selection.
1760
The add method automatically selects the lowest unused numeric key
1772
{0: 'first', 1: 'second'}
1774
The keys can be reused:
1779
{0: 'third', 1: 'second'}
1790
class CalendarEventView(BrowserView):
1791
"""View for single events."""
1793
# XXX what are these used for?
1797
def __init__(self, context, request):
1798
self.context = context
1799
self.request = request
1801
self.preferences = ViewPreferences(request)
1803
self.dtstart = context.dtstart.astimezone(self.preferences.timezone)
1804
self.dtend = self.dtstart + context.duration
1805
self.start = self.dtstart.strftime(self.preferences.timeformat)
1806
self.end = self.dtend.strftime(self.preferences.timeformat)
1808
dayformat = '%A, ' + self.preferences.dateformat
1809
self.day = unicode(self.dtstart.strftime(dayformat))
1811
self.display = EventForDisplay(context, self.request,
1812
self.color1, self.color2,
1814
timezone=self.preferences.timezone)
1817
class ICalendarEventAddForm(Interface):
1818
"""Schema for event adding form."""
1829
start_time = TextLine(
1831
description=_("Start time in 24h format"),
1835
title=_("Duration"),
1839
duration_type = Choice(
1840
title=_("Duration Type"),
1843
vocabulary=vocabulary([("minutes", _("Minutes")),
1844
("hours", _("Hours")),
1845
("days", _("Days"))]))
1847
location = TextLine(
1848
title=_("Location"),
1851
description = HtmlFragment(
1852
title=_("Description"),
1857
title=_("Recurring"),
1860
recurrence_type = Choice(
1861
title=_("Recurs every"),
1864
vocabulary=vocabulary([("daily", _("Day")),
1865
("weekly", _("Week")),
1866
("monthly", _("Month")),
1867
("yearly", _("Year"))]))
1870
title=_("Repeat every"),
1878
vocabulary=vocabulary([("count", _("Count")),
1879
("until", _("Until")),
1880
("forever", _("forever"))]))
1883
title=_("Number of events"),
1887
title=_("Repeat until"),
1891
title=_("Weekdays"),
1895
vocabulary=vocabulary([(0, _("Mon")),
1907
vocabulary=vocabulary([("monthday", "md"),
1909
("lastweekday", "lwd")]))
1912
title=_("Exception dates"),
1916
class CalendarEventViewMixin(object):
1917
"""A mixin that holds the code common to CalendarEventAdd and Edit Views."""
1921
def _setError(self, name, error=RequiredMissing()):
1922
"""Set an error on a widget."""
1923
# XXX Touching widget._error is bad, see
1924
# http://dev.zope.org/Zope3/AccessToWidgetErrors
1925
# The call to setRenderedValue is necessary because
1926
# otherwise _getFormValue will call getInputValue and
1927
# overwrite _error while rendering.
1928
widget = getattr(self, name + '_widget')
1929
widget.setRenderedValue(widget._getFormValue())
1930
if not IWidgetInputError.providedBy(error):
1931
error = WidgetInputError(name, widget.label, error)
1932
widget._error = error
1934
def _requireField(self, name, errors):
1935
"""If widget has no input, WidgetInputError is set.
1937
Also adds the exception to the `errors` list.
1939
widget = getattr(self, name + '_widget')
1940
field = widget.context
1942
if widget.getInputValue() == field.missing_value:
1943
self._setError(name)
1944
errors.append(widget._error)
1945
except WidgetInputError, e:
1946
# getInputValue might raise an exception on invalid input
1949
def setUpEditorWidget(self, editor):
1950
editor.editorWidth = 430
1951
editor.editorHeight = 300
1952
editor.toolbarConfiguration = "schooltool"
1953
url = absoluteURL(ISchoolToolApplication(None), self.request)
1954
editor.configurationPath = (url + '/@@/editor_config.js')
1956
def weekdayChecked(self, weekday):
1957
"""Return True if the given weekday should be checked.
1959
The weekday of start_date is always checked, others can be selected by
1962
Used to format checkboxes for weekly recurrences.
1964
return (int(weekday) in self.weekdays_widget._getFormValue() or
1965
self.weekdayDisabled(weekday))
1967
def weekdayDisabled(self, weekday):
1968
"""Return True if the given weekday should be disabled.
1970
The weekday of start_date is always disabled, all others are always
1973
Used to format checkboxes for weekly recurrences.
1975
day = self.getStartDate()
1976
return bool(day and day.weekday() == int(weekday))
1978
def getMonthDay(self):
1979
"""Return the day number in a month, according to start_date.
1981
Used by the page template to format monthly recurrence rules.
1983
evdate = self.getStartDate()
1987
return str(evdate.day)
1989
def getWeekDay(self):
1990
"""Return the week and weekday in a month, according to start_date.
1992
The output looks like '4th Tuesday'
1994
Used by the page template to format monthly recurrence rules.
1996
evdate = self.getStartDate()
1998
return _("same weekday")
2000
weekday = evdate.weekday()
2001
index = (evdate.day + 6) // 7
2003
indexes = {1: _('1st'), 2: _('2nd'), 3: _('3rd'), 4: _('4th'),
2005
day_of_week = day_of_week_names[weekday]
2006
return "%s %s" % (indexes[index], day_of_week)
2008
def getLastWeekDay(self):
2009
"""Return the week and weekday in a month, counting from the end.
2011
The output looks like 'Last Friday'
2013
Used by the page template to format monthly recurrence rules.
2015
evdate = self.getStartDate()
2018
return _("last weekday")
2020
lastday = calendar.monthrange(evdate.year, evdate.month)[1]
2022
if lastday - evdate.day >= 7:
2025
weekday = evdate.weekday()
2026
day_of_week_msgid = day_of_week_names[weekday]
2027
day_of_week = translate(day_of_week_msgid, context=self.request)
2028
msg = _("Last ${weekday}", mapping={'weekday': day_of_week})
2031
def getStartDate(self):
2032
"""Return the value of the widget if a start_date is set."""
2034
return self.start_date_widget.getInputValue()
2035
except (WidgetInputError, ConversionError):
2038
def updateForm(self):
2039
# Just refresh the form. It is necessary because some labels for
2040
# monthly recurrence rules depend on the event start date.
2041
self.update_status = ''
2043
data = getWidgetsData(self, self.schema, names=self.fieldNames)
2045
for name in self._keyword_arguments:
2047
kw[str(name)] = data[name]
2048
self.processRequest(kw)
2049
except WidgetsError, errors:
2050
self.errors = errors
2051
self.update_status = _("An error occurred.")
2052
return self.update_status
2053
# AddView.update() sets self.update_status and returns it. Weird,
2054
# but let's copy that behavior.
2055
return self.update_status
2057
def processRequest(self, kwargs):
2058
"""Put information from the widgets into a dict.
2060
This method performs additional validation, because Zope 3 forms aren't
2061
powerful enough. If any errors are encountered, a WidgetsError is
2065
self._requireField("title", errors)
2066
self._requireField("start_date", errors)
2068
# What we require depends on weather or not we have an allday event
2069
allday = kwargs.pop('allday', None)
2071
self._requireField("start_time", errors)
2073
self._requireField("duration", errors)
2075
# Remove fields not needed for makeRecurrenceRule from kwargs
2076
title = kwargs.pop('title', None)
2077
start_date = kwargs.pop('start_date', None)
2078
start_time = kwargs.pop('start_time', None)
2081
start_time = parse_time(start_time)
2083
self._setError("start_time",
2084
ConversionError(_("Invalid time")))
2085
errors.append(self.start_time_widget._error)
2086
duration = kwargs.pop('duration', None)
2087
duration_type = kwargs.pop('duration_type', 'minutes')
2088
location = kwargs.pop('location', None)
2089
description = kwargs.pop('description', None)
2090
recurrence = kwargs.pop('recurrence', None)
2093
self._requireField("interval", errors)
2094
self._requireField("recurrence_type", errors)
2095
self._requireField("range", errors)
2097
range = kwargs.get('range')
2098
if range == "count":
2099
self._requireField("count", errors)
2100
elif range == "until":
2101
self._requireField("until", errors)
2102
if start_date and kwargs.get('until'):
2103
if kwargs['until'] < start_date:
2104
self._setError("until", ConstraintNotSatisfied(
2105
_("End date is earlier than start date")))
2106
errors.append(self.until_widget._error)
2108
exceptions = kwargs.pop("exceptions", None)
2111
kwargs["exceptions"] = datesParser(exceptions)
2113
self._setError("exceptions", ConversionError(
2114
_("Invalid date. Please specify YYYY-MM-DD, one per line.")))
2115
errors.append(self.exceptions_widget._error)
2118
raise WidgetsError(errors)
2120
# Some fake data for allday events, based on what iCalendar seems to
2123
# iCalendar has no spec for describing all-day events, but it seems
2124
# to be the de facto standard to give them a 1d duration.
2125
# XXX ignas: ical has allday events, they are different
2126
# from normal events, because they have a date as their
2127
# dtstart not a datetime
2128
duration_type = "days"
2129
start_time = time(0, 0, tzinfo=utc)
2130
start = datetime.combine(start_date, start_time)
2132
start = datetime.combine(start_date, start_time)
2133
start = self.timezone.localize(start).astimezone(utc)
2135
dargs = {duration_type : duration}
2136
duration = timedelta(**dargs)
2138
# Shift the weekdays to the correct timezone
2139
if 'weekdays' in kwargs and kwargs['weekdays']:
2140
kwargs['weekdays'] = tuple(convertWeekdaysList(start,
2143
kwargs['weekdays']))
2146
rrule = recurrence and makeRecurrenceRule(**kwargs) or None
2147
return {'location': location,
2148
'description': description,
2152
'duration': duration,
2156
class CalendarEventAddView(CalendarEventViewMixin, AddView):
2157
"""A view for adding an event."""
2159
__used_for__ = ISchoolToolCalendar
2160
schema = ICalendarEventAddForm
2162
title = _("Add event")
2163
submit_button_title = _("Add")
2165
show_book_checkbox = True
2166
show_book_link = False
2171
def __init__(self, context, request):
2173
prefs = ViewPreferences(request)
2174
self.timezone = prefs.timezone
2176
if "field.start_date" not in request:
2177
# XXX shouldn't use date.today; it depends on the server's timezone
2178
# which may not match user expectations
2179
today = date.today().strftime("%Y-%m-%d")
2180
request.form["field.start_date"] = today
2181
super(AddView, self).__init__(context, request)
2182
self.setUpEditorWidget(self.description_widget)
2184
def create(self, **kwargs):
2185
"""Create an event."""
2186
data = self.processRequest(kwargs)
2187
event = self._factory(data['start'], data['duration'], data['title'],
2188
recurrence=data['rrule'],
2189
location=data['location'],
2190
allday=data['allday'],
2191
description=data['description'])
2194
def add(self, event):
2195
"""Add the event to a calendar."""
2196
self.context.addEvent(event)
2197
uid = event.unique_id
2198
self._event_name = event.__name__
2199
session_data = ISession(self.request)['schooltool.calendar']
2200
session_data.setdefault('added_event_uids', set()).add(uid)
2204
"""Process the form."""
2205
if 'UPDATE' in self.request:
2206
return self.updateForm()
2207
elif 'CANCEL' in self.request:
2208
self.update_status = ''
2209
self.request.response.redirect(self.nextURL())
2210
return self.update_status
2212
return AddView.update(self)
2215
"""Return the URL to be displayed after the add operation."""
2216
if "field.book" in self.request:
2217
url = absoluteURL(self.context, self.request)
2218
return '%s/%s/booking.html' % (url, self._event_name)
2220
return absoluteURL(self.context, self.request)
2223
class ICalendarEventEditForm(ICalendarEventAddForm):
2227
class CalendarEventEditView(CalendarEventViewMixin, EditView):
2228
"""A view for editing an event."""
2231
show_book_checkbox = False
2232
show_book_link = True
2234
title = _("Edit event")
2235
submit_button_title = _("Update")
2237
def __init__(self, context, request):
2238
prefs = ViewPreferences(request)
2239
self.timezone = prefs.timezone
2240
EditView.__init__(self, context, request)
2241
self.setUpEditorWidget(self.description_widget)
2243
def keyword_arguments(self):
2244
"""Wraps fieldNames under another name.
2246
AddView and EditView API does not match so some wrapping is needed.
2248
return self.fieldNames
2250
_keyword_arguments = property(keyword_arguments, None)
2252
def _setUpWidgets(self):
2253
setUpWidgets(self, self.schema, IInputWidget, names=self.fieldNames,
2254
initial=self._getInitialData(self.context))
2256
def _getInitialData(self, context):
2257
"""Extract initial widgets data from context."""
2260
initial["title"] = context.title
2261
initial["allday"] = context.allday
2262
initial["start_date"] = context.dtstart.date()
2263
initial["start_time"] = context.dtstart.astimezone(self.timezone).strftime("%H:%M")
2264
duration = context.duration.seconds / 60 + context.duration.days * 1440
2265
initial["duration_type"] = (duration % 60 and "minutes" or
2266
duration % (24 * 60) and "hours" or
2268
initial["duration"] = (initial["duration_type"] == "minutes" and duration or
2269
initial["duration_type"] == "hours" and duration / 60 or
2270
initial["duration_type"] == "days" and duration / 60 / 24)
2271
initial["location"] = context.location
2272
initial["description"] = context.description
2273
recurrence = context.recurrence
2274
initial["recurrence"] = recurrence is not None
2276
initial["interval"] = recurrence.interval
2278
IDailyRecurrenceRule.providedBy(recurrence) and "daily" or
2279
IWeeklyRecurrenceRule.providedBy(recurrence) and "weekly" or
2280
IMonthlyRecurrenceRule.providedBy(recurrence) and "monthly" or
2281
IYearlyRecurrenceRule.providedBy(recurrence) and "yearly")
2283
initial["recurrence_type"] = recurrence_type
2284
if recurrence.until:
2285
initial["until"] = recurrence.until
2286
initial["range"] = "until"
2287
elif recurrence.count:
2288
initial["count"] = recurrence.count
2289
initial["range"] = "count"
2291
initial["range"] = "forever"
2293
if recurrence.exceptions:
2294
exceptions = map(str, recurrence.exceptions)
2295
initial["exceptions"] = "\n".join(exceptions)
2297
if recurrence_type == "weekly":
2298
if recurrence.weekdays:
2299
# Convert weekdays to the correct TZ
2300
initial["weekdays"] = convertWeekdaysList(
2301
self.context.dtstart,
2302
self.context.dtstart.tzinfo,
2304
recurrence.weekdays)
2306
if recurrence_type == "monthly":
2307
if recurrence.monthly:
2308
initial["monthly"] = recurrence.monthly
2312
def getStartDate(self):
2313
if "field.start_date" in self.request:
2314
return CalendarEventViewMixin.getStartDate(self)
2316
return self.context.dtstart.astimezone(self.timezone).date()
2318
def applyChanges(self):
2319
data = getWidgetsData(self, self.schema, names=self.fieldNames)
2321
for name in self._keyword_arguments:
2323
kw[str(name)] = data[name]
2325
widget_data = self.processRequest(kw)
2327
parsed_date = parse_datetimetz(widget_data['start'].isoformat())
2328
self.context.dtstart = parsed_date
2329
self.context.recurrence = widget_data['rrule']
2330
for attrname in ['allday', 'duration', 'title',
2331
'location', 'description']:
2332
setattr(self.context, attrname, widget_data[attrname])
2336
if self.update_status is not None:
2337
# We've been called before. Just return the status we previously
2339
return self.update_status
2343
start_date = self.context.dtstart.strftime("%Y-%m-%d")
2345
if "UPDATE" in self.request:
2346
return self.updateForm()
2347
elif 'CANCEL' in self.request:
2348
self.update_status = ''
2349
self.request.response.redirect(self.nextURL())
2350
return self.update_status
2351
elif "UPDATE_SUBMIT" in self.request:
2352
# Replicating EditView functionality
2355
changed = self.applyChanges()
2357
notify(ObjectModifiedEvent(self.context))
2358
except WidgetsError, errors:
2359
self.errors = errors
2360
status = _("An error occurred.")
2364
formatter = self.request.locale.dates.getFormatter(
2365
'dateTime', 'medium')
2366
status = _("Updated on ${date_time}",
2367
mapping = {'date_time': formatter.format(
2368
datetime.utcnow())})
2369
self.request.response.redirect(self.nextURL())
2371
self.update_status = status
2375
"""Return the URL to be displayed after the add operation."""
2376
if "field.book" in self.request:
2377
return absoluteURL(self.context, self.request) + '/booking.html'
2378
elif 'CANCEL' in self.request and self.request.get('cancel_url', None):
2379
return self.request['cancel_url']
2381
return absoluteURL(self.context.__parent__, self.request)
2384
class EventForBookingDisplay(object):
2385
"""Event wrapper for display in booking view.
2387
This is a wrapper around an ICalendarEvent object. It adds view-specific
2390
dtend -- timestamp when the event ends
2391
shortTitle -- title truncated to ~15 characters
2395
def __init__(self, event):
2396
# The event came from resource calendar, so its parent might
2397
# be a calendar we don't have permission to view.
2398
self.context = removeSecurityProxy(event)
2399
self.dtstart = self.context.dtstart
2400
self.dtend = self.context.dtstart + self.context.duration
2401
self.title = self.context.title
2402
if len(self.title) > 16:
2403
# Title needs truncation.
2404
self.shortTitle = self.title[:15] + '...'
2406
self.shortTitle = self.title
2407
self.unique_id = self.context.unique_id
2410
class CalendarEventBookingView(CalendarEventView):
2411
"""A view for booking resources."""
2414
update_status = None
2416
template = ViewPageTemplateFile("templates/event_booking.pt")
2418
def __init__(self, context, request):
2419
CalendarEventView.__init__(self, context, request)
2421
format = '%s - %s' % (self.preferences.dateformat,
2422
self.preferences.timeformat)
2423
self.start = u'' + self.dtstart.strftime(format)
2424
self.end = u'' + self.dtend.strftime(format)
2427
self.checkPermission()
2428
return self.template()
2430
def checkPermission(self):
2431
if canAccess(self.context, 'bookResource'):
2433
# If the authenticated user has the addEvent permission and has
2434
# come here directly from the event adding form, let him book.
2435
# (Fixes issue 486.)
2436
if self.justAddedThisEvent():
2438
raise Unauthorized("user not allowed to book")
2440
def hasBookedItems(self):
2441
return bool(self.context.resources)
2443
def bookingStatus(self, item, formatter):
2444
conflicts = list(self.getConflictingEvents(item))
2446
for conflict in conflicts:
2447
if conflict.context.__parent__ and conflict.context.__parent__.__parent__:
2448
absoluteURL(self.context, self.request)
2449
owner = conflict.context.__parent__.__parent__
2450
url = absoluteURL(owner, self.request)
2452
owner = conflict.context.activity.owner
2453
url = owner.absolute_url()
2454
owner_url = "%s/calendar" % url
2455
owner_name = owner.title
2456
status[owner_name] = owner_url
2461
def statusFormatter(value, item, formatter):
2464
for eventOwner, ownerCalendar in value.items():
2465
url.append('<a href="%s">%s</a>' % (ownerCalendar, eventOwner))
2466
return ", ".join(url)
2469
return [GetterColumn(name='title',
2471
getter=lambda i, f: i.title,
2473
GetterColumn(name='type',
2475
getter=lambda i, f: IResourceTypeInformation(i).title,
2477
GetterColumn(title="Booked by others",
2478
cell_formatter=statusFormatter,
2479
getter=self.bookingStatus
2482
def getBookedItems(self):
2483
return removeSecurityProxy(self.context.resources)
2485
def updateBatch(self, lst):
2487
if self.filter_widget:
2488
extra_url = self.filter_widget.extra_url()
2489
self.batch = IterableBatch(lst, self.request, sort_by='title', extra_url=extra_url)
2491
def renderBookedTable(self):
2492
prefix = "remove_item"
2493
columns = [CheckboxColumn(prefix=prefix, name='remove', title=u'')]
2494
available_columns = self.columns()
2495
available_columns[0].cell_formatter = label_cell_formatter_factory(prefix)
2496
columns.extend(available_columns)
2497
formatter = table.FormFullFormatter(
2498
self.context, self.request, self.getBookedItems(),
2500
sort_on=self.sortOn(),
2502
formatter.cssClasses['table'] = 'data'
2505
def renderAvailableTable(self):
2507
columns = [CheckboxColumn(prefix=prefix, name='add', title=u'',
2508
isDisabled=self.getConflictingEvents)]
2509
available_columns = self.columns()
2510
available_columns[0].cell_formatter = label_cell_formatter_factory(prefix)
2511
columns.extend(available_columns)
2512
formatter = table.FormFullFormatter(
2513
self.context, self.request, self.filter(self.availableResources),
2515
batch_start=self.batch.start, batch_size=self.batch.size,
2516
sort_on=self.sortOn(),
2518
formatter.cssClasses['table'] = 'data'
2522
return (("title", False),)
2524
def getAvailableItemsContainer(self):
2525
return ISchoolToolApplication(None)['resources']
2527
def getAvailableItems(self):
2528
container = self.getAvailableItemsContainer()
2529
bookedItems = set(self.getBookedItems())
2530
allItems = set(self.availableResources)
2531
return list(allItems - bookedItems)
2533
def filter(self, list):
2534
return self.filter_widget.filter(list)
2537
def justAddedThisEvent(self):
2538
session_data = ISession(self.request)['schooltool.calendar']
2539
added_event_ids = session_data.get('added_event_uids', [])
2540
return self.context.unique_id in added_event_ids
2542
def clearJustAddedStatus(self):
2543
"""Remove the context uid from the list of added events."""
2544
session_data = ISession(self.request)['schooltool.calendar']
2545
added_event_ids = session_data.get('added_event_uids', [])
2546
uid = self.context.unique_id
2547
if uid in added_event_ids:
2548
added_event_ids.remove(uid)
2551
"""Book/unbook resources according to the request."""
2552
start_date = self.context.dtstart.strftime("%Y-%m-%d")
2553
self.filter_widget = queryMultiAdapter((self.getAvailableItemsContainer(),
2557
if 'CANCEL' in self.request:
2558
url = absoluteURL(self.context, self.request)
2559
self.request.response.redirect(self.nextURL())
2561
elif "BOOK" in self.request: # and not self.update_status:
2562
self.update_status = ''
2563
sb = getSchoolToolApplication()
2564
for res_id, resource in sb["resources"].items():
2565
if 'add_item.%s' % res_id in self.request:
2566
booked = self.hasBooked(resource)
2568
event = removeSecurityProxy(self.context)
2569
event.bookResource(resource)
2570
self.clearJustAddedStatus()
2572
elif "UNBOOK" in self.request:
2573
self.update_status = ''
2574
sb = getSchoolToolApplication()
2575
for res_id, resource in sb["resources"].items():
2576
if 'remove_item.%s' % res_id in self.request:
2577
booked = self.hasBooked(resource)
2579
# Always allow unbooking, even if permission to
2580
# book that specific resource was revoked.
2581
self.context.unbookResource(resource)
2582
self.updateBatch(self.filter(self.availableResources))
2583
return self.update_status
2586
def availableResources(self):
2587
"""Gives us a list of all bookable resources."""
2588
sb = getSchoolToolApplication()
2589
calendar_owner = removeSecurityProxy(self.context.__parent__.__parent__)
2590
def isBookable(resource):
2591
if resource is calendar_owner:
2592
# A calendar event in a resource's calendar shouldn't book
2593
# that resource, it would be silly.
2595
if self.canBook(resource) and not self.hasBooked(resource):
2598
return filter(isBookable, sb['resources'].values())
2600
def canBook(self, resource):
2601
"""Can the user book this resource?"""
2602
return canAccess(ISchoolToolCalendar(resource), "addEvent")
2604
def hasBooked(self, resource):
2605
"""Checks whether a resource is booked by this event."""
2606
return resource in self.context.resources
2609
"""Return the URL to be displayed after the add operation."""
2610
return absoluteURL(self.context.__parent__, self.request)
2612
def getConflictingEvents(self, resource):
2613
"""Return a list of events that would conflict when booking a resource."""
2614
calendar = ISchoolToolCalendar(resource)
2615
if not canAccess(calendar, "expand"):
2618
events = list(calendar.expand(self.context.dtstart,
2619
self.context.dtstart + self.context.duration))
2621
return [EventForBookingDisplay(event)
2623
if event != self.context]
2626
def makeRecurrenceRule(interval=None, until=None,
2627
count=None, range=None,
2628
exceptions=None, recurrence_type=None,
2629
weekdays=None, monthly=None):
2630
"""Return a recurrence rule according to the arguments."""
2631
if interval is None:
2634
if range != 'until':
2636
if range != 'count':
2639
if exceptions is None:
2642
kwargs = {'interval': interval, 'count': count,
2643
'until': until, 'exceptions': exceptions}
2645
if recurrence_type == 'daily':
2646
return DailyRecurrenceRule(**kwargs)
2647
elif recurrence_type == 'weekly':
2648
weekdays = weekdays or ()
2649
return WeeklyRecurrenceRule(weekdays=tuple(weekdays), **kwargs)
2650
elif recurrence_type == 'monthly':
2651
monthly = monthly or "monthday"
2652
return MonthlyRecurrenceRule(monthly=monthly, **kwargs)
2653
elif recurrence_type == 'yearly':
2654
return YearlyRecurrenceRule(**kwargs)
2656
raise NotImplementedError()
2659
def convertWeekdaysList(dt, fromtz, totz, weekdays):
2660
"""Convert the weekday list from one timezone to the other.
2662
The days can shift by one day in either direction or stay,
2663
depending on the timezones and the time of the event.
2665
The arguments are as follows:
2667
dt -- the tz-aware start of the event
2668
fromtz -- the timezone the weekdays list is in
2669
totz -- the timezone the weekdays list is converted to
2670
weekdays -- a list of values in range(7), 0 is Monday.
2673
delta_td = dt.astimezone(totz).date() - dt.astimezone(fromtz).date()
2674
delta = delta_td.days
2675
return [(wd + delta) % 7 for wd in weekdays]
2678
def datesParser(raw_dates):
2679
r"""Parse dates on separate lines into a tuple of date objects.
2681
Incorrect lines are ignored.
2683
>>> datesParser('2004-05-17\n\n\n2004-01-29')
2684
(datetime.date(2004, 5, 17), datetime.date(2004, 1, 29))
2686
>>> datesParser('2004-05-17\n123\n\nNone\n2004-01-29')
2687
Traceback (most recent call last):
2689
ValueError: Invalid date: '123'
2693
for dstr in raw_dates.splitlines():
2695
d = parse_date(dstr)
2696
if isinstance(d, date):
2698
return tuple(results)
2701
def enableVfbView(ical_view):
2702
"""XXX wanna docstring!"""
2703
return IReadFile(ical_view.context)
2706
def enableICalendarUpload(ical_view):
2707
"""An adapter that enables HTTP PUT for calendars.
2709
When the user performs an HTTP PUT request on /path/to/calendar.ics,
2710
Zope 3 traverses to a view named 'calendar.ics' (which is most likely
2711
a schooltool.calendar.browser.Calendar ICalendarView). Then Zope 3 finds an
2712
IHTTPrequest view named 'PUT'. There is a standard one, that adapts
2713
its context (which happens to be the view named 'calendar.ics' in this
2714
case) to IWriteFile, and calls `write` on it.
2716
So, to hook up iCalendar uploads, the simplest way is to register an
2717
adapter for CalendarICalendarView that provides IWriteFile.
2719
>>> from zope.app.testing import setup, ztapi
2720
>>> setup.placelessSetUp()
2722
We have a calendar that provides IEditCalendar.
2724
>>> from schooltool.calendar.interfaces import IEditCalendar
2725
>>> from schooltool.app.cal import Calendar
2726
>>> calendar = Calendar(None)
2728
We have a fake "real adapter" for IEditCalendar
2730
>>> class RealAdapter:
2731
... implements(IWriteFile)
2732
... def __init__(self, context):
2734
... def write(self, data):
2735
... print 'real adapter got %r' % data
2736
>>> ztapi.provideAdapter(IEditCalendar, IWriteFile, RealAdapter)
2738
We have a fake view on that calendar
2740
>>> from zope.publisher.browser import BrowserView
2741
>>> from zope.publisher.browser import TestRequest
2742
>>> view = BrowserView(calendar, TestRequest())
2744
And now we can hook things up together
2746
>>> adapter = enableICalendarUpload(view)
2747
>>> adapter.write('iCalendar data')
2748
real adapter got 'iCalendar data'
2750
>>> setup.placelessTearDown()
2753
return IWriteFile(ical_view.context)
2756
class CalendarEventBreadcrumbInfo(breadcrumbs.GenericBreadcrumbInfo):
2757
"""Calendar Event Breadcrumb Info
2759
First, set up a parent:
2761
>>> class Object(object):
2762
... def __init__(self, parent=None, name=None):
2763
... self.__parent__ = parent
2764
... self.__name__ = name
2766
>>> calendar = Object()
2767
>>> from zope.traversing.interfaces import IContainmentRoot
2768
>>> import zope.interface
2769
>>> zope.interface.directlyProvides(calendar, IContainmentRoot)
2771
Now setup the event:
2773
>>> event = Object(calendar, u'+1243@localhost')
2777
>>> from zope.publisher.browser import TestRequest
2778
>>> request = TestRequest()
2780
Now register the breadcrumb info component and other setup:
2782
>>> import zope.component
2783
>>> import zope.interface
2784
>>> from schooltool.skin import interfaces, breadcrumbs
2785
>>> zope.component.provideAdapter(breadcrumbs.GenericBreadcrumbInfo,
2786
... (Object, TestRequest),
2787
... interfaces.IBreadcrumbInfo)
2789
>>> from zope.app.testing import setup
2790
>>> setup.setUpTraversal()
2792
Now initialize this info and test it:
2794
>>> info = CalendarEventBreadcrumbInfo(event, request)
2796
'http://127.0.0.1/+1243@localhost/edit.html'
2801
name = urllib.quote(self.context.__name__.encode('utf-8'), "@+")
2802
parent_info = getMultiAdapter(
2803
(self.context.__parent__, self.request), IBreadcrumbInfo)
2804
return '%s/%s/edit.html' %(parent_info.url, name)
2806
CalendarBreadcrumbInfo = breadcrumbs.CustomNameBreadCrumbInfo(_('Calendar'))
2809
class CalendarActionMenuViewlet(object):
2810
implements(ICalendarMenuViewlet)
2813
class CalendarMenuViewletCrowd(Crowd):
2814
adapts(ICalendarMenuViewlet)
2816
def contains(self, principal):
2817
"""Returns true if you have the permission to see the calendar."""
2818
crowd = queryAdapter(ISchoolToolCalendar(self.context.context),
2820
name="schooltool.view")
2821
return crowd.contains(principal)
2823
class ICalendarPortletViewletManager(IViewletManager):
2824
""" Interface for the Calendar Portlet Viewlet Manager """