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