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