1 #!/usr/bin/env python 2 3 """ 4 A Web interface to a calendar event. 5 6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from datetime import datetime, timedelta 23 from imiptools.client import update_attendees, update_participation 24 from imiptools.data import get_uri, uri_dict, uri_values 25 from imiptools.dates import format_datetime, to_date, get_datetime, \ 26 get_datetime_item, get_period_item, \ 27 get_start_of_day, to_timezone 28 from imiptools.mail import Messenger 29 from imiptools.period import have_conflict, Period 30 from imipweb.handler import ManagerHandler 31 from imipweb.resource import Resource 32 import pytz 33 34 class EventPeriod(Period): 35 36 "A simple period plus attribute details, compatible with RecurringPeriod." 37 38 def __init__(self, start, end, start_attr=None, end_attr=None): 39 Period.__init__(self, start, end) 40 self.start_attr = start_attr 41 self.end_attr = end_attr 42 43 def as_tuple(self): 44 return self.start, self.end, self.start_attr, self.end_attr 45 46 def __repr__(self): 47 return "EventPeriod(%r, %r, %r, %r)" % self.as_tuple() 48 49 class EventPage(Resource): 50 51 "A request handler for the event page." 52 53 def __init__(self, resource=None, messenger=None): 54 Resource.__init__(self, resource) 55 self.messenger = messenger or Messenger() 56 57 # Various property values and labels. 58 59 property_items = [ 60 ("SUMMARY", "Summary"), 61 ("DTSTART", "Start"), 62 ("DTEND", "End"), 63 ("ORGANIZER", "Organiser"), 64 ("ATTENDEE", "Attendee"), 65 ] 66 67 partstat_items = [ 68 ("NEEDS-ACTION", "Not confirmed"), 69 ("ACCEPTED", "Attending"), 70 ("TENTATIVE", "Tentatively attending"), 71 ("DECLINED", "Not attending"), 72 ("DELEGATED", "Delegated"), 73 (None, "Not indicated"), 74 ] 75 76 def is_organiser(self, obj): 77 return get_uri(obj.get_value("ORGANIZER")) == self.user 78 79 # Request logic methods. 80 81 def handle_request(self, uid, recurrenceid, obj): 82 83 """ 84 Handle actions involving the given 'uid', 'recurrenceid', and 'obj' as 85 the object's representation, returning an error if one occurred, or None 86 if the request was successfully handled. 87 """ 88 89 # Handle a submitted form. 90 91 args = self.env.get_args() 92 93 # Get the possible actions. 94 95 reply = args.has_key("reply") 96 discard = args.has_key("discard") 97 invite = args.has_key("invite") 98 cancel = args.has_key("cancel") 99 save = args.has_key("save") 100 ignore = args.has_key("ignore") 101 102 have_action = reply or discard or invite or cancel or save or ignore 103 104 if not have_action: 105 return ["action"] 106 107 # If ignoring the object, return to the calendar. 108 109 if ignore: 110 self.redirect(self.env.get_path()) 111 return None 112 113 update = False 114 115 # Update the object. 116 117 if reply or invite or cancel or save: 118 119 # Update principal event details if organiser. 120 121 if self.is_organiser(obj): 122 123 # Update time periods (main and recurring). 124 125 period, errors = self.handle_main_period() 126 if errors: 127 return errors 128 129 periods, errors = self.handle_recurrence_periods() 130 if errors: 131 return errors 132 133 self.set_period_in_object(obj, period) 134 self.set_periods_in_object(obj, periods) 135 136 # Update summary. 137 138 if args.has_key("summary"): 139 obj["SUMMARY"] = [(args["summary"][0], {})] 140 141 # Obtain any participants and those to be removed. 142 143 attendees = self.get_attendees() 144 removed = [attendees[int(i)] for i in args.get("remove", [])] 145 to_cancel = update_attendees(obj, attendees, removed) 146 147 # Update attendee participation. 148 149 if args.has_key("partstat"): 150 update_participation(obj, self.user, args["partstat"][0]) 151 152 # Process any action. 153 154 handled = True 155 156 if reply or invite or cancel: 157 158 handler = ManagerHandler(obj, self.user, self.messenger) 159 160 # Process the object and remove it from the list of requests. 161 162 if reply and handler.process_received_request(update): 163 self.remove_request(uid, recurrenceid) 164 165 elif self.is_organiser(obj) and (invite or cancel): 166 167 if handler.process_created_request( 168 invite and "REQUEST" or "CANCEL", update, to_cancel): 169 170 self.remove_request(uid, recurrenceid) 171 172 # Save single user events. 173 174 elif save: 175 self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node()) 176 self.update_freebusy(uid, recurrenceid, obj) 177 self.remove_request(uid, recurrenceid) 178 179 # Remove the request and the object. 180 181 elif discard: 182 self.remove_from_freebusy(uid, recurrenceid) 183 self.remove_event(uid, recurrenceid) 184 self.remove_request(uid, recurrenceid) 185 186 else: 187 handled = False 188 189 # Upon handling an action, redirect to the main page. 190 191 if handled: 192 self.redirect(self.env.get_path()) 193 194 return None 195 196 def handle_main_period(self): 197 198 "Return period details for the main start/end period in an event." 199 200 args = self.env.get_args() 201 202 dtend_enabled = args.get("dtend-control", [None])[0] 203 dttimes_enabled = args.get("dttimes-control", [None])[0] 204 start_values = self.get_date_control_values("dtstart") 205 end_values = self.get_date_control_values("dtend") 206 207 period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled) 208 209 if errors: 210 return None, errors 211 else: 212 return period, errors 213 214 def handle_recurrence_periods(self): 215 216 "Return period details for the recurrences specified for an event." 217 218 args = self.env.get_args() 219 220 all_dtend_enabled = args.get("dtend-control-recur", []) 221 all_dttimes_enabled = args.get("dttimes-control-recur", []) 222 all_start_values = self.get_date_control_values("dtstart-recur", multiple=True) 223 all_end_values = self.get_date_control_values("dtend-recur", multiple=True, tzid_name="dtstart-recur") 224 225 periods = [] 226 errors = [] 227 228 for index, (start_values, end_values, dtend_enabled, dttimes_enabled) in \ 229 enumerate(map(None, all_start_values, all_end_values, all_dtend_enabled, all_dttimes_enabled)): 230 231 dtend_enabled = str(index) in all_dtend_enabled 232 dttimes_enabled = str(index) in all_dttimes_enabled 233 period, _errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled, index) 234 235 periods.append(period) 236 errors += _errors 237 238 return periods, errors 239 240 def handle_period_controls(self, start_values, end_values, dtend_enabled, dttimes_enabled, index=None): 241 242 """ 243 Handle datetime controls for a particular period, described by the given 244 'start_values' and 'end_values', with 'dtend_enabled' and 245 'dttimes_enabled' affecting the usage of the provided values. 246 247 If 'index' is specified, incorporate it into any error indicator. 248 """ 249 250 t = self.handle_date_control_values(start_values, dttimes_enabled) 251 if t: 252 dtstart, dtstart_attr = t 253 else: 254 return None, [index is not None and ("dtstart", index) or "dtstart"] 255 256 # Handle specified end datetimes. 257 258 if dtend_enabled: 259 t = self.handle_date_control_values(end_values, dttimes_enabled) 260 if t: 261 dtend, dtend_attr = t 262 263 # Convert end dates to iCalendar "next day" dates. 264 265 if not isinstance(dtend, datetime): 266 dtend += timedelta(1) 267 else: 268 return None, [index is not None and ("dtend", index) or "dtend"] 269 270 # Otherwise, treat the end date as the start date. Datetimes are 271 # handled by making the event occupy the rest of the day. 272 273 else: 274 dtend = dtstart + timedelta(1) 275 dtend_attr = dtstart_attr 276 277 if isinstance(dtstart, datetime): 278 dtend = get_start_of_day(dtend, attr["TZID"]) 279 280 if dtstart > dtend: 281 return None, [ 282 index is not None and ("dtstart", index) or "dtstart", 283 index is not None and ("dtend", index) or "dtend" 284 ] 285 286 return EventPeriod(dtstart, dtend, dtstart_attr, dtend_attr), None 287 288 def handle_date_control_values(self, values, with_time=True): 289 290 """ 291 Handle date control information for the given 'values', returning a 292 (datetime, attr) tuple, or None if the fields cannot be used to 293 construct a datetime object. 294 """ 295 296 if not values or not values["date"]: 297 return None 298 elif with_time: 299 value = "%s%s" % (values["date"], values["time"]) 300 attr = {"TZID" : values["tzid"], "VALUE" : "DATE-TIME"} 301 dt = get_datetime(value, attr) 302 else: 303 attr = {"VALUE" : "DATE"} 304 dt = get_datetime(values["date"]) 305 306 if dt: 307 return dt, attr 308 309 return None 310 311 def get_date_control_values(self, name, multiple=False, tzid_name=None): 312 313 """ 314 Return a dictionary containing date, time and tzid entries for fields 315 starting with 'name'. If 'multiple' is set to a true value, many 316 dictionaries will be returned corresponding to a collection of 317 datetimes. If 'tzid_name' is specified, the time zone information will 318 be acquired from a field starting with 'tzid_name' instead of 'name'. 319 """ 320 321 args = self.env.get_args() 322 323 dates = args.get("%s-date" % name, []) 324 hours = args.get("%s-hour" % name, []) 325 minutes = args.get("%s-minute" % name, []) 326 seconds = args.get("%s-second" % name, []) 327 tzids = args.get("%s-tzid" % (tzid_name or name), []) 328 329 # Handle absent values by employing None values. 330 331 field_values = map(None, dates, hours, minutes, seconds, tzids) 332 if not field_values and not multiple: 333 field_values = [(None, None, None, None, None)] 334 335 all_values = [] 336 337 for date, hour, minute, second, tzid in field_values: 338 339 # Construct a usable dictionary of values. 340 341 time = (hour or minute or second) and \ 342 "T%s%s%s" % ( 343 (hour or "").rjust(2, "0")[:2], 344 (minute or "").rjust(2, "0")[:2], 345 (second or "").rjust(2, "0")[:2] 346 ) or "" 347 348 value = { 349 "date" : date, 350 "time" : time, 351 "tzid" : tzid or self.get_tzid() 352 } 353 354 # Return a single value or append to a collection of all values. 355 356 if not multiple: 357 return value 358 else: 359 all_values.append(value) 360 361 return all_values 362 363 def set_period_in_object(self, obj, period): 364 365 "Set in the given 'obj' the given 'period' as the main start and end." 366 367 p = period 368 result = self.set_datetime_in_object(p.start, p.start_attr and p.start_attr.get("TZID"), "DTSTART", obj) 369 result = self.set_datetime_in_object(p.end, p.end_attr and p.end_attr.get("TZID"), "DTEND", obj) or result 370 return result 371 372 def set_periods_in_object(self, obj, periods): 373 374 "Set in the given 'obj' the given 'periods'." 375 376 update = False 377 378 old_values = obj.get_values("RDATE") 379 new_rdates = [] 380 381 if obj.has_key("RDATE"): 382 del obj["RDATE"] 383 384 for p in periods: 385 tzid = p.start_attr and p.start_attr.get("TZID") or p.end_attr and p.end_attr.get("TZID") 386 new_rdates.append(get_period_item(p.start, p.end, tzid)) 387 388 obj["RDATE"] = new_rdates 389 390 # NOTE: To do: calculate the update status. 391 return update 392 393 def set_datetime_in_object(self, dt, tzid, property, obj): 394 395 """ 396 Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether 397 an update has occurred. 398 """ 399 400 if dt: 401 old_value = obj.get_value(property) 402 obj[property] = [get_datetime_item(dt, tzid)] 403 return format_datetime(dt) != old_value 404 405 return False 406 407 def get_event_period(self, obj): 408 409 """ 410 Return (dtstart, dtstart attributes), (dtend, dtend attributes) for 411 'obj'. 412 """ 413 414 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 415 if obj.has_key("DTEND"): 416 dtend, dtend_attr = obj.get_datetime_item("DTEND") 417 elif obj.has_key("DURATION"): 418 duration = obj.get_duration("DURATION") 419 dtend = dtstart + duration 420 dtend_attr = dtstart_attr 421 else: 422 dtend, dtend_attr = dtstart, dtstart_attr 423 return (dtstart, dtstart_attr), (dtend, dtend_attr) 424 425 def get_current_attendees(self, obj): 426 427 """ 428 Return attendees for 'obj' depending on whether the object is being 429 edited. 430 """ 431 432 args = self.env.get_args() 433 initial_load = not args.has_key("editing") 434 435 if initial_load or not self.is_organiser(obj): 436 return self.get_existing_attendees(obj) 437 else: 438 return self.get_attendees() 439 440 def get_existing_attendees(self, obj): 441 return uri_values(obj.get_values("ATTENDEE") or []) 442 443 def get_attendees(self): 444 445 """ 446 Return attendees from the request, normalised for iCalendar purposes, 447 and without duplicates. 448 """ 449 450 args = self.env.get_args() 451 452 attendees = args.get("attendee", []) 453 unique_attendees = set() 454 ordered_attendees = [] 455 456 for attendee in attendees: 457 if not attendee.strip(): 458 continue 459 attendee = get_uri(attendee) 460 if attendee not in unique_attendees: 461 unique_attendees.add(attendee) 462 ordered_attendees.append(attendee) 463 464 return ordered_attendees 465 466 def update_attendees(self, obj): 467 468 "Add or remove attendees. This does not affect the stored object." 469 470 args = self.env.get_args() 471 472 attendees = self.get_attendees() 473 existing_attendees = self.get_existing_attendees(obj) 474 sequence = obj.get_value("SEQUENCE") 475 476 if args.has_key("add"): 477 attendees.append("") 478 479 # Only actually remove attendees if the event is unsent, if the attendee 480 # is new, or if it is the current user being removed. 481 482 if args.has_key("remove"): 483 for i in args["remove"]: 484 try: 485 attendee = attendees[int(i)] 486 except IndexError: 487 continue 488 489 existing = attendee in existing_attendees 490 491 if not existing or sequence is None or attendee == self.user: 492 attendees.remove(attendee) 493 494 return attendees 495 496 # Page fragment methods. 497 498 def show_request_controls(self, obj): 499 500 "Show form controls for a request concerning 'obj'." 501 502 page = self.page 503 args = self.env.get_args() 504 505 attendees = self.get_current_attendees(obj) 506 is_attendee = self.user in attendees 507 508 is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests() 509 510 have_other_attendees = len(attendees) > (is_attendee and 1 or 0) 511 512 # Show appropriate options depending on the role of the user. 513 514 if is_attendee and not self.is_organiser(obj): 515 page.p("An action is required for this request:") 516 517 page.p() 518 page.input(name="reply", type="submit", value="Send reply") 519 page.add(" ") 520 page.input(name="discard", type="submit", value="Discard event") 521 page.add(" ") 522 page.input(name="ignore", type="submit", value="Do nothing for now") 523 page.p.close() 524 525 if self.is_organiser(obj): 526 page.p("As organiser, you can perform the following:") 527 528 if have_other_attendees: 529 page.p() 530 page.input(name="invite", type="submit", value="Invite/notify attendees") 531 page.add(" ") 532 if is_request: 533 page.input(name="discard", type="submit", value="Discard event") 534 else: 535 page.input(name="cancel", type="submit", value="Cancel event") 536 page.add(" ") 537 page.input(name="ignore", type="submit", value="Do nothing for now") 538 page.p.close() 539 else: 540 page.p() 541 page.input(name="save", type="submit", value="Save event") 542 page.add(" ") 543 page.input(name="discard", type="submit", value="Discard event") 544 page.add(" ") 545 page.input(name="ignore", type="submit", value="Do nothing for now") 546 page.p.close() 547 548 def show_object_on_page(self, uid, obj, errors=None): 549 550 """ 551 Show the calendar object with the given 'uid' and representation 'obj' 552 on the current page. If 'errors' is given, show a suitable message for 553 the different errors provided. 554 """ 555 556 page = self.page 557 page.form(method="POST") 558 559 page.input(name="editing", type="hidden", value="true") 560 561 args = self.env.get_args() 562 563 # Obtain basic event information, generating any necessary editing controls. 564 565 initial_load = not args.has_key("editing") 566 567 if initial_load or not self.is_organiser(obj): 568 attendees = self.get_existing_attendees(obj) 569 else: 570 attendees = self.update_attendees(obj) 571 572 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_event_period(obj) 573 self.show_object_datetime_controls(dtstart, dtend) 574 575 # Obtain any separate recurrences for this event. 576 577 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 578 recurrenceids = self._get_recurrences(uid) 579 start_utc = format_datetime(to_timezone(dtstart, "UTC")) 580 replaced = not recurrenceid and recurrenceids and start_utc in recurrenceids 581 582 # Provide a summary of the object. 583 584 page.table(class_="object", cellspacing=5, cellpadding=5) 585 page.thead() 586 page.tr() 587 page.th("Event", class_="mainheading", colspan=2) 588 page.tr.close() 589 page.thead.close() 590 page.tbody() 591 592 for name, label in self.property_items: 593 field = name.lower() 594 595 items = obj.get_items(name) or [] 596 rowspan = len(items) 597 598 if name == "ATTENDEE": 599 rowspan = len(attendees) + 1 # for the add button 600 elif not items: 601 continue 602 603 page.tr() 604 page.th(label, class_="objectheading %s%s" % (field, errors and field in errors and " error" or ""), rowspan=rowspan) 605 606 # Handle datetimes specially. 607 608 if name in ["DTSTART", "DTEND"]: 609 if not replaced: 610 611 # Obtain the datetime. 612 613 if name == "DTSTART": 614 dt, attr = dtstart, dtstart_attr 615 616 # Where no end datetime exists, use the start datetime as the 617 # basis of any potential datetime specified if dt-control is 618 # set. 619 620 else: 621 dt, attr = dtend or dtstart, dtend_attr or dtstart_attr 622 623 self.show_datetime_controls(obj, dt, attr, name == "DTSTART") 624 625 elif name == "DTSTART": 626 page.td(class_="objectvalue %s replaced" % field, rowspan=2) 627 page.a("First occurrence replaced by a separate event", href=self.link_to(uid, start_utc)) 628 page.td.close() 629 630 page.tr.close() 631 632 # Handle the summary specially. 633 634 elif name == "SUMMARY": 635 value = args.get("summary", [obj.get_value(name)])[0] 636 637 page.td(class_="objectvalue summary") 638 if self.is_organiser(obj): 639 page.input(name="summary", type="text", value=value, size=80) 640 else: 641 page.add(value) 642 page.td.close() 643 page.tr.close() 644 645 # Handle attendees specially. 646 647 elif name == "ATTENDEE": 648 attendee_map = dict(items) 649 first = True 650 651 for i, value in enumerate(attendees): 652 if not first: 653 page.tr() 654 else: 655 first = False 656 657 # Obtain details of attendees to supply attributes. 658 659 self.show_attendee(obj, i, value, attendee_map.get(value)) 660 page.tr.close() 661 662 # Allow more attendees to be specified. 663 664 if self.is_organiser(obj): 665 if not first: 666 page.tr() 667 668 page.td() 669 page.input(name="add", type="submit", value="add", id="add", class_="add") 670 page.label("Add attendee", for_="add", class_="add") 671 page.td.close() 672 page.tr.close() 673 674 # Handle potentially many values of other kinds. 675 676 else: 677 first = True 678 679 for i, (value, attr) in enumerate(items): 680 if not first: 681 page.tr() 682 else: 683 first = False 684 685 page.td(class_="objectvalue %s" % field) 686 page.add(value) 687 page.td.close() 688 page.tr.close() 689 690 page.tbody.close() 691 page.table.close() 692 693 self.show_recurrences(obj, errors) 694 self.show_conflicting_events(uid, obj) 695 self.show_request_controls(obj) 696 697 page.form.close() 698 699 def show_attendee(self, obj, i, attendee, attendee_attr): 700 701 """ 702 For the given object 'obj', show the attendee in position 'i' with the 703 given 'attendee' value, having 'attendee_attr' as any stored attributes. 704 """ 705 706 page = self.page 707 args = self.env.get_args() 708 709 existing = attendee_attr is not None 710 partstat = attendee_attr and attendee_attr.get("PARTSTAT") 711 sequence = obj.get_value("SEQUENCE") 712 713 page.td(class_="objectvalue") 714 715 # Show a form control as organiser for new attendees. 716 717 if self.is_organiser(obj) and (not existing or sequence is None): 718 page.input(name="attendee", type="value", value=attendee, size="40") 719 else: 720 page.input(name="attendee", type="hidden", value=attendee) 721 page.add(attendee) 722 page.add(" ") 723 724 # Show participation status, editable for the current user. 725 726 if attendee == self.user: 727 self._show_menu("partstat", partstat, self.partstat_items, "partstat") 728 729 # Allow the participation indicator to act as a submit 730 # button in order to refresh the page and show a control for 731 # the current user, if indicated. 732 733 elif self.is_organiser(obj) and not existing: 734 page.input(name="partstat-refresh", type="submit", value="refresh", id="partstat-%d" % i, class_="refresh") 735 page.label(dict(self.partstat_items).get(partstat, ""), for_="partstat-%s" % i, class_="partstat") 736 else: 737 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 738 739 # Permit organisers to remove attendees. 740 741 if self.is_organiser(obj): 742 743 # Permit the removal of newly-added attendees. 744 745 remove_type = (not existing or sequence is None or attendee == self.user) and "submit" or "checkbox" 746 747 self._control("remove", remove_type, str(i), str(i) in args.get("remove", []), id="remove-%d" % i, class_="remove") 748 749 page.label("Remove", for_="remove-%d" % i, class_="remove") 750 page.label("Uninvited", for_="remove-%d" % i, class_="removed") 751 752 page.td.close() 753 754 def show_recurrences(self, obj, errors=None): 755 756 """ 757 Show recurrences for the object having the given representation 'obj'. 758 If 'errors' is given, show a suitable message for the different errors 759 provided. 760 """ 761 762 page = self.page 763 764 # Obtain any parent object if this object is a specific recurrence. 765 766 uid = obj.get_value("UID") 767 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 768 769 if recurrenceid: 770 parent = self._get_object(uid) 771 if not parent: 772 return 773 774 page.p() 775 page.a("This event modifies a recurring event.", href=self.link_to(uid)) 776 page.p.close() 777 778 # Obtain the periods associated with the event in the user's time zone. 779 780 periods = obj.get_periods(self.get_tzid(), self.get_window_end()) 781 recurrenceids = self._get_recurrences(uid) 782 783 if len(periods) == 1: 784 return 785 786 if self.is_organiser(obj): 787 page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size()) 788 else: 789 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 790 791 # Determine whether any periods are explicitly created or are part of a 792 # rule. 793 794 explicit_periods = filter(lambda p: p.origin != "RRULE", periods) 795 796 # Show each recurrence in a separate table if editable. 797 798 if self.is_organiser(obj) and explicit_periods: 799 800 for index, p in enumerate(periods[1:]): 801 802 # Isolate the controls from neighbouring tables. 803 804 page.div() 805 806 self.show_object_datetime_controls(p.start, p.end, index) 807 808 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 809 page.caption("Occurrence") 810 page.tbody() 811 page.tr() 812 error = errors and ("dtstart", index) in errors and " error" or "" 813 page.th("Start", class_="objectheading start%s" % error) 814 self.show_recurrence_controls(obj, index, p, recurrenceid, recurrenceids, True) 815 page.tr.close() 816 page.tr() 817 error = errors and ("dtend", index) in errors and " error" or "" 818 page.th("End", class_="objectheading end%s" % error) 819 self.show_recurrence_controls(obj, index, p, recurrenceid, recurrenceids, False) 820 page.tr.close() 821 page.tbody.close() 822 page.table.close() 823 824 page.div.close() 825 826 # Otherwise, use a compact single table. 827 828 else: 829 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 830 page.caption("Occurrences") 831 page.thead() 832 page.tr() 833 page.th("Start", class_="objectheading start") 834 page.th("End", class_="objectheading end") 835 page.tr.close() 836 page.thead.close() 837 page.tbody() 838 839 # Show only subsequent periods if organiser, since the principal 840 # period will be the start and end datetimes. 841 842 for index, p in enumerate(self.is_organiser(obj) and periods[1:] or periods): 843 page.tr() 844 self.show_recurrence_controls(obj, index, p, recurrenceid, recurrenceids, True) 845 self.show_recurrence_controls(obj, index, p, recurrenceid, recurrenceids, False) 846 page.tr.close() 847 848 page.tbody.close() 849 page.table.close() 850 851 def show_conflicting_events(self, uid, obj): 852 853 """ 854 Show conflicting events for the object having the given 'uid' and 855 representation 'obj'. 856 """ 857 858 page = self.page 859 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 860 861 # Obtain the user's timezone. 862 863 tzid = self.get_tzid() 864 periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) 865 866 # Indicate whether there are conflicting events. 867 868 conflicts = [] 869 870 for participant in self.get_current_attendees(obj): 871 if participant == self.user: 872 freebusy = self.store.get_freebusy(participant) 873 else: 874 freebusy = self.store.get_freebusy_for_other(self.user, participant) 875 876 if not freebusy: 877 continue 878 879 # Obtain any time zone details from the suggested event. 880 881 _dtstart, attr = obj.get_item("DTSTART") 882 tzid = attr.get("TZID", tzid) 883 884 # Show any conflicts with periods of actual attendance. 885 886 for p in have_conflict(freebusy, periods, True): 887 if (p.uid != uid or p.recurrenceid != recurrenceid) and p.transp != "ORG": 888 conflicts.append(p) 889 890 conflicts.sort() 891 892 # Show any conflicts with periods of actual attendance. 893 894 if conflicts: 895 page.p("This event conflicts with others:") 896 897 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 898 page.thead() 899 page.tr() 900 page.th("Event") 901 page.th("Start") 902 page.th("End") 903 page.tr.close() 904 page.thead.close() 905 page.tbody() 906 907 for p in conflicts: 908 909 # Provide details of any conflicting event. 910 911 start = self.format_datetime(to_timezone(get_datetime(p.start), tzid), "long") 912 end = self.format_datetime(to_timezone(get_datetime(p.end), tzid), "long") 913 914 page.tr() 915 916 # Show the event summary for the conflicting event. 917 918 page.td() 919 if p.summary: 920 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid)) 921 else: 922 page.add("(Unspecified event)") 923 page.td.close() 924 925 page.td(start) 926 page.td(end) 927 928 page.tr.close() 929 930 page.tbody.close() 931 page.table.close() 932 933 # Generation of controls within page fragments. 934 935 def show_object_datetime_controls(self, start, end, index=None): 936 937 """ 938 Show datetime-related controls if already active or if an object needs 939 them for the given 'start' to 'end' period. The given 'index' is used to 940 parameterise individual controls for dynamic manipulation. 941 """ 942 943 page = self.page 944 args = self.env.get_args() 945 sn = self._suffixed_name 946 ssn = self._simple_suffixed_name 947 948 # Add a dynamic stylesheet to permit the controls to modify the display. 949 # NOTE: The style details need to be coordinated with the static 950 # NOTE: stylesheet. 951 952 if index is not None: 953 page.style(type="text/css") 954 955 # Unlike the rules for object properties, these affect recurrence 956 # properties. 957 958 page.add("""\ 959 input#dttimes-enable-%(index)d, 960 input#dtend-enable-%(index)d, 961 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 962 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 963 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 964 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 965 display: none; 966 }""" % {"index" : index}) 967 968 page.style.close() 969 970 dtend_control = args.get(ssn("dtend-control", "recur", index), []) 971 dttimes_control = args.get(ssn("dttimes-control", "recur", index), []) 972 973 dtend_enabled = index is not None and str(index) in dtend_control or index is None and dtend_control 974 dttimes_enabled = index is not None and str(index) in dttimes_control or index is None and dttimes_control 975 976 initial_load = not args.has_key("editing") 977 978 dtend_enabled = dtend_enabled or initial_load and (isinstance(end, datetime) or start != end - timedelta(1)) 979 dttimes_enabled = dttimes_enabled or initial_load and (isinstance(start, datetime) or isinstance(end, datetime)) 980 981 self._control( 982 ssn("dtend-control", "recur", index), "checkbox", 983 index is not None and str(index) or "enable", dtend_enabled, 984 id=sn("dtend-enable", index) 985 ) 986 987 self._control( 988 ssn("dttimes-control", "recur", index), "checkbox", 989 index is not None and str(index) or "enable", dttimes_enabled, 990 id=sn("dttimes-enable", index) 991 ) 992 993 def show_datetime_controls(self, obj, dt, attr, show_start): 994 995 """ 996 Show datetime details from the given 'obj' for the datetime 'dt' and 997 attributes 'attr', showing start details if 'show_start' is set 998 to a true value. Details will appear as controls for organisers and 999 labels for attendees. 1000 """ 1001 1002 page = self.page 1003 1004 # Change end dates to refer to the actual dates, not the iCalendar 1005 # "next day" dates. 1006 1007 if not show_start and not isinstance(dt, datetime): 1008 dt -= timedelta(1) 1009 1010 # Show controls for editing as organiser. 1011 1012 if self.is_organiser(obj): 1013 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 1014 1015 if show_start: 1016 page.div(class_="dt enabled") 1017 self._show_date_controls("dtstart", dt, attr.get("TZID")) 1018 page.br() 1019 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 1020 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 1021 page.div.close() 1022 1023 else: 1024 page.div(class_="dt disabled") 1025 page.label("Specify end date", for_="dtend-enable", class_="enable") 1026 page.div.close() 1027 page.div(class_="dt enabled") 1028 self._show_date_controls("dtend", dt, attr.get("TZID")) 1029 page.br() 1030 page.label("End on same day", for_="dtend-enable", class_="disable") 1031 page.div.close() 1032 1033 page.td.close() 1034 1035 # Show a label as attendee. 1036 1037 else: 1038 page.td(self.format_datetime(dt, "full")) 1039 1040 def show_recurrence_controls(self, obj, index, period, recurrenceid, recurrenceids, show_start): 1041 1042 """ 1043 Show datetime details from the given 'obj' for the recurrence having the 1044 given 'index', with the recurrence period described by 'period', 1045 indicating a start, end and origin of the period from the event details, 1046 employing any 'recurrenceid' and 'recurrenceids' for the object to 1047 configure the displayed information. 1048 1049 If 'show_start' is set to a true value, the start details will be shown; 1050 otherwise, the end details will be shown. 1051 """ 1052 1053 page = self.page 1054 sn = self._suffixed_name 1055 ssn = self._simple_suffixed_name 1056 p = period 1057 1058 # Change end dates to refer to the actual dates, not the iCalendar 1059 # "next day" dates. 1060 1061 if not isinstance(p.end, datetime): 1062 p.end -= timedelta(1) 1063 1064 start_utc = format_datetime(to_timezone(p.start, "UTC")) 1065 replaced = recurrenceids and start_utc in recurrenceids and "replaced" or "" 1066 css = " ".join([ 1067 replaced, 1068 recurrenceid and start_utc == recurrenceid and "affected" or "" 1069 ]) 1070 1071 # Show controls for editing as organiser. 1072 1073 if self.is_organiser(obj) and not replaced and p.origin != "RRULE": 1074 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 1075 1076 if show_start: 1077 page.div(class_="dt enabled") 1078 self._show_date_controls(ssn("dtstart", "recur", index), p.start, p.start_attr.get("TZID"), index=index) 1079 page.br() 1080 page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable") 1081 page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable") 1082 page.div.close() 1083 1084 else: 1085 page.div(class_="dt disabled") 1086 page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable") 1087 page.div.close() 1088 page.div(class_="dt enabled") 1089 self._show_date_controls(ssn("dtend", "recur", index), p.end, index=index, show_tzid=False) 1090 page.br() 1091 page.label("End on same day", for_=sn("dtend-enable", index), class_="disable") 1092 page.div.close() 1093 1094 page.td.close() 1095 1096 # Show label as attendee. 1097 1098 else: 1099 page.td(self.format_datetime(show_start and p.start or p.end, "long"), class_=css) 1100 1101 # Full page output methods. 1102 1103 def show(self, path_info): 1104 1105 "Show an object request using the given 'path_info' for the current user." 1106 1107 uid, recurrenceid = self._get_identifiers(path_info) 1108 obj = self._get_object(uid, recurrenceid) 1109 1110 if not obj: 1111 return False 1112 1113 errors = self.handle_request(uid, recurrenceid, obj) 1114 1115 if not errors: 1116 return True 1117 1118 self.new_page(title="Event") 1119 self.show_object_on_page(uid, obj, errors) 1120 1121 return True 1122 1123 # Utility methods. 1124 1125 def _control(self, name, type, value, selected, **kw): 1126 1127 """ 1128 Show a control with the given 'name', 'type' and 'value', with 1129 'selected' indicating whether it should be selected (checked or 1130 equivalent), and with keyword arguments setting other properties. 1131 """ 1132 1133 page = self.page 1134 if selected: 1135 page.input(name=name, type=type, value=value, checked=selected, **kw) 1136 else: 1137 page.input(name=name, type=type, value=value, **kw) 1138 1139 def _show_menu(self, name, default, items, class_="", index=None): 1140 1141 """ 1142 Show a select menu having the given 'name', set to the given 'default', 1143 providing the given (value, label) 'items', and employing the given CSS 1144 'class_' if specified. 1145 """ 1146 1147 page = self.page 1148 values = self.env.get_args().get(name, [default]) 1149 if index is not None: 1150 values = values[index:] 1151 values = values and values[0:1] or [default] 1152 1153 page.select(name=name, class_=class_) 1154 for v, label in items: 1155 if v is None: 1156 continue 1157 if v in values: 1158 page.option(label, value=v, selected="selected") 1159 else: 1160 page.option(label, value=v) 1161 page.select.close() 1162 1163 def _show_date_controls(self, name, default, tzid=None, index=None, show_tzid=True): 1164 1165 """ 1166 Show date controls for a field with the given 'name' and 'default' value 1167 and 'tzid'. If 'index' is specified, default field values will be 1168 overridden by the element from a collection of existing form values with 1169 the specified index; otherwise, field values will be overridden by a 1170 single form value. 1171 1172 If 'show_tzid' is set to a false value, the time zone menu will not be 1173 provided. 1174 """ 1175 1176 page = self.page 1177 args = self.env.get_args() 1178 1179 # Show dates for up to one week around the current date. 1180 1181 base = to_date(default) 1182 items = [] 1183 for i in range(-7, 8): 1184 d = base + timedelta(i) 1185 items.append((format_datetime(d), self.format_date(d, "full"))) 1186 1187 self._show_menu("%s-date" % name, format_datetime(base), items, index=index) 1188 1189 # Show time details. 1190 1191 default_time = isinstance(default, datetime) and default or None 1192 1193 hour = args.get("%s-hour" % name, [])[index or 0:] 1194 hour = hour and hour[0] or "%02d" % (default_time and default_time.hour or 0) 1195 minute = args.get("%s-minute" % name, [])[index or 0:] 1196 minute = minute and minute[0] or "%02d" % (default_time and default_time.minute or 0) 1197 second = args.get("%s-second" % name, [])[index or 0:] 1198 second = second and second[0] or "%02d" % (default_time and default_time.second or 0) 1199 1200 page.span(class_="time enabled") 1201 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1202 page.add(":") 1203 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1204 page.add(":") 1205 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1206 if show_tzid: 1207 tzid = tzid or self.get_tzid() 1208 page.add(" ") 1209 self._show_timezone_menu("%s-tzid" % name, tzid, index) 1210 page.span.close() 1211 1212 def _show_timezone_menu(self, name, default, index=None): 1213 1214 """ 1215 Show timezone controls using a menu with the given 'name', set to the 1216 given 'default' unless a field of the given 'name' provides a value. 1217 """ 1218 1219 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 1220 self._show_menu(name, default, entries, index=index) 1221 1222 # vim: tabstop=4 expandtab shiftwidth=4