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 for group_type, columns in zip(group_types, group_columns): 732 page.colgroup(span=max(columns, 2), id="columns-%s" % group_type) 733 734 page.thead() 735 page.tr() 736 page.th("", class_="emptyheading") 737 738 for group_type, source, columns in zip(group_types, group_sources, group_columns): 739 page.th(source, 740 class_=(group_type == "request" and "requestheading" or "participantheading"), 741 colspan=max(columns, 2)) 742 743 page.tr.close() 744 page.thead.close() 745 746 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, 747 partitioned_group_sources, group_columns): 748 749 """ 750 Show calendar days, defined by a collection of 'days', the contributing 751 period information as 'partitioned_groups' (partitioned by day), the 752 'partitioned_group_types' indicating the kind of contribution involved, 753 the 'partitioned_group_sources' indicating the origin of each group, and 754 the 'group_columns' defining the number of columns in each group. 755 """ 756 757 page = self.page 758 759 # Determine the number of columns required. Where participants provide 760 # no columns for events, one still needs to be provided for the 761 # participant itself. 762 763 all_columns = sum([max(columns, 1) for columns in group_columns]) 764 765 # Determine the days providing time slots. 766 767 all_days = days.items() 768 all_days.sort() 769 770 # Produce a heading and time points for each day. 771 772 i = 0 773 774 for day, intervals in all_days: 775 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 776 is_empty = True 777 778 for slots in groups_for_day: 779 if not slots: 780 continue 781 782 for active in slots.values(): 783 if active: 784 is_empty = False 785 break 786 787 daystr, dayid = self._day_value_and_identifier(day) 788 789 # Put calendar tables within elements for quicker CSS selection. 790 791 page.div(class_="calendar") 792 793 # Show the controls permitting day selection as well as the controls 794 # configuring the new event display. 795 796 self.show_calendar_day_controls(day) 797 self.show_calendar_interval_controls(day, intervals) 798 799 # Show an actual table containing the day information. 800 801 page.table(cellspacing=5, cellpadding=5, class_="calendar %s" % (is_empty and " empty" or ""), id="region-%s" % dayid) 802 803 page.caption(class_="dayheading container separator") 804 self._day_heading(day) 805 page.caption.close() 806 807 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 808 809 page.tbody(class_="points") 810 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 811 page.tbody.close() 812 813 page.table.close() 814 815 # Show a button for scheduling a new event. 816 817 page.p(class_="newevent-with-periods") 818 page.label("Summary:") 819 page.input(name="summary-%d" % i, type="text") 820 page.input(name="newevent-%d" % i, type="submit", value="New event", accesskey="N") 821 page.p.close() 822 823 page.p(class_="newevent-with-periods") 824 page.label("Clear selections", for_="reset", class_="reset") 825 page.p.close() 826 827 page.div.close() 828 829 i += 1 830 831 def show_calendar_points(self, intervals, groups, group_types, group_columns): 832 833 """ 834 Show the time 'intervals' along with period information from the given 835 'groups', having the indicated 'group_types', each with the number of 836 columns given by 'group_columns'. 837 """ 838 839 page = self.page 840 841 # Obtain the user's timezone. 842 843 tzid = self.get_tzid() 844 845 # Get view information for links. 846 847 link_args = self.get_time_navigation_args() 848 849 # Produce a row for each interval. 850 851 intervals = list(intervals) 852 intervals.sort() 853 854 for point, endpoint in intervals: 855 continuation = point.point == get_start_of_day(point.point, tzid) 856 857 # Some rows contain no period details and are marked as such. 858 859 have_active = False 860 have_active_request = False 861 862 for slots, group_type in zip(groups, group_types): 863 if slots and slots.get(point): 864 if group_type == "request": 865 have_active_request = True 866 else: 867 have_active = True 868 869 # Emit properties of the time interval, where post-instant intervals 870 # are also treated as busy. 871 872 css = " ".join([ 873 "slot", 874 (have_active or point.indicator == Point.REPEATED) and "busy" or \ 875 have_active_request and "suggested" or "empty", 876 continuation and "daystart" or "" 877 ]) 878 879 page.tr(class_=css) 880 881 # Produce a time interval heading, spanning two rows if this point 882 # represents an instant. 883 884 if point.indicator == Point.PRINCIPAL: 885 timestr, timeid = self._slot_value_and_identifier(point, endpoint) 886 page.th(class_="timeslot", id="region-%s" % timeid, 887 rowspan=(endpoint and point.point == endpoint.point and 2 or 1)) 888 self._time_point(point, endpoint) 889 page.th.close() 890 891 # Obtain slots for the time point from each group. 892 893 for columns, slots, group_type in zip(group_columns, groups, group_types): 894 active = slots and slots.get(point) 895 896 # Where no periods exist for the given time interval, generate 897 # an empty cell. Where a participant provides no periods at all, 898 # one column is provided; otherwise, one more column than the 899 # number required is provided. 900 901 if not active: 902 self._empty_slot(point, endpoint, max(columns, 2)) 903 continue 904 905 slots = slots.items() 906 slots.sort() 907 spans = get_spans(slots) 908 909 empty = 0 910 911 # Show a column for each active period. 912 913 for p in active: 914 915 # The period can be None, meaning an empty column. 916 917 if p: 918 919 # Flush empty slots preceding this one. 920 921 if empty: 922 self._empty_slot(point, endpoint, empty) 923 empty = 0 924 925 key = p.get_key() 926 span = spans[key] 927 928 # Produce a table cell only at the start of the period 929 # or when continued at the start of a day. 930 # Points defining the ends of instant events should 931 # never define the start of new events. 932 933 if point.indicator == Point.PRINCIPAL and (point.point == p.get_start() or continuation): 934 935 has_continued = continuation and point.point != p.get_start() 936 will_continue = not ends_on_same_day(point.point, p.get_end(), tzid) 937 is_organiser = p.organiser == self.user 938 939 css = " ".join([ 940 "event", 941 has_continued and "continued" or "", 942 will_continue and "continues" or "", 943 p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending", 944 self._have_request(p.uid, p.recurrenceid, "COUNTER", True) and "counter" or "", 945 ]) 946 947 # Only anchor the first cell of events. 948 # Need to only anchor the first period for a recurring 949 # event. 950 951 html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "") 952 953 if point.point == p.get_start() and html_id not in self.html_ids: 954 page.td(class_=css, rowspan=span, id=html_id) 955 self.html_ids.add(html_id) 956 else: 957 page.td(class_=css, rowspan=span) 958 959 # Only link to events if they are not being updated 960 # by requests. 961 962 if not p.summary or \ 963 group_type != "request" and self._have_request(p.uid, p.recurrenceid, None, True): 964 965 page.span(p.summary or "(Participant is busy)") 966 967 # Link to requests and events (including ones for 968 # which counter-proposals exist). 969 970 elif group_type == "request" and self._have_request(p.uid, p.recurrenceid, "COUNTER", True): 971 d = {"counter" : self._period_identifier(p)} 972 d.update(link_args) 973 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, d)) 974 975 else: 976 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, link_args)) 977 978 page.td.close() 979 else: 980 empty += 1 981 982 # Pad with empty columns. 983 984 empty = columns - len(active) 985 986 if empty: 987 self._empty_slot(point, endpoint, empty, True) 988 989 page.tr.close() 990 991 def _day_heading(self, day): 992 993 """ 994 Generate a heading for 'day' of the following form: 995 996 <label class="day" for="day-20150203">Tuesday, 3 February 2015</label> 997 """ 998 999 page = self.page 1000 value, identifier = self._day_value_and_identifier(day) 1001 page.label(self.format_date(day, "full"), class_="day", for_=identifier) 1002 1003 def _time_point(self, point, endpoint): 1004 1005 """ 1006 Generate headings for the 'point' to 'endpoint' period of the following 1007 form: 1008 1009 <label class="timepoint" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1010 <span class="endpoint">10:00:00 CET</span> 1011 """ 1012 1013 page = self.page 1014 tzid = self.get_tzid() 1015 value, identifier = self._slot_value_and_identifier(point, endpoint) 1016 page.label(self.format_time(point.point, "long"), class_="timepoint", for_=identifier) 1017 page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint") 1018 1019 def _slot_selector(self, value, identifier, slots): 1020 1021 """ 1022 Provide a timeslot control having the given 'value', employing the 1023 indicated HTML 'identifier', and using the given 'slots' collection 1024 to select any control whose 'value' is in this collection, unless the 1025 "reset" request parameter has been asserted. 1026 """ 1027 1028 reset = self.env.get_args().has_key("reset") 1029 page = self.page 1030 if not reset and value in slots: 1031 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1032 else: 1033 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1034 1035 def _empty_slot(self, point, endpoint, colspan, at_end=False): 1036 1037 """ 1038 Show an empty slot cell for the given 'point' and 'endpoint', with the 1039 given 'colspan' configuring the cell's appearance. 1040 """ 1041 1042 page = self.page 1043 page.td(class_="empty%s%s" % (point.indicator == Point.PRINCIPAL and " container" or "", at_end and " padding" or ""), colspan=colspan) 1044 if point.indicator == Point.PRINCIPAL: 1045 value, identifier = self._slot_value_and_identifier(point, endpoint) 1046 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1047 page.td.close() 1048 1049 def _day_value_and_identifier(self, day): 1050 1051 "Return a day value and HTML identifier for the given 'day'." 1052 1053 value = format_datetime(day) 1054 identifier = "day-%s" % value 1055 return value, identifier 1056 1057 def _slot_value_and_identifier(self, point, endpoint): 1058 1059 """ 1060 Return a slot value and HTML identifier for the given 'point' and 1061 'endpoint'. 1062 """ 1063 1064 value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "") 1065 identifier = "slot-%s" % value 1066 return value, identifier 1067 1068 def _period_identifier(self, period): 1069 return "%s-%s" % (format_datetime(period.get_start()), format_datetime(period.get_end())) 1070 1071 def get_date_arg(self, args, name): 1072 values = args.get(name) 1073 if not values: 1074 return None 1075 return get_datetime(values[0], {"VALUE-TYPE" : "DATE"}) 1076 1077 # vim: tabstop=4 expandtab shiftwidth=4