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