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, showing 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 # Provide a summary of the object. 563 564 page.table(class_="object", cellspacing=5, cellpadding=5) 565 page.thead() 566 page.tr() 567 page.th("Event", class_="mainheading", colspan=2) 568 page.tr.close() 569 page.thead.close() 570 page.tbody() 571 572 for name, label in self.property_items: 573 field = name.lower() 574 575 items = obj.get_items(name) or [] 576 rowspan = len(items) 577 578 if name == "ATTENDEE": 579 rowspan = len(attendees) + 1 # for the add button 580 elif not items: 581 continue 582 583 page.tr() 584 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""), rowspan=rowspan) 585 586 # Handle datetimes specially. 587 588 if name in ["DTSTART", "DTEND"]: 589 590 # Obtain the datetime. 591 592 if name == "DTSTART": 593 dt, attr = dtstart, dtstart_attr 594 595 # Where no end datetime exists, use the start datetime as the 596 # basis of any potential datetime specified if dt-control is 597 # set. 598 599 else: 600 dt, attr = dtend or dtstart, dtend_attr or dtstart_attr 601 602 self.show_datetime_controls(obj, dt, attr, name == "DTSTART") 603 604 page.tr.close() 605 606 # Handle the summary specially. 607 608 elif name == "SUMMARY": 609 value = args.get("summary", [obj.get_value(name)])[0] 610 611 page.td() 612 if self.is_organiser(obj): 613 page.input(name="summary", type="text", value=value, size=80) 614 else: 615 page.add(value) 616 page.td.close() 617 page.tr.close() 618 619 # Handle attendees specially. 620 621 elif name == "ATTENDEE": 622 attendee_map = dict(items) 623 first = True 624 625 for i, value in enumerate(attendees): 626 if not first: 627 page.tr() 628 else: 629 first = False 630 631 # Obtain details of attendees to supply attributes. 632 633 self.show_attendee(obj, i, value, attendee_map.get(value)) 634 page.tr.close() 635 636 # Allow more attendees to be specified. 637 638 if self.is_organiser(obj): 639 i = len(attendees) 640 641 if not first: 642 page.tr() 643 644 page.td() 645 page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add") 646 page.label("Add attendee", for_="add-%d" % i, class_="add") 647 page.td.close() 648 page.tr.close() 649 650 # Handle potentially many values of other kinds. 651 652 else: 653 first = True 654 655 for i, (value, attr) in enumerate(items): 656 if not first: 657 page.tr() 658 else: 659 first = False 660 661 page.td(class_="objectvalue") 662 page.add(value) 663 page.td.close() 664 page.tr.close() 665 666 page.tbody.close() 667 page.table.close() 668 669 self.show_recurrences(obj) 670 self.show_conflicting_events(uid, obj) 671 self.show_request_controls(obj) 672 673 page.form.close() 674 675 def show_attendee(self, obj, i, attendee, attendee_attr): 676 677 """ 678 For the given object 'obj', show the attendee in position 'i' with the 679 given 'attendee' value, having 'attendee_attr' as any stored attributes. 680 """ 681 682 page = self.page 683 args = self.env.get_args() 684 685 existing = attendee_attr is not None 686 partstat = attendee_attr and attendee_attr.get("PARTSTAT") 687 sequence = obj.get_value("SEQUENCE") 688 689 page.td(class_="objectvalue") 690 691 # Show a form control as organiser for new attendees. 692 693 if self.is_organiser(obj) and (not existing or sequence is None): 694 page.input(name="attendee", type="value", value=attendee, size="40") 695 else: 696 page.input(name="attendee", type="hidden", value=attendee) 697 page.add(attendee) 698 page.add(" ") 699 700 # Show participation status, editable for the current user. 701 702 if attendee == self.user: 703 self._show_menu("partstat", partstat, self.partstat_items, "partstat") 704 705 # Allow the participation indicator to act as a submit 706 # button in order to refresh the page and show a control for 707 # the current user, if indicated. 708 709 elif self.is_organiser(obj) and not existing: 710 page.input(name="partstat-refresh", type="submit", value="refresh", id="partstat-%d" % i, class_="refresh") 711 page.label(dict(self.partstat_items).get(partstat, ""), for_="partstat-%s" % i, class_="partstat") 712 else: 713 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 714 715 # Permit organisers to remove attendees. 716 717 if self.is_organiser(obj): 718 719 # Permit the removal of newly-added attendees. 720 721 remove_type = (not existing or sequence is None or attendee == self.user) and "submit" or "checkbox" 722 723 self._control("remove", remove_type, str(i), str(i) in args.get("remove", []), id="remove-%d" % i, class_="remove") 724 725 page.label("Remove", for_="remove-%d" % i, class_="remove") 726 page.label("Uninvited", for_="remove-%d" % i, class_="removed") 727 728 page.td.close() 729 730 def show_recurrences(self, obj): 731 732 "Show recurrences for the object having the given representation 'obj'." 733 734 page = self.page 735 736 # Obtain any parent object if this object is a specific recurrence. 737 738 uid = obj.get_value("UID") 739 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 740 741 if recurrenceid: 742 obj = self._get_object(uid) 743 if not obj: 744 return 745 746 page.p("This event modifies a recurring event.") 747 748 # Obtain the periods associated with the event in the user's time zone. 749 750 periods = obj.get_periods(self.get_tzid(), self.get_window_end()) 751 recurrenceids = self._get_recurrences(uid) 752 753 if len(periods) == 1: 754 return 755 756 if self.is_organiser(obj): 757 page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size()) 758 else: 759 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 760 761 # Determine whether any periods are explicitly created or are part of a 762 # rule. 763 764 explicit_periods = filter(lambda p: p.origin != "RRULE", periods) 765 766 # Show each recurrence in a separate table if editable. 767 768 if self.is_organiser(obj) and explicit_periods: 769 770 for index, p in enumerate(periods[1:]): 771 772 # Isolate the controls from neighbouring tables. 773 774 page.div() 775 776 self.show_object_datetime_controls(p.start, p.end, index) 777 778 # NOTE: Need to customise the TH classes according to errors and 779 # NOTE: index information. 780 781 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 782 page.caption("Occurrence") 783 page.tbody() 784 page.tr() 785 page.th("Start", class_="objectheading start") 786 self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, True) 787 page.tr.close() 788 page.tr() 789 page.th("End", class_="objectheading end") 790 self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, False) 791 page.tr.close() 792 page.tbody.close() 793 page.table.close() 794 795 page.div.close() 796 797 # Otherwise, use a compact single table. 798 799 else: 800 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 801 page.caption("Occurrences") 802 page.thead() 803 page.tr() 804 page.th("Start", class_="objectheading start") 805 page.th("End", class_="objectheading end") 806 page.tr.close() 807 page.thead.close() 808 page.tbody() 809 810 # Show only subsequent periods if organiser, since the principal 811 # period will be the start and end datetimes. 812 813 for index, p in enumerate(self.is_organiser(obj) and periods[1:] or periods): 814 page.tr() 815 self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, True) 816 self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, False) 817 page.tr.close() 818 819 page.tbody.close() 820 page.table.close() 821 822 def show_conflicting_events(self, uid, obj): 823 824 """ 825 Show conflicting events for the object having the given 'uid' and 826 representation 'obj'. 827 """ 828 829 page = self.page 830 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 831 832 # Obtain the user's timezone. 833 834 tzid = self.get_tzid() 835 periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) 836 837 # Indicate whether there are conflicting events. 838 839 conflicts = [] 840 841 for participant in self.get_current_attendees(obj): 842 if participant == self.user: 843 freebusy = self.store.get_freebusy(participant) 844 else: 845 freebusy = self.store.get_freebusy_for_other(self.user, participant) 846 847 if not freebusy: 848 continue 849 850 # Obtain any time zone details from the suggested event. 851 852 _dtstart, attr = obj.get_item("DTSTART") 853 tzid = attr.get("TZID", tzid) 854 855 # Show any conflicts with periods of actual attendance. 856 857 for p in have_conflict(freebusy, periods, True): 858 if (p.uid != uid or p.recurrenceid != recurrenceid) and p.transp != "ORG": 859 conflicts.append(p) 860 861 conflicts.sort() 862 863 # Show any conflicts with periods of actual attendance. 864 865 if conflicts: 866 page.p("This event conflicts with others:") 867 868 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 869 page.thead() 870 page.tr() 871 page.th("Event") 872 page.th("Start") 873 page.th("End") 874 page.tr.close() 875 page.thead.close() 876 page.tbody() 877 878 for p in conflicts: 879 880 # Provide details of any conflicting event. 881 882 start = self.format_datetime(to_timezone(get_datetime(p.start), tzid), "long") 883 end = self.format_datetime(to_timezone(get_datetime(p.end), tzid), "long") 884 885 page.tr() 886 887 # Show the event summary for the conflicting event. 888 889 page.td() 890 if p.summary: 891 page.a(p.summary, href=self.link_to(p.uid)) 892 else: 893 page.add("(Unspecified event)") 894 page.td.close() 895 896 page.td(start) 897 page.td(end) 898 899 page.tr.close() 900 901 page.tbody.close() 902 page.table.close() 903 904 # Generation of controls within page fragments. 905 906 def show_object_datetime_controls(self, start, end, index=None): 907 908 """ 909 Show datetime-related controls if already active or if an object needs 910 them for the given 'start' to 'end' period. The given 'index' is used to 911 parameterise individual controls for dynamic manipulation. 912 """ 913 914 page = self.page 915 args = self.env.get_args() 916 sn = self._suffixed_name 917 ssn = self._simple_suffixed_name 918 919 # Add a dynamic stylesheet to permit the controls to modify the display. 920 # NOTE: The style details need to be coordinated with the static 921 # NOTE: stylesheet. 922 923 if index is not None: 924 page.style(type="text/css") 925 926 # Unlike the rules for object properties, these affect recurrence 927 # properties. 928 929 page.add("""\ 930 input#dttimes-enable-%(index)d, 931 input#dtend-enable-%(index)d, 932 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 933 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 934 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 935 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 936 display: none; 937 }""" % {"index" : index}) 938 939 page.style.close() 940 941 dtend_control = args.get(ssn("dtend-control", "recur", index), []) 942 dttimes_control = args.get(ssn("dttimes-control", "recur", index), []) 943 944 dtend_enabled = index is not None and str(index) in dtend_control or index is None and dtend_control 945 dttimes_enabled = index is not None and str(index) in dttimes_control or index is None and dttimes_control 946 947 initial_load = not args.has_key("editing") 948 949 dtend_enabled = dtend_enabled or initial_load and (isinstance(end, datetime) or start != end - timedelta(1)) 950 dttimes_enabled = dttimes_enabled or initial_load and (isinstance(start, datetime) or isinstance(end, datetime)) 951 952 self._control( 953 ssn("dtend-control", "recur", index), "checkbox", 954 index is not None and str(index) or "enable", dtend_enabled, 955 id=sn("dtend-enable", index) 956 ) 957 958 self._control( 959 ssn("dttimes-control", "recur", index), "checkbox", 960 index is not None and str(index) or "enable", dttimes_enabled, 961 id=sn("dttimes-enable", index) 962 ) 963 964 def show_datetime_controls(self, obj, dt, attr, show_start): 965 966 """ 967 Show datetime details from the given 'obj' for the datetime 'dt' and 968 attributes 'attr', showing start details if 'show_start' is set 969 to a true value. Details will appear as controls for organisers and 970 labels for attendees. 971 """ 972 973 page = self.page 974 975 # Change end dates to refer to the actual dates, not the iCalendar 976 # "next day" dates. 977 978 if not show_start and not isinstance(dt, datetime): 979 dt -= timedelta(1) 980 981 # Show controls for editing as organiser. 982 983 if self.is_organiser(obj): 984 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 985 986 if show_start: 987 page.div(class_="dt enabled") 988 self._show_date_controls("dtstart", dt, attr.get("TZID")) 989 page.br() 990 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 991 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 992 page.div.close() 993 994 else: 995 page.div(class_="dt disabled") 996 page.label("Specify end date", for_="dtend-enable", class_="enable") 997 page.div.close() 998 page.div(class_="dt enabled") 999 self._show_date_controls("dtend", dt, attr.get("TZID")) 1000 page.br() 1001 page.label("End on same day", for_="dtend-enable", class_="disable") 1002 page.div.close() 1003 1004 page.td.close() 1005 1006 # Show a label as attendee. 1007 1008 else: 1009 page.td(self.format_datetime(dt, "full")) 1010 1011 def show_recurrence_controls(self, obj, index, start, end, origin, recurrenceid, recurrenceids, show_start): 1012 1013 """ 1014 Show datetime details from the given 'obj' for the recurrence having the 1015 given 'index', with the recurrence period described by the datetimes 1016 'start' and 'end', indicating the 'origin' of the period from the event 1017 details, employing any 'recurrenceid' and 'recurrenceids' for the object 1018 to configure the displayed information. 1019 1020 If 'show_start' is set to a true value, the start details will be shown; 1021 otherwise, the end details will be shown. 1022 """ 1023 1024 page = self.page 1025 sn = self._suffixed_name 1026 ssn = self._simple_suffixed_name 1027 1028 # Change end dates to refer to the actual dates, not the iCalendar 1029 # "next day" dates. 1030 1031 if not isinstance(end, datetime): 1032 end -= timedelta(1) 1033 1034 start_utc = format_datetime(to_timezone(start, "UTC")) 1035 replaced = recurrenceids and start_utc in recurrenceids and "replaced" or "" 1036 css = " ".join([ 1037 replaced, 1038 recurrenceid and start_utc == recurrenceid and "affected" or "" 1039 ]) 1040 1041 # Show controls for editing as organiser. 1042 1043 if self.is_organiser(obj) and not replaced and origin != "RRULE": 1044 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 1045 1046 if show_start: 1047 page.div(class_="dt enabled") 1048 self._show_date_controls(ssn("dtstart", "recur", index), start, index=index) 1049 page.br() 1050 page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable") 1051 page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable") 1052 page.div.close() 1053 1054 else: 1055 page.div(class_="dt disabled") 1056 page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable") 1057 page.div.close() 1058 page.div(class_="dt enabled") 1059 self._show_date_controls(ssn("dtend", "recur", index), end, index=index, show_tzid=False) 1060 page.br() 1061 page.label("End on same day", for_=sn("dtend-enable", index), class_="disable") 1062 page.div.close() 1063 1064 page.td.close() 1065 1066 # Show label as attendee. 1067 1068 else: 1069 page.td(self.format_datetime(show_start and start or end, "long"), class_=css) 1070 1071 # Full page output methods. 1072 1073 def show(self, path_info): 1074 1075 "Show an object request using the given 'path_info' for the current user." 1076 1077 uid, recurrenceid = self._get_identifiers(path_info) 1078 obj = self._get_object(uid, recurrenceid) 1079 1080 if not obj: 1081 return False 1082 1083 error = self.handle_request(uid, recurrenceid, obj) 1084 1085 if not error: 1086 return True 1087 1088 self.new_page(title="Event") 1089 self.show_object_on_page(uid, obj, error) 1090 1091 return True 1092 1093 # Utility methods. 1094 1095 def _control(self, name, type, value, selected, **kw): 1096 1097 """ 1098 Show a control with the given 'name', 'type' and 'value', with 1099 'selected' indicating whether it should be selected (checked or 1100 equivalent), and with keyword arguments setting other properties. 1101 """ 1102 1103 page = self.page 1104 if selected: 1105 page.input(name=name, type=type, value=value, checked=selected, **kw) 1106 else: 1107 page.input(name=name, type=type, value=value, **kw) 1108 1109 def _show_menu(self, name, default, items, class_="", index=None): 1110 1111 """ 1112 Show a select menu having the given 'name', set to the given 'default', 1113 providing the given (value, label) 'items', and employing the given CSS 1114 'class_' if specified. 1115 """ 1116 1117 page = self.page 1118 values = self.env.get_args().get(name, [default]) 1119 if index is not None: 1120 values = values[index:] 1121 values = values and values[0:1] or [default] 1122 1123 page.select(name=name, class_=class_) 1124 for v, label in items: 1125 if v is None: 1126 continue 1127 if v in values: 1128 page.option(label, value=v, selected="selected") 1129 else: 1130 page.option(label, value=v) 1131 page.select.close() 1132 1133 def _show_date_controls(self, name, default, tzid=None, index=None, show_tzid=True): 1134 1135 """ 1136 Show date controls for a field with the given 'name' and 'default' value 1137 and 'tzid'. If 'index' is specified, default field values will be 1138 overridden by the element from a collection of existing form values with 1139 the specified index; otherwise, field values will be overridden by a 1140 single form value. 1141 1142 If 'show_tzid' is set to a false value, the time zone menu will not be 1143 provided. 1144 """ 1145 1146 page = self.page 1147 args = self.env.get_args() 1148 1149 # Show dates for up to one week around the current date. 1150 1151 base = to_date(default) 1152 items = [] 1153 for i in range(-7, 8): 1154 d = base + timedelta(i) 1155 items.append((format_datetime(d), self.format_date(d, "full"))) 1156 1157 self._show_menu("%s-date" % name, format_datetime(base), items, index=index) 1158 1159 # Show time details. 1160 1161 default_time = isinstance(default, datetime) and default or None 1162 1163 hour = args.get("%s-hour" % name, [])[index or 0:] 1164 hour = hour and hour[0] or "%02d" % (default_time and default_time.hour or 0) 1165 minute = args.get("%s-minute" % name, [])[index or 0:] 1166 minute = minute and minute[0] or "%02d" % (default_time and default_time.minute or 0) 1167 second = args.get("%s-second" % name, [])[index or 0:] 1168 second = second and second[0] or "%02d" % (default_time and default_time.second or 0) 1169 1170 page.span(class_="time enabled") 1171 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1172 page.add(":") 1173 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1174 page.add(":") 1175 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1176 if show_tzid: 1177 tzid = tzid or self.get_tzid() 1178 page.add(" ") 1179 self._show_timezone_menu("%s-tzid" % name, tzid, index) 1180 page.span.close() 1181 1182 def _show_timezone_menu(self, name, default, index=None): 1183 1184 """ 1185 Show timezone controls using a menu with the given 'name', set to the 1186 given 'default' unless a field of the given 'name' provides a value. 1187 """ 1188 1189 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 1190 self._show_menu(name, default, entries, index=index) 1191 1192 # vim: tabstop=4 expandtab shiftwidth=4