"""Личный кабинет любого аутентифицированного пользователя gnexus-auth.
Роли user (не admin) в админ-часть не пускаются (require_admin), но личное
им доступно: /me — профиль; /me/events — личный лог (канал user);
/me/push* — web-push подписки (channel push, docs/06-settings-pwa.md).
Без ролевого гейта: get_current_user — только аутентификация call-home.
"""
import uuid
from datetime import datetime
from fastapi import Depends, HTTPException, Query, Response, status
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.config import get_settings
from app.database import get_db
from app.api.routes import AdminMe
from app.models import Delivery, Event, PushSubscription, Source
from app.settings_store import get_setting_row
router = APIRouter(prefix="/api/v1", tags=["me"])
class MeEventOut(BaseModel):
id: uuid.UUID
source_name: str
subject: str
action: str
priority: str
payload: dict
created_at: datetime
delivered_at: datetime | None
rendered_message: str | None
class MePushOut(BaseModel):
"""VAPID для подписки браузера; configured=False — пуш ещё не настроен."""
configured: bool
public_key: str | None
class PushSubscriptionOut(BaseModel):
id: int
# endpoint целиком — не секрет; UI укорачивает для показа сам
endpoint: str
ua: str | None
created_at: datetime
class PushSubscriptionIn(BaseModel):
endpoint: str = Field(min_length=10, max_length=500)
keys: dict # {"p256dh": ..., "auth": ...}
ua: str | None = Field(None, max_length=255)
@router.get("/me", response_model=AdminMe)
def me(user=Depends(get_current_user)) -> AdminMe:
"""Профиль по токену gnexus-auth (любая роль). SPA-гейт решает по system_role."""
return AdminMe(
sub=str(user.user_id),
email=user.email,
system_role=user.system_role or "unknown",
username=user.profile.get("username"),
display_name=user.profile.get("display_name"),
avatar_url=user.profile.get("avatar_url"),
account_url=f"{get_settings().gauth_base_url.rstrip('/')}/account/profile",
)
@router.get("/me/events", response_model=list[MeEventOut])
def my_events(
user=Depends(get_current_user),
db: Session = Depends(get_db),
limit: int = Query(50, ge=1, le=200),
) -> list[MeEventOut]:
"""События, направленные лично этому пользователю.
Только доставки канала user его адресата: событие попадает сюда
исключительно через правило маршрутизации. Строгий фильтр по sub —
чужое не видно никогда. Дедуп по событию (одно событие могут направить
два правила — показываем один раз).
"""
sub = str(user.user_id)
rows = (
db.execute(
select(Delivery, Event, Source.name)
.join(Event, Event.id == Delivery.event_id)
.join(Source, Source.id == Event.source_id)
.where(
Delivery.channel == "user",
Delivery.recipient_user_id == sub,
Delivery.status == "delivered",
)
.order_by(Delivery.created_at.desc(), Delivery.id.desc())
.limit(limit * 2) # запас на дубли двух правил
)
.all()
)
seen: set[uuid.UUID] = set()
out: list[MeEventOut] = []
for delivery, event, source_name in rows:
if event.id in seen:
continue
seen.add(event.id)
out.append(
MeEventOut(
id=event.id,
source_name=source_name,
subject=event.subject,
action=event.action,
priority=event.priority,
payload=dict(event.payload or {}),
created_at=event.created_at,
delivered_at=delivery.delivered_at,
rendered_message=delivery.rendered_message,
)
)
if len(out) >= limit:
break
return out
# --- Web-push подписки (канал push; самопользовательская привязка, docs/06) ---
@router.get("/me/push", response_model=MePushOut)
def push_config(user=Depends(get_current_user), db: Session = Depends(get_db)):
"""Public-ключ VAPID для pushManager.subscribe браузера клиента."""
public = get_setting_row(db, "vapid_public_key")
return MePushOut(configured=bool(public), public_key=public)
@router.get("/me/push/subscriptions", response_model=list[PushSubscriptionOut])
def my_subscriptions(
user=Depends(get_current_user), db: Session = Depends(get_db)
) -> list[PushSubscriptionOut]:
"""Подписки текущего пользователя (его браузерные устройства)."""
sub = str(user.user_id)
rows = (
db.execute(
select(PushSubscription)
.where(PushSubscription.user_id == sub)
.order_by(PushSubscription.created_at.desc())
)
.scalars()
.all()
)
return [PushSubscriptionOut(id=r.id, endpoint=r.endpoint, ua=r.ua, created_at=r.created_at) for r in rows]
@router.post("/me/push/subscriptions", response_model=PushSubscriptionOut, status_code=201)
def save_subscription(
body: PushSubscriptionIn,
user=Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Регистрация/обновление подписки этого браузера (upsert по endpoint)."""
keys = body.keys or {}
p256dh = str(keys.get("p256dh") or "").strip()
auth = str(keys.get("auth") or "").strip()
if not p256dh or not auth:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"В подписке нет ключей keys.p256dh/keys.auth от pushManager",
)
sub = (
db.execute(
select(PushSubscription).where(PushSubscription.endpoint == body.endpoint)
)
.scalars()
.first()
)
if sub is None:
sub = PushSubscription(endpoint=body.endpoint, p256dh=p256dh, auth=auth, ua=body.ua,
user_id=str(user.user_id))
db.add(sub)
else:
# endpoint уникален: могла остаться под прошлым владельцем (браузер тот же,
# а пользователь в нём другой) — теперь подписка принадлежит этому юзеру.
sub.user_id = str(user.user_id)
sub.p256dh, sub.auth, sub.ua = p256dh, auth, body.ua
db.commit()
db.refresh(sub)
return PushSubscriptionOut(id=sub.id, endpoint=sub.endpoint, ua=sub.ua, created_at=sub.created_at)
@router.delete("/me/push/subscriptions/{sub_id}", status_code=204)
def remove_subscription(
sub_id: int,
user=Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Отписать свой (user_id совпадает) endpoint; чужой — 404."""
sub = db.get(PushSubscription, sub_id)
if sub is None or sub.user_id != str(user.user_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Подписка не найдена")
db.delete(sub)
db.commit()
return Response(status_code=204)