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