feat(settings): add SMTP admin configuration

This commit is contained in:
Schubert Ferenc 2026-07-04 23:54:20 +02:00
parent c58e212e3a
commit 884a20e043
32 changed files with 1200 additions and 42 deletions

View file

@ -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=

View file

@ -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

View file

@ -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")

View file

@ -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.",

View 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)

View file

@ -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():

View file

@ -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__)

View 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(),
)

View file

@ -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"),

View file

@ -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(

View file

@ -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

View file

@ -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

View 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("/")

View file

@ -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)

View file

@ -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(

View file

@ -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

View 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}