fix: purge tackle provenance on deletion
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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-политика. Критерий: длинные подписи читаемы, неизвестный источник не становится официальным.
|
||||||
|
|||||||
Reference in New Issue
Block a user