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