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