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 attr = attendee_map.get(value) 586 partstat = attr and attr.get("PARTSTAT") 587 588 # Show a form control as organiser for new attendees. 589 590 if is_organiser and not partstat: 591 page.input(name="attendee", type="value", value=value, size="40") 592 else: 593 page.input(name="attendee", type="hidden", value=value) 594 page.add(value) 595 page.add(" ") 596 597 # Show participation status, editable for the current user. 598 599 if value == self.user: 600 self._show_menu("partstat", partstat, self.partstat_items, "partstat") 601 602 # Allow the participation indicator to act as a submit 603 # button in order to refresh the page and show a control for 604 # the current user, if indicated. 605 606 elif is_organiser: 607 page.input(name="partstat-refresh", type="submit", value="refresh", id="partstat-%d" % i, class_="refresh") 608 page.label(dict(self.partstat_items).get(partstat, ""), for_="partstat-%s" % i, class_="partstat") 609 else: 610 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 611 612 # Permit organisers to remove attendees. 613 614 if is_organiser: 615 616 # Permit the removal of newly-added attendees. 617 618 remove_type = (value in existing_attendees and value != self.user) and "checkbox" or "submit" 619 620 self._control("remove", remove_type, value, value in args.get("remove", []), id="remove-%d" % i, class_="remove") 621 622 page.label("Remove", for_="remove-%d" % i, class_="remove") 623 page.label("Uninvited", for_="remove-%d" % i, class_="removed") 624 625 page.td.close() 626 page.tr.close() 627 628 # Allow more attendees to be specified. 629 630 if is_organiser: 631 i = len(attendees) 632 633 if not first: 634 page.tr() 635 636 page.td() 637 page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add") 638 page.label("Add attendee", for_="add-%d" % i, class_="add") 639 page.td.close() 640 page.tr.close() 641 642 # Handle potentially many values of other kinds. 643 644 else: 645 first = True 646 647 for i, (value, attr) in enumerate(items): 648 if not first: 649 page.tr() 650 else: 651 first = False 652 653 page.td(class_="objectvalue") 654 page.add(value) 655 page.td.close() 656 page.tr.close() 657 658 page.tbody.close() 659 page.table.close() 660 661 self.show_recurrences(obj) 662 self.show_conflicting_events(uid, obj) 663 self.show_request_controls(obj) 664 665 page.form.close() 666 667 def show_recurrences(self, obj): 668 669 "Show recurrences for the object having the given representation 'obj'." 670 671 page = self.page 672 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 673 674 # Obtain any parent object if this object is a specific recurrence. 675 676 uid = obj.get_value("UID") 677 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 678 679 if recurrenceid: 680 obj = self._get_object(uid) 681 if not obj: 682 return 683 684 page.p("This event modifies a recurring event.") 685 686 # Obtain the periods associated with the event in the user's time zone. 687 688 periods = obj.get_periods(self.get_tzid(), self.get_window_end()) 689 recurrenceids = self._get_recurrences(uid) 690 691 if len(periods) == 1: 692 return 693 694 if is_organiser: 695 page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size()) 696 else: 697 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 698 699 # Determine whether any periods are explicitly created or are part of a 700 # rule. 701 702 explicit_periods = filter(lambda p: p.origin != "RRULE", periods) 703 704 # Show each recurrence in a separate table if editable. 705 706 if is_organiser and explicit_periods: 707 708 for index, p in enumerate(periods[1:]): 709 710 # Isolate the controls from neighbouring tables. 711 712 page.div() 713 714 self.show_object_datetime_controls(p.start, p.end, index) 715 716 # NOTE: Need to customise the TH classes according to errors and 717 # NOTE: index information. 718 719 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 720 page.caption("Occurrence") 721 page.tbody() 722 page.tr() 723 page.th("Start", class_="objectheading start") 724 self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, True) 725 page.tr.close() 726 page.tr() 727 page.th("End", class_="objectheading end") 728 self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, False) 729 page.tr.close() 730 page.tbody.close() 731 page.table.close() 732 733 page.div.close() 734 735 # Otherwise, use a compact single table. 736 737 else: 738 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 739 page.caption("Occurrences") 740 page.thead() 741 page.tr() 742 page.th("Start", class_="objectheading start") 743 page.th("End", class_="objectheading end") 744 page.tr.close() 745 page.thead.close() 746 page.tbody() 747 748 # Show only subsequent periods if organiser, since the principal 749 # period will be the start and end datetimes. 750 751 for index, p in enumerate(is_organiser and periods[1:] or periods): 752 page.tr() 753 self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, True) 754 self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, False) 755 page.tr.close() 756 page.tbody.close() 757 page.table.close() 758 759 def show_conflicting_events(self, uid, obj): 760 761 """ 762 Show conflicting events for the object having the given 'uid' and 763 representation 'obj'. 764 """ 765 766 page = self.page 767 768 # Obtain the user's timezone. 769 770 tzid = self.get_tzid() 771 periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) 772 773 # Indicate whether there are conflicting events. 774 775 freebusy = self.store.get_freebusy(self.user) 776 777 if freebusy: 778 779 # Obtain any time zone details from the suggested event. 780 781 _dtstart, attr = obj.get_item("DTSTART") 782 tzid = attr.get("TZID", tzid) 783 784 # Show any conflicts. 785 786 conflicts = list([p for p in have_conflict(freebusy, periods, True) if p.uid != uid]) 787 conflicts.sort() 788 789 if conflicts: 790 page.p("This event conflicts with others:") 791 792 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 793 page.thead() 794 page.tr() 795 page.th("Event") 796 page.th("Start") 797 page.th("End") 798 page.tr.close() 799 page.thead.close() 800 page.tbody() 801 802 for p in conflicts: 803 804 # Provide details of any conflicting event. 805 806 start = self.format_datetime(to_timezone(get_datetime(p.start), tzid), "long") 807 end = self.format_datetime(to_timezone(get_datetime(p.end), tzid), "long") 808 809 page.tr() 810 811 # Show the event summary for the conflicting event. 812 813 page.td() 814 page.a(p.summary, href=self.link_to(p.uid)) 815 page.td.close() 816 817 page.td(start) 818 page.td(end) 819 820 page.tr.close() 821 822 page.tbody.close() 823 page.table.close() 824 825 # Generation of controls within page fragments. 826 827 def show_object_datetime_controls(self, start, end, index=None): 828 829 """ 830 Show datetime-related controls if already active or if an object needs 831 them for the given 'start' to 'end' period. The given 'index' is used to 832 parameterise individual controls for dynamic manipulation. 833 """ 834 835 page = self.page 836 args = self.env.get_args() 837 sn = self._suffixed_name 838 ssn = self._simple_suffixed_name 839 840 # Add a dynamic stylesheet to permit the controls to modify the display. 841 # NOTE: The style details need to be coordinated with the static 842 # NOTE: stylesheet. 843 844 if index is not None: 845 page.style(type="text/css") 846 847 # Unlike the rules for object properties, these affect recurrence 848 # properties. 849 850 page.add("""\ 851 input#dttimes-enable-%(index)d, 852 input#dtend-enable-%(index)d, 853 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 854 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 855 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 856 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 857 display: none; 858 }""" % {"index" : index}) 859 860 page.style.close() 861 862 dtend_control = args.get(ssn("dtend-control", "recur", index), []) 863 dttimes_control = args.get(ssn("dttimes-control", "recur", index), []) 864 865 dtend_enabled = index is not None and str(index) in dtend_control or index is None and dtend_control 866 dttimes_enabled = index is not None and str(index) in dttimes_control or index is None and dttimes_control 867 868 initial_load = not args.has_key("editing") 869 870 dtend_enabled = dtend_enabled or initial_load and (isinstance(end, datetime) or start != end - timedelta(1)) 871 dttimes_enabled = dttimes_enabled or initial_load and (isinstance(start, datetime) or isinstance(end, datetime)) 872 873 self._control( 874 ssn("dtend-control", "recur", index), "checkbox", 875 index is not None and str(index) or "enable", dtend_enabled, 876 id=sn("dtend-enable", index) 877 ) 878 879 self._control( 880 ssn("dttimes-control", "recur", index), "checkbox", 881 index is not None and str(index) or "enable", dttimes_enabled, 882 id=sn("dttimes-enable", index) 883 ) 884 885 def show_datetime_controls(self, obj, dt, attr, show_start): 886 887 """ 888 Show datetime details from the given 'obj' for the datetime 'dt' and 889 attributes 'attr', showing start details if 'show_start' is set 890 to a true value. Details will appear as controls for organisers and 891 labels for attendees. 892 """ 893 894 page = self.page 895 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 896 897 # Change end dates to refer to the actual dates, not the iCalendar 898 # "next day" dates. 899 900 if not show_start and not isinstance(dt, datetime): 901 dt -= timedelta(1) 902 903 # Show controls for editing as organiser. 904 905 if is_organiser: 906 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 907 908 if show_start: 909 page.div(class_="dt enabled") 910 self._show_date_controls("dtstart", dt, attr.get("TZID")) 911 page.br() 912 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 913 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 914 page.div.close() 915 916 else: 917 page.div(class_="dt disabled") 918 page.label("Specify end date", for_="dtend-enable", class_="enable") 919 page.div.close() 920 page.div(class_="dt enabled") 921 self._show_date_controls("dtend", dt, attr.get("TZID")) 922 page.br() 923 page.label("End on same day", for_="dtend-enable", class_="disable") 924 page.div.close() 925 926 page.td.close() 927 928 # Show a label as attendee. 929 930 else: 931 page.td(self.format_datetime(dt, "full")) 932 933 def show_recurrence_controls(self, obj, index, start, end, origin, recurrenceid, recurrenceids, show_start): 934 935 """ 936 Show datetime details from the given 'obj' for the recurrence having the 937 given 'index', with the recurrence period described by the datetimes 938 'start' and 'end', indicating the 'origin' of the period from the event 939 details, employing any 'recurrenceid' and 'recurrenceids' for the object 940 to configure the displayed information. 941 942 If 'show_start' is set to a true value, the start details will be shown; 943 otherwise, the end details will be shown. 944 """ 945 946 page = self.page 947 sn = self._suffixed_name 948 ssn = self._simple_suffixed_name 949 950 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 951 952 # Change end dates to refer to the actual dates, not the iCalendar 953 # "next day" dates. 954 955 if not isinstance(end, datetime): 956 end -= timedelta(1) 957 958 start_utc = format_datetime(to_timezone(start, "UTC")) 959 replaced = recurrenceids and start_utc in recurrenceids and "replaced" or "" 960 css = " ".join([ 961 replaced, 962 recurrenceid and start_utc == recurrenceid and "affected" or "" 963 ]) 964 965 # Show controls for editing as organiser. 966 967 if is_organiser and not replaced and origin != "RRULE": 968 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 969 970 if show_start: 971 page.div(class_="dt enabled") 972 self._show_date_controls(ssn("dtstart", "recur", index), start, index=index) 973 page.br() 974 page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable") 975 page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable") 976 page.div.close() 977 978 else: 979 page.div(class_="dt disabled") 980 page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable") 981 page.div.close() 982 page.div(class_="dt enabled") 983 self._show_date_controls(ssn("dtend", "recur", index), end, index=index, show_tzid=False) 984 page.br() 985 page.label("End on same day", for_=sn("dtend-enable", index), class_="disable") 986 page.div.close() 987 988 page.td.close() 989 990 # Show label as attendee. 991 992 else: 993 page.td(self.format_datetime(show_start and start or end, "long"), class_=css) 994 995 # Full page output methods. 996 997 def show(self, path_info): 998 999 "Show an object request using the given 'path_info' for the current user." 1000 1001 uid, recurrenceid = self._get_identifiers(path_info) 1002 obj = self._get_object(uid, recurrenceid) 1003 1004 if not obj: 1005 return False 1006 1007 error = self.handle_request(uid, recurrenceid, obj) 1008 1009 if not error: 1010 return True 1011 1012 self.new_page(title="Event") 1013 self.show_object_on_page(uid, obj, error) 1014 1015 return True 1016 1017 # Utility methods. 1018 1019 def _control(self, name, type, value, selected, **kw): 1020 1021 """ 1022 Show a control with the given 'name', 'type' and 'value', with 1023 'selected' indicating whether it should be selected (checked or 1024 equivalent), and with keyword arguments setting other properties. 1025 """ 1026 1027 page = self.page 1028 if selected: 1029 page.input(name=name, type=type, value=value, checked=selected, **kw) 1030 else: 1031 page.input(name=name, type=type, value=value, **kw) 1032 1033 def _show_menu(self, name, default, items, class_="", index=None): 1034 1035 """ 1036 Show a select menu having the given 'name', set to the given 'default', 1037 providing the given (value, label) 'items', and employing the given CSS 1038 'class_' if specified. 1039 """ 1040 1041 page = self.page 1042 values = self.env.get_args().get(name, [default]) 1043 if index is not None: 1044 values = values[index:] 1045 values = values and values[0:1] or [default] 1046 1047 page.select(name=name, class_=class_) 1048 for v, label in items: 1049 if v is None: 1050 continue 1051 if v in values: 1052 page.option(label, value=v, selected="selected") 1053 else: 1054 page.option(label, value=v) 1055 page.select.close() 1056 1057 def _show_date_controls(self, name, default, tzid=None, index=None, show_tzid=True): 1058 1059 """ 1060 Show date controls for a field with the given 'name' and 'default' value 1061 and 'tzid'. If 'index' is specified, default field values will be 1062 overridden by the element from a collection of existing form values with 1063 the specified index; otherwise, field values will be overridden by a 1064 single form value. 1065 1066 If 'show_tzid' is set to a false value, the time zone menu will not be 1067 provided. 1068 """ 1069 1070 page = self.page 1071 args = self.env.get_args() 1072 1073 # Show dates for up to one week around the current date. 1074 1075 base = to_date(default) 1076 items = [] 1077 for i in range(-7, 8): 1078 d = base + timedelta(i) 1079 items.append((format_datetime(d), self.format_date(d, "full"))) 1080 1081 self._show_menu("%s-date" % name, format_datetime(base), items, index=index) 1082 1083 # Show time details. 1084 1085 default_time = isinstance(default, datetime) and default or None 1086 1087 hour = args.get("%s-hour" % name, [])[index or 0:] 1088 hour = hour and hour[0] or "%02d" % (default_time and default_time.hour or 0) 1089 minute = args.get("%s-minute" % name, [])[index or 0:] 1090 minute = minute and minute[0] or "%02d" % (default_time and default_time.minute or 0) 1091 second = args.get("%s-second" % name, [])[index or 0:] 1092 second = second and second[0] or "%02d" % (default_time and default_time.second or 0) 1093 1094 page.span(class_="time enabled") 1095 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1096 page.add(":") 1097 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1098 page.add(":") 1099 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1100 if show_tzid: 1101 tzid = tzid or self.get_tzid() 1102 page.add(" ") 1103 self._show_timezone_menu("%s-tzid" % name, tzid, index) 1104 page.span.close() 1105 1106 def _show_timezone_menu(self, name, default, index=None): 1107 1108 """ 1109 Show timezone controls using a menu with the given 'name', set to the 1110 given 'default' unless a field of the given 'name' provides a value. 1111 """ 1112 1113 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 1114 self._show_menu(name, default, entries, index=index) 1115 1116 # vim: tabstop=4 expandtab shiftwidth=4