diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..334c8b5 --- /dev/null +++ b/.env.template @@ -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 diff --git a/deploy/.env.template b/deploy/.env.template new file mode 100644 index 0000000..169046a --- /dev/null +++ b/deploy/.env.template @@ -0,0 +1,3 @@ +REDIS_HOST=localhost +REDIS_PORT=6379 +WHATSAPP_APP_SECRET=dein_s...te \ No newline at end of file diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..b472f1f --- /dev/null +++ b/deploy/README.md @@ -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" +``` diff --git a/deploy/bsn-chatbot.service b/deploy/bsn-chatbot.service new file mode 100644 index 0000000..46c3be0 --- /dev/null +++ b/deploy/bsn-chatbot.service @@ -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 diff --git a/deploy/bsn-worker.service b/deploy/bsn-worker.service new file mode 100644 index 0000000..c2e5d08 --- /dev/null +++ b/deploy/bsn-worker.service @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 766c376..61ff319 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/app.py b/src/app.py index ae0b8e7..8117931 100644 --- a/src/app.py +++ b/src/app.py @@ -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") @@ -34,4 +34,4 @@ def create_app() -> Flask: """Health-Check Endpoint für Monitoring.""" return {"status": "ok", "queue_size": app.queue_handler.queue_size()} - return app + return app \ No newline at end of file diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..39c7de1 --- /dev/null +++ b/src/config.py @@ -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() diff --git a/src/error_handler.py b/src/error_handler.py new file mode 100644 index 0000000..a3cfc08 --- /dev/null +++ b/src/error_handler.py @@ -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") diff --git a/src/queue/worker_entry.py b/src/queue/worker_entry.py new file mode 100644 index 0000000..541384e --- /dev/null +++ b/src/queue/worker_entry.py @@ -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) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d0400a3 --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_error_handler.py b/tests/test_error_handler.py new file mode 100644 index 0000000..5d0679f --- /dev/null +++ b/tests/test_error_handler.py @@ -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"