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