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