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) 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 832 for p in have_conflict(freebusy, periods, True): 833 if not recurrenceid and p.is_replaced(recurrenceids): 834 continue 835 836 if ( # Unidentified or different event 837 (p.uid != uid or recurrenceid and p.recurrenceid and p.recurrenceid != recurrenceid) and 838 # Different period or unclear participation with the same period 839 (p not in periods or not partstat in ("ACCEPTED", "TENTATIVE")) and 840 # Participant not limited to organising 841 p.transp != "ORG" 842 ): 843 844 conflicts.append(p) 845 846 conflicts.sort() 847 848 # Show any conflicts with periods of actual attendance. 849 850 if conflicts: 851 page.p("This event conflicts with others:") 852 853 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 854 page.thead() 855 page.tr() 856 page.th("Event") 857 page.th("Start") 858 page.th("End") 859 page.tr.close() 860 page.thead.close() 861 page.tbody() 862 863 for p in conflicts: 864 865 # Provide details of any conflicting event. 866 867 start = self.format_datetime(to_timezone(p.get_start(), tzid), "long") 868 end = self.format_datetime(to_timezone(p.get_end(), tzid), "long") 869 870 page.tr() 871 872 # Show the event summary for the conflicting event. 873 874 page.td() 875 if p.summary: 876 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid)) 877 else: 878 page.add("(Unspecified event)") 879 page.td.close() 880 881 page.td(start) 882 page.td(end) 883 884 page.tr.close() 885 886 page.tbody.close() 887 page.table.close() 888 889 # Generation of controls within page fragments. 890 891 def show_object_datetime_controls(self, period, index=None): 892 893 """ 894 Show datetime-related controls if already active or if an object needs 895 them for the given 'period'. The given 'index' is used to parameterise 896 individual controls for dynamic manipulation. 897 """ 898 899 p = form_period_from_period(period) 900 901 page = self.page 902 args = self.env.get_args() 903 _id = self.element_identifier 904 _name = self.element_name 905 _enable = self.element_enable 906 907 # Add a dynamic stylesheet to permit the controls to modify the display. 908 # NOTE: The style details need to be coordinated with the static 909 # NOTE: stylesheet. 910 911 if index is not None: 912 page.style(type="text/css") 913 914 # Unlike the rules for object properties, these affect recurrence 915 # properties. 916 917 page.add("""\ 918 input#dttimes-enable-%(index)d, 919 input#dtend-enable-%(index)d, 920 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 921 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 922 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 923 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 924 display: none; 925 }""" % {"index" : index}) 926 927 page.style.close() 928 929 self._control( 930 _name("dtend-control", "recur", index), "checkbox", 931 _enable(index), p.end_enabled, 932 id=_id("dtend-enable", index) 933 ) 934 935 self._control( 936 _name("dttimes-control", "recur", index), "checkbox", 937 _enable(index), p.times_enabled, 938 id=_id("dttimes-enable", index) 939 ) 940 941 def show_datetime_controls(self, obj, formdate, show_start): 942 943 """ 944 Show datetime details from the given 'obj' for the 'formdate', showing 945 start details if 'show_start' is set to a true value. Details will 946 appear as controls for organisers and labels for attendees. 947 """ 948 949 page = self.page 950 951 # Show controls for editing as organiser. 952 953 if self.is_organiser(obj): 954 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 955 956 if show_start: 957 page.div(class_="dt enabled") 958 self._show_date_controls("dtstart", formdate) 959 page.br() 960 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 961 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 962 page.div.close() 963 964 else: 965 page.div(class_="dt disabled") 966 page.label("Specify end date", for_="dtend-enable", class_="enable") 967 page.div.close() 968 page.div(class_="dt enabled") 969 self._show_date_controls("dtend", formdate) 970 page.br() 971 page.label("End on same day", for_="dtend-enable", class_="disable") 972 page.div.close() 973 974 page.td.close() 975 976 # Show a label as attendee. 977 978 else: 979 dt = formdate.as_datetime() 980 if dt: 981 page.td(self.format_datetime(dt, "full")) 982 else: 983 page.td("(Unrecognised date)") 984 985 def show_recurrence_controls(self, obj, index, period, recurrenceid, recurrenceids, show_start): 986 987 """ 988 Show datetime details from the given 'obj' for the recurrence having the 989 given 'index', with the recurrence period described by 'period', 990 indicating a start, end and origin of the period from the event details, 991 employing any 'recurrenceid' and 'recurrenceids' for the object to 992 configure the displayed information. 993 994 If 'show_start' is set to a true value, the start details will be shown; 995 otherwise, the end details will be shown. 996 """ 997 998 page = self.page 999 _id = self.element_identifier 1000 _name = self.element_name 1001 1002 p = event_period_from_period(period) 1003 replaced = not recurrenceid and p.is_replaced(recurrenceids) 1004 1005 # Show controls for editing as organiser. 1006 1007 if self.is_organiser(obj) and not replaced: 1008 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 1009 1010 read_only = period.origin == "RRULE" 1011 1012 if show_start: 1013 page.div(class_="dt enabled") 1014 self._show_date_controls(_name("dtstart", "recur", index), p.get_form_start(), index=index, read_only=read_only) 1015 if not read_only: 1016 page.br() 1017 page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable") 1018 page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable") 1019 page.div.close() 1020 1021 # Put the origin somewhere. 1022 1023 self._control("recur-origin", "hidden", p.origin or "") 1024 1025 else: 1026 page.div(class_="dt disabled") 1027 if not read_only: 1028 page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") 1029 page.div.close() 1030 page.div(class_="dt enabled") 1031 self._show_date_controls(_name("dtend", "recur", index), p.get_form_end(), index=index, show_tzid=False, read_only=read_only) 1032 if not read_only: 1033 page.br() 1034 page.label("End on same day", for_=_id("dtend-enable", index), class_="disable") 1035 page.div.close() 1036 1037 page.td.close() 1038 1039 # Show label as attendee. 1040 1041 else: 1042 self.show_recurrence_label(p, recurrenceid, recurrenceids, show_start) 1043 1044 def show_recurrence_label(self, period, recurrenceid, recurrenceids, show_start): 1045 1046 """ 1047 Show datetime details for the given 'period', employing any 1048 'recurrenceid' and 'recurrenceids' for the object to configure the 1049 displayed information. 1050 1051 If 'show_start' is set to a true value, the start details will be shown; 1052 otherwise, the end details will be shown. 1053 """ 1054 1055 page = self.page 1056 1057 p = event_period_from_period(period) 1058 replaced = not recurrenceid and p.is_replaced(recurrenceids) 1059 1060 css = " ".join([ 1061 replaced and "replaced" or "", 1062 p.is_affected(recurrenceid) and "affected" or "" 1063 ]) 1064 1065 formdate = show_start and p.get_form_start() or p.get_form_end() 1066 dt = formdate.as_datetime() 1067 if dt: 1068 page.td(self.format_datetime(dt, "long"), class_=css) 1069 else: 1070 page.td("(Unrecognised date)") 1071 1072 # Full page output methods. 1073 1074 def show(self, path_info): 1075 1076 "Show an object request using the given 'path_info' for the current user." 1077 1078 uid, recurrenceid = self._get_identifiers(path_info) 1079 obj = self.get_stored_object(uid, recurrenceid) 1080 1081 if not obj: 1082 return False 1083 1084 errors = self.handle_request(obj) 1085 1086 if not errors: 1087 return True 1088 1089 self.new_page(title="Event") 1090 self.show_object_on_page(obj, errors) 1091 1092 return True 1093 1094 # Utility methods. 1095 1096 def _control(self, name, type, value, selected=False, **kw): 1097 1098 """ 1099 Show a control with the given 'name', 'type' and 'value', with 1100 'selected' indicating whether it should be selected (checked or 1101 equivalent), and with keyword arguments setting other properties. 1102 """ 1103 1104 page = self.page 1105 if selected: 1106 page.input(name=name, type=type, value=value, checked=selected, **kw) 1107 else: 1108 page.input(name=name, type=type, value=value, **kw) 1109 1110 def _show_menu(self, name, default, items, class_="", index=None): 1111 1112 """ 1113 Show a select menu having the given 'name', set to the given 'default', 1114 providing the given (value, label) 'items', and employing the given CSS 1115 'class_' if specified. 1116 """ 1117 1118 page = self.page 1119 values = self.env.get_args().get(name, [default]) 1120 if index is not None: 1121 values = values[index:] 1122 values = values and values[0:1] or [default] 1123 1124 page.select(name=name, class_=class_) 1125 for v, label in items: 1126 if v is None: 1127 continue 1128 if v in values: 1129 page.option(label, value=v, selected="selected") 1130 else: 1131 page.option(label, value=v) 1132 page.select.close() 1133 1134 def _show_date_controls(self, name, default, index=None, show_tzid=True, read_only=False): 1135 1136 """ 1137 Show date controls for a field with the given 'name' and 'default' form 1138 date value. 1139 1140 If 'index' is specified, default field values will be overridden by the 1141 element from a collection of existing form values with the specified 1142 index; otherwise, field values will be overridden by a single form 1143 value. 1144 1145 If 'show_tzid' is set to a false value, the time zone menu will not be 1146 provided. 1147 1148 If 'read_only' is set to a true value, the controls will be hidden and 1149 labels will be employed instead. 1150 """ 1151 1152 page = self.page 1153 1154 # Show dates for up to one week around the current date. 1155 1156 dt = default.as_datetime() 1157 if not dt: 1158 dt = date.today() 1159 1160 base = to_date(dt) 1161 1162 # Show a date label with a hidden field if read-only. 1163 1164 if read_only: 1165 self._control("%s-date" % name, "hidden", format_datetime(base)) 1166 page.span(self.format_date(base, "long")) 1167 1168 # Show dates for up to one week around the current date. 1169 # NOTE: Support paging to other dates. 1170 1171 else: 1172 items = [] 1173 for i in range(-7, 8): 1174 d = base + timedelta(i) 1175 items.append((format_datetime(d), self.format_date(d, "full"))) 1176 self._show_menu("%s-date" % name, format_datetime(base), items, index=index) 1177 1178 # Show time details. 1179 1180 page.span(class_="time enabled") 1181 1182 if read_only: 1183 page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second())) 1184 self._control("%s-hour" % name, "hidden", default.get_hour()) 1185 self._control("%s-minute" % name, "hidden", default.get_minute()) 1186 self._control("%s-second" % name, "hidden", default.get_second()) 1187 else: 1188 self._control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2) 1189 page.add(":") 1190 self._control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2) 1191 page.add(":") 1192 self._control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2) 1193 1194 # Show time zone details. 1195 1196 if show_tzid: 1197 page.add(" ") 1198 tzid = default.get_tzid() or self.get_tzid() 1199 1200 # Show a label if read-only or a menu otherwise. 1201 1202 if read_only: 1203 self._control("%s-tzid" % name, "hidden", tzid) 1204 page.span(tzid) 1205 else: 1206 self._show_timezone_menu("%s-tzid" % name, tzid, index) 1207 1208 page.span.close() 1209 1210 def _show_timezone_menu(self, name, default, index=None): 1211 1212 """ 1213 Show timezone controls using a menu with the given 'name', set to the 1214 given 'default' unless a field of the given 'name' provides a value. 1215 """ 1216 1217 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 1218 self._show_menu(name, default, entries, index=index) 1219 1220 # vim: tabstop=4 expandtab shiftwidth=4