Newer
Older
gn-synapse / app / api / admin_routes.py
"""Admin API: CRUD конфигурации + просмотр потока. Всё под require_admin.

Это бэкенд SPA админки (#32+): списки и создание/удаление — то, без чего
CLI не нужен в проде. Полное редактирование правил (PATCH) — в этой же
схеме: только enabled/name/template/throttle/weight/conditions/actions.
"""

import uuid
from datetime import UTC, datetime

from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import ValidationError
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session

from app.auth.apikeys import generate_token, token_hash_of
from app.auth.deps import AuthenticatedUser, require_admin
from app.config import get_settings
from app.database import get_db
from app.models import (
    ApiKey,
    ChannelTarget,
    Delivery,
    Event,
    NotificationType,
    RoutingRule,
    RoutingRuleAction,
    Source,
)
from app.api.admin_schemas import (
    AdminEventOut,
    DeliveryOut,
    KeyCreated,
    KeyIn,
    KeyOut,
    RuleActionIn,
    RuleActionOut,
    RuleIn,
    RuleOut,
    RulePatch,
    SettingOut,
    SettingsPut,
    SourceIn,
    SourceOut,
    TargetIn,
    TargetOut,
    TargetPatch,
    TypeIn,
    TypeOut,
)
from app.settings_registry import EDITABLE, is_secret, validate as validate_setting
from app.settings_store import default_of, get_setting_row, set_setting

router = APIRouter(prefix="/api/v1/admin", tags=["admin"])


def _conflict(detail: str) -> HTTPException:
    return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)


# --- sources ---

@router.get("/sources", response_model=list[SourceOut])
def list_sources(
    include_archived: bool = False,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> list[SourceOut]:
    q = select(Source).order_by(Source.name)
    if not include_archived:
        q = q.where(Source.deleted_at.is_(None))
    sources = db.execute(q).scalars().all()
    key_counts = dict(
        db.execute(select(ApiKey.source_id, func.count(ApiKey.id)).group_by(ApiKey.source_id)).all()
    )
    type_counts = dict(
        db.execute(
            select(NotificationType.source_id, func.count(NotificationType.id))
            .where(NotificationType.deleted_at.is_(None))
            .group_by(NotificationType.source_id)
        ).all()
    )
    return [
        SourceOut(
            id=s.id, name=s.name, label=s.label, description=s.description,
            created_at=s.created_at, deleted_at=s.deleted_at,
            keys_count=key_counts.get(s.id, 0),
            types_count=type_counts.get(s.id, 0),
        )
        for s in sources
    ]


@router.post("/sources", response_model=SourceOut, status_code=201)
def create_source(
    payload: SourceIn,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> SourceOut:
    dup = db.execute(select(Source).where(Source.name == payload.name)).scalar_one_or_none()
    if dup is not None:
        if dup.deleted_at is not None:
            raise _conflict(
                f"source '{payload.name}' есть в архиве (id={dup.id}) — "
                "восстанови (restore) или выбери другое имя"
            )
        raise _conflict(f"source '{payload.name}' уже существует")
    source = Source(**payload.model_dump())
    db.add(source)
    db.commit()
    db.refresh(source)
    return _source_out_full(db, source)


@router.post("/sources/{source_id}/restore", response_model=SourceOut)
def restore_source(
    source_id: int,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> SourceOut:
    source = db.get(Source, source_id)
    if source is None or source.deleted_at is None:
        raise HTTPException(status_code=404, detail="В архиве нет источника с таким id")
    _raise_restore_conflict(
        db,
        f"source '{source.name}'",
        select(Source.id).where(
            Source.name == source.name,
            Source.deleted_at.is_(None),
            Source.id != source.id,
        ),
    )
    source.deleted_at = None
    db.commit()
    db.refresh(source)
    return _source_out_full(db, source)


def _raise_restore_conflict(db: Session, what: str, live_query) -> None:
    """Имя восстанавливаемой записи занято живой записью → 409 с подсказкой."""
    live_id = db.execute(live_query).scalar_one_or_none()
    if live_id is not None:
        raise _conflict(f"{what}: имя занято живой записью id={live_id} — restore невозможен")


def _source_out_full(db: Session, source: Source) -> SourceOut:
    """SourceOut одной строки (create/restore): счётчики врозь, не группой."""
    keys_count = db.execute(
        select(func.count(ApiKey.id)).where(ApiKey.source_id == source.id)
    ).scalar_one()
    types_count = db.execute(
        select(func.count(NotificationType.id)).where(NotificationType.source_id == source.id)
    ).scalar_one()
    return SourceOut(
        id=source.id, name=source.name, label=source.label, description=source.description,
        created_at=source.created_at, deleted_at=source.deleted_at,
        keys_count=keys_count, types_count=types_count,
    )


@router.delete("/sources/{source_id}", status_code=204)
def delete_source(
    source_id: int,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> None:
    """Архив, не удаление: события и ключи остаются в БД, restore снимает метку."""
    source = db.get(Source, source_id)
    if source is None or source.deleted_at is not None:
        raise HTTPException(status_code=404, detail="Источник не найден")
    source.deleted_at = datetime.now(UTC)
    db.commit()


# --- keys ---

@router.get("/sources/{source_id}/keys", response_model=list[KeyOut])
def list_keys(
    source_id: int,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> list[KeyOut]:
    if db.get(Source, source_id) is None:
        raise HTTPException(status_code=404, detail="Источник не найден")
    keys = db.execute(
        select(ApiKey).where(ApiKey.source_id == source_id).order_by(ApiKey.created_at)
    ).scalars().all()
    return [
        KeyOut(
            id=k.id, name=k.name, token_hint=k.token_hint, created_at=k.created_at,
            last_used_at=k.last_used_at, revoked_at=k.revoked_at,
        )
        for k in keys
    ]


@router.post("/sources/{source_id}/keys", response_model=KeyCreated, status_code=201)
def create_key(
    source_id: int,
    payload: KeyIn,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> KeyCreated:
    source = db.get(Source, source_id)
    if source is None:
        raise HTTPException(status_code=404, detail="Источник не найден")
    if source.deleted_at is not None:
        raise HTTPException(status_code=404, detail="Источник в архиве — сначала restore")
    token = generate_token()
    key = ApiKey(
        source_id=source_id,
        name=payload.name,
        token_hash=token_hash_of(token),
        token_hint=token[-4:],
    )
    db.add(key)
    db.commit()
    db.refresh(key)
    return KeyCreated(
        id=key.id, name=key.name, token_hint=key.token_hint, created_at=key.created_at,
        last_used_at=None, revoked_at=None, token=token,
    )


@router.post("/sources/{source_id}/keys/{key_id}/revoke", response_model=KeyOut)
def revoke_key(
    source_id: int,
    key_id: int,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> KeyOut:
    key = db.get(ApiKey, key_id)
    if key is None or key.source_id != source_id:
        raise HTTPException(status_code=404, detail="Ключ не найден")
    if key.revoked_at is not None:
        raise _conflict("Ключ уже отозван")
    key.revoked_at = datetime.now(UTC)
    db.commit()
    db.refresh(key)
    return KeyOut(
        id=key.id, name=key.name, token_hint=key.token_hint, created_at=key.created_at,
        last_used_at=key.last_used_at, revoked_at=key.revoked_at,
    )


# --- types ---

@router.get("/types", response_model=list[TypeOut])
def list_types(
    include_archived: bool = False,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> list[TypeOut]:
    q = select(NotificationType, Source.name).join(
        Source, NotificationType.source_id == Source.id
    ).order_by(Source.name, NotificationType.subject, NotificationType.action)
    if not include_archived:
        # архив скрыт двойным фильтром: архивный тип или тип архивного источника
        q = q.where(NotificationType.deleted_at.is_(None), Source.deleted_at.is_(None))
    rows = db.execute(q).all()
    return [
        TypeOut(
            id=nt.id, source_id=nt.source_id, source_name=source_name, subject=nt.subject,
            action=nt.action, payload_schema=nt.payload_schema, description=nt.description,
            created_at=nt.created_at, deleted_at=nt.deleted_at,
        )
        for nt, source_name in rows
    ]


@router.post("/types", response_model=TypeOut, status_code=201)
def create_type(
    payload: TypeIn,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> TypeOut:
    source = db.get(Source, payload.source_id)
    if source is None:
        raise HTTPException(status_code=422, detail="Источник не найден")
    if source.deleted_at is not None:
        raise HTTPException(status_code=422, detail="Источник в архиве — сначала restore")
    dup = db.execute(
        select(NotificationType)
        .where(
            NotificationType.source_id == payload.source_id,
            NotificationType.subject == payload.subject,
            NotificationType.action == payload.action,
        )
    ).scalar_one_or_none()
    if dup is not None:
        if dup.deleted_at is not None:
            raise _conflict(
                f"Тип ({source.name}, {payload.subject}, {payload.action}) есть в архиве "
                f"(id={dup.id}) — восстанови (restore) или зарегистрируй заново с другим action"
            )
        raise _conflict(f"Тип ({source.name}, {payload.subject}, {payload.action}) уже есть")
    nt = NotificationType(**payload.model_dump())
    db.add(nt)
    db.commit()
    db.refresh(nt)
    return TypeOut(
        id=nt.id, source_name=source.name, created_at=nt.created_at, deleted_at=None,
        **payload.model_dump(),
    )


@router.delete("/types/{type_id}", status_code=204)
def delete_type(
    type_id: int,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> None:
    nt = db.get(NotificationType, type_id)
    if nt is None or nt.deleted_at is not None:
        raise HTTPException(status_code=404, detail="Тип не найден")
    nt.deleted_at = datetime.now(UTC)
    db.commit()


@router.post("/types/{type_id}/restore", response_model=TypeOut)
def restore_type(
    type_id: int,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> TypeOut:
    nt = db.get(NotificationType, type_id)
    if nt is None or nt.deleted_at is None:
        raise HTTPException(status_code=404, detail="В архиве нет типа с таким id")
    source = db.get(Source, nt.source_id)
    if source is None or source.deleted_at is not None:
        raise HTTPException(status_code=409, detail="Источник типа в архиве — restore невозможен")
    _raise_restore_conflict(
        db,
        f"тип ({source.name}, {nt.subject}, {nt.action})",
        select(NotificationType.id).where(
            NotificationType.source_id == nt.source_id,
            NotificationType.subject == nt.subject,
            NotificationType.action == nt.action,
            NotificationType.deleted_at.is_(None),
            NotificationType.id != nt.id,
        ),
    )
    nt.deleted_at = None
    db.commit()
    db.refresh(nt)
    return TypeOut(
        id=nt.id, source_id=nt.source_id, source_name=source.name, subject=nt.subject,
        action=nt.action, payload_schema=nt.payload_schema, description=nt.description,
        created_at=nt.created_at, deleted_at=None,
    )


# --- targets ---

@router.get("/targets", response_model=list[TargetOut])
def list_targets(
    include_archived: bool = False,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> list[TargetOut]:
    q = select(ChannelTarget).order_by(ChannelTarget.channel, ChannelTarget.name)
    if not include_archived:
        q = q.where(ChannelTarget.deleted_at.is_(None))
    targets = db.execute(q).scalars().all()
    return [
        TargetOut(
            id=t.id, channel=t.channel, name=t.name, description=t.description,
            config=t.config, enabled=t.enabled, created_at=t.created_at,
            deleted_at=t.deleted_at,
        )
        for t in targets
    ]


@router.post("/targets", response_model=TargetOut, status_code=201)
def create_target(
    payload: TargetIn,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> TargetOut:
    dup = db.execute(
        select(ChannelTarget)
        .where(ChannelTarget.channel == payload.channel, ChannelTarget.name == payload.name)
    ).scalar_one_or_none()
    if dup is not None:
        if dup.deleted_at is not None:
            raise _conflict(
                f"Цель {payload.channel}/{payload.name} есть в архиве (id={dup.id}) — "
                "восстанови (restore) или выбери другое имя"
            )
        raise _conflict(f"Цель {payload.channel}/{payload.name} уже есть")
    target = ChannelTarget(**payload.model_dump())
    db.add(target)
    db.commit()
    db.refresh(target)
    return TargetOut(
        id=target.id, enabled=target.enabled, created_at=target.created_at,
        deleted_at=None, **payload.model_dump(),
    )


@router.patch("/targets/{target_id}", response_model=TargetOut)
def patch_target(
    target_id: int,
    payload: TargetPatch,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> TargetOut:
    target = db.get(ChannelTarget, target_id)
    if target is None or target.deleted_at is not None:
        raise HTTPException(status_code=404, detail="Цель не найдена")
    data = payload.model_dump(exclude_unset=True)
    if "name" in data and (data["name"] != target.name):
        dup = db.execute(
            select(ChannelTarget)
            .where(
                ChannelTarget.channel == target.channel,
                ChannelTarget.name == data["name"],
                ChannelTarget.id != target.id,
            )
        ).scalar_one_or_none()
        if dup is not None:
            if dup.deleted_at is not None:
                raise _conflict(
                    f"Цель {target.channel}/{data['name']} есть в архиве (id={dup.id})"
                )
            raise _conflict(f"Цель {target.channel}/{data['name']} уже есть")
    for field, value in data.items():
        setattr(target, field, value)
    db.commit()
    db.refresh(target)
    return TargetOut(
        id=target.id, channel=target.channel, name=target.name, description=target.description,
        config=target.config, enabled=target.enabled, created_at=target.created_at,
        deleted_at=target.deleted_at,
    )


@router.delete("/targets/{target_id}", status_code=204)
def delete_target(
    target_id: int,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> None:
    """Архив: ссылки из правил НЕ обнуляются (restore возвращает цель в строй)."""
    target = db.get(ChannelTarget, target_id)
    if target is None or target.deleted_at is not None:
        raise HTTPException(status_code=404, detail="Цель не найдена")
    target.deleted_at = datetime.now(UTC)
    db.commit()


@router.post("/targets/{target_id}/restore", response_model=TargetOut)
def restore_target(
    target_id: int,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> TargetOut:
    target = db.get(ChannelTarget, target_id)
    if target is None or target.deleted_at is None:
        raise HTTPException(status_code=404, detail="В архиве нет цели с таким id")
    _raise_restore_conflict(
        db,
        f"цель {target.channel}/{target.name}",
        select(ChannelTarget.id).where(
            ChannelTarget.channel == target.channel,
            ChannelTarget.name == target.name,
            ChannelTarget.deleted_at.is_(None),
            ChannelTarget.id != target.id,
        ),
    )
    target.deleted_at = None
    db.commit()
    db.refresh(target)
    return TargetOut(
        id=target.id, channel=target.channel, name=target.name, description=target.description,
        config=target.config, enabled=target.enabled, created_at=target.created_at,
        deleted_at=None,
    )


# --- rules ---

def _rule_to_out(db: Session, rule: RoutingRule) -> RuleOut:
    targets = {
        t.id: t.name
        for t in db.execute(select(ChannelTarget)).scalars().all()
    }
    actions = db.execute(
        select(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule.id).order_by(RoutingRuleAction.id)
    ).scalars().all()
    return RuleOut(
        id=rule.id, name=rule.name, enabled=rule.enabled, weight=rule.weight,
        conditions=rule.conditions, template=rule.template,
        throttle_seconds=rule.throttle_seconds, created_at=rule.created_at,
        deleted_at=rule.deleted_at,
        actions=[
            RuleActionOut(
                id=act.id, channel=act.channel, target_id=act.target_id,
                template=act.template, target_name=targets.get(act.target_id),
            )
            for act in actions
        ],
    )


@router.get("/rules", response_model=list[RuleOut])
def list_rules(
    include_archived: bool = False,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> list[RuleOut]:
    q = select(RoutingRule).order_by(RoutingRule.weight, RoutingRule.id)
    if not include_archived:
        q = q.where(RoutingRule.deleted_at.is_(None))
    rules = db.execute(q).scalars().all()
    return [_rule_to_out(db, r) for r in rules]


@router.post("/rules", response_model=RuleOut, status_code=201)
def create_rule(
    payload: RuleIn,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> RuleOut:
    for act in payload.actions:
        _raise_action_target_error(db, act.channel, act.target_id)
        if act.channel in ("user", "push") and act.target_id:
            raise HTTPException(
                status_code=422,
                detail=f"Канал {act.channel} адресует пользователя через payload.user_id — цель не задаётся",
            )
    data = payload.model_dump()
    actions = data.pop("actions")
    rule = RoutingRule(**data)
    db.add(rule)
    db.flush()
    for act in actions:
        db.add(RoutingRuleAction(rule_id=rule.id, **act))
    db.commit()
    db.refresh(rule)
    return _rule_to_out(db, rule)


def _raise_action_target_error(db: Session, channel: str, target_id: int | None) -> None:
    """Цель действия: живая — ok; в архиве — 422 «цель в архиве»; нет — 422."""
    if not target_id:
        return
    target = db.get(ChannelTarget, target_id)
    if target is None:
        raise HTTPException(status_code=422, detail=f"Цель id={target_id} не найдена")
    if target.deleted_at is not None:
        raise HTTPException(
            status_code=422,
            detail=f"Цель id={target_id} ({target.channel}/{target.name}) в архиве — restore, "
                   "другая цель или канал без цели",
        )


@router.patch("/rules/{rule_id}", response_model=RuleOut)
def patch_rule(
    rule_id: int,
    payload: RulePatch,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> RuleOut:
    rule = db.get(RoutingRule, rule_id)
    if rule is None or rule.deleted_at is not None:
        raise HTTPException(status_code=404, detail="Правило не найдено")
    data = payload.model_dump(exclude_unset=True)
    if "actions" in data:
        actions = data.pop("actions")
        for act in actions:
            _raise_action_target_error(db, act["channel"], act.get("target_id"))
            if act.get("channel") in ("user", "push") and act.get("target_id"):
                raise HTTPException(
                    status_code=422,
                    detail=f"Канал {act['channel']} адресует пользователя через payload.user_id — цель не задаётся",
                )
        db.execute(delete(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule.id))
        for act in actions:
            db.add(RoutingRuleAction(rule_id=rule.id, **act))
    for field, value in data.items():
        setattr(rule, field, value)
    db.commit()
    db.refresh(rule)
    return _rule_to_out(db, rule)


@router.delete("/rules/{rule_id}", status_code=204)
def delete_rule(
    rule_id: int,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> None:
    """Архив: действия правила не удаляются — restore вернёт их целиком."""
    rule = db.get(RoutingRule, rule_id)
    if rule is None or rule.deleted_at is not None:
        raise HTTPException(status_code=404, detail="Правило не найдено")
    rule.deleted_at = datetime.now(UTC)
    db.commit()


@router.post("/rules/{rule_id}/restore", response_model=RuleOut)
def restore_rule(
    rule_id: int,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> RuleOut:
    rule = db.get(RoutingRule, rule_id)
    if rule is None or rule.deleted_at is None:
        raise HTTPException(status_code=404, detail="В архиве нет правила с таким id")
    rule.deleted_at = None
    db.commit()
    db.refresh(rule)
    return _rule_to_out(db, rule)


# --- events / deliveries (чтение) ---

def _target_names(db: Session) -> dict[int, str]:
    return {t.id: t.name for t in db.execute(select(ChannelTarget)).scalars().all()}


def _deliveries_out(db: Session, event_ids) -> dict[uuid.UUID, list[DeliveryOut]]:
    if not event_ids:
        return {}
    names = _target_names(db)
    rows = (
        db.execute(select(Delivery).where(Delivery.event_id.in_(event_ids)).order_by(Delivery.id))
        .scalars()
        .all()
    )
    out: dict[uuid.UUID, list[DeliveryOut]] = {}
    for d in rows:
        out.setdefault(d.event_id, []).append(
            DeliveryOut(
                id=d.id, event_id=d.event_id, rule_id=d.rule_id, channel=d.channel,
                target_id=d.channel_target_id, target_name=names.get(d.channel_target_id),
                recipient_user_id=d.recipient_user_id,
                status=d.status, attempts=d.attempts, last_error=d.last_error,
                next_retry_at=d.next_retry_at, rendered_message=d.rendered_message,
                created_at=d.created_at, delivered_at=d.delivered_at,
            )
        )
    return out


@router.get("/events", response_model=list[AdminEventOut])
def list_events(
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
    limit: int = Query(50, ge=1, le=200),
    status_filter: str | None = Query(None, alias="status"),
    source_id: int | None = None,
) -> list[AdminEventOut]:
    q = select(Event).order_by(Event.created_at.desc()).limit(limit)
    if status_filter:
        q = q.where(Event.status == status_filter)
    if source_id:
        q = q.where(Event.source_id == source_id)
    events = db.execute(q).scalars().all()
    names = {s.id: s.name for s in db.execute(select(Source)).scalars().all()}
    group = _deliveries_out(db, [e.id for e in events])
    return [
        AdminEventOut(
            id=e.id, source_id=e.source_id, source_name=names.get(e.source_id, "?"),
            subject=e.subject, action=e.action, priority=e.priority, status=e.status,
            payload=dict(e.payload or {}), dedup_key=e.dedup_key, created_at=e.created_at,
            expires_at=e.expires_at, deliveries=group.get(e.id, []),
        )
        for e in events
    ]


@router.get("/events/{event_id}", response_model=AdminEventOut)
def get_event(
    event_id: uuid.UUID,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
) -> AdminEventOut:
    event = db.get(Event, event_id)
    if event is None:
        raise HTTPException(status_code=404, detail="Событие не найдено")
    source = db.get(Source, event.source_id)
    group = _deliveries_out(db, [event.id])
    return AdminEventOut(
        id=event.id, source_id=event.source_id, source_name=source.name if source else "?",
        subject=event.subject, action=event.action, priority=event.priority, status=event.status,
        payload=dict(event.payload or {}), dedup_key=event.dedup_key, created_at=event.created_at,
        expires_at=event.expires_at, deliveries=group.get(event.id, []),
    )


@router.get("/deliveries", response_model=list[DeliveryOut])
def list_deliveries(
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
    limit: int = Query(100, ge=1, le=500),
    status_filter: str | None = Query(None, alias="status"),
    channel: str | None = None,
) -> list[DeliveryOut]:
    q = select(Delivery).order_by(Delivery.created_at.desc(), Delivery.id.desc()).limit(limit)
    if status_filter:
        q = q.where(Delivery.status == status_filter)
    if channel:
        q = q.where(Delivery.channel == channel)
    rows = db.execute(q).scalars().all()
    names = _target_names(db)
    return [
        DeliveryOut(
            id=d.id, event_id=d.event_id, rule_id=d.rule_id, channel=d.channel,
            target_id=d.channel_target_id, target_name=names.get(d.channel_target_id),
            recipient_user_id=d.recipient_user_id,
            status=d.status, attempts=d.attempts, last_error=d.last_error,
            next_retry_at=d.next_retry_at, rendered_message=d.rendered_message,
            created_at=d.created_at, delivered_at=d.delivered_at,
        )
        for d in rows
    ]

# --- Настройки (оверрайды .env; секреты write-only, docs/06) ---

@router.get("/settings", response_model=list[SettingOut])
def list_settings(
    user: AuthenticatedUser = Depends(require_admin), db: Session = Depends(get_db)
):
    """Реестр редактируемых настроек: оверрайд из БД, дефолт из .env, маска секретов."""
    out: list[SettingOut] = []
    env_settings = get_settings()
    for defn in EDITABLE.values():
        raw = get_setting_row(db, defn.key)
        default = getattr(env_settings, defn.key, None)
        out.append(
            SettingOut(
                key=defn.key,
                section=defn.section,
                label=defn.label,
                kind=defn.kind,
                help=defn.help,
                options=list(defn.options),
                # secret — write-only: факт наличия в set, значение не возвращается.
                # Оверрайд показывается для любого ключа реестра (у push/smtp
                # .env-дефолта нет — `default None` не значит «не возвращать»).
                value=None if (is_secret(defn) or raw is None) else raw,
                set=raw is not None or default is not None,
            )
        )
    return out


@router.put("/settings")
def put_settings(
    body: SettingsPut,
    user: AuthenticatedUser = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """Оверрайды поверх .env: строка — установить, null — вернуть дефолт .env.

    Секрет — write-only: "" значит «не менять» (UI не отправляет пустое),
    null — очистить оверрайд. Значение хранится строкой; коэрсия — в
    settings_store.get_setting (int/bool по реестру).
    """
    unknown = [k for k in body.values if k not in EDITABLE]
    if unknown:
        raise HTTPException(
            status.HTTP_422_UNPROCESSABLE_ENTITY,
            f"Неизвестные ключи настроек: {', '.join(sorted(unknown))}",
        )
    updated: list[str] = []
    for key, value in body.values.items():
        defn = EDITABLE[key]
        if value is None:
            set_setting(db, key, None)
        elif is_secret(defn):
            if value == "":
                continue  # пустая строка = не менять
            set_setting(db, key, value)
        else:
            err = validate_setting(defn, value)
            if err is not None:
                raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, err)
            set_setting(db, key, value)
        updated.append(key)
    db.commit()
    return {"ok": True, "updated": updated}