2026-09-13 18:14:19 +02:00
|
|
|
import os
|
2026-10-03 20:24:37 +02:00
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-10-05 19:28:37 +02:00
|
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
2026-09-13 18:14:19 +02:00
|
|
|
|
2026-10-03 19:31:38 +02:00
|
|
|
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,
|
|
|
|
|
)
|
2026-09-13 18:14:19 +02:00
|
|
|
|
2026-10-03 19:31:38 +02:00
|
|
|
from models import Sensor, SensorReadout, User, db
|
|
|
|
|
|
|
|
|
|
login_manager = LoginManager()
|
2026-09-13 18:14:19 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
2026-10-03 19:31:38 +02:00
|
|
|
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key-change-me")
|
2026-09-13 18:14:19 +02:00
|
|
|
|
|
|
|
|
db.init_app(app)
|
2026-10-03 19:31:38 +02:00
|
|
|
login_manager.init_app(app)
|
|
|
|
|
login_manager.login_view = "login"
|
|
|
|
|
|
|
|
|
|
@login_manager.user_loader
|
|
|
|
|
def load_user(user_id):
|
|
|
|
|
return db.session.get(User, int(user_id))
|
|
|
|
|
|
|
|
|
|
@login_manager.unauthorized_handler
|
|
|
|
|
def unauthorized():
|
2026-10-03 20:24:37 +02:00
|
|
|
# Return JSON for API calls instead of redirecting to the login page.
|
2026-10-03 19:31:38 +02:00
|
|
|
if request.path.startswith("/api/"):
|
|
|
|
|
return jsonify({"error": "authentication required"}), 401
|
|
|
|
|
next_page = request.path
|
|
|
|
|
login_url = url_for("login")
|
|
|
|
|
if next_page and next_page != login_url:
|
|
|
|
|
login_url += f"?next={next_page}"
|
|
|
|
|
return redirect(login_url)
|
|
|
|
|
|
|
|
|
|
def _is_safe_next(target: str | None) -> bool:
|
|
|
|
|
return bool(target) and target.startswith("/") and not target.startswith("//")
|
|
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
# --- Pages ---
|
2026-10-03 19:31:38 +02:00
|
|
|
|
|
|
|
|
@app.route("/")
|
|
|
|
|
def index():
|
|
|
|
|
if current_user.is_authenticated:
|
|
|
|
|
return redirect(url_for("home"))
|
|
|
|
|
return redirect(url_for("login"))
|
|
|
|
|
|
|
|
|
|
@app.route("/login", methods=["GET", "POST"])
|
|
|
|
|
def login():
|
|
|
|
|
if current_user.is_authenticated:
|
|
|
|
|
return redirect(url_for("home"))
|
|
|
|
|
error = None
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
username = request.form.get("username", "").strip()
|
|
|
|
|
password = request.form.get("password", "")
|
|
|
|
|
user = User.query.filter_by(username=username).first()
|
|
|
|
|
if user and user.check_password(password):
|
|
|
|
|
login_user(user)
|
|
|
|
|
next_page = request.args.get("next") or request.form.get("next")
|
|
|
|
|
if not _is_safe_next(next_page):
|
|
|
|
|
next_page = url_for("home")
|
|
|
|
|
return redirect(next_page)
|
2026-10-03 20:24:37 +02:00
|
|
|
error = "Invalid username or password."
|
2026-10-03 19:31:38 +02:00
|
|
|
return render_template("login.html", error=error, next=request.args.get("next", ""))
|
|
|
|
|
|
|
|
|
|
@app.route("/logout")
|
|
|
|
|
@login_required
|
|
|
|
|
def logout():
|
|
|
|
|
logout_user()
|
|
|
|
|
return redirect(url_for("login"))
|
|
|
|
|
|
|
|
|
|
@app.route("/home")
|
|
|
|
|
@login_required
|
|
|
|
|
def home():
|
|
|
|
|
return render_template("home.html", user=current_user)
|
2026-09-13 18:14:19 +02:00
|
|
|
|
|
|
|
|
@app.route("/sensors")
|
2026-10-03 19:31:38 +02:00
|
|
|
@login_required
|
2026-09-13 18:14:19 +02:00
|
|
|
def sensors_page():
|
2026-10-03 20:24:37 +02:00
|
|
|
return render_template("sensors.html", user=current_user)
|
2026-09-13 18:14:19 +02:00
|
|
|
|
|
|
|
|
@app.route("/api/docs")
|
2026-10-03 19:31:38 +02:00
|
|
|
@login_required
|
2026-09-13 18:14:19 +02:00
|
|
|
def api_docs():
|
2026-10-03 20:24:37 +02:00
|
|
|
return render_template("docs.html", user=current_user)
|
2026-09-13 18:14:19 +02:00
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
# --- 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.
|
2026-10-03 19:31:38 +02:00
|
|
|
|
2026-09-13 18:14:19 +02:00
|
|
|
@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])
|
|
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
# --- API: dashboard reads (login required, the browser session cookie is enough) ---
|
|
|
|
|
|
2026-09-13 18:14:19 +02:00
|
|
|
@app.route("/api/sensors/<int:sensor_id>/types")
|
2026-10-03 19:31:38 +02:00
|
|
|
@login_required
|
2026-09-13 18:14:19 +02:00
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
@app.route("/api/sensors/<int:sensor_id>/readouts")
|
2026-10-03 19:31:38 +02:00
|
|
|
@login_required
|
2026-09-13 18:14:19 +02:00
|
|
|
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(
|
|
|
|
|
[
|
2026-09-20 18:24:32 +02:00
|
|
|
{"value": r.value, "timestamp": r.timestamp.isoformat() + "Z"}
|
2026-09-13 18:14:19 +02:00
|
|
|
for r in readouts
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.route("/api/sensors/<int:sensor_id>/readouts/hourly")
|
2026-10-03 19:31:38 +02:00
|
|
|
@login_required
|
2026-09-13 18:14:19 +02:00
|
|
|
def api_sensor_readouts_hourly(sensor_id):
|
|
|
|
|
type_ = request.args.get("type")
|
2026-10-03 20:24:37 +02:00
|
|
|
range_ = request.args.get("range", "all")
|
2026-10-05 19:28:37 +02:00
|
|
|
# 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
|
2026-10-03 20:24:37 +02:00
|
|
|
# Timestamps are stored as naive UTC, so the cutoff must be naive UTC too.
|
2026-10-05 19:28:37 +02:00
|
|
|
now_utc = datetime.now(timezone.utc)
|
|
|
|
|
now_local = now_utc.astimezone(local_tz)
|
2026-10-03 20:24:37 +02:00
|
|
|
if range_ == "today":
|
2026-10-05 19:28:37 +02:00
|
|
|
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)
|
2026-10-03 20:24:37 +02:00
|
|
|
elif range_ == "week":
|
2026-10-05 19:28:37 +02:00
|
|
|
cutoff = (now_utc - timedelta(days=7)).replace(tzinfo=None)
|
2026-10-03 20:24:37 +02:00
|
|
|
elif range_ == "month":
|
2026-10-05 19:28:37 +02:00
|
|
|
cutoff = (now_utc - timedelta(days=30)).replace(tzinfo=None)
|
2026-10-03 20:24:37 +02:00
|
|
|
elif range_ == "all":
|
|
|
|
|
cutoff = None
|
|
|
|
|
else:
|
2026-10-05 19:28:37 +02:00
|
|
|
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
|
2026-09-13 18:14:19 +02:00
|
|
|
)
|
2026-10-03 20:24:37 +02:00
|
|
|
if cutoff is not None:
|
|
|
|
|
query = query.filter(SensorReadout.timestamp >= cutoff)
|
2026-09-13 18:14:19 +02:00
|
|
|
if type_:
|
|
|
|
|
query = query.filter(SensorReadout.type == type_)
|
2026-10-05 19:28:37 +02:00
|
|
|
rows = query.order_by(SensorReadout.timestamp).all()
|
2026-10-23 11:37:55 +02:00
|
|
|
# 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:
|
2026-10-05 19:28:37 +02:00
|
|
|
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)
|
2026-09-13 18:14:19 +02:00
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
# --- API: device writes (intentionally public — called by devices/sensors) ---
|
2026-10-03 19:31:38 +02:00
|
|
|
|
2026-09-13 18:14:19 +02:00
|
|
|
@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,
|
2026-09-20 18:24:32 +02:00
|
|
|
"timestamp": readout.timestamp.isoformat() + "Z",
|
2026-09-13 18:14:19 +02:00
|
|
|
}
|
|
|
|
|
), 201
|
|
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
# --- 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)
|
2026-10-03 19:31:38 +02:00
|
|
|
|
|
|
|
|
@app.cli.command("create-admin")
|
2026-10-03 20:24:37 +02:00
|
|
|
@click.option("--username", default=None, help="Administrator username")
|
|
|
|
|
@click.option("--password", default=None, help="Administrator password")
|
2026-10-03 19:31:38 +02:00
|
|
|
def create_admin(username, password):
|
2026-10-03 20:24:37 +02:00
|
|
|
"""Create or update an administrator account (is_admin=True)."""
|
2026-10-03 19:31:38 +02:00
|
|
|
username = username or os.environ.get("ADMIN_USERNAME")
|
|
|
|
|
if not username:
|
|
|
|
|
username = click.prompt("Admin username")
|
|
|
|
|
username = username.strip()
|
|
|
|
|
if not username:
|
|
|
|
|
raise click.ClickException("Username cannot be empty.")
|
|
|
|
|
|
|
|
|
|
password = password or os.environ.get("ADMIN_PASSWORD")
|
|
|
|
|
if not password:
|
|
|
|
|
password = click.prompt("Admin password", hide_input=True, confirmation_prompt=True)
|
|
|
|
|
if not password:
|
|
|
|
|
raise click.ClickException("Password cannot be empty.")
|
|
|
|
|
|
|
|
|
|
user = User.query.filter_by(username=username).first()
|
|
|
|
|
if user:
|
|
|
|
|
user.set_password(password)
|
|
|
|
|
user.is_admin = True
|
|
|
|
|
db.session.commit()
|
2026-10-03 20:24:37 +02:00
|
|
|
click.echo(f"Updated existing user '{username}' (granted is_admin=True).")
|
2026-10-03 19:31:38 +02:00
|
|
|
else:
|
|
|
|
|
user = User(username=username, is_admin=True)
|
|
|
|
|
user.set_password(password)
|
|
|
|
|
db.session.add(user)
|
|
|
|
|
db.session.commit()
|
2026-10-03 20:24:37 +02:00
|
|
|
click.echo(f"Created administrator '{username}'.")
|
2026-10-03 19:31:38 +02:00
|
|
|
|
2026-09-13 18:14:19 +02:00
|
|
|
return app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
app = create_app()
|
|
|
|
|
app.run(debug=True, host='0.0.0.0')
|