sensors is separate API with td

This commit is contained in:
2026-11-01 12:07:04 +01:00
parent b7048b401d
commit 85179c8a80
24 changed files with 1229 additions and 321 deletions
+64 -182
View File
@@ -1,52 +1,61 @@
import os
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import click
from flask import Flask, jsonify, redirect, render_template, request, url_for
from flask_login import (
LoginManager,
current_user,
login_required,
login_user,
logout_user,
from flask import (
Flask,
jsonify,
redirect,
render_template,
request,
url_for,
)
from flask_login import current_user, login_required, login_user, logout_user
from models import Sensor, SensorReadout, User, db
login_manager = LoginManager()
from auth import init_login_manager
from config import load_secret_key, sensors_api_url, users_database_uri
from sensors_client import SensorsAPIError, SensorsClient
from user_models import User, user_db
def create_app():
app = Flask(__name__)
db_path = os.path.join(os.path.dirname(__file__), "data.db")
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
app.config["SECRET_KEY"] = load_secret_key()
app.config["SQLALCHEMY_DATABASE_URI"] = users_database_uri()
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key-change-me")
app.config["SENSORS_API_URL"] = sensors_api_url()
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = "login"
user_db.init_app(app)
sensors = SensorsClient(app.config["SENSORS_API_URL"])
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
@login_manager.unauthorized_handler
def unauthorized():
# Return JSON for API calls instead of redirecting to the login page.
if request.path.startswith("/api/"):
return jsonify({"error": "authentication required"}), 401
next_page = request.path
def _login_redirect(req):
login_url = url_for("login")
if next_page and next_page != login_url:
login_url += f"?next={next_page}"
return redirect(login_url)
if req.path and req.path != login_url:
return url_for("login", next=req.path)
return login_url
def _load_user(user_id):
try:
return user_db.session.get(User, int(user_id))
except (TypeError, ValueError):
return None
init_login_manager(app, _login_redirect, _load_user)
def _is_safe_next(target: str | None) -> bool:
return bool(target) and target.startswith("/") and not target.startswith("//")
def _proxy(call):
try:
status, data = call()
except SensorsAPIError as exc:
return jsonify({"error": str(exc)}), 502
return jsonify(data), status
@app.context_processor
def inject_service_urls():
return {"main_api_url": request.url_root.rstrip("/") + "/api"}
# --- Pages ---
@app.route("/")
@@ -94,176 +103,49 @@ def create_app():
def api_docs():
return render_template("docs.html", user=current_user)
# --- API: device endpoints (intentionally public) ---
# The sensor firmware has no browser session, but it needs the sensor
# list to resolve a name to an id before pushing readouts.
# --- API: proxied to the sensors service ---
# The browser only talks to this app; the sensors service owns the
# sensor database and the actual API implementation.
@app.route("/api/sensors")
def api_sensors():
sensors = Sensor.query.order_by(Sensor.name).all()
return jsonify([{"id": s.id, "name": s.name} for s in sensors])
return _proxy(sensors.list_sensors)
# --- API: dashboard reads (login required, the browser session cookie is enough) ---
@app.route("/api/sensors", methods=["POST"])
def api_create_sensor():
return _proxy(lambda: sensors.create_sensor(request.get_json(silent=True)))
@app.route("/api/sensors/<int:sensor_id>/readouts", methods=["POST"])
def api_create_readout(sensor_id):
return _proxy(
lambda: sensors.create_readout(sensor_id, request.get_json(silent=True))
)
@app.route("/api/sensors/<int:sensor_id>/types")
@login_required
def api_sensor_types(sensor_id):
types = (
db.session.query(SensorReadout.type)
.filter_by(sensor_id=sensor_id)
.distinct()
.order_by(SensorReadout.type)
.all()
)
return jsonify([t[0] for t in types])
return _proxy(lambda: sensors.sensor_types(sensor_id))
@app.route("/api/sensors/<int:sensor_id>/readouts")
@login_required
def api_sensor_readouts(sensor_id):
type_ = request.args.get("type")
query = SensorReadout.query.filter_by(sensor_id=sensor_id)
if type_:
query = query.filter_by(type=type_)
readouts = query.order_by(SensorReadout.timestamp.desc()).limit(300).all()
return jsonify(
[
{"value": r.value, "timestamp": r.timestamp.isoformat() + "Z"}
for r in readouts
]
return _proxy(
lambda: sensors.sensor_readouts(
sensor_id, {"type": request.args.get("type")}
)
)
@app.route("/api/sensors/<int:sensor_id>/readouts/hourly")
@login_required
def api_sensor_readouts_hourly(sensor_id):
type_ = request.args.get("type")
range_ = request.args.get("range", "all")
# Frontend wysyła strefę czasową przeglądarki (np. Europe/Warsaw),
# żeby "today" znaczyło "od północy czasu lokalnego", a nie UTC.
tz_name = request.args.get("tz", "Europe/Warsaw") or "Europe/Warsaw"
try:
local_tz = ZoneInfo(tz_name)
except (ZoneInfoNotFoundError, ValueError):
return jsonify({"error": f"unknown timezone: {tz_name}"}), 400
# Timestamps are stored as naive UTC, so the cutoff must be naive UTC too.
now_utc = datetime.now(timezone.utc)
now_local = now_utc.astimezone(local_tz)
if range_ == "today":
midnight_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
cutoff = midnight_local.astimezone(timezone.utc).replace(tzinfo=None)
elif range_ == "24h":
cutoff = (now_utc - timedelta(hours=24)).replace(tzinfo=None)
elif range_ == "week":
cutoff = (now_utc - timedelta(days=7)).replace(tzinfo=None)
elif range_ == "month":
cutoff = (now_utc - timedelta(days=30)).replace(tzinfo=None)
elif range_ == "all":
cutoff = None
else:
return jsonify({"error": "range must be one of: today, 24h, week, month, all"}), 400
query = db.session.query(SensorReadout.timestamp, SensorReadout.value).filter(
SensorReadout.sensor_id == sensor_id
return _proxy(
lambda: sensors.sensor_readouts_hourly(sensor_id, request.args.to_dict())
)
if cutoff is not None:
query = query.filter(SensorReadout.timestamp >= cutoff)
if type_:
query = query.filter(SensorReadout.type == type_)
rows = query.order_by(SensorReadout.timestamp).all()
# Zakresy today i 24h domyślnie zwracają wszystkie pomiary bez
# uśredniania; checkbox "hourly average" wymusza średnie godzinowe.
averaged = range_ not in ("today", "24h") or request.args.get(
"avg", "0"
).lower() in ("1", "true", "yes")
if not averaged:
return jsonify(
[
{
"timestamp": ts.strftime("%Y-%m-%dT%H:%M:%S"),
"value": value,
}
for ts, value in rows
]
)
# Pozostałe zakresy: średnia godzinowa liczona po pełnej godzinie
# lokalnej (poprawne też przy zmianie DST), znacznik to początek
# godziny w UTC (frontend dokleja "Z").
buckets: dict[datetime, list[float]] = {}
for ts, value in rows:
ts_utc = ts.replace(tzinfo=timezone.utc)
hour_local = ts_utc.astimezone(local_tz).replace(
minute=0, second=0, microsecond=0
)
hour_utc = hour_local.astimezone(timezone.utc).replace(tzinfo=None)
buckets.setdefault(hour_utc, []).append(value)
result = [
{
"timestamp": hour.strftime("%Y-%m-%dT%H:00:00"),
"value": round(sum(values) / len(values), 2),
}
for hour, values in sorted(buckets.items())
]
return jsonify(result)
# --- API: device writes (intentionally public — called by devices/sensors) ---
@app.route("/api/sensors", methods=["POST"])
def api_create_sensor():
data = request.get_json(silent=True)
if not data or "name" not in data:
return jsonify({"error": "name is required"}), 400
name = data["name"].strip()
if not name:
return jsonify({"error": "name cannot be empty"}), 400
if Sensor.query.filter_by(name=name).first():
return jsonify({"error": "sensor already exists"}), 409
sensor = Sensor(name=name)
db.session.add(sensor)
db.session.commit()
return jsonify({"id": sensor.id, "name": sensor.name}), 201
@app.route("/api/sensors/<int:sensor_id>/readouts", methods=["POST"])
def api_create_readout(sensor_id):
sensor = db.session.get(Sensor, sensor_id)
if not sensor:
return jsonify({"error": "sensor not found"}), 404
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "request body is required"}), 400
missing = [f for f in ("type", "value") if f not in data]
if missing:
return jsonify({"error": f"missing fields: {', '.join(missing)}"}), 400
try:
value = float(data["value"])
except (TypeError, ValueError):
return jsonify({"error": "value must be a number"}), 400
readout = SensorReadout(
sensor_id=sensor_id,
type=str(data["type"]).strip(),
value=value,
)
db.session.add(readout)
db.session.commit()
return jsonify(
{
"id": readout.id,
"sensor_id": readout.sensor_id,
"type": readout.type,
"value": readout.value,
"timestamp": readout.timestamp.isoformat() + "Z",
}
), 201
# --- Command for creating the first admin ---
# Usage:
# flask --app app create-admin --username admin --password 'secret'
# ADMIN_USERNAME=admin ADMIN_PASSWORD=secret flask --app app create-admin
# flask --app app create-admin (asks interactively)
@app.cli.command("create-admin")
@click.option("--username", default=None, help="Administrator username")
@@ -287,13 +169,13 @@ def create_app():
if user:
user.set_password(password)
user.is_admin = True
db.session.commit()
user_db.session.commit()
click.echo(f"Updated existing user '{username}' (granted is_admin=True).")
else:
user = User(username=username, is_admin=True)
user.set_password(password)
db.session.add(user)
db.session.commit()
user_db.session.add(user)
user_db.session.commit()
click.echo(f"Created administrator '{username}'.")
return app
@@ -301,4 +183,4 @@ def create_app():
if __name__ == "__main__":
app = create_app()
app.run(debug=True, host='0.0.0.0')
app.run(debug=True, host='0.0.0.0', port=8000)