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