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 23 from imiptools.data import get_address, get_uri, uri_values 24 from imiptools.dates import format_datetime, 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 convert_periods, get_freebusy_details, \ 30 get_scale, get_slots, get_spans, partition_by_day, \ 31 Point 32 from imipweb.resource import Resource 33 34 class CalendarPage(Resource): 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 if not args.has_key("newevent"): 52 return 53 54 # Create a new event using the available information. 55 56 slots = args.get("slot", []) 57 participants = args.get("participants", []) 58 59 if not slots: 60 return 61 62 # Obtain the user's timezone. 63 64 tzid = self.get_tzid() 65 66 # Coalesce the selected slots. 67 68 slots.sort() 69 coalesced = [] 70 last = None 71 72 for slot in slots: 73 start, end = slot.split("-") 74 start = get_datetime(start, {"TZID" : tzid}) 75 end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) 76 77 if last: 78 last_start, last_end = last 79 80 # Merge adjacent dates and datetimes. 81 82 if start == last_end or \ 83 not isinstance(start, datetime) and \ 84 get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): 85 86 last = last_start, end 87 continue 88 89 # Handle datetimes within dates. 90 # Datetime periods are within single days and are therefore 91 # discarded. 92 93 elif not isinstance(last_start, datetime) and \ 94 get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): 95 96 continue 97 98 # Add separate dates and datetimes. 99 100 else: 101 coalesced.append(last) 102 103 last = start, end 104 105 if last: 106 coalesced.append(last) 107 108 # Invent a unique identifier. 109 110 utcnow = get_timestamp() 111 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 112 113 # Create a calendar object and store it as a request. 114 115 record = [] 116 rwrite = record.append 117 118 # Define a single occurrence if only one coalesced slot exists. 119 120 start, end = coalesced[0] 121 start_value, start_attr = get_datetime_item(start, tzid) 122 end_value, end_attr = get_datetime_item(end, tzid) 123 124 rwrite(("UID", {}, uid)) 125 rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) 126 rwrite(("DTSTAMP", {}, utcnow)) 127 rwrite(("DTSTART", start_attr, start_value)) 128 rwrite(("DTEND", end_attr, end_value)) 129 rwrite(("ORGANIZER", {}, self.user)) 130 131 participants = uri_values(filter(None, participants)) 132 133 for participant in participants: 134 rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant)) 135 136 if self.user not in participants: 137 rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user)) 138 139 # Define additional occurrences if many slots are defined. 140 141 rdates = [] 142 143 for start, end in coalesced[1:]: 144 start_value, start_attr = get_datetime_item(start, tzid) 145 end_value, end_attr = get_datetime_item(end, tzid) 146 rdates.append("%s/%s" % (start_value, end_value)) 147 148 if rdates: 149 rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates)) 150 151 node = ("VEVENT", {}, record) 152 153 self.store.set_event(self.user, uid, None, node=node) 154 self.store.queue_request(self.user, uid) 155 156 # Redirect to the object (or the first of the objects), where instead of 157 # attendee controls, there will be organiser controls. 158 159 self.redirect(self.link_to(uid)) 160 161 # Page fragment methods. 162 163 def show_requests_on_page(self): 164 165 "Show requests for the current user." 166 167 page = self.page 168 169 # NOTE: This list could be more informative, but it is envisaged that 170 # NOTE: the requests would be visited directly anyway. 171 172 requests = self._get_requests() 173 174 page.div(id="pending-requests") 175 176 if requests: 177 page.p("Pending requests:") 178 179 page.ul() 180 181 for uid, recurrenceid in requests: 182 obj = self._get_object(uid, recurrenceid) 183 if obj: 184 page.li() 185 page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) 186 page.li.close() 187 188 page.ul.close() 189 190 else: 191 page.p("There are no pending requests.") 192 193 page.div.close() 194 195 def show_participants_on_page(self): 196 197 "Show participants for scheduling purposes." 198 199 page = self.page 200 args = self.env.get_args() 201 participants = args.get("participants", []) 202 203 try: 204 for name, value in args.items(): 205 if name.startswith("remove-participant-"): 206 i = int(name[len("remove-participant-"):]) 207 del participants[i] 208 break 209 except ValueError: 210 pass 211 212 # Trim empty participants. 213 214 while participants and not participants[-1].strip(): 215 participants.pop() 216 217 # Show any specified participants together with controls to remove and 218 # add participants. 219 220 page.div(id="participants") 221 222 page.p("Participants for scheduling:") 223 224 for i, participant in enumerate(participants): 225 page.p() 226 page.input(name="participants", type="text", value=participant) 227 page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 228 page.p.close() 229 230 page.p() 231 page.input(name="participants", type="text") 232 page.input(name="add-participant", type="submit", value="Add") 233 page.p.close() 234 235 page.div.close() 236 237 return participants 238 239 # Full page output methods. 240 241 def show(self): 242 243 "Show the calendar for the current user." 244 245 handled = self.handle_newevent() 246 247 self.new_page(title="Calendar") 248 page = self.page 249 250 # Form controls are used in various places on the calendar page. 251 252 page.form(method="POST") 253 254 self.show_requests_on_page() 255 participants = self.show_participants_on_page() 256 257 # Show a button for scheduling a new event. 258 259 page.p(class_="controls") 260 page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") 261 page.p.close() 262 263 # Show controls for hiding empty days and busy slots. 264 # The positioning of the control, paragraph and table are important here. 265 266 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 267 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 268 269 page.p(class_="controls") 270 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 271 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 272 page.label("Show empty days", for_="showdays", class_="showdays disable") 273 page.label("Hide empty days", for_="showdays", class_="showdays enable") 274 page.input(name="reset", type="submit", value="Clear selections", id="reset") 275 page.label("Clear selections", for_="reset", class_="reset") 276 page.p.close() 277 278 freebusy = self.store.get_freebusy(self.user) 279 280 if not freebusy: 281 page.p("No events scheduled.") 282 return 283 284 # Obtain the user's timezone. 285 286 tzid = self.get_tzid() 287 288 # Day view: start at the earliest known day and produce days until the 289 # latest known day, perhaps with expandable sections of empty days. 290 291 # Month view: start at the earliest known month and produce months until 292 # the latest known month, perhaps with expandable sections of empty 293 # months. 294 295 # Details of users to invite to new events could be superimposed on the 296 # calendar. 297 298 # Requests are listed and linked to their tentative positions in the 299 # calendar. Other participants are also shown. 300 301 request_summary = self._get_request_summary() 302 303 period_groups = [request_summary, freebusy] 304 period_group_types = ["request", "freebusy"] 305 period_group_sources = ["Pending requests", "Your schedule"] 306 307 for i, participant in enumerate(participants): 308 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 309 period_group_types.append("freebusy-part%d" % i) 310 period_group_sources.append(participant) 311 312 groups = [] 313 group_columns = [] 314 group_types = period_group_types 315 group_sources = period_group_sources 316 all_points = set() 317 318 # Obtain time point information for each group of periods. 319 320 for periods in period_groups: 321 periods = convert_periods(periods, tzid) 322 323 # Get the time scale with start and end points. 324 325 scale = get_scale(periods) 326 327 # Get the time slots for the periods. 328 # Time slots are collections of Point objects with lists of active 329 # periods. 330 331 slots = get_slots(scale) 332 333 # Add start of day time points for multi-day periods. 334 335 add_day_start_points(slots, tzid) 336 337 # Record the slots and all time points employed. 338 339 groups.append(slots) 340 all_points.update([point for point, active in slots]) 341 342 # Partition the groups into days. 343 344 days = {} 345 partitioned_groups = [] 346 partitioned_group_types = [] 347 partitioned_group_sources = [] 348 349 for slots, group_type, group_source in zip(groups, group_types, group_sources): 350 351 # Propagate time points to all groups of time slots. 352 353 add_slots(slots, all_points) 354 355 # Count the number of columns employed by the group. 356 357 columns = 0 358 359 # Partition the time slots by day. 360 361 partitioned = {} 362 363 for day, day_slots in partition_by_day(slots).items(): 364 365 # Construct a list of time intervals within the day. 366 367 intervals = [] 368 369 # Convert each partition to a mapping from points to active 370 # periods. 371 372 partitioned[day] = day_points = {} 373 374 last = None 375 376 for point, active in day_slots: 377 columns = max(columns, len(active)) 378 day_points[point] = active 379 380 if last: 381 intervals.append((last, point)) 382 383 last = point 384 385 if last: 386 intervals.append((last, None)) 387 388 if not days.has_key(day): 389 days[day] = set() 390 391 # Record the divisions or intervals within each day. 392 393 days[day].update(intervals) 394 395 # Only include the requests column if it provides objects. 396 397 if group_type != "request" or columns: 398 group_columns.append(columns) 399 partitioned_groups.append(partitioned) 400 partitioned_group_types.append(group_type) 401 partitioned_group_sources.append(group_source) 402 403 # Add empty days. 404 405 add_empty_days(days, tzid) 406 407 # Show the controls permitting day selection. 408 409 self.show_calendar_day_controls(days) 410 411 # Show the calendar itself. 412 413 page.table(cellspacing=5, cellpadding=5, class_="calendar") 414 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 415 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 416 page.table.close() 417 418 # End the form region. 419 420 page.form.close() 421 422 # More page fragment methods. 423 424 def show_calendar_day_controls(self, days): 425 426 "Show controls for the given 'days' in the calendar." 427 428 page = self.page 429 slots = self.env.get_args().get("slot", []) 430 431 for day in days: 432 value, identifier = self._day_value_and_identifier(day) 433 self._slot_selector(value, identifier, slots) 434 435 # Generate a dynamic stylesheet to allow day selections to colour 436 # specific days. 437 # NOTE: The style details need to be coordinated with the static 438 # NOTE: stylesheet. 439 440 page.style(type="text/css") 441 442 for day in days: 443 daystr = format_datetime(day) 444 page.add("""\ 445 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 446 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 447 background-color: #5f4; 448 text-decoration: underline; 449 } 450 """ % (daystr, daystr, daystr, daystr)) 451 452 page.style.close() 453 454 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 455 456 """ 457 Show headings for the participants and other scheduling contributors, 458 defined by 'group_types', 'group_sources' and 'group_columns'. 459 """ 460 461 page = self.page 462 463 page.colgroup(span=1, id="columns-timeslot") 464 465 for group_type, columns in zip(group_types, group_columns): 466 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 467 468 page.thead() 469 page.tr() 470 page.th("", class_="emptyheading") 471 472 for group_type, source, columns in zip(group_types, group_sources, group_columns): 473 page.th(source, 474 class_=(group_type == "request" and "requestheading" or "participantheading"), 475 colspan=max(columns, 1)) 476 477 page.tr.close() 478 page.thead.close() 479 480 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 481 482 """ 483 Show calendar days, defined by a collection of 'days', the contributing 484 period information as 'partitioned_groups' (partitioned by day), the 485 'partitioned_group_types' indicating the kind of contribution involved, 486 and the 'group_columns' defining the number of columns in each group. 487 """ 488 489 page = self.page 490 491 # Determine the number of columns required. Where participants provide 492 # no columns for events, one still needs to be provided for the 493 # participant itself. 494 495 all_columns = sum([max(columns, 1) for columns in group_columns]) 496 497 # Determine the days providing time slots. 498 499 all_days = days.items() 500 all_days.sort() 501 502 # Produce a heading and time points for each day. 503 504 for day, intervals in all_days: 505 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 506 is_empty = True 507 508 for slots in groups_for_day: 509 if not slots: 510 continue 511 512 for active in slots.values(): 513 if active: 514 is_empty = False 515 break 516 517 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 518 page.tr() 519 page.th(class_="dayheading container", colspan=all_columns+1) 520 self._day_heading(day) 521 page.th.close() 522 page.tr.close() 523 page.thead.close() 524 525 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 526 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 527 page.tbody.close() 528 529 def show_calendar_points(self, intervals, groups, group_types, group_columns): 530 531 """ 532 Show the time 'intervals' along with period information from the given 533 'groups', having the indicated 'group_types', each with the number of 534 columns given by 'group_columns'. 535 """ 536 537 page = self.page 538 539 # Obtain the user's timezone. 540 541 tzid = self.get_tzid() 542 543 # Produce a row for each interval. 544 545 intervals = list(intervals) 546 intervals.sort() 547 548 for point, endpoint in intervals: 549 continuation = point.point == get_start_of_day(point.point, tzid) 550 551 # Some rows contain no period details and are marked as such. 552 553 have_active = False 554 have_active_request = False 555 556 for slots, group_type in zip(groups, group_types): 557 if slots and slots.get(point): 558 if group_type == "request": 559 have_active_request = True 560 else: 561 have_active = True 562 563 # Emit properties of the time interval, where post-instant intervals 564 # are also treated as busy. 565 566 css = " ".join([ 567 "slot", 568 (have_active or point.indicator == Point.REPEATED) and "busy" or \ 569 have_active_request and "suggested" or "empty", 570 continuation and "daystart" or "" 571 ]) 572 573 page.tr(class_=css) 574 if point.indicator == Point.PRINCIPAL: 575 page.th(class_="timeslot") 576 self._time_point(point, endpoint) 577 else: 578 page.th() 579 page.th.close() 580 581 # Obtain slots for the time point from each group. 582 583 for columns, slots, group_type in zip(group_columns, groups, group_types): 584 active = slots and slots.get(point) 585 586 # Where no periods exist for the given time interval, generate 587 # an empty cell. Where a participant provides no periods at all, 588 # the colspan is adjusted to be 1, not 0. 589 590 if not active: 591 self._empty_slot(point, endpoint, max(columns, 1)) 592 continue 593 594 slots = slots.items() 595 slots.sort() 596 spans = get_spans(slots) 597 598 empty = 0 599 600 # Show a column for each active period. 601 602 for t in active: 603 if t and len(t) >= 2: 604 605 # Flush empty slots preceding this one. 606 607 if empty: 608 self._empty_slot(point, endpoint, empty) 609 empty = 0 610 611 start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t) 612 span = spans[key] 613 614 # Produce a table cell only at the start of the period 615 # or when continued at the start of a day. 616 # Points defining the ends of instant events should 617 # never define the start of new events. 618 619 if point.indicator == Point.PRINCIPAL and (point.point == start or continuation): 620 621 has_continued = continuation and point.point != start 622 will_continue = not ends_on_same_day(point.point, end, tzid) 623 is_organiser = organiser == self.user 624 625 css = " ".join([ 626 "event", 627 has_continued and "continued" or "", 628 will_continue and "continues" or "", 629 is_organiser and "organising" or "attending" 630 ]) 631 632 # Only anchor the first cell of events. 633 # Need to only anchor the first period for a recurring 634 # event. 635 636 html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "") 637 638 if point.point == start and html_id not in self.html_ids: 639 page.td(class_=css, rowspan=span, id=html_id) 640 self.html_ids.add(html_id) 641 else: 642 page.td(class_=css, rowspan=span) 643 644 # Only link to events if they are not being 645 # updated by requests. 646 647 if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request": 648 page.span(summary or "(Participant is busy)") 649 else: 650 page.a(summary, href=self.link_to(uid, recurrenceid)) 651 652 page.td.close() 653 else: 654 empty += 1 655 656 # Pad with empty columns. 657 658 empty = columns - len(active) 659 660 if empty: 661 self._empty_slot(point, endpoint, empty) 662 663 page.tr.close() 664 665 def _day_heading(self, day): 666 667 """ 668 Generate a heading for 'day' of the following form: 669 670 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 671 """ 672 673 page = self.page 674 daystr = format_datetime(day) 675 value, identifier = self._day_value_and_identifier(day) 676 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 677 678 def _time_point(self, point, endpoint): 679 680 """ 681 Generate headings for the 'point' to 'endpoint' period of the following 682 form: 683 684 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 685 <span class="endpoint">10:00:00 CET</span> 686 """ 687 688 page = self.page 689 tzid = self.get_tzid() 690 daystr = format_datetime(point.point.date()) 691 value, identifier = self._slot_value_and_identifier(point, endpoint) 692 slots = self.env.get_args().get("slot", []) 693 self._slot_selector(value, identifier, slots) 694 page.label(self.format_time(point.point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 695 page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint") 696 697 def _slot_selector(self, value, identifier, slots): 698 699 """ 700 Provide a timeslot control having the given 'value', employing the 701 indicated HTML 'identifier', and using the given 'slots' collection 702 to select any control whose 'value' is in this collection, unless the 703 "reset" request parameter has been asserted. 704 """ 705 706 reset = self.env.get_args().has_key("reset") 707 page = self.page 708 if not reset and value in slots: 709 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 710 else: 711 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 712 713 def _empty_slot(self, point, endpoint, colspan): 714 715 """ 716 Show an empty slot cell for the given 'point' and 'endpoint', with the 717 given 'colspan' configuring the cell's appearance. 718 """ 719 720 page = self.page 721 page.td(class_="empty%s" % (point.indicator == Point.PRINCIPAL and " container" or ""), colspan=colspan) 722 if point.indicator == Point.PRINCIPAL: 723 value, identifier = self._slot_value_and_identifier(point, endpoint) 724 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 725 page.td.close() 726 727 def _day_value_and_identifier(self, day): 728 729 "Return a day value and HTML identifier for the given 'day'." 730 731 value = "%s-" % format_datetime(day) 732 identifier = "day-%s" % value 733 return value, identifier 734 735 def _slot_value_and_identifier(self, point, endpoint): 736 737 """ 738 Return a slot value and HTML identifier for the given 'point' and 739 'endpoint'. 740 """ 741 742 value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "") 743 identifier = "slot-%s" % value 744 return value, identifier 745 746 # vim: tabstop=4 expandtab shiftwidth=4