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