feat(settings): add SMTP admin configuration
This commit is contained in:
parent
c58e212e3a
commit
884a20e043
32 changed files with 1200 additions and 42 deletions
|
|
@ -19,7 +19,11 @@ STORAGE_MAX_UPLOAD_MB=50
|
|||
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||
KNOWLEDGE_MAX_UPLOAD_MB=50
|
||||
OLYMPUS_REPAIR_INTAKE_TOKEN=
|
||||
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
||||
PUBLIC_REPAIR_STATUS_BASE_URL=
|
||||
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
||||
# Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv,
|
||||
# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import app.models.knowledge
|
|||
import app.models.audit
|
||||
import app.models.rbac
|
||||
import app.models.repair
|
||||
import app.models.system_setting
|
||||
|
||||
config = context.config
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,69 @@
|
|||
"""create system settings
|
||||
|
||||
Revision ID: f2b8d4e6a910
|
||||
Revises: e9a7c3d5b812
|
||||
Create Date: 2026-07-04 14:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "f2b8d4e6a910"
|
||||
down_revision: Union[str, Sequence[str], None] = "e9a7c3d5b812"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"system_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("key", sa.String(length=160), nullable=False),
|
||||
sa.Column("value", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("is_secret", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("key"),
|
||||
)
|
||||
op.create_index(op.f("ix_system_settings_key"), "system_settings", ["key"], unique=True)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
VALUES (
|
||||
'system_settings.manage',
|
||||
'Systemeinstellungen verwalten',
|
||||
'SMTP und öffentliche Systemlinks verwalten',
|
||||
'system'
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles, permissions
|
||||
WHERE roles.name = 'administrator'
|
||||
AND permissions.name = 'system_settings.manage'
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE name = 'system_settings.manage'
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute("DELETE FROM permissions WHERE name = 'system_settings.manage'")
|
||||
op.drop_index(op.f("ix_system_settings_key"), table_name="system_settings")
|
||||
op.drop_table("system_settings")
|
||||
|
|
@ -7,11 +7,13 @@ from sqlalchemy.orm import Session
|
|||
from app.core.rbac import get_user_permission_names, require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.rbac import Role
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.user import User
|
||||
from app.repositories.customer_repository import CustomerRepository
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard
|
||||
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -32,6 +34,7 @@ def get_dashboard_summary(
|
|||
users: list[MetricCard] = []
|
||||
roles: list[MetricCard] = []
|
||||
repairs: list[MetricCard] = []
|
||||
system_status: list[SystemStatusItem] = []
|
||||
|
||||
if "customers.read" in permissions:
|
||||
customers = [
|
||||
|
|
@ -69,6 +72,43 @@ def get_dashboard_summary(
|
|||
MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)),
|
||||
]
|
||||
|
||||
if "system_settings.manage" in permissions:
|
||||
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||
public_links = SystemSettingsService.get_public_links_settings(db)
|
||||
latest_testmail = db.scalar(
|
||||
select(AuditLog)
|
||||
.where(AuditLog.action.in_(["system_settings.smtp.test_sent", "system_settings.smtp.test_failed"]))
|
||||
.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_failed_status_mail = RepairRepository.latest_failed_status_mail(db)
|
||||
system_status = [
|
||||
SystemStatusItem(
|
||||
label="SMTP-Konfiguration",
|
||||
value={
|
||||
"database": "Admin-Konfiguration",
|
||||
"environment": "Umgebung",
|
||||
"missing": "Nicht konfiguriert",
|
||||
}[smtp_config.source],
|
||||
status="ok" if smtp_config.is_configured else "warning",
|
||||
),
|
||||
SystemStatusItem(
|
||||
label="Public Status Base URL",
|
||||
value=public_links.repair_status_base_url or "Nicht konfiguriert",
|
||||
status="ok" if public_links.repair_status_base_url else "warning",
|
||||
),
|
||||
SystemStatusItem(
|
||||
label="Letzte SMTP-Testmail",
|
||||
value=latest_testmail.created_at.isoformat() if latest_testmail else "Noch keine Testmail",
|
||||
status="info" if latest_testmail else "warning",
|
||||
),
|
||||
SystemStatusItem(
|
||||
label="Letzte fehlgeschlagene Statusmail",
|
||||
value=latest_failed_status_mail.created_at.isoformat() if latest_failed_status_mail else "Keine Fehler",
|
||||
status="warning" if latest_failed_status_mail else "ok",
|
||||
),
|
||||
]
|
||||
|
||||
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
|
||||
|
||||
return DashboardSummary(
|
||||
|
|
@ -77,6 +117,7 @@ def get_dashboard_summary(
|
|||
users=users,
|
||||
roles=roles,
|
||||
repairs=repairs,
|
||||
system_status=system_status,
|
||||
activities=EmptyWidget(
|
||||
title="Letzte Aktivitäten",
|
||||
message="Noch keine Aktivitäten vorhanden.",
|
||||
|
|
|
|||
67
backend/hermes/app/api/system_settings.py
Normal file
67
backend/hermes/app/api/system_settings.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.rbac import require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.system_setting import (
|
||||
PublicLinksSettingsResponse,
|
||||
PublicLinksSettingsUpdate,
|
||||
SmtpSettingsResponse,
|
||||
SmtpSettingsUpdate,
|
||||
SmtpTestRequest,
|
||||
SmtpTestResponse,
|
||||
)
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/system-settings",
|
||||
tags=["System Settings"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/smtp", response_model=SmtpSettingsResponse)
|
||||
def get_smtp_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("system_settings.manage")),
|
||||
):
|
||||
return SystemSettingsService.get_smtp_settings(db)
|
||||
|
||||
|
||||
@router.put("/smtp", response_model=SmtpSettingsResponse)
|
||||
def update_smtp_settings(
|
||||
payload: SmtpSettingsUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("system_settings.manage")),
|
||||
):
|
||||
return SystemSettingsService.update_smtp_settings(db, payload, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/smtp/test", response_model=SmtpTestResponse)
|
||||
def send_smtp_test_mail(
|
||||
payload: SmtpTestRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("system_settings.manage")),
|
||||
):
|
||||
return SystemSettingsService.send_test_mail(db, payload.recipient, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/public-links", response_model=PublicLinksSettingsResponse)
|
||||
def get_public_links_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("system_settings.manage")),
|
||||
):
|
||||
return SystemSettingsService.get_public_links_settings(db)
|
||||
|
||||
|
||||
@router.put("/public-links", response_model=PublicLinksSettingsResponse)
|
||||
def update_public_links_settings(
|
||||
payload: PublicLinksSettingsUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("system_settings.manage")),
|
||||
):
|
||||
return SystemSettingsService.update_public_links_settings(db, payload, actor=current_user, request=request)
|
||||
|
|
@ -25,6 +25,8 @@ import app.models.customer
|
|||
import app.models.knowledge
|
||||
import app.models.audit
|
||||
import app.models.user
|
||||
import app.models.repair
|
||||
import app.models.system_setting
|
||||
|
||||
|
||||
def get_db():
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from app.api.knowledge import router as knowledge_router
|
|||
from app.api.permissions import router as permissions_router
|
||||
from app.api.repairs import router as repairs_router
|
||||
from app.api.roles import router as roles_router
|
||||
from app.api.system_settings import router as system_settings_router
|
||||
from app.api.users import router as users_router
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.health import check_database
|
||||
|
|
@ -42,6 +43,7 @@ app.include_router(customers_router)
|
|||
app.include_router(knowledge_router)
|
||||
app.include_router(repairs_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(system_settings_router)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
21
backend/hermes/app/models/system_setting.py
Normal file
21
backend/hermes/app/models/system_setting.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
is_secret: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
|
@ -69,6 +69,7 @@ STANDARD_PERMISSIONS = [
|
|||
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
|
||||
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
|
||||
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
|
||||
("system_settings.manage", "Systemeinstellungen verwalten", "SMTP und öffentliche Systemlinks verwalten", "system"),
|
||||
("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"),
|
||||
("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"),
|
||||
("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"),
|
||||
|
|
|
|||
|
|
@ -343,6 +343,17 @@ class RepairRepository:
|
|||
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
|
||||
) or 0
|
||||
|
||||
@staticmethod
|
||||
def latest_failed_status_mail(db: Session) -> RepairNotificationEvent | None:
|
||||
return db.scalar(
|
||||
select(RepairNotificationEvent)
|
||||
.where(RepairNotificationEvent.event_type == "repair_status_mail")
|
||||
.where(RepairNotificationEvent.success.is_(False))
|
||||
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
|
||||
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def count_open_repairs_without_customer_email(db: Session) -> int:
|
||||
return db.scalar(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.system_setting import SystemSetting
|
||||
|
||||
|
||||
class SystemSettingsRepository:
|
||||
@staticmethod
|
||||
def get(db: Session, key: str) -> SystemSetting | None:
|
||||
return db.scalar(select(SystemSetting).where(SystemSetting.key == key))
|
||||
|
||||
@staticmethod
|
||||
def get_many(db: Session, keys: Iterable[str]) -> dict[str, SystemSetting]:
|
||||
key_list = list(keys)
|
||||
if not key_list:
|
||||
return {}
|
||||
|
||||
settings = db.scalars(select(SystemSetting).where(SystemSetting.key.in_(key_list))).all()
|
||||
return {setting.key: setting for setting in settings}
|
||||
|
||||
@staticmethod
|
||||
def upsert(db: Session, *, key: str, value: str, is_secret: bool = False) -> SystemSetting:
|
||||
setting = SystemSettingsRepository.get(db, key)
|
||||
if setting is None:
|
||||
setting = SystemSetting(key=key, value=value, is_secret=is_secret)
|
||||
db.add(setting)
|
||||
db.flush()
|
||||
return setting
|
||||
|
||||
setting.value = value
|
||||
setting.is_secret = is_secret
|
||||
db.flush()
|
||||
return setting
|
||||
|
|
@ -13,12 +13,19 @@ class EmptyWidget(BaseModel):
|
|||
message: str
|
||||
|
||||
|
||||
class SystemStatusItem(BaseModel):
|
||||
label: str
|
||||
value: str
|
||||
status: str = "info"
|
||||
|
||||
|
||||
class DashboardSummary(BaseModel):
|
||||
customers: list[MetricCard] = []
|
||||
latest_customers: list[CustomerResponse] = []
|
||||
users: list[MetricCard] = []
|
||||
roles: list[MetricCard] = []
|
||||
repairs: list[MetricCard] = []
|
||||
system_status: list[SystemStatusItem] = []
|
||||
activities: EmptyWidget
|
||||
tasks: EmptyWidget
|
||||
tickets: EmptyWidget
|
||||
|
|
|
|||
70
backend/hermes/app/schemas/system_setting.py
Normal file
70
backend/hermes/app/schemas/system_setting.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
|
||||
|
||||
|
||||
SettingsSource = Literal["database", "environment", "missing"]
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
class SmtpSettingsResponse(BaseModel):
|
||||
host: str = ""
|
||||
port: int = 587
|
||||
username: str = ""
|
||||
password_is_set: bool = False
|
||||
from_email: str = ""
|
||||
from_name: str = "Funktechnik Schubert"
|
||||
use_tls: bool = True
|
||||
enabled: bool = False
|
||||
source: SettingsSource
|
||||
|
||||
|
||||
class SmtpSettingsUpdate(BaseModel):
|
||||
host: str = Field(default="", max_length=255)
|
||||
port: int = Field(default=587, ge=1, le=65535)
|
||||
username: str = Field(default="", max_length=255)
|
||||
password: str | None = Field(default=None, max_length=1000)
|
||||
from_email: str = Field(default="", max_length=255)
|
||||
from_name: str = Field(default="Funktechnik Schubert", max_length=255)
|
||||
use_tls: bool = True
|
||||
enabled: bool = False
|
||||
|
||||
@field_validator("host", "username", "password", "from_email", "from_name", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: object) -> str:
|
||||
return normalize_text(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_enabled_configuration(self):
|
||||
if self.enabled and (not self.host or not self.from_email):
|
||||
raise ValueError("SMTP Host und Absenderadresse sind erforderlich, wenn SMTP aktiviert ist")
|
||||
return self
|
||||
|
||||
|
||||
class SmtpTestRequest(BaseModel):
|
||||
recipient: EmailStr
|
||||
|
||||
|
||||
class SmtpTestResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
source: SettingsSource
|
||||
|
||||
|
||||
class PublicLinksSettingsResponse(BaseModel):
|
||||
repair_status_base_url: str = ""
|
||||
source: SettingsSource
|
||||
|
||||
|
||||
class PublicLinksSettingsUpdate(BaseModel):
|
||||
repair_status_base_url: str = Field(default="", max_length=500)
|
||||
|
||||
@field_validator("repair_status_base_url", mode="before")
|
||||
@classmethod
|
||||
def normalize_url(cls, value: object) -> str:
|
||||
return normalize_text(value).rstrip("/")
|
||||
|
|
@ -169,6 +169,10 @@ def action_title(action: str) -> str:
|
|||
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
|
||||
"repairs.status_mail.sent": "Statusmail versendet",
|
||||
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
|
||||
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
|
||||
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
|
||||
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
|
||||
"system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert",
|
||||
}
|
||||
return labels.get(action, action)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
from html import escape
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.repair import Repair, RepairNotificationEvent
|
||||
from app.models.user import User
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
|
|
@ -15,6 +12,7 @@ from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotific
|
|||
from app.services.audit_service import write_audit_log
|
||||
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||
from app.services.repair_status_labels import MAIL_STATUS_LABELS, STATUS_LABELS
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -98,16 +96,6 @@ def _repair_label(repair: Repair) -> str:
|
|||
return f"{repair.repair_number} · {repair.customer_name}"
|
||||
|
||||
|
||||
def _smtp_configured() -> bool:
|
||||
return bool(settings.smtp_host and settings.smtp_from_email)
|
||||
|
||||
|
||||
def _from_header() -> str:
|
||||
if settings.smtp_from_name:
|
||||
return f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
|
||||
return str(settings.smtp_from_email)
|
||||
|
||||
|
||||
def _plain_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str:
|
||||
return (
|
||||
f"Hallo {repair.customer_name},\n\n"
|
||||
|
|
@ -235,8 +223,9 @@ class RepairNotificationService:
|
|||
|
||||
text = _plain_status_mail(repair, status_label=status_label, public_status_url=public_status_url)
|
||||
html = _html_status_mail(repair, status_label=status_label, public_status_url=public_status_url)
|
||||
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||
|
||||
if not _smtp_configured():
|
||||
if not smtp_config.is_configured:
|
||||
event = RepairRepository.create_notification_event(
|
||||
db,
|
||||
repair_id=repair.id,
|
||||
|
|
@ -253,7 +242,7 @@ class RepairNotificationService:
|
|||
return event
|
||||
|
||||
try:
|
||||
RepairNotificationService._send_email(recipient=recipient, subject=subject, text=text, html=html)
|
||||
SystemSettingsService.send_email(smtp_config, recipient=recipient, subject=subject, text=text, html=html)
|
||||
except Exception:
|
||||
event = RepairRepository.create_notification_event(
|
||||
db,
|
||||
|
|
@ -294,22 +283,6 @@ class RepairNotificationService:
|
|||
)
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def _send_email(*, recipient: str, subject: str, text: str, html: str) -> None:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = _from_header()
|
||||
message["To"] = recipient
|
||||
message.set_content(text)
|
||||
message.add_alternative(html, subtype="html")
|
||||
|
||||
with smtplib.SMTP(str(settings.smtp_host), settings.smtp_port, timeout=15) as smtp:
|
||||
if settings.smtp_use_tls:
|
||||
smtp.starttls()
|
||||
if settings.smtp_username and settings.smtp_password:
|
||||
smtp.login(settings.smtp_username, settings.smtp_password)
|
||||
smtp.send_message(message)
|
||||
|
||||
@staticmethod
|
||||
def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None:
|
||||
write_audit_log(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from app.schemas.repair import (
|
|||
)
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.services.repair_status_labels import STATUS_LABELS
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
|
||||
|
||||
def normalize_token(token: str) -> str:
|
||||
|
|
@ -29,10 +30,10 @@ def repair_label(repair: Repair) -> str:
|
|||
return f"{repair.repair_number} · {repair.customer_name}"
|
||||
|
||||
|
||||
def public_status_path(token: str) -> str:
|
||||
def public_status_path(db: Session, token: str) -> str:
|
||||
normalized_token = normalize_token(token)
|
||||
path = f"/status/{normalized_token}"
|
||||
base_url = (settings.public_repair_status_base_url or "").strip().rstrip("/")
|
||||
base_url = SystemSettingsService.get_public_repair_status_base_url(db)
|
||||
if not base_url:
|
||||
return path
|
||||
return f"{base_url}/{normalized_token}"
|
||||
|
|
@ -109,7 +110,7 @@ class RepairPublicLinkService:
|
|||
last_used_at=public_link.last_used_at,
|
||||
created_at=public_link.created_at,
|
||||
revoked_at=public_link.revoked_at,
|
||||
public_status_path=public_status_path(token),
|
||||
public_status_path=public_status_path(db, token),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
316
backend/hermes/app/services/system_settings_service.py
Normal file
316
backend/hermes/app/services/system_settings_service.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
|
||||
from pydantic import EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.repositories.system_settings_repository import SystemSettingsRepository
|
||||
from app.schemas.system_setting import (
|
||||
PublicLinksSettingsResponse,
|
||||
PublicLinksSettingsUpdate,
|
||||
SmtpSettingsResponse,
|
||||
SmtpSettingsUpdate,
|
||||
SmtpTestResponse,
|
||||
SettingsSource,
|
||||
)
|
||||
from app.services.audit_service import write_audit_log
|
||||
|
||||
|
||||
SMTP_KEYS = (
|
||||
"smtp.host",
|
||||
"smtp.port",
|
||||
"smtp.username",
|
||||
"smtp.password",
|
||||
"smtp.from_email",
|
||||
"smtp.from_name",
|
||||
"smtp.use_tls",
|
||||
"smtp.enabled",
|
||||
)
|
||||
|
||||
SECRET_KEYS = {"smtp.password"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmtpRuntimeConfig:
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
password: str
|
||||
from_email: str
|
||||
from_name: str
|
||||
use_tls: bool
|
||||
enabled: bool
|
||||
source: SettingsSource
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self.enabled and bool(self.host and self.from_email)
|
||||
|
||||
@property
|
||||
def password_is_set(self) -> bool:
|
||||
return bool(self.password)
|
||||
|
||||
@property
|
||||
def from_header(self) -> str:
|
||||
if self.from_name:
|
||||
return f"{self.from_name} <{self.from_email}>"
|
||||
return self.from_email
|
||||
|
||||
|
||||
def parse_bool(value: str | None, *, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def parse_int(value: str | None, *, default: int) -> int:
|
||||
try:
|
||||
parsed = int(str(value or "").strip())
|
||||
except ValueError:
|
||||
return default
|
||||
if parsed < 1 or parsed > 65535:
|
||||
return default
|
||||
return parsed
|
||||
|
||||
|
||||
class SystemSettingsService:
|
||||
@staticmethod
|
||||
def get_smtp_settings(db: Session) -> SmtpSettingsResponse:
|
||||
return SystemSettingsService.smtp_response(SystemSettingsService.get_smtp_runtime_config(db))
|
||||
|
||||
@staticmethod
|
||||
def get_smtp_runtime_config(db: Session) -> SmtpRuntimeConfig:
|
||||
values = SystemSettingsService._get_values(db, SMTP_KEYS)
|
||||
database_enabled = parse_bool(values.get("smtp.enabled"), default=False)
|
||||
database_host = values.get("smtp.host", "")
|
||||
database_from_email = values.get("smtp.from_email", "")
|
||||
|
||||
if database_enabled and database_host and database_from_email:
|
||||
return SmtpRuntimeConfig(
|
||||
host=database_host,
|
||||
port=parse_int(values.get("smtp.port"), default=587),
|
||||
username=values.get("smtp.username", ""),
|
||||
password=values.get("smtp.password", ""),
|
||||
from_email=database_from_email,
|
||||
from_name=values.get("smtp.from_name", "Funktechnik Schubert") or "Funktechnik Schubert",
|
||||
use_tls=parse_bool(values.get("smtp.use_tls"), default=True),
|
||||
enabled=True,
|
||||
source="database",
|
||||
)
|
||||
|
||||
if settings.smtp_host and settings.smtp_from_email:
|
||||
return SmtpRuntimeConfig(
|
||||
host=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_username or "",
|
||||
password=settings.smtp_password or "",
|
||||
from_email=settings.smtp_from_email,
|
||||
from_name=settings.smtp_from_name,
|
||||
use_tls=settings.smtp_use_tls,
|
||||
enabled=True,
|
||||
source="environment",
|
||||
)
|
||||
|
||||
return SmtpRuntimeConfig(
|
||||
host=database_host or settings.smtp_host or "",
|
||||
port=parse_int(values.get("smtp.port"), default=settings.smtp_port),
|
||||
username=values.get("smtp.username", "") or settings.smtp_username or "",
|
||||
password=values.get("smtp.password", "") or settings.smtp_password or "",
|
||||
from_email=database_from_email or settings.smtp_from_email or "",
|
||||
from_name=values.get("smtp.from_name", "") or settings.smtp_from_name,
|
||||
use_tls=parse_bool(values.get("smtp.use_tls"), default=settings.smtp_use_tls),
|
||||
enabled=False,
|
||||
source="missing",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_smtp_settings(
|
||||
db: Session,
|
||||
payload: SmtpSettingsUpdate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> SmtpSettingsResponse:
|
||||
current_values = SystemSettingsService._get_values(db, SMTP_KEYS)
|
||||
password = payload.password if payload.password else current_values.get("smtp.password", "")
|
||||
|
||||
updates = {
|
||||
"smtp.host": payload.host,
|
||||
"smtp.port": str(payload.port),
|
||||
"smtp.username": payload.username,
|
||||
"smtp.password": password,
|
||||
"smtp.from_email": payload.from_email,
|
||||
"smtp.from_name": payload.from_name or "Funktechnik Schubert",
|
||||
"smtp.use_tls": "true" if payload.use_tls else "false",
|
||||
"smtp.enabled": "true" if payload.enabled else "false",
|
||||
}
|
||||
|
||||
for key, value in updates.items():
|
||||
SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in SECRET_KEYS)
|
||||
db.commit()
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="system_settings.smtp.update",
|
||||
entity_type="system_settings",
|
||||
entity_label="SMTP-Konfiguration",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"enabled": payload.enabled,
|
||||
"host": payload.host,
|
||||
"from_email": payload.from_email,
|
||||
"password_changed": bool(payload.password),
|
||||
},
|
||||
)
|
||||
return SystemSettingsService.get_smtp_settings(db)
|
||||
|
||||
@staticmethod
|
||||
def get_public_links_settings(db: Session) -> PublicLinksSettingsResponse:
|
||||
value = SystemSettingsService.get_public_repair_status_base_url(db)
|
||||
source: SettingsSource = "missing"
|
||||
if value:
|
||||
db_value = SystemSettingsRepository.get(db, "public.repair_status_base_url")
|
||||
source = "database" if db_value and db_value.value.strip() else "environment"
|
||||
return PublicLinksSettingsResponse(repair_status_base_url=value, source=source)
|
||||
|
||||
@staticmethod
|
||||
def get_public_repair_status_base_url(db: Session) -> str:
|
||||
setting = SystemSettingsRepository.get(db, "public.repair_status_base_url")
|
||||
if setting and setting.value.strip():
|
||||
return setting.value.strip().rstrip("/")
|
||||
return (settings.public_repair_status_base_url or "").strip().rstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def update_public_links_settings(
|
||||
db: Session,
|
||||
payload: PublicLinksSettingsUpdate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> PublicLinksSettingsResponse:
|
||||
SystemSettingsRepository.upsert(
|
||||
db,
|
||||
key="public.repair_status_base_url",
|
||||
value=payload.repair_status_base_url,
|
||||
is_secret=False,
|
||||
)
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="system_settings.public_links.update",
|
||||
entity_type="system_settings",
|
||||
entity_label="Öffentliche Links",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"repair_status_base_url": payload.repair_status_base_url},
|
||||
)
|
||||
return SystemSettingsService.get_public_links_settings(db)
|
||||
|
||||
@staticmethod
|
||||
def send_test_mail(
|
||||
db: Session,
|
||||
recipient: EmailStr,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> SmtpTestResponse:
|
||||
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||
if not smtp_config.is_configured:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="system_settings.smtp.test_failed",
|
||||
entity_type="system_settings",
|
||||
entity_label="SMTP-Testmail",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": "smtp_not_configured", "source": smtp_config.source},
|
||||
)
|
||||
return SmtpTestResponse(success=False, message="SMTP ist nicht vollständig konfiguriert.", source=smtp_config.source)
|
||||
|
||||
try:
|
||||
SystemSettingsService.send_email(
|
||||
smtp_config,
|
||||
recipient=str(recipient),
|
||||
subject="Olympus CRM SMTP-Test",
|
||||
text=(
|
||||
"Hallo,\n\n"
|
||||
"dies ist eine Testmail aus Olympus CRM. "
|
||||
"Die SMTP-Konfiguration ist grundsätzlich erreichbar.\n\n"
|
||||
"Olympus CRM"
|
||||
),
|
||||
html=(
|
||||
"<p>Hallo,</p>"
|
||||
"<p>dies ist eine Testmail aus Olympus CRM. "
|
||||
"Die SMTP-Konfiguration ist grundsätzlich erreichbar.</p>"
|
||||
"<p>Olympus CRM</p>"
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="system_settings.smtp.test_failed",
|
||||
entity_type="system_settings",
|
||||
entity_label="SMTP-Testmail",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"reason": "smtp_send_failed", "source": smtp_config.source},
|
||||
)
|
||||
return SmtpTestResponse(success=False, message="Testmail konnte nicht versendet werden.", source=smtp_config.source)
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="system_settings.smtp.test_sent",
|
||||
entity_type="system_settings",
|
||||
entity_label="SMTP-Testmail",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"source": smtp_config.source},
|
||||
)
|
||||
return SmtpTestResponse(success=True, message="Testmail wurde versendet.", source=smtp_config.source)
|
||||
|
||||
@staticmethod
|
||||
def send_email(
|
||||
smtp_config: SmtpRuntimeConfig,
|
||||
*,
|
||||
recipient: str,
|
||||
subject: str,
|
||||
text: str,
|
||||
html: str,
|
||||
) -> None:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = smtp_config.from_header
|
||||
message["To"] = recipient
|
||||
message.set_content(text)
|
||||
message.add_alternative(html, subtype="html")
|
||||
|
||||
with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=15) as smtp:
|
||||
if smtp_config.use_tls:
|
||||
smtp.starttls()
|
||||
if smtp_config.username and smtp_config.password:
|
||||
smtp.login(smtp_config.username, smtp_config.password)
|
||||
smtp.send_message(message)
|
||||
|
||||
@staticmethod
|
||||
def smtp_response(smtp_config: SmtpRuntimeConfig) -> SmtpSettingsResponse:
|
||||
return SmtpSettingsResponse(
|
||||
host=smtp_config.host,
|
||||
port=smtp_config.port,
|
||||
username=smtp_config.username,
|
||||
password_is_set=smtp_config.password_is_set,
|
||||
from_email=smtp_config.from_email,
|
||||
from_name=smtp_config.from_name,
|
||||
use_tls=smtp_config.use_tls,
|
||||
enabled=smtp_config.enabled,
|
||||
source=smtp_config.source,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_values(db: Session, keys: tuple[str, ...]) -> dict[str, str]:
|
||||
settings_by_key = SystemSettingsRepository.get_many(db, keys)
|
||||
return {key: settings_by_key[key].value.strip() for key in keys if key in settings_by_key}
|
||||
Loading…
Add table
Add a link
Reference in a new issue