Add audited user report deletion

This commit is contained in:
ik
2026-09-03 08:14:56 +07:00
parent 03a0ecb5f4
commit 0b3e6d4b2f
8 changed files with 78 additions and 10 deletions
@@ -0,0 +1,16 @@
"""Soft deletion and anonymization marker for user reports."""
from alembic import op
import sqlalchemy as sa
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("catch_report", sa.Column("deleted_at", sa.DateTime(timezone=True)))
def downgrade() -> None:
op.drop_column("catch_report", "deleted_at")
+26 -4
View File
@@ -17,14 +17,14 @@ from .config import settings
from .importer import ImportSourceError, import_records, normalize from .importer import ImportSourceError, import_records, normalize
from .models import Bait, BaitKind, CatchReport, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody from .models import Bait, BaitKind, CatchReport, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
from .storage import ScreenshotError, signed_screenshot_url, upload_screenshot from .storage import ScreenshotError, delete_screenshot, signed_screenshot_url, upload_screenshot
app = FastAPI(title="RF4 Spotter API", version="0.1.0") app = FastAPI(title="RF4 Spotter API", version="0.1.0")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"], allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"],
allow_methods=["GET", "PATCH"], allow_methods=["GET", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"], allow_headers=["Authorization", "Content-Type"],
) )
Db = Annotated[Session, Depends(get_session)] Db = Annotated[Session, Depends(get_session)]
@@ -190,14 +190,14 @@ def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) ->
@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut]) @app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]: def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]:
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status).order_by(CatchReport.reported_at).limit(limit))) reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at).limit(limit)))
return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports] return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports]
@app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated) @app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated)
def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> CatchReportCreated: def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> CatchReportCreated:
report = db.get(CatchReport, report_id) report = db.get(CatchReport, report_id)
if report is None or report.source_type != SourceType.user: if report is None or report.source_type != SourceType.user or report.deleted_at is not None:
raise HTTPException(status_code=404, detail="catch report not found") raise HTTPException(status_code=404, detail="catch report not found")
previous = report.moderation_status previous = report.moderation_status
report.moderation_status = ModerationStatus(payload.status) report.moderation_status = ModerationStatus(payload.status)
@@ -206,6 +206,28 @@ def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderato
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value) return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
@app.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response)
def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)]) -> Response:
report = db.get(CatchReport, report_id)
if report is None or report.source_type != SourceType.user or report.deleted_at is not None:
raise HTTPException(status_code=404, detail="catch report not found")
previous = report.moderation_status
if report.screenshot_key:
try:
delete_screenshot(report.screenshot_key)
except Exception as exc:
raise HTTPException(status_code=502, detail="screenshot deletion failed") from exc
report.moderation_status = ModerationStatus.rejected
report.deleted_at = datetime.now(timezone.utc)
report.player_name = None
report.source_url = None
report.screenshot_key = None
report.raw_payload = None
db.add(ModerationEvent(catch_report=report, created_at=report.deleted_at, previous_status=previous, new_status=ModerationStatus.rejected, moderator=moderator, reason="user report deleted and anonymized"))
db.commit()
return Response(status_code=204)
def _check_rate_limit(client: str) -> None: def _check_rate_limit(client: str) -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
recent = _submissions[client] recent = _submissions[client]
+1
View File
@@ -92,6 +92,7 @@ class CatchReport(Base):
source_confidence: Mapped[int] source_confidence: Mapped[int]
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus)) moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
screenshot_key: Mapped[str | None] = mapped_column(Text) screenshot_key: Mapped[str | None] = mapped_column(Text)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON) raw_payload: Mapped[dict | None] = mapped_column(JSON)
fish: Mapped[Fish] = relationship() fish: Mapped[Fish] = relationship()
spot: Mapped[Spot | None] = relationship() spot: Mapped[Spot | None] = relationship()
+4
View File
@@ -64,3 +64,7 @@ def upload_screenshot(raw: bytes) -> str:
def signed_screenshot_url(key: str) -> str: def signed_screenshot_url(key: str) -> str:
return public_client().generate_presigned_url("get_object", Params={"Bucket": settings.s3_bucket, "Key": key}, ExpiresIn=900) return public_client().generate_presigned_url("get_object", Params={"Bucket": settings.s3_bucket, "Key": key}, ExpiresIn=900)
def delete_screenshot(key: str) -> None:
client().delete_object(Bucket=settings.s3_bucket, Key=key)
+26 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -9,7 +10,7 @@ from sqlalchemy.pool import StaticPool
from app.database import Base, get_session from app.database import Base, get_session
from app.main import app from app.main import app
from app.models import Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody from app.models import Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
@@ -122,3 +123,27 @@ def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
assert response.status_code == 204 assert response.status_code == 204
duplicate = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}) duplicate = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
assert duplicate.status_code == 409 assert duplicate.status_code == 409
def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monkeypatch) -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 93, "y": 94, "weight_g": 4300, "player_name": "Private Player", "source_url": "https://example.test/private", "comment": "private comment"}).json()
with Session(engine) as db:
report = db.get(CatchReport, UUID(created["id"]))
report.screenshot_key = "reports/private.jpg"
db.commit()
deleted_keys: list[str] = []
monkeypatch.setattr("app.main.delete_screenshot", deleted_keys.append)
headers = {"Authorization": "Bearer change-me-in-production"}
response = client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers)
assert response.status_code == 204
assert deleted_keys == ["reports/private.jpg"]
with Session(engine) as db:
report = db.get(CatchReport, UUID(created["id"]))
assert report.deleted_at is not None
assert report.moderation_status == ModerationStatus.rejected
assert report.player_name is None and report.source_url is None
assert report.screenshot_key is None and report.raw_payload is None
event = db.query(ModerationEvent).filter_by(catch_report_id=report.id).order_by(ModerationEvent.created_at.desc()).first()
assert event is not None
assert event.reason == "user report deleted and anonymized"
assert client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers).status_code == 404
+3 -3
View File
@@ -24,13 +24,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : "Не удалось загрузить очередь."); if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : "Не удалось загрузить очередь.");
const reports: Record<string, unknown>[] = await response.json(); const reports: Record<string, unknown>[] = await response.json();
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; } if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
list.innerHTML = reports.map(report => `<article class="moderation-card" data-report-id="${esc(report.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${report.screenshot_url ? `<a href="${esc(report.screenshot_url)}" target="_blank" rel="noreferrer"><img src="${esc(report.screenshot_url)}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button type="button" data-decision="approved">Одобрить</button><button class="reject" type="button" data-decision="rejected">Отклонить</button></div></div></article>`).join(""); list.innerHTML = reports.map(report => `<article class="moderation-card" data-report-id="${esc(report.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${report.screenshot_url ? `<a href="${esc(report.screenshot_url)}" target="_blank" rel="noreferrer"><img src="${esc(report.screenshot_url)}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button type="button" data-decision="approved">Одобрить</button><button class="reject" type="button" data-decision="rejected">Отклонить</button><button class="delete" type="button" data-delete>Удалить</button></div></div></article>`).join("");
} }
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
list?.addEventListener("click", async event => { list?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return; const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
button.disabled = true; const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value || null; button.disabled = true; const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value || null;
try { const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({status:button.dataset.decision,reason})}); if (!response.ok) throw new Error("Не удалось сохранить решение."); card.remove(); if (list && !list.children.length) list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } try { const deleting = button.hasAttribute("data-delete"); const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason})}); if (!response.ok) throw new Error("Не удалось сохранить решение."); card.remove(); if (list && !list.children.length) list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
}); });
</script> </script>
</Layout> </Layout>
+1 -1
View File
@@ -7,7 +7,7 @@
.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} .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}
.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}.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} .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}.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}
.records-hero,.form-hero{width:min(1360px,calc(100% - 64px));margin:auto;padding:76px 0 48px;display:flex;align-items:end;justify-content:space-between;gap:70px}.records-hero h1,.form-hero h1{font-size:clamp(58px,7vw,108px);margin-bottom:0}.records-hero>div:last-child,.form-hero>p{max-width:500px;color:#627370;line-height:1.65}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px}.source-status small{grid-column:2}.status-dot{width:9px;height:9px;border-radius:50%;background:#82928f}.status-dot.success{background:#83b83a}.record-filters,.report-form{width:min(1360px,calc(100% - 64px));margin:0 auto 55px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:18px;box-shadow:0 30px 80px #16383c12}.record-filters{display:flex;gap:14px;background:var(--deep)}.record-filters label{flex:1}.record-filters input,.report-form input,.report-form textarea,.report-form select{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #d6dfd7;border-radius:9px;background:#f3f6f1;color:var(--deep)}.record-table{width:min(1360px,calc(100% - 64px));margin:auto;background:#fff;border-radius:16px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:20px;border-top:1px solid #e3e9e3}.record-head{background:#e4ebe3;border:0}.record-head span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#647572}.record-row>span,.record-row>time{font-size:13px;color:#60716e}.official-note{width:min(1360px,calc(100% - 64px));margin:20px auto 100px;color:#647572;font-size:12px}.form-hero{align-items:center}.form-hero>div{}.form-hero>p{font-size:17px}.notice{width:min(1360px,calc(100% - 64px));margin:0 auto 18px;padding:16px;border-radius:10px}.notice.success{background:#dff0c3;color:#426315}.notice.error{background:#f5d8d2;color:#8a3025}.report-form{max-width:900px;padding:34px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 16px}.report-form label{display:block;color:#60716e}.report-form .wide{display:block;margin-top:20px}.report-form input:focus,.report-form textarea:focus,.report-form select:focus{outline:none;border-color:#839d4b;box-shadow:0 0 0 3px #c9f45b45}.privacy{font-size:12px;color:#647572}.honeypot{position:absolute;left:-9999px}.back{display:inline-block;margin:40px max(32px,calc((100% - 1360px)/2)) 10px;color:#60716e}.spot-hero,.periods,.detail-grid{width:min(1180px,calc(100% - 64px));margin-inline:auto}.spot-hero{padding:50px;background:var(--deep);color:#fff;border-radius:18px;display:flex;justify-content:space-between}.spot-hero h1{font-size:70px}.spot-hero p{color:#a9b8b5}.pin{width:160px;height:160px;border:1px solid #ffffff34;border-radius:50%;display:grid;place-items:center;color:var(--lime);font:400 35px Georgia}.periods{display:grid;grid-template-columns:repeat(3,1fr);margin-top:20px;background:#fff;border-radius:14px;overflow:hidden}.periods>div{text-align:center;padding:25px;border-right:1px solid #e2e8e2}.periods strong{display:block;font:400 32px Georgia;color:#537014}.periods span{font-size:12px;color:#647572}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:24px;padding-bottom:80px}.catch-list article{display:flex;justify-content:space-between;padding:16px 0;border-bottom:1px solid #d5ded7}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{font-size:12px;color:#647572}.detail-grid aside{margin-top:50px;padding:25px;background:#fff;border-radius:14px}.detail-grid aside li{padding:10px 0;border-bottom:1px solid #e2e8e2}.note{font-size:12px;color:#647572} .records-hero,.form-hero{width:min(1360px,calc(100% - 64px));margin:auto;padding:76px 0 48px;display:flex;align-items:end;justify-content:space-between;gap:70px}.records-hero h1,.form-hero h1{font-size:clamp(58px,7vw,108px);margin-bottom:0}.records-hero>div:last-child,.form-hero>p{max-width:500px;color:#627370;line-height:1.65}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px}.source-status small{grid-column:2}.status-dot{width:9px;height:9px;border-radius:50%;background:#82928f}.status-dot.success{background:#83b83a}.record-filters,.report-form{width:min(1360px,calc(100% - 64px));margin:0 auto 55px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:18px;box-shadow:0 30px 80px #16383c12}.record-filters{display:flex;gap:14px;background:var(--deep)}.record-filters label{flex:1}.record-filters input,.report-form input,.report-form textarea,.report-form select{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #d6dfd7;border-radius:9px;background:#f3f6f1;color:var(--deep)}.record-table{width:min(1360px,calc(100% - 64px));margin:auto;background:#fff;border-radius:16px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:20px;border-top:1px solid #e3e9e3}.record-head{background:#e4ebe3;border:0}.record-head span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#647572}.record-row>span,.record-row>time{font-size:13px;color:#60716e}.official-note{width:min(1360px,calc(100% - 64px));margin:20px auto 100px;color:#647572;font-size:12px}.form-hero{align-items:center}.form-hero>div{}.form-hero>p{font-size:17px}.notice{width:min(1360px,calc(100% - 64px));margin:0 auto 18px;padding:16px;border-radius:10px}.notice.success{background:#dff0c3;color:#426315}.notice.error{background:#f5d8d2;color:#8a3025}.report-form{max-width:900px;padding:34px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 16px}.report-form label{display:block;color:#60716e}.report-form .wide{display:block;margin-top:20px}.report-form input:focus,.report-form textarea:focus,.report-form select:focus{outline:none;border-color:#839d4b;box-shadow:0 0 0 3px #c9f45b45}.privacy{font-size:12px;color:#647572}.honeypot{position:absolute;left:-9999px}.back{display:inline-block;margin:40px max(32px,calc((100% - 1360px)/2)) 10px;color:#60716e}.spot-hero,.periods,.detail-grid{width:min(1180px,calc(100% - 64px));margin-inline:auto}.spot-hero{padding:50px;background:var(--deep);color:#fff;border-radius:18px;display:flex;justify-content:space-between}.spot-hero h1{font-size:70px}.spot-hero p{color:#a9b8b5}.pin{width:160px;height:160px;border:1px solid #ffffff34;border-radius:50%;display:grid;place-items:center;color:var(--lime);font:400 35px Georgia}.periods{display:grid;grid-template-columns:repeat(3,1fr);margin-top:20px;background:#fff;border-radius:14px;overflow:hidden}.periods>div{text-align:center;padding:25px;border-right:1px solid #e2e8e2}.periods strong{display:block;font:400 32px Georgia;color:#537014}.periods span{font-size:12px;color:#647572}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:24px;padding-bottom:80px}.catch-list article{display:flex;justify-content:space-between;padding:16px 0;border-bottom:1px solid #d5ded7}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{font-size:12px;color:#647572}.detail-grid aside{margin-top:50px;padding:25px;background:#fff;border-radius:14px}.detail-grid aside li{padding:10px 0;border-bottom:1px solid #e2e8e2}.note{font-size:12px;color:#647572}
.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 button:disabled{opacity:.55} .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}
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 p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)} 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 p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
@media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}} @media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
@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-top:48px}.lake-card{height:225px}.filters-wrap{position:relative}.filters{grid-template-columns:1fr 1fr}.dashboard{padding:45px 0 74px}.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){.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-top:48px}.lake-card{height:225px}.filters-wrap{position:relative}.filters{grid-template-columns:1fr 1fr}.dashboard{padding:45px 0 74px}.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}}
+1 -1
View File
@@ -31,7 +31,7 @@
- [x] Добавить административный веб-интерфейс очереди модерации поверх существующего API (`/admin/moderation`, токен только в памяти страницы). - [x] Добавить административный веб-интерфейс очереди модерации поверх существующего API (`/admin/moderation`, токен только в памяти страницы).
- [x] Показать скриншот, данные улова и причину решения; реализовать действия «одобрить» и «отклонить» (проверено в браузере на desktop и 390 px). - [x] Показать скриншот, данные улова и причину решения; реализовать действия «одобрить» и «отклонить» (проверено в браузере на desktop и 390 px).
- [ ] Добавить удаление пользовательского сообщения администратором с аудитом действия. - [x] Добавить удаление пользовательского сообщения администратором с аудитом действия (обезличивание записи, удаление объекта MinIO, миграция `0006`).
- [ ] Заменить in-memory rate limit на общее хранилище, пригодное для нескольких API-процессов и перезапусков. - [ ] Заменить in-memory rate limit на общее хранилище, пригодное для нескольких API-процессов и перезапусков.
- [ ] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа. - [ ] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа.
- [ ] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике. - [ ] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике.