feat: filter external moderation queue
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-12 21:37:10 +07:00
parent 6e18e8e565
commit 8e419c1832
7 changed files with 93 additions and 10 deletions
+24 -3
View File
@@ -15,7 +15,7 @@ import httpx
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
@@ -178,7 +178,11 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut:
def admin_external_observations(
db: Db, _: Annotated[str, Depends(_admin)],
status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None,
source_system: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
source_system: str | None = None,
completeness: Literal["all", "complete", "incomplete"] = "all",
order: Literal["newest", "oldest"] = "newest",
q: str | None = Query(None, max_length=100),
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
) -> list[ExternalObservationOut]:
query = select(ExternalObservation).options(
joinedload(ExternalObservation.fish), joinedload(ExternalObservation.waterbody),
@@ -189,7 +193,24 @@ def admin_external_observations(
query = query.where(ExternalObservation.status == status)
if source_system:
query = query.where(ExternalObservation.source_system == source_system)
items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit))
if completeness == "complete":
query = query.where(
ExternalObservation.x.is_not(None), ExternalObservation.y.is_not(None),
ExternalObservation.weight_g.is_not(None),
)
elif completeness == "incomplete":
query = query.where(or_(
ExternalObservation.x.is_(None), ExternalObservation.y.is_(None),
ExternalObservation.weight_g.is_(None),
))
if q and q.strip():
term = q.strip()
query = query.where(or_(
ExternalObservation.fish_name.icontains(term, autoescape=True),
ExternalObservation.waterbody_name.icontains(term, autoescape=True),
))
direction = ExternalObservation.last_seen_at.asc() if order == "oldest" else ExternalObservation.last_seen_at.desc()
items = db.scalars(query.order_by(direction, ExternalObservation.id.desc()).offset(offset).limit(limit))
return [_external_out(item) for item in items]
+46
View File
@@ -1979,6 +1979,52 @@
"title": "Source System"
}
},
{
"in": "query",
"name": "completeness",
"required": false,
"schema": {
"default": "all",
"enum": [
"all",
"complete",
"incomplete"
],
"title": "Completeness",
"type": "string"
}
},
{
"in": "query",
"name": "order",
"required": false,
"schema": {
"default": "newest",
"enum": [
"newest",
"oldest"
],
"title": "Order",
"type": "string"
}
},
{
"in": "query",
"name": "q",
"required": false,
"schema": {
"anyOf": [
{
"maxLength": 100,
"type": "string"
},
{
"type": "null"
}
],
"title": "Q"
}
},
{
"in": "query",
"name": "limit",
+5 -1
View File
@@ -339,8 +339,12 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
assert signal["source_system"] == "rf4db"
assert signal["quality"] == "incomplete"
assert signal["missing_fields"] == ["вес"]
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
headers = {"Authorization": "Bearer change-me-in-production"}
incomplete = client.get("/api/v1/admin/external-observations?status=review&completeness=incomplete&q=Pike", headers=headers)
assert any(item["id"] == str(observation_id) for item in incomplete.json())
complete = client.get("/api/v1/admin/external-observations?status=review&completeness=complete&q=Pike", headers=headers)
assert all(item["id"] != str(observation_id) for item in complete.json())
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
mapped = client.patch(
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
json={"fish_slug": "pike", "waterbody_slug": "test-lake"},
@@ -8,6 +8,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
<p class="privacy">Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<form class="admin-queue-filters" hidden>
<label>Поиск<input name="q" type="search" maxlength="100" placeholder="Рыба или водоём" /></label>
<label>Источник<select name="source"><option value="">Все источники</option></select></label>
<label>Полнота<select name="completeness"><option value="all">Все записи</option><option value="complete">Только полные</option><option value="incomplete">Только неполные</option></select></label>
<label>Порядок<select name="order"><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label>
<button data-action="primary" type="submit">Применить</button>
</form>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
<nav data-pages aria-label="Страницы очереди" hidden>
<button data-action="secondary" type="button" data-previous>Предыдущая</button>
@@ -26,6 +33,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
let offset = 0;
let token = ""; let fishes: Record<string, string>[] = []; let waters: Record<string, string>[] = [];
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
@@ -34,15 +42,16 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (!response.ok) throw new Error(`Запрос завершился ошибкой ${response.status}.`); if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
async function loadQueue() {
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
[fishes, waters] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`)]);
if (!fishes.length || !waters.length) { const [fishRows, waterRows, sources] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`), json(`${root.dataset.apiUrl}/api/v1/source-status`)]); fishes = fishRows; waters = waterRows; const sourceSelect = filters?.querySelector<HTMLSelectElement>('[name="source"]'); if (sourceSelect) sourceSelect.innerHTML = '<option value="">Все источники</option>' + (sources as Record<string, unknown>[]).map(source => `<option value="${esc(source.source_system)}">${esc(source.name)}</option>`).join(""); }
pages?.setAttribute("hidden", "");
const rows = await json(`${root.dataset.apiUrl}/api/v1/admin/external-observations?status=review&limit=51&offset=${offset}`, {headers:{Authorization:`Bearer ${token}`}});
const values = new FormData(filters ?? undefined); const params = new URLSearchParams({status:"review",limit:"51",offset:String(offset),completeness:String(values.get("completeness") || "all"),order:String(values.get("order") || "newest")}); const source = String(values.get("source") || ""); const query = String(values.get("q") || "").trim(); if (source) params.set("source_system", source); if (query) params.set("q", query);
const rows = await json(`${root.dataset.apiUrl}/api/v1/admin/external-observations?${params}`, {headers:{Authorization:`Bearer ${token}`}});
setLoading(false);
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
const pending = rows.slice(0, 50);
@@ -63,7 +72,8 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
}
previous?.addEventListener("click", () => changePage(-50));
next?.addEventListener("click", () => changePage(50));
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await loadQueue(); } catch (cause) { setLoading(false); fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try {
if (button.hasAttribute("data-suggest")) {
+2
View File
@@ -14,6 +14,8 @@
.notice.warning{background:#fff0c7;color:#73550c}.screenshot-retry{width:min(900px,calc(100% - 64px));margin:0 auto 24px;padding:22px;background:#fff;border:1px solid #e2c975;border-radius:12px}.screenshot-retry label{color:#60716e}.screenshot-retry input{display:block;width:100%;margin:8px 0 14px}.screenshot-retry button{border:0;border-radius:9px;padding:13px 20px;background:var(--deep);color:#fff;font-weight:750}
.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55}
.admin-session-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 18px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--muted);font-size:13px}.admin-session-bar[hidden]{display:none}.admin-session-bar button{min-height:38px}
.admin-queue-filters{display:grid;grid-template-columns:1.3fr repeat(3,1fr) auto;gap:10px;align-items:end;margin:0 0 18px;padding:16px;border-radius:12px;background:var(--deep)}.admin-queue-filters[hidden]{display:none}.admin-queue-filters label{color:#b8c5c2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.admin-queue-filters input,.admin-queue-filters select{display:block;width:100%;height:42px;margin-top:6px;padding:0 10px;border:1px solid #ffffff29;border-radius:8px;background:#ffffff0c;color:#fff}.admin-queue-filters option{color:var(--ink)}.admin-queue-filters button{height:42px}
@media(max-width:900px){.admin-queue-filters{grid-template-columns:1fr 1fr}}@media(max-width:520px){.admin-queue-filters{grid-template-columns:1fr}}
.admin-dashboard{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-dashboard>.privacy{margin:10px 4px 25px}.admin-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.admin-kpis>a,.admin-kpis>article,.admin-dashboard-grid>section{padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.admin-kpis>a{color:inherit;text-decoration:none}.admin-kpis span,.admin-kpis small{display:block;color:var(--text-muted)}.admin-kpis strong{display:block;margin:10px 0;font:400 42px Georgia,serif;color:var(--deep)}.admin-kpis>a:hover{border-color:var(--focus);transform:translateY(-2px)}.admin-dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.admin-dashboard-grid h2{margin:0 0 16px;font:400 25px Georgia,serif}.admin-dashboard-grid ul{list-style:none;margin:0 0 16px;padding:0}.admin-dashboard-grid li{display:flex;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:13px}.admin-dashboard-grid li span{display:flex;align-items:center;gap:8px}.admin-dashboard-grid time{color:var(--text-muted)}
@media(max-width:900px){.admin-kpis{grid-template-columns:1fr 1fr}}@media(max-width:720px){.admin-dashboard{width:calc(100% - 28px)}.admin-kpis,.admin-dashboard-grid{grid-template-columns:1fr}}
footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer .brand-name span{color:#9dafaa}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}