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