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