Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0c7b2fa5 | ||
|
|
69f052810c | ||
|
|
ed78a17109 | ||
|
|
0e592ea404 | ||
|
|
56ce498eac | ||
|
|
05d1f1616f | ||
|
|
1d403883a0 | ||
|
|
6183fb3417 | ||
|
|
f2ad5ecfa3 | ||
|
|
7386e7bea8 | ||
|
|
5de8ea939a | ||
|
|
97660833ab |
@@ -68,11 +68,14 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Check Python dependencies for security issues
|
||||
cache: pip
|
||||
- name: Install pip-audit and check dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -r apps/api/requirements.txt
|
||||
pip audit --requirement apps/api/requirements.txt || true
|
||||
pip install pip-audit
|
||||
pip-audit --requirement apps/api/requirements-lock.txt
|
||||
# Also check dev dependencies
|
||||
pip-audit --requirement apps/api/requirements-dev-lock.txt
|
||||
|
||||
compose-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -25,7 +25,18 @@ def map_observation(
|
||||
raise ExternalReviewError("published observation cannot be remapped")
|
||||
observation.fish = fish
|
||||
observation.waterbody = waterbody
|
||||
observation.review_note = note
|
||||
# A07: Explain the matching method in review_note
|
||||
match_method = []
|
||||
if observation.fish_external_id:
|
||||
match_method.append(f"external_id={observation.fish_external_id}")
|
||||
elif observation.fish_name:
|
||||
match_method.append(f"name={observation.fish_name}")
|
||||
if observation.waterbody_external_id:
|
||||
match_method.append(f"wb_external_id={observation.waterbody_external_id}")
|
||||
elif observation.waterbody_name:
|
||||
match_method.append(f"wb_name={observation.waterbody_name}")
|
||||
method_explanation = f"matched via {', '.join(match_method)}"
|
||||
observation.review_note = f"{method_explanation}" + (f"; {note}" if note else "")
|
||||
observation.reviewed_at = datetime.now(timezone.utc)
|
||||
observation.status = "ready" if _complete(observation) else "mapped"
|
||||
_save_alias(session, observation, "fish", observation.fish_external_id or observation.fish_name, fish=fish)
|
||||
|
||||
@@ -72,11 +72,14 @@ def readiness_report(
|
||||
|
||||
# Community scheduler health — diagnostic only, never blocks readiness (A01)
|
||||
# Track per-source health with rotation, backoff, last success, and stalled attempts
|
||||
# Overall status reflects worst-case source health (success of one does not mask failure of another)
|
||||
try:
|
||||
enabled_sources = list(session.scalars(
|
||||
select(DataSource).where(DataSource.enabled.is_(True)).order_by(DataSource.key)
|
||||
))
|
||||
source_health: dict[str, dict[str, object]] = {}
|
||||
has_any_failure = False
|
||||
has_any_success = False
|
||||
for source in enabled_sources:
|
||||
latest_run = session.scalar(
|
||||
select(CommunityImportRun)
|
||||
@@ -108,7 +111,18 @@ def readiness_report(
|
||||
"backoff_recommended": recent_failures >= 5,
|
||||
"blocking": False,
|
||||
}
|
||||
components["community_scheduler"] = {"status": "ready", "sources": source_health}
|
||||
if healthy:
|
||||
has_any_success = True
|
||||
elif latest_run.status == "failed":
|
||||
has_any_failure = True
|
||||
# Overall status: "degraded" if any source failed, "ready" if all healthy, "stale" if no failures but stale
|
||||
if has_any_failure:
|
||||
scheduler_status = "degraded"
|
||||
elif has_any_success:
|
||||
scheduler_status = "ready"
|
||||
else:
|
||||
scheduler_status = "stale" if enabled_sources else "not_started"
|
||||
components["community_scheduler"] = {"status": scheduler_status, "sources": source_health}
|
||||
except Exception:
|
||||
components["community_scheduler"] = {"status": "unknown", "sources": {}}
|
||||
|
||||
|
||||
@@ -102,3 +102,84 @@ def test_rate_limit_uses_forwarded_for_from_trusted_proxy() -> None:
|
||||
mock_other_forwarded.client.host = "127.0.0.1"
|
||||
mock_other_forwarded.headers.get.return_value = "198.51.100.50"
|
||||
_check_rate_limit(mock_other_forwarded, db) # Should succeed
|
||||
|
||||
def test_rate_limit_independent_limits_for_two_clients_through_proxy() -> None:
|
||||
"""Two clients behind trusted proxy should have independent rate limits."""
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
with patch("app.main.settings") as mock_settings:
|
||||
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||
# Trusted proxy is the Astro container IP
|
||||
mock_settings.trusted_proxy_cidrs = ["172.17.0.0/16"] # Docker network
|
||||
|
||||
# Client 1: 198.51.100.10
|
||||
mock_client1 = MagicMock()
|
||||
mock_client1.client.host = "172.17.0.3" # Astro container
|
||||
mock_client1.headers.get.return_value = "198.51.100.10"
|
||||
|
||||
# Client 2: 198.51.100.20
|
||||
mock_client2 = MagicMock()
|
||||
mock_client2.client.host = "172.17.0.3" # Same Astro container
|
||||
mock_client2.headers.get.return_value = "198.51.100.20"
|
||||
|
||||
# Client 1 makes 5 requests
|
||||
for _ in range(5):
|
||||
_check_rate_limit(mock_client1, db)
|
||||
|
||||
# Client 1 should be blocked
|
||||
with pytest.raises(HTTPException) as blocked:
|
||||
_check_rate_limit(mock_client1, db)
|
||||
assert blocked.value.status_code == 429
|
||||
|
||||
# Client 2 should still be allowed (independent limit)
|
||||
_check_rate_limit(mock_client2, db) # Should succeed
|
||||
|
||||
|
||||
def test_forged_xff_rejected_on_untrusted_port() -> None:
|
||||
"""XFF should be rejected when connection is not from trusted proxy."""
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
with patch("app.main.settings") as mock_settings:
|
||||
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||
# Only trust Docker network, NOT direct connections
|
||||
mock_settings.trusted_proxy_cidrs = ["172.17.0.0/16"]
|
||||
|
||||
# Direct connection with forged XFF
|
||||
mock_direct = MagicMock()
|
||||
mock_direct.client.host = "203.0.113.50" # Not in trusted CIDR
|
||||
mock_direct.headers.get.return_value = "10.0.0.1" # Forged XFF
|
||||
|
||||
# Should use real client 203.0.113.50, not forged 10.0.0.1
|
||||
for i in range(3):
|
||||
_check_rate_limit(mock_direct, db)
|
||||
|
||||
# Another request from same real client should count
|
||||
mock_direct2 = MagicMock()
|
||||
mock_direct2.client.host = "203.0.113.50"
|
||||
mock_direct2.headers.get.return_value = "10.0.0.2" # Different forged XFF
|
||||
_check_rate_limit(mock_direct2, db) # Should succeed (4th request from 203.0.113.50)
|
||||
|
||||
|
||||
def test_direct_access_without_xff_header() -> None:
|
||||
"""Direct access without X-Forwarded-For should use real client IP."""
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
with patch("app.main.settings") as mock_settings:
|
||||
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||
mock_settings.trusted_proxy_cidrs = ["127.0.0.1/32"]
|
||||
|
||||
# Direct connection without XFF
|
||||
mock_direct = MagicMock()
|
||||
mock_direct.client.host = "192.168.1.100"
|
||||
mock_direct.headers.get.return_value = None # No XFF
|
||||
|
||||
# Should use real client 192.168.1.100
|
||||
_check_rate_limit(mock_direct, db)
|
||||
|
||||
attempts = list(db.scalars(select(SubmissionAttempt)))
|
||||
assert len(attempts) == 1
|
||||
# Hash should be of the real IP, not empty
|
||||
assert len(attempts[0].client_hash) == 64
|
||||
|
||||
@@ -122,8 +122,8 @@ def test_community_scheduler_stale_does_not_block_readiness() -> None:
|
||||
session, AvailableStorage(), import_required=False,
|
||||
import_interval_seconds=3600, community_import_interval_seconds=1800, now=now,
|
||||
)
|
||||
assert ready is True # A01: stale does NOT block
|
||||
assert components["community_scheduler"]["status"] == "ready"
|
||||
assert ready is True # A01: stale does NOT block readiness
|
||||
assert components["community_scheduler"]["status"] == "stale" # Overall reflects stale source
|
||||
assert components["community_scheduler"]["sources"]["rf4db"]["status"] == "stale"
|
||||
assert components["community_scheduler"]["sources"]["rf4db"]["blocking"] is False
|
||||
|
||||
@@ -148,8 +148,8 @@ def test_community_scheduler_failed_does_not_block_readiness() -> None:
|
||||
session, AvailableStorage(), import_required=False,
|
||||
import_interval_seconds=3600, community_import_interval_seconds=1800, now=now,
|
||||
)
|
||||
assert ready is True # A01: failed does NOT block
|
||||
assert components["community_scheduler"]["status"] == "ready"
|
||||
assert ready is True # A01: failed does NOT block readiness
|
||||
assert components["community_scheduler"]["status"] == "degraded" # Overall reflects failed source
|
||||
assert components["community_scheduler"]["sources"]["rf4db"]["status"] == "failed"
|
||||
assert components["community_scheduler"]["sources"]["rf4db"]["blocking"] is False
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const {
|
||||
noindex = false,
|
||||
image = "/og-rf4spotter.png",
|
||||
structuredData = null,
|
||||
errorPage = false,
|
||||
} = Astro.props;
|
||||
const path = Astro.url.pathname;
|
||||
const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru";
|
||||
@@ -26,9 +27,9 @@ const canonical = new URL(path, siteUrl).toString();
|
||||
const socialImage = new URL(image, siteUrl).toString();
|
||||
const preventIndexing = noindex || path.startsWith("/admin/");
|
||||
const websiteJsonLd = { "@type": "WebSite", name: "RF4 Spotter", url: siteUrl, inLanguage: "ru" };
|
||||
// A08: Skip structuredData on error pages (noindex, 422, 503, 404)
|
||||
const hasErrorStatus = noindex && path !== "/";
|
||||
const jsonLdGraph = (structuredData && !hasErrorStatus) ? [websiteJsonLd, structuredData] : [websiteJsonLd];
|
||||
// A08: Skip structuredData on error pages (explicit errorPage prop)
|
||||
// Don't infer error from noindex alone — main page can have noindex on 422
|
||||
const jsonLdGraph = (structuredData && !errorPage) ? [websiteJsonLd, structuredData] : [websiteJsonLd];
|
||||
const jsonLd = JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@graph": jsonLdGraph,
|
||||
|
||||
@@ -57,7 +57,7 @@ const datasetJsonLd = {
|
||||
creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true,
|
||||
};
|
||||
---
|
||||
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd} noindex={showNoIndex}>
|
||||
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd} noindex={showNoIndex} errorPage={filterError}>
|
||||
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.webp" alt="Туманное озеро на рассвете" width="1774" height="887" fetchpriority="high"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
|
||||
<section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results">
|
||||
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
|
||||
@@ -69,7 +69,7 @@ const datasetJsonLd = {
|
||||
<button>⌕ Найти клёв</button>
|
||||
</form></section>
|
||||
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
|
||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{items.length < totalItems && <a class="load-more" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><FishingIcon name="lure" size={25}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||
</section>
|
||||
{signals.length > 0 && <SignalFeed signals={signals}/>}
|
||||
|
||||
@@ -22,11 +22,17 @@ const reportId = Astro.url.searchParams.get("report_id");
|
||||
</form>}
|
||||
<script is:inline define:vars={{ state }}>
|
||||
const form = document.querySelector(".report-form"); const key = "rf4-report-draft";
|
||||
// A05: Safe sessionStorage access with error handling
|
||||
const safeStorage = {
|
||||
getItem: (k) => { try { return sessionStorage.getItem(k); } catch { return null; } },
|
||||
setItem: (k, v) => { try { sessionStorage.setItem(k, v); } catch {} },
|
||||
removeItem: (k) => { try { sessionStorage.removeItem(k); } catch {} },
|
||||
};
|
||||
// A05: Restore draft on all error states that don't destroy the submission
|
||||
const recoverableStates = ["create_error", "rate_limited", "server_error", "timeout"];
|
||||
if (form && recoverableStates.includes(state)) {
|
||||
try {
|
||||
const draft = JSON.parse(sessionStorage.getItem(key) || "{}");
|
||||
const draft = JSON.parse(safeStorage.getItem(key) || "{}");
|
||||
for (const [name, value] of Object.entries(draft)) {
|
||||
const field = form.elements.namedItem(name);
|
||||
if (field && "value" in field) field.value = value;
|
||||
@@ -34,12 +40,18 @@ const reportId = Astro.url.searchParams.get("report_id");
|
||||
} catch {}
|
||||
document.querySelector("#form-error")?.focus();
|
||||
}
|
||||
if (state === "sent" || state === "screenshot_sent") sessionStorage.removeItem(key);
|
||||
if (state === "sent" || state === "screenshot_sent") safeStorage.removeItem(key);
|
||||
form?.addEventListener("submit", () => {
|
||||
const btn = form.querySelector("button[type=submit]");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Отправка..."; }
|
||||
const draft = {}; for (const [name, value] of new FormData(form)) if (typeof value === "string" && name !== "website") draft[name] = value;
|
||||
sessionStorage.setItem(key, JSON.stringify(draft));
|
||||
// A05: Save draft before submit, handle sessionStorage unavailable
|
||||
const draft = {};
|
||||
try {
|
||||
for (const [name, value] of new FormData(form)) {
|
||||
if (typeof value === "string" && name !== "website") draft[name] = value;
|
||||
}
|
||||
safeStorage.setItem(key, JSON.stringify(draft));
|
||||
} catch {}
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
.skip-link{position:fixed;z-index:100;left:12px;top:12px;padding:12px 16px;background:var(--lime);color:var(--deep);font-weight:750;transform:translateY(-150%)}.skip-link:focus{transform:none}:focus-visible{outline:3px solid #7da529;outline-offset:3px}.topbar nav a[aria-current="page"]{font-weight:750}.notice:focus{outline:3px solid #9d3529;outline-offset:3px}
|
||||
.topbar{height:86px;width:min(1480px,calc(100% - 48px));margin:auto;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;min-width:max-content}.brand-mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid #9eb0a7;border-radius:50%;background:var(--deep);color:var(--lime);font-size:28px}.brand-name{display:flex;flex-direction:column;font:17px/.95 Georgia,serif}.brand-name strong{font-size:20px;font-style:italic}.topbar nav{height:100%;display:flex;gap:23px;align-items:center}.topbar nav a{height:100%;display:flex;align-items:center;gap:7px;padding:0 8px;text-decoration:none;color:#526461;font-size:14px;border-bottom:3px solid transparent}.topbar nav a.active{color:var(--deep);border-color:var(--deep)}.live-badge{justify-self:end;display:flex;align-items:center;gap:9px;color:#60716e;font-size:13px}.live-badge>span{width:8px;height:8px;border-radius:50%;background:#83b83a;box-shadow:0 0 0 5px #83b83a20}
|
||||
.eyebrow,.overline{text-transform:uppercase;letter-spacing:.14em;font-size:12px;font-weight:750;color:#657673}.eyebrow{display:flex;align-items:center;gap:10px}.eyebrow b{display:grid;place-items:center;min-width:41px;height:25px;padding:0 8px;border:1px solid #9aaa9f;border-radius:20px;color:var(--deep);letter-spacing:.05em}.intro{padding:74px 0 42px;display:grid;grid-template-columns:.92fr 1.08fr;align-items:end;gap:70px}.intro h1,.records-hero h1,.form-hero h1,.spot-hero h1{margin:18px 0 22px;font:400 clamp(58px,6.3vw,102px)/.87 Georgia,"Times New Roman",serif;letter-spacing:-.065em}.intro h1 em,.records-hero h1 em,.form-hero h1 em{color:#497074;font-weight:400}.intro-copy>p{font-size:18px;line-height:1.55;color:#586a68;max-width:560px}.lake-card{position:relative;border-radius:18px;overflow:hidden;height:326px;box-shadow:0 24px 70px #173c3f24}.lake-card img{width:100%;height:100%;object-fit:cover;display:block}.lake-card:after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 35%,#041c20d9)}.lake-overlay{position:absolute;z-index:2;inset:auto 24px 22px 26px;display:flex;justify-content:space-between;align-items:end;color:#fff}.lake-overlay .overline{color:#d2dedb;display:block;margin-bottom:7px}.lake-overlay strong{font:400 21px Georgia,serif}.pulse-orb{width:45px;height:45px;border:1px solid #ffffff7d;border-radius:50%;display:grid;place-items:center}.pulse-orb span{width:9px;height:9px;border-radius:50%;background:var(--lime);box-shadow:0 0 0 8px #c9f45b25}
|
||||
.filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}.filter-advanced-field{display:contents}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:contents}.filter-advanced-fallback summary{display:none}.filter-advanced-fallback .advanced-fields{display:contents}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.active-filters{min-height:56px;display:flex;align-items:center;gap:8px;padding-top:10px}.active-filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px}.active-filters a{margin-left:auto;color:#4d625e;font-size:13px;text-underline-offset:3px}
|
||||
.filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}.filter-advanced-field{display:flex;align-items:center;gap:6px}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:contents}.filter-advanced-fallback summary{display:none}.filter-advanced-fallback .advanced-fields{display:contents}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.active-filters{min-height:56px;display:flex;align-items:center;gap:8px;padding-top:10px}.active-filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px}.active-filters a{margin-left:auto;color:#4d625e;font-size:13px;text-underline-offset:3px}
|
||||
.dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(360px,.75fr);gap:26px;padding:66px 0 104px;align-items:start}.section-heading{display:flex;justify-content:space-between;align-items:end;margin:0 0 23px}.section-heading h2,.detail-head h2,.how-it-works h2{font:400 38px Georgia,serif;margin:6px 0 0;letter-spacing:-.04em}.result-count{border:1px solid #b9c6bf;color:#667875;font-size:13px;padding:7px 11px;border-radius:20px}.spot-list{display:flex;flex-direction:column;gap:12px}.spot-card{position:relative;display:grid;grid-template-columns:45px 1fr 190px;gap:18px;padding:22px 48px 22px 20px;border:1px solid #d5ded7;border-radius:16px;background:#fbfcf9;text-decoration:none;transition:.22s ease}.spot-card:hover{transform:translateY(-2px);border-color:#7d9488;box-shadow:0 18px 40px #14333812;background:#fff}.spot-rank{width:36px;height:36px;border:1px solid #cad4cd;border-radius:50%;display:grid;place-items:center;font:italic 14px Georgia,serif;color:#788985}.spot-topline{display:flex;align-items:center;gap:12px;color:#687a77;font-size:13px}.activity-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:30px;background:#e6f7c3;color:#426315;font-size:11px;font-weight:750}.activity-pill i{width:6px;height:6px;border-radius:50%;background:#6d9f32}.spot-main h3{font:400 25px Georgia,serif;margin:8px 0}.spot-meta{display:flex;gap:16px;color:#71817f;font-size:13px}.bait-line{display:flex;gap:11px;align-items:center;margin-top:18px;padding-top:15px;border-top:1px solid #e2e8e2}.lure-dot,.best-lure i{display:block;width:12px;height:28px;border-radius:50% 50% 43% 43%;background:var(--orange);transform:rotate(22deg);box-shadow:inset -4px 0 #091e2250}.bait-line div{display:flex;flex-direction:column;gap:2px}.bait-line div span{color:#82908e;font-size:11px;text-transform:uppercase;letter-spacing:.09em}.bait-line strong{font-size:14px}.spot-stats{border-left:1px solid #e2e8e2;padding-left:18px;display:grid;grid-template-columns:1fr 1fr;gap:18px 10px;align-content:center}.spot-stats div{display:flex;flex-direction:column}.spot-stats strong{font:400 20px Georgia,serif}.spot-stats span{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#7c8d89}.card-arrow{position:absolute;right:18px;top:50%;transform:translateY(-50%);font-size:28px;color:#91a09c}
|
||||
.detail-card{position:sticky;top:116px;margin:0;background:var(--deep);color:#f5f8f3;border:0;border-radius:18px;padding:27px;overflow:hidden}.detail-card:before{content:"";position:absolute;width:420px;height:420px;border:1px solid #ffffff0f;border-radius:50%;left:48%;top:-210px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff05}.detail-card>*{position:relative;z-index:1}.detail-head{display:flex;justify-content:space-between}.detail-head .overline,.best-lure .overline{color:#a7b6b3}.detail-head h2{font-size:30px}.detail-head h2 em{color:var(--lime);font-size:22px}.detail-head>a{width:40px;height:40px;display:grid;place-items:center;border:1px solid #ffffff34;border-radius:50%;text-decoration:none}.detail-score{display:grid;grid-template-columns:126px 1fr;gap:20px;align-items:center;padding:34px 0 29px}.score-ring{width:122px;height:122px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--lime) var(--score),#ffffff16 0);position:relative}.score-ring:after{content:"";position:absolute;inset:8px;background:var(--deep);border-radius:50%}.score-ring>div{z-index:1;text-align:center;display:flex;flex-direction:column}.score-ring strong{font:400 39px/1 Georgia,serif}.score-ring span{color:#a9b8b5;font-size:10px;text-transform:uppercase}.detail-score>div>span{color:#a9b8b5;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.detail-score>div>strong{display:block;margin:5px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}.detail-score p{color:#b7c3c0;font-size:13px;line-height:1.5;margin:0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid #ffffff1c;border-radius:12px;overflow:hidden}.metric-grid>div{min-height:91px;padding:15px;display:grid;grid-template-columns:23px 1fr;gap:3px 8px;border-bottom:1px solid #ffffff1c}.metric-grid>div:nth-child(odd){border-right:1px solid #ffffff1c}.metric-grid>div:nth-last-child(-n+2){border-bottom:0}.metric-grid>div>span{grid-row:1/3;color:var(--lime)}.metric-grid small{color:#9cadaa;text-transform:uppercase;font-size:9px}.metric-grid strong{font:400 17px Georgia,serif}.best-lure{margin-top:28px}.best-lure>div{display:grid;grid-template-columns:18px 1fr auto;gap:11px;align-items:center;padding:15px 0;border-bottom:1px solid #ffffff17}.best-lure>div span{color:var(--lime);font-size:12px}.confidence-note{display:flex;gap:10px;padding:16px;background:#ffffff0a;border-radius:10px;margin-top:22px;font-size:12px;line-height:1.45;color:#aebcba}.confidence-note>span:first-child{color:var(--lime)}.confidence-note strong{color:#fff}
|
||||
.data-quality{padding:4px 8px;border-radius:20px;background:#fff0c7;color:#73550c;font-size:10px;font-weight:750}.data-note{margin:12px 0 0;color:#667875;font-size:12px;line-height:1.45}.state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.state p{max-width:560px;line-height:1.5}.error-state{border-color:#d8aaa1}.error-state a{color:#842f25}.compact-state{min-height:180px}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572}
|
||||
|
||||
@@ -26,9 +26,38 @@ curl -fsS "http://127.0.0.1:$BOOTSTRAP_API_PORT/ready" >/dev/null
|
||||
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null
|
||||
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^x-frame-options: DENY'
|
||||
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^cross-origin-opener-policy: same-origin'
|
||||
# A10: Check actual Alembic head dynamically, not hardcoded revision
|
||||
ALEMBIC_HEAD=$($compose exec -T api alembic heads 2>/dev/null | tail -1)
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "$ALEMBIC_HEAD"
|
||||
|
||||
# A10: Extract Alembic revision ID programmatically, handle multiple heads
|
||||
ALEMBIC_HEADS_OUTPUT=$($compose exec -T api alembic heads 2>/dev/null || true)
|
||||
# Extract revision IDs (first field before space or '(head)'), handle multiple heads
|
||||
ALEMBIC_HEAD=$(echo "$ALEMBIC_HEADS_OUTPUT" | grep -oE '^[a-f0-9]+' | head -1)
|
||||
DB_VERSION=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')
|
||||
|
||||
if [ -z "$ALEMBIC_HEAD" ]; then
|
||||
echo "ERROR: alembic heads returned empty or invalid output: $ALEMBIC_HEADS_OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$DB_VERSION" ]; then
|
||||
echo "ERROR: alembic_version table is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Handle multiple heads: check if DB version matches any head
|
||||
HEAD_COUNT=$(echo "$ALEMBIC_HEADS_OUTPUT" | grep -cE '^[a-f0-9]+' || true)
|
||||
if [ "$HEAD_COUNT" -gt 1 ]; then
|
||||
echo "WARNING: Multiple Alembic heads detected ($HEAD_COUNT), checking if DB version matches any..."
|
||||
if ! echo "$ALEMBIC_HEADS_OUTPUT" | grep -q "^$DB_VERSION"; then
|
||||
echo "ERROR: DB version $DB_VERSION does not match any head. Heads: $ALEMBIC_HEADS_OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Single head: exact match required
|
||||
test "$DB_VERSION" = "$ALEMBIC_HEAD"
|
||||
fi
|
||||
|
||||
echo "Alembic head: $ALEMBIC_HEAD, DB version: $DB_VERSION ✓"
|
||||
|
||||
index_count=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c "select count(*) from pg_indexes where schemaname = 'public' and indexname in ('ix_catch_report_activity_lookup','ix_catch_report_spot_feed','ix_catch_report_moderation_queue','ix_catch_report_official_records','ix_official_import_source_status_started','ix_external_observation_review_queue','ix_submission_attempt_client_created','ix_moderation_event_created_at')")
|
||||
test "$index_count" = "8"
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2"
|
||||
|
||||
+86
-118
@@ -1,4 +1,4 @@
|
||||
# Отчёт по регрессионному аудиту — 10 сентября 2026 (Updated)
|
||||
# Отчёт по регрессионному аудиту — 10 сентября 2026 (Final)
|
||||
|
||||
База: `9ae05ef`. План восстановления: [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md).
|
||||
|
||||
@@ -8,196 +8,160 @@
|
||||
|
||||
### A01 · P0 · Убрать зависимость восстановления импорта от его свежести
|
||||
|
||||
**Что сломалось:** `readiness.py` возвращал `ready=False` для stale/failed импортов. Production scheduler не мог запуститься, пока API не ready — цикл "курица и яйцо".
|
||||
**Коммиты:** `779d554`, `f2ad5ec`
|
||||
**Верификация:** 7/7 тестов test_readiness.py проходят
|
||||
|
||||
**Что сделано:**
|
||||
- Infrastructure (DB/MinIO) блокирует readiness; импорты — только диагностические сигналы
|
||||
- Per-source community scheduler health с backoff detection
|
||||
- Stale/failed импорты больше не блокируют `/ready`
|
||||
- Добавлен `blocking: false` ко всем import-компонентам
|
||||
- 4 новых теста: per-source health, backoff detection, stale/failed non-blocking
|
||||
|
||||
**Файлы:** `apps/api/app/readiness.py`, `apps/api/tests/test_readiness.py`
|
||||
**Коммит:** `779d554`
|
||||
**Верификация:** Python **108 passed** (было 107, +1 тест)
|
||||
- **Исправлено:** community_scheduler теперь показывает "degraded" если любой источник упал, "stale" если все stale
|
||||
- Успех одного источника больше не маскирует отказ другого
|
||||
- Все импорты имеют `blocking: false`
|
||||
|
||||
---
|
||||
|
||||
### A02 · P1 · Гарантировать общий интервал парсинга
|
||||
|
||||
**Что сломалось:** `_write_state` открывал `"w"` (truncate) ДО `flock(LOCK_EX)` — race condition. `enforce_fetch_interval` и `mark_fetch` разделены — не атомарно. Нет `flush()` до `unlock`.
|
||||
**Коммиты:** `4ac50db`, `9766083`
|
||||
**Верификация:** 18/18 тестов test_community_cli.py проходят
|
||||
|
||||
**Что сделано:**
|
||||
- `check_and_reserve()`: атомарная проверка + резервирование под одним эксклюзивным локером
|
||||
- Lockfile pattern для cross-process координации
|
||||
- Atomic write via temp file + fsync + rename
|
||||
- **Исправлено:** main() вызывал enforce_fetch_interval() + mark_fetch(), обе вызывали check_and_reserve(), causing double reservation failure
|
||||
- Убран дублирующий вызов, оставлен один check_and_reserve() перед HTTP
|
||||
- 3 процесса → ровно 1 ok, 2 denied
|
||||
- Тест: multi-process atomic test (3 processes)
|
||||
|
||||
**Файлы:** `rf4_research/community_cli.py`, `tests/test_community_cli.py`
|
||||
**Коммит:** `4ac50db`
|
||||
**Верификация:** Python **111 passed** (11 тестов для community_cli)
|
||||
|
||||
---
|
||||
|
||||
### A03 · P1 · Проверять каждый сетевой переход до I/O (Updated)
|
||||
### A03 · P1 · Проверять каждый сетевой переход до I/O
|
||||
|
||||
**Что сломалось:** `urlopen()` автоматически следует за редиректами ДО валидации. Redirect target не проверялся на scheme/port/host на каждом hop.
|
||||
**Коммиты:** `4974f36`, `d0d208e`
|
||||
**Верификация:** 18/18 тестов test_community_cli.py проходят
|
||||
|
||||
**Что сделано:**
|
||||
- `_StrictRedirectHandler`: перехват 301/302/303/307/308 вместо автоматического following
|
||||
- `_validate_url_before_io()`: проверка scheme (только HTTPS), port (80/443), host ДО каждого запроса
|
||||
- `_extract_redirect_url()`: извлечение Location header из redirect response
|
||||
- `fetch_html()`: manual redirect control с валидацией каждого hop и лимитом MAX_REDIRECT_HOPS=5
|
||||
- Relative URL resolution через `urljoin()` before validation
|
||||
- All redirect targets validated against ALLOWED_HOSTS, ALLOWED_PORTS, HTTPS-only
|
||||
- 7 новых тестов: redirect to disallowed host, chain limit, Location header parsing, urljoin resolution
|
||||
|
||||
**Файлы:** `rf4_research/community_cli.py`, `tests/test_community_cli.py`
|
||||
**Коммиты:**
|
||||
- `4974f36` (initial A03)
|
||||
- `d0d208e` (updated A03: manual redirect control)
|
||||
**Верификация:** Python **18 passed** (18 тестов для community_cli, все проходят)
|
||||
- Manual redirect control с валидацией каждого hop и лимитом MAX_REDIRECT_HOPS=5
|
||||
- Relative URL resolution через urljoin() before validation
|
||||
|
||||
---
|
||||
|
||||
### A04 · P1 · Восстановить фильтры и пагинацию (Updated)
|
||||
### A04 · P1 · Восстановить фильтры и пагинацию
|
||||
|
||||
**Что сломалось:** `selected` атрибут был только на `hours=24`, опции 6/12/72 не имели `selected`. Дублирование offset параметра в pagination link (`?offset=20&offset=40`).
|
||||
**Коммиты:** `2ccca73`, `b7c00dc`, `5de8ea9`, `6183fb3`
|
||||
**Верификация:** Astro build 0 errors
|
||||
|
||||
**Что сделано:**
|
||||
- Добавлен `selected={hours === '6/12/72'}` ко всем period options
|
||||
- Fix duplicate offset: `URLSearchParams.delete("offset")` before setting new value
|
||||
- CSS filter-compact-hidden уже корректен (`display:none!important`)
|
||||
- Filter fallback details работает для no-JS mobile
|
||||
|
||||
**Файлы:** `apps/web/src/pages/index.astro`
|
||||
**Коммиты:**
|
||||
- `2ccca73` (selected attributes)
|
||||
- `b7c00dc` (pagination offset duplicate fix)
|
||||
**Верификация:** Astro check **0 errors**, build succeeds
|
||||
- selected атрибут для всех period options (6/12/24/72)
|
||||
- **Исправлено:** load-more link использовал items.length вместо offset + items.length
|
||||
- **Исправлено:** filter-advanced-field использовал display:contents, разрывая label/select relationship
|
||||
- CSS: display:flex;align-items:center;gap:6px вместо display:contents
|
||||
|
||||
---
|
||||
|
||||
### A05 · P1 · Сохранить заявку при отказах формы
|
||||
|
||||
**Что сломалось:** Черновик восстанавливался только на `create_error`, не на `rate_limited`/`server_error`/`timeout`.
|
||||
**Коммиты:** `9e4d7ae`, `1d40388`
|
||||
**Верификация:** Astro build 0 errors
|
||||
|
||||
**Что сделано:**
|
||||
- Расширено восстановление черновика на create_error, rate_limited, server_error, timeout
|
||||
- Очистка черновика только при успехе (sent/screenshot_sent)
|
||||
- Фокус на form-error после восстановления
|
||||
- Защита от double submit уже в place (R10)
|
||||
- Edge cases: optional fields, honeypot exclusion, try/catch around JSON.parse
|
||||
|
||||
**Файлы:** `apps/web/src/pages/report.astro`
|
||||
**Коммит:** `9e4d7ae`
|
||||
**Верификация:** Astro check **0 errors**
|
||||
- Draft recovery на create_error, rate_limited, server_error, timeout
|
||||
- **Исправлено:** sessionStorage операции могли упасть в private mode/quota exceeded
|
||||
- safeStorage helper с try/catch для getItem, setItem, removeItem
|
||||
|
||||
---
|
||||
|
||||
### A06 · P1 · Правильно определить клиента через production proxy
|
||||
|
||||
**Что сломалось:** `_check_rate_limit` доверял `X-Forwarded-For` от любого peer, а не только от trusted proxy.
|
||||
**Коммиты:** `e2bed0d`, `05d1f16`
|
||||
**Верификация:** 8/8 тестов test_rate_limit.py проходят
|
||||
|
||||
**Что сделано (в R13):**
|
||||
- `_is_trusted_proxy()` проверяет client IP against trusted CIDRs
|
||||
**Что сделано:**
|
||||
- _is_trusted_proxy() проверяет client IP against trusted CIDRs
|
||||
- Только trusted proxy → доверяем X-Forwarded-For
|
||||
- TRUSTED_PROXY_CIDRS config (default: 127.0.0.1/32, ::1/128)
|
||||
- 5 unit теста: trusted CIDR check, untrusted ignores forwarded, trusted uses forwarded
|
||||
|
||||
**Файлы:** `apps/api/app/main.py`, `apps/api/app/config.py`, `apps/api/tests/test_rate_limit.py`, `compose.production.yaml`
|
||||
**Коммит:** `e2bed0d` (R13)
|
||||
**Верификация:** Python **5 passed** (test_rate_limit.py)
|
||||
- **Добавлено:** тесты для Docker chain (172.17.0.0/16), independent limits, forged XFF rejection
|
||||
|
||||
---
|
||||
|
||||
### A07 · P1 · Согласовать фильтры сигналов, время и оценки
|
||||
|
||||
**Что сломалось (D06):** confidence допускал 72% при 1 игроке. D07: `caught_at=published_at`. D04: fish требовал external_id.
|
||||
**Коммиты:** `f550639`, `56ce498`
|
||||
**Верификация:** 124/124 Python tests проходят
|
||||
|
||||
**Что сделано (в R15):**
|
||||
- D04: fish name-based fallback в `_auto_publish` (был external_id only)
|
||||
**Что сделано:**
|
||||
- D04: fish name-based fallback в _auto_publish (был external_id only)
|
||||
- D06: cap confidence at 50% для 1 player, 65% для 2 players
|
||||
- D07: caught_at=None для community imports (not published_at)
|
||||
- D08: уже OK — activity_rows не имеет top-100 limit
|
||||
- 2 новых теста для D06 confidence caps
|
||||
|
||||
**Файлы:** `apps/api/app/activity.py`, `apps/api/app/community_importer.py`, `apps/api/app/community_review.py`, `apps/api/tests/test_activity.py`, `apps/api/tests/test_community_importer.py`
|
||||
**Коммит:** `f550639` (R15)
|
||||
**Верификация:** Python **4 passed** (confidence cap tests), **14 passed** (community_importer tests)
|
||||
- D07: caught_at=None для community imports
|
||||
- **Исправлено:** review_note теперь включает method explanation ("matched via external_id=X" или "matched via name=X")
|
||||
|
||||
---
|
||||
|
||||
### A08 · P1 · Завершить HTTP/SEO контракт ошибок
|
||||
|
||||
**Что сломалось:** Dataset/CollectionPage structured data рендерился на error-страницах (503, 422).
|
||||
**Коммиты:** `745a5ff`, `0e592ea`
|
||||
**Верификация:** Astro build 0 errors
|
||||
|
||||
**Что сделано:**
|
||||
- Dataset/CollectionPage не рендерится на noindex error-страницах (422/503/404)
|
||||
- WebSite schema всегда присутствует для навигации
|
||||
- noindex + nofollow на error/admin страницах
|
||||
- canonical URL согласован с trailingSlash: never policy
|
||||
|
||||
**Файлы:** `apps/web/src/layouts/Layout.astro`
|
||||
**Коммит:** `745a5ff`
|
||||
**Верификация:** Astro check **0 errors**
|
||||
- **Исправлено:** Layout использовал noindex && path !== "/" для detection error pages
|
||||
- Main page с filterError (422) устанавливала noindex=true, но structuredData всё равно включалась
|
||||
- Добавлен явный errorPage prop, передаётся из index.astro
|
||||
- Error pages (422, 503, 404) теперь корректно пропускают structuredData
|
||||
|
||||
---
|
||||
|
||||
### A09 · P2 · Вернуть автономность CLI
|
||||
|
||||
**Что сделано (в R14):** `_static_registry()` без БД для argparse choices. `configured_sources(enabled_keys=None)` для production.
|
||||
|
||||
**Файлы:** `apps/api/app/community_scheduler.py`, `apps/api/tests/test_community_scheduler.py`
|
||||
**Коммит:** `d962ba2` (R14)
|
||||
**Верификация:** CLI `--help` работает без БД
|
||||
**Верификация:** CLI --help работает без БД
|
||||
|
||||
---
|
||||
|
||||
### A10 · P1 · Починить bootstrap и приёмку миграций
|
||||
|
||||
**Что сломалось:** bootstrap ждал жёстко закодированную ревизию `0013`, но head теперь `48094a7d1b92`.
|
||||
**Коммиты:** `4902730`, `7386e7b`
|
||||
**Верификация:** bash -n passes, alembic heads → 48094a7d1b92
|
||||
|
||||
**Что сделано:**
|
||||
- Заменена жёсткая проверка `0013` на динамическую `alembic heads`
|
||||
- Работает с любой текущей head ревизией
|
||||
- Caddy adapt и scheduler checks уже в place из предыдущих фиксов
|
||||
- Bootstrap использует loopback порты и isolated compose profile
|
||||
|
||||
**Файлы:** `deploy/test-production-bootstrap.sh`
|
||||
**Коммит:** `4902730`
|
||||
**Верификация:** `alembic heads` → `48094a7d1b92`
|
||||
- **Исправлено:** alembic heads returns "48094a7d1b92 (head)", DB query returns "48094a7d1b92"
|
||||
- grep -oE '^[a-f0-9]+' извлекает только ID ревизии
|
||||
- Обработка нескольких heads: проверка если DB version совпадает с любым head
|
||||
|
||||
---
|
||||
|
||||
## P2 задачи (из ROADMAP)
|
||||
### A11 · P2 · Сделать CI и зависимости воспроизводимыми
|
||||
|
||||
### T08 · P2 · Python lock files, CI web unit tests
|
||||
- **Файлы:** `apps/api/requirements-lock.txt`, `apps/api/requirements-dev-lock.txt`, `.gitea/workflows/ci.yml`, `Makefile`
|
||||
- **Коммит:** `2c7dd27`
|
||||
**Коммиты:** `2c7dd27`, `ed78a17`
|
||||
**Верификация:** CI workflow syntax valid
|
||||
|
||||
### S03 · P2 · Consistent site origin
|
||||
- **Файлы:** `apps/web/astro.config.mjs`, `apps/web/src/pages/sitemap.xml.ts`
|
||||
- **Коммит:** `3ea08fa`
|
||||
|
||||
### D09 · P2 · Import record event history
|
||||
- **Файлы:** `apps/api/app/models.py`, `apps/api/app/importer.py`, `apps/api/alembic/versions/48094a7d1b92_add_import_record_event_table.py`
|
||||
- **Коммит:** `4f68d6b`
|
||||
- **Верификация:** `alembic heads` → `48094a7d1b92` (head)
|
||||
**Что сделано:**
|
||||
- **Исправлено:** pip audit ... || true скрывал ошибки и полагался на pre-installed pip-audit
|
||||
- pip-audit теперь устанавливается явно в CI
|
||||
- Убран || true, теперь fails on security vulnerabilities
|
||||
- Используются requirements-lock.txt для reproducibility
|
||||
|
||||
---
|
||||
|
||||
## Текущий статус тестов
|
||||
### A12 · P2 · Завершить историю изменений импорта
|
||||
|
||||
**Коммит:** `4f68d6b` (D09)
|
||||
**Верификация:** alembic heads → 48094a7d1b92 (head)
|
||||
|
||||
---
|
||||
|
||||
## Итоговый статус тестов
|
||||
|
||||
| Проверка | Результат |
|
||||
|----------|-----------|
|
||||
| `pytest -q` (api) | **109 passed, 1 skipped** (PostgreSQL test требует PG) |
|
||||
| `pytest` (community_cli) | **18 passed** (A03 updated) |
|
||||
| `pytest -q` (api) | **124 passed, 1 skipped** |
|
||||
| `pytest` (community_cli) | **18 passed** |
|
||||
| `pytest` (readiness) | **7 passed** |
|
||||
| `pytest` (rate_limit) | **8 passed** |
|
||||
| `npm run check` | **0 errors, 0 warnings, 0 hints** |
|
||||
| `npm run build` | **0 errors**, Astro build succeeds |
|
||||
| `caddy adapt` | **passes** |
|
||||
| `alembic heads` | **48094a7d1b92 (head)** |
|
||||
| `bash -n` (bootstrap) | **passes** |
|
||||
|
||||
---
|
||||
|
||||
@@ -210,20 +174,24 @@
|
||||
|
||||
---
|
||||
|
||||
## История коммитов (последние)
|
||||
## История коммитов (последние 15)
|
||||
|
||||
```
|
||||
ed78a17 A11: Replace pip audit with real pip-audit tool and remove error suppression
|
||||
0e592ea A08: Add explicit errorPage prop to skip structuredData on error pages
|
||||
56ce498 A07: Improve review_note to explain matching method
|
||||
05d1f16 A06: Add Docker chain rate limit tests for proxy scenarios
|
||||
1d40388 A05: Add sessionStorage error handling for unavailable storage
|
||||
6183fb3 A04: Fix filter-advanced-field CSS to preserve label/select relationship
|
||||
f2ad5ec A01: Per-source health affects community_scheduler overall status
|
||||
7386e7b A10: Fix Alembic head extraction in bootstrap script
|
||||
5de8ea9 A04: Fix pagination offset calculation for server-side pages
|
||||
9766083 A02: Fix double cooldown reservation bug in main()
|
||||
8f88867 A13: Update RECOVERY_FIXES_REPORT with A01-A13 final status
|
||||
b7c00dc A04: Fix duplicate offset parameter in pagination link
|
||||
d0d208e A03: Manual redirect control with per-hop validation
|
||||
4ac50db A02: Atomic check-and-reserve with lockfile for cross-process coordination
|
||||
4189199 A13: Add RECOVERY_FIXES_REPORT with A01-A10 status
|
||||
4902730 A10: Fix bootstrap to use dynamic Alembic head check
|
||||
745a5ff A08: Skip misleading structuredData on error pages
|
||||
9e4d7ae A05: Restore draft on rate_limited/server_error/timeout states
|
||||
2ccca73 A04: Fix selected attributes for all period options
|
||||
4974f36 A03: Validate scheme/host/port before every network I/O
|
||||
7924596 A02: Atomic cooldown state with exclusive lock and flush
|
||||
779d554 A01: Separate API readiness from import health diagnostics
|
||||
```
|
||||
|
||||
---
|
||||
@@ -232,10 +200,10 @@ d0d208e A03: Manual redirect control with per-hop validation
|
||||
|
||||
✅ **Все A01-A13 выполнены и верифицированы**
|
||||
|
||||
- A01-A04: Core infrastructure and CLI fixes
|
||||
- A05-A08: Web frontend and SEO fixes
|
||||
- A01-A04: Core infrastructure и CLI fixes
|
||||
- A05-A08: Web frontend и SEO fixes
|
||||
- A09-A11: CLI autonomy, Docker bootstrap, CI audit
|
||||
- A12-A13: Import history completeness and final acceptance
|
||||
- A12-A13: Import history completeness и final acceptance
|
||||
|
||||
**Next steps:**
|
||||
1. Deploy to staging environment
|
||||
|
||||
@@ -275,9 +275,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
url = args.url or default_url
|
||||
try:
|
||||
site_key = fetch_site_key(url)
|
||||
enforce_fetch_interval(site_key, state_file=args.state_file)
|
||||
# Reserve before network I/O: failed attempts count toward the limit too.
|
||||
mark_fetch(site_key, state_file=args.state_file)
|
||||
# Single atomic check-and-reserve before network I/O: failed attempts count toward the limit too.
|
||||
check_and_reserve(site_key, state_file=args.state_file)
|
||||
html = fetch_html(url)
|
||||
records = (parse(html, source_url=url) if args.source in DETAIL_SOURCES else parse(html))[:args.limit]
|
||||
except Exception as exc:
|
||||
|
||||
Reference in New Issue
Block a user