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