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