fix: purge tackle provenance on deletion
CI / backend-and-migrations (push) Failing after 37s
CI / dependency-audit (push) Failing after 1s
CI / astro-build (push) Failing after 10m9s
CI / compose-e2e (push) Failing after 14m40s

This commit is contained in:
ik
2026-09-22 19:55:34 +07:00
parent b0c1a05754
commit c46b28d1bc
3 changed files with 18 additions and 11 deletions
+11 -8
View File
@@ -4,8 +4,8 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Callable from typing import Callable
from sqlalchemy import delete, or_, select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, selectinload
from .models import CatchReport, ExternalObservation, ModerationEvent, ModerationStatus, SourceType, SubmissionAttempt from .models import CatchReport, ExternalObservation, ModerationEvent, ModerationStatus, SourceType, SubmissionAttempt
@@ -42,15 +42,15 @@ def apply_retention(
counts["submission_attempts"] = len(attempts) counts["submission_attempts"] = len(attempts)
candidate_cutoff = current - timedelta(days=min(policy.unreviewed_days, policy.approved_personal_days)) candidate_cutoff = current - timedelta(days=min(policy.unreviewed_days, policy.approved_personal_days))
candidates = list(session.scalars(select(CatchReport).where( candidate_rows = list(session.scalars(select(CatchReport).options(selectinload(CatchReport.tackle_components)).where(
CatchReport.source_type == SourceType.user, CatchReport.source_type == SourceType.user,
CatchReport.reported_at < candidate_cutoff, CatchReport.reported_at < candidate_cutoff,
or_(
CatchReport.player_name.is_not(None), CatchReport.source_url.is_not(None),
CatchReport.raw_payload.is_not(None), CatchReport.screenshot_key.is_not(None),
CatchReport.screenshot_upload_token_hash.is_not(None),
),
))) )))
candidates = [item for item in candidate_rows if (
item.player_name is not None or item.source_url is not None or item.raw_payload is not None
or item.screenshot_key is not None or item.screenshot_upload_token_hash is not None
or any(component.source_url is not None or component.raw_payload is not None for component in item.tackle_components)
)]
reports = [item for item in candidates if item.reported_at.replace(tzinfo=item.reported_at.tzinfo or timezone.utc) < current - timedelta( reports = [item for item in candidates if item.reported_at.replace(tzinfo=item.reported_at.tzinfo or timezone.utc) < current - timedelta(
days=policy.approved_personal_days if item.moderation_status == ModerationStatus.approved else policy.unreviewed_days, days=policy.approved_personal_days if item.moderation_status == ModerationStatus.approved else policy.unreviewed_days,
)] )]
@@ -96,6 +96,9 @@ def apply_retention(
report.raw_payload = None report.raw_payload = None
report.screenshot_key = None report.screenshot_key = None
report.screenshot_upload_token_hash = None report.screenshot_upload_token_hash = None
for component in report.tackle_components:
component.source_url = None
component.raw_payload = None
for observation in stale: for observation in stale:
session.delete(observation) session.delete(observation)
for observation in published: for observation in published:
+6 -2
View File
@@ -8,7 +8,7 @@ import httpx
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from sqlalchemy import case, func, or_, select from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload, selectinload
from ..admin_security import verify_admin from ..admin_security import verify_admin
from ..community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases from ..community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
@@ -479,7 +479,7 @@ def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: Moderation
@router.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated) @router.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.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update()) report = db.scalar(select(CatchReport).options(selectinload(CatchReport.tackle_components)).where(CatchReport.id == report_id).with_for_update())
if report is None or report.source_type != SourceType.user or report.deleted_at is not None: 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")
if report.moderation_version != payload.expected_version: if report.moderation_version != payload.expected_version:
@@ -534,6 +534,10 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
report.source_url = None report.source_url = None
report.screenshot_key = None report.screenshot_key = None
report.raw_payload = None report.raw_payload = None
report.screenshot_upload_token_hash = None
for component in report.tackle_components:
component.source_url = None
component.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.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() db.commit()
public_cache.invalidate() public_cache.invalidate()
+1 -1
View File
@@ -31,7 +31,7 @@ R-пункты уточняют критерии существующих B/G/U/
- [ ] **R06 · P1 · Надёжный перенос плана (U05).** Preview, объединение/замена с восстановлением, однократный импорт, storage errors, вычисляемая свежесть и точное описание передачи share-данных. Критерий: ссылка не стирает план без выбора, reload не возвращает удалённое, старые данные отмечены. - [ ] **R06 · P1 · Надёжный перенос плана (U05).** Preview, объединение/замена с восстановлением, однократный импорт, storage errors, вычисляемая свежесть и точное описание передачи share-данных. Критерий: ссылка не стирает план без выбора, reload не возвращает удалённое, старые данные отмечены.
- [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия. - [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия.
- [x] **R08 · P1 · Достоверная аналитика снастей (G07).** Публичные пороги закреплены минимум на 3 наблюдениях и 2 игроках независимо от URL-параметров; окно и freshness используют время улова с fallback на время импорта, ответ ограничен `limit`, а canonical ID назначается только при полном однозначном покрытии компонентных наблюдений. `1/1` нельзя превратить в рекомендацию, unresolved не получает ложную привязку. - [x] **R08 · P1 · Достоверная аналитика снастей (G07).** Публичные пороги закреплены минимум на 3 наблюдениях и 2 игроках независимо от URL-параметров; окно и freshness используют время улова с fallback на время импорта, ответ ограничен `limit`, а canonical ID назначается только при полном однозначном покрытии компонентных наблюдений. `1/1` нельзя превратить в рекомендацию, unresolved не получает ложную привязку.
- [ ] **R09 · P1 · Полное удаление личных данных.** Retention/delete учитывают дочерние source_url/raw_payload и восстановление при сбоях S3/БД. Критерий: личные копии не остаются в компонентах, повторная очистка безопасна. - [x] **R09 · P1 · Полное удаление личных данных.** Retention и admin-delete загружают дочерние компоненты, очищают их `source_url/raw_payload` вместе с родительским report и сбрасывают upload-token; retention теперь обнаруживает старые reports даже при уже очищенных родительских полях, если provenance осталась в component. Повторная очистка остаётся безопасной; S3/БД failure drill — отдельный эксплуатационный gate.
- [ ] **R10 · P1 · Единый cooldown и стадии запроса.** Координация CLI/media/scheduler; release только до доказанного обращения. Критерий: DNS до первого запроса освобождает резерв, ошибка после redirect — нет; конкурентные пути делят одно окно. Проверять offline. - [ ] **R10 · P1 · Единый cooldown и стадии запроса.** Координация CLI/media/scheduler; release только до доказанного обращения. Критерий: DNS до первого запроса освобождает резерв, ошибка после redirect — нет; конкурентные пути делят одно окно. Проверять offline.
- [ ] **R18 · P1 · Границы локального и production запуска.** Loopback для dev-портов с явным opt-in LAN; проверить доверенные proxy/client-IP и rate-limit на двух клиентах. Критерий: default dev не открыт в LAN, production различает клиентов. Реальные серверные gates — A07. - [ ] **R18 · P1 · Границы локального и production запуска.** Loopback для dev-портов с явным opt-in LAN; проверить доверенные proxy/client-IP и rate-limit на двух клиентах. Критерий: default dev не открыт в LAN, production различает клиентов. Реальные серверные gates — A07.
- [ ] **R11 · P2 · Честное качество и происхождение media (B25).** Unknown вместо ложного official, hostname allowlist, размеры отдельно от наличия альтернатив, compact подписи и cache/revocation-политика. Критерий: длинные подписи читаемы, неизвестный источник не становится официальным. - [ ] **R11 · P2 · Честное качество и происхождение media (B25).** Unknown вместо ложного official, hostname allowlist, размеры отдельно от наличия альтернатив, compact подписи и cache/revocation-политика. Критерий: длинные подписи читаемы, неизвестный источник не становится официальным.