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