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 imiptools.data import get_uri, get_verbose_address, uri_dict, uri_items, \ 23 uri_parts, uri_values 24 from imiptools.dates import format_datetime, to_timezone 25 from imiptools.mail import Messenger 26 from imiptools.period import have_conflict 27 from imipweb.data import EventPeriod, event_period_from_period, FormPeriod, PeriodError 28 from imipweb.resource import DateTimeFormUtilities, FormUtilities, ResourceClientForObject 29 30 class EventPageFragment(ResourceClientForObject, DateTimeFormUtilities, FormUtilities): 31 32 "A resource presenting the details of an event." 33 34 def __init__(self, resource=None): 35 ResourceClientForObject.__init__(self, resource) 36 37 # Various property values and labels. 38 39 property_items = [ 40 ("SUMMARY", "Summary"), 41 ("DTSTART", "Start"), 42 ("DTEND", "End"), 43 ("ORGANIZER", "Organiser"), 44 ("ATTENDEE", "Attendee"), 45 ] 46 47 partstat_items = [ 48 ("NEEDS-ACTION", "Not confirmed"), 49 ("ACCEPTED", "Attending"), 50 ("TENTATIVE", "Tentatively attending"), 51 ("DECLINED", "Not attending"), 52 ("DELEGATED", "Delegated"), 53 (None, "Not indicated"), 54 ] 55 56 def can_change_object(self): 57 return self.is_organiser() or self._is_request() 58 59 def can_remove_recurrence(self, recurrence): 60 61 """ 62 Return whether the 'recurrence' can be removed from the current object 63 without notification. 64 """ 65 66 return self.can_edit_recurrence(recurrence) and recurrence.origin != "RRULE" 67 68 def can_edit_recurrence(self, recurrence): 69 70 "Return whether 'recurrence' can be edited." 71 72 return self.recurrence_is_new(recurrence) or not self.obj.is_shared() 73 74 def recurrence_is_new(self, recurrence): 75 76 "Return whether 'recurrence' is new to the current object." 77 78 return recurrence not in self.get_stored_recurrences() 79 80 def can_remove_attendee(self, attendee): 81 82 """ 83 Return whether 'attendee' can be removed from the current object without 84 notification. 85 """ 86 87 return self.can_edit_attendee(attendee) or attendee == self.user and self.is_organiser() 88 89 def can_edit_attendee(self, attendee): 90 91 "Return whether 'attendee' can be edited by an organiser." 92 93 return self.attendee_is_new(attendee) or not self.obj.is_shared() 94 95 def attendee_is_new(self, attendee): 96 97 "Return whether 'attendee' is new to the current object." 98 99 return attendee not in uri_values(self.get_stored_attendees()) 100 101 # Access to stored object information. 102 103 def get_stored_attendees(self): 104 return [get_verbose_address(value, attr) for value, attr in self.obj.get_items("ATTENDEE") or []] 105 106 def get_stored_main_period(self): 107 108 "Return the main event period for the current object." 109 110 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.obj.get_main_period_items(self.get_tzid()) 111 return EventPeriod(dtstart, dtend, self.get_tzid(), None, dtstart_attr, dtend_attr) 112 113 def get_stored_recurrences(self): 114 115 "Return recurrences computed using the current object." 116 117 recurrences = [] 118 for period in self.get_periods(self.obj): 119 if period.origin != "DTSTART": 120 recurrences.append(period) 121 return recurrences 122 123 # Access to current object information. 124 125 def get_current_main_period(self): 126 return self.get_stored_main_period() 127 128 def get_current_recurrences(self): 129 return self.get_stored_recurrences() 130 131 def get_current_attendees(self): 132 return self.get_stored_attendees() 133 134 # Page fragment methods. 135 136 def show_request_controls(self): 137 138 "Show form controls for a request." 139 140 page = self.page 141 args = self.env.get_args() 142 143 attendees = uri_values(self.get_current_attendees()) 144 is_attendee = self.user in attendees 145 146 if not self.obj.is_shared(): 147 page.p("This event has not been shared.") 148 149 # Show appropriate options depending on the role of the user. 150 151 if is_attendee and not self.is_organiser(): 152 page.p("An action is required for this request:") 153 154 page.p() 155 self.control("reply", "submit", "Send reply") 156 page.add(" ") 157 self.control("discard", "submit", "Discard event") 158 page.add(" ") 159 self.control("ignore", "submit", "Do nothing for now") 160 page.p.close() 161 162 if self.is_organiser(): 163 page.p("As organiser, you can perform the following:") 164 165 page.p() 166 self.control("create", "submit", "Update event") 167 page.add(" ") 168 169 if self._get_counters(self.uid, self.recurrenceid): 170 self.control("uncounter", "submit", "Ignore counter-proposals") 171 page.add(" ") 172 173 if self.obj.is_shared() and not self._is_request(): 174 self.control("cancel", "submit", "Cancel event") 175 else: 176 self.control("discard", "submit", "Discard event") 177 178 page.add(" ") 179 self.control("ignore", "submit", "Do nothing for now") 180 page.add(" ") 181 self.control("save", "submit", "Save without sending") 182 page.p.close() 183 184 def show_object_on_page(self, errors=None): 185 186 """ 187 Show the calendar object on the current page. If 'errors' is given, show 188 a suitable message for the different errors provided. 189 """ 190 191 page = self.page 192 page.form(method="POST") 193 194 # Add a hidden control to help determine whether editing has already begun. 195 196 self.control("editing", "hidden", "true") 197 198 args = self.env.get_args() 199 200 # Obtain basic event information, generating any necessary editing controls. 201 202 attendees = self.get_current_attendees() 203 period = self.get_current_main_period() 204 stored_period = self.get_stored_main_period() 205 self.show_object_datetime_controls(period) 206 207 # Obtain any separate recurrences for this event. 208 209 recurrenceids = self._get_active_recurrences(self.uid) 210 replaced = not self.recurrenceid and period.is_replaced(recurrenceids) 211 excluded = period == stored_period and period not in self.get_periods(self.obj) 212 213 # Provide a summary of the object. 214 215 page.table(class_="object", cellspacing=5, cellpadding=5) 216 page.thead() 217 page.tr() 218 page.th("Event", class_="mainheading", colspan=2) 219 page.tr.close() 220 page.thead.close() 221 page.tbody() 222 223 for name, label in self.property_items: 224 field = name.lower() 225 226 items = uri_items(self.obj.get_items(name) or []) 227 rowspan = len(items) 228 229 if name == "ATTENDEE": 230 rowspan = len(attendees) + 1 # for the add button 231 elif not items: 232 continue 233 234 page.tr() 235 page.th(label, class_="objectheading %s%s" % (field, errors and field in errors and " error" or ""), rowspan=rowspan) 236 237 # Handle datetimes specially. 238 239 if name in ["DTSTART", "DTEND"]: 240 if not replaced and not excluded: 241 242 # Obtain the datetime. 243 244 is_start = name == "DTSTART" 245 246 # Where no end datetime exists, use the start datetime as the 247 # basis of any potential datetime specified if dt-control is 248 # set. 249 250 self.show_datetime_controls(is_start and period.get_form_start() or period.get_form_end(), is_start) 251 252 elif name == "DTSTART": 253 254 # Replaced occurrences link to their replacements. 255 256 if replaced: 257 page.td(class_="objectvalue %s replaced" % field, rowspan=2) 258 page.a("First occurrence replaced by a separate event", href=self.link_to(self.uid, replaced)) 259 page.td.close() 260 261 # NOTE: Should provide a way of editing recurrences when the 262 # NOTE: first occurrence is excluded, plus a way of 263 # NOTE: reinstating the occurrence. 264 265 elif excluded: 266 page.td(class_="objectvalue %s excluded" % field, rowspan=2) 267 page.add("First occurrence excluded") 268 page.td.close() 269 270 page.tr.close() 271 272 # Handle the summary specially. 273 274 elif name == "SUMMARY": 275 value = args.get("summary", [self.obj.get_value(name)])[0] 276 277 page.td(class_="objectvalue summary") 278 if self.is_organiser(): 279 self.control("summary", "text", value, size=80) 280 else: 281 page.add(value) 282 page.td.close() 283 page.tr.close() 284 285 # Handle attendees specially. 286 287 elif name == "ATTENDEE": 288 attendee_map = dict(items) 289 first = True 290 291 for i, value in enumerate(attendees): 292 if not first: 293 page.tr() 294 else: 295 first = False 296 297 # Obtain details of attendees to supply attributes. 298 299 self.show_attendee(i, value, attendee_map.get(get_uri(value))) 300 page.tr.close() 301 302 # Allow more attendees to be specified. 303 304 if self.can_change_object(): 305 if not first: 306 page.tr() 307 308 page.td() 309 self.control("add", "submit", "add", id="add", class_="add") 310 page.label("Add attendee", for_="add", class_="add") 311 page.td.close() 312 page.tr.close() 313 314 # NOTE: Permit attendees to suggest others for counter-proposals. 315 316 # Handle potentially many values of other kinds. 317 318 else: 319 first = True 320 321 for i, (value, attr) in enumerate(items): 322 if not first: 323 page.tr() 324 else: 325 first = False 326 327 page.td(class_="objectvalue %s" % field) 328 if name == "ORGANIZER": 329 page.add(get_verbose_address(value, attr)) 330 else: 331 page.add(value) 332 page.td.close() 333 page.tr.close() 334 335 page.tbody.close() 336 page.table.close() 337 338 self.show_recurrences(errors) 339 self.show_counters() 340 self.show_conflicting_events() 341 self.show_request_controls() 342 343 page.form.close() 344 345 def show_attendee(self, i, attendee, attendee_attr): 346 347 """ 348 For the current object, show the attendee in position 'i' with the given 349 'attendee' value, having 'attendee_attr' as any stored attributes. 350 """ 351 352 page = self.page 353 args = self.env.get_args() 354 355 attendee_uri = get_uri(attendee) 356 partstat = attendee_attr and attendee_attr.get("PARTSTAT") 357 358 page.td(class_="objectvalue") 359 360 # Show a form control as organiser for new attendees. 361 # NOTE: Permit suggested attendee editing for counter-proposals. 362 363 if self.can_change_object() and self.can_edit_attendee(attendee_uri): 364 self.control("attendee", "value", attendee, size="40") 365 else: 366 self.control("attendee", "hidden", attendee) 367 page.add(attendee) 368 page.add(" ") 369 370 # Show participation status, editable for the current user. 371 372 if attendee_uri == self.user: 373 self.menu("partstat", partstat, self.partstat_items, "partstat") 374 375 # Allow the participation indicator to act as a submit 376 # button in order to refresh the page and show a control for 377 # the current user, if indicated. 378 379 elif self.is_organiser() and self.attendee_is_new(attendee_uri): 380 self.control("partstat-refresh", "submit", "refresh", id="partstat-%d" % i, class_="refresh") 381 page.label(dict(self.partstat_items).get(partstat, ""), for_="partstat-%s" % i, class_="partstat") 382 383 # Otherwise, just show a label with the participation status. 384 385 else: 386 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 387 388 # Permit organisers to remove attendees. 389 # NOTE: Permit the removal of suggested attendees for counter-proposals. 390 391 if self.can_change_object() and (self.can_remove_attendee(attendee_uri) or self.is_organiser()): 392 393 # Permit the removal of newly-added attendees. 394 395 remove_type = self.can_remove_attendee(attendee_uri) and "submit" or "checkbox" 396 self.control("remove", remove_type, str(i), str(i) in args.get("remove", []), id="remove-%d" % i, class_="remove") 397 398 page.label("Remove", for_="remove-%d" % i, class_="remove") 399 page.label(for_="remove-%d" % i, class_="removed") 400 page.add("(Uninvited)") 401 page.span("Re-invite", class_="action") 402 page.label.close() 403 404 page.td.close() 405 406 def show_recurrences(self, errors=None): 407 408 """ 409 Show recurrences for the current object. If 'errors' is given, show a 410 suitable message for the different errors provided. 411 """ 412 413 page = self.page 414 415 # Obtain any parent object if this object is a specific recurrence. 416 417 if self.recurrenceid: 418 parent = self.get_stored_object(self.uid, None) 419 if not parent: 420 return 421 422 page.p() 423 page.a("This event modifies a recurring event.", href=self.link_to(self.uid)) 424 page.p.close() 425 426 # Obtain the periods associated with the event. 427 # NOTE: Add a control to add recurrences here. 428 429 recurrences = self.get_current_recurrences() 430 431 if len(recurrences) < 1: 432 return 433 434 recurrenceids = self._get_recurrences(self.uid) 435 436 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 437 438 # Show each recurrence in a separate table if editable. 439 # NOTE: Allow recurrence editing for counter-proposals. 440 441 if self.can_change_object() and recurrences: 442 443 for index, period in enumerate(recurrences): 444 self.show_recurrence(index, period, self.recurrenceid, recurrenceids, errors) 445 446 # Otherwise, use a compact single table. 447 448 else: 449 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 450 page.caption("Occurrences") 451 page.thead() 452 page.tr() 453 page.th("Start", class_="objectheading start") 454 page.th("End", class_="objectheading end") 455 page.tr.close() 456 page.thead.close() 457 page.tbody() 458 459 for index, period in enumerate(recurrences): 460 page.tr() 461 self.show_recurrence_label(period, self.recurrenceid, recurrenceids, True) 462 self.show_recurrence_label(period, self.recurrenceid, recurrenceids, False) 463 page.tr.close() 464 465 page.tbody.close() 466 page.table.close() 467 468 def show_recurrence(self, index, period, recurrenceid, recurrenceids, errors=None): 469 470 """ 471 Show recurrence controls for a recurrence provided by the current object 472 with the given 'index' position in the list of periods, the given 473 'period' details, where a 'recurrenceid' indicates any specific 474 recurrence, and where 'recurrenceids' indicates all known additional 475 recurrences for the object. 476 477 If 'errors' is given, show a suitable message for the different errors 478 provided. 479 """ 480 481 page = self.page 482 args = self.env.get_args() 483 484 p = event_period_from_period(period) 485 replaced = not recurrenceid and p.is_replaced(recurrenceids) 486 487 # Isolate the controls from neighbouring tables. 488 489 page.div() 490 491 self.show_object_datetime_controls(period, index) 492 493 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 494 page.caption(period.origin == "RRULE" and "Occurrence from rule" or "Occurrence") 495 page.tbody() 496 497 page.tr() 498 error = errors and ("dtstart", index) in errors and " error" or "" 499 page.th("Start", class_="objectheading start%s" % error) 500 self.show_recurrence_controls(index, period, recurrenceid, recurrenceids, True) 501 page.tr.close() 502 page.tr() 503 error = errors and ("dtend", index) in errors and " error" or "" 504 page.th("End", class_="objectheading end%s" % error) 505 self.show_recurrence_controls(index, period, recurrenceid, recurrenceids, False) 506 page.tr.close() 507 508 # Permit the removal of recurrences. 509 510 if not replaced: 511 page.tr() 512 page.th("") 513 page.td() 514 515 remove_type = self.can_remove_recurrence(period) and "submit" or "checkbox" 516 517 self.control("recur-remove", remove_type, str(index), 518 str(index) in args.get("recur-remove", []), 519 id="recur-remove-%d" % index, class_="remove") 520 521 page.label("Remove", for_="recur-remove-%d" % index, class_="remove") 522 page.label(for_="recur-remove-%d" % index, class_="removed") 523 page.add("(Removed)") 524 page.span("Re-add", class_="action") 525 page.label.close() 526 527 page.td.close() 528 page.tr.close() 529 530 page.tbody.close() 531 page.table.close() 532 533 page.div.close() 534 535 def show_counters(self): 536 537 "Show any counter-proposals for the current object." 538 539 page = self.page 540 query = self.env.get_query() 541 counter = query.get("counter", [None])[0] 542 543 attendees = self._get_counters(self.uid, self.recurrenceid) 544 tzid = self.get_tzid() 545 546 if not attendees: 547 return 548 549 attendees = self.get_verbose_attendees(attendees) 550 current_attendees = [uri for (name, uri) in uri_parts(self.get_current_attendees())] 551 552 # Get suggestions. Attendees are aggregated and reference the existing 553 # attendees suggesting them. Periods are referenced by each existing 554 # attendee. 555 556 suggested_attendees = {} 557 suggested_periods = {} 558 559 for i, attendee in enumerate(attendees): 560 attendee_uri = get_uri(attendee) 561 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee_uri) 562 563 # Get suggested attendees. 564 565 for suggested_uri, suggested_attr in uri_dict(obj.get_value_map("ATTENDEE")).items(): 566 if suggested_uri == attendee_uri or suggested_uri in current_attendees: 567 continue 568 suggested = get_verbose_address(suggested_uri, suggested_attr) 569 570 if not suggested_attendees.has_key(suggested): 571 suggested_attendees[suggested] = [] 572 suggested_attendees[suggested].append(attendee) 573 574 # Get suggested periods. 575 576 periods = self.get_periods(obj) 577 if set(periods).difference(self.get_periods(self.obj)): 578 suggested_periods[attendee] = periods 579 580 # Present the suggested attendees. 581 582 if suggested_attendees: 583 page.p("The following attendees have been suggested for this event:") 584 585 page.table(cellspacing=5, cellpadding=5, class_="counters") 586 page.thead() 587 page.tr() 588 page.th("Attendee") 589 page.th("Suggested by...") 590 page.tr.close() 591 page.thead.close() 592 page.tbody() 593 594 suggested_attendees = list(suggested_attendees.items()) 595 suggested_attendees.sort() 596 597 for i, (suggested, attendees) in enumerate(suggested_attendees): 598 page.tr() 599 page.td(suggested) 600 page.td(", ".join(attendees)) 601 page.td() 602 self.control("suggested-attendee", "hidden", suggested) 603 self.control("add-suggested-attendee-%d" % i, "submit", "Add") 604 page.td.close() 605 page.tr.close() 606 607 page.tbody.close() 608 page.table.close() 609 610 # Present the suggested periods. 611 612 if suggested_periods: 613 page.p("The following periods have been suggested for this event:") 614 615 page.table(cellspacing=5, cellpadding=5, class_="counters") 616 page.thead() 617 page.tr() 618 page.th("Periods", colspan=2) 619 page.th("Suggested by...", rowspan=2) 620 page.tr.close() 621 page.tr() 622 page.th("Start") 623 page.th("End") 624 page.tr.close() 625 page.thead.close() 626 page.tbody() 627 628 suggested_periods = list(suggested_periods.items()) 629 suggested_periods.sort() 630 631 for attendee, periods in suggested_periods: 632 first = True 633 for p in periods: 634 identifier = "%s-%s" % (format_datetime(p.get_start_point()), format_datetime(p.get_end_point())) 635 css = identifier == counter and "selected" or "" 636 637 page.tr(class_=css) 638 639 start = self.format_datetime(to_timezone(p.get_start(), tzid), "long") 640 end = self.format_datetime(to_timezone(p.get_end(), tzid), "long") 641 642 # Show each period. 643 644 page.td(start) 645 page.td(end) 646 647 # Show attendees and controls alongside the first period in each 648 # attendee's collection. 649 650 if first: 651 page.td(attendee, rowspan=len(periods)) 652 page.td(rowspan=len(periods)) 653 self.control("accept-%d" % i, "submit", "Accept") 654 self.control("decline-%d" % i, "submit", "Decline") 655 self.control("counter", "hidden", attendee) 656 page.td.close() 657 658 page.tr.close() 659 first = False 660 661 page.tbody.close() 662 page.table.close() 663 664 def show_conflicting_events(self): 665 666 "Show conflicting events for the current object." 667 668 page = self.page 669 recurrenceids = self._get_active_recurrences(self.uid) 670 671 # Obtain the user's timezone. 672 673 tzid = self.get_tzid() 674 periods = self.get_periods(self.obj) 675 676 # Indicate whether there are conflicting events. 677 678 conflicts = [] 679 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 680 681 for name, participant in uri_parts(self.get_current_attendees()): 682 if participant == self.user: 683 freebusy = self.store.get_freebusy(participant) 684 elif participant: 685 freebusy = self.store.get_freebusy_for_other(self.user, participant) 686 else: 687 continue 688 689 if not freebusy: 690 continue 691 692 # Obtain any time zone details from the suggested event. 693 694 _dtstart, attr = self.obj.get_item("DTSTART") 695 tzid = attr.get("TZID", tzid) 696 697 # Show any conflicts with periods of actual attendance. 698 699 participant_attr = attendee_map.get(participant) 700 partstat = participant_attr and participant_attr.get("PARTSTAT") 701 recurrences = self.obj.get_recurrence_start_points(recurrenceids, tzid) 702 703 for p in have_conflict(freebusy, periods, True): 704 if not self.recurrenceid and p.is_replaced(recurrences): 705 continue 706 707 if ( # Unidentified or different event 708 (p.uid != self.uid or self.recurrenceid and p.recurrenceid and p.recurrenceid != self.recurrenceid) and 709 # Different period or unclear participation with the same period 710 (p not in periods or not partstat in ("ACCEPTED", "TENTATIVE")) and 711 # Participant not limited to organising 712 p.transp != "ORG" 713 ): 714 715 conflicts.append(p) 716 717 conflicts.sort() 718 719 # Show any conflicts with periods of actual attendance. 720 721 if conflicts: 722 page.p("This event conflicts with others:") 723 724 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 725 page.thead() 726 page.tr() 727 page.th("Event") 728 page.th("Start") 729 page.th("End") 730 page.tr.close() 731 page.thead.close() 732 page.tbody() 733 734 for p in conflicts: 735 736 # Provide details of any conflicting event. 737 738 start = self.format_datetime(to_timezone(p.get_start(), tzid), "long") 739 end = self.format_datetime(to_timezone(p.get_end(), tzid), "long") 740 741 page.tr() 742 743 # Show the event summary for the conflicting event. 744 745 page.td() 746 if p.summary: 747 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid)) 748 else: 749 page.add("(Unspecified event)") 750 page.td.close() 751 752 page.td(start) 753 page.td(end) 754 755 page.tr.close() 756 757 page.tbody.close() 758 page.table.close() 759 760 class EventPage(EventPageFragment): 761 762 "A request handler for the event page." 763 764 def __init__(self, resource=None, messenger=None): 765 ResourceClientForObject.__init__(self, resource, messenger or Messenger()) 766 767 # Request logic methods. 768 769 def is_initial_load(self): 770 771 "Return whether the event is being loaded and shown for the first time." 772 773 return not self.env.get_args().has_key("editing") 774 775 def handle_request(self): 776 777 """ 778 Handle actions involving the current object, returning an error if one 779 occurred, or None if the request was successfully handled. 780 """ 781 782 # Handle a submitted form. 783 784 args = self.env.get_args() 785 786 # Get the possible actions. 787 788 reply = args.has_key("reply") 789 discard = args.has_key("discard") 790 create = args.has_key("create") 791 cancel = args.has_key("cancel") 792 ignore = args.has_key("ignore") 793 save = args.has_key("save") 794 uncounter = args.has_key("uncounter") 795 accept = self.prefixed_args("accept-", int) 796 decline = self.prefixed_args("decline-", int) 797 798 have_action = reply or discard or create or cancel or ignore or save or accept or decline or uncounter 799 800 if not have_action: 801 return ["action"] 802 803 # If ignoring the object, return to the calendar. 804 805 if ignore: 806 self.redirect(self.env.get_path()) 807 return None 808 809 # Update the object. 810 811 single_user = False 812 changed = False 813 814 if reply or create or cancel or save: 815 816 # Update principal event details if organiser. 817 # NOTE: Handle edited details for counter-proposals. 818 819 if self.can_change_object(): 820 821 # Update time periods (main and recurring). 822 823 try: 824 period = self.handle_main_period() 825 except PeriodError, exc: 826 return exc.args 827 828 try: 829 periods = self.handle_recurrence_periods() 830 except PeriodError, exc: 831 return exc.args 832 833 # Set the periods in the object, first obtaining removed and 834 # modified period information. 835 836 to_unschedule, to_exclude = self.get_removed_periods(periods) 837 838 changed = self.obj.set_period(period) or changed 839 changed = self.obj.set_periods(periods) or changed 840 changed = self.obj.update_exceptions(to_exclude) or changed 841 842 # Organiser-only changes... 843 844 if self.is_organiser(): 845 846 # Update summary. 847 848 if args.has_key("summary"): 849 self.obj["SUMMARY"] = [(args["summary"][0], {})] 850 851 # Obtain any new participants and those to be removed. 852 853 if self.can_change_object(): 854 attendees = self.get_attendees_from_page() 855 removed = [attendees[int(i)] for i in args.get("remove", [])] 856 added, to_cancel = self.update_attendees(attendees, removed) 857 single_user = not attendees or attendees == [self.user] 858 changed = added or changed 859 860 # Update attendee participation for the current user. 861 862 if args.has_key("partstat"): 863 self.update_participation(args["partstat"][0]) 864 865 # Process any action. 866 867 invite = not save and create and not single_user 868 save = save or create and single_user 869 870 handled = True 871 872 if reply or invite or cancel: 873 874 # Process the object and remove it from the list of requests. 875 876 if reply and self.process_received_request(changed): 877 self.remove_request() 878 879 elif self.is_organiser() and (invite or cancel): 880 881 # Invitation, uninvitation and unscheduling... 882 883 if self.process_created_request( 884 invite and "REQUEST" or "CANCEL", to_cancel, to_unschedule): 885 886 self.remove_request() 887 888 # Save single user events. 889 890 elif save: 891 self.store.set_event(self.user, self.uid, self.recurrenceid, node=self.obj.to_node()) 892 self.update_event_in_freebusy() 893 self.remove_request() 894 895 # Remove the request and the object. 896 897 elif discard: 898 self.remove_event_from_freebusy() 899 self.remove_event() 900 self.remove_request() 901 902 # Update counter-proposal records synchronously instead of assuming 903 # that the outgoing handler will have done so before the form is 904 # refreshed. 905 906 # Accept a counter-proposal and decline all others, sending a new 907 # request to all attendees. 908 909 elif accept: 910 911 # Take the first accepted proposal, although there should be only 912 # one anyway. 913 914 for i in accept: 915 attendee_uri = get_uri(args.get("counter", [])[i]) 916 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee_uri) 917 self.obj.set_periods(self.get_periods(obj)) 918 break 919 920 # Remove counter-proposals and issue a new invitation. 921 922 attendees = uri_values(args.get("counter", [])) 923 self.remove_counters(attendees) 924 self.process_created_request("REQUEST") 925 926 # Decline a counter-proposal individually. 927 928 elif decline: 929 for i in decline: 930 attendee_uri = get_uri(args.get("counter", [])[i]) 931 self.process_declined_counter(attendee_uri) 932 self.remove_counter(attendee_uri) 933 934 # Redirect to the event. 935 936 self.redirect(self.env.get_url()) 937 handled = False 938 939 # Remove counter-proposals without acknowledging them. 940 941 elif uncounter: 942 self.store.remove_counters(self.user, self.uid, self.recurrenceid) 943 self.remove_request() 944 945 # Redirect to the event. 946 947 self.redirect(self.env.get_url()) 948 handled = False 949 950 else: 951 handled = False 952 953 # Upon handling an action, redirect to the main page. 954 955 if handled: 956 self.redirect(self.env.get_path()) 957 958 return None 959 960 def handle_main_period(self): 961 962 "Return period details for the main start/end period in an event." 963 964 return self.get_main_period_from_page().as_event_period() 965 966 def handle_recurrence_periods(self): 967 968 "Return period details for the recurrences specified for an event." 969 970 return [p.as_event_period(i) for i, p in enumerate(self.get_recurrences_from_page())] 971 972 # Access to form-originating object information. 973 974 def get_main_period_from_page(self): 975 976 "Return the main period defined in the event form." 977 978 args = self.env.get_args() 979 980 dtend_enabled = args.get("dtend-control", [None])[0] 981 dttimes_enabled = args.get("dttimes-control", [None])[0] 982 start = self.get_date_control_values("dtstart") 983 end = self.get_date_control_values("dtend") 984 985 period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid(), "DTSTART") 986 987 # Handle absent main period details. 988 989 if not period.get_start(): 990 return self.get_stored_main_period() 991 else: 992 return period 993 994 def get_recurrences_from_page(self): 995 996 "Return the recurrences defined in the event form." 997 998 args = self.env.get_args() 999 1000 all_dtend_enabled = args.get("dtend-control-recur", []) 1001 all_dttimes_enabled = args.get("dttimes-control-recur", []) 1002 all_starts = self.get_date_control_values("dtstart-recur", multiple=True) 1003 all_ends = self.get_date_control_values("dtend-recur", multiple=True, tzid_name="dtstart-recur") 1004 all_origins = args.get("recur-origin", []) 1005 1006 periods = [] 1007 1008 for index, (start, end, dtend_enabled, dttimes_enabled, origin) in \ 1009 enumerate(map(None, all_starts, all_ends, all_dtend_enabled, all_dttimes_enabled, all_origins)): 1010 1011 dtend_enabled = str(index) in all_dtend_enabled 1012 dttimes_enabled = str(index) in all_dttimes_enabled 1013 period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid(), origin) 1014 periods.append(period) 1015 1016 return periods 1017 1018 def set_recurrences_in_page(self, recurrences): 1019 1020 "Set the recurrences defined in the event form." 1021 1022 args = self.env.get_args() 1023 1024 args["dtend-control-recur"] = [] 1025 args["dttimes-control-recur"] = [] 1026 args["recur-origin"] = [] 1027 1028 all_starts = [] 1029 all_ends = [] 1030 1031 for index, period in enumerate(recurrences): 1032 if period.end_enabled: 1033 args["dtend-control-recur"].append(str(index)) 1034 if period.times_enabled: 1035 args["dttimes-control-recur"].append(str(index)) 1036 args["recur-origin"].append(period.origin or "") 1037 1038 all_starts.append(period.get_form_start()) 1039 all_ends.append(period.get_form_end()) 1040 1041 self.set_date_control_values("dtstart-recur", all_starts) 1042 self.set_date_control_values("dtend-recur", all_ends, tzid_name="dtstart-recur") 1043 1044 def get_removed_periods(self, periods): 1045 1046 """ 1047 Return those from the recurrence 'periods' to remove upon updating an 1048 event along with those to exclude in a tuple of the form (unscheduled, 1049 excluded). 1050 """ 1051 1052 args = self.env.get_args() 1053 to_unschedule = [] 1054 to_exclude = [] 1055 1056 for i in args.get("recur-remove", []): 1057 try: 1058 period = periods[int(i)] 1059 except (IndexError, ValueError): 1060 continue 1061 1062 if not self.can_edit_recurrence(period): 1063 to_unschedule.append(period) 1064 else: 1065 to_exclude.append(period) 1066 1067 return to_unschedule, to_exclude 1068 1069 def get_attendees_from_page(self): 1070 1071 """ 1072 Return attendees from the request, using any stored attributes to obtain 1073 verbose details. 1074 """ 1075 1076 return self.get_verbose_attendees(self.env.get_args().get("attendee", [])) 1077 1078 def get_verbose_attendees(self, attendees): 1079 1080 """ 1081 Use any stored attributes to obtain verbose details for the given 1082 'attendees'. 1083 """ 1084 1085 attendee_map = self.obj.get_value_map("ATTENDEE") 1086 return [get_verbose_address(value, attendee_map.get(value)) for value in attendees] 1087 1088 def update_attendees_from_page(self): 1089 1090 "Add or remove attendees. This does not affect the stored object." 1091 1092 args = self.env.get_args() 1093 1094 attendees = self.get_attendees_from_page() 1095 1096 if args.has_key("add"): 1097 attendees.append("") 1098 1099 # Add attendees suggested in counter-proposals. 1100 1101 add_suggested = self.prefixed_args("add-suggested-attendee-", int) 1102 1103 if add_suggested: 1104 for i in add_suggested: 1105 try: 1106 suggested = args["suggested-attendee"][i] 1107 except (IndexError, KeyError): 1108 continue 1109 if suggested not in attendees: 1110 attendees.append(suggested) 1111 1112 # Only actually remove attendees if the event is unsent, if the attendee 1113 # is new, or if it is the current user being removed. 1114 1115 if args.has_key("remove"): 1116 still_to_remove = [] 1117 correction = 0 1118 1119 for i in args["remove"]: 1120 try: 1121 i = int(i) - correction 1122 attendee = attendees[i] 1123 except (IndexError, ValueError): 1124 continue 1125 1126 if self.can_remove_attendee(get_uri(attendee)): 1127 del attendees[i] 1128 correction += 1 1129 else: 1130 still_to_remove.append(str(i)) 1131 1132 args["remove"] = still_to_remove 1133 1134 args["attendee"] = attendees 1135 return attendees 1136 1137 def update_recurrences_from_page(self): 1138 1139 "Add or remove recurrences. This does not affect the stored object." 1140 1141 args = self.env.get_args() 1142 1143 recurrences = self.get_recurrences_from_page() 1144 1145 # NOTE: Addition of recurrences to be supported. 1146 1147 # Only actually remove recurrences if the event is unsent, or if the 1148 # recurrence is new, but only for explicit recurrences. 1149 1150 if args.has_key("recur-remove"): 1151 still_to_remove = [] 1152 correction = 0 1153 1154 for i in args["recur-remove"]: 1155 try: 1156 i = int(i) - correction 1157 recurrence = recurrences[i] 1158 except (IndexError, ValueError): 1159 continue 1160 1161 if self.can_remove_recurrence(recurrence): 1162 del recurrences[i] 1163 correction += 1 1164 else: 1165 still_to_remove.append(str(i)) 1166 1167 args["recur-remove"] = still_to_remove 1168 1169 self.set_recurrences_in_page(recurrences) 1170 return recurrences 1171 1172 # Access to current object information. 1173 1174 def get_current_main_period(self): 1175 1176 """ 1177 Return the currently active main period for the current object depending 1178 on whether editing has begun or whether the object has just been loaded. 1179 """ 1180 1181 if self.is_initial_load() or not self.can_change_object(): 1182 return self.get_stored_main_period() 1183 else: 1184 return self.get_main_period_from_page() 1185 1186 def get_current_recurrences(self): 1187 1188 """ 1189 Return recurrences for the current object using the original object 1190 details where no editing is in progress, using form data otherwise. 1191 """ 1192 1193 if self.is_initial_load() or not self.can_change_object(): 1194 return self.get_stored_recurrences() 1195 else: 1196 return self.get_recurrences_from_page() 1197 1198 def update_current_recurrences(self): 1199 1200 "Return an updated collection of recurrences for the current object." 1201 1202 if self.is_initial_load() or not self.can_change_object(): 1203 return self.get_stored_recurrences() 1204 else: 1205 return self.update_recurrences_from_page() 1206 1207 def get_current_attendees(self): 1208 1209 """ 1210 Return attendees for the current object depending on whether the object 1211 has been edited or instead provides such information from its stored 1212 form. 1213 """ 1214 1215 if self.is_initial_load() or not self.can_change_object(): 1216 return self.get_stored_attendees() 1217 else: 1218 return self.get_attendees_from_page() 1219 1220 def update_current_attendees(self): 1221 1222 "Return an updated collection of attendees for the current object." 1223 1224 if self.is_initial_load() or not self.can_change_object(): 1225 return self.get_stored_attendees() 1226 else: 1227 return self.update_attendees_from_page() 1228 1229 # Full page output methods. 1230 1231 def show(self, path_info): 1232 1233 "Show an object request using the given 'path_info' for the current user." 1234 1235 uid, recurrenceid = self.get_identifiers(path_info) 1236 obj = self.get_stored_object(uid, recurrenceid) 1237 self.set_object(obj) 1238 1239 if not obj: 1240 return False 1241 1242 errors = self.handle_request() 1243 1244 if not errors: 1245 return True 1246 1247 self.update_current_attendees() 1248 self.update_current_recurrences() 1249 1250 self.new_page(title="Event") 1251 self.show_object_on_page(errors) 1252 1253 return True 1254 1255 # vim: tabstop=4 expandtab shiftwidth=4