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=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 if self.has_indicated_attendance(): 881 self.remove_request() 882 883 elif self.is_organiser() and (invite or cancel): 884 885 # Invitation, uninvitation and unscheduling... 886 887 if self.process_created_request( 888 invite and "REQUEST" or "CANCEL", to_cancel, to_unschedule): 889 890 self.remove_request() 891 892 # Save single user events. 893 894 elif save: 895 self.store.set_event(self.user, self.uid, self.recurrenceid, node=self.obj.to_node()) 896 self.update_event_in_freebusy() 897 self.remove_request() 898 899 # Remove the request and the object. 900 901 elif discard: 902 self.remove_event_from_freebusy() 903 self.remove_event() 904 self.remove_request() 905 906 # Update counter-proposal records synchronously instead of assuming 907 # that the outgoing handler will have done so before the form is 908 # refreshed. 909 910 # Accept a counter-proposal and decline all others, sending a new 911 # request to all attendees. 912 913 elif accept: 914 915 # Take the first accepted proposal, although there should be only 916 # one anyway. 917 918 for i in accept: 919 attendee_uri = get_uri(args.get("counter", [])[i]) 920 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee_uri) 921 self.obj.set_periods(self.get_periods(obj)) 922 self.obj.set_rule(obj.get_item("RRULE")) 923 self.obj.set_exceptions(obj.get_items("EXDATE")) 924 break 925 926 # Remove counter-proposals and issue a new invitation. 927 928 attendees = uri_values(args.get("counter", [])) 929 self.remove_counters(attendees) 930 self.process_created_request("REQUEST") 931 932 # Decline a counter-proposal individually. 933 934 elif decline: 935 for i in decline: 936 attendee_uri = get_uri(args.get("counter", [])[i]) 937 self.process_declined_counter(attendee_uri) 938 self.remove_counter(attendee_uri) 939 940 # Redirect to the event. 941 942 self.redirect(self.env.get_url()) 943 handled = False 944 945 # Remove counter-proposals without acknowledging them. 946 947 elif uncounter: 948 self.store.remove_counters(self.user, self.uid, self.recurrenceid) 949 self.remove_request() 950 951 # Redirect to the event. 952 953 self.redirect(self.env.get_url()) 954 handled = False 955 956 else: 957 handled = False 958 959 # Upon handling an action, redirect to the main page. 960 961 if handled: 962 self.redirect(self.link_to()) 963 964 return None 965 966 def handle_main_period(self): 967 968 "Return period details for the main start/end period in an event." 969 970 return self.get_main_period_from_page().as_event_period() 971 972 def handle_recurrence_periods(self): 973 974 "Return period details for the recurrences specified for an event." 975 976 return [p.as_event_period(i) for i, p in enumerate(self.get_recurrences_from_page())] 977 978 # Access to form-originating object information. 979 980 def get_main_period_from_page(self): 981 982 "Return the main period defined in the event form." 983 984 args = self.env.get_args() 985 986 dtend_enabled = args.get("dtend-control", [None])[0] 987 dttimes_enabled = args.get("dttimes-control", [None])[0] 988 start = self.get_date_control_values("dtstart") 989 end = self.get_date_control_values("dtend") 990 991 period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid(), "DTSTART") 992 993 # Handle absent main period details. 994 995 if not period.get_start(): 996 return self.get_stored_main_period() 997 else: 998 return period 999 1000 def get_recurrences_from_page(self): 1001 1002 "Return the recurrences defined in the event form." 1003 1004 args = self.env.get_args() 1005 1006 all_dtend_enabled = args.get("dtend-control-recur", []) 1007 all_dttimes_enabled = args.get("dttimes-control-recur", []) 1008 all_starts = self.get_date_control_values("dtstart-recur", multiple=True) 1009 all_ends = self.get_date_control_values("dtend-recur", multiple=True, tzid_name="dtstart-recur") 1010 all_origins = args.get("recur-origin", []) 1011 all_replaced = args.get("recur-replaced", []) 1012 1013 periods = [] 1014 1015 for index, (start, end, origin) in \ 1016 enumerate(map(None, all_starts, all_ends, all_origins)): 1017 1018 dtend_enabled = str(index) in all_dtend_enabled 1019 dttimes_enabled = str(index) in all_dttimes_enabled 1020 replaced = str(index) in all_replaced 1021 1022 period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid(), origin, replaced) 1023 periods.append(period) 1024 1025 return periods 1026 1027 def set_recurrences_in_page(self, recurrences): 1028 1029 "Set the recurrences defined in the event form." 1030 1031 args = self.env.get_args() 1032 1033 args["dtend-control-recur"] = [] 1034 args["dttimes-control-recur"] = [] 1035 args["recur-origin"] = [] 1036 args["recur-replaced"] = [] 1037 1038 all_starts = [] 1039 all_ends = [] 1040 1041 for index, period in enumerate(recurrences): 1042 if period.end_enabled: 1043 args["dtend-control-recur"].append(str(index)) 1044 if period.times_enabled: 1045 args["dttimes-control-recur"].append(str(index)) 1046 if period.replaced: 1047 args["recur-replaced"].append(str(index)) 1048 args["recur-origin"].append(period.origin or "") 1049 1050 all_starts.append(period.get_form_start()) 1051 all_ends.append(period.get_form_end()) 1052 1053 self.set_date_control_values("dtstart-recur", all_starts) 1054 self.set_date_control_values("dtend-recur", all_ends, tzid_name="dtstart-recur") 1055 1056 def get_removed_periods(self, periods): 1057 1058 """ 1059 Return those from the recurrence 'periods' to remove upon updating an 1060 event along with those to exclude in a tuple of the form (unscheduled, 1061 excluded). 1062 """ 1063 1064 args = self.env.get_args() 1065 to_unschedule = [] 1066 to_exclude = [] 1067 1068 for i in args.get("recur-remove", []): 1069 try: 1070 period = periods[int(i)] 1071 except (IndexError, ValueError): 1072 continue 1073 1074 if not self.can_edit_recurrence(period) and self.is_organiser(): 1075 to_unschedule.append(period) 1076 else: 1077 to_exclude.append(period) 1078 1079 return to_unschedule, to_exclude 1080 1081 def get_attendees_from_page(self): 1082 1083 """ 1084 Return attendees from the request, using any stored attributes to obtain 1085 verbose details. 1086 """ 1087 1088 return self.get_verbose_attendees(self.env.get_args().get("attendee", [])) 1089 1090 def get_verbose_attendees(self, attendees): 1091 1092 """ 1093 Use any stored attributes to obtain verbose details for the given 1094 'attendees'. 1095 """ 1096 1097 attendee_map = self.obj.get_value_map("ATTENDEE") 1098 return [get_verbose_address(value, attendee_map.get(value)) for value in attendees] 1099 1100 def update_attendees_from_page(self): 1101 1102 "Add or remove attendees. This does not affect the stored object." 1103 1104 args = self.env.get_args() 1105 1106 attendees = self.get_attendees_from_page() 1107 1108 if args.has_key("add"): 1109 attendees.append("") 1110 1111 # Add attendees suggested in counter-proposals. 1112 1113 add_suggested = self.prefixed_args("add-suggested-attendee-", int) 1114 1115 if add_suggested: 1116 for i in add_suggested: 1117 try: 1118 suggested = args["suggested-attendee"][i] 1119 except (IndexError, KeyError): 1120 continue 1121 if suggested not in attendees: 1122 attendees.append(suggested) 1123 1124 # Only actually remove attendees if the event is unsent, if the attendee 1125 # is new, or if it is the current user being removed. 1126 1127 if args.has_key("remove"): 1128 still_to_remove = [] 1129 correction = 0 1130 1131 for i in args["remove"]: 1132 try: 1133 i = int(i) - correction 1134 attendee = attendees[i] 1135 except (IndexError, ValueError): 1136 continue 1137 1138 if self.can_remove_attendee(get_uri(attendee)): 1139 del attendees[i] 1140 correction += 1 1141 else: 1142 still_to_remove.append(str(i)) 1143 1144 args["remove"] = still_to_remove 1145 1146 args["attendee"] = attendees 1147 return attendees 1148 1149 def update_recurrences_from_page(self): 1150 1151 "Add or remove recurrences. This does not affect the stored object." 1152 1153 args = self.env.get_args() 1154 1155 recurrences = self.get_recurrences_from_page() 1156 1157 if args.has_key("recur-add"): 1158 period = self.get_current_main_period().as_form_period() 1159 period.origin = "RDATE" 1160 recurrences.append(period) 1161 1162 # Only actually remove recurrences if the event is unsent, or if the 1163 # recurrence is new, but only for explicit recurrences. 1164 1165 if args.has_key("recur-remove"): 1166 still_to_remove = [] 1167 correction = 0 1168 1169 for i in args["recur-remove"]: 1170 try: 1171 i = int(i) - correction 1172 recurrence = recurrences[i] 1173 except (IndexError, ValueError): 1174 continue 1175 1176 if self.can_remove_recurrence(recurrence): 1177 del recurrences[i] 1178 correction += 1 1179 else: 1180 still_to_remove.append(str(i)) 1181 1182 args["recur-remove"] = still_to_remove 1183 1184 self.set_recurrences_in_page(recurrences) 1185 return recurrences 1186 1187 # Access to current object information. 1188 1189 def get_current_main_period(self): 1190 1191 """ 1192 Return the currently active main period for the current object depending 1193 on whether editing has begun or whether the object has just been loaded. 1194 """ 1195 1196 if self.is_initial_load(): 1197 return self.get_stored_main_period() 1198 else: 1199 return self.get_main_period_from_page() 1200 1201 def get_current_recurrences(self): 1202 1203 """ 1204 Return recurrences for the current object using the original object 1205 details where no editing is in progress, using form data otherwise. 1206 """ 1207 1208 if self.is_initial_load(): 1209 return self.get_stored_recurrences() 1210 else: 1211 return self.get_recurrences_from_page() 1212 1213 def update_current_recurrences(self): 1214 1215 "Return an updated collection of recurrences for the current object." 1216 1217 if self.is_initial_load(): 1218 return self.get_stored_recurrences() 1219 else: 1220 return self.update_recurrences_from_page() 1221 1222 def get_current_attendees(self): 1223 1224 """ 1225 Return attendees for the current object depending on whether the object 1226 has been edited or instead provides such information from its stored 1227 form. 1228 """ 1229 1230 if self.is_initial_load(): 1231 return self.get_stored_attendees() 1232 else: 1233 return self.get_attendees_from_page() 1234 1235 def update_current_attendees(self): 1236 1237 "Return an updated collection of attendees for the current object." 1238 1239 if self.is_initial_load(): 1240 return self.get_stored_attendees() 1241 else: 1242 return self.update_attendees_from_page() 1243 1244 # Full page output methods. 1245 1246 def show(self, path_info): 1247 1248 "Show an object request using the given 'path_info' for the current user." 1249 1250 uid, recurrenceid = self.get_identifiers(path_info) 1251 obj = self.get_stored_object(uid, recurrenceid) 1252 self.set_object(obj) 1253 1254 if not obj: 1255 return False 1256 1257 errors = self.handle_request() 1258 1259 if not errors: 1260 return True 1261 1262 self.update_current_attendees() 1263 self.update_current_recurrences() 1264 1265 self.new_page(title="Event") 1266 self.show_object_on_page(errors) 1267 1268 return True 1269 1270 # vim: tabstop=4 expandtab shiftwidth=4