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