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