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