acec9c55c4db9aa3a4dd659ef3d8042e19d20585
[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 from flask import Flask, render_template, jsonify, request, abort, Response, make_response
8 import flask.json
9 from flask_sqlalchemy import SQLAlchemy, inspect
10 from sqlalchemy import func
11
12
13 # https://stackoverflow.com/a/37350445
14 def sqlalchemy_model_to_dict(model):
15     return {c.key: getattr(model, c.key)
16         for c in inspect(model).mapper.column_attrs}
17
18
19 class JSONEncoder(flask.json.JSONEncoder):
20     def default(self, object):
21         if isinstance(object, datetime.datetime):
22             return object.isoformat()
23         elif isinstance(object, db.Model):
24             return sqlalchemy_model_to_dict(object)
25         return super().default(object)
26
27
28 def parse_datetime(date_str):
29     return datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S')
30
31
32 def get_sqlalchemy_database_uri(config):
33     user = config.get('database', 'user')
34     pwd = config.get('database', 'password')
35     host = config.get('database', 'hostname')
36     db = config.get('database', 'database')
37     return 'mysql+mysqldb://{}:{}@{}/{}'.format(user, pwd, host, db)
38
39
40 config = configparser.ConfigParser()
41 config.read(os.environ['SEEPARKINI'])
42 apikey = config.get('openweathermap', 'apikey')
43 cityid = config.get('openweathermap', 'cityid')
44 mainsensor = config.get('webapp', 'mainsensor')
45
46 app = Flask(__name__)
47 app.json_encoder = JSONEncoder
48 app.config['SQLALCHEMY_DATABASE_URI'] = get_sqlalchemy_database_uri(config)
49 app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
50 db = SQLAlchemy(app)
51 db.reflect(app=app)
52
53
54 class Sensors(db.Model):
55     __tablename__ = 'sensors'
56
57
58 class OpenWeatherMap(db.Model):
59     __tablename__ = 'openweathermap'
60
61
62 def calc_grouping_resolution(begin, end):
63     """How many data points should be between the timestamps begin and end?"""
64     # copied from munin/master/_bin/munin-cgi-graph.in
65     # except day: 300 -> 600
66     resolutions = dict(
67         day   =   600,
68         week  =  1800,
69         month =  7200,
70         year  = 86400,
71     )
72     duration = (end - begin).total_seconds()
73     day = 60 * 60 * 24
74     if duration <= day:
75         resolution = resolutions['day']
76     elif duration <= 7 * day:
77         resolution = resolutions['week']
78     elif duration <= 31 * day:
79         resolution = resolutions['month']
80     else:
81         resolution = resolutions['year']
82     return resolution
83
84
85 def select_sensordata(sensor_id, sensor_type, begin, end):
86     query = Sensors.query
87     if sensor_id is not None:
88         query = query.filter(Sensors.sensor_id == sensor_id)
89     if sensor_type is not None:
90         query = query.filter(Sensors.value_type == sensor_type)
91     if begin is not None:
92         query = query.filter(Sensors.timestamp >= begin)
93     if end is not None:
94         query = query.filter(Sensors.timestamp <= end)
95     return query.all()
96
97
98 def select_sensordata_grouped(sensor_id, sensor_type, begin, end):
99     # determine resolution (interval in seconds for data points)
100     resolution = calc_grouping_resolution(begin, end)
101
102     # Let the database do the grouping. Example in SQL (MySQL):
103     # 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;
104     query = db.session.query(func.to_seconds(Sensors.timestamp).op('div')(resolution).label('g'),
105             func.from_unixtime(func.avg(func.unix_timestamp(Sensors.timestamp))).label('timestamp'),
106             func.avg(Sensors.value).label('value'),
107             Sensors.sensor_id, Sensors.value_type, Sensors.sensor_name)
108     if sensor_id is not None:
109         query = query.filter(Sensors.sensor_id == sensor_id)
110     if sensor_type is not None:
111         query = query.filter(Sensors.value_type == sensor_type)
112     query = query.filter(Sensors.timestamp >= begin)
113     query = query.filter(Sensors.timestamp <= end)
114     query = query.group_by('g', Sensors.sensor_id, Sensors.value_type, Sensors.sensor_name)
115     return query.all()
116
117
118 def select_openweatherdata(cityid, begin, end):
119     query = OpenWeatherMap.query.filter(OpenWeatherMap.cityid == cityid)
120     if begin is not None:
121         query = query.filter(OpenWeatherMap.datetime >= begin)
122     if end is not None:
123         query = query.filter(OpenWeatherMap.datetime <= end)
124     return query.all()
125
126
127 def select_openweatherdata_grouped(cityid, begin, end):
128     # determine resolution (interval in seconds for data points)
129     resolution = calc_grouping_resolution(begin, end)
130
131     # Let the database do the grouping. Example in SQL (MySQL):
132     # 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;
133     query = db.session.query(func.to_seconds(OpenWeatherMap.datetime).op('div')(resolution).label('g'),
134             func.from_unixtime(func.avg(func.unix_timestamp(OpenWeatherMap.datetime))).label('datetime'),
135             func.avg(OpenWeatherMap.temp).label('temp'),
136             OpenWeatherMap.cityid)
137     OpenWeatherMap.query.filter(OpenWeatherMap.cityid == cityid)
138     query = query.filter(OpenWeatherMap.datetime >= begin)
139     query = query.filter(OpenWeatherMap.datetime <= end)
140     query = query.group_by('g', OpenWeatherMap.cityid)
141     return query.all()
142
143
144 def convert_to_c3(result, id, field_x, field_y):
145     c3result = defaultdict(list)
146     for row in result:
147         c3result[str(getattr(row, id))].append(getattr(row, field_y))
148         dt = getattr(row, field_x).strftime('%Y-%m-%d %H:%M:%S')
149         c3result[str(getattr(row, id)) + '_x'].append(dt)
150     return c3result
151
152
153 def request_arg(key, type, default=None):
154     """Returns the key from the request if available, otherwise the default value.
155     In case type is provided and the key is present, the value is converted by calling type.
156     In other words: Reimplement request.args.get but don't return default value if
157     type raises a ValueError."""
158     if key in request.args:
159         try:
160             return type(request.args[key])
161         except ValueError as e:
162             abort(Response(str(e), 400))
163     else:
164         return default
165
166
167 def sensordata(sensor_id=None, sensor_type=None):
168     begin = request_arg('begin', parse_datetime)
169     end = request_arg('end', parse_datetime)
170     mode = request.args.get('mode', 'full')
171     format = request.args.get('format', 'default')
172
173     if mode == 'full':
174         result = select_sensordata(sensor_id, sensor_type, begin, end)
175     elif mode == 'consolidated':
176         if begin is None or end is None:
177             abort(Response('begin and end have to be set for mode==consolidated', 400))
178         result = select_sensordata_grouped(sensor_id, sensor_type, begin, end)
179     else:
180         abort(Response('unknown value for mode', 400))
181
182     if format == 'c3':
183         return convert_to_c3(result, 'sensor_id', 'timestamp', 'value')
184     return result
185
186
187 def openweathermapdata(cityid):
188     begin = request_arg('begin', parse_datetime)
189     end = request_arg('end', parse_datetime)
190     mode = request.args.get('mode', 'full')
191     format = request.args.get('format', 'default')
192
193     if mode == 'full':
194         result = select_openweatherdata(cityid, begin, end)
195     elif mode == 'consolidated':
196         if begin is None or end is None:
197             abort(Response('begin and end have to be set for mode==consolidated', 400))
198         result = select_openweatherdata_grouped(cityid, begin, end)
199     else:
200         abort(Response('unknown value for mode', 400))
201
202     if format == 'c3':
203         return convert_to_c3(result, 'cityid', 'datetime', 'temp')
204     return result
205
206
207 def currentairtemperature(cityid):
208     result = OpenWeatherMap.query.filter_by(cityid=cityid).order_by(OpenWeatherMap.datetime.desc()).first()
209     return result.temp, result.datetime
210
211
212 def currentwatertemperature(sensorid):
213     result = Sensors.query.filter_by(sensor_id=sensorid).order_by(Sensors.timestamp.desc()).first()
214     return result.value, result.timestamp
215
216
217 @app.route('/api/<version>/sensors/')
218 def sensors(version):
219     """List all sensors found in the database"""
220     result = db.session.query(Sensors.sensor_id, Sensors.sensor_name, Sensors.value_type).distinct().all()
221     return jsonify(result)
222
223
224 @app.route('/api/<version>/sensor/id/<sensor_id>')
225 def sensorid(version, sensor_id):
226     """Return all data for a specific sensor
227
228     URL parameters:
229     begin=<datetime>, optional, format like "2018-05-19T21:07:53"
230     end=<datetime>, optional, format like "2018-05-19T21:07:53"
231     mode=<full|consolidated>, optional. return all rows (default) or with lower resolution (for charts)
232     format=<default|c3>, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
233     """
234     result = sensordata(sensor_id=sensor_id)
235     return jsonify(result)
236
237
238 @app.route('/api/<version>/sensor/type/<sensor_type>')
239 def sensortype(version, sensor_type):
240     """Return all data for a specific sensor type
241
242     URL parameters:
243     begin=<datetime>, optional, format like "2018-05-19T21:07:53"
244     end=<datetime>, optional, format like "2018-05-19T21:07:53"
245     mode=<full|consolidated>, optional. return all rows (default) or with lower resolution (for charts)
246     format=<default|c3>, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
247     """
248     result = sensordata(sensor_type=sensor_type)
249     return jsonify(result)
250
251
252 @app.route('/api/<version>/openweathermap/cities')
253 def openweathermap_cities(version):
254     """List all city IDs found in the database"""
255     result = db.session.query(OpenWeatherMap.cityid).distinct().all()
256     return jsonify(result)
257
258
259 @app.route('/api/<version>/openweathermap/city/<cityid>')
260 def openweathermap_city(version, cityid):
261     """List all data found for a city"""
262     result = openweathermapdata(cityid=cityid)
263     return jsonify(result)
264
265
266 @app.route('/api/<version>/currentairtemperature')
267 def currentair(version):
268     value, timestamp = currentairtemperature(cityid)
269     return jsonify({"value": value, "timestamp": timestamp})
270
271
272 @app.route('/api/<version>/currentwatertemperature')
273 def currentwater(version):
274     value, timestamp = currentwatertemperature(mainsensor)
275     return jsonify({"value": value, "timestamp": timestamp})
276
277
278 @app.route('/report/<int:year>-<int:month>')
279 def report(year, month):
280     import io
281     import numpy as np
282     import matplotlib.pyplot as plt
283     from matplotlib.backends.backend_pdf import PdfPages
284
285     binary_pdf = io.BytesIO()
286     with PdfPages(binary_pdf) as pdf:
287         a4 = (21./2.54, 29.7/2.54)
288         plt.figure(figsize=a4)
289         x = np.arange(100)
290         y = np.sin(x/4)
291         plt.plot(x, y)
292         pdf.savefig()
293     response = make_response(binary_pdf.getvalue())
294     response.headers['Content-Type'] = 'application/pdf'
295     response.headers['Content-Disposition'] = 'attachment; filename=seepark_{:04d}-{:02d}.pdf'.format(year, month)
296     return response
297
298
299 @app.route("/")
300 def index():
301     airvalue, airtime     = currentairtemperature(cityid)
302     watervalue, watertime = currentwatertemperature(mainsensor)
303
304     return render_template(
305         'seepark_web.html',
306         apikey=apikey,
307         watervalue=watervalue,
308         watertime=watertime,
309         airvalue=airvalue,
310         airtime=airtime,
311     )