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