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