Files
solar-pv-dashboard/backend/app/app_factory.py
Mateusz Gruszczyński 138059945e poprawki i zmiany ux
2026-03-26 09:30:39 +01:00

203 lines
7.6 KiB
Python

from __future__ import annotations
import logging
import os
from logging.config import dictConfig
import click
from flask import Flask, jsonify, make_response, request, session
from werkzeug.exceptions import HTTPException
from app.core_settings import get_settings
from app.routes import (
analytics_blueprint,
auth_blueprint,
dashboard_blueprint,
health_blueprint,
historical_blueprint,
realtime_blueprint,
)
from app.services.auth import get_auth_service
from app.services.historical_sync import get_historical_sync_service
def configure_logging(debug: bool) -> None:
level = "DEBUG" if debug else "INFO"
dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {"format": "%(asctime)s | %(levelname)s | %(name)s | %(message)s"}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": level,
}
},
"root": {"handlers": ["console"], "level": level},
}
)
def create_app() -> Flask:
settings = get_settings()
configure_logging(settings.debug)
app = Flask(__name__)
app.config["JSON_SORT_KEYS"] = False
get_auth_service().configure_app(app)
app.register_blueprint(health_blueprint)
app.register_blueprint(auth_blueprint, url_prefix=settings.api_prefix)
app.register_blueprint(dashboard_blueprint, url_prefix=settings.api_prefix)
app.register_blueprint(realtime_blueprint, url_prefix=settings.api_prefix)
app.register_blueprint(analytics_blueprint, url_prefix=settings.api_prefix)
app.register_blueprint(historical_blueprint, url_prefix=settings.api_prefix)
@app.get("/")
def index():
return {
"app": settings.app_name,
"version": settings.version,
"api_prefix": settings.api_prefix,
"message": "PV Insight backend is running",
}
@app.before_request
def handle_preflight_and_auth():
if request.method == "OPTIONS":
response = make_response("", 204)
return _apply_cors(response)
if not settings.auth["enabled"]:
return None
if request.path in {"/", "/health", "/favicon.ico"}:
return None
if request.path.startswith(f"{settings.api_prefix}/auth/"):
return None
public_kiosk = request.args.get("publicKiosk") == "1"
public_kiosk_allowed_paths = {
f"{settings.api_prefix}/dashboard/config",
f"{settings.api_prefix}/dashboard/kiosk-settings",
f"{settings.api_prefix}/realtime/snapshot",
f"{settings.api_prefix}/realtime/history",
f"{settings.api_prefix}/analytics/production",
f"{settings.api_prefix}/analytics/distribution",
}
if public_kiosk and request.method == "GET" and request.path in public_kiosk_allowed_paths:
return None
if request.path.startswith(settings.api_prefix) and "auth_user" not in session:
return _apply_cors(make_response(jsonify({"detail": "Authentication required"}), 401))
return None
@app.after_request
def append_cors_headers(response):
response = _apply_cors(response)
return _apply_cache_headers(response)
@app.errorhandler(HTTPException)
def handle_http_exception(exc: HTTPException):
response = jsonify({"detail": exc.description})
return _apply_cors(make_response(response, exc.code or 500))
@app.errorhandler(Exception)
def handle_exception(exc: Exception):
logging.getLogger(__name__).exception("Unhandled application error")
response = {"detail": str(exc) if settings.debug else "Internal server error"}
return _apply_cors(make_response(response, 500))
_register_cli_commands(app)
_bootstrap_background_services(settings.debug)
return app
def _register_cli_commands(app: Flask) -> None:
auth_service = get_auth_service()
@app.cli.command("create-admin")
@click.option("--username", required=True, help="Username")
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
@click.option("--display-name", default=None, help="Name")
def create_admin_command(username: str, password: str, display_name: str | None):
try:
user = auth_service.create_user(
username=username,
password=password,
role="admin",
display_name=display_name,
)
click.echo(f"Admin account created: {user.username}")
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
except Exception as exc: # pragma: no cover
raise click.ClickException(f"Cannot create admin account: {exc}") from exc
@app.cli.command("create-user")
@click.option("--username", required=True, help="Username")
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
@click.option("--display-name", default=None, help="Name")
def create_user_command(username: str, password: str, display_name: str | None):
try:
user = auth_service.create_user(
username=username,
password=password,
role="user",
display_name=display_name,
)
click.echo(f"User account created: {user.username}")
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
except Exception as exc: # pragma: no cover
raise click.ClickException(f"Cannot create user account: {exc}") from exc
@app.cli.command("reset-password")
@click.option("--username", required=True, help="Username")
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
def reset_password_command(username: str, password: str):
try:
user = auth_service.reset_password(username=username, new_password=password)
click.echo(f"Password reset for: {user.username}")
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
except Exception as exc: # pragma: no cover
raise click.ClickException(f"Cannot reset password: {exc}") from exc
def _bootstrap_background_services(debug: bool) -> None:
should_run = (not debug) or os.environ.get("WERKZEUG_RUN_MAIN") == "true"
if not should_run:
return
get_historical_sync_service().start_scheduler_if_enabled()
def _apply_cors(response):
settings = get_settings()
origin = request.headers.get("Origin")
allowed = settings.cors_origins
if origin and (origin in allowed or "*" in allowed):
response.headers["Access-Control-Allow-Origin"] = origin
response.headers.add("Vary", "Origin")
elif "*" in allowed:
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT"
response.headers["Access-Control-Allow-Credentials"] = "true"
return response
def _apply_cache_headers(response):
if request.path == "/" or request.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response