From 34cabc49a63af0dc404f56722f07ffbbddfc0b4e Mon Sep 17 00:00:00 2001 From: 2022117144 <162019766+2022117144@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:02:51 +0800 Subject: [PATCH] feat(config): unify environment-backed settings across backend (#13) --- .env.example | 21 ++++++++++++ .gitignore | 13 ++++++++ app/alertrules.py | 10 ++++-- app/autodiscover.py | 5 +-- app/config.py | 8 ++++- app/main.py | 7 ++-- docs/CONFIGURATION.md | 42 ++++++++++++++++++++++++ requirements.txt | 9 +++++ tests/test_config.py | 76 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 docs/CONFIGURATION.md create mode 100644 requirements.txt create mode 100644 tests/test_config.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1c80f3a --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Copy to .env for local development (do not commit real secrets). +# Environment variables override values in this file. +APP_NAME=RPi-NMS +ENV=development +DB_PATH=data/rpi_nms.db +DEFAULT_NETWORK=192.168.1.0/24 +SCAN_INTERVAL_MINUTES=5 + +# Set these only when enabling notifications. +# TELEGRAM_TOKEN= +# TELEGRAM_CHAT_ID= +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_USER= +# SMTP_PASS= +# SMTP_FROM= +# WEBHOOK_URLS= + +# Required: replace with a fresh, strong secret before any public deployment. +SECRET_KEY=replace-me-with-secure-random +ACCESS_TOKEN_EXPIRE_MINUTES=60 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5aa7da2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Never commit real local environment settings or credentials. +.env +.env.* +!.env.example + +# Python/test artifacts +__pycache__/ +*.py[cod] +.pytest_cache/ + +# Local SQLite databases +*.db +*.sqlite diff --git a/app/alertrules.py b/app/alertrules.py index 98a0e8b..65a3d92 100644 --- a/app/alertrules.py +++ b/app/alertrules.py @@ -7,12 +7,16 @@ import time from .models import Database +from .config import settings from .alerts import Alerts class AlertRules: - def __init__(self, db_path='data/rpi_nms.db', telegram_token=None, telegram_chat_id=None): - self.db = Database(db_path) - self.alerts = Alerts(token=telegram_token, chat_id=telegram_chat_id) + def __init__(self, db_path=None, telegram_token=None, telegram_chat_id=None): + self.db = Database(db_path if db_path is not None else settings.db_path) + self.alerts = Alerts( + token=telegram_token if telegram_token is not None else settings.telegram_token, + chat_id=telegram_chat_id if telegram_chat_id is not None else settings.telegram_chat_id, + ) self.db.init_db() # ensure history table exists with self.db.conn() as c: diff --git a/app/autodiscover.py b/app/autodiscover.py index 0d6e622..ab73edb 100644 --- a/app/autodiscover.py +++ b/app/autodiscover.py @@ -5,11 +5,12 @@ """ import subprocess from .models import Database +from .config import settings import re class ArpDiscover: - def __init__(self, db_path='data/rpi_nms.db'): - self.db = Database(db_path) + def __init__(self, db_path=None): + self.db = Database(db_path if db_path is not None else settings.db_path) self.db.init_db() def _parse_arp_output(self, text): diff --git a/app/config.py b/app/config.py index c8e1301..0481d64 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,12 @@ -from pydantic import BaseSettings, AnyHttpUrl from typing import Optional +# Pydantic 1.x exports BaseSettings directly. Pydantic 2.x retains the v1 +# compatibility namespace so existing deployments can upgrade safely. +try: + from pydantic.v1 import BaseSettings +except ImportError: + from pydantic import BaseSettings + class Settings(BaseSettings): # app app_name: str = "RPi-NMS" diff --git a/app/main.py b/app/main.py index 0d957bd..2777e45 100644 --- a/app/main.py +++ b/app/main.py @@ -2,10 +2,11 @@ from pydantic import BaseModel from typing import List import uvicorn -from .models import Database, Device +from .models import Database +from .config import settings -app = FastAPI(title="RPi Network Monitoring System") -db = Database('data/rpi_nms.db') +app = FastAPI(title=settings.app_name) +db = Database(settings.db_path) class DeviceOut(BaseModel): id: int diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..6e88760 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,42 @@ +# Environment configuration + +The backend reads configuration from `app.config.settings` using Pydantic +`BaseSettings`. All variables are optional locally; the defaults are declared +in `app/config.py`. Python environment variables take precedence over values +in an optional repository-root `.env` file. + +## Run locally + +1. Install dependencies: `python -m pip install -r requirements.txt`. +2. Copy `.env.example` to `.env` and change values for your machine. +3. Launch from the repository root: `python -m uvicorn app.main:app --reload`. +4. Open `http://127.0.0.1:8000/docs` for the generated API schema. + +**Never commit real `.env` credentials.** For deployments exposed beyond a +trusted development machine, replace the example `SECRET_KEY` with a strong, +random secret and configure any authentication layer before sharing endpoints. + +| Variable | Purpose | Default | +| --- | --- | --- | +| `APP_NAME` | FastAPI application title | `RPi-NMS` | +| `ENV` | Deployment environment label | `development` | +| `DB_PATH` | SQLite database file used by API, discovery and alerts | `data/rpi_nms.db` | +| `DEFAULT_NETWORK` | Configured discovery network | `192.168.1.0/24` | +| `SCAN_INTERVAL_MINUTES` | Discovery scheduling interval | `5` | +| `TELEGRAM_TOKEN` / `TELEGRAM_CHAT_ID` | Alert destination (optional) | Unset | +| `SECRET_KEY` | Auth token signing secret (must be changed for production) | Development placeholder | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT expiration in minutes | `60` | +| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` | Optional mail notifications | Unset / port `587` | +| `WEBHOOK_URLS` | Optional comma-separated webhook list | Unset | + +In Python, import `from app.config import settings`. Use explicit values only +when a component intentionally overrides the deployment configuration; otherwise +reuse `settings.db_path` and notification settings. + +## Compatibility + +Pydantic 1.10 and Pydantic 2.x expose the established BaseSettings behavior +through the `pydantic.v1` namespace. The code falls back to the direct +Pydantic 1.x import where necessary. The `python-dotenv` dependency is required +to parse an actual `.env` file. The settings model does not require a real +Raspberry Pi or network connection to test. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..17d5dcc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.100,<1 +uvicorn>=0.20,<1 +pydantic>=1.10,<3 +python-dotenv>=1,<2 +PyJWT>=2.8,<3 +passlib[bcrypt]>=1.7,<2 + +# The upstream CI currently invokes pytest directly. +pytest>=7,<10 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..21981ce --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,76 @@ +"""Configuration regression tests: typed env, dotenv, and shared database path.""" +import importlib + +import pytest + +from app import config + + +def test_defaults_without_env_file(monkeypatch): + for key in ("APP_NAME", "DB_PATH", "SCAN_INTERVAL_MINUTES", "TELEGRAM_TOKEN"): + monkeypatch.delenv(key, raising=False) + settings = config.Settings(_env_file=None) + assert settings.app_name == "RPi-NMS" + assert settings.db_path == "data/rpi_nms.db" + assert settings.scan_interval_minutes == 5 + assert settings.telegram_token is None + + +def test_environment_variables_override_defaults(monkeypatch, tmp_path): + db_path = tmp_path / "configured" / "devices.db" + monkeypatch.setenv("DB_PATH", str(db_path)) + monkeypatch.setenv("APP_NAME", "Testing RPi") + monkeypatch.setenv("SCAN_INTERVAL_MINUTES", "17") + monkeypatch.setenv("TELEGRAM_TOKEN", "dummy-test-value") + loaded = config.Settings(_env_file=None) + assert loaded.db_path == str(db_path) + assert loaded.app_name == "Testing RPi" + assert loaded.scan_interval_minutes == 17 + assert loaded.telegram_token == "dummy-test-value" + + +def test_invalid_scan_interval_is_rejected(monkeypatch): + monkeypatch.setenv("SCAN_INTERVAL_MINUTES", "not-an-integer") + with pytest.raises(ValueError): + config.Settings(_env_file=None) + + +def test_dotenv_file_and_environment_precedence(monkeypatch, tmp_path): + pytest.importorskip("dotenv") + env_file = tmp_path / ".env" + env_file.write_text( + "APP_NAME=From file\nDB_PATH=from_file.db\nSCAN_INTERVAL_MINUTES=12\n", + encoding="utf-8", + ) + monkeypatch.delenv("DB_PATH", raising=False) + monkeypatch.delenv("SCAN_INTERVAL_MINUTES", raising=False) + monkeypatch.setenv("APP_NAME", "From environment") + loaded = config.Settings(_env_file=env_file) + assert loaded.db_path == "from_file.db" + assert loaded.scan_interval_minutes == 12 + assert loaded.app_name == "From environment" + + +def test_main_api_uses_shared_settings(monkeypatch, tmp_path): + db_path = tmp_path / "api" / "devices.db" + monkeypatch.setenv("APP_NAME", "My Raspberry Pis") + monkeypatch.setenv("DB_PATH", str(db_path)) + new_settings = config.Settings(_env_file=None) + monkeypatch.setattr(config, "settings", new_settings) + from app import main + module = importlib.reload(main) + assert module.db.path == str(db_path) + assert module.app.title == "My Raspberry Pis" + module.db.init_db() + assert db_path.is_file() + + +def test_discovery_uses_configured_db_unless_explicitly_overridden(monkeypatch, tmp_path): + db_path = tmp_path / "discover" / "db.sqlite" + monkeypatch.setattr(config.settings, "db_path", str(db_path)) + from app.autodiscover import ArpDiscover + instance = ArpDiscover() + assert instance.db.path == str(db_path) + assert db_path.exists() + custom = tmp_path / "custom" / "db.sqlite" + assert ArpDiscover(db_path=str(custom)).db.path == str(custom)