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