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