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