12 changed files with 76 additions and 57 deletions
|
|
@ -40,8 +40,7 @@ class TradeInEstimateInput(BaseModel):
|
|||
# CRM-поля (#395) — операционные, на расчёт оценки не влияют
|
||||
ownership_type: str | None = Field(default=None, max_length=100)
|
||||
has_mortgage: bool | None = None
|
||||
client_name: str | None = Field(default=None, max_length=200)
|
||||
client_phone: str | None = Field(default=None, max_length=50)
|
||||
# client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167).
|
||||
|
||||
|
||||
class AnalogLot(BaseModel):
|
||||
|
|
|
|||
|
|
@ -3302,7 +3302,7 @@ async def estimate_quality(
|
|||
id, address, lat, lon,
|
||||
area_m2, rooms, floor, total_floors,
|
||||
year_built, house_type, repair_state, has_balcony,
|
||||
ownership_type, has_mortgage, client_name, client_phone,
|
||||
ownership_type, has_mortgage,
|
||||
median_price, range_low, range_high, median_price_per_m2,
|
||||
confidence, confidence_explanation, n_analogs,
|
||||
analogs, actual_deals,
|
||||
|
|
@ -3319,7 +3319,7 @@ async def estimate_quality(
|
|||
:address, :lat, :lon,
|
||||
:area, :rooms, :floor, :total_floors,
|
||||
:year_built, :house_type, :repair_state, :has_balcony,
|
||||
:ownership_type, :has_mortgage, :client_name, :client_phone,
|
||||
:ownership_type, :has_mortgage,
|
||||
:median_price, :range_low, :range_high, :median_ppm2,
|
||||
:confidence, :explanation, :n_analogs,
|
||||
CAST(:analogs_json AS jsonb),
|
||||
|
|
@ -3352,8 +3352,6 @@ async def estimate_quality(
|
|||
"has_balcony": payload.has_balcony,
|
||||
"ownership_type": payload.ownership_type,
|
||||
"has_mortgage": payload.has_mortgage,
|
||||
"client_name": payload.client_name,
|
||||
"client_phone": payload.client_phone,
|
||||
"median_price": median_price,
|
||||
"range_low": range_low,
|
||||
"range_high": range_high,
|
||||
|
|
@ -5344,7 +5342,7 @@ def _empty_estimate(
|
|||
id, address,
|
||||
area_m2, rooms, floor, total_floors,
|
||||
year_built, house_type, repair_state, has_balcony,
|
||||
ownership_type, has_mortgage, client_name, client_phone,
|
||||
ownership_type, has_mortgage,
|
||||
median_price, range_low, range_high, median_price_per_m2,
|
||||
confidence, confidence_explanation, n_analogs,
|
||||
analogs, actual_deals,
|
||||
|
|
@ -5355,7 +5353,7 @@ def _empty_estimate(
|
|||
CAST(:id AS uuid), :address,
|
||||
:area, :rooms, :floor, :total_floors,
|
||||
:year_built, :house_type, :repair_state, :has_balcony,
|
||||
:ownership_type, :has_mortgage, :client_name, :client_phone,
|
||||
:ownership_type, :has_mortgage,
|
||||
0, 0, 0, 0,
|
||||
'low', :explanation, 0,
|
||||
'[]'::jsonb, '[]'::jsonb,
|
||||
|
|
@ -5378,8 +5376,6 @@ def _empty_estimate(
|
|||
"has_balcony": payload.has_balcony,
|
||||
"ownership_type": payload.ownership_type,
|
||||
"has_mortgage": payload.has_mortgage,
|
||||
"client_name": payload.client_name,
|
||||
"client_phone": payload.client_phone,
|
||||
"explanation": reason,
|
||||
"created_by": created_by,
|
||||
"expires_at": expires_at,
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@ LISTING_FIELD_PRIORITY: dict[str, list[str] | str] = {
|
|||
"combined_wcs_count": ["cian_detail"],
|
||||
"room_type": ["cian_detail", "avito_detail"],
|
||||
"has_furniture": ["cian_serp", "avito_detail"],
|
||||
"phones": ["cian_serp"],
|
||||
"description": ["cian_serp", "avito_detail", "yandex_detail"],
|
||||
"photo_urls": "union",
|
||||
# Avito Domoteka unique
|
||||
|
|
|
|||
|
|
@ -934,7 +934,11 @@ class CianScraper(BaseScraper):
|
|||
repair_state: str | None = normalize_repair_state(offer.get("decoration"))
|
||||
|
||||
# ── Продавец ─────────────────────────────────────────────────────
|
||||
phones: list[dict[str, Any]] = offer.get("phones") or []
|
||||
# PII purge (#2217): телефоны продавцов больше не сохраняем.
|
||||
# ScrapedLot.phones остаётся в модели (см. base.py), но на вход
|
||||
# всегда передаём пустой список — write-path на listings.phones
|
||||
# выключен здесь, а не на уровне upsert.
|
||||
phones: list[dict[str, Any]] = []
|
||||
is_homeowner: bool | None = offer.get("isByHomeowner")
|
||||
is_pro_seller: bool | None = offer.get("isPro")
|
||||
|
||||
|
|
|
|||
26
tradein-mvp/backend/data/sql/166_purge_listings_phones.sql
Normal file
26
tradein-mvp/backend/data/sql/166_purge_listings_phones.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- 166_purge_listings_phones.sql
|
||||
--
|
||||
-- PII PURGE (#2217): телефоны продавцов из listings.phones.
|
||||
-- ПРОДУКТОВОЕ РЕШЕНИЕ (user): колонку НЕ дропаем — обнуляем данные и
|
||||
-- останавливаем write-path у скрейперов (cian.py / scraper-kit serp.py
|
||||
-- больше не читают offer.get("phones") — см. #2217 backend PR). Колонка
|
||||
-- остаётся в схеме (и в модели ScrapedLot) как задел на будущее, просто
|
||||
-- больше никогда не заполняется непустым значением.
|
||||
--
|
||||
-- ЧТО ДЕЛАЕТ:
|
||||
-- UPDATE listings SET phones = NULL — вычищает все ранее сохранённые
|
||||
-- телефоны продавцов (единственный источник, который их писал — Cian SERP).
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ:
|
||||
-- WHERE phones IS NOT NULL -> повторный прогон трогает 0 строк.
|
||||
--
|
||||
-- НЕ ТРОГАЕТ: management_companies.phones (text[], миграция 021) — контакты
|
||||
-- УК/ТСЖ, другая сущность, вне scope этого issue.
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE listings
|
||||
SET phones = NULL
|
||||
WHERE phones IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
24
tradein-mvp/backend/data/sql/167_drop_client_pii.sql
Normal file
24
tradein-mvp/backend/data/sql/167_drop_client_pii.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- 167_drop_client_pii.sql
|
||||
--
|
||||
-- PII PURGE (#1969): client_name / client_phone в trade_in_estimates.
|
||||
-- ПРОДУКТОВОЕ РЕШЕНИЕ (user): DROP COLUMN (не NULL-обнуление, как для
|
||||
-- listings.phones в 166 — здесь колонки не нужны совсем, CRM-поля
|
||||
-- ownership_type/has_mortgage остаются). client_email физически не
|
||||
-- существует (только имя в Sentry-scrub allowlist app/observability/
|
||||
-- sentry_scrub.py) — трогать нечего кроме этих двух реальных колонок.
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: нет VIEW / индексов на эти колонки (проверено разведкой
|
||||
-- перед миграцией) — DROP безопасен.
|
||||
--
|
||||
-- Backend-код (app/schemas/trade_in.py, app/services/estimator.py — оба
|
||||
-- INSERT INTO trade_in_estimates) деплоится синхронно с этой миграцией.
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ: DROP COLUMN IF EXISTS -> повторный прогон no-op.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
DROP COLUMN IF EXISTS client_name,
|
||||
DROP COLUMN IF EXISTS client_phone;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -170,3 +170,5 @@
|
|||
163_disable_deactivate_stale_domklik.sql
|
||||
164_yandex_url_canonicalize_active_dups.sql
|
||||
165_remove_n1_source.sql
|
||||
166_purge_listings_phones.sql
|
||||
167_drop_client_pii.sql
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@ def _cian_lot(**overrides) -> ScrapedLot:
|
|||
"description_minhash": "abc123def456abc1",
|
||||
"cadastral_number": "66:41:0204016:1234",
|
||||
"building_cadastral_number": "66:41:0204016:100",
|
||||
"phones": [{"countryCode": "7", "number": "9012345678"}],
|
||||
# PII purge (#2217): phones больше не заполняется — по умолчанию пусто
|
||||
# (ScrapedLot.phones default_factory=list, сериализуется в None).
|
||||
"is_homeowner": True,
|
||||
"is_pro_seller": False,
|
||||
"bargain_allowed": True,
|
||||
|
|
@ -163,10 +164,10 @@ def test_save_listings_persists_cian_specific_fields():
|
|||
assert params["bargain_allowed"] is True
|
||||
assert params["sale_type"] == "free"
|
||||
|
||||
# phones и metro_stations — JSON-строки, проверяем десериализацией
|
||||
phones = json.loads(params["phones"])
|
||||
assert phones == [{"countryCode": "7", "number": "9012345678"}]
|
||||
# PII purge (#2217): phones больше не заполняется cian-фикстурой → None.
|
||||
assert params["phones"] is None
|
||||
|
||||
# metro_stations — JSON-строка, проверяем десериализацией
|
||||
metro = json.loads(params["metro_stations"])
|
||||
assert metro[0]["name"] == "Геологическая"
|
||||
assert metro[0]["time"] == 10
|
||||
|
|
|
|||
|
|
@ -265,7 +265,8 @@ def test_cian_serp_description_minhash_list_int():
|
|||
|
||||
|
||||
def test_cian_serp_phones():
|
||||
"""phones — список dicts из state."""
|
||||
"""PII purge (#2217): даже если raw offer содержит phones, lot.phones пуст —
|
||||
скрейпер больше не читает offer.get("phones")."""
|
||||
offer = _make_full_offer(has_phones=True)
|
||||
html = _make_cian_serp_html([offer])
|
||||
|
||||
|
|
@ -274,9 +275,7 @@ def test_cian_serp_phones():
|
|||
|
||||
lot = lots[0]
|
||||
assert isinstance(lot.phones, list)
|
||||
assert len(lot.phones) == 1
|
||||
assert lot.phones[0]["countryCode"] == "7"
|
||||
assert lot.phones[0]["number"] == "9012345678"
|
||||
assert lot.phones == []
|
||||
|
||||
|
||||
def test_cian_serp_house_ext_id_newbuilding():
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ interface FormState {
|
|||
has_balcony: boolean;
|
||||
ownership_type: string;
|
||||
has_mortgage: boolean;
|
||||
client_name: string;
|
||||
client_phone: string;
|
||||
}
|
||||
|
||||
const INITIAL: FormState = {
|
||||
|
|
@ -44,8 +42,6 @@ const INITIAL: FormState = {
|
|||
has_balcony: false,
|
||||
ownership_type: "",
|
||||
has_mortgage: false,
|
||||
client_name: "",
|
||||
client_phone: "",
|
||||
};
|
||||
|
||||
function validate(f: FormState): string | null {
|
||||
|
|
@ -100,8 +96,6 @@ function toPayload(
|
|||
if (rs) out.repair_state = rs;
|
||||
if (f.ownership_type) out.ownership_type = f.ownership_type;
|
||||
if (f.has_mortgage) out.has_mortgage = true;
|
||||
if (f.client_name.trim()) out.client_name = f.client_name.trim();
|
||||
if (f.client_phone.trim()) out.client_phone = f.client_phone.trim();
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -389,34 +383,6 @@ export function EstimateForm({
|
|||
<span style={{ color: "var(--fg)" }}>Ипотека / обременение</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="cname">
|
||||
Имя клиента <span className="hint">опц.</span>
|
||||
</label>
|
||||
<input
|
||||
className="control"
|
||||
id="cname"
|
||||
type="text"
|
||||
placeholder="Иван Петров"
|
||||
value={form.client_name}
|
||||
onChange={field("client_name")}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="cphone">
|
||||
Телефон <span className="hint">опц.</span>
|
||||
</label>
|
||||
<input
|
||||
className="control"
|
||||
id="cphone"
|
||||
type="tel"
|
||||
placeholder="+7 999 …"
|
||||
value={form.client_phone}
|
||||
onChange={field("client_phone")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -62,8 +62,7 @@ export interface TradeInEstimateInput {
|
|||
// CRM-поля (#395)
|
||||
ownership_type?: string;
|
||||
has_mortgage?: boolean;
|
||||
client_name?: string;
|
||||
client_phone?: string;
|
||||
// client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167).
|
||||
// Координаты из автокомплита — позволяют бэкенду пропустить геокодинг
|
||||
// для адресов, которые DaData/Yandex не резолвят по строке.
|
||||
lat?: number | null;
|
||||
|
|
|
|||
|
|
@ -878,7 +878,11 @@ class CianScraper(BaseScraper):
|
|||
repair_state: str | None = normalize_repair_state(offer.get("decoration"))
|
||||
|
||||
# ── Продавец ─────────────────────────────────────────────────────
|
||||
phones: list[dict[str, Any]] = offer.get("phones") or []
|
||||
# PII purge (#2217): телефоны продавцов больше не сохраняем.
|
||||
# ScrapedLot.phones остаётся в модели (см. base.py), но на вход
|
||||
# всегда передаём пустой список — write-path на listings.phones
|
||||
# выключен здесь, а не на уровне upsert.
|
||||
phones: list[dict[str, Any]] = []
|
||||
is_homeowner: bool | None = offer.get("isByHomeowner")
|
||||
is_pro_seller: bool | None = offer.get("isPro")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue