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