feat(tradein): аккаунт praktika + лимит 15 оценок/мес, чистка брендинга PRINZIP в UI
Account quota (server-side, по X-Authenticated-User): - новая таблица account_estimate_usage (миграция 076, идемпотентная) - app/services/account_quota.py: 15 успешных оценок в календарный месяц на аккаунт - enforcement в POST /estimate (429 при исчерпании) + GET /quota - admin и kopylov — без лимита; нет заголовка (dev) → fail-open - frontend: useQuota hook, счётчик «осталось N из 15», блок формы + баннер «Свяжитесь с Артёмом Копыловым» при исчерпании Account praktika: - caddy/users.caddy.snippet + auth/roles.yaml (роль pilot) Брендинг PRINZIP убран только из UI (футер + css-коммент); таблица brands, PDF white-label и скрейпер Яндекса (реальный застройщик PRINZIP) не тронуты.
This commit is contained in:
parent
312df8536a
commit
496f18e16d
12 changed files with 753 additions and 38 deletions
|
|
@ -13,7 +13,7 @@
|
||||||
# any pattern in `deny`. `deny` overrides `paths` (deny-by-default within
|
# any pattern in `deny`. `deny` overrides `paths` (deny-by-default within
|
||||||
# match scope).
|
# 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
|
# — Caddy basic_auth sets X-Authenticated-User from {http.auth.user.id}, and
|
||||||
# unknown users hit /me with 403 ("user not in roles config").
|
# unknown users hit /me with 403 ("user not in roles config").
|
||||||
#
|
#
|
||||||
|
|
@ -53,5 +53,6 @@ users:
|
||||||
user8: pilot
|
user8: pilot
|
||||||
user9: pilot
|
user9: pilot
|
||||||
user10: pilot
|
user10: pilot
|
||||||
|
praktika: pilot # пилот-аккаунт агентства «Практика» 2026-05-29
|
||||||
admintest: admin # temp QA 2026-05-26
|
admintest: admin # temp QA 2026-05-26
|
||||||
pilottest: pilot # 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)
|
user8 JDJhJDE0JDVxNEE4RVRVWi4yUkY2OG9KR2Z4MU8xYWVDYTdkTDNVckJGZHBZM3c5VjlqS3ZyNEFtcWVl # pilot slot (regenerated 2026-05-26)
|
||||||
user9 JDJhJDE0JGwuaDl1U2JkT1dOWDguVXhJMFozZk9Ec09NVmxyM2J2VVdxdlFzdktXWVNkZDVBMVBuY3Z5 # pilot slot (regenerated 2026-05-26)
|
user9 JDJhJDE0JGwuaDl1U2JkT1dOWDguVXhJMFozZk9Ec09NVmxyM2J2VVdxdlFzdktXWVNkZDVBMVBuY3Z5 # pilot slot (regenerated 2026-05-26)
|
||||||
user10 JDJhJDE0JEN4QlV2Z3Q3c2VqMU92OFV4YUo3cWVUUXJkRGExVU9RL3BYNzUweXhXZDdhdGh1MlFwcnN5 # 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)
|
kopylov JDJhJDE0JExUald3alVMbS5RdVV0TTRTcGk0cE9YeHNXS2E1QU5rTFp2TnFFQzdmWWNFV3djRVAzOUdH # Копылов (2026-05-23)
|
||||||
admintest JDJhJDE0JHB6TDV6ZWZFTmZuTTd0S2I0Uy8vZ3VlL2w5bEpKbjEwTGVYVTd2bkRhNW5qbGlYTlhvVkxT # QA test admin role 2026-05-26
|
admintest JDJhJDE0JHB6TDV6ZWZFTmZuTTd0S2I0Uy8vZ3VlL2w5bEpKbjEwTGVYVTd2bkRhNW5qbGlYTlhvVkxT # QA test admin role 2026-05-26
|
||||||
pilottest JDJhJDE0JFQwTW9UV1I0WHUwek5qeFR5SWxTQy4veFFFUzlvL1VoMHFycnJDTkU5czRoVXdwNXRKM2ph # QA test pilot 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 typing import Annotated, Any
|
||||||
from uuid import UUID
|
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 import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -26,6 +26,7 @@ from app.schemas.trade_in import (
|
||||||
PhotoMeta,
|
PhotoMeta,
|
||||||
PlacementHistoryEntry,
|
PlacementHistoryEntry,
|
||||||
PriceHistoryYearPoint,
|
PriceHistoryYearPoint,
|
||||||
|
QuotaStatus,
|
||||||
RecentSoldEntry,
|
RecentSoldEntry,
|
||||||
SalesListingPair,
|
SalesListingPair,
|
||||||
SalesVsListingsResponse,
|
SalesVsListingsResponse,
|
||||||
|
|
@ -34,6 +35,7 @@ from app.schemas.trade_in import (
|
||||||
StreetDealsResponse,
|
StreetDealsResponse,
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
)
|
)
|
||||||
|
from app.services import account_quota
|
||||||
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
||||||
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
||||||
|
|
||||||
|
|
@ -46,6 +48,7 @@ router = APIRouter()
|
||||||
async def estimate(
|
async def estimate(
|
||||||
payload: TradeInEstimateInput,
|
payload: TradeInEstimateInput,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
) -> AggregatedEstimate:
|
) -> AggregatedEstimate:
|
||||||
"""Реальная оценка через SQL aggregation поверх listings + deals.
|
"""Реальная оценка через SQL aggregation поверх listings + deals.
|
||||||
|
|
||||||
|
|
@ -53,9 +56,28 @@ async def estimate(
|
||||||
2. PostGIS ST_DWithin радиус 800м (или 2км fallback)
|
2. PostGIS ST_DWithin радиус 800м (или 2км fallback)
|
||||||
3. Tukey IQR outlier filter
|
3. Tukey IQR outlier filter
|
||||||
4. Median + Q1 + Q3 + confidence с explanation
|
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
|
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)
|
@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
|
linkage_rate_pct: float # deals_with_listings / total_deals * 100
|
||||||
median_discount_pct: float | None # медиана по парам с listing
|
median_discount_pct: float | None # медиана по парам с listing
|
||||||
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
|
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 { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import "@/components/trade-in/trade-in.css";
|
import "@/components/trade-in/trade-in.css";
|
||||||
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
|
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 { OfferCard } from "@/components/trade-in/OfferCard";
|
||||||
import { TestPresets } from "@/components/trade-in/TestPresets";
|
import { TestPresets } from "@/components/trade-in/TestPresets";
|
||||||
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
||||||
|
import { useQuota } from "@/lib/useQuota";
|
||||||
|
|
||||||
function useEstimateId() {
|
function useEstimateId() {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
|
|
@ -36,6 +38,17 @@ function useEstimateId() {
|
||||||
|
|
||||||
export default function TradeInPage() {
|
export default function TradeInPage() {
|
||||||
const router = useRouter();
|
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<{
|
const [freshResult, setFreshResult] = useState<{
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
input: TradeInEstimateInput;
|
input: TradeInEstimateInput;
|
||||||
|
|
@ -59,6 +72,7 @@ export default function TradeInPage() {
|
||||||
setFreshResult({ estimate, input });
|
setFreshResult({ estimate, input });
|
||||||
// basePath auto-prefixes — passing "/" returns user to /trade-in/?id=...
|
// basePath auto-prefixes — passing "/" returns user to /trade-in/?id=...
|
||||||
router.replace(`/?id=${estimate.estimate_id}`, { scroll: false });
|
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">
|
<div className="layout">
|
||||||
{/* Sticky form left */}
|
{/* Sticky form left */}
|
||||||
<aside className="form-card">
|
<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>
|
</aside>
|
||||||
|
|
||||||
{/* Result column */}
|
{/* Result column */}
|
||||||
|
|
@ -205,7 +226,7 @@ export default function TradeInPage() {
|
||||||
|
|
||||||
<footer className="page-foot">
|
<footer className="page-foot">
|
||||||
<div>
|
<div>
|
||||||
PRINZIP Trade-In · MVP ·{" "}
|
Trade-In · MVP ·{" "}
|
||||||
<span className="mono">data: Avito + Cian + Yandex + Росреестр</span>
|
<span className="mono">data: Avito + Cian + Yandex + Росреестр</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 18 }}>
|
<div style={{ display: "flex", gap: 18 }}>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ interface Props {
|
||||||
onSubmit: (input: TradeInEstimateInput) => void;
|
onSubmit: (input: TradeInEstimateInput) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
blocked?: boolean;
|
||||||
|
remaining?: number | null;
|
||||||
|
limit?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
|
|
@ -92,12 +95,19 @@ function toPayload(f: FormState): TradeInEstimateInput {
|
||||||
return out;
|
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 [form, setForm] = useState<FormState>(INITIAL);
|
||||||
const [touched, setTouched] = useState(false);
|
const [touched, setTouched] = useState(false);
|
||||||
const [mapOpen, setMapOpen] = useState(false);
|
const [mapOpen, setMapOpen] = useState(false);
|
||||||
const valError = validate(form);
|
const valError = validate(form);
|
||||||
const canSubmit = !valError && !isPending;
|
const canSubmit = !valError && !isPending && !blocked;
|
||||||
|
|
||||||
function field(key: keyof FormState) {
|
function field(key: keyof FormState) {
|
||||||
return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
|
@ -407,37 +417,73 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-foot">
|
<div className="form-foot">
|
||||||
<button
|
{blocked ? (
|
||||||
type="submit"
|
<div
|
||||||
disabled={!canSubmit}
|
style={{
|
||||||
className="btn btn-primary"
|
padding: "10px 12px",
|
||||||
style={{ opacity: canSubmit ? 1 : 0.55 }}
|
background: "var(--danger-soft)",
|
||||||
>
|
border: "1px solid var(--danger)",
|
||||||
{isPending ? (
|
borderRadius: 6,
|
||||||
<>
|
color: "var(--danger)",
|
||||||
<svg
|
fontSize: 12,
|
||||||
width="14"
|
marginBottom: 8,
|
||||||
height="14"
|
}}
|
||||||
viewBox="0 0 24 24"
|
>
|
||||||
fill="none"
|
Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым.
|
||||||
stroke="currentColor"
|
</div>
|
||||||
strokeWidth="2"
|
) : (
|
||||||
strokeLinecap="round"
|
<>
|
||||||
style={{ animation: "ti-spin 1s linear infinite" }}
|
{remaining != null && limit != null && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: "right",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
Осталось{" "}
|
||||||
</svg>
|
<span className="mono" style={{ color: "var(--fg-2)" }}>
|
||||||
Считаем…
|
{remaining}
|
||||||
</>
|
</span>{" "}
|
||||||
) : (
|
из{" "}
|
||||||
<>
|
<span className="mono">{limit}</span>{" "}
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
оценок в этом месяце
|
||||||
<polyline points="20 6 9 17 4 12" />
|
</div>
|
||||||
</svg>
|
)}
|
||||||
Оценить квартиру
|
<button
|
||||||
</>
|
type="submit"
|
||||||
)}
|
disabled={!canSubmit}
|
||||||
</button>
|
className="btn btn-primary"
|
||||||
|
style={{ opacity: canSubmit ? 1 : 0.55 }}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ animation: "ti-spin 1s linear infinite" }}
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
Считаем…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
Оценить квартиру
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="form-foot-meta" style={{ marginTop: 8, fontSize: 11, color: "var(--muted)" }}>
|
<div className="form-foot-meta" style={{ marginTop: 8, fontSize: 11, color: "var(--muted)" }}>
|
||||||
<span>Кэш по адресу — <span className="num">24 ч</span></span>
|
<span>Кэш по адресу — <span className="num">24 ч</span></span>
|
||||||
<span className="ok" style={{ marginLeft: 12, color: "var(--success)" }}>● готов</span>
|
<span className="ok" style={{ marginLeft: 12, color: "var(--success)" }}>● готов</span>
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
HEADER (white-label — PRINZIP wordmark slot)
|
HEADER (white-label — wordmark slot)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.topbar {
|
.topbar {
|
||||||
position: sticky;
|
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
|
// Trade-In Estimator — TypeScript types
|
||||||
// Mirrors Pydantic schemas from backend/app/schemas/trade_in.py.
|
// 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 =
|
export type HouseType =
|
||||||
| "panel"
|
| "panel"
|
||||||
| "brick"
|
| "brick"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue