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