3 """Renders an upcoming events page and countdowns page based on the
4 contents of several Google calendars."""
10 from typing import Any, Dict, List, Optional, Tuple
12 from dateutil.parser import parse
16 import kiosk_constants
22 logger = logging.getLogger(__name__)
25 class gcal_renderer(renderer.abstaining_renderer):
26 """A renderer to fetch upcoming events from www.google.com/calendar"""
28 calendar_whitelist = frozenset(
32 "Holidays in United States",
36 "Scott Gasch External - Misc",
37 "Birthdays", # <-- from g+ contacts
41 @functools.total_ordering
42 class comparable_event(object):
43 """A helper class to sort events."""
47 start_time: Optional[datetime.datetime],
48 end_time: Optional[datetime.datetime],
52 if start_time is None:
53 assert end_time is None
55 assert isinstance(start_time, datetime.datetime)
56 if end_time is not None:
57 assert isinstance(end_time, datetime.datetime)
58 self.start_time = start_time
59 self.end_time = end_time
60 self.summary = summary
61 self.calendar = calendar
63 def __lt__(self, that) -> bool:
64 if self.start_time is None and that.start_time is None:
65 return self.summary < that.summary
66 if self.start_time is None or that.start_time is None:
67 return self.start_time is None
68 return (self.start_time, self.end_time, self.summary, self.calendar) < (
75 def __eq__(self, that) -> bool:
77 self.start_time == that.start_time
78 and self.end_time == that.end_time
79 and self.summary == that.summary
80 and self.calendar == that.calendar
83 def __repr__(self) -> str:
84 return "[%s] %s" % (self.timestamp(), self.friendly_name())
86 def friendly_name(self) -> str:
88 name = name.replace("countdown:", "")
89 return "<B>%s</B>" % name
91 def timestamp(self) -> str:
92 now = datetime.datetime.now(pytz.timezone("US/Pacific"))
93 if self.start_time is None:
96 self.start_time.hour == 0
97 and self.start_time.minute == 0
98 and self.start_time.second == 0
100 if self.start_time.year == now.year:
101 return datetime.datetime.strftime(self.start_time, "%a %b %d")
103 return datetime.datetime.strftime(self.start_time, "%a %b %d, %Y")
107 local_dt = dt.replace(tzinfo=zone).astimezone(
108 tz=pytz.timezone("US/Pacific")
110 if local_dt.year == now.year:
111 return datetime.datetime.strftime(local_dt, "%a %b %d %I:%M%p")
113 return datetime.datetime.strftime(local_dt, "%a %b %d, %Y %I:%M%p")
116 self, name_to_timeout_dict: Dict[str, int], oauth: gdata_oauth.OAuth
118 super().__init__(name_to_timeout_dict)
120 self.client = self.oauth.calendar_service()
121 self.sortable_events: List[gcal_renderer.comparable_event] = []
122 self.countdown_events: List[gcal_renderer.comparable_event] = []
124 def debug_prefix(self) -> str:
127 def periodic_render(self, key: str) -> bool:
128 logger.debug(f'called for "{key}"')
129 if key == "Render Upcoming Events":
130 return self.render_upcoming_events()
131 elif key == "Look For Triggered Events":
132 return self.look_for_triggered_events()
134 raise Exception("Unexpected operation")
136 def get_min_max_timewindow(self) -> Tuple[str, str]:
137 now = datetime.datetime.now(pytz.timezone("US/Pacific"))
138 _time_min = now - datetime.timedelta(hours=6)
139 _time_max = now + datetime.timedelta(days=95)
140 time_min = datetime.datetime.strftime(_time_min, "%Y-%m-%dT%H:%M:%SZ")
141 time_max = datetime.datetime.strftime(_time_max, "%Y-%m-%dT%H:%M:%SZ")
142 logger.debug(f"time_min is {time_min}")
143 logger.debug(f"time_max is {time_max}")
144 return (time_min, time_max)
147 def parse_date(date: Any) -> Optional[datetime.datetime]:
148 if isinstance(date, datetime.datetime):
150 elif isinstance(date, dict):
151 if "dateTime" in date:
154 if dt.tzinfo is None:
155 dt = dt.replace(tzinfo=None).astimezone(
156 tz=pytz.timezone("US/Pacific")
161 dt = datetime.datetime.strptime(d, "%Y-%m-%d")
162 dt = dt.replace(tzinfo=None).astimezone(tz=pytz.timezone("US/Pacific"))
164 print(f"Not sure what to do with this {date} ({type(date)}), help?!")
167 def get_events_from_interesting_calendars(
168 self, time_min: str, time_max: str
169 ) -> Tuple[List[comparable_event], List[comparable_event]]:
172 countdown_events = []
175 self.client.calendarList().list(pageToken=page_token).execute()
177 for calendar in calendar_list["items"]:
178 if calendar["summary"] in gcal_renderer.calendar_whitelist:
179 logger.debug(f"******* {calendar['summary']} is an interesting calendar...")
183 calendarId=calendar["id"],
191 for event in events["items"]:
192 summary = event["summary"]
193 start = gcal_renderer.parse_date(event["start"])
194 end = gcal_renderer.parse_date(event["end"])
196 f" ... event '{summary}' ({event['start']} ({start}) to {event['end']} ({end})"
198 if start is not None and end is not None:
199 logger.debug(f" ... adding {summary} to sortable_events")
200 sortable_events.append(
201 gcal_renderer.comparable_event(
202 start, end, summary, calendar["summary"]
206 "countdown" in summary
207 or "Holidays" in calendar["summary"]
208 or "Countdown" in summary
211 f" ... adding {summary} to countdown_events"
213 countdown_events.append(
214 gcal_renderer.comparable_event(
215 start, end, summary, calendar["summary"]
218 page_token = calendar_list.get("nextPageToken")
221 return (sortable_events, countdown_events)
223 def render_upcoming_events(self) -> bool:
224 (time_min, time_max) = self.get_min_max_timewindow()
226 # Populate the "Upcoming Events" page.
227 logger.debug("Rendering the Upcoming Events page...")
229 self.sortable_events,
230 self.countdown_events,
231 ) = self.get_events_from_interesting_calendars(time_min, time_max)
232 self.sortable_events.sort()
233 with file_writer.file_writer("gcal_3_86400.html") as f:
236 <h1>Upcoming Calendar Events:</h1>
239 <table width=96% style="border-collapse: collapse;">
242 upcoming_sortable_events = self.sortable_events[:12]
243 for n, event in enumerate(upcoming_sortable_events):
244 logger.debug(f"{n}/12: {event.friendly_name()} / {event.calendar}")
252 <td style="margin: 0; padding: 0; background: {color};">
255 <td style="margin: 0; padding: 0; background: {color};">
256 {event.friendly_name()}
261 f.write("</table></center>\n")
263 # Populate the "Countdown" page.
264 logger.debug("Rendering the Countdowns page")
265 self.countdown_events.sort()
266 with file_writer.file_writer("countdown_3_7200.html") as g:
267 g.write("<h1>Countdowns:</h1><hr><ul>\n")
268 now = datetime.datetime.now(pytz.timezone("US/Pacific"))
269 upcoming_countdown_events = self.countdown_events[:12]
272 for event in upcoming_countdown_events:
273 eventstamp = event.start_time
274 if eventstamp is None:
276 name = event.friendly_name()
277 delta = eventstamp - now
278 x = int(delta.total_seconds())
280 identifier = "id%d" % count
281 days = divmod(x, kiosk_constants.seconds_per_day)
282 hours = divmod(days[1], kiosk_constants.seconds_per_hour)
283 minutes = divmod(hours[1], kiosk_constants.seconds_per_minute)
285 '<li><SPAN id="%s">%d days, %02d:%02d</SPAN> until %s</li>\n'
294 timestamps[identifier] = time.mktime(eventstamp.timetuple())
297 "countdown to %s is %dd %dh %dm"
298 % (name, days[0], hours[0], minutes[0])
301 g.write("<SCRIPT>\nlet timestampMap = new Map([")
302 for _ in list(timestamps.keys()):
303 g.write(f' ["{_}", {timestamps[_] * 1000.0}],\n')
307 // Pad things with a leading zero if necessary.
309 return (n < 10) ? ("0" + n) : n;
312 // Return an 's' if things are plural.
314 return (n == 1) ? "" : "s";
317 // Periodic function to run the page timers.
318 var fn = setInterval(function() {
319 var now = new Date().getTime();
320 for (let [id, timestamp] of timestampMap) {
321 var delta = timestamp - now;
324 var days = Math.floor(delta / (1000 * 60 * 60 * 24));
325 var hours = pad(Math.floor((delta % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)));
326 var minutes = pad(Math.floor((delta % (1000 * 60 * 60)) / (1000 * 60)));
327 var seconds = pad(Math.floor((delta % (1000 * 60)) / 1000));
329 var s = days + " day" + plural(days) + ", ";
330 s = s + hours + ":" + minutes;
331 document.getElementById(id).innerHTML = s;
333 document.getElementById(id).innerHTML = "EXPIRED";
340 except Exception as e:
344 def look_for_triggered_events(self) -> bool:
345 logger.debug("Looking for Imminent Upcoming Calendar events...")
346 with file_writer.file_writer(kiosk_constants.gcal_imminent_pagename) as f:
347 f.write("<h1>Imminent Upcoming Calendar Events:</h1>\n<hr>\n")
348 f.write("<center><table width=99%>\n")
349 now = datetime.datetime.now(pytz.timezone("US/Pacific"))
351 for event in self.sortable_events:
352 eventstamp = event.start_time
353 if eventstamp is None:
355 delta = eventstamp - now
356 x = int(delta.total_seconds())
357 if x > 0 and x < 4 * kiosk_constants.seconds_per_minute:
358 days = divmod(x, kiosk_constants.seconds_per_day)
359 hours = divmod(days[1], kiosk_constants.seconds_per_hour)
360 minutes = divmod(hours[1], kiosk_constants.seconds_per_minute)
361 eventstamp = event.start_time
362 name = event.friendly_name()
363 calendar = event.calendar
364 logger.debug(f"Event {event} ({name}) triggered the page.")
366 f"<LI> {name} ({calendar}) upcoming in {int(minutes[0])} minutes.\n"
371 globals.put("gcal_triggered", True)
373 globals.put("gcal_triggered", False)
378 #import kiosk_secrets as secrets
381 #logger.setLevel(logging.DEBUG)
382 #logger.addHandler(logging.StreamHandler(sys.stdout))
383 #oauth = gdata_oauth.OAuth(secrets.google_client_secret)
385 # {"Render Upcoming Events": 10000, "Look For Triggered Events": 1}, oauth
387 #x.periodic_render("Render Upcoming Events")