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