From aa8ca9bcfca39533cea695148075dc965dfc5ed8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 29 May 2026 15:53:29 +0000 Subject: [PATCH 1/5] feat(backend): add /api/v1/ping liveness endpoint (#634) Co-authored-by: bot-backend Co-committed-by: bot-backend --- backend/app/api/v1/ping.py | 8 ++++++++ backend/app/main.py | 4 +++- backend/tests/test_ping.py | 10 ++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/v1/ping.py create mode 100644 backend/tests/test_ping.py diff --git a/backend/app/api/v1/ping.py b/backend/app/api/v1/ping.py new file mode 100644 index 00000000..2b9ba5ec --- /dev/null +++ b/backend/app/api/v1/ping.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/ping") +async def ping() -> dict[str, bool]: + return {"pong": True} diff --git a/backend/app/main.py b/backend/app/main.py index 3fef12a1..53903ac5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,6 +30,7 @@ from app.api.v1 import ( parcels, photos, pilot, + ping, trade_in, users, ) @@ -86,7 +87,7 @@ app = FastAPI(title="GenDesign API", version="0.1.0", lifespan=lifespan) # Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки — # X-Authenticated-User там просто не приходит из Caddy. _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") -_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) +_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"}) @app.middleware("http") @@ -164,6 +165,7 @@ app.include_router(landing.router, prefix="/api/v1", tags=["landing"]) app.include_router(pilot.router, prefix="/api/v1/pilot", tags=["pilot"]) app.include_router(users.router, prefix="/api/v1", tags=["users"]) app.include_router(me.router, prefix="/api/v1", tags=["me"]) +app.include_router(ping.router, prefix="/api/v1", tags=["ping"]) @app.get("/health") diff --git a/backend/tests/test_ping.py b/backend/tests/test_ping.py new file mode 100644 index 00000000..160a49ff --- /dev/null +++ b/backend/tests/test_ping.py @@ -0,0 +1,10 @@ +from fastapi.testclient import TestClient + +from app.main import app + + +def test_ping() -> None: + client = TestClient(app) + response = client.get("/api/v1/ping") + assert response.status_code == 200 + assert response.json() == {"pong": True} From 6ddc9bc661f5e2517f9af5dcb9f3d72d3555dcef Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 29 May 2026 15:56:30 +0000 Subject: [PATCH 2/5] fix(tradein): bypass rate-limit for authenticated pilots + configurable limit (#655) (#669) Co-authored-by: bot-backend Co-committed-by: bot-backend --- tradein-mvp/backend/app/core/config.py | 7 +++ tradein-mvp/backend/app/core/ratelimit.py | 20 ++++--- tradein-mvp/backend/tests/test_ratelimit.py | 58 +++++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_ratelimit.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 86e3296b..2a772e01 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -34,6 +34,13 @@ class Settings(BaseSettings): # Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL. redis_url: str = "redis://localhost:6379/0" + # Rate-limit публичного /api/* (per-IP sliding window). ENV: RATE_LIMIT, + # RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за rate_limit_window_s + # секунд с одного IP. Аутентифицированный трафик (X-Authenticated-User от + # Caddy basic_auth) не лимитируется — см. ratelimit.py (#655). + rate_limit: int = 300 + rate_limit_window_s: float = 60.0 + # Password for tradein_fdw_reader role — used by backend startup to create/refresh # USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server). # Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev). diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py index 9ab432b4..d7655f24 100644 --- a/tradein-mvp/backend/app/core/ratelimit.py +++ b/tradein-mvp/backend/app/core/ratelimit.py @@ -16,9 +16,11 @@ from fastapi import Request from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware -# Окно и лимит: не более RATE_LIMIT запросов за RATE_WINDOW секунд с одного IP. -RATE_WINDOW = 60.0 -RATE_LIMIT = 90 +from app.core.config import settings + +# Окно и лимит читаются из settings (env RATE_LIMIT / RATE_LIMIT_WINDOW_S): +# не более settings.rate_limit запросов за settings.rate_limit_window_s секунд +# с одного IP. class RateLimitMiddleware(BaseHTTPMiddleware): @@ -34,17 +36,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware): if not path.startswith("/api/"): return await call_next(request) + # Аутентифицированный трафик не лимитируем: Caddy basic_auth ставит + # X-Authenticated-User на каждый запрос пилота. Лимит — только для + # анонимного трафика (заголовок отсутствует/пуст). #655. + if request.headers.get("x-authenticated-user"): + return await call_next(request) + ip = _client_ip(request) now = time.monotonic() bucket = self._hits[ip] # Выкидываем устаревшие отметки за пределами окна. - cutoff = now - RATE_WINDOW + cutoff = now - settings.rate_limit_window_s while bucket and bucket[0] < cutoff: bucket.popleft() - if len(bucket) >= RATE_LIMIT: - retry = int(RATE_WINDOW - (now - bucket[0])) + 1 + if len(bucket) >= settings.rate_limit: + retry = int(settings.rate_limit_window_s - (now - bucket[0])) + 1 return JSONResponse( status_code=429, content={"detail": "Слишком много запросов. Попробуйте позже."}, diff --git a/tradein-mvp/backend/tests/test_ratelimit.py b/tradein-mvp/backend/tests/test_ratelimit.py new file mode 100644 index 00000000..bc891851 --- /dev/null +++ b/tradein-mvp/backend/tests/test_ratelimit.py @@ -0,0 +1,58 @@ +"""Tests for RateLimitMiddleware — per-IP sliding window + auth bypass (#655). + +Тестируем middleware в изоляции на минимальном FastAPI-приложении, чтобы не +тянуть тяжёлый app.main (DB / scheduler / sentry). Лимит/окно читаются из +settings на каждый dispatch → monkeypatch'им их в маленькие значения. +""" + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.core import config +from app.core.ratelimit import RateLimitMiddleware + + +@pytest.fixture +def client(monkeypatch): + """Минимальное приложение с лимитом 3 запроса / 60 с (для быстрого 429).""" + monkeypatch.setattr(config.settings, "rate_limit", 3) + monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0) + + app = FastAPI() + app.add_middleware(RateLimitMiddleware) + + @app.get("/api/v1/ping") + def ping() -> dict[str, bool]: + return {"ok": True} + + return TestClient(app) + + +def test_anonymous_throttled_past_limit(client): + # Первые rate_limit запросов проходят, следующий — 429. + for _ in range(3): + assert client.get("/api/v1/ping").status_code == 200 + resp = client.get("/api/v1/ping") + assert resp.status_code == 429 + assert resp.json() == {"detail": "Слишком много запросов. Попробуйте позже."} + assert "Retry-After" in resp.headers + + +def test_authenticated_user_bypasses_limit(client): + # X-Authenticated-User (Caddy basic_auth) → лимит не применяется. + headers = {"X-Authenticated-User": "pilot@example.com"} + for _ in range(10): # сильно больше rate_limit=3 + assert client.get("/api/v1/ping", headers=headers).status_code == 200 + + +def test_empty_auth_header_does_not_bypass(client): + # Пустой заголовок не должен no-op'ить лимит (header present но falsy). + headers = {"X-Authenticated-User": ""} + for _ in range(3): + assert client.get("/api/v1/ping", headers=headers).status_code == 200 + assert client.get("/api/v1/ping", headers=headers).status_code == 429 From 47fdd6e4df933aeec5db30b6b7888d0df7b2be2d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 29 May 2026 15:59:03 +0000 Subject: [PATCH 3/5] fix(tradein): scope GET /history to caller, close cross-pilot data leak (#656) (#670) Co-authored-by: bot-backend Co-committed-by: bot-backend --- tradein-mvp/backend/app/api/v1/trade_in.py | 46 +++++- tradein-mvp/backend/app/services/estimator.py | 14 +- .../sql/083_trade_in_estimates_created_by.sql | 34 ++++ .../backend/tests/test_history_scope.py | 155 ++++++++++++++++++ 4 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/083_trade_in_estimates_created_by.sql create mode 100644 tradein-mvp/backend/tests/test_history_scope.py diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index dd9928f6..4a8fd865 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -61,7 +61,7 @@ async def estimate( """ account_quota.check_and_raise(db, x_authenticated_user) from app.services.estimator import estimate_quality - result = await estimate_quality(payload, db) + result = await estimate_quality(payload, db, created_by=x_authenticated_user) account_quota.increment(db, x_authenticated_user) return result @@ -397,19 +397,57 @@ def get_photo( def estimate_history( db: Annotated[Session, Depends(get_db)], limit: int = 50, + account: str | None = None, + x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, ) -> list[dict[str, object]]: - """История оценок (#399) — последние N записей trade_in_estimates.""" + """История оценок (#399) — последние N записей trade_in_estimates. + + Скоупинг (#656 — закрывает cross-pilot data-leak): non-admin видит ТОЛЬКО + свои оценки (created_by = X-Authenticated-User); legacy NULL-строки без + владельца не попадают. Admin видит все строки, либо фильтрует по ?account=. + 401 если заголовок отсутствует (mirror /me — Caddy basic_auth обязателен). + """ + if not x_authenticated_user: + raise HTTPException( + status_code=401, + detail="no authenticated user (Caddy basic_auth required)", + ) + + from app.core.auth import get_role + + try: + role = get_role(x_authenticated_user) + except KeyError: + logger.warning( + "user %r authenticated via Caddy but missing from roles.yaml", + x_authenticated_user, + ) + raise HTTPException(status_code=403, detail="user not in roles config") from None + + params: dict[str, object] = {"limit": min(max(limit, 1), 200)} + where = "" + if role == "admin": + # Admin: все строки, либо фильтр по конкретному аккаунту. + if account: + where = "WHERE created_by = :account" + params["account"] = account + else: + # Non-admin: жёстко скоупим на свои строки; ?account игнорируется. + where = "WHERE created_by = :owner" + params["owner"] = x_authenticated_user + rows = db.execute( text( - """ + f""" SELECT id, address, rooms, area_m2, median_price, confidence, n_analogs, created_at FROM trade_in_estimates + {where} ORDER BY created_at DESC LIMIT :limit """ ), - {"limit": min(max(limit, 1), 200)}, + params, ).mappings().all() return [dict(r) for r in rows] diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 3fc96d3e..1f9fae3f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -659,7 +659,9 @@ def _save_yandex_history_items( # ── Public ─────────────────────────────────────────────────────────────────── -async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> AggregatedEstimate: +async def estimate_quality( + payload: TradeInEstimateInput, db: Session, created_by: str | None = None +) -> AggregatedEstimate: """Главная функция — оценка квартиры по реальным данным. PR M / #564 Phase 3: rosreestr_deals **included** в actual_deals output. @@ -680,7 +682,7 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg if geo is None: # Без координат не можем искать через PostGIS. Возвращаем low confidence. logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address) - return _empty_estimate(payload, db, reason="address_not_geocoded") + return _empty_estimate(payload, db, reason="address_not_geocoded", created_by=created_by) # 1b. DaData enrichment (PR Q1) — on-demand cleanup для target адреса. # Best-effort: graceful None при отсутствии credentials / quota / fail. @@ -1025,6 +1027,7 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis, + created_by, expires_at ) VALUES ( CAST(:id AS uuid), @@ -1044,6 +1047,7 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg :expected_sold_price, :expected_sold_range_low, :expected_sold_range_high, :expected_sold_per_m2, :asking_to_sold_ratio, :ratio_basis, + :created_by, :expires_at ) """ @@ -1092,6 +1096,7 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg "expected_sold_per_m2": expected_sold_per_m2, "asking_to_sold_ratio": asking_to_sold_ratio, "ratio_basis": ratio_basis, + "created_by": created_by, "expires_at": expires_at, }, ) @@ -2026,7 +2031,7 @@ def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: def _empty_estimate( - payload: TradeInEstimateInput, db: Session, *, reason: str + payload: TradeInEstimateInput, db: Session, *, reason: str, created_by: str | None = None ) -> AggregatedEstimate: """Fallback когда нет данных для оценки. @@ -2049,6 +2054,7 @@ def _empty_estimate( confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, + created_by, expires_at ) VALUES ( CAST(:id AS uuid), :address, @@ -2059,6 +2065,7 @@ def _empty_estimate( 'low', :explanation, 0, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, + :created_by, :expires_at ) """ @@ -2079,6 +2086,7 @@ def _empty_estimate( "client_name": payload.client_name, "client_phone": payload.client_phone, "explanation": reason, + "created_by": created_by, "expires_at": expires_at, }, ) diff --git a/tradein-mvp/backend/data/sql/083_trade_in_estimates_created_by.sql b/tradein-mvp/backend/data/sql/083_trade_in_estimates_created_by.sql new file mode 100644 index 00000000..b21616b0 --- /dev/null +++ b/tradein-mvp/backend/data/sql/083_trade_in_estimates_created_by.sql @@ -0,0 +1,34 @@ +-- 083_trade_in_estimates_created_by.sql +-- #656 [P1, security/data-leak] — добавляем владельца оценки для скоупинга GET /history. +-- +-- ПРОБЛЕМА: GET /history SELECT'ил последние оценки ВСЕХ пользователей (без фильтра) +-- → cross-pilot data leak (один пилот видел адреса/клиентов другого). trade_in_estimates +-- не имела колонки владельца. Caddy basic_auth прокидывает X-Authenticated-User в каждый +-- аутентифицированный запрос; RBAC middleware уже 401'ит неизвестных юзеров, так что +-- заголовок гарантированно присутствует для реальных вызовов. +-- +-- ФИКС: created_by text (username из X-Authenticated-User). Estimator пишет его на INSERT. +-- GET /history скоупит: non-admin → WHERE created_by = :user; admin → все строки +-- (или ?account= фильтр). Legacy-строки остаются created_by=NULL — они без +-- владельца, поэтому non-admin их НЕ видит (корректно: нет owner = не показываем). +-- +-- Индекс (created_by, created_at DESC) обслуживает основной паттерн запроса /history +-- (ORDER BY created_at DESC LIMIT N, опционально WHERE created_by = :user). +-- Idempotent: ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS. +-- Apply after: 082_scrape_schedules_seed_ratio_refresh.sql + +BEGIN; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS created_by text; + +CREATE INDEX IF NOT EXISTS idx_trade_in_estimates_created_by_created_at + ON trade_in_estimates (created_by, created_at DESC); + +COMMENT ON COLUMN trade_in_estimates.created_by IS + 'Username (X-Authenticated-User из Caddy basic_auth), создавший оценку. ' + 'Используется для скоупинга GET /history (#656): non-admin видит только свои ' + 'строки. NULL для legacy-строк, созданных до миграции 083 — они без владельца ' + 'и в non-admin историю не попадают.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_history_scope.py b/tradein-mvp/backend/tests/test_history_scope.py new file mode 100644 index 00000000..07469290 --- /dev/null +++ b/tradein-mvp/backend/tests/test_history_scope.py @@ -0,0 +1,155 @@ +"""Tests for GET /api/v1/trade-in/history scoping (#656). + +Закрывает cross-pilot data-leak: non-admin видит только свои оценки +(created_by = X-Authenticated-User), admin видит все либо фильтрует по ?account. + +Проверяем САМ скоупинг через перехват SQL/params, переданных в db.execute — +реальная БД не нужна (DB + get_role мокируются). +""" + +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. Stub before any app import. +_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 + + +@pytest.fixture() +def trade_in_app() -> FastAPI: + """Minimal FastAPI app mounting only the trade-in router.""" + from app.api.v1 import trade_in as trade_in_module + + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + return application + + +def _make_db_mock(rows: list[dict]) -> MagicMock: + """DB session mock returning *rows* from .mappings().all().""" + db = MagicMock() + mapping_result = MagicMock() + mapping_result.all.return_value = rows + execute_result = MagicMock() + execute_result.mappings.return_value = mapping_result + db.execute.return_value = execute_result + return db + + +def _captured_sql_and_params(db_mock: MagicMock) -> tuple[str, dict]: + """Extract the SQL text + params dict from the single db.execute call.""" + args, _ = db_mock.execute.call_args + sql = str(args[0]) + params = args[1] + return sql, params + + +def _client_with(app: FastAPI, db_mock: MagicMock, role: str) -> TestClient: + """Override get_db with *db_mock* and patch get_role to return *role*.""" + from app.api.v1 import trade_in as trade_in_module + from app.core.db import get_db + + def _override_db(): + yield db_mock + + app.dependency_overrides[get_db] = _override_db + # estimate_history imports get_role from app.core.auth at call time → patch source. + trade_in_module_auth = sys.modules["app.core.auth"] + trade_in_module_auth.get_role = lambda _u: role # type: ignore[assignment] + _ = trade_in_module # keep router import side-effect explicit + return TestClient(app) + + +def test_history_requires_authenticated_user(trade_in_app: FastAPI) -> None: + """No X-Authenticated-User header → 401 (mirror /me).""" + db_mock = _make_db_mock([]) + from app.core.db import get_db + + def _override_db(): + yield db_mock + + trade_in_app.dependency_overrides[get_db] = _override_db + client = TestClient(trade_in_app) + resp = client.get("/api/v1/trade-in/history") + assert resp.status_code == 401 + # DB must not be touched when unauthenticated. + db_mock.execute.assert_not_called() + + +def test_non_admin_sees_only_own_rows(trade_in_app: FastAPI) -> None: + """Pilot user → WHERE created_by = :owner with owner == header value.""" + db_mock = _make_db_mock([{"id": "1", "address": "A", "rooms": 2, + "area_m2": 50, "median_price": 5_000_000, + "confidence": "medium", "n_analogs": 7, + "created_at": "2026-05-29T00:00:00"}]) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + "/api/v1/trade-in/history", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + sql, params = _captured_sql_and_params(db_mock) + assert "created_by = :owner" in sql + assert params["owner"] == "kopylov" + assert "account" not in params + + +def test_non_admin_account_param_is_ignored(trade_in_app: FastAPI) -> None: + """Pilot passing ?account=victim must still be scoped to themselves.""" + db_mock = _make_db_mock([]) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + "/api/v1/trade-in/history", + params={"account": "victim"}, + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + sql, params = _captured_sql_and_params(db_mock) + assert "created_by = :owner" in sql + assert params["owner"] == "kopylov" + # The attacker-supplied account must NOT reach the query. + assert "account" not in params + assert "victim" not in params.values() + + +def test_admin_sees_all_rows(trade_in_app: FastAPI) -> None: + """Admin without ?account → no WHERE filter (all rows).""" + db_mock = _make_db_mock([]) + client = _client_with(trade_in_app, db_mock, role="admin") + resp = client.get( + "/api/v1/trade-in/history", + headers={"X-Authenticated-User": "admin"}, + ) + assert resp.status_code == 200 + sql, params = _captured_sql_and_params(db_mock) + assert "WHERE" not in sql.upper() + assert "owner" not in params + assert "account" not in params + + +def test_admin_filters_by_account(trade_in_app: FastAPI) -> None: + """Admin with ?account=kopylov → WHERE created_by = :account.""" + db_mock = _make_db_mock([]) + client = _client_with(trade_in_app, db_mock, role="admin") + resp = client.get( + "/api/v1/trade-in/history", + params={"account": "kopylov"}, + headers={"X-Authenticated-User": "admin"}, + ) + assert resp.status_code == 200 + sql, params = _captured_sql_and_params(db_mock) + assert "created_by = :account" in sql + assert params["account"] == "kopylov" + assert "owner" not in params From ed4c8b3f02b5c06c433bce9ff0dc1462b7b977f5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 29 May 2026 16:07:02 +0000 Subject: [PATCH 4/5] feat(tradein): configurable estimate quota + new exhausted-quota copy (#658) (#671) Co-authored-by: bot-backend Co-committed-by: bot-backend --- tradein-mvp/backend/app/core/config.py | 4 ++++ tradein-mvp/backend/app/services/account_quota.py | 13 +++++++++---- tradein-mvp/backend/tests/test_account_quota.py | 3 ++- .../src/components/trade-in/EstimateForm.tsx | 3 ++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 2a772e01..f0d0792d 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -54,5 +54,9 @@ class Settings(BaseSettings): dadata_api_token: str | None = None dadata_api_secret: str | None = None + # Лимит успешных оценок trade-in за календарный месяц на аккаунт (#658). + # Конфигурируется через env ESTIMATE_QUOTA_LIMIT. Default 15. + estimate_quota_limit: int = 15 + settings = Settings() diff --git a/tradein-mvp/backend/app/services/account_quota.py b/tradein-mvp/backend/app/services/account_quota.py index 4b51f5d7..70354e2e 100644 --- a/tradein-mvp/backend/app/services/account_quota.py +++ b/tradein-mvp/backend/app/services/account_quota.py @@ -1,7 +1,8 @@ -"""Сервис квоты оценок trade-in — 15 успешных оценок в месяц на аккаунт. +"""Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт. Правила: -- Лимит = 15 успешных оценок за календарный месяц (UTC, период 'YYYY-MM'). +- Лимит = settings.estimate_quota_limit успешных оценок за календарный месяц + (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT, default 15. - Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'. - Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality). - Если заголовок X-Authenticated-User отсутствует (dev без Caddy) → unlimited, @@ -19,12 +20,16 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.auth import get_role +from app.core.config import settings logger = logging.getLogger(__name__) -MONTHLY_LIMIT = 15 +# Лимит успешных оценок за календарный месяц — конфигурируется через +# env ESTIMATE_QUOTA_LIMIT (core.config.Settings), default 15 (#658). +MONTHLY_LIMIT = settings.estimate_quota_limit LIMIT_EXHAUSTED_MESSAGE = ( - "Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым." + f"Лимит из {MONTHLY_LIMIT} оценок в этом месяце исчерпан. " + "За полной версией обращайтесь к Копылову." ) diff --git a/tradein-mvp/backend/tests/test_account_quota.py b/tradein-mvp/backend/tests/test_account_quota.py index 5c55b4b3..732adda0 100644 --- a/tradein-mvp/backend/tests/test_account_quota.py +++ b/tradein-mvp/backend/tests/test_account_quota.py @@ -147,7 +147,8 @@ def test_check_and_raise_pilot_blocked_exact_detail() -> None: with pytest.raises(HTTPException) as exc_info: check_and_raise(db, "user3") assert exc_info.value.detail == ( - "Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым." + f"Лимит из {MONTHLY_LIMIT} оценок в этом месяце исчерпан. " + "За полной версией обращайтесь к Копылову." ) diff --git a/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx b/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx index f369c0e8..84abec2d 100644 --- a/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx @@ -429,7 +429,8 @@ export function EstimateForm({ marginBottom: 8, }} > - Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым. + Лимит из {limit ?? 15} оценок в этом месяце исчерпан. За полной версией + обращайтесь к Копылову. ) : ( <> From 3b2993624587474572821aaa5c1ec1b33a3dee93 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 29 May 2026 16:09:23 +0000 Subject: [PATCH 5/5] =?UTF-8?q?feat(tradein):=20white-label=20branded=20pa?= =?UTF-8?q?ge=20for=20client=20=C2=AB=D0=9F=D1=80=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=C2=BB=20(#657)=20(#672)=20Co-authored-by:=20bot-ba?= =?UTF-8?q?ckend=20=20Co-committed-by:=20bot-ba?= =?UTF-8?q?ckend=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/sql/084_brand_praktika_fill.sql | 25 ++++++++ tradein-mvp/frontend/src/app/page.tsx | 26 ++++++++- .../src/components/trade-in/OfferCard.tsx | 9 ++- .../src/components/trade-in/Topbar.tsx | 19 ++++++- .../src/components/trade-in/trade-in.css | 14 +++++ tradein-mvp/frontend/src/lib/useBrand.ts | 57 +++++++++++++++++++ 6 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/084_brand_praktika_fill.sql create mode 100644 tradein-mvp/frontend/src/lib/useBrand.ts diff --git a/tradein-mvp/backend/data/sql/084_brand_praktika_fill.sql b/tradein-mvp/backend/data/sql/084_brand_praktika_fill.sql new file mode 100644 index 00000000..976286c5 --- /dev/null +++ b/tradein-mvp/backend/data/sql/084_brand_praktika_fill.sql @@ -0,0 +1,25 @@ +-- 084_brand_praktika_fill.sql +-- #657 White-label для клиента «Практика» (gk-praktika.ru). +-- +-- ПРОБЛЕМА: seed бренда 'praktika' в 002_core_tables.sql задан заглушкой — +-- primary_color='#e87722' (orange, НЕ из брендбука клиента), logo_url/accent NULL. +-- Реальные фирменные цвета ГК «Практика» (с gk-praktika.ru): +-- primary #235F49 — тёмно-зелёный (основной), +-- accent #1FCECB — циан (акцент). +-- Логотип хотлинкается с сайта клиента (magenta-метка logo-pink.svg). +-- +-- Идемпотентно: UPDATE по PK slug='praktika'. Безопасно ре-применять. +-- +-- NUMBERING: highest на main = 082; PR #656 занимает 083 → используем 084, +-- чтобы избежать коллизии нумерации (см. PR body). + +BEGIN; + +UPDATE brands +SET primary_color = '#235F49', -- тёмно-зелёный (бренд) + accent_color = '#1FCECB', -- циан (акцент) + logo_url = 'https://gk-praktika.ru/images/logo/logo-pink.svg', -- hotlink с сайта клиента + footer_text = 'ГК «Практика» · Екатеринбург' +WHERE slug = 'praktika'; + +COMMIT; diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 58f4c47c..8580b179 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -5,7 +5,7 @@ * С basePath=/trade-in user видит её на gendsgn.ru/trade-in/. * Использует CSS из /components/trade-in/trade-in.css. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; @@ -29,6 +29,7 @@ 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"; +import { useBrand, getActiveBrandSlug } from "@/lib/useBrand"; function useEstimateId() { if (typeof window === "undefined") return null; @@ -41,6 +42,27 @@ export default function TradeInPage() { const queryClient = useQueryClient(); const quota = useQuota(); + // #657 white-label: «отдельная страничка под Практику» = re-skin того же + // UI при ?brand=praktika. Бренд резолвится из ?brand= slug (см. useBrand.ts). + const { data: brand } = useBrand(); + const activeBrandSlug = getActiveBrandSlug(); + + // Переопределяем CSS-переменные палитры в рантайме + вешаем .brand-active + // (переключает --font-sans на Manrope, см. trade-in.css). Cleanup снимает + // overrides → без ?brand= дефолтный UI полностью нетронут (no-op). + useEffect(() => { + const root = document.documentElement; + if (!brand) return; + root.style.setProperty("--accent", brand.primary_color); // зелёный (бренд) + root.style.setProperty("--accent-2", brand.accent_color); // циан (акцент) + root.classList.add("brand-active"); + return () => { + root.style.removeProperty("--accent"); + root.style.removeProperty("--accent-2"); + root.classList.remove("brand-active"); + }; + }, [brand]); + const blocked = quota.data ? !quota.data.unlimited && quota.data.remaining <= 0 : false; @@ -205,7 +227,7 @@ export default function TradeInPage() { - + ) : (
diff --git a/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx b/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx index 70052aae..0e8dfdaf 100644 --- a/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx @@ -9,13 +9,18 @@ import { API_BASE_URL } from "@/lib/api"; interface Props { estimate: AggregatedEstimate; + /** #657 white-label: активный ?brand= slug — прокидывается в PDF endpoint. */ + brandSlug?: string | null; } function fmtRub(v: number): string { return Math.round(v).toLocaleString("ru-RU"); } -export function OfferCard({ estimate }: Props) { +export function OfferCard({ estimate, brandSlug }: Props) { + // PDF endpoint (api/v1/trade_in.py) уже honor'ит ?brand= для white-label. + // Без бренда query пустой → стандартный PDF. + const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : ""; const basePrice = estimate.median_price_rub; const baseMln = basePrice / 1_000_000; // Расчёт издержек самостоятельной продажи (от рыночной цены) @@ -175,7 +180,7 @@ export function OfferCard({ estimate }: Props) {
diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx index 63117a55..a2737747 100644 --- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx @@ -1,8 +1,11 @@ "use client"; +/* eslint-disable @next/next/no-img-element -- white-label логотип хотлинком с сайта клиента, next/image не нужен */ + import { UserMenu } from "@/components/auth/UserMenu"; import { API_BASE_URL, HTTPError } from "@/lib/api"; import { isPathAllowed } from "@/lib/isPathAllowed"; +import { useBrand } from "@/lib/useBrand"; import { useMe } from "@/lib/useMe"; type ActiveTab = @@ -72,6 +75,9 @@ const NAV_ITEMS: Array<{ export function Topbar({ active }: TopbarProps) { const { data, error } = useMe(); + // #657 white-label: при активном ?brand= с логотипом рендерим логотип + // клиента вместо «TI»-метки. Без бренда brand === null → стандартный wordmark. + const { data: brand } = useBrand(); // 401 (dev без Caddy basic_auth) — показываем все айтемы. // Если /me ещё грузится или дал ошибку без scope — fallback на все айтемы @@ -88,8 +94,17 @@ export function Topbar({ active }: TopbarProps) {
- TI - Trade-In + {brand?.logo_url ? ( + <> + {brand.name} + {brand.name} + + ) : ( + <> + TI + Trade-In + + )}