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