paul@446 | 1 | #!/usr/bin/env python |
paul@446 | 2 | |
paul@446 | 3 | """ |
paul@446 | 4 | A Web interface to an event calendar. |
paul@446 | 5 | |
paul@1213 | 6 | Copyright (C) 2014, 2015, 2016, 2017 Paul Boddie <paul@boddie.org.uk> |
paul@446 | 7 | |
paul@446 | 8 | This program is free software; you can redistribute it and/or modify it under |
paul@446 | 9 | the terms of the GNU General Public License as published by the Free Software |
paul@446 | 10 | Foundation; either version 3 of the License, or (at your option) any later |
paul@446 | 11 | version. |
paul@446 | 12 | |
paul@446 | 13 | This program is distributed in the hope that it will be useful, but WITHOUT |
paul@446 | 14 | ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
paul@446 | 15 | FOR A PARTICULAR PURPOSE. See the GNU General Public License for more |
paul@446 | 16 | details. |
paul@446 | 17 | |
paul@446 | 18 | You should have received a copy of the GNU General Public License along with |
paul@446 | 19 | this program. If not, see <http://www.gnu.org/licenses/>. |
paul@446 | 20 | """ |
paul@446 | 21 | |
paul@876 | 22 | from datetime import datetime, timedelta |
paul@1204 | 23 | from imiptools.data import get_address, get_uri, get_verbose_address, make_uid, \ |
paul@1204 | 24 | uri_parts |
paul@883 | 25 | from imiptools.dates import format_datetime, get_date, get_datetime, \ |
paul@446 | 26 | get_datetime_item, get_end_of_day, get_start_of_day, \ |
paul@446 | 27 | get_start_of_next_day, get_timestamp, ends_on_same_day, \ |
paul@889 | 28 | to_date, to_timezone |
paul@446 | 29 | from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ |
paul@874 | 30 | get_scale, get_slots, get_spans, partition_by_day, \ |
paul@931 | 31 | remove_end_slot, Period, Point |
paul@928 | 32 | from imipweb.resource import FormUtilities, ResourceClient |
paul@446 | 33 | |
paul@928 | 34 | class CalendarPage(ResourceClient, FormUtilities): |
paul@446 | 35 | |
paul@446 | 36 | "A request handler for the calendar page." |
paul@446 | 37 | |
paul@446 | 38 | # Request logic methods. |
paul@446 | 39 | |
paul@446 | 40 | def handle_newevent(self): |
paul@446 | 41 | |
paul@446 | 42 | """ |
paul@446 | 43 | Handle any new event operation, creating a new event and redirecting to |
paul@446 | 44 | the event page for further activity. |
paul@446 | 45 | """ |
paul@446 | 46 | |
paul@1005 | 47 | _ = self.get_translator() |
paul@1005 | 48 | |
paul@1162 | 49 | # Check the validation token. |
paul@1162 | 50 | |
paul@1162 | 51 | if not self.check_validation_token(): |
paul@1162 | 52 | return False |
paul@1162 | 53 | |
paul@446 | 54 | # Handle a submitted form. |
paul@446 | 55 | |
paul@446 | 56 | args = self.env.get_args() |
paul@446 | 57 | |
paul@778 | 58 | for key in args.keys(): |
paul@778 | 59 | if key.startswith("newevent-"): |
paul@778 | 60 | i = key[len("newevent-"):] |
paul@778 | 61 | break |
paul@778 | 62 | else: |
paul@873 | 63 | return False |
paul@446 | 64 | |
paul@446 | 65 | # Create a new event using the available information. |
paul@446 | 66 | |
paul@446 | 67 | slots = args.get("slot", []) |
paul@446 | 68 | participants = args.get("participants", []) |
paul@778 | 69 | summary = args.get("summary-%s" % i, [None])[0] |
paul@446 | 70 | |
paul@446 | 71 | if not slots: |
paul@873 | 72 | return False |
paul@446 | 73 | |
paul@446 | 74 | # Obtain the user's timezone. |
paul@446 | 75 | |
paul@446 | 76 | tzid = self.get_tzid() |
paul@446 | 77 | |
paul@446 | 78 | # Coalesce the selected slots. |
paul@446 | 79 | |
paul@446 | 80 | slots.sort() |
paul@446 | 81 | coalesced = [] |
paul@446 | 82 | last = None |
paul@446 | 83 | |
paul@446 | 84 | for slot in slots: |
paul@537 | 85 | start, end = (slot.split("-", 1) + [None])[:2] |
paul@446 | 86 | start = get_datetime(start, {"TZID" : tzid}) |
paul@446 | 87 | end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) |
paul@446 | 88 | |
paul@446 | 89 | if last: |
paul@446 | 90 | last_start, last_end = last |
paul@446 | 91 | |
paul@446 | 92 | # Merge adjacent dates and datetimes. |
paul@446 | 93 | |
paul@446 | 94 | if start == last_end or \ |
paul@446 | 95 | not isinstance(start, datetime) and \ |
paul@446 | 96 | get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): |
paul@446 | 97 | |
paul@446 | 98 | last = last_start, end |
paul@446 | 99 | continue |
paul@446 | 100 | |
paul@446 | 101 | # Handle datetimes within dates. |
paul@446 | 102 | # Datetime periods are within single days and are therefore |
paul@446 | 103 | # discarded. |
paul@446 | 104 | |
paul@446 | 105 | elif not isinstance(last_start, datetime) and \ |
paul@446 | 106 | get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): |
paul@446 | 107 | |
paul@446 | 108 | continue |
paul@446 | 109 | |
paul@446 | 110 | # Add separate dates and datetimes. |
paul@446 | 111 | |
paul@446 | 112 | else: |
paul@446 | 113 | coalesced.append(last) |
paul@446 | 114 | |
paul@446 | 115 | last = start, end |
paul@446 | 116 | |
paul@446 | 117 | if last: |
paul@446 | 118 | coalesced.append(last) |
paul@446 | 119 | |
paul@446 | 120 | # Invent a unique identifier. |
paul@446 | 121 | |
paul@1204 | 122 | uid = make_uid(self.user) |
paul@446 | 123 | |
paul@446 | 124 | # Create a calendar object and store it as a request. |
paul@446 | 125 | |
paul@446 | 126 | record = [] |
paul@446 | 127 | rwrite = record.append |
paul@446 | 128 | |
paul@446 | 129 | # Define a single occurrence if only one coalesced slot exists. |
paul@446 | 130 | |
paul@446 | 131 | start, end = coalesced[0] |
paul@446 | 132 | start_value, start_attr = get_datetime_item(start, tzid) |
paul@446 | 133 | end_value, end_attr = get_datetime_item(end, tzid) |
paul@794 | 134 | user_attr = self.get_user_attributes() |
paul@446 | 135 | |
paul@1213 | 136 | utcnow = get_timestamp() |
paul@1213 | 137 | |
paul@446 | 138 | rwrite(("UID", {}, uid)) |
paul@1005 | 139 | rwrite(("SUMMARY", {}, summary or (_("New event at %s") % utcnow))) |
paul@446 | 140 | rwrite(("DTSTAMP", {}, utcnow)) |
paul@446 | 141 | rwrite(("DTSTART", start_attr, start_value)) |
paul@446 | 142 | rwrite(("DTEND", end_attr, end_value)) |
paul@794 | 143 | rwrite(("ORGANIZER", user_attr, self.user)) |
paul@794 | 144 | |
paul@794 | 145 | cn_participants = uri_parts(filter(None, participants)) |
paul@794 | 146 | participants = [] |
paul@446 | 147 | |
paul@794 | 148 | for cn, participant in cn_participants: |
paul@794 | 149 | d = {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"} |
paul@794 | 150 | if cn: |
paul@794 | 151 | d["CN"] = cn |
paul@794 | 152 | rwrite(("ATTENDEE", d, participant)) |
paul@794 | 153 | participants.append(participant) |
paul@446 | 154 | |
paul@446 | 155 | if self.user not in participants: |
paul@794 | 156 | d = {"PARTSTAT" : "ACCEPTED"} |
paul@794 | 157 | d.update(user_attr) |
paul@794 | 158 | rwrite(("ATTENDEE", d, self.user)) |
paul@446 | 159 | |
paul@446 | 160 | # Define additional occurrences if many slots are defined. |
paul@446 | 161 | |
paul@446 | 162 | rdates = [] |
paul@446 | 163 | |
paul@446 | 164 | for start, end in coalesced[1:]: |
paul@446 | 165 | start_value, start_attr = get_datetime_item(start, tzid) |
paul@446 | 166 | end_value, end_attr = get_datetime_item(end, tzid) |
paul@446 | 167 | rdates.append("%s/%s" % (start_value, end_value)) |
paul@446 | 168 | |
paul@446 | 169 | if rdates: |
paul@446 | 170 | rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates)) |
paul@446 | 171 | |
paul@446 | 172 | node = ("VEVENT", {}, record) |
paul@446 | 173 | |
paul@446 | 174 | self.store.set_event(self.user, uid, None, node=node) |
paul@446 | 175 | self.store.queue_request(self.user, uid) |
paul@446 | 176 | |
paul@446 | 177 | # Redirect to the object (or the first of the objects), where instead of |
paul@446 | 178 | # attendee controls, there will be organiser controls. |
paul@446 | 179 | |
paul@877 | 180 | self.redirect(self.link_to(uid, args=self.get_time_navigation_args())) |
paul@873 | 181 | return True |
paul@446 | 182 | |
paul@881 | 183 | def update_participants(self): |
paul@881 | 184 | |
paul@881 | 185 | "Update the participants used for scheduling purposes." |
paul@881 | 186 | |
paul@881 | 187 | args = self.env.get_args() |
paul@881 | 188 | participants = args.get("participants", []) |
paul@881 | 189 | |
paul@881 | 190 | try: |
paul@881 | 191 | for name, value in args.items(): |
paul@881 | 192 | if name.startswith("remove-participant-"): |
paul@881 | 193 | i = int(name[len("remove-participant-"):]) |
paul@881 | 194 | del participants[i] |
paul@881 | 195 | break |
paul@881 | 196 | except ValueError: |
paul@881 | 197 | pass |
paul@881 | 198 | |
paul@881 | 199 | # Trim empty participants. |
paul@881 | 200 | |
paul@881 | 201 | while participants and not participants[-1].strip(): |
paul@881 | 202 | participants.pop() |
paul@881 | 203 | |
paul@881 | 204 | return participants |
paul@881 | 205 | |
paul@446 | 206 | # Page fragment methods. |
paul@446 | 207 | |
paul@923 | 208 | def show_user_navigation(self): |
paul@923 | 209 | |
paul@923 | 210 | "Show user-specific navigation." |
paul@923 | 211 | |
paul@923 | 212 | page = self.page |
paul@923 | 213 | user_attr = self.get_user_attributes() |
paul@923 | 214 | |
paul@923 | 215 | page.p(id_="user-navigation") |
paul@923 | 216 | page.a(get_verbose_address(self.user, user_attr), href=self.link_to("profile"), class_="username") |
paul@923 | 217 | page.p.close() |
paul@923 | 218 | |
paul@446 | 219 | def show_requests_on_page(self): |
paul@446 | 220 | |
paul@446 | 221 | "Show requests for the current user." |
paul@446 | 222 | |
paul@1005 | 223 | _ = self.get_translator() |
paul@1005 | 224 | |
paul@446 | 225 | page = self.page |
paul@889 | 226 | view_period = self.get_view_period() |
paul@889 | 227 | duration = view_period and view_period.get_duration() or timedelta(1) |
paul@446 | 228 | |
paul@446 | 229 | # NOTE: This list could be more informative, but it is envisaged that |
paul@446 | 230 | # NOTE: the requests would be visited directly anyway. |
paul@446 | 231 | |
paul@446 | 232 | requests = self._get_requests() |
paul@446 | 233 | |
paul@446 | 234 | page.div(id="pending-requests") |
paul@446 | 235 | |
paul@446 | 236 | if requests: |
paul@1008 | 237 | page.p(_("Pending requests:")) |
paul@446 | 238 | |
paul@446 | 239 | page.ul() |
paul@446 | 240 | |
paul@751 | 241 | for uid, recurrenceid, request_type in requests: |
paul@751 | 242 | obj = self._get_object(uid, recurrenceid) |
paul@446 | 243 | if obj: |
paul@889 | 244 | |
paul@889 | 245 | # Provide a link showing the request in context. |
paul@889 | 246 | |
paul@889 | 247 | periods = self.get_periods(obj) |
paul@889 | 248 | if periods: |
paul@889 | 249 | start = to_date(periods[0].get_start()) |
paul@889 | 250 | end = max(to_date(periods[0].get_end()), start + duration) |
paul@889 | 251 | d = {"start" : format_datetime(start), "end" : format_datetime(end)} |
paul@889 | 252 | page.li() |
paul@889 | 253 | page.a(obj.get_value("SUMMARY"), href="%s#request-%s-%s" % (self.link_to(args=d), uid, recurrenceid or "")) |
paul@889 | 254 | page.li.close() |
paul@446 | 255 | |
paul@446 | 256 | page.ul.close() |
paul@446 | 257 | |
paul@446 | 258 | else: |
paul@1005 | 259 | page.p(_("There are no pending requests.")) |
paul@446 | 260 | |
paul@446 | 261 | page.div.close() |
paul@446 | 262 | |
paul@881 | 263 | def show_participants_on_page(self, participants): |
paul@446 | 264 | |
paul@446 | 265 | "Show participants for scheduling purposes." |
paul@446 | 266 | |
paul@1005 | 267 | _ = self.get_translator() |
paul@1005 | 268 | |
paul@446 | 269 | page = self.page |
paul@446 | 270 | |
paul@446 | 271 | # Show any specified participants together with controls to remove and |
paul@446 | 272 | # add participants. |
paul@446 | 273 | |
paul@446 | 274 | page.div(id="participants") |
paul@446 | 275 | |
paul@1005 | 276 | page.p(_("Participants for scheduling:")) |
paul@446 | 277 | |
paul@446 | 278 | for i, participant in enumerate(participants): |
paul@446 | 279 | page.p() |
paul@446 | 280 | page.input(name="participants", type="text", value=participant) |
paul@1005 | 281 | page.input(name="remove-participant-%d" % i, type="submit", value=_("Remove")) |
paul@446 | 282 | page.p.close() |
paul@446 | 283 | |
paul@446 | 284 | page.p() |
paul@446 | 285 | page.input(name="participants", type="text") |
paul@1005 | 286 | page.input(name="add-participant", type="submit", value=_("Add")) |
paul@446 | 287 | page.p.close() |
paul@446 | 288 | |
paul@446 | 289 | page.div.close() |
paul@446 | 290 | |
paul@881 | 291 | def show_calendar_controls(self): |
paul@881 | 292 | |
paul@881 | 293 | """ |
paul@881 | 294 | Show controls for hiding empty days and busy slots in the calendar. |
paul@881 | 295 | |
paul@881 | 296 | The positioning of the controls, paragraph and table are important here: |
paul@881 | 297 | the CSS file describes the relationship between them and the calendar |
paul@881 | 298 | tables. |
paul@881 | 299 | """ |
paul@881 | 300 | |
paul@1005 | 301 | _ = self.get_translator() |
paul@1005 | 302 | |
paul@881 | 303 | page = self.page |
paul@928 | 304 | args = self.env.get_args() |
paul@881 | 305 | |
paul@928 | 306 | self.control("showdays", "checkbox", "show", ("show" in args.get("showdays", [])), id="showdays", accesskey="D") |
paul@928 | 307 | self.control("hidebusy", "checkbox", "hide", ("hide" in args.get("hidebusy", [])), id="hidebusy", accesskey="B") |
paul@881 | 308 | |
paul@883 | 309 | page.p(id_="calendar-controls", class_="controls") |
paul@1005 | 310 | page.span(_("Select days or periods for a new event.")) |
paul@1005 | 311 | page.label(_("Hide busy time periods"), for_="hidebusy", class_="hidebusy enable") |
paul@1005 | 312 | page.label(_("Show busy time periods"), for_="hidebusy", class_="hidebusy disable") |
paul@1005 | 313 | page.label(_("Show empty days"), for_="showdays", class_="showdays disable") |
paul@1005 | 314 | page.label(_("Hide empty days"), for_="showdays", class_="showdays enable") |
paul@1005 | 315 | page.input(name="reset", type="submit", value=_("Clear selections"), id="reset") |
paul@881 | 316 | page.p.close() |
paul@446 | 317 | |
paul@926 | 318 | def show_time_navigation(self, freebusy, view_period): |
paul@876 | 319 | |
paul@876 | 320 | """ |
paul@926 | 321 | Show the calendar navigation links for the schedule defined by |
paul@926 | 322 | 'freebusy' and for the period defined by 'view_period'. |
paul@876 | 323 | """ |
paul@876 | 324 | |
paul@1005 | 325 | _ = self.get_translator() |
paul@1005 | 326 | |
paul@876 | 327 | page = self.page |
paul@889 | 328 | view_start = view_period.get_start() |
paul@889 | 329 | view_end = view_period.get_end() |
paul@889 | 330 | duration = view_period.get_duration() |
paul@876 | 331 | |
paul@1189 | 332 | preceding_events = view_start and freebusy.get_overlapping([Period(None, view_start, self.get_tzid())]) or [] |
paul@1189 | 333 | following_events = view_end and freebusy.get_overlapping([Period(view_end, None, self.get_tzid())]) or [] |
paul@926 | 334 | |
paul@926 | 335 | last_preceding = preceding_events and to_date(preceding_events[-1].get_end()) + timedelta(1) or None |
paul@926 | 336 | first_following = following_events and to_date(following_events[0].get_start()) or None |
paul@926 | 337 | |
paul@881 | 338 | page.p(id_="time-navigation") |
paul@876 | 339 | |
paul@876 | 340 | if view_start: |
paul@930 | 341 | page.input(name="start", type="hidden", value=format_datetime(view_start)) |
paul@930 | 342 | |
paul@926 | 343 | if last_preceding: |
paul@926 | 344 | preceding_start = last_preceding - duration |
paul@1005 | 345 | page.label(_("Show earlier events"), for_="earlier-events", class_="earlier-events") |
paul@926 | 346 | page.input(name="earlier-events", id_="earlier-events", type="submit") |
paul@926 | 347 | page.input(name="earlier-events-start", type="hidden", value=format_datetime(preceding_start)) |
paul@926 | 348 | page.input(name="earlier-events-end", type="hidden", value=format_datetime(last_preceding)) |
paul@926 | 349 | |
paul@889 | 350 | earlier_start = view_start - duration |
paul@1005 | 351 | page.label(_("Show earlier"), for_="earlier", class_="earlier") |
paul@876 | 352 | page.input(name="earlier", id_="earlier", type="submit") |
paul@876 | 353 | page.input(name="earlier-start", type="hidden", value=format_datetime(earlier_start)) |
paul@876 | 354 | page.input(name="earlier-end", type="hidden", value=format_datetime(view_start)) |
paul@926 | 355 | |
paul@876 | 356 | if view_end: |
paul@930 | 357 | page.input(name="end", type="hidden", value=format_datetime(view_end)) |
paul@926 | 358 | |
paul@889 | 359 | later_end = view_end + duration |
paul@1005 | 360 | page.label(_("Show later"), for_="later", class_="later") |
paul@876 | 361 | page.input(name="later", id_="later", type="submit") |
paul@876 | 362 | page.input(name="later-start", type="hidden", value=format_datetime(view_end)) |
paul@876 | 363 | page.input(name="later-end", type="hidden", value=format_datetime(later_end)) |
paul@930 | 364 | |
paul@930 | 365 | if first_following: |
paul@930 | 366 | following_end = first_following + duration |
paul@1005 | 367 | page.label(_("Show later events"), for_="later-events", class_="later-events") |
paul@930 | 368 | page.input(name="later-events", id_="later-events", type="submit") |
paul@930 | 369 | page.input(name="later-events-start", type="hidden", value=format_datetime(first_following)) |
paul@930 | 370 | page.input(name="later-events-end", type="hidden", value=format_datetime(following_end)) |
paul@876 | 371 | |
paul@876 | 372 | page.p.close() |
paul@876 | 373 | |
paul@876 | 374 | def get_time_navigation(self): |
paul@876 | 375 | |
paul@876 | 376 | "Return the start and end dates for the calendar view." |
paul@876 | 377 | |
paul@876 | 378 | for args in [self.env.get_args(), self.env.get_query()]: |
paul@876 | 379 | if args.has_key("earlier"): |
paul@876 | 380 | start_name, end_name = "earlier-start", "earlier-end" |
paul@876 | 381 | break |
paul@926 | 382 | elif args.has_key("earlier-events"): |
paul@926 | 383 | start_name, end_name = "earlier-events-start", "earlier-events-end" |
paul@926 | 384 | break |
paul@876 | 385 | elif args.has_key("later"): |
paul@876 | 386 | start_name, end_name = "later-start", "later-end" |
paul@876 | 387 | break |
paul@926 | 388 | elif args.has_key("later-events"): |
paul@926 | 389 | start_name, end_name = "later-events-start", "later-events-end" |
paul@926 | 390 | break |
paul@876 | 391 | elif args.has_key("start") or args.has_key("end"): |
paul@876 | 392 | start_name, end_name = "start", "end" |
paul@876 | 393 | break |
paul@876 | 394 | else: |
paul@876 | 395 | return None, None |
paul@876 | 396 | |
paul@876 | 397 | view_start = self.get_date_arg(args, start_name) |
paul@876 | 398 | view_end = self.get_date_arg(args, end_name) |
paul@876 | 399 | return view_start, view_end |
paul@876 | 400 | |
paul@877 | 401 | def get_time_navigation_args(self): |
paul@877 | 402 | |
paul@877 | 403 | "Return a dictionary containing start and/or end navigation details." |
paul@877 | 404 | |
paul@889 | 405 | view_period = self.get_view_period() |
paul@889 | 406 | view_start = view_period.get_start() |
paul@889 | 407 | view_end = view_period.get_end() |
paul@877 | 408 | link_args = {} |
paul@877 | 409 | if view_start: |
paul@877 | 410 | link_args["start"] = format_datetime(view_start) |
paul@877 | 411 | if view_end: |
paul@877 | 412 | link_args["end"] = format_datetime(view_end) |
paul@877 | 413 | return link_args |
paul@877 | 414 | |
paul@889 | 415 | def get_view_period(self): |
paul@889 | 416 | |
paul@889 | 417 | "Return the current view period." |
paul@889 | 418 | |
paul@889 | 419 | view_start, view_end = self.get_time_navigation() |
paul@889 | 420 | |
paul@889 | 421 | # Without any explicit limits, impose a reasonable view period. |
paul@889 | 422 | |
paul@889 | 423 | if not (view_start or view_end): |
paul@889 | 424 | view_start = get_date() |
paul@889 | 425 | view_end = get_date(timedelta(7)) |
paul@889 | 426 | |
paul@889 | 427 | return Period(view_start, view_end, self.get_tzid()) |
paul@889 | 428 | |
paul@913 | 429 | def show_view_period(self, view_period): |
paul@913 | 430 | |
paul@913 | 431 | "Show a description of the 'view_period'." |
paul@913 | 432 | |
paul@1005 | 433 | _ = self.get_translator() |
paul@1005 | 434 | |
paul@913 | 435 | page = self.page |
paul@913 | 436 | |
paul@913 | 437 | view_start = view_period.get_start() |
paul@913 | 438 | view_end = view_period.get_end() |
paul@913 | 439 | |
paul@913 | 440 | if not (view_start or view_end): |
paul@913 | 441 | return |
paul@913 | 442 | |
paul@913 | 443 | page.p(class_="view-period") |
paul@913 | 444 | |
paul@913 | 445 | if view_start and view_end: |
paul@1005 | 446 | page.add(_("Showing events from %(start)s until %(end)s") % { |
paul@1005 | 447 | "start" : self.format_date(view_start, "full"), |
paul@1005 | 448 | "end" : self.format_date(view_end, "full")}) |
paul@913 | 449 | elif view_start: |
paul@1005 | 450 | page.add(_("Showing events from %s") % self.format_date(view_start, "full")) |
paul@913 | 451 | elif view_end: |
paul@1005 | 452 | page.add(_("Showing events until %s") % self.format_date(view_end, "full")) |
paul@913 | 453 | |
paul@913 | 454 | page.p.close() |
paul@913 | 455 | |
paul@883 | 456 | def get_period_group_details(self, freebusy, participants, view_period): |
paul@873 | 457 | |
paul@883 | 458 | """ |
paul@883 | 459 | Return details of periods in the given 'freebusy' collection and for the |
paul@883 | 460 | collections of the given 'participants'. |
paul@883 | 461 | """ |
paul@446 | 462 | |
paul@1005 | 463 | _ = self.get_translator() |
paul@1005 | 464 | |
paul@446 | 465 | # Obtain the user's timezone. |
paul@446 | 466 | |
paul@446 | 467 | tzid = self.get_tzid() |
paul@446 | 468 | |
paul@446 | 469 | # Requests are listed and linked to their tentative positions in the |
paul@446 | 470 | # calendar. Other participants are also shown. |
paul@446 | 471 | |
paul@446 | 472 | request_summary = self._get_request_summary() |
paul@446 | 473 | |
paul@446 | 474 | period_groups = [request_summary, freebusy] |
paul@446 | 475 | period_group_types = ["request", "freebusy"] |
paul@1005 | 476 | period_group_sources = [_("Pending requests"), _("Your schedule")] |
paul@446 | 477 | |
paul@446 | 478 | for i, participant in enumerate(participants): |
paul@446 | 479 | period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) |
paul@446 | 480 | period_group_types.append("freebusy-part%d" % i) |
paul@446 | 481 | period_group_sources.append(participant) |
paul@446 | 482 | |
paul@446 | 483 | groups = [] |
paul@446 | 484 | group_columns = [] |
paul@446 | 485 | group_types = period_group_types |
paul@446 | 486 | group_sources = period_group_sources |
paul@446 | 487 | all_points = set() |
paul@446 | 488 | |
paul@446 | 489 | # Obtain time point information for each group of periods. |
paul@446 | 490 | |
paul@446 | 491 | for periods in period_groups: |
paul@446 | 492 | |
paul@874 | 493 | # Filter periods outside the given view. |
paul@874 | 494 | |
paul@874 | 495 | if view_period: |
paul@1189 | 496 | periods = periods.get_overlapping([view_period]) |
paul@874 | 497 | |
paul@446 | 498 | # Get the time scale with start and end points. |
paul@446 | 499 | |
paul@884 | 500 | scale = get_scale(periods, tzid, view_period) |
paul@446 | 501 | |
paul@446 | 502 | # Get the time slots for the periods. |
paul@456 | 503 | # Time slots are collections of Point objects with lists of active |
paul@456 | 504 | # periods. |
paul@446 | 505 | |
paul@446 | 506 | slots = get_slots(scale) |
paul@446 | 507 | |
paul@446 | 508 | # Add start of day time points for multi-day periods. |
paul@446 | 509 | |
paul@446 | 510 | add_day_start_points(slots, tzid) |
paul@446 | 511 | |
paul@931 | 512 | # Remove the slot at the end of a view. |
paul@931 | 513 | |
paul@931 | 514 | if view_period: |
paul@931 | 515 | remove_end_slot(slots, view_period) |
paul@931 | 516 | |
paul@446 | 517 | # Record the slots and all time points employed. |
paul@446 | 518 | |
paul@446 | 519 | groups.append(slots) |
paul@456 | 520 | all_points.update([point for point, active in slots]) |
paul@446 | 521 | |
paul@446 | 522 | # Partition the groups into days. |
paul@446 | 523 | |
paul@446 | 524 | days = {} |
paul@446 | 525 | partitioned_groups = [] |
paul@446 | 526 | partitioned_group_types = [] |
paul@446 | 527 | partitioned_group_sources = [] |
paul@446 | 528 | |
paul@446 | 529 | for slots, group_type, group_source in zip(groups, group_types, group_sources): |
paul@446 | 530 | |
paul@446 | 531 | # Propagate time points to all groups of time slots. |
paul@446 | 532 | |
paul@446 | 533 | add_slots(slots, all_points) |
paul@446 | 534 | |
paul@446 | 535 | # Count the number of columns employed by the group. |
paul@446 | 536 | |
paul@446 | 537 | columns = 0 |
paul@446 | 538 | |
paul@446 | 539 | # Partition the time slots by day. |
paul@446 | 540 | |
paul@446 | 541 | partitioned = {} |
paul@446 | 542 | |
paul@446 | 543 | for day, day_slots in partition_by_day(slots).items(): |
paul@446 | 544 | |
paul@446 | 545 | # Construct a list of time intervals within the day. |
paul@446 | 546 | |
paul@446 | 547 | intervals = [] |
paul@449 | 548 | |
paul@449 | 549 | # Convert each partition to a mapping from points to active |
paul@449 | 550 | # periods. |
paul@449 | 551 | |
paul@449 | 552 | partitioned[day] = day_points = {} |
paul@449 | 553 | |
paul@446 | 554 | last = None |
paul@446 | 555 | |
paul@455 | 556 | for point, active in day_slots: |
paul@446 | 557 | columns = max(columns, len(active)) |
paul@455 | 558 | day_points[point] = active |
paul@451 | 559 | |
paul@446 | 560 | if last: |
paul@446 | 561 | intervals.append((last, point)) |
paul@449 | 562 | |
paul@455 | 563 | last = point |
paul@446 | 564 | |
paul@446 | 565 | if last: |
paul@446 | 566 | intervals.append((last, None)) |
paul@446 | 567 | |
paul@446 | 568 | if not days.has_key(day): |
paul@446 | 569 | days[day] = set() |
paul@446 | 570 | |
paul@446 | 571 | # Record the divisions or intervals within each day. |
paul@446 | 572 | |
paul@446 | 573 | days[day].update(intervals) |
paul@446 | 574 | |
paul@446 | 575 | # Only include the requests column if it provides objects. |
paul@446 | 576 | |
paul@446 | 577 | if group_type != "request" or columns: |
paul@869 | 578 | if group_type != "request": |
paul@869 | 579 | columns += 1 |
paul@446 | 580 | group_columns.append(columns) |
paul@446 | 581 | partitioned_groups.append(partitioned) |
paul@446 | 582 | partitioned_group_types.append(group_type) |
paul@446 | 583 | partitioned_group_sources.append(group_source) |
paul@446 | 584 | |
paul@883 | 585 | return days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns |
paul@883 | 586 | |
paul@883 | 587 | # Full page output methods. |
paul@883 | 588 | |
paul@883 | 589 | def show(self): |
paul@883 | 590 | |
paul@883 | 591 | "Show the calendar for the current user." |
paul@883 | 592 | |
paul@1005 | 593 | _ = self.get_translator() |
paul@1005 | 594 | |
paul@1005 | 595 | self.new_page(title=_("Calendar")) |
paul@883 | 596 | page = self.page |
paul@883 | 597 | |
paul@883 | 598 | if self.handle_newevent(): |
paul@883 | 599 | return |
paul@883 | 600 | |
paul@883 | 601 | freebusy = self.store.get_freebusy(self.user) |
paul@883 | 602 | participants = self.update_participants() |
paul@883 | 603 | |
paul@883 | 604 | # Form controls are used in various places on the calendar page. |
paul@883 | 605 | |
paul@883 | 606 | page.form(method="POST") |
paul@1162 | 607 | self.validator() |
paul@923 | 608 | self.show_user_navigation() |
paul@883 | 609 | self.show_requests_on_page() |
paul@883 | 610 | self.show_participants_on_page(participants) |
paul@883 | 611 | |
paul@926 | 612 | # Get the view period and details of events within it and outside it. |
paul@926 | 613 | |
paul@926 | 614 | view_period = self.get_view_period() |
paul@926 | 615 | |
paul@883 | 616 | # Day view: start at the earliest known day and produce days until the |
paul@883 | 617 | # latest known day, with expandable sections of empty days. |
paul@883 | 618 | |
paul@883 | 619 | (days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns) = \ |
paul@883 | 620 | self.get_period_group_details(freebusy, participants, view_period) |
paul@883 | 621 | |
paul@446 | 622 | # Add empty days. |
paul@446 | 623 | |
paul@889 | 624 | add_empty_days(days, self.get_tzid(), view_period.get_start(), view_period.get_end()) |
paul@513 | 625 | |
paul@881 | 626 | # Show controls to change the calendar appearance. |
paul@513 | 627 | |
paul@913 | 628 | self.show_view_period(view_period) |
paul@881 | 629 | self.show_calendar_controls() |
paul@926 | 630 | self.show_time_navigation(freebusy, view_period) |
paul@446 | 631 | |
paul@446 | 632 | # Show the calendar itself. |
paul@446 | 633 | |
paul@772 | 634 | self.show_calendar_days(days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns) |
paul@446 | 635 | |
paul@446 | 636 | # End the form region. |
paul@446 | 637 | |
paul@446 | 638 | page.form.close() |
paul@446 | 639 | |
paul@446 | 640 | # More page fragment methods. |
paul@446 | 641 | |
paul@773 | 642 | def show_calendar_day_controls(self, day): |
paul@446 | 643 | |
paul@773 | 644 | "Show controls for the given 'day' in the calendar." |
paul@446 | 645 | |
paul@446 | 646 | page = self.page |
paul@773 | 647 | daystr, dayid = self._day_value_and_identifier(day) |
paul@446 | 648 | |
paul@446 | 649 | # Generate a dynamic stylesheet to allow day selections to colour |
paul@446 | 650 | # specific days. |
paul@446 | 651 | # NOTE: The style details need to be coordinated with the static |
paul@446 | 652 | # NOTE: stylesheet. |
paul@446 | 653 | |
paul@446 | 654 | page.style(type="text/css") |
paul@446 | 655 | |
paul@773 | 656 | page.add("""\ |
paul@773 | 657 | input.newevent.selector#%s:checked ~ table#region-%s label.day, |
paul@773 | 658 | input.newevent.selector#%s:checked ~ table#region-%s label.timepoint { |
paul@773 | 659 | background-color: #5f4; |
paul@773 | 660 | text-decoration: underline; |
paul@773 | 661 | } |
paul@773 | 662 | """ % (dayid, dayid, dayid, dayid)) |
paul@773 | 663 | |
paul@773 | 664 | page.style.close() |
paul@773 | 665 | |
paul@773 | 666 | # Generate controls to select days. |
paul@773 | 667 | |
paul@773 | 668 | slots = self.env.get_args().get("slot", []) |
paul@773 | 669 | value, identifier = self._day_value_and_identifier(day) |
paul@773 | 670 | self._slot_selector(value, identifier, slots) |
paul@773 | 671 | |
paul@773 | 672 | def show_calendar_interval_controls(self, day, intervals): |
paul@773 | 673 | |
paul@773 | 674 | "Show controls for the intervals provided by 'day' and 'intervals'." |
paul@773 | 675 | |
paul@773 | 676 | page = self.page |
paul@773 | 677 | daystr, dayid = self._day_value_and_identifier(day) |
paul@773 | 678 | |
paul@773 | 679 | # Generate a dynamic stylesheet to allow day selections to colour |
paul@773 | 680 | # specific days. |
paul@773 | 681 | # NOTE: The style details need to be coordinated with the static |
paul@773 | 682 | # NOTE: stylesheet. |
paul@773 | 683 | |
paul@513 | 684 | l = [] |
paul@513 | 685 | |
paul@773 | 686 | for point, endpoint in intervals: |
paul@773 | 687 | timestr, timeid = self._slot_value_and_identifier(point, endpoint) |
paul@513 | 688 | l.append("""\ |
paul@773 | 689 | input.newevent.selector#%s:checked ~ table#region-%s th#region-%s""" % (timeid, dayid, timeid)) |
paul@773 | 690 | |
paul@773 | 691 | page.style(type="text/css") |
paul@513 | 692 | |
paul@513 | 693 | page.add(",\n".join(l)) |
paul@513 | 694 | page.add(""" { |
paul@446 | 695 | background-color: #5f4; |
paul@446 | 696 | text-decoration: underline; |
paul@446 | 697 | } |
paul@513 | 698 | """) |
paul@513 | 699 | |
paul@513 | 700 | page.style.close() |
paul@513 | 701 | |
paul@773 | 702 | # Generate controls to select time periods. |
paul@513 | 703 | |
paul@773 | 704 | slots = self.env.get_args().get("slot", []) |
paul@774 | 705 | last = None |
paul@774 | 706 | |
paul@774 | 707 | # Produce controls for the intervals/slots. Where instants in time are |
paul@774 | 708 | # encountered, they are merged with the following slots, permitting the |
paul@774 | 709 | # selection of contiguous time periods. However, the identifiers |
paul@774 | 710 | # employed by controls corresponding to merged periods will encode the |
paul@774 | 711 | # instant so that labels may reference them conveniently. |
paul@774 | 712 | |
paul@774 | 713 | intervals = list(intervals) |
paul@774 | 714 | intervals.sort() |
paul@774 | 715 | |
paul@773 | 716 | for point, endpoint in intervals: |
paul@774 | 717 | |
paul@774 | 718 | # Merge any previous slot with this one, producing a control. |
paul@774 | 719 | |
paul@774 | 720 | if last: |
paul@774 | 721 | _value, identifier = self._slot_value_and_identifier(last, last) |
paul@774 | 722 | value, _identifier = self._slot_value_and_identifier(last, endpoint) |
paul@774 | 723 | self._slot_selector(value, identifier, slots) |
paul@774 | 724 | |
paul@774 | 725 | # If representing an instant, hold the slot for merging. |
paul@774 | 726 | |
paul@774 | 727 | if endpoint and point.point == endpoint.point: |
paul@774 | 728 | last = point |
paul@774 | 729 | |
paul@774 | 730 | # If not representing an instant, produce a control. |
paul@774 | 731 | |
paul@774 | 732 | else: |
paul@774 | 733 | value, identifier = self._slot_value_and_identifier(point, endpoint) |
paul@774 | 734 | self._slot_selector(value, identifier, slots) |
paul@774 | 735 | last = None |
paul@774 | 736 | |
paul@774 | 737 | # Produce a control for any unmerged slot. |
paul@774 | 738 | |
paul@774 | 739 | if last: |
paul@774 | 740 | _value, identifier = self._slot_value_and_identifier(last, last) |
paul@774 | 741 | value, _identifier = self._slot_value_and_identifier(last, endpoint) |
paul@773 | 742 | self._slot_selector(value, identifier, slots) |
paul@446 | 743 | |
paul@446 | 744 | def show_calendar_participant_headings(self, group_types, group_sources, group_columns): |
paul@446 | 745 | |
paul@446 | 746 | """ |
paul@446 | 747 | Show headings for the participants and other scheduling contributors, |
paul@446 | 748 | defined by 'group_types', 'group_sources' and 'group_columns'. |
paul@446 | 749 | """ |
paul@446 | 750 | |
paul@446 | 751 | page = self.page |
paul@446 | 752 | |
paul@446 | 753 | page.colgroup(span=1, id="columns-timeslot") |
paul@446 | 754 | |
paul@995 | 755 | # Make column groups at least two cells wide. |
paul@995 | 756 | |
paul@446 | 757 | for group_type, columns in zip(group_types, group_columns): |
paul@929 | 758 | page.colgroup(span=max(columns, 2), id="columns-%s" % group_type) |
paul@446 | 759 | |
paul@446 | 760 | page.thead() |
paul@446 | 761 | page.tr() |
paul@446 | 762 | page.th("", class_="emptyheading") |
paul@446 | 763 | |
paul@446 | 764 | for group_type, source, columns in zip(group_types, group_sources, group_columns): |
paul@446 | 765 | page.th(source, |
paul@446 | 766 | class_=(group_type == "request" and "requestheading" or "participantheading"), |
paul@929 | 767 | colspan=max(columns, 2)) |
paul@446 | 768 | |
paul@446 | 769 | page.tr.close() |
paul@446 | 770 | page.thead.close() |
paul@446 | 771 | |
paul@772 | 772 | def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, |
paul@772 | 773 | partitioned_group_sources, group_columns): |
paul@446 | 774 | |
paul@446 | 775 | """ |
paul@446 | 776 | Show calendar days, defined by a collection of 'days', the contributing |
paul@446 | 777 | period information as 'partitioned_groups' (partitioned by day), the |
paul@446 | 778 | 'partitioned_group_types' indicating the kind of contribution involved, |
paul@772 | 779 | the 'partitioned_group_sources' indicating the origin of each group, and |
paul@772 | 780 | the 'group_columns' defining the number of columns in each group. |
paul@446 | 781 | """ |
paul@446 | 782 | |
paul@1005 | 783 | _ = self.get_translator() |
paul@1005 | 784 | |
paul@446 | 785 | page = self.page |
paul@446 | 786 | |
paul@446 | 787 | # Determine the number of columns required. Where participants provide |
paul@446 | 788 | # no columns for events, one still needs to be provided for the |
paul@446 | 789 | # participant itself. |
paul@446 | 790 | |
paul@446 | 791 | all_columns = sum([max(columns, 1) for columns in group_columns]) |
paul@446 | 792 | |
paul@446 | 793 | # Determine the days providing time slots. |
paul@446 | 794 | |
paul@446 | 795 | all_days = days.items() |
paul@446 | 796 | all_days.sort() |
paul@446 | 797 | |
paul@446 | 798 | # Produce a heading and time points for each day. |
paul@446 | 799 | |
paul@778 | 800 | i = 0 |
paul@778 | 801 | |
paul@446 | 802 | for day, intervals in all_days: |
paul@446 | 803 | groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] |
paul@446 | 804 | is_empty = True |
paul@446 | 805 | |
paul@446 | 806 | for slots in groups_for_day: |
paul@446 | 807 | if not slots: |
paul@446 | 808 | continue |
paul@446 | 809 | |
paul@446 | 810 | for active in slots.values(): |
paul@446 | 811 | if active: |
paul@446 | 812 | is_empty = False |
paul@446 | 813 | break |
paul@446 | 814 | |
paul@768 | 815 | daystr, dayid = self._day_value_and_identifier(day) |
paul@768 | 816 | |
paul@773 | 817 | # Put calendar tables within elements for quicker CSS selection. |
paul@773 | 818 | |
paul@773 | 819 | page.div(class_="calendar") |
paul@773 | 820 | |
paul@773 | 821 | # Show the controls permitting day selection as well as the controls |
paul@773 | 822 | # configuring the new event display. |
paul@773 | 823 | |
paul@773 | 824 | self.show_calendar_day_controls(day) |
paul@773 | 825 | self.show_calendar_interval_controls(day, intervals) |
paul@773 | 826 | |
paul@773 | 827 | # Show an actual table containing the day information. |
paul@773 | 828 | |
paul@772 | 829 | page.table(cellspacing=5, cellpadding=5, class_="calendar %s" % (is_empty and " empty" or ""), id="region-%s" % dayid) |
paul@772 | 830 | |
paul@772 | 831 | page.caption(class_="dayheading container separator") |
paul@446 | 832 | self._day_heading(day) |
paul@772 | 833 | page.caption.close() |
paul@446 | 834 | |
paul@772 | 835 | self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) |
paul@772 | 836 | |
paul@772 | 837 | page.tbody(class_="points") |
paul@446 | 838 | self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) |
paul@446 | 839 | page.tbody.close() |
paul@446 | 840 | |
paul@772 | 841 | page.table.close() |
paul@772 | 842 | |
paul@773 | 843 | # Show a button for scheduling a new event. |
paul@773 | 844 | |
paul@773 | 845 | page.p(class_="newevent-with-periods") |
paul@1005 | 846 | page.label(_("Summary:")) |
paul@778 | 847 | page.input(name="summary-%d" % i, type="text") |
paul@1005 | 848 | page.input(name="newevent-%d" % i, type="submit", value=_("New event"), accesskey="N") |
paul@773 | 849 | page.p.close() |
paul@773 | 850 | |
paul@876 | 851 | page.p(class_="newevent-with-periods") |
paul@1005 | 852 | page.label(_("Clear selections"), for_="reset", class_="reset") |
paul@876 | 853 | page.p.close() |
paul@876 | 854 | |
paul@773 | 855 | page.div.close() |
paul@773 | 856 | |
paul@778 | 857 | i += 1 |
paul@778 | 858 | |
paul@446 | 859 | def show_calendar_points(self, intervals, groups, group_types, group_columns): |
paul@446 | 860 | |
paul@446 | 861 | """ |
paul@446 | 862 | Show the time 'intervals' along with period information from the given |
paul@446 | 863 | 'groups', having the indicated 'group_types', each with the number of |
paul@446 | 864 | columns given by 'group_columns'. |
paul@446 | 865 | """ |
paul@446 | 866 | |
paul@1005 | 867 | _ = self.get_translator() |
paul@1005 | 868 | |
paul@446 | 869 | page = self.page |
paul@446 | 870 | |
paul@446 | 871 | # Obtain the user's timezone. |
paul@446 | 872 | |
paul@446 | 873 | tzid = self.get_tzid() |
paul@446 | 874 | |
paul@877 | 875 | # Get view information for links. |
paul@877 | 876 | |
paul@877 | 877 | link_args = self.get_time_navigation_args() |
paul@877 | 878 | |
paul@446 | 879 | # Produce a row for each interval. |
paul@446 | 880 | |
paul@446 | 881 | intervals = list(intervals) |
paul@446 | 882 | intervals.sort() |
paul@446 | 883 | |
paul@455 | 884 | for point, endpoint in intervals: |
paul@455 | 885 | continuation = point.point == get_start_of_day(point.point, tzid) |
paul@446 | 886 | |
paul@446 | 887 | # Some rows contain no period details and are marked as such. |
paul@446 | 888 | |
paul@448 | 889 | have_active = False |
paul@448 | 890 | have_active_request = False |
paul@448 | 891 | |
paul@448 | 892 | for slots, group_type in zip(groups, group_types): |
paul@455 | 893 | if slots and slots.get(point): |
paul@448 | 894 | if group_type == "request": |
paul@448 | 895 | have_active_request = True |
paul@448 | 896 | else: |
paul@448 | 897 | have_active = True |
paul@446 | 898 | |
paul@450 | 899 | # Emit properties of the time interval, where post-instant intervals |
paul@450 | 900 | # are also treated as busy. |
paul@450 | 901 | |
paul@446 | 902 | css = " ".join([ |
paul@446 | 903 | "slot", |
paul@455 | 904 | (have_active or point.indicator == Point.REPEATED) and "busy" or \ |
paul@455 | 905 | have_active_request and "suggested" or "empty", |
paul@446 | 906 | continuation and "daystart" or "" |
paul@446 | 907 | ]) |
paul@446 | 908 | |
paul@446 | 909 | page.tr(class_=css) |
paul@774 | 910 | |
paul@774 | 911 | # Produce a time interval heading, spanning two rows if this point |
paul@774 | 912 | # represents an instant. |
paul@774 | 913 | |
paul@455 | 914 | if point.indicator == Point.PRINCIPAL: |
paul@768 | 915 | timestr, timeid = self._slot_value_and_identifier(point, endpoint) |
paul@774 | 916 | page.th(class_="timeslot", id="region-%s" % timeid, |
paul@774 | 917 | rowspan=(endpoint and point.point == endpoint.point and 2 or 1)) |
paul@449 | 918 | self._time_point(point, endpoint) |
paul@774 | 919 | page.th.close() |
paul@446 | 920 | |
paul@446 | 921 | # Obtain slots for the time point from each group. |
paul@446 | 922 | |
paul@446 | 923 | for columns, slots, group_type in zip(group_columns, groups, group_types): |
paul@995 | 924 | |
paul@995 | 925 | # Make column groups at least two cells wide. |
paul@995 | 926 | |
paul@995 | 927 | columns = max(columns, 2) |
paul@455 | 928 | active = slots and slots.get(point) |
paul@446 | 929 | |
paul@446 | 930 | # Where no periods exist for the given time interval, generate |
paul@446 | 931 | # an empty cell. Where a participant provides no periods at all, |
paul@869 | 932 | # one column is provided; otherwise, one more column than the |
paul@869 | 933 | # number required is provided. |
paul@446 | 934 | |
paul@446 | 935 | if not active: |
paul@929 | 936 | self._empty_slot(point, endpoint, max(columns, 2)) |
paul@446 | 937 | continue |
paul@446 | 938 | |
paul@446 | 939 | slots = slots.items() |
paul@446 | 940 | slots.sort() |
paul@446 | 941 | spans = get_spans(slots) |
paul@446 | 942 | |
paul@446 | 943 | empty = 0 |
paul@446 | 944 | |
paul@446 | 945 | # Show a column for each active period. |
paul@446 | 946 | |
paul@458 | 947 | for p in active: |
paul@458 | 948 | |
paul@458 | 949 | # The period can be None, meaning an empty column. |
paul@458 | 950 | |
paul@458 | 951 | if p: |
paul@446 | 952 | |
paul@446 | 953 | # Flush empty slots preceding this one. |
paul@446 | 954 | |
paul@446 | 955 | if empty: |
paul@455 | 956 | self._empty_slot(point, endpoint, empty) |
paul@446 | 957 | empty = 0 |
paul@446 | 958 | |
paul@458 | 959 | key = p.get_key() |
paul@446 | 960 | span = spans[key] |
paul@446 | 961 | |
paul@446 | 962 | # Produce a table cell only at the start of the period |
paul@446 | 963 | # or when continued at the start of a day. |
paul@453 | 964 | # Points defining the ends of instant events should |
paul@453 | 965 | # never define the start of new events. |
paul@446 | 966 | |
paul@546 | 967 | if point.indicator == Point.PRINCIPAL and (point.point == p.get_start() or continuation): |
paul@446 | 968 | |
paul@546 | 969 | has_continued = continuation and point.point != p.get_start() |
paul@546 | 970 | will_continue = not ends_on_same_day(point.point, p.get_end(), tzid) |
paul@458 | 971 | is_organiser = p.organiser == self.user |
paul@446 | 972 | |
paul@446 | 973 | css = " ".join([ |
paul@446 | 974 | "event", |
paul@446 | 975 | has_continued and "continued" or "", |
paul@446 | 976 | will_continue and "continues" or "", |
paul@763 | 977 | p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending", |
paul@763 | 978 | self._have_request(p.uid, p.recurrenceid, "COUNTER", True) and "counter" or "", |
paul@446 | 979 | ]) |
paul@446 | 980 | |
paul@446 | 981 | # Only anchor the first cell of events. |
paul@446 | 982 | # Need to only anchor the first period for a recurring |
paul@446 | 983 | # event. |
paul@446 | 984 | |
paul@458 | 985 | html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "") |
paul@446 | 986 | |
paul@546 | 987 | if point.point == p.get_start() and html_id not in self.html_ids: |
paul@446 | 988 | page.td(class_=css, rowspan=span, id=html_id) |
paul@446 | 989 | self.html_ids.add(html_id) |
paul@446 | 990 | else: |
paul@446 | 991 | page.td(class_=css, rowspan=span) |
paul@446 | 992 | |
paul@755 | 993 | # Only link to events if they are not being updated |
paul@755 | 994 | # by requests. |
paul@755 | 995 | |
paul@755 | 996 | if not p.summary or \ |
paul@755 | 997 | group_type != "request" and self._have_request(p.uid, p.recurrenceid, None, True): |
paul@755 | 998 | |
paul@1005 | 999 | page.span(p.summary or _("(Participant is busy)")) |
paul@446 | 1000 | |
paul@755 | 1001 | # Link to requests and events (including ones for |
paul@755 | 1002 | # which counter-proposals exist). |
paul@755 | 1003 | |
paul@777 | 1004 | elif group_type == "request" and self._have_request(p.uid, p.recurrenceid, "COUNTER", True): |
paul@877 | 1005 | d = {"counter" : self._period_identifier(p)} |
paul@877 | 1006 | d.update(link_args) |
paul@877 | 1007 | page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, d)) |
paul@777 | 1008 | |
paul@446 | 1009 | else: |
paul@877 | 1010 | page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, link_args)) |
paul@446 | 1011 | |
paul@446 | 1012 | page.td.close() |
paul@446 | 1013 | else: |
paul@446 | 1014 | empty += 1 |
paul@446 | 1015 | |
paul@446 | 1016 | # Pad with empty columns. |
paul@446 | 1017 | |
paul@446 | 1018 | empty = columns - len(active) |
paul@446 | 1019 | |
paul@446 | 1020 | if empty: |
paul@904 | 1021 | self._empty_slot(point, endpoint, empty, True) |
paul@446 | 1022 | |
paul@446 | 1023 | page.tr.close() |
paul@446 | 1024 | |
paul@446 | 1025 | def _day_heading(self, day): |
paul@446 | 1026 | |
paul@446 | 1027 | """ |
paul@446 | 1028 | Generate a heading for 'day' of the following form: |
paul@446 | 1029 | |
paul@768 | 1030 | <label class="day" for="day-20150203">Tuesday, 3 February 2015</label> |
paul@446 | 1031 | """ |
paul@446 | 1032 | |
paul@446 | 1033 | page = self.page |
paul@446 | 1034 | value, identifier = self._day_value_and_identifier(day) |
paul@768 | 1035 | page.label(self.format_date(day, "full"), class_="day", for_=identifier) |
paul@446 | 1036 | |
paul@446 | 1037 | def _time_point(self, point, endpoint): |
paul@446 | 1038 | |
paul@446 | 1039 | """ |
paul@446 | 1040 | Generate headings for the 'point' to 'endpoint' period of the following |
paul@446 | 1041 | form: |
paul@446 | 1042 | |
paul@768 | 1043 | <label class="timepoint" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> |
paul@446 | 1044 | <span class="endpoint">10:00:00 CET</span> |
paul@446 | 1045 | """ |
paul@446 | 1046 | |
paul@446 | 1047 | page = self.page |
paul@446 | 1048 | tzid = self.get_tzid() |
paul@446 | 1049 | value, identifier = self._slot_value_and_identifier(point, endpoint) |
paul@768 | 1050 | page.label(self.format_time(point.point, "long"), class_="timepoint", for_=identifier) |
paul@455 | 1051 | page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint") |
paul@446 | 1052 | |
paul@446 | 1053 | def _slot_selector(self, value, identifier, slots): |
paul@446 | 1054 | |
paul@446 | 1055 | """ |
paul@446 | 1056 | Provide a timeslot control having the given 'value', employing the |
paul@446 | 1057 | indicated HTML 'identifier', and using the given 'slots' collection |
paul@446 | 1058 | to select any control whose 'value' is in this collection, unless the |
paul@446 | 1059 | "reset" request parameter has been asserted. |
paul@446 | 1060 | """ |
paul@446 | 1061 | |
paul@446 | 1062 | reset = self.env.get_args().has_key("reset") |
paul@446 | 1063 | page = self.page |
paul@446 | 1064 | if not reset and value in slots: |
paul@446 | 1065 | page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") |
paul@446 | 1066 | else: |
paul@446 | 1067 | page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") |
paul@446 | 1068 | |
paul@904 | 1069 | def _empty_slot(self, point, endpoint, colspan, at_end=False): |
paul@446 | 1070 | |
paul@453 | 1071 | """ |
paul@453 | 1072 | Show an empty slot cell for the given 'point' and 'endpoint', with the |
paul@455 | 1073 | given 'colspan' configuring the cell's appearance. |
paul@453 | 1074 | """ |
paul@446 | 1075 | |
paul@1005 | 1076 | _ = self.get_translator() |
paul@1005 | 1077 | |
paul@446 | 1078 | page = self.page |
paul@904 | 1079 | page.td(class_="empty%s%s" % (point.indicator == Point.PRINCIPAL and " container" or "", at_end and " padding" or ""), colspan=colspan) |
paul@455 | 1080 | if point.indicator == Point.PRINCIPAL: |
paul@453 | 1081 | value, identifier = self._slot_value_and_identifier(point, endpoint) |
paul@1005 | 1082 | page.label(_("Select/deselect period"), class_="newevent popup", for_=identifier) |
paul@453 | 1083 | page.td.close() |
paul@446 | 1084 | |
paul@446 | 1085 | def _day_value_and_identifier(self, day): |
paul@446 | 1086 | |
paul@446 | 1087 | "Return a day value and HTML identifier for the given 'day'." |
paul@446 | 1088 | |
paul@513 | 1089 | value = format_datetime(day) |
paul@446 | 1090 | identifier = "day-%s" % value |
paul@446 | 1091 | return value, identifier |
paul@446 | 1092 | |
paul@446 | 1093 | def _slot_value_and_identifier(self, point, endpoint): |
paul@446 | 1094 | |
paul@446 | 1095 | """ |
paul@446 | 1096 | Return a slot value and HTML identifier for the given 'point' and |
paul@446 | 1097 | 'endpoint'. |
paul@446 | 1098 | """ |
paul@446 | 1099 | |
paul@455 | 1100 | value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "") |
paul@446 | 1101 | identifier = "slot-%s" % value |
paul@446 | 1102 | return value, identifier |
paul@446 | 1103 | |
paul@777 | 1104 | def _period_identifier(self, period): |
paul@777 | 1105 | return "%s-%s" % (format_datetime(period.get_start()), format_datetime(period.get_end())) |
paul@777 | 1106 | |
paul@874 | 1107 | def get_date_arg(self, args, name): |
paul@874 | 1108 | values = args.get(name) |
paul@874 | 1109 | if not values: |
paul@874 | 1110 | return None |
paul@874 | 1111 | return get_datetime(values[0], {"VALUE-TYPE" : "DATE"}) |
paul@874 | 1112 | |
paul@446 | 1113 | # vim: tabstop=4 expandtab shiftwidth=4 |