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