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