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