9871b040d87bbca510124decb3f2b42e9fd15c8e
[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
8 import flask.json
9 from flask_sqlalchemy import SQLAlchemy, inspect
10
11
12 # https://stackoverflow.com/a/37350445
13 def sqlalchemy_model_to_dict(model):
14     return {c.key: getattr(model, c.key)
15         for c in inspect(model).mapper.column_attrs}
16
17
18 class JSONEncoder(flask.json.JSONEncoder):
19     def default(self, object):
20         if isinstance(object, datetime.datetime):
21             return object.isoformat()
22         elif isinstance(object, db.Model):
23             return sqlalchemy_model_to_dict(object)
24         return super().default(object)
25
26
27 def parse_datetime(date_str):
28     return datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S')
29
30
31 def get_sqlalchemy_database_uri(config):
32     user = config.get('database', 'user')
33     pwd = config.get('database', 'password')
34     host = config.get('database', 'hostname')
35     db = config.get('database', 'database')
36     return 'mysql+mysqldb://{}:{}@{}/{}'.format(user, pwd, host, db)
37
38
39 config = configparser.ConfigParser()
40 config.read(os.environ['SEEPARKINI'])
41 apikey = config.get('openweathermap', 'apikey')
42 cityid = config.get('openweathermap', 'cityid')
43 mainsensor = config.get('webapp', 'mainsensor')
44
45 app = Flask(__name__)
46 app.json_encoder = JSONEncoder
47 app.config['SQLALCHEMY_DATABASE_URI'] = get_sqlalchemy_database_uri(config)
48 app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
49 db = SQLAlchemy(app)
50 db.reflect(app=app)
51
52
53 class Sensors(db.Model):
54     __tablename__ = 'sensors'
55
56
57 class OpenWeatherMap(db.Model):
58     __tablename__ = 'openweathermap'
59
60
61 def select_sensordata(sensor_id, sensor_type, begin, end, mode):
62     query = Sensors.query
63     if sensor_id is not None:
64         query = query.filter(Sensors.sensor_id == sensor_id)
65     if sensor_type is not None:
66         query = query.filter(Sensors.value_type == sensor_type)
67     if begin is not None:
68         query = query.filter(Sensors.timestamp >= begin)
69     if end is not None:
70         query = query.filter(Sensors.timestamp <= end)
71     if mode == 'consolidated' and begin is not None and end is not None:
72         # copied from munin/master/_bin/munin-cgi-graph.in
73         # interval in seconds for data points
74         resolutions = dict(
75             day   =   300,
76             week  =  1800,
77             month =  7200,
78             year  = 86400,
79         )
80         duration = (end - begin).total_seconds()
81         day = 60 * 60 * 24
82         if duration <= day:
83             resolution = resolutions['day']
84         elif duration <= 7 * day:
85             resolution = resolutions['week']
86         elif duration <= 31 * day:
87             resolution = resolutions['month']
88         else:
89             resolution = resolutions['year']
90         # TODO: filter out samples from 'result'
91         # something like
92         # 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;
93         # seepark_web.db.session.query(func.to_seconds(Sensors.timestamp).op('div')(60*60*24).label('g'), func.min(Sensors.timestamp), func.min(Sensors.value)).group_by('g').all()
94     return query.all()
95
96
97 def select_openweatherdata(cityid, begin, end, mode):
98     query = OpenWeatherMap.query.filter(OpenWeatherMap.cityid == cityid)
99     if begin is not None:
100         query = query.filter(OpenWeatherMap.datetime >= begin)
101     if end is not None:
102         query = query.filter(OpenWeatherMap.datetime <= end)
103     if mode == 'consolidated' and begin is not None and end is not None:
104         # copied from munin/master/_bin/munin-cgi-graph.in
105         # interval in seconds for data points
106         resolutions = dict(
107             day   =   300,
108             week  =  1800,
109             month =  7200,
110             year  = 86400,
111         )
112         duration = (end - begin).total_seconds()
113         day = 60 * 60 * 24
114         if duration < day:
115             resolution = resolutions['day']
116         elif duration < 7 * day:
117             resolution = resolutions['week']
118         elif duration < 31 * day:
119             resolution = resolutions['month']
120         else:
121             resolution = resolutions['year']
122         # TODO: filter out samples from 'result'
123         # something like 
124         # 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;
125     return query.all()
126
127
128 def convert_to_c3(result, id, field_x, field_y):
129     c3result = defaultdict(list)
130     for row in result:
131         c3result[getattr(row, id)].append(getattr(row, field_y))
132         dt = getattr(row, field_x).strftime('%Y-%m-%d %H:%M:%S')
133         c3result[str(getattr(row, id)) + '_x'].append(dt)
134     return c3result
135
136
137 def request_arg(key, type, default=None):
138     """Returns the key from the request if available, otherwise the default value.
139     In case type is provided and the key is present, the value is converted by calling type.
140     In other words: Reimplement request.args.get but don't return default value if
141     type raises a ValueError."""
142     if key in request.args:
143         try:
144             return type(request.args[key])
145         except ValueError as e:
146             abort(Response(str(e), 400))
147     else:
148         return default
149
150
151 def sensordata(sensor_id=None, sensor_type=None):
152     begin = request_arg('begin', parse_datetime)
153     end = request_arg('end', parse_datetime)
154     mode = request.args.get('mode', 'full')
155     format = request.args.get('format', 'default')
156
157     result = select_sensordata(sensor_id, sensor_type, begin, end, mode)
158
159     if format == 'c3':
160         return convert_to_c3(result, 'sensor_id', 'timestamp', 'value')
161     return result
162
163
164 def openweathermapdata(cityid):
165     begin = request_arg('begin', parse_datetime)
166     end = request_arg('end', parse_datetime)
167     mode = request.args.get('mode', 'full')
168     format = request.args.get('format', 'default')
169
170     result = select_openweatherdata(cityid, begin, end, mode)
171
172     if format == 'c3':
173         return convert_to_c3(result, 'cityid', 'datetime', 'temp')
174     return result
175
176
177 def currentairtemperature(cityid):
178     result = OpenWeatherMap.query.filter_by(cityid=cityid).order_by(OpenWeatherMap.datetime.desc()).first()
179     return result.temp, result.datetime
180
181
182 def currentwatertemperature(sensorid):
183     result = Sensors.query.filter_by(sensor_id=sensorid).order_by(Sensors.timestamp.desc()).first()
184     return result.value, result.timestamp
185
186
187 @app.route('/api/<version>/sensors/')
188 def sensors(version):
189     """List all sensors found in the database"""
190     result = db.session.query(Sensors.sensor_id, Sensors.sensor_name, Sensors.value_type).distinct().all()
191     return jsonify(result)
192
193
194 @app.route('/api/<version>/sensor/id/<sensor_id>')
195 def sensorid(version, sensor_id):
196     """Return all data for a specific sensor
197
198     URL parameters:
199     begin=<datetime>, optional, format like "2018-05-19T21:07:53"
200     end=<datetime>, optional, format like "2018-05-19T21:07:53"
201     mode=<full|consolidated>, optional. return all rows (default) or with lower resolution (for charts)
202     format=<default|c3>, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
203     """
204     result = sensordata(sensor_id=sensor_id)
205     return jsonify(result)
206
207
208 @app.route('/api/<version>/sensor/type/<sensor_type>')
209 def sensortype(version, sensor_type):
210     """Return all data for a specific sensor type
211
212     URL parameters:
213     begin=<datetime>, optional, format like "2018-05-19T21:07:53"
214     end=<datetime>, optional, format like "2018-05-19T21:07:53"
215     mode=<full|consolidated>, optional. return all rows (default) or with lower resolution (for charts)
216     format=<default|c3>, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
217     """
218     result = sensordata(sensor_type=sensor_type)
219     return jsonify(result)
220
221
222 @app.route('/api/<version>/openweathermap/cities')
223 def openweathermap_cities(version):
224     """List all city IDs found in the database"""
225     result = db.session.query(OpenWeatherMap.cityid).distinct().all()
226     return jsonify(result)
227
228
229 @app.route('/api/<version>/openweathermap/city/<cityid>')
230 def openweathermap_city(version, cityid):
231     """List all data found for a city"""
232     result = openweathermapdata(cityid=cityid)
233     return jsonify(result)
234
235
236 @app.route("/")
237 def index():
238     airvalue, airtime     = currentairtemperature(cityid)
239     watervalue, watertime = currentwatertemperature(mainsensor)
240
241     return render_template(
242         'seepark_web.html',
243         apikey=apikey,
244         watervalue=watervalue,
245         watertime=watertime,
246         airvalue=airvalue,
247         airtime=airtime,
248     )