1 #!/usr/bin/env python 2 3 """ 4 A Web interface to an event calendar. 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 datetime, timedelta 23 from imiptools.data import get_address, get_uri, uri_parts 24 from imiptools.dates import format_datetime, get_datetime, \ 25 get_datetime_item, get_end_of_day, get_start_of_day, \ 26 get_start_of_next_day, get_timestamp, ends_on_same_day, \ 27 to_timezone 28 from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ 29 get_overlapping, \ 30 get_scale, get_slots, get_spans, partition_by_day, \ 31 Period, Point 32 from imipweb.resource import ResourceClient 33 34 class CalendarPage(ResourceClient): 35 36 "A request handler for the calendar page." 37 38 # Request logic methods. 39 40 def handle_newevent(self): 41 42 """ 43 Handle any new event operation, creating a new event and redirecting to 44 the event page for further activity. 45 """ 46 47 # Handle a submitted form. 48 49 args = self.env.get_args() 50 51 for key in args.keys(): 52 if key.startswith("newevent-"): 53 i = key[len("newevent-"):] 54 break 55 else: 56 return False 57 58 # Create a new event using the available information. 59 60 slots = args.get("slot", []) 61 participants = args.get("participants", []) 62 summary = args.get("summary-%s" % i, [None])[0] 63 64 if not slots: 65 return False 66 67 # Obtain the user's timezone. 68 69 tzid = self.get_tzid() 70 71 # Coalesce the selected slots. 72 73 slots.sort() 74 coalesced = [] 75 last = None 76 77 for slot in slots: 78 start, end = (slot.split("-", 1) + [None])[:2] 79 start = get_datetime(start, {"TZID" : tzid}) 80 end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) 81 82 if last: 83 last_start, last_end = last 84 85 # Merge adjacent dates and datetimes. 86 87 if start == last_end or \ 88 not isinstance(start, datetime) and \ 89 get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): 90 91 last = last_start, end 92 continue 93 94 # Handle datetimes within dates. 95 # Datetime periods are within single days and are therefore 96 # discarded. 97 98 elif not isinstance(last_start, datetime) and \ 99 get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): 100 101 continue 102 103 # Add separate dates and datetimes. 104 105 else: 106 coalesced.append(last) 107 108 last = start, end 109 110 if last: 111 coalesced.append(last) 112 113 # Invent a unique identifier. 114 115 utcnow = get_timestamp() 116 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 117 118 # Create a calendar object and store it as a request. 119 120 record = [] 121 rwrite = record.append 122 123 # Define a single occurrence if only one coalesced slot exists. 124 125 start, end = coalesced[0] 126 start_value, start_attr = get_datetime_item(start, tzid) 127 end_value, end_attr = get_datetime_item(end, tzid) 128 user_attr = self.get_user_attributes() 129 130 rwrite(("UID", {}, uid)) 131 rwrite(("SUMMARY", {}, summary or ("New event at %s" % utcnow))) 132 rwrite(("DTSTAMP", {}, utcnow)) 133 rwrite(("DTSTART", start_attr, start_value)) 134 rwrite(("DTEND", end_attr, end_value)) 135 rwrite(("ORGANIZER", user_attr, self.user)) 136 137 cn_participants = uri_parts(filter(None, participants)) 138 participants = [] 139 140 for cn, participant in cn_participants: 141 d = {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"} 142 if cn: 143 d["CN"] = cn 144 rwrite(("ATTENDEE", d, participant)) 145 participants.append(participant) 146 147 if self.user not in participants: 148 d = {"PARTSTAT" : "ACCEPTED"} 149 d.update(user_attr) 150 rwrite(("ATTENDEE", d, self.user)) 151 152 # Define additional occurrences if many slots are defined. 153 154 rdates = [] 155 156 for start, end in coalesced[1:]: 157 start_value, start_attr = get_datetime_item(start, tzid) 158 end_value, end_attr = get_datetime_item(end, tzid) 159 rdates.append("%s/%s" % (start_value, end_value)) 160 161 if rdates: 162 rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates)) 163 164 node = ("VEVENT", {}, record) 165 166 self.store.set_event(self.user, uid, None, node=node) 167 self.store.queue_request(self.user, uid) 168 169 # Redirect to the object (or the first of the objects), where instead of 170 # attendee controls, there will be organiser controls. 171 172 self.redirect(self.link_to(uid, args=self.get_time_navigation_args())) 173 return True 174 175 def update_participants(self): 176 177 "Update the participants used for scheduling purposes." 178 179 args = self.env.get_args() 180 participants = args.get("participants", []) 181 182 try: 183 for name, value in args.items(): 184 if name.startswith("remove-participant-"): 185 i = int(name[len("remove-participant-"):]) 186 del participants[i] 187 break 188 except ValueError: 189 pass 190 191 # Trim empty participants. 192 193 while participants and not participants[-1].strip(): 194 participants.pop() 195 196 return participants 197 198 # Page fragment methods. 199 200 def show_requests_on_page(self): 201 202 "Show requests for the current user." 203 204 page = self.page 205 206 # NOTE: This list could be more informative, but it is envisaged that 207 # NOTE: the requests would be visited directly anyway. 208 209 requests = self._get_requests() 210 211 page.div(id="pending-requests") 212 213 if requests: 214 page.p("Pending requests:") 215 216 page.ul() 217 218 for uid, recurrenceid, request_type in requests: 219 obj = self._get_object(uid, recurrenceid) 220 if obj: 221 page.li() 222 page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) 223 page.li.close() 224 225 page.ul.close() 226 227 else: 228 page.p("There are no pending requests.") 229 230 page.div.close() 231 232 def show_participants_on_page(self, participants): 233 234 "Show participants for scheduling purposes." 235 236 page = self.page 237 238 # Show any specified participants together with controls to remove and 239 # add participants. 240 241 page.div(id="participants") 242 243 page.p("Participants for scheduling:") 244 245 for i, participant in enumerate(participants): 246 page.p() 247 page.input(name="participants", type="text", value=participant) 248 page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 249 page.p.close() 250 251 page.p() 252 page.input(name="participants", type="text") 253 page.input(name="add-participant", type="submit", value="Add") 254 page.p.close() 255 256 page.div.close() 257 258 def show_calendar_controls(self): 259 260 """ 261 Show controls for hiding empty days and busy slots in the calendar. 262 263 The positioning of the controls, paragraph and table are important here: 264 the CSS file describes the relationship between them and the calendar 265 tables. 266 """ 267 268 page = self.page 269 270 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 271 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 272 273 page.p(class_="controls") 274 page.span("Select days or periods for a new event.") 275 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 276 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 277 page.label("Show empty days", for_="showdays", class_="showdays disable") 278 page.label("Hide empty days", for_="showdays", class_="showdays enable") 279 page.input(name="reset", type="submit", value="Clear selections", id="reset") 280 page.p.close() 281 282 def show_time_navigation(self, view_start, view_end, view_period=None): 283 284 """ 285 Show the calendar navigation links for the period defined by 286 'view_start' and 'view_end'. 287 """ 288 289 page = self.page 290 view_period = view_period or timedelta(7) 291 292 page.p(id_="time-navigation") 293 294 if view_start: 295 if view_end: 296 earlier_start = view_start - (view_end - view_start) 297 else: 298 earlier_start = view_start - view_period 299 page.label("Show earlier events", for_="earlier", class_="earlier") 300 page.input(name="earlier", id_="earlier", type="submit") 301 page.input(name="earlier-start", type="hidden", value=format_datetime(earlier_start)) 302 page.input(name="earlier-end", type="hidden", value=format_datetime(view_start)) 303 page.input(name="start", type="hidden", value=format_datetime(view_start)) 304 305 if view_end: 306 if view_start: 307 later_end = view_end + (view_end - view_start) 308 else: 309 later_end = view_end + view_period 310 page.label("Show later events", for_="later", class_="later") 311 page.input(name="later", id_="later", type="submit") 312 page.input(name="later-start", type="hidden", value=format_datetime(view_end)) 313 page.input(name="later-end", type="hidden", value=format_datetime(later_end)) 314 page.input(name="end", type="hidden", value=format_datetime(view_end)) 315 316 page.p.close() 317 318 def get_time_navigation(self): 319 320 "Return the start and end dates for the calendar view." 321 322 for args in [self.env.get_args(), self.env.get_query()]: 323 if args.has_key("earlier"): 324 start_name, end_name = "earlier-start", "earlier-end" 325 break 326 elif args.has_key("later"): 327 start_name, end_name = "later-start", "later-end" 328 break 329 elif args.has_key("start") or args.has_key("end"): 330 start_name, end_name = "start", "end" 331 break 332 else: 333 return None, None 334 335 view_start = self.get_date_arg(args, start_name) 336 view_end = self.get_date_arg(args, end_name) 337 return view_start, view_end 338 339 def get_time_navigation_args(self): 340 341 "Return a dictionary containing start and/or end navigation details." 342 343 view_start, view_end = self.get_time_navigation() 344 link_args = {} 345 if view_start: 346 link_args["start"] = format_datetime(view_start) 347 if view_end: 348 link_args["end"] = format_datetime(view_end) 349 return link_args 350 351 # Full page output methods. 352 353 def show(self): 354 355 "Show the calendar for the current user." 356 357 self.new_page(title="Calendar") 358 page = self.page 359 360 if self.handle_newevent(): 361 return 362 363 freebusy = self.store.get_freebusy(self.user) 364 365 if not freebusy: 366 page.p("No events scheduled.") 367 return 368 369 participants = self.update_participants() 370 371 # Form controls are used in various places on the calendar page. 372 373 page.form(method="POST") 374 375 self.show_requests_on_page() 376 self.show_participants_on_page(participants) 377 378 # Obtain the user's timezone. 379 380 tzid = self.get_tzid() 381 382 # Day view: start at the earliest known day and produce days until the 383 # latest known day, with expandable sections of empty days. 384 385 view_start, view_end = self.get_time_navigation() 386 view_period = (view_start or view_end) and Period(view_start, view_end, self.get_tzid()) 387 388 self.show_time_navigation(view_start, view_end) 389 390 # Requests are listed and linked to their tentative positions in the 391 # calendar. Other participants are also shown. 392 393 request_summary = self._get_request_summary() 394 395 period_groups = [request_summary, freebusy] 396 period_group_types = ["request", "freebusy"] 397 period_group_sources = ["Pending requests", "Your schedule"] 398 399 for i, participant in enumerate(participants): 400 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 401 period_group_types.append("freebusy-part%d" % i) 402 period_group_sources.append(participant) 403 404 groups = [] 405 group_columns = [] 406 group_types = period_group_types 407 group_sources = period_group_sources 408 all_points = set() 409 410 # Obtain time point information for each group of periods. 411 412 for periods in period_groups: 413 414 # Filter periods outside the given view. 415 416 if view_period: 417 periods = get_overlapping(periods, view_period) 418 419 # Get the time scale with start and end points. 420 421 scale = get_scale(periods, tzid) 422 423 # Get the time slots for the periods. 424 # Time slots are collections of Point objects with lists of active 425 # periods. 426 427 slots = get_slots(scale) 428 429 # Add start of day time points for multi-day periods. 430 431 add_day_start_points(slots, tzid) 432 433 # Record the slots and all time points employed. 434 435 groups.append(slots) 436 all_points.update([point for point, active in slots]) 437 438 # Partition the groups into days. 439 440 days = {} 441 partitioned_groups = [] 442 partitioned_group_types = [] 443 partitioned_group_sources = [] 444 445 for slots, group_type, group_source in zip(groups, group_types, group_sources): 446 447 # Propagate time points to all groups of time slots. 448 449 add_slots(slots, all_points) 450 451 # Count the number of columns employed by the group. 452 453 columns = 0 454 455 # Partition the time slots by day. 456 457 partitioned = {} 458 459 for day, day_slots in partition_by_day(slots).items(): 460 461 # Construct a list of time intervals within the day. 462 463 intervals = [] 464 465 # Convert each partition to a mapping from points to active 466 # periods. 467 468 partitioned[day] = day_points = {} 469 470 last = None 471 472 for point, active in day_slots: 473 columns = max(columns, len(active)) 474 day_points[point] = active 475 476 if last: 477 intervals.append((last, point)) 478 479 last = point 480 481 if last: 482 intervals.append((last, None)) 483 484 if not days.has_key(day): 485 days[day] = set() 486 487 # Record the divisions or intervals within each day. 488 489 days[day].update(intervals) 490 491 # Only include the requests column if it provides objects. 492 493 if group_type != "request" or columns: 494 if group_type != "request": 495 columns += 1 496 group_columns.append(columns) 497 partitioned_groups.append(partitioned) 498 partitioned_group_types.append(group_type) 499 partitioned_group_sources.append(group_source) 500 501 # Add empty days. 502 503 add_empty_days(days, tzid, view_start, view_end) 504 505 # Show controls to change the calendar appearance. 506 507 self.show_calendar_controls() 508 509 # Show the calendar itself. 510 511 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns) 512 513 # End the form region. 514 515 page.form.close() 516 517 # More page fragment methods. 518 519 def show_calendar_day_controls(self, day): 520 521 "Show controls for the given 'day' in the calendar." 522 523 page = self.page 524 daystr, dayid = self._day_value_and_identifier(day) 525 526 # Generate a dynamic stylesheet to allow day selections to colour 527 # specific days. 528 # NOTE: The style details need to be coordinated with the static 529 # NOTE: stylesheet. 530 531 page.style(type="text/css") 532 533 page.add("""\ 534 input.newevent.selector#%s:checked ~ table#region-%s label.day, 535 input.newevent.selector#%s:checked ~ table#region-%s label.timepoint { 536 background-color: #5f4; 537 text-decoration: underline; 538 } 539 """ % (dayid, dayid, dayid, dayid)) 540 541 page.style.close() 542 543 # Generate controls to select days. 544 545 slots = self.env.get_args().get("slot", []) 546 value, identifier = self._day_value_and_identifier(day) 547 self._slot_selector(value, identifier, slots) 548 549 def show_calendar_interval_controls(self, day, intervals): 550 551 "Show controls for the intervals provided by 'day' and 'intervals'." 552 553 page = self.page 554 daystr, dayid = self._day_value_and_identifier(day) 555 556 # Generate a dynamic stylesheet to allow day selections to colour 557 # specific days. 558 # NOTE: The style details need to be coordinated with the static 559 # NOTE: stylesheet. 560 561 l = [] 562 563 for point, endpoint in intervals: 564 timestr, timeid = self._slot_value_and_identifier(point, endpoint) 565 l.append("""\ 566 input.newevent.selector#%s:checked ~ table#region-%s th#region-%s""" % (timeid, dayid, timeid)) 567 568 page.style(type="text/css") 569 570 page.add(",\n".join(l)) 571 page.add(""" { 572 background-color: #5f4; 573 text-decoration: underline; 574 } 575 """) 576 577 page.style.close() 578 579 # Generate controls to select time periods. 580 581 slots = self.env.get_args().get("slot", []) 582 last = None 583 584 # Produce controls for the intervals/slots. Where instants in time are 585 # encountered, they are merged with the following slots, permitting the 586 # selection of contiguous time periods. However, the identifiers 587 # employed by controls corresponding to merged periods will encode the 588 # instant so that labels may reference them conveniently. 589 590 intervals = list(intervals) 591 intervals.sort() 592 593 for point, endpoint in intervals: 594 595 # Merge any previous slot with this one, producing a control. 596 597 if last: 598 _value, identifier = self._slot_value_and_identifier(last, last) 599 value, _identifier = self._slot_value_and_identifier(last, endpoint) 600 self._slot_selector(value, identifier, slots) 601 602 # If representing an instant, hold the slot for merging. 603 604 if endpoint and point.point == endpoint.point: 605 last = point 606 607 # If not representing an instant, produce a control. 608 609 else: 610 value, identifier = self._slot_value_and_identifier(point, endpoint) 611 self._slot_selector(value, identifier, slots) 612 last = None 613 614 # Produce a control for any unmerged slot. 615 616 if last: 617 _value, identifier = self._slot_value_and_identifier(last, last) 618 value, _identifier = self._slot_value_and_identifier(last, endpoint) 619 self._slot_selector(value, identifier, slots) 620 621 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 622 623 """ 624 Show headings for the participants and other scheduling contributors, 625 defined by 'group_types', 'group_sources' and 'group_columns'. 626 """ 627 628 page = self.page 629 630 page.colgroup(span=1, id="columns-timeslot") 631 632 for group_type, columns in zip(group_types, group_columns): 633 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 634 635 page.thead() 636 page.tr() 637 page.th("", class_="emptyheading") 638 639 for group_type, source, columns in zip(group_types, group_sources, group_columns): 640 page.th(source, 641 class_=(group_type == "request" and "requestheading" or "participantheading"), 642 colspan=max(columns, 1)) 643 644 page.tr.close() 645 page.thead.close() 646 647 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, 648 partitioned_group_sources, group_columns): 649 650 """ 651 Show calendar days, defined by a collection of 'days', the contributing 652 period information as 'partitioned_groups' (partitioned by day), the 653 'partitioned_group_types' indicating the kind of contribution involved, 654 the 'partitioned_group_sources' indicating the origin of each group, and 655 the 'group_columns' defining the number of columns in each group. 656 """ 657 658 page = self.page 659 660 # Determine the number of columns required. Where participants provide 661 # no columns for events, one still needs to be provided for the 662 # participant itself. 663 664 all_columns = sum([max(columns, 1) for columns in group_columns]) 665 666 # Determine the days providing time slots. 667 668 all_days = days.items() 669 all_days.sort() 670 671 # Produce a heading and time points for each day. 672 673 i = 0 674 675 for day, intervals in all_days: 676 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 677 is_empty = True 678 679 for slots in groups_for_day: 680 if not slots: 681 continue 682 683 for active in slots.values(): 684 if active: 685 is_empty = False 686 break 687 688 daystr, dayid = self._day_value_and_identifier(day) 689 690 # Put calendar tables within elements for quicker CSS selection. 691 692 page.div(class_="calendar") 693 694 # Show the controls permitting day selection as well as the controls 695 # configuring the new event display. 696 697 self.show_calendar_day_controls(day) 698 self.show_calendar_interval_controls(day, intervals) 699 700 # Show an actual table containing the day information. 701 702 page.table(cellspacing=5, cellpadding=5, class_="calendar %s" % (is_empty and " empty" or ""), id="region-%s" % dayid) 703 704 page.caption(class_="dayheading container separator") 705 self._day_heading(day) 706 page.caption.close() 707 708 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 709 710 page.tbody(class_="points") 711 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 712 page.tbody.close() 713 714 page.table.close() 715 716 # Show a button for scheduling a new event. 717 718 page.p(class_="newevent-with-periods") 719 page.label("Summary:") 720 page.input(name="summary-%d" % i, type="text") 721 page.input(name="newevent-%d" % i, type="submit", value="New event", accesskey="N") 722 page.p.close() 723 724 page.p(class_="newevent-with-periods") 725 page.label("Clear selections", for_="reset", class_="reset") 726 page.p.close() 727 728 page.div.close() 729 730 i += 1 731 732 def show_calendar_points(self, intervals, groups, group_types, group_columns): 733 734 """ 735 Show the time 'intervals' along with period information from the given 736 'groups', having the indicated 'group_types', each with the number of 737 columns given by 'group_columns'. 738 """ 739 740 page = self.page 741 742 # Obtain the user's timezone. 743 744 tzid = self.get_tzid() 745 746 # Get view information for links. 747 748 link_args = self.get_time_navigation_args() 749 750 # Produce a row for each interval. 751 752 intervals = list(intervals) 753 intervals.sort() 754 755 for point, endpoint in intervals: 756 continuation = point.point == get_start_of_day(point.point, tzid) 757 758 # Some rows contain no period details and are marked as such. 759 760 have_active = False 761 have_active_request = False 762 763 for slots, group_type in zip(groups, group_types): 764 if slots and slots.get(point): 765 if group_type == "request": 766 have_active_request = True 767 else: 768 have_active = True 769 770 # Emit properties of the time interval, where post-instant intervals 771 # are also treated as busy. 772 773 css = " ".join([ 774 "slot", 775 (have_active or point.indicator == Point.REPEATED) and "busy" or \ 776 have_active_request and "suggested" or "empty", 777 continuation and "daystart" or "" 778 ]) 779 780 page.tr(class_=css) 781 782 # Produce a time interval heading, spanning two rows if this point 783 # represents an instant. 784 785 if point.indicator == Point.PRINCIPAL: 786 timestr, timeid = self._slot_value_and_identifier(point, endpoint) 787 page.th(class_="timeslot", id="region-%s" % timeid, 788 rowspan=(endpoint and point.point == endpoint.point and 2 or 1)) 789 self._time_point(point, endpoint) 790 page.th.close() 791 792 # Obtain slots for the time point from each group. 793 794 for columns, slots, group_type in zip(group_columns, groups, group_types): 795 active = slots and slots.get(point) 796 797 # Where no periods exist for the given time interval, generate 798 # an empty cell. Where a participant provides no periods at all, 799 # one column is provided; otherwise, one more column than the 800 # number required is provided. 801 802 if not active: 803 self._empty_slot(point, endpoint, max(columns, 1)) 804 continue 805 806 slots = slots.items() 807 slots.sort() 808 spans = get_spans(slots) 809 810 empty = 0 811 812 # Show a column for each active period. 813 814 for p in active: 815 816 # The period can be None, meaning an empty column. 817 818 if p: 819 820 # Flush empty slots preceding this one. 821 822 if empty: 823 self._empty_slot(point, endpoint, empty) 824 empty = 0 825 826 key = p.get_key() 827 span = spans[key] 828 829 # Produce a table cell only at the start of the period 830 # or when continued at the start of a day. 831 # Points defining the ends of instant events should 832 # never define the start of new events. 833 834 if point.indicator == Point.PRINCIPAL and (point.point == p.get_start() or continuation): 835 836 has_continued = continuation and point.point != p.get_start() 837 will_continue = not ends_on_same_day(point.point, p.get_end(), tzid) 838 is_organiser = p.organiser == self.user 839 840 css = " ".join([ 841 "event", 842 has_continued and "continued" or "", 843 will_continue and "continues" or "", 844 p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending", 845 self._have_request(p.uid, p.recurrenceid, "COUNTER", True) and "counter" or "", 846 ]) 847 848 # Only anchor the first cell of events. 849 # Need to only anchor the first period for a recurring 850 # event. 851 852 html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "") 853 854 if point.point == p.get_start() and html_id not in self.html_ids: 855 page.td(class_=css, rowspan=span, id=html_id) 856 self.html_ids.add(html_id) 857 else: 858 page.td(class_=css, rowspan=span) 859 860 # Only link to events if they are not being updated 861 # by requests. 862 863 if not p.summary or \ 864 group_type != "request" and self._have_request(p.uid, p.recurrenceid, None, True): 865 866 page.span(p.summary or "(Participant is busy)") 867 868 # Link to requests and events (including ones for 869 # which counter-proposals exist). 870 871 elif group_type == "request" and self._have_request(p.uid, p.recurrenceid, "COUNTER", True): 872 d = {"counter" : self._period_identifier(p)} 873 d.update(link_args) 874 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, d)) 875 876 else: 877 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, link_args)) 878 879 page.td.close() 880 else: 881 empty += 1 882 883 # Pad with empty columns. 884 885 empty = columns - len(active) 886 887 if empty: 888 self._empty_slot(point, endpoint, empty) 889 890 page.tr.close() 891 892 def _day_heading(self, day): 893 894 """ 895 Generate a heading for 'day' of the following form: 896 897 <label class="day" for="day-20150203">Tuesday, 3 February 2015</label> 898 """ 899 900 page = self.page 901 value, identifier = self._day_value_and_identifier(day) 902 page.label(self.format_date(day, "full"), class_="day", for_=identifier) 903 904 def _time_point(self, point, endpoint): 905 906 """ 907 Generate headings for the 'point' to 'endpoint' period of the following 908 form: 909 910 <label class="timepoint" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 911 <span class="endpoint">10:00:00 CET</span> 912 """ 913 914 page = self.page 915 tzid = self.get_tzid() 916 value, identifier = self._slot_value_and_identifier(point, endpoint) 917 page.label(self.format_time(point.point, "long"), class_="timepoint", for_=identifier) 918 page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint") 919 920 def _slot_selector(self, value, identifier, slots): 921 922 """ 923 Provide a timeslot control having the given 'value', employing the 924 indicated HTML 'identifier', and using the given 'slots' collection 925 to select any control whose 'value' is in this collection, unless the 926 "reset" request parameter has been asserted. 927 """ 928 929 reset = self.env.get_args().has_key("reset") 930 page = self.page 931 if not reset and value in slots: 932 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 933 else: 934 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 935 936 def _empty_slot(self, point, endpoint, colspan): 937 938 """ 939 Show an empty slot cell for the given 'point' and 'endpoint', with the 940 given 'colspan' configuring the cell's appearance. 941 """ 942 943 page = self.page 944 page.td(class_="empty%s" % (point.indicator == Point.PRINCIPAL and " container" or ""), colspan=colspan) 945 if point.indicator == Point.PRINCIPAL: 946 value, identifier = self._slot_value_and_identifier(point, endpoint) 947 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 948 page.td.close() 949 950 def _day_value_and_identifier(self, day): 951 952 "Return a day value and HTML identifier for the given 'day'." 953 954 value = format_datetime(day) 955 identifier = "day-%s" % value 956 return value, identifier 957 958 def _slot_value_and_identifier(self, point, endpoint): 959 960 """ 961 Return a slot value and HTML identifier for the given 'point' and 962 'endpoint'. 963 """ 964 965 value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "") 966 identifier = "slot-%s" % value 967 return value, identifier 968 969 def _period_identifier(self, period): 970 return "%s-%s" % (format_datetime(period.get_start()), format_datetime(period.get_end())) 971 972 def get_date_arg(self, args, name): 973 values = args.get(name) 974 if not values: 975 return None 976 return get_datetime(values[0], {"VALUE-TYPE" : "DATE"}) 977 978 # vim: tabstop=4 expandtab shiftwidth=4