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