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