Autogenerated API documentation.
[chrisu/seepark.git] / web / seepark_web.py
1 import collections
2 import datetime
3 import itertools
4 import time
5 import configparser
6 import os
7 import sys
8 from collections import defaultdict
9 import io
10 import numpy as np
11 import matplotlib
12 matplotlib.use('pdf')
13 import matplotlib.pyplot as plt
14 from matplotlib.backends.backend_pdf import PdfPages
15
16 from flask import Flask, render_template, jsonify, request, abort, Response, make_response
17 import flask.json
18 from flask_sqlalchemy import SQLAlchemy, inspect
19 from sqlalchemy import func
20
21 MONTH_DE = [
22     'Jänner',
23     'Februar',
24     'März',
25     'April',
26     'Mai',
27     'Juni',
28     'Juli',
29     'August',
30     'September',
31     'Oktober',
32     'November',
33     'Dezember']
34
35 DAY_OF_WEEK_DE = [
36     'Montag',
37     'Dienstag',
38     'Mittwoch',
39     'Donnerstag',
40     'Freitag',
41     'Samstag',
42     'Sonntag']
43
44
45 # https://stackoverflow.com/a/37350445
46 def sqlalchemy_model_to_dict(model):
47     return {c.key: getattr(model, c.key)
48         for c in inspect(model).mapper.column_attrs}
49
50
51 class JSONEncoder(flask.json.JSONEncoder):
52     def default(self, object):
53         if isinstance(object, datetime.datetime):
54             return object.isoformat()
55         elif isinstance(object, db.Model):
56             return sqlalchemy_model_to_dict(object)
57         return super().default(object)
58
59
60 def parse_datetime(date_str):
61     return datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S')
62
63
64 def ntimes(it, n):
65     for v in it:
66         yield from itertools.repeat(v, n)
67
68
69 def get_sqlalchemy_database_uri(config):
70     user = config.get('database', 'user')
71     pwd = config.get('database', 'password')
72     host = config.get('database', 'hostname')
73     db = config.get('database', 'database')
74     return 'mysql+mysqldb://{}:{}@{}/{}'.format(user, pwd, host, db)
75
76
77 config = configparser.ConfigParser()
78 config.read(os.environ['SEEPARKINI'])
79 apikey = config.get('openweathermap', 'apikey')
80 cityid = config.get('openweathermap', 'cityid')
81 mainsensor = config.get('webapp', 'mainsensor')
82
83 app = Flask(__name__)
84 app.json_encoder = JSONEncoder
85 app.config['SQLALCHEMY_DATABASE_URI'] = get_sqlalchemy_database_uri(config)
86 app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
87 db = SQLAlchemy(app)
88 db.reflect(app=app)
89
90
91 class Sensors(db.Model):
92     __tablename__ = 'sensors'
93
94
95 class OpenWeatherMap(db.Model):
96     __tablename__ = 'openweathermap'
97
98
99 def calc_grouping_resolution(begin, end):
100     """How many data points should be between the timestamps begin and end?"""
101     # copied from munin/master/_bin/munin-cgi-graph.in
102     # except day: 300 -> 600
103     resolutions = dict(
104         day   =   600,
105         week  =  1800,
106         month =  7200,
107         year  = 86400,
108     )
109     duration = (end - begin).total_seconds()
110     day = 60 * 60 * 24
111     if duration <= day:
112         resolution = resolutions['day']
113     elif duration <= 7 * day:
114         resolution = resolutions['week']
115     elif duration <= 31 * day:
116         resolution = resolutions['month']
117     else:
118         resolution = resolutions['year']
119     return resolution
120
121
122 def select_sensordata(sensor_id, sensor_type, begin, end):
123     query = Sensors.query
124     if sensor_id is not None:
125         query = query.filter(Sensors.sensor_id == sensor_id)
126     if sensor_type is not None:
127         query = query.filter(Sensors.value_type == sensor_type)
128     if begin is not None:
129         query = query.filter(Sensors.timestamp >= begin)
130     if end is not None:
131         query = query.filter(Sensors.timestamp <= end)
132     return query.all()
133
134
135 def sensordata_to_xy(sensordata):
136     sensordata = list(sensordata)
137     x = np.array([d.timestamp for d in sensordata])
138     y = np.array([d.value for d in sensordata])
139     return x, y
140
141
142 def select_sensordata_grouped(sensor_id, sensor_type, begin, end):
143     # determine resolution (interval in seconds for data points)
144     resolution = calc_grouping_resolution(begin, end)
145
146     # Let the database do the grouping. Example in SQL (MySQL):
147     # select to_seconds(datetime) DIV (60*60*24) as interval_id, min(datetime), max(datetime), min(temp), avg(temp), max(temp), count(temp) from openweathermap group by interval_id order by interval_id;
148     query = db.session.query(func.to_seconds(Sensors.timestamp).op('div')(resolution).label('g'),
149             func.from_unixtime(func.avg(func.unix_timestamp(Sensors.timestamp))).label('timestamp'),
150             func.avg(Sensors.value).label('value'),
151             Sensors.sensor_id, Sensors.value_type, Sensors.sensor_name)
152     if sensor_id is not None:
153         query = query.filter(Sensors.sensor_id == sensor_id)
154     if sensor_type is not None:
155         query = query.filter(Sensors.value_type == sensor_type)
156     query = query.filter(Sensors.timestamp >= begin)
157     query = query.filter(Sensors.timestamp <= end)
158     query = query.group_by('g', Sensors.sensor_id, Sensors.value_type, Sensors.sensor_name)
159     return query.all()
160
161
162 def select_openweatherdata(cityid, begin, end):
163     query = OpenWeatherMap.query.filter(OpenWeatherMap.cityid == cityid)
164     if begin is not None:
165         query = query.filter(OpenWeatherMap.datetime >= begin)
166     if end is not None:
167         query = query.filter(OpenWeatherMap.datetime <= end)
168     return query.all()
169
170
171 def openweatherdata_to_xy(openweatherdata):
172     openweatherdata = list(openweatherdata)
173     x = np.array([d.datetime for d in openweatherdata])
174     y = np.array([d.temp for d in openweatherdata])
175     return x, y
176
177
178 def select_openweatherdata_grouped(cityid, begin, end):
179     # determine resolution (interval in seconds for data points)
180     resolution = calc_grouping_resolution(begin, end)
181
182     # Let the database do the grouping. Example in SQL (MySQL):
183     # select to_seconds(datetime) DIV (60*60*24) as interval_id, min(datetime), max(datetime), min(temp), avg(temp), max(temp), count(temp) from openweathermap group by interval_id order by interval_id;
184     query = db.session.query(func.to_seconds(OpenWeatherMap.datetime).op('div')(resolution).label('g'),
185             func.from_unixtime(func.avg(func.unix_timestamp(OpenWeatherMap.datetime))).label('datetime'),
186             func.avg(OpenWeatherMap.temp).label('temp'),
187             OpenWeatherMap.cityid)
188     OpenWeatherMap.query.filter(OpenWeatherMap.cityid == cityid)
189     query = query.filter(OpenWeatherMap.datetime >= begin)
190     query = query.filter(OpenWeatherMap.datetime <= end)
191     query = query.group_by('g', OpenWeatherMap.cityid)
192     return query.all()
193
194
195 def estimate_swimmer_count(date):
196     return date.day
197
198
199 def select_swimmerdata(begin, end):
200     def report_times(begin, end):
201         d = begin
202         while d < end:
203             for t in [10, 15]:
204                 a = datetime.datetime.combine(d.date(), datetime.time(t))
205                 if a >= d:
206                     yield a
207             d += datetime.timedelta(days=1)
208     SwimmerData = collections.namedtuple('SwimmerData', ['datetime', 'count'])
209     for d in report_times(begin, end):
210         count = estimate_swimmer_count(d)
211         yield SwimmerData(d, count)
212
213
214 def swimmerdata_to_xy(swimmerdata):
215     swimmerdata = list(swimmerdata)
216     x = np.array([d.datetime for d in swimmerdata])
217     y = np.array([d.count for d in swimmerdata])
218     return x, y
219
220
221 def convert_to_c3(result, id, field_x, field_y):
222     c3result = defaultdict(list)
223     for row in result:
224         c3result[str(getattr(row, id))].append(getattr(row, field_y))
225         dt = getattr(row, field_x).strftime('%Y-%m-%d %H:%M:%S')
226         c3result[str(getattr(row, id)) + '_x'].append(dt)
227     return c3result
228
229
230 def request_arg(key, type, default=None):
231     """Returns the key from the request if available, otherwise the default value.
232     In case type is provided and the key is present, the value is converted by calling type.
233     In other words: Reimplement request.args.get but don't return default value if
234     type raises a ValueError."""
235     if key in request.args:
236         try:
237             return type(request.args[key])
238         except ValueError as e:
239             abort(Response(str(e), 400))
240     else:
241         return default
242
243
244 def sensordata(sensor_id=None, sensor_type=None):
245     begin = request_arg('begin', parse_datetime)
246     end = request_arg('end', parse_datetime)
247     mode = request.args.get('mode', 'full')
248     format = request.args.get('format', 'default')
249
250     if mode == 'full':
251         result = select_sensordata(sensor_id, sensor_type, begin, end)
252     elif mode == 'consolidated':
253         if begin is None or end is None:
254             abort(Response('begin and end have to be set for mode==consolidated', 400))
255         result = select_sensordata_grouped(sensor_id, sensor_type, begin, end)
256     else:
257         abort(Response('unknown value for mode', 400))
258
259     if format == 'c3':
260         return convert_to_c3(result, 'sensor_id', 'timestamp', 'value')
261     return result
262
263
264 def openweathermapdata(cityid):
265     begin = request_arg('begin', parse_datetime)
266     end = request_arg('end', parse_datetime)
267     mode = request.args.get('mode', 'full')
268     format = request.args.get('format', 'default')
269
270     if mode == 'full':
271         result = select_openweatherdata(cityid, begin, end)
272     elif mode == 'consolidated':
273         if begin is None or end is None:
274             abort(Response('begin and end have to be set for mode==consolidated', 400))
275         result = select_openweatherdata_grouped(cityid, begin, end)
276     else:
277         abort(Response('unknown value for mode', 400))
278
279     if format == 'c3':
280         return convert_to_c3(result, 'cityid', 'datetime', 'temp')
281     return result
282
283
284 def currentairtemperature(cityid):
285     result = OpenWeatherMap.query.filter_by(cityid=cityid).order_by(OpenWeatherMap.datetime.desc()).first()
286     return result.temp, result.datetime
287
288
289 def currentwatertemperature(sensorid):
290     result = Sensors.query.filter_by(sensor_id=sensorid).order_by(Sensors.timestamp.desc()).first()
291     return result.value, result.timestamp
292
293
294 def add_month(date):
295     return (date + datetime.timedelta(days=42)).replace(day=1)
296
297
298 @app.route('/api/<version>/sensors/')
299 def sensors(version):
300     """List all sensors found in the database"""
301     result = db.session.query(Sensors.sensor_id, Sensors.sensor_name, Sensors.value_type).distinct().all()
302     return jsonify(result)
303
304
305 @app.route('/api/<version>/sensor/id/<sensor_id>')
306 def sensorid(version, sensor_id):
307     """Return all data for a specific sensor
308
309     URL parameters:
310     begin=<datetime>, optional, format like "2018-05-19T21:07:53"
311     end=<datetime>, optional, format like "2018-05-19T21:07:53"
312     mode=<full|consolidated>, optional. return all rows (default) or with lower resolution (for charts)
313     format=<default|c3>, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
314     """
315     result = sensordata(sensor_id=sensor_id)
316     return jsonify(result)
317
318
319 @app.route('/api/<version>/sensor/type/<sensor_type>')
320 def sensortype(version, sensor_type):
321     """Return all data for a specific sensor type
322
323     URL parameters:
324     begin=<datetime>, optional, format like "2018-05-19T21:07:53"
325     end=<datetime>, optional, format like "2018-05-19T21:07:53"
326     mode=<full|consolidated>, optional. return all rows (default) or with lower resolution (for charts)
327     format=<default|c3>, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
328     """
329     result = sensordata(sensor_type=sensor_type)
330     return jsonify(result)
331
332
333 @app.route('/api/<version>/openweathermap/cities')
334 def openweathermap_cities(version):
335     """List all city IDs found in the database"""
336     result = db.session.query(OpenWeatherMap.cityid).distinct().all()
337     return jsonify(result)
338
339
340 @app.route('/api/<version>/openweathermap/city/<cityid>')
341 def openweathermap_city(version, cityid):
342     """List all data found for a city"""
343     result = openweathermapdata(cityid=cityid)
344     return jsonify(result)
345
346
347 @app.route('/api/<version>/currentairtemperature')
348 def currentair(version):
349     value, timestamp = currentairtemperature(cityid)
350     return jsonify({"value": value, "timestamp": timestamp})
351
352
353 @app.route('/api/<version>/currentwatertemperature')
354 def currentwater(version):
355     value, timestamp = currentwatertemperature(mainsensor)
356     return jsonify({"value": value, "timestamp": timestamp})
357
358
359 @app.route('/report/<int:year>/<int:month>')
360 def report(year, month):
361     """Report for given year and month
362     """
363     paper_size = (29.7 / 2.54, 21. / 2.54)  # A4
364
365     begin = datetime.datetime(year, month, 1)
366     end = add_month(begin)
367
368     water_data = sensordata_to_xy(select_sensordata(mainsensor, 'Wassertemperatur', begin, end))
369     air_data = openweatherdata_to_xy(select_openweatherdata(cityid, begin, end))
370     swimmer_data = swimmerdata_to_xy(select_swimmerdata(begin, end))
371
372     report_times = [datetime.time(10), datetime.time(15)]
373     report_data = {'Wasser': water_data, 'Luft': air_data}
374
375     days_datetime = []
376     d = begin
377     while d < end:
378         days_datetime.append(d)
379         d = d + datetime.timedelta(1)
380
381     binary_pdf = io.BytesIO()
382     with PdfPages(binary_pdf) as pdf:
383         title = 'Seepark Obsteig {} {}'.format(MONTH_DE[begin.month-1], begin.year)
384
385         # graphic
386         plt.figure(figsize=paper_size)
387         report_colors = []
388         for label, data in sorted(report_data.items(), reverse=True):
389             x, y = data
390             lines = plt.plot(x, y, label=label)
391             report_colors.append(lines[0].get_color())
392         plt.xticks(days_datetime, [''] * len(days_datetime))
393         plt.ylabel('Temperatur in °C')
394         plt.axis(xmin=begin, xmax=end)
395         plt.legend()
396         plt.grid()
397         plt.title(title)
398
399         # table
400         columns = []
401         for d in days_datetime:
402             columns.append('{}.'.format(d.day))
403         rows = []
404         for label in sorted(report_data.keys(), reverse=True):
405             for t in report_times:
406                 rows.append('{:02d}:{:02d} {} °C'.format(t.hour, t.minute, label))
407         for t in report_times:
408             rows.append('{:02d}:{:02d} Badende'.format(t.hour, t.minute))
409         cells = []
410         for label, data in sorted(report_data.items(), reverse=True):
411             for t in report_times:
412                 row_cells = []
413                 x, y = data
414                 for d in days_datetime:
415                     report_datetime = datetime.datetime.combine(d.date(), t)
416                     closest_index = np.argmin(np.abs(x - report_datetime))
417                     if abs(x[closest_index] - report_datetime) > datetime.timedelta(hours=1):
418                         cell = 'N/A'
419                     else:
420                         value = y[closest_index]
421                         cell = '{:.1f}'.format(value)
422                     row_cells.append(cell)
423                 cells.append(row_cells)
424         for t in report_times:
425             row_cells = []
426             x, y = swimmer_data
427             for d in days_datetime:
428                 report_datetime = datetime.datetime.combine(d.date(), t)
429                 closest_index = np.argmin(np.abs(x - report_datetime))
430                 if abs(x[closest_index] - report_datetime) > datetime.timedelta(hours=1):
431                     cell = 'N/A'
432                 else:
433                     cell = y[closest_index]
434                 row_cells.append(cell)
435             cells.append(row_cells)
436         row_colors = list(ntimes(report_colors + ['w'], len(report_times)))
437         table = plt.table(cellText=cells, colLabels=columns, rowLabels=rows, rowColours=row_colors, loc='bottom')
438         table.scale(xscale=1, yscale=2)
439         plt.title(title)
440         plt.subplots_adjust(left=0.15, right=0.97, bottom=0.3)  # do not cut row labels
441         pdf.savefig()
442
443         pdf_info = pdf.infodict()
444         pdf_info['Title'] = title
445         pdf_info['Author'] = 'Chrisu Jähnl'
446         pdf_info['Subject'] = 'Temperaturen'
447         pdf_info['Keywords'] = 'Seepark Obsteig'
448         pdf_info['CreationDate'] = datetime.datetime.now()
449         pdf_info['ModDate'] = datetime.datetime.today()
450
451     response = make_response(binary_pdf.getvalue())
452     response.headers['Content-Type'] = 'application/pdf'
453     response.headers['Content-Disposition'] = 'attachment; filename=seepark_{:04d}-{:02d}.pdf'.format(year, month)
454     return response
455
456
457 @app.route("/")
458 def index():
459     airvalue, airtime     = currentairtemperature(cityid)
460     watervalue, watertime = currentwatertemperature(mainsensor)
461
462     return render_template(
463         'seepark_web.html',
464         apikey=apikey,
465         watervalue=watervalue,
466         watertime=watertime,
467         airvalue=airvalue,
468         airtime=airtime,
469     )