Compare commits
6
Commits
d6bc5ce85c
...
2ad2bdcdff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ad2bdcdff | ||
|
|
6664f24f20 | ||
|
|
0b7bbc78a6 | ||
|
|
fc963101b3 | ||
|
|
d28ad31aca | ||
|
|
67d81f81ff |
+15
-3
@@ -18,6 +18,7 @@ from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Reques
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .activity import activity_rows
|
||||
@@ -509,13 +510,24 @@ def create_catch_report(
|
||||
if idempotency_key else secrets.token_urlsafe(32))
|
||||
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
|
||||
db.add(report)
|
||||
db.commit()
|
||||
# Store idempotency key if provided
|
||||
if idempotency_key:
|
||||
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest()
|
||||
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report_id=report.id, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
|
||||
db.commit()
|
||||
# One transaction: a unique-key race must roll back the report too.
|
||||
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Another request won the same idempotency key race.
|
||||
db.rollback()
|
||||
winner = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))
|
||||
if winner and winner.catch_report:
|
||||
replay_token = hmac.new(settings.rate_limit_secret.encode(), (key_hash + ":upload").encode(), hashlib.sha256).hexdigest()
|
||||
return JSONResponse(status_code=200, content={"id": str(winner.catch_report.id), "moderation_status": winner.catch_report.moderation_status.value, "screenshot_upload_token": replay_token, "idempotent": True})
|
||||
raise
|
||||
logger.info("idempotency key stored", extra={"idempotency_key": idempotency_key[:8]})
|
||||
else:
|
||||
db.commit()
|
||||
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
|
||||
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ class SubmissionAttempt(Base):
|
||||
__tablename__ = "submission_attempt"
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
client_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(String(128), unique=True, index=True)
|
||||
catch_report_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("catch_report.id"), nullable=True)
|
||||
payload_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
|
||||
@@ -18,7 +18,7 @@ if (!fish && !unavailable) Astro.response.status = 404;
|
||||
const waters = [...new Map(items.map(item => [item.waterbody_slug, item.waterbody])).entries()];
|
||||
const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Где ловить ${fish.name_ru} в RF4`, url:`https://rf4spotter.ru/fish/${fish.slug}` } : null;
|
||||
---
|
||||
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish} structuredData={schema}>
|
||||
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish || unavailable} structuredData={schema} errorPage={!fish || unavailable}>
|
||||
<a class="back" href="/fish">← Все виды рыб</a>
|
||||
<section class="catalog-hero content-grid"><span class="overline">Свежие данные за 72 часа</span><h1>{fish?.name_ru ?? "Рыба не найдена"}</h1>{fish && <p>{items.length} {plural(items.length,["активная точка","активные точки","активных точек"])} на {waters.length} {plural(waters.length,["водоёме","водоёмах","водоёмах"])}.</p>}</section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Данные временно недоступны</h2></div> : !fish ? <div class="state content-grid"><h2>Такой рыбы нет в справочнике</h2><a href="/fish">Открыть каталог</a></div> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <a href={`/waterbodies/${waterSlug}/${fish!.slug}`}>{name}</a>)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <div class="state"><h2>Свежих точек пока нет</h2><p>Проверьте позже или посмотрите полевые сигналы на главной.</p></div>}</div></section>}
|
||||
|
||||
@@ -78,16 +78,14 @@ const datasetJsonLd = {
|
||||
<script>
|
||||
const advancedFields = document.querySelectorAll<HTMLElement>(".filter-advanced-field");
|
||||
const compactFilters = window.matchMedia("(max-width: 720px)");
|
||||
const syncFilters = (event: MediaQueryList | MediaQueryListEvent) => {
|
||||
const syncFilters = () => {
|
||||
advancedFields.forEach(field => {
|
||||
if (event.matches) {
|
||||
field.classList.add("filter-compact-hidden");
|
||||
} else {
|
||||
field.classList.remove("filter-compact-hidden");
|
||||
}
|
||||
// The details element is the responsive disclosure control. Do not
|
||||
// hide its fields with a second JS-only rule.
|
||||
field.classList.remove("filter-compact-hidden");
|
||||
});
|
||||
};
|
||||
syncFilters(compactFilters);
|
||||
syncFilters();
|
||||
compactFilters.addEventListener("change", syncFilters);
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
@@ -18,7 +18,7 @@ if (!water && !unavailable) Astro.response.status = 404;
|
||||
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()];
|
||||
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
|
||||
---
|
||||
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water} structuredData={schema}>
|
||||
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
|
||||
<a class="back" href="/waterbodies">← Все водоёмы</a>
|
||||
<section class="catalog-hero content-grid"><span class="overline">Свежие данные за 72 часа</span><h1>{water?.name_ru ?? "Водоём не найден"}</h1>{water && <p>{items.length} {plural(items.length,["активная точка","активные точки","активных точек"])} для {fishes.length} {plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.</p>}</section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Данные временно недоступны</h2></div> : !water ? <div class="state content-grid"><h2>Такого водоёма нет в справочнике</h2><a href="/waterbodies">Открыть каталог</a></div> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <a href={`/waterbodies/${water!.slug}/${fishSlug}`}>{name}</a>)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <div class="state"><h2>Свежих точек пока нет</h2><p>Проверьте позже или посмотрите полевые сигналы на главной.</p></div>}</div></section>}
|
||||
|
||||
@@ -18,7 +18,7 @@ if ((!water || !fish) && !unavailable) Astro.response.status = 404;
|
||||
const valid = Boolean(water && fish);
|
||||
const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на ${water!.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water!.slug}/${fish!.slug}` } : null;
|
||||
---
|
||||
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid} structuredData={schema}>
|
||||
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid || unavailable} structuredData={schema} errorPage={!valid || unavailable}>
|
||||
<a class="back" href={water ? `/waterbodies/${water.slug}` : "/waterbodies"}>← К водоёму</a>
|
||||
<section class="catalog-hero content-grid"><span class="overline">{water?.name_ru ?? "Каталог RF4"}</span><h1>{fish?.name_ru ?? "Данные не найдены"}</h1>{valid && <p>{items.length} {plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.</p>}</section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Данные временно недоступны</h2></div> : !valid ? <div class="state content-grid"><h2>Такого сочетания нет в справочнике</h2><a href="/">Вернуться на главную</a></div> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <div class="state"><h2>Свежих точек пока нет</h2><p>Данные появятся после новых подтверждённых наблюдений.</p></div>}</div></section>}
|
||||
|
||||
@@ -18,6 +18,7 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
||||
@media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"+";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:"−"}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}}
|
||||
@media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}}
|
||||
@media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
|
||||
@media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}}
|
||||
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
|
||||
/* RF4 field-guide iconography and float activity gauge */
|
||||
|
||||
@@ -29,7 +29,7 @@ curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep
|
||||
|
||||
# A10: Validate Caddy configuration without running the proxy
|
||||
echo "Validating Caddy configuration..."
|
||||
docker compose --env-file .env.production.example -f compose.production.yaml -f deploy/compose.bootstrap.yaml run --rm --no-deps --entrypoint "caddy adapt --config /etc/caddy/Caddyfile --pretty" proxy >/dev/null 2>&1 || {
|
||||
docker compose --env-file .env.production.example -f compose.production.yaml -f deploy/compose.bootstrap.yaml run --rm --no-deps --entrypoint caddy proxy adapt --config /etc/caddy/Caddyfile --pretty >/dev/null 2>&1 || {
|
||||
echo "ERROR: Caddy configuration is invalid" >&2
|
||||
exit 1
|
||||
}
|
||||
@@ -44,7 +44,10 @@ $compose run --rm --no-deps community-scheduler python -c "import app.community_
|
||||
echo "Community scheduler valid ✓"
|
||||
|
||||
# A10: Extract Alembic revision ID programmatically, handle multiple heads
|
||||
ALEMBIC_HEADS_OUTPUT=$($compose exec -T api alembic heads 2>/dev/null || true)
|
||||
if ! ALEMBIC_HEADS_OUTPUT=$($compose exec -T api alembic heads 2>/dev/null); then
|
||||
echo "ERROR: unable to read Alembic heads" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 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')
|
||||
@@ -60,7 +63,7 @@ if [ -z "$DB_VERSION" ]; then
|
||||
fi
|
||||
|
||||
# Handle multiple heads: check if DB version matches any head
|
||||
HEAD_COUNT=$(echo "$ALEMBIC_HEADS_OUTPUT" | grep -cE '^[a-f0-9]+' || true)
|
||||
HEAD_COUNT=$(echo "$ALEMBIC_HEADS_OUTPUT" | grep -cE '^[a-f0-9]+') || HEAD_COUNT=0
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user