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 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=2) 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) 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) 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() 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") 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() 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) 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, "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 # Permit organisers to remove attendees. 403 404 if self.can_remove_attendee(attendee_uri) or self.is_organiser(): 405 406 # Permit the removal of newly-added attendees. 407 408 remove_type = self.can_remove_attendee(attendee_uri) and "submit" or "checkbox" 409 self.control("remove", remove_type, str(i), str(i) in args.get("remove", []), id="remove-%d" % i, class_="remove") 410 411 page.label("Remove", for_="remove-%d" % i, class_="remove") 412 page.label(for_="remove-%d" % i, class_="removed") 413 page.add("(Uninvited)") 414 page.span("Re-invite", class_="action") 415 page.label.close() 416 417 page.td.close() 418 419 def show_recurrences(self, errors=None): 420 421 """ 422 Show recurrences for the current object. If 'errors' is given, show a 423 suitable message for the different errors provided. 424 """ 425 426 page = self.page 427 428 # Obtain any parent object if this object is a specific recurrence. 429 430 if self.recurrenceid: 431 parent = self.get_stored_object(self.uid, None) 432 if not parent: 433 return 434 435 page.p() 436 page.a("This event modifies a recurring event.", href=self.link_to(self.uid)) 437 page.p.close() 438 439 # Obtain the periods associated with the event. 440 441 recurrences = self.get_current_recurrences() 442 443 if len(recurrences) < 1: 444 return 445 446 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 447 448 # Show each recurrence in a separate table. 449 450 for index, period in enumerate(recurrences): 451 self.show_recurrence(index, period, self.recurrenceid, errors) 452 453 def show_recurrence(self, index, period, recurrenceid, errors=None): 454 455 """ 456 Show recurrence controls for a recurrence provided by the current object 457 with the given 'index' position in the list of periods, the given 458 'period' details, where a 'recurrenceid' indicates any specific 459 recurrence. 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 # Isolate the controls from neighbouring tables. 469 470 page.div() 471 472 self.show_object_datetime_controls(period, index) 473 474 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 475 page.caption(period.origin == "RRULE" and "Occurrence from rule" or "Occurrence") 476 page.tbody() 477 478 page.tr() 479 error = errors and ("dtstart", index) in errors and " error" or "" 480 page.th("Start", class_="objectheading start%s" % error) 481 self.show_recurrence_controls(index, period, recurrenceid, True) 482 page.tr.close() 483 page.tr() 484 error = errors and ("dtend", index) in errors and " error" or "" 485 page.th("End", class_="objectheading end%s" % error) 486 self.show_recurrence_controls(index, period, recurrenceid, False) 487 page.tr.close() 488 489 # Permit the removal of recurrences. 490 491 if not period.replaced: 492 page.tr() 493 page.th("") 494 page.td() 495 496 # Attendees can instantly remove recurrences and thus produce a 497 # counter-proposal. Organisers may need to unschedule recurrences 498 # instead. 499 500 remove_type = self.can_remove_recurrence(period) and "submit" or "checkbox" 501 502 self.control("recur-remove", remove_type, str(index), 503 str(index) in args.get("recur-remove", []), 504 id="recur-remove-%d" % index, class_="remove") 505 506 page.label("Remove", for_="recur-remove-%d" % index, class_="remove") 507 page.label(for_="recur-remove-%d" % index, class_="removed") 508 page.add("(Removed)") 509 page.span("Re-add", class_="action") 510 page.label.close() 511 512 page.td.close() 513 page.tr.close() 514 515 page.tbody.close() 516 page.table.close() 517 518 page.div.close() 519 520 def show_counters(self): 521 522 "Show any counter-proposals for the current object." 523 524 page = self.page 525 query = self.env.get_query() 526 counter = query.get("counter", [None])[0] 527 528 attendees = self._get_counters(self.uid, self.recurrenceid) 529 tzid = self.get_tzid() 530 531 if not attendees: 532 return 533 534 attendees = self.get_verbose_attendees(attendees) 535 current_attendees = [uri for (name, uri) in uri_parts(self.get_current_attendees())] 536 current_periods = set(self.get_periods(self.obj)) 537 538 # Get suggestions. Attendees are aggregated and reference the existing 539 # attendees suggesting them. Periods are referenced by each existing 540 # attendee. 541 542 suggested_attendees = {} 543 suggested_periods = {} 544 545 for i, attendee in enumerate(attendees): 546 attendee_uri = get_uri(attendee) 547 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee_uri) 548 549 # Get suggested attendees. 550 551 for suggested_uri, suggested_attr in uri_dict(obj.get_value_map("ATTENDEE")).items(): 552 if suggested_uri == attendee_uri or suggested_uri in current_attendees: 553 continue 554 suggested = get_verbose_address(suggested_uri, suggested_attr) 555 556 if not suggested_attendees.has_key(suggested): 557 suggested_attendees[suggested] = [] 558 suggested_attendees[suggested].append(attendee) 559 560 # Get suggested periods. 561 562 periods = self.get_periods(obj) 563 if current_periods.symmetric_difference(periods): 564 suggested_periods[attendee] = periods 565 566 # Present the suggested attendees. 567 568 if suggested_attendees: 569 page.p("The following attendees have been suggested for this event:") 570 571 page.table(cellspacing=5, cellpadding=5, class_="counters") 572 page.thead() 573 page.tr() 574 page.th("Attendee") 575 page.th("Suggested by...") 576 page.tr.close() 577 page.thead.close() 578 page.tbody() 579 580 suggested_attendees = list(suggested_attendees.items()) 581 suggested_attendees.sort() 582 583 for i, (suggested, attendees) in enumerate(suggested_attendees): 584 page.tr() 585 page.td(suggested) 586 page.td(", ".join(attendees)) 587 page.td() 588 self.control("suggested-attendee", "hidden", suggested) 589 self.control("add-suggested-attendee-%d" % i, "submit", "Add") 590 page.td.close() 591 page.tr.close() 592 593 page.tbody.close() 594 page.table.close() 595 596 # Present the suggested periods. 597 598 if suggested_periods: 599 page.p("The following periods have been suggested for this event:") 600 601 page.table(cellspacing=5, cellpadding=5, class_="counters") 602 page.thead() 603 page.tr() 604 page.th("Periods", colspan=2) 605 page.th("Suggested by...", rowspan=2) 606 page.tr.close() 607 page.tr() 608 page.th("Start") 609 page.th("End") 610 page.tr.close() 611 page.thead.close() 612 page.tbody() 613 614 recurrenceids = self._get_recurrences(self.uid) 615 616 suggested_periods = list(suggested_periods.items()) 617 suggested_periods.sort() 618 619 for attendee, periods in suggested_periods: 620 first = True 621 for p in periods: 622 replaced = not self.recurrenceid and p.is_replaced(recurrenceids) 623 identifier = "%s-%s" % (format_datetime(p.get_start_point()), format_datetime(p.get_end_point())) 624 css = identifier == counter and "selected" or "" 625 626 page.tr(class_=css) 627 628 start = self.format_datetime(to_timezone(p.get_start(), tzid), "long") 629 end = self.format_datetime(to_timezone(p.get_end(), tzid), "long") 630 631 # Show each period. 632 633 css = replaced and "replaced" or "" 634 page.td(start, class_=css) 635 page.td(end, class_=css) 636 637 # Show attendees and controls alongside the first period in each 638 # attendee's collection. 639 640 if first: 641 page.td(attendee, rowspan=len(periods)) 642 page.td(rowspan=len(periods)) 643 self.control("accept-%d" % i, "submit", "Accept") 644 self.control("decline-%d" % i, "submit", "Decline") 645 self.control("counter", "hidden", attendee) 646 page.td.close() 647 648 page.tr.close() 649 first = False 650 651 page.tbody.close() 652 page.table.close() 653 654 def show_conflicting_events(self): 655 656 "Show conflicting events for the current object." 657 658 page = self.page 659 recurrenceids = self._get_active_recurrences(self.uid) 660 661 # Obtain the user's timezone. 662 663 tzid = self.get_tzid() 664 periods = self.get_periods(self.obj) 665 666 # Indicate whether there are conflicting events. 667 668 conflicts = set() 669 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 670 671 for name, participant in uri_parts(self.get_current_attendees()): 672 if participant == self.user: 673 freebusy = self.store.get_freebusy(participant) 674 elif participant: 675 freebusy = self.store.get_freebusy_for_other(self.user, participant) 676 else: 677 continue 678 679 if not freebusy: 680 continue 681 682 # Obtain any time zone details from the suggested event. 683 684 _dtstart, attr = self.obj.get_item("DTSTART") 685 tzid = attr.get("TZID", tzid) 686 687 # Show any conflicts with periods of actual attendance. 688 689 participant_attr = attendee_map.get(participant) 690 partstat = participant_attr and participant_attr.get("PARTSTAT") 691 recurrences = self.obj.get_recurrence_start_points(recurrenceids, tzid) 692 693 for p in have_conflict(freebusy, periods, True): 694 if not self.recurrenceid and p.is_replaced(recurrences): 695 continue 696 697 if ( # Unidentified or different event 698 (p.uid != self.uid or self.recurrenceid and p.recurrenceid and p.recurrenceid != self.recurrenceid) and 699 # Different period or unclear participation with the same period 700 (p not in periods or not partstat in ("ACCEPTED", "TENTATIVE")) and 701 # Participant not limited to organising 702 p.transp != "ORG" 703 ): 704 705 conflicts.add(p) 706 707 conflicts = list(conflicts) 708 conflicts.sort() 709 710 # Show any conflicts with periods of actual attendance. 711 712 if conflicts: 713 page.p("This event conflicts with others:") 714 715 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 716 page.thead() 717 page.tr() 718 page.th("Event") 719 page.th("Start") 720 page.th("End") 721 page.tr.close() 722 page.thead.close() 723 page.tbody() 724 725 for p in conflicts: 726 727 # Provide details of any conflicting event. 728 729 start = self.format_datetime(to_timezone(p.get_start(), tzid), "long") 730 end = self.format_datetime(to_timezone(p.get_end(), tzid), "long") 731 732 page.tr() 733 734 # Show the event summary for the conflicting event. 735 736 page.td() 737 if p.summary: 738 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid)) 739 else: 740 page.add("(Unspecified event)") 741 page.td.close() 742 743 page.td(start) 744 page.td(end) 745 746 page.tr.close() 747 748 page.tbody.close() 749 page.table.close() 750 751 class EventPage(EventPageFragment): 752 753 "A request handler for the event page." 754 755 def __init__(self, resource=None, messenger=None): 756 ResourceClientForObject.__init__(self, resource, messenger or Messenger()) 757 758 def link_to(self, uid=None, recurrenceid=None): 759 args = self.env.get_query() 760 d = {} 761 for name in ("start", "end"): 762 if args.get(name): 763 d[name] = args[name][0] 764 return ResourceClientForObject.link_to(self, uid, recurrenceid, d) 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.link_to()) 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 # NOTE: Currently, rules are not updated. 830 831 to_unschedule, to_exclude = self.get_removed_periods(periods) 832 periods = set(periods) 833 active_periods = [p for p in periods if not p.replaced] 834 835 changed = self.obj.set_period(period) or changed 836 changed = self.obj.set_periods(periods) or changed 837 838 # Add and remove exceptions. 839 840 changed = self.obj.update_exceptions(to_exclude, active_periods) or changed 841 842 # Assert periods restored after cancellation. 843 844 changed = self.revert_cancellations(active_periods) or changed 845 846 # Organiser-only changes... 847 848 if self.is_organiser(): 849 850 # Update summary. 851 852 if args.has_key("summary"): 853 self.obj["SUMMARY"] = [(args["summary"][0], {})] 854 855 # Obtain any new participants and those to be removed. 856 857 attendees = self.get_attendees_from_page() 858 removed = [attendees[int(i)] for i in args.get("remove", [])] 859 added, to_cancel = self.update_attendees(attendees, removed) 860 single_user = not attendees or attendees == [self.user] 861 changed = added or changed 862 863 # Update attendee participation for the current user. 864 865 if args.has_key("partstat"): 866 self.update_participation(args["partstat"][0]) 867 868 # Process any action. 869 870 invite = not save and create and not single_user 871 save = save or create and single_user 872 873 handled = True 874 875 if reply or invite or cancel: 876 877 # Process the object and remove it from the list of requests. 878 879 if reply and self.process_received_request(changed): 880 self.remove_request() 881 882 elif self.is_organiser() and (invite or cancel): 883 884 # Invitation, uninvitation and unscheduling... 885 886 if self.process_created_request( 887 invite and "REQUEST" or "CANCEL", to_cancel, to_unschedule): 888 889 self.remove_request() 890 891 # Save single user events. 892 893 elif save: 894 self.store.set_event(self.user, self.uid, self.recurrenceid, node=self.obj.to_node()) 895 self.update_event_in_freebusy() 896 self.remove_request() 897 898 # Remove the request and the object. 899 900 elif discard: 901 self.remove_event_from_freebusy() 902 self.remove_event() 903 self.remove_request() 904 905 # Update counter-proposal records synchronously instead of assuming 906 # that the outgoing handler will have done so before the form is 907 # refreshed. 908 909 # Accept a counter-proposal and decline all others, sending a new 910 # request to all attendees. 911 912 elif accept: 913 914 # Take the first accepted proposal, although there should be only 915 # one anyway. 916 917 for i in accept: 918 attendee_uri = get_uri(args.get("counter", [])[i]) 919 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee_uri) 920 self.obj.set_periods(self.get_periods(obj)) 921 self.obj.set_rule(obj.get_item("RRULE")) 922 self.obj.set_exceptions(obj.get_items("EXDATE")) 923 break 924 925 # Remove counter-proposals and issue a new invitation. 926 927 attendees = uri_values(args.get("counter", [])) 928 self.remove_counters(attendees) 929 self.process_created_request("REQUEST") 930 931 # Decline a counter-proposal individually. 932 933 elif decline: 934 for i in decline: 935 attendee_uri = get_uri(args.get("counter", [])[i]) 936 self.process_declined_counter(attendee_uri) 937 self.remove_counter(attendee_uri) 938 939 # Redirect to the event. 940 941 self.redirect(self.env.get_url()) 942 handled = False 943 944 # Remove counter-proposals without acknowledging them. 945 946 elif uncounter: 947 self.store.remove_counters(self.user, self.uid, self.recurrenceid) 948 self.remove_request() 949 950 # Redirect to the event. 951 952 self.redirect(self.env.get_url()) 953 handled = False 954 955 else: 956 handled = False 957 958 # Upon handling an action, redirect to the main page. 959 960 if handled: 961 self.redirect(self.link_to()) 962 963 return None 964 965 def handle_main_period(self): 966 967 "Return period details for the main start/end period in an event." 968 969 return self.get_main_period_from_page().as_event_period() 970 971 def handle_recurrence_periods(self): 972 973 "Return period details for the recurrences specified for an event." 974 975 return [p.as_event_period(i) for i, p in enumerate(self.get_recurrences_from_page())] 976 977 # Access to form-originating object information. 978 979 def get_main_period_from_page(self): 980 981 "Return the main period defined in the event form." 982 983 args = self.env.get_args() 984 985 dtend_enabled = args.get("dtend-control", [None])[0] 986 dttimes_enabled = args.get("dttimes-control", [None])[0] 987 start = self.get_date_control_values("dtstart") 988 end = self.get_date_control_values("dtend") 989 990 period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid(), "DTSTART") 991 992 # Handle absent main period details. 993 994 if not period.get_start(): 995 return self.get_stored_main_period() 996 else: 997 return period 998 999 def get_recurrences_from_page(self): 1000 1001 "Return the recurrences defined in the event form." 1002 1003 args = self.env.get_args() 1004 1005 all_dtend_enabled = args.get("dtend-control-recur", []) 1006 all_dttimes_enabled = args.get("dttimes-control-recur", []) 1007 all_starts = self.get_date_control_values("dtstart-recur", multiple=True) 1008 all_ends = self.get_date_control_values("dtend-recur", multiple=True, tzid_name="dtstart-recur") 1009 all_origins = args.get("recur-origin", []) 1010 all_replaced = args.get("recur-replaced", []) 1011 1012 periods = [] 1013 1014 for index, (start, end, origin) in \ 1015 enumerate(map(None, all_starts, all_ends, all_origins)): 1016 1017 dtend_enabled = str(index) in all_dtend_enabled 1018 dttimes_enabled = str(index) in all_dttimes_enabled 1019 replaced = str(index) in all_replaced 1020 1021 period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid(), origin, replaced) 1022 periods.append(period) 1023 1024 return periods 1025 1026 def set_recurrences_in_page(self, recurrences): 1027 1028 "Set the recurrences defined in the event form." 1029 1030 args = self.env.get_args() 1031 1032 args["dtend-control-recur"] = [] 1033 args["dttimes-control-recur"] = [] 1034 args["recur-origin"] = [] 1035 args["recur-replaced"] = [] 1036 1037 all_starts = [] 1038 all_ends = [] 1039 1040 for index, period in enumerate(recurrences): 1041 if period.end_enabled: 1042 args["dtend-control-recur"].append(str(index)) 1043 if period.times_enabled: 1044 args["dttimes-control-recur"].append(str(index)) 1045 if period.replaced: 1046 args["recur-replaced"].append(str(index)) 1047 args["recur-origin"].append(period.origin or "") 1048 1049 all_starts.append(period.get_form_start()) 1050 all_ends.append(period.get_form_end()) 1051 1052 self.set_date_control_values("dtstart-recur", all_starts) 1053 self.set_date_control_values("dtend-recur", all_ends, tzid_name="dtstart-recur") 1054 1055 def get_removed_periods(self, periods): 1056 1057 """ 1058 Return those from the recurrence 'periods' to remove upon updating an 1059 event along with those to exclude in a tuple of the form (unscheduled, 1060 excluded). 1061 """ 1062 1063 args = self.env.get_args() 1064 to_unschedule = [] 1065 to_exclude = [] 1066 1067 for i in args.get("recur-remove", []): 1068 try: 1069 period = periods[int(i)] 1070 except (IndexError, ValueError): 1071 continue 1072 1073 if not self.can_edit_recurrence(period) and self.is_organiser(): 1074 to_unschedule.append(period) 1075 else: 1076 to_exclude.append(period) 1077 1078 return to_unschedule, to_exclude 1079 1080 def get_attendees_from_page(self): 1081 1082 """ 1083 Return attendees from the request, using any stored attributes to obtain 1084 verbose details. 1085 """ 1086 1087 return self.get_verbose_attendees(self.env.get_args().get("attendee", [])) 1088 1089 def get_verbose_attendees(self, attendees): 1090 1091 """ 1092 Use any stored attributes to obtain verbose details for the given 1093 'attendees'. 1094 """ 1095 1096 attendee_map = self.obj.get_value_map("ATTENDEE") 1097 return [get_verbose_address(value, attendee_map.get(value)) for value in attendees] 1098 1099 def update_attendees_from_page(self): 1100 1101 "Add or remove attendees. This does not affect the stored object." 1102 1103 args = self.env.get_args() 1104 1105 attendees = self.get_attendees_from_page() 1106 1107 if args.has_key("add"): 1108 attendees.append("") 1109 1110 # Add attendees suggested in counter-proposals. 1111 1112 add_suggested = self.prefixed_args("add-suggested-attendee-", int) 1113 1114 if add_suggested: 1115 for i in add_suggested: 1116 try: 1117 suggested = args["suggested-attendee"][i] 1118 except (IndexError, KeyError): 1119 continue 1120 if suggested not in attendees: 1121 attendees.append(suggested) 1122 1123 # Only actually remove attendees if the event is unsent, if the attendee 1124 # is new, or if it is the current user being removed. 1125 1126 if args.has_key("remove"): 1127 still_to_remove = [] 1128 correction = 0 1129 1130 for i in args["remove"]: 1131 try: 1132 i = int(i) - correction 1133 attendee = attendees[i] 1134 except (IndexError, ValueError): 1135 continue 1136 1137 if self.can_remove_attendee(get_uri(attendee)): 1138 del attendees[i] 1139 correction += 1 1140 else: 1141 still_to_remove.append(str(i)) 1142 1143 args["remove"] = still_to_remove 1144 1145 args["attendee"] = attendees 1146 return attendees 1147 1148 def update_recurrences_from_page(self): 1149 1150 "Add or remove recurrences. This does not affect the stored object." 1151 1152 args = self.env.get_args() 1153 1154 recurrences = self.get_recurrences_from_page() 1155 1156 if args.has_key("recur-add"): 1157 period = self.get_current_main_period().as_form_period() 1158 period.origin = "RDATE" 1159 recurrences.append(period) 1160 1161 # Only actually remove recurrences if the event is unsent, or if the 1162 # recurrence is new, but only for explicit recurrences. 1163 1164 if args.has_key("recur-remove"): 1165 still_to_remove = [] 1166 correction = 0 1167 1168 for i in args["recur-remove"]: 1169 try: 1170 i = int(i) - correction 1171 recurrence = recurrences[i] 1172 except (IndexError, ValueError): 1173 continue 1174 1175 if self.can_remove_recurrence(recurrence): 1176 del recurrences[i] 1177 correction += 1 1178 else: 1179 still_to_remove.append(str(i)) 1180 1181 args["recur-remove"] = still_to_remove 1182 1183 self.set_recurrences_in_page(recurrences) 1184 return recurrences 1185 1186 # Access to current object information. 1187 1188 def get_current_main_period(self): 1189 1190 """ 1191 Return the currently active main period for the current object depending 1192 on whether editing has begun or whether the object has just been loaded. 1193 """ 1194 1195 if self.is_initial_load(): 1196 return self.get_stored_main_period() 1197 else: 1198 return self.get_main_period_from_page() 1199 1200 def get_current_recurrences(self): 1201 1202 """ 1203 Return recurrences for the current object using the original object 1204 details where no editing is in progress, using form data otherwise. 1205 """ 1206 1207 if self.is_initial_load(): 1208 return self.get_stored_recurrences() 1209 else: 1210 return self.get_recurrences_from_page() 1211 1212 def update_current_recurrences(self): 1213 1214 "Return an updated collection of recurrences for the current object." 1215 1216 if self.is_initial_load(): 1217 return self.get_stored_recurrences() 1218 else: 1219 return self.update_recurrences_from_page() 1220 1221 def get_current_attendees(self): 1222 1223 """ 1224 Return attendees for the current object depending on whether the object 1225 has been edited or instead provides such information from its stored 1226 form. 1227 """ 1228 1229 if self.is_initial_load(): 1230 return self.get_stored_attendees() 1231 else: 1232 return self.get_attendees_from_page() 1233 1234 def update_current_attendees(self): 1235 1236 "Return an updated collection of attendees for the current object." 1237 1238 if self.is_initial_load(): 1239 return self.get_stored_attendees() 1240 else: 1241 return self.update_attendees_from_page() 1242 1243 # Full page output methods. 1244 1245 def show(self, path_info): 1246 1247 "Show an object request using the given 'path_info' for the current user." 1248 1249 uid, recurrenceid = self.get_identifiers(path_info) 1250 obj = self.get_stored_object(uid, recurrenceid) 1251 self.set_object(obj) 1252 1253 if not obj: 1254 return False 1255 1256 errors = self.handle_request() 1257 1258 if not errors: 1259 return True 1260 1261 self.update_current_attendees() 1262 self.update_current_recurrences() 1263 1264 self.new_page(title="Event") 1265 self.show_object_on_page(errors) 1266 1267 return True 1268 1269 # vim: tabstop=4 expandtab shiftwidth=4