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