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