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