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(__file__)
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 and
78 self.end_time == that.end_time and
79 self.summary == that.summary and
80 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 and
97 self.start_time.minute == 0 and
98 self.start_time.second == 0
100 if self.start_time.year == now.year:
101 return datetime.datetime.strftime(
106 return datetime.datetime.strftime(
113 local_dt = dt.replace(tzinfo=zone).astimezone(tz=pytz.timezone('US/Pacific'))
114 if local_dt.year == now.year:
115 return datetime.datetime.strftime(
116 local_dt, "%a %b %d %I:%M%p"
119 return datetime.datetime.strftime(
120 local_dt, "%a %b %d, %Y %I:%M%p"
124 self, name_to_timeout_dict: Dict[str, int], oauth: gdata_oauth.OAuth
126 super().__init__(name_to_timeout_dict)
128 self.client = self.oauth.calendar_service()
129 self.sortable_events: List[gcal_renderer.comparable_event] = []
130 self.countdown_events: List[gcal_renderer.comparable_event] = []
132 def debug_prefix(self) -> str:
135 def periodic_render(self, key: str) -> bool:
136 logger.debug('called for "%s"' % key)
137 if key == "Render Upcoming Events":
138 return self.render_upcoming_events()
139 elif key == "Look For Triggered Events":
140 return self.look_for_triggered_events()
142 raise Exception("Unexpected operation")
144 def get_min_max_timewindow(self) -> Tuple[str, str]:
145 now = datetime.datetime.now(pytz.timezone("US/Pacific"))
146 _time_min = now - datetime.timedelta(hours=6)
147 _time_max = now + datetime.timedelta(days=95)
148 time_min = datetime.datetime.strftime(_time_min, "%Y-%m-%dT%H:%M:%SZ")
149 time_max = datetime.datetime.strftime(_time_max, "%Y-%m-%dT%H:%M:%SZ")
150 logger.debug(f"time_min is {time_min}")
151 logger.debug(f"time_max is {time_max}")
152 return (time_min, time_max)
155 def parse_date(date: Any) -> Optional[datetime.datetime]:
156 if isinstance(date, datetime.datetime):
158 elif isinstance(date, dict):
159 if 'dateTime' in date:
162 if dt.tzinfo is None:
163 dt = dt.replace(tzinfo=None).astimezone(tz=pytz.timezone('US/Pacific'))
167 dt = datetime.datetime.strptime(d, '%Y-%m-%d')
168 dt = dt.replace(tzinfo=None).astimezone(tz=pytz.timezone('US/Pacific'))
170 print(f'Not sure what to do with this {date} ({type(date)}), help?!')
173 def get_events_from_interesting_calendars(
174 self, time_min: str, time_max: str
175 ) -> Tuple[List[comparable_event], List[comparable_event]]:
178 countdown_events = []
181 self.client.calendarList().list(pageToken=page_token).execute()
183 for calendar in calendar_list["items"]:
184 if calendar["summary"] in gcal_renderer.calendar_whitelist:
186 f"{calendar['summary']} is an interesting calendar..."
191 calendarId=calendar["id"],
199 for event in events["items"]:
200 summary = event["summary"]
201 start = gcal_renderer.parse_date(event["start"])
202 end = gcal_renderer.parse_date(event["end"])
204 f" ... event '{summary}' ({event['start']} ({start}) to {event['end']} ({end})"
206 if start is not None and end is not None:
207 logger.debug(f' ... adding {summary} to sortable_events')
208 sortable_events.append(
209 gcal_renderer.comparable_event(
210 start, end, summary, calendar["summary"]
214 "countdown" in summary
215 or "Holidays" in calendar["summary"]
216 or "Countdown" in summary
218 logger.debug(f" ... adding {summary} to countdown_events")
219 countdown_events.append(
220 gcal_renderer.comparable_event(
221 start, end, summary, calendar["summary"]
224 page_token = calendar_list.get("nextPageToken")
227 return (sortable_events, countdown_events)
229 def render_upcoming_events(self) -> bool:
230 (time_min, time_max) = self.get_min_max_timewindow()
232 # Populate the "Upcoming Events" page.
234 self.sortable_events,
235 self.countdown_events,
236 ) = self.get_events_from_interesting_calendars(time_min, time_max)
237 self.sortable_events.sort()
238 with file_writer.file_writer("gcal_3_86400.html") as f:
241 <h1>Upcoming Calendar Events:</h1>
244 <table width=96% style="border-collapse: collapse;">
247 upcoming_sortable_events = self.sortable_events[:12]
248 for n, event in enumerate(upcoming_sortable_events):
249 logger.debug(f'{n}/12: {event.friendly_name()} / {event.calendar}')
257 <td style="margin: 0; padding: 0; background: {color};">
260 <td style="margin: 0; padding: 0; background: {color};">
261 {event.friendly_name()}
266 f.write("</table></center>\n")
268 # Populate the "Countdown" page.
269 self.countdown_events.sort()
270 with file_writer.file_writer("countdown_3_7200.html") as g:
271 g.write("<h1>Countdowns:</h1><hr><ul>\n")
272 now = datetime.datetime.now(pytz.timezone("US/Pacific"))
273 upcoming_countdown_events = self.countdown_events[:12]
276 for event in upcoming_countdown_events:
277 eventstamp = event.start_time
278 if eventstamp is None:
280 name = event.friendly_name()
281 delta = eventstamp - now
282 x = int(delta.total_seconds())
284 identifier = "id%d" % count
285 days = divmod(x, kiosk_constants.seconds_per_day)
286 hours = divmod(days[1], kiosk_constants.seconds_per_hour)
287 minutes = divmod(hours[1], kiosk_constants.seconds_per_minute)
289 f'<li><SPAN id="%s">%d days, %02d:%02d</SPAN> until %s</li>\n'
298 timestamps[identifier] = time.mktime(eventstamp.timetuple())
301 "countdown to %s is %dd %dh %dm"
302 % (name, days[0], hours[0], minutes[0])
305 g.write("<SCRIPT>\nlet timestampMap = new Map([")
306 for _ in list(timestamps.keys()):
307 g.write(f' ["{_}", {timestamps[_] * 1000.0}],\n')
311 // Pad things with a leading zero if necessary.
313 return (n < 10) ? ("0" + n) : n;
316 // Return an 's' if things are plural.
318 return (n == 1) ? "" : "s";
321 // Periodic function to run the page timers.
322 var fn = setInterval(function() {
323 var now = new Date().getTime();
324 for (let [id, timestamp] of timestampMap) {
325 var delta = timestamp - now;
328 var days = Math.floor(delta / (1000 * 60 * 60 * 24));
329 var hours = pad(Math.floor((delta % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)));
330 var minutes = pad(Math.floor((delta % (1000 * 60 * 60)) / (1000 * 60)));
331 var seconds = pad(Math.floor((delta % (1000 * 60)) / 1000));
333 var s = days + " day" + plural(days) + ", ";
334 s = s + hours + ":" + minutes;
335 document.getElementById(id).innerHTML = s;
337 document.getElementById(id).innerHTML = "EXPIRED";
344 except Exception as e:
346 print("********* TRYING TO REFRESH GCAL CLIENT *********")
347 # self.oauth.refresh_token()
348 # self.client = self.oauth.calendar_service()
351 def look_for_triggered_events(self) -> bool:
352 with file_writer.file_writer(kiosk_constants.gcal_imminent_pagename) as f:
353 f.write("<h1>Imminent Upcoming Calendar Events:</h1>\n<hr>\n")
354 f.write("<center><table width=99%>\n")
355 now = datetime.datetime.now(pytz.timezone("US/Pacific"))
357 for event in self.sortable_events:
358 eventstamp = event.start_time
359 if eventstamp is None:
361 delta = eventstamp - now
362 x = int(delta.total_seconds())
363 if x > 0 and x < 4 * kiosk_constants.seconds_per_minute:
364 days = divmod(x, kiosk_constants.seconds_per_day)
365 hours = divmod(days[1], kiosk_constants.seconds_per_hour)
366 minutes = divmod(hours[1], kiosk_constants.seconds_per_minute)
367 eventstamp = event.start_time
368 name = event.friendly_name()
369 calendar = event.calendar
371 f"<LI> {name} ({calendar}) upcoming in {int(minutes[0])} minutes.\n"
376 globals.put("gcal_triggered", True)
378 globals.put("gcal_triggered", False)
383 #oauth = gdata_oauth.OAuth(secrets.google_client_secret)
385 # {"Render Upcoming Events": 10000, "Look For Triggered Events": 1},
387 #x.periodic_render("Render Upcoming Events")