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