Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907ad537e2 | ||
|
|
51728bf3f2 | ||
|
|
8f76f70409 | ||
|
|
8c94d40766 | ||
|
|
1ffaf7478d |
@@ -206,6 +206,14 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
|
|||||||
if old_val != new_val:
|
if old_val != new_val:
|
||||||
changed_fields[field] = {"old": old_val, "new": new_val}
|
changed_fields[field] = {"old": old_val, "new": new_val}
|
||||||
if changed_fields:
|
if changed_fields:
|
||||||
|
# Keep the queryable normalized record in sync with its
|
||||||
|
# versioned source payload.
|
||||||
|
report.fish = fish
|
||||||
|
report.waterbody = waterbody
|
||||||
|
report.bait = bait
|
||||||
|
report.weight_g = raw.weight_g
|
||||||
|
report.player_name = raw.player
|
||||||
|
report.caught_at = caught
|
||||||
report.raw_payload = payload
|
report.raw_payload = payload
|
||||||
report.source_url = url
|
report.source_url = url
|
||||||
session.add(ImportRecordEvent(
|
session.add(ImportRecordEvent(
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
|||||||
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
||||||
<a class="back" href="/">← Все активные точки</a>
|
<a class="back" href="/">← Все активные точки</a>
|
||||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||||
<ActivityTimeline buckets={timeline}/>
|
<ActivityTimeline buckets={timeline}/>
|
||||||
|
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
|
||||||
<section class="detail-grid">
|
<section class="detail-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="section-heading"><h2>Последние уловы</h2></div>
|
<div class="section-heading"><h2>Последние уловы</h2></div>
|
||||||
@@ -44,3 +45,13 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
|||||||
</section>
|
</section>
|
||||||
</>}
|
</>}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
<script is:inline>
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-copy-coordinates]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const value = button.dataset.copyCoordinates || "";
|
||||||
|
const status = button.parentElement?.querySelector<HTMLElement>(".copy-status");
|
||||||
|
try { await navigator.clipboard.writeText(value); if (status) status.textContent = "Скопировано"; }
|
||||||
|
catch { if (status) status.textContent = value; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
|||||||
@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: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(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}}
|
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||||
|
.activity-legend{display:flex;gap:10px;flex-wrap:wrap;margin:8px 0;color:#7d918b;font-size:11px}.activity-legend span:before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;background:var(--lime)}.activity-legend span:nth-child(2):before{background:#e5b95c}.activity-legend span:nth-child(3):before{background:#ef795e}
|
||||||
|
|
||||||
/* RF4 field-guide iconography and float activity gauge */
|
/* RF4 field-guide iconography and float activity gauge */
|
||||||
.fish-icon{display:inline-block;flex:none;vertical-align:-.2em}.brand-mark .fish-icon{transform:rotate(-12deg)}.topbar nav a.active .fish-icon{color:#6b9229}.spot-meta span{display:flex;align-items:center;gap:4px}.bait-line>.fish-icon,.best-lure .fish-icon{color:#e19a43;transform:rotate(14deg)}.card-arrow{font-size:0}.detail-head>a{transition:background .2s ease,color .2s ease}.detail-head>a:hover{background:var(--lime);color:var(--deep)}
|
.fish-icon{display:inline-block;flex:none;vertical-align:-.2em}.brand-mark .fish-icon{transform:rotate(-12deg)}.topbar nav a.active .fish-icon{color:#6b9229}.spot-meta span{display:flex;align-items:center;gap:4px}.bait-line>.fish-icon,.best-lure .fish-icon{color:#e19a43;transform:rotate(14deg)}.card-arrow{font-size:0}.detail-head>a{transition:background .2s ease,color .2s ease}.detail-head>a:hover{background:var(--lime);color:var(--deep)}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ if ! ALEMBIC_HEADS_OUTPUT=$($compose exec -T api alembic heads 2>/dev/null); the
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Extract revision IDs (first field before space or '(head)'), handle multiple heads
|
# 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)
|
ALEMBIC_HEAD=$(printf '%s\n' "$ALEMBIC_HEADS_OUTPUT" | awk 'NF {print $1; exit}')
|
||||||
DB_VERSION=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')
|
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
|
if [ -z "$ALEMBIC_HEAD" ]; then
|
||||||
@@ -63,10 +63,10 @@ if [ -z "$DB_VERSION" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Handle multiple heads: check if DB version matches any head
|
# Handle multiple heads: check if DB version matches any head
|
||||||
HEAD_COUNT=$(echo "$ALEMBIC_HEADS_OUTPUT" | grep -cE '^[a-f0-9]+') || HEAD_COUNT=0
|
HEAD_COUNT=$(printf '%s\n' "$ALEMBIC_HEADS_OUTPUT" | awk 'NF {count++} END {print count+0}')
|
||||||
if [ "$HEAD_COUNT" -gt 1 ]; then
|
if [ "$HEAD_COUNT" -gt 1 ]; then
|
||||||
echo "WARNING: Multiple Alembic heads detected ($HEAD_COUNT), checking if DB version matches any..."
|
echo "WARNING: Multiple Alembic heads detected ($HEAD_COUNT), checking if DB version matches any..."
|
||||||
if ! echo "$ALEMBIC_HEADS_OUTPUT" | grep -q "^$DB_VERSION"; then
|
if ! printf '%s\n' "$ALEMBIC_HEADS_OUTPUT" | awk '{print $1}' | grep -Fxq "$DB_VERSION"; then
|
||||||
echo "ERROR: DB version $DB_VERSION does not match any head. Heads: $ALEMBIC_HEADS_OUTPUT" >&2
|
echo "ERROR: DB version $DB_VERSION does not match any head. Heads: $ALEMBIC_HEADS_OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
+21
-17
@@ -137,24 +137,28 @@ def test_check_and_reserve_atomic_under_concurrent_access(tmp_path: Path) -> Non
|
|||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
|
||||||
state_file = tmp_path / "concurrent.json"
|
state_file = tmp_path / "concurrent.json"
|
||||||
results = multiprocessing.Manager().list()
|
# Explicitly shut down the manager and never leak a timed-out child.
|
||||||
|
with multiprocessing.Manager() as manager:
|
||||||
|
results = manager.list()
|
||||||
|
processes = []
|
||||||
|
for i in range(3):
|
||||||
|
args = (i, str(state_file), results)
|
||||||
|
p = multiprocessing.Process(target=_try_reserve_for_test, args=(args,))
|
||||||
|
processes.append(p)
|
||||||
|
for p in processes:
|
||||||
|
p.start()
|
||||||
|
for p in processes:
|
||||||
|
p.join(timeout=10)
|
||||||
|
for p in processes:
|
||||||
|
if p.is_alive():
|
||||||
|
p.terminate()
|
||||||
|
p.join(timeout=2)
|
||||||
|
|
||||||
# Launch 3 processes simultaneously
|
# At most one should succeed
|
||||||
processes = []
|
ok_count = sum(1 for _, r in results if r == "ok")
|
||||||
for i in range(3):
|
assert ok_count == 1, f"Expected exactly 1 ok, got {ok_count}: {results}"
|
||||||
args = (i, str(state_file), results)
|
denied_count = sum(1 for _, r in results if "cooldown" in r)
|
||||||
p = multiprocessing.Process(target=_try_reserve_for_test, args=(args,))
|
assert denied_count == 2, f"Expected 2 denied, got {denied_count}: {results}"
|
||||||
processes.append(p)
|
|
||||||
for p in processes:
|
|
||||||
p.start()
|
|
||||||
for p in processes:
|
|
||||||
p.join(timeout=10)
|
|
||||||
|
|
||||||
# At most one should succeed
|
|
||||||
ok_count = sum(1 for _, r in results if r == "ok")
|
|
||||||
assert ok_count == 1, f"Expected exactly 1 ok, got {ok_count}: {results}"
|
|
||||||
denied_count = sum(1 for _, r in results if "cooldown" in r)
|
|
||||||
assert denied_count == 2, f"Expected 2 denied, got {denied_count}: {results}"
|
|
||||||
|
|
||||||
|
|
||||||
# A03: Manual redirect control tests
|
# A03: Manual redirect control tests
|
||||||
|
|||||||
Reference in New Issue
Block a user