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 self.new_page(title="Calendar") 245 page = self.page 246 247 handled = self.handle_newevent() 248 freebusy = self.store.get_freebusy(self.user) 249 250 if not freebusy: 251 page.p("No events scheduled.") 252 return 253 254 # Form controls are used in various places on the calendar page. 255 256 page.form(method="POST") 257 258 self.show_requests_on_page() 259 participants = self.show_participants_on_page() 260 261 # Obtain the user's timezone. 262 263 tzid = self.get_tzid() 264 265 # Day view: start at the earliest known day and produce days until the 266 # latest known day, perhaps with expandable sections of empty days. 267 268 # Month view: start at the earliest known month and produce months until 269 # the latest known month, perhaps with expandable sections of empty 270 # months. 271 272 # Details of users to invite to new events could be superimposed on the 273 # calendar. 274 275 # Requests are listed and linked to their tentative positions in the 276 # calendar. Other participants are also shown. 277 278 request_summary = self._get_request_summary() 279 280 period_groups = [request_summary, freebusy] 281 period_group_types = ["request", "freebusy"] 282 period_group_sources = ["Pending requests", "Your schedule"] 283 284 for i, participant in enumerate(participants): 285 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 286 period_group_types.append("freebusy-part%d" % i) 287 period_group_sources.append(participant) 288 289 groups = [] 290 group_columns = [] 291 group_types = period_group_types 292 group_sources = period_group_sources 293 all_points = set() 294 295 # Obtain time point information for each group of periods. 296 297 for periods in period_groups: 298 convert_periods(periods, tzid) 299 300 # Get the time scale with start and end points. 301 302 scale = get_scale(periods) 303 304 # Get the time slots for the periods. 305 # Time slots are collections of Point objects with lists of active 306 # periods. 307 308 slots = get_slots(scale) 309 310 # Add start of day time points for multi-day periods. 311 312 add_day_start_points(slots, tzid) 313 314 # Record the slots and all time points employed. 315 316 groups.append(slots) 317 all_points.update([point for point, active in slots]) 318 319 # Partition the groups into days. 320 321 days = {} 322 partitioned_groups = [] 323 partitioned_group_types = [] 324 partitioned_group_sources = [] 325 326 for slots, group_type, group_source in zip(groups, group_types, group_sources): 327 328 # Propagate time points to all groups of time slots. 329 330 add_slots(slots, all_points) 331 332 # Count the number of columns employed by the group. 333 334 columns = 0 335 336 # Partition the time slots by day. 337 338 partitioned = {} 339 340 for day, day_slots in partition_by_day(slots).items(): 341 342 # Construct a list of time intervals within the day. 343 344 intervals = [] 345 346 # Convert each partition to a mapping from points to active 347 # periods. 348 349 partitioned[day] = day_points = {} 350 351 last = None 352 353 for point, active in day_slots: 354 columns = max(columns, len(active)) 355 day_points[point] = active 356 357 if last: 358 intervals.append((last, point)) 359 360 last = point 361 362 if last: 363 intervals.append((last, None)) 364 365 if not days.has_key(day): 366 days[day] = set() 367 368 # Record the divisions or intervals within each day. 369 370 days[day].update(intervals) 371 372 # Only include the requests column if it provides objects. 373 374 if group_type != "request" or columns: 375 group_columns.append(columns) 376 partitioned_groups.append(partitioned) 377 partitioned_group_types.append(group_type) 378 partitioned_group_sources.append(group_source) 379 380 # Add empty days. 381 382 add_empty_days(days, tzid) 383 384 # Show the controls permitting day selection as well as the controls 385 # configuring the new event display. 386 387 self.show_calendar_day_controls(days) 388 self.show_calendar_interval_controls(days) 389 390 # Show a button for scheduling a new event. 391 392 page.p(class_="controls") 393 page.input(name="newevent", type="submit", value="New event", id="newevent", class_="newevent-with-periods", accesskey="N") 394 page.span("Select days or periods for a new event.", class_="newevent-no-periods") 395 page.p.close() 396 397 # Show controls for hiding empty days and busy slots. 398 # The positioning of the control, paragraph and table are important here. 399 400 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 401 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 402 403 page.p(class_="controls") 404 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 405 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 406 page.label("Show empty days", for_="showdays", class_="showdays disable") 407 page.label("Hide empty days", for_="showdays", class_="showdays enable") 408 page.input(name="reset", type="submit", value="Clear selections", id="reset") 409 page.label("Clear selections", for_="reset", class_="reset newevent-with-periods") 410 page.p.close() 411 412 # Show the calendar itself. 413 414 page.table(cellspacing=5, cellpadding=5, class_="calendar") 415 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 416 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 417 page.table.close() 418 419 # End the form region. 420 421 page.form.close() 422 423 # More page fragment methods. 424 425 def show_calendar_day_controls(self, days): 426 427 "Show controls for the given 'days' in the calendar." 428 429 page = self.page 430 slots = self.env.get_args().get("slot", []) 431 432 for day in days: 433 value, identifier = self._day_value_and_identifier(day) 434 self._slot_selector(value, identifier, slots) 435 436 # Generate a dynamic stylesheet to allow day selections to colour 437 # specific days. 438 # NOTE: The style details need to be coordinated with the static 439 # NOTE: stylesheet. 440 441 page.style(type="text/css") 442 443 l = [] 444 445 for day in days: 446 daystr, dayid = self._day_value_and_identifier(day) 447 l.append("""\ 448 input.newevent.selector#%s:checked ~ table label.day.day-%s, 449 input.newevent.selector#%s:checked ~ table label.timepoint.day-%s""" % (dayid, daystr, dayid, daystr)) 450 451 page.add(",\n".join(l)) 452 page.add(""" { 453 background-color: #5f4; 454 text-decoration: underline; 455 } 456 """) 457 458 page.style.close() 459 460 def show_calendar_interval_controls(self, days): 461 462 "Show controls for the intervals provided by 'days'." 463 464 page = self.page 465 slots = self.env.get_args().get("slot", []) 466 467 for day, intervals in days.items(): 468 for point, endpoint in intervals: 469 value, identifier = self._slot_value_and_identifier(point, endpoint) 470 self._slot_selector(value, identifier, slots) 471 472 # Generate a dynamic stylesheet to allow day selections to colour 473 # specific days. 474 # NOTE: The style details need to be coordinated with the static 475 # NOTE: stylesheet. 476 477 page.style(type="text/css") 478 479 l = []; l2 = []; l3 = [] 480 481 for day, intervals in days.items(): 482 for point, endpoint in intervals: 483 daystr, dayid = self._day_value_and_identifier(day) 484 timestr, timeid = self._slot_value_and_identifier(point, endpoint) 485 l.append("""\ 486 input.newevent.selector#%s:checked ~ p .newevent-no-periods, 487 input.newevent.selector#%s:checked ~ p .newevent-no-periods""" % (dayid, timeid)) 488 l2.append("""\ 489 input.newevent.selector#%s:checked ~ p .newevent-with-periods, 490 input.newevent.selector#%s:checked ~ p .newevent-with-periods""" % (dayid, timeid)) 491 l3.append("""\ 492 input.newevent.selector#%s:checked ~ table label.timepoint[for=%s]""" % (timeid, timeid)) 493 494 page.add(",\n".join(l)) 495 page.add(""" { 496 display: none; 497 }""") 498 499 page.add(",\n".join(l2)) 500 page.add(""" { 501 display: inline; 502 } 503 """) 504 505 page.add(",\n".join(l3)) 506 page.add(""" { 507 background-color: #5f4; 508 text-decoration: underline; 509 } 510 """) 511 512 page.style.close() 513 514 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 515 516 """ 517 Show headings for the participants and other scheduling contributors, 518 defined by 'group_types', 'group_sources' and 'group_columns'. 519 """ 520 521 page = self.page 522 523 page.colgroup(span=1, id="columns-timeslot") 524 525 for group_type, columns in zip(group_types, group_columns): 526 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 527 528 page.thead() 529 page.tr() 530 page.th("", class_="emptyheading") 531 532 for group_type, source, columns in zip(group_types, group_sources, group_columns): 533 page.th(source, 534 class_=(group_type == "request" and "requestheading" or "participantheading"), 535 colspan=max(columns, 1)) 536 537 page.tr.close() 538 page.thead.close() 539 540 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 541 542 """ 543 Show calendar days, defined by a collection of 'days', the contributing 544 period information as 'partitioned_groups' (partitioned by day), the 545 'partitioned_group_types' indicating the kind of contribution involved, 546 and the 'group_columns' defining the number of columns in each group. 547 """ 548 549 page = self.page 550 551 # Determine the number of columns required. Where participants provide 552 # no columns for events, one still needs to be provided for the 553 # participant itself. 554 555 all_columns = sum([max(columns, 1) for columns in group_columns]) 556 557 # Determine the days providing time slots. 558 559 all_days = days.items() 560 all_days.sort() 561 562 # Produce a heading and time points for each day. 563 564 for day, intervals in all_days: 565 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 566 is_empty = True 567 568 for slots in groups_for_day: 569 if not slots: 570 continue 571 572 for active in slots.values(): 573 if active: 574 is_empty = False 575 break 576 577 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 578 page.tr() 579 page.th(class_="dayheading container", colspan=all_columns+1) 580 self._day_heading(day) 581 page.th.close() 582 page.tr.close() 583 page.thead.close() 584 585 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 586 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 587 page.tbody.close() 588 589 def show_calendar_points(self, intervals, groups, group_types, group_columns): 590 591 """ 592 Show the time 'intervals' along with period information from the given 593 'groups', having the indicated 'group_types', each with the number of 594 columns given by 'group_columns'. 595 """ 596 597 page = self.page 598 599 # Obtain the user's timezone. 600 601 tzid = self.get_tzid() 602 603 # Produce a row for each interval. 604 605 intervals = list(intervals) 606 intervals.sort() 607 608 for point, endpoint in intervals: 609 continuation = point.point == get_start_of_day(point.point, tzid) 610 611 # Some rows contain no period details and are marked as such. 612 613 have_active = False 614 have_active_request = False 615 616 for slots, group_type in zip(groups, group_types): 617 if slots and slots.get(point): 618 if group_type == "request": 619 have_active_request = True 620 else: 621 have_active = True 622 623 # Emit properties of the time interval, where post-instant intervals 624 # are also treated as busy. 625 626 css = " ".join([ 627 "slot", 628 (have_active or point.indicator == Point.REPEATED) and "busy" or \ 629 have_active_request and "suggested" or "empty", 630 continuation and "daystart" or "" 631 ]) 632 633 page.tr(class_=css) 634 if point.indicator == Point.PRINCIPAL: 635 page.th(class_="timeslot") 636 self._time_point(point, endpoint) 637 else: 638 page.th() 639 page.th.close() 640 641 # Obtain slots for the time point from each group. 642 643 for columns, slots, group_type in zip(group_columns, groups, group_types): 644 active = slots and slots.get(point) 645 646 # Where no periods exist for the given time interval, generate 647 # an empty cell. Where a participant provides no periods at all, 648 # the colspan is adjusted to be 1, not 0. 649 650 if not active: 651 self._empty_slot(point, endpoint, max(columns, 1)) 652 continue 653 654 slots = slots.items() 655 slots.sort() 656 spans = get_spans(slots) 657 658 empty = 0 659 660 # Show a column for each active period. 661 662 for p in active: 663 664 # The period can be None, meaning an empty column. 665 666 if p: 667 668 # Flush empty slots preceding this one. 669 670 if empty: 671 self._empty_slot(point, endpoint, empty) 672 empty = 0 673 674 key = p.get_key() 675 span = spans[key] 676 677 # Produce a table cell only at the start of the period 678 # or when continued at the start of a day. 679 # Points defining the ends of instant events should 680 # never define the start of new events. 681 682 if point.indicator == Point.PRINCIPAL and (point.point == p.start or continuation): 683 684 has_continued = continuation and point.point != p.start 685 will_continue = not ends_on_same_day(point.point, p.end, tzid) 686 is_organiser = p.organiser == self.user 687 688 css = " ".join([ 689 "event", 690 has_continued and "continued" or "", 691 will_continue and "continues" or "", 692 p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending" 693 ]) 694 695 # Only anchor the first cell of events. 696 # Need to only anchor the first period for a recurring 697 # event. 698 699 html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "") 700 701 if point.point == p.start and html_id not in self.html_ids: 702 page.td(class_=css, rowspan=span, id=html_id) 703 self.html_ids.add(html_id) 704 else: 705 page.td(class_=css, rowspan=span) 706 707 # Only link to events if they are not being 708 # updated by requests. 709 710 if not p.summary or (p.uid, p.recurrenceid) in self._get_requests() and group_type != "request": 711 page.span(p.summary or "(Participant is busy)") 712 else: 713 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid)) 714 715 page.td.close() 716 else: 717 empty += 1 718 719 # Pad with empty columns. 720 721 empty = columns - len(active) 722 723 if empty: 724 self._empty_slot(point, endpoint, empty) 725 726 page.tr.close() 727 728 def _day_heading(self, day): 729 730 """ 731 Generate a heading for 'day' of the following form: 732 733 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 734 """ 735 736 page = self.page 737 daystr = format_datetime(day) 738 value, identifier = self._day_value_and_identifier(day) 739 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 740 741 def _time_point(self, point, endpoint): 742 743 """ 744 Generate headings for the 'point' to 'endpoint' period of the following 745 form: 746 747 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 748 <span class="endpoint">10:00:00 CET</span> 749 """ 750 751 page = self.page 752 tzid = self.get_tzid() 753 daystr = format_datetime(point.point.date()) 754 value, identifier = self._slot_value_and_identifier(point, endpoint) 755 page.label(self.format_time(point.point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 756 page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint") 757 758 def _slot_selector(self, value, identifier, slots): 759 760 """ 761 Provide a timeslot control having the given 'value', employing the 762 indicated HTML 'identifier', and using the given 'slots' collection 763 to select any control whose 'value' is in this collection, unless the 764 "reset" request parameter has been asserted. 765 """ 766 767 reset = self.env.get_args().has_key("reset") 768 page = self.page 769 if not reset and value in slots: 770 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 771 else: 772 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 773 774 def _empty_slot(self, point, endpoint, colspan): 775 776 """ 777 Show an empty slot cell for the given 'point' and 'endpoint', with the 778 given 'colspan' configuring the cell's appearance. 779 """ 780 781 page = self.page 782 page.td(class_="empty%s" % (point.indicator == Point.PRINCIPAL and " container" or ""), colspan=colspan) 783 if point.indicator == Point.PRINCIPAL: 784 value, identifier = self._slot_value_and_identifier(point, endpoint) 785 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 786 page.td.close() 787 788 def _day_value_and_identifier(self, day): 789 790 "Return a day value and HTML identifier for the given 'day'." 791 792 value = format_datetime(day) 793 identifier = "day-%s" % value 794 return value, identifier 795 796 def _slot_value_and_identifier(self, point, endpoint): 797 798 """ 799 Return a slot value and HTML identifier for the given 'point' and 800 'endpoint'. 801 """ 802 803 value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "") 804 identifier = "slot-%s" % value 805 return value, identifier 806 807 # vim: tabstop=4 expandtab shiftwidth=4