1 #!/usr/bin/env python 2 3 """ 4 A Web interface to an event calendar. 5 6 Copyright (C) 2014, 2015, 2016 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_scale, get_slots, get_spans, partition_by_day, \ 30 remove_end_slot, Period, Point 31 from imipweb.resource import FormUtilities, ResourceClient 32 33 class CalendarPage(ResourceClient, FormUtilities): 34 35 "A request handler for the calendar page." 36 37 # Request logic methods. 38 39 def handle_newevent(self): 40 41 """ 42 Handle any new event operation, creating a new event and redirecting to 43 the event page for further activity. 44 """ 45 46 _ = self.get_translator() 47 48 # Handle a submitted form. 49 50 args = self.env.get_args() 51 52 for key in args.keys(): 53 if key.startswith("newevent-"): 54 i = key[len("newevent-"):] 55 break 56 else: 57 return False 58 59 # Create a new event using the available information. 60 61 slots = args.get("slot", []) 62 participants = args.get("participants", []) 63 summary = args.get("summary-%s" % i, [None])[0] 64 65 if not slots: 66 return False 67 68 # Obtain the user's timezone. 69 70 tzid = self.get_tzid() 71 72 # Coalesce the selected slots. 73 74 slots.sort() 75 coalesced = [] 76 last = None 77 78 for slot in slots: 79 start, end = (slot.split("-", 1) + [None])[:2] 80 start = get_datetime(start, {"TZID" : tzid}) 81 end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) 82 83 if last: 84 last_start, last_end = last 85 86 # Merge adjacent dates and datetimes. 87 88 if start == last_end or \ 89 not isinstance(start, datetime) and \ 90 get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): 91 92 last = last_start, end 93 continue 94 95 # Handle datetimes within dates. 96 # Datetime periods are within single days and are therefore 97 # discarded. 98 99 elif not isinstance(last_start, datetime) and \ 100 get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): 101 102 continue 103 104 # Add separate dates and datetimes. 105 106 else: 107 coalesced.append(last) 108 109 last = start, end 110 111 if last: 112 coalesced.append(last) 113 114 # Invent a unique identifier. 115 116 utcnow = get_timestamp() 117 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 118 119 # Create a calendar object and store it as a request. 120 121 record = [] 122 rwrite = record.append 123 124 # Define a single occurrence if only one coalesced slot exists. 125 126 start, end = coalesced[0] 127 start_value, start_attr = get_datetime_item(start, tzid) 128 end_value, end_attr = get_datetime_item(end, tzid) 129 user_attr = self.get_user_attributes() 130 131 rwrite(("UID", {}, uid)) 132 rwrite(("SUMMARY", {}, summary or (_("New event at %s") % utcnow))) 133 rwrite(("DTSTAMP", {}, utcnow)) 134 rwrite(("DTSTART", start_attr, start_value)) 135 rwrite(("DTEND", end_attr, end_value)) 136 rwrite(("ORGANIZER", user_attr, self.user)) 137 138 cn_participants = uri_parts(filter(None, participants)) 139 participants = [] 140 141 for cn, participant in cn_participants: 142 d = {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"} 143 if cn: 144 d["CN"] = cn 145 rwrite(("ATTENDEE", d, participant)) 146 participants.append(participant) 147 148 if self.user not in participants: 149 d = {"PARTSTAT" : "ACCEPTED"} 150 d.update(user_attr) 151 rwrite(("ATTENDEE", d, self.user)) 152 153 # Define additional occurrences if many slots are defined. 154 155 rdates = [] 156 157 for start, end in coalesced[1:]: 158 start_value, start_attr = get_datetime_item(start, tzid) 159 end_value, end_attr = get_datetime_item(end, tzid) 160 rdates.append("%s/%s" % (start_value, end_value)) 161 162 if rdates: 163 rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates)) 164 165 node = ("VEVENT", {}, record) 166 167 self.store.set_event(self.user, uid, None, node=node) 168 self.store.queue_request(self.user, uid) 169 170 # Redirect to the object (or the first of the objects), where instead of 171 # attendee controls, there will be organiser controls. 172 173 self.redirect(self.link_to(uid, args=self.get_time_navigation_args())) 174 return True 175 176 def update_participants(self): 177 178 "Update the participants used for scheduling purposes." 179 180 args = self.env.get_args() 181 participants = args.get("participants", []) 182 183 try: 184 for name, value in args.items(): 185 if name.startswith("remove-participant-"): 186 i = int(name[len("remove-participant-"):]) 187 del participants[i] 188 break 189 except ValueError: 190 pass 191 192 # Trim empty participants. 193 194 while participants and not participants[-1].strip(): 195 participants.pop() 196 197 return participants 198 199 # Page fragment methods. 200 201 def show_user_navigation(self): 202 203 "Show user-specific navigation." 204 205 page = self.page 206 user_attr = self.get_user_attributes() 207 208 page.p(id_="user-navigation") 209 page.a(get_verbose_address(self.user, user_attr), href=self.link_to("profile"), class_="username") 210 page.p.close() 211 212 def show_requests_on_page(self): 213 214 "Show requests for the current user." 215 216 _ = self.get_translator() 217 218 page = self.page 219 view_period = self.get_view_period() 220 duration = view_period and view_period.get_duration() or timedelta(1) 221 222 # NOTE: This list could be more informative, but it is envisaged that 223 # NOTE: the requests would be visited directly anyway. 224 225 requests = self._get_requests() 226 227 page.div(id="pending-requests") 228 229 if requests: 230 page.p(_("Pending requests:")) 231 232 page.ul() 233 234 for uid, recurrenceid, request_type in requests: 235 obj = self._get_object(uid, recurrenceid) 236 if obj: 237 238 # Provide a link showing the request in context. 239 240 periods = self.get_periods(obj) 241 if periods: 242 start = to_date(periods[0].get_start()) 243 end = max(to_date(periods[0].get_end()), start + duration) 244 d = {"start" : format_datetime(start), "end" : format_datetime(end)} 245 page.li() 246 page.a(obj.get_value("SUMMARY"), href="%s#request-%s-%s" % (self.link_to(args=d), uid, recurrenceid or "")) 247 page.li.close() 248 249 page.ul.close() 250 251 else: 252 page.p(_("There are no pending requests.")) 253 254 page.div.close() 255 256 def show_participants_on_page(self, participants): 257 258 "Show participants for scheduling purposes." 259 260 _ = self.get_translator() 261 262 page = self.page 263 264 # Show any specified participants together with controls to remove and 265 # add participants. 266 267 page.div(id="participants") 268 269 page.p(_("Participants for scheduling:")) 270 271 for i, participant in enumerate(participants): 272 page.p() 273 page.input(name="participants", type="text", value=participant) 274 page.input(name="remove-participant-%d" % i, type="submit", value=_("Remove")) 275 page.p.close() 276 277 page.p() 278 page.input(name="participants", type="text") 279 page.input(name="add-participant", type="submit", value=_("Add")) 280 page.p.close() 281 282 page.div.close() 283 284 def show_calendar_controls(self): 285 286 """ 287 Show controls for hiding empty days and busy slots in the calendar. 288 289 The positioning of the controls, paragraph and table are important here: 290 the CSS file describes the relationship between them and the calendar 291 tables. 292 """ 293 294 _ = self.get_translator() 295 296 page = self.page 297 args = self.env.get_args() 298 299 self.control("showdays", "checkbox", "show", ("show" in args.get("showdays", [])), id="showdays", accesskey="D") 300 self.control("hidebusy", "checkbox", "hide", ("hide" in args.get("hidebusy", [])), id="hidebusy", accesskey="B") 301 302 page.p(id_="calendar-controls", class_="controls") 303 page.span(_("Select days or periods for a new event.")) 304 page.label(_("Hide busy time periods"), for_="hidebusy", class_="hidebusy enable") 305 page.label(_("Show busy time periods"), for_="hidebusy", class_="hidebusy disable") 306 page.label(_("Show empty days"), for_="showdays", class_="showdays disable") 307 page.label(_("Hide empty days"), for_="showdays", class_="showdays enable") 308 page.input(name="reset", type="submit", value=_("Clear selections"), id="reset") 309 page.p.close() 310 311 def show_time_navigation(self, freebusy, view_period): 312 313 """ 314 Show the calendar navigation links for the schedule defined by 315 'freebusy' and for the period defined by 'view_period'. 316 """ 317 318 _ = self.get_translator() 319 320 page = self.page 321 view_start = view_period.get_start() 322 view_end = view_period.get_end() 323 duration = view_period.get_duration() 324 325 preceding_events = view_start and freebusy.get_overlapping(Period(None, view_start, self.get_tzid())) or [] 326 following_events = view_end and freebusy.get_overlapping(Period(view_end, None, self.get_tzid())) or [] 327 328 last_preceding = preceding_events and to_date(preceding_events[-1].get_end()) + timedelta(1) or None 329 first_following = following_events and to_date(following_events[0].get_start()) or None 330 331 page.p(id_="time-navigation") 332 333 if view_start: 334 page.input(name="start", type="hidden", value=format_datetime(view_start)) 335 336 if last_preceding: 337 preceding_start = last_preceding - duration 338 page.label(_("Show earlier events"), for_="earlier-events", class_="earlier-events") 339 page.input(name="earlier-events", id_="earlier-events", type="submit") 340 page.input(name="earlier-events-start", type="hidden", value=format_datetime(preceding_start)) 341 page.input(name="earlier-events-end", type="hidden", value=format_datetime(last_preceding)) 342 343 earlier_start = view_start - duration 344 page.label(_("Show earlier"), for_="earlier", class_="earlier") 345 page.input(name="earlier", id_="earlier", type="submit") 346 page.input(name="earlier-start", type="hidden", value=format_datetime(earlier_start)) 347 page.input(name="earlier-end", type="hidden", value=format_datetime(view_start)) 348 349 if view_end: 350 page.input(name="end", type="hidden", value=format_datetime(view_end)) 351 352 later_end = view_end + duration 353 page.label(_("Show later"), for_="later", class_="later") 354 page.input(name="later", id_="later", type="submit") 355 page.input(name="later-start", type="hidden", value=format_datetime(view_end)) 356 page.input(name="later-end", type="hidden", value=format_datetime(later_end)) 357 358 if first_following: 359 following_end = first_following + duration 360 page.label(_("Show later events"), for_="later-events", class_="later-events") 361 page.input(name="later-events", id_="later-events", type="submit") 362 page.input(name="later-events-start", type="hidden", value=format_datetime(first_following)) 363 page.input(name="later-events-end", type="hidden", value=format_datetime(following_end)) 364 365 page.p.close() 366 367 def get_time_navigation(self): 368 369 "Return the start and end dates for the calendar view." 370 371 for args in [self.env.get_args(), self.env.get_query()]: 372 if args.has_key("earlier"): 373 start_name, end_name = "earlier-start", "earlier-end" 374 break 375 elif args.has_key("earlier-events"): 376 start_name, end_name = "earlier-events-start", "earlier-events-end" 377 break 378 elif args.has_key("later"): 379 start_name, end_name = "later-start", "later-end" 380 break 381 elif args.has_key("later-events"): 382 start_name, end_name = "later-events-start", "later-events-end" 383 break 384 elif args.has_key("start") or args.has_key("end"): 385 start_name, end_name = "start", "end" 386 break 387 else: 388 return None, None 389 390 view_start = self.get_date_arg(args, start_name) 391 view_end = self.get_date_arg(args, end_name) 392 return view_start, view_end 393 394 def get_time_navigation_args(self): 395 396 "Return a dictionary containing start and/or end navigation details." 397 398 view_period = self.get_view_period() 399 view_start = view_period.get_start() 400 view_end = view_period.get_end() 401 link_args = {} 402 if view_start: 403 link_args["start"] = format_datetime(view_start) 404 if view_end: 405 link_args["end"] = format_datetime(view_end) 406 return link_args 407 408 def get_view_period(self): 409 410 "Return the current view period." 411 412 view_start, view_end = self.get_time_navigation() 413 414 # Without any explicit limits, impose a reasonable view period. 415 416 if not (view_start or view_end): 417 view_start = get_date() 418 view_end = get_date(timedelta(7)) 419 420 return Period(view_start, view_end, self.get_tzid()) 421 422 def show_view_period(self, view_period): 423 424 "Show a description of the 'view_period'." 425 426 _ = self.get_translator() 427 428 page = self.page 429 430 view_start = view_period.get_start() 431 view_end = view_period.get_end() 432 433 if not (view_start or view_end): 434 return 435 436 page.p(class_="view-period") 437 438 if view_start and view_end: 439 page.add(_("Showing events from %(start)s until %(end)s") % { 440 "start" : self.format_date(view_start, "full"), 441 "end" : self.format_date(view_end, "full")}) 442 elif view_start: 443 page.add(_("Showing events from %s") % self.format_date(view_start, "full")) 444 elif view_end: 445 page.add(_("Showing events until %s") % self.format_date(view_end, "full")) 446 447 page.p.close() 448 449 def get_period_group_details(self, freebusy, participants, view_period): 450 451 """ 452 Return details of periods in the given 'freebusy' collection and for the 453 collections of the given 'participants'. 454 """ 455 456 _ = self.get_translator() 457 458 # Obtain the user's timezone. 459 460 tzid = self.get_tzid() 461 462 # Requests are listed and linked to their tentative positions in the 463 # calendar. Other participants are also shown. 464 465 request_summary = self._get_request_summary() 466 467 period_groups = [request_summary, freebusy] 468 period_group_types = ["request", "freebusy"] 469 period_group_sources = [_("Pending requests"), _("Your schedule")] 470 471 for i, participant in enumerate(participants): 472 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 473 period_group_types.append("freebusy-part%d" % i) 474 period_group_sources.append(participant) 475 476 groups = [] 477 group_columns = [] 478 group_types = period_group_types 479 group_sources = period_group_sources 480 all_points = set() 481 482 # Obtain time point information for each group of periods. 483 484 for periods in period_groups: 485 486 # Filter periods outside the given view. 487 488 if view_period: 489 periods = get_overlapping(periods, view_period) 490 491 # Get the time scale with start and end points. 492 493 scale = get_scale(periods, tzid, view_period) 494 495 # Get the time slots for the periods. 496 # Time slots are collections of Point objects with lists of active 497 # periods. 498 499 slots = get_slots(scale) 500 501 # Add start of day time points for multi-day periods. 502 503 add_day_start_points(slots, tzid) 504 505 # Remove the slot at the end of a view. 506 507 if view_period: 508 remove_end_slot(slots, view_period) 509 510 # Record the slots and all time points employed. 511 512 groups.append(slots) 513 all_points.update([point for point, active in slots]) 514 515 # Partition the groups into days. 516 517 days = {} 518 partitioned_groups = [] 519 partitioned_group_types = [] 520 partitioned_group_sources = [] 521 522 for slots, group_type, group_source in zip(groups, group_types, group_sources): 523 524 # Propagate time points to all groups of time slots. 525 526 add_slots(slots, all_points) 527 528 # Count the number of columns employed by the group. 529 530 columns = 0 531 532 # Partition the time slots by day. 533 534 partitioned = {} 535 536 for day, day_slots in partition_by_day(slots).items(): 537 538 # Construct a list of time intervals within the day. 539 540 intervals = [] 541 542 # Convert each partition to a mapping from points to active 543 # periods. 544 545 partitioned[day] = day_points = {} 546 547 last = None 548 549 for point, active in day_slots: 550 columns = max(columns, len(active)) 551 day_points[point] = active 552 553 if last: 554 intervals.append((last, point)) 555 556 last = point 557 558 if last: 559 intervals.append((last, None)) 560 561 if not days.has_key(day): 562 days[day] = set() 563 564 # Record the divisions or intervals within each day. 565 566 days[day].update(intervals) 567 568 # Only include the requests column if it provides objects. 569 570 if group_type != "request" or columns: 571 if group_type != "request": 572 columns += 1 573 group_columns.append(columns) 574 partitioned_groups.append(partitioned) 575 partitioned_group_types.append(group_type) 576 partitioned_group_sources.append(group_source) 577 578 return days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns 579 580 # Full page output methods. 581 582 def show(self): 583 584 "Show the calendar for the current user." 585 586 _ = self.get_translator() 587 588 self.new_page(title=_("Calendar")) 589 page = self.page 590 591 if self.handle_newevent(): 592 return 593 594 freebusy = self.store.get_freebusy(self.user) 595 participants = self.update_participants() 596 597 # Form controls are used in various places on the calendar page. 598 599 page.form(method="POST") 600 601 self.show_user_navigation() 602 self.show_requests_on_page() 603 self.show_participants_on_page(participants) 604 605 # Get the view period and details of events within it and outside it. 606 607 view_period = self.get_view_period() 608 609 # Day view: start at the earliest known day and produce days until the 610 # latest known day, with expandable sections of empty days. 611 612 (days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns) = \ 613 self.get_period_group_details(freebusy, participants, view_period) 614 615 # Add empty days. 616 617 add_empty_days(days, self.get_tzid(), view_period.get_start(), view_period.get_end()) 618 619 # Show controls to change the calendar appearance. 620 621 self.show_view_period(view_period) 622 self.show_calendar_controls() 623 self.show_time_navigation(freebusy, view_period) 624 625 # Show the calendar itself. 626 627 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns) 628 629 # End the form region. 630 631 page.form.close() 632 633 # More page fragment methods. 634 635 def show_calendar_day_controls(self, day): 636 637 "Show controls for the given 'day' in the calendar." 638 639 page = self.page 640 daystr, dayid = self._day_value_and_identifier(day) 641 642 # Generate a dynamic stylesheet to allow day selections to colour 643 # specific days. 644 # NOTE: The style details need to be coordinated with the static 645 # NOTE: stylesheet. 646 647 page.style(type="text/css") 648 649 page.add("""\ 650 input.newevent.selector#%s:checked ~ table#region-%s label.day, 651 input.newevent.selector#%s:checked ~ table#region-%s label.timepoint { 652 background-color: #5f4; 653 text-decoration: underline; 654 } 655 """ % (dayid, dayid, dayid, dayid)) 656 657 page.style.close() 658 659 # Generate controls to select days. 660 661 slots = self.env.get_args().get("slot", []) 662 value, identifier = self._day_value_and_identifier(day) 663 self._slot_selector(value, identifier, slots) 664 665 def show_calendar_interval_controls(self, day, intervals): 666 667 "Show controls for the intervals provided by 'day' and 'intervals'." 668 669 page = self.page 670 daystr, dayid = self._day_value_and_identifier(day) 671 672 # Generate a dynamic stylesheet to allow day selections to colour 673 # specific days. 674 # NOTE: The style details need to be coordinated with the static 675 # NOTE: stylesheet. 676 677 l = [] 678 679 for point, endpoint in intervals: 680 timestr, timeid = self._slot_value_and_identifier(point, endpoint) 681 l.append("""\ 682 input.newevent.selector#%s:checked ~ table#region-%s th#region-%s""" % (timeid, dayid, timeid)) 683 684 page.style(type="text/css") 685 686 page.add(",\n".join(l)) 687 page.add(""" { 688 background-color: #5f4; 689 text-decoration: underline; 690 } 691 """) 692 693 page.style.close() 694 695 # Generate controls to select time periods. 696 697 slots = self.env.get_args().get("slot", []) 698 last = None 699 700 # Produce controls for the intervals/slots. Where instants in time are 701 # encountered, they are merged with the following slots, permitting the 702 # selection of contiguous time periods. However, the identifiers 703 # employed by controls corresponding to merged periods will encode the 704 # instant so that labels may reference them conveniently. 705 706 intervals = list(intervals) 707 intervals.sort() 708 709 for point, endpoint in intervals: 710 711 # Merge any previous slot with this one, producing a control. 712 713 if last: 714 _value, identifier = self._slot_value_and_identifier(last, last) 715 value, _identifier = self._slot_value_and_identifier(last, endpoint) 716 self._slot_selector(value, identifier, slots) 717 718 # If representing an instant, hold the slot for merging. 719 720 if endpoint and point.point == endpoint.point: 721 last = point 722 723 # If not representing an instant, produce a control. 724 725 else: 726 value, identifier = self._slot_value_and_identifier(point, endpoint) 727 self._slot_selector(value, identifier, slots) 728 last = None 729 730 # Produce a control for any unmerged slot. 731 732 if last: 733 _value, identifier = self._slot_value_and_identifier(last, last) 734 value, _identifier = self._slot_value_and_identifier(last, endpoint) 735 self._slot_selector(value, identifier, slots) 736 737 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 738 739 """ 740 Show headings for the participants and other scheduling contributors, 741 defined by 'group_types', 'group_sources' and 'group_columns'. 742 """ 743 744 page = self.page 745 746 page.colgroup(span=1, id="columns-timeslot") 747 748 # Make column groups at least two cells wide. 749 750 for group_type, columns in zip(group_types, group_columns): 751 page.colgroup(span=max(columns, 2), id="columns-%s" % group_type) 752 753 page.thead() 754 page.tr() 755 page.th("", class_="emptyheading") 756 757 for group_type, source, columns in zip(group_types, group_sources, group_columns): 758 page.th(source, 759 class_=(group_type == "request" and "requestheading" or "participantheading"), 760 colspan=max(columns, 2)) 761 762 page.tr.close() 763 page.thead.close() 764 765 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, 766 partitioned_group_sources, group_columns): 767 768 """ 769 Show calendar days, defined by a collection of 'days', the contributing 770 period information as 'partitioned_groups' (partitioned by day), the 771 'partitioned_group_types' indicating the kind of contribution involved, 772 the 'partitioned_group_sources' indicating the origin of each group, and 773 the 'group_columns' defining the number of columns in each group. 774 """ 775 776 _ = self.get_translator() 777 778 page = self.page 779 780 # Determine the number of columns required. Where participants provide 781 # no columns for events, one still needs to be provided for the 782 # participant itself. 783 784 all_columns = sum([max(columns, 1) for columns in group_columns]) 785 786 # Determine the days providing time slots. 787 788 all_days = days.items() 789 all_days.sort() 790 791 # Produce a heading and time points for each day. 792 793 i = 0 794 795 for day, intervals in all_days: 796 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 797 is_empty = True 798 799 for slots in groups_for_day: 800 if not slots: 801 continue 802 803 for active in slots.values(): 804 if active: 805 is_empty = False 806 break 807 808 daystr, dayid = self._day_value_and_identifier(day) 809 810 # Put calendar tables within elements for quicker CSS selection. 811 812 page.div(class_="calendar") 813 814 # Show the controls permitting day selection as well as the controls 815 # configuring the new event display. 816 817 self.show_calendar_day_controls(day) 818 self.show_calendar_interval_controls(day, intervals) 819 820 # Show an actual table containing the day information. 821 822 page.table(cellspacing=5, cellpadding=5, class_="calendar %s" % (is_empty and " empty" or ""), id="region-%s" % dayid) 823 824 page.caption(class_="dayheading container separator") 825 self._day_heading(day) 826 page.caption.close() 827 828 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 829 830 page.tbody(class_="points") 831 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 832 page.tbody.close() 833 834 page.table.close() 835 836 # Show a button for scheduling a new event. 837 838 page.p(class_="newevent-with-periods") 839 page.label(_("Summary:")) 840 page.input(name="summary-%d" % i, type="text") 841 page.input(name="newevent-%d" % i, type="submit", value=_("New event"), accesskey="N") 842 page.p.close() 843 844 page.p(class_="newevent-with-periods") 845 page.label(_("Clear selections"), for_="reset", class_="reset") 846 page.p.close() 847 848 page.div.close() 849 850 i += 1 851 852 def show_calendar_points(self, intervals, groups, group_types, group_columns): 853 854 """ 855 Show the time 'intervals' along with period information from the given 856 'groups', having the indicated 'group_types', each with the number of 857 columns given by 'group_columns'. 858 """ 859 860 _ = self.get_translator() 861 862 page = self.page 863 864 # Obtain the user's timezone. 865 866 tzid = self.get_tzid() 867 868 # Get view information for links. 869 870 link_args = self.get_time_navigation_args() 871 872 # Produce a row for each interval. 873 874 intervals = list(intervals) 875 intervals.sort() 876 877 for point, endpoint in intervals: 878 continuation = point.point == get_start_of_day(point.point, tzid) 879 880 # Some rows contain no period details and are marked as such. 881 882 have_active = False 883 have_active_request = False 884 885 for slots, group_type in zip(groups, group_types): 886 if slots and slots.get(point): 887 if group_type == "request": 888 have_active_request = True 889 else: 890 have_active = True 891 892 # Emit properties of the time interval, where post-instant intervals 893 # are also treated as busy. 894 895 css = " ".join([ 896 "slot", 897 (have_active or point.indicator == Point.REPEATED) and "busy" or \ 898 have_active_request and "suggested" or "empty", 899 continuation and "daystart" or "" 900 ]) 901 902 page.tr(class_=css) 903 904 # Produce a time interval heading, spanning two rows if this point 905 # represents an instant. 906 907 if point.indicator == Point.PRINCIPAL: 908 timestr, timeid = self._slot_value_and_identifier(point, endpoint) 909 page.th(class_="timeslot", id="region-%s" % timeid, 910 rowspan=(endpoint and point.point == endpoint.point and 2 or 1)) 911 self._time_point(point, endpoint) 912 page.th.close() 913 914 # Obtain slots for the time point from each group. 915 916 for columns, slots, group_type in zip(group_columns, groups, group_types): 917 918 # Make column groups at least two cells wide. 919 920 columns = max(columns, 2) 921 active = slots and slots.get(point) 922 923 # Where no periods exist for the given time interval, generate 924 # an empty cell. Where a participant provides no periods at all, 925 # one column is provided; otherwise, one more column than the 926 # number required is provided. 927 928 if not active: 929 self._empty_slot(point, endpoint, max(columns, 2)) 930 continue 931 932 slots = slots.items() 933 slots.sort() 934 spans = get_spans(slots) 935 936 empty = 0 937 938 # Show a column for each active period. 939 940 for p in active: 941 942 # The period can be None, meaning an empty column. 943 944 if p: 945 946 # Flush empty slots preceding this one. 947 948 if empty: 949 self._empty_slot(point, endpoint, empty) 950 empty = 0 951 952 key = p.get_key() 953 span = spans[key] 954 955 # Produce a table cell only at the start of the period 956 # or when continued at the start of a day. 957 # Points defining the ends of instant events should 958 # never define the start of new events. 959 960 if point.indicator == Point.PRINCIPAL and (point.point == p.get_start() or continuation): 961 962 has_continued = continuation and point.point != p.get_start() 963 will_continue = not ends_on_same_day(point.point, p.get_end(), tzid) 964 is_organiser = p.organiser == self.user 965 966 css = " ".join([ 967 "event", 968 has_continued and "continued" or "", 969 will_continue and "continues" or "", 970 p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending", 971 self._have_request(p.uid, p.recurrenceid, "COUNTER", True) and "counter" or "", 972 ]) 973 974 # Only anchor the first cell of events. 975 # Need to only anchor the first period for a recurring 976 # event. 977 978 html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "") 979 980 if point.point == p.get_start() and html_id not in self.html_ids: 981 page.td(class_=css, rowspan=span, id=html_id) 982 self.html_ids.add(html_id) 983 else: 984 page.td(class_=css, rowspan=span) 985 986 # Only link to events if they are not being updated 987 # by requests. 988 989 if not p.summary or \ 990 group_type != "request" and self._have_request(p.uid, p.recurrenceid, None, True): 991 992 page.span(p.summary or _("(Participant is busy)")) 993 994 # Link to requests and events (including ones for 995 # which counter-proposals exist). 996 997 elif group_type == "request" and self._have_request(p.uid, p.recurrenceid, "COUNTER", True): 998 d = {"counter" : self._period_identifier(p)} 999 d.update(link_args) 1000 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, d)) 1001 1002 else: 1003 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, link_args)) 1004 1005 page.td.close() 1006 else: 1007 empty += 1 1008 1009 # Pad with empty columns. 1010 1011 empty = columns - len(active) 1012 1013 if empty: 1014 self._empty_slot(point, endpoint, empty, True) 1015 1016 page.tr.close() 1017 1018 def _day_heading(self, day): 1019 1020 """ 1021 Generate a heading for 'day' of the following form: 1022 1023 <label class="day" for="day-20150203">Tuesday, 3 February 2015</label> 1024 """ 1025 1026 page = self.page 1027 value, identifier = self._day_value_and_identifier(day) 1028 page.label(self.format_date(day, "full"), class_="day", for_=identifier) 1029 1030 def _time_point(self, point, endpoint): 1031 1032 """ 1033 Generate headings for the 'point' to 'endpoint' period of the following 1034 form: 1035 1036 <label class="timepoint" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1037 <span class="endpoint">10:00:00 CET</span> 1038 """ 1039 1040 page = self.page 1041 tzid = self.get_tzid() 1042 value, identifier = self._slot_value_and_identifier(point, endpoint) 1043 page.label(self.format_time(point.point, "long"), class_="timepoint", for_=identifier) 1044 page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint") 1045 1046 def _slot_selector(self, value, identifier, slots): 1047 1048 """ 1049 Provide a timeslot control having the given 'value', employing the 1050 indicated HTML 'identifier', and using the given 'slots' collection 1051 to select any control whose 'value' is in this collection, unless the 1052 "reset" request parameter has been asserted. 1053 """ 1054 1055 reset = self.env.get_args().has_key("reset") 1056 page = self.page 1057 if not reset and value in slots: 1058 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1059 else: 1060 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1061 1062 def _empty_slot(self, point, endpoint, colspan, at_end=False): 1063 1064 """ 1065 Show an empty slot cell for the given 'point' and 'endpoint', with the 1066 given 'colspan' configuring the cell's appearance. 1067 """ 1068 1069 _ = self.get_translator() 1070 1071 page = self.page 1072 page.td(class_="empty%s%s" % (point.indicator == Point.PRINCIPAL and " container" or "", at_end and " padding" or ""), colspan=colspan) 1073 if point.indicator == Point.PRINCIPAL: 1074 value, identifier = self._slot_value_and_identifier(point, endpoint) 1075 page.label(_("Select/deselect period"), class_="newevent popup", for_=identifier) 1076 page.td.close() 1077 1078 def _day_value_and_identifier(self, day): 1079 1080 "Return a day value and HTML identifier for the given 'day'." 1081 1082 value = format_datetime(day) 1083 identifier = "day-%s" % value 1084 return value, identifier 1085 1086 def _slot_value_and_identifier(self, point, endpoint): 1087 1088 """ 1089 Return a slot value and HTML identifier for the given 'point' and 1090 'endpoint'. 1091 """ 1092 1093 value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "") 1094 identifier = "slot-%s" % value 1095 return value, identifier 1096 1097 def _period_identifier(self, period): 1098 return "%s-%s" % (format_datetime(period.get_start()), format_datetime(period.get_end())) 1099 1100 def get_date_arg(self, args, name): 1101 values = args.get(name) 1102 if not values: 1103 return None 1104 return get_datetime(values[0], {"VALUE-TYPE" : "DATE"}) 1105 1106 # vim: tabstop=4 expandtab shiftwidth=4