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