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 = []
|
||||
|
||||
[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"]
|
||||
|
||||
[tool.ruff.format]
|
||||
|
||||
+7
-7
@@ -1,7 +1,11 @@
|
||||
"""Flask App-Factory – Einstiegspunkt des BSN-Livesystems."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.queue.handler import QueueHandler
|
||||
|
||||
@@ -18,15 +22,11 @@ def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
|
||||
setup_logging()
|
||||
|
||||
app.config.from_mapping(
|
||||
REDIS_HOST="localhost",
|
||||
REDIS_PORT=6379,
|
||||
)
|
||||
register_error_handlers(app)
|
||||
|
||||
app.queue_handler = QueueHandler(
|
||||
redis_host=app.config["REDIS_HOST"],
|
||||
redis_port=app.config["REDIS_PORT"],
|
||||
redis_host=settings.REDIS_HOST,
|
||||
redis_port=settings.REDIS_PORT,
|
||||
)
|
||||
|
||||
@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