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