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