from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from api.depends import ( bind_identity_actor, clear_auth_cookie, get_request_actor, get_session, hash_token, verify_identity_token, ) from api.depends import AUTH_COOKIE_NAME from api.v2.routes.auth._common import _resolve_partner_snapshot from api.v2.schemas.identities import ( ChangePasswordRequest, IdentityResponse, IdentitySessionItem, IdentitySessionsResponse, SetPasswordRequest, ) from api.v2.schemas.web_public import AccountSummaryResponse from database import ( get_balance, get_keys, get_trial, identities as idb, identity_sessions as idsess, ) from database.models import CouponUsage, Gift, GiftUsage from database.referrals import get_referral_stats from database.web_notifications import count_unread_for_identity from utils.referral_codes import encode_referral_code router = APIRouter() @router.get("/me", response_model=IdentityResponse) async def me( identity=Depends(verify_identity_token), ): """Текущая идентичность по HttpOnly cookie `auth_token`.""" return IdentityResponse.model_validate(identity) def _current_token_hash(request: Request) -> str | None: raw = request.cookies.get(AUTH_COOKIE_NAME) if not raw or not raw.strip(): return None return hash_token(raw.strip()) @router.get("/sessions", response_model=IdentitySessionsResponse) async def list_my_sessions( request: Request, session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): """Возвращает активные сессии текущей identity (все устройства).""" current_hash = _current_token_hash(request) rows = await idsess.list_sessions_for_identity(session, identity.id) items = [ IdentitySessionItem( id=row.id, device_label=row.device_label, ip=row.ip, created_at=row.created_at, last_seen_at=row.last_seen_at, expires_at=row.expires_at, is_current=bool(current_hash and row.token_hash == current_hash), ) for row in rows ] return IdentitySessionsResponse(sessions=items) @router.delete("/sessions/{session_id}") async def revoke_my_session( session_id: str, request: Request, response: Response, session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): """Удаляет одну сессию текущей identity. Если удалена текущая — очищаем cookie.""" ok = await idsess.delete_session_by_id( session, session_id=session_id, identity_id=identity.id ) if not ok: raise HTTPException(status_code=404, detail="Сессия не найдена") current_hash = _current_token_hash(request) rows = await idsess.list_sessions_for_identity(session, identity.id) if current_hash and not any(r.token_hash == current_hash for r in rows): clear_auth_cookie(response, request) return {"ok": True} @router.post("/sessions/revoke-others") async def revoke_other_sessions( request: Request, session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): """Удаляет все сессии текущей identity кроме текущей.""" current_hash = _current_token_hash(request) if not current_hash: raise HTTPException(status_code=400, detail="Текущая сессия не определена") removed = await idsess.delete_other_sessions( session, identity_id=identity.id, keep_token_hash=current_hash ) return {"ok": True, "removed": removed} @router.post("/logout") async def logout( request: Request, response: Response, session: AsyncSession = Depends(get_session), ): """Удаляет текущую сессию из БД и очищает auth cookie. Не требует валидной сессии.""" raw = request.cookies.get(AUTH_COOKIE_NAME) if raw and raw.strip(): try: await idsess.delete_session_by_token_hash(session, hash_token(raw.strip())) except Exception: pass clear_auth_cookie(response, request) return {"ok": True} @router.post("/me/onboarding/complete", response_model=IdentityResponse) async def onboarding_complete( session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): """Отмечает, что админ прошёл/скипнул онбординг-тур.""" from datetime import datetime as _dt if identity.onboarding_completed_at is None: identity.onboarding_completed_at = _dt.utcnow() return IdentityResponse.model_validate(identity) @router.post("/me/onboarding/reset", response_model=IdentityResponse) async def onboarding_reset( session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): """Сбрасывает флаг онбординга — туториал запустится снова.""" identity.onboarding_completed_at = None identity.onboarding_stage = "landing" return IdentityResponse.model_validate(identity) _ONBOARDING_STAGES = {"landing", "header", "cabinet", "flow", "elements", "done"} @router.post("/me/onboarding/stage", response_model=IdentityResponse) async def onboarding_set_stage( body: dict, session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): """Переводит админа на указанный этап онбординга.""" from datetime import datetime as _dt stage = str(body.get("stage") or "").strip() if stage not in _ONBOARDING_STAGES: raise HTTPException(status_code=400, detail="Неизвестный этап онбординга") identity.onboarding_stage = stage if stage == "done" and identity.onboarding_completed_at is None: identity.onboarding_completed_at = _dt.utcnow() return IdentityResponse.model_validate(identity) @router.get("/summary", response_model=AccountSummaryResponse) async def auth_summary( request: Request, session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): actor = get_request_actor(request) billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None if billing_user_id is None: billing_user_id = await idb.ensure_billing_user_for_identity(session, identity) balance = float(await get_balance(session, billing_user_id)) trial_status = await get_trial(session, billing_user_id) keys = await get_keys(session, billing_user_id) keys_total = len(keys) if keys else 0 gifts_sent_r = await session.execute( select(func.count()).select_from(Gift).where(Gift.sender_user_id == billing_user_id) ) gifts_sent = gifts_sent_r.scalar_one() or 0 gifts_claimed_r = await session.execute( select(func.count()).select_from(GiftUsage).where(GiftUsage.user_id == billing_user_id) ) gifts_claimed = gifts_claimed_r.scalar_one() or 0 coupons_r = await session.execute( select(func.count()).select_from(CouponUsage).where(CouponUsage.user_id == billing_user_id) ) coupons_used = coupons_r.scalar_one() or 0 ref = await get_referral_stats(session, billing_user_id) partner = await _resolve_partner_snapshot(session, int(billing_user_id)) unread_notifications = await count_unread_for_identity(session, identity.id) return AccountSummaryResponse( identity_id=identity.id, email=identity.email, tg_id=identity.tg_id, linked_telegram=identity.tg_id is not None, referral_code=encode_referral_code(int(billing_user_id)), balance=balance, trial_status=int(trial_status), keys_total=keys_total, referrals_total=int(ref.get("total_referrals") or 0), referrals_active=int(ref.get("active_referrals") or 0), referral_bonus_total=float(ref.get("total_referral_bonus") or 0), gifts_sent=int(gifts_sent), gifts_claimed=int(gifts_claimed), coupons_used=int(coupons_used), partner_enabled=bool(partner.get("partner_enabled", False)), partner_code=str(partner.get("partner_code") or ""), partner_balance=float(partner.get("partner_balance") or 0.0), partner_percent=float(partner.get("partner_percent") or 0.0), partner_percent_custom=bool(partner.get("partner_percent_custom", False)), partner_referred_total=int(partner.get("partner_referred_total") or 0), partner_payout_method=partner.get("partner_payout_method"), unread_notifications=int(unread_notifications), ) @router.post("/set-password") async def set_password( body: SetPasswordRequest, request: Request, session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): if body.password != body.password_confirm: raise HTTPException(status_code=400, detail="Пароли не совпадают") updated = await idb.set_initial_password(session, identity.id, body.password) if not updated: raise HTTPException( status_code=409, detail="Пароль уже установлен или аккаунт недоступен", ) await bind_identity_actor(request, session, updated) return {"ok": True} @router.post("/change-password") async def change_password( body: ChangePasswordRequest, request: Request, session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): if body.password != body.password_confirm: raise HTTPException(status_code=400, detail="Новые пароли не совпадают") err = await idb.change_identity_password( session, identity.id, body.current_password, body.password, ) if err == "no_password": raise HTTPException( status_code=409, detail="Пароль ещё не установлен. Сначала задайте пароль в кабинете.", ) if err == "wrong_password": raise HTTPException(status_code=401, detail="Неверный текущий пароль") refreshed = await idb.get_identity_by_id(session, identity.id) if refreshed: await bind_identity_actor(request, session, refreshed) return {"ok": True}