from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.auth.jwt_auth import require_admin from app.database import get_db from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message from app.services.notification_policy import ( NotificationPolicyConfig, get_effective_notification_policy, upsert_notification_policy, ) from app.services.notification_settings import ( VALID_SEVERITIES, EmailConfig, TelegramConfig, WebhookConfig, get_effective_email_config, get_effective_telegram_config, get_effective_webhook_config, upsert_email_channel, upsert_telegram_channel, upsert_webhook_channel, ) from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message from app.services.webhook_notify import ( WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message, ) from app.services.event_severity_overrides import ( SOURCE_DB, list_severity_override_items, replace_severity_overrides, ) from app.services.ui_settings import ( get_effective_ui_settings, upsert_ui_settings, ) from app.services.linux_admin_settings import ( get_effective_linux_admin_config, upsert_linux_admin_settings, ) from app.services.win_admin_settings import ( get_effective_win_admin_config, upsert_win_admin_settings, ) from app.services.agent_update_settings import ( get_effective_agent_update_config, upsert_agent_update_settings, ) from app.services.winrm_connect import ( HostNotWindowsError, HostTargetMissingError, WinAdminNotConfiguredError, resolve_windows_host_target, test_winrm_connection, ) router = APIRouter(prefix="/settings", tags=["settings"]) def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None: text = value.strip() if not text: return None if len(text) <= visible_tail: return "*" * len(text) return f"{'*' * 8}…{text[-visible_tail:]}" def _mask_url(value: str) -> str | None: text = value.strip() if not text: return None if len(text) <= 20: return f"{'*' * 6}…{text[-4:]}" return f"{text[:16]}…{text[-6:]}" class TelegramSettingsResponse(BaseModel): enabled: bool configured: bool chat_id_hint: str | None = None bot_token_hint: str | None = None min_severity: str source: str = Field(description="env или db") class WebhookSettingsResponse(BaseModel): enabled: bool configured: bool url_hint: str | None = None secret_header: str | None = None secret_hint: str | None = None min_severity: str source: str = Field(description="env или db") class EmailSettingsResponse(BaseModel): enabled: bool configured: bool smtp_host_hint: str | None = None smtp_port: int smtp_user: str | None = None smtp_password_hint: str | None = None mail_from: str | None = None mail_to_hint: str | None = None smtp_starttls: bool smtp_ssl: bool min_severity: str source: str = Field(description="env или db") class NotificationPolicyResponse(BaseModel): min_severity: str use_telegram: bool use_webhook: bool use_email: bool use_mobile: bool source: str = Field(description="env или db") class NotificationSettingsResponse(BaseModel): policy: NotificationPolicyResponse telegram: TelegramSettingsResponse webhook: WebhookSettingsResponse email: EmailSettingsResponse class NotificationPolicyUpdate(BaseModel): min_severity: str use_telegram: bool use_webhook: bool use_email: bool use_mobile: bool = False class TelegramSettingsUpdate(BaseModel): enabled: bool bot_token: str | None = None chat_id: str | None = None class WebhookSettingsUpdate(BaseModel): enabled: bool url: str | None = None secret_header: str | None = None secret: str | None = None class EmailSettingsUpdate(BaseModel): enabled: bool smtp_host: str | None = None smtp_port: int | None = None smtp_user: str | None = None smtp_password: str | None = None mail_from: str | None = None mail_to: str | None = None smtp_starttls: bool | None = None smtp_ssl: bool | None = None class ChannelTestResponse(BaseModel): ok: bool = True message: str def _policy_to_response(cfg: NotificationPolicyConfig) -> NotificationPolicyResponse: return NotificationPolicyResponse( min_severity=cfg.min_severity, use_telegram=cfg.use_telegram, use_webhook=cfg.use_webhook, use_email=cfg.use_email, use_mobile=cfg.use_mobile, source=cfg.source, ) def _telegram_to_response(cfg: TelegramConfig, policy: NotificationPolicyConfig) -> TelegramSettingsResponse: return TelegramSettingsResponse( enabled=cfg.enabled, configured=cfg.configured, chat_id_hint=_mask_secret(cfg.chat_id), bot_token_hint=_mask_secret(cfg.bot_token), min_severity=policy.min_severity, source=cfg.source, ) def _webhook_to_response(cfg: WebhookConfig, policy: NotificationPolicyConfig) -> WebhookSettingsResponse: return WebhookSettingsResponse( enabled=cfg.enabled, configured=cfg.configured, url_hint=_mask_url(cfg.url), secret_header=cfg.secret_header or None, secret_hint=_mask_secret(cfg.secret), min_severity=policy.min_severity, source=cfg.source, ) def _email_to_response(cfg: EmailConfig, policy: NotificationPolicyConfig) -> EmailSettingsResponse: return EmailSettingsResponse( enabled=cfg.enabled, configured=cfg.configured, smtp_host_hint=_mask_url(cfg.smtp_host) if cfg.smtp_host else None, smtp_port=cfg.smtp_port, smtp_user=cfg.smtp_user or None, smtp_password_hint=_mask_secret(cfg.smtp_password), mail_from=cfg.mail_from or None, mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None, smtp_starttls=cfg.smtp_starttls, smtp_ssl=cfg.smtp_ssl, min_severity=policy.min_severity, source=cfg.source, ) @router.get("/notifications", response_model=NotificationSettingsResponse) def get_notification_settings( db: Session = Depends(get_db), _user=Depends(require_admin), ) -> NotificationSettingsResponse: policy = get_effective_notification_policy(db) return NotificationSettingsResponse( policy=_policy_to_response(policy), telegram=_telegram_to_response(get_effective_telegram_config(db), policy), webhook=_webhook_to_response(get_effective_webhook_config(db), policy), email=_email_to_response(get_effective_email_config(db), policy), ) @router.put("/notifications/policy", response_model=NotificationPolicyResponse) def update_notification_policy( body: NotificationPolicyUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> NotificationPolicyResponse: if body.min_severity not in VALID_SEVERITIES: raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") if not any((body.use_telegram, body.use_webhook, body.use_email, body.use_mobile)): raise HTTPException(status_code=422, detail="Выберите хотя бы один канал") try: policy = upsert_notification_policy( db, min_severity=body.min_severity, use_telegram=body.use_telegram, use_webhook=body.use_webhook, use_email=body.use_email, use_mobile=body.use_mobile, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc return _policy_to_response(policy) @router.put("/notifications/telegram", response_model=TelegramSettingsResponse) def update_telegram_settings( body: TelegramSettingsUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> TelegramSettingsResponse: try: cfg = upsert_telegram_channel( db, enabled=body.enabled, bot_token=body.bot_token, chat_id=body.chat_id, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc policy = get_effective_notification_policy(db) return _telegram_to_response(cfg, policy) @router.put("/notifications/webhook", response_model=WebhookSettingsResponse) def update_webhook_settings( body: WebhookSettingsUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> WebhookSettingsResponse: try: cfg = upsert_webhook_channel( db, enabled=body.enabled, url=body.url, secret_header=body.secret_header, secret=body.secret, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc policy = get_effective_notification_policy(db) return _webhook_to_response(cfg, policy) @router.put("/notifications/email", response_model=EmailSettingsResponse) def update_email_settings( body: EmailSettingsUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> EmailSettingsResponse: try: cfg = upsert_email_channel( db, enabled=body.enabled, smtp_host=body.smtp_host, smtp_port=body.smtp_port, smtp_user=body.smtp_user, smtp_password=body.smtp_password, mail_from=body.mail_from, mail_to=body.mail_to, smtp_starttls=body.smtp_starttls, smtp_ssl=body.smtp_ssl, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc policy = get_effective_notification_policy(db) return _email_to_response(cfg, policy) @router.post("/notifications/telegram/test", response_model=ChannelTestResponse) def test_telegram_settings( db: Session = Depends(get_db), _user=Depends(require_admin), ) -> ChannelTestResponse: cfg = get_effective_telegram_config(db) try: send_telegram_test_message(config=cfg) except TelegramNotConfiguredError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except TelegramSendError as exc: code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502 raise HTTPException(status_code=code, detail=str(exc)) from exc return ChannelTestResponse(message="Тестовое сообщение отправлено в Telegram") @router.post("/notifications/webhook/test", response_model=ChannelTestResponse) def test_webhook_settings( db: Session = Depends(get_db), _user=Depends(require_admin), ) -> ChannelTestResponse: cfg = get_effective_webhook_config(db) try: send_webhook_test_message(config=cfg) except WebhookNotConfiguredError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except WebhookSendError as exc: code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502 raise HTTPException(status_code=code, detail=str(exc)) from exc return ChannelTestResponse(message="Тестовый POST отправлен на webhook URL") @router.post("/notifications/email/test", response_model=ChannelTestResponse) def test_email_settings( db: Session = Depends(get_db), _user=Depends(require_admin), ) -> ChannelTestResponse: cfg = get_effective_email_config(db) try: send_email_test_message(config=cfg) except EmailNotConfiguredError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except EmailSendError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP") class EventSeverityOverrideRow(BaseModel): event_type: str default_severity: str override_severity: str | None = None class EventSeverityOverridesResponse(BaseModel): items: list[EventSeverityOverrideRow] source: str = Field(description="db — overrides хранятся в PostgreSQL") class EventSeverityOverridesUpdate(BaseModel): overrides: dict[str, str | None] = Field( description="event_type → severity; null или пустая строка сбрасывает override", ) @router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse) def get_severity_overrides( db: Session = Depends(get_db), _user=Depends(require_admin), ) -> EventSeverityOverridesResponse: items = list_severity_override_items(db) return EventSeverityOverridesResponse( items=[ EventSeverityOverrideRow( event_type=i.event_type, default_severity=i.default_severity, override_severity=i.override_severity, ) for i in items ], source=SOURCE_DB, ) @router.put("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse) def update_severity_overrides( body: EventSeverityOverridesUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> EventSeverityOverridesResponse: try: items = replace_severity_overrides(db, body.overrides) db.commit() except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc return EventSeverityOverridesResponse( items=[ EventSeverityOverrideRow( event_type=i.event_type, default_severity=i.default_severity, override_severity=i.override_severity, ) for i in items ], source=SOURCE_DB, ) class UiSettingsResponse(BaseModel): show_sidebar_system_stats: bool source: str = Field(description="db или default") class UiSettingsUpdate(BaseModel): show_sidebar_system_stats: bool @router.get("/ui", response_model=UiSettingsResponse) def get_ui_settings( db: Session = Depends(get_db), _user=Depends(require_admin), ) -> UiSettingsResponse: cfg = get_effective_ui_settings(db) return UiSettingsResponse( show_sidebar_system_stats=cfg.show_sidebar_system_stats, source=cfg.source, ) @router.put("/ui", response_model=UiSettingsResponse) def update_ui_settings( body: UiSettingsUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> UiSettingsResponse: cfg = upsert_ui_settings(db, show_sidebar_system_stats=body.show_sidebar_system_stats) return UiSettingsResponse( show_sidebar_system_stats=cfg.show_sidebar_system_stats, source=cfg.source, ) class WinAdminSettingsResponse(BaseModel): configured: bool user: str | None = None password_hint: str | None = None source: str = Field(description="env или db") class WinAdminSettingsUpdate(BaseModel): user: str | None = None password: str | None = None @router.get("/win-admin", response_model=WinAdminSettingsResponse) def get_win_admin_settings( db: Session = Depends(get_db), _user=Depends(require_admin), ) -> WinAdminSettingsResponse: cfg = get_effective_win_admin_config(db) return WinAdminSettingsResponse( configured=cfg.configured, user=cfg.user or None, password_hint=_mask_secret(cfg.password), source=cfg.source, ) @router.put("/win-admin", response_model=WinAdminSettingsResponse) def update_win_admin_settings( body: WinAdminSettingsUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> WinAdminSettingsResponse: if body.user is not None and not body.user.strip(): raise HTTPException(status_code=422, detail="user must not be empty") cfg = upsert_win_admin_settings(db, user=body.user, password=body.password) return WinAdminSettingsResponse( configured=cfg.configured, user=cfg.user or None, password_hint=_mask_secret(cfg.password), source=cfg.source, ) class LinuxAdminSettingsResponse(BaseModel): configured: bool user: str | None = None password_hint: str | None = None source: str = Field(description="env или db") class LinuxAdminSettingsUpdate(BaseModel): user: str | None = None password: str | None = None @router.get("/linux-admin", response_model=LinuxAdminSettingsResponse) def get_linux_admin_settings( db: Session = Depends(get_db), _user=Depends(require_admin), ) -> LinuxAdminSettingsResponse: cfg = get_effective_linux_admin_config(db) return LinuxAdminSettingsResponse( configured=cfg.configured, user=cfg.user or None, password_hint=_mask_secret(cfg.password), source=cfg.source, ) @router.put("/linux-admin", response_model=LinuxAdminSettingsResponse) def update_linux_admin_settings( body: LinuxAdminSettingsUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> LinuxAdminSettingsResponse: if body.user is not None and not body.user.strip(): raise HTTPException(status_code=422, detail="user must not be empty") cfg = upsert_linux_admin_settings(db, user=body.user, password=body.password) return LinuxAdminSettingsResponse( configured=cfg.configured, user=cfg.user or None, password_hint=_mask_secret(cfg.password), source=cfg.source, ) class AgentUpdateSettingsResponse(BaseModel): mode: str fallback_enabled: bool fallback_after_minutes: int recommended_rdp_version: str | None = None recommended_ssh_version: str | None = None min_rdp_version: str | None = None min_ssh_version: str | None = None win_agent_update_script: str | None = None source: str = Field(description="env или db") class AgentUpdateSettingsUpdate(BaseModel): mode: str | None = None fallback_enabled: bool | None = None fallback_after_minutes: int | None = None recommended_rdp_version: str | None = None recommended_ssh_version: str | None = None min_rdp_version: str | None = None min_ssh_version: str | None = None win_agent_update_script: str | None = None def _agent_update_to_response(cfg) -> AgentUpdateSettingsResponse: return AgentUpdateSettingsResponse( mode=cfg.mode, fallback_enabled=cfg.fallback_enabled, fallback_after_minutes=cfg.fallback_after_minutes, recommended_rdp_version=cfg.recommended_rdp_version or None, recommended_ssh_version=cfg.recommended_ssh_version or None, min_rdp_version=cfg.min_rdp_version or None, min_ssh_version=cfg.min_ssh_version or None, win_agent_update_script=cfg.win_agent_update_script or None, source=cfg.source, ) @router.get("/agent-updates", response_model=AgentUpdateSettingsResponse) def get_agent_update_settings( db: Session = Depends(get_db), _user=Depends(require_admin), ) -> AgentUpdateSettingsResponse: return _agent_update_to_response(get_effective_agent_update_config(db)) @router.put("/agent-updates", response_model=AgentUpdateSettingsResponse) def update_agent_update_settings( body: AgentUpdateSettingsUpdate, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> AgentUpdateSettingsResponse: try: cfg = upsert_agent_update_settings( db, mode=body.mode, fallback_enabled=body.fallback_enabled, fallback_after_minutes=body.fallback_after_minutes, recommended_rdp_version=body.recommended_rdp_version, recommended_ssh_version=body.recommended_ssh_version, min_rdp_version=body.min_rdp_version, min_ssh_version=body.min_ssh_version, win_agent_update_script=body.win_agent_update_script, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc return _agent_update_to_response(cfg)