All checks were successful
Deploy / changes (push) Successful in 5s
Deploy Trade-In / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 29s
Deploy / build-frontend (push) Successful in 28s
Deploy / build-worker (push) Successful in 29s
Deploy Trade-In / build-backend (push) Successful in 46s
Deploy / deploy (push) Successful in 58s
Deploy Trade-In / build-frontend (push) Successful in 1m40s
Deploy Trade-In / deploy (push) Successful in 39s
157 lines
4.8 KiB
Python
157 lines
4.8 KiB
Python
"""Сервис квоты оценок 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
|
||
)
|