import os import click from flask import ( Flask, jsonify, redirect, render_template, request, url_for, ) from flask_login import current_user, login_required, login_user, logout_user 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 Note, NoteItem, User, user_db def create_app(): app = Flask(__name__) app.config["SECRET_KEY"] = load_secret_key() app.config["SQLALCHEMY_DATABASE_URI"] = users_database_uri() app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SENSORS_API_URL"] = sensors_api_url() user_db.init_app(app) sensors = SensorsClient(app.config["SENSORS_API_URL"]) def _login_redirect(req): login_url = url_for("login") 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("/") 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) error = "Invalid username or password." 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) @app.route("/sensors") @login_required def sensors_page(): return render_template("sensors.html", user=current_user) @app.route("/api/docs") @login_required def api_docs(): return render_template("docs.html", user=current_user) # --- Notes (stored in main_app.db) --- def _note_or_404(note_id): return Note.query.filter_by( id=note_id, user_id=current_user.id ).first_or_404() def _note_redirect(note_id): return redirect( url_for("notes_page", _anchor=f"note-{note_id}") ) @app.route("/notes") @login_required def notes_page(): notes = ( Note.query.filter_by(user_id=current_user.id) .order_by(Note.updated_at.desc(), Note.id.desc()) .all() ) return render_template("notes.html", user=current_user, notes=notes) @app.route("/notes", methods=["POST"]) @login_required def notes_create(): title = request.form.get("title", "").strip() content = request.form.get("content", "").strip() if not title or not content: return redirect(url_for("notes_page")) note = Note( user_id=current_user.id, title=title[:200], content=content[:2000], ) user_db.session.add(note) user_db.session.commit() return _note_redirect(note.id) @app.route("/notes/", methods=["POST"]) @login_required def notes_update(note_id): note = _note_or_404(note_id) title = request.form.get("title", "").strip() content = request.form.get("content", "").strip() if title and content: note.title = title[:200] note.content = content[:2000] note.touch() user_db.session.commit() return _note_redirect(note.id) @app.route("/notes//delete", methods=["POST"]) @login_required def notes_delete(note_id): note = _note_or_404(note_id) user_db.session.delete(note) user_db.session.commit() return redirect(url_for("notes_page")) @app.route("/notes//items", methods=["POST"]) @login_required def notes_add_item(note_id): note = _note_or_404(note_id) text = request.form.get("text", "").strip() if text: item = NoteItem(note=note, text=text[:255]) note.touch() user_db.session.add(item) user_db.session.commit() return _note_redirect(note.id) @app.route( "/notes//items//toggle", methods=["POST"] ) @login_required def notes_toggle_item(note_id, item_id): note = _note_or_404(note_id) item = NoteItem.query.filter_by(id=item_id, note_id=note.id).first_or_404() item.is_done = request.form.get("is_done") in {"1", "true", "on", "yes"} note.touch() user_db.session.commit() return _note_redirect(note.id) @app.route( "/notes//items//delete", methods=["POST"] ) @login_required def notes_delete_item(note_id, item_id): note = _note_or_404(note_id) item = NoteItem.query.filter_by(id=item_id, note_id=note.id).first_or_404() user_db.session.delete(item) note.touch() user_db.session.commit() return _note_redirect(note.id) # --- 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(): return _proxy(sensors.list_sensors) @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//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//types") @login_required def api_sensor_types(sensor_id): return _proxy(lambda: sensors.sensor_types(sensor_id)) @app.route("/api/sensors//readouts") @login_required def api_sensor_readouts(sensor_id): return _proxy( lambda: sensors.sensor_readouts( sensor_id, {"type": request.args.get("type")} ) ) @app.route("/api/sensors//readouts/hourly") @login_required def api_sensor_readouts_hourly(sensor_id): return _proxy( lambda: sensors.sensor_readouts_hourly(sensor_id, request.args.to_dict()) ) # --- 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 @app.cli.command("create-admin") @click.option("--username", default=None, help="Administrator username") @click.option("--password", default=None, help="Administrator password") def create_admin(username, password): """Create or update an administrator account (is_admin=True).""" 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 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) user_db.session.add(user) user_db.session.commit() click.echo(f"Created administrator '{username}'.") return app if __name__ == "__main__": app = create_app() app.run(debug=True, host='0.0.0.0', port=8000)