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 attendee = attendees[int(i)] 485 existing = attendee in existing_attendees 486 487 if not existing or sequence is None or attendee == self.user: 488 attendees.remove(attendee) 489 490 return attendees 491 492 # Page fragment methods. 493 494 def show_request_controls(self, obj): 495 496 "Show form controls for a request concerning 'obj'." 497 498 page = self.page 499 args = self.env.get_args() 500 501 attendees = self.get_current_attendees(obj) 502 is_attendee = self.user in attendees 503 504 is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests() 505 506 have_other_attendees = len(attendees) > (is_attendee and 1 or 0) 507 508 # Show appropriate options depending on the role of the user. 509 510 if is_attendee and not self.is_organiser(obj): 511 page.p("An action is required for this request:") 512 513 page.p() 514 page.input(name="reply", type="submit", value="Send reply") 515 page.add(" ") 516 page.input(name="discard", type="submit", value="Discard event") 517 page.add(" ") 518 page.input(name="ignore", type="submit", value="Do nothing for now") 519 page.p.close() 520 521 if self.is_organiser(obj): 522 page.p("As organiser, you can perform the following:") 523 524 if have_other_attendees: 525 page.p() 526 page.input(name="invite", type="submit", value="Invite/notify attendees") 527 page.add(" ") 528 if is_request: 529 page.input(name="discard", type="submit", value="Discard event") 530 else: 531 page.input(name="cancel", type="submit", value="Cancel event") 532 page.add(" ") 533 page.input(name="ignore", type="submit", value="Do nothing for now") 534 page.p.close() 535 else: 536 page.p() 537 page.input(name="save", type="submit", value="Save event") 538 page.add(" ") 539 page.input(name="discard", type="submit", value="Discard event") 540 page.add(" ") 541 page.input(name="ignore", type="submit", value="Do nothing for now") 542 page.p.close() 543 544 def show_object_on_page(self, uid, obj, errors=None): 545 546 """ 547 Show the calendar object with the given 'uid' and representation 'obj' 548 on the current page. If 'errors' is given, show a suitable message for 549 the different errors provided. 550 """ 551 552 page = self.page 553 page.form(method="POST") 554 555 page.input(name="editing", type="hidden", value="true") 556 557 args = self.env.get_args() 558 559 # Obtain the user's timezone. 560 561 tzid = self.get_tzid() 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 i = len(attendees) 666 667 if not first: 668 page.tr() 669 670 page.td() 671 page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add") 672 page.label("Add attendee", for_="add-%d" % i, class_="add") 673 page.td.close() 674 page.tr.close() 675 676 # Handle potentially many values of other kinds. 677 678 else: 679 first = True 680 681 for i, (value, attr) in enumerate(items): 682 if not first: 683 page.tr() 684 else: 685 first = False 686 687 page.td(class_="objectvalue %s" % field) 688 page.add(value) 689 page.td.close() 690 page.tr.close() 691 692 page.tbody.close() 693 page.table.close() 694 695 self.show_recurrences(obj, errors) 696 self.show_conflicting_events(uid, obj) 697 self.show_request_controls(obj) 698 699 page.form.close() 700 701 def show_attendee(self, obj, i, attendee, attendee_attr): 702 703 """ 704 For the given object 'obj', show the attendee in position 'i' with the 705 given 'attendee' value, having 'attendee_attr' as any stored attributes. 706 """ 707 708 page = self.page 709 args = self.env.get_args() 710 711 existing = attendee_attr is not None 712 partstat = attendee_attr and attendee_attr.get("PARTSTAT") 713 sequence = obj.get_value("SEQUENCE") 714 715 page.td(class_="objectvalue") 716 717 # Show a form control as organiser for new attendees. 718 719 if self.is_organiser(obj) and (not existing or sequence is None): 720 page.input(name="attendee", type="value", value=attendee, size="40") 721 else: 722 page.input(name="attendee", type="hidden", value=attendee) 723 page.add(attendee) 724 page.add(" ") 725 726 # Show participation status, editable for the current user. 727 728 if attendee == self.user: 729 self._show_menu("partstat", partstat, self.partstat_items, "partstat") 730 731 # Allow the participation indicator to act as a submit 732 # button in order to refresh the page and show a control for 733 # the current user, if indicated. 734 735 elif self.is_organiser(obj) and not existing: 736 page.input(name="partstat-refresh", type="submit", value="refresh", id="partstat-%d" % i, class_="refresh") 737 page.label(dict(self.partstat_items).get(partstat, ""), for_="partstat-%s" % i, class_="partstat") 738 else: 739 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 740 741 # Permit organisers to remove attendees. 742 743 if self.is_organiser(obj): 744 745 # Permit the removal of newly-added attendees. 746 747 remove_type = (not existing or sequence is None or attendee == self.user) and "submit" or "checkbox" 748 749 self._control("remove", remove_type, str(i), str(i) in args.get("remove", []), id="remove-%d" % i, class_="remove") 750 751 page.label("Remove", for_="remove-%d" % i, class_="remove") 752 page.label("Uninvited", for_="remove-%d" % i, class_="removed") 753 754 page.td.close() 755 756 def show_recurrences(self, obj, errors=None): 757 758 """ 759 Show recurrences for the object having the given representation 'obj'. 760 If 'errors' is given, show a suitable message for the different errors 761 provided. 762 """ 763 764 page = self.page 765 766 # Obtain any parent object if this object is a specific recurrence. 767 768 uid = obj.get_value("UID") 769 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 770 771 if recurrenceid: 772 parent = self._get_object(uid) 773 if not parent: 774 return 775 776 page.p() 777 page.a("This event modifies a recurring event.", href=self.link_to(uid)) 778 page.p.close() 779 780 # Obtain the periods associated with the event in the user's time zone. 781 782 periods = obj.get_periods(self.get_tzid(), self.get_window_end()) 783 recurrenceids = self._get_recurrences(uid) 784 785 if len(periods) == 1: 786 return 787 788 if self.is_organiser(obj): 789 page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size()) 790 else: 791 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 792 793 # Determine whether any periods are explicitly created or are part of a 794 # rule. 795 796 explicit_periods = filter(lambda p: p.origin != "RRULE", periods) 797 798 # Show each recurrence in a separate table if editable. 799 800 if self.is_organiser(obj) and explicit_periods: 801 802 for index, p in enumerate(periods[1:]): 803 804 # Isolate the controls from neighbouring tables. 805 806 page.div() 807 808 self.show_object_datetime_controls(p.start, p.end, index) 809 810 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 811 page.caption("Occurrence") 812 page.tbody() 813 page.tr() 814 error = errors and ("dtstart", index) in errors and " error" or "" 815 page.th("Start", class_="objectheading start%s" % error) 816 self.show_recurrence_controls(obj, index, p, recurrenceid, recurrenceids, True) 817 page.tr.close() 818 page.tr() 819 error = errors and ("dtend", index) in errors and " error" or "" 820 page.th("End", class_="objectheading end%s" % error) 821 self.show_recurrence_controls(obj, index, p, recurrenceid, recurrenceids, False) 822 page.tr.close() 823 page.tbody.close() 824 page.table.close() 825 826 page.div.close() 827 828 # Otherwise, use a compact single table. 829 830 else: 831 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 832 page.caption("Occurrences") 833 page.thead() 834 page.tr() 835 page.th("Start", class_="objectheading start") 836 page.th("End", class_="objectheading end") 837 page.tr.close() 838 page.thead.close() 839 page.tbody() 840 841 # Show only subsequent periods if organiser, since the principal 842 # period will be the start and end datetimes. 843 844 for index, p in enumerate(self.is_organiser(obj) and periods[1:] or periods): 845 page.tr() 846 self.show_recurrence_controls(obj, index, p, recurrenceid, recurrenceids, True) 847 self.show_recurrence_controls(obj, index, p, recurrenceid, recurrenceids, False) 848 page.tr.close() 849 850 page.tbody.close() 851 page.table.close() 852 853 def show_conflicting_events(self, uid, obj): 854 855 """ 856 Show conflicting events for the object having the given 'uid' and 857 representation 'obj'. 858 """ 859 860 page = self.page 861 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 862 863 # Obtain the user's timezone. 864 865 tzid = self.get_tzid() 866 periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) 867 868 # Indicate whether there are conflicting events. 869 870 conflicts = [] 871 872 for participant in self.get_current_attendees(obj): 873 if participant == self.user: 874 freebusy = self.store.get_freebusy(participant) 875 else: 876 freebusy = self.store.get_freebusy_for_other(self.user, participant) 877 878 if not freebusy: 879 continue 880 881 # Obtain any time zone details from the suggested event. 882 883 _dtstart, attr = obj.get_item("DTSTART") 884 tzid = attr.get("TZID", tzid) 885 886 # Show any conflicts with periods of actual attendance. 887 888 for p in have_conflict(freebusy, periods, True): 889 if (p.uid != uid or p.recurrenceid != recurrenceid) and p.transp != "ORG": 890 conflicts.append(p) 891 892 conflicts.sort() 893 894 # Show any conflicts with periods of actual attendance. 895 896 if conflicts: 897 page.p("This event conflicts with others:") 898 899 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 900 page.thead() 901 page.tr() 902 page.th("Event") 903 page.th("Start") 904 page.th("End") 905 page.tr.close() 906 page.thead.close() 907 page.tbody() 908 909 for p in conflicts: 910 911 # Provide details of any conflicting event. 912 913 start = self.format_datetime(to_timezone(get_datetime(p.start), tzid), "long") 914 end = self.format_datetime(to_timezone(get_datetime(p.end), tzid), "long") 915 916 page.tr() 917 918 # Show the event summary for the conflicting event. 919 920 page.td() 921 if p.summary: 922 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid)) 923 else: 924 page.add("(Unspecified event)") 925 page.td.close() 926 927 page.td(start) 928 page.td(end) 929 930 page.tr.close() 931 932 page.tbody.close() 933 page.table.close() 934 935 # Generation of controls within page fragments. 936 937 def show_object_datetime_controls(self, start, end, index=None): 938 939 """ 940 Show datetime-related controls if already active or if an object needs 941 them for the given 'start' to 'end' period. The given 'index' is used to 942 parameterise individual controls for dynamic manipulation. 943 """ 944 945 page = self.page 946 args = self.env.get_args() 947 sn = self._suffixed_name 948 ssn = self._simple_suffixed_name 949 950 # Add a dynamic stylesheet to permit the controls to modify the display. 951 # NOTE: The style details need to be coordinated with the static 952 # NOTE: stylesheet. 953 954 if index is not None: 955 page.style(type="text/css") 956 957 # Unlike the rules for object properties, these affect recurrence 958 # properties. 959 960 page.add("""\ 961 input#dttimes-enable-%(index)d, 962 input#dtend-enable-%(index)d, 963 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 964 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 965 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 966 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 967 display: none; 968 }""" % {"index" : index}) 969 970 page.style.close() 971 972 dtend_control = args.get(ssn("dtend-control", "recur", index), []) 973 dttimes_control = args.get(ssn("dttimes-control", "recur", index), []) 974 975 dtend_enabled = index is not None and str(index) in dtend_control or index is None and dtend_control 976 dttimes_enabled = index is not None and str(index) in dttimes_control or index is None and dttimes_control 977 978 initial_load = not args.has_key("editing") 979 980 dtend_enabled = dtend_enabled or initial_load and (isinstance(end, datetime) or start != end - timedelta(1)) 981 dttimes_enabled = dttimes_enabled or initial_load and (isinstance(start, datetime) or isinstance(end, datetime)) 982 983 self._control( 984 ssn("dtend-control", "recur", index), "checkbox", 985 index is not None and str(index) or "enable", dtend_enabled, 986 id=sn("dtend-enable", index) 987 ) 988 989 self._control( 990 ssn("dttimes-control", "recur", index), "checkbox", 991 index is not None and str(index) or "enable", dttimes_enabled, 992 id=sn("dttimes-enable", index) 993 ) 994 995 def show_datetime_controls(self, obj, dt, attr, show_start): 996 997 """ 998 Show datetime details from the given 'obj' for the datetime 'dt' and 999 attributes 'attr', showing start details if 'show_start' is set 1000 to a true value. Details will appear as controls for organisers and 1001 labels for attendees. 1002 """ 1003 1004 page = self.page 1005 1006 # Change end dates to refer to the actual dates, not the iCalendar 1007 # "next day" dates. 1008 1009 if not show_start and not isinstance(dt, datetime): 1010 dt -= timedelta(1) 1011 1012 # Show controls for editing as organiser. 1013 1014 if self.is_organiser(obj): 1015 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 1016 1017 if show_start: 1018 page.div(class_="dt enabled") 1019 self._show_date_controls("dtstart", dt, attr.get("TZID")) 1020 page.br() 1021 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 1022 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 1023 page.div.close() 1024 1025 else: 1026 page.div(class_="dt disabled") 1027 page.label("Specify end date", for_="dtend-enable", class_="enable") 1028 page.div.close() 1029 page.div(class_="dt enabled") 1030 self._show_date_controls("dtend", dt, attr.get("TZID")) 1031 page.br() 1032 page.label("End on same day", for_="dtend-enable", class_="disable") 1033 page.div.close() 1034 1035 page.td.close() 1036 1037 # Show a label as attendee. 1038 1039 else: 1040 page.td(self.format_datetime(dt, "full")) 1041 1042 def show_recurrence_controls(self, obj, index, period, recurrenceid, recurrenceids, show_start): 1043 1044 """ 1045 Show datetime details from the given 'obj' for the recurrence having the 1046 given 'index', with the recurrence period described by 'period', 1047 indicating a start, end and origin of the period from the event details, 1048 employing any 'recurrenceid' and 'recurrenceids' for the object to 1049 configure the displayed information. 1050 1051 If 'show_start' is set to a true value, the start details will be shown; 1052 otherwise, the end details will be shown. 1053 """ 1054 1055 page = self.page 1056 sn = self._suffixed_name 1057 ssn = self._simple_suffixed_name 1058 p = period 1059 1060 # Change end dates to refer to the actual dates, not the iCalendar 1061 # "next day" dates. 1062 1063 if not isinstance(p.end, datetime): 1064 p.end -= timedelta(1) 1065 1066 start_utc = format_datetime(to_timezone(p.start, "UTC")) 1067 replaced = recurrenceids and start_utc in recurrenceids and "replaced" or "" 1068 css = " ".join([ 1069 replaced, 1070 recurrenceid and start_utc == recurrenceid and "affected" or "" 1071 ]) 1072 1073 # Show controls for editing as organiser. 1074 1075 if self.is_organiser(obj) and not replaced and p.origin != "RRULE": 1076 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 1077 1078 if show_start: 1079 page.div(class_="dt enabled") 1080 self._show_date_controls(ssn("dtstart", "recur", index), p.start, p.start_attr.get("TZID"), index=index) 1081 page.br() 1082 page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable") 1083 page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable") 1084 page.div.close() 1085 1086 else: 1087 page.div(class_="dt disabled") 1088 page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable") 1089 page.div.close() 1090 page.div(class_="dt enabled") 1091 self._show_date_controls(ssn("dtend", "recur", index), p.end, index=index, show_tzid=False) 1092 page.br() 1093 page.label("End on same day", for_=sn("dtend-enable", index), class_="disable") 1094 page.div.close() 1095 1096 page.td.close() 1097 1098 # Show label as attendee. 1099 1100 else: 1101 page.td(self.format_datetime(show_start and p.start or p.end, "long"), class_=css) 1102 1103 # Full page output methods. 1104 1105 def show(self, path_info): 1106 1107 "Show an object request using the given 'path_info' for the current user." 1108 1109 uid, recurrenceid = self._get_identifiers(path_info) 1110 obj = self._get_object(uid, recurrenceid) 1111 1112 if not obj: 1113 return False 1114 1115 errors = self.handle_request(uid, recurrenceid, obj) 1116 1117 if not errors: 1118 return True 1119 1120 self.new_page(title="Event") 1121 self.show_object_on_page(uid, obj, errors) 1122 1123 return True 1124 1125 # Utility methods. 1126 1127 def _control(self, name, type, value, selected, **kw): 1128 1129 """ 1130 Show a control with the given 'name', 'type' and 'value', with 1131 'selected' indicating whether it should be selected (checked or 1132 equivalent), and with keyword arguments setting other properties. 1133 """ 1134 1135 page = self.page 1136 if selected: 1137 page.input(name=name, type=type, value=value, checked=selected, **kw) 1138 else: 1139 page.input(name=name, type=type, value=value, **kw) 1140 1141 def _show_menu(self, name, default, items, class_="", index=None): 1142 1143 """ 1144 Show a select menu having the given 'name', set to the given 'default', 1145 providing the given (value, label) 'items', and employing the given CSS 1146 'class_' if specified. 1147 """ 1148 1149 page = self.page 1150 values = self.env.get_args().get(name, [default]) 1151 if index is not None: 1152 values = values[index:] 1153 values = values and values[0:1] or [default] 1154 1155 page.select(name=name, class_=class_) 1156 for v, label in items: 1157 if v is None: 1158 continue 1159 if v in values: 1160 page.option(label, value=v, selected="selected") 1161 else: 1162 page.option(label, value=v) 1163 page.select.close() 1164 1165 def _show_date_controls(self, name, default, tzid=None, index=None, show_tzid=True): 1166 1167 """ 1168 Show date controls for a field with the given 'name' and 'default' value 1169 and 'tzid'. If 'index' is specified, default field values will be 1170 overridden by the element from a collection of existing form values with 1171 the specified index; otherwise, field values will be overridden by a 1172 single form value. 1173 1174 If 'show_tzid' is set to a false value, the time zone menu will not be 1175 provided. 1176 """ 1177 1178 page = self.page 1179 args = self.env.get_args() 1180 1181 # Show dates for up to one week around the current date. 1182 1183 base = to_date(default) 1184 items = [] 1185 for i in range(-7, 8): 1186 d = base + timedelta(i) 1187 items.append((format_datetime(d), self.format_date(d, "full"))) 1188 1189 self._show_menu("%s-date" % name, format_datetime(base), items, index=index) 1190 1191 # Show time details. 1192 1193 default_time = isinstance(default, datetime) and default or None 1194 1195 hour = args.get("%s-hour" % name, [])[index or 0:] 1196 hour = hour and hour[0] or "%02d" % (default_time and default_time.hour or 0) 1197 minute = args.get("%s-minute" % name, [])[index or 0:] 1198 minute = minute and minute[0] or "%02d" % (default_time and default_time.minute or 0) 1199 second = args.get("%s-second" % name, [])[index or 0:] 1200 second = second and second[0] or "%02d" % (default_time and default_time.second or 0) 1201 1202 page.span(class_="time enabled") 1203 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1204 page.add(":") 1205 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1206 page.add(":") 1207 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1208 if show_tzid: 1209 tzid = tzid or self.get_tzid() 1210 page.add(" ") 1211 self._show_timezone_menu("%s-tzid" % name, tzid, index) 1212 page.span.close() 1213 1214 def _show_timezone_menu(self, name, default, index=None): 1215 1216 """ 1217 Show timezone controls using a menu with the given 'name', set to the 1218 given 'default' unless a field of the given 'name' provides a value. 1219 """ 1220 1221 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 1222 self._show_menu(name, default, entries, index=index) 1223 1224 # vim: tabstop=4 expandtab shiftwidth=4