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