Merge branch 'master' of ssh://wwwjail.house/usr/local/git/base/kiosk
[kiosk.git] / gcal_renderer.py
1 #!/usr/bin/env python3
2
3 """Renders an upcoming events page and countdowns page based on the
4 contents of several Google calendars."""
5
6 import datetime
7 import functools
8 import logging
9 import time
10 from typing import Any, Dict, List, Optional, Tuple
11
12 from dateutil.parser import parse
13 import gdata_oauth
14 import pytz
15
16 import kiosk_constants
17 import file_writer
18 import globals
19 import renderer
20
21
22 logger = logging.getLogger(__name__)
23
24
25 class gcal_renderer(renderer.abstaining_renderer):
26     """A renderer to fetch upcoming events from www.google.com/calendar"""
27
28     calendar_whitelist = frozenset(
29         [
30             "Alex's calendar",
31             "Family",
32             "Holidays in United States",
33             "Lynn Gasch",
34             "Lynn's Work",
35             "[email protected]",
36             "Scott Gasch External - Misc",
37             "Birthdays",  # <-- from g+ contacts
38         ]
39     )
40
41     @functools.total_ordering
42     class comparable_event(object):
43         """A helper class to sort events."""
44
45         def __init__(
46             self,
47             start_time: Optional[datetime.datetime],
48             end_time: Optional[datetime.datetime],
49             summary: str,
50             calendar: str,
51         ) -> None:
52             if start_time is None:
53                 assert end_time is None
54             else:
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
62
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) < (
69                 that.start_time,
70                 that.end_time,
71                 that.summary,
72                 that.calendar,
73             )
74
75         def __eq__(self, that) -> bool:
76             return (
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
81             )
82
83         def __repr__(self) -> str:
84             return "[%s]&nbsp;%s" % (self.timestamp(), self.friendly_name())
85
86         def friendly_name(self) -> str:
87             name = self.summary
88             name = name.replace("countdown:", "")
89             return "<B>%s</B>" % name
90
91         def timestamp(self) -> str:
92             now = datetime.datetime.now(pytz.timezone("US/Pacific"))
93             if self.start_time is None:
94                 return "None"
95             elif (
96                 self.start_time.hour == 0
97                 and self.start_time.minute == 0
98                 and self.start_time.second == 0
99             ):
100                 if self.start_time.year == now.year:
101                     return datetime.datetime.strftime(self.start_time, "%a %b %d")
102                 else:
103                     return datetime.datetime.strftime(self.start_time, "%a %b %d, %Y")
104             else:
105                 dt = self.start_time
106                 zone = dt.tzinfo
107                 local_dt = dt.replace(tzinfo=zone).astimezone(
108                     tz=pytz.timezone("US/Pacific")
109                 )
110                 if local_dt.year == now.year:
111                     return datetime.datetime.strftime(local_dt, "%a %b %d %I:%M%p")
112                 else:
113                     return datetime.datetime.strftime(local_dt, "%a %b %d, %Y %I:%M%p")
114
115     def __init__(
116         self, name_to_timeout_dict: Dict[str, int], oauth: gdata_oauth.OAuth
117     ) -> None:
118         super().__init__(name_to_timeout_dict)
119         self.oauth = oauth
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] = []
123
124     def debug_prefix(self) -> str:
125         return "gcal"
126
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()
133         else:
134             raise Exception("Unexpected operation")
135
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)
145
146     @staticmethod
147     def parse_date(date: Any) -> Optional[datetime.datetime]:
148         if isinstance(date, datetime.datetime):
149             return date
150         elif isinstance(date, dict):
151             if "dateTime" in date:
152                 d = date["dateTime"]
153                 dt = parse(d)
154                 if dt.tzinfo is None:
155                     dt = dt.replace(tzinfo=None).astimezone(
156                         tz=pytz.timezone("US/Pacific")
157                     )
158                 return dt
159             elif "date" in date:
160                 d = date["date"]
161                 dt = datetime.datetime.strptime(d, "%Y-%m-%d")
162                 dt = dt.replace(tzinfo=None).astimezone(tz=pytz.timezone("US/Pacific"))
163                 return dt
164         print(f"Not sure what to do with this {date} ({type(date)}), help?!")
165         return None
166
167     def get_events_from_interesting_calendars(
168         self, time_min: str, time_max: str
169     ) -> Tuple[List[comparable_event], List[comparable_event]]:
170         page_token = None
171         sortable_events = []
172         countdown_events = []
173         while True:
174             calendar_list = (
175                 self.client.calendarList().list(pageToken=page_token).execute()
176             )
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...")
180                     events = (
181                         self.client.events()
182                         .list(
183                             calendarId=calendar["id"],
184                             singleEvents=True,
185                             timeMin=time_min,
186                             timeMax=time_max,
187                             maxResults=500,
188                         )
189                         .execute()
190                     )
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"])
195                         logger.debug(
196                             f" ... event '{summary}' ({event['start']} ({start}) to {event['end']} ({end})"
197                         )
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"]
203                                 )
204                             )
205                             if (
206                                 "countdown" in summary
207                                 or "Holidays" in calendar["summary"]
208                                 or "Countdown" in summary
209                             ):
210                                 logger.debug(
211                                     f" ... adding {summary} to countdown_events"
212                                 )
213                                 countdown_events.append(
214                                     gcal_renderer.comparable_event(
215                                         start, end, summary, calendar["summary"]
216                                     )
217                                 )
218             page_token = calendar_list.get("nextPageToken")
219             if not page_token:
220                 break
221         return (sortable_events, countdown_events)
222
223     def render_upcoming_events(self) -> bool:
224         (time_min, time_max) = self.get_min_max_timewindow()
225         try:
226             # Populate the "Upcoming Events" page.
227             logger.debug("Rendering the Upcoming Events page...")
228             (
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:
234                 f.write(
235                     """
236 <h1>Upcoming Calendar Events:</h1>
237 <hr>
238 <center>
239 <table width=96% style="border-collapse: collapse;">
240 """
241                 )
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}")
245                     if n % 2 == 0:
246                         color = "#c6b0b0"
247                     else:
248                         color = "#eeeeee"
249                     f.write(
250                         f"""
251     <tr>
252       <td style="margin: 0; padding: 0; background: {color};">
253         {event.timestamp()}
254       </td>
255       <td style="margin: 0; padding: 0; background: {color};">
256         {event.friendly_name()}
257       </td>
258     </tr>
259 """
260                     )
261                 f.write("</table></center>\n")
262
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]
270                 count = 0
271                 timestamps = {}
272                 for event in upcoming_countdown_events:
273                     eventstamp = event.start_time
274                     if eventstamp is None:
275                         return False
276                     name = event.friendly_name()
277                     delta = eventstamp - now
278                     x = int(delta.total_seconds())
279                     if x > 0:
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)
284                         g.write(
285                             '<li><SPAN id="%s">%d days, %02d:%02d</SPAN> until %s</li>\n'
286                             % (
287                                 identifier,
288                                 int(days[0]),
289                                 int(hours[0]),
290                                 int(minutes[0]),
291                                 name,
292                             )
293                         )
294                         timestamps[identifier] = time.mktime(eventstamp.timetuple())
295                         count += 1
296                         logger.debug(
297                             "countdown to %s is %dd %dh %dm"
298                             % (name, days[0], hours[0], minutes[0])
299                         )
300                 g.write("</ul>")
301                 g.write("<SCRIPT>\nlet timestampMap = new Map([")
302                 for _ in list(timestamps.keys()):
303                     g.write(f'    ["{_}", {timestamps[_] * 1000.0}],\n')
304                 g.write("]);\n\n")
305                 g.write(
306                     """
307 // Pad things with a leading zero if necessary.
308 function pad(n) {
309     return (n < 10) ? ("0" + n) : n;
310 }
311
312 // Return an 's' if things are plural.
313 function plural(n) {
314     return (n == 1) ? "" : "s";
315 }
316
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;
322
323         if (delta > 0) {
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));
328
329             var s = days + " day" + plural(days) + ", ";
330             s = s + hours + ":" + minutes;
331             document.getElementById(id).innerHTML = s;
332         } else {
333             document.getElementById(id).innerHTML = "EXPIRED";
334         }
335     }
336 }, 1000);
337 </script>"""
338                 )
339             return True
340         except Exception as e:
341             logger.exception(e)
342             return False
343
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"))
350             count = 0
351             for event in self.sortable_events:
352                 eventstamp = event.start_time
353                 if eventstamp is None:
354                     continue
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.")
365                     f.write(
366                         f"<LI> {name} ({calendar}) upcoming in {int(minutes[0])} minutes.\n"
367                     )
368                     count += 1
369             f.write("</table>")
370         if count > 0:
371             globals.put("gcal_triggered", True)
372         else:
373             globals.put("gcal_triggered", False)
374         return True
375
376
377 # Test
378 #import kiosk_secrets as secrets
379 #import sys
380
381 #logger.setLevel(logging.DEBUG)
382 #logger.addHandler(logging.StreamHandler(sys.stdout))
383 #oauth = gdata_oauth.OAuth(secrets.google_client_secret)
384 #x = gcal_renderer(
385 #   {"Render Upcoming Events": 10000, "Look For Triggered Events": 1}, oauth
386 #)
387 #x.periodic_render("Render Upcoming Events")