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