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