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