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