Initial commit: Location Management Flask app

This commit is contained in:
ske087
2026-02-26 19:24:17 +02:00
commit 7a22575dab
52 changed files with 3481 additions and 0 deletions

1
app/routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Routes package."""

87
app/routes/admin.py Normal file
View File

@@ -0,0 +1,87 @@
"""Admin routes user management (admin only)."""
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user
from werkzeug.security import generate_password_hash
from app import db
from app.models.user import User
admin_bp = Blueprint("admin", __name__)
def _require_admin():
if not current_user.is_authenticated or not current_user.is_admin():
abort(403)
@admin_bp.route("/users")
@login_required
def list_users():
_require_admin()
users = User.query.order_by(User.username).all()
return render_template("admin/users.html", users=users)
@admin_bp.route("/users/add", methods=["GET", "POST"])
@login_required
def add_user():
_require_admin()
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
role = request.form.get("role", "user")
if not username or not password:
flash("Username and password are required.", "danger")
return render_template("admin/user_form.html", user=None)
if User.query.filter_by(username=username).first():
flash("Username already exists.", "danger")
return render_template("admin/user_form.html", user=None)
user = User(
username=username,
password_hash=generate_password_hash(password),
role=role,
is_active=True,
)
db.session.add(user)
db.session.commit()
flash(f"User '{username}' created.", "success")
return redirect(url_for("admin.list_users"))
return render_template("admin/user_form.html", user=None)
@admin_bp.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
@login_required
def edit_user(user_id: int):
_require_admin()
user = db.get_or_404(User, user_id)
if request.method == "POST":
user.username = request.form.get("username", user.username).strip()
user.role = request.form.get("role", user.role)
user.is_active = "is_active" in request.form
new_password = request.form.get("password", "").strip()
if new_password:
user.password_hash = generate_password_hash(new_password)
db.session.commit()
flash("User updated.", "success")
return redirect(url_for("admin.list_users"))
return render_template("admin/user_form.html", user=user)
@admin_bp.route("/users/<int:user_id>/delete", methods=["POST"])
@login_required
def delete_user(user_id: int):
_require_admin()
if user_id == current_user.id:
flash("You cannot delete your own account.", "danger")
return redirect(url_for("admin.list_users"))
user = db.get_or_404(User, user_id)
db.session.delete(user)
db.session.commit()
flash(f"User '{user.username}' deleted.", "warning")
return redirect(url_for("admin.list_users"))

81
app/routes/api.py Normal file
View File

@@ -0,0 +1,81 @@
"""REST API board webhook receiver and JSON relay control."""
from datetime import datetime
from flask import Blueprint, request, jsonify, abort
from app import db, socketio
from app.models.board import Board
from app.services import workflow_engine
api_bp = Blueprint("api", __name__)
# ── webhook endpoint (boards POST input events here) ──────────────────────────
@api_bp.route("/webhook/<int:board_id>", methods=["POST"])
def webhook(board_id: int):
board = db.session.get(Board, board_id)
if board is None:
abort(404)
data = request.get_json(silent=True) or {}
input_num = data.get("input")
state = data.get("state")
if input_num is None or state is None:
return jsonify({"error": "missing input or state"}), 400
# Update cached input state
states = board.input_states
states[f"input_{input_num}"] = bool(state)
board.input_states = states
board.is_online = True
board.last_seen = datetime.utcnow()
db.session.commit()
# Let the workflow engine decide what to do
workflow_engine.process_input_event(board_id, int(input_num), bool(state))
# Push live update to all connected clients immediately
socketio.emit("board_update", {
"board_id": board_id,
"is_online": True,
"input_states": board.input_states,
"relay_states": board.relay_states,
})
return jsonify({"status": "ok"})
# ── JSON relay status ─────────────────────────────────────────────────────────
@api_bp.route("/boards/<int:board_id>/relays")
def relay_states(board_id: int):
board = db.get_or_404(Board, board_id)
return jsonify({
"board_id": board.id,
"name": board.name,
"is_online": board.is_online,
"relay_states": board.relay_states,
"input_states": board.input_states,
})
# ── JSON board list ───────────────────────────────────────────────────────────
@api_bp.route("/boards")
def board_list():
boards = Board.query.order_by(Board.name).all()
return jsonify([
{
"id": b.id,
"name": b.name,
"board_type": b.board_type,
"host": b.host,
"port": b.port,
"is_online": b.is_online,
"last_seen": b.last_seen.isoformat() if b.last_seen else None,
"relay_states": b.relay_states,
"input_states": b.input_states,
}
for b in boards
])

38
app/routes/auth.py Normal file
View File

@@ -0,0 +1,38 @@
"""Authentication routes."""
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash
from app.models.user import User
auth_bp = Blueprint("auth", __name__)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("dashboard.index"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
remember = bool(request.form.get("remember"))
user = User.query.filter_by(username=username).first()
if user and user.is_active and check_password_hash(user.password_hash, password):
login_user(user, remember=remember)
next_page = request.args.get("next")
flash(f"Welcome back, {user.username}!", "success")
return redirect(next_page or url_for("dashboard.index"))
flash("Invalid username or password.", "danger")
return render_template("auth/login.html")
@auth_bp.route("/logout")
@login_required
def logout():
logout_user()
flash("You have been logged out.", "info")
return redirect(url_for("auth.login"))

258
app/routes/boards.py Normal file
View File

@@ -0,0 +1,258 @@
"""Board management routes."""
import json
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify
from flask_login import login_required, current_user
from app import db
from app.models.board import Board
from app.services.board_service import poll_board, register_webhook, set_relay
from app.drivers.registry import registry
from flask import current_app
boards_bp = Blueprint("boards", __name__)
def _board_types():
"""Dynamic list read from the driver registry — auto-updates when drivers are added."""
return registry.choices()
# ── list ──────────────────────────────────────────────────────────────────────
@boards_bp.route("/")
@login_required
def list_boards():
boards = Board.query.order_by(Board.name).all()
return render_template("boards/list.html", boards=boards)
# ── detail / quick controls ───────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>")
@login_required
def board_detail(board_id: int):
board = db.get_or_404(Board, board_id)
# Refresh states from device
poll_board(current_app._get_current_object(), board_id)
board = db.session.get(Board, board_id)
return render_template("boards/detail.html", board=board)
# ── add ───────────────────────────────────────────────────────────────────────
@boards_bp.route("/add", methods=["GET", "POST"])
@login_required
def add_board():
if not current_user.is_admin():
abort(403)
if request.method == "POST":
name = request.form.get("name", "").strip()
board_type = request.form.get("board_type", "olimex_esp32_c6_evb")
host = request.form.get("host", "").strip()
port = int(request.form.get("port", 80))
num_relays = int(request.form.get("num_relays", 4))
num_inputs = int(request.form.get("num_inputs", 4))
if not name or not host:
flash("Name and host are required.", "danger")
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
board = Board(
name=name,
board_type=board_type,
host=host,
port=port,
num_relays=num_relays,
num_inputs=num_inputs,
)
db.session.add(board)
db.session.commit()
# Try to register webhook immediately
server_url = current_app.config.get("SERVER_BASE_URL", "http://localhost:5000")
register_webhook(board, server_url)
flash(f"Board '{name}' added successfully.", "success")
return redirect(url_for("boards.board_detail", board_id=board.id))
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
# ── edit ──────────────────────────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>/edit", methods=["GET", "POST"])
@login_required
def edit_board(board_id: int):
if not current_user.is_admin():
abort(403)
board = db.get_or_404(Board, board_id)
if request.method == "POST":
board.name = request.form.get("name", board.name).strip()
board.host = request.form.get("host", board.host).strip()
board.port = int(request.form.get("port", board.port))
board.board_type = request.form.get("board_type", board.board_type)
board.num_relays = int(request.form.get("num_relays", board.num_relays))
board.num_inputs = int(request.form.get("num_inputs", board.num_inputs))
# Update labels
labels = {}
for n in range(1, board.num_relays + 1):
lbl = request.form.get(f"relay_{n}_label", "").strip()
if lbl:
labels[f"relay_{n}"] = lbl
for n in range(1, board.num_inputs + 1):
lbl = request.form.get(f"input_{n}_label", "").strip()
if lbl:
labels[f"input_{n}"] = lbl
board.labels = labels
db.session.commit()
flash("Board updated.", "success")
return redirect(url_for("boards.board_detail", board_id=board.id))
return render_template("boards/edit.html", board=board, board_types=_board_types())
# ── delete ────────────────────────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>/delete", methods=["POST"])
@login_required
def delete_board(board_id: int):
if not current_user.is_admin():
abort(403)
board = db.get_or_404(Board, board_id)
db.session.delete(board)
db.session.commit()
flash(f"Board '{board.name}' deleted.", "warning")
return redirect(url_for("boards.list_boards"))
# ── quick relay toggle ───────────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>/relay/<int:relay_num>/toggle", methods=["POST"])
@login_required
def toggle_relay_view(board_id: int, relay_num: int):
from app import socketio
board = db.get_or_404(Board, board_id)
# Always flip the local cached state first (optimistic update)
states = board.relay_states
current = states.get(f"relay_{relay_num}", False)
new_state = not current
states[f"relay_{relay_num}"] = new_state
board.relay_states = states
db.session.commit()
# Best-effort: send the command to the physical board using set_relay
# (uses /relay/on or /relay/off — same endpoints as the detail page ON/OFF buttons)
hw_ok = set_relay(board, relay_num, new_state)
# Push live update to all clients
socketio.emit("board_update", {
"board_id": board_id,
"is_online": board.is_online,
"relay_states": board.relay_states,
"input_states": board.input_states,
})
label = board.get_relay_label(relay_num)
status_text = "ON" if new_state else "OFF"
hw_warning = not hw_ok # True when board was unreachable
# JSON response for AJAX callers (dashboard)
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
return jsonify({
"relay": relay_num,
"state": new_state,
"label": label,
"hw_ok": not hw_warning,
})
# HTML response for form-submit callers (detail page)
if hw_warning:
flash(f"{label}: {status_text} (board unreachable — local state updated)", "warning")
else:
flash(f"{label}: {status_text}", "info")
return redirect(url_for("boards.board_detail", board_id=board_id))
# ── quick relay set ───────────────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>/relay/<int:relay_num>/set", methods=["POST"])
@login_required
def set_relay_view(board_id: int, relay_num: int):
from app import socketio
board = db.get_or_404(Board, board_id)
state = request.form.get("state", "off") == "on"
# Always update local state
states = board.relay_states
states[f"relay_{relay_num}"] = state
board.relay_states = states
db.session.commit()
# Best-effort send to hardware
hw_ok = set_relay(board, relay_num, state)
socketio.emit("board_update", {
"board_id": board_id,
"is_online": board.is_online,
"relay_states": board.relay_states,
"input_states": board.input_states,
})
label = board.get_relay_label(relay_num)
status_text = "ON" if state else "OFF"
if not hw_ok:
flash(f"{label}: {status_text} (board unreachable — local state updated)", "warning")
else:
flash(f"{label}: {status_text}", "info")
return redirect(url_for("boards.board_detail", board_id=board_id))
# ── edit entity configuration ─────────────────────────────────────────────────
@boards_bp.route("/<int:board_id>/entities", methods=["GET", "POST"])
@login_required
def edit_entities(board_id: int):
if not current_user.is_admin():
abort(403)
board = db.get_or_404(Board, board_id)
if request.method == "POST":
entities = {}
for n in range(1, board.num_relays + 1):
entities[f"relay_{n}"] = {
"type": request.form.get(f"relay_{n}_type", "switch"),
"name": request.form.get(f"relay_{n}_name", "").strip()[:20],
"icon": request.form.get(f"relay_{n}_icon", "").strip(),
}
for n in range(1, board.num_inputs + 1):
entities[f"input_{n}"] = {
"type": request.form.get(f"input_{n}_type", "generic"),
"name": request.form.get(f"input_{n}_name", "").strip()[:20],
"icon": request.form.get(f"input_{n}_icon", "").strip(),
}
board.entities = entities
db.session.commit()
flash("Entity configuration saved.", "success")
return redirect(url_for("boards.board_detail", board_id=board_id))
from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES, ICON_PALETTE
return render_template(
"boards/edit_entities.html",
board=board,
relay_types=RELAY_ENTITY_TYPES,
input_types=INPUT_ENTITY_TYPES,
icon_palette=ICON_PALETTE,
)
# ── edit labels (legacy — redirects to edit_entities) ─────────────────────────
@boards_bp.route("/<int:board_id>/labels", methods=["GET", "POST"])
@login_required
def edit_labels(board_id: int):
return redirect(url_for("boards.edit_entities", board_id=board_id))

20
app/routes/dashboard.py Normal file
View File

@@ -0,0 +1,20 @@
"""Dashboard route."""
from flask import Blueprint, render_template
from flask_login import login_required
from app.models.board import Board
from app.models.workflow import Workflow
dashboard_bp = Blueprint("dashboard", __name__)
@dashboard_bp.route("/")
@login_required
def index():
boards = Board.query.order_by(Board.name).all()
workflows = Workflow.query.filter_by(is_enabled=True).count()
return render_template(
"dashboard/index.html",
boards=boards,
active_workflows=workflows,
)

98
app/routes/workflows.py Normal file
View File

@@ -0,0 +1,98 @@
"""Workflow management routes."""
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user
from app import db
from app.models.board import Board
from app.models.workflow import Workflow
workflows_bp = Blueprint("workflows", __name__)
EVENTS = [("press", "Press (rising edge)"), ("release", "Release (falling)"), ("both", "Both")]
ACTIONS = [("on", "Turn ON"), ("off", "Turn OFF"), ("toggle", "Toggle")]
@workflows_bp.route("/")
@login_required
def list_workflows():
workflows = Workflow.query.order_by(Workflow.name).all()
return render_template("workflows/list.html", workflows=workflows)
@workflows_bp.route("/add", methods=["GET", "POST"])
@login_required
def add_workflow():
if not current_user.is_admin():
abort(403)
boards = Board.query.order_by(Board.name).all()
if request.method == "POST":
wf = Workflow(
name=request.form.get("name", "").strip(),
trigger_board_id=int(request.form["trigger_board_id"]),
trigger_input=int(request.form["trigger_input"]),
trigger_event=request.form.get("trigger_event", "press"),
action_board_id=int(request.form["action_board_id"]),
action_relay=int(request.form["action_relay"]),
action_type=request.form.get("action_type", "toggle"),
is_enabled=True,
)
if not wf.name:
flash("Name is required.", "danger")
return render_template("workflows/edit.html", wf=None, boards=boards,
events=EVENTS, actions=ACTIONS)
db.session.add(wf)
db.session.commit()
flash(f"Workflow '{wf.name}' created.", "success")
return redirect(url_for("workflows.list_workflows"))
return render_template("workflows/edit.html", wf=None, boards=boards,
events=EVENTS, actions=ACTIONS)
@workflows_bp.route("/<int:wf_id>/edit", methods=["GET", "POST"])
@login_required
def edit_workflow(wf_id: int):
if not current_user.is_admin():
abort(403)
wf = db.get_or_404(Workflow, wf_id)
boards = Board.query.order_by(Board.name).all()
if request.method == "POST":
wf.name = request.form.get("name", wf.name).strip()
wf.trigger_board_id = int(request.form.get("trigger_board_id", wf.trigger_board_id))
wf.trigger_input = int(request.form.get("trigger_input", wf.trigger_input))
wf.trigger_event = request.form.get("trigger_event", wf.trigger_event)
wf.action_board_id = int(request.form.get("action_board_id", wf.action_board_id))
wf.action_relay = int(request.form.get("action_relay", wf.action_relay))
wf.action_type = request.form.get("action_type", wf.action_type)
wf.is_enabled = "is_enabled" in request.form
db.session.commit()
flash("Workflow updated.", "success")
return redirect(url_for("workflows.list_workflows"))
return render_template("workflows/edit.html", wf=wf, boards=boards,
events=EVENTS, actions=ACTIONS)
@workflows_bp.route("/<int:wf_id>/delete", methods=["POST"])
@login_required
def delete_workflow(wf_id: int):
if not current_user.is_admin():
abort(403)
wf = db.get_or_404(Workflow, wf_id)
db.session.delete(wf)
db.session.commit()
flash(f"Workflow '{wf.name}' deleted.", "warning")
return redirect(url_for("workflows.list_workflows"))
@workflows_bp.route("/<int:wf_id>/toggle", methods=["POST"])
@login_required
def toggle_workflow(wf_id: int):
wf = db.get_or_404(Workflow, wf_id)
wf.is_enabled = not wf.is_enabled
db.session.commit()
state = "enabled" if wf.is_enabled else "disabled"
flash(f"Workflow '{wf.name}' {state}.", "info")
return redirect(url_for("workflows.list_workflows"))