More cleanup.
[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 constants
17 import file_writer
18 import globals
19 import renderer
20
21
22 logger = logging.getLogger(__file__)
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 and
78                 self.end_time == that.end_time and
79                 self.summary == that.summary and
80                 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 and
97                     self.start_time.minute == 0 and
98                     self.start_time.second == 0
99             ):
100                 if self.start_time.year == now.year:
101                     return datetime.datetime.strftime(
102                         self.start_time,
103                         "%a %b %d"
104                     )
105                 else:
106                     return datetime.datetime.strftime(
107                         self.start_time,
108                         "%a %b %d, %Y"
109                     )
110             else:
111                 dt = self.start_time
112                 zone = dt.tzinfo
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"
117                     )
118                 else:
119                     return datetime.datetime.strftime(
120                         local_dt, "%a %b %d, %Y %I:%M%p"
121                     )
122
123     def __init__(
124         self, name_to_timeout_dict: Dict[str, int], oauth: gdata_oauth.OAuth
125     ) -> None:
126         super().__init__(name_to_timeout_dict)
127         self.oauth = oauth
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] = []
131
132     def debug_prefix(self) -> str:
133         return "gcal"
134
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()
141         else:
142             raise Exception("Unexpected operation")
143
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)
153
154     @staticmethod
155     def parse_date(date: Any) -> Optional[datetime.datetime]:
156         if isinstance(date, datetime.datetime):
157             return date
158         elif isinstance(date, dict):
159             if 'dateTime' in date:
160                 d = date['dateTime']
161                 dt = parse(d)
162                 if dt.tzinfo is None:
163                     dt = dt.replace(tzinfo=None).astimezone(tz=pytz.timezone('US/Pacific'))
164                 return dt
165             elif 'date' in date:
166                 d = date['date']
167                 dt = datetime.datetime.strptime(d, '%Y-%m-%d')
168                 dt = dt.replace(tzinfo=None).astimezone(tz=pytz.timezone('US/Pacific'))
169                 return dt
170         print(f'Not sure what to do with this {date} ({type(date)}), help?!')
171         return None
172
173     def get_events_from_interesting_calendars(
174         self, time_min: str, time_max: str
175     ) -> Tuple[List[comparable_event], List[comparable_event]]:
176         page_token = None
177         sortable_events = []
178         countdown_events = []
179         while True:
180             calendar_list = (
181                 self.client.calendarList().list(pageToken=page_token).execute()
182             )
183             for calendar in calendar_list["items"]:
184                 if calendar["summary"] in gcal_renderer.calendar_whitelist:
185                     logger.debug(
186                         f"{calendar['summary']} is an interesting calendar..."
187                     )
188                     events = (
189                         self.client.events()
190                         .list(
191                             calendarId=calendar["id"],
192                             singleEvents=True,
193                             timeMin=time_min,
194                             timeMax=time_max,
195                             maxResults=50,
196                         )
197                         .execute()
198                     )
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"])
203                         logger.debug(
204                             f" ... event '{summary}' ({event['start']} ({start}) to {event['end']} ({end})"
205                         )
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"]
211                                 )
212                             )
213                             if (
214                                 "countdown" in summary
215                                 or "Holidays" in calendar["summary"]
216                                 or "Countdown" in summary
217                             ):
218                                 logger.debug(f" ... adding {summary} to countdown_events")
219                                 countdown_events.append(
220                                     gcal_renderer.comparable_event(
221                                         start, end, summary, calendar["summary"]
222                                     )
223                                 )
224             page_token = calendar_list.get("nextPageToken")
225             if not page_token:
226                 break
227         return (sortable_events, countdown_events)
228
229     def render_upcoming_events(self) -> bool:
230         (time_min, time_max) = self.get_min_max_timewindow()
231         try:
232             # Populate the "Upcoming Events" page.
233             (
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:
239                 f.write(
240 f"""
241 <h1>Upcoming Calendar Events:</h1>
242 <hr>
243 <center>
244 <table width=96% style="border-collapse: collapse;">
245 """
246                 )
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}')
250                     if n % 2 == 0:
251                         color = "#c6b0b0"
252                     else:
253                         color = "#eeeeee"
254                     f.write(
255 f"""
256     <tr>
257       <td style="margin: 0; padding: 0; background: {color};">
258         {event.timestamp()}
259       </td>
260       <td style="margin: 0; padding: 0; background: {color};">
261         {event.friendly_name()}
262       </td>
263     </tr>
264 """
265                     )
266                 f.write("</table></center>\n")
267
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]
274                 count = 0
275                 timestamps = {}
276                 for event in upcoming_countdown_events:
277                     eventstamp = event.start_time
278                     if eventstamp is None:
279                         return False
280                     name = event.friendly_name()
281                     delta = eventstamp - now
282                     x = int(delta.total_seconds())
283                     if x > 0:
284                         identifier = "id%d" % count
285                         days = divmod(x, constants.seconds_per_day)
286                         hours = divmod(days[1], constants.seconds_per_hour)
287                         minutes = divmod(hours[1], constants.seconds_per_minute)
288                         g.write(
289                             f'<li><SPAN id="%s">%d days, %02d:%02d</SPAN> until %s</li>\n'
290                             % (
291                                 identifier,
292                                 int(days[0]),
293                                 int(hours[0]),
294                                 int(minutes[0]),
295                                 name,
296                             )
297                         )
298                         timestamps[identifier] = time.mktime(eventstamp.timetuple())
299                         count += 1
300                         logger.debug(
301                             "countdown to %s is %dd %dh %dm"
302                             % (name, days[0], hours[0], minutes[0])
303                         )
304                 g.write("</ul>")
305                 g.write("<SCRIPT>\nlet timestampMap = new Map([")
306                 for _ in list(timestamps.keys()):
307                     g.write(f'    ["{_}", {timestamps[_] * 1000.0}],\n')
308                 g.write("]);\n\n")
309                 g.write(
310                     """
311 // Pad things with a leading zero if necessary.
312 function pad(n) {
313     return (n < 10) ? ("0" + n) : n;
314 }
315
316 // Return an 's' if things are plural.
317 function plural(n) {
318     return (n == 1) ? "" : "s";
319 }
320
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;
326
327         if (delta > 0) {
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));
332
333             var s = days + " day" + plural(days) + ", ";
334             s = s + hours + ":" + minutes;
335             document.getElementById(id).innerHTML = s;
336         } else {
337             document.getElementById(id).innerHTML = "EXPIRED";
338         }
339     }
340 }, 1000);
341 </script>"""
342                 )
343             return True
344         except Exception as e:
345             print("********* TRYING TO REFRESH GCAL CLIENT *********")
346 #            self.oauth.refresh_token()
347 #            self.client = self.oauth.calendar_service()
348             return False
349         except:
350             raise
351
352     def look_for_triggered_events(self) -> bool:
353         with file_writer.file_writer(constants.gcal_imminent_pagename) as f:
354             f.write("<h1>Imminent Upcoming Calendar Events:</h1>\n<hr>\n")
355             f.write("<center><table width=99%>\n")
356             now = datetime.datetime.now(pytz.timezone("US/Pacific"))
357             count = 0
358             for event in self.sortable_events:
359                 eventstamp = event.start_time
360                 if eventstamp is None:
361                     return False
362                 delta = eventstamp - now
363                 x = int(delta.total_seconds())
364                 if x > 0 and x <= constants.seconds_per_minute * 3:
365                     days = divmod(x, constants.seconds_per_day)
366                     hours = divmod(days[1], constants.seconds_per_hour)
367                     minutes = divmod(hours[1], constants.seconds_per_minute)
368                     eventstamp = event.start_time
369                     name = event.friendly_name()
370                     calendar = event.calendar
371                     f.write(
372                         f"<LI> {name} ({calendar}) upcoming in {int(minutes[0])} minutes.\n"
373                     )
374                     count += 1
375             f.write("</table>")
376         if count > 0:
377             globals.put("gcal_triggered", True)
378         else:
379             globals.put("gcal_triggered", False)
380         return True
381
382
383 # Test
384 #oauth = gdata_oauth.OAuth(secrets.google_client_secret)
385 #x = gcal_renderer(
386 #   {"Render Upcoming Events": 10000, "Look For Triggered Events": 1},
387 #   oauth)
388 #x.periodic_render("Render Upcoming Events")