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