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 participants = self.update_participants() 534 535 # Form controls are used in various places on the calendar page. 536 537 page.form(method="POST") 538 539 self.show_requests_on_page() 540 self.show_participants_on_page(participants) 541 542 # Day view: start at the earliest known day and produce days until the 543 # latest known day, with expandable sections of empty days. 544 545 view_period = self.get_view_period() 546 547 (days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns) = \ 548 self.get_period_group_details(freebusy, participants, view_period) 549 550 # Add empty days. 551 552 add_empty_days(days, self.get_tzid(), view_period.get_start(), view_period.get_end()) 553 554 # Show controls to change the calendar appearance. 555 556 self.show_view_period(view_period) 557 self.show_calendar_controls() 558 self.show_time_navigation(view_period) 559 560 # Show the calendar itself. 561 562 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns) 563 564 # End the form region. 565 566 page.form.close() 567 568 # More page fragment methods. 569 570 def show_calendar_day_controls(self, day): 571 572 "Show controls for the given 'day' in the calendar." 573 574 page = self.page 575 daystr, dayid = self._day_value_and_identifier(day) 576 577 # Generate a dynamic stylesheet to allow day selections to colour 578 # specific days. 579 # NOTE: The style details need to be coordinated with the static 580 # NOTE: stylesheet. 581 582 page.style(type="text/css") 583 584 page.add("""\ 585 input.newevent.selector#%s:checked ~ table#region-%s label.day, 586 input.newevent.selector#%s:checked ~ table#region-%s label.timepoint { 587 background-color: #5f4; 588 text-decoration: underline; 589 } 590 """ % (dayid, dayid, dayid, dayid)) 591 592 page.style.close() 593 594 # Generate controls to select days. 595 596 slots = self.env.get_args().get("slot", []) 597 value, identifier = self._day_value_and_identifier(day) 598 self._slot_selector(value, identifier, slots) 599 600 def show_calendar_interval_controls(self, day, intervals): 601 602 "Show controls for the intervals provided by 'day' and 'intervals'." 603 604 page = self.page 605 daystr, dayid = self._day_value_and_identifier(day) 606 607 # Generate a dynamic stylesheet to allow day selections to colour 608 # specific days. 609 # NOTE: The style details need to be coordinated with the static 610 # NOTE: stylesheet. 611 612 l = [] 613 614 for point, endpoint in intervals: 615 timestr, timeid = self._slot_value_and_identifier(point, endpoint) 616 l.append("""\ 617 input.newevent.selector#%s:checked ~ table#region-%s th#region-%s""" % (timeid, dayid, timeid)) 618 619 page.style(type="text/css") 620 621 page.add(",\n".join(l)) 622 page.add(""" { 623 background-color: #5f4; 624 text-decoration: underline; 625 } 626 """) 627 628 page.style.close() 629 630 # Generate controls to select time periods. 631 632 slots = self.env.get_args().get("slot", []) 633 last = None 634 635 # Produce controls for the intervals/slots. Where instants in time are 636 # encountered, they are merged with the following slots, permitting the 637 # selection of contiguous time periods. However, the identifiers 638 # employed by controls corresponding to merged periods will encode the 639 # instant so that labels may reference them conveniently. 640 641 intervals = list(intervals) 642 intervals.sort() 643 644 for point, endpoint in intervals: 645 646 # Merge any previous slot with this one, producing a control. 647 648 if last: 649 _value, identifier = self._slot_value_and_identifier(last, last) 650 value, _identifier = self._slot_value_and_identifier(last, endpoint) 651 self._slot_selector(value, identifier, slots) 652 653 # If representing an instant, hold the slot for merging. 654 655 if endpoint and point.point == endpoint.point: 656 last = point 657 658 # If not representing an instant, produce a control. 659 660 else: 661 value, identifier = self._slot_value_and_identifier(point, endpoint) 662 self._slot_selector(value, identifier, slots) 663 last = None 664 665 # Produce a control for any unmerged slot. 666 667 if last: 668 _value, identifier = self._slot_value_and_identifier(last, last) 669 value, _identifier = self._slot_value_and_identifier(last, endpoint) 670 self._slot_selector(value, identifier, slots) 671 672 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 673 674 """ 675 Show headings for the participants and other scheduling contributors, 676 defined by 'group_types', 'group_sources' and 'group_columns'. 677 """ 678 679 page = self.page 680 681 page.colgroup(span=1, id="columns-timeslot") 682 683 for group_type, columns in zip(group_types, group_columns): 684 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 685 686 page.thead() 687 page.tr() 688 page.th("", class_="emptyheading") 689 690 for group_type, source, columns in zip(group_types, group_sources, group_columns): 691 page.th(source, 692 class_=(group_type == "request" and "requestheading" or "participantheading"), 693 colspan=max(columns, 1)) 694 695 page.tr.close() 696 page.thead.close() 697 698 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, 699 partitioned_group_sources, group_columns): 700 701 """ 702 Show calendar days, defined by a collection of 'days', the contributing 703 period information as 'partitioned_groups' (partitioned by day), the 704 'partitioned_group_types' indicating the kind of contribution involved, 705 the 'partitioned_group_sources' indicating the origin of each group, and 706 the 'group_columns' defining the number of columns in each group. 707 """ 708 709 page = self.page 710 711 # Determine the number of columns required. Where participants provide 712 # no columns for events, one still needs to be provided for the 713 # participant itself. 714 715 all_columns = sum([max(columns, 1) for columns in group_columns]) 716 717 # Determine the days providing time slots. 718 719 all_days = days.items() 720 all_days.sort() 721 722 # Produce a heading and time points for each day. 723 724 i = 0 725 726 for day, intervals in all_days: 727 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 728 is_empty = True 729 730 for slots in groups_for_day: 731 if not slots: 732 continue 733 734 for active in slots.values(): 735 if active: 736 is_empty = False 737 break 738 739 daystr, dayid = self._day_value_and_identifier(day) 740 741 # Put calendar tables within elements for quicker CSS selection. 742 743 page.div(class_="calendar") 744 745 # Show the controls permitting day selection as well as the controls 746 # configuring the new event display. 747 748 self.show_calendar_day_controls(day) 749 self.show_calendar_interval_controls(day, intervals) 750 751 # Show an actual table containing the day information. 752 753 page.table(cellspacing=5, cellpadding=5, class_="calendar %s" % (is_empty and " empty" or ""), id="region-%s" % dayid) 754 755 page.caption(class_="dayheading container separator") 756 self._day_heading(day) 757 page.caption.close() 758 759 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 760 761 page.tbody(class_="points") 762 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 763 page.tbody.close() 764 765 page.table.close() 766 767 # Show a button for scheduling a new event. 768 769 page.p(class_="newevent-with-periods") 770 page.label("Summary:") 771 page.input(name="summary-%d" % i, type="text") 772 page.input(name="newevent-%d" % i, type="submit", value="New event", accesskey="N") 773 page.p.close() 774 775 page.p(class_="newevent-with-periods") 776 page.label("Clear selections", for_="reset", class_="reset") 777 page.p.close() 778 779 page.div.close() 780 781 i += 1 782 783 def show_calendar_points(self, intervals, groups, group_types, group_columns): 784 785 """ 786 Show the time 'intervals' along with period information from the given 787 'groups', having the indicated 'group_types', each with the number of 788 columns given by 'group_columns'. 789 """ 790 791 page = self.page 792 793 # Obtain the user's timezone. 794 795 tzid = self.get_tzid() 796 797 # Get view information for links. 798 799 link_args = self.get_time_navigation_args() 800 801 # Produce a row for each interval. 802 803 intervals = list(intervals) 804 intervals.sort() 805 806 for point, endpoint in intervals: 807 continuation = point.point == get_start_of_day(point.point, tzid) 808 809 # Some rows contain no period details and are marked as such. 810 811 have_active = False 812 have_active_request = False 813 814 for slots, group_type in zip(groups, group_types): 815 if slots and slots.get(point): 816 if group_type == "request": 817 have_active_request = True 818 else: 819 have_active = True 820 821 # Emit properties of the time interval, where post-instant intervals 822 # are also treated as busy. 823 824 css = " ".join([ 825 "slot", 826 (have_active or point.indicator == Point.REPEATED) and "busy" or \ 827 have_active_request and "suggested" or "empty", 828 continuation and "daystart" or "" 829 ]) 830 831 page.tr(class_=css) 832 833 # Produce a time interval heading, spanning two rows if this point 834 # represents an instant. 835 836 if point.indicator == Point.PRINCIPAL: 837 timestr, timeid = self._slot_value_and_identifier(point, endpoint) 838 page.th(class_="timeslot", id="region-%s" % timeid, 839 rowspan=(endpoint and point.point == endpoint.point and 2 or 1)) 840 self._time_point(point, endpoint) 841 page.th.close() 842 843 # Obtain slots for the time point from each group. 844 845 for columns, slots, group_type in zip(group_columns, groups, group_types): 846 active = slots and slots.get(point) 847 848 # Where no periods exist for the given time interval, generate 849 # an empty cell. Where a participant provides no periods at all, 850 # one column is provided; otherwise, one more column than the 851 # number required is provided. 852 853 if not active: 854 self._empty_slot(point, endpoint, max(columns, 1)) 855 continue 856 857 slots = slots.items() 858 slots.sort() 859 spans = get_spans(slots) 860 861 empty = 0 862 863 # Show a column for each active period. 864 865 for p in active: 866 867 # The period can be None, meaning an empty column. 868 869 if p: 870 871 # Flush empty slots preceding this one. 872 873 if empty: 874 self._empty_slot(point, endpoint, empty) 875 empty = 0 876 877 key = p.get_key() 878 span = spans[key] 879 880 # Produce a table cell only at the start of the period 881 # or when continued at the start of a day. 882 # Points defining the ends of instant events should 883 # never define the start of new events. 884 885 if point.indicator == Point.PRINCIPAL and (point.point == p.get_start() or continuation): 886 887 has_continued = continuation and point.point != p.get_start() 888 will_continue = not ends_on_same_day(point.point, p.get_end(), tzid) 889 is_organiser = p.organiser == self.user 890 891 css = " ".join([ 892 "event", 893 has_continued and "continued" or "", 894 will_continue and "continues" or "", 895 p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending", 896 self._have_request(p.uid, p.recurrenceid, "COUNTER", True) and "counter" or "", 897 ]) 898 899 # Only anchor the first cell of events. 900 # Need to only anchor the first period for a recurring 901 # event. 902 903 html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "") 904 905 if point.point == p.get_start() and html_id not in self.html_ids: 906 page.td(class_=css, rowspan=span, id=html_id) 907 self.html_ids.add(html_id) 908 else: 909 page.td(class_=css, rowspan=span) 910 911 # Only link to events if they are not being updated 912 # by requests. 913 914 if not p.summary or \ 915 group_type != "request" and self._have_request(p.uid, p.recurrenceid, None, True): 916 917 page.span(p.summary or "(Participant is busy)") 918 919 # Link to requests and events (including ones for 920 # which counter-proposals exist). 921 922 elif group_type == "request" and self._have_request(p.uid, p.recurrenceid, "COUNTER", True): 923 d = {"counter" : self._period_identifier(p)} 924 d.update(link_args) 925 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, d)) 926 927 else: 928 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, link_args)) 929 930 page.td.close() 931 else: 932 empty += 1 933 934 # Pad with empty columns. 935 936 empty = columns - len(active) 937 938 if empty: 939 self._empty_slot(point, endpoint, empty, True) 940 941 page.tr.close() 942 943 def _day_heading(self, day): 944 945 """ 946 Generate a heading for 'day' of the following form: 947 948 <label class="day" for="day-20150203">Tuesday, 3 February 2015</label> 949 """ 950 951 page = self.page 952 value, identifier = self._day_value_and_identifier(day) 953 page.label(self.format_date(day, "full"), class_="day", for_=identifier) 954 955 def _time_point(self, point, endpoint): 956 957 """ 958 Generate headings for the 'point' to 'endpoint' period of the following 959 form: 960 961 <label class="timepoint" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 962 <span class="endpoint">10:00:00 CET</span> 963 """ 964 965 page = self.page 966 tzid = self.get_tzid() 967 value, identifier = self._slot_value_and_identifier(point, endpoint) 968 page.label(self.format_time(point.point, "long"), class_="timepoint", for_=identifier) 969 page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint") 970 971 def _slot_selector(self, value, identifier, slots): 972 973 """ 974 Provide a timeslot control having the given 'value', employing the 975 indicated HTML 'identifier', and using the given 'slots' collection 976 to select any control whose 'value' is in this collection, unless the 977 "reset" request parameter has been asserted. 978 """ 979 980 reset = self.env.get_args().has_key("reset") 981 page = self.page 982 if not reset and value in slots: 983 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 984 else: 985 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 986 987 def _empty_slot(self, point, endpoint, colspan, at_end=False): 988 989 """ 990 Show an empty slot cell for the given 'point' and 'endpoint', with the 991 given 'colspan' configuring the cell's appearance. 992 """ 993 994 page = self.page 995 page.td(class_="empty%s%s" % (point.indicator == Point.PRINCIPAL and " container" or "", at_end and " padding" or ""), colspan=colspan) 996 if point.indicator == Point.PRINCIPAL: 997 value, identifier = self._slot_value_and_identifier(point, endpoint) 998 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 999 page.td.close() 1000 1001 def _day_value_and_identifier(self, day): 1002 1003 "Return a day value and HTML identifier for the given 'day'." 1004 1005 value = format_datetime(day) 1006 identifier = "day-%s" % value 1007 return value, identifier 1008 1009 def _slot_value_and_identifier(self, point, endpoint): 1010 1011 """ 1012 Return a slot value and HTML identifier for the given 'point' and 1013 'endpoint'. 1014 """ 1015 1016 value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "") 1017 identifier = "slot-%s" % value 1018 return value, identifier 1019 1020 def _period_identifier(self, period): 1021 return "%s-%s" % (format_datetime(period.get_start()), format_datetime(period.get_end())) 1022 1023 def get_date_arg(self, args, name): 1024 values = args.get(name) 1025 if not values: 1026 return None 1027 return get_datetime(values[0], {"VALUE-TYPE" : "DATE"}) 1028 1029 # vim: tabstop=4 expandtab shiftwidth=4