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, REPEATED 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 329 slots = get_slots(scale) 330 331 # Add start of day time points for multi-day periods. 332 333 add_day_start_points(slots, tzid) 334 335 # Record the slots and all time points employed. 336 337 groups.append(slots) 338 all_points.update([point_details for point_details, active in slots]) 339 340 # Partition the groups into days. 341 342 days = {} 343 partitioned_groups = [] 344 partitioned_group_types = [] 345 partitioned_group_sources = [] 346 347 for slots, group_type, group_source in zip(groups, group_types, group_sources): 348 349 # Propagate time points to all groups of time slots. 350 351 add_slots(slots, all_points) 352 353 # Count the number of columns employed by the group. 354 355 columns = 0 356 357 # Partition the time slots by day. 358 359 partitioned = {} 360 361 for day, day_slots in partition_by_day(slots).items(): 362 363 # Construct a list of time intervals within the day. 364 365 intervals = [] 366 367 # Convert each partition to a mapping from points to active 368 # periods. 369 370 partitioned[day] = day_points = {} 371 372 last = None 373 374 for point_details, active in day_slots: 375 point, indicator = point_details 376 columns = max(columns, len(active)) 377 day_points[point_details] = active 378 379 if last: 380 intervals.append((last, point)) 381 382 last = point_details 383 384 if last: 385 intervals.append((last, None)) 386 387 if not days.has_key(day): 388 days[day] = set() 389 390 # Record the divisions or intervals within each day. 391 392 days[day].update(intervals) 393 394 # Only include the requests column if it provides objects. 395 396 if group_type != "request" or columns: 397 group_columns.append(columns) 398 partitioned_groups.append(partitioned) 399 partitioned_group_types.append(group_type) 400 partitioned_group_sources.append(group_source) 401 402 # Add empty days. 403 404 add_empty_days(days, tzid) 405 406 # Show the controls permitting day selection. 407 408 self.show_calendar_day_controls(days) 409 410 # Show the calendar itself. 411 412 page.table(cellspacing=5, cellpadding=5, class_="calendar") 413 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 414 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 415 page.table.close() 416 417 # End the form region. 418 419 page.form.close() 420 421 # More page fragment methods. 422 423 def show_calendar_day_controls(self, days): 424 425 "Show controls for the given 'days' in the calendar." 426 427 page = self.page 428 slots = self.env.get_args().get("slot", []) 429 430 for day in days: 431 value, identifier = self._day_value_and_identifier(day) 432 self._slot_selector(value, identifier, slots) 433 434 # Generate a dynamic stylesheet to allow day selections to colour 435 # specific days. 436 # NOTE: The style details need to be coordinated with the static 437 # NOTE: stylesheet. 438 439 page.style(type="text/css") 440 441 for day in days: 442 daystr = format_datetime(day) 443 page.add("""\ 444 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 445 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 446 background-color: #5f4; 447 text-decoration: underline; 448 } 449 """ % (daystr, daystr, daystr, daystr)) 450 451 page.style.close() 452 453 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 454 455 """ 456 Show headings for the participants and other scheduling contributors, 457 defined by 'group_types', 'group_sources' and 'group_columns'. 458 """ 459 460 page = self.page 461 462 page.colgroup(span=1, id="columns-timeslot") 463 464 for group_type, columns in zip(group_types, group_columns): 465 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 466 467 page.thead() 468 page.tr() 469 page.th("", class_="emptyheading") 470 471 for group_type, source, columns in zip(group_types, group_sources, group_columns): 472 page.th(source, 473 class_=(group_type == "request" and "requestheading" or "participantheading"), 474 colspan=max(columns, 1)) 475 476 page.tr.close() 477 page.thead.close() 478 479 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 480 481 """ 482 Show calendar days, defined by a collection of 'days', the contributing 483 period information as 'partitioned_groups' (partitioned by day), the 484 'partitioned_group_types' indicating the kind of contribution involved, 485 and the 'group_columns' defining the number of columns in each group. 486 """ 487 488 page = self.page 489 490 # Determine the number of columns required. Where participants provide 491 # no columns for events, one still needs to be provided for the 492 # participant itself. 493 494 all_columns = sum([max(columns, 1) for columns in group_columns]) 495 496 # Determine the days providing time slots. 497 498 all_days = days.items() 499 all_days.sort() 500 501 # Produce a heading and time points for each day. 502 503 for day, intervals in all_days: 504 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 505 is_empty = True 506 507 for slots in groups_for_day: 508 if not slots: 509 continue 510 511 for active in slots.values(): 512 if active: 513 is_empty = False 514 break 515 516 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 517 page.tr() 518 page.th(class_="dayheading container", colspan=all_columns+1) 519 self._day_heading(day) 520 page.th.close() 521 page.tr.close() 522 page.thead.close() 523 524 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 525 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 526 page.tbody.close() 527 528 def show_calendar_points(self, intervals, groups, group_types, group_columns): 529 530 """ 531 Show the time 'intervals' along with period information from the given 532 'groups', having the indicated 'group_types', each with the number of 533 columns given by 'group_columns'. 534 """ 535 536 page = self.page 537 538 # Obtain the user's timezone. 539 540 tzid = self.get_tzid() 541 542 # Produce a row for each interval. 543 544 intervals = list(intervals) 545 intervals.sort() 546 547 for (point, indicator), endpoint in intervals: 548 continuation = point == get_start_of_day(point, tzid) 549 550 # Some rows contain no period details and are marked as such. 551 552 have_active = False 553 have_active_request = False 554 555 for slots, group_type in zip(groups, group_types): 556 if slots and slots.get((point, indicator)): 557 if group_type == "request": 558 have_active_request = True 559 else: 560 have_active = True 561 562 # Emit properties of the time interval, where post-instant intervals 563 # are also treated as busy. 564 565 css = " ".join([ 566 "slot", 567 (have_active or indicator == REPEATED) and "busy" or have_active_request and "suggested" or "empty", 568 continuation and "daystart" or "" 569 ]) 570 571 page.tr(class_=css) 572 if indicator == POINT: 573 page.th(class_="timeslot") 574 self._time_point(point, endpoint) 575 else: 576 page.th() 577 page.th.close() 578 579 # Obtain slots for the time point from each group. 580 581 for columns, slots, group_type in zip(group_columns, groups, group_types): 582 active = slots and slots.get((point, indicator)) 583 584 # Where no periods exist for the given time interval, generate 585 # an empty cell. Where a participant provides no periods at all, 586 # the colspan is adjusted to be 1, not 0. 587 588 if not active: 589 self._empty_slot(point, endpoint, max(columns, 1), indicator) 590 continue 591 592 slots = slots.items() 593 slots.sort() 594 spans = get_spans(slots) 595 596 empty = 0 597 598 # Show a column for each active period. 599 600 for t in active: 601 if t and len(t) >= 2: 602 603 # Flush empty slots preceding this one. 604 605 if empty: 606 self._empty_slot(point, endpoint, empty, indicator) 607 empty = 0 608 609 start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t) 610 span = spans[key] 611 612 # Produce a table cell only at the start of the period 613 # or when continued at the start of a day. 614 # Points defining the ends of instant events should 615 # never define the start of new events. 616 617 if indicator == POINT and (point == start or continuation): 618 619 has_continued = continuation and point != start 620 will_continue = not ends_on_same_day(point, end, tzid) 621 is_organiser = organiser == self.user 622 623 css = " ".join([ 624 "event", 625 has_continued and "continued" or "", 626 will_continue and "continues" or "", 627 is_organiser and "organising" or "attending" 628 ]) 629 630 # Only anchor the first cell of events. 631 # Need to only anchor the first period for a recurring 632 # event. 633 634 html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "") 635 636 if point == start and html_id not in self.html_ids: 637 page.td(class_=css, rowspan=span, id=html_id) 638 self.html_ids.add(html_id) 639 else: 640 page.td(class_=css, rowspan=span) 641 642 # Only link to events if they are not being 643 # updated by requests. 644 645 if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request": 646 page.span(summary or "(Participant is busy)") 647 else: 648 page.a(summary, href=self.link_to(uid, recurrenceid)) 649 650 page.td.close() 651 else: 652 empty += 1 653 654 # Pad with empty columns. 655 656 empty = columns - len(active) 657 658 if empty: 659 self._empty_slot(point, endpoint, empty, indicator) 660 661 page.tr.close() 662 663 def _day_heading(self, day): 664 665 """ 666 Generate a heading for 'day' of the following form: 667 668 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 669 """ 670 671 page = self.page 672 daystr = format_datetime(day) 673 value, identifier = self._day_value_and_identifier(day) 674 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 675 676 def _time_point(self, point, endpoint): 677 678 """ 679 Generate headings for the 'point' to 'endpoint' period of the following 680 form: 681 682 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 683 <span class="endpoint">10:00:00 CET</span> 684 """ 685 686 page = self.page 687 tzid = self.get_tzid() 688 daystr = format_datetime(point.date()) 689 value, identifier = self._slot_value_and_identifier(point, endpoint) 690 slots = self.env.get_args().get("slot", []) 691 self._slot_selector(value, identifier, slots) 692 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 693 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 694 695 def _slot_selector(self, value, identifier, slots): 696 697 """ 698 Provide a timeslot control having the given 'value', employing the 699 indicated HTML 'identifier', and using the given 'slots' collection 700 to select any control whose 'value' is in this collection, unless the 701 "reset" request parameter has been asserted. 702 """ 703 704 reset = self.env.get_args().has_key("reset") 705 page = self.page 706 if not reset and value in slots: 707 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 708 else: 709 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 710 711 def _empty_slot(self, point, endpoint, colspan, indicator): 712 713 """ 714 Show an empty slot cell for the given 'point' and 'endpoint', with the 715 given 'colspan' and 'indicator' (0 as POINT or 1 as REPEATED) 716 configuring the cell's appearance. 717 """ 718 719 page = self.page 720 page.td(class_="empty%s" % (indicator == POINT and " container" or ""), colspan=colspan) 721 if indicator == POINT: 722 value, identifier = self._slot_value_and_identifier(point, endpoint) 723 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 724 page.td.close() 725 726 def _day_value_and_identifier(self, day): 727 728 "Return a day value and HTML identifier for the given 'day'." 729 730 value = "%s-" % format_datetime(day) 731 identifier = "day-%s" % value 732 return value, identifier 733 734 def _slot_value_and_identifier(self, point, endpoint): 735 736 """ 737 Return a slot value and HTML identifier for the given 'point' and 738 'endpoint'. 739 """ 740 741 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 742 identifier = "slot-%s" % value 743 return value, identifier 744 745 # vim: tabstop=4 expandtab shiftwidth=4