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