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