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