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