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