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