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