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