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