~hoegerd17/schooltool/schooltool.xml_genolot

« back to all changes in this revision

Viewing changes to app/browser/cal.py

  • Committer: Daniel Höger
  • Date: 2008-11-01 14:07:43 UTC
  • Revision ID: daniel@daniel-desktop-20081101140743-o1q95y2xtjllyqvh
edit comments and importfiles

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
#
 
2
# SchoolTool - common information systems platform for school administration
 
3
# Copyright (c) 2005 Shuttleworth Foundation
 
4
#
 
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.
 
9
#
 
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.
 
14
#
 
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
 
18
#
 
19
"""
 
20
SchoolTool application views.
 
21
 
 
22
$Id$
 
23
"""
 
24
 
 
25
import urllib
 
26
import base64
 
27
import calendar
 
28
from datetime import datetime, date, time, timedelta
 
29
 
 
30
import transaction
 
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
 
61
 
 
62
from zc.table.column import GetterColumn
 
63
from zc.table import table
 
64
 
 
65
from schooltool.common import SchoolToolMessage as _
 
66
 
 
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
 
106
 
 
107
#
 
108
# Constants
 
109
#
 
110
 
 
111
month_names = {
 
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")}
 
116
 
 
117
day_of_week_names = {
 
118
    0: _("Monday"), 1: _("Tuesday"), 2: _("Wednesday"), 3: _("Thursday"),
 
119
    4: _("Friday"), 5: _("Saturday"), 6: _("Sunday")}
 
120
 
 
121
short_day_of_week_names = {
 
122
    0: _("Mon"), 1: _("Tue"), 2: _("Wed"), 3: _("Thu"),
 
123
    4: _("Fri"), 5: _("Sat"), 6: _("Sun"),
 
124
}
 
125
 
 
126
 
 
127
#
 
128
# Traversal
 
129
#
 
130
 
 
131
class ToCalendarTraverser(object):
 
132
    """A traverser that allows to traverse to a calendar owner's calendar."""
 
133
 
 
134
    adapts(IHaveCalendar)
 
135
    implements(IBrowserPublisher)
 
136
 
 
137
    def __init__(self, context, request):
 
138
        self.context = context
 
139
        self.request = request
 
140
 
 
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)
 
147
            if view is not None:
 
148
                return view
 
149
 
 
150
        raise NotFound(self.context, name, request)
 
151
 
 
152
    def browserDefault(self, request):
 
153
        return self.context, ('index.html', )
 
154
 
 
155
 
 
156
class CalendarTraverser(object):
 
157
    """A smart calendar traverser that can handle dates in the URL."""
 
158
 
 
159
    adapts(ICalendar)
 
160
    implements(IBrowserPublisher)
 
161
 
 
162
    queryMultiAdapter = staticmethod(queryMultiAdapter)
 
163
 
 
164
    def __init__(self, context, request):
 
165
        self.context = context
 
166
        self.request = request
 
167
 
 
168
    def browserDefault(self, request):
 
169
        return self.context, ('daily.html', )
 
170
 
 
171
    def publishTraverse(self, request, name):
 
172
        view_name = self.getHTMLViewByDate(request, name)
 
173
        if not view_name:
 
174
            view_name = self.getPDFViewByDate(request, name)
 
175
        if view_name:
 
176
            return self.queryMultiAdapter((self.context, request),
 
177
                                          name=view_name)
 
178
 
 
179
        view = queryMultiAdapter((self.context, request), name=name)
 
180
        if view is not None:
 
181
            return view
 
182
 
 
183
        try:
 
184
            event_id = base64.decodestring(name).decode("utf-8")
 
185
        except:
 
186
            raise NotFound(self.context, name, request)
 
187
 
 
188
        try:
 
189
            return self.context.find(event_id)
 
190
        except KeyError:
 
191
            raise NotFound(self.context, event_id, request)
 
192
 
 
193
    def getHTMLViewByDate(self, request, name):
 
194
        """Get HTML view name from URL component."""
 
195
        return self.getViewByDate(request, name, 'html')
 
196
 
 
197
    def getPDFViewByDate(self, request, name):
 
198
        """Get PDF view name from URL component."""
 
199
        if not name.endswith('.pdf'):
 
200
            return None
 
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
 
205
        else:
 
206
            return view_name
 
207
 
 
208
    def getViewByDate(self, request, name, suffix):
 
209
        """Get view name from URL component."""
 
210
        parts = name.split('-')
 
211
 
 
212
        if len(parts) == 2 and parts[1].startswith('w'): # a week was given
 
213
            try:
 
214
                year = int(parts[0])
 
215
                week = int(parts[1][1:])
 
216
            except ValueError:
 
217
                return
 
218
            request.form['date'] = self.getWeek(year, week).isoformat()
 
219
            return 'weekly.%s' % suffix
 
220
 
 
221
        # a year, month or day might have been given
 
222
        try:
 
223
            parts = [int(part) for part in parts]
 
224
        except ValueError:
 
225
            return
 
226
        if not parts:
 
227
            return
 
228
        parts = tuple(parts)
 
229
 
 
230
        if not (1900 < parts[0] < 2100):
 
231
            return
 
232
 
 
233
        if len(parts) == 1:
 
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
 
242
 
 
243
    def getWeek(self, year, week):
 
244
        """Get the start of a week by week number.
 
245
 
 
246
        The Monday of the given week is returned as a datetime.date.
 
247
 
 
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)
 
255
 
 
256
        """
 
257
        return weeknum_bounds(year, week)[0]
 
258
 
 
259
 
 
260
#
 
261
# Calendar displaying backend
 
262
#
 
263
 
 
264
class EventForDisplay(object):
 
265
    """A decorated calendar event."""
 
266
 
 
267
    implements(IEventForDisplay)
 
268
 
 
269
    cssClass = 'event'  # at the moment no other classes are used
 
270
 
 
271
    def __init__(self, event, request, color1, color2, source_calendar,
 
272
                 timezone):
 
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)
 
285
        self.context = event
 
286
        self.dtend = event.dtstart + event.duration
 
287
        self.color1 = color1
 
288
        self.color2 = color2
 
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)
 
294
 
 
295
    def __cmp__(self, other):
 
296
        return cmp(self.context.dtstart, other.context.dtstart)
 
297
 
 
298
    def __getattr__(self, name):
 
299
        return getattr(self.context, name)
 
300
 
 
301
    @property
 
302
    def title(self):
 
303
        return self.context.title
 
304
 
 
305
    def getBooker(self):
 
306
        """Return the booker."""
 
307
        event = ISchoolToolCalendarEvent(self.context, None)
 
308
        if event:
 
309
            return event.owner
 
310
 
 
311
    def getBookedResources(self):
 
312
        """Return the list of booked resources."""
 
313
        booker = ISchoolToolCalendarEvent(self.context, None)
 
314
        if booker:
 
315
            return booker.resources
 
316
        else:
 
317
            return ()
 
318
 
 
319
    def viewLink(self):
 
320
        """Return the URL where you can view this event.
 
321
 
 
322
        Returns None if the event is not viewable (e.g. it is a timetable
 
323
        event).
 
324
        """
 
325
        if self.context.__parent__ is None:
 
326
            return None
 
327
 
 
328
        if IEditCalendar.providedBy(self.source_calendar):
 
329
            # display the link of the source calendar (the event is a
 
330
            # booking event)
 
331
            return '%s/%s' % (absoluteURL(self.source_calendar, self.request),
 
332
                              urllib.quote(self.__name__))
 
333
 
 
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)
 
337
 
 
338
 
 
339
    def editLink(self):
 
340
        """Return the URL where you can edit this event.
 
341
 
 
342
        Returns None if the event is not editable (e.g. it is a timetable
 
343
        event).
 
344
        """
 
345
        if self.context.__parent__ is None:
 
346
            return None
 
347
        return '%s/edit.html?date=%s' % (
 
348
                        absoluteURL(self.context, self.request),
 
349
                        self.dtstarttz.strftime('%Y-%m-%d'))
 
350
 
 
351
    def deleteLink(self):
 
352
        """Return the URL where you can delete this event.
 
353
 
 
354
        Returns None if the event is not deletable (e.g. it is a timetable
 
355
        event).
 
356
        """
 
357
        if self.context.__parent__ is None:
 
358
            return None
 
359
        return '%s/delete.html?event_id=%s&date=%s' % (
 
360
                        absoluteURL(self.source_calendar, self.request),
 
361
                        self.unique_id,
 
362
                        self.dtstarttz.strftime('%Y-%m-%d'))
 
363
 
 
364
    def linkAllowed(self):
 
365
        """Return the URL where you can view/edit this event.
 
366
 
 
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.
 
369
        """
 
370
 
 
371
        try:
 
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()
 
377
            else:
 
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()
 
383
 
 
384
    def bookingLink(self):
 
385
        """Return the URL where you can book resources for this event.
 
386
 
 
387
        Returns None if you can't do that.
 
388
        """
 
389
        if self.context.__parent__ is None:
 
390
            return None
 
391
        return '%s/booking.html?date=%s' % (
 
392
                        absoluteURL(self.context, self.request),
 
393
                        self.dtstarttz.strftime('%Y-%m-%d'))
 
394
 
 
395
    def renderShort(self):
 
396
        """Short representation of the event for the monthly view."""
 
397
        if self.dtstarttz.date() == self.dtendtz.date():
 
398
            fmt = '%H:%M'
 
399
        else:
 
400
            fmt = '%b&nbsp;%d'
 
401
        return "%s (%s&ndash;%s)" % (self.shortTitle,
 
402
                                     self.dtstarttz.strftime(fmt),
 
403
                                     self.dtendtz.strftime(fmt))
 
404
 
 
405
 
 
406
class CalendarDay(object):
 
407
    """A single day in a calendar.
 
408
 
 
409
    Attributes:
 
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).
 
414
    """
 
415
 
 
416
    def __init__(self, date, events=None):
 
417
        if events is None:
 
418
            events = []
 
419
        self.date = date
 
420
        self.events = events
 
421
        day_of_week = day_of_week_names[date.weekday()]
 
422
 
 
423
    def __cmp__(self, other):
 
424
        return cmp(self.date, other.date)
 
425
 
 
426
    def today(self):
 
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 ''
 
431
 
 
432
 
 
433
#
 
434
# Calendar display views
 
435
#
 
436
 
 
437
class CalendarViewBase(BrowserView):
 
438
    """A base class for the calendar views.
 
439
 
 
440
    This class provides functionality that is useful to several calendar views.
 
441
    """
 
442
 
 
443
    __used_for__ = ISchoolToolCalendar
 
444
 
 
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
 
447
 
 
448
    def __init__(self, context, request):
 
449
        self.context = context
 
450
        self.request = request
 
451
 
 
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
 
458
 
 
459
        self._days_cache = None
 
460
 
 
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)
 
471
        else:
 
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'])
 
475
        return url
 
476
 
 
477
    def pdfURL(self):
 
478
        if pdfcal.disabled:
 
479
            return None
 
480
        else:
 
481
            assert self.cal_type != 'yearly'
 
482
            url = self.calURL(self.cal_type, cursor=self.cursor)
 
483
            return url + '.pdf'
 
484
 
 
485
    def dayTitle(self, day):
 
486
        formatter = getMultiAdapter((day, self.request), name='fullDate')
 
487
        return formatter()
 
488
 
 
489
    __url = None
 
490
 
 
491
    def calURL(self, cal_type, cursor=None):
 
492
        """Construct a URL to a calendar at cursor."""
 
493
        if cursor is None:
 
494
            session = ISession(self.request)['calendar']
 
495
            dt = session.get('last_visited_day')
 
496
            if dt and self.inCurrentPeriod(dt):
 
497
                cursor = dt
 
498
            else:
 
499
                cursor = self.cursor
 
500
 
 
501
        if self.__url is None:
 
502
            self.__url = absoluteURL(self.context, self.request)
 
503
 
 
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)
 
512
        else:
 
513
            raise ValueError(cal_type)
 
514
 
 
515
        return '%s/%s' % (self.__url, dt)
 
516
 
 
517
    def _initDaysCache(self):
 
518
        """Initialize the _days_cache attribute.
 
519
 
 
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.
 
523
 
 
524
        The base implementation designates three months around self.cursor as
 
525
        the time interval for caching.
 
526
        """
 
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)
 
534
 
 
535
    def update(self):
 
536
        """Figure out which date we're supposed to be showing.
 
537
 
 
538
        Can extract date from the request or the session.  Defaults on today.
 
539
        """
 
540
        session = ISession(self.request)['calendar']
 
541
        dt = session.get('last_visited_day')
 
542
 
 
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()
 
547
        else:
 
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
 
550
            # specified.
 
551
            self.cursor = parse_date(self.request['date'])
 
552
 
 
553
        if not (dt and self.inCurrentPeriod(dt)):
 
554
            session['last_visited_day'] = self.cursor
 
555
 
 
556
        self._initDaysCache()
 
557
 
 
558
    def inCurrentPeriod(self, dt):
 
559
        """Return True if dt is in the period currently being shown."""
 
560
        raise NotImplementedError("override in subclasses")
 
561
 
 
562
    def pigeonhole(self, intervals, days):
 
563
        """Sort CalendarDay objects into date intervals.
 
564
 
 
565
        Can be used to sort a list of CalendarDay objects into weeks,
 
566
        months, quarters etc.
 
567
 
 
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.
 
571
 
 
572
        Returns a list of CalendarDay object lists -- one list for
 
573
        each interval.
 
574
        """
 
575
        results = []
 
576
        for start, end in intervals:
 
577
            results.append([day for day in days if start <= day.date < end])
 
578
        return results
 
579
 
 
580
    def getWeek(self, dt):
 
581
        """Return the week that contains the day dt.
 
582
 
 
583
        Returns a list of CalendarDay objects.
 
584
        """
 
585
        start = week_start(dt, self.first_day_of_week)
 
586
        end = start + timedelta(7)
 
587
        return self.getDays(start, end)
 
588
 
 
589
    def getMonth(self, dt, days=None):
 
590
        """Return a nested list of days in the month that contains dt.
 
591
 
 
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
 
594
        the current month.
 
595
        """
 
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
 
599
 
 
600
        week_intervals = []
 
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
 
605
 
 
606
        end_of_display_month = start_of_week
 
607
        if not days:
 
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)
 
613
        return weeks
 
614
 
 
615
    def getYear(self, dt):
 
616
        """Return the current year.
 
617
 
 
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.
 
620
        """
 
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)
 
627
 
 
628
        day_cache = self.getDays(year_start_day_padded_weeks,
 
629
                                 year_end_day_padded_weeks)
 
630
 
 
631
        quarters = []
 
632
        for q in range(4):
 
633
            quarter = [self.getMonth(date(dt.year, month + (q * 3), 1),
 
634
                                     day_cache)
 
635
                       for month in range(1, 4)]
 
636
            quarters.append(quarter)
 
637
        return quarters
 
638
 
 
639
    _day_events = None # cache
 
640
 
 
641
    def dayEvents(self, date):
 
642
        """Return events for a day sorted by start time.
 
643
 
 
644
        Events spanning several days and overlapping with this day
 
645
        are included.
 
646
        """
 
647
        if self._day_events is None:
 
648
            self._day_events = {}
 
649
 
 
650
        if date in self._day_events:
 
651
            day = self._day_events[date]
 
652
        else:
 
653
            day = self.getDays(date, date + timedelta(1))[0]
 
654
            self._day_events[date] = day
 
655
        return day.events
 
656
 
 
657
    _calendars = None # cache
 
658
 
 
659
    def getCalendars(self):
 
660
        providers = subscribers((self.context, self.request), ICalendarProvider)
 
661
 
 
662
        if self._calendars is None:
 
663
            result = []
 
664
            for provider in providers:
 
665
                result += provider.getCalendars()
 
666
            self._calendars = result
 
667
        return self._calendars
 
668
 
 
669
    def getEvents(self, start_dt, end_dt):
 
670
        """Get a list of EventForDisplay objects for a selected time interval.
 
671
 
 
672
        `start_dt` and `end_dt` (datetime objects) are bounds (half-open) for
 
673
        the result.
 
674
        """
 
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.
 
685
                    continue
 
686
                yield EventForDisplay(event, self.request, color1, color2,
 
687
                                      calendar, self.timezone)
 
688
 
 
689
    def getDays(self, start, end):
 
690
        """Get a list of CalendarDay objects for a selected period of time.
 
691
 
 
692
        Uses the _days_cache.
 
693
 
 
694
        `start` and `end` (date objects) are bounds (half-open) for the result.
 
695
 
 
696
        Events spanning more than one day get included in all days they
 
697
        overlap.
 
698
        """
 
699
        if self._days_cache is None:
 
700
            return self._getDays(start, end)
 
701
        else:
 
702
            return self._days_cache.getDays(start, end)
 
703
 
 
704
    def _getDays(self, start, end):
 
705
        """Get a list of CalendarDay objects for a selected period of time.
 
706
 
 
707
        No caching.
 
708
 
 
709
        `start` and `end` (date objects) are bounds (half-open) for the result.
 
710
 
 
711
        Events spanning more than one day get included in all days they
 
712
        overlap.
 
713
        """
 
714
        events = {}
 
715
        day = start
 
716
        while day < end:
 
717
            events[day] = []
 
718
            day += timedelta(1)
 
719
 
 
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 --)  |     |
 
727
            # |     |  ^  |     |  ^  |     |
 
728
            # |     |  `dtstart |  `dtend   |
 
729
            #        ^^^^^       ^^^^^
 
730
            #      first_day   last_day
 
731
            #
 
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
 
737
            #                                      and duration > 0
 
738
            #               dtend.date()           otherwise
 
739
            dtend = event.dtend
 
740
            if event.allday:
 
741
                first_day = event.dtstart.date()
 
742
                last_day = max(first_day, (dtend - dtend.resolution).date())
 
743
            else:
 
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))
 
754
            while day < limit:
 
755
                events[day].append(event)
 
756
                day += timedelta(1)
 
757
 
 
758
        days = []
 
759
        day = start
 
760
        while day < end:
 
761
            events[day].sort()
 
762
            days.append(CalendarDay(day, events[day]))
 
763
            day += timedelta(1)
 
764
        return days
 
765
 
 
766
    def prevMonth(self):
 
767
        """Return the first day of the previous month."""
 
768
        return prev_month(self.cursor)
 
769
 
 
770
    def nextMonth(self):
 
771
        """Return the first day of the next month."""
 
772
        return next_month(self.cursor)
 
773
 
 
774
    def prevDay(self):
 
775
        return self.cursor - timedelta(1)
 
776
 
 
777
    def nextDay(self):
 
778
        return self.cursor + timedelta(1)
 
779
 
 
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,
 
784
                 'label': year,
 
785
                 'href': self.calURL('yearly', date(year, 1, 1))}
 
786
                for year in range(this_year - 2, this_year + 3)]
 
787
 
 
788
    def getJumpToMonths(self):
 
789
        """Return a list of months for the drop down in the jump portlet."""
 
790
        year = self.cursor.year
 
791
        return [{'label': v,
 
792
                 'href': self.calURL('monthly', date(year, k, 1))}
 
793
                for k, v in month_names.items()]
 
794
 
 
795
    def monthTitle(self, date):
 
796
        return month_names[date.month]
 
797
 
 
798
    def renderRow(self, week, month):
 
799
        """Do some HTML rendering in Python for performance.
 
800
 
 
801
        This gains us 0.4 seconds out of 0.6 on my machine.
 
802
        Here is the original piece of ZPT:
 
803
 
 
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'
 
810
                                           or  'cal_yearly_day')
 
811
                                        + (day.today() and ' today' or '')"/>
 
812
         </td>
 
813
        """
 
814
        result = []
 
815
 
 
816
        for day in week:
 
817
            result.append('<td class="cal_yearly_day">')
 
818
            if day.date.month == month:
 
819
                if len(day.events):
 
820
                    cssClass = 'cal_yearly_day_busy'
 
821
                else:
 
822
                    cssClass = 'cal_yearly_day'
 
823
                if day.today():
 
824
                    cssClass += ' today'
 
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,
 
830
                               day.date.day))
 
831
            result.append('</td>')
 
832
        return "\n".join(result)
 
833
 
 
834
    def canAddEvents(self):
 
835
        """Return True if current viewer can add events to this calendar."""
 
836
        return canAccess(self.context, "addEvent")
 
837
 
 
838
    def canRemoveEvents(self):
 
839
        """Return True if current viewer can remove events to this calendar."""
 
840
        return canAccess(self.context, "removeEvent")
 
841
 
 
842
 
 
843
class DaysCache(object):
 
844
    """A cache of calendar days.
 
845
 
 
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.
 
850
 
 
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.
 
853
    """
 
854
 
 
855
    def __init__(self, expensive_getDays, cache_first, cache_last):
 
856
        """Create a cache.
 
857
 
 
858
        ``expensive_getDays`` is a function that takes a half-open date range
 
859
        and returns a list of CalendarDay objects.
 
860
 
 
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``.
 
864
        """
 
865
        self.expensive_getDays = expensive_getDays
 
866
        self.cache_first = cache_first
 
867
        self.cache_last = cache_last
 
868
        self._cache = None
 
869
 
 
870
    def extend(self, first, last):
 
871
        """Extend the cache.
 
872
 
 
873
        You should call ``extend`` before any calls to ``getDays``, and not
 
874
        after.
 
875
        """
 
876
        self.cache_first = min(self.cache_first, first)
 
877
        self.cache_last = max(self.cache_last, last)
 
878
 
 
879
    def getDays(self, first, last):
 
880
        """Return a list of calendar days from ``first`` to ``last``.
 
881
 
 
882
        If the interval from ``first`` to ``last`` falls into the cached
 
883
        range, and the cache is already computed, this operation becomes
 
884
        fast.
 
885
 
 
886
        If the interval is not in cache, delegates to the expensive_getDays
 
887
        computation.
 
888
        """
 
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,
 
893
                                                     self.cache_last)
 
894
            first_idx = (first - self.cache_first).days
 
895
            last_idx = (last - self.cache_first).days
 
896
            return self._cache[first_idx:last_idx]
 
897
        else:
 
898
            return self.expensive_getDays(first, last)
 
899
 
 
900
 
 
901
class WeeklyCalendarView(CalendarViewBase):
 
902
    """A view that shows one week of the calendar."""
 
903
    implements(IHaveEventLegend)
 
904
 
 
905
    __used_for__ = ISchoolToolCalendar
 
906
 
 
907
    cal_type = 'weekly'
 
908
 
 
909
    next_title = _("Next week")
 
910
    current_title = _("Current week")
 
911
    prev_title = _("Previous week")
 
912
 
 
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")
 
916
 
 
917
    non_timetable_template = ViewPageTemplateFile("templates/cal_weekly.pt")
 
918
    timetable_template = ViewPageTemplateFile("templates/cal_weekly_timetable.pt")
 
919
 
 
920
    def __call__(self):
 
921
        app = getSchoolToolApplication()
 
922
        if app['ttschemas'].default_id is not None:
 
923
            return self.timetable_template()
 
924
        return self.non_timetable_template()
 
925
 
 
926
    def inCurrentPeriod(self, dt):
 
927
        # XXX wrong if week starts on Sunday.
 
928
        return dt.isocalendar()[:2] == self.cursor.isocalendar()[:2]
 
929
 
 
930
    def title(self):
 
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]})
 
937
        return msg
 
938
 
 
939
    def prev(self):
 
940
        """Return the link for the previous week."""
 
941
        return self.calURL('weekly', self.cursor - timedelta(weeks=1))
 
942
 
 
943
    def current(self):
 
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())
 
948
 
 
949
    def next(self):
 
950
        """Return the link for the next week."""
 
951
        return self.calURL('weekly', self.cursor + timedelta(weeks=1))
 
952
 
 
953
    def getCurrentWeek(self):
 
954
        """Return the current week as a list of CalendarDay objects."""
 
955
        return self.getWeek(self.cursor)
 
956
 
 
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,
 
960
                                    event.duration,
 
961
                                    event.title),
 
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
 
967
        return new_event
 
968
 
 
969
    def getCurrentWeekEvents(self, eventCheck):
 
970
        week = self.getWeek(self.cursor)
 
971
        week_by_rows = []
 
972
        start_times = []
 
973
 
 
974
        for day in week:
 
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([])
 
981
 
 
982
        start_times.sort()
 
983
        for day in week:
 
984
            events_in_day = []
 
985
            for index in range(0, len(start_times)):
 
986
                block = []
 
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))
 
993
 
 
994
                if block == []:
 
995
                    block = [None]
 
996
 
 
997
                events_in_day.append(block)
 
998
 
 
999
            row_num = 0
 
1000
            for event in events_in_day:
 
1001
                week_by_rows[row_num].append(event)
 
1002
                row_num += 1
 
1003
 
 
1004
        self.formatCurrentWeekEvents(week_by_rows)
 
1005
        return week_by_rows
 
1006
 
 
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."""
 
1010
        row_num = 0
 
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([])
 
1016
 
 
1017
            for block in week_by_rows[row_num]:
 
1018
                for event in block:
 
1019
                    if event is not None:
 
1020
                        non_empty_row = True
 
1021
                        break
 
1022
 
 
1023
            if non_empty_row:
 
1024
                row_num += 1
 
1025
            else:
 
1026
                del week_by_rows[row_num]
 
1027
 
 
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)
 
1032
 
 
1033
    def getCurrentWeekTimetableEvents(self):
 
1034
        """Return the current week's timetable events in formatted lists."""
 
1035
        week = self.getWeek(self.cursor)
 
1036
        week_by_rows = []
 
1037
        view = getMultiAdapter((self.context, self.request),
 
1038
                                name='daily_calendar_rows')
 
1039
        empty_begin_days = 0 #added 2008-10-18 by Daniel Hoeger
 
1040
        for day in week:
 
1041
            periods = view.getPeriods(day.date)
 
1042
            events_in_day = []
 
1043
            start_times = []
 
1044
            
 
1045
            #added 2008-10-18 by Daniel Hoeger
 
1046
            if periods == [] and week_by_rows == []:
 
1047
                empty_begin_days += 1
 
1048
 
 
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([])
 
1056
            
 
1057
            for index in range(0, len(start_times)-1):
 
1058
                block = []
 
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
 
1065
                           not event.allday):
 
1066
                        block.append(self.cloneEvent(event))
 
1067
                if block == []:
 
1068
                    block = [None]
 
1069
 
 
1070
                events_in_day.append(block)
 
1071
 
 
1072
            row_num = 0
 
1073
            for event in events_in_day:
 
1074
                week_by_rows[row_num].append(event)
 
1075
                row_num += 1
 
1076
   
 
1077
        self.formatCurrentWeekEvents(week_by_rows)
 
1078
        
 
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])
 
1087
               j = 0
 
1088
               while j < len(week_by_row)-empty_begin_days:
 
1089
                   new_week_by_row.append(week_by_row[j])
 
1090
                   j += 1
 
1091
               new_week_by_rows.append(new_week_by_row)
 
1092
           week_by_rows = new_week_by_rows 
 
1093
      
 
1094
        return week_by_rows
 
1095
 
 
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)
 
1100
 
 
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)
 
1110
 
 
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)
 
1122
 
 
1123
 
 
1124
class AtomCalendarView(WeeklyCalendarView):
 
1125
    """View the upcoming week's events in Atom formatted xml."""
 
1126
 
 
1127
    def __call__(self):
 
1128
        return super(WeeklyCalendarView, self).__call__()
 
1129
 
 
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())
 
1135
 
 
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")
 
1140
 
 
1141
    def w3cdtf_datetime_now(self):
 
1142
        return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
 
1143
 
 
1144
 
 
1145
class MonthlyCalendarView(CalendarViewBase):
 
1146
    """Monthly calendar view."""
 
1147
    implements(IHaveEventLegend)
 
1148
 
 
1149
    __used_for__ = ISchoolToolCalendar
 
1150
 
 
1151
    cal_type = 'monthly'
 
1152
 
 
1153
    next_title = _("Next month")
 
1154
    current_title = _("Current month")
 
1155
    prev_title = _("Previous month")
 
1156
 
 
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")
 
1160
 
 
1161
    def inCurrentPeriod(self, dt):
 
1162
        return (dt.year, dt.month) == (self.cursor.year, self.cursor.month)
 
1163
 
 
1164
    def title(self):
 
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})
 
1169
        return msg
 
1170
 
 
1171
    def prev(self):
 
1172
        """Return the link for the previous month."""
 
1173
        return self.calURL('monthly', self.prevMonth())
 
1174
 
 
1175
    def current(self):
 
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())
 
1180
 
 
1181
    def next(self):
 
1182
        """Return the link for the next month."""
 
1183
        return self.calURL('monthly', self.nextMonth())
 
1184
 
 
1185
    def dayOfWeek(self, date):
 
1186
        return day_of_week_names[date.weekday()]
 
1187
 
 
1188
    def weekTitle(self, date):
 
1189
        msg = _('Week ${week_no}',
 
1190
                mapping={'week_no': date.isocalendar()[1]})
 
1191
        return msg
 
1192
 
 
1193
    def getCurrentMonth(self):
 
1194
        """Return the current month as a nested list of CalendarDays."""
 
1195
        return self.getMonth(self.cursor)
 
1196
 
 
1197
 
 
1198
class YearlyCalendarView(CalendarViewBase):
 
1199
    """Yearly calendar view."""
 
1200
 
 
1201
    __used_for__ = ISchoolToolCalendar
 
1202
 
 
1203
    cal_type = 'yearly'
 
1204
 
 
1205
    next_title = _("Next year")
 
1206
    current_title = _("Current year")
 
1207
    prev_title = _("Previous year")
 
1208
 
 
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")
 
1212
 
 
1213
    def pdfURL(self):
 
1214
        return None
 
1215
 
 
1216
    def inCurrentPeriod(self, dt):
 
1217
        return dt.year == self.cursor.year
 
1218
 
 
1219
    def title(self):
 
1220
        return unicode(self.cursor.year)
 
1221
 
 
1222
    def prev(self):
 
1223
        """Return the link for the previous year."""
 
1224
        return self.calURL('yearly', date(self.cursor.year - 1, 1, 1))
 
1225
 
 
1226
    def current(self):
 
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())
 
1231
 
 
1232
    def next(self):
 
1233
        """Return the link for the next year."""
 
1234
        return self.calURL('yearly', date(self.cursor.year + 1, 1, 1))
 
1235
 
 
1236
    def shortDayOfWeek(self, date):
 
1237
        return short_day_of_week_names[date.weekday()]
 
1238
 
 
1239
    def _initDaysCache(self):
 
1240
        """Initialize the _days_cache attribute.
 
1241
 
 
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.
 
1245
 
 
1246
        This implementation designates the year of self.cursor as the time
 
1247
        interval for caching.
 
1248
        """
 
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)
 
1256
 
 
1257
 
 
1258
class DailyCalendarView(CalendarViewBase):
 
1259
    """Daily calendar view.
 
1260
 
 
1261
    The events are presented as boxes on a 'sheet' with rows
 
1262
    representing hours.
 
1263
 
 
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.
 
1267
    """
 
1268
    implements(IHaveEventLegend)
 
1269
 
 
1270
    __used_for__ = ISchoolToolCalendar
 
1271
 
 
1272
    cal_type = 'daily'
 
1273
 
 
1274
    starthour = 8
 
1275
    endhour = 19
 
1276
 
 
1277
    next_title = _("The next day")
 
1278
    current_title = _("Today")
 
1279
    prev_title = _("The previous day")
 
1280
 
 
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")
 
1284
 
 
1285
    def inCurrentPeriod(self, dt):
 
1286
        return dt == self.cursor
 
1287
 
 
1288
    def title(self):
 
1289
        return self.dayTitle(self.cursor)
 
1290
 
 
1291
    def prev(self):
 
1292
        """Return the link for the next day."""
 
1293
        return self.calURL('daily', self.cursor - timedelta(1))
 
1294
 
 
1295
    def current(self):
 
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())
 
1300
 
 
1301
    def next(self):
 
1302
        """Return the link for the previous day."""
 
1303
        return self.calURL('daily', self.cursor + timedelta(1))
 
1304
 
 
1305
    def getColumns(self):
 
1306
        """Return the maximum number of events that are overlapping.
 
1307
 
 
1308
        Extends the event so that start and end times fall on hour
 
1309
        boundaries before calculating overlaps.
 
1310
        """
 
1311
        width = [0] * 24
 
1312
        daystart = datetime.combine(self.cursor, time(tzinfo=utc))
 
1313
        events = self.dayEvents(self.cursor)
 
1314
        for event in events:
 
1315
            t = daystart
 
1316
            dtend = daystart + timedelta(1)
 
1317
            for title, start, duration in self.calendarRows(events):
 
1318
                if start <= event.dtstart < start + duration:
 
1319
                    t = start
 
1320
                if start < event.dtstart + event.duration <= start + duration:
 
1321
                    dtend = start + duration
 
1322
            while True:
 
1323
                width[t.hour] += 1
 
1324
                t += timedelta(hours=1)
 
1325
                if t >= dtend:
 
1326
                    break
 
1327
        return max(width) or 1
 
1328
 
 
1329
    def _setRange(self, events):
 
1330
        """Set the starthour and endhour attributes according to events.
 
1331
 
 
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
 
1334
        list.
 
1335
        """
 
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
 
1346
 
 
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:
 
1356
                    self.endhour = 24
 
1357
 
 
1358
    __cursor = None
 
1359
    __calendar_rows = None
 
1360
 
 
1361
    def calendarRows(self, events):
 
1362
        """Iterate over (title, start, duration) of time slots that make up
 
1363
        the daily calendar.
 
1364
 
 
1365
        Returns a list, caches the answer for subsequent calls.
 
1366
        """
 
1367
        view = getMultiAdapter((self.context, self.request),
 
1368
                                    name='daily_calendar_rows')
 
1369
        return view.calendarRows(self.cursor, self.starthour, self.endhour,
 
1370
                                 events)
 
1371
 
 
1372
    def _getCurrentTime(self):
 
1373
        """Returns current time localized to UTC timezone."""
 
1374
        return utc.localize(datetime.utcnow())
 
1375
 
 
1376
    def getHours(self):
 
1377
        """Return an iterator over the rows of the table.
 
1378
 
 
1379
        Every row is a dict with the following keys:
 
1380
 
 
1381
            'time' -- row label (e.g. 8:00)
 
1382
            'cols' -- sequence of cell values for this row
 
1383
 
 
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
 
1388
 
 
1389
        """
 
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)
 
1396
        slots = Slots()
 
1397
        top = 0
 
1398
        for title, start, duration in self.calendarRows(simple_events):
 
1399
            end = start + duration
 
1400
            hour = start.hour
 
1401
 
 
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:
 
1406
                    del slots[i]
 
1407
 
 
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)
 
1411
                slots.add(event)
 
1412
 
 
1413
            cols = []
 
1414
            # Format the row
 
1415
            for i in range(nr_cols):
 
1416
                ev = slots.get(i, None)
 
1417
                if (ev is not None
 
1418
                    and ev.dtstart < start
 
1419
                    and hour != self.starthour):
 
1420
                    # The event started before this hour (except first row)
 
1421
                    cols.append('')
 
1422
                else:
 
1423
                    # Either None, or new event
 
1424
                    cols.append(ev)
 
1425
 
 
1426
            height = duration.seconds / 900.0
 
1427
            if height < 1.5:
 
1428
                # Do not display the time of the start of the period when there
 
1429
                # is too little space as that looks rather ugly.
 
1430
                title = ''
 
1431
 
 
1432
            active = start <= self._getCurrentTime() < end
 
1433
 
 
1434
            yield {'title': title,
 
1435
                   'cols': tuple(cols),
 
1436
                   'time': start.strftime("%H:%M"),
 
1437
                   'active': active,
 
1438
                   'top': top,
 
1439
                   'height': height,
 
1440
                   # We can trust no period will be longer than a day
 
1441
                   'duration': duration.seconds // 60}
 
1442
 
 
1443
            top += height
 
1444
 
 
1445
    def snapToGrid(self, dt):
 
1446
        """Calculate the position of a datetime on the display grid.
 
1447
 
 
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.
 
1451
 
 
1452
        Clips dt so that it is never outside today's box.
 
1453
        """
 
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.
 
1461
 
 
1462
    def eventTop(self, event):
 
1463
        """Calculate the position of the top of the event block in the display.
 
1464
 
 
1465
        See `snapToGrid`.
 
1466
        """
 
1467
        return self.snapToGrid(event.dtstart.astimezone(self.timezone))
 
1468
 
 
1469
    def eventHeight(self, event, minheight=3):
 
1470
        """Calculate the height of the event block in the display.
 
1471
 
 
1472
        Rounds the height up to a minimum of minheight.
 
1473
 
 
1474
        See `snapToGrid`.
 
1475
        """
 
1476
        dtend = event.dtstart + event.duration
 
1477
        return max(minheight,
 
1478
                   self.snapToGrid(dtend) - self.snapToGrid(event.dtstart))
 
1479
 
 
1480
    def getAllDayEvents(self):
 
1481
        """Get a list of EventForDisplay objects for the all-day events at the
 
1482
        cursors current position.
 
1483
        """
 
1484
        for event in self.dayEvents(self.cursor):
 
1485
            if event.allday:
 
1486
                yield event
 
1487
 
 
1488
 
 
1489
class DailyCalendarRowsView(BrowserView):
 
1490
    """Daily calendar rows view for SchoolTool.
 
1491
 
 
1492
    This view differs from the original view in SchoolBell in that it can
 
1493
    also show day periods instead of hour numbers.
 
1494
    """
 
1495
 
 
1496
    __used_for__ = ISchoolToolCalendar
 
1497
 
 
1498
    def getPersonTimezone(self):
 
1499
        """Return the prefered timezone of the user."""
 
1500
        return ViewPreferences(self.request).timezone
 
1501
 
 
1502
    def getPeriodsForDay(self, date):
 
1503
        """Return a list of timetable periods defined for `date`.
 
1504
 
 
1505
        This function uses the default timetable schema and the appropriate time
 
1506
        period for `date`.
 
1507
 
 
1508
        Retuns a list of (id, dtstart, duration) tuples.  The times
 
1509
        are timezone-aware and in the timezone of the timetable.
 
1510
 
 
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).
 
1514
        """
 
1515
        schooldays = getTermForDate(date)
 
1516
        ttcontainer = getSchoolToolApplication()['ttschemas']
 
1517
        if ttcontainer.default_id is None or schooldays is None:
 
1518
            return []
 
1519
        ttschema = ttcontainer.getDefault()
 
1520
        tttz = timezone(ttschema.timezone)
 
1521
        displaytz = self.getPersonTimezone()
 
1522
 
 
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()
 
1528
 
 
1529
        def resolvePeriods(date):
 
1530
            term = getTermForDate(date)
 
1531
            if not term:
 
1532
                return []
 
1533
 
 
1534
            periods = ttschema.model.periodsInDay(term, ttschema, date)
 
1535
            result = []
 
1536
            for id, tstart, duration in  periods:
 
1537
                dtstart = datetime.combine(date, tstart)
 
1538
                dtstart = tttz.localize(dtstart)
 
1539
                result.append((id, dtstart, duration))
 
1540
            return result
 
1541
 
 
1542
        periods = resolvePeriods(day1)
 
1543
        if day2 != day1:
 
1544
            periods += resolvePeriods(day2)
 
1545
 
 
1546
        result = []
 
1547
 
 
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):
 
1552
                continue
 
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))
 
1559
 
 
1560
        return result
 
1561
 
 
1562
    def getPeriods(self, cursor):
 
1563
        """Return the date we get from getPeriodsForDay.
 
1564
 
 
1565
        Checks user preferences, returns an empty list if no user is
 
1566
        logged in.
 
1567
        """
 
1568
        person = IPerson(self.request.principal, None)
 
1569
        if (person is not None and
 
1570
            IPersonPreferences(person).cal_periods):
 
1571
            return self.getPeriodsForDay(cursor)
 
1572
        else:
 
1573
            return []
 
1574
 
 
1575
    def _addPeriodsToRows(self, rows, periods, events):
 
1576
        """Populate the row list with rows from periods."""
 
1577
        tz = self.getPersonTimezone()
 
1578
 
 
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:
 
1585
                    rows.remove(point)
 
1586
            if pstart not in rows:
 
1587
                rows.append(pstart)
 
1588
            if pend not in rows:
 
1589
                rows.append(pend)
 
1590
        rows.sort()
 
1591
        return rows
 
1592
 
 
1593
    def calendarRows(self, cursor, starthour, endhour, events):
 
1594
        """Iterate over (title, start, duration) of time slots that make up
 
1595
        the daily calendar.
 
1596
 
 
1597
        Returns a generator.
 
1598
        """
 
1599
        tz = self.getPersonTimezone()
 
1600
        periods = self.getPeriods(cursor)
 
1601
 
 
1602
        daystart = tz.localize(datetime.combine(cursor, time()))
 
1603
        rows = [daystart + timedelta(hours=hour)
 
1604
                for hour in range(starthour, endhour+1)]
 
1605
 
 
1606
        if periods:
 
1607
            rows = self._addPeriodsToRows(rows, periods, events)
 
1608
 
 
1609
        calendarRows = []
 
1610
 
 
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]))
 
1617
            else:
 
1618
                duration = end - start
 
1619
                calendarRows.append(('%d:%02d' % (start.hour, start.minute),
 
1620
                                     start, duration))
 
1621
            start = end
 
1622
        return calendarRows
 
1623
 
 
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)
 
1628
 
 
1629
 
 
1630
class CalendarListSubscriber(object):
 
1631
    """A subscriber that can tell which calendars should be displayed.
 
1632
 
 
1633
    This subscriber includes composite timetable calendars, overlaid
 
1634
    calendars and the calendar you are looking at.
 
1635
    """
 
1636
 
 
1637
    def __init__(self, context, request):
 
1638
        self.context = context
 
1639
        self.request = request
 
1640
 
 
1641
    def getCalendars(self):
 
1642
        """Get a list of calendars to display.
 
1643
 
 
1644
        Yields tuples (calendar, color1, color2).
 
1645
        """
 
1646
        # personal calendar
 
1647
        yield (self.context, '#9db8d2', '#7590ae')
 
1648
 
 
1649
        parent = getParent(self.context)
 
1650
 
 
1651
        user = IPerson(self.request.principal, None)
 
1652
        if user is None:
 
1653
            return # unauthenticated user
 
1654
 
 
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
 
1659
 
 
1660
        for item in user.overlaid_calendars:
 
1661
            if canAccess(item.calendar, '__iter__'):
 
1662
                # overlaid calendars
 
1663
                if item.show:
 
1664
                    yield (item.calendar, item.color1, item.color2)
 
1665
 
 
1666
 
 
1667
#
 
1668
# Calendar modification views
 
1669
#
 
1670
 
 
1671
 
 
1672
class EventDeleteView(BrowserView):
 
1673
    """A view for deleting events."""
 
1674
 
 
1675
    __used_for__ = ISchoolToolCalendar
 
1676
 
 
1677
    recevent_template = ViewPageTemplateFile("templates/recevent_delete.pt")
 
1678
    simple_event_template = ViewPageTemplateFile("templates/simple_event_delete.pt")
 
1679
 
 
1680
    def __call__(self):
 
1681
        event_id = self.request['event_id']
 
1682
        date = parse_date(self.request['date'])
 
1683
        self.event = self._findEvent(event_id)
 
1684
 
 
1685
        if self.event is None:
 
1686
            # The event was not found.
 
1687
            return self._redirectBack()
 
1688
 
 
1689
        if self.event.recurrence is None or self.event.__parent__ != self.context:
 
1690
            return self._deleteSimpleEvent(self.event)
 
1691
        else:
 
1692
            # The event is recurrent, we might need to show a form.
 
1693
            return self._deleteRepeatingEvent(self.event, date)
 
1694
 
 
1695
    def _findEvent(self, event_id):
 
1696
        """Find an event that has the id event_id.
 
1697
 
 
1698
        First the event is searched for in the current calendar and then,
 
1699
        overlaid calendars if any.
 
1700
 
 
1701
        If no event with the given id is found, None is returned.
 
1702
        """
 
1703
        try:
 
1704
            return self.context.find(event_id)
 
1705
        except KeyError:
 
1706
            pass
 
1707
 
 
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)
 
1712
 
 
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)),
 
1721
                                       count=None)
 
1722
        elif 'CURRENT' in self.request:
 
1723
            exceptions = event.recurrence.exceptions + (date, )
 
1724
            self._modifyRecurrenceRule(event, exceptions=exceptions)
 
1725
        else:
 
1726
            return self.recevent_template()
 
1727
 
 
1728
        # We did our job, redirect back to the calendar view.
 
1729
        return self._redirectBack()
 
1730
 
 
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))
 
1737
        else:
 
1738
            return self.simple_event_template()
 
1739
 
 
1740
        # We did our job, redirect back to the calendar view.
 
1741
        return self._redirectBack()
 
1742
 
 
1743
    def _modifyRecurrenceRule(self, event, **kwargs):
 
1744
        """Modify the recurrence rule of an event.
 
1745
 
 
1746
        If the event does not have any recurrences afterwards, it is removed
 
1747
        from the parent calendar
 
1748
        """
 
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))
 
1755
 
 
1756
 
 
1757
class Slots(dict):
 
1758
    """A dict with automatic key selection.
 
1759
 
 
1760
    The add method automatically selects the lowest unused numeric key
 
1761
    (starting from 0).
 
1762
 
 
1763
    Example:
 
1764
 
 
1765
      >>> s = Slots()
 
1766
      >>> s.add("first")
 
1767
      >>> s
 
1768
      {0: 'first'}
 
1769
 
 
1770
      >>> s.add("second")
 
1771
      >>> s
 
1772
      {0: 'first', 1: 'second'}
 
1773
 
 
1774
    The keys can be reused:
 
1775
 
 
1776
      >>> del s[0]
 
1777
      >>> s.add("third")
 
1778
      >>> s
 
1779
      {0: 'third', 1: 'second'}
 
1780
 
 
1781
    """
 
1782
 
 
1783
    def add(self, obj):
 
1784
        i = 0
 
1785
        while i in self:
 
1786
            i += 1
 
1787
        self[i] = obj
 
1788
 
 
1789
 
 
1790
class CalendarEventView(BrowserView):
 
1791
    """View for single events."""
 
1792
 
 
1793
    # XXX what are these used for?
 
1794
    color1 = '#9db8d2'
 
1795
    color2 = '#7590ae'
 
1796
 
 
1797
    def __init__(self, context, request):
 
1798
        self.context = context
 
1799
        self.request = request
 
1800
 
 
1801
        self.preferences = ViewPreferences(request)
 
1802
 
 
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)
 
1807
 
 
1808
        dayformat = '%A, ' + self.preferences.dateformat
 
1809
        self.day = unicode(self.dtstart.strftime(dayformat))
 
1810
 
 
1811
        self.display = EventForDisplay(context, self.request,
 
1812
                                       self.color1, self.color2,
 
1813
                                       context.__parent__,
 
1814
                                       timezone=self.preferences.timezone)
 
1815
 
 
1816
 
 
1817
class ICalendarEventAddForm(Interface):
 
1818
    """Schema for event adding form."""
 
1819
 
 
1820
    title = TextLine(
 
1821
        title=_("Title"),
 
1822
        required=False)
 
1823
    allday = Bool(
 
1824
        title=_("All day"),
 
1825
        required=False)
 
1826
    start_date = Date(
 
1827
        title=_("Date"),
 
1828
        required=False)
 
1829
    start_time = TextLine(
 
1830
        title=_("Time"),
 
1831
        description=_("Start time in 24h format"),
 
1832
        required=False)
 
1833
 
 
1834
    duration = Int(
 
1835
        title=_("Duration"),
 
1836
        required=False,
 
1837
        default=60)
 
1838
 
 
1839
    duration_type = Choice(
 
1840
        title=_("Duration Type"),
 
1841
        required=False,
 
1842
        default="minutes",
 
1843
        vocabulary=vocabulary([("minutes", _("Minutes")),
 
1844
                               ("hours", _("Hours")),
 
1845
                               ("days", _("Days"))]))
 
1846
 
 
1847
    location = TextLine(
 
1848
        title=_("Location"),
 
1849
        required=False)
 
1850
 
 
1851
    description = HtmlFragment(
 
1852
        title=_("Description"),
 
1853
        required=False)
 
1854
 
 
1855
    # Recurrence
 
1856
    recurrence = Bool(
 
1857
        title=_("Recurring"),
 
1858
        required=False)
 
1859
 
 
1860
    recurrence_type = Choice(
 
1861
        title=_("Recurs every"),
 
1862
        required=True,
 
1863
        default="daily",
 
1864
        vocabulary=vocabulary([("daily", _("Day")),
 
1865
                               ("weekly", _("Week")),
 
1866
                               ("monthly", _("Month")),
 
1867
                               ("yearly", _("Year"))]))
 
1868
 
 
1869
    interval = Int(
 
1870
        title=_("Repeat every"),
 
1871
        required=False,
 
1872
        default=1)
 
1873
 
 
1874
    range = Choice(
 
1875
        title=_("Range"),
 
1876
        required=False,
 
1877
        default="forever",
 
1878
        vocabulary=vocabulary([("count", _("Count")),
 
1879
                               ("until", _("Until")),
 
1880
                               ("forever", _("forever"))]))
 
1881
 
 
1882
    count = Int(
 
1883
        title=_("Number of events"),
 
1884
        required=False)
 
1885
 
 
1886
    until = Date(
 
1887
        title=_("Repeat until"),
 
1888
        required=False)
 
1889
 
 
1890
    weekdays = List(
 
1891
        title=_("Weekdays"),
 
1892
        required=False,
 
1893
        value_type=Choice(
 
1894
            title=_("Weekday"),
 
1895
            vocabulary=vocabulary([(0, _("Mon")),
 
1896
                                   (1, _("Tue")),
 
1897
                                   (2, _("Wed")),
 
1898
                                   (3, _("Thu")),
 
1899
                                   (4, _("Fri")),
 
1900
                                   (5, _("Sat")),
 
1901
                                   (6, _("Sun"))])))
 
1902
 
 
1903
    monthly = Choice(
 
1904
        title=_("Monthly"),
 
1905
        default="monthday",
 
1906
        required=False,
 
1907
        vocabulary=vocabulary([("monthday", "md"),
 
1908
                               ("weekday", "wd"),
 
1909
                               ("lastweekday", "lwd")]))
 
1910
 
 
1911
    exceptions = Text(
 
1912
        title=_("Exception dates"),
 
1913
        required=False)
 
1914
 
 
1915
 
 
1916
class CalendarEventViewMixin(object):
 
1917
    """A mixin that holds the code common to CalendarEventAdd and Edit Views."""
 
1918
 
 
1919
    timezone = utc
 
1920
 
 
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
 
1933
 
 
1934
    def _requireField(self, name, errors):
 
1935
        """If widget has no input, WidgetInputError is set.
 
1936
 
 
1937
        Also adds the exception to the `errors` list.
 
1938
        """
 
1939
        widget = getattr(self, name + '_widget')
 
1940
        field = widget.context
 
1941
        try:
 
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
 
1947
            errors.append(e)
 
1948
 
 
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')
 
1955
 
 
1956
    def weekdayChecked(self, weekday):
 
1957
        """Return True if the given weekday should be checked.
 
1958
 
 
1959
        The weekday of start_date is always checked, others can be selected by
 
1960
        the user.
 
1961
 
 
1962
        Used to format checkboxes for weekly recurrences.
 
1963
        """
 
1964
        return (int(weekday) in self.weekdays_widget._getFormValue() or
 
1965
                self.weekdayDisabled(weekday))
 
1966
 
 
1967
    def weekdayDisabled(self, weekday):
 
1968
        """Return True if the given weekday should be disabled.
 
1969
 
 
1970
        The weekday of start_date is always disabled, all others are always
 
1971
        enabled.
 
1972
 
 
1973
        Used to format checkboxes for weekly recurrences.
 
1974
        """
 
1975
        day = self.getStartDate()
 
1976
        return bool(day and day.weekday() == int(weekday))
 
1977
 
 
1978
    def getMonthDay(self):
 
1979
        """Return the day number in a month, according to start_date.
 
1980
 
 
1981
        Used by the page template to format monthly recurrence rules.
 
1982
        """
 
1983
        evdate = self.getStartDate()
 
1984
        if evdate is None:
 
1985
            return '??'
 
1986
        else:
 
1987
            return str(evdate.day)
 
1988
 
 
1989
    def getWeekDay(self):
 
1990
        """Return the week and weekday in a month, according to start_date.
 
1991
 
 
1992
        The output looks like '4th Tuesday'
 
1993
 
 
1994
        Used by the page template to format monthly recurrence rules.
 
1995
        """
 
1996
        evdate = self.getStartDate()
 
1997
        if evdate is None:
 
1998
            return _("same weekday")
 
1999
 
 
2000
        weekday = evdate.weekday()
 
2001
        index = (evdate.day + 6) // 7
 
2002
 
 
2003
        indexes = {1: _('1st'), 2: _('2nd'), 3: _('3rd'), 4: _('4th'),
 
2004
                   5: _('5th')}
 
2005
        day_of_week = day_of_week_names[weekday]
 
2006
        return "%s %s" % (indexes[index], day_of_week)
 
2007
 
 
2008
    def getLastWeekDay(self):
 
2009
        """Return the week and weekday in a month, counting from the end.
 
2010
 
 
2011
        The output looks like 'Last Friday'
 
2012
 
 
2013
        Used by the page template to format monthly recurrence rules.
 
2014
        """
 
2015
        evdate = self.getStartDate()
 
2016
 
 
2017
        if evdate is None:
 
2018
            return _("last weekday")
 
2019
 
 
2020
        lastday = calendar.monthrange(evdate.year, evdate.month)[1]
 
2021
 
 
2022
        if lastday - evdate.day >= 7:
 
2023
            return None
 
2024
        else:
 
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})
 
2029
            return msg
 
2030
 
 
2031
    def getStartDate(self):
 
2032
        """Return the value of the widget if a start_date is set."""
 
2033
        try:
 
2034
            return self.start_date_widget.getInputValue()
 
2035
        except (WidgetInputError, ConversionError):
 
2036
            return None
 
2037
 
 
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 = ''
 
2042
        try:
 
2043
            data = getWidgetsData(self, self.schema, names=self.fieldNames)
 
2044
            kw = {}
 
2045
            for name in self._keyword_arguments:
 
2046
                if name in data:
 
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
 
2056
 
 
2057
    def processRequest(self, kwargs):
 
2058
        """Put information from the widgets into a dict.
 
2059
 
 
2060
        This method performs additional validation, because Zope 3 forms aren't
 
2061
        powerful enough.  If any errors are encountered, a WidgetsError is
 
2062
        raised.
 
2063
        """
 
2064
        errors = []
 
2065
        self._requireField("title", errors)
 
2066
        self._requireField("start_date", errors)
 
2067
 
 
2068
        # What we require depends on weather or not we have an allday event
 
2069
        allday = kwargs.pop('allday', None)
 
2070
        if not allday:
 
2071
            self._requireField("start_time", errors)
 
2072
 
 
2073
        self._requireField("duration", errors)
 
2074
 
 
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)
 
2079
        if start_time:
 
2080
            try:
 
2081
                start_time = parse_time(start_time)
 
2082
            except ValueError:
 
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)
 
2091
 
 
2092
        if recurrence:
 
2093
            self._requireField("interval", errors)
 
2094
            self._requireField("recurrence_type", errors)
 
2095
            self._requireField("range", errors)
 
2096
 
 
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)
 
2107
 
 
2108
        exceptions = kwargs.pop("exceptions", None)
 
2109
        if exceptions:
 
2110
            try:
 
2111
                kwargs["exceptions"] = datesParser(exceptions)
 
2112
            except ValueError:
 
2113
                self._setError("exceptions", ConversionError(
 
2114
                 _("Invalid date.  Please specify YYYY-MM-DD, one per line.")))
 
2115
                errors.append(self.exceptions_widget._error)
 
2116
 
 
2117
        if errors:
 
2118
            raise WidgetsError(errors)
 
2119
 
 
2120
        # Some fake data for allday events, based on what iCalendar seems to
 
2121
        # expect
 
2122
        if allday is True:
 
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)
 
2131
        else:
 
2132
            start = datetime.combine(start_date, start_time)
 
2133
            start = self.timezone.localize(start).astimezone(utc)
 
2134
 
 
2135
        dargs = {duration_type : duration}
 
2136
        duration = timedelta(**dargs)
 
2137
 
 
2138
        # Shift the weekdays to the correct timezone
 
2139
        if 'weekdays' in kwargs and kwargs['weekdays']:
 
2140
            kwargs['weekdays'] = tuple(convertWeekdaysList(start,
 
2141
                                                           self.timezone,
 
2142
                                                           start.tzinfo,
 
2143
                                                           kwargs['weekdays']))
 
2144
 
 
2145
 
 
2146
        rrule = recurrence and makeRecurrenceRule(**kwargs) or None
 
2147
        return {'location': location,
 
2148
                'description': description,
 
2149
                'title': title,
 
2150
                'allday': allday,
 
2151
                'start': start,
 
2152
                'duration': duration,
 
2153
                'rrule': rrule}
 
2154
 
 
2155
 
 
2156
class CalendarEventAddView(CalendarEventViewMixin, AddView):
 
2157
    """A view for adding an event."""
 
2158
 
 
2159
    __used_for__ = ISchoolToolCalendar
 
2160
    schema = ICalendarEventAddForm
 
2161
 
 
2162
    title = _("Add event")
 
2163
    submit_button_title = _("Add")
 
2164
 
 
2165
    show_book_checkbox = True
 
2166
    show_book_link = False
 
2167
    _event_uid = None
 
2168
 
 
2169
    error = None
 
2170
 
 
2171
    def __init__(self, context, request):
 
2172
 
 
2173
        prefs = ViewPreferences(request)
 
2174
        self.timezone = prefs.timezone
 
2175
 
 
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)
 
2183
 
 
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'])
 
2192
        return event
 
2193
 
 
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)
 
2201
        return event
 
2202
 
 
2203
    def update(self):
 
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
 
2211
        else:
 
2212
            return AddView.update(self)
 
2213
 
 
2214
    def nextURL(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)
 
2219
        else:
 
2220
            return absoluteURL(self.context, self.request)
 
2221
 
 
2222
 
 
2223
class ICalendarEventEditForm(ICalendarEventAddForm):
 
2224
    pass
 
2225
 
 
2226
 
 
2227
class CalendarEventEditView(CalendarEventViewMixin, EditView):
 
2228
    """A view for editing an event."""
 
2229
 
 
2230
    error = None
 
2231
    show_book_checkbox = False
 
2232
    show_book_link = True
 
2233
 
 
2234
    title = _("Edit event")
 
2235
    submit_button_title = _("Update")
 
2236
 
 
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)
 
2242
 
 
2243
    def keyword_arguments(self):
 
2244
        """Wraps fieldNames under another name.
 
2245
 
 
2246
        AddView and EditView API does not match so some wrapping is needed.
 
2247
        """
 
2248
        return self.fieldNames
 
2249
 
 
2250
    _keyword_arguments = property(keyword_arguments, None)
 
2251
 
 
2252
    def _setUpWidgets(self):
 
2253
        setUpWidgets(self, self.schema, IInputWidget, names=self.fieldNames,
 
2254
                     initial=self._getInitialData(self.context))
 
2255
 
 
2256
    def _getInitialData(self, context):
 
2257
        """Extract initial widgets data from context."""
 
2258
 
 
2259
        initial = {}
 
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
 
2267
                                    "days")
 
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
 
2275
        if recurrence:
 
2276
            initial["interval"] = recurrence.interval
 
2277
            recurrence_type = (
 
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")
 
2282
 
 
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"
 
2290
            else:
 
2291
                initial["range"] = "forever"
 
2292
 
 
2293
            if recurrence.exceptions:
 
2294
                exceptions = map(str, recurrence.exceptions)
 
2295
                initial["exceptions"] = "\n".join(exceptions)
 
2296
 
 
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,
 
2303
                        self.timezone,
 
2304
                        recurrence.weekdays)
 
2305
 
 
2306
            if recurrence_type == "monthly":
 
2307
                if recurrence.monthly:
 
2308
                    initial["monthly"] = recurrence.monthly
 
2309
 
 
2310
        return initial
 
2311
 
 
2312
    def getStartDate(self):
 
2313
        if "field.start_date" in self.request:
 
2314
            return CalendarEventViewMixin.getStartDate(self)
 
2315
        else:
 
2316
            return self.context.dtstart.astimezone(self.timezone).date()
 
2317
 
 
2318
    def applyChanges(self):
 
2319
        data = getWidgetsData(self, self.schema, names=self.fieldNames)
 
2320
        kw = {}
 
2321
        for name in self._keyword_arguments:
 
2322
            if name in data:
 
2323
                kw[str(name)] = data[name]
 
2324
 
 
2325
        widget_data = self.processRequest(kw)
 
2326
 
 
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])
 
2333
        return True
 
2334
 
 
2335
    def update(self):
 
2336
        if self.update_status is not None:
 
2337
            # We've been called before. Just return the status we previously
 
2338
            # computed.
 
2339
            return self.update_status
 
2340
 
 
2341
        status = ''
 
2342
 
 
2343
        start_date = self.context.dtstart.strftime("%Y-%m-%d")
 
2344
 
 
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
 
2353
            changed = False
 
2354
            try:
 
2355
                changed = self.applyChanges()
 
2356
                if changed:
 
2357
                    notify(ObjectModifiedEvent(self.context))
 
2358
            except WidgetsError, errors:
 
2359
                self.errors = errors
 
2360
                status = _("An error occurred.")
 
2361
                transaction.abort()
 
2362
            else:
 
2363
                if changed:
 
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())
 
2370
 
 
2371
        self.update_status = status
 
2372
        return status
 
2373
 
 
2374
    def nextURL(self):
 
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']
 
2380
        else:
 
2381
            return absoluteURL(self.context.__parent__, self.request)
 
2382
 
 
2383
 
 
2384
class EventForBookingDisplay(object):
 
2385
    """Event wrapper for display in booking view.
 
2386
 
 
2387
    This is a wrapper around an ICalendarEvent object.  It adds view-specific
 
2388
    attributes:
 
2389
 
 
2390
        dtend -- timestamp when the event ends
 
2391
        shortTitle -- title truncated to ~15 characters
 
2392
 
 
2393
    """
 
2394
 
 
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] + '...'
 
2405
        else:
 
2406
            self.shortTitle = self.title
 
2407
        self.unique_id = self.context.unique_id
 
2408
 
 
2409
 
 
2410
class CalendarEventBookingView(CalendarEventView):
 
2411
    """A view for booking resources."""
 
2412
 
 
2413
    errors = ()
 
2414
    update_status = None
 
2415
 
 
2416
    template = ViewPageTemplateFile("templates/event_booking.pt")
 
2417
 
 
2418
    def __init__(self, context, request):
 
2419
        CalendarEventView.__init__(self, context, request)
 
2420
 
 
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)
 
2425
 
 
2426
    def __call__(self):
 
2427
        self.checkPermission()
 
2428
        return self.template()
 
2429
 
 
2430
    def checkPermission(self):
 
2431
        if canAccess(self.context, 'bookResource'):
 
2432
            return
 
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():
 
2437
            return
 
2438
        raise Unauthorized("user not allowed to book")
 
2439
 
 
2440
    def hasBookedItems(self):
 
2441
        return bool(self.context.resources)
 
2442
 
 
2443
    def bookingStatus(self, item, formatter):
 
2444
        conflicts = list(self.getConflictingEvents(item))
 
2445
        status = {}
 
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)
 
2451
            else:
 
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
 
2457
        return status
 
2458
 
 
2459
    def columns(self):
 
2460
 
 
2461
        def statusFormatter(value, item, formatter):
 
2462
            url = []
 
2463
            if value:
 
2464
                for eventOwner, ownerCalendar in value.items():
 
2465
                    url.append('<a href="%s">%s</a>' % (ownerCalendar, eventOwner))
 
2466
                return ", ".join(url)
 
2467
            else:
 
2468
                return 'Free'
 
2469
        return [GetterColumn(name='title',
 
2470
                             title=u"Title",
 
2471
                             getter=lambda i, f: i.title,
 
2472
                             subsort=True),
 
2473
                GetterColumn(name='type',
 
2474
                             title=u"Type",
 
2475
                             getter=lambda i, f: IResourceTypeInformation(i).title,
 
2476
                             subsort=True),
 
2477
                GetterColumn(title="Booked by others",
 
2478
                             cell_formatter=statusFormatter,
 
2479
                             getter=self.bookingStatus
 
2480
                             )]
 
2481
 
 
2482
    def getBookedItems(self):
 
2483
        return removeSecurityProxy(self.context.resources)
 
2484
 
 
2485
    def updateBatch(self, lst):
 
2486
        extra_url = ""
 
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)
 
2490
 
 
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(),
 
2499
            columns=columns,
 
2500
            sort_on=self.sortOn(),
 
2501
            prefix="booked")
 
2502
        formatter.cssClasses['table'] = 'data'
 
2503
        return formatter()
 
2504
 
 
2505
    def renderAvailableTable(self):
 
2506
        prefix = "add_item"
 
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),
 
2514
            columns=columns,
 
2515
            batch_start=self.batch.start, batch_size=self.batch.size,
 
2516
            sort_on=self.sortOn(),
 
2517
            prefix="available")
 
2518
        formatter.cssClasses['table'] = 'data'
 
2519
        return formatter()
 
2520
 
 
2521
    def sortOn(self):
 
2522
        return (("title", False),)
 
2523
 
 
2524
    def getAvailableItemsContainer(self):
 
2525
        return ISchoolToolApplication(None)['resources']
 
2526
 
 
2527
    def getAvailableItems(self):
 
2528
        container = self.getAvailableItemsContainer()
 
2529
        bookedItems = set(self.getBookedItems())
 
2530
        allItems = set(self.availableResources)
 
2531
        return list(allItems - bookedItems)
 
2532
 
 
2533
    def filter(self, list):
 
2534
        return self.filter_widget.filter(list)
 
2535
 
 
2536
 
 
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
 
2541
 
 
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)
 
2549
 
 
2550
    def update(self):
 
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(),
 
2554
                                                self.request),
 
2555
                                                IFilterWidget)
 
2556
 
 
2557
        if 'CANCEL' in self.request:
 
2558
            url = absoluteURL(self.context, self.request)
 
2559
            self.request.response.redirect(self.nextURL())
 
2560
 
 
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)
 
2567
                    if not booked:
 
2568
                        event = removeSecurityProxy(self.context)
 
2569
                        event.bookResource(resource)
 
2570
            self.clearJustAddedStatus()
 
2571
 
 
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)
 
2578
                    if booked:
 
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
 
2584
 
 
2585
    @property
 
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.
 
2594
                return False
 
2595
            if self.canBook(resource) and not self.hasBooked(resource):
 
2596
                return True
 
2597
            return False
 
2598
        return filter(isBookable, sb['resources'].values())
 
2599
 
 
2600
    def canBook(self, resource):
 
2601
        """Can the user book this resource?"""
 
2602
        return canAccess(ISchoolToolCalendar(resource), "addEvent")
 
2603
 
 
2604
    def hasBooked(self, resource):
 
2605
        """Checks whether a resource is booked by this event."""
 
2606
        return resource in self.context.resources
 
2607
 
 
2608
    def nextURL(self):
 
2609
        """Return the URL to be displayed after the add operation."""
 
2610
        return absoluteURL(self.context.__parent__, self.request)
 
2611
 
 
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"):
 
2616
            return []
 
2617
 
 
2618
        events = list(calendar.expand(self.context.dtstart,
 
2619
                                      self.context.dtstart + self.context.duration))
 
2620
 
 
2621
        return [EventForBookingDisplay(event)
 
2622
                for event in events
 
2623
                if event != self.context]
 
2624
 
 
2625
 
 
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:
 
2632
        interval = 1
 
2633
 
 
2634
    if range != 'until':
 
2635
        until = None
 
2636
    if range != 'count':
 
2637
        count = None
 
2638
 
 
2639
    if exceptions is None:
 
2640
        exceptions = ()
 
2641
 
 
2642
    kwargs = {'interval': interval, 'count': count,
 
2643
              'until': until, 'exceptions': exceptions}
 
2644
 
 
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)
 
2655
    else:
 
2656
        raise NotImplementedError()
 
2657
 
 
2658
 
 
2659
def convertWeekdaysList(dt, fromtz, totz, weekdays):
 
2660
    """Convert the weekday list from one timezone to the other.
 
2661
 
 
2662
    The days can shift by one day in either direction or stay,
 
2663
    depending on the timezones and the time of the event.
 
2664
 
 
2665
    The arguments are as follows:
 
2666
 
 
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.
 
2671
 
 
2672
    """
 
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]
 
2676
 
 
2677
 
 
2678
def datesParser(raw_dates):
 
2679
    r"""Parse dates on separate lines into a tuple of date objects.
 
2680
 
 
2681
    Incorrect lines are ignored.
 
2682
 
 
2683
    >>> datesParser('2004-05-17\n\n\n2004-01-29')
 
2684
    (datetime.date(2004, 5, 17), datetime.date(2004, 1, 29))
 
2685
 
 
2686
    >>> datesParser('2004-05-17\n123\n\nNone\n2004-01-29')
 
2687
    Traceback (most recent call last):
 
2688
    ...
 
2689
    ValueError: Invalid date: '123'
 
2690
 
 
2691
    """
 
2692
    results = []
 
2693
    for dstr in raw_dates.splitlines():
 
2694
        if dstr:
 
2695
            d = parse_date(dstr)
 
2696
            if isinstance(d, date):
 
2697
                results.append(d)
 
2698
    return tuple(results)
 
2699
 
 
2700
 
 
2701
def enableVfbView(ical_view):
 
2702
    """XXX wanna docstring!"""
 
2703
    return IReadFile(ical_view.context)
 
2704
 
 
2705
 
 
2706
def enableICalendarUpload(ical_view):
 
2707
    """An adapter that enables HTTP PUT for calendars.
 
2708
 
 
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.
 
2715
 
 
2716
    So, to hook up iCalendar uploads, the simplest way is to register an
 
2717
    adapter for CalendarICalendarView that provides IWriteFile.
 
2718
 
 
2719
        >>> from zope.app.testing import setup, ztapi
 
2720
        >>> setup.placelessSetUp()
 
2721
 
 
2722
    We have a calendar that provides IEditCalendar.
 
2723
 
 
2724
        >>> from schooltool.calendar.interfaces import IEditCalendar
 
2725
        >>> from schooltool.app.cal import Calendar
 
2726
        >>> calendar = Calendar(None)
 
2727
 
 
2728
    We have a fake "real adapter" for IEditCalendar
 
2729
 
 
2730
        >>> class RealAdapter:
 
2731
        ...     implements(IWriteFile)
 
2732
        ...     def __init__(self, context):
 
2733
        ...         pass
 
2734
        ...     def write(self, data):
 
2735
        ...         print 'real adapter got %r' % data
 
2736
        >>> ztapi.provideAdapter(IEditCalendar, IWriteFile, RealAdapter)
 
2737
 
 
2738
    We have a fake view on that calendar
 
2739
 
 
2740
        >>> from zope.publisher.browser import BrowserView
 
2741
        >>> from zope.publisher.browser import TestRequest
 
2742
        >>> view = BrowserView(calendar, TestRequest())
 
2743
 
 
2744
    And now we can hook things up together
 
2745
 
 
2746
        >>> adapter = enableICalendarUpload(view)
 
2747
        >>> adapter.write('iCalendar data')
 
2748
        real adapter got 'iCalendar data'
 
2749
 
 
2750
        >>> setup.placelessTearDown()
 
2751
 
 
2752
    """
 
2753
    return IWriteFile(ical_view.context)
 
2754
 
 
2755
 
 
2756
class CalendarEventBreadcrumbInfo(breadcrumbs.GenericBreadcrumbInfo):
 
2757
    """Calendar Event Breadcrumb Info
 
2758
 
 
2759
    First, set up a parent:
 
2760
 
 
2761
      >>> class Object(object):
 
2762
      ...     def __init__(self, parent=None, name=None):
 
2763
      ...         self.__parent__ = parent
 
2764
      ...         self.__name__ = name
 
2765
 
 
2766
      >>> calendar = Object()
 
2767
      >>> from zope.traversing.interfaces import IContainmentRoot
 
2768
      >>> import zope.interface
 
2769
      >>> zope.interface.directlyProvides(calendar, IContainmentRoot)
 
2770
 
 
2771
    Now setup the event:
 
2772
 
 
2773
      >>> event = Object(calendar, u'+1243@localhost')
 
2774
 
 
2775
    Setup a request:
 
2776
 
 
2777
      >>> from zope.publisher.browser import TestRequest
 
2778
      >>> request = TestRequest()
 
2779
 
 
2780
    Now register the breadcrumb info component and other setup:
 
2781
 
 
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)
 
2788
 
 
2789
      >>> from zope.app.testing import setup
 
2790
      >>> setup.setUpTraversal()
 
2791
 
 
2792
    Now initialize this info and test it:
 
2793
 
 
2794
      >>> info = CalendarEventBreadcrumbInfo(event, request)
 
2795
      >>> info.url
 
2796
      'http://127.0.0.1/+1243@localhost/edit.html'
 
2797
    """
 
2798
 
 
2799
    @property
 
2800
    def url(self):
 
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)
 
2805
 
 
2806
CalendarBreadcrumbInfo = breadcrumbs.CustomNameBreadCrumbInfo(_('Calendar'))
 
2807
 
 
2808
 
 
2809
class CalendarActionMenuViewlet(object):
 
2810
    implements(ICalendarMenuViewlet)
 
2811
 
 
2812
 
 
2813
class CalendarMenuViewletCrowd(Crowd):
 
2814
    adapts(ICalendarMenuViewlet)
 
2815
 
 
2816
    def contains(self, principal):
 
2817
        """Returns true if you have the permission to see the calendar."""
 
2818
        crowd = queryAdapter(ISchoolToolCalendar(self.context.context),
 
2819
                             ICrowd,
 
2820
                             name="schooltool.view")
 
2821
        return crowd.contains(principal)
 
2822
 
 
2823
class ICalendarPortletViewletManager(IViewletManager):
 
2824
    """ Interface for the Calendar Portlet Viewlet Manager """