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