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