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