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 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 editable_periods, to_change, to_remove = self.classify_periods(periods) 861 862 active_periods = editable_periods + to_change 863 to_unschedule = self.is_organiser() and to_remove or [] 864 to_exclude = not self.is_organiser() and to_remove or [] 865 866 periods = set(periods) 867 changed = self.obj.set_period(period) or changed 868 changed = self.obj.set_periods(periods) or changed 869 870 # Add and remove exceptions. 871 872 changed = self.obj.update_exceptions(to_exclude, active_periods) or changed 873 874 # Assert periods restored after cancellation. 875 876 changed = self.revert_cancellations(active_periods) or changed 877 878 # Organiser-only changes... 879 880 if self.is_organiser(): 881 882 # Update summary. 883 884 if args.has_key("summary"): 885 self.obj["SUMMARY"] = [(args["summary"][0], {})] 886 887 # Obtain any new participants and those to be removed. 888 889 attendees = self.get_current_attendees() 890 removed = self.get_removed_attendees() 891 892 added, to_cancel = self.update_attendees(attendees, removed) 893 single_user = not attendees or uri_values(attendees) == [self.user] 894 changed = added or changed 895 896 # Update attendee participation for the current user. 897 898 if args.has_key("partstat"): 899 self.update_participation(args["partstat"][0]) 900 901 # Process any action. 902 903 invite = not save and create and not single_user 904 save = save or create and single_user 905 906 handled = True 907 908 if reply or invite or cancel: 909 910 # Process the object and remove it from the list of requests. 911 912 if reply and self.process_received_request(changed): 913 if self.has_indicated_attendance(): 914 self.remove_request() 915 916 elif self.is_organiser() and (invite or cancel): 917 918 # Invitation, uninvitation and unscheduling... 919 920 if self.process_created_request( 921 invite and "REQUEST" or "CANCEL", to_cancel, to_unschedule): 922 923 self.remove_request() 924 925 # Save single user events. 926 927 elif save: 928 self.store.set_event(self.user, self.uid, self.recurrenceid, node=self.obj.to_node()) 929 self.update_event_in_freebusy() 930 self.remove_request() 931 932 # Remove the request and the object. 933 934 elif discard: 935 self.remove_event_from_freebusy() 936 self.remove_event() 937 self.remove_request() 938 939 # Update counter-proposal records synchronously instead of assuming 940 # that the outgoing handler will have done so before the form is 941 # refreshed. 942 943 # Accept a counter-proposal and decline all others, sending a new 944 # request to all attendees. 945 946 elif accept: 947 948 # Take the first accepted proposal, although there should be only 949 # one anyway. 950 951 for i in accept: 952 attendee_uri = get_uri(args.get("counter", [])[i]) 953 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee_uri) 954 self.obj.set_periods(self.get_periods(obj)) 955 self.obj.set_rule(obj.get_item("RRULE")) 956 self.obj.set_exceptions(obj.get_items("EXDATE")) 957 break 958 959 # Remove counter-proposals and issue a new invitation. 960 961 attendees = uri_values(args.get("counter", [])) 962 self.remove_counters(attendees) 963 self.process_created_request("REQUEST") 964 965 # Decline a counter-proposal individually. 966 967 elif decline: 968 for i in decline: 969 attendee_uri = get_uri(args.get("counter", [])[i]) 970 self.process_declined_counter(attendee_uri) 971 self.remove_counter(attendee_uri) 972 973 # Redirect to the event. 974 975 self.redirect(self.env.get_url()) 976 handled = False 977 978 # Remove counter-proposals without acknowledging them. 979 980 elif uncounter: 981 self.store.remove_counters(self.user, self.uid, self.recurrenceid) 982 self.remove_request() 983 984 # Redirect to the event. 985 986 self.redirect(self.env.get_url()) 987 handled = False 988 989 else: 990 handled = False 991 992 # Upon handling an action, redirect to the main page. 993 994 if handled: 995 self.redirect(self.link_to()) 996 997 return None 998 999 def handle_main_period(self): 1000 1001 "Return period details for the main start/end period in an event." 1002 1003 return self.get_current_main_period().as_event_period() 1004 1005 def handle_recurrence_periods(self): 1006 1007 "Return period details for the recurrences specified for an event." 1008 1009 periods = [] 1010 for i, p in enumerate(self.get_current_recurrences()): 1011 periods.append(p.as_event_period(i)) 1012 return periods 1013 1014 # Access to form-originating object information. 1015 1016 def get_main_period_from_page(self): 1017 1018 "Return the main period defined in the event form." 1019 1020 period = get_period_control_values(self.env.get_args(), 1021 "dtstart", "dtend", 1022 "dtend-control", "dttimes-control", 1023 origin="DTSTART", 1024 tzid=self.get_tzid()) 1025 1026 # Handle absent main period details. 1027 1028 if not period.get_start(): 1029 return self.get_stored_main_period() 1030 else: 1031 return period 1032 1033 def get_recurrences_from_page(self): 1034 1035 "Return the recurrences defined in the event form." 1036 1037 return get_period_control_values(self.env.get_args(), 1038 "dtstart-recur", "dtend-recur", 1039 "dtend-control-recur", "dttimes-control-recur", 1040 origin_name="recur-origin", replaced_name="recur-replaced", 1041 recurrenceid_name="recur-id", 1042 tzid=self.get_tzid()) 1043 1044 def classify_periods(self, periods): 1045 1046 """ 1047 From the recurrence 'periods' and information provided in the request, 1048 return a tuple containing the new and unchanged periods, the changed 1049 periods, and the periods to be removed. 1050 """ 1051 1052 # Get remaining periods and those whose removal is deferred. 1053 1054 new, changed, unchanged, replaced, to_remove = classify_periods(periods, 1055 self.get_state("recur-remove", list)) 1056 1057 return new + unchanged, changed, to_remove 1058 1059 def get_attendees_from_page(self): 1060 1061 """ 1062 Return attendees from the request, using any stored attributes to obtain 1063 verbose details. 1064 """ 1065 1066 return self.get_verbose_attendees(self.env.get_args().get("attendee", [])) 1067 1068 def get_verbose_attendees(self, attendees): 1069 1070 """ 1071 Use any stored attributes to obtain verbose details for the given 1072 'attendees'. 1073 """ 1074 1075 attendee_map = self.obj.get_value_map("ATTENDEE") 1076 l = [] 1077 for value in attendees: 1078 address = get_verbose_address(value, attendee_map.get(value)) 1079 if address: 1080 l.append(address) 1081 return l 1082 1083 def update_attendees_from_page(self): 1084 1085 "Add or remove attendees. This does not affect the stored object." 1086 1087 args = self.env.get_args() 1088 1089 attendees = self.get_attendees_from_page() 1090 1091 add = args.has_key("add") 1092 1093 if add: 1094 attendees.append("") 1095 1096 # Add attendees suggested in counter-proposals. 1097 1098 add_suggested = self.prefixed_args("add-suggested-attendee-", int) 1099 1100 if add_suggested: 1101 for i in add_suggested: 1102 try: 1103 suggested = args["suggested-attendee"][i] 1104 except (IndexError, KeyError): 1105 continue 1106 if suggested not in attendees: 1107 attendees.append(suggested) 1108 1109 # Only actually remove attendees if the event is unsent, if the attendee 1110 # is new, or if it is the current user being removed. 1111 1112 remove = args.has_key("remove") 1113 1114 if remove: 1115 still_to_remove = remove_from_collection(attendees, 1116 args["remove"], self.can_remove_attendee) 1117 self.set_state("remove", still_to_remove) 1118 1119 if add or add_suggested or remove: 1120 attendees = filter_duplicates(attendees) 1121 1122 return attendees 1123 1124 def update_recurrences_from_page(self): 1125 1126 "Add or remove recurrences. This does not affect the stored object." 1127 1128 args = self.env.get_args() 1129 1130 recurrences = self.get_recurrences_from_page() 1131 1132 add = args.has_key("recur-add") 1133 1134 if add: 1135 period = self.get_current_main_period().as_form_period() 1136 period.origin = "RDATE" 1137 recurrences.append(period) 1138 1139 # Only actually remove recurrences if the event is unsent, or if the 1140 # recurrence is new, but only for explicit recurrences. 1141 1142 remove = args.has_key("recur-remove") 1143 1144 if remove: 1145 still_to_remove = remove_from_collection(recurrences, 1146 args["recur-remove"], self.can_remove_recurrence) 1147 self.set_state("recur-remove", still_to_remove) 1148 1149 return recurrences 1150 1151 # Access to current object information. 1152 1153 def get_state(self, key, fn, overwrite=False): 1154 1155 """ 1156 Return state for the given 'key', using 'fn' if no state exists to 1157 compute and set the state. If 'overwrite' is set to a true value, 1158 compute and return the state using 'fn' regardless of existing state. 1159 """ 1160 1161 state = self.env.get_state() 1162 if overwrite or not state.has_key(key): 1163 state[key] = fn() 1164 return state[key] 1165 1166 def set_state(self, key, value): 1167 1168 """ 1169 Set state for the given 'key', establishing new state or replacing any 1170 existing state with the given 'value'. 1171 """ 1172 1173 self.env.get_state()[key] = value 1174 1175 def get_current_main_period(self): 1176 1177 """ 1178 Return the currently active main period for the current object depending 1179 on whether editing has begun or whether the object has just been loaded. 1180 """ 1181 1182 return self.get_state("main", self.is_initial_load() and 1183 self.get_stored_main_period or self.get_main_period_from_page) 1184 1185 def get_current_recurrences(self): 1186 1187 """ 1188 Return recurrences for the current object using the original object 1189 details where no editing is in progress, using form data otherwise. 1190 """ 1191 1192 return self.get_state("recurrences", self.is_initial_load() and 1193 self.get_stored_recurrences or self.get_recurrences_from_page) 1194 1195 def update_current_recurrences(self): 1196 1197 "Return an updated collection of recurrences for the current object." 1198 1199 return self.get_state("recurrences", self.is_initial_load() and 1200 self.get_stored_recurrences or self.update_recurrences_from_page, 1201 overwrite=True) 1202 1203 def get_current_attendees(self): 1204 1205 """ 1206 Return attendees for the current object depending on whether the object 1207 has been edited or instead provides such information from its stored 1208 form. 1209 """ 1210 1211 return self.get_state("attendees", self.is_initial_load() and 1212 self.get_stored_attendees or self.get_attendees_from_page) 1213 1214 def update_current_attendees(self): 1215 1216 "Return an updated collection of attendees for the current object." 1217 1218 return self.get_state("attendees", self.is_initial_load() and 1219 self.get_stored_attendees or self.update_attendees_from_page, 1220 overwrite=True) 1221 1222 def get_removed_attendees(self): 1223 1224 """ 1225 Return details of attendees to be removed according to previously 1226 determined removal information. 1227 """ 1228 1229 return self.get_state("remove", list) 1230 1231 # Full page output methods. 1232 1233 def show(self, path_info): 1234 1235 "Show an object request using the given 'path_info' for the current user." 1236 1237 uid, recurrenceid = self.get_identifiers(path_info) 1238 obj = self.get_stored_object(uid, recurrenceid) 1239 self.set_object(obj) 1240 1241 if not obj: 1242 return False 1243 1244 errors = self.handle_request() 1245 1246 if not errors: 1247 return True 1248 1249 _ = self.get_translator() 1250 1251 self.new_page(title=_("Event")) 1252 self.show_object_on_page(errors) 1253 1254 return True 1255 1256 # vim: tabstop=4 expandtab shiftwidth=4