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