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