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_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 update_attendees_from_page(self): 380 381 "Add or remove attendees. This does not affect the stored object." 382 383 args = self.env.get_args() 384 385 attendees = self.get_attendees_from_page() 386 existing_attendees = self.get_stored_attendees() 387 388 if args.has_key("add"): 389 attendees.append("") 390 391 # Only actually remove attendees if the event is unsent, if the attendee 392 # is new, or if it is the current user being removed. 393 394 if args.has_key("remove"): 395 for i in args["remove"]: 396 try: 397 attendee = attendees[int(i)] 398 except IndexError: 399 continue 400 401 existing = attendee in existing_attendees 402 403 if not existing or not self.obj.is_shared() or attendee == self.user: 404 attendees.remove(attendee) 405 406 return attendees 407 408 # Page fragment methods. 409 410 def show_request_controls(self): 411 412 "Show form controls for a request." 413 414 page = self.page 415 args = self.env.get_args() 416 417 attendees = self.get_current_attendees() 418 is_attendee = self.user in attendees 419 is_request = self._have_request(self.uid, self.recurrenceid) 420 421 # Show appropriate options depending on the role of the user. 422 423 if is_attendee and not self.is_organiser(): 424 page.p("An action is required for this request:") 425 426 page.p() 427 self._control("reply", "submit", "Send reply") 428 page.add(" ") 429 self._control("discard", "submit", "Discard event") 430 page.add(" ") 431 self._control("ignore", "submit", "Do nothing for now") 432 page.p.close() 433 434 if self.is_organiser(): 435 page.p("As organiser, you can perform the following:") 436 437 page.p() 438 self._control("create", "submit", not self.obj.is_shared() and "Create event" or "Update event") 439 page.add(" ") 440 441 if self.obj.is_shared() and not is_request: 442 self._control("cancel", "submit", "Cancel event") 443 else: 444 self._control("discard", "submit", "Discard event") 445 446 page.add(" ") 447 self._control("save", "submit", "Save without sending") 448 page.p.close() 449 450 def show_object_on_page(self, errors=None): 451 452 """ 453 Show the calendar object on the current page. If 'errors' is given, show 454 a suitable message for the different errors provided. 455 """ 456 457 page = self.page 458 page.form(method="POST") 459 460 # Add a hidden control to help determine whether editing has already begun. 461 462 self._control("editing", "hidden", "true") 463 464 args = self.env.get_args() 465 466 # Obtain basic event information, generating any necessary editing controls. 467 468 if self.is_initial_load() or not self.is_organiser(): 469 attendees = self.get_stored_attendees() 470 else: 471 attendees = self.update_attendees_from_page() 472 473 p = self.get_current_main_period() 474 self.show_object_datetime_controls(p) 475 476 # Obtain any separate recurrences for this event. 477 478 recurrenceids = self._get_active_recurrences(self.uid) 479 replaced = not self.recurrenceid and p.is_replaced(recurrenceids) 480 481 # Provide a summary of the object. 482 483 page.table(class_="object", cellspacing=5, cellpadding=5) 484 page.thead() 485 page.tr() 486 page.th("Event", class_="mainheading", colspan=2) 487 page.tr.close() 488 page.thead.close() 489 page.tbody() 490 491 for name, label in self.property_items: 492 field = name.lower() 493 494 items = self.obj.get_items(name) or [] 495 rowspan = len(items) 496 497 if name == "ATTENDEE": 498 rowspan = len(attendees) + 1 # for the add button 499 elif not items: 500 continue 501 502 page.tr() 503 page.th(label, class_="objectheading %s%s" % (field, errors and field in errors and " error" or ""), rowspan=rowspan) 504 505 # Handle datetimes specially. 506 507 if name in ["DTSTART", "DTEND"]: 508 if not replaced: 509 510 # Obtain the datetime. 511 512 is_start = name == "DTSTART" 513 514 # Where no end datetime exists, use the start datetime as the 515 # basis of any potential datetime specified if dt-control is 516 # set. 517 518 self.show_datetime_controls(is_start and p.get_form_start() or p.get_form_end(), is_start) 519 520 elif name == "DTSTART": 521 page.td(class_="objectvalue %s replaced" % field, rowspan=2) 522 page.a("First occurrence replaced by a separate event", href=self.link_to(self.uid, replaced)) 523 page.td.close() 524 525 page.tr.close() 526 527 # Handle the summary specially. 528 529 elif name == "SUMMARY": 530 value = args.get("summary", [self.obj.get_value(name)])[0] 531 532 page.td(class_="objectvalue summary") 533 if self.is_organiser(): 534 self._control("summary", "text", value, size=80) 535 else: 536 page.add(value) 537 page.td.close() 538 page.tr.close() 539 540 # Handle attendees specially. 541 542 elif name == "ATTENDEE": 543 attendee_map = dict(items) 544 first = True 545 546 for i, value in enumerate(attendees): 547 if not first: 548 page.tr() 549 else: 550 first = False 551 552 # Obtain details of attendees to supply attributes. 553 554 self.show_attendee(i, value, attendee_map.get(value)) 555 page.tr.close() 556 557 # Allow more attendees to be specified. 558 559 if self.is_organiser(): 560 if not first: 561 page.tr() 562 563 page.td() 564 self._control("add", "submit", "add", id="add", class_="add") 565 page.label("Add attendee", for_="add", class_="add") 566 page.td.close() 567 page.tr.close() 568 569 # Handle potentially many values of other kinds. 570 571 else: 572 first = True 573 574 for i, (value, attr) in enumerate(items): 575 if not first: 576 page.tr() 577 else: 578 first = False 579 580 page.td(class_="objectvalue %s" % field) 581 page.add(value) 582 page.td.close() 583 page.tr.close() 584 585 page.tbody.close() 586 page.table.close() 587 588 self.show_recurrences(errors) 589 self.show_conflicting_events() 590 self.show_request_controls() 591 592 page.form.close() 593 594 def show_attendee(self, i, attendee, attendee_attr): 595 596 """ 597 For the current object, show the attendee in position 'i' with the given 598 'attendee' value, having 'attendee_attr' as any stored attributes. 599 """ 600 601 page = self.page 602 args = self.env.get_args() 603 604 existing = attendee_attr is not None 605 partstat = attendee_attr and attendee_attr.get("PARTSTAT") 606 607 page.td(class_="objectvalue") 608 609 # Show a form control as organiser for new attendees. 610 611 if self.is_organiser() and (not existing or not self.obj.is_shared()): 612 self._control("attendee", "value", attendee, size="40") 613 else: 614 self._control("attendee", "hidden", attendee) 615 page.add(attendee) 616 page.add(" ") 617 618 # Show participation status, editable for the current user. 619 620 if attendee == self.user: 621 self._show_menu("partstat", partstat, self.partstat_items, "partstat") 622 623 # Allow the participation indicator to act as a submit 624 # button in order to refresh the page and show a control for 625 # the current user, if indicated. 626 627 elif self.is_organiser() and not existing: 628 self._control("partstat-refresh", "submit", "refresh", id="partstat-%d" % i, class_="refresh") 629 page.label(dict(self.partstat_items).get(partstat, ""), for_="partstat-%s" % i, class_="partstat") 630 else: 631 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 632 633 # Permit organisers to remove attendees. 634 635 if self.is_organiser(): 636 637 # Permit the removal of newly-added attendees. 638 639 remove_type = (not existing or not self.obj.is_shared() or attendee == self.user) and "submit" or "checkbox" 640 self._control("remove", remove_type, str(i), str(i) in args.get("remove", []), id="remove-%d" % i, class_="remove") 641 642 page.label("Remove", for_="remove-%d" % i, class_="remove") 643 page.label(for_="remove-%d" % i, class_="removed") 644 page.add("(Uninvited)") 645 page.span("Re-invite", class_="action") 646 page.label.close() 647 648 page.td.close() 649 650 def show_recurrences(self, errors=None): 651 652 """ 653 Show recurrences for the current object. If 'errors' is given, show a 654 suitable message for the different errors provided. 655 """ 656 657 page = self.page 658 659 # Obtain any parent object if this object is a specific recurrence. 660 661 if self.recurrenceid: 662 parent = self.get_stored_object(self.uid, None) 663 if not parent: 664 return 665 666 page.p() 667 page.a("This event modifies a recurring event.", href=self.link_to(self.uid)) 668 page.p.close() 669 670 # Obtain the periods associated with the event. 671 # NOTE: Add a control to add recurrences here. 672 673 recurrences = self.get_current_recurrences() 674 675 if len(recurrences) < 1: 676 return 677 678 recurrenceids = self._get_recurrences(self.uid) 679 680 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 681 682 # Show each recurrence in a separate table if editable. 683 684 if self.is_organiser() and recurrences: 685 686 for index, period in enumerate(recurrences): 687 self.show_recurrence(index, period, self.recurrenceid, recurrenceids, errors) 688 689 # Otherwise, use a compact single table. 690 691 else: 692 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 693 page.caption("Occurrences") 694 page.thead() 695 page.tr() 696 page.th("Start", class_="objectheading start") 697 page.th("End", class_="objectheading end") 698 page.tr.close() 699 page.thead.close() 700 page.tbody() 701 702 for index, period in enumerate(recurrences): 703 page.tr() 704 self.show_recurrence_label(period, self.recurrenceid, recurrenceids, True) 705 self.show_recurrence_label(period, self.recurrenceid, recurrenceids, False) 706 page.tr.close() 707 708 page.tbody.close() 709 page.table.close() 710 711 def show_recurrence(self, index, period, recurrenceid, recurrenceids, errors=None): 712 713 """ 714 Show recurrence controls for a recurrence provided by the current object 715 with the given 'index' position in the list of periods, the given 716 'period' details, where a 'recurrenceid' indicates any specific 717 recurrence, and where 'recurrenceids' indicates all known additional 718 recurrences for the object. 719 720 If 'errors' is given, show a suitable message for the different errors 721 provided. 722 """ 723 724 page = self.page 725 args = self.env.get_args() 726 727 p = event_period_from_period(period) 728 replaced = not recurrenceid and p.is_replaced(recurrenceids) 729 730 # Isolate the controls from neighbouring tables. 731 732 page.div() 733 734 self.show_object_datetime_controls(period, index) 735 736 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 737 page.caption(period.origin == "RRULE" and "Occurrence from rule" or "Occurrence") 738 page.tbody() 739 740 page.tr() 741 error = errors and ("dtstart", index) in errors and " error" or "" 742 page.th("Start", class_="objectheading start%s" % error) 743 self.show_recurrence_controls(index, period, recurrenceid, recurrenceids, True) 744 page.tr.close() 745 page.tr() 746 error = errors and ("dtend", index) in errors and " error" or "" 747 page.th("End", class_="objectheading end%s" % error) 748 self.show_recurrence_controls(index, period, recurrenceid, recurrenceids, False) 749 page.tr.close() 750 751 # Permit the removal of recurrences. 752 753 if not replaced: 754 page.tr() 755 page.th("") 756 page.td() 757 758 remove_type = not self.obj.is_shared() or not period.origin and "submit" or "checkbox" 759 self._control("recur-remove", remove_type, str(index), 760 str(index) in args.get("recur-remove", []), 761 id="recur-remove-%d" % index, class_="remove") 762 763 page.label("Remove", for_="recur-remove-%d" % index, class_="remove") 764 page.label(for_="recur-remove-%d" % index, class_="removed") 765 page.add("(Removed)") 766 page.span("Re-add", class_="action") 767 page.label.close() 768 769 page.td.close() 770 page.tr.close() 771 772 page.tbody.close() 773 page.table.close() 774 775 page.div.close() 776 777 def show_conflicting_events(self): 778 779 "Show conflicting events for the current object." 780 781 page = self.page 782 recurrenceids = self._get_active_recurrences(self.uid) 783 784 # Obtain the user's timezone. 785 786 tzid = self.get_tzid() 787 periods = self.get_periods(self.obj) 788 789 # Indicate whether there are conflicting events. 790 791 conflicts = [] 792 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 793 794 for participant in self.get_current_attendees(): 795 if participant == self.user: 796 freebusy = self.store.get_freebusy(participant) 797 else: 798 freebusy = self.store.get_freebusy_for_other(self.user, participant) 799 800 if not freebusy: 801 continue 802 803 # Obtain any time zone details from the suggested event. 804 805 _dtstart, attr = self.obj.get_item("DTSTART") 806 tzid = attr.get("TZID", tzid) 807 808 # Show any conflicts with periods of actual attendance. 809 810 participant_attr = attendee_map.get(participant) 811 partstat = participant_attr and participant_attr.get("PARTSTAT") 812 recurrences = self.obj.get_recurrence_start_points(recurrenceids, tzid) 813 814 for p in have_conflict(freebusy, periods, True): 815 if not self.recurrenceid and p.is_replaced(recurrences): 816 continue 817 818 if ( # Unidentified or different event 819 (p.uid != self.uid or self.recurrenceid and p.recurrenceid and p.recurrenceid != self.recurrenceid) and 820 # Different period or unclear participation with the same period 821 (p not in periods or not partstat in ("ACCEPTED", "TENTATIVE")) and 822 # Participant not limited to organising 823 p.transp != "ORG" 824 ): 825 826 conflicts.append(p) 827 828 conflicts.sort() 829 830 # Show any conflicts with periods of actual attendance. 831 832 if conflicts: 833 page.p("This event conflicts with others:") 834 835 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 836 page.thead() 837 page.tr() 838 page.th("Event") 839 page.th("Start") 840 page.th("End") 841 page.tr.close() 842 page.thead.close() 843 page.tbody() 844 845 for p in conflicts: 846 847 # Provide details of any conflicting event. 848 849 start = self.format_datetime(to_timezone(p.get_start(), tzid), "long") 850 end = self.format_datetime(to_timezone(p.get_end(), tzid), "long") 851 852 page.tr() 853 854 # Show the event summary for the conflicting event. 855 856 page.td() 857 if p.summary: 858 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid)) 859 else: 860 page.add("(Unspecified event)") 861 page.td.close() 862 863 page.td(start) 864 page.td(end) 865 866 page.tr.close() 867 868 page.tbody.close() 869 page.table.close() 870 871 # Generation of controls within page fragments. 872 873 def show_object_datetime_controls(self, period, index=None): 874 875 """ 876 Show datetime-related controls if already active or if an object needs 877 them for the given 'period'. The given 'index' is used to parameterise 878 individual controls for dynamic manipulation. 879 """ 880 881 p = form_period_from_period(period) 882 883 page = self.page 884 args = self.env.get_args() 885 _id = self.element_identifier 886 _name = self.element_name 887 _enable = self.element_enable 888 889 # Add a dynamic stylesheet to permit the controls to modify the display. 890 # NOTE: The style details need to be coordinated with the static 891 # NOTE: stylesheet. 892 893 if index is not None: 894 page.style(type="text/css") 895 896 # Unlike the rules for object properties, these affect recurrence 897 # properties. 898 899 page.add("""\ 900 input#dttimes-enable-%(index)d, 901 input#dtend-enable-%(index)d, 902 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 903 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 904 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 905 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 906 display: none; 907 }""" % {"index" : index}) 908 909 page.style.close() 910 911 self._control( 912 _name("dtend-control", "recur", index), "checkbox", 913 _enable(index), p.end_enabled, 914 id=_id("dtend-enable", index) 915 ) 916 917 self._control( 918 _name("dttimes-control", "recur", index), "checkbox", 919 _enable(index), p.times_enabled, 920 id=_id("dttimes-enable", index) 921 ) 922 923 def show_datetime_controls(self, formdate, show_start): 924 925 """ 926 Show datetime details from the current object for the 'formdate', 927 showing start details if 'show_start' is set to a true value. Details 928 will appear as controls for organisers and labels for attendees. 929 """ 930 931 page = self.page 932 933 # Show controls for editing as organiser. 934 935 if self.is_organiser(): 936 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 937 938 if show_start: 939 page.div(class_="dt enabled") 940 self._show_date_controls("dtstart", formdate) 941 page.br() 942 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 943 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 944 page.div.close() 945 946 else: 947 page.div(class_="dt disabled") 948 page.label("Specify end date", for_="dtend-enable", class_="enable") 949 page.div.close() 950 page.div(class_="dt enabled") 951 self._show_date_controls("dtend", formdate) 952 page.br() 953 page.label("End on same day", for_="dtend-enable", class_="disable") 954 page.div.close() 955 956 page.td.close() 957 958 # Show a label as attendee. 959 960 else: 961 dt = formdate.as_datetime() 962 if dt: 963 page.td(self.format_datetime(dt, "full")) 964 else: 965 page.td("(Unrecognised date)") 966 967 def show_recurrence_controls(self, index, period, recurrenceid, recurrenceids, show_start): 968 969 """ 970 Show datetime details from the current object for the recurrence having 971 the given 'index', with the recurrence period described by 'period', 972 indicating a start, end and origin of the period from the event details, 973 employing any 'recurrenceid' and 'recurrenceids' for the object to 974 configure the displayed information. 975 976 If 'show_start' is set to a true value, the start details will be shown; 977 otherwise, the end details will be shown. 978 """ 979 980 page = self.page 981 _id = self.element_identifier 982 _name = self.element_name 983 984 p = event_period_from_period(period) 985 replaced = not recurrenceid and p.is_replaced(recurrenceids) 986 987 # Show controls for editing as organiser. 988 989 if self.is_organiser() and not replaced: 990 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 991 992 read_only = period.origin == "RRULE" 993 994 if show_start: 995 page.div(class_="dt enabled") 996 self._show_date_controls(_name("dtstart", "recur", index), p.get_form_start(), index=index, read_only=read_only) 997 if not read_only: 998 page.br() 999 page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable") 1000 page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable") 1001 page.div.close() 1002 1003 # Put the origin somewhere. 1004 1005 self._control("recur-origin", "hidden", p.origin or "") 1006 1007 else: 1008 page.div(class_="dt disabled") 1009 if not read_only: 1010 page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") 1011 page.div.close() 1012 page.div(class_="dt enabled") 1013 self._show_date_controls(_name("dtend", "recur", index), p.get_form_end(), index=index, show_tzid=False, read_only=read_only) 1014 if not read_only: 1015 page.br() 1016 page.label("End on same day", for_=_id("dtend-enable", index), class_="disable") 1017 page.div.close() 1018 1019 page.td.close() 1020 1021 # Show label as attendee. 1022 1023 else: 1024 self.show_recurrence_label(p, recurrenceid, recurrenceids, show_start) 1025 1026 def show_recurrence_label(self, period, recurrenceid, recurrenceids, show_start): 1027 1028 """ 1029 Show datetime details for the given 'period', employing any 1030 'recurrenceid' and 'recurrenceids' for the object to configure the 1031 displayed information. 1032 1033 If 'show_start' is set to a true value, the start details will be shown; 1034 otherwise, the end details will be shown. 1035 """ 1036 1037 page = self.page 1038 1039 p = event_period_from_period(period) 1040 replaced = not recurrenceid and p.is_replaced(recurrenceids) 1041 1042 css = " ".join([ 1043 replaced and "replaced" or "", 1044 p.is_affected(recurrenceid) and "affected" or "" 1045 ]) 1046 1047 formdate = show_start and p.get_form_start() or p.get_form_end() 1048 dt = formdate.as_datetime() 1049 if dt: 1050 page.td(self.format_datetime(dt, "long"), class_=css) 1051 else: 1052 page.td("(Unrecognised date)") 1053 1054 # Full page output methods. 1055 1056 def show(self, path_info): 1057 1058 "Show an object request using the given 'path_info' for the current user." 1059 1060 uid, recurrenceid, section = self.get_identifiers(path_info) 1061 obj = self.get_stored_object(uid, recurrenceid, section) 1062 self.set_object(obj) 1063 1064 if not obj: 1065 return False 1066 1067 errors = self.handle_request() 1068 1069 if not errors: 1070 return True 1071 1072 self.new_page(title="Event") 1073 self.show_object_on_page(errors) 1074 1075 return True 1076 1077 # Utility methods. 1078 1079 def _control(self, name, type, value, selected=False, **kw): 1080 1081 """ 1082 Show a control with the given 'name', 'type' and 'value', with 1083 'selected' indicating whether it should be selected (checked or 1084 equivalent), and with keyword arguments setting other properties. 1085 """ 1086 1087 page = self.page 1088 if selected: 1089 page.input(name=name, type=type, value=value, checked=selected, **kw) 1090 else: 1091 page.input(name=name, type=type, value=value, **kw) 1092 1093 def _show_menu(self, name, default, items, class_="", index=None): 1094 1095 """ 1096 Show a select menu having the given 'name', set to the given 'default', 1097 providing the given (value, label) 'items', and employing the given CSS 1098 'class_' if specified. 1099 """ 1100 1101 page = self.page 1102 values = self.env.get_args().get(name, [default]) 1103 if index is not None: 1104 values = values[index:] 1105 values = values and values[0:1] or [default] 1106 1107 page.select(name=name, class_=class_) 1108 for v, label in items: 1109 if v is None: 1110 continue 1111 if v in values: 1112 page.option(label, value=v, selected="selected") 1113 else: 1114 page.option(label, value=v) 1115 page.select.close() 1116 1117 def _show_date_controls(self, name, default, index=None, show_tzid=True, read_only=False): 1118 1119 """ 1120 Show date controls for a field with the given 'name' and 'default' form 1121 date value. 1122 1123 If 'index' is specified, default field values will be overridden by the 1124 element from a collection of existing form values with the specified 1125 index; otherwise, field values will be overridden by a single form 1126 value. 1127 1128 If 'show_tzid' is set to a false value, the time zone menu will not be 1129 provided. 1130 1131 If 'read_only' is set to a true value, the controls will be hidden and 1132 labels will be employed instead. 1133 """ 1134 1135 page = self.page 1136 1137 # Show dates for up to one week around the current date. 1138 1139 dt = default.as_datetime() 1140 if not dt: 1141 dt = date.today() 1142 1143 base = to_date(dt) 1144 1145 # Show a date label with a hidden field if read-only. 1146 1147 if read_only: 1148 self._control("%s-date" % name, "hidden", format_datetime(base)) 1149 page.span(self.format_date(base, "long")) 1150 1151 # Show dates for up to one week around the current date. 1152 # NOTE: Support paging to other dates. 1153 1154 else: 1155 items = [] 1156 for i in range(-7, 8): 1157 d = base + timedelta(i) 1158 items.append((format_datetime(d), self.format_date(d, "full"))) 1159 self._show_menu("%s-date" % name, format_datetime(base), items, index=index) 1160 1161 # Show time details. 1162 1163 page.span(class_="time enabled") 1164 1165 if read_only: 1166 page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second())) 1167 self._control("%s-hour" % name, "hidden", default.get_hour()) 1168 self._control("%s-minute" % name, "hidden", default.get_minute()) 1169 self._control("%s-second" % name, "hidden", default.get_second()) 1170 else: 1171 self._control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2) 1172 page.add(":") 1173 self._control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2) 1174 page.add(":") 1175 self._control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2) 1176 1177 # Show time zone details. 1178 1179 if show_tzid: 1180 page.add(" ") 1181 tzid = default.get_tzid() or self.get_tzid() 1182 1183 # Show a label if read-only or a menu otherwise. 1184 1185 if read_only: 1186 self._control("%s-tzid" % name, "hidden", tzid) 1187 page.span(tzid) 1188 else: 1189 self._show_timezone_menu("%s-tzid" % name, tzid, index) 1190 1191 page.span.close() 1192 1193 def _show_timezone_menu(self, name, default, index=None): 1194 1195 """ 1196 Show timezone controls using a menu with the given 'name', set to the 1197 given 'default' unless a field of the given 'name' provides a value. 1198 """ 1199 1200 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 1201 self._show_menu(name, default, entries, index=index) 1202 1203 # vim: tabstop=4 expandtab shiftwidth=4