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