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