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 # NOTE: Currently, rules are not updated. 822 823 to_unschedule, to_exclude = self.get_removed_periods(periods) 824 periods = set(periods) 825 826 changed = self.obj.set_period(period) or changed 827 changed = self.obj.set_periods(periods) or changed 828 changed = self.obj.update_exceptions(to_exclude) or changed 829 830 # Assert periods restored after cancellation. 831 832 changed = self.revert_cancellations([p for p in periods if not p.replaced]) or changed 833 834 # Organiser-only changes... 835 836 if self.is_organiser(): 837 838 # Update summary. 839 840 if args.has_key("summary"): 841 self.obj["SUMMARY"] = [(args["summary"][0], {})] 842 843 # Obtain any new participants and those to be removed. 844 845 attendees = self.get_attendees_from_page() 846 removed = [attendees[int(i)] for i in args.get("remove", [])] 847 added, to_cancel = self.update_attendees(attendees, removed) 848 single_user = not attendees or attendees == [self.user] 849 changed = added or changed 850 851 # Update attendee participation for the current user. 852 853 if args.has_key("partstat"): 854 self.update_participation(args["partstat"][0]) 855 856 # Process any action. 857 858 invite = not save and create and not single_user 859 save = save or create and single_user 860 861 handled = True 862 863 if reply or invite or cancel: 864 865 # Process the object and remove it from the list of requests. 866 867 if reply and self.process_received_request(changed): 868 self.remove_request() 869 870 elif self.is_organiser() and (invite or cancel): 871 872 # Invitation, uninvitation and unscheduling... 873 874 if self.process_created_request( 875 invite and "REQUEST" or "CANCEL", to_cancel, to_unschedule): 876 877 self.remove_request() 878 879 # Save single user events. 880 881 elif save: 882 self.store.set_event(self.user, self.uid, self.recurrenceid, node=self.obj.to_node()) 883 self.update_event_in_freebusy() 884 self.remove_request() 885 886 # Remove the request and the object. 887 888 elif discard: 889 self.remove_event_from_freebusy() 890 self.remove_event() 891 self.remove_request() 892 893 # Update counter-proposal records synchronously instead of assuming 894 # that the outgoing handler will have done so before the form is 895 # refreshed. 896 897 # Accept a counter-proposal and decline all others, sending a new 898 # request to all attendees. 899 900 elif accept: 901 902 # Take the first accepted proposal, although there should be only 903 # one anyway. 904 905 for i in accept: 906 attendee_uri = get_uri(args.get("counter", [])[i]) 907 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee_uri) 908 self.obj.set_periods(self.get_periods(obj)) 909 self.obj.set_rule(obj.get_item("RRULE")) 910 self.obj.set_exceptions(obj.get_items("EXDATE")) 911 break 912 913 # Remove counter-proposals and issue a new invitation. 914 915 attendees = uri_values(args.get("counter", [])) 916 self.remove_counters(attendees) 917 self.process_created_request("REQUEST") 918 919 # Decline a counter-proposal individually. 920 921 elif decline: 922 for i in decline: 923 attendee_uri = get_uri(args.get("counter", [])[i]) 924 self.process_declined_counter(attendee_uri) 925 self.remove_counter(attendee_uri) 926 927 # Redirect to the event. 928 929 self.redirect(self.env.get_url()) 930 handled = False 931 932 # Remove counter-proposals without acknowledging them. 933 934 elif uncounter: 935 self.store.remove_counters(self.user, self.uid, self.recurrenceid) 936 self.remove_request() 937 938 # Redirect to the event. 939 940 self.redirect(self.env.get_url()) 941 handled = False 942 943 else: 944 handled = False 945 946 # Upon handling an action, redirect to the main page. 947 948 if handled: 949 self.redirect(self.env.get_path()) 950 951 return None 952 953 def handle_main_period(self): 954 955 "Return period details for the main start/end period in an event." 956 957 return self.get_main_period_from_page().as_event_period() 958 959 def handle_recurrence_periods(self): 960 961 "Return period details for the recurrences specified for an event." 962 963 return [p.as_event_period(i) for i, p in enumerate(self.get_recurrences_from_page())] 964 965 # Access to form-originating object information. 966 967 def get_main_period_from_page(self): 968 969 "Return the main period defined in the event form." 970 971 args = self.env.get_args() 972 973 dtend_enabled = args.get("dtend-control", [None])[0] 974 dttimes_enabled = args.get("dttimes-control", [None])[0] 975 start = self.get_date_control_values("dtstart") 976 end = self.get_date_control_values("dtend") 977 978 period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid(), "DTSTART") 979 980 # Handle absent main period details. 981 982 if not period.get_start(): 983 return self.get_stored_main_period() 984 else: 985 return period 986 987 def get_recurrences_from_page(self): 988 989 "Return the recurrences defined in the event form." 990 991 args = self.env.get_args() 992 993 all_dtend_enabled = args.get("dtend-control-recur", []) 994 all_dttimes_enabled = args.get("dttimes-control-recur", []) 995 all_starts = self.get_date_control_values("dtstart-recur", multiple=True) 996 all_ends = self.get_date_control_values("dtend-recur", multiple=True, tzid_name="dtstart-recur") 997 all_origins = args.get("recur-origin", []) 998 all_replaced = args.get("recur-replaced", []) 999 1000 periods = [] 1001 1002 for index, (start, end, origin) in \ 1003 enumerate(map(None, all_starts, all_ends, all_origins)): 1004 1005 dtend_enabled = str(index) in all_dtend_enabled 1006 dttimes_enabled = str(index) in all_dttimes_enabled 1007 replaced = str(index) in all_replaced 1008 1009 period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid(), origin, replaced) 1010 periods.append(period) 1011 1012 return periods 1013 1014 def set_recurrences_in_page(self, recurrences): 1015 1016 "Set the recurrences defined in the event form." 1017 1018 args = self.env.get_args() 1019 1020 args["dtend-control-recur"] = [] 1021 args["dttimes-control-recur"] = [] 1022 args["recur-origin"] = [] 1023 args["recur-replaced"] = [] 1024 1025 all_starts = [] 1026 all_ends = [] 1027 1028 for index, period in enumerate(recurrences): 1029 if period.end_enabled: 1030 args["dtend-control-recur"].append(str(index)) 1031 if period.times_enabled: 1032 args["dttimes-control-recur"].append(str(index)) 1033 if period.replaced: 1034 args["recur-replaced"].append(str(index)) 1035 args["recur-origin"].append(period.origin or "") 1036 1037 all_starts.append(period.get_form_start()) 1038 all_ends.append(period.get_form_end()) 1039 1040 self.set_date_control_values("dtstart-recur", all_starts) 1041 self.set_date_control_values("dtend-recur", all_ends, tzid_name="dtstart-recur") 1042 1043 def get_removed_periods(self, periods): 1044 1045 """ 1046 Return those from the recurrence 'periods' to remove upon updating an 1047 event along with those to exclude in a tuple of the form (unscheduled, 1048 excluded). 1049 """ 1050 1051 args = self.env.get_args() 1052 to_unschedule = [] 1053 to_exclude = [] 1054 1055 for i in args.get("recur-remove", []): 1056 try: 1057 period = periods[int(i)] 1058 except (IndexError, ValueError): 1059 continue 1060 1061 if not self.can_edit_recurrence(period) and self.is_organiser(): 1062 to_unschedule.append(period) 1063 else: 1064 to_exclude.append(period) 1065 1066 return to_unschedule, to_exclude 1067 1068 def get_attendees_from_page(self): 1069 1070 """ 1071 Return attendees from the request, using any stored attributes to obtain 1072 verbose details. 1073 """ 1074 1075 return self.get_verbose_attendees(self.env.get_args().get("attendee", [])) 1076 1077 def get_verbose_attendees(self, attendees): 1078 1079 """ 1080 Use any stored attributes to obtain verbose details for the given 1081 'attendees'. 1082 """ 1083 1084 attendee_map = self.obj.get_value_map("ATTENDEE") 1085 return [get_verbose_address(value, attendee_map.get(value)) for value in attendees] 1086 1087 def update_attendees_from_page(self): 1088 1089 "Add or remove attendees. This does not affect the stored object." 1090 1091 args = self.env.get_args() 1092 1093 attendees = self.get_attendees_from_page() 1094 1095 if args.has_key("add"): 1096 attendees.append("") 1097 1098 # Add attendees suggested in counter-proposals. 1099 1100 add_suggested = self.prefixed_args("add-suggested-attendee-", int) 1101 1102 if add_suggested: 1103 for i in add_suggested: 1104 try: 1105 suggested = args["suggested-attendee"][i] 1106 except (IndexError, KeyError): 1107 continue 1108 if suggested not in attendees: 1109 attendees.append(suggested) 1110 1111 # Only actually remove attendees if the event is unsent, if the attendee 1112 # is new, or if it is the current user being removed. 1113 1114 if args.has_key("remove"): 1115 still_to_remove = [] 1116 correction = 0 1117 1118 for i in args["remove"]: 1119 try: 1120 i = int(i) - correction 1121 attendee = attendees[i] 1122 except (IndexError, ValueError): 1123 continue 1124 1125 if self.can_remove_attendee(get_uri(attendee)): 1126 del attendees[i] 1127 correction += 1 1128 else: 1129 still_to_remove.append(str(i)) 1130 1131 args["remove"] = still_to_remove 1132 1133 args["attendee"] = attendees 1134 return attendees 1135 1136 def update_recurrences_from_page(self): 1137 1138 "Add or remove recurrences. This does not affect the stored object." 1139 1140 args = self.env.get_args() 1141 1142 recurrences = self.get_recurrences_from_page() 1143 1144 if args.has_key("recur-add"): 1145 period = self.get_current_main_period().as_form_period() 1146 period.origin = "RDATE" 1147 recurrences.append(period) 1148 1149 # Only actually remove recurrences if the event is unsent, or if the 1150 # recurrence is new, but only for explicit recurrences. 1151 1152 if args.has_key("recur-remove"): 1153 still_to_remove = [] 1154 correction = 0 1155 1156 for i in args["recur-remove"]: 1157 try: 1158 i = int(i) - correction 1159 recurrence = recurrences[i] 1160 except (IndexError, ValueError): 1161 continue 1162 1163 if self.can_remove_recurrence(recurrence): 1164 del recurrences[i] 1165 correction += 1 1166 else: 1167 still_to_remove.append(str(i)) 1168 1169 args["recur-remove"] = still_to_remove 1170 1171 self.set_recurrences_in_page(recurrences) 1172 return recurrences 1173 1174 # Access to current object information. 1175 1176 def get_current_main_period(self): 1177 1178 """ 1179 Return the currently active main period for the current object depending 1180 on whether editing has begun or whether the object has just been loaded. 1181 """ 1182 1183 if self.is_initial_load(): 1184 return self.get_stored_main_period() 1185 else: 1186 return self.get_main_period_from_page() 1187 1188 def get_current_recurrences(self): 1189 1190 """ 1191 Return recurrences for the current object using the original object 1192 details where no editing is in progress, using form data otherwise. 1193 """ 1194 1195 if self.is_initial_load(): 1196 return self.get_stored_recurrences() 1197 else: 1198 return self.get_recurrences_from_page() 1199 1200 def update_current_recurrences(self): 1201 1202 "Return an updated collection of recurrences for the current object." 1203 1204 if self.is_initial_load(): 1205 return self.get_stored_recurrences() 1206 else: 1207 return self.update_recurrences_from_page() 1208 1209 def get_current_attendees(self): 1210 1211 """ 1212 Return attendees for the current object depending on whether the object 1213 has been edited or instead provides such information from its stored 1214 form. 1215 """ 1216 1217 if self.is_initial_load(): 1218 return self.get_stored_attendees() 1219 else: 1220 return self.get_attendees_from_page() 1221 1222 def update_current_attendees(self): 1223 1224 "Return an updated collection of attendees for the current object." 1225 1226 if self.is_initial_load(): 1227 return self.get_stored_attendees() 1228 else: 1229 return self.update_attendees_from_page() 1230 1231 # Full page output methods. 1232 1233 def show(self, path_info): 1234 1235 "Show an object request using the given 'path_info' for the current user." 1236 1237 uid, recurrenceid = self.get_identifiers(path_info) 1238 obj = self.get_stored_object(uid, recurrenceid) 1239 self.set_object(obj) 1240 1241 if not obj: 1242 return False 1243 1244 errors = self.handle_request() 1245 1246 if not errors: 1247 return True 1248 1249 self.update_current_attendees() 1250 self.update_current_recurrences() 1251 1252 self.new_page(title="Event") 1253 self.show_object_on_page(errors) 1254 1255 return True 1256 1257 # vim: tabstop=4 expandtab shiftwidth=4