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