"""Сервис квоты оценок trade-in — 15 успешных оценок в месяц на аккаунт. Правила: - Лимит = 15 успешных оценок за календарный месяц (UTC, период 'YYYY-MM'). - Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'. - Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality). - Если заголовок X-Authenticated-User отсутствует (dev без Caddy) → unlimited, лимит не применяется (fail-open). - При исчерпании лимита поднимается HTTPException(429). """ from __future__ import annotations import logging from datetime import UTC, datetime from fastapi import HTTPException from sqlalchemy import text from sqlalchemy.orm import Session from app.core.auth import get_role logger = logging.getLogger(__name__) MONTHLY_LIMIT = 15 LIMIT_EXHAUSTED_MESSAGE = ( "Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым." ) def current_period() -> str: """Возвращает текущий период в формате 'YYYY-MM' (UTC).""" return datetime.now(UTC).strftime("%Y-%m") def is_unlimited(username: str) -> bool: """True если пользователь не ограничен квотой. Unlimited: роль admin ИЛИ username == 'kopylov'. KeyError (неизвестный пользователь) → трактуется как limited (False). """ if username == "kopylov": return True try: role = get_role(username) return role == "admin" except KeyError: return False def get_status(db: Session, username: str | None) -> dict: """Возвращает статус квоты для пользователя. Если username is None → unlimited True, used 0, remaining 15. Если unlimited → used = фактический или 0, remaining = limit. """ if username is None: return { "limit": MONTHLY_LIMIT, "used": 0, "remaining": MONTHLY_LIMIT, "unlimited": True, } unlimited = is_unlimited(username) period = current_period() row = db.execute( text( """ SELECT used FROM account_estimate_usage WHERE username = :u AND period_month = :p """ ), {"u": username, "p": period}, ).fetchone() used = row.used if row is not None else 0 if unlimited: return { "limit": MONTHLY_LIMIT, "used": used, "remaining": MONTHLY_LIMIT, "unlimited": True, } remaining = max(0, MONTHLY_LIMIT - used) return { "limit": MONTHLY_LIMIT, "used": used, "remaining": remaining, "unlimited": False, } def check_and_raise(db: Session, username: str | None) -> None: """Проверяет лимит квоты и поднимает 429 если исчерпан. Если username is None или пользователь unlimited → no-op. """ if username is None: return if is_unlimited(username): return period = current_period() row = db.execute( text( """ SELECT used FROM account_estimate_usage WHERE username = :u AND period_month = :p """ ), {"u": username, "p": period}, ).fetchone() used = row.used if row is not None else 0 if used >= MONTHLY_LIMIT: logger.warning( "quota exhausted: username=%r period=%s used=%d limit=%d", username, period, used, MONTHLY_LIMIT, ) raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE) def increment(db: Session, username: str | None) -> None: """Инкрементирует счётчик успешных оценок для пользователя. Если username is None или пользователь unlimited → no-op. UPSERT: INSERT ... ON CONFLICT DO UPDATE SET used = used + 1. """ if username is None: return if is_unlimited(username): return period = current_period() db.execute( text( """ INSERT INTO account_estimate_usage (username, period_month, used, updated_at) VALUES (:u, :p, 1, NOW()) ON CONFLICT (username, period_month) DO UPDATE SET used = account_estimate_usage.used + 1, updated_at = NOW() """ ), {"u": username, "p": period}, ) db.commit() logger.debug( "quota incremented: username=%r period=%s", username, period )