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