feat(tradein): аккаунт praktika + лимит 15 оценок/мес, чистка PRINZIP в UI #635
12 changed files with 753 additions and 38 deletions
|
|
@ -13,7 +13,7 @@
|
|||
# any pattern in `deny`. `deny` overrides `paths` (deny-by-default within
|
||||
# match scope).
|
||||
#
|
||||
# Username list MUST match exactly the 12 entries in caddy/users.caddy.snippet
|
||||
# Username list MUST match exactly the entries in caddy/users.caddy.snippet
|
||||
# — Caddy basic_auth sets X-Authenticated-User from {http.auth.user.id}, and
|
||||
# unknown users hit /me with 403 ("user not in roles config").
|
||||
#
|
||||
|
|
@ -53,5 +53,6 @@ users:
|
|||
user8: pilot
|
||||
user9: pilot
|
||||
user10: pilot
|
||||
praktika: pilot # пилот-аккаунт агентства «Практика» 2026-05-29
|
||||
admintest: admin # temp QA 2026-05-26
|
||||
pilottest: pilot # temp QA 2026-05-26
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ basic_auth bcrypt "GenDesign Pilot" {
|
|||
user8 JDJhJDE0JDVxNEE4RVRVWi4yUkY2OG9KR2Z4MU8xYWVDYTdkTDNVckJGZHBZM3c5VjlqS3ZyNEFtcWVl # pilot slot (regenerated 2026-05-26)
|
||||
user9 JDJhJDE0JGwuaDl1U2JkT1dOWDguVXhJMFozZk9Ec09NVmxyM2J2VVdxdlFzdktXWVNkZDVBMVBuY3Z5 # pilot slot (regenerated 2026-05-26)
|
||||
user10 JDJhJDE0JEN4QlV2Z3Q3c2VqMU92OFV4YUo3cWVUUXJkRGExVU9RL3BYNzUweXhXZDdhdGh1MlFwcnN5 # pilot slot (regenerated 2026-05-26)
|
||||
praktika JDJhJDE0JDV4UzZiWmNPRXZIOWNERkpQSURjbXU0bjJJUnBCNHZDaFZYTkw2d0YwT3d0ekQ0U1dPVjdL # Практика (pilot) 2026-05-29
|
||||
kopylov JDJhJDE0JExUald3alVMbS5RdVV0TTRTcGk0cE9YeHNXS2E1QU5rTFp2TnFFQzdmWWNFV3djRVAzOUdH # Копылов (2026-05-23)
|
||||
admintest JDJhJDE0JHB6TDV6ZWZFTmZuTTd0S2I0Uy8vZ3VlL2w5bEpKbjEwTGVYVTd2bkRhNW5qbGlYTlhvVkxT # QA test admin role 2026-05-26
|
||||
pilottest JDJhJDE0JFQwTW9UV1I0WHUwek5qeFR5SWxTQy4veFFFUzlvL1VoMHFycnJDTkU5czRoVXdwNXRKM2ph # QA test pilot role 2026-05-26
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from datetime import UTC, date, datetime, timedelta
|
|||
from typing import Annotated, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, Response, UploadFile
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -26,6 +26,7 @@ from app.schemas.trade_in import (
|
|||
PhotoMeta,
|
||||
PlacementHistoryEntry,
|
||||
PriceHistoryYearPoint,
|
||||
QuotaStatus,
|
||||
RecentSoldEntry,
|
||||
SalesListingPair,
|
||||
SalesVsListingsResponse,
|
||||
|
|
@ -34,6 +35,7 @@ from app.schemas.trade_in import (
|
|||
StreetDealsResponse,
|
||||
TradeInEstimateInput,
|
||||
)
|
||||
from app.services import account_quota
|
||||
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
||||
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ router = APIRouter()
|
|||
async def estimate(
|
||||
payload: TradeInEstimateInput,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> AggregatedEstimate:
|
||||
"""Реальная оценка через SQL aggregation поверх listings + deals.
|
||||
|
||||
|
|
@ -53,9 +56,28 @@ async def estimate(
|
|||
2. PostGIS ST_DWithin радиус 800м (или 2км fallback)
|
||||
3. Tukey IQR outlier filter
|
||||
4. Median + Q1 + Q3 + confidence с explanation
|
||||
|
||||
Применяется лимит 15 успешных оценок в месяц на аккаунт (кроме admin/kopylov).
|
||||
"""
|
||||
account_quota.check_and_raise(db, x_authenticated_user)
|
||||
from app.services.estimator import estimate_quality
|
||||
return await estimate_quality(payload, db)
|
||||
result = await estimate_quality(payload, db)
|
||||
account_quota.increment(db, x_authenticated_user)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/quota", response_model=QuotaStatus)
|
||||
def get_quota(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> QuotaStatus:
|
||||
"""Статус квоты оценок для текущего аккаунта.
|
||||
|
||||
Возвращает limit / used / remaining / unlimited для X-Authenticated-User.
|
||||
Без заголовка (dev-режим без Caddy) — unlimited True, used 0.
|
||||
"""
|
||||
status = account_quota.get_status(db, x_authenticated_user)
|
||||
return QuotaStatus(**status)
|
||||
|
||||
|
||||
@router.get("/estimate/{estimate_id}", response_model=AggregatedEstimate)
|
||||
|
|
|
|||
|
|
@ -382,3 +382,15 @@ class SalesVsListingsResponse(BaseModel):
|
|||
linkage_rate_pct: float # deals_with_listings / total_deals * 100
|
||||
median_discount_pct: float | None # медиана по парам с listing
|
||||
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
|
||||
|
||||
|
||||
# ── Account quota (#quota) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class QuotaStatus(BaseModel):
|
||||
"""Статус квоты оценок для текущего аккаунта (GET /api/v1/trade-in/quota)."""
|
||||
|
||||
limit: int # MONTHLY_LIMIT = 15
|
||||
used: int # использовано в текущем месяце
|
||||
remaining: int # max(0, limit - used)
|
||||
unlimited: bool # True для admin / kopylov / без заголовка
|
||||
|
|
|
|||
157
tradein-mvp/backend/app/services/account_quota.py
Normal file
157
tradein-mvp/backend/app/services/account_quota.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Сервис квоты оценок 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
|
||||
)
|
||||
36
tradein-mvp/backend/data/sql/076_account_estimate_quota.sql
Normal file
36
tradein-mvp/backend/data/sql/076_account_estimate_quota.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- Migration 076: account_estimate_usage — квота оценок на аккаунт (#quota)
|
||||
--
|
||||
-- Хранит счётчик успешных оценок per (username, period_month).
|
||||
-- period_month — строка 'YYYY-MM' (UTC). Лимит проверяется/инкрементится
|
||||
-- в app.services.account_quota.
|
||||
--
|
||||
-- Idempotent: CREATE TABLE IF NOT EXISTS + существующие COMMENT переписываются
|
||||
-- безопасно (COMMENT ON TABLE/COLUMN — DML, не DDL, не падает при повторе).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_estimate_usage (
|
||||
username text NOT NULL,
|
||||
period_month text NOT NULL, -- 'YYYY-MM' (UTC)
|
||||
used integer NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (username, period_month)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE account_estimate_usage IS
|
||||
'Счётчик успешных оценок trade-in per (username, calendar month UTC). '
|
||||
'Используется для применения лимита 15 оценок/месяц на pilot-аккаунтах.';
|
||||
|
||||
COMMENT ON COLUMN account_estimate_usage.username IS
|
||||
'Имя пользователя из заголовка X-Authenticated-User (Caddy basic_auth).';
|
||||
|
||||
COMMENT ON COLUMN account_estimate_usage.period_month IS
|
||||
'Календарный месяц UTC в формате YYYY-MM, напр. ''2026-05''.';
|
||||
|
||||
COMMENT ON COLUMN account_estimate_usage.used IS
|
||||
'Количество успешных оценок в данном месяце.';
|
||||
|
||||
COMMENT ON COLUMN account_estimate_usage.updated_at IS
|
||||
'Время последнего инкремента (UTC).';
|
||||
|
||||
COMMIT;
|
||||
390
tradein-mvp/backend/tests/test_account_quota.py
Normal file
390
tradein-mvp/backend/tests/test_account_quota.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
"""Tests for app.services.account_quota — monthly estimate quota enforcement.
|
||||
|
||||
Coverage:
|
||||
(a) admin и kopylov unlimited — не блокируются, increment является no-op
|
||||
(b) обычный pilot-юзер блокируется на 16-м запросе (429 + нужный detail)
|
||||
(c) increment растит used счётчик
|
||||
(d) get_status корректен для different сценариев
|
||||
(e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open)
|
||||
|
||||
DB мокируется через MagicMock — реальная БД не требуется.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
# WeasyPrint requires GTK — not present in CI/Windows
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.services.account_quota import ( # noqa: E402
|
||||
LIMIT_EXHAUSTED_MESSAGE,
|
||||
MONTHLY_LIMIT,
|
||||
check_and_raise,
|
||||
get_status,
|
||||
increment,
|
||||
is_unlimited,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _db_with_used(used: int) -> MagicMock:
|
||||
"""DB session mock whose execute().fetchone() returns row with .used = N."""
|
||||
row = MagicMock()
|
||||
row.used = used
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = row
|
||||
db = MagicMock()
|
||||
db.execute.return_value = execute_result
|
||||
return db
|
||||
|
||||
|
||||
def _db_no_row() -> MagicMock:
|
||||
"""DB session mock where no row exists yet (first estimate of the month)."""
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = None
|
||||
db = MagicMock()
|
||||
db.execute.return_value = execute_result
|
||||
return db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (a) admin and kopylov are unlimited
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_unlimited_admin() -> None:
|
||||
assert is_unlimited("admin") is True
|
||||
|
||||
|
||||
def test_is_unlimited_kopylov() -> None:
|
||||
assert is_unlimited("kopylov") is True
|
||||
|
||||
|
||||
def test_is_unlimited_pilot_user1() -> None:
|
||||
assert is_unlimited("user1") is False
|
||||
|
||||
|
||||
def test_is_unlimited_unknown_user() -> None:
|
||||
"""Неизвестный пользователь → False (KeyError трактуется как limited)."""
|
||||
assert is_unlimited("ghost_unknown_xyz") is False
|
||||
|
||||
|
||||
def test_check_and_raise_admin_not_blocked() -> None:
|
||||
"""admin с used=15 не получает 429."""
|
||||
db = _db_with_used(MONTHLY_LIMIT)
|
||||
# Should not raise
|
||||
check_and_raise(db, "admin")
|
||||
|
||||
|
||||
def test_check_and_raise_kopylov_not_blocked() -> None:
|
||||
"""kopylov с used=100 не получает 429."""
|
||||
db = _db_with_used(100)
|
||||
check_and_raise(db, "kopylov")
|
||||
|
||||
|
||||
def test_increment_admin_is_noop() -> None:
|
||||
"""increment для admin → никаких db.execute вызовов."""
|
||||
db = MagicMock()
|
||||
increment(db, "admin")
|
||||
db.execute.assert_not_called()
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_increment_kopylov_is_noop() -> None:
|
||||
"""increment для kopylov → no-op."""
|
||||
db = MagicMock()
|
||||
increment(db, "kopylov")
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (b) pilot-юзер блокируется на 16-м запросе
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_and_raise_pilot_not_blocked_at_14() -> None:
|
||||
"""used=14 < 15 → не блокируется."""
|
||||
db = _db_with_used(14)
|
||||
check_and_raise(db, "user1") # должно пройти без исключения
|
||||
|
||||
|
||||
def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
|
||||
"""used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется).
|
||||
|
||||
Логика: used >= MONTHLY_LIMIT → block. После 15-й успешной оценки
|
||||
increment делает used=15, поэтому следующий запрос (16-й) блокируется.
|
||||
"""
|
||||
db = _db_with_used(MONTHLY_LIMIT)
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
check_and_raise(db, "user1")
|
||||
assert exc_info.value.status_code == 429
|
||||
assert exc_info.value.detail == LIMIT_EXHAUSTED_MESSAGE
|
||||
|
||||
|
||||
def test_check_and_raise_pilot_blocked_exact_detail() -> None:
|
||||
"""Проверяем точный текст сообщения 429."""
|
||||
db = _db_with_used(MONTHLY_LIMIT)
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
check_and_raise(db, "user3")
|
||||
assert exc_info.value.detail == (
|
||||
"Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым."
|
||||
)
|
||||
|
||||
|
||||
def test_check_and_raise_pilot_blocked_over_limit() -> None:
|
||||
"""used=20 тоже блокируется."""
|
||||
db = _db_with_used(20)
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
check_and_raise(db, "user2")
|
||||
assert exc_info.value.status_code == 429
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (c) increment растит used
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_increment_pilot_calls_upsert() -> None:
|
||||
"""increment для pilot → выполняет db.execute (UPSERT) и db.commit."""
|
||||
db = MagicMock()
|
||||
increment(db, "user1")
|
||||
db.execute.assert_called_once()
|
||||
db.commit.assert_called_once()
|
||||
|
||||
# Проверяем что SQL содержит ON CONFLICT ... DO UPDATE
|
||||
call_args = db.execute.call_args
|
||||
sql_text = str(call_args[0][0]) # first positional arg — text() object
|
||||
assert "ON CONFLICT" in sql_text
|
||||
assert "used" in sql_text
|
||||
|
||||
|
||||
def test_increment_none_username_is_noop() -> None:
|
||||
"""None username → no-op."""
|
||||
db = MagicMock()
|
||||
increment(db, None)
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_increment_pilot_first_estimate_of_month() -> None:
|
||||
"""Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT."""
|
||||
db = MagicMock()
|
||||
increment(db, "user5")
|
||||
db.execute.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (d) get_status корректен
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_status_none_username() -> None:
|
||||
"""username None → unlimited, used=0, remaining=15."""
|
||||
db = MagicMock()
|
||||
status = get_status(db, None)
|
||||
assert status["unlimited"] is True
|
||||
assert status["used"] == 0
|
||||
assert status["remaining"] == MONTHLY_LIMIT
|
||||
assert status["limit"] == MONTHLY_LIMIT
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_get_status_admin() -> None:
|
||||
"""admin → unlimited, remaining=15 вне зависимости от used."""
|
||||
db = _db_with_used(7)
|
||||
status = get_status(db, "admin")
|
||||
assert status["unlimited"] is True
|
||||
assert status["remaining"] == MONTHLY_LIMIT
|
||||
assert status["used"] == 7 # фактический used из БД
|
||||
|
||||
|
||||
def test_get_status_pilot_with_used() -> None:
|
||||
"""pilot с used=10 → remaining=5."""
|
||||
db = _db_with_used(10)
|
||||
status = get_status(db, "user2")
|
||||
assert status["unlimited"] is False
|
||||
assert status["used"] == 10
|
||||
assert status["remaining"] == 5
|
||||
assert status["limit"] == MONTHLY_LIMIT
|
||||
|
||||
|
||||
def test_get_status_pilot_no_row_yet() -> None:
|
||||
"""Новый месяц — строки нет → used=0, remaining=15."""
|
||||
db = _db_no_row()
|
||||
status = get_status(db, "user3")
|
||||
assert status["used"] == 0
|
||||
assert status["remaining"] == MONTHLY_LIMIT
|
||||
assert status["unlimited"] is False
|
||||
|
||||
|
||||
def test_get_status_pilot_exhausted() -> None:
|
||||
"""used=15 → remaining=0."""
|
||||
db = _db_with_used(MONTHLY_LIMIT)
|
||||
status = get_status(db, "user4")
|
||||
assert status["remaining"] == 0
|
||||
assert status["unlimited"] is False
|
||||
|
||||
|
||||
def test_get_status_pilot_over_limit_remaining_zero() -> None:
|
||||
"""used=20 → remaining=0 (не отрицательное)."""
|
||||
db = _db_with_used(20)
|
||||
status = get_status(db, "user5")
|
||||
assert status["remaining"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (e) отсутствие заголовка = unlimited (через /quota endpoint)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_and_raise_no_header_is_noop() -> None:
|
||||
"""Без заголовка (None) → no-op, не поднимает HTTPException."""
|
||||
db = MagicMock()
|
||||
check_and_raise(db, None) # не должно поднять исключение
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: /quota endpoint через TestClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def quota_app() -> FastAPI:
|
||||
"""Минимальное FastAPI app с /quota endpoint и мок-БД."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core.db import get_db
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield _db_no_row()
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
def test_quota_endpoint_no_header(quota_app: FastAPI) -> None:
|
||||
"""GET /quota без заголовка → unlimited=True, remaining=15."""
|
||||
client = TestClient(quota_app)
|
||||
resp = client.get("/api/v1/trade-in/quota")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["unlimited"] is True
|
||||
assert data["remaining"] == MONTHLY_LIMIT
|
||||
assert data["used"] == 0
|
||||
|
||||
|
||||
def test_quota_endpoint_with_pilot_user(quota_app: FastAPI) -> None:
|
||||
"""GET /quota с user1 (pilot, нет строки в БД) → unlimited=False, used=0."""
|
||||
client = TestClient(quota_app)
|
||||
resp = client.get(
|
||||
"/api/v1/trade-in/quota",
|
||||
headers={"X-Authenticated-User": "user1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["unlimited"] is False
|
||||
assert data["used"] == 0
|
||||
assert data["remaining"] == MONTHLY_LIMIT
|
||||
|
||||
|
||||
def test_quota_endpoint_admin_unlimited(quota_app: FastAPI) -> None:
|
||||
"""GET /quota с admin → unlimited=True."""
|
||||
client = TestClient(quota_app)
|
||||
resp = client.get(
|
||||
"/api/v1/trade-in/quota",
|
||||
headers={"X-Authenticated-User": "admin"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["unlimited"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: POST /estimate quota enforcement через TestClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def estimate_app_exhausted() -> FastAPI:
|
||||
"""FastAPI app где БД возвращает used=15 для pilot → 429 на POST /estimate."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core.db import get_db
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield _db_with_used(MONTHLY_LIMIT)
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
def test_estimate_pilot_blocked_429(estimate_app_exhausted: FastAPI) -> None:
|
||||
"""POST /estimate с user1 при used=15 → 429 с нужным detail."""
|
||||
client = TestClient(estimate_app_exhausted, raise_server_exceptions=False)
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/estimate",
|
||||
json={
|
||||
"address": "г. Екатеринбург, ул. Малышева, 1",
|
||||
"area_m2": 50.0,
|
||||
"rooms": 2,
|
||||
},
|
||||
headers={"X-Authenticated-User": "user1"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
assert resp.json()["detail"] == LIMIT_EXHAUSTED_MESSAGE
|
||||
|
||||
|
||||
def test_estimate_admin_not_blocked(estimate_app_exhausted: FastAPI) -> None:
|
||||
"""POST /estimate с admin при used=15 → не 429 (blocked by quota), но может падать
|
||||
с другой ошибкой (нет реального estimator). Ключевое: не 429."""
|
||||
client = TestClient(estimate_app_exhausted, raise_server_exceptions=False)
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/estimate",
|
||||
json={
|
||||
"address": "г. Екатеринбург, ул. Малышева, 1",
|
||||
"area_m2": 50.0,
|
||||
"rooms": 2,
|
||||
},
|
||||
headers={"X-Authenticated-User": "admin"},
|
||||
)
|
||||
assert resp.status_code != 429
|
||||
|
||||
|
||||
def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None:
|
||||
"""POST /estimate без заголовка → не 429 (fail-open, dev-режим)."""
|
||||
client = TestClient(estimate_app_exhausted, raise_server_exceptions=False)
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/estimate",
|
||||
json={
|
||||
"address": "г. Екатеринбург, ул. Малышева, 1",
|
||||
"area_m2": 50.0,
|
||||
"rooms": 2,
|
||||
},
|
||||
)
|
||||
assert resp.status_code != 429
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import "@/components/trade-in/trade-in.css";
|
||||
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
|
||||
|
|
@ -27,6 +28,7 @@ import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
|
|||
import { OfferCard } from "@/components/trade-in/OfferCard";
|
||||
import { TestPresets } from "@/components/trade-in/TestPresets";
|
||||
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
||||
import { useQuota } from "@/lib/useQuota";
|
||||
|
||||
function useEstimateId() {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
|
@ -36,6 +38,17 @@ function useEstimateId() {
|
|||
|
||||
export default function TradeInPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const quota = useQuota();
|
||||
|
||||
const blocked = quota.data
|
||||
? !quota.data.unlimited && quota.data.remaining <= 0
|
||||
: false;
|
||||
const remainingQuota =
|
||||
quota.data && !quota.data.unlimited ? quota.data.remaining : null;
|
||||
const limitQuota =
|
||||
quota.data && !quota.data.unlimited ? quota.data.limit : null;
|
||||
|
||||
const [freshResult, setFreshResult] = useState<{
|
||||
estimate: AggregatedEstimate;
|
||||
input: TradeInEstimateInput;
|
||||
|
|
@ -59,6 +72,7 @@ export default function TradeInPage() {
|
|||
setFreshResult({ estimate, input });
|
||||
// basePath auto-prefixes — passing "/" returns user to /trade-in/?id=...
|
||||
router.replace(`/?id=${estimate.estimate_id}`, { scroll: false });
|
||||
void queryClient.invalidateQueries({ queryKey: ["trade-in", "quota"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -142,7 +156,14 @@ export default function TradeInPage() {
|
|||
<div className="layout">
|
||||
{/* Sticky form left */}
|
||||
<aside className="form-card">
|
||||
<EstimateForm onSubmit={handleSubmit} isPending={isPending} error={apiError} />
|
||||
<EstimateForm
|
||||
onSubmit={handleSubmit}
|
||||
isPending={isPending}
|
||||
error={apiError}
|
||||
blocked={blocked}
|
||||
remaining={remainingQuota}
|
||||
limit={limitQuota}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Result column */}
|
||||
|
|
@ -205,7 +226,7 @@ export default function TradeInPage() {
|
|||
|
||||
<footer className="page-foot">
|
||||
<div>
|
||||
PRINZIP Trade-In · MVP ·{" "}
|
||||
Trade-In · MVP ·{" "}
|
||||
<span className="mono">data: Avito + Cian + Yandex + Росреестр</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 18 }}>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ interface Props {
|
|||
onSubmit: (input: TradeInEstimateInput) => void;
|
||||
isPending: boolean;
|
||||
error: string | null;
|
||||
blocked?: boolean;
|
||||
remaining?: number | null;
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
|
|
@ -92,12 +95,19 @@ function toPayload(f: FormState): TradeInEstimateInput {
|
|||
return out;
|
||||
}
|
||||
|
||||
export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
||||
export function EstimateForm({
|
||||
onSubmit,
|
||||
isPending,
|
||||
error,
|
||||
blocked = false,
|
||||
remaining = null,
|
||||
limit = null,
|
||||
}: Props) {
|
||||
const [form, setForm] = useState<FormState>(INITIAL);
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [mapOpen, setMapOpen] = useState(false);
|
||||
const valError = validate(form);
|
||||
const canSubmit = !valError && !isPending;
|
||||
const canSubmit = !valError && !isPending && !blocked;
|
||||
|
||||
function field(key: keyof FormState) {
|
||||
return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
|
|
@ -407,6 +417,40 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
|||
</div>
|
||||
|
||||
<div className="form-foot">
|
||||
{blocked ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--danger-soft)",
|
||||
border: "1px solid var(--danger)",
|
||||
borderRadius: 6,
|
||||
color: "var(--danger)",
|
||||
fontSize: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{remaining != null && limit != null && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--muted)",
|
||||
marginBottom: 8,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
Осталось{" "}
|
||||
<span className="mono" style={{ color: "var(--fg-2)" }}>
|
||||
{remaining}
|
||||
</span>{" "}
|
||||
из{" "}
|
||||
<span className="mono">{limit}</span>{" "}
|
||||
оценок в этом месяце
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
|
|
@ -438,6 +482,8 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
|||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="form-foot-meta" style={{ marginTop: 8, fontSize: 11, color: "var(--muted)" }}>
|
||||
<span>Кэш по адресу — <span className="num">24 ч</span></span>
|
||||
<span className="ok" style={{ marginLeft: 12, color: "var(--success)" }}>● готов</span>
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ============================================================
|
||||
HEADER (white-label — PRINZIP wordmark slot)
|
||||
HEADER (white-label — wordmark slot)
|
||||
============================================================ */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
|
|
|
|||
21
tradein-mvp/frontend/src/lib/useQuota.ts
Normal file
21
tradein-mvp/frontend/src/lib/useQuota.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { QuotaStatus } from "@/types/trade-in";
|
||||
|
||||
const BASE = "/api/v1/trade-in";
|
||||
|
||||
/**
|
||||
* GET /api/v1/trade-in/quota
|
||||
* Fetches the monthly evaluation quota status for the current session/account.
|
||||
* Fail-open: if the query errors, the UI must NOT block the user (treat as unlimited).
|
||||
*/
|
||||
export function useQuota() {
|
||||
return useQuery<QuotaStatus>({
|
||||
queryKey: ["trade-in", "quota"],
|
||||
queryFn: () => apiFetch<QuotaStatus>(`${BASE}/quota`),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
// Trade-In Estimator — TypeScript types
|
||||
// Mirrors Pydantic schemas from backend/app/schemas/trade_in.py.
|
||||
|
||||
// ── Quota ──
|
||||
export interface QuotaStatus {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
export type HouseType =
|
||||
| "panel"
|
||||
| "brick"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue