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_scale, get_slots, get_spans, \ 30 partition_by_day, Point 31 from imipweb.resource import Resource 32 33 class CalendarPage(Resource): 34 35 "A request handler for the calendar page." 36 37 # Request logic methods. 38 39 def handle_newevent(self): 40 41 """ 42 Handle any new event operation, creating a new event and redirecting to 43 the event page for further activity. 44 """ 45 46 # Handle a submitted form. 47 48 args = self.env.get_args() 49 50 if not args.has_key("newevent"): 51 return 52 53 # Create a new event using the available information. 54 55 slots = args.get("slot", []) 56 participants = args.get("participants", []) 57 58 if not slots: 59 return 60 61 # Obtain the user's timezone. 62 63 tzid = self.get_tzid() 64 65 # Coalesce the selected slots. 66 67 slots.sort() 68 coalesced = [] 69 last = None 70 71 for slot in slots: 72 start, end = slot.split("-") 73 start = get_datetime(start, {"TZID" : tzid}) 74 end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) 75 76 if last: 77 last_start, last_end = last 78 79 # Merge adjacent dates and datetimes. 80 81 if start == last_end or \ 82 not isinstance(start, datetime) and \ 83 get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): 84 85 last = last_start, end 86 continue 87 88 # Handle datetimes within dates. 89 # Datetime periods are within single days and are therefore 90 # discarded. 91 92 elif not isinstance(last_start, datetime) and \ 93 get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): 94 95 continue 96 97 # Add separate dates and datetimes. 98 99 else: 100 coalesced.append(last) 101 102 last = start, end 103 104 if last: 105 coalesced.append(last) 106 107 # Invent a unique identifier. 108 109 utcnow = get_timestamp() 110 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 111 112 # Create a calendar object and store it as a request. 113 114 record = [] 115 rwrite = record.append 116 117 # Define a single occurrence if only one coalesced slot exists. 118 119 start, end = coalesced[0] 120 start_value, start_attr = get_datetime_item(start, tzid) 121 end_value, end_attr = get_datetime_item(end, tzid) 122 123 rwrite(("UID", {}, uid)) 124 rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) 125 rwrite(("DTSTAMP", {}, utcnow)) 126 rwrite(("DTSTART", start_attr, start_value)) 127 rwrite(("DTEND", end_attr, end_value)) 128 rwrite(("ORGANIZER", {}, self.user)) 129 130 participants = uri_values(filter(None, participants)) 131 132 for participant in participants: 133 rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant)) 134 135 if self.user not in participants: 136 rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user)) 137 138 # Define additional occurrences if many slots are defined. 139 140 rdates = [] 141 142 for start, end in coalesced[1:]: 143 start_value, start_attr = get_datetime_item(start, tzid) 144 end_value, end_attr = get_datetime_item(end, tzid) 145 rdates.append("%s/%s" % (start_value, end_value)) 146 147 if rdates: 148 rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates)) 149 150 node = ("VEVENT", {}, record) 151 152 self.store.set_event(self.user, uid, None, node=node) 153 self.store.queue_request(self.user, uid) 154 155 # Redirect to the object (or the first of the objects), where instead of 156 # attendee controls, there will be organiser controls. 157 158 self.redirect(self.link_to(uid)) 159 160 # Page fragment methods. 161 162 def show_requests_on_page(self): 163 164 "Show requests for the current user." 165 166 page = self.page 167 168 # NOTE: This list could be more informative, but it is envisaged that 169 # NOTE: the requests would be visited directly anyway. 170 171 requests = self._get_requests() 172 173 page.div(id="pending-requests") 174 175 if requests: 176 page.p("Pending requests:") 177 178 page.ul() 179 180 for uid, recurrenceid in requests: 181 obj = self._get_object(uid, recurrenceid) 182 if obj: 183 page.li() 184 page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) 185 page.li.close() 186 187 page.ul.close() 188 189 else: 190 page.p("There are no pending requests.") 191 192 page.div.close() 193 194 def show_participants_on_page(self): 195 196 "Show participants for scheduling purposes." 197 198 page = self.page 199 args = self.env.get_args() 200 participants = args.get("participants", []) 201 202 try: 203 for name, value in args.items(): 204 if name.startswith("remove-participant-"): 205 i = int(name[len("remove-participant-"):]) 206 del participants[i] 207 break 208 except ValueError: 209 pass 210 211 # Trim empty participants. 212 213 while participants and not participants[-1].strip(): 214 participants.pop() 215 216 # Show any specified participants together with controls to remove and 217 # add participants. 218 219 page.div(id="participants") 220 221 page.p("Participants for scheduling:") 222 223 for i, participant in enumerate(participants): 224 page.p() 225 page.input(name="participants", type="text", value=participant) 226 page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 227 page.p.close() 228 229 page.p() 230 page.input(name="participants", type="text") 231 page.input(name="add-participant", type="submit", value="Add") 232 page.p.close() 233 234 page.div.close() 235 236 return participants 237 238 # Full page output methods. 239 240 def show(self): 241 242 "Show the calendar for the current user." 243 244 handled = self.handle_newevent() 245 246 self.new_page(title="Calendar") 247 page = self.page 248 249 # Form controls are used in various places on the calendar page. 250 251 page.form(method="POST") 252 253 self.show_requests_on_page() 254 participants = self.show_participants_on_page() 255 256 # Show a button for scheduling a new event. 257 258 page.p(class_="controls") 259 page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") 260 page.p.close() 261 262 # Show controls for hiding empty days and busy slots. 263 # The positioning of the control, paragraph and table are important here. 264 265 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 266 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 267 268 page.p(class_="controls") 269 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 270 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 271 page.label("Show empty days", for_="showdays", class_="showdays disable") 272 page.label("Hide empty days", for_="showdays", class_="showdays enable") 273 page.input(name="reset", type="submit", value="Clear selections", id="reset") 274 page.label("Clear selections", for_="reset", class_="reset") 275 page.p.close() 276 277 freebusy = self.store.get_freebusy(self.user) 278 279 if not freebusy: 280 page.p("No events scheduled.") 281 return 282 283 # Obtain the user's timezone. 284 285 tzid = self.get_tzid() 286 287 # Day view: start at the earliest known day and produce days until the 288 # latest known day, perhaps with expandable sections of empty days. 289 290 # Month view: start at the earliest known month and produce months until 291 # the latest known month, perhaps with expandable sections of empty 292 # months. 293 294 # Details of users to invite to new events could be superimposed on the 295 # calendar. 296 297 # Requests are listed and linked to their tentative positions in the 298 # calendar. Other participants are also shown. 299 300 request_summary = self._get_request_summary() 301 302 period_groups = [request_summary, freebusy] 303 period_group_types = ["request", "freebusy"] 304 period_group_sources = ["Pending requests", "Your schedule"] 305 306 for i, participant in enumerate(participants): 307 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 308 period_group_types.append("freebusy-part%d" % i) 309 period_group_sources.append(participant) 310 311 groups = [] 312 group_columns = [] 313 group_types = period_group_types 314 group_sources = period_group_sources 315 all_points = set() 316 317 # Obtain time point information for each group of periods. 318 319 for periods in period_groups: 320 convert_periods(periods, tzid) 321 322 # Get the time scale with start and end points. 323 324 scale = get_scale(periods) 325 326 # Get the time slots for the periods. 327 # Time slots are collections of Point objects with lists of active 328 # periods. 329 330 slots = get_slots(scale) 331 332 # Add start of day time points for multi-day periods. 333 334 add_day_start_points(slots, tzid) 335 336 # Record the slots and all time points employed. 337 338 groups.append(slots) 339 all_points.update([point for point, active in slots]) 340 341 # Partition the groups into days. 342 343 days = {} 344 partitioned_groups = [] 345 partitioned_group_types = [] 346 partitioned_group_sources = [] 347 348 for slots, group_type, group_source in zip(groups, group_types, group_sources): 349 350 # Propagate time points to all groups of time slots. 351 352 add_slots(slots, all_points) 353 354 # Count the number of columns employed by the group. 355 356 columns = 0 357 358 # Partition the time slots by day. 359 360 partitioned = {} 361 362 for day, day_slots in partition_by_day(slots).items(): 363 364 # Construct a list of time intervals within the day. 365 366 intervals = [] 367 368 # Convert each partition to a mapping from points to active 369 # periods. 370 371 partitioned[day] = day_points = {} 372 373 last = None 374 375 for point, active in day_slots: 376 columns = max(columns, len(active)) 377 day_points[point] = active 378 379 if last: 380 intervals.append((last, point)) 381 382 last = point 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, endpoint in intervals: 548 continuation = point.point == get_start_of_day(point.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): 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 point.indicator == Point.REPEATED) and "busy" or \ 568 have_active_request and "suggested" or "empty", 569 continuation and "daystart" or "" 570 ]) 571 572 page.tr(class_=css) 573 if point.indicator == Point.PRINCIPAL: 574 page.th(class_="timeslot") 575 self._time_point(point, endpoint) 576 else: 577 page.th() 578 page.th.close() 579 580 # Obtain slots for the time point from each group. 581 582 for columns, slots, group_type in zip(group_columns, groups, group_types): 583 active = slots and slots.get(point) 584 585 # Where no periods exist for the given time interval, generate 586 # an empty cell. Where a participant provides no periods at all, 587 # the colspan is adjusted to be 1, not 0. 588 589 if not active: 590 self._empty_slot(point, endpoint, max(columns, 1)) 591 continue 592 593 slots = slots.items() 594 slots.sort() 595 spans = get_spans(slots) 596 597 empty = 0 598 599 # Show a column for each active period. 600 601 for p in active: 602 603 # The period can be None, meaning an empty column. 604 605 if p: 606 607 # Flush empty slots preceding this one. 608 609 if empty: 610 self._empty_slot(point, endpoint, empty) 611 empty = 0 612 613 key = p.get_key() 614 span = spans[key] 615 616 # Produce a table cell only at the start of the period 617 # or when continued at the start of a day. 618 # Points defining the ends of instant events should 619 # never define the start of new events. 620 621 if point.indicator == Point.PRINCIPAL and (point.point == p.start or continuation): 622 623 has_continued = continuation and point.point != p.start 624 will_continue = not ends_on_same_day(point.point, p.end, tzid) 625 is_organiser = p.organiser == self.user 626 627 css = " ".join([ 628 "event", 629 has_continued and "continued" or "", 630 will_continue and "continues" or "", 631 is_organiser and "organising" or "attending" 632 ]) 633 634 # Only anchor the first cell of events. 635 # Need to only anchor the first period for a recurring 636 # event. 637 638 html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "") 639 640 if point.point == p.start and html_id not in self.html_ids: 641 page.td(class_=css, rowspan=span, id=html_id) 642 self.html_ids.add(html_id) 643 else: 644 page.td(class_=css, rowspan=span) 645 646 # Only link to events if they are not being 647 # updated by requests. 648 649 if not p.summary or (p.uid, p.recurrenceid) in self._get_requests() and group_type != "request": 650 page.span(p.summary or "(Participant is busy)") 651 else: 652 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid)) 653 654 page.td.close() 655 else: 656 empty += 1 657 658 # Pad with empty columns. 659 660 empty = columns - len(active) 661 662 if empty: 663 self._empty_slot(point, endpoint, empty) 664 665 page.tr.close() 666 667 def _day_heading(self, day): 668 669 """ 670 Generate a heading for 'day' of the following form: 671 672 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 673 """ 674 675 page = self.page 676 daystr = format_datetime(day) 677 value, identifier = self._day_value_and_identifier(day) 678 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 679 680 def _time_point(self, point, endpoint): 681 682 """ 683 Generate headings for the 'point' to 'endpoint' period of the following 684 form: 685 686 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 687 <span class="endpoint">10:00:00 CET</span> 688 """ 689 690 page = self.page 691 tzid = self.get_tzid() 692 daystr = format_datetime(point.point.date()) 693 value, identifier = self._slot_value_and_identifier(point, endpoint) 694 slots = self.env.get_args().get("slot", []) 695 self._slot_selector(value, identifier, slots) 696 page.label(self.format_time(point.point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 697 page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint") 698 699 def _slot_selector(self, value, identifier, slots): 700 701 """ 702 Provide a timeslot control having the given 'value', employing the 703 indicated HTML 'identifier', and using the given 'slots' collection 704 to select any control whose 'value' is in this collection, unless the 705 "reset" request parameter has been asserted. 706 """ 707 708 reset = self.env.get_args().has_key("reset") 709 page = self.page 710 if not reset and value in slots: 711 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 712 else: 713 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 714 715 def _empty_slot(self, point, endpoint, colspan): 716 717 """ 718 Show an empty slot cell for the given 'point' and 'endpoint', with the 719 given 'colspan' configuring the cell's appearance. 720 """ 721 722 page = self.page 723 page.td(class_="empty%s" % (point.indicator == Point.PRINCIPAL and " container" or ""), colspan=colspan) 724 if point.indicator == Point.PRINCIPAL: 725 value, identifier = self._slot_value_and_identifier(point, endpoint) 726 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 727 page.td.close() 728 729 def _day_value_and_identifier(self, day): 730 731 "Return a day value and HTML identifier for the given 'day'." 732 733 value = "%s-" % format_datetime(day) 734 identifier = "day-%s" % value 735 return value, identifier 736 737 def _slot_value_and_identifier(self, point, endpoint): 738 739 """ 740 Return a slot value and HTML identifier for the given 'point' and 741 'endpoint'. 742 """ 743 744 value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "") 745 identifier = "slot-%s" % value 746 return value, identifier 747 748 # vim: tabstop=4 expandtab shiftwidth=4