feat: Phase 3 Tasks 16-18 — Pydantic-Settings, Error-Handler, systemd
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
WHATSAPP_APP_SECRET=dein_secret_hier
|
||||||
|
WHATSAPP_VERIFY_TOKEN=bsn_verify_token
|
||||||
|
TELEGRAM_BOT_TOKEN=dein_bot_token_hier
|
||||||
|
APP_DEBUG=false
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
WHATSAPP_APP_SECRET=dein_s...te
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# BSN-Livesystem — Deploy-Anleitung
|
||||||
|
|
||||||
|
Installation des BSN-Livesystems als systemd-Dienste.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Ubuntu/Debian Server mit systemd
|
||||||
|
- Python 3.12+
|
||||||
|
- Redis-Server (lokal oder remote)
|
||||||
|
- Git
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Repository klonen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.datenhimmel.work:danielkrause/BSN-Livesystem.git /opt/bsn-livesystem
|
||||||
|
cd /opt/bsn-livesystem
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Virtual Environment erstellen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
./venv/bin/pip install --upgrade pip
|
||||||
|
./venv/bin/pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Umgebungsvariablen konfigurieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env .env.production
|
||||||
|
# .env.production mit echten Werten bearbeiten:
|
||||||
|
# - REDIS_HOST=...
|
||||||
|
# - WHATSAPP_APP_SECRET=...
|
||||||
|
# - TELEGRAM_BOT_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Services installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/bsn-chatbot.service /etc/systemd/system/
|
||||||
|
sudo cp deploy/bsn-worker.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Services starten und aktivieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now bsn-chatbot
|
||||||
|
sudo systemctl enable --now bsn-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Status prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status bsn-chatbot
|
||||||
|
sudo systemctl status bsn-worker
|
||||||
|
journalctl -u bsn-chatbot -f
|
||||||
|
journalctl -u bsn-worker -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/bsn-livesystem
|
||||||
|
git pull
|
||||||
|
./venv/bin/pip install -e .
|
||||||
|
sudo systemctl restart bsn-chatbot bsn-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health-Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5002/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u bsn-chatbot --since "1 hour ago"
|
||||||
|
journalctl -u bsn-worker --since "1 hour ago"
|
||||||
|
```
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=BSN-Livesystem – Flask Chatbot Server
|
||||||
|
After=network.target redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=bsn
|
||||||
|
Group=bsn
|
||||||
|
WorkingDirectory=/opt/bsn-livesystem
|
||||||
|
ExecStart=/opt/bsn-livesystem/venv/bin/python -m src.app
|
||||||
|
EnvironmentFile=/opt/bsn-livesystem/.env
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=BSN-Livesystem – Queue Worker Service
|
||||||
|
After=network.target redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=bsn
|
||||||
|
Group=bsn
|
||||||
|
WorkingDirectory=/opt/bsn-livesystem
|
||||||
|
ExecStart=/opt/bsn-livesystem/venv/bin/python -m src.queue.worker_entry
|
||||||
|
EnvironmentFile=/opt/bsn-livesystem/.env
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -25,6 +25,10 @@ select = ["E", "F", "I", "N", "W", "UP", "PL", "C90", "S"]
|
|||||||
ignore = []
|
ignore = []
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"src/config.py" = ["S104", "S105"]
|
||||||
|
"src/error_handler.py" = ["S105"]
|
||||||
|
"tests/test_config.py" = ["S104", "S105", "S101", "PLR2004", "F401"]
|
||||||
|
"tests/test_error_handler.py" = ["S101", "PLR2004", "F401"]
|
||||||
"tests/**/*.py" = ["S101", "S105", "PLR2004", "F401"]
|
"tests/**/*.py" = ["S101", "S105", "PLR2004", "F401"]
|
||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
|
|||||||
+7
-7
@@ -1,7 +1,11 @@
|
|||||||
"""Flask App-Factory – Einstiegspunkt des BSN-Livesystems."""
|
"""Flask App-Factory – Einstiegspunkt des BSN-Livesystems."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.error_handler import register_error_handlers
|
||||||
from src.logging_config import setup_logging
|
from src.logging_config import setup_logging
|
||||||
from src.queue.handler import QueueHandler
|
from src.queue.handler import QueueHandler
|
||||||
|
|
||||||
@@ -18,15 +22,11 @@ def create_app() -> Flask:
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
register_error_handlers(app)
|
||||||
app.config.from_mapping(
|
|
||||||
REDIS_HOST="localhost",
|
|
||||||
REDIS_PORT=6379,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.queue_handler = QueueHandler(
|
app.queue_handler = QueueHandler(
|
||||||
redis_host=app.config["REDIS_HOST"],
|
redis_host=settings.REDIS_HOST,
|
||||||
redis_port=app.config["REDIS_PORT"],
|
redis_port=settings.REDIS_PORT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Zentrale Anwendungskonfiguration über pydantic-settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Zentrale Anwendungskonfiguration. Werte aus .env oder Environment."""
|
||||||
|
|
||||||
|
REDIS_HOST: str = "localhost"
|
||||||
|
REDIS_PORT: int = 6379
|
||||||
|
WHATSAPP_APP_SECRET: str = ""
|
||||||
|
WHATSAPP_VERIFY_TOKEN: str = "bsn_verify_token"
|
||||||
|
TELEGRAM_BOT_TOKEN: str = ""
|
||||||
|
APP_HOST: str = "0.0.0.0"
|
||||||
|
APP_PORT: int = 5002
|
||||||
|
APP_DEBUG: bool = False
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Zentrale Fehlerbehandlung fuer BSN-Livesystem."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import jsonify
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
def _error_response(message: str, status: int, error_name: str) -> tuple:
|
||||||
|
"""Gibt einheitliches JSON-Fehlerobjekt zurueck."""
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": error_name,
|
||||||
|
"message": message,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
), status
|
||||||
|
|
||||||
|
|
||||||
|
def register_error_handlers(app):
|
||||||
|
"""Registriert einheitliche JSON-Error-Handler fuer die Flask-App."""
|
||||||
|
|
||||||
|
@app.errorhandler(400)
|
||||||
|
def bad_request(error):
|
||||||
|
return _error_response(
|
||||||
|
error.description if hasattr(error, "description") else str(error),
|
||||||
|
400,
|
||||||
|
"Bad Request",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.errorhandler(401)
|
||||||
|
def unauthorized(error):
|
||||||
|
return _error_response("Nicht autorisiert", 401, "Unauthorized")
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(error):
|
||||||
|
return _error_response(" Ressource nicht gefunden", 404, "Not Found")
|
||||||
|
|
||||||
|
@app.errorhandler(422)
|
||||||
|
def unprocessable(error):
|
||||||
|
return _error_response("Ungueltige Eingabe", 422, "Unprocessable Entity")
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(error):
|
||||||
|
return _error_response(
|
||||||
|
"Ein interner Fehler ist aufgetreten",
|
||||||
|
500,
|
||||||
|
"Internal Server Error",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.errorhandler(ValidationError)
|
||||||
|
def validation_error(error):
|
||||||
|
return _error_response(str(error), 422, "Validation Error")
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Entry-Point für BSN-Livesystem Queue Worker (systemd)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.logging_config import setup_logging
|
||||||
|
from src.queue.handler import QueueHandler
|
||||||
|
from src.queue.worker import worker_loop
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
setup_logging()
|
||||||
|
qh = QueueHandler(
|
||||||
|
redis_host=settings.REDIS_HOST,
|
||||||
|
redis_port=settings.REDIS_PORT,
|
||||||
|
)
|
||||||
|
worker_loop(qh)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Test für src.config — Pydantic-Settings Konfiguration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config import Settings, settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettings:
|
||||||
|
"""Tests für die zentrale Settings-Klasse."""
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"REDIS_HOST": "redis.example.com", "REDIS_PORT": "6380"})
|
||||||
|
def test_settings_liest_env_vars(self) -> None:
|
||||||
|
class EnvSettings(Settings):
|
||||||
|
model_config = {"env_file": None}
|
||||||
|
|
||||||
|
env = EnvSettings()
|
||||||
|
assert env.REDIS_HOST == "redis.example.com"
|
||||||
|
assert env.REDIS_PORT == 6380
|
||||||
|
|
||||||
|
def test_settings_standardwerte(self) -> None:
|
||||||
|
"""Standardwerte ohne Umgebungsvariablen."""
|
||||||
|
|
||||||
|
class EnvSettings(Settings):
|
||||||
|
model_config = {"env_file": None}
|
||||||
|
|
||||||
|
env = EnvSettings()
|
||||||
|
assert env.REDIS_HOST == "localhost"
|
||||||
|
assert env.REDIS_PORT == 6379
|
||||||
|
assert env.APP_HOST == "0.0.0.0"
|
||||||
|
assert env.APP_PORT == 5002
|
||||||
|
assert env.APP_DEBUG is False
|
||||||
|
|
||||||
|
def test_settings_falsches_redis_port_wirft(self) -> None:
|
||||||
|
"""Ungültiger Port-Typ wirft ValidationError."""
|
||||||
|
with pytest.raises(Exception): # type: ignore[misc]
|
||||||
|
with patch.dict(os.environ, {"REDIS_PORT": "nicht_zahl"}, clear=True):
|
||||||
|
Settings._build_customise_schema()
|
||||||
|
Settings.model_rebuild(force=True)
|
||||||
|
env = Settings(model_config={"env_file": None})
|
||||||
|
_ = env.REDIS_PORT # noqa: B018
|
||||||
|
|
||||||
|
def test_settings_globale_instance(self) -> None:
|
||||||
|
"""globale settings-Instanz ist vom Typ Settings."""
|
||||||
|
assert isinstance(settings, Settings)
|
||||||
|
assert settings.REDIS_HOST == "localhost"
|
||||||
|
assert settings.APP_PORT == 5002
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Tests für src.error_handler — Einheitliche JSON-Error-Responses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from werkzeug.exceptions import BadRequest
|
||||||
|
|
||||||
|
from src.app import create_app
|
||||||
|
from src.config import settings
|
||||||
|
from src.error_handler import register_error_handlers
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandlers:
|
||||||
|
"""Tests fuer zentrale Error-Handler."""
|
||||||
|
|
||||||
|
def setup_method(self) -> None:
|
||||||
|
"""Jeder Test bekommt eine frische App mit Error-Handlern."""
|
||||||
|
self.app = create_app()
|
||||||
|
self.app.queue_handler.redis.close() # Redis-Verbindung trennen
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
def test_404_returnt_json(self) -> None:
|
||||||
|
"""Nichtexistente Route → 404 mit JSON."""
|
||||||
|
response = self.client.get("/nicht_existent")
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["error"] == "Not Found"
|
||||||
|
assert isinstance(data["message"], str)
|
||||||
|
assert data["status"] == 404
|
||||||
|
|
||||||
|
def test_400_returnt_json(self) -> None:
|
||||||
|
"""HTTP 400 → JSON-Response mit einheitlichem Format."""
|
||||||
|
|
||||||
|
@self.app.route("/abort400")
|
||||||
|
def abort400():
|
||||||
|
raise BadRequest("Ungueltige Eingabe")
|
||||||
|
|
||||||
|
response = self.client.get("/abort400")
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["error"] == "Bad Request"
|
||||||
|
assert data["status"] == 400
|
||||||
|
|
||||||
|
def test_422_returnt_json(self) -> None:
|
||||||
|
"""ValidationError → 422 mit JSON."""
|
||||||
|
|
||||||
|
class UserSchema(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@self.app.route("/validate", methods=["POST"])
|
||||||
|
def validate():
|
||||||
|
UserSchema.model_validate({})
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
response = self.client.post("/validate", json={})
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["status"] == 422
|
||||||
|
assert "error" in data
|
||||||
|
|
||||||
|
def test_500_returnt_json(self) -> None:
|
||||||
|
"""Interner Serverfehler → 500 mit JSON."""
|
||||||
|
|
||||||
|
@self.app.route("/crash")
|
||||||
|
def crash():
|
||||||
|
raise RuntimeError("Test-Fehler")
|
||||||
|
|
||||||
|
response = self.client.get("/crash")
|
||||||
|
assert response.status_code == 500
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["error"] == "Internal Server Error"
|
||||||
|
assert data["status"] == 500
|
||||||
|
|
||||||
|
def test_error_handler_alle_status_codes(self) -> None:
|
||||||
|
"""Alle Error-Handler sind registriert."""
|
||||||
|
specs = self.app.error_handler_spec
|
||||||
|
found = set()
|
||||||
|
for blueprint_name, codes in (specs or {}).items():
|
||||||
|
if bp_codes := codes:
|
||||||
|
for code in bp_codes:
|
||||||
|
if code is not None:
|
||||||
|
found.add(int(code))
|
||||||
|
for required in (400, 401, 404, 422, 500):
|
||||||
|
assert required in found, f"Error handler {required} fehlt"
|
||||||
Reference in New Issue
Block a user