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