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