1 #!/usr/bin/env python 2 3 """ 4 A Web interface to a calendar event. 5 6 Copyright (C) 2014, 2015, 2016, 2017 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from imiptools.data import get_uri, get_verbose_address, uri_dict, uri_items, \ 23 uri_parts, uri_values 24 from imiptools.dates import format_datetime, to_timezone 25 from imiptools.mail import Messenger 26 from imipweb.data import EventPeriod, event_period_from_period, \ 27 filter_duplicates, get_active_periods, \ 28 remove_from_collection, \ 29 get_period_control_values, set_period_control_values, \ 30 PeriodError 31 from imipweb.resource import DateTimeFormUtilities, FormUtilities, ResourceClientForObject 32 33 # Fake gettext method for strings to be translated later. 34 35 _ = lambda s: s 36 37 class EventPageFragment(ResourceClientForObject, DateTimeFormUtilities, FormUtilities): 38 39 "A resource presenting the details of an event." 40 41 def __init__(self, resource=None): 42 ResourceClientForObject.__init__(self, resource) 43 44 # Various property values and labels. 45 46 property_items = [ 47 ("SUMMARY", _("Summary")), 48 ("DTSTART", _("Start")), 49 ("DTEND", _("End")), 50 ("ORGANIZER", _("Organiser")), 51 ("ATTENDEE", _("Attendee")), 52 ] 53 54 partstat_items = [ 55 ("NEEDS-ACTION", _("Not confirmed")), 56 ("ACCEPTED", _("Attending")), 57 ("TENTATIVE", _("Tentatively attending")), 58 ("DECLINED", _("Not attending")), 59 ("DELEGATED", _("Delegated")), 60 (None, _("Not indicated")), 61 ] 62 63 def can_remove_recurrence(self, recurrence): 64 65 """ 66 Return whether the 'recurrence' can be removed from the current object 67 without notification. 68 """ 69 70 return (not self.is_organiser() or 71 self.can_edit_recurrence(recurrence)) and \ 72 recurrence.origin != "RRULE" 73 74 def can_edit_recurrence(self, recurrence): 75 76 "Return whether 'recurrence' can be edited." 77 78 return self.recurrence_is_new(recurrence) or not self.obj.is_shared() 79 80 def recurrence_is_new(self, recurrence): 81 82 "Return whether 'recurrence' is new to the current object." 83 84 return recurrence not in self.get_stored_recurrences() 85 86 def can_remove_attendee(self, attendee): 87 88 """ 89 Return whether 'attendee' can be removed from the current object without 90 notification. 91 """ 92 93 attendee = get_uri(attendee) 94 return self.can_edit_attendee(attendee) or attendee == self.user and self.is_organiser() 95 96 def can_edit_attendee(self, attendee): 97 98 "Return whether 'attendee' can be edited by an organiser." 99 100 return self.attendee_is_new(attendee) or not self.obj.is_shared() 101 102 def attendee_is_new(self, attendee): 103 104 "Return whether 'attendee' is new to the current object." 105 106 return attendee not in uri_values(self.get_stored_attendees()) 107 108 # Access to stored object information. 109 110 def get_stored_attendees(self): 111 return [get_verbose_address(value, attr) for value, attr in self.obj.get_items("ATTENDEE") or []] 112 113 def get_stored_main_period(self): 114 115 "Return the main event period for the current object." 116 117 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.obj.get_main_period_items() 118 return EventPeriod(dtstart, dtend, self.get_tzid(), None, dtstart_attr, dtend_attr) 119 120 def get_stored_recurrences(self): 121 122 "Return recurrences computed using the current object." 123 124 recurrenceids = self._get_recurrences(self.uid) 125 recurrences = [] 126 for period in self.get_periods(self.obj): 127 period = event_period_from_period(period) 128 period.replaced = period.is_replaced(recurrenceids) 129 if period.origin != "DTSTART": 130 recurrences.append(period) 131 return recurrences 132 133 # Access to current object information. 134 135 def get_current_main_period(self): 136 return self.get_stored_main_period() 137 138 def get_current_recurrences(self): 139 return self.get_stored_recurrences() 140 141 def get_current_attendees(self): 142 return self.get_stored_attendees() 143 144 # Page fragment methods. 145 146 def show_request_controls(self): 147 148 "Show form controls for a request." 149 150 _ = self.get_translator() 151 152 page = self.page 153 args = self.env.get_args() 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 args = self.env.get_args() 375 376 attendee_uri = get_uri(attendee) 377 partstat = attendee_attr and attendee_attr.get("PARTSTAT") 378 379 page.td(class_="objectvalue") 380 381 # Show a form control as organiser for new attendees. 382 383 if self.can_edit_attendee(attendee_uri): 384 self.control("attendee", "value", attendee, size="40") 385 else: 386 self.control("attendee", "hidden", attendee) 387 page.add(attendee) 388 page.add(" ") 389 390 # Show participation status, editable for the current user. 391 392 partstat_items = [(key, _(partstat_label)) for (key, partstat_label) in self.partstat_items] 393 394 if attendee_uri == self.user: 395 self.menu("partstat", partstat, partstat_items, class_="partstat") 396 397 # Allow the participation indicator to act as a submit 398 # button in order to refresh the page and show a control for 399 # the current user, if indicated. 400 401 elif self.is_organiser() and self.attendee_is_new(attendee_uri): 402 self.control("partstat-refresh", "submit", "refresh", id="partstat-%d" % i, class_="refresh") 403 page.label(dict(partstat_items).get(partstat, ""), for_="partstat-%s" % i, class_="partstat") 404 405 # Otherwise, just show a label with the participation status. 406 407 else: 408 page.span(dict(partstat_items).get(partstat, ""), class_="partstat") 409 410 page.td.close() 411 page.td() 412 413 # Permit organisers to remove attendees. 414 415 if self.can_remove_attendee(attendee_uri) or self.is_organiser(): 416 417 # Permit the removal of newly-added attendees. 418 419 remove_type = self.can_remove_attendee(attendee_uri) and "submit" or "checkbox" 420 self.control("remove", remove_type, str(i), str(i) in args.get("remove", []), id="remove-%d" % i, class_="remove") 421 422 page.label(_("Remove"), for_="remove-%d" % i, class_="remove") 423 page.label(for_="remove-%d" % i, class_="removed") 424 page.add(_("(Uninvited)")) 425 page.span(_("Re-invite"), class_="action") 426 page.label.close() 427 428 page.td.close() 429 430 def show_recurrences(self, errors=None): 431 432 """ 433 Show recurrences for the current object. If 'errors' is given, show a 434 suitable message for the different errors provided. 435 """ 436 437 _ = self.get_translator() 438 439 page = self.page 440 441 # Obtain any parent object if this object is a specific recurrence. 442 443 if self.recurrenceid: 444 parent = self.get_stored_object(self.uid, None) 445 if not parent: 446 return 447 448 page.p() 449 page.a(_("This event modifies a recurring event."), href=self.link_to(self.uid)) 450 page.p.close() 451 452 # Obtain the periods associated with the event. 453 454 recurrences = self.get_current_recurrences() 455 456 if len(recurrences) < 1: 457 return 458 459 page.p(_("This event occurs on the following occasions within the next %d days:") % self.get_window_size()) 460 461 # Show each recurrence in a separate table. 462 463 for index, period in enumerate(recurrences): 464 self.show_recurrence(index, period, self.recurrenceid, errors) 465 466 def show_recurrence(self, index, period, recurrenceid, errors=None): 467 468 """ 469 Show recurrence controls for a recurrence provided by the current object 470 with the given 'index' position in the list of periods, the given 471 'period' details, where a 'recurrenceid' indicates any specific 472 recurrence. 473 474 If 'errors' is given, show a suitable message for the different errors 475 provided. 476 """ 477 478 _ = self.get_translator() 479 480 page = self.page 481 args = self.env.get_args() 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 str(index) in args.get("recur-remove", []), 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 # Get the possible actions. 805 806 reply = args.has_key("reply") 807 discard = args.has_key("discard") 808 create = args.has_key("create") 809 cancel = args.has_key("cancel") 810 ignore = args.has_key("ignore") 811 save = args.has_key("save") 812 uncounter = args.has_key("uncounter") 813 accept = self.prefixed_args("accept-", int) 814 decline = self.prefixed_args("decline-", int) 815 816 have_action = reply or discard or create or cancel or ignore or save or accept or decline or uncounter 817 818 if not have_action: 819 return ["action"] 820 821 # Check the validation token. 822 823 if not self.check_validation_token(): 824 return ["token"] 825 826 # If ignoring the object, return to the calendar. 827 828 if ignore: 829 self.redirect(self.link_to()) 830 return None 831 832 # Update the object. 833 834 single_user = False 835 changed = False 836 837 if reply or create or cancel or save: 838 839 # Update time periods (main and recurring). 840 841 try: 842 period = self.handle_main_period() 843 except PeriodError, exc: 844 return exc.args 845 846 try: 847 periods = self.handle_recurrence_periods() 848 except PeriodError, exc: 849 return exc.args 850 851 # Set the periods in the object, first obtaining removed and 852 # modified period information. 853 # NOTE: Currently, rules are not updated. 854 855 active_periods, to_unschedule, to_exclude = self.get_removed_periods(periods) 856 857 periods = set(periods) 858 changed = self.obj.set_period(period) or changed 859 changed = self.obj.set_periods(periods) or changed 860 861 # Add and remove exceptions. 862 863 changed = self.obj.update_exceptions(to_exclude, active_periods) or changed 864 865 # Assert periods restored after cancellation. 866 867 changed = self.revert_cancellations(active_periods) or changed 868 869 # Organiser-only changes... 870 871 if self.is_organiser(): 872 873 # Update summary. 874 875 if args.has_key("summary"): 876 self.obj["SUMMARY"] = [(args["summary"][0], {})] 877 878 # Obtain any new participants and those to be removed. 879 880 attendees = self.get_attendees_from_page() 881 removed = [attendees[int(i)] for i in args.get("remove", [])] 882 added, to_cancel = self.update_attendees(attendees, removed) 883 single_user = not attendees or uri_values(attendees) == [self.user] 884 changed = added or changed 885 886 # Update attendee participation for the current user. 887 888 if args.has_key("partstat"): 889 self.update_participation(args["partstat"][0]) 890 891 # Process any action. 892 893 invite = not save and create and not single_user 894 save = save or create and single_user 895 896 handled = True 897 898 if reply or invite or cancel: 899 900 # Process the object and remove it from the list of requests. 901 902 if reply and self.process_received_request(changed): 903 if self.has_indicated_attendance(): 904 self.remove_request() 905 906 elif self.is_organiser() and (invite or cancel): 907 908 # Invitation, uninvitation and unscheduling... 909 910 if self.process_created_request( 911 invite and "REQUEST" or "CANCEL", to_cancel, to_unschedule): 912 913 self.remove_request() 914 915 # Save single user events. 916 917 elif save: 918 self.store.set_event(self.user, self.uid, self.recurrenceid, node=self.obj.to_node()) 919 self.update_event_in_freebusy() 920 self.remove_request() 921 922 # Remove the request and the object. 923 924 elif discard: 925 self.remove_event_from_freebusy() 926 self.remove_event() 927 self.remove_request() 928 929 # Update counter-proposal records synchronously instead of assuming 930 # that the outgoing handler will have done so before the form is 931 # refreshed. 932 933 # Accept a counter-proposal and decline all others, sending a new 934 # request to all attendees. 935 936 elif accept: 937 938 # Take the first accepted proposal, although there should be only 939 # one anyway. 940 941 for i in accept: 942 attendee_uri = get_uri(args.get("counter", [])[i]) 943 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee_uri) 944 self.obj.set_periods(self.get_periods(obj)) 945 self.obj.set_rule(obj.get_item("RRULE")) 946 self.obj.set_exceptions(obj.get_items("EXDATE")) 947 break 948 949 # Remove counter-proposals and issue a new invitation. 950 951 attendees = uri_values(args.get("counter", [])) 952 self.remove_counters(attendees) 953 self.process_created_request("REQUEST") 954 955 # Decline a counter-proposal individually. 956 957 elif decline: 958 for i in decline: 959 attendee_uri = get_uri(args.get("counter", [])[i]) 960 self.process_declined_counter(attendee_uri) 961 self.remove_counter(attendee_uri) 962 963 # Redirect to the event. 964 965 self.redirect(self.env.get_url()) 966 handled = False 967 968 # Remove counter-proposals without acknowledging them. 969 970 elif uncounter: 971 self.store.remove_counters(self.user, self.uid, self.recurrenceid) 972 self.remove_request() 973 974 # Redirect to the event. 975 976 self.redirect(self.env.get_url()) 977 handled = False 978 979 else: 980 handled = False 981 982 # Upon handling an action, redirect to the main page. 983 984 if handled: 985 self.redirect(self.link_to()) 986 987 return None 988 989 def handle_main_period(self): 990 991 "Return period details for the main start/end period in an event." 992 993 return self.get_main_period_from_page().as_event_period() 994 995 def handle_recurrence_periods(self): 996 997 "Return period details for the recurrences specified for an event." 998 999 return [p.as_event_period(i) for i, p in enumerate(self.get_recurrences_from_page())] 1000 1001 # Access to form-originating object information. 1002 1003 def get_main_period_from_page(self): 1004 1005 "Return the main period defined in the event form." 1006 1007 period = get_period_control_values(self.env.get_args(), 1008 "dtstart", "dtend", 1009 "dtend-control", "dttimes-control", 1010 origin="DTSTART", 1011 tzid=self.get_tzid()) 1012 1013 # Handle absent main period details. 1014 1015 if not period.get_start(): 1016 return self.get_stored_main_period() 1017 else: 1018 return period 1019 1020 def get_recurrences_from_page(self): 1021 1022 "Return the recurrences defined in the event form." 1023 1024 return get_period_control_values(self.env.get_args(), 1025 "dtstart-recur", "dtend-recur", 1026 "dtend-control-recur", "dttimes-control-recur", 1027 origin_name="recur-origin", replaced_name="recur-replaced", 1028 tzid=self.get_tzid()) 1029 1030 def set_recurrences_in_page(self, recurrences): 1031 1032 "Set the recurrences defined in the event form." 1033 1034 set_period_control_values(recurrences, self.env.get_args(), 1035 "dtstart-recur", "dtend-recur", 1036 "dtend-control-recur", "dttimes-control-recur", 1037 "recur-origin", "recur-replaced") 1038 1039 def get_removed_periods(self, periods): 1040 1041 """ 1042 From the recurrence 'periods' and information provided in the request, 1043 return the remaining active periods, the periods to unschedule, and the 1044 periods to exclude, in a tuple of the form (active, unscheduled, 1045 excluded). 1046 """ 1047 1048 args = self.env.get_args() 1049 to_unschedule = set() 1050 to_exclude = set() 1051 1052 # Get all periods that are not replaced. 1053 1054 active_periods = get_active_periods(periods) 1055 1056 for i in args.get("recur-remove", []): 1057 try: 1058 period = periods[int(i)] 1059 except (IndexError, ValueError): 1060 continue 1061 1062 active_periods[period] -= 1 1063 1064 if not self.can_edit_recurrence(period) and self.is_organiser(): 1065 l = to_unschedule 1066 else: 1067 l = to_exclude 1068 1069 l.add(period) 1070 1071 # Determine whether some periods are both removed and added. 1072 1073 remaining = [] 1074 for period, n in active_periods.items(): 1075 if n > 0: 1076 remaining.append(period) 1077 1078 to_unschedule.difference_update(remaining) 1079 to_exclude.difference_update(remaining) 1080 1081 return remaining, to_unschedule, to_exclude 1082 1083 def get_attendees_from_page(self): 1084 1085 """ 1086 Return attendees from the request, using any stored attributes to obtain 1087 verbose details. 1088 """ 1089 1090 return self.get_verbose_attendees(self.env.get_args().get("attendee", [])) 1091 1092 def get_verbose_attendees(self, attendees): 1093 1094 """ 1095 Use any stored attributes to obtain verbose details for the given 1096 'attendees'. 1097 """ 1098 1099 attendee_map = self.obj.get_value_map("ATTENDEE") 1100 return [get_verbose_address(value, attendee_map.get(value)) for value in attendees] 1101 1102 def update_attendees_from_page(self): 1103 1104 "Add or remove attendees. This does not affect the stored object." 1105 1106 args = self.env.get_args() 1107 1108 attendees = self.get_attendees_from_page() 1109 1110 add = args.has_key("add") 1111 1112 if add: 1113 attendees.append("") 1114 1115 # Add attendees suggested in counter-proposals. 1116 1117 add_suggested = self.prefixed_args("add-suggested-attendee-", int) 1118 1119 if add_suggested: 1120 for i in add_suggested: 1121 try: 1122 suggested = args["suggested-attendee"][i] 1123 except (IndexError, KeyError): 1124 continue 1125 if suggested not in attendees: 1126 attendees.append(suggested) 1127 1128 # Only actually remove attendees if the event is unsent, if the attendee 1129 # is new, or if it is the current user being removed. 1130 1131 remove = args.has_key("remove") 1132 1133 if remove: 1134 still_to_remove = remove_from_collection(attendees, 1135 args["remove"], self.can_remove_attendee) 1136 args["remove"] = still_to_remove 1137 1138 if add or add_suggested or remove: 1139 attendees = filter_duplicates(attendees) 1140 1141 args["attendee"] = attendees 1142 return attendees 1143 1144 def update_recurrences_from_page(self): 1145 1146 "Add or remove recurrences. This does not affect the stored object." 1147 1148 args = self.env.get_args() 1149 1150 recurrences = self.get_recurrences_from_page() 1151 1152 add = args.has_key("recur-add") 1153 1154 if add: 1155 period = self.get_current_main_period().as_form_period() 1156 period.origin = "RDATE" 1157 recurrences.append(period) 1158 1159 # Only actually remove recurrences if the event is unsent, or if the 1160 # recurrence is new, but only for explicit recurrences. 1161 1162 remove = args.has_key("recur-remove") 1163 1164 if remove: 1165 still_to_remove = remove_from_collection(recurrences, 1166 args["recur-remove"], self.can_remove_recurrence) 1167 args["recur-remove"] = still_to_remove 1168 1169 if add or remove: 1170 recurrences = filter_duplicates(recurrences) 1171 1172 self.set_recurrences_in_page(recurrences) 1173 return recurrences 1174 1175 # Access to current object information. 1176 1177 def get_current_main_period(self): 1178 1179 """ 1180 Return the currently active main period for the current object depending 1181 on whether editing has begun or whether the object has just been loaded. 1182 """ 1183 1184 if self.is_initial_load(): 1185 return self.get_stored_main_period() 1186 else: 1187 return self.get_main_period_from_page() 1188 1189 def get_current_recurrences(self): 1190 1191 """ 1192 Return recurrences for the current object using the original object 1193 details where no editing is in progress, using form data otherwise. 1194 """ 1195 1196 if self.is_initial_load(): 1197 return self.get_stored_recurrences() 1198 else: 1199 return self.get_recurrences_from_page() 1200 1201 def update_current_recurrences(self): 1202 1203 "Return an updated collection of recurrences for the current object." 1204 1205 if self.is_initial_load(): 1206 return self.get_stored_recurrences() 1207 else: 1208 return self.update_recurrences_from_page() 1209 1210 def get_current_attendees(self): 1211 1212 """ 1213 Return attendees for the current object depending on whether the object 1214 has been edited or instead provides such information from its stored 1215 form. 1216 """ 1217 1218 if self.is_initial_load(): 1219 return self.get_stored_attendees() 1220 else: 1221 return self.get_attendees_from_page() 1222 1223 def update_current_attendees(self): 1224 1225 "Return an updated collection of attendees for the current object." 1226 1227 if self.is_initial_load(): 1228 return self.get_stored_attendees() 1229 else: 1230 return self.update_attendees_from_page() 1231 1232 # Full page output methods. 1233 1234 def show(self, path_info): 1235 1236 "Show an object request using the given 'path_info' for the current user." 1237 1238 uid, recurrenceid = self.get_identifiers(path_info) 1239 obj = self.get_stored_object(uid, recurrenceid) 1240 self.set_object(obj) 1241 1242 if not obj: 1243 return False 1244 1245 errors = self.handle_request() 1246 1247 if not errors: 1248 return True 1249 1250 self.update_current_attendees() 1251 self.update_current_recurrences() 1252 1253 _ = self.get_translator() 1254 1255 self.new_page(title=_("Event")) 1256 self.show_object_on_page(errors) 1257 1258 return True 1259 1260 # vim: tabstop=4 expandtab shiftwidth=4