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