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