diff --git a/run/initialization/reset_environment.py b/run/initialization/reset_environment.py index aca34ca..70d0c84 100644 --- a/run/initialization/reset_environment.py +++ b/run/initialization/reset_environment.py @@ -5,6 +5,7 @@ Clears: - Neo4j: drops ALL databases except system, neo4j (including gaisdata, cc.users.*, cc.institutes.*) - Supabase: deletes ALL data tables except gais_local_authorities and gais_schools - Supabase: deletes all auth users except kcar, then re-seeds kcar profile state + - Granular scopes can clear exam corpus, timetable data, or --user-subset seed copies Safe invariants (never touched): - kcar auth account @@ -261,10 +262,40 @@ def _clear_exam_storage() -> Dict[str, Any]: return {"removed": removed, "buckets": list(by_bucket)} +def _clear_user_subset_files() -> Dict[str, Any]: + """Remove files rows and cc.users storage objects created by --user-subset seeding. + + Reuses the seed/unseed implementation so reset(scope="user-subset") has the + same storage-before-row deletion order and idempotency guarantees as + seed_exam_corpus.py --unseed. The helper only targets rows marked by the seeder: + bucket='cc.users', source='exam-corpus-seed', path LIKE 'exam-marker/%'. + """ + try: + from modules.database.supabase.utils.client import SupabaseServiceRoleClient + from modules.database.supabase.utils.storage import StorageAdmin + from run.initialization.seed_exam_corpus import LoadReport, _delete_user_subset_files + except Exception as exc: + logger.warning(f" user-subset clear skipped (import): {exc}") + return {"files_rows_deleted": 0, "storage_objects_removed": 0, "errors": [str(exc)]} + + rep = LoadReport() + _delete_user_subset_files( + SupabaseServiceRoleClient(), + StorageAdmin(), + exam_codes=None, + rep=rep, + ) + return { + "files_rows_deleted": rep.unseed_user_files, + "storage_objects_removed": rep.unseed_objects, + "errors": rep.errors, + } + + # ─── Main reset ─────────────────────────────────────────────────────────────── def reset(scope: str = "all") -> Dict[str, Any]: - """Destructive reset. scope ∈ {all, exam-corpus, timetable}. + """Destructive reset. scope ∈ {all, exam-corpus, timetable, user-subset}. - all : full wipe (Neo4j + Supabase data + auth users) AND the entire exam-marker subsystem listed below. @@ -273,10 +304,12 @@ def reset(scope: str = "all") -> Dict[str, Any]: templates, template layouts, questions, boundaries, response areas, marking batches, student submissions, and mark entries. - timetable : ONLY timetable/calendar materialization tables. + - user-subset : ONLY files rows and cc.users storage objects created by + seed_exam_corpus.py --user-subset. """ scope = (scope or "all").lower() - if scope not in ("all", "exam-corpus", "timetable"): - raise ValueError(f"invalid scope {scope!r} (want all|exam-corpus|timetable)") + if scope not in ("all", "exam-corpus", "timetable", "user-subset"): + raise ValueError(f"invalid scope {scope!r} (want all|exam-corpus|timetable|user-subset)") url, headers = _sb_headers() _assert_reset_allowed(url, scope) @@ -291,6 +324,11 @@ def reset(scope: str = "all") -> Dict[str, Any]: cleared, failed = _clear_tables(url, headers, TIMETABLE_TABLES) return {"scope": scope, "tables_cleared": cleared, "tables_failed": failed} + if scope == "user-subset": + logger.info("RESET (scope=user-subset) — --user-subset cc.users storage objects and files rows") + user_subset = _clear_user_subset_files() + return {"scope": scope, "user_subset": user_subset} + logger.info("=" * 60) logger.info("RESET ENVIRONMENT — full destructive wipe starting") logger.info("=" * 60) diff --git a/tests/test_reset_environment_user_subset.py b/tests/test_reset_environment_user_subset.py new file mode 100644 index 0000000..8eb91bf --- /dev/null +++ b/tests/test_reset_environment_user_subset.py @@ -0,0 +1,51 @@ +from run.initialization import reset_environment + + +def test_reset_user_subset_scope_only_runs_user_subset_cleanup(monkeypatch): + calls = [] + + monkeypatch.setattr( + reset_environment, + "_sb_headers", + lambda: ("http://192.168.0.94:8000", {"Authorization": "Bearer redacted"}), + ) + monkeypatch.setattr( + reset_environment, + "_assert_reset_allowed", + lambda url, scope: calls.append(("guard", url, scope)), + ) + monkeypatch.setattr( + reset_environment, + "_clear_user_subset_files", + lambda: {"files_rows_deleted": 2, "storage_objects_removed": 2, "errors": []}, + ) + + def fail_if_called(*_args, **_kwargs): + raise AssertionError("reset(scope='user-subset') must not clear unrelated tables or databases") + + monkeypatch.setattr(reset_environment, "_clear_tables", fail_if_called) + monkeypatch.setattr(reset_environment, "_neo4j_drop_all_non_system", fail_if_called) + monkeypatch.setattr(reset_environment, "_clear_exam_storage", fail_if_called) + + result = reset_environment.reset(scope="user-subset") + + assert calls == [("guard", "http://192.168.0.94:8000", "user-subset")] + assert result == { + "scope": "user-subset", + "user_subset": {"files_rows_deleted": 2, "storage_objects_removed": 2, "errors": []}, + } + + +def test_reset_accepts_case_insensitive_user_subset_scope(monkeypatch): + monkeypatch.setattr(reset_environment, "_sb_headers", lambda: ("http://192.168.0.94:8000", {})) + monkeypatch.setattr(reset_environment, "_assert_reset_allowed", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + reset_environment, + "_clear_user_subset_files", + lambda: {"files_rows_deleted": 0, "storage_objects_removed": 0, "errors": []}, + ) + + assert reset_environment.reset(scope="USER-SUBSET") == { + "scope": "user-subset", + "user_subset": {"files_rows_deleted": 0, "storage_objects_removed": 0, "errors": []}, + }