1.1 --- a/imip_manager.py Thu Mar 26 00:27:06 2015 +0100
1.2 +++ b/imip_manager.py Thu Mar 26 16:11:46 2015 +0100
1.3 @@ -24,1907 +24,17 @@
1.4
1.5 LIBRARY_PATH = "/var/lib/imip-agent"
1.6
1.7 -from datetime import date, datetime, timedelta
1.8 -import babel.dates
1.9 -import pytz
1.10 import sys
1.11 -
1.12 sys.path.append(LIBRARY_PATH)
1.13
1.14 -from imiptools.client import Client, update_attendees
1.15 -from imiptools.data import get_address, get_uri, get_window_end, Object, \
1.16 - uri_dict, uri_values
1.17 -from imiptools.dates import format_datetime, format_time, to_date, get_datetime, \
1.18 - get_datetime_item, get_end_of_day, get_period_item, \
1.19 - get_start_of_day, get_start_of_next_day, get_timestamp, \
1.20 - ends_on_same_day, to_timezone
1.21 -from imiptools.mail import Messenger
1.22 -from imiptools.period import add_day_start_points, add_empty_days, add_slots, \
1.23 - convert_periods, get_freebusy_details, \
1.24 - get_scale, have_conflict, get_slots, get_spans, \
1.25 - partition_by_day, remove_period, remove_affected_period, \
1.26 - update_freebusy
1.27 -from imipweb.env import CGIEnvironment
1.28 -from imipweb.handler import ManagerHandler
1.29 -import imip_store
1.30 -import markup
1.31 +from imipweb.calendar import CalendarPage
1.32 +from imipweb.event import EventPage
1.33 +from imipweb.resource import Resource
1.34
1.35 -class Manager(Client):
1.36 +class Manager(Resource):
1.37
1.38 "A simple manager application."
1.39
1.40 - def __init__(self, messenger=None):
1.41 - self.messenger = messenger or Messenger()
1.42 - self.encoding = "utf-8"
1.43 - self.env = CGIEnvironment(self.encoding)
1.44 -
1.45 - user = self.env.get_user()
1.46 - Client.__init__(self, user and get_uri(user) or None)
1.47 -
1.48 - self.locale = None
1.49 - self.requests = None
1.50 -
1.51 - self.out = self.env.get_output()
1.52 - self.page = markup.page()
1.53 - self.html_ids = None
1.54 -
1.55 - self.store = imip_store.FileStore()
1.56 - self.objects = {}
1.57 -
1.58 - try:
1.59 - self.publisher = imip_store.FilePublisher()
1.60 - except OSError:
1.61 - self.publisher = None
1.62 -
1.63 - def _suffixed_name(self, name, index=None):
1.64 - return index is not None and "%s-%d" % (name, index) or name
1.65 -
1.66 - def _simple_suffixed_name(self, name, suffix, index=None):
1.67 - return index is not None and "%s-%s" % (name, suffix) or name
1.68 -
1.69 - def _get_identifiers(self, path_info):
1.70 - parts = path_info.lstrip("/").split("/")
1.71 - if len(parts) == 1:
1.72 - return parts[0], None
1.73 - else:
1.74 - return parts[:2]
1.75 -
1.76 - def _get_object(self, uid, recurrenceid=None):
1.77 - if self.objects.has_key((uid, recurrenceid)):
1.78 - return self.objects[(uid, recurrenceid)]
1.79 -
1.80 - fragment = uid and self.store.get_event(self.user, uid, recurrenceid) or None
1.81 - obj = self.objects[(uid, recurrenceid)] = fragment and Object(fragment)
1.82 - return obj
1.83 -
1.84 - def _get_recurrences(self, uid):
1.85 - return self.store.get_recurrences(self.user, uid)
1.86 -
1.87 - def _get_requests(self):
1.88 - if self.requests is None:
1.89 - cancellations = self.store.get_cancellations(self.user)
1.90 - requests = set(self.store.get_requests(self.user))
1.91 - self.requests = requests.difference(cancellations)
1.92 - return self.requests
1.93 -
1.94 - def _get_request_summary(self):
1.95 - summary = []
1.96 - for uid, recurrenceid in self._get_requests():
1.97 - obj = self._get_object(uid, recurrenceid)
1.98 - if obj:
1.99 - periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end())
1.100 - recurrenceids = self._get_recurrences(uid)
1.101 -
1.102 - # Convert the periods to more substantial free/busy items.
1.103 -
1.104 - for start, end in periods:
1.105 -
1.106 - # Subtract any recurrences from the free/busy details of a
1.107 - # parent object.
1.108 -
1.109 - if recurrenceid or start not in recurrenceids:
1.110 - summary.append((
1.111 - start, end, uid,
1.112 - obj.get_value("TRANSP"),
1.113 - recurrenceid,
1.114 - obj.get_value("SUMMARY"),
1.115 - obj.get_value("ORGANIZER")
1.116 - ))
1.117 - return summary
1.118 -
1.119 - # Preference methods.
1.120 -
1.121 - def get_user_locale(self):
1.122 - if not self.locale:
1.123 - self.locale = self.get_preferences().get("LANG", "en")
1.124 - return self.locale
1.125 -
1.126 - # Prettyprinting of dates and times.
1.127 -
1.128 - def format_date(self, dt, format):
1.129 - return self._format_datetime(babel.dates.format_date, dt, format)
1.130 -
1.131 - def format_time(self, dt, format):
1.132 - return self._format_datetime(babel.dates.format_time, dt, format)
1.133 -
1.134 - def format_datetime(self, dt, format):
1.135 - return self._format_datetime(
1.136 - isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date,
1.137 - dt, format)
1.138 -
1.139 - def _format_datetime(self, fn, dt, format):
1.140 - return fn(dt, format=format, locale=self.get_user_locale())
1.141 -
1.142 - # Data management methods.
1.143 -
1.144 - def remove_request(self, uid, recurrenceid=None):
1.145 - return self.store.dequeue_request(self.user, uid, recurrenceid)
1.146 -
1.147 - def remove_event(self, uid, recurrenceid=None):
1.148 - return self.store.remove_event(self.user, uid, recurrenceid)
1.149 -
1.150 - def update_freebusy(self, uid, recurrenceid, obj):
1.151 -
1.152 - """
1.153 - Update stored free/busy details for the event with the given 'uid' and
1.154 - 'recurrenceid' having a representation of 'obj'.
1.155 - """
1.156 -
1.157 - is_only_organiser = self.user not in uri_values(obj.get_values("ATTENDEE"))
1.158 -
1.159 - freebusy = self.store.get_freebusy(self.user)
1.160 -
1.161 - update_freebusy(freebusy,
1.162 - obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()),
1.163 - is_only_organiser and "ORG" or obj.get_value("TRANSP"),
1.164 - uid, recurrenceid,
1.165 - obj.get_value("SUMMARY"),
1.166 - obj.get_value("ORGANIZER"))
1.167 -
1.168 - # Subtract any recurrences from the free/busy details of a parent
1.169 - # object.
1.170 -
1.171 - for recurrenceid in self._get_recurrences(uid):
1.172 - remove_affected_period(freebusy, uid, recurrenceid)
1.173 -
1.174 - self.store.set_freebusy(self.user, freebusy)
1.175 -
1.176 - def remove_from_freebusy(self, uid, recurrenceid=None):
1.177 - freebusy = self.store.get_freebusy(self.user)
1.178 - remove_period(freebusy, uid, recurrenceid)
1.179 - self.store.set_freebusy(self.user, freebusy)
1.180 -
1.181 - # Presentation methods.
1.182 -
1.183 - def new_page(self, title):
1.184 - self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css"))
1.185 - self.html_ids = set()
1.186 -
1.187 - def status(self, code, message):
1.188 - self.header("Status", "%s %s" % (code, message))
1.189 -
1.190 - def header(self, header, value):
1.191 - print >>self.out, "%s: %s" % (header, value)
1.192 -
1.193 - def no_user(self):
1.194 - self.status(403, "Forbidden")
1.195 - self.new_page(title="Forbidden")
1.196 - self.page.p("You are not logged in and thus cannot access scheduling requests.")
1.197 -
1.198 - def no_page(self):
1.199 - self.status(404, "Not Found")
1.200 - self.new_page(title="Not Found")
1.201 - self.page.p("No page is provided at the given address.")
1.202 -
1.203 - def redirect(self, url):
1.204 - self.status(302, "Redirect")
1.205 - self.header("Location", url)
1.206 - self.new_page(title="Redirect")
1.207 - self.page.p("Redirecting to: %s" % url)
1.208 -
1.209 - def link_to(self, uid, recurrenceid=None):
1.210 - if recurrenceid:
1.211 - return self.env.new_url("/".join([uid, recurrenceid]))
1.212 - else:
1.213 - return self.env.new_url(uid)
1.214 -
1.215 - # Request logic methods.
1.216 -
1.217 - def handle_newevent(self):
1.218 -
1.219 - """
1.220 - Handle any new event operation, creating a new event and redirecting to
1.221 - the event page for further activity.
1.222 - """
1.223 -
1.224 - # Handle a submitted form.
1.225 -
1.226 - args = self.env.get_args()
1.227 -
1.228 - if not args.has_key("newevent"):
1.229 - return
1.230 -
1.231 - # Create a new event using the available information.
1.232 -
1.233 - slots = args.get("slot", [])
1.234 - participants = args.get("participants", [])
1.235 -
1.236 - if not slots:
1.237 - return
1.238 -
1.239 - # Obtain the user's timezone.
1.240 -
1.241 - tzid = self.get_tzid()
1.242 -
1.243 - # Coalesce the selected slots.
1.244 -
1.245 - slots.sort()
1.246 - coalesced = []
1.247 - last = None
1.248 -
1.249 - for slot in slots:
1.250 - start, end = slot.split("-")
1.251 - start = get_datetime(start, {"TZID" : tzid})
1.252 - end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid)
1.253 -
1.254 - if last:
1.255 - last_start, last_end = last
1.256 -
1.257 - # Merge adjacent dates and datetimes.
1.258 -
1.259 - if start == last_end or \
1.260 - not isinstance(start, datetime) and \
1.261 - get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):
1.262 -
1.263 - last = last_start, end
1.264 - continue
1.265 -
1.266 - # Handle datetimes within dates.
1.267 - # Datetime periods are within single days and are therefore
1.268 - # discarded.
1.269 -
1.270 - elif not isinstance(last_start, datetime) and \
1.271 - get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):
1.272 -
1.273 - continue
1.274 -
1.275 - # Add separate dates and datetimes.
1.276 -
1.277 - else:
1.278 - coalesced.append(last)
1.279 -
1.280 - last = start, end
1.281 -
1.282 - if last:
1.283 - coalesced.append(last)
1.284 -
1.285 - # Invent a unique identifier.
1.286 -
1.287 - utcnow = get_timestamp()
1.288 - uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))
1.289 -
1.290 - # Create a calendar object and store it as a request.
1.291 -
1.292 - record = []
1.293 - rwrite = record.append
1.294 -
1.295 - # Define a single occurrence if only one coalesced slot exists.
1.296 -
1.297 - start, end = coalesced[0]
1.298 - start_value, start_attr = get_datetime_item(start, tzid)
1.299 - end_value, end_attr = get_datetime_item(end, tzid)
1.300 -
1.301 - rwrite(("UID", {}, uid))
1.302 - rwrite(("SUMMARY", {}, "New event at %s" % utcnow))
1.303 - rwrite(("DTSTAMP", {}, utcnow))
1.304 - rwrite(("DTSTART", start_attr, start_value))
1.305 - rwrite(("DTEND", end_attr, end_value))
1.306 - rwrite(("ORGANIZER", {}, self.user))
1.307 -
1.308 - participants = uri_values(filter(None, participants))
1.309 -
1.310 - for participant in participants:
1.311 - rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant))
1.312 -
1.313 - if self.user not in participants:
1.314 - rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user))
1.315 -
1.316 - # Define additional occurrences if many slots are defined.
1.317 -
1.318 - rdates = []
1.319 -
1.320 - for start, end in coalesced[1:]:
1.321 - start_value, start_attr = get_datetime_item(start, tzid)
1.322 - end_value, end_attr = get_datetime_item(end, tzid)
1.323 - rdates.append("%s/%s" % (start_value, end_value))
1.324 -
1.325 - if rdates:
1.326 - rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates))
1.327 -
1.328 - node = ("VEVENT", {}, record)
1.329 -
1.330 - self.store.set_event(self.user, uid, None, node=node)
1.331 - self.store.queue_request(self.user, uid)
1.332 -
1.333 - # Redirect to the object (or the first of the objects), where instead of
1.334 - # attendee controls, there will be organiser controls.
1.335 -
1.336 - self.redirect(self.link_to(uid))
1.337 -
1.338 - def handle_request(self, uid, recurrenceid, obj):
1.339 -
1.340 - """
1.341 - Handle actions involving the given 'uid', 'recurrenceid', and 'obj' as
1.342 - the object's representation, returning an error if one occurred, or None
1.343 - if the request was successfully handled.
1.344 - """
1.345 -
1.346 - # Handle a submitted form.
1.347 -
1.348 - args = self.env.get_args()
1.349 -
1.350 - # Get the possible actions.
1.351 -
1.352 - reply = args.has_key("reply")
1.353 - discard = args.has_key("discard")
1.354 - invite = args.has_key("invite")
1.355 - cancel = args.has_key("cancel")
1.356 - save = args.has_key("save")
1.357 - ignore = args.has_key("ignore")
1.358 -
1.359 - have_action = reply or discard or invite or cancel or save or ignore
1.360 -
1.361 - if not have_action:
1.362 - return ["action"]
1.363 -
1.364 - # If ignoring the object, return to the calendar.
1.365 -
1.366 - if ignore:
1.367 - self.redirect(self.env.get_path())
1.368 - return None
1.369 -
1.370 - # Update the object.
1.371 -
1.372 - if args.has_key("summary"):
1.373 - obj["SUMMARY"] = [(args["summary"][0], {})]
1.374 -
1.375 - attendees = uri_dict(obj.get_value_map("ATTENDEE"))
1.376 -
1.377 - if args.has_key("partstat"):
1.378 - if attendees.has_key(self.user):
1.379 - attendees[self.user]["PARTSTAT"] = args["partstat"][0]
1.380 - if attendees[self.user].has_key("RSVP"):
1.381 - del attendees[self.user]["RSVP"]
1.382 -
1.383 - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
1.384 -
1.385 - # Obtain the user's timezone and process datetime values.
1.386 -
1.387 - update = False
1.388 -
1.389 - if is_organiser:
1.390 - periods, errors = self.handle_all_period_controls()
1.391 - if errors:
1.392 - return errors
1.393 - elif periods:
1.394 - self.set_period_in_object(obj, periods[0])
1.395 - self.set_periods_in_object(obj, periods[1:])
1.396 -
1.397 - # Obtain any participants to be added or removed.
1.398 -
1.399 - removed = args.get("remove")
1.400 - added = args.get("added")
1.401 -
1.402 - # Process any action.
1.403 -
1.404 - handled = True
1.405 -
1.406 - if reply or invite or cancel:
1.407 -
1.408 - handler = ManagerHandler(obj, self.user, self.messenger)
1.409 -
1.410 - # Process the object and remove it from the list of requests.
1.411 -
1.412 - if reply and handler.process_received_request(update) or \
1.413 - is_organiser and (invite or cancel) and \
1.414 - handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added):
1.415 -
1.416 - self.remove_request(uid, recurrenceid)
1.417 -
1.418 - # Save single user events.
1.419 -
1.420 - elif save:
1.421 - to_cancel = update_attendees(obj, added, removed)
1.422 - self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node())
1.423 - self.update_freebusy(uid, recurrenceid, obj)
1.424 - self.remove_request(uid, recurrenceid)
1.425 -
1.426 - # Remove the request and the object.
1.427 -
1.428 - elif discard:
1.429 - self.remove_from_freebusy(uid, recurrenceid)
1.430 - self.remove_event(uid, recurrenceid)
1.431 - self.remove_request(uid, recurrenceid)
1.432 -
1.433 - else:
1.434 - handled = False
1.435 -
1.436 - # Upon handling an action, redirect to the main page.
1.437 -
1.438 - if handled:
1.439 - self.redirect(self.env.get_path())
1.440 -
1.441 - return None
1.442 -
1.443 - def handle_all_period_controls(self):
1.444 -
1.445 - """
1.446 - Handle datetime controls for a particular period, where 'index' may be
1.447 - used to indicate a recurring period, or the main start and end datetimes
1.448 - are handled.
1.449 - """
1.450 -
1.451 - args = self.env.get_args()
1.452 -
1.453 - periods = []
1.454 -
1.455 - # Get the main period details.
1.456 -
1.457 - dtend_enabled = args.get("dtend-control", [None])[0]
1.458 - dttimes_enabled = args.get("dttimes-control", [None])[0]
1.459 - start_values = self.get_date_control_values("dtstart")
1.460 - end_values = self.get_date_control_values("dtend")
1.461 -
1.462 - period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled)
1.463 -
1.464 - if errors:
1.465 - return None, errors
1.466 -
1.467 - periods.append(period)
1.468 -
1.469 - # Get the recurring period details.
1.470 -
1.471 - all_dtend_enabled = args.get("dtend-control-recur", [])
1.472 - all_dttimes_enabled = args.get("dttimes-control-recur", [])
1.473 - all_start_values = self.get_date_control_values("dtstart-recur", multiple=True)
1.474 - all_end_values = self.get_date_control_values("dtend-recur", multiple=True)
1.475 -
1.476 - for index, (start_values, end_values, dtend_enabled, dttimes_enabled) in \
1.477 - enumerate(map(None, all_start_values, all_end_values, all_dtend_enabled, all_dttimes_enabled)):
1.478 -
1.479 - dtend_enabled = str(index) in all_dtend_enabled
1.480 - dttimes_enabled = str(index) in all_dttimes_enabled
1.481 - period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled)
1.482 -
1.483 - if errors:
1.484 - return None, errors
1.485 -
1.486 - periods.append(period)
1.487 -
1.488 - return periods, None
1.489 -
1.490 - def handle_period_controls(self, start_values, end_values, dtend_enabled, dttimes_enabled):
1.491 -
1.492 - """
1.493 - Handle datetime controls for a particular period, described by the given
1.494 - 'start_values' and 'end_values', with 'dtend_enabled' and
1.495 - 'dttimes_enabled' affecting the usage of the provided values.
1.496 - """
1.497 -
1.498 - t = self.handle_date_control_values(start_values, dttimes_enabled)
1.499 - if t:
1.500 - dtstart, dtstart_attr = t
1.501 - else:
1.502 - return None, ["dtstart"]
1.503 -
1.504 - # Handle specified end datetimes.
1.505 -
1.506 - if dtend_enabled:
1.507 - t = self.handle_date_control_values(end_values, dttimes_enabled)
1.508 - if t:
1.509 - dtend, dtend_attr = t
1.510 -
1.511 - # Convert end dates to iCalendar "next day" dates.
1.512 -
1.513 - if not isinstance(dtend, datetime):
1.514 - dtend += timedelta(1)
1.515 - else:
1.516 - return None, ["dtend"]
1.517 -
1.518 - # Otherwise, treat the end date as the start date. Datetimes are
1.519 - # handled by making the event occupy the rest of the day.
1.520 -
1.521 - else:
1.522 - dtend = dtstart + timedelta(1)
1.523 - dtend_attr = dtstart_attr
1.524 -
1.525 - if isinstance(dtstart, datetime):
1.526 - dtend = get_start_of_day(dtend, attr["TZID"])
1.527 -
1.528 - if dtstart >= dtend:
1.529 - return None, ["dtstart", "dtend"]
1.530 -
1.531 - return ((dtstart, dtstart_attr), (dtend, dtend_attr)), None
1.532 -
1.533 - def handle_date_control_values(self, values, with_time=True):
1.534 -
1.535 - """
1.536 - Handle date control information for the given 'values', returning a
1.537 - (datetime, attr) tuple, or None if the fields cannot be used to
1.538 - construct a datetime object.
1.539 - """
1.540 -
1.541 - if not values or not values["date"]:
1.542 - return None
1.543 - elif with_time:
1.544 - value = "%s%s" % (values["date"], values["time"])
1.545 - attr = {"TZID" : values["tzid"], "VALUE" : "DATE-TIME"}
1.546 - dt = get_datetime(value, attr)
1.547 - else:
1.548 - attr = {"VALUE" : "DATE"}
1.549 - dt = get_datetime(values["date"])
1.550 -
1.551 - if dt:
1.552 - return dt, attr
1.553 -
1.554 - return None
1.555 -
1.556 - def get_date_control_values(self, name, multiple=False):
1.557 -
1.558 - """
1.559 - Return a dictionary containing date, time and tzid entries for fields
1.560 - starting with 'name'.
1.561 - """
1.562 -
1.563 - args = self.env.get_args()
1.564 -
1.565 - dates = args.get("%s-date" % name, [])
1.566 - hours = args.get("%s-hour" % name, [])
1.567 - minutes = args.get("%s-minute" % name, [])
1.568 - seconds = args.get("%s-second" % name, [])
1.569 - tzids = args.get("%s-tzid" % name, [])
1.570 -
1.571 - # Handle absent values by employing None values.
1.572 -
1.573 - field_values = map(None, dates, hours, minutes, seconds, tzids)
1.574 - if not field_values and not multiple:
1.575 - field_values = [(None, None, None, None, None)]
1.576 -
1.577 - all_values = []
1.578 -
1.579 - for date, hour, minute, second, tzid in field_values:
1.580 -
1.581 - # Construct a usable dictionary of values.
1.582 -
1.583 - time = (hour or minute or second) and \
1.584 - "T%s%s%s" % (
1.585 - (hour or "").rjust(2, "0")[:2],
1.586 - (minute or "").rjust(2, "0")[:2],
1.587 - (second or "").rjust(2, "0")[:2]
1.588 - ) or ""
1.589 -
1.590 - value = {
1.591 - "date" : date,
1.592 - "time" : time,
1.593 - "tzid" : tzid or self.get_tzid()
1.594 - }
1.595 -
1.596 - # Return a single value or append to a collection of all values.
1.597 -
1.598 - if not multiple:
1.599 - return value
1.600 - else:
1.601 - all_values.append(value)
1.602 -
1.603 - return all_values
1.604 -
1.605 - def set_period_in_object(self, obj, period):
1.606 -
1.607 - "Set in the given 'obj' the given 'period' as the main start and end."
1.608 -
1.609 - (dtstart, dtstart_attr), (dtend, dtend_attr) = period
1.610 -
1.611 - return self.set_datetime_in_object(dtstart, dtstart_attr.get("TZID"), "DTSTART", obj) or \
1.612 - self.set_datetime_in_object(dtend, dtend_attr.get("TZID"), "DTEND", obj)
1.613 -
1.614 - def set_periods_in_object(self, obj, periods):
1.615 -
1.616 - "Set in the given 'obj' the given 'periods'."
1.617 -
1.618 - update = False
1.619 -
1.620 - old_values = obj.get_values("RDATE")
1.621 - new_rdates = []
1.622 -
1.623 - if obj.has_key("RDATE"):
1.624 - del obj["RDATE"]
1.625 -
1.626 - for period in periods:
1.627 - (dtstart, dtstart_attr), (dtend, dtend_attr) = period
1.628 - tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID")
1.629 - new_rdates.append(get_period_item(dtstart, dtend, tzid))
1.630 -
1.631 - obj["RDATE"] = new_rdates
1.632 -
1.633 - # NOTE: To do: calculate the update status.
1.634 - return update
1.635 -
1.636 - def set_datetime_in_object(self, dt, tzid, property, obj):
1.637 -
1.638 - """
1.639 - Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether
1.640 - an update has occurred.
1.641 - """
1.642 -
1.643 - if dt:
1.644 - old_value = obj.get_value(property)
1.645 - obj[property] = [get_datetime_item(dt, tzid)]
1.646 - return format_datetime(dt) != old_value
1.647 -
1.648 - return False
1.649 -
1.650 - def handle_new_attendees(self, obj):
1.651 -
1.652 - "Add or remove new attendees. This does not affect the stored object."
1.653 -
1.654 - args = self.env.get_args()
1.655 -
1.656 - existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])
1.657 - new_attendees = args.get("added", [])
1.658 - new_attendee = args.get("attendee", [""])[0]
1.659 -
1.660 - if args.has_key("add"):
1.661 - if new_attendee.strip():
1.662 - new_attendee = get_uri(new_attendee.strip())
1.663 - if new_attendee not in new_attendees and new_attendee not in existing_attendees:
1.664 - new_attendees.append(new_attendee)
1.665 - new_attendee = ""
1.666 -
1.667 - if args.has_key("removenew"):
1.668 - removed_attendee = args["removenew"][0]
1.669 - if removed_attendee in new_attendees:
1.670 - new_attendees.remove(removed_attendee)
1.671 -
1.672 - return new_attendees, new_attendee
1.673 -
1.674 - def get_event_period(self, obj):
1.675 -
1.676 - """
1.677 - Return (dtstart, dtstart attributes), (dtend, dtend attributes) for
1.678 - 'obj'.
1.679 - """
1.680 -
1.681 - dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")
1.682 - if obj.has_key("DTEND"):
1.683 - dtend, dtend_attr = obj.get_datetime_item("DTEND")
1.684 - elif obj.has_key("DURATION"):
1.685 - duration = obj.get_duration("DURATION")
1.686 - dtend = dtstart + duration
1.687 - dtend_attr = dtstart_attr
1.688 - else:
1.689 - dtend, dtend_attr = dtstart, dtstart_attr
1.690 - return (dtstart, dtstart_attr), (dtend, dtend_attr)
1.691 -
1.692 - # Page fragment methods.
1.693 -
1.694 - def show_request_controls(self, obj):
1.695 -
1.696 - "Show form controls for a request concerning 'obj'."
1.697 -
1.698 - page = self.page
1.699 - args = self.env.get_args()
1.700 -
1.701 - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
1.702 -
1.703 - attendees = uri_values((obj.get_values("ATTENDEE") or []) + filter(None, args.get("attendee", [])))
1.704 - is_attendee = self.user in attendees
1.705 -
1.706 - is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests()
1.707 -
1.708 - have_other_attendees = len(attendees) > (is_attendee and 1 or 0)
1.709 -
1.710 - # Show appropriate options depending on the role of the user.
1.711 -
1.712 - if is_attendee and not is_organiser:
1.713 - page.p("An action is required for this request:")
1.714 -
1.715 - page.p()
1.716 - page.input(name="reply", type="submit", value="Send reply")
1.717 - page.add(" ")
1.718 - page.input(name="discard", type="submit", value="Discard event")
1.719 - page.add(" ")
1.720 - page.input(name="ignore", type="submit", value="Do nothing for now")
1.721 - page.p.close()
1.722 -
1.723 - if is_organiser:
1.724 - page.p("As organiser, you can perform the following:")
1.725 -
1.726 - if have_other_attendees:
1.727 - page.p()
1.728 - page.input(name="invite", type="submit", value="Invite/notify attendees")
1.729 - page.add(" ")
1.730 - if is_request:
1.731 - page.input(name="discard", type="submit", value="Discard event")
1.732 - else:
1.733 - page.input(name="cancel", type="submit", value="Cancel event")
1.734 - page.add(" ")
1.735 - page.input(name="ignore", type="submit", value="Do nothing for now")
1.736 - page.p.close()
1.737 - else:
1.738 - page.p()
1.739 - page.input(name="save", type="submit", value="Save event")
1.740 - page.add(" ")
1.741 - page.input(name="discard", type="submit", value="Discard event")
1.742 - page.add(" ")
1.743 - page.input(name="ignore", type="submit", value="Do nothing for now")
1.744 - page.p.close()
1.745 -
1.746 - property_items = [
1.747 - ("SUMMARY", "Summary"),
1.748 - ("DTSTART", "Start"),
1.749 - ("DTEND", "End"),
1.750 - ("ORGANIZER", "Organiser"),
1.751 - ("ATTENDEE", "Attendee"),
1.752 - ]
1.753 -
1.754 - partstat_items = [
1.755 - ("NEEDS-ACTION", "Not confirmed"),
1.756 - ("ACCEPTED", "Attending"),
1.757 - ("TENTATIVE", "Tentatively attending"),
1.758 - ("DECLINED", "Not attending"),
1.759 - ("DELEGATED", "Delegated"),
1.760 - (None, "Not indicated"),
1.761 - ]
1.762 -
1.763 - def show_object_on_page(self, uid, obj, error=None):
1.764 -
1.765 - """
1.766 - Show the calendar object with the given 'uid' and representation 'obj'
1.767 - on the current page. If 'error' is given, show a suitable message.
1.768 - """
1.769 -
1.770 - page = self.page
1.771 - page.form(method="POST")
1.772 -
1.773 - page.input(name="editing", type="hidden", value="true")
1.774 -
1.775 - args = self.env.get_args()
1.776 -
1.777 - # Obtain the user's timezone.
1.778 -
1.779 - tzid = self.get_tzid()
1.780 -
1.781 - # Obtain basic event information, showing any necessary editing controls.
1.782 -
1.783 - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
1.784 -
1.785 - if is_organiser:
1.786 - new_attendees, new_attendee = self.handle_new_attendees(obj)
1.787 - else:
1.788 - new_attendees = []
1.789 - new_attendee = ""
1.790 -
1.791 - (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_event_period(obj)
1.792 - self.show_object_datetime_controls(dtstart, dtend)
1.793 -
1.794 - # Provide a summary of the object.
1.795 -
1.796 - page.table(class_="object", cellspacing=5, cellpadding=5)
1.797 - page.thead()
1.798 - page.tr()
1.799 - page.th("Event", class_="mainheading", colspan=2)
1.800 - page.tr.close()
1.801 - page.thead.close()
1.802 - page.tbody()
1.803 -
1.804 - for name, label in self.property_items:
1.805 - field = name.lower()
1.806 -
1.807 - items = obj.get_items(name) or []
1.808 - rowspan = len(items)
1.809 -
1.810 - if name == "ATTENDEE":
1.811 - rowspan += len(new_attendees) + 1
1.812 - elif not items:
1.813 - continue
1.814 -
1.815 - page.tr()
1.816 - page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""), rowspan=rowspan)
1.817 -
1.818 - # Handle datetimes specially.
1.819 -
1.820 - if name in ["DTSTART", "DTEND"]:
1.821 -
1.822 - # Obtain the datetime.
1.823 -
1.824 - if name == "DTSTART":
1.825 - dt, attr = dtstart, dtstart_attr
1.826 -
1.827 - # Where no end datetime exists, use the start datetime as the
1.828 - # basis of any potential datetime specified if dt-control is
1.829 - # set.
1.830 -
1.831 - else:
1.832 - dt, attr = dtend or dtstart, dtend_attr or dtstart_attr
1.833 -
1.834 - self.show_datetime_controls(obj, dt, attr, name == "DTSTART")
1.835 -
1.836 - page.tr.close()
1.837 -
1.838 - # Handle the summary specially.
1.839 -
1.840 - elif name == "SUMMARY":
1.841 - value = args.get("summary", [obj.get_value(name)])[0]
1.842 -
1.843 - page.td()
1.844 - if is_organiser:
1.845 - page.input(name="summary", type="text", value=value, size=80)
1.846 - else:
1.847 - page.add(value)
1.848 - page.td.close()
1.849 - page.tr.close()
1.850 -
1.851 - # Handle potentially many values.
1.852 -
1.853 - else:
1.854 - first = True
1.855 -
1.856 - for i, (value, attr) in enumerate(items):
1.857 - if not first:
1.858 - page.tr()
1.859 - else:
1.860 - first = False
1.861 -
1.862 - if name == "ATTENDEE":
1.863 - value = get_uri(value)
1.864 -
1.865 - page.td(class_="objectvalue")
1.866 - page.add(value)
1.867 - page.add(" ")
1.868 -
1.869 - partstat = attr.get("PARTSTAT")
1.870 - if value == self.user:
1.871 - self._show_menu("partstat", partstat, self.partstat_items, "partstat")
1.872 - else:
1.873 - page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat")
1.874 -
1.875 - if is_organiser:
1.876 - if value in args.get("remove", []):
1.877 - page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked")
1.878 - else:
1.879 - page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove")
1.880 - page.label("Remove", for_="remove-%d" % i, class_="remove")
1.881 - page.label("Uninvited", for_="remove-%d" % i, class_="removed")
1.882 -
1.883 - else:
1.884 - page.td(class_="objectvalue")
1.885 - page.add(value)
1.886 -
1.887 - page.td.close()
1.888 - page.tr.close()
1.889 -
1.890 - # Allow more attendees to be specified.
1.891 -
1.892 - if is_organiser and name == "ATTENDEE":
1.893 - for i, attendee in enumerate(new_attendees):
1.894 - if not first:
1.895 - page.tr()
1.896 - else:
1.897 - first = False
1.898 -
1.899 - page.td()
1.900 - page.input(name="added", type="value", value=attendee)
1.901 - page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove")
1.902 - page.label("Remove", for_="removenew-%d" % i, class_="remove")
1.903 - page.td.close()
1.904 - page.tr.close()
1.905 -
1.906 - if not first:
1.907 - page.tr()
1.908 -
1.909 - page.td()
1.910 - page.input(name="attendee", type="value", value=new_attendee)
1.911 - page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add")
1.912 - page.label("Add", for_="add-%d" % i, class_="add")
1.913 - page.td.close()
1.914 - page.tr.close()
1.915 -
1.916 - page.tbody.close()
1.917 - page.table.close()
1.918 -
1.919 - self.show_recurrences(obj)
1.920 - self.show_conflicting_events(uid, obj)
1.921 - self.show_request_controls(obj)
1.922 -
1.923 - page.form.close()
1.924 -
1.925 - def show_object_datetime_controls(self, start, end, index=None):
1.926 -
1.927 - """
1.928 - Show datetime-related controls if already active or if an object needs
1.929 - them for the given 'start' to 'end' period. The given 'index' is used to
1.930 - parameterise individual controls for dynamic manipulation.
1.931 - """
1.932 -
1.933 - page = self.page
1.934 - args = self.env.get_args()
1.935 - sn = self._suffixed_name
1.936 - ssn = self._simple_suffixed_name
1.937 -
1.938 - # Add a dynamic stylesheet to permit the controls to modify the display.
1.939 - # NOTE: The style details need to be coordinated with the static
1.940 - # NOTE: stylesheet.
1.941 -
1.942 - if index is not None:
1.943 - page.style(type="text/css")
1.944 -
1.945 - # Unlike the rules for object properties, these affect recurrence
1.946 - # properties.
1.947 -
1.948 - page.add("""\
1.949 -input#dttimes-enable-%(index)d,
1.950 -input#dtend-enable-%(index)d,
1.951 -input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled,
1.952 -input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled,
1.953 -input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled,
1.954 -input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled {
1.955 - display: none;
1.956 -}""" % {"index" : index})
1.957 -
1.958 - page.style.close()
1.959 -
1.960 - dtend_control = args.get(ssn("dtend-control", "recur", index), [])
1.961 - dttimes_control = args.get(ssn("dttimes-control", "recur", index), [])
1.962 -
1.963 - dtend_enabled = index is not None and str(index) in dtend_control or index is None and dtend_control
1.964 - dttimes_enabled = index is not None and str(index) in dttimes_control or index is None and dttimes_control
1.965 -
1.966 - initial_load = not args.has_key("editing")
1.967 -
1.968 - dtend_enabled = dtend_enabled or initial_load and (isinstance(end, datetime) or start != end - timedelta(1))
1.969 - dttimes_enabled = dttimes_enabled or initial_load and (isinstance(start, datetime) or isinstance(end, datetime))
1.970 -
1.971 - if dtend_enabled:
1.972 - page.input(name=ssn("dtend-control", "recur", index), type="checkbox",
1.973 - value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index), checked="checked")
1.974 - else:
1.975 - page.input(name=ssn("dtend-control", "recur", index), type="checkbox",
1.976 - value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index))
1.977 -
1.978 - if dttimes_enabled:
1.979 - page.input(name=ssn("dttimes-control", "recur", index), type="checkbox",
1.980 - value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index), checked="checked")
1.981 - else:
1.982 - page.input(name=ssn("dttimes-control", "recur", index), type="checkbox",
1.983 - value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index))
1.984 -
1.985 - def show_datetime_controls(self, obj, dt, attr, show_start):
1.986 -
1.987 - """
1.988 - Show datetime details from the given 'obj' for the datetime 'dt' and
1.989 - attributes 'attr', showing start details if 'show_start' is set
1.990 - to a true value. Details will appear as controls for organisers and
1.991 - labels for attendees.
1.992 - """
1.993 -
1.994 - page = self.page
1.995 - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
1.996 -
1.997 - # Change end dates to refer to the actual dates, not the iCalendar
1.998 - # "next day" dates.
1.999 -
1.1000 - if not show_start and not isinstance(dt, datetime):
1.1001 - dt -= timedelta(1)
1.1002 -
1.1003 - # Show controls for editing as organiser.
1.1004 -
1.1005 - if is_organiser:
1.1006 - page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))
1.1007 -
1.1008 - if show_start:
1.1009 - page.div(class_="dt enabled")
1.1010 - self._show_date_controls("dtstart", dt, attr.get("TZID"))
1.1011 - page.br()
1.1012 - page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")
1.1013 - page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable")
1.1014 - page.div.close()
1.1015 -
1.1016 - else:
1.1017 - page.div(class_="dt disabled")
1.1018 - page.label("Specify end date", for_="dtend-enable", class_="enable")
1.1019 - page.div.close()
1.1020 - page.div(class_="dt enabled")
1.1021 - self._show_date_controls("dtend", dt, attr.get("TZID"))
1.1022 - page.br()
1.1023 - page.label("End on same day", for_="dtend-enable", class_="disable")
1.1024 - page.div.close()
1.1025 -
1.1026 - page.td.close()
1.1027 -
1.1028 - # Show a label as attendee.
1.1029 -
1.1030 - else:
1.1031 - page.td(self.format_datetime(dt, "full"))
1.1032 -
1.1033 - def show_recurrence_controls(self, obj, index, start, end, origin, recurrenceid, recurrenceids, show_start):
1.1034 -
1.1035 - """
1.1036 - Show datetime details from the given 'obj' for the recurrence having the
1.1037 - given 'index', with the recurrence period described by the datetimes
1.1038 - 'start' and 'end', indicating the 'origin' of the period from the event
1.1039 - details, employing any 'recurrenceid' and 'recurrenceids' for the object
1.1040 - to configure the displayed information.
1.1041 -
1.1042 - If 'show_start' is set to a true value, the start details will be shown;
1.1043 - otherwise, the end details will be shown.
1.1044 - """
1.1045 -
1.1046 - page = self.page
1.1047 - sn = self._suffixed_name
1.1048 - ssn = self._simple_suffixed_name
1.1049 -
1.1050 - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
1.1051 -
1.1052 - # Change end dates to refer to the actual dates, not the iCalendar
1.1053 - # "next day" dates.
1.1054 -
1.1055 - if not isinstance(end, datetime):
1.1056 - end -= timedelta(1)
1.1057 -
1.1058 - start_utc = format_datetime(to_timezone(start, "UTC"))
1.1059 - replaced = recurrenceids and start_utc in recurrenceids and "replaced" or ""
1.1060 - css = " ".join([
1.1061 - replaced,
1.1062 - recurrenceid and start_utc == recurrenceid and "affected" or ""
1.1063 - ])
1.1064 -
1.1065 - # Show controls for editing as organiser.
1.1066 -
1.1067 - if is_organiser and not replaced and origin != "RRULE":
1.1068 - page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))
1.1069 -
1.1070 - if show_start:
1.1071 - page.div(class_="dt enabled")
1.1072 - self._show_date_controls(ssn("dtstart", "recur", index), start, None, index)
1.1073 - page.br()
1.1074 - page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable")
1.1075 - page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable")
1.1076 - page.div.close()
1.1077 -
1.1078 - else:
1.1079 - page.div(class_="dt disabled")
1.1080 - page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable")
1.1081 - page.div.close()
1.1082 - page.div(class_="dt enabled")
1.1083 - self._show_date_controls(ssn("dtend", "recur", index), end, None, index)
1.1084 - page.br()
1.1085 - page.label("End on same day", for_=sn("dtend-enable", index), class_="disable")
1.1086 - page.div.close()
1.1087 -
1.1088 - page.td.close()
1.1089 -
1.1090 - # Show label as attendee.
1.1091 -
1.1092 - else:
1.1093 - page.td(self.format_datetime(show_start and start or end, "long"), class_=css)
1.1094 -
1.1095 - def show_recurrences(self, obj):
1.1096 -
1.1097 - "Show recurrences for the object having the given representation 'obj'."
1.1098 -
1.1099 - page = self.page
1.1100 - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
1.1101 -
1.1102 - # Obtain any parent object if this object is a specific recurrence.
1.1103 -
1.1104 - uid = obj.get_value("UID")
1.1105 - recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))
1.1106 -
1.1107 - if recurrenceid:
1.1108 - obj = self._get_object(uid)
1.1109 - if not obj:
1.1110 - return
1.1111 -
1.1112 - page.p("This event modifies a recurring event.")
1.1113 -
1.1114 - # Obtain the periods associated with the event in the user's time zone.
1.1115 -
1.1116 - periods = obj.get_periods(self.get_tzid(), self.get_window_end(), origin=True)
1.1117 - recurrenceids = self._get_recurrences(uid)
1.1118 -
1.1119 - if len(periods) == 1:
1.1120 - return
1.1121 -
1.1122 - if is_organiser:
1.1123 - page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size())
1.1124 - else:
1.1125 - page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size())
1.1126 -
1.1127 - # Determine whether any periods are explicitly created or are part of a
1.1128 - # rule.
1.1129 -
1.1130 - explicit_periods = filter(lambda t: t[2] != "RRULE", periods)
1.1131 -
1.1132 - # Show each recurrence in a separate table if editable.
1.1133 -
1.1134 - if is_organiser and explicit_periods:
1.1135 -
1.1136 - for index, (start, end, origin) in enumerate(periods[1:]):
1.1137 -
1.1138 - # Isolate the controls from neighbouring tables.
1.1139 -
1.1140 - page.div()
1.1141 -
1.1142 - self.show_object_datetime_controls(start, end, index)
1.1143 -
1.1144 - # NOTE: Need to customise the TH classes according to errors and
1.1145 - # NOTE: index information.
1.1146 -
1.1147 - page.table(cellspacing=5, cellpadding=5, class_="recurrence")
1.1148 - page.caption("Occurrence")
1.1149 - page.tbody()
1.1150 - page.tr()
1.1151 - page.th("Start", class_="objectheading start")
1.1152 - self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True)
1.1153 - page.tr.close()
1.1154 - page.tr()
1.1155 - page.th("End", class_="objectheading end")
1.1156 - self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False)
1.1157 - page.tr.close()
1.1158 - page.tbody.close()
1.1159 - page.table.close()
1.1160 -
1.1161 - page.div.close()
1.1162 -
1.1163 - # Otherwise, use a compact single table.
1.1164 -
1.1165 - else:
1.1166 - page.table(cellspacing=5, cellpadding=5, class_="recurrence")
1.1167 - page.caption("Occurrences")
1.1168 - page.thead()
1.1169 - page.tr()
1.1170 - page.th("Start", class_="objectheading start")
1.1171 - page.th("End", class_="objectheading end")
1.1172 - page.tr.close()
1.1173 - page.thead.close()
1.1174 - page.tbody()
1.1175 -
1.1176 - # Show only subsequent periods if organiser, since the principal
1.1177 - # period will be the start and end datetimes.
1.1178 -
1.1179 - for index, (start, end, origin) in enumerate(is_organiser and periods[1:] or periods):
1.1180 - page.tr()
1.1181 - self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True)
1.1182 - self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False)
1.1183 - page.tr.close()
1.1184 - page.tbody.close()
1.1185 - page.table.close()
1.1186 -
1.1187 - def show_conflicting_events(self, uid, obj):
1.1188 -
1.1189 - """
1.1190 - Show conflicting events for the object having the given 'uid' and
1.1191 - representation 'obj'.
1.1192 - """
1.1193 -
1.1194 - page = self.page
1.1195 -
1.1196 - # Obtain the user's timezone.
1.1197 -
1.1198 - tzid = self.get_tzid()
1.1199 - periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end())
1.1200 -
1.1201 - # Indicate whether there are conflicting events.
1.1202 -
1.1203 - freebusy = self.store.get_freebusy(self.user)
1.1204 -
1.1205 - if freebusy:
1.1206 -
1.1207 - # Obtain any time zone details from the suggested event.
1.1208 -
1.1209 - _dtstart, attr = obj.get_item("DTSTART")
1.1210 - tzid = attr.get("TZID", tzid)
1.1211 -
1.1212 - # Show any conflicts.
1.1213 -
1.1214 - conflicts = [t for t in have_conflict(freebusy, periods, True) if t[2] != uid]
1.1215 -
1.1216 - if conflicts:
1.1217 - page.p("This event conflicts with others:")
1.1218 -
1.1219 - page.table(cellspacing=5, cellpadding=5, class_="conflicts")
1.1220 - page.thead()
1.1221 - page.tr()
1.1222 - page.th("Event")
1.1223 - page.th("Start")
1.1224 - page.th("End")
1.1225 - page.tr.close()
1.1226 - page.thead.close()
1.1227 - page.tbody()
1.1228 -
1.1229 - for t in conflicts:
1.1230 - start, end, found_uid, transp, found_recurrenceid, summary = t[:6]
1.1231 -
1.1232 - # Provide details of any conflicting event.
1.1233 -
1.1234 - start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long")
1.1235 - end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long")
1.1236 -
1.1237 - page.tr()
1.1238 -
1.1239 - # Show the event summary for the conflicting event.
1.1240 -
1.1241 - page.td()
1.1242 - page.a(summary, href=self.link_to(found_uid))
1.1243 - page.td.close()
1.1244 -
1.1245 - page.td(start)
1.1246 - page.td(end)
1.1247 -
1.1248 - page.tr.close()
1.1249 -
1.1250 - page.tbody.close()
1.1251 - page.table.close()
1.1252 -
1.1253 - def show_requests_on_page(self):
1.1254 -
1.1255 - "Show requests for the current user."
1.1256 -
1.1257 - page = self.page
1.1258 -
1.1259 - # NOTE: This list could be more informative, but it is envisaged that
1.1260 - # NOTE: the requests would be visited directly anyway.
1.1261 -
1.1262 - requests = self._get_requests()
1.1263 -
1.1264 - page.div(id="pending-requests")
1.1265 -
1.1266 - if requests:
1.1267 - page.p("Pending requests:")
1.1268 -
1.1269 - page.ul()
1.1270 -
1.1271 - for uid, recurrenceid in requests:
1.1272 - obj = self._get_object(uid, recurrenceid)
1.1273 - if obj:
1.1274 - page.li()
1.1275 - page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or ""))
1.1276 - page.li.close()
1.1277 -
1.1278 - page.ul.close()
1.1279 -
1.1280 - else:
1.1281 - page.p("There are no pending requests.")
1.1282 -
1.1283 - page.div.close()
1.1284 -
1.1285 - def show_participants_on_page(self):
1.1286 -
1.1287 - "Show participants for scheduling purposes."
1.1288 -
1.1289 - page = self.page
1.1290 - args = self.env.get_args()
1.1291 - participants = args.get("participants", [])
1.1292 -
1.1293 - try:
1.1294 - for name, value in args.items():
1.1295 - if name.startswith("remove-participant-"):
1.1296 - i = int(name[len("remove-participant-"):])
1.1297 - del participants[i]
1.1298 - break
1.1299 - except ValueError:
1.1300 - pass
1.1301 -
1.1302 - # Trim empty participants.
1.1303 -
1.1304 - while participants and not participants[-1].strip():
1.1305 - participants.pop()
1.1306 -
1.1307 - # Show any specified participants together with controls to remove and
1.1308 - # add participants.
1.1309 -
1.1310 - page.div(id="participants")
1.1311 -
1.1312 - page.p("Participants for scheduling:")
1.1313 -
1.1314 - for i, participant in enumerate(participants):
1.1315 - page.p()
1.1316 - page.input(name="participants", type="text", value=participant)
1.1317 - page.input(name="remove-participant-%d" % i, type="submit", value="Remove")
1.1318 - page.p.close()
1.1319 -
1.1320 - page.p()
1.1321 - page.input(name="participants", type="text")
1.1322 - page.input(name="add-participant", type="submit", value="Add")
1.1323 - page.p.close()
1.1324 -
1.1325 - page.div.close()
1.1326 -
1.1327 - return participants
1.1328 -
1.1329 - # Full page output methods.
1.1330 -
1.1331 - def show_object(self, path_info):
1.1332 -
1.1333 - "Show an object request using the given 'path_info' for the current user."
1.1334 -
1.1335 - uid, recurrenceid = self._get_identifiers(path_info)
1.1336 - obj = self._get_object(uid, recurrenceid)
1.1337 -
1.1338 - if not obj:
1.1339 - return False
1.1340 -
1.1341 - error = self.handle_request(uid, recurrenceid, obj)
1.1342 -
1.1343 - if not error:
1.1344 - return True
1.1345 -
1.1346 - self.new_page(title="Event")
1.1347 - self.show_object_on_page(uid, obj, error)
1.1348 -
1.1349 - return True
1.1350 -
1.1351 - def show_calendar(self):
1.1352 -
1.1353 - "Show the calendar for the current user."
1.1354 -
1.1355 - handled = self.handle_newevent()
1.1356 -
1.1357 - self.new_page(title="Calendar")
1.1358 - page = self.page
1.1359 -
1.1360 - # Form controls are used in various places on the calendar page.
1.1361 -
1.1362 - page.form(method="POST")
1.1363 -
1.1364 - self.show_requests_on_page()
1.1365 - participants = self.show_participants_on_page()
1.1366 -
1.1367 - # Show a button for scheduling a new event.
1.1368 -
1.1369 - page.p(class_="controls")
1.1370 - page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N")
1.1371 - page.p.close()
1.1372 -
1.1373 - # Show controls for hiding empty days and busy slots.
1.1374 - # The positioning of the control, paragraph and table are important here.
1.1375 -
1.1376 - page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")
1.1377 - page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")
1.1378 -
1.1379 - page.p(class_="controls")
1.1380 - page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")
1.1381 - page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")
1.1382 - page.label("Show empty days", for_="showdays", class_="showdays disable")
1.1383 - page.label("Hide empty days", for_="showdays", class_="showdays enable")
1.1384 - page.input(name="reset", type="submit", value="Clear selections", id="reset")
1.1385 - page.label("Clear selections", for_="reset", class_="reset")
1.1386 - page.p.close()
1.1387 -
1.1388 - freebusy = self.store.get_freebusy(self.user)
1.1389 -
1.1390 - if not freebusy:
1.1391 - page.p("No events scheduled.")
1.1392 - return
1.1393 -
1.1394 - # Obtain the user's timezone.
1.1395 -
1.1396 - tzid = self.get_tzid()
1.1397 -
1.1398 - # Day view: start at the earliest known day and produce days until the
1.1399 - # latest known day, perhaps with expandable sections of empty days.
1.1400 -
1.1401 - # Month view: start at the earliest known month and produce months until
1.1402 - # the latest known month, perhaps with expandable sections of empty
1.1403 - # months.
1.1404 -
1.1405 - # Details of users to invite to new events could be superimposed on the
1.1406 - # calendar.
1.1407 -
1.1408 - # Requests are listed and linked to their tentative positions in the
1.1409 - # calendar. Other participants are also shown.
1.1410 -
1.1411 - request_summary = self._get_request_summary()
1.1412 -
1.1413 - period_groups = [request_summary, freebusy]
1.1414 - period_group_types = ["request", "freebusy"]
1.1415 - period_group_sources = ["Pending requests", "Your schedule"]
1.1416 -
1.1417 - for i, participant in enumerate(participants):
1.1418 - period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))
1.1419 - period_group_types.append("freebusy-part%d" % i)
1.1420 - period_group_sources.append(participant)
1.1421 -
1.1422 - groups = []
1.1423 - group_columns = []
1.1424 - group_types = period_group_types
1.1425 - group_sources = period_group_sources
1.1426 - all_points = set()
1.1427 -
1.1428 - # Obtain time point information for each group of periods.
1.1429 -
1.1430 - for periods in period_groups:
1.1431 - periods = convert_periods(periods, tzid)
1.1432 -
1.1433 - # Get the time scale with start and end points.
1.1434 -
1.1435 - scale = get_scale(periods)
1.1436 -
1.1437 - # Get the time slots for the periods.
1.1438 -
1.1439 - slots = get_slots(scale)
1.1440 -
1.1441 - # Add start of day time points for multi-day periods.
1.1442 -
1.1443 - add_day_start_points(slots, tzid)
1.1444 -
1.1445 - # Record the slots and all time points employed.
1.1446 -
1.1447 - groups.append(slots)
1.1448 - all_points.update([point for point, active in slots])
1.1449 -
1.1450 - # Partition the groups into days.
1.1451 -
1.1452 - days = {}
1.1453 - partitioned_groups = []
1.1454 - partitioned_group_types = []
1.1455 - partitioned_group_sources = []
1.1456 -
1.1457 - for slots, group_type, group_source in zip(groups, group_types, group_sources):
1.1458 -
1.1459 - # Propagate time points to all groups of time slots.
1.1460 -
1.1461 - add_slots(slots, all_points)
1.1462 -
1.1463 - # Count the number of columns employed by the group.
1.1464 -
1.1465 - columns = 0
1.1466 -
1.1467 - # Partition the time slots by day.
1.1468 -
1.1469 - partitioned = {}
1.1470 -
1.1471 - for day, day_slots in partition_by_day(slots).items():
1.1472 -
1.1473 - # Construct a list of time intervals within the day.
1.1474 -
1.1475 - intervals = []
1.1476 - last = None
1.1477 -
1.1478 - for point, active in day_slots:
1.1479 - columns = max(columns, len(active))
1.1480 - if last:
1.1481 - intervals.append((last, point))
1.1482 - last = point
1.1483 -
1.1484 - if last:
1.1485 - intervals.append((last, None))
1.1486 -
1.1487 - if not days.has_key(day):
1.1488 - days[day] = set()
1.1489 -
1.1490 - # Convert each partition to a mapping from points to active
1.1491 - # periods.
1.1492 -
1.1493 - partitioned[day] = dict(day_slots)
1.1494 -
1.1495 - # Record the divisions or intervals within each day.
1.1496 -
1.1497 - days[day].update(intervals)
1.1498 -
1.1499 - # Only include the requests column if it provides objects.
1.1500 -
1.1501 - if group_type != "request" or columns:
1.1502 - group_columns.append(columns)
1.1503 - partitioned_groups.append(partitioned)
1.1504 - partitioned_group_types.append(group_type)
1.1505 - partitioned_group_sources.append(group_source)
1.1506 -
1.1507 - # Add empty days.
1.1508 -
1.1509 - add_empty_days(days, tzid)
1.1510 -
1.1511 - # Show the controls permitting day selection.
1.1512 -
1.1513 - self.show_calendar_day_controls(days)
1.1514 -
1.1515 - # Show the calendar itself.
1.1516 -
1.1517 - page.table(cellspacing=5, cellpadding=5, class_="calendar")
1.1518 - self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)
1.1519 - self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)
1.1520 - page.table.close()
1.1521 -
1.1522 - # End the form region.
1.1523 -
1.1524 - page.form.close()
1.1525 -
1.1526 - # More page fragment methods.
1.1527 -
1.1528 - def show_calendar_day_controls(self, days):
1.1529 -
1.1530 - "Show controls for the given 'days' in the calendar."
1.1531 -
1.1532 - page = self.page
1.1533 - slots = self.env.get_args().get("slot", [])
1.1534 -
1.1535 - for day in days:
1.1536 - value, identifier = self._day_value_and_identifier(day)
1.1537 - self._slot_selector(value, identifier, slots)
1.1538 -
1.1539 - # Generate a dynamic stylesheet to allow day selections to colour
1.1540 - # specific days.
1.1541 - # NOTE: The style details need to be coordinated with the static
1.1542 - # NOTE: stylesheet.
1.1543 -
1.1544 - page.style(type="text/css")
1.1545 -
1.1546 - for day in days:
1.1547 - daystr = format_datetime(day)
1.1548 - page.add("""\
1.1549 -input.newevent.selector#day-%s-:checked ~ table label.day.day-%s,
1.1550 -input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s {
1.1551 - background-color: #5f4;
1.1552 - text-decoration: underline;
1.1553 -}
1.1554 -""" % (daystr, daystr, daystr, daystr))
1.1555 -
1.1556 - page.style.close()
1.1557 -
1.1558 - def show_calendar_participant_headings(self, group_types, group_sources, group_columns):
1.1559 -
1.1560 - """
1.1561 - Show headings for the participants and other scheduling contributors,
1.1562 - defined by 'group_types', 'group_sources' and 'group_columns'.
1.1563 - """
1.1564 -
1.1565 - page = self.page
1.1566 -
1.1567 - page.colgroup(span=1, id="columns-timeslot")
1.1568 -
1.1569 - for group_type, columns in zip(group_types, group_columns):
1.1570 - page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)
1.1571 -
1.1572 - page.thead()
1.1573 - page.tr()
1.1574 - page.th("", class_="emptyheading")
1.1575 -
1.1576 - for group_type, source, columns in zip(group_types, group_sources, group_columns):
1.1577 - page.th(source,
1.1578 - class_=(group_type == "request" and "requestheading" or "participantheading"),
1.1579 - colspan=max(columns, 1))
1.1580 -
1.1581 - page.tr.close()
1.1582 - page.thead.close()
1.1583 -
1.1584 - def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):
1.1585 -
1.1586 - """
1.1587 - Show calendar days, defined by a collection of 'days', the contributing
1.1588 - period information as 'partitioned_groups' (partitioned by day), the
1.1589 - 'partitioned_group_types' indicating the kind of contribution involved,
1.1590 - and the 'group_columns' defining the number of columns in each group.
1.1591 - """
1.1592 -
1.1593 - page = self.page
1.1594 -
1.1595 - # Determine the number of columns required. Where participants provide
1.1596 - # no columns for events, one still needs to be provided for the
1.1597 - # participant itself.
1.1598 -
1.1599 - all_columns = sum([max(columns, 1) for columns in group_columns])
1.1600 -
1.1601 - # Determine the days providing time slots.
1.1602 -
1.1603 - all_days = days.items()
1.1604 - all_days.sort()
1.1605 -
1.1606 - # Produce a heading and time points for each day.
1.1607 -
1.1608 - for day, intervals in all_days:
1.1609 - groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]
1.1610 - is_empty = True
1.1611 -
1.1612 - for slots in groups_for_day:
1.1613 - if not slots:
1.1614 - continue
1.1615 -
1.1616 - for active in slots.values():
1.1617 - if active:
1.1618 - is_empty = False
1.1619 - break
1.1620 -
1.1621 - page.thead(class_="separator%s" % (is_empty and " empty" or ""))
1.1622 - page.tr()
1.1623 - page.th(class_="dayheading container", colspan=all_columns+1)
1.1624 - self._day_heading(day)
1.1625 - page.th.close()
1.1626 - page.tr.close()
1.1627 - page.thead.close()
1.1628 -
1.1629 - page.tbody(class_="points%s" % (is_empty and " empty" or ""))
1.1630 - self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)
1.1631 - page.tbody.close()
1.1632 -
1.1633 - def show_calendar_points(self, intervals, groups, group_types, group_columns):
1.1634 -
1.1635 - """
1.1636 - Show the time 'intervals' along with period information from the given
1.1637 - 'groups', having the indicated 'group_types', each with the number of
1.1638 - columns given by 'group_columns'.
1.1639 - """
1.1640 -
1.1641 - page = self.page
1.1642 -
1.1643 - # Obtain the user's timezone.
1.1644 -
1.1645 - tzid = self.get_tzid()
1.1646 -
1.1647 - # Produce a row for each interval.
1.1648 -
1.1649 - intervals = list(intervals)
1.1650 - intervals.sort()
1.1651 -
1.1652 - for point, endpoint in intervals:
1.1653 - continuation = point == get_start_of_day(point, tzid)
1.1654 -
1.1655 - # Some rows contain no period details and are marked as such.
1.1656 -
1.1657 - have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None)
1.1658 -
1.1659 - css = " ".join([
1.1660 - "slot",
1.1661 - have_active and "busy" or "empty",
1.1662 - continuation and "daystart" or ""
1.1663 - ])
1.1664 -
1.1665 - page.tr(class_=css)
1.1666 - page.th(class_="timeslot")
1.1667 - self._time_point(point, endpoint)
1.1668 - page.th.close()
1.1669 -
1.1670 - # Obtain slots for the time point from each group.
1.1671 -
1.1672 - for columns, slots, group_type in zip(group_columns, groups, group_types):
1.1673 - active = slots and slots.get(point)
1.1674 -
1.1675 - # Where no periods exist for the given time interval, generate
1.1676 - # an empty cell. Where a participant provides no periods at all,
1.1677 - # the colspan is adjusted to be 1, not 0.
1.1678 -
1.1679 - if not active:
1.1680 - page.td(class_="empty container", colspan=max(columns, 1))
1.1681 - self._empty_slot(point, endpoint)
1.1682 - page.td.close()
1.1683 - continue
1.1684 -
1.1685 - slots = slots.items()
1.1686 - slots.sort()
1.1687 - spans = get_spans(slots)
1.1688 -
1.1689 - empty = 0
1.1690 -
1.1691 - # Show a column for each active period.
1.1692 -
1.1693 - for t in active:
1.1694 - if t and len(t) >= 2:
1.1695 -
1.1696 - # Flush empty slots preceding this one.
1.1697 -
1.1698 - if empty:
1.1699 - page.td(class_="empty container", colspan=empty)
1.1700 - self._empty_slot(point, endpoint)
1.1701 - page.td.close()
1.1702 - empty = 0
1.1703 -
1.1704 - start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t)
1.1705 - span = spans[key]
1.1706 -
1.1707 - # Produce a table cell only at the start of the period
1.1708 - # or when continued at the start of a day.
1.1709 -
1.1710 - if point == start or continuation:
1.1711 -
1.1712 - has_continued = continuation and point != start
1.1713 - will_continue = not ends_on_same_day(point, end, tzid)
1.1714 - is_organiser = organiser == self.user
1.1715 -
1.1716 - css = " ".join([
1.1717 - "event",
1.1718 - has_continued and "continued" or "",
1.1719 - will_continue and "continues" or "",
1.1720 - is_organiser and "organising" or "attending"
1.1721 - ])
1.1722 -
1.1723 - # Only anchor the first cell of events.
1.1724 - # Need to only anchor the first period for a recurring
1.1725 - # event.
1.1726 -
1.1727 - html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "")
1.1728 -
1.1729 - if point == start and html_id not in self.html_ids:
1.1730 - page.td(class_=css, rowspan=span, id=html_id)
1.1731 - self.html_ids.add(html_id)
1.1732 - else:
1.1733 - page.td(class_=css, rowspan=span)
1.1734 -
1.1735 - # Only link to events if they are not being
1.1736 - # updated by requests.
1.1737 -
1.1738 - if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request":
1.1739 - page.span(summary or "(Participant is busy)")
1.1740 - else:
1.1741 - page.a(summary, href=self.link_to(uid, recurrenceid))
1.1742 -
1.1743 - page.td.close()
1.1744 - else:
1.1745 - empty += 1
1.1746 -
1.1747 - # Pad with empty columns.
1.1748 -
1.1749 - empty = columns - len(active)
1.1750 -
1.1751 - if empty:
1.1752 - page.td(class_="empty container", colspan=empty)
1.1753 - self._empty_slot(point, endpoint)
1.1754 - page.td.close()
1.1755 -
1.1756 - page.tr.close()
1.1757 -
1.1758 - def _day_heading(self, day):
1.1759 -
1.1760 - """
1.1761 - Generate a heading for 'day' of the following form:
1.1762 -
1.1763 - <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>
1.1764 - """
1.1765 -
1.1766 - page = self.page
1.1767 - daystr = format_datetime(day)
1.1768 - value, identifier = self._day_value_and_identifier(day)
1.1769 - page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)
1.1770 -
1.1771 - def _time_point(self, point, endpoint):
1.1772 -
1.1773 - """
1.1774 - Generate headings for the 'point' to 'endpoint' period of the following
1.1775 - form:
1.1776 -
1.1777 - <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>
1.1778 - <span class="endpoint">10:00:00 CET</span>
1.1779 - """
1.1780 -
1.1781 - page = self.page
1.1782 - tzid = self.get_tzid()
1.1783 - daystr = format_datetime(point.date())
1.1784 - value, identifier = self._slot_value_and_identifier(point, endpoint)
1.1785 - slots = self.env.get_args().get("slot", [])
1.1786 - self._slot_selector(value, identifier, slots)
1.1787 - page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)
1.1788 - page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint")
1.1789 -
1.1790 - def _slot_selector(self, value, identifier, slots):
1.1791 -
1.1792 - """
1.1793 - Provide a timeslot control having the given 'value', employing the
1.1794 - indicated HTML 'identifier', and using the given 'slots' collection
1.1795 - to select any control whose 'value' is in this collection, unless the
1.1796 - "reset" request parameter has been asserted.
1.1797 - """
1.1798 -
1.1799 - reset = self.env.get_args().has_key("reset")
1.1800 - page = self.page
1.1801 - if not reset and value in slots:
1.1802 - page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")
1.1803 - else:
1.1804 - page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")
1.1805 -
1.1806 - def _empty_slot(self, point, endpoint):
1.1807 -
1.1808 - "Show an empty slot label for the given 'point' and 'endpoint'."
1.1809 -
1.1810 - page = self.page
1.1811 - value, identifier = self._slot_value_and_identifier(point, endpoint)
1.1812 - page.label("Select/deselect period", class_="newevent popup", for_=identifier)
1.1813 -
1.1814 - def _day_value_and_identifier(self, day):
1.1815 -
1.1816 - "Return a day value and HTML identifier for the given 'day'."
1.1817 -
1.1818 - value = "%s-" % format_datetime(day)
1.1819 - identifier = "day-%s" % value
1.1820 - return value, identifier
1.1821 -
1.1822 - def _slot_value_and_identifier(self, point, endpoint):
1.1823 -
1.1824 - """
1.1825 - Return a slot value and HTML identifier for the given 'point' and
1.1826 - 'endpoint'.
1.1827 - """
1.1828 -
1.1829 - value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")
1.1830 - identifier = "slot-%s" % value
1.1831 - return value, identifier
1.1832 -
1.1833 - def _show_menu(self, name, default, items, class_="", index=None):
1.1834 -
1.1835 - """
1.1836 - Show a select menu having the given 'name', set to the given 'default',
1.1837 - providing the given (value, label) 'items', and employing the given CSS
1.1838 - 'class_' if specified.
1.1839 - """
1.1840 -
1.1841 - page = self.page
1.1842 - values = self.env.get_args().get(name, [default])
1.1843 - if index is not None:
1.1844 - values = values[index:]
1.1845 - values = values and values[0:1] or [default]
1.1846 -
1.1847 - page.select(name=name, class_=class_)
1.1848 - for v, label in items:
1.1849 - if v is None:
1.1850 - continue
1.1851 - if v in values:
1.1852 - page.option(label, value=v, selected="selected")
1.1853 - else:
1.1854 - page.option(label, value=v)
1.1855 - page.select.close()
1.1856 -
1.1857 - def _show_date_controls(self, name, default, tzid, index=None):
1.1858 -
1.1859 - """
1.1860 - Show date controls for a field with the given 'name' and 'default' value
1.1861 - and 'tzid'.
1.1862 - """
1.1863 -
1.1864 - page = self.page
1.1865 - args = self.env.get_args()
1.1866 -
1.1867 - event_tzid = tzid or self.get_tzid()
1.1868 -
1.1869 - # Show dates for up to one week around the current date.
1.1870 -
1.1871 - base = to_date(default)
1.1872 - items = []
1.1873 - for i in range(-7, 8):
1.1874 - d = base + timedelta(i)
1.1875 - items.append((format_datetime(d), self.format_date(d, "full")))
1.1876 -
1.1877 - self._show_menu("%s-date" % name, format_datetime(base), items, index=index)
1.1878 -
1.1879 - # Show time details.
1.1880 -
1.1881 - default_time = isinstance(default, datetime) and default or None
1.1882 -
1.1883 - hour = args.get("%s-hour" % name, [])[index or 0:]
1.1884 - hour = hour and hour[0] or "%02d" % (default_time and default_time.hour or 0)
1.1885 - minute = args.get("%s-minute" % name, [])[index or 0:]
1.1886 - minute = minute and minute[0] or "%02d" % (default_time and default_time.minute or 0)
1.1887 - second = args.get("%s-second" % name, [])[index or 0:]
1.1888 - second = second and second[0] or "%02d" % (default_time and default_time.second or 0)
1.1889 -
1.1890 - page.span(class_="time enabled")
1.1891 - page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2)
1.1892 - page.add(":")
1.1893 - page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2)
1.1894 - page.add(":")
1.1895 - page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2)
1.1896 - page.add(" ")
1.1897 - self._show_timezone_menu("%s-tzid" % name, event_tzid, index)
1.1898 - page.span.close()
1.1899 -
1.1900 - def _show_timezone_menu(self, name, default, index=None):
1.1901 -
1.1902 - """
1.1903 - Show timezone controls using a menu with the given 'name', set to the
1.1904 - given 'default' unless a field of the given 'name' provides a value.
1.1905 - """
1.1906 -
1.1907 - entries = [(tzid, tzid) for tzid in pytz.all_timezones]
1.1908 - self._show_menu(name, default, entries, index=index)
1.1909 -
1.1910 - # Incoming HTTP request direction.
1.1911 -
1.1912 def select_action(self):
1.1913
1.1914 "Select the desired action and show the result."
1.1915 @@ -1932,8 +42,8 @@
1.1916 path_info = self.env.get_path_info().strip("/")
1.1917
1.1918 if not path_info:
1.1919 - self.show_calendar()
1.1920 - elif self.show_object(path_info):
1.1921 + CalendarPage(self).show()
1.1922 + elif EventPage(self).show(path_info):
1.1923 pass
1.1924 else:
1.1925 self.no_page()
2.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000
2.2 +++ b/imipweb/calendar.py Thu Mar 26 16:11:46 2015 +0100
2.3 @@ -0,0 +1,722 @@
2.4 +#!/usr/bin/env python
2.5 +
2.6 +"""
2.7 +A Web interface to an event calendar.
2.8 +
2.9 +Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
2.10 +
2.11 +This program is free software; you can redistribute it and/or modify it under
2.12 +the terms of the GNU General Public License as published by the Free Software
2.13 +Foundation; either version 3 of the License, or (at your option) any later
2.14 +version.
2.15 +
2.16 +This program is distributed in the hope that it will be useful, but WITHOUT
2.17 +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
2.18 +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
2.19 +details.
2.20 +
2.21 +You should have received a copy of the GNU General Public License along with
2.22 +this program. If not, see <http://www.gnu.org/licenses/>.
2.23 +"""
2.24 +
2.25 +from datetime import datetime
2.26 +from imiptools.data import get_address, get_uri, uri_values
2.27 +from imiptools.dates import format_datetime, get_datetime, \
2.28 + get_datetime_item, get_end_of_day, get_start_of_day, \
2.29 + get_start_of_next_day, get_timestamp, ends_on_same_day, \
2.30 + to_timezone
2.31 +from imiptools.period import add_day_start_points, add_empty_days, add_slots, \
2.32 + convert_periods, get_freebusy_details, \
2.33 + get_scale, get_slots, get_spans, partition_by_day
2.34 +from imipweb.resource import Resource
2.35 +
2.36 +class CalendarPage(Resource):
2.37 +
2.38 + "A request handler for the calendar page."
2.39 +
2.40 + # Request logic methods.
2.41 +
2.42 + def handle_newevent(self):
2.43 +
2.44 + """
2.45 + Handle any new event operation, creating a new event and redirecting to
2.46 + the event page for further activity.
2.47 + """
2.48 +
2.49 + # Handle a submitted form.
2.50 +
2.51 + args = self.env.get_args()
2.52 +
2.53 + if not args.has_key("newevent"):
2.54 + return
2.55 +
2.56 + # Create a new event using the available information.
2.57 +
2.58 + slots = args.get("slot", [])
2.59 + participants = args.get("participants", [])
2.60 +
2.61 + if not slots:
2.62 + return
2.63 +
2.64 + # Obtain the user's timezone.
2.65 +
2.66 + tzid = self.get_tzid()
2.67 +
2.68 + # Coalesce the selected slots.
2.69 +
2.70 + slots.sort()
2.71 + coalesced = []
2.72 + last = None
2.73 +
2.74 + for slot in slots:
2.75 + start, end = slot.split("-")
2.76 + start = get_datetime(start, {"TZID" : tzid})
2.77 + end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid)
2.78 +
2.79 + if last:
2.80 + last_start, last_end = last
2.81 +
2.82 + # Merge adjacent dates and datetimes.
2.83 +
2.84 + if start == last_end or \
2.85 + not isinstance(start, datetime) and \
2.86 + get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):
2.87 +
2.88 + last = last_start, end
2.89 + continue
2.90 +
2.91 + # Handle datetimes within dates.
2.92 + # Datetime periods are within single days and are therefore
2.93 + # discarded.
2.94 +
2.95 + elif not isinstance(last_start, datetime) and \
2.96 + get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):
2.97 +
2.98 + continue
2.99 +
2.100 + # Add separate dates and datetimes.
2.101 +
2.102 + else:
2.103 + coalesced.append(last)
2.104 +
2.105 + last = start, end
2.106 +
2.107 + if last:
2.108 + coalesced.append(last)
2.109 +
2.110 + # Invent a unique identifier.
2.111 +
2.112 + utcnow = get_timestamp()
2.113 + uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))
2.114 +
2.115 + # Create a calendar object and store it as a request.
2.116 +
2.117 + record = []
2.118 + rwrite = record.append
2.119 +
2.120 + # Define a single occurrence if only one coalesced slot exists.
2.121 +
2.122 + start, end = coalesced[0]
2.123 + start_value, start_attr = get_datetime_item(start, tzid)
2.124 + end_value, end_attr = get_datetime_item(end, tzid)
2.125 +
2.126 + rwrite(("UID", {}, uid))
2.127 + rwrite(("SUMMARY", {}, "New event at %s" % utcnow))
2.128 + rwrite(("DTSTAMP", {}, utcnow))
2.129 + rwrite(("DTSTART", start_attr, start_value))
2.130 + rwrite(("DTEND", end_attr, end_value))
2.131 + rwrite(("ORGANIZER", {}, self.user))
2.132 +
2.133 + participants = uri_values(filter(None, participants))
2.134 +
2.135 + for participant in participants:
2.136 + rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant))
2.137 +
2.138 + if self.user not in participants:
2.139 + rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user))
2.140 +
2.141 + # Define additional occurrences if many slots are defined.
2.142 +
2.143 + rdates = []
2.144 +
2.145 + for start, end in coalesced[1:]:
2.146 + start_value, start_attr = get_datetime_item(start, tzid)
2.147 + end_value, end_attr = get_datetime_item(end, tzid)
2.148 + rdates.append("%s/%s" % (start_value, end_value))
2.149 +
2.150 + if rdates:
2.151 + rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates))
2.152 +
2.153 + node = ("VEVENT", {}, record)
2.154 +
2.155 + self.store.set_event(self.user, uid, None, node=node)
2.156 + self.store.queue_request(self.user, uid)
2.157 +
2.158 + # Redirect to the object (or the first of the objects), where instead of
2.159 + # attendee controls, there will be organiser controls.
2.160 +
2.161 + self.redirect(self.link_to(uid))
2.162 +
2.163 + # Page fragment methods.
2.164 +
2.165 + def show_requests_on_page(self):
2.166 +
2.167 + "Show requests for the current user."
2.168 +
2.169 + page = self.page
2.170 +
2.171 + # NOTE: This list could be more informative, but it is envisaged that
2.172 + # NOTE: the requests would be visited directly anyway.
2.173 +
2.174 + requests = self._get_requests()
2.175 +
2.176 + page.div(id="pending-requests")
2.177 +
2.178 + if requests:
2.179 + page.p("Pending requests:")
2.180 +
2.181 + page.ul()
2.182 +
2.183 + for uid, recurrenceid in requests:
2.184 + obj = self._get_object(uid, recurrenceid)
2.185 + if obj:
2.186 + page.li()
2.187 + page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or ""))
2.188 + page.li.close()
2.189 +
2.190 + page.ul.close()
2.191 +
2.192 + else:
2.193 + page.p("There are no pending requests.")
2.194 +
2.195 + page.div.close()
2.196 +
2.197 + def show_participants_on_page(self):
2.198 +
2.199 + "Show participants for scheduling purposes."
2.200 +
2.201 + page = self.page
2.202 + args = self.env.get_args()
2.203 + participants = args.get("participants", [])
2.204 +
2.205 + try:
2.206 + for name, value in args.items():
2.207 + if name.startswith("remove-participant-"):
2.208 + i = int(name[len("remove-participant-"):])
2.209 + del participants[i]
2.210 + break
2.211 + except ValueError:
2.212 + pass
2.213 +
2.214 + # Trim empty participants.
2.215 +
2.216 + while participants and not participants[-1].strip():
2.217 + participants.pop()
2.218 +
2.219 + # Show any specified participants together with controls to remove and
2.220 + # add participants.
2.221 +
2.222 + page.div(id="participants")
2.223 +
2.224 + page.p("Participants for scheduling:")
2.225 +
2.226 + for i, participant in enumerate(participants):
2.227 + page.p()
2.228 + page.input(name="participants", type="text", value=participant)
2.229 + page.input(name="remove-participant-%d" % i, type="submit", value="Remove")
2.230 + page.p.close()
2.231 +
2.232 + page.p()
2.233 + page.input(name="participants", type="text")
2.234 + page.input(name="add-participant", type="submit", value="Add")
2.235 + page.p.close()
2.236 +
2.237 + page.div.close()
2.238 +
2.239 + return participants
2.240 +
2.241 + # Full page output methods.
2.242 +
2.243 + def show(self):
2.244 +
2.245 + "Show the calendar for the current user."
2.246 +
2.247 + handled = self.handle_newevent()
2.248 +
2.249 + self.new_page(title="Calendar")
2.250 + page = self.page
2.251 +
2.252 + # Form controls are used in various places on the calendar page.
2.253 +
2.254 + page.form(method="POST")
2.255 +
2.256 + self.show_requests_on_page()
2.257 + participants = self.show_participants_on_page()
2.258 +
2.259 + # Show a button for scheduling a new event.
2.260 +
2.261 + page.p(class_="controls")
2.262 + page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N")
2.263 + page.p.close()
2.264 +
2.265 + # Show controls for hiding empty days and busy slots.
2.266 + # The positioning of the control, paragraph and table are important here.
2.267 +
2.268 + page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")
2.269 + page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")
2.270 +
2.271 + page.p(class_="controls")
2.272 + page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")
2.273 + page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")
2.274 + page.label("Show empty days", for_="showdays", class_="showdays disable")
2.275 + page.label("Hide empty days", for_="showdays", class_="showdays enable")
2.276 + page.input(name="reset", type="submit", value="Clear selections", id="reset")
2.277 + page.label("Clear selections", for_="reset", class_="reset")
2.278 + page.p.close()
2.279 +
2.280 + freebusy = self.store.get_freebusy(self.user)
2.281 +
2.282 + if not freebusy:
2.283 + page.p("No events scheduled.")
2.284 + return
2.285 +
2.286 + # Obtain the user's timezone.
2.287 +
2.288 + tzid = self.get_tzid()
2.289 +
2.290 + # Day view: start at the earliest known day and produce days until the
2.291 + # latest known day, perhaps with expandable sections of empty days.
2.292 +
2.293 + # Month view: start at the earliest known month and produce months until
2.294 + # the latest known month, perhaps with expandable sections of empty
2.295 + # months.
2.296 +
2.297 + # Details of users to invite to new events could be superimposed on the
2.298 + # calendar.
2.299 +
2.300 + # Requests are listed and linked to their tentative positions in the
2.301 + # calendar. Other participants are also shown.
2.302 +
2.303 + request_summary = self._get_request_summary()
2.304 +
2.305 + period_groups = [request_summary, freebusy]
2.306 + period_group_types = ["request", "freebusy"]
2.307 + period_group_sources = ["Pending requests", "Your schedule"]
2.308 +
2.309 + for i, participant in enumerate(participants):
2.310 + period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))
2.311 + period_group_types.append("freebusy-part%d" % i)
2.312 + period_group_sources.append(participant)
2.313 +
2.314 + groups = []
2.315 + group_columns = []
2.316 + group_types = period_group_types
2.317 + group_sources = period_group_sources
2.318 + all_points = set()
2.319 +
2.320 + # Obtain time point information for each group of periods.
2.321 +
2.322 + for periods in period_groups:
2.323 + periods = convert_periods(periods, tzid)
2.324 +
2.325 + # Get the time scale with start and end points.
2.326 +
2.327 + scale = get_scale(periods)
2.328 +
2.329 + # Get the time slots for the periods.
2.330 +
2.331 + slots = get_slots(scale)
2.332 +
2.333 + # Add start of day time points for multi-day periods.
2.334 +
2.335 + add_day_start_points(slots, tzid)
2.336 +
2.337 + # Record the slots and all time points employed.
2.338 +
2.339 + groups.append(slots)
2.340 + all_points.update([point for point, active in slots])
2.341 +
2.342 + # Partition the groups into days.
2.343 +
2.344 + days = {}
2.345 + partitioned_groups = []
2.346 + partitioned_group_types = []
2.347 + partitioned_group_sources = []
2.348 +
2.349 + for slots, group_type, group_source in zip(groups, group_types, group_sources):
2.350 +
2.351 + # Propagate time points to all groups of time slots.
2.352 +
2.353 + add_slots(slots, all_points)
2.354 +
2.355 + # Count the number of columns employed by the group.
2.356 +
2.357 + columns = 0
2.358 +
2.359 + # Partition the time slots by day.
2.360 +
2.361 + partitioned = {}
2.362 +
2.363 + for day, day_slots in partition_by_day(slots).items():
2.364 +
2.365 + # Construct a list of time intervals within the day.
2.366 +
2.367 + intervals = []
2.368 + last = None
2.369 +
2.370 + for point, active in day_slots:
2.371 + columns = max(columns, len(active))
2.372 + if last:
2.373 + intervals.append((last, point))
2.374 + last = point
2.375 +
2.376 + if last:
2.377 + intervals.append((last, None))
2.378 +
2.379 + if not days.has_key(day):
2.380 + days[day] = set()
2.381 +
2.382 + # Convert each partition to a mapping from points to active
2.383 + # periods.
2.384 +
2.385 + partitioned[day] = dict(day_slots)
2.386 +
2.387 + # Record the divisions or intervals within each day.
2.388 +
2.389 + days[day].update(intervals)
2.390 +
2.391 + # Only include the requests column if it provides objects.
2.392 +
2.393 + if group_type != "request" or columns:
2.394 + group_columns.append(columns)
2.395 + partitioned_groups.append(partitioned)
2.396 + partitioned_group_types.append(group_type)
2.397 + partitioned_group_sources.append(group_source)
2.398 +
2.399 + # Add empty days.
2.400 +
2.401 + add_empty_days(days, tzid)
2.402 +
2.403 + # Show the controls permitting day selection.
2.404 +
2.405 + self.show_calendar_day_controls(days)
2.406 +
2.407 + # Show the calendar itself.
2.408 +
2.409 + page.table(cellspacing=5, cellpadding=5, class_="calendar")
2.410 + self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)
2.411 + self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)
2.412 + page.table.close()
2.413 +
2.414 + # End the form region.
2.415 +
2.416 + page.form.close()
2.417 +
2.418 + # More page fragment methods.
2.419 +
2.420 + def show_calendar_day_controls(self, days):
2.421 +
2.422 + "Show controls for the given 'days' in the calendar."
2.423 +
2.424 + page = self.page
2.425 + slots = self.env.get_args().get("slot", [])
2.426 +
2.427 + for day in days:
2.428 + value, identifier = self._day_value_and_identifier(day)
2.429 + self._slot_selector(value, identifier, slots)
2.430 +
2.431 + # Generate a dynamic stylesheet to allow day selections to colour
2.432 + # specific days.
2.433 + # NOTE: The style details need to be coordinated with the static
2.434 + # NOTE: stylesheet.
2.435 +
2.436 + page.style(type="text/css")
2.437 +
2.438 + for day in days:
2.439 + daystr = format_datetime(day)
2.440 + page.add("""\
2.441 +input.newevent.selector#day-%s-:checked ~ table label.day.day-%s,
2.442 +input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s {
2.443 + background-color: #5f4;
2.444 + text-decoration: underline;
2.445 +}
2.446 +""" % (daystr, daystr, daystr, daystr))
2.447 +
2.448 + page.style.close()
2.449 +
2.450 + def show_calendar_participant_headings(self, group_types, group_sources, group_columns):
2.451 +
2.452 + """
2.453 + Show headings for the participants and other scheduling contributors,
2.454 + defined by 'group_types', 'group_sources' and 'group_columns'.
2.455 + """
2.456 +
2.457 + page = self.page
2.458 +
2.459 + page.colgroup(span=1, id="columns-timeslot")
2.460 +
2.461 + for group_type, columns in zip(group_types, group_columns):
2.462 + page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)
2.463 +
2.464 + page.thead()
2.465 + page.tr()
2.466 + page.th("", class_="emptyheading")
2.467 +
2.468 + for group_type, source, columns in zip(group_types, group_sources, group_columns):
2.469 + page.th(source,
2.470 + class_=(group_type == "request" and "requestheading" or "participantheading"),
2.471 + colspan=max(columns, 1))
2.472 +
2.473 + page.tr.close()
2.474 + page.thead.close()
2.475 +
2.476 + def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):
2.477 +
2.478 + """
2.479 + Show calendar days, defined by a collection of 'days', the contributing
2.480 + period information as 'partitioned_groups' (partitioned by day), the
2.481 + 'partitioned_group_types' indicating the kind of contribution involved,
2.482 + and the 'group_columns' defining the number of columns in each group.
2.483 + """
2.484 +
2.485 + page = self.page
2.486 +
2.487 + # Determine the number of columns required. Where participants provide
2.488 + # no columns for events, one still needs to be provided for the
2.489 + # participant itself.
2.490 +
2.491 + all_columns = sum([max(columns, 1) for columns in group_columns])
2.492 +
2.493 + # Determine the days providing time slots.
2.494 +
2.495 + all_days = days.items()
2.496 + all_days.sort()
2.497 +
2.498 + # Produce a heading and time points for each day.
2.499 +
2.500 + for day, intervals in all_days:
2.501 + groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]
2.502 + is_empty = True
2.503 +
2.504 + for slots in groups_for_day:
2.505 + if not slots:
2.506 + continue
2.507 +
2.508 + for active in slots.values():
2.509 + if active:
2.510 + is_empty = False
2.511 + break
2.512 +
2.513 + page.thead(class_="separator%s" % (is_empty and " empty" or ""))
2.514 + page.tr()
2.515 + page.th(class_="dayheading container", colspan=all_columns+1)
2.516 + self._day_heading(day)
2.517 + page.th.close()
2.518 + page.tr.close()
2.519 + page.thead.close()
2.520 +
2.521 + page.tbody(class_="points%s" % (is_empty and " empty" or ""))
2.522 + self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)
2.523 + page.tbody.close()
2.524 +
2.525 + def show_calendar_points(self, intervals, groups, group_types, group_columns):
2.526 +
2.527 + """
2.528 + Show the time 'intervals' along with period information from the given
2.529 + 'groups', having the indicated 'group_types', each with the number of
2.530 + columns given by 'group_columns'.
2.531 + """
2.532 +
2.533 + page = self.page
2.534 +
2.535 + # Obtain the user's timezone.
2.536 +
2.537 + tzid = self.get_tzid()
2.538 +
2.539 + # Produce a row for each interval.
2.540 +
2.541 + intervals = list(intervals)
2.542 + intervals.sort()
2.543 +
2.544 + for point, endpoint in intervals:
2.545 + continuation = point == get_start_of_day(point, tzid)
2.546 +
2.547 + # Some rows contain no period details and are marked as such.
2.548 +
2.549 + have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None)
2.550 +
2.551 + css = " ".join([
2.552 + "slot",
2.553 + have_active and "busy" or "empty",
2.554 + continuation and "daystart" or ""
2.555 + ])
2.556 +
2.557 + page.tr(class_=css)
2.558 + page.th(class_="timeslot")
2.559 + self._time_point(point, endpoint)
2.560 + page.th.close()
2.561 +
2.562 + # Obtain slots for the time point from each group.
2.563 +
2.564 + for columns, slots, group_type in zip(group_columns, groups, group_types):
2.565 + active = slots and slots.get(point)
2.566 +
2.567 + # Where no periods exist for the given time interval, generate
2.568 + # an empty cell. Where a participant provides no periods at all,
2.569 + # the colspan is adjusted to be 1, not 0.
2.570 +
2.571 + if not active:
2.572 + page.td(class_="empty container", colspan=max(columns, 1))
2.573 + self._empty_slot(point, endpoint)
2.574 + page.td.close()
2.575 + continue
2.576 +
2.577 + slots = slots.items()
2.578 + slots.sort()
2.579 + spans = get_spans(slots)
2.580 +
2.581 + empty = 0
2.582 +
2.583 + # Show a column for each active period.
2.584 +
2.585 + for t in active:
2.586 + if t and len(t) >= 2:
2.587 +
2.588 + # Flush empty slots preceding this one.
2.589 +
2.590 + if empty:
2.591 + page.td(class_="empty container", colspan=empty)
2.592 + self._empty_slot(point, endpoint)
2.593 + page.td.close()
2.594 + empty = 0
2.595 +
2.596 + start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t)
2.597 + span = spans[key]
2.598 +
2.599 + # Produce a table cell only at the start of the period
2.600 + # or when continued at the start of a day.
2.601 +
2.602 + if point == start or continuation:
2.603 +
2.604 + has_continued = continuation and point != start
2.605 + will_continue = not ends_on_same_day(point, end, tzid)
2.606 + is_organiser = organiser == self.user
2.607 +
2.608 + css = " ".join([
2.609 + "event",
2.610 + has_continued and "continued" or "",
2.611 + will_continue and "continues" or "",
2.612 + is_organiser and "organising" or "attending"
2.613 + ])
2.614 +
2.615 + # Only anchor the first cell of events.
2.616 + # Need to only anchor the first period for a recurring
2.617 + # event.
2.618 +
2.619 + html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "")
2.620 +
2.621 + if point == start and html_id not in self.html_ids:
2.622 + page.td(class_=css, rowspan=span, id=html_id)
2.623 + self.html_ids.add(html_id)
2.624 + else:
2.625 + page.td(class_=css, rowspan=span)
2.626 +
2.627 + # Only link to events if they are not being
2.628 + # updated by requests.
2.629 +
2.630 + if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request":
2.631 + page.span(summary or "(Participant is busy)")
2.632 + else:
2.633 + page.a(summary, href=self.link_to(uid, recurrenceid))
2.634 +
2.635 + page.td.close()
2.636 + else:
2.637 + empty += 1
2.638 +
2.639 + # Pad with empty columns.
2.640 +
2.641 + empty = columns - len(active)
2.642 +
2.643 + if empty:
2.644 + page.td(class_="empty container", colspan=empty)
2.645 + self._empty_slot(point, endpoint)
2.646 + page.td.close()
2.647 +
2.648 + page.tr.close()
2.649 +
2.650 + def _day_heading(self, day):
2.651 +
2.652 + """
2.653 + Generate a heading for 'day' of the following form:
2.654 +
2.655 + <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>
2.656 + """
2.657 +
2.658 + page = self.page
2.659 + daystr = format_datetime(day)
2.660 + value, identifier = self._day_value_and_identifier(day)
2.661 + page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)
2.662 +
2.663 + def _time_point(self, point, endpoint):
2.664 +
2.665 + """
2.666 + Generate headings for the 'point' to 'endpoint' period of the following
2.667 + form:
2.668 +
2.669 + <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>
2.670 + <span class="endpoint">10:00:00 CET</span>
2.671 + """
2.672 +
2.673 + page = self.page
2.674 + tzid = self.get_tzid()
2.675 + daystr = format_datetime(point.date())
2.676 + value, identifier = self._slot_value_and_identifier(point, endpoint)
2.677 + slots = self.env.get_args().get("slot", [])
2.678 + self._slot_selector(value, identifier, slots)
2.679 + page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)
2.680 + page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint")
2.681 +
2.682 + def _slot_selector(self, value, identifier, slots):
2.683 +
2.684 + """
2.685 + Provide a timeslot control having the given 'value', employing the
2.686 + indicated HTML 'identifier', and using the given 'slots' collection
2.687 + to select any control whose 'value' is in this collection, unless the
2.688 + "reset" request parameter has been asserted.
2.689 + """
2.690 +
2.691 + reset = self.env.get_args().has_key("reset")
2.692 + page = self.page
2.693 + if not reset and value in slots:
2.694 + page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")
2.695 + else:
2.696 + page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")
2.697 +
2.698 + def _empty_slot(self, point, endpoint):
2.699 +
2.700 + "Show an empty slot label for the given 'point' and 'endpoint'."
2.701 +
2.702 + page = self.page
2.703 + value, identifier = self._slot_value_and_identifier(point, endpoint)
2.704 + page.label("Select/deselect period", class_="newevent popup", for_=identifier)
2.705 +
2.706 + def _day_value_and_identifier(self, day):
2.707 +
2.708 + "Return a day value and HTML identifier for the given 'day'."
2.709 +
2.710 + value = "%s-" % format_datetime(day)
2.711 + identifier = "day-%s" % value
2.712 + return value, identifier
2.713 +
2.714 + def _slot_value_and_identifier(self, point, endpoint):
2.715 +
2.716 + """
2.717 + Return a slot value and HTML identifier for the given 'point' and
2.718 + 'endpoint'.
2.719 + """
2.720 +
2.721 + value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")
2.722 + identifier = "slot-%s" % value
2.723 + return value, identifier
2.724 +
2.725 +# vim: tabstop=4 expandtab shiftwidth=4
3.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000
3.2 +++ b/imipweb/event.py Thu Mar 26 16:11:46 2015 +0100
3.3 @@ -0,0 +1,1060 @@
3.4 +#!/usr/bin/env python
3.5 +
3.6 +"""
3.7 +A Web interface to a calendar event.
3.8 +
3.9 +Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
3.10 +
3.11 +This program is free software; you can redistribute it and/or modify it under
3.12 +the terms of the GNU General Public License as published by the Free Software
3.13 +Foundation; either version 3 of the License, or (at your option) any later
3.14 +version.
3.15 +
3.16 +This program is distributed in the hope that it will be useful, but WITHOUT
3.17 +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
3.18 +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
3.19 +details.
3.20 +
3.21 +You should have received a copy of the GNU General Public License along with
3.22 +this program. If not, see <http://www.gnu.org/licenses/>.
3.23 +"""
3.24 +
3.25 +from datetime import datetime, timedelta
3.26 +from imiptools.client import update_attendees
3.27 +from imiptools.data import get_uri, uri_dict, uri_values
3.28 +from imiptools.dates import format_datetime, to_date, get_datetime, \
3.29 + get_datetime_item, get_period_item, \
3.30 + get_start_of_day, to_timezone
3.31 +from imiptools.mail import Messenger
3.32 +from imiptools.period import have_conflict
3.33 +from imipweb.handler import ManagerHandler
3.34 +from imipweb.resource import Resource
3.35 +import pytz
3.36 +
3.37 +class EventPage(Resource):
3.38 +
3.39 + "A request handler for the event page."
3.40 +
3.41 + def __init__(self, resource=None, messenger=None):
3.42 + Resource.__init__(self, resource)
3.43 + self.messenger = messenger or Messenger()
3.44 +
3.45 + # Request logic methods.
3.46 +
3.47 + def handle_request(self, uid, recurrenceid, obj):
3.48 +
3.49 + """
3.50 + Handle actions involving the given 'uid', 'recurrenceid', and 'obj' as
3.51 + the object's representation, returning an error if one occurred, or None
3.52 + if the request was successfully handled.
3.53 + """
3.54 +
3.55 + # Handle a submitted form.
3.56 +
3.57 + args = self.env.get_args()
3.58 +
3.59 + # Get the possible actions.
3.60 +
3.61 + reply = args.has_key("reply")
3.62 + discard = args.has_key("discard")
3.63 + invite = args.has_key("invite")
3.64 + cancel = args.has_key("cancel")
3.65 + save = args.has_key("save")
3.66 + ignore = args.has_key("ignore")
3.67 +
3.68 + have_action = reply or discard or invite or cancel or save or ignore
3.69 +
3.70 + if not have_action:
3.71 + return ["action"]
3.72 +
3.73 + # If ignoring the object, return to the calendar.
3.74 +
3.75 + if ignore:
3.76 + self.redirect(self.env.get_path())
3.77 + return None
3.78 +
3.79 + # Update the object.
3.80 +
3.81 + if args.has_key("summary"):
3.82 + obj["SUMMARY"] = [(args["summary"][0], {})]
3.83 +
3.84 + attendees = uri_dict(obj.get_value_map("ATTENDEE"))
3.85 +
3.86 + if args.has_key("partstat"):
3.87 + if attendees.has_key(self.user):
3.88 + attendees[self.user]["PARTSTAT"] = args["partstat"][0]
3.89 + if attendees[self.user].has_key("RSVP"):
3.90 + del attendees[self.user]["RSVP"]
3.91 +
3.92 + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
3.93 +
3.94 + # Obtain the user's timezone and process datetime values.
3.95 +
3.96 + update = False
3.97 +
3.98 + if is_organiser:
3.99 + periods, errors = self.handle_all_period_controls()
3.100 + if errors:
3.101 + return errors
3.102 + elif periods:
3.103 + self.set_period_in_object(obj, periods[0])
3.104 + self.set_periods_in_object(obj, periods[1:])
3.105 +
3.106 + # Obtain any participants to be added or removed.
3.107 +
3.108 + removed = args.get("remove")
3.109 + added = args.get("added")
3.110 +
3.111 + # Process any action.
3.112 +
3.113 + handled = True
3.114 +
3.115 + if reply or invite or cancel:
3.116 +
3.117 + handler = ManagerHandler(obj, self.user, self.messenger)
3.118 +
3.119 + # Process the object and remove it from the list of requests.
3.120 +
3.121 + if reply and handler.process_received_request(update) or \
3.122 + is_organiser and (invite or cancel) and \
3.123 + handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added):
3.124 +
3.125 + self.remove_request(uid, recurrenceid)
3.126 +
3.127 + # Save single user events.
3.128 +
3.129 + elif save:
3.130 + to_cancel = update_attendees(obj, added, removed)
3.131 + self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node())
3.132 + self.update_freebusy(uid, recurrenceid, obj)
3.133 + self.remove_request(uid, recurrenceid)
3.134 +
3.135 + # Remove the request and the object.
3.136 +
3.137 + elif discard:
3.138 + self.remove_from_freebusy(uid, recurrenceid)
3.139 + self.remove_event(uid, recurrenceid)
3.140 + self.remove_request(uid, recurrenceid)
3.141 +
3.142 + else:
3.143 + handled = False
3.144 +
3.145 + # Upon handling an action, redirect to the main page.
3.146 +
3.147 + if handled:
3.148 + self.redirect(self.env.get_path())
3.149 +
3.150 + return None
3.151 +
3.152 + def handle_all_period_controls(self):
3.153 +
3.154 + """
3.155 + Handle datetime controls for a particular period, where 'index' may be
3.156 + used to indicate a recurring period, or the main start and end datetimes
3.157 + are handled.
3.158 + """
3.159 +
3.160 + args = self.env.get_args()
3.161 +
3.162 + periods = []
3.163 +
3.164 + # Get the main period details.
3.165 +
3.166 + dtend_enabled = args.get("dtend-control", [None])[0]
3.167 + dttimes_enabled = args.get("dttimes-control", [None])[0]
3.168 + start_values = self.get_date_control_values("dtstart")
3.169 + end_values = self.get_date_control_values("dtend")
3.170 +
3.171 + period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled)
3.172 +
3.173 + if errors:
3.174 + return None, errors
3.175 +
3.176 + periods.append(period)
3.177 +
3.178 + # Get the recurring period details.
3.179 +
3.180 + all_dtend_enabled = args.get("dtend-control-recur", [])
3.181 + all_dttimes_enabled = args.get("dttimes-control-recur", [])
3.182 + all_start_values = self.get_date_control_values("dtstart-recur", multiple=True)
3.183 + all_end_values = self.get_date_control_values("dtend-recur", multiple=True)
3.184 +
3.185 + for index, (start_values, end_values, dtend_enabled, dttimes_enabled) in \
3.186 + enumerate(map(None, all_start_values, all_end_values, all_dtend_enabled, all_dttimes_enabled)):
3.187 +
3.188 + dtend_enabled = str(index) in all_dtend_enabled
3.189 + dttimes_enabled = str(index) in all_dttimes_enabled
3.190 + period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled)
3.191 +
3.192 + if errors:
3.193 + return None, errors
3.194 +
3.195 + periods.append(period)
3.196 +
3.197 + return periods, None
3.198 +
3.199 + def handle_period_controls(self, start_values, end_values, dtend_enabled, dttimes_enabled):
3.200 +
3.201 + """
3.202 + Handle datetime controls for a particular period, described by the given
3.203 + 'start_values' and 'end_values', with 'dtend_enabled' and
3.204 + 'dttimes_enabled' affecting the usage of the provided values.
3.205 + """
3.206 +
3.207 + t = self.handle_date_control_values(start_values, dttimes_enabled)
3.208 + if t:
3.209 + dtstart, dtstart_attr = t
3.210 + else:
3.211 + return None, ["dtstart"]
3.212 +
3.213 + # Handle specified end datetimes.
3.214 +
3.215 + if dtend_enabled:
3.216 + t = self.handle_date_control_values(end_values, dttimes_enabled)
3.217 + if t:
3.218 + dtend, dtend_attr = t
3.219 +
3.220 + # Convert end dates to iCalendar "next day" dates.
3.221 +
3.222 + if not isinstance(dtend, datetime):
3.223 + dtend += timedelta(1)
3.224 + else:
3.225 + return None, ["dtend"]
3.226 +
3.227 + # Otherwise, treat the end date as the start date. Datetimes are
3.228 + # handled by making the event occupy the rest of the day.
3.229 +
3.230 + else:
3.231 + dtend = dtstart + timedelta(1)
3.232 + dtend_attr = dtstart_attr
3.233 +
3.234 + if isinstance(dtstart, datetime):
3.235 + dtend = get_start_of_day(dtend, attr["TZID"])
3.236 +
3.237 + if dtstart >= dtend:
3.238 + return None, ["dtstart", "dtend"]
3.239 +
3.240 + return ((dtstart, dtstart_attr), (dtend, dtend_attr)), None
3.241 +
3.242 + def handle_date_control_values(self, values, with_time=True):
3.243 +
3.244 + """
3.245 + Handle date control information for the given 'values', returning a
3.246 + (datetime, attr) tuple, or None if the fields cannot be used to
3.247 + construct a datetime object.
3.248 + """
3.249 +
3.250 + if not values or not values["date"]:
3.251 + return None
3.252 + elif with_time:
3.253 + value = "%s%s" % (values["date"], values["time"])
3.254 + attr = {"TZID" : values["tzid"], "VALUE" : "DATE-TIME"}
3.255 + dt = get_datetime(value, attr)
3.256 + else:
3.257 + attr = {"VALUE" : "DATE"}
3.258 + dt = get_datetime(values["date"])
3.259 +
3.260 + if dt:
3.261 + return dt, attr
3.262 +
3.263 + return None
3.264 +
3.265 + def get_date_control_values(self, name, multiple=False):
3.266 +
3.267 + """
3.268 + Return a dictionary containing date, time and tzid entries for fields
3.269 + starting with 'name'.
3.270 + """
3.271 +
3.272 + args = self.env.get_args()
3.273 +
3.274 + dates = args.get("%s-date" % name, [])
3.275 + hours = args.get("%s-hour" % name, [])
3.276 + minutes = args.get("%s-minute" % name, [])
3.277 + seconds = args.get("%s-second" % name, [])
3.278 + tzids = args.get("%s-tzid" % name, [])
3.279 +
3.280 + # Handle absent values by employing None values.
3.281 +
3.282 + field_values = map(None, dates, hours, minutes, seconds, tzids)
3.283 + if not field_values and not multiple:
3.284 + field_values = [(None, None, None, None, None)]
3.285 +
3.286 + all_values = []
3.287 +
3.288 + for date, hour, minute, second, tzid in field_values:
3.289 +
3.290 + # Construct a usable dictionary of values.
3.291 +
3.292 + time = (hour or minute or second) and \
3.293 + "T%s%s%s" % (
3.294 + (hour or "").rjust(2, "0")[:2],
3.295 + (minute or "").rjust(2, "0")[:2],
3.296 + (second or "").rjust(2, "0")[:2]
3.297 + ) or ""
3.298 +
3.299 + value = {
3.300 + "date" : date,
3.301 + "time" : time,
3.302 + "tzid" : tzid or self.get_tzid()
3.303 + }
3.304 +
3.305 + # Return a single value or append to a collection of all values.
3.306 +
3.307 + if not multiple:
3.308 + return value
3.309 + else:
3.310 + all_values.append(value)
3.311 +
3.312 + return all_values
3.313 +
3.314 + def set_period_in_object(self, obj, period):
3.315 +
3.316 + "Set in the given 'obj' the given 'period' as the main start and end."
3.317 +
3.318 + (dtstart, dtstart_attr), (dtend, dtend_attr) = period
3.319 +
3.320 + return self.set_datetime_in_object(dtstart, dtstart_attr.get("TZID"), "DTSTART", obj) or \
3.321 + self.set_datetime_in_object(dtend, dtend_attr.get("TZID"), "DTEND", obj)
3.322 +
3.323 + def set_periods_in_object(self, obj, periods):
3.324 +
3.325 + "Set in the given 'obj' the given 'periods'."
3.326 +
3.327 + update = False
3.328 +
3.329 + old_values = obj.get_values("RDATE")
3.330 + new_rdates = []
3.331 +
3.332 + if obj.has_key("RDATE"):
3.333 + del obj["RDATE"]
3.334 +
3.335 + for period in periods:
3.336 + (dtstart, dtstart_attr), (dtend, dtend_attr) = period
3.337 + tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID")
3.338 + new_rdates.append(get_period_item(dtstart, dtend, tzid))
3.339 +
3.340 + obj["RDATE"] = new_rdates
3.341 +
3.342 + # NOTE: To do: calculate the update status.
3.343 + return update
3.344 +
3.345 + def set_datetime_in_object(self, dt, tzid, property, obj):
3.346 +
3.347 + """
3.348 + Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether
3.349 + an update has occurred.
3.350 + """
3.351 +
3.352 + if dt:
3.353 + old_value = obj.get_value(property)
3.354 + obj[property] = [get_datetime_item(dt, tzid)]
3.355 + return format_datetime(dt) != old_value
3.356 +
3.357 + return False
3.358 +
3.359 + def handle_new_attendees(self, obj):
3.360 +
3.361 + "Add or remove new attendees. This does not affect the stored object."
3.362 +
3.363 + args = self.env.get_args()
3.364 +
3.365 + existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])
3.366 + new_attendees = args.get("added", [])
3.367 + new_attendee = args.get("attendee", [""])[0]
3.368 +
3.369 + if args.has_key("add"):
3.370 + if new_attendee.strip():
3.371 + new_attendee = get_uri(new_attendee.strip())
3.372 + if new_attendee not in new_attendees and new_attendee not in existing_attendees:
3.373 + new_attendees.append(new_attendee)
3.374 + new_attendee = ""
3.375 +
3.376 + if args.has_key("removenew"):
3.377 + removed_attendee = args["removenew"][0]
3.378 + if removed_attendee in new_attendees:
3.379 + new_attendees.remove(removed_attendee)
3.380 +
3.381 + return new_attendees, new_attendee
3.382 +
3.383 + def get_event_period(self, obj):
3.384 +
3.385 + """
3.386 + Return (dtstart, dtstart attributes), (dtend, dtend attributes) for
3.387 + 'obj'.
3.388 + """
3.389 +
3.390 + dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")
3.391 + if obj.has_key("DTEND"):
3.392 + dtend, dtend_attr = obj.get_datetime_item("DTEND")
3.393 + elif obj.has_key("DURATION"):
3.394 + duration = obj.get_duration("DURATION")
3.395 + dtend = dtstart + duration
3.396 + dtend_attr = dtstart_attr
3.397 + else:
3.398 + dtend, dtend_attr = dtstart, dtstart_attr
3.399 + return (dtstart, dtstart_attr), (dtend, dtend_attr)
3.400 +
3.401 + # Page fragment methods.
3.402 +
3.403 + def show_request_controls(self, obj):
3.404 +
3.405 + "Show form controls for a request concerning 'obj'."
3.406 +
3.407 + page = self.page
3.408 + args = self.env.get_args()
3.409 +
3.410 + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
3.411 +
3.412 + attendees = uri_values((obj.get_values("ATTENDEE") or []) + filter(None, args.get("attendee", [])))
3.413 + is_attendee = self.user in attendees
3.414 +
3.415 + is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests()
3.416 +
3.417 + have_other_attendees = len(attendees) > (is_attendee and 1 or 0)
3.418 +
3.419 + # Show appropriate options depending on the role of the user.
3.420 +
3.421 + if is_attendee and not is_organiser:
3.422 + page.p("An action is required for this request:")
3.423 +
3.424 + page.p()
3.425 + page.input(name="reply", type="submit", value="Send reply")
3.426 + page.add(" ")
3.427 + page.input(name="discard", type="submit", value="Discard event")
3.428 + page.add(" ")
3.429 + page.input(name="ignore", type="submit", value="Do nothing for now")
3.430 + page.p.close()
3.431 +
3.432 + if is_organiser:
3.433 + page.p("As organiser, you can perform the following:")
3.434 +
3.435 + if have_other_attendees:
3.436 + page.p()
3.437 + page.input(name="invite", type="submit", value="Invite/notify attendees")
3.438 + page.add(" ")
3.439 + if is_request:
3.440 + page.input(name="discard", type="submit", value="Discard event")
3.441 + else:
3.442 + page.input(name="cancel", type="submit", value="Cancel event")
3.443 + page.add(" ")
3.444 + page.input(name="ignore", type="submit", value="Do nothing for now")
3.445 + page.p.close()
3.446 + else:
3.447 + page.p()
3.448 + page.input(name="save", type="submit", value="Save event")
3.449 + page.add(" ")
3.450 + page.input(name="discard", type="submit", value="Discard event")
3.451 + page.add(" ")
3.452 + page.input(name="ignore", type="submit", value="Do nothing for now")
3.453 + page.p.close()
3.454 +
3.455 + property_items = [
3.456 + ("SUMMARY", "Summary"),
3.457 + ("DTSTART", "Start"),
3.458 + ("DTEND", "End"),
3.459 + ("ORGANIZER", "Organiser"),
3.460 + ("ATTENDEE", "Attendee"),
3.461 + ]
3.462 +
3.463 + partstat_items = [
3.464 + ("NEEDS-ACTION", "Not confirmed"),
3.465 + ("ACCEPTED", "Attending"),
3.466 + ("TENTATIVE", "Tentatively attending"),
3.467 + ("DECLINED", "Not attending"),
3.468 + ("DELEGATED", "Delegated"),
3.469 + (None, "Not indicated"),
3.470 + ]
3.471 +
3.472 + def show_object_on_page(self, uid, obj, error=None):
3.473 +
3.474 + """
3.475 + Show the calendar object with the given 'uid' and representation 'obj'
3.476 + on the current page. If 'error' is given, show a suitable message.
3.477 + """
3.478 +
3.479 + page = self.page
3.480 + page.form(method="POST")
3.481 +
3.482 + page.input(name="editing", type="hidden", value="true")
3.483 +
3.484 + args = self.env.get_args()
3.485 +
3.486 + # Obtain the user's timezone.
3.487 +
3.488 + tzid = self.get_tzid()
3.489 +
3.490 + # Obtain basic event information, showing any necessary editing controls.
3.491 +
3.492 + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
3.493 +
3.494 + if is_organiser:
3.495 + new_attendees, new_attendee = self.handle_new_attendees(obj)
3.496 + else:
3.497 + new_attendees = []
3.498 + new_attendee = ""
3.499 +
3.500 + (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_event_period(obj)
3.501 + self.show_object_datetime_controls(dtstart, dtend)
3.502 +
3.503 + # Provide a summary of the object.
3.504 +
3.505 + page.table(class_="object", cellspacing=5, cellpadding=5)
3.506 + page.thead()
3.507 + page.tr()
3.508 + page.th("Event", class_="mainheading", colspan=2)
3.509 + page.tr.close()
3.510 + page.thead.close()
3.511 + page.tbody()
3.512 +
3.513 + for name, label in self.property_items:
3.514 + field = name.lower()
3.515 +
3.516 + items = obj.get_items(name) or []
3.517 + rowspan = len(items)
3.518 +
3.519 + if name == "ATTENDEE":
3.520 + rowspan += len(new_attendees) + 1
3.521 + elif not items:
3.522 + continue
3.523 +
3.524 + page.tr()
3.525 + page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""), rowspan=rowspan)
3.526 +
3.527 + # Handle datetimes specially.
3.528 +
3.529 + if name in ["DTSTART", "DTEND"]:
3.530 +
3.531 + # Obtain the datetime.
3.532 +
3.533 + if name == "DTSTART":
3.534 + dt, attr = dtstart, dtstart_attr
3.535 +
3.536 + # Where no end datetime exists, use the start datetime as the
3.537 + # basis of any potential datetime specified if dt-control is
3.538 + # set.
3.539 +
3.540 + else:
3.541 + dt, attr = dtend or dtstart, dtend_attr or dtstart_attr
3.542 +
3.543 + self.show_datetime_controls(obj, dt, attr, name == "DTSTART")
3.544 +
3.545 + page.tr.close()
3.546 +
3.547 + # Handle the summary specially.
3.548 +
3.549 + elif name == "SUMMARY":
3.550 + value = args.get("summary", [obj.get_value(name)])[0]
3.551 +
3.552 + page.td()
3.553 + if is_organiser:
3.554 + page.input(name="summary", type="text", value=value, size=80)
3.555 + else:
3.556 + page.add(value)
3.557 + page.td.close()
3.558 + page.tr.close()
3.559 +
3.560 + # Handle potentially many values.
3.561 +
3.562 + else:
3.563 + first = True
3.564 +
3.565 + for i, (value, attr) in enumerate(items):
3.566 + if not first:
3.567 + page.tr()
3.568 + else:
3.569 + first = False
3.570 +
3.571 + if name == "ATTENDEE":
3.572 + value = get_uri(value)
3.573 +
3.574 + page.td(class_="objectvalue")
3.575 + page.add(value)
3.576 + page.add(" ")
3.577 +
3.578 + partstat = attr.get("PARTSTAT")
3.579 + if value == self.user:
3.580 + self._show_menu("partstat", partstat, self.partstat_items, "partstat")
3.581 + else:
3.582 + page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat")
3.583 +
3.584 + if is_organiser:
3.585 + if value in args.get("remove", []):
3.586 + page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked")
3.587 + else:
3.588 + page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove")
3.589 + page.label("Remove", for_="remove-%d" % i, class_="remove")
3.590 + page.label("Uninvited", for_="remove-%d" % i, class_="removed")
3.591 +
3.592 + else:
3.593 + page.td(class_="objectvalue")
3.594 + page.add(value)
3.595 +
3.596 + page.td.close()
3.597 + page.tr.close()
3.598 +
3.599 + # Allow more attendees to be specified.
3.600 +
3.601 + if is_organiser and name == "ATTENDEE":
3.602 + for i, attendee in enumerate(new_attendees):
3.603 + if not first:
3.604 + page.tr()
3.605 + else:
3.606 + first = False
3.607 +
3.608 + page.td()
3.609 + page.input(name="added", type="value", value=attendee)
3.610 + page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove")
3.611 + page.label("Remove", for_="removenew-%d" % i, class_="remove")
3.612 + page.td.close()
3.613 + page.tr.close()
3.614 +
3.615 + if not first:
3.616 + page.tr()
3.617 +
3.618 + page.td()
3.619 + page.input(name="attendee", type="value", value=new_attendee)
3.620 + page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add")
3.621 + page.label("Add", for_="add-%d" % i, class_="add")
3.622 + page.td.close()
3.623 + page.tr.close()
3.624 +
3.625 + page.tbody.close()
3.626 + page.table.close()
3.627 +
3.628 + self.show_recurrences(obj)
3.629 + self.show_conflicting_events(uid, obj)
3.630 + self.show_request_controls(obj)
3.631 +
3.632 + page.form.close()
3.633 +
3.634 + def show_object_datetime_controls(self, start, end, index=None):
3.635 +
3.636 + """
3.637 + Show datetime-related controls if already active or if an object needs
3.638 + them for the given 'start' to 'end' period. The given 'index' is used to
3.639 + parameterise individual controls for dynamic manipulation.
3.640 + """
3.641 +
3.642 + page = self.page
3.643 + args = self.env.get_args()
3.644 + sn = self._suffixed_name
3.645 + ssn = self._simple_suffixed_name
3.646 +
3.647 + # Add a dynamic stylesheet to permit the controls to modify the display.
3.648 + # NOTE: The style details need to be coordinated with the static
3.649 + # NOTE: stylesheet.
3.650 +
3.651 + if index is not None:
3.652 + page.style(type="text/css")
3.653 +
3.654 + # Unlike the rules for object properties, these affect recurrence
3.655 + # properties.
3.656 +
3.657 + page.add("""\
3.658 +input#dttimes-enable-%(index)d,
3.659 +input#dtend-enable-%(index)d,
3.660 +input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled,
3.661 +input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled,
3.662 +input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled,
3.663 +input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled {
3.664 + display: none;
3.665 +}""" % {"index" : index})
3.666 +
3.667 + page.style.close()
3.668 +
3.669 + dtend_control = args.get(ssn("dtend-control", "recur", index), [])
3.670 + dttimes_control = args.get(ssn("dttimes-control", "recur", index), [])
3.671 +
3.672 + dtend_enabled = index is not None and str(index) in dtend_control or index is None and dtend_control
3.673 + dttimes_enabled = index is not None and str(index) in dttimes_control or index is None and dttimes_control
3.674 +
3.675 + initial_load = not args.has_key("editing")
3.676 +
3.677 + dtend_enabled = dtend_enabled or initial_load and (isinstance(end, datetime) or start != end - timedelta(1))
3.678 + dttimes_enabled = dttimes_enabled or initial_load and (isinstance(start, datetime) or isinstance(end, datetime))
3.679 +
3.680 + if dtend_enabled:
3.681 + page.input(name=ssn("dtend-control", "recur", index), type="checkbox",
3.682 + value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index), checked="checked")
3.683 + else:
3.684 + page.input(name=ssn("dtend-control", "recur", index), type="checkbox",
3.685 + value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index))
3.686 +
3.687 + if dttimes_enabled:
3.688 + page.input(name=ssn("dttimes-control", "recur", index), type="checkbox",
3.689 + value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index), checked="checked")
3.690 + else:
3.691 + page.input(name=ssn("dttimes-control", "recur", index), type="checkbox",
3.692 + value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index))
3.693 +
3.694 + def show_datetime_controls(self, obj, dt, attr, show_start):
3.695 +
3.696 + """
3.697 + Show datetime details from the given 'obj' for the datetime 'dt' and
3.698 + attributes 'attr', showing start details if 'show_start' is set
3.699 + to a true value. Details will appear as controls for organisers and
3.700 + labels for attendees.
3.701 + """
3.702 +
3.703 + page = self.page
3.704 + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
3.705 +
3.706 + # Change end dates to refer to the actual dates, not the iCalendar
3.707 + # "next day" dates.
3.708 +
3.709 + if not show_start and not isinstance(dt, datetime):
3.710 + dt -= timedelta(1)
3.711 +
3.712 + # Show controls for editing as organiser.
3.713 +
3.714 + if is_organiser:
3.715 + page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))
3.716 +
3.717 + if show_start:
3.718 + page.div(class_="dt enabled")
3.719 + self._show_date_controls("dtstart", dt, attr.get("TZID"))
3.720 + page.br()
3.721 + page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")
3.722 + page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable")
3.723 + page.div.close()
3.724 +
3.725 + else:
3.726 + page.div(class_="dt disabled")
3.727 + page.label("Specify end date", for_="dtend-enable", class_="enable")
3.728 + page.div.close()
3.729 + page.div(class_="dt enabled")
3.730 + self._show_date_controls("dtend", dt, attr.get("TZID"))
3.731 + page.br()
3.732 + page.label("End on same day", for_="dtend-enable", class_="disable")
3.733 + page.div.close()
3.734 +
3.735 + page.td.close()
3.736 +
3.737 + # Show a label as attendee.
3.738 +
3.739 + else:
3.740 + page.td(self.format_datetime(dt, "full"))
3.741 +
3.742 + def show_recurrence_controls(self, obj, index, start, end, origin, recurrenceid, recurrenceids, show_start):
3.743 +
3.744 + """
3.745 + Show datetime details from the given 'obj' for the recurrence having the
3.746 + given 'index', with the recurrence period described by the datetimes
3.747 + 'start' and 'end', indicating the 'origin' of the period from the event
3.748 + details, employing any 'recurrenceid' and 'recurrenceids' for the object
3.749 + to configure the displayed information.
3.750 +
3.751 + If 'show_start' is set to a true value, the start details will be shown;
3.752 + otherwise, the end details will be shown.
3.753 + """
3.754 +
3.755 + page = self.page
3.756 + sn = self._suffixed_name
3.757 + ssn = self._simple_suffixed_name
3.758 +
3.759 + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
3.760 +
3.761 + # Change end dates to refer to the actual dates, not the iCalendar
3.762 + # "next day" dates.
3.763 +
3.764 + if not isinstance(end, datetime):
3.765 + end -= timedelta(1)
3.766 +
3.767 + start_utc = format_datetime(to_timezone(start, "UTC"))
3.768 + replaced = recurrenceids and start_utc in recurrenceids and "replaced" or ""
3.769 + css = " ".join([
3.770 + replaced,
3.771 + recurrenceid and start_utc == recurrenceid and "affected" or ""
3.772 + ])
3.773 +
3.774 + # Show controls for editing as organiser.
3.775 +
3.776 + if is_organiser and not replaced and origin != "RRULE":
3.777 + page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))
3.778 +
3.779 + if show_start:
3.780 + page.div(class_="dt enabled")
3.781 + self._show_date_controls(ssn("dtstart", "recur", index), start, None, index)
3.782 + page.br()
3.783 + page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable")
3.784 + page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable")
3.785 + page.div.close()
3.786 +
3.787 + else:
3.788 + page.div(class_="dt disabled")
3.789 + page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable")
3.790 + page.div.close()
3.791 + page.div(class_="dt enabled")
3.792 + self._show_date_controls(ssn("dtend", "recur", index), end, None, index)
3.793 + page.br()
3.794 + page.label("End on same day", for_=sn("dtend-enable", index), class_="disable")
3.795 + page.div.close()
3.796 +
3.797 + page.td.close()
3.798 +
3.799 + # Show label as attendee.
3.800 +
3.801 + else:
3.802 + page.td(self.format_datetime(show_start and start or end, "long"), class_=css)
3.803 +
3.804 + def show_recurrences(self, obj):
3.805 +
3.806 + "Show recurrences for the object having the given representation 'obj'."
3.807 +
3.808 + page = self.page
3.809 + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
3.810 +
3.811 + # Obtain any parent object if this object is a specific recurrence.
3.812 +
3.813 + uid = obj.get_value("UID")
3.814 + recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))
3.815 +
3.816 + if recurrenceid:
3.817 + obj = self._get_object(uid)
3.818 + if not obj:
3.819 + return
3.820 +
3.821 + page.p("This event modifies a recurring event.")
3.822 +
3.823 + # Obtain the periods associated with the event in the user's time zone.
3.824 +
3.825 + periods = obj.get_periods(self.get_tzid(), self.get_window_end(), origin=True)
3.826 + recurrenceids = self._get_recurrences(uid)
3.827 +
3.828 + if len(periods) == 1:
3.829 + return
3.830 +
3.831 + if is_organiser:
3.832 + page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size())
3.833 + else:
3.834 + page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size())
3.835 +
3.836 + # Determine whether any periods are explicitly created or are part of a
3.837 + # rule.
3.838 +
3.839 + explicit_periods = filter(lambda t: t[2] != "RRULE", periods)
3.840 +
3.841 + # Show each recurrence in a separate table if editable.
3.842 +
3.843 + if is_organiser and explicit_periods:
3.844 +
3.845 + for index, (start, end, origin) in enumerate(periods[1:]):
3.846 +
3.847 + # Isolate the controls from neighbouring tables.
3.848 +
3.849 + page.div()
3.850 +
3.851 + self.show_object_datetime_controls(start, end, index)
3.852 +
3.853 + # NOTE: Need to customise the TH classes according to errors and
3.854 + # NOTE: index information.
3.855 +
3.856 + page.table(cellspacing=5, cellpadding=5, class_="recurrence")
3.857 + page.caption("Occurrence")
3.858 + page.tbody()
3.859 + page.tr()
3.860 + page.th("Start", class_="objectheading start")
3.861 + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True)
3.862 + page.tr.close()
3.863 + page.tr()
3.864 + page.th("End", class_="objectheading end")
3.865 + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False)
3.866 + page.tr.close()
3.867 + page.tbody.close()
3.868 + page.table.close()
3.869 +
3.870 + page.div.close()
3.871 +
3.872 + # Otherwise, use a compact single table.
3.873 +
3.874 + else:
3.875 + page.table(cellspacing=5, cellpadding=5, class_="recurrence")
3.876 + page.caption("Occurrences")
3.877 + page.thead()
3.878 + page.tr()
3.879 + page.th("Start", class_="objectheading start")
3.880 + page.th("End", class_="objectheading end")
3.881 + page.tr.close()
3.882 + page.thead.close()
3.883 + page.tbody()
3.884 +
3.885 + # Show only subsequent periods if organiser, since the principal
3.886 + # period will be the start and end datetimes.
3.887 +
3.888 + for index, (start, end, origin) in enumerate(is_organiser and periods[1:] or periods):
3.889 + page.tr()
3.890 + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True)
3.891 + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False)
3.892 + page.tr.close()
3.893 + page.tbody.close()
3.894 + page.table.close()
3.895 +
3.896 + def show_conflicting_events(self, uid, obj):
3.897 +
3.898 + """
3.899 + Show conflicting events for the object having the given 'uid' and
3.900 + representation 'obj'.
3.901 + """
3.902 +
3.903 + page = self.page
3.904 +
3.905 + # Obtain the user's timezone.
3.906 +
3.907 + tzid = self.get_tzid()
3.908 + periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end())
3.909 +
3.910 + # Indicate whether there are conflicting events.
3.911 +
3.912 + freebusy = self.store.get_freebusy(self.user)
3.913 +
3.914 + if freebusy:
3.915 +
3.916 + # Obtain any time zone details from the suggested event.
3.917 +
3.918 + _dtstart, attr = obj.get_item("DTSTART")
3.919 + tzid = attr.get("TZID", tzid)
3.920 +
3.921 + # Show any conflicts.
3.922 +
3.923 + conflicts = [t for t in have_conflict(freebusy, periods, True) if t[2] != uid]
3.924 +
3.925 + if conflicts:
3.926 + page.p("This event conflicts with others:")
3.927 +
3.928 + page.table(cellspacing=5, cellpadding=5, class_="conflicts")
3.929 + page.thead()
3.930 + page.tr()
3.931 + page.th("Event")
3.932 + page.th("Start")
3.933 + page.th("End")
3.934 + page.tr.close()
3.935 + page.thead.close()
3.936 + page.tbody()
3.937 +
3.938 + for t in conflicts:
3.939 + start, end, found_uid, transp, found_recurrenceid, summary = t[:6]
3.940 +
3.941 + # Provide details of any conflicting event.
3.942 +
3.943 + start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long")
3.944 + end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long")
3.945 +
3.946 + page.tr()
3.947 +
3.948 + # Show the event summary for the conflicting event.
3.949 +
3.950 + page.td()
3.951 + page.a(summary, href=self.link_to(found_uid))
3.952 + page.td.close()
3.953 +
3.954 + page.td(start)
3.955 + page.td(end)
3.956 +
3.957 + page.tr.close()
3.958 +
3.959 + page.tbody.close()
3.960 + page.table.close()
3.961 +
3.962 + # Full page output methods.
3.963 +
3.964 + def show(self, path_info):
3.965 +
3.966 + "Show an object request using the given 'path_info' for the current user."
3.967 +
3.968 + uid, recurrenceid = self._get_identifiers(path_info)
3.969 + obj = self._get_object(uid, recurrenceid)
3.970 +
3.971 + if not obj:
3.972 + return False
3.973 +
3.974 + error = self.handle_request(uid, recurrenceid, obj)
3.975 +
3.976 + if not error:
3.977 + return True
3.978 +
3.979 + self.new_page(title="Event")
3.980 + self.show_object_on_page(uid, obj, error)
3.981 +
3.982 + return True
3.983 +
3.984 + # Utility methods.
3.985 +
3.986 + def _show_menu(self, name, default, items, class_="", index=None):
3.987 +
3.988 + """
3.989 + Show a select menu having the given 'name', set to the given 'default',
3.990 + providing the given (value, label) 'items', and employing the given CSS
3.991 + 'class_' if specified.
3.992 + """
3.993 +
3.994 + page = self.page
3.995 + values = self.env.get_args().get(name, [default])
3.996 + if index is not None:
3.997 + values = values[index:]
3.998 + values = values and values[0:1] or [default]
3.999 +
3.1000 + page.select(name=name, class_=class_)
3.1001 + for v, label in items:
3.1002 + if v is None:
3.1003 + continue
3.1004 + if v in values:
3.1005 + page.option(label, value=v, selected="selected")
3.1006 + else:
3.1007 + page.option(label, value=v)
3.1008 + page.select.close()
3.1009 +
3.1010 + def _show_date_controls(self, name, default, tzid, index=None):
3.1011 +
3.1012 + """
3.1013 + Show date controls for a field with the given 'name' and 'default' value
3.1014 + and 'tzid'.
3.1015 + """
3.1016 +
3.1017 + page = self.page
3.1018 + args = self.env.get_args()
3.1019 +
3.1020 + event_tzid = tzid or self.get_tzid()
3.1021 +
3.1022 + # Show dates for up to one week around the current date.
3.1023 +
3.1024 + base = to_date(default)
3.1025 + items = []
3.1026 + for i in range(-7, 8):
3.1027 + d = base + timedelta(i)
3.1028 + items.append((format_datetime(d), self.format_date(d, "full")))
3.1029 +
3.1030 + self._show_menu("%s-date" % name, format_datetime(base), items, index=index)
3.1031 +
3.1032 + # Show time details.
3.1033 +
3.1034 + default_time = isinstance(default, datetime) and default or None
3.1035 +
3.1036 + hour = args.get("%s-hour" % name, [])[index or 0:]
3.1037 + hour = hour and hour[0] or "%02d" % (default_time and default_time.hour or 0)
3.1038 + minute = args.get("%s-minute" % name, [])[index or 0:]
3.1039 + minute = minute and minute[0] or "%02d" % (default_time and default_time.minute or 0)
3.1040 + second = args.get("%s-second" % name, [])[index or 0:]
3.1041 + second = second and second[0] or "%02d" % (default_time and default_time.second or 0)
3.1042 +
3.1043 + page.span(class_="time enabled")
3.1044 + page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2)
3.1045 + page.add(":")
3.1046 + page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2)
3.1047 + page.add(":")
3.1048 + page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2)
3.1049 + page.add(" ")
3.1050 + self._show_timezone_menu("%s-tzid" % name, event_tzid, index)
3.1051 + page.span.close()
3.1052 +
3.1053 + def _show_timezone_menu(self, name, default, index=None):
3.1054 +
3.1055 + """
3.1056 + Show timezone controls using a menu with the given 'name', set to the
3.1057 + given 'default' unless a field of the given 'name' provides a value.
3.1058 + """
3.1059 +
3.1060 + entries = [(tzid, tzid) for tzid in pytz.all_timezones]
3.1061 + self._show_menu(name, default, entries, index=index)
3.1062 +
3.1063 +# vim: tabstop=4 expandtab shiftwidth=4
4.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000
4.2 +++ b/imipweb/resource.py Thu Mar 26 16:11:46 2015 +0100
4.3 @@ -0,0 +1,212 @@
4.4 +#!/usr/bin/env python
4.5 +
4.6 +"""
4.7 +Common resource functionality for Web calendar clients.
4.8 +
4.9 +Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
4.10 +
4.11 +This program is free software; you can redistribute it and/or modify it under
4.12 +the terms of the GNU General Public License as published by the Free Software
4.13 +Foundation; either version 3 of the License, or (at your option) any later
4.14 +version.
4.15 +
4.16 +This program is distributed in the hope that it will be useful, but WITHOUT
4.17 +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
4.18 +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
4.19 +details.
4.20 +
4.21 +You should have received a copy of the GNU General Public License along with
4.22 +this program. If not, see <http://www.gnu.org/licenses/>.
4.23 +"""
4.24 +
4.25 +from datetime import datetime
4.26 +from imiptools.client import Client
4.27 +from imiptools.data import get_uri, get_window_end, Object, uri_values
4.28 +from imiptools.dates import format_datetime, format_time
4.29 +from imiptools.period import remove_period, remove_affected_period, update_freebusy
4.30 +from imipweb.env import CGIEnvironment
4.31 +import babel.dates
4.32 +import imip_store
4.33 +import markup
4.34 +
4.35 +class Resource(Client):
4.36 +
4.37 + "A Web application resource and calendar client."
4.38 +
4.39 + def __init__(self, resource=None):
4.40 + self.encoding = "utf-8"
4.41 + self.env = CGIEnvironment(self.encoding)
4.42 +
4.43 + user = self.env.get_user()
4.44 + Client.__init__(self, user and get_uri(user) or None)
4.45 +
4.46 + self.locale = None
4.47 + self.requests = None
4.48 +
4.49 + self.out = resource and resource.out or self.env.get_output()
4.50 + self.page = resource and resource.page or markup.page()
4.51 + self.html_ids = None
4.52 +
4.53 + self.store = imip_store.FileStore()
4.54 + self.objects = {}
4.55 +
4.56 + try:
4.57 + self.publisher = imip_store.FilePublisher()
4.58 + except OSError:
4.59 + self.publisher = None
4.60 +
4.61 + # Presentation methods.
4.62 +
4.63 + def new_page(self, title):
4.64 + self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css"))
4.65 + self.html_ids = set()
4.66 +
4.67 + def status(self, code, message):
4.68 + self.header("Status", "%s %s" % (code, message))
4.69 +
4.70 + def header(self, header, value):
4.71 + print >>self.out, "%s: %s" % (header, value)
4.72 +
4.73 + def no_user(self):
4.74 + self.status(403, "Forbidden")
4.75 + self.new_page(title="Forbidden")
4.76 + self.page.p("You are not logged in and thus cannot access scheduling requests.")
4.77 +
4.78 + def no_page(self):
4.79 + self.status(404, "Not Found")
4.80 + self.new_page(title="Not Found")
4.81 + self.page.p("No page is provided at the given address.")
4.82 +
4.83 + def redirect(self, url):
4.84 + self.status(302, "Redirect")
4.85 + self.header("Location", url)
4.86 + self.new_page(title="Redirect")
4.87 + self.page.p("Redirecting to: %s" % url)
4.88 +
4.89 + def link_to(self, uid, recurrenceid=None):
4.90 + if recurrenceid:
4.91 + return self.env.new_url("/".join([uid, recurrenceid]))
4.92 + else:
4.93 + return self.env.new_url(uid)
4.94 +
4.95 + # Access to objects.
4.96 +
4.97 + def _suffixed_name(self, name, index=None):
4.98 + return index is not None and "%s-%d" % (name, index) or name
4.99 +
4.100 + def _simple_suffixed_name(self, name, suffix, index=None):
4.101 + return index is not None and "%s-%s" % (name, suffix) or name
4.102 +
4.103 + def _get_identifiers(self, path_info):
4.104 + parts = path_info.lstrip("/").split("/")
4.105 + if len(parts) == 1:
4.106 + return parts[0], None
4.107 + else:
4.108 + return parts[:2]
4.109 +
4.110 + def _get_object(self, uid, recurrenceid=None):
4.111 + if self.objects.has_key((uid, recurrenceid)):
4.112 + return self.objects[(uid, recurrenceid)]
4.113 +
4.114 + fragment = uid and self.store.get_event(self.user, uid, recurrenceid) or None
4.115 + obj = self.objects[(uid, recurrenceid)] = fragment and Object(fragment)
4.116 + return obj
4.117 +
4.118 + def _get_recurrences(self, uid):
4.119 + return self.store.get_recurrences(self.user, uid)
4.120 +
4.121 + def _get_requests(self):
4.122 + if self.requests is None:
4.123 + cancellations = self.store.get_cancellations(self.user)
4.124 + requests = set(self.store.get_requests(self.user))
4.125 + self.requests = requests.difference(cancellations)
4.126 + return self.requests
4.127 +
4.128 + def _get_request_summary(self):
4.129 + summary = []
4.130 + for uid, recurrenceid in self._get_requests():
4.131 + obj = self._get_object(uid, recurrenceid)
4.132 + if obj:
4.133 + periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end())
4.134 + recurrenceids = self._get_recurrences(uid)
4.135 +
4.136 + # Convert the periods to more substantial free/busy items.
4.137 +
4.138 + for start, end in periods:
4.139 +
4.140 + # Subtract any recurrences from the free/busy details of a
4.141 + # parent object.
4.142 +
4.143 + if recurrenceid or start not in recurrenceids:
4.144 + summary.append((
4.145 + start, end, uid,
4.146 + obj.get_value("TRANSP"),
4.147 + recurrenceid,
4.148 + obj.get_value("SUMMARY"),
4.149 + obj.get_value("ORGANIZER")
4.150 + ))
4.151 + return summary
4.152 +
4.153 + # Preference methods.
4.154 +
4.155 + def get_user_locale(self):
4.156 + if not self.locale:
4.157 + self.locale = self.get_preferences().get("LANG", "en")
4.158 + return self.locale
4.159 +
4.160 + # Prettyprinting of dates and times.
4.161 +
4.162 + def format_date(self, dt, format):
4.163 + return self._format_datetime(babel.dates.format_date, dt, format)
4.164 +
4.165 + def format_time(self, dt, format):
4.166 + return self._format_datetime(babel.dates.format_time, dt, format)
4.167 +
4.168 + def format_datetime(self, dt, format):
4.169 + return self._format_datetime(
4.170 + isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date,
4.171 + dt, format)
4.172 +
4.173 + def _format_datetime(self, fn, dt, format):
4.174 + return fn(dt, format=format, locale=self.get_user_locale())
4.175 +
4.176 + # Data management methods.
4.177 +
4.178 + def remove_request(self, uid, recurrenceid=None):
4.179 + return self.store.dequeue_request(self.user, uid, recurrenceid)
4.180 +
4.181 + def remove_event(self, uid, recurrenceid=None):
4.182 + return self.store.remove_event(self.user, uid, recurrenceid)
4.183 +
4.184 + def update_freebusy(self, uid, recurrenceid, obj):
4.185 +
4.186 + """
4.187 + Update stored free/busy details for the event with the given 'uid' and
4.188 + 'recurrenceid' having a representation of 'obj'.
4.189 + """
4.190 +
4.191 + is_only_organiser = self.user not in uri_values(obj.get_values("ATTENDEE"))
4.192 +
4.193 + freebusy = self.store.get_freebusy(self.user)
4.194 +
4.195 + update_freebusy(freebusy,
4.196 + obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()),
4.197 + is_only_organiser and "ORG" or obj.get_value("TRANSP"),
4.198 + uid, recurrenceid,
4.199 + obj.get_value("SUMMARY"),
4.200 + obj.get_value("ORGANIZER"))
4.201 +
4.202 + # Subtract any recurrences from the free/busy details of a parent
4.203 + # object.
4.204 +
4.205 + for recurrenceid in self._get_recurrences(uid):
4.206 + remove_affected_period(freebusy, uid, recurrenceid)
4.207 +
4.208 + self.store.set_freebusy(self.user, freebusy)
4.209 +
4.210 + def remove_from_freebusy(self, uid, recurrenceid=None):
4.211 + freebusy = self.store.get_freebusy(self.user)
4.212 + remove_period(freebusy, uid, recurrenceid)
4.213 + self.store.set_freebusy(self.user, freebusy)
4.214 +
4.215 +# vim: tabstop=4 expandtab shiftwidth=4