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