From 221cf7bb6c3049c2cb832f906da464c25b211643 Mon Sep 17 00:00:00 2001 From: MEP Agent Date: Tue, 14 Jul 2026 12:19:53 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat(foundation):=20Phase=200=20complete?= =?UTF-8?q?=20=E2=80=94=20MEP=20project=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructured repo: existing engine preserved in backend/migration/ - FastAPI backend skeleton with health endpoint - React + TypeScript frontend shell with Azure Portal UI - Docker Compose stack (React + FastAPI + PostgreSQL) - GitHub Actions CI/CD pipeline - Documentation (Architecture, DeveloperGuide, API, ADRs) --- .abacus.donotdelete | 1 + .github/workflows/ci.yml | 61 + .gitignore | 21 + ...20638-5cc98304bd6b46cd8995f7366abcb348.err | 0 ...8-5cc98304bd6b46cd8995f7366abcb348.retcode | 1 + ...20638-76081c43c42740afab863c4d1aa7e876.err | 0 ...8-76081c43c42740afab863c4d1aa7e876.retcode | 1 + ...20638-dd029799a43140f59bf3dc3a9bbc06d1.err | 0 ...8-dd029799a43140f59bf3dc3a9bbc06d1.retcode | 1 + ...20926-043b35346de54103ae934160cb40769a.err | 0 ...6-043b35346de54103ae934160cb40769a.retcode | 1 + ...20926-29affadedd99466d8c20bed323741c6d.err | 0 ...6-29affadedd99466d8c20bed323741c6d.retcode | 1 + ...20926-b371c17d1ec94ea99f98fc35e4e2a8b6.err | 0 ...6-b371c17d1ec94ea99f98fc35e4e2a8b6.retcode | 1 + ...21622-3109e609c96e4d10a4efba1bc6cd4c6c.err | 0 ...2-3109e609c96e4d10a4efba1bc6cd4c6c.retcode | 1 + ...21622-98fea740f407491aa80f035877571b98.err | 0 ...2-98fea740f407491aa80f035877571b98.retcode | 1 + ...21623-8634b54612934d2a920e6e9ebdaa642c.err | 0 ...3-8634b54612934d2a920e6e9ebdaa642c.retcode | 1 + ...21623-b8972cb1fc964026bc7faf57c4a5f651.err | 0 ...3-b8972cb1fc964026bc7faf57c4a5f651.retcode | 1 + README.md | 48 +- backend/.abacus.donotdelete | 1 + backend/Dockerfile | 13 + backend/api/__init__.py | 1 + backend/api/routes/__init__.py | 1 + backend/api/routes/health.py | 26 + backend/config.py | 30 + backend/database/__init__.py | 1 + backend/database/connection.py | 58 + backend/database/models.py | 12 + backend/evaluation/__init__.py | 1 + backend/main.py | 51 + backend/migration/README.md | 29 + .../build}/adapters/adapter_influxdb.sh | 0 .../build}/adapters/adapter_mariadb.sh | 0 .../build}/adapters/adapter_postgresql.sh | 0 .../build}/adapters/adapter_redis.sh | 0 .../build}/adapters/adapter_sqlite.sh | 0 .../build}/adapters/adapter_teradata.sh | 0 .../migration/build}/config.env.example | 0 .../migration/build}/csv/loader_influxdb.sh | 0 .../migration/build}/csv/loader_mariadb.sh | 0 .../migration/build}/csv/loader_postgresql.sh | 0 .../migration/build}/csv/loader_redis.sh | 0 .../migration/build}/csv/loader_sqlite.sh | 0 .../migration/build}/csv/loader_teradata.sh | 0 .../build}/csv/samples/customers.csv | 0 .../build}/csv/samples/inventory.csv | 0 .../migration/build}/csv/samples/orders.csv | 0 .../migration/build}/csv/validator.py | 0 .../migration/build}/csv/validator.sh | 0 .../migration/build}/csv_loader.sh | 0 .../migration/build}/csv_utilise.sh | 0 .../migration/build}/deploy_all.sh | 0 .../migration/build}/environments/env_dev.sql | 0 .../build}/environments/env_prod.sql | 0 .../build}/environments/env_staging.sql | 0 .../build}/environments/env_test.sql | 0 .../build}/schema/influxdb/te_seed_data.lp | 0 .../build}/schema/mariadb/te_core_schema.sql | 0 .../build}/schema/mariadb/te_seed_data.sql | 0 .../schema/postgresql/te_core_schema.sql | 0 .../build}/schema/postgresql/te_seed_data.sql | 0 .../build}/schema/redis/te_seed_data.sh | 0 .../build}/schema/sqlite/te_core_schema.sql | 0 .../build}/schema/sqlite/te_seed_data.sql | 0 .../build}/schema/teradata/te_core_schema.sql | 0 .../build}/schema/teradata/te_seed_data.sql | 0 {build => backend/migration/build}/setup.sh | 0 .../migration/build}/te_core_schema.sql | 0 .../migration/build}/te_seed_data.sql | 0 .../build}/terraform-github-repos/.gitignore | 0 .../build}/terraform-github-repos/main.tf | 0 .../build}/terraform-github-repos/outputs.tf | 0 .../terraform.tfvars.example | 0 .../terraform-github-repos/variables.tf | 0 .../migration/evals}/FAILURE_MODES.md | 0 {evals => backend/migration/evals}/HANDOFF.md | 0 {evals => backend/migration/evals}/PLAN.md | 0 {evals => backend/migration/evals}/USAGE.md | 0 .../tier_i/01_deploy_dev_twice/NOTES.txt | 0 .../datasets/tier_p/01_happy_path/input.csv | 0 .../datasets/tier_p/02_empty_file/input.csv | 0 .../03_empty_header_only_newline/input.csv | 0 .../tier_p/04_no_valid_rows/input.csv | 0 .../tier_p/05_mixed_valid_skipped/input.csv | 0 .../tier_p/06_duplicate_headers/input.csv | 0 .../tier_p/07_column_mismatch_short/input.csv | 0 .../tier_p/08_column_mismatch_long/input.csv | 0 .../datasets/tier_p/09_empty_row/input.csv | 0 .../datasets/tier_p/10_utf8_bom/input.csv | 0 .../datasets/tier_p/11_utf8_emoji/input.csv | 0 .../tier_p/12_crlf_line_endings/input.csv | 0 .../datasets/tier_p/13_quoted_comma/input.csv | 0 .../tier_p/14_quoted_newline/input.csv | 0 .../datasets/tier_p/15_quoted_quote/input.csv | 0 .../tier_p/16_whitespace_only_row/input.csv | 0 .../tier_p/17_header_whitespace/input.csv | 0 .../datasets/tier_p/18_utf8_cjk/input.csv | 0 .../tier_p/19_missing_env_vars/README.txt | 0 .../tier_p/20_missing_csv_file/README.txt | 0 .../datasets/tier_p/21_utf8_arabic/input.csv | 0 .../tier_p/22_very_long_field/README.txt | 0 .../tier_p/23_invalid_utf8_bytes/README.txt | 0 .../NOTES.txt | 0 .../expected/tier_i/01_deploy_dev_twice.json | 0 .../evals}/expected/tier_p/01_happy_path.json | 0 .../evals}/expected/tier_p/02_empty_file.json | 0 .../tier_p/03_empty_header_only_newline.json | 0 .../expected/tier_p/04_no_valid_rows.json | 0 .../tier_p/05_mixed_valid_skipped.json | 0 .../expected/tier_p/06_duplicate_headers.json | 0 .../tier_p/07_column_mismatch_short.json | 0 .../tier_p/08_column_mismatch_long.json | 0 .../evals}/expected/tier_p/09_empty_row.json | 0 .../evals}/expected/tier_p/10_utf8_bom.json | 0 .../evals}/expected/tier_p/11_utf8_emoji.json | 0 .../expected/tier_p/12_crlf_line_endings.json | 0 .../expected/tier_p/13_quoted_comma.json | 0 .../expected/tier_p/14_quoted_newline.json | 0 .../expected/tier_p/15_quoted_quote.json | 0 .../tier_p/16_whitespace_only_row.json | 0 .../expected/tier_p/17_header_whitespace.json | 0 .../evals}/expected/tier_p/18_utf8_cjk.json | 0 .../expected/tier_p/19_missing_env_vars.json | 0 .../expected/tier_p/20_missing_csv_file.json | 0 .../expected/tier_p/21_utf8_arabic.json | 0 .../expected/tier_p/22_very_long_field.json | 0 .../tier_p/23_invalid_utf8_bytes.json | 0 .../01_fresh_deploy_then_all_tests_pass.json | 0 .../migration/evals}/gap_report.py | 0 {evals => backend/migration/evals}/runner.py | 0 .../migration/infra}/.dockerignore | 0 {infra => backend/migration/infra}/Dockerfile | 0 .../infra}/azure-portal-walkthrough.md | 0 .../migration/infra}/entrypoint.sh | 0 .../infra}/terraform-prod/.gitignore | 0 .../migration/infra}/terraform-prod/main.tf | 0 .../infra}/terraform-prod/outputs.tf | 0 .../terraform-prod/terraform.tfvars.example | 0 .../infra}/terraform-prod/variables.tf | 0 .../migration/infra}/terraform/.gitignore | 0 .../migration/infra}/terraform/main.tf | 0 .../migration/infra}/terraform/outputs.tf | 0 .../infra}/terraform/terraform.tfvars.example | 0 .../migration/infra}/terraform/variables.tf | 0 .../migration/tests}/conftest.py | 0 .../tests}/framework/test_framework.sql | 0 .../migration/tests}/run_all_tests.sql | 0 .../migration/tests}/run_python_tests.ps1 | 0 .../migration/tests}/run_tests.sh | 0 .../tests}/snapshots/basic_expected_skip.csv | 0 .../tests}/snapshots/basic_expected_valid.csv | 0 .../tests}/snapshots/basic_input.csv | 0 .../test_01_organisations_personnel.sql | 0 .../tests}/suites/test_02_programs_phases.sql | 0 .../suites/test_03_requirements_vcrm.sql | 0 .../suites/test_04_execution_defects.sql | 0 .../test_05_schema_and_business_rules.sql | 0 .../test_csv_loader_arbitrary_shapes.py | 0 .../migration/tests}/test_csv_utilise.py | 0 .../migration/tests}/test_csv_validator.py | 0 .../migration/tests}/test_e2e_pipeline.py | 0 .../migration/tests}/test_evals_runner.py | 0 .../migration/tests}/test_parity.py | 0 .../migration/tests}/test_regression.py | 0 .../migration/tests}/test_security.py | 0 .../migration/tests}/test_snapshot.py | 0 backend/reports/__init__.py | 1 + backend/requirements.txt | 11 + backend/services/__init__.py | 1 + backend/tests/__init__.py | 1 + backend/tests/test_health.py | 30 + docker-compose.yml | 46 + docker/.gitkeep | 0 docs/ADR/ADR-001-Technology-Choices.md | 31 + docs/ADR/ADR-002-Repository-Structure.md | 38 + docs/API.md | 59 + docs/Architecture.md | 57 + docs/DeveloperGuide.md | 85 + frontend/.abacus.donotdelete | 1 + frontend/.dockerignore | 7 + frontend/.gitignore | 24 + frontend/.gitkeep | 0 frontend/.oxlintrc.json | 8 + frontend/Dockerfile | 25 + frontend/README.md | 32 + frontend/index.html | 13 + frontend/nginx.conf | 23 + frontend/package-lock.json | 2713 +++++++++++++++++ frontend/package.json | 32 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 28 + frontend/src/api/client.ts | 33 + frontend/src/assets/vite.svg | 1 + frontend/src/components/Layout/AppLayout.tsx | 49 + frontend/src/components/Layout/Sidebar.tsx | 141 + frontend/src/components/Layout/TopBar.tsx | 62 + frontend/src/components/Layout/navConfig.tsx | 25 + frontend/src/main.tsx | 23 + frontend/src/pages/Administration.tsx | 12 + frontend/src/pages/Dashboard.tsx | 12 + frontend/src/pages/History.tsx | 12 + frontend/src/pages/MigrationRuns.tsx | 12 + frontend/src/pages/NewMigration.tsx | 12 + frontend/src/pages/PagePlaceholder.tsx | 49 + frontend/src/pages/Reports.tsx | 12 + frontend/src/pages/Validation.tsx | 12 + frontend/src/theme.ts | 81 + frontend/tsconfig.app.json | 26 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 18 + uploads/.gitkeep | 0 218 files changed, 4277 insertions(+), 1 deletion(-) create mode 100644 .abacus.donotdelete create mode 100644 .github/workflows/ci.yml rename evals/datasets/tier_p/02_empty_file/input.csv => .logs/20260714-120638-5cc98304bd6b46cd8995f7366abcb348.err (100%) create mode 100644 .logs/20260714-120638-5cc98304bd6b46cd8995f7366abcb348.retcode create mode 100644 .logs/20260714-120638-76081c43c42740afab863c4d1aa7e876.err create mode 100644 .logs/20260714-120638-76081c43c42740afab863c4d1aa7e876.retcode create mode 100644 .logs/20260714-120638-dd029799a43140f59bf3dc3a9bbc06d1.err create mode 100644 .logs/20260714-120638-dd029799a43140f59bf3dc3a9bbc06d1.retcode create mode 100644 .logs/20260714-120926-043b35346de54103ae934160cb40769a.err create mode 100644 .logs/20260714-120926-043b35346de54103ae934160cb40769a.retcode create mode 100644 .logs/20260714-120926-29affadedd99466d8c20bed323741c6d.err create mode 100644 .logs/20260714-120926-29affadedd99466d8c20bed323741c6d.retcode create mode 100644 .logs/20260714-120926-b371c17d1ec94ea99f98fc35e4e2a8b6.err create mode 100644 .logs/20260714-120926-b371c17d1ec94ea99f98fc35e4e2a8b6.retcode create mode 100644 .logs/20260714-121622-3109e609c96e4d10a4efba1bc6cd4c6c.err create mode 100644 .logs/20260714-121622-3109e609c96e4d10a4efba1bc6cd4c6c.retcode create mode 100644 .logs/20260714-121622-98fea740f407491aa80f035877571b98.err create mode 100644 .logs/20260714-121622-98fea740f407491aa80f035877571b98.retcode create mode 100644 .logs/20260714-121623-8634b54612934d2a920e6e9ebdaa642c.err create mode 100644 .logs/20260714-121623-8634b54612934d2a920e6e9ebdaa642c.retcode create mode 100644 .logs/20260714-121623-b8972cb1fc964026bc7faf57c4a5f651.err create mode 100644 .logs/20260714-121623-b8972cb1fc964026bc7faf57c4a5f651.retcode create mode 100644 backend/.abacus.donotdelete create mode 100644 backend/Dockerfile create mode 100644 backend/api/__init__.py create mode 100644 backend/api/routes/__init__.py create mode 100644 backend/api/routes/health.py create mode 100644 backend/config.py create mode 100644 backend/database/__init__.py create mode 100644 backend/database/connection.py create mode 100644 backend/database/models.py create mode 100644 backend/evaluation/__init__.py create mode 100644 backend/main.py create mode 100644 backend/migration/README.md rename {build => backend/migration/build}/adapters/adapter_influxdb.sh (100%) rename {build => backend/migration/build}/adapters/adapter_mariadb.sh (100%) rename {build => backend/migration/build}/adapters/adapter_postgresql.sh (100%) rename {build => backend/migration/build}/adapters/adapter_redis.sh (100%) rename {build => backend/migration/build}/adapters/adapter_sqlite.sh (100%) rename {build => backend/migration/build}/adapters/adapter_teradata.sh (100%) rename {build => backend/migration/build}/config.env.example (100%) rename {build => backend/migration/build}/csv/loader_influxdb.sh (100%) rename {build => backend/migration/build}/csv/loader_mariadb.sh (100%) rename {build => backend/migration/build}/csv/loader_postgresql.sh (100%) rename {build => backend/migration/build}/csv/loader_redis.sh (100%) rename {build => backend/migration/build}/csv/loader_sqlite.sh (100%) rename {build => backend/migration/build}/csv/loader_teradata.sh (100%) rename {build => backend/migration/build}/csv/samples/customers.csv (100%) rename {build => backend/migration/build}/csv/samples/inventory.csv (100%) rename {build => backend/migration/build}/csv/samples/orders.csv (100%) rename {build => backend/migration/build}/csv/validator.py (100%) rename {build => backend/migration/build}/csv/validator.sh (100%) rename {build => backend/migration/build}/csv_loader.sh (100%) rename {build => backend/migration/build}/csv_utilise.sh (100%) rename {build => backend/migration/build}/deploy_all.sh (100%) rename {build => backend/migration/build}/environments/env_dev.sql (100%) rename {build => backend/migration/build}/environments/env_prod.sql (100%) rename {build => backend/migration/build}/environments/env_staging.sql (100%) rename {build => backend/migration/build}/environments/env_test.sql (100%) rename {build => backend/migration/build}/schema/influxdb/te_seed_data.lp (100%) rename {build => backend/migration/build}/schema/mariadb/te_core_schema.sql (100%) rename {build => backend/migration/build}/schema/mariadb/te_seed_data.sql (100%) rename {build => backend/migration/build}/schema/postgresql/te_core_schema.sql (100%) rename {build => backend/migration/build}/schema/postgresql/te_seed_data.sql (100%) rename {build => backend/migration/build}/schema/redis/te_seed_data.sh (100%) rename {build => backend/migration/build}/schema/sqlite/te_core_schema.sql (100%) rename {build => backend/migration/build}/schema/sqlite/te_seed_data.sql (100%) rename {build => backend/migration/build}/schema/teradata/te_core_schema.sql (100%) rename {build => backend/migration/build}/schema/teradata/te_seed_data.sql (100%) rename {build => backend/migration/build}/setup.sh (100%) rename {build => backend/migration/build}/te_core_schema.sql (100%) rename {build => backend/migration/build}/te_seed_data.sql (100%) rename {build => backend/migration/build}/terraform-github-repos/.gitignore (100%) rename {build => backend/migration/build}/terraform-github-repos/main.tf (100%) rename {build => backend/migration/build}/terraform-github-repos/outputs.tf (100%) rename {build => backend/migration/build}/terraform-github-repos/terraform.tfvars.example (100%) rename {build => backend/migration/build}/terraform-github-repos/variables.tf (100%) rename {evals => backend/migration/evals}/FAILURE_MODES.md (100%) rename {evals => backend/migration/evals}/HANDOFF.md (100%) rename {evals => backend/migration/evals}/PLAN.md (100%) rename {evals => backend/migration/evals}/USAGE.md (100%) rename {evals => backend/migration/evals}/datasets/tier_i/01_deploy_dev_twice/NOTES.txt (100%) rename {evals => backend/migration/evals}/datasets/tier_p/01_happy_path/input.csv (100%) create mode 100644 backend/migration/evals/datasets/tier_p/02_empty_file/input.csv rename {evals => backend/migration/evals}/datasets/tier_p/03_empty_header_only_newline/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/04_no_valid_rows/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/05_mixed_valid_skipped/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/06_duplicate_headers/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/07_column_mismatch_short/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/08_column_mismatch_long/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/09_empty_row/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/10_utf8_bom/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/11_utf8_emoji/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/12_crlf_line_endings/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/13_quoted_comma/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/14_quoted_newline/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/15_quoted_quote/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/16_whitespace_only_row/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/17_header_whitespace/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/18_utf8_cjk/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/19_missing_env_vars/README.txt (100%) rename {evals => backend/migration/evals}/datasets/tier_p/20_missing_csv_file/README.txt (100%) rename {evals => backend/migration/evals}/datasets/tier_p/21_utf8_arabic/input.csv (100%) rename {evals => backend/migration/evals}/datasets/tier_p/22_very_long_field/README.txt (100%) rename {evals => backend/migration/evals}/datasets/tier_p/23_invalid_utf8_bytes/README.txt (100%) rename {evals => backend/migration/evals}/datasets/tier_s/01_fresh_deploy_then_all_tests_pass/NOTES.txt (100%) rename {evals => backend/migration/evals}/expected/tier_i/01_deploy_dev_twice.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/01_happy_path.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/02_empty_file.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/03_empty_header_only_newline.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/04_no_valid_rows.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/05_mixed_valid_skipped.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/06_duplicate_headers.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/07_column_mismatch_short.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/08_column_mismatch_long.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/09_empty_row.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/10_utf8_bom.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/11_utf8_emoji.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/12_crlf_line_endings.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/13_quoted_comma.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/14_quoted_newline.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/15_quoted_quote.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/16_whitespace_only_row.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/17_header_whitespace.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/18_utf8_cjk.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/19_missing_env_vars.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/20_missing_csv_file.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/21_utf8_arabic.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/22_very_long_field.json (100%) rename {evals => backend/migration/evals}/expected/tier_p/23_invalid_utf8_bytes.json (100%) rename {evals => backend/migration/evals}/expected/tier_s/01_fresh_deploy_then_all_tests_pass.json (100%) rename {evals => backend/migration/evals}/gap_report.py (100%) rename {evals => backend/migration/evals}/runner.py (100%) rename {infra => backend/migration/infra}/.dockerignore (100%) rename {infra => backend/migration/infra}/Dockerfile (100%) rename {infra => backend/migration/infra}/azure-portal-walkthrough.md (100%) rename {infra => backend/migration/infra}/entrypoint.sh (100%) rename {infra => backend/migration/infra}/terraform-prod/.gitignore (100%) rename {infra => backend/migration/infra}/terraform-prod/main.tf (100%) rename {infra => backend/migration/infra}/terraform-prod/outputs.tf (100%) rename {infra => backend/migration/infra}/terraform-prod/terraform.tfvars.example (100%) rename {infra => backend/migration/infra}/terraform-prod/variables.tf (100%) rename {infra => backend/migration/infra}/terraform/.gitignore (100%) rename {infra => backend/migration/infra}/terraform/main.tf (100%) rename {infra => backend/migration/infra}/terraform/outputs.tf (100%) rename {infra => backend/migration/infra}/terraform/terraform.tfvars.example (100%) rename {infra => backend/migration/infra}/terraform/variables.tf (100%) rename {tests => backend/migration/tests}/conftest.py (100%) rename {tests => backend/migration/tests}/framework/test_framework.sql (100%) rename {tests => backend/migration/tests}/run_all_tests.sql (100%) rename {tests => backend/migration/tests}/run_python_tests.ps1 (100%) rename {tests => backend/migration/tests}/run_tests.sh (100%) rename {tests => backend/migration/tests}/snapshots/basic_expected_skip.csv (100%) rename {tests => backend/migration/tests}/snapshots/basic_expected_valid.csv (100%) rename {tests => backend/migration/tests}/snapshots/basic_input.csv (100%) rename {tests => backend/migration/tests}/suites/test_01_organisations_personnel.sql (100%) rename {tests => backend/migration/tests}/suites/test_02_programs_phases.sql (100%) rename {tests => backend/migration/tests}/suites/test_03_requirements_vcrm.sql (100%) rename {tests => backend/migration/tests}/suites/test_04_execution_defects.sql (100%) rename {tests => backend/migration/tests}/suites/test_05_schema_and_business_rules.sql (100%) rename {tests => backend/migration/tests}/test_csv_loader_arbitrary_shapes.py (100%) rename {tests => backend/migration/tests}/test_csv_utilise.py (100%) rename {tests => backend/migration/tests}/test_csv_validator.py (100%) rename {tests => backend/migration/tests}/test_e2e_pipeline.py (100%) rename {tests => backend/migration/tests}/test_evals_runner.py (100%) rename {tests => backend/migration/tests}/test_parity.py (100%) rename {tests => backend/migration/tests}/test_regression.py (100%) rename {tests => backend/migration/tests}/test_security.py (100%) rename {tests => backend/migration/tests}/test_snapshot.py (100%) create mode 100644 backend/reports/__init__.py create mode 100644 backend/requirements.txt create mode 100644 backend/services/__init__.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_health.py create mode 100644 docker-compose.yml create mode 100644 docker/.gitkeep create mode 100644 docs/ADR/ADR-001-Technology-Choices.md create mode 100644 docs/ADR/ADR-002-Repository-Structure.md create mode 100644 docs/API.md create mode 100644 docs/Architecture.md create mode 100644 docs/DeveloperGuide.md create mode 100644 frontend/.abacus.donotdelete create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/.gitkeep create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/Layout/AppLayout.tsx create mode 100644 frontend/src/components/Layout/Sidebar.tsx create mode 100644 frontend/src/components/Layout/TopBar.tsx create mode 100644 frontend/src/components/Layout/navConfig.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Administration.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/History.tsx create mode 100644 frontend/src/pages/MigrationRuns.tsx create mode 100644 frontend/src/pages/NewMigration.tsx create mode 100644 frontend/src/pages/PagePlaceholder.tsx create mode 100644 frontend/src/pages/Reports.tsx create mode 100644 frontend/src/pages/Validation.tsx create mode 100644 frontend/src/theme.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 uploads/.gitkeep diff --git a/.abacus.donotdelete b/.abacus.donotdelete new file mode 100644 index 0000000..c7002ee --- /dev/null +++ b/.abacus.donotdelete @@ -0,0 +1 @@ +gAAAAABqViYyd3d0F4BEcAOUTgo-sXGPwqHKtcb9FIxeSulmTeVeKePkx8d3lxX6sbX2PEVoMK53ZC9G318UpxdHLvc0e7gPjlw-4P0Jl_5nRhv4YNyLV7RhIMcXCBmHQhgo-GvkU_ksMGQ2S1AxYPXKEXTyi4bsGp4ftIMxd4c6HoHZzDqe9z4Shhx8CCA6jFQYBf4ilqVM0dBBxCSTOLabaI5L4kilHP6LhWIEiYy0Gv0CBmoJYosziTGrq0XzgHxnQNO1HJeDOAj3e9EFmGKKUwSRKIpVUM6Sg1yOG4qQyZcAZFT9glBAiTMn9jI0VCAkVux9dPsLmSHF-PVsfCc1Ru6cOI7_0f9OoYMxyJzhuSbYwf-6RZxxsdPmYJyxv-BoCd9rlMgBZutrq7HtJRWuS-qfwS6VWXDg_koCCjhPxTGp0kv6A4f-yzRcTN9MIGDLpyqDzU0QSmSQzoJyv7_uopFSGJZinQrN77JehXHmzZ-LtA-kPup4BfzuasrnW0iWGVwSOrBSrRmL_0y5M939TiHX7WPshaeCaDC_alhyCr7OpEwYp_WAmIh9ePPU6eUQI8WuE7YPzJTfcAbX5_M0R7GIaWAToQxpb4ZoOsK8fvo3mOzUnA8QMlPA7QcMP7MhXjszLi8DbzaroNDGT0xMA0njBBuS2EsnszllcvhH4M-3OMI7TlDQVAiudt8GjtXWaBB636jhKyKXKCY5fhfbCXmA81XAHZYLTkGBKlg7BwVkkQMK_4L-PCAbJNO3s_hgSUDw1F3PFmETH_kNS9tB4tGT-ZwbLizSy4QC5hCH7R8eCZM7nWuG-BXRUy5rAJ5DI_mHx2Q_yZS3KHckbglraPs6TDrFZ5JtaJFJpa5oSYwjCBBxrFu9s4-sLoPn8373oh3443mhGslnC_T_OCYKjfYF-nMzaQBmqm3FMwx59yWPdNzFeG95t86jVL7MTwgeGdU4qdKzm7Oz7bukrcfKHuPZUydiIvjShPmKd6HOJR438jyvBxWEUjkuwFvhDjBavTjlRjaDJrFTPooRhiY10i4XoATB8UdjFaz7kOSGHJ6Dm_uKga-fH1Y2-1ZHapRe3v-CH5VzxGDqlZJqdFYCtG7PvsJLgUouOBrg0dP2yxjaqlff0vSgiDqWyLauHqrW5xZlX1RTrQ64r6av6i2IGdsUTkgguAXdzm4UIMNYsNvY-e6GV9nAVZDatXAptcjSWlSGLg4UyZZF1L5bDzXHTSfvLxwg30Sw5UjoVF-rdZjLZvmydto3LMBe4WiyydV0fpXtjn-ZF-w13y3fOBHlfkhI32j3VsROfOiWzjuYRKm2d6IJrf0NfkwcNKeB2FOZAdYFDnD9qOxL0fkfks6viwhByJAaTni6836pY_6ZMJgh3lKwXRcnMUxZxbNR_6qLKSnR_yUMlixVl6a2ZU1g0uW5EmVjITUKVsLe1uDuFG-yM6nYSWcsunXmGQ-2CavyXIrUtePWq-MVkvvlk-FAHleXQpvAEXuy1IwumEMuRC29Io7YEkOIrWAzVRBtwunPQMv8wyaSaj6iQPuiFB4Jit5hhZiTE83bFGf2noUNZjY2XK1b3f8t9GkZEaHOKria22UD4sthbO90_XsIMb7BuLdaWeaNA0jAkYbeu6a5_GlSgRSKpAAxJPAJsiSpfvI9ImJVu6QcQeFm_7usbFmFEpze9PwdZZxQ3UawLEriUYkZ0fAtz8UN1-Ybx-3fvvEPkHQEmglP_1aTbccdYgyccRyzb6fctoAS0U4AbSWrw4UViNbYcZxlOCotEDFWn-PHOq673kjl-hRXLSpIhBvQzc10LlYliY4Ki1alh7L87mZdL2auR5HIgAkg4Rw9HjrAskxLMJ1v22fNnc-Txf5ZoT3-7gxSMAI3i3a41NvydackXA377AqO4dnYalkQqhSe1TL7N_7tMZpsxf7ET_kMcLt5pptSiE0Pgn0N44xpKQM9W07cuXtEBLK9-5SnOCMjdQc-HL7zXHMNybeFJwNvTFLhoCbswqurPw0Np4ueCu66JhNjOtJSWS0w5Y2aAr1VzyNImO-TbUqRG6aE4SJEgEfgOFutROdXPd3_fNGNJepw0qnqkcPDzH9v9MleA0xfK5zt3iNO5M5KSkvnG7y4R-88d_v4nzw0Lvu0tEpmhe1u169QXHnvzhPbb9tV2TlNig2JCPSiY8dU72Kakmzr1DApIB_nSdYqncUit1Lmr7JqIK5ZvUbCEi37E5WOnRsp_lYQefJNLV3ZTiYh3VmaeoVlyh3xRK9QJ4pad8bY6C4YsCDxrahRkISsloK2XdTnsEbGP2zP0rHDWCcmNHjSVEXUDsIn6duuRZ0lcLrm3l73yXy6ZWe6ABFTS9Ffaub_PsPHxTG6AoG7-0QEmNaMvF8C-Vxu6IzISLkwjRLy1BRVBFt2q8zrZ0YZf77BYQ8zD9sqMmaCqudbyNRqAdfwu7E6fF353VVz6UMw8KhvIRp-Aho7iJSg073zbBsysAjZsc42ux2c10mvx02afjfX3D-y4KB7ox6JvfE-eEyxZxAmjYqxAT2hGntPSxvKtwgh0pD7gaVx07jgpyL6vmXWeyCVH3EmGBZaxRyOq4ITE87rxOA-rGd56gQsOfQPyTjtMwSjnwj-qtAlTMJsE_D9SzorWzHd6yw8_CCZPnMT7GcYDpK1bfbNNfaz0kRIhn3GRxlt95Ivy-RK71d-W3SW1QSkOrfhAx6OmJafO-awpgysT6w5BeEFhuXHCoYY5SGq0bwboxOp6WqGHMhNfdMVt4o5bi6hQBF0dMRVKOWxK4u-bf6VA4nzzAMN5R1mKxkJ0YW3UR3eOx6WlYqjgEmlGnufXH_19xJ6yMIbGC1MzK07Z7DijREJ0m931dQtl7jTXCYYAM5AhVtkMtvNrXuanDskARsXVl2XRlbhSrsgxTkusFCBCSP7-qeZGQkGiOuilT-FqdrsHsUkKKzSAnJfmRCKQMbNNm2AszLcslOjbCFGQzXlBBTcXXLcDWhM8_hs3MNuixx_bwQDDjmZ4-ZhMzSz6vyJashL9Gm0k74CT9blEiWgxg1FhUMaWtZ19T_4zxVEKOlXk63m9e9Z3311aSKVvQhrt0wweLsaMB5-ViqhCluTHqSdcDNHiosytnmNeXuzjPfZeXfJWKM8t1qdwOwz7kYKaYSrtioXcIMOvOBw4OOgFDEwKaQHR9quB1VWXTfKUI7S2DRyx2vf9lio04g-zfVir_VarobVgWvZV_hlL9nzR2c8z5wYb2eki9eAvUvURpH0rDKBKdGe5mc7ojdZKK7ZoKX_UQBEHwxI1oy_1dhzXfTORu82el1RLvb3ypvJK07hNFkPVXRjLtlEV6fM_iMsGZCT9p3xNaIAChi0QhKDX_17aZRtc_GuBvLJMTqbHFMixkH6nv18REzJBFwumMAZ_YYesiNPsm4esUBKqAtE6YhT6BCoIlLJXaFKyWg7zAPDwJfOk1qIQQdL-h8EslG-JODQd19t5TaUDNX7xhkU3OeAjzgyNCRvZuJBo8BIeWzBxuJW2WSARU8F0aLsGCGMicsmx92U6-ucjSWGE7D7_tmMdWuNXXq9H1ANBHlRyr6WvPJzgsZN7Twj6CWoUKxKptYhGohC1wckASy7OFGc_7_OJM_Sd_Ts3Zgy630nyK3UzpbTvtgbWl2x485RFEQS0qw4CVLJGlk2iCYvkrau2gfPmGhe9UyZZCSZ3xU6sM-gdLJg6cXUrk8h_JPcr6iawFG1NqG6f9hjFX_rme1W21TT21BqisBRZVJJcSgLL0SVZEsGAeRQJqFlnNqUygp_glO8pbrKFHKQ3-8l8o8tCcWi-wv8mIkcuA2c6rMKthXyGuMkZ46f8V81Furq7XSCHMfdy2mvGgPgmRgIKjiquFiQzYx2MrAd775SGpfNdJgY-U4ZGGp_uYe_YTI1h7j3VuhJxMsqc6FWnmE-AYVE7VlnMbvvHb6WM5N2AvkbmHemmqJth5atAEHkjXlQjx01H_RjEndL2wiL_1b-l2bjRB0Zh186KiVsoYokNpVWh5jFd2Yp-jR-BQVAnr2sbKXHywaaC7eDfmC8PXm6KWnnQj_74p-iekN2UAZIEOWuvyi3KmHxnwc4tkqps2TnwGcWwphP2bGCls43KjDBq8VCXZ4uCsqNoIGMhZGroJ3ux9NDwxYFki-rU3o7wMw8kRSaF1S7Y0o-11ZJJderke6yq9XNqho18VRms8ZpzrASBcaDNbbmgULPrfSRXTBvyT2mVlOjxFrELa87A4d2j07TXi336DDlpr4rHz5d-wI8zTfBekekgYwCBRJIdUdlfwDcD20sslGGf7vF-TY-EyNLnN_WOzd9gy-Gm39lZPwj9FstRfH0lJ4-GYzTWf6qIIQmPHxdBBgi22PO7HWHBIDGUhwYB2fUD3LUdQtMWG5RBSKZdoGga4AER18WOfgXBOegtGi3sw0PPy_ySQPZ9dA2sIRZ3iisMhFTZdrOfSvgrC4P8D-smj-YDkLRP5rjCmlQYKtGq34i1xHraazNfvydgHl4m4j2HgU4f4iDUxHjqkvSJk_oQAc8Wc6eFLj1Iw8gor1exTRZPM21_-DtMPMsDw22kV_tREJ0NlF9m_8BiQpCPhD_DkFvmqaxhCpOGB0NxRLL1qlmPjwfsSzMn5BL8zhLmu_OrSsfsZKeQ7EyzXK9h_wBaePhm6KbYIqnefSy1Gv629J5lp6Uicd0swZtJpEdnm_Rzoei4i2W1iMCd6sbD5QfVEuBH73ezBfVB0-UdRkEWhTXJckJ9QWhpGlN3MlJm7cfDmMnSZSy4hmO8gYppNYvWRGrWnPxCdb2LZk-LTxQqDRgJmIxhGamN0gOAjipk4pu-TDY0Mj-8KPUMy6_H4_n5GsEqKNGONmGX4Eh8xLJR4GsCu6qEnG66CCtYO2fbyIQDV_fr-nXfOKc1CqyCbqhpSRmzJXz6jueJHaxl5gLcpqoRtmjyAt_c0AFqazFjGOI8vYIaJnPePWDQT27WABHTnjTHvFonD4uVxRZE3d8DxmYlelxENAfk1AJiCAKiXvxlbJYfgavWxSC37v5x3JUDc1DV2GGGW9uVe7boC1Bs3Qk5kyRpfKnJAvdKcrVZPF-qzneGRTHJq_WkQC9ItXPxizN0hCb1ZQbtFcg4Cp62fikYYAbiYTpAD9EQFmcIHsffxbxFgvSQnpjSRA7qR8j_8vfpkQB7Qm7IFD25DBWWm50x6xB9jeK5-SAWFlmFfnHLy1az4idiJwyjkIQ5v3tltdutAU4Z_epSrUfCXvVYhSZXcLUJkiZHRb-KcrSqtBC1NHDdEo9ne36rofi4qfM020FT00FG0PmJYtpXTlZIkGY3n3XTvWUHCRBbJUez0fBlTrlVgtbTDBLuvK_nXUsJjgBScFR7ZmXHNeBDhQ8R3foyO-vQnkwSzpkOy39sIzsQcAKf2OxfRmIye15pV3c0M-bKG9TmZImvQUkvA8IetdN3JIR__LDO-e6xC2M9kqr0fPm2SPAO11Y8cjxSVVmdkVKjTlMluIKTEk29i42gjrFZox3Zgf_IzVm-At9hMzeazPIJCxRAHnlb-1v_oHzf7MNat_bBI-gOeWyGAcdl03jJ6MwrfBrTAnSXZ6QCuAo_aE_CebHS5kkg2lEQD_4GHGPCGNJBTMTa-lTS14ZtHqC_u5st9moD71QzkWo4dbmheIjb0GCiPxNfERRxX76WyfLn3XZMsJTygOk3K4c-Jg14JSELAyP3XPkYy5kcF-67a-XLNfMZrU0B25SYxgesiREUphE2tBMo8MsXe2aGOdgmoUBggb5YvcLEtBQZZrZald6UtMBkp7ASUGRadWtC2yTo1mv-_hxt29pO4PjO-GLA81Bn-Qb3vNqDgcqZNEp9q5SxzrAEoht2BZwSdKM9trSpF1jQVSbyyIDhVBaXPusRT1N7htOWx_4kJkl6-bjWGbLtg2WozNyt7v0upVBBOnbkwV-HkrK7hu-ACltYXlxSchJLiHlP_2ysnZhUmQqZ2QDfHYmf7gqg_U83qVCoapibJC6sBcvgGpPC7lB4axQ047gbZM36gcU1YN8YLQQv3bUTD9plZqzXhv5pinNhQGhxoTwQCBqYc0jq6kwJS5Mhh58GnCD08F06SAb_Yxd-9CiBo2hCjCFlfDZyu52sFV3qoFsPHoHTyYfFQioPHT2TeWbFKJJNfHtdI5gTHmp3-SDywLtaiOXt2JAgLLRcevrUOYSlQAw1abTp-A3qY0jQ5rYsKeJzAPLOSwmMy6k-WBHAmZKB9xubuwyrEfDzavBR1FRKWWYhOKVuolq5WRYpi8qPaCDu8oPKBGYAt3yUTI3tWLQwy1qAslqmlLRDGhdUDvHuSeIqbgsOFthyachxdDq7SQQPwomrcHKoMyvr8YLfT0ypcIBOxgKqlTKrvot6Vj9HSnCMu5EdJnIJ4SLL_OBWlGJuPdNwrQ-BRfqV1XoMWeTvuHCRquBoX4ZF8kwG6apKl2qPFkRPysB2Tx9QMjG7fD0O8fbd4HmASBX-dz7PBsyyzUXnUHX9VH5CV59NUULtztZ3NxSJVc_rsEL3LdFEvwNSZQdXTfCQMEtYuuNkmfG8i3KXdSx9UPZhCN9TcPObfhU36h6tXL-QtZ0U_eckngLjQGOvo2mg373o0023UYrbtJwK0oWCXSo5DzDWPXq2mwpdhjSkgtYGqOXCpfxN_EyM6JDBIsN4-8gstY7qrrtxpBvF38CcicUaC3e1E0-id06Ur_VdEtV2Ii2u96FMfqTjtyZb6r7OduTiwYzG7DFETCIj1ajVEUjBpOgSrftrljW2D8uVJXaFkchDSDogZZ95QfCN1LdtSzWMsR4udXSuBY_LhDbr2g2ixELEVQ1iZOem5CRUrenx6uTAWvfzlBQkBFc5pB4Q_H_AuezyXF97J0mA0DfkeIVoCqvni4pn5qlmtyBBPAPop0vbA7TTZzLL2WEgAYHKY1I14bzC5WdF-c6KX4o9v3dzXkGydoJJv9AfAZ4ljz4X8YIaMEDRDaR5NPZ9hN0aYjvJUxux3JmjXuqz2C7Y5fpSecJYYE3yrePYcwHMLYAUkhhcstIjUW-Eun6JviVtTfFlao__IBTefVdtKpstm8Qq_izMFv0cN8G-iWYRyZHaEzZ6PgGVuQv96KJl0yJ0ARJviYG1igmQ3xlWKTuDgAoyug2YnwguCefKRxhTck9EbGRgsJlgfbWTcBbv4A9jxCsF3OaWRbmK53Ffjawolxq-VhbMITpzYmpEFJSnj5Sa2__SN0GopTvQetyI2FBYPXCk_bQ-imRBZPwgcKLWko8dp0EKZdQ1CYvkY01puOEBS-QFN50iHhriMFCcXyAxJNTFscNu2FFzeWjWP_rH7WOv0z45FukQNzsu_zOSzmVzg618Sm5NCUxoW2FVNBuedWbeYtWNJTpheY-L_vZ5b6xUCoXJkR3duX8gJXNfWNa5lDY-bYmxudpUC_uAW0jLTHYvlGIsqF9PlHZ8rqWMx0d_F2Gc8kEVpM5aSvPjdSoVAUaExvX7et_UQe2rMHS-U2zO_Yx3mrXBvesKc2yafyObUTsUUqAEV8MIcoRp2A-dRME7NFFjVbIKT3bcO3POziYOvEo-1H0okuA8SBdei8oQ3Jp7HvlVJ0sWIs84DCvJsizFHYwRZg7sV4TiB7F1AmpzAyPVIvngeIZTKGA40FtudfZHl0RhJdWtFlyykdq2yVC0dmtlYmDv1XC6KSSR7Hzdl_890fZkgvozVsQbGupWXG4nQ7q68UAiPqr_O-GfnAZX-HduqZknWA9g3EdwjpJ0keLUO6Zxg3W3sCUfus_gFlP7QCXh6VbzAj2OT_t7hxSTo-eHVlz09vib1ouahU1au6eOWxi1Rsc2enQMiD8SXQ7jmam1I3aGsBQTSG3Z02H8D2rkcRt88eEQq17RgGR3th1dI1IWnxI7dQDkHsib7xNQCWkDcZZ8HOFXKft2JkjXwFfOrus-aye2NUTkeObWjkKkIl5LpIt_8iPcruDDhoQWrG0GJKryTL29_JA85B-Sb2ETQSm4LDpBPPf-OYheibpyf-aofNs-guR416GdzulY_fdfav2t_2-q-eEA-ntGVOEN-uoADw_ljyUDf1cSe7Ngnl_K1DkAAuruFgFsJxIudJs8sSxtUhMIrFYYHtYmZ-Kat-_iqX59IKpUjWuz7278379423EDCwYFdJjHyXPnRw1AW_XfxEn6DQT6M-u_85mLiRUvlbkXjga96efzLJBVfEG8f8I-rPvg0jHbrlsMvVQSKUoFbqGXNOJEXjKRtBFvR3cnRNGcGUDcnCggJdBRnHAyRljYANjSBD2KY30TDYZ6aBZEJn6_r3PIugAEnRrzhkcA2Us5nKx6C6Ztz-E7znTS2v_IUQ_2KLFiOBR0CAV8WhI1JvxCo2aO-Y5HLvyyBSl0Rt2vjpB6hKQl8omv-DbEcuQob-sUP8PzWBVNsiUFJICn1CKxqqTILnBaCIrxIp0gLnaHukiG7nqFNcHToSZN7kqHr9YOE7fkR_AgKF7GEMJjbWhE_K04kaH0sS5ZznH2-elUaZUcYYLT__qN-_5t8FhXv2z-F_kjmVpMJ3N9MRAZQW3s6FqRxZDfoMw1ZXJFDYexuUwyqwNjiQTtErc424zVCa3_kANQ6010jChk0ksvc68ZK-0WqjlWrSmqvPhQGRLRjGZsWfftG8dx-6J0rZkO-FChUKxkMc5JG2zkTEnuLHQuyihhUTx-TR0DUJqJMdhuI6YMZrR4rmN74Cc7oklje27WvW4S-1RfQ2um8uC_xT21tGHuVlUNphS25awmpc48y_m-uUh5koAt9zHwZEIIBeweOKyf4sExdEfuL9pYpu1ixMLKxj0-oJ2HwKRDzZbGRRtrL1S5uQbCSOHHjSxVvO1O__wNCjO3G8ELbgC9Pfib87o5o5h3rF5vCcLJoL62MPUpH0dsZpcXjBAx6T5bXRUCJXpgiCK9SfQDygl-M1VEcNPe6WZbYbtS7EnNGAdzdGdXsMNRUo6QWT92xpkq8vaV9Z0KWBP7mJUMoJKYe6i10mJvY7Nnb35DBIKPEak5t-ce8PKNDJ6PA-91GxUphqz_98LlEVWGStHyRaverVf6VZcqZbpQyu-sKq2U1B3-c1_7kCPkJ7PMpmryeEEjOl8tXPo4T4HhO1AIg_qePMxUJdXavvqdDGMPq-xp1dA_xScz15GqcL0U-_xCKwiCNZCa2b-7Q_hX2JfOAKvASNb596abj8tf6rHToCflwnzZa6CuovfU4Cv7NzNQ5-MOXdIeybg6jReKDbjilsIl63rasUEbM0MAISu4kafUTpKmUHAYsAUopSAWrBnLFNmaNiCw_RE4CVtqEESz6mo5gelkB8kvgyPbVhbDKXM4zTzrUGx4rI9N4knJ_4IdUUvj-eUttVaFWEMl9YyhyiKMsYbp4N8KU6H1darFZV_69Lo610AAhOlW1erWFyVJvOYfr7ocK-jeSLRi_g1FD6tHRbHkxVVuWDibM2O3SmrdWXaQVMeh3797c3SvBHAkvYYlhX1NHa42EOq-Pi7YLalhjhEEMQBkGCsNC2tg9DKoUyO1eWX0otFbJRw0VHZseNUk5N8kZml6A9VeFbmwaXfR36vaEW91dmMK_HiGE4bpXGcbObGb7LDtpAIuTIgKUvJmmQOQZSZ8kOO2vmCgClGN0l9_4nFLbPhjvt3S6MrNb2XTlAjzRSZ-cnh3HApAriZxR1cmm_mM5VlGzMM9nk7i9FCU-VYd6onz7Por9rp0OU-depU4gfPn2AbVeIKmBIX51cNy6q0L-g1Khj_FO2tiQcxeKtqQra_wzsyk-IBNtfWbpdy18mEQP-Ikr-Dxokeuhr5p-3AIZG6F5g1SJaCE6Hx4rfTHZPvpjk97stbkxLJRuLvWekfm6uuu-TOja-NtKHwHhTsRaQV7S6oz1Cbn_kPuOVA34G_Fyc25SqiDNDXgEIQQ0Qw1kEb0psu0tNeR_5FZ3tgZE-dP5YEZ_qhzLbkprNrd4Tmo3tZ6YLZYaJ69u4yhzZsonz1f6vMeJbvwE1Bk38ZASjE6MVLzkbSMH4-puDodUaxZiyPPZQYIOBPJVEX2WmIhI8iLkSEIFEzUBUb2Afg2fHNUHF7ROCyNcV3tXrDpcGojgLk4istw-ZPr6UbVX7xJPqBz33F8imb-qN-4tLaB7UE-KsgbIoXoK4D5vfb1fMiK_nkeCdHaFjBkbiwxYLe-bKDN3zgla-_I_QOymPEirx5r-jpRiDyr6KnH_KIgQPFCGVzwOvlk_5Da0KWRi1obXDjKcBkIdHAez4U4Gp8Tqb7NNcm87w3bheLDsEUC-TNOB0RdH2_-nkRBBImUfdxbsfzJ4KZ2aT_Mb-_6YZUyKSvnemPKgr-CEiQ1SI9p9a2JcI2Q9bNCC9I9AENfP1Lo6s0_v296BfZ3F9Rx8pyxX5xf-v8esFMX0fsZ_thRhQpSxxd1gnK0h-dNp2bReXu_TvVk-Ci0yriaV4yrrrxk_ygQuOVgmH11K5UV7FJ9axqAtOiZHQ6rPHsp81XHw40L912zzmFoyudRU95joPwchMTafhPXQYrmrQ-kACafWN-SrxOVhMpIcN3JAwiXLSSF3rUfkbGMIag0Qq4BHRK6RifT59FCcswFBhCFOQWPGXvAdb03d3kvSb8TYZJVsdejdFuznw2MuQ0_F8okLwdwAq5Afn5nBvLrdLcoF8o24BpntWLwKJj9rskGifM1QzVOaFEz6ei7VKzS7VKdjWFBO4pR98O_KLvkaenzQSNgHaxXR2IOR4KYNVfQHtHao8GMCoYH5-CQLXHL0woA_c9h42qP7t1Ydcriz9jR4NJQhPEtpXR8H5DJxN78J2NCRwaUjwB8Hd_oEqI8ua-55wUglpp0drQ4yLAZ9KPORWJ792rDE-rQwrWlyq3yEZZb0k0a6PypB6tUEviJ0JdL7G2hS-OUi-DCFWYnS7-HvSPZprDLp1eGWptXhPg7oyVrbCUIverD9bHNLlJQrkaDyAcFM5TM5QN9oUy9OaZl7cMl7B49x-PxTfW44rDmPlV2ce6idFfnH0F1_mCXU5TT2NTScnGHCNYq6DF6cIAP083Sk3YVnng5W-vKYHeJnZnIVIG21Ja__xXJCrk7EjhBaAXjbAxpLpxeMbhSHITu5RsV8q7mAVKMwhr8rcdcjanLoDSnqfT4WpK74KZ1ImK4WmwpUDuP0ZYMuijNMDJ3oQ-Jlr_Kp2E-L9yu2pzbwxLoUoeaJtnFvNU_-W3mPYpkb4_I9hzwDPomhtPyqWtKcPywne8PtmsYKEP3fAalxx_zY0leLdQRtfYn_Igx3nyZRkM9SiHvs5_nmFs4zXaE2NGVMiNKXMcy6qtJZIcTyzjjl2rTXDkdiMlqNJ5-k2rVc7--iAduqXgKSuYhxOwMHpjbZV7dSiNAWgDDKD21c5WCV5ll5silYmWFVykg1Ta5goCg71NjhC-r-VSwUtJDDLHU6UnLWaOQ77FmsA7kmvNnYekYmOOml3S1tMVRDY58ojrukVxDIeramclTw766SPJCDB7rm4o3OexfXbB_I0ZXIa5O2hxI-Az3LMEQ5twd6QQ4LnpLzZjGJEoDklDEXDn6Ld_K-71kKN4KmpBgD4ZRj2izFqP3Nen1D_v-OgDdHd97bPsEQlFkVtcIq0FuHqZLSZpDWZzlrDhXxPzJI-1AXKDuH8LY61RZzcYyNVUzd2v8RRQj3m0vaRSazCWj_J8XHtXR14yuFs-kIXxaYEafXIZQJ3fMLRRA8P8hI3XGmN11sza5Ka80VvPKUvfXitn2dtrP8o83g9rWaQ2FerLxdtsA5D9GGDf51lYzESKzIfd3Q00z-yzuMcStUTjekoxVqwuH9b4frvrogeIcxARh36145UkLwfM8DQodB8X4Yc0FoAYiFWLMpP--xGH3oX7xEhBQwoW6x7MGXkwUXPSNn1FskdEtvSBPKh8f_8hNw6yVXgYhmjr0ZjvkDrhtl8P47IRHnSTjfGXR9eaLZKUoge_Dz_p1Nd4-KoYO7_OtoPERfzo48UeNb7JD_KLO53oqJXjeeRi8iZu75YUApiZE0uVomDjRyCaN114uSkN3FTQaqoxPVZs8K_5I8Q1XisYFaD_QfRB6Z5MSnyZpwLbo1e5rm5sEK7FMJcCosNlzYPzhcoG1lF_n847kNcFQokUQDI-DJdz8KzBfs1xJO96mg1efD0R8tkDY3HD715pcfujjW1sBDFrMYpnwfDNo0kqQR1ar8QbPd3n9nV1zO0utri_pBMoHyOpBgwl0QSRPrHD4c5Nud4EuygIjNhezb38UdIs2hep_okovPQQGffxfSYYNJjRVGh1AbXmAYXoZpdZhywVEUZxzWgTnjjrGNiNpomGOO3hC-f0jaUcnMS9YCKHkMJ7sQKp0jP2OLpYnC9sLTvWavv2ZzlDMAjyw6hgrZddfcwEcy8m_su7vg1-WYP4zDgA23j5GXOLEexLTAC0Ofha7tDC5mO11ijU57FTph9p1-DWGEbzcI4ytHBQR4VFDWV9lqpwQDKUZrFtWAIU0MDerHTLNA2Dqg0fvcNS5XsKwvCgCtrpF-IlNXJ57iGbdTCo8RuvngLEETNJmgMw0PhCDl1GgbZ4OI1UVcUifYqgYciKpD1qzQtALve0TEFasVelchOQSfp9Lv_ywcgPjk6og_jaa_MJ1d4i9KfvauuwC-eFAqZWwvvpkW8rkObzJAEULolcGoIWibVbFRtn7d6jOcKEt3R5ppHQwS6mwss3yElPlB2BgHVNKyiUM9TU_QARF-muxf2n_eL2EXYzDPfXqYAdLDjxUON-ChUUIIJRAXCWvpeXwXQJ6uKuTBZptN3lJcKN0G6Whllgi38KdR4YD2cg2LSwyXmd6myqAK27eh3pA9x_57Gjy3xzz2f-DW9nYV-gaqIuEU3ScmALDbx7d6kTnp8zAnQAULsrraH5u2Q5OuYDpng3ax4ZFbhp6odIDXczXfFDpIjqitw6UmPYNIohnDXpoAJKk0edpXyKEV-uVs3xCOQLdUp7aukF6Z_8BfV1ejwZ9lTiZsO0Gg46IqsqFoblQQhnW_3fx1BR2yKKHq-4YbxLX7N46kTnFYdLD_KOFouPcgpcNO64h_jOFXW3wd1jqW42EVuKq4OD41ygkcoTdi9dLBWIEFSlpBZD8sz-zU8OvJ5sYuPOBQ6UeCsrNzLqHjgkhCSGbaOAExS1QIAKiAfMShFe9qbGJs67Ws_zvNNBhlWvp325yGT7jiJkZUrOySMNRa-9qJ8-CGZjTJKiqG-fuiH7mjNdUoS55nIqbGlVjUBONjLLxkG_DTOgTF5c4PDeTsOfnfajqjTVp0PSbl85UyC868h3szcHnb-UQXLDfg1bPQe9laADMLA7bPIPheipA72MhLrj5OO3HfHhSb1v0hvvqUb1DY3EJRcVdoa7sYT89qdgzZdESP2Aa3XvH6OPCjNJnuyygiEUxExPiO0CId1uXrr_yVTKwQtzvmAwzH-RYcioedYClrdDfC0hNp8Cx1neqFoRCcBouIhc6Am69toT1Ituv2fDuhkBPGeuW-2d01UxKLueofEJCh9nVfOVsG1CyzFzulJ5p20SeEHBPjzxUSzYhdKtaAnSq8bjrJARyIe1bJG6ozMMJjzzJks-Sp2HkTgL92CHL8DZ3bq3fDjALSqOLKWI-3jzGf-kMog-DFPpFI72YmAWke55q2zXPSB0IlYWRafddH68xOlArB59Z_jTLBP60Oh2UhI9F-w4kt3Ws-RvxfHKIUyRkelS96N8vQc7AAtAjdLwizDBGy1AgFR-exeWbaHJviyMRZ1GrpEQ3H4XJ80OUS5_zDgNozWULBWgamzN49j1ajsVNqd2X9an7xcJL06LUCLh9I0mkbtuTkhRlfnx-zkizIMVN9HJLr7fb-mWnonNFWfplAoWD_eAc7aAqJBqKYpW4YDP3fPAVtr3Mffb4o97LOFC3JmyOdPJDoUQcJt6NcJut_H_24Vf_hzp_WdDnaW6Y5GNViMom-fgvRYC-WCkzIg4e6Z8duAkFZ9pDFkthVrxt8j4hfoBSW_p7STlN75KDSWb4t_XscK5gpi9CjIV55JB8yHAuhgITF1AXXqseafoXDTPkmN97TV21fS5rQ97eadFxoaQiTFxTcY9YtAjWYph-IDQ_VJO_Ym1I2HD34K1qz6Xnu2RqcsWm7D-iqzxXO49BwGEJShYInW4TAlhknHZxxNDByUqMg3qmiNnVWBy3lutTshFLabz8ZTF-gi9jJK7x8QBsUuFc1kwScdAjLqAgFgpR0P6JP8jfITLw6n1VDBob8ko0fpxwMzjXBngZ3w9SbLZ5sRRNdLSNSezjilrG33Yh_EPNpXwiLxf8eDGPUOmaJ_YvLb26x3_bcni9yAP9wbgbEQj7kjByBGhajLN4yOdaqb5RogaXPkJ1KC0Adn_mNL6jgSB9GMirLYi7f-lBjyWVLQ6eflKUdyOzpQBNdW1PWYBQyFvZ-ZlkvWndwByUQopyt8pemuLGM8Z20tkT7NkYMBuapQI_lOCVBGaTBCBaKhgJ6580_2t-Vxef0YPZBeRwy8CmxHDiNcDHNVzkuz-J8TxFH94PBFQeDpdnwxQQ1T2ck-NYBrIG-bgNyASWDR3nxwwvXKEHVhh5XLGjY2mQXi_sO8zdSX46H2U9l3AYhLouxjuw7KRhLMSV1lMflLfm23JaNODYY58bmCIPhzFxT0FYZu5FC0uKBLl1JxTENEXMCBPhXZmGwBHLXnLgaDkiaRdSyxaopLUptGiemhVsgoGuSYMidj63dCfsWYB9KBS7EJ1YQpY9ym8g07wiq_a7toWh1SDz3oG5Yiccso3GHncGMGblh8vx8nJFN-Q44hENB-BGxMouJsHKnvqau9zVRUpc6V84er3nmxxYtfjUV7xB8j09wXiy4heU5MBDtF0GjBKzrhi7Kkvt2op5cXa5xvtAO5Joq_29w5I4p60yUfXta_HimvrkdjZdXlBlLA5yNbA7AdFywdITE4Wc7XAiJ4GOC9QyK0CkAi0SU2IVEbE-JyB7G4hnMVD5fpf37y_oIzck9abnVkeALN5Gbf-sbv5GhH3Ge3nDYFULr1zq_LuqfxDv4a7Mwoc8Gl3geYVuoRKzcUgTOqyfCVztgxy0QzMiUoY58ipFgo56tbREQvEO_NhMboMVYEZNdbWH0QdK34femht2x2ICmdkSla9J4x8ezxeXg2xbA78LQ_bOGwupykHkOGfZXHvA_Zxsyruonbvc4Sn1UKShBewSXE0rP_JkrTjSd9GGixvy_UzQGozEz4bJikWbpkMVMAocOOgCDuvrPzxquTV0_zy4oT87PJqeXMaF9sR3fQQPShsgwNbZ2jFWNGg0mYIEHGasd7pTJ9rrALMIf_X7YhrFxPBZ6dMZilCRbRLlb_sk0_CVlJBdq4xQvBOMKJJjEE67dJwpgVugBMN8Zh8y8kQfeTgYv3QOXjm2QyqwdEMpFf8ZuKEAeioHkmfa00yAnyOpjviFwM-XSWnqs2G-yRRftmcf_COhcNZ-4vlbfQ19eczuzwRYzbW-rtRWRpGwurqpcs4GZ2z9tMu4jVZR_D31JVAuVXjAOEQq6WCfUD_gh98racMYrfZ6FTcc5pyLP9fOpqmkCiaVgLlZHDlugBWNFxytFutA4uBruTlfasC4Y7XVIHmfTMSCdJd42qXGHvKFibDUgIG50seZosOs5HKLQMt8IfYtc47AIRHqSHN2e_N3vk5beErp4Q-QrX_0Okcp5NeP_q9iC1-_BYK-wAGUPr-nRxd0GT5yeZYmZQnDvPe8Xz-Io1i93gPJscf36YmD5TIhZN-4AtD_nZA_X0yf3i_hJpNKPQEvezW-XstPV0OfPaknB8btDXxfkXAAjyXICK_J6VCd0REP5i-CBOjPGQo-O8uhQxp1uv9tWtrI6VNyBqgl6VAvUKmNFjHpKL9h8rZQZAz2KKQj2EYfSWg4uTjSMeOZDNOgKT7TJZ5a-68Wl9k1YwkgPkbEQyxk-fLz-l4KRPNaeNWFGfa0j0Bwsf_dU7OWCzMSI2xBo7Mqcd_sx6Ovp1HZ0u-t8hs1MtDmVaQUPsRsbz2vBxifQqNRMlx2jHh7GmX-Q37VHFTBYGzWD9nNAvAV4GbLcXxhm_iPPzweuCml4Y7ox7k5m4lzejajYbjuYSn7Zep3eLG47qZwZhL_01ASURm10Op1_Ub2N23d1vACz9ngHwsTbjxPpPDMCTVhVNRIfBIouiiVNJBudGpUb5p4hzOVw1YQoZHTQNnjs4bxFpC0cz2Yzaa1CuQGf4TD9R4N8ICcS6dR9l2LjdNFr3bDqG8fV80WrL2XZ6VKdcKUYRtKR5aRHlidzYnahEtSQztBIQN91qcfSp9z9u9p3E3zNkSYgmGnR1qs1mpOnCT-k7zagMjDHOPoQ-i_ZygzvI3BUKrL91RV2MM6lc5L4lcNbCrybDMMzqnSYRBNJ7Hwi8nm49FEHTogr33JawcUxffIyPivUydrjaJ78sxikAEWyzRoW2fURTk0jgZu2E-Cj94Bdil2EkTWqjQE_eeTMVAIpZHYpzbP3XFxccFF2Ks40Ja_nNOgldRCKv5Tn8ZZzluzUBJkBEpZ7JwNiYIiarApPJrQlsiTBN8BXbEl7GpHeyl28iEkQGeE9segp5j_pJNLNWMvCkmfhvzhQpOp3XYMdF4lNyIHsXt6V3IY9a9Cu4-ncHiUhdx6poc1VupEa8QpReo2-i62cPUi0n3hQIDfZke81FrW7Kn8DrxfxaS0n0HkEMIdzhKUM_ZlXV0sGWfOddeTRaZ8F0Koc8CgeRB3mRtFWj4Kp0eBss3P4DHsCfI45d3WBI0ucz1uWnpppykiLMnZsg6XpgTqIbNm6EI8eKHsVzhUVDaK2RGO5LEknZNlGzV785U8-mFPamY869JOF_ZTaA2oXiO6o-zs7V-I6-AM35K4BlVaoZOySboynzazo_t3-uolUwpEIdA3FetzgYTcT2YVm1Sp1YL0-Qqvp7sHIWeYiIV2OU1UkqalwS8QTOWpy0zMNkTI0TMCjJ57VmW61FvGrzk_TDrUVMNi34SPXjaX4WJa5OMF7NCXpvXa3PUkitICb5ME_MtsqxuhoHX2-PDT8mGccmEpuVaDuzJqHf_SW-1Bq2STuEH22SZn-odPnH_6s2sewL2CblzuBs_qxSPshoySG-k1S8ITEC1_PJHSO_MCsdQcM8B0y7jw0dRtqOLT58xhG9NYbGqyaR3Q3_wpvM_ml9g3fgJfjJOq4IXVp6sxtGGyB2CM9DrIaqa5g4vd3nUkC-g2LbO5PfjUq_Q21hFZum1JwJv3lpDrJsDdoIjuzoVwPNAEtRwM8EyRD3iNcHJAvlyN_neCEYBk8VifMr-Zr35ksNhchkNiMHqZrfcuy00PVDFH19Yl2lW33Iv7vtGNXZAvGcd6v48keQUMBf_N-coop4-4kVwRhAktwXvIx4dz_zeiIfnFQwYgq9HtoOmUXPdLkP1366y5RrFAtA39fazs1gDhSlXYIC46L9AkOlOA3RvN085IRAsFldpEa-7f1W0cfh4QdECJCR8BkxhLbf1kC-TDfrRkaUm \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..109c8b3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: MEP CI/CD + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + backend-test: + name: Backend Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install dependencies + run: pip install -r backend/requirements.txt + + - name: Run pytest + working-directory: backend + run: pytest tests/ -v + + frontend-build: + name: Frontend Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Build + working-directory: frontend + run: npm run build + + docker-build: + name: Docker Build + runs-on: ubuntu-latest + needs: [backend-test, frontend-build] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build images + run: docker compose build diff --git a/.gitignore b/.gitignore index fd8c885..6c865d2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,11 @@ pg_dump_* test_output/ *.out *.log +# Migration engine runtime artifacts (relocated under backend/migration/) csv/logs/ evals/reports/ +backend/migration/build/csv/logs/ +backend/migration/evals/reports/ *_report_*.txt *_skipped_*.csv *_valid_*.csv @@ -48,6 +51,24 @@ terraform-github-repos/*.tfstate.* terraform-github-repos/crash.log .abacusai/ +# --- MEP v2 (Migration Evaluation Platform) --- +# Node / React frontend +node_modules/ +frontend/node_modules/ +frontend/build/ +frontend/dist/ +frontend/.next/ +npm-debug.log* +yarn-error.log* +# Python bytecode / virtualenvs +__pycache__/ +*.py[cod] +.env +.env.* +# CSV upload staging (keep the directory, ignore its contents) +uploads/* +!uploads/.gitkeep + # Terraform plan artifacts (binary, environment-specific, never commit) *.tfplan tfplan diff --git a/evals/datasets/tier_p/02_empty_file/input.csv b/.logs/20260714-120638-5cc98304bd6b46cd8995f7366abcb348.err similarity index 100% rename from evals/datasets/tier_p/02_empty_file/input.csv rename to .logs/20260714-120638-5cc98304bd6b46cd8995f7366abcb348.err diff --git a/.logs/20260714-120638-5cc98304bd6b46cd8995f7366abcb348.retcode b/.logs/20260714-120638-5cc98304bd6b46cd8995f7366abcb348.retcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.logs/20260714-120638-5cc98304bd6b46cd8995f7366abcb348.retcode @@ -0,0 +1 @@ +0 diff --git a/.logs/20260714-120638-76081c43c42740afab863c4d1aa7e876.err b/.logs/20260714-120638-76081c43c42740afab863c4d1aa7e876.err new file mode 100644 index 0000000..e69de29 diff --git a/.logs/20260714-120638-76081c43c42740afab863c4d1aa7e876.retcode b/.logs/20260714-120638-76081c43c42740afab863c4d1aa7e876.retcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.logs/20260714-120638-76081c43c42740afab863c4d1aa7e876.retcode @@ -0,0 +1 @@ +0 diff --git a/.logs/20260714-120638-dd029799a43140f59bf3dc3a9bbc06d1.err b/.logs/20260714-120638-dd029799a43140f59bf3dc3a9bbc06d1.err new file mode 100644 index 0000000..e69de29 diff --git a/.logs/20260714-120638-dd029799a43140f59bf3dc3a9bbc06d1.retcode b/.logs/20260714-120638-dd029799a43140f59bf3dc3a9bbc06d1.retcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.logs/20260714-120638-dd029799a43140f59bf3dc3a9bbc06d1.retcode @@ -0,0 +1 @@ +0 diff --git a/.logs/20260714-120926-043b35346de54103ae934160cb40769a.err b/.logs/20260714-120926-043b35346de54103ae934160cb40769a.err new file mode 100644 index 0000000..e69de29 diff --git a/.logs/20260714-120926-043b35346de54103ae934160cb40769a.retcode b/.logs/20260714-120926-043b35346de54103ae934160cb40769a.retcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.logs/20260714-120926-043b35346de54103ae934160cb40769a.retcode @@ -0,0 +1 @@ +0 diff --git a/.logs/20260714-120926-29affadedd99466d8c20bed323741c6d.err b/.logs/20260714-120926-29affadedd99466d8c20bed323741c6d.err new file mode 100644 index 0000000..e69de29 diff --git a/.logs/20260714-120926-29affadedd99466d8c20bed323741c6d.retcode b/.logs/20260714-120926-29affadedd99466d8c20bed323741c6d.retcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.logs/20260714-120926-29affadedd99466d8c20bed323741c6d.retcode @@ -0,0 +1 @@ +0 diff --git a/.logs/20260714-120926-b371c17d1ec94ea99f98fc35e4e2a8b6.err b/.logs/20260714-120926-b371c17d1ec94ea99f98fc35e4e2a8b6.err new file mode 100644 index 0000000..e69de29 diff --git a/.logs/20260714-120926-b371c17d1ec94ea99f98fc35e4e2a8b6.retcode b/.logs/20260714-120926-b371c17d1ec94ea99f98fc35e4e2a8b6.retcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.logs/20260714-120926-b371c17d1ec94ea99f98fc35e4e2a8b6.retcode @@ -0,0 +1 @@ +0 diff --git a/.logs/20260714-121622-3109e609c96e4d10a4efba1bc6cd4c6c.err b/.logs/20260714-121622-3109e609c96e4d10a4efba1bc6cd4c6c.err new file mode 100644 index 0000000..e69de29 diff --git a/.logs/20260714-121622-3109e609c96e4d10a4efba1bc6cd4c6c.retcode b/.logs/20260714-121622-3109e609c96e4d10a4efba1bc6cd4c6c.retcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.logs/20260714-121622-3109e609c96e4d10a4efba1bc6cd4c6c.retcode @@ -0,0 +1 @@ +0 diff --git a/.logs/20260714-121622-98fea740f407491aa80f035877571b98.err b/.logs/20260714-121622-98fea740f407491aa80f035877571b98.err new file mode 100644 index 0000000..e69de29 diff --git a/.logs/20260714-121622-98fea740f407491aa80f035877571b98.retcode b/.logs/20260714-121622-98fea740f407491aa80f035877571b98.retcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.logs/20260714-121622-98fea740f407491aa80f035877571b98.retcode @@ -0,0 +1 @@ +0 diff --git a/.logs/20260714-121623-8634b54612934d2a920e6e9ebdaa642c.err b/.logs/20260714-121623-8634b54612934d2a920e6e9ebdaa642c.err new file mode 100644 index 0000000..e69de29 diff --git a/.logs/20260714-121623-8634b54612934d2a920e6e9ebdaa642c.retcode b/.logs/20260714-121623-8634b54612934d2a920e6e9ebdaa642c.retcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.logs/20260714-121623-8634b54612934d2a920e6e9ebdaa642c.retcode @@ -0,0 +1 @@ +0 diff --git a/.logs/20260714-121623-b8972cb1fc964026bc7faf57c4a5f651.err b/.logs/20260714-121623-b8972cb1fc964026bc7faf57c4a5f651.err new file mode 100644 index 0000000..e69de29 diff --git a/.logs/20260714-121623-b8972cb1fc964026bc7faf57c4a5f651.retcode b/.logs/20260714-121623-b8972cb1fc964026bc7faf57c4a5f651.retcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.logs/20260714-121623-b8972cb1fc964026bc7faf57c4a5f651.retcode @@ -0,0 +1 @@ +0 diff --git a/README.md b/README.md index 9ae0a0a..86f96e2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,50 @@ -# T&E Database Framework +# Migration Evaluation Platform (MEP) v2 + +> **MEP** is a web application that wraps the existing CSV → PostgreSQL migration engine +> with a **React frontend** and a **FastAPI backend**, turning a powerful CLI/script-based +> tool into an interactive platform for **uploading CSV files, running migrations, and +> evaluating migration quality** (completeness, integrity, business-rule conformance, and +> reconciliation reporting). + +[![Status](https://img.shields.io/badge/MEP-v2%20(in%20development)-blueviolet)](#) +[![Backend](https://img.shields.io/badge/Backend-FastAPI-009688?logo=fastapi&logoColor=white)](#) +[![Frontend](https://img.shields.io/badge/Frontend-React-61DAFB?logo=react&logoColor=white)](#) +[![Engine](https://img.shields.io/badge/Engine-CSV%20%E2%86%92%20PostgreSQL-336791?logo=postgresql&logoColor=white)](backend/migration/) + +## What MEP Is + +The **Migration Evaluation Platform** does not replace the proven migration engine — it +**wraps** it. The original, battle-tested SQL-first framework and CSV → PostgreSQL +loader/validator pipeline now lives, unchanged, at **[`backend/migration/`](backend/migration/)**. +The FastAPI backend shells out to / orchestrates that engine rather than reimplementing it, +and a React frontend provides a UI for the full migrate-and-evaluate workflow. + +### Repository layout (MEP v2) + +``` +PostgreDataMigrationApp/ +├── backend/ +│ ├── api/ FastAPI routes (HTTP layer) +│ ├── services/ Business logic +│ ├── migration/ ← ORIGINAL migration engine (build/ evals/ infra/ tests/), preserved +│ ├── evaluation/ Migration-quality evaluation logic +│ ├── reports/ Reconciliation / evaluation report generation +│ └── database/ MEP metadata models (MigrationRun, UploadedFile, ...) +├── frontend/ React app +├── docker/ Dockerfiles / compose +├── docs/ Documentation +├── uploads/ CSV upload staging +└── .github/ CI workflows +``` + +> **Original engine:** everything that shipped before MEP — the multi-engine database +> framework, the CSV pipeline, the 23 eval scenarios, the 85 SQL assertions, and the Azure +> IaC — is preserved verbatim under **[`backend/migration/`](backend/migration/README.md)**. +> The documentation below describes that original engine. + +--- + +# T&E Database Framework (original engine — now under `backend/migration/`) > A fully parameterised, idempotent **PostgreSQL database framework** for **Test & Evaluation (T&E)** programme management — covering TEMP documents, VCRM traceability, test execution, defect reporting, and multi-environment deployment, with a built-in SQL test suite. diff --git a/backend/.abacus.donotdelete b/backend/.abacus.donotdelete new file mode 100644 index 0000000..8250b60 --- /dev/null +++ b/backend/.abacus.donotdelete @@ -0,0 +1 @@ +gAAAAABqVibVniO0KajGcO16Z2DqpjOsoz_Wwk-hlK9h55ElasbUvl4iTms8CgWxLyIWlmajRqbRNtlFNceS2bR4o1kKdK2nib9It0X3o1c34pJjYBC9oChcduhdGK_hA4mWGMRPGXtCgyc4lLTVE5RM21CmiEsjT-DWcI48t3iJFE92mzBEYfyIGG5oVaIF3pKHeBTenmEhmmj7Dp5tqNxCRk1sS9zcZdZU2Klx-vF_0VzjCLuR2zRgj8nZQyeaUpGRlg0fhKMPwvrOCEsi9CtYxB_-IVQUAPvXbOYNAs-MsVfexG2hhNiF43wNDXjm3YSLmlFno0kYPdoDOWBVPwzfRv_VfJZ2D97wAV7Br6zXzSdLw5sZ7NrAosR6Y0nFfz6qXMbS1shhPAI3tZVBTHMZenJomS8pliMo0MiaciortzmqUyr1_dw2yFXkg8XN-MGKgwvHS9EkrRQEZEQxvXzDImotMdRU576oVI7qlrIizTnndguWzjlttgVdX74XiGnHJP8ViddRZ8D9qp1_CbY5oNXUXRWrZaoNvN1HHOnSw7eg8JWZFIXmxuNsvWEFnjA4NRqYWwK3vHfmtFvSHi070JFAXCquFlaTnz6Wk4YwlfAvnsjiM5-I4lCWe30ze01Q3oS2xjPWVQExROIuPQhmHBDgSl-HFj5w_9KXRtf_AU_cxzYRYIYOsyr1t5Z74FBaSoDBjtRFX1FLu53xifRSLAz0b8tW-ZQwOK9T03pYQGd0-R73-P8-GUVrz3wJBPf2sfxEXbYrpOEDLOPBqIeA0iTaSLB3jm0ptMYgLOs06kdbFok8CZ4sinqkpk8vAqYO0BAAZ8CPGnt-30PLnf-6iRHmwMHoKie1QZKp2xssV_mRyZbUvJOd9_UJP1hmp-D9b2le72tiYJXIBdNy7cMfmt1FLodxK3KX3Hk3wkGUwPJjkyJJKxIYaH7GnJwQ5mA-IhufjfCpzGRIUpX0-UBQtJVfJrr5xWaJNO0DTdMXDmBipmA7DthHMyXIk4IxhOZi4M7huhnlS68J-5D8h1QL6DGSrhqOAmyMxd623qC224cpalNI7Y-fDm9eO2_IN3QwPL3M7ba087T2k29VY05B9RxHHaJKV5sflZ9nJzIV8XE8LyCtn2p-_o_I6YOUlSoZeFZmSB9g3ulCV4s0hGrL6xqftdL0Dq03cHkrS_xGWQqiMHXDtmrhigq3OelNv-QTLe46nMhqviE-1oUUk9uetVnF2bzJA-DwT-mQMjxr0MyuNT69NSja-xt61KuIEdW0aA4pBr5McCmyQ1Nr-3FH2XAgW82CSFydVlnB2pXnbYM1kdAtLzHVMpTyoXV2GT9j2zHbsuyIXiULpsekGnDgePTxBiqTmlD9pwUFbzZ4zemRz6SAmZRVIXI_T4UDXUeaGa9bK2-Fmcwl4ZtJJRLmbINpNBVF4FYX9GYE3vGC34XIzB67u2O2_tIaii_CzG_oG6tjIqepoD79ArwaILOy-6EgRXcWhmbaZnn7vbogz-ayHhE9QB_anAfDZ0cXbdyyd8-T36SUkpM4GNwkejtt4VfkI6kmivN3PDfZN5N59n-S6eqOI8p7aiwo6DOkDZ4h4OBM02wVF1MeIQ63wnV3kwDI-UdJ90WKoJEFQPdFOIegGHjUITS8Zt3sWEMp59GmEO_ekIbtHtvhSKGg5PzuyJF0_CXKhfW1H92p5kvpeH8eSbqBvrGGR89F-uOhwbq17rSA_m5tNcY3L53xGXREnyeUo9iBP19hvNZPYdqYhDRzwJvjACqNhMEzSjIklgW15QFyAs-Xh9phCGdmFvLQaapmTSFmA6--cD0w6BrmoEVyZ91lYhthVf_-3QVbPEdGNyEmWkw4Oi1r6fkwdIFh7DHZQwua8WeHFMwqklxBuWxV-VGs-FdAPbO850MeSBW3pyJXSOYcDkEk2VHXbMnP5E8Z25_QCKk-jv_liGG13vEMgMZ0U7lqbJCTFKnTDooA2MM8NE_Nxfy7phHk17RbeAksst0eMHvTaU33399S8FRC8U3wMGG_rGl8TA2QAaRk4NOCuthcrO7yMXyoxQbRph8C24tDviTT8c21dnbdvZAJOAXXDjEWvk1SzlfVHe5S7rc0jo9i1b0R5Ijia4e3M7ZMdrCrDpEMA0oUj8Q6pDEaEnCe-AjrQuUGB1vpxxZRSWzJyMwHZ0WKq5xzm6RQZMeNI7_nENDWpbTAAOZIY4gDRlRGBbI5VPYSEfxQSPG8egmwwB2odnTUJUi28y_72al1bdiNOMCcTuqp8LBwZwGkRJYeHjZG3sShIDA_yeeROgQShg35sjbrOGc5pdZ3USZ4xJY66HKSMTOw2cdZDilUJpBscsat4SCajjUekZBybZZLHGHnSW4pJInhr2Vj6sySH1GtnAQHFwmmsB0tqmD37AldEAGRwiQOrKHyo7ueARipVYz_6PkEMjp1VkVGCPehakW3atQ_yApqPzvEm_OlxWdBTiB1sVc_paEteobEuMQrSvImpJBMjRlxJXkuMDl6axw9Sjy0viXrYBi70PdEayQm0SIDQSOvyMq78gMEHDVPF7XIkZSPMgfBPFnBxpFBjphkxcROqAE97pCjqnwvtjYzEz4pWauzy1A4URXnsdRN3zVsBXflqm0vgYUMxX7OsMg8-Eh9fPQ4tVPupO-l3ERWV-NoEgCmslQQ2g78g1n6cECq4vpdnFebo1oZFAv71OH59W2u6xcUi_a5WZkmD9Fumgg4fOlXvc7hzUBdxfbxsOEW3feg2N2Mv1a3DqNyhtq_B_pitJ4zWFYH7VWyVAYHyeazmbnSEbHhg0hG2uHOWAiBKAET5jcESjq9ONK41Dm4PuTx-JICq9WHP7Iumyx3qR8YMePDsxFmCPPlWq7KOfPawx7HALfg9cj3woERz6CgDAzqH01Zmbsn6tATMxiQFEcImkm1ymCKMvULvs8OylQAK6ypxBsSkSYViMvIqmwFdIUMMKv_-9DXdxts55tbrUb--owC5td0ynfeIscUFqTmT2Mcwo8xvO1kme8MrQevKoXJy1Ohx6vrAGIl6QqU4Xm-UAF7L2eJh6iYQCtAMOJn6caAr5bCxOdmSfzcFjLMW6udjdh7O53FO7f2V6gHhjMi-mfGS5j5mksvx_luKlxpiUhm_lsiHuSE5ZlKqjIpffWT3l4LeTlp8-Xw2OIJQzM6hYMdjiEx7TGja9kpLsefHp30UH6Eg4aO0AukIynTHOGP5HVW8SU7JIRZWuejkIfdcFSoDjCj7SsTJzk67PcwzPZkhi4kjLAwnDv_w-6LfMlzwcyYn7O8pbIAL63kdfJomI-jScN1ySRE9a1J0pBt6BUMvFLLwhkNxGTzamM-BwUOTQ2KXIjrk08lw-ct4pdy9amtFC_Olsj7FmCS5zuGzQXvMniH1is5co7LwnmEPlw3ecy6ASj0-QZW_83JR7Z8_K6RV4KykGt71BavEfIbY5KJJgiia9bwhAkcPR3ZtRKEHg6uLrBXPG60i3_yziKN0miyLq9sMkM94si8PmYuWNEF_496ajwZKazQ6oT13TWayj4O85JnJDOBV0PQXXNiU0flovBWY5w0SKPWbR3f3r1fdsepqAflakjRP9224dMK8CGVaE2bHWnTuPheIk8Fd-go2wbBCHYDKo7G_Hpyib3p2CxCjhKHzbEB0IuuSU4U4OtVauGoOL_0mKP-Pn8DyIRBNoFaOuL8wWbKaTsPRz-S8vkreeJx512aXXE6UNhIHgQ4GkekG1vy0u-0pioR2BtkXz1vIkEOxEmbchKoxdtoeahNshGFMQW6qOryJbgVg5VTsKAIUe_Fa0LGzEmut1_R9_932JKGGho3tajE8pgBR78_gPF7ntMT9tbClgECxz5KzJ_GCu_RLJb6xzVrLFMVTubmESNBWtz2uU6Zd3-t_jPsdqv_ehM2uBgnYdZc7RykXnNDkBCT38NfEzUi-2Rso6OO2wonUBf-bZ_Vx9N6fxnrohCNAqFenhT76KHOTYHQJyLTFUlGaxdFlobhq4upkN-G6TC2oaSc2rq5NmtDdy0CKIoz6Uc4dESJestLgVpNEI-TN0JUEBJlheBeCZBFFOnah4j_Bt6vfmBbzZLiX9U7LUjS_ERBGCqDRds8rNW5u7y31TmVyskP_rAbIrTQp8oRkfm1pj9rjJGzZqr8xGsmZoqlXMmNzTRrj2nmPevBh2Hg6S69HX0nDIxaHzZzfmh7t-_OuGEqD-VMg3fG9BLIVdmYDZQgsfYopolcDQPNUvDoffkIzSZoncmyOnqq1x9iepJKRMXp-ctv7kl1ADxIzh953IGUbv5SrN3W8Pr5U5thnmq3atRUKgByGiqCEVprwLy_Yr_cVnDrgKpc81f1drCKFSHNcy3dOsIQF57HaG4NV7F7kO3_N_z5xG90CvLRgSshKzGFgHi0nHSOm9zzjrbamw5njXWeBit7FgJnpyWyaRy4t7PqIJdIKBNUO7Lw03Wtu8mpATtrxWoaOVmC8J53v5imO8quHd1RlK8CRbkHPWcMbFA71mLY_oIFb-_AQK7lmM2L1b5FUa8fDTki5htzGFdhofnRg-AHHiIR0tKPJ5W5oMLHUfON52mokUhMXZ8sN7QSeOwkfrNeVF15_CX8cVJ7qqC1_jxWG0EceYTukaKx8WfzfAP6iEKklGSxasVo2WYHtkSzVgzBlZo7fpCWUkG9rqDmKgFzE7wwz8S1B1iRfj67Ja3e14Wc3alcmiyT3odw3f2L5tfojonLuLoQvRcCoKuf3qDDgk-tw1KVpJS8VyDlrOHKHfAya__4mWqXNIOtjoVGCzoI1WyUgDu7Cxn21uR4bBH5JPo3fBZ1NcfEbHfEJhxfqYo2sdpw1-_VFZPfBTjczZpmO62JxS6maJaUekSGLA0wqtFuE0vRocV0g9JEPYd8OSN27c94gBfgKxgDPrF5yRWVSmVZJyn5TLZQgIUNtqwqmI1Lo5U7-iwEMwhvjYCr61zmCkS2HKnB8AFHh6kNEUUftqM9LbJj2BmEcy9xmPyETN7JNr77fFVDlQmgFFZaIlKa3Y09kHegx08HgKufhCx1fENa-U6bKonAW_-O2tGiUsGuTQmYzLGxSYyGLXoPT6mC1TMAoq5POJxE_u5K8tEd1KiVKJ6c9-iISTCTxNRqgLeLk6OBQo3v_x_xNnjgiCB4Gm5OwfOGd9sljfIpnMq0DbRJremGsF2MW8igbYoHO_NryX6irr13R8kxQL1PF0LQc__A0fYLom32FKnK6y98WRX6A23dkHMMiDtpngNOodGwvORQtp9YNLp6oRZ_3WpdY9v6tmuPqEDPWB7SsZ_RskuzLPK2MMkDb8BhTsVYS2LbjeWHpaVAWD-rAo3ZUQdkWaHQ1q3LECcIEBLPn2dx0cKOnDrj2Fee-kmhCKxKWFkrWFDLsiwZWwsZ5c5lxufsJmOzFihGcPkvfKQ6Yxlu2K_RF-Ya3jSJ14pKnzn2OM5GiWMxjTuE541Xd0opX9KAbMZq3rgaCVHNM7tmCOJmkKXtkwSBTYL9znHhCCDcQ1mXQ5FUGaWR_hR7KWr5P97V5tOAFv01yt8InRLt2h2jY_oX3rcUpVp4MLP-UXsWAHmUJ2uX63IXFBXu58hq5Li7puqFpLyLUh-2NpVGaYGTGzSjw6FVMxqEhk3l2lYVLc08SBks4hDxV0x8U9r0l7LsxETp0Yn43sL-ijP0rjGqasLF3v3U8Xv8rYwbh9lEnrfl31e0z-gaiTOUAW138x15zG0YBO8ZjUcQR5jSY4uaeV3kv02KqD4GhE1aI4vTsKjduWaGZWo75sa2CA0zNDKpMyBah07I0bszrsqr3qj7g09POzGXfZjeOHaCbdG80GwF5INfCZsDrX3dpES8CkYNXKfDvTJNoFvmTEPXdw8Qk-QEpFeQ-gmmrpAExO5mpTxFHtdnGMnKLaVBm3IHitWxYwuPjIIMrNgq3MyG4a7f_rdH7b90qwzInD69w3VFwY8Fh6kPKsY_goFJL5_0iL_dXta7x94Gw5W_seMxCEKHt32of2IbtgpfUtRQfw1jCyg_Ep_z-7ECT7mmiGfvRo1XBORFsQo-wh8K0M8ZfPFeFbsXNSrt3LhnKFJVHc07fuarzF79gOC3bo8dd8KGenIKUf0JYW9gcdB7P8MJy6bsiFMYLbJ1mwGYeWyWTolJJwsTvmGVaTGPWqEFStk8BLmdzLI4Js1bCMBzRjAUtJX3t9cw0HkDvDJfjxUZLhghzK1LdKeYnw0BfDmN-ICiMnQVDF8r3eXCcZhd6uo5JR4c4gY0ppgHvQvxm6sB5so4YwnAlRMn0sTG8FDl6Bc7ZBO5ghMFo2Byr_UjkU_W86_JWZopRu3YTcBSMRIdYXS5cgHHSyLJYlycr29QSsslbCSYtCHRAT6AVaetMFxke11aeC80MrCFj70BW8ItRKcA5HxCEEGpNjRJPGuyI09gXepjAZElZTEsm5GgDNdsVEZaipbnGFgJyRBWjVlOGsRaiQcRr8KkDGZp0Y7S8_rD1QBbhyaBWv4tTxb3F1OkqGj4LBXgq-Tdz6HdeSU0Z3wAEErWG4R9v-FAPjh69BqwGTo_mXlM2_qsUlyhGo6Teob8NN3a3cRD4YTz1xZN5mMmTDc4z9DlzqeFOnlG-WLeGt9I1iE8v59BXH8Rj9GjgBEcj8xAuiG00_CrN0c7asnLccftt5bUfAF8hKC_IgE4Vw-8q3dhAXtJbWQYE_jW2DQ6KhxwNLfxDbODFG2ID0cdmPqZkk_Wc3131bByE0m0XghRCwLwpxHFfgRM-iSp9MxTVrhIyeUmivnKU-DnT2Co7E0q76GlZOCYahy9ASjIxF8t8kl0Noc8OVBK-brwQubTAmQHjlVh25eYTgWW9H8X9s22owASK_1JEt9TEFs-Fd7xD-GYt20POsI-Y2AZhl1f7ZqG0kFSuiAA_NutGR0ogTKN0JjSHj17o1vUkYv3XS6FqXX_xYzF4yuq0l5krhvhfl1mE2XlZBocoviW9s2ROyBva_8ht6PP-AAsqVYO3t84EPtKXmTLwmxSP2t_-C6ozo678Nvc1MuyQ5aow0Mso1MUF5_ucMid4pf7QE3QE_v5NuEgB7tarsp9_4IEtSZhRCUS4euqXs93EK4BW10NE4nMpuNT0fd-bOpK77nLs5QaUB2Z-EsiivTjtLwDPftvI4yCPVXkBupP-yNk0F52gWWRHqAILkkbHa4iszkTNDCjZRgmS23SfeGz4ZO0uKaJJmYmW8BxUBX0FJ4ZjN8nt2DUOAWoA5iHh_sO1SD_EBNS1grT0w147I1y1iyZ2pqtCSEdawSbypgkVItvR1k3UoTXzHJ3Fp5eX_K26EuC0PXn-czsa-XiSVdLAncz3LiORq15oSERRU2aTpP0XV-pzyi4cLpHyorMm--dRhdq2F27_sGJ7aEVQU-6CMmI-iAfNK32dNVYDdUFORLD1UNJe_vWbDHvwQF4re0qhcs0Nqh-oxBx_PsBaAeI5ehnurnTT8fGwoVDeXB5gWwwavQW_E3F5COiJnWiobIw46KsiH_g-L2xJp3XTt61-llbULUr5BwzJ7SvFPsM3-KQlRneBOaJVbN69_NFb_ebmJchg8akyLIWbf5KjxcEIKeEl_EKYs3hgJh_PGJRRuZRtJG7zrSX90I7gyxse32vVq2SNH1d7eze4yAzSs4EuelN4NPAuVJyx7McsvIXmd4xiLByZBO8MP8JUyQ-Mf3hEPDHcZpcjHZeSjBSXYak-laFPEt3797cAWI7fThvM80FLA96GSPbAuhJbDhZbofUqXbKbCJFZcdgkLqmCziWGGdJD1RqymLLQ1fOzTFC-DFKsqp7Vl_7nw64a2npnRuS2nauW-zy33CahKiOfM68eMwyJ4-nddPXKycJn_BJJS-cVHIKVGJpbnzz2H8osiPp7vNX-3iskwefo8EltzpmMTMMnCE9YPIbr1ISsrG_Y8cAXxo2lxgckZsNrI5tIBt5XuqilyOnndqFmfILXNJQSVs_K2OffqygOfiCFR_NWcNg7BF2NNxPZjvof_yWwZlVgfw2jI1ikLLfTOGLaIyYTTwX7HtqlpD2205I-nBWVH9ciRC3MijHmn4TqyAnwhQOfjKDXMEGnO8q-2JJ3gHaBkmdL558WIC5q3kPvzg7V6VBJ39s68fQv-l6sHf0B5st4WgFC1p9-iyrLN5GEc3FfKg7O5cZBmPLAsVuBLQQsjjqR43Fl2VGYllamhJ0mdR_sS1CPEeiNJdYfZ2ZELWg5PDAuWGyLxjzm6ZwTf9pbmhruOKi_reHtwPNLp22BVmTEu5ytIqIfJN9m9hNmMOdpLSlK69kfejK9NAomWR4UXrZXqDkdzuDWtlWuSY73xdOvIqK_GQHFMhJeVjfrDT-FeAYUTKfA_nLpvyZ0XSaPR3RURM6pJneUahxCx_gcUyqghPoedJyBEYEAaeK-bkeBlc5Zb1XNVfcTRn4Jo5rs_OfH-ep2U8FiboeIz98EpQEFrN6u5NO6vZqQVmQsmOdyilJuJkSYhaIMZuHvQgYp04dvBPbrmUt5RsjU3YOUkP-slE9Awz4942UEoxG2Ds5ux1QFt6QrgjJv7JUjP4e028DwaZvLGWAZJGPibAvCsQYXdvdblq64ZrQNQlrPaATuVXhreDLPMANzCzuJSBeb0rIzNdOzgjsmPriAp2v9oSoafCZL-aaDpVCaHCFFgIGivcCUX6mUnJbLKna7GpJSAME9YmoOcs1OZeG42i_Ueih00r04VSJCsJ54fG2O5dptn7u6Vd90-QSAdBk-865UNSwDen_XNu_i1Rf77qAr7Wvx9bCCNsr2Sh_mbJR2csP_Y-Kar9ZzBRCELqbRKHx2x8VjTHmcA0fGv7jlNxm1TZ1URftgYUiz-10VejboDCL2q1D6GnOnmN41t2CAvAUoPSL1_mu9-YfwGlvHJxBw6TmSXXY-dbKXTq19t-uVtVTJXfRQDQrJKQYMjZ8M8ziNtluH9o7zi0KvjZjQUHkD-xn2o4833xp8lLiaECGKaoh6W1dkU65jIqXsq-lczwTitKEl02nMdoB6qPdDVCAovzVFgXZmehfiX3UR4dXGvXutXG2HQPSbpB-yHMZg41ty-Qh_pA9irNGv0K5L-2b-nklcLjbNjcYCtTJAIMcmsDRBGqeIiwGfLu0NQLqbH6SHSmi2cqDcVsIzAI1ipAbG6sKBdqBqMxB4kC7acFCTEB9fPMqOZWBu_auc11TV_sLSXvwhUpdv4OjfWhZ3O21qmNIuyqR7VjNT_KoJq3I4LkuLDFeXUfquWJ8mLYLahcxh4WiihYl-vskVA44HoIHrxoK4NgMUBENJ7w0AW9eKcPSmznyjlJ0mRiQ5_eXHRC9LU2oA_hgYX0RClTEL8PhgRoL0jUsv0P5IUkbIl3DS_GF7qj4YmZZ4zKfYdUHj7cLxaMMi7zoyxe80M63uwy0eC-2QgrOiV5YER1SM-Es0X-J43bdFPkZPRFkB5eGe4l1SRIrLK3wlAiMW-xEg2CbLaQsMYNSEeBQwltbFFkGhww93efusX0VTMcRNA0EBYcbdnthoMD2XsVR0dKFdxcjk9Lqremoy5Di0isn9Gitlq629DnmAyh7UJmUiHjQQxaHPHYTz3zd32KNT3e3KhzPcVK6cjnaeYwLZSy7SwfxpJS6DdOVCT2F1XzX-pO0FjCgTuwpH_bAvAyup80V2p0DCZKQ_b04jY31pEvm7zsnIibKYOvs4aH6x1D9Z69dQc5uLxw20N8dOxtJI89vMoV3__ClyvJ0u1gZfdCJ2vIZwDnwgEixFgByAxFIe_Ph_L0K7BtGvcgjnoxZYHUHNix-Zf0mbfI_WdmV_CkIL-U2DU-De4hfOGvWNfRAFblwQfd2PFnh5nQrpAZ6F3A78m7RJdFjXs6ZpeZdWX_sA2eobDXrZHQtMDW7HZHe0vkO-3LHGC6CM4NcsKOIwXsdXKCDnBOj7UJfp24Un6N_ITlV0Yuxm67-xDcynvcqrYkqoodlLAYJd0hbJro29dVjpZLpn10M7TTErZz0tCUm8b_l99sApe2Lst-a-OARY9-qfydqv7Inpr_0oN4wDC__taENGRWi3BbmnSyXyuY059MuVno_dLNC9tK3Ilz4rChJTfxFcaPswN4wxcFaj-dShOFDLcu_rrNDOL2MlPMWBdox0o4fJCusMQxR1Up5LcCOcYbym7k-G10YxGalIr_az91P_ULuvuQN2RF1Sjr5fNkORBiF0sJ8pvD5E-nTiRs4uAo3Uf0WkOm8zQ-cso-nELx2dB7aufYolPFrgog93IW64KuOEu4V4pqU8QcetmKZqchLyr35PgR6u0lWruaejqloSaXGqAxR7BDvk2pvRN-WlGcx1j3Ko6c6cXiPPIpPJVInay35xUpdnkpiJrT44ukyIEWN_LaI3uZ63HtJLZF_BTaWD4hmGsn9iO44wBWuykH2nd8a6O0dc4RaE0vYitF7f6ai_eFkVMg8waEwcEvdc-Ema5Q2FeapGUmMTin6l_qHVOxPx8GSzNonn5gqxfIUzV6XlR5FRnbEjSG1l6r-1VvI6ox8Qg9dQDegZLSWPcnJzApt2AodtJXYAllbZ5n-EPJPb-auup9LVzPMwj23lTzufQTHhAJoExdVApdi8V5KcNkusug37WVQlucdIHBLQoKZF3tZhcB9k-H3zuEN8nP6qZV0M7La0JD2owNh5pVYRplXIDbFrApzwpoqUp8QIMbEy9WbiEXyetdpW0zoPnxvhE0xLzdOw5HN2FRB7-G-eFxpr3X4K5NwrO9PbkhafqWISG9K4s9m93Y9yjnhmLg6fvNZ7JZJ90gIJdKZkxFIY25_X9BXJUr_ReaBl602WoRvrD8y2vlDXNv97qvZnH7njP7u6MDsSODunUzTN9ZPSMTrENMzjPBH7oYmdiDkd-wajA2xl7738DRC59c-eY_ZJk9fow6Z3N5h19jpPBMF4yEyhICSyiPoEf9cN4NOQju9AyONkqeo0SVDNN8TML5Frmq3ilhl6ESuOxGMx0kPXXe5kHi_RlLEn7K8rqpEK7DMmT6--oDJr2Eur5iZwWkbnQgclje52P8OOZuoXoyYjAak4qwV9fAkpddUNDnKqPKBqggLQ7BXEyPrnUGBj2a92SaVZF9ZYlBLF1qmrzm8gAq6UGXXdI3Gf1w8G7Eo_OnoU07RwMDGmsPdQv8V1tAVvNyLa8RX_RWzFmXzHbJo6k1b7JSzfrUf48iP0yZLoNVcfxdF2p2C2x5fAaZcJfFJiOqIQW2kFGpoA1A43UlUym8lxuAq6zFu4qPq0HpSfogJFftLmdvwprbfB84X3Xj0Ve6ASUoBydVCidmGrTW4zZmluzgynL01wb9ZEPMpvPxqg0hzW-Y4Aqzf6vB_2xi0C0pWP98kU-0Va79pwluvzmUwDTEEmOJr6zFz6tjXKMQCRV1oXBx2xdqH9HNJhU2cpHz5rTxrNxuZLxdVHny0zIk8XPQ7fhrcyMRGc6QqEwmq94yfqT1KXfhWb5UIH-F_JpIE71OnihMjguMXq6ml1on6A9R7rqh4nFb9hH9tBLJ67ijAFSaSLHtCkYXl1wv1KaKT9LilEsvIJp87kNl7MWW4IF2gSiuhKXGqR24P-hDaBMnPu_5PUmrTKjEcilJpGs8ovtf_wdOxEQsaTxgWMzm7Yuqo4VS5L1daJCSAaiMFRumvtWDu23Hr5jS090PGLyLTkK9IuqsMuxlEwxppDEzbdczc_OQU7cHmNqXKgyQaUk_qvqEPF0av0ixoL8m-CKSEs65jpc-a_L9xhVbMXcyh2wJY4G_awOsJcNrQwwlFYHwW_89yoZMx-4BhLfTfj-qMVgNXoU6wteP_ywRv4Peg9S-GcdYtfQoRPeiDm1u32Zd7ROgZn2fkihnC2MnZdBEy31ST3LBMhVq8da_w38UfIZT4lZS2yZy8J0f5SqHYkCMg90_5AKoNSrwoDbKzEdwAzfsXw9IrBbYqWbHRq6gmkb_n-P7ag0zqE1CCRIzunCl7YDx9mxRUX43TAAAeabQtXgnHl5HS1h74_4fHvvwHsZYwUxwqPpy6GK8HLZbW117rrxTN2BMagmL4WJJH0UFcGVk5PVC34Rcc8rzLUkVVeRx6quiT_iyxkUt6aAUg3egHJS7x73sB6kYhHHcdh5cXTe05QaJT7mkxB2gXwPV3u8DRqHzhcKNMpVQ8uRExfCqduds4QpCW9n4naBrGDBQ1io4loBPUqrif-8M4BYfsfyxtXPO1Fl2a-7gUUsT8pK6-pRPRwBChweKjd8cqfw2e08ewnpPG93OLorFkR6LqRO4gp-fKT5_s9XOClTBiqXKvnVMtDi3NikVaJvjt5Bl7JA28bzwtjcM1vwXfSiiq7smkdVmXgBdDiRDVideLLmDFL2Y3l_bU37rUfas3G0Q_SnbFijXVAdUByZLiGPO9cxbfWN0uXVaRy4MmB3XY5MiqC12jHNDlkVDfgPkSa0ONj1YUcUSu8yvi5eil5pjFrXN8llcuhAI9A-6g4ZM8GnJcte5qCAgSQxfHlISU9b7UeLemwu6E8kqM7jNsH-APkuqA3REJU2XJR9LvNbYZ1-JZ0aKtu-WScmJmffZl6ur8t0Lf_MmYAMPvm44q7oUdwLHH55UGHytjCdhYwspjM-6FfQ7Y5GcSdNGGhiWVB7tz7_9C6bdTdM7dnZzFfq_OAIaCS_RRXe_dSSdnAYQQbLltCil3P10GZgc4V5J7tKJ-rmlNO7QT0kPcj6N1S50EeFTEoYk1-8aQmsBR0u0QFdDEIxIO7BEQTs9lVgZnComaqRZF4x0NVVeEkL5eHeBdN_Vz5MvOSHScFhk6Z4XTatxWBlM7Huv8Yd0b59gbCqBJDmTkUBgGAoKUN2NrM_3E-wJbisBZ-vDH64c4WNGCCFp0Vy8h_n-J5Fa-cI_bVNa1eu1gutFw-HLvnxR3JzD1_vClIFyq-4VhKgM6TeyN_grpa-IzHoSZpjdOHQtpxCBH4ipeEUNIoPk8nFi0XqpSYinLKNhfLKodTrAFgEBlL39YH4_j8eFjmnEAMNpenTuPKFkX9qSvvnl7BEx9O_3QHnU5CWXyKHsJHDpsgKAgT5uyDgJDI2OSooiAAHTHNQv2mIzFgcPdqWLkf6tyIjtikCO5CTAEOPgyBdPqy88EBEevyMHI1fdlGLGEzDe6SrSnKl33LpBj-GGQ097WNi7eOYtcue9VAKT66s8u7wrMD2d7YmhUzL-NEhM0YrEtFwEHXPaK5yzRODc5PDOKkoWzRWT3XXWifCfhnwed7psgXAehmjhX-Ioi_ziJAlznz0a-yC-XFPDxbD1KBj58kEromHzOa_f-INa3x_q_Ld4yQkEp_lAylyhL68ShTMwe7ErCdvN6pNcr6oUSh8xfpIa7lJL9lCeXlTN4xE57PFZJPNphqEvYEN3YWiTOy8a23dakQiG0UzX_fnq9yQ5KJX-yCcHFs2ypaJ0o884FxMaLHWGtJIjX19Fl08NuulJz3p4fxsX6kzS8TLILiRSmoaycPeaESKKki3BWU0nXeQPAvUOE-I62qZYMwOyXjSrB7Z4eu7YSSwKxvR_Si9aq-JXS3nVn4f9DhIDVkbCiaKWaqhLJxdae-Azk5vwcjqLvDNDj3P2ZxaGH4QGLSM4fg-9MwTc2UbdWUjleM1UfquY61Up1fl2x8bEC3OjfjIXA_GoqvnaKbdfOGDbTlM3w-mKO92aELRwxlSlq-lf6PfaGtMa56Nb3C0aic9NHjmA3yQAF4PdDGDiulq1wolRUwyBzgq99sj85e9_MRcZ3KGzmA9ZwLNd1DNqB4Yfk44sbTHlfTvFRSWSsMQO2iZ1ZWbp6XuKB3hUryqxsfFwohePuWxFrFqHOTYyReEBJSmZzbaVhTvnaf2jHc8WJtV65mzqOh4d6U8aGkFhO6zs0n_0QGDcHziLrVvQpghY_AZ6xgEXwFsDZlznAsclZIcxYq0uF9AYvHaAlfuFDMhfCa68SmBvGUDH2a2hMto9MzOaL8gaV7yxvqycLhoVKHDzIWPaxjiQBnv-tVVtQGUVdoDNAL7XU0aKbuH1x1gnYs_SKkC-aA-gKL2On9jhj5Bmhe_PHSvFOGnAT7VffgIovGjceWnzdZRgw4tW0UWzjxZzJfpHi47QvN3pu5bK3ZW9ANFXCTk5IpUV_S44mYb_35hNNKemO35c82AwKkBuM8oxmXpA5MRnMqql7pkm99JpM23tgYMs9DWSzRcJja1nBAn6j1EuQfh3yS-DMhNmE8PehF6JANJrU6IdluPHPmDd_KDaPPfDnLJYNl1kGoVt36vbm1XlXB4Lzo7FJKLhvnUZNK9YIRz5ggG4luN5oXhaOL74cdWUbmNIHJLk-BYMYnWKXMpcoocj-AfXBxHjCWcIxf_ZPR0I_DWUTqEuoo0I7eEsaSWD6rbHX0foGNPp4h24oDiJM1OzS6vx7VRkPc0J6jPiKepAxYybbb5hGs3YAfqD7jdX6b0FLeo2TWoh6nZfEozY8gExX4VUeomZ_WkYFwAgaBhNjAibLK2KDp-pya6usI27Ew579BwscPOjxFJzF9wcAFY6TtVg4YtYgfzXXldEdeTFet9kLFHiyL40KJqwXN2MTBL5yCi5mtqYui2tKAXlO12JcM6RpZ3tnDJyPRic8BzV3wzvr5MR8klHNysSBzIrt4p4nMD_OQQ_P5ouw50wtmsqdOXCOFq4S7bJiGTSOV4tTCK23kx0FXJQ9cqPCKPo_SvVVPV8hRzYGFTjWUzD-HBa4LgkEQm4z2EiVUBkg8A1DxWjipHmZZDY49ZoAawW9er62nd8TghJjb3ckEdtbEcVwlID1up71YS0tL8KSN6MRPzVWNuzu3PK0PSyXPPBYjIhu1aoJM6r6oZr0dYx2WMl2CRJnjajpL7wosg_-VdUeYkDDIdH6CATX8c4ZLGWV9ig1FmUuOzt9iobTZjdS86p8xx4DFwxaV9EqB0TfzZxgMbSNreOQ3XyhxPKZPc_8scyG7AD9i-fnWel4QnMu55CIPcjJb4CDAPlfYvnfR9vyV5AUwE88KrI3Q15ApcybnI1x68iz8I__1qV3DKHQ1dDlqFvKOLw3WxqMKiwnFmt-oPV4I5PyNjfqiUksx6L0wbqII9njnlITTL1lSNn_WmsULqyRLY5JnY0KzVmbWna2Ces07vIhsKi6zJRx6fNkgOI5FAsftncJPK0bgNAlkjPr2tIrIxPIpf22rdtFN46NfC8WqGMm9gwBorBEPUsFENfxsYdxLLF2rVmFEHcfPVPzOhTpCxnj6XsISX6zAbJmGBEsfS2Kpiz4dQ1WQwTWgrZHR3JyAG_f3sY2oxcBiPit_119ZINW4XzayK0pXM_Q1ZcJvcUeUNN8vwbu_tehdRT3Ng_rU18uqokNiRuGGyV1RbyJJ89ocGDihDgVkHtkNDB_fJE4ii7AzNGNWFJXZ1z5aiG5tDioYUupk97AkrIQstAgqf3UlWOlliykJzabr9GzRWeDAcETj1lc1iFRNW0bGSEcd0kZN_CZQPr0qtxGVAmP3P1xN_Ihu_t9wzZ71ea6KNnbJcGt9sRp93T6o0P67zDiqQ7k57CrWE-GjxVHKuDVC6q0JtUeNnZb3nAQYV7Eiu5FS62rkQZ7KO03fRazIqUqzz3r1a3jg-eloZsLg12XRJt72d0uuQ8rjamtSOYi2MF8y_4j8102tuuhPfeKCm5uG8nb5x4p0CYLRc9iX_z996pAC2llnPAh61wgc_oSOLpnrR489F5nRudy-TQJFpEV6TUAWDyNtFWzO1lIQVYIMQRLYzrJB5vaWvphkGmVSXIU5ybKXMs0f2z4TCnaIMsWHdkS7f-Uj2VABaR2UN3aW-DX4A7az6Z8 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7e6e638 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +# System deps for psycopg2-binary are bundled, but keep image lean. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..a8cac65 --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1 @@ +"""API package for the Migration Evaluation Platform (MEP).""" diff --git a/backend/api/routes/__init__.py b/backend/api/routes/__init__.py new file mode 100644 index 0000000..2795fe0 --- /dev/null +++ b/backend/api/routes/__init__.py @@ -0,0 +1 @@ +"""API route modules for the Migration Evaluation Platform (MEP).""" diff --git a/backend/api/routes/health.py b/backend/api/routes/health.py new file mode 100644 index 0000000..d2165ce --- /dev/null +++ b/backend/api/routes/health.py @@ -0,0 +1,26 @@ +"""Health-check route for the Migration Evaluation Platform (MEP).""" +from fastapi import APIRouter + +from config import settings +from database.connection import check_db_connection + +router = APIRouter(tags=["health"]) + +# Application version, kept in sync with the FastAPI app version. +VERSION = "0.1.0" + + +@router.get("/health") +def health() -> dict: + """Report service health. + + Always returns HTTP 200. If the database is unreachable, the response still + succeeds but reports ``"database": "disconnected"``. + """ + db_connected = check_db_connection() + return { + "status": "healthy", + "version": VERSION, + "environment": settings.APP_ENV, + "database": "connected" if db_connected else "disconnected", + } diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..799b2c3 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,30 @@ +"""Application configuration for the Migration Evaluation Platform (MEP). + +Settings are loaded from environment variables and/or a local ``.env`` file +using pydantic-settings. +""" +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """MEP backend settings.""" + + # Database connection string, e.g. + # postgresql://mep_user:mep_password@db:5432/mep_db + DATABASE_URL: str = "postgresql://mep_user:mep_password@db:5432/mep_db" + + # Runtime environment: development | staging | production + APP_ENV: str = "development" + + # Enable debug behaviour (verbose logging, etc.) + DEBUG: bool = True + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + +# Single, importable settings instance used across the app. +settings = Settings() diff --git a/backend/database/__init__.py b/backend/database/__init__.py new file mode 100644 index 0000000..79fcb95 --- /dev/null +++ b/backend/database/__init__.py @@ -0,0 +1 @@ +"""Database package for the Migration Evaluation Platform (MEP).""" diff --git a/backend/database/connection.py b/backend/database/connection.py new file mode 100644 index 0000000..03ad1c3 --- /dev/null +++ b/backend/database/connection.py @@ -0,0 +1,58 @@ +"""Database engine, session factory, and connection helpers for MEP. + +Provides a synchronous SQLAlchemy engine built from ``settings.DATABASE_URL``, +a session factory, a ``get_db`` context manager / FastAPI dependency, and a +``check_db_connection`` helper used by the health endpoint. +""" +import logging +from contextlib import contextmanager +from typing import Generator + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session, sessionmaker + +from config import settings + +logger = logging.getLogger(__name__) + +# Create the SQLAlchemy engine. ``pool_pre_ping`` transparently recycles stale +# connections, which is helpful when the database container restarts. +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + future=True, +) + +# Session factory bound to the engine. +SessionLocal = sessionmaker( + bind=engine, + autoflush=False, + autocommit=False, + expire_on_commit=False, + class_=Session, +) + + +@contextmanager +def get_db() -> Generator[Session, None, None]: + """Yield a database session, ensuring it is closed afterwards. + + Usable both as a context manager (``with get_db() as db:``) and as a + FastAPI dependency (``db: Session = Depends(get_db)``). + """ + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def check_db_connection() -> bool: + """Return ``True`` if a simple ``SELECT 1`` succeeds, else ``False``.""" + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + return True + except Exception as exc: # noqa: BLE001 - health check must never raise + logger.warning("Database connection check failed: %s", exc) + return False diff --git a/backend/database/models.py b/backend/database/models.py new file mode 100644 index 0000000..51ff44d --- /dev/null +++ b/backend/database/models.py @@ -0,0 +1,12 @@ +"""SQLAlchemy declarative base and ORM models for MEP. + +This module currently only exposes the declarative ``Base``. MEP metadata +models (e.g. ``MigrationRun``, ``UploadedFile``) will be added in later steps. +""" +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Base declarative class for all MEP ORM models.""" + + pass diff --git a/backend/evaluation/__init__.py b/backend/evaluation/__init__.py new file mode 100644 index 0000000..fc45eea --- /dev/null +++ b/backend/evaluation/__init__.py @@ -0,0 +1 @@ +"""Evaluation package (migration-quality evaluation) for MEP.""" diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..cb559a7 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,51 @@ +"""FastAPI application entrypoint for the Migration Evaluation Platform (MEP). + +This is the foundation-only skeleton: it wires up CORS, the health router, a +root route, and a startup hook that logs and tests the database connection. +Business logic (migration orchestration, evaluation, reporting) is added in +later steps. +""" +import logging + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from api.routes import health +from config import settings +from database.connection import check_db_connection + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("mep") + +VERSION = "0.1.0" + +app = FastAPI(title="Migration Evaluation Platform", version=VERSION) + +# CORS — allow all origins during development. +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount routers under the /api prefix. +app.include_router(health.router, prefix="/api") + + +@app.on_event("startup") +def on_startup() -> None: + """Log startup and test the database connection.""" + logger.info("MEP Backend starting...") + logger.info("Environment: %s | Debug: %s", settings.APP_ENV, settings.DEBUG) + if check_db_connection(): + logger.info("Database connection: OK") + else: + logger.warning("Database connection: UNAVAILABLE") + + +@app.get("/") +def root() -> dict: + """Root route confirming the API is running.""" + return {"message": "MEP API is running", "version": VERSION} diff --git a/backend/migration/README.md b/backend/migration/README.md new file mode 100644 index 0000000..dc87479 --- /dev/null +++ b/backend/migration/README.md @@ -0,0 +1,29 @@ +# Migration Engine + +Original migration engine — wrapped by FastAPI. + +This directory contains the original `PostgreDataMigrationApp` codebase, preserved +unchanged and relocated here as part of the Migration Evaluation Platform (MEP) v2 +restructure. Nothing here was rewritten — files were only moved. + +## Contents + +| Path | Description | +|------|-------------| +| `build/` | "Everything that ships" — the SQL-first, multi-engine database framework and the CSV → PostgreSQL loader/validator pipeline (`csv_loader.sh`, `csv/validator.py`, `csv/loader_postgresql.sh`). | +| `evals/` | Data-driven black-box scenarios (23 CSV edge-case scenarios across tiers P/I/S) plus the eval runner and VCRM gap report generator. | +| `infra/` | Containerisation (`Dockerfile`, `entrypoint.sh`) and Azure Terraform IaC. | +| `tests/` | Correctness coverage — 85 SQL assertions across 5 suites plus 9 Python test modules. | + +## Role within MEP + +The reusable "migration engine" is the `build/csv_loader.sh` + `build/csv/validator.py` ++ `build/csv/loader_postgresql.sh` pipeline (CSV → PostgreSQL via `COPY`). The MEP +FastAPI backend (`backend/api/`, `backend/services/`) wraps this engine rather than +reimplementing CSV → PostgreSQL loading. + +> **Note:** Internal path references inside `evals/runner.py` and `tests/*` were written +> relative to the original repository root (e.g. `Path(__file__).parents[1] / "build"`). +> Because `build/`, `evals/`, `infra/` and `tests/` were moved together into +> `backend/migration/`, their relative layout is preserved. Any absolute-from-root +> references should be re-verified when the FastAPI wrapper is wired up. diff --git a/build/adapters/adapter_influxdb.sh b/backend/migration/build/adapters/adapter_influxdb.sh similarity index 100% rename from build/adapters/adapter_influxdb.sh rename to backend/migration/build/adapters/adapter_influxdb.sh diff --git a/build/adapters/adapter_mariadb.sh b/backend/migration/build/adapters/adapter_mariadb.sh similarity index 100% rename from build/adapters/adapter_mariadb.sh rename to backend/migration/build/adapters/adapter_mariadb.sh diff --git a/build/adapters/adapter_postgresql.sh b/backend/migration/build/adapters/adapter_postgresql.sh similarity index 100% rename from build/adapters/adapter_postgresql.sh rename to backend/migration/build/adapters/adapter_postgresql.sh diff --git a/build/adapters/adapter_redis.sh b/backend/migration/build/adapters/adapter_redis.sh similarity index 100% rename from build/adapters/adapter_redis.sh rename to backend/migration/build/adapters/adapter_redis.sh diff --git a/build/adapters/adapter_sqlite.sh b/backend/migration/build/adapters/adapter_sqlite.sh similarity index 100% rename from build/adapters/adapter_sqlite.sh rename to backend/migration/build/adapters/adapter_sqlite.sh diff --git a/build/adapters/adapter_teradata.sh b/backend/migration/build/adapters/adapter_teradata.sh similarity index 100% rename from build/adapters/adapter_teradata.sh rename to backend/migration/build/adapters/adapter_teradata.sh diff --git a/build/config.env.example b/backend/migration/build/config.env.example similarity index 100% rename from build/config.env.example rename to backend/migration/build/config.env.example diff --git a/build/csv/loader_influxdb.sh b/backend/migration/build/csv/loader_influxdb.sh similarity index 100% rename from build/csv/loader_influxdb.sh rename to backend/migration/build/csv/loader_influxdb.sh diff --git a/build/csv/loader_mariadb.sh b/backend/migration/build/csv/loader_mariadb.sh similarity index 100% rename from build/csv/loader_mariadb.sh rename to backend/migration/build/csv/loader_mariadb.sh diff --git a/build/csv/loader_postgresql.sh b/backend/migration/build/csv/loader_postgresql.sh similarity index 100% rename from build/csv/loader_postgresql.sh rename to backend/migration/build/csv/loader_postgresql.sh diff --git a/build/csv/loader_redis.sh b/backend/migration/build/csv/loader_redis.sh similarity index 100% rename from build/csv/loader_redis.sh rename to backend/migration/build/csv/loader_redis.sh diff --git a/build/csv/loader_sqlite.sh b/backend/migration/build/csv/loader_sqlite.sh similarity index 100% rename from build/csv/loader_sqlite.sh rename to backend/migration/build/csv/loader_sqlite.sh diff --git a/build/csv/loader_teradata.sh b/backend/migration/build/csv/loader_teradata.sh similarity index 100% rename from build/csv/loader_teradata.sh rename to backend/migration/build/csv/loader_teradata.sh diff --git a/build/csv/samples/customers.csv b/backend/migration/build/csv/samples/customers.csv similarity index 100% rename from build/csv/samples/customers.csv rename to backend/migration/build/csv/samples/customers.csv diff --git a/build/csv/samples/inventory.csv b/backend/migration/build/csv/samples/inventory.csv similarity index 100% rename from build/csv/samples/inventory.csv rename to backend/migration/build/csv/samples/inventory.csv diff --git a/build/csv/samples/orders.csv b/backend/migration/build/csv/samples/orders.csv similarity index 100% rename from build/csv/samples/orders.csv rename to backend/migration/build/csv/samples/orders.csv diff --git a/build/csv/validator.py b/backend/migration/build/csv/validator.py similarity index 100% rename from build/csv/validator.py rename to backend/migration/build/csv/validator.py diff --git a/build/csv/validator.sh b/backend/migration/build/csv/validator.sh similarity index 100% rename from build/csv/validator.sh rename to backend/migration/build/csv/validator.sh diff --git a/build/csv_loader.sh b/backend/migration/build/csv_loader.sh similarity index 100% rename from build/csv_loader.sh rename to backend/migration/build/csv_loader.sh diff --git a/build/csv_utilise.sh b/backend/migration/build/csv_utilise.sh similarity index 100% rename from build/csv_utilise.sh rename to backend/migration/build/csv_utilise.sh diff --git a/build/deploy_all.sh b/backend/migration/build/deploy_all.sh similarity index 100% rename from build/deploy_all.sh rename to backend/migration/build/deploy_all.sh diff --git a/build/environments/env_dev.sql b/backend/migration/build/environments/env_dev.sql similarity index 100% rename from build/environments/env_dev.sql rename to backend/migration/build/environments/env_dev.sql diff --git a/build/environments/env_prod.sql b/backend/migration/build/environments/env_prod.sql similarity index 100% rename from build/environments/env_prod.sql rename to backend/migration/build/environments/env_prod.sql diff --git a/build/environments/env_staging.sql b/backend/migration/build/environments/env_staging.sql similarity index 100% rename from build/environments/env_staging.sql rename to backend/migration/build/environments/env_staging.sql diff --git a/build/environments/env_test.sql b/backend/migration/build/environments/env_test.sql similarity index 100% rename from build/environments/env_test.sql rename to backend/migration/build/environments/env_test.sql diff --git a/build/schema/influxdb/te_seed_data.lp b/backend/migration/build/schema/influxdb/te_seed_data.lp similarity index 100% rename from build/schema/influxdb/te_seed_data.lp rename to backend/migration/build/schema/influxdb/te_seed_data.lp diff --git a/build/schema/mariadb/te_core_schema.sql b/backend/migration/build/schema/mariadb/te_core_schema.sql similarity index 100% rename from build/schema/mariadb/te_core_schema.sql rename to backend/migration/build/schema/mariadb/te_core_schema.sql diff --git a/build/schema/mariadb/te_seed_data.sql b/backend/migration/build/schema/mariadb/te_seed_data.sql similarity index 100% rename from build/schema/mariadb/te_seed_data.sql rename to backend/migration/build/schema/mariadb/te_seed_data.sql diff --git a/build/schema/postgresql/te_core_schema.sql b/backend/migration/build/schema/postgresql/te_core_schema.sql similarity index 100% rename from build/schema/postgresql/te_core_schema.sql rename to backend/migration/build/schema/postgresql/te_core_schema.sql diff --git a/build/schema/postgresql/te_seed_data.sql b/backend/migration/build/schema/postgresql/te_seed_data.sql similarity index 100% rename from build/schema/postgresql/te_seed_data.sql rename to backend/migration/build/schema/postgresql/te_seed_data.sql diff --git a/build/schema/redis/te_seed_data.sh b/backend/migration/build/schema/redis/te_seed_data.sh similarity index 100% rename from build/schema/redis/te_seed_data.sh rename to backend/migration/build/schema/redis/te_seed_data.sh diff --git a/build/schema/sqlite/te_core_schema.sql b/backend/migration/build/schema/sqlite/te_core_schema.sql similarity index 100% rename from build/schema/sqlite/te_core_schema.sql rename to backend/migration/build/schema/sqlite/te_core_schema.sql diff --git a/build/schema/sqlite/te_seed_data.sql b/backend/migration/build/schema/sqlite/te_seed_data.sql similarity index 100% rename from build/schema/sqlite/te_seed_data.sql rename to backend/migration/build/schema/sqlite/te_seed_data.sql diff --git a/build/schema/teradata/te_core_schema.sql b/backend/migration/build/schema/teradata/te_core_schema.sql similarity index 100% rename from build/schema/teradata/te_core_schema.sql rename to backend/migration/build/schema/teradata/te_core_schema.sql diff --git a/build/schema/teradata/te_seed_data.sql b/backend/migration/build/schema/teradata/te_seed_data.sql similarity index 100% rename from build/schema/teradata/te_seed_data.sql rename to backend/migration/build/schema/teradata/te_seed_data.sql diff --git a/build/setup.sh b/backend/migration/build/setup.sh similarity index 100% rename from build/setup.sh rename to backend/migration/build/setup.sh diff --git a/build/te_core_schema.sql b/backend/migration/build/te_core_schema.sql similarity index 100% rename from build/te_core_schema.sql rename to backend/migration/build/te_core_schema.sql diff --git a/build/te_seed_data.sql b/backend/migration/build/te_seed_data.sql similarity index 100% rename from build/te_seed_data.sql rename to backend/migration/build/te_seed_data.sql diff --git a/build/terraform-github-repos/.gitignore b/backend/migration/build/terraform-github-repos/.gitignore similarity index 100% rename from build/terraform-github-repos/.gitignore rename to backend/migration/build/terraform-github-repos/.gitignore diff --git a/build/terraform-github-repos/main.tf b/backend/migration/build/terraform-github-repos/main.tf similarity index 100% rename from build/terraform-github-repos/main.tf rename to backend/migration/build/terraform-github-repos/main.tf diff --git a/build/terraform-github-repos/outputs.tf b/backend/migration/build/terraform-github-repos/outputs.tf similarity index 100% rename from build/terraform-github-repos/outputs.tf rename to backend/migration/build/terraform-github-repos/outputs.tf diff --git a/build/terraform-github-repos/terraform.tfvars.example b/backend/migration/build/terraform-github-repos/terraform.tfvars.example similarity index 100% rename from build/terraform-github-repos/terraform.tfvars.example rename to backend/migration/build/terraform-github-repos/terraform.tfvars.example diff --git a/build/terraform-github-repos/variables.tf b/backend/migration/build/terraform-github-repos/variables.tf similarity index 100% rename from build/terraform-github-repos/variables.tf rename to backend/migration/build/terraform-github-repos/variables.tf diff --git a/evals/FAILURE_MODES.md b/backend/migration/evals/FAILURE_MODES.md similarity index 100% rename from evals/FAILURE_MODES.md rename to backend/migration/evals/FAILURE_MODES.md diff --git a/evals/HANDOFF.md b/backend/migration/evals/HANDOFF.md similarity index 100% rename from evals/HANDOFF.md rename to backend/migration/evals/HANDOFF.md diff --git a/evals/PLAN.md b/backend/migration/evals/PLAN.md similarity index 100% rename from evals/PLAN.md rename to backend/migration/evals/PLAN.md diff --git a/evals/USAGE.md b/backend/migration/evals/USAGE.md similarity index 100% rename from evals/USAGE.md rename to backend/migration/evals/USAGE.md diff --git a/evals/datasets/tier_i/01_deploy_dev_twice/NOTES.txt b/backend/migration/evals/datasets/tier_i/01_deploy_dev_twice/NOTES.txt similarity index 100% rename from evals/datasets/tier_i/01_deploy_dev_twice/NOTES.txt rename to backend/migration/evals/datasets/tier_i/01_deploy_dev_twice/NOTES.txt diff --git a/evals/datasets/tier_p/01_happy_path/input.csv b/backend/migration/evals/datasets/tier_p/01_happy_path/input.csv similarity index 100% rename from evals/datasets/tier_p/01_happy_path/input.csv rename to backend/migration/evals/datasets/tier_p/01_happy_path/input.csv diff --git a/backend/migration/evals/datasets/tier_p/02_empty_file/input.csv b/backend/migration/evals/datasets/tier_p/02_empty_file/input.csv new file mode 100644 index 0000000..e69de29 diff --git a/evals/datasets/tier_p/03_empty_header_only_newline/input.csv b/backend/migration/evals/datasets/tier_p/03_empty_header_only_newline/input.csv similarity index 100% rename from evals/datasets/tier_p/03_empty_header_only_newline/input.csv rename to backend/migration/evals/datasets/tier_p/03_empty_header_only_newline/input.csv diff --git a/evals/datasets/tier_p/04_no_valid_rows/input.csv b/backend/migration/evals/datasets/tier_p/04_no_valid_rows/input.csv similarity index 100% rename from evals/datasets/tier_p/04_no_valid_rows/input.csv rename to backend/migration/evals/datasets/tier_p/04_no_valid_rows/input.csv diff --git a/evals/datasets/tier_p/05_mixed_valid_skipped/input.csv b/backend/migration/evals/datasets/tier_p/05_mixed_valid_skipped/input.csv similarity index 100% rename from evals/datasets/tier_p/05_mixed_valid_skipped/input.csv rename to backend/migration/evals/datasets/tier_p/05_mixed_valid_skipped/input.csv diff --git a/evals/datasets/tier_p/06_duplicate_headers/input.csv b/backend/migration/evals/datasets/tier_p/06_duplicate_headers/input.csv similarity index 100% rename from evals/datasets/tier_p/06_duplicate_headers/input.csv rename to backend/migration/evals/datasets/tier_p/06_duplicate_headers/input.csv diff --git a/evals/datasets/tier_p/07_column_mismatch_short/input.csv b/backend/migration/evals/datasets/tier_p/07_column_mismatch_short/input.csv similarity index 100% rename from evals/datasets/tier_p/07_column_mismatch_short/input.csv rename to backend/migration/evals/datasets/tier_p/07_column_mismatch_short/input.csv diff --git a/evals/datasets/tier_p/08_column_mismatch_long/input.csv b/backend/migration/evals/datasets/tier_p/08_column_mismatch_long/input.csv similarity index 100% rename from evals/datasets/tier_p/08_column_mismatch_long/input.csv rename to backend/migration/evals/datasets/tier_p/08_column_mismatch_long/input.csv diff --git a/evals/datasets/tier_p/09_empty_row/input.csv b/backend/migration/evals/datasets/tier_p/09_empty_row/input.csv similarity index 100% rename from evals/datasets/tier_p/09_empty_row/input.csv rename to backend/migration/evals/datasets/tier_p/09_empty_row/input.csv diff --git a/evals/datasets/tier_p/10_utf8_bom/input.csv b/backend/migration/evals/datasets/tier_p/10_utf8_bom/input.csv similarity index 100% rename from evals/datasets/tier_p/10_utf8_bom/input.csv rename to backend/migration/evals/datasets/tier_p/10_utf8_bom/input.csv diff --git a/evals/datasets/tier_p/11_utf8_emoji/input.csv b/backend/migration/evals/datasets/tier_p/11_utf8_emoji/input.csv similarity index 100% rename from evals/datasets/tier_p/11_utf8_emoji/input.csv rename to backend/migration/evals/datasets/tier_p/11_utf8_emoji/input.csv diff --git a/evals/datasets/tier_p/12_crlf_line_endings/input.csv b/backend/migration/evals/datasets/tier_p/12_crlf_line_endings/input.csv similarity index 100% rename from evals/datasets/tier_p/12_crlf_line_endings/input.csv rename to backend/migration/evals/datasets/tier_p/12_crlf_line_endings/input.csv diff --git a/evals/datasets/tier_p/13_quoted_comma/input.csv b/backend/migration/evals/datasets/tier_p/13_quoted_comma/input.csv similarity index 100% rename from evals/datasets/tier_p/13_quoted_comma/input.csv rename to backend/migration/evals/datasets/tier_p/13_quoted_comma/input.csv diff --git a/evals/datasets/tier_p/14_quoted_newline/input.csv b/backend/migration/evals/datasets/tier_p/14_quoted_newline/input.csv similarity index 100% rename from evals/datasets/tier_p/14_quoted_newline/input.csv rename to backend/migration/evals/datasets/tier_p/14_quoted_newline/input.csv diff --git a/evals/datasets/tier_p/15_quoted_quote/input.csv b/backend/migration/evals/datasets/tier_p/15_quoted_quote/input.csv similarity index 100% rename from evals/datasets/tier_p/15_quoted_quote/input.csv rename to backend/migration/evals/datasets/tier_p/15_quoted_quote/input.csv diff --git a/evals/datasets/tier_p/16_whitespace_only_row/input.csv b/backend/migration/evals/datasets/tier_p/16_whitespace_only_row/input.csv similarity index 100% rename from evals/datasets/tier_p/16_whitespace_only_row/input.csv rename to backend/migration/evals/datasets/tier_p/16_whitespace_only_row/input.csv diff --git a/evals/datasets/tier_p/17_header_whitespace/input.csv b/backend/migration/evals/datasets/tier_p/17_header_whitespace/input.csv similarity index 100% rename from evals/datasets/tier_p/17_header_whitespace/input.csv rename to backend/migration/evals/datasets/tier_p/17_header_whitespace/input.csv diff --git a/evals/datasets/tier_p/18_utf8_cjk/input.csv b/backend/migration/evals/datasets/tier_p/18_utf8_cjk/input.csv similarity index 100% rename from evals/datasets/tier_p/18_utf8_cjk/input.csv rename to backend/migration/evals/datasets/tier_p/18_utf8_cjk/input.csv diff --git a/evals/datasets/tier_p/19_missing_env_vars/README.txt b/backend/migration/evals/datasets/tier_p/19_missing_env_vars/README.txt similarity index 100% rename from evals/datasets/tier_p/19_missing_env_vars/README.txt rename to backend/migration/evals/datasets/tier_p/19_missing_env_vars/README.txt diff --git a/evals/datasets/tier_p/20_missing_csv_file/README.txt b/backend/migration/evals/datasets/tier_p/20_missing_csv_file/README.txt similarity index 100% rename from evals/datasets/tier_p/20_missing_csv_file/README.txt rename to backend/migration/evals/datasets/tier_p/20_missing_csv_file/README.txt diff --git a/evals/datasets/tier_p/21_utf8_arabic/input.csv b/backend/migration/evals/datasets/tier_p/21_utf8_arabic/input.csv similarity index 100% rename from evals/datasets/tier_p/21_utf8_arabic/input.csv rename to backend/migration/evals/datasets/tier_p/21_utf8_arabic/input.csv diff --git a/evals/datasets/tier_p/22_very_long_field/README.txt b/backend/migration/evals/datasets/tier_p/22_very_long_field/README.txt similarity index 100% rename from evals/datasets/tier_p/22_very_long_field/README.txt rename to backend/migration/evals/datasets/tier_p/22_very_long_field/README.txt diff --git a/evals/datasets/tier_p/23_invalid_utf8_bytes/README.txt b/backend/migration/evals/datasets/tier_p/23_invalid_utf8_bytes/README.txt similarity index 100% rename from evals/datasets/tier_p/23_invalid_utf8_bytes/README.txt rename to backend/migration/evals/datasets/tier_p/23_invalid_utf8_bytes/README.txt diff --git a/evals/datasets/tier_s/01_fresh_deploy_then_all_tests_pass/NOTES.txt b/backend/migration/evals/datasets/tier_s/01_fresh_deploy_then_all_tests_pass/NOTES.txt similarity index 100% rename from evals/datasets/tier_s/01_fresh_deploy_then_all_tests_pass/NOTES.txt rename to backend/migration/evals/datasets/tier_s/01_fresh_deploy_then_all_tests_pass/NOTES.txt diff --git a/evals/expected/tier_i/01_deploy_dev_twice.json b/backend/migration/evals/expected/tier_i/01_deploy_dev_twice.json similarity index 100% rename from evals/expected/tier_i/01_deploy_dev_twice.json rename to backend/migration/evals/expected/tier_i/01_deploy_dev_twice.json diff --git a/evals/expected/tier_p/01_happy_path.json b/backend/migration/evals/expected/tier_p/01_happy_path.json similarity index 100% rename from evals/expected/tier_p/01_happy_path.json rename to backend/migration/evals/expected/tier_p/01_happy_path.json diff --git a/evals/expected/tier_p/02_empty_file.json b/backend/migration/evals/expected/tier_p/02_empty_file.json similarity index 100% rename from evals/expected/tier_p/02_empty_file.json rename to backend/migration/evals/expected/tier_p/02_empty_file.json diff --git a/evals/expected/tier_p/03_empty_header_only_newline.json b/backend/migration/evals/expected/tier_p/03_empty_header_only_newline.json similarity index 100% rename from evals/expected/tier_p/03_empty_header_only_newline.json rename to backend/migration/evals/expected/tier_p/03_empty_header_only_newline.json diff --git a/evals/expected/tier_p/04_no_valid_rows.json b/backend/migration/evals/expected/tier_p/04_no_valid_rows.json similarity index 100% rename from evals/expected/tier_p/04_no_valid_rows.json rename to backend/migration/evals/expected/tier_p/04_no_valid_rows.json diff --git a/evals/expected/tier_p/05_mixed_valid_skipped.json b/backend/migration/evals/expected/tier_p/05_mixed_valid_skipped.json similarity index 100% rename from evals/expected/tier_p/05_mixed_valid_skipped.json rename to backend/migration/evals/expected/tier_p/05_mixed_valid_skipped.json diff --git a/evals/expected/tier_p/06_duplicate_headers.json b/backend/migration/evals/expected/tier_p/06_duplicate_headers.json similarity index 100% rename from evals/expected/tier_p/06_duplicate_headers.json rename to backend/migration/evals/expected/tier_p/06_duplicate_headers.json diff --git a/evals/expected/tier_p/07_column_mismatch_short.json b/backend/migration/evals/expected/tier_p/07_column_mismatch_short.json similarity index 100% rename from evals/expected/tier_p/07_column_mismatch_short.json rename to backend/migration/evals/expected/tier_p/07_column_mismatch_short.json diff --git a/evals/expected/tier_p/08_column_mismatch_long.json b/backend/migration/evals/expected/tier_p/08_column_mismatch_long.json similarity index 100% rename from evals/expected/tier_p/08_column_mismatch_long.json rename to backend/migration/evals/expected/tier_p/08_column_mismatch_long.json diff --git a/evals/expected/tier_p/09_empty_row.json b/backend/migration/evals/expected/tier_p/09_empty_row.json similarity index 100% rename from evals/expected/tier_p/09_empty_row.json rename to backend/migration/evals/expected/tier_p/09_empty_row.json diff --git a/evals/expected/tier_p/10_utf8_bom.json b/backend/migration/evals/expected/tier_p/10_utf8_bom.json similarity index 100% rename from evals/expected/tier_p/10_utf8_bom.json rename to backend/migration/evals/expected/tier_p/10_utf8_bom.json diff --git a/evals/expected/tier_p/11_utf8_emoji.json b/backend/migration/evals/expected/tier_p/11_utf8_emoji.json similarity index 100% rename from evals/expected/tier_p/11_utf8_emoji.json rename to backend/migration/evals/expected/tier_p/11_utf8_emoji.json diff --git a/evals/expected/tier_p/12_crlf_line_endings.json b/backend/migration/evals/expected/tier_p/12_crlf_line_endings.json similarity index 100% rename from evals/expected/tier_p/12_crlf_line_endings.json rename to backend/migration/evals/expected/tier_p/12_crlf_line_endings.json diff --git a/evals/expected/tier_p/13_quoted_comma.json b/backend/migration/evals/expected/tier_p/13_quoted_comma.json similarity index 100% rename from evals/expected/tier_p/13_quoted_comma.json rename to backend/migration/evals/expected/tier_p/13_quoted_comma.json diff --git a/evals/expected/tier_p/14_quoted_newline.json b/backend/migration/evals/expected/tier_p/14_quoted_newline.json similarity index 100% rename from evals/expected/tier_p/14_quoted_newline.json rename to backend/migration/evals/expected/tier_p/14_quoted_newline.json diff --git a/evals/expected/tier_p/15_quoted_quote.json b/backend/migration/evals/expected/tier_p/15_quoted_quote.json similarity index 100% rename from evals/expected/tier_p/15_quoted_quote.json rename to backend/migration/evals/expected/tier_p/15_quoted_quote.json diff --git a/evals/expected/tier_p/16_whitespace_only_row.json b/backend/migration/evals/expected/tier_p/16_whitespace_only_row.json similarity index 100% rename from evals/expected/tier_p/16_whitespace_only_row.json rename to backend/migration/evals/expected/tier_p/16_whitespace_only_row.json diff --git a/evals/expected/tier_p/17_header_whitespace.json b/backend/migration/evals/expected/tier_p/17_header_whitespace.json similarity index 100% rename from evals/expected/tier_p/17_header_whitespace.json rename to backend/migration/evals/expected/tier_p/17_header_whitespace.json diff --git a/evals/expected/tier_p/18_utf8_cjk.json b/backend/migration/evals/expected/tier_p/18_utf8_cjk.json similarity index 100% rename from evals/expected/tier_p/18_utf8_cjk.json rename to backend/migration/evals/expected/tier_p/18_utf8_cjk.json diff --git a/evals/expected/tier_p/19_missing_env_vars.json b/backend/migration/evals/expected/tier_p/19_missing_env_vars.json similarity index 100% rename from evals/expected/tier_p/19_missing_env_vars.json rename to backend/migration/evals/expected/tier_p/19_missing_env_vars.json diff --git a/evals/expected/tier_p/20_missing_csv_file.json b/backend/migration/evals/expected/tier_p/20_missing_csv_file.json similarity index 100% rename from evals/expected/tier_p/20_missing_csv_file.json rename to backend/migration/evals/expected/tier_p/20_missing_csv_file.json diff --git a/evals/expected/tier_p/21_utf8_arabic.json b/backend/migration/evals/expected/tier_p/21_utf8_arabic.json similarity index 100% rename from evals/expected/tier_p/21_utf8_arabic.json rename to backend/migration/evals/expected/tier_p/21_utf8_arabic.json diff --git a/evals/expected/tier_p/22_very_long_field.json b/backend/migration/evals/expected/tier_p/22_very_long_field.json similarity index 100% rename from evals/expected/tier_p/22_very_long_field.json rename to backend/migration/evals/expected/tier_p/22_very_long_field.json diff --git a/evals/expected/tier_p/23_invalid_utf8_bytes.json b/backend/migration/evals/expected/tier_p/23_invalid_utf8_bytes.json similarity index 100% rename from evals/expected/tier_p/23_invalid_utf8_bytes.json rename to backend/migration/evals/expected/tier_p/23_invalid_utf8_bytes.json diff --git a/evals/expected/tier_s/01_fresh_deploy_then_all_tests_pass.json b/backend/migration/evals/expected/tier_s/01_fresh_deploy_then_all_tests_pass.json similarity index 100% rename from evals/expected/tier_s/01_fresh_deploy_then_all_tests_pass.json rename to backend/migration/evals/expected/tier_s/01_fresh_deploy_then_all_tests_pass.json diff --git a/evals/gap_report.py b/backend/migration/evals/gap_report.py similarity index 100% rename from evals/gap_report.py rename to backend/migration/evals/gap_report.py diff --git a/evals/runner.py b/backend/migration/evals/runner.py similarity index 100% rename from evals/runner.py rename to backend/migration/evals/runner.py diff --git a/infra/.dockerignore b/backend/migration/infra/.dockerignore similarity index 100% rename from infra/.dockerignore rename to backend/migration/infra/.dockerignore diff --git a/infra/Dockerfile b/backend/migration/infra/Dockerfile similarity index 100% rename from infra/Dockerfile rename to backend/migration/infra/Dockerfile diff --git a/infra/azure-portal-walkthrough.md b/backend/migration/infra/azure-portal-walkthrough.md similarity index 100% rename from infra/azure-portal-walkthrough.md rename to backend/migration/infra/azure-portal-walkthrough.md diff --git a/infra/entrypoint.sh b/backend/migration/infra/entrypoint.sh similarity index 100% rename from infra/entrypoint.sh rename to backend/migration/infra/entrypoint.sh diff --git a/infra/terraform-prod/.gitignore b/backend/migration/infra/terraform-prod/.gitignore similarity index 100% rename from infra/terraform-prod/.gitignore rename to backend/migration/infra/terraform-prod/.gitignore diff --git a/infra/terraform-prod/main.tf b/backend/migration/infra/terraform-prod/main.tf similarity index 100% rename from infra/terraform-prod/main.tf rename to backend/migration/infra/terraform-prod/main.tf diff --git a/infra/terraform-prod/outputs.tf b/backend/migration/infra/terraform-prod/outputs.tf similarity index 100% rename from infra/terraform-prod/outputs.tf rename to backend/migration/infra/terraform-prod/outputs.tf diff --git a/infra/terraform-prod/terraform.tfvars.example b/backend/migration/infra/terraform-prod/terraform.tfvars.example similarity index 100% rename from infra/terraform-prod/terraform.tfvars.example rename to backend/migration/infra/terraform-prod/terraform.tfvars.example diff --git a/infra/terraform-prod/variables.tf b/backend/migration/infra/terraform-prod/variables.tf similarity index 100% rename from infra/terraform-prod/variables.tf rename to backend/migration/infra/terraform-prod/variables.tf diff --git a/infra/terraform/.gitignore b/backend/migration/infra/terraform/.gitignore similarity index 100% rename from infra/terraform/.gitignore rename to backend/migration/infra/terraform/.gitignore diff --git a/infra/terraform/main.tf b/backend/migration/infra/terraform/main.tf similarity index 100% rename from infra/terraform/main.tf rename to backend/migration/infra/terraform/main.tf diff --git a/infra/terraform/outputs.tf b/backend/migration/infra/terraform/outputs.tf similarity index 100% rename from infra/terraform/outputs.tf rename to backend/migration/infra/terraform/outputs.tf diff --git a/infra/terraform/terraform.tfvars.example b/backend/migration/infra/terraform/terraform.tfvars.example similarity index 100% rename from infra/terraform/terraform.tfvars.example rename to backend/migration/infra/terraform/terraform.tfvars.example diff --git a/infra/terraform/variables.tf b/backend/migration/infra/terraform/variables.tf similarity index 100% rename from infra/terraform/variables.tf rename to backend/migration/infra/terraform/variables.tf diff --git a/tests/conftest.py b/backend/migration/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to backend/migration/tests/conftest.py diff --git a/tests/framework/test_framework.sql b/backend/migration/tests/framework/test_framework.sql similarity index 100% rename from tests/framework/test_framework.sql rename to backend/migration/tests/framework/test_framework.sql diff --git a/tests/run_all_tests.sql b/backend/migration/tests/run_all_tests.sql similarity index 100% rename from tests/run_all_tests.sql rename to backend/migration/tests/run_all_tests.sql diff --git a/tests/run_python_tests.ps1 b/backend/migration/tests/run_python_tests.ps1 similarity index 100% rename from tests/run_python_tests.ps1 rename to backend/migration/tests/run_python_tests.ps1 diff --git a/tests/run_tests.sh b/backend/migration/tests/run_tests.sh similarity index 100% rename from tests/run_tests.sh rename to backend/migration/tests/run_tests.sh diff --git a/tests/snapshots/basic_expected_skip.csv b/backend/migration/tests/snapshots/basic_expected_skip.csv similarity index 100% rename from tests/snapshots/basic_expected_skip.csv rename to backend/migration/tests/snapshots/basic_expected_skip.csv diff --git a/tests/snapshots/basic_expected_valid.csv b/backend/migration/tests/snapshots/basic_expected_valid.csv similarity index 100% rename from tests/snapshots/basic_expected_valid.csv rename to backend/migration/tests/snapshots/basic_expected_valid.csv diff --git a/tests/snapshots/basic_input.csv b/backend/migration/tests/snapshots/basic_input.csv similarity index 100% rename from tests/snapshots/basic_input.csv rename to backend/migration/tests/snapshots/basic_input.csv diff --git a/tests/suites/test_01_organisations_personnel.sql b/backend/migration/tests/suites/test_01_organisations_personnel.sql similarity index 100% rename from tests/suites/test_01_organisations_personnel.sql rename to backend/migration/tests/suites/test_01_organisations_personnel.sql diff --git a/tests/suites/test_02_programs_phases.sql b/backend/migration/tests/suites/test_02_programs_phases.sql similarity index 100% rename from tests/suites/test_02_programs_phases.sql rename to backend/migration/tests/suites/test_02_programs_phases.sql diff --git a/tests/suites/test_03_requirements_vcrm.sql b/backend/migration/tests/suites/test_03_requirements_vcrm.sql similarity index 100% rename from tests/suites/test_03_requirements_vcrm.sql rename to backend/migration/tests/suites/test_03_requirements_vcrm.sql diff --git a/tests/suites/test_04_execution_defects.sql b/backend/migration/tests/suites/test_04_execution_defects.sql similarity index 100% rename from tests/suites/test_04_execution_defects.sql rename to backend/migration/tests/suites/test_04_execution_defects.sql diff --git a/tests/suites/test_05_schema_and_business_rules.sql b/backend/migration/tests/suites/test_05_schema_and_business_rules.sql similarity index 100% rename from tests/suites/test_05_schema_and_business_rules.sql rename to backend/migration/tests/suites/test_05_schema_and_business_rules.sql diff --git a/tests/test_csv_loader_arbitrary_shapes.py b/backend/migration/tests/test_csv_loader_arbitrary_shapes.py similarity index 100% rename from tests/test_csv_loader_arbitrary_shapes.py rename to backend/migration/tests/test_csv_loader_arbitrary_shapes.py diff --git a/tests/test_csv_utilise.py b/backend/migration/tests/test_csv_utilise.py similarity index 100% rename from tests/test_csv_utilise.py rename to backend/migration/tests/test_csv_utilise.py diff --git a/tests/test_csv_validator.py b/backend/migration/tests/test_csv_validator.py similarity index 100% rename from tests/test_csv_validator.py rename to backend/migration/tests/test_csv_validator.py diff --git a/tests/test_e2e_pipeline.py b/backend/migration/tests/test_e2e_pipeline.py similarity index 100% rename from tests/test_e2e_pipeline.py rename to backend/migration/tests/test_e2e_pipeline.py diff --git a/tests/test_evals_runner.py b/backend/migration/tests/test_evals_runner.py similarity index 100% rename from tests/test_evals_runner.py rename to backend/migration/tests/test_evals_runner.py diff --git a/tests/test_parity.py b/backend/migration/tests/test_parity.py similarity index 100% rename from tests/test_parity.py rename to backend/migration/tests/test_parity.py diff --git a/tests/test_regression.py b/backend/migration/tests/test_regression.py similarity index 100% rename from tests/test_regression.py rename to backend/migration/tests/test_regression.py diff --git a/tests/test_security.py b/backend/migration/tests/test_security.py similarity index 100% rename from tests/test_security.py rename to backend/migration/tests/test_security.py diff --git a/tests/test_snapshot.py b/backend/migration/tests/test_snapshot.py similarity index 100% rename from tests/test_snapshot.py rename to backend/migration/tests/test_snapshot.py diff --git a/backend/reports/__init__.py b/backend/reports/__init__.py new file mode 100644 index 0000000..98714ac --- /dev/null +++ b/backend/reports/__init__.py @@ -0,0 +1 @@ +"""Reports package (reconciliation & evaluation reports) for MEP.""" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a6e19f6 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +sqlalchemy>=2.0.0 +alembic>=1.13.0 +pydantic-settings>=2.0.0 +python-multipart +python-dotenv +psycopg2-binary +pytest +httpx +pytest-asyncio diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..ce0902b --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +"""Services package (business logic) for MEP.""" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e1e29a3 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for the MEP backend.""" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..240da57 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,30 @@ +"""Smoke tests for the MEP backend skeleton.""" +from fastapi.testclient import TestClient + +from main import app + +client = TestClient(app) + + +def test_root_returns_200(): + """Root route responds with 200 and the running message.""" + response = client.get("/") + assert response.status_code == 200 + body = response.json() + assert body["message"] == "MEP API is running" + assert body["version"] == "0.1.0" + + +def test_health_returns_200_with_status(): + """Health route responds with 200 and a status field. + + The database may be unavailable in CI; the endpoint must still return 200 + and report the database as either connected or disconnected. + """ + response = client.get("/api/health") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "healthy" + assert body["version"] == "0.1.0" + assert "environment" in body + assert body["database"] in ("connected", "disconnected") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5007b06 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + db: + image: postgres:15-alpine + container_name: mep_db + environment: + POSTGRES_DB: mep_db + POSTGRES_USER: mep_user + POSTGRES_PASSWORD: mep_password + ports: + - "5432:5432" + volumes: + - mep_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mep_user -d mep_db"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: mep_backend + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql://mep_user:mep_password@db:5432/mep_db + APP_ENV: development + depends_on: + db: + condition: service_healthy + volumes: + - ./uploads:/app/uploads + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: mep_frontend + ports: + - "3000:80" + depends_on: + - backend + +volumes: + mep_pgdata: diff --git a/docker/.gitkeep b/docker/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/ADR/ADR-001-Technology-Choices.md b/docs/ADR/ADR-001-Technology-Choices.md new file mode 100644 index 0000000..8d2f8b7 --- /dev/null +++ b/docs/ADR/ADR-001-Technology-Choices.md @@ -0,0 +1,31 @@ +# ADR-001: Technology Choices + +- **Status:** Accepted +- **Date:** 2026-07-14 + +## Context + +MEP needs a web frontend, an API layer, and a relational datastore to wrap the +existing CSV → PostgreSQL migration engine. We need a stack that is productive, +widely supported, and a good fit for the engine's existing PostgreSQL focus. + +## Decision + +- **Frontend: React + TypeScript + Material UI (MUI).** + React is the dominant SPA framework with a deep ecosystem and hiring pool. + TypeScript adds type safety. MUI provides a mature component library that + lets us mirror the Azure Portal look-and-feel quickly. +- **Backend: FastAPI (Python 3.11).** + The migration engine is already Python/Bash, so a Python API keeps one + language for backend logic and enables direct reuse of engine code. FastAPI + gives async performance, Pydantic validation, and automatic OpenAPI docs. +- **Database: PostgreSQL 15.** + The engine's primary, best-tested target is PostgreSQL, so using it for + application data keeps operational tooling and expertise consistent. + +## Consequences + +- One backend language (Python) across API and engine reduces context switching. +- Automatic OpenAPI docs and typed frontend contracts speed up development. +- MUI accelerates a polished, Azure-style UI at the cost of a heavier bundle. +- Committing to PostgreSQL is low-risk given the engine's existing reliance on it. diff --git a/docs/ADR/ADR-002-Repository-Structure.md b/docs/ADR/ADR-002-Repository-Structure.md new file mode 100644 index 0000000..dadc495 --- /dev/null +++ b/docs/ADR/ADR-002-Repository-Structure.md @@ -0,0 +1,38 @@ +# ADR-002: Repository Structure + +- **Status:** Accepted +- **Date:** 2026-07-14 + +## Context + +MEP adds a frontend, backend, and infrastructure around an existing migration +engine that already lives in this repository. We must decide whether to split +these into multiple repositories or keep a single one, and how to preserve the +existing engine. + +## Decision + +Adopt a **monorepo** with the following top-level layout: + +``` +PostgreDataMigrationApp/ +├── backend/ # FastAPI application +│ └── migration/ # Existing engine, preserved unchanged +├── frontend/ # React + TypeScript SPA +├── docs/ # Architecture, guides, ADRs +├── uploads/ # Runtime CSV uploads (gitignored) +├── docker-compose.yml # Full-stack local orchestration +└── .github/workflows/ # CI/CD +``` + +The original engine is moved verbatim into `backend/migration/` so its internal +relative paths (`build/`, `evals/`, `infra/`, `tests/`) remain valid. + +## Consequences + +- Atomic commits can span frontend, backend, and engine together. +- Single clone and one `docker compose up` for the whole stack simplifies onboarding. +- The engine is preserved without code changes, minimizing regression risk. +- A shared CI pipeline covers all components. +- Trade-off: a larger repo and coarser-grained access control than split repos, + acceptable at the current team size. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..fbe7e32 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,59 @@ +# MEP API Reference + +Base URL (local): `http://localhost:8000` + +The API is a FastAPI service. Interactive docs are available at `/docs` +(Swagger UI) and `/redoc` when the backend is running. + +> **Status:** Phase 0 foundation. Only the root and health endpoints exist +> today; migration, evaluation, and reporting endpoints are added in later phases. + +## `GET /` + +Root route confirming the API is running. + +**Request** + +```http +GET / HTTP/1.1 +Host: localhost:8000 +``` + +**Response** — `200 OK` + +```json +{ + "message": "MEP API is running", + "version": "0.1.0" +} +``` + +## `GET /api/health` + +Service health check. Always returns `200 OK`. If the database is unreachable, +the response still succeeds but reports `"database": "disconnected"`. + +**Request** + +```http +GET /api/health HTTP/1.1 +Host: localhost:8000 +``` + +**Response** — `200 OK` + +```json +{ + "status": "healthy", + "version": "0.1.0", + "environment": "development", + "database": "connected" +} +``` + +| Field | Type | Description | +| ------------- | ------ | -------------------------------------------------- | +| `status` | string | Always `"healthy"` when the service responds. | +| `version` | string | API version. | +| `environment` | string | Value of `APP_ENV`. | +| `database` | string | `"connected"` or `"disconnected"`. | diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 0000000..66eb518 --- /dev/null +++ b/docs/Architecture.md @@ -0,0 +1,57 @@ +# MEP Architecture + +The Migration Evaluation Platform (MEP) is a 3-tier web application that wraps +the existing CSV → PostgreSQL migration engine with a modern UI and API. + +## Overview + +``` +┌──────────────┐ HTTP/JSON ┌──────────────┐ SQL ┌──────────────┐ +│ Frontend │ ───────────────▶ │ Backend │ ──────────▶ │ PostgreSQL │ +│ React + MUI │ │ FastAPI │ │ Database │ +│ (nginx :80) │ ◀─────────────── │ (uvicorn │ ◀────────── │ (:5432) │ +│ │ responses │ :8000) │ rows │ │ +└──────────────┘ └──────────────┘ └──────────────┘ +``` + +### 1. Presentation tier — React + TypeScript (`frontend/`) +- Vite-built single-page application styled with Material UI to resemble the + Azure Portal. +- Served in production by nginx, which also proxies `/api` calls to the backend. + +### 2. Application tier — FastAPI (`backend/`) +- Python 3.11 service run with uvicorn on port 8000. +- Exposes the REST API (health today; migration/evaluation/reporting later). +- CORS enabled for local development. +- Configuration is environment-driven via `pydantic-settings` (see `backend/config.py`). + +### 3. Data tier — PostgreSQL +- `postgres:15-alpine`, provisioned by Docker Compose with a named volume for + persistence. + +## Migration engine location + +The original, battle-tested migration engine is preserved **unchanged** at +`backend/migration/`: + +- `backend/migration/build/` — SQL-first, multi-engine framework and the + CSV → PostgreSQL loader/validator pipeline. +- `backend/migration/evals/` — data-driven black-box scenarios and the runner. +- `backend/migration/infra/` — containerization and Azure Terraform IaC. +- `backend/migration/tests/` — SQL assertions and Python test modules. + +The FastAPI backend (`backend/api/`, `backend/services/`) **wraps and +orchestrates** this engine rather than reimplementing its logic. + +## Key design decisions + +- **Wrap, don't rewrite.** The proven CSV → PostgreSQL engine is retained as-is; + new value is added around it. +- **Monorepo layout.** Frontend, backend, engine, docs, and infra live in one + repository for atomic changes and simple onboarding. +- **Container-first.** The whole stack (React + FastAPI + PostgreSQL) is + reproducible via a single `docker compose up`. +- **Config via environment.** No secrets in code; all runtime config flows + through environment variables / `.env`. + +See the `docs/ADR/` directory for detailed Architecture Decision Records. diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md new file mode 100644 index 0000000..a1562be --- /dev/null +++ b/docs/DeveloperGuide.md @@ -0,0 +1,85 @@ +# MEP Developer Guide + +How to run, develop, and test the Migration Evaluation Platform locally. + +## Prerequisites + +- **Docker** and **Docker Compose** (v2, `docker compose`) +- For running the tiers outside containers: + - **Python 3.11+** + - **Node.js 20+** + +## Environment setup + +1. Copy the example environment file and adjust if needed: + + ```bash + cp .env.example .env + ``` + + | Variable | Default | Purpose | + | ------------------- | -------------------------------------------------------- | ------------------------------- | + | `DATABASE_URL` | `postgresql://mep_user:mep_password@db:5432/mep_db` | Backend → PostgreSQL connection | + | `APP_ENV` | `development` | Runtime environment | + | `DEBUG` | `true` | Verbose logging | + | `POSTGRES_DB` | `mep_db` | Database name | + | `POSTGRES_USER` | `mep_user` | Database user | + | `POSTGRES_PASSWORD` | `mep_password` | Database password | + +## Run locally (recommended — Docker Compose) + +```bash +docker compose up --build +``` + +Services: + +- Frontend → http://localhost:3000 +- Backend → http://localhost:8000 (docs at http://localhost:8000/docs) +- Postgres → localhost:5432 + +Stop with `Ctrl+C`, or `docker compose down` (add `-v` to also drop the DB volume). + +## Run tiers individually (optional) + +### Backend + +```bash +cd backend +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +## Running tests + +### Backend (pytest) + +```bash +cd backend +pytest tests/ -v +``` + +### Frontend build check + +```bash +cd frontend +npm run build +``` + +## Branch strategy + +- `main` — production-ready, protected. Releases are cut from here. +- `develop` — integration branch for completed features. +- `feature/*` — one branch per feature/fix, branched from `develop`, + merged back via pull request. + +CI (GitHub Actions, `.github/workflows/ci.yml`) runs backend tests, the +frontend build, and a Docker build on every push/PR to `main` and `develop`. diff --git a/frontend/.abacus.donotdelete b/frontend/.abacus.donotdelete new file mode 100644 index 0000000..204d229 --- /dev/null +++ b/frontend/.abacus.donotdelete @@ -0,0 +1 @@ +gAAAAABqVijPkE_IN3BxmFw0_JWUt9q5GwApRWu1r_S-g3aguTH0N3YF8lRCZZ_u1LRqSBVbvVsjCfj_m-gDJwtj77nnitPftbzWJKc4YcM0-V1VH2OL9XuU26F_NwhJgJ-KycglH2aI1VLtA9VpFZaceO5kWeqaTZU2XMGbI6e5wywbFmRY9UkE5IsA6dPa4M4NpGLaTsYjFkQoNDc5FpHA7-CAETtd12lVuvczEcry-m0epLvE_NPSDw9FsEmWVtxKptnm2ER0cJ-nrX6a0g75TbXApbzI0VB_ecp4Hjrwiibbkk1rwq_X49nVEvwCXJJhZzeEAz549eHWL6viOtPWZwWSuVxlE3nf-hlclyB9thYHkFglR9_Lx_D08s1ukgkyXLwnWnl10AXI3yU35YEGv8JBeIcm8FVXB5PZQ9QuxYfht48qU-v2eaYEwMtw5zq1zUaSb4plRX5FVutZy1R_mgTjXqGruw2NXwUr2EzZK5r-cTMp05g1pumxlB0W_OMZgHUMeZz3diCKHVypFndoX0sIldo_XHDOo0lXoxiRkLfvqh3q7o4QqKzzl81KCsAo0BCoeaNYgv3YSiVGS_CB14yroi4MVUCPiS6Lj5LMceIsER2ha9gJXk26P06PyhZhRRsp8sZLbgkDKI0KctvM1g4VUMJsKIJzC8blQadfxfS0tJ5IBvUgj6HIYEW8TI8FMaRm9VVF-g64xaBvjhJEC6yaQfBQqfiD853j72ZXN7Bieu256UGr8x41o_T9It17B3aJLUIgFbKQwbxRVxMJ4prR5XIai_ITqorhg952M_i5hOytMQSSsJhEUR5rrF9BE3GsUK5fAFC625eclsBWCHJdF-qPnKCQ3lu2rKO8KcGHU8DC-AO_jei-gFp5MbWUJUPXwqbl591CZixj7lz-5k6ZUcVjBwyiyVgtXpB4eKzQrMV_hPN6MkTsdQL4aSVyP84-QcBtwuh3YOjFmclmC55OUimdjBEXelrekZ_07bDhrYDA-F4vMpNldJQf7rW4UB988LN8QBZ0G-4pZVVjqt9_DkloO38qJyEXgA8y3m27h_2aIof7GnnK4QHbAafKbGHFoUpzmOgCmjgks339R2WizMp5Pgm3nsHRZRgTjKtKDop8YEo7QtSjJIrJ9-n-DW0ru3jpZOWCw_6IipRld_NJTRgg_h7ahLmGW21siIqToSXUotW2RLMoY4fNGZ5EBFoeFGPPR1GJDVH0DUCqv_0I5rGARGb-js91ytqgV3UFrbaGJQYW-LgAVeSdVnWY_UX8WUR_M3MWUnF-hEeoets08lIsFW4mJSx6xgAdySVa3ViBloiUDp4mTT17pngyO9l_W3gz-DMJOih7KzooaQfkbPRTxOiSSg5-OdTKyNy0XJFv3Ex_03nf8VUT3SKJZPe8YTmor89Njng1qHQFbBwlVU6PqNOgqQEWy6RHYNg_kEiFE87RFcrvJyoS_qcd6-uZaqkHKP5qhXq105UOv02oeV3d25ZRkuuRhji8BWG3yTFERxKXK2Y-OXIccJRobRoqS_4-JMpRFtHhZ0COcdaTML7iRHMu0SsW25TgP-6-h4g2sO9dSfu72jyzbtNU764W84NVDXscSbFbg2tFQgqU3SKCfm3WPMg8sCqlUAzwfgJuLw2NBJ1y9UPsUKe6FG0bmdDR8-GPbPjCuIhXKyF1ciQsUJL4Y687gp9jzv5L6z3wMwYmUExdA-sYEJxwnSMspH_-sxyPy5HeshUoa8oguMG1Gwh5Ej95bWznG82xIz7IPrvc_hkylAI6XqtdMUOzcBlRvfLwhtRHSUisfne-aTTfb96Dax4gvddhcrQfN8LVeWM4LaZ68DgeowbviGDICfFlgouCOm-F2PNOAOefxeoFN8ogQPsCOz7OBZYKP5vLj5vDRoqoLSrI3UiXmfRlE6PCuKxsnZTUJfwsiOG0o_NZJLT-eQsStEWxOlBHmWMazSG2cnrpseIvDWgdjNiGS1esx7iV0pCJ0774Xj5D4uM8kSNQloy4DREhN9YZWOXbq39D03fs-uNV-6pcyA-HQPhVJENICsNHjJ9gCrhr9whVC4vDzTbS7DuxkJ1Xe7SqiJTh5FdAs4WmX0zzGvKliuYN70kdeb7QLbUsywErm1c68U72vrHiQy0ghzK2Ul7P_FagqEHBs_CRCBpQSSU00oPKxfze5s2vNEVmMSZ1KgLuOol5OJ33IV_dAQ6otcDqvfEpgTNqTASgme0TyHazPC-vhnI7l63Nx5OU_tUy9QM5lXU_LZf6FjHYeAvfKKUjKD2eAK4PGyKo7LgEqtl4ZzV_4qPMHwG8zMObRFNz17Z6Tx0qGAWW9uKIXoXfHaspNdMSLmFx5akPiLKj95ZrGyJYuy8vwYy10fRYPqkmTw1Z5cQRSJZiR0coKiaDTRsGOdlhQKFrD-ODEbCt78NQj8XGCwQAyVgD0hkKtG85FBGhYYrd0byhynldZ3Zvzy5wDwnPsCIzXTkGb0ql2-slWbwiaBhqe4I4b7YFplSwhNf04UkW-L1xYmayKIdvp311dZ7kqoTD1eVc-F70I_r9EU-Ec048NTsV4GvKUAiF4Y0R98YbgsT6yYJ-FEmdcxCt1yN275p1rJxtAYvAXjfxrzWcc2N3lpUq0eLD2zlWq-YbqEh8yO1eMiXbJ5j3A5h-pSjnn6dznrYhP7g-QbRyrbJhhobsoXxMnfeMq5JEbpw1gOgPWLYSf4r1y7buXgMa5SyHgIeTYynJ8vdwDeFciPLJTmjHF26PzVu_IpkUgwy-18-ob_QOI-4r8nruTJWIBroX8j_tM-qdedHterOOHAao_cTZUtN0ce0Os347JwOCXOL0uuz1DIi4fVB4-SsJRR9JKpPkyqRnkYPoYf5sM3nUzE8WkccuthO0DXzfS3AbbQ6hOFZfjdBHf4HTQMlNhsQe-p5S4HKO9m-PKKyOXVNHLV05c8Nm3j7CMZaEHSpFBpUzxpKoL5ZlcW7wpZesZoGHpubsaa8UWtHa-JOO950bK6e3tVnVBzwFr8kgJqO6NOpPNPicVkGkgoeZgxe5XAVveO8d2hCFhhMOPRnKxyVeqdl9kugKcmapF5M3zryxW5zNWz61AaLAwWr-WYLJSlDStuFhyPSmfnaCnkbbj8e1KuqY7JxQw98YQpUdAA8bK5c5gaKWYHAM7iOQSDUv2DGMiRRS5t0NUzWJ1Q535AEsS1xtvclSBubTLbdhabPdZAETk1oE8SvQdd0VqIjStYVgdB_OfWdej8lG2IEk5sPRYLhCIjkdmfKA0ySZnIWCx2qTrSCsre0awYmhC-ye3ENxBVD2fLrqxLyZ7lRFh_H4YcWjhKiQtLEAluoo-dGqpy-r-OXbi79Pu7pGLgpY6kBEHFvuBMb10TcPx2lMqjUy7hIeeIoGv5S9WOs-oZB90uJP5PvhgaQzrmgDAmXB4km6R9DULtL2_BkwuG8CbAbsJspZsBivyy-WT_c_nOb6Zvr9ZkKSIEJKDVJwkLKRES7FwEq0I8MC0YFUu2qSKDs-1_XQ3HJF-ySi4ZpIk500f3mVARVEo_3eyINbx5E2hb9HQ6LyjBkhps6pjOsutzFJMr2klklx7v4Zs8TVuXXzoqI9tGeK9xU4Inwwk8B_MGfImyAhiruwaRI_RvXgDJJez8oY2Qwnv7r_jgWXBWqbBeUo8j16By9sPBsyIM-6UhC_OjJ2RYjJkzYwXSDm9tiwRf_Akvon5WF-tpKCoagS08RjszsRNXa_MYD139oGBj4BqWswMSpikBrShzNqyJvAZrDT5pU4RQsMMqJo6vGQzownASSNhxxT1oPF2zkYWtbATspn2vO7YRC3fWxRUKprUvrY9k1Kcqub4gvnBuMC4JkbmcCkbhBVWJv-PUG86QQBKkNyl4eTpsGq3myhsU2b3NWDVNt-O6tmTZ9MHMdn-kJlK4NjT8LPstpXEwm0f6bk5dNNrNu2aHSmDHdVRgfkvWKicYrDyfQu5VaZM8iwbYnB_BjzLV8IzyKdhNadaS1aM22aKPoN1YzXC75Bx6V7Jc3HlP8SZhtfLwZUNfuj58HTWkv-vRKUDzqrt5KX9Y3cBks1bVgRUoIR6C7H-FLGu7pXYvLGE7E048AtHJJbROU0PV4GoAsNmd5Uw55CWhU640p6l17sHzXw3nIaeGhciDO3O4lLM_Clb8e3Vv2Wr1LhS9_A5kqiz7G3Gtumo1MnEXPg7v38-Myb6mqE0Kn-MXbwSP2ozUSooxa_1tv5nBIe4kI7j3ewMF11BAOxwG9o_BlQiOKlW0Gpnr_mkSy6fEazK3oxsGh4_mhOmI1zcGSXMLpz0bCYjalhsC9mfX6kH4lyl_dcCyeYWY4Zna1aWe0Csq0bv8biEpYIe-EMvuLv8m38hvLRN_CH9ToKgxQcvTIFvB5fPtijDb7PdexSNS8ymQwv_MMhgReAtPThv8Kn6hSEkh75unOXA9kvoivVw34NPy7nwpGTPyM_SaMX1o8fnCy9GVnj9AhsGocnyA8LI7euiWiDM9yMcM0ZPEpnbQwT5QDcLTkoyQeQCNgjbKF-nQaXGEJ1pktBFV_UKZmYy6R283jcs_ei3ORB6pBIC3mLaZBYS9MVZcf4TcWrzypo51FDLm0nfk9MfpAb-U2DgBmmOZ7hSALVYLPsPsRIFbbb0ZZqff8MAt2DW15iRkMcRWNbeNJY8Dllw-2cdJvTEIC6-iHyStfxJtcMYdW-nHlXTPmpRw509lZZaXkuxcg_snZKCj_AkuX0TTKKgI8TtYagaHOvWeT7Dy5CEmYNSJTob5ZJRPSjBLuMmRs_hJDd9rluRZJOLsbOQcmWG_kI5ht-KpFJAitdhkg8Xt_MEj3A3p3OysJBvJZKGfMRDY-piMM6QPlOHtWNUpZJcajAXeqvHGJUrSS5MxrC06ts6uwDX9YHyngbvNUh-0evT77dlp2HMB5aUhSvR9jpps-Y56ed84jZOlbVmtTvFaGtSv935S9LBL6kaYo8NO_vAasV5vZWEI6InTUklILFJG-qpBKJ6sdCl1FCavr_Cq0j_jWxOv5w7Shn5e0epMwnxNmdTy5EsdaVJbBCLJ2pqq1BCCr6D5h_x0o1Poo9GWsOOf4T3MxzXQQc1lsMWj-7xSaqrOEJjlQZP8zggUKgkYqilXb8Aqhpd1ynWMNwSIKFCL-pjIIa8Hoc6FuBwSZEGh_PwLPWWcF6jPncQhwAd0wPUxoi0Y7c_cmIw5xI6fBzHqqXvSF1r4GTVJtxZI9btbnAwnthyOUrWGyHglx9yQhboN9kFCRgchTHgKJoJoPsdNJoWLXK3yd0BECoslnPMx15JMbwx0pZ5tWsVLAJSgql-Rd0gHcNzjUWVQflDNCeXlqM3uzoTDjeoNgSiV3tm6ZDPA0tnS67FR-OEXkYW-qeCilS_e9GWyUf19f-sx3k7quYrmZJJLzYwsV28saO1SZMErJnLKeu0_ChfttLkGEqpOBJvE2BP78Ts12jbm1V_rqTMLjBYC3KXEeSuJgnEKPCI0TujcrkzDqez94GjV52jHnNYP3allsOYVE0fgMGglSfPPajdNAPk7Vjs02SD9oi_erBXne96gEM0FihB51j5ywbBRbpNq5L9KDLRm1E-hunhELZUxq2rl3PFF37ZAvXlxsdva9aNiPWBxH9jMS71wdu_6Sm7d8Ak8xLFWd4Cz72jLTghQNjaXnLIBbh7NTmmEv8aaa3y9q0Xbk0nSDzG-k4EqaSVMqhSc2_C2zZ4qvzafKUPayL6iD3VywVtI14-d9IMv-lJT4pOMFit0FOT93Kj1kkymd4brBgk2Qwxv2EnJTqHusTVpVJcSpxT41dV3vpqeM0-u6hMdnjgSv5DT985xUnX_NHKml8aFYQPaa5JfBMvzBSH_haJBXnZHbVt97uVPJJAfWnb3mttuCNr4luh2qw3ceNjBTrNqIIBWM5O35AyHuWob83XqrwwQtdoSrQYF1YKI7hBSgmo_vygQ-HZDw1quf-f3DK83u7ZfoD6wcHbeTpr3bVoplN2g0hldhViHaAPHpjBAcoyRF98onDsVO8V1asxvL33Di4OaCfwFJVfDwVplCNKnUXoZgjhPXEBamY3fDxDnzC3onvHxNStti4UwfLEFCTFBZuoufa0Tro7G7AGe8sk6042mOFeC-0igNkQgvjyax7FZej2itZsitVGBDldm_ai4uXMXeuxECMOvhw8KGgE4hIELGKvinuSpZs7voc4gtAYAitPgarjs0WXI3t6mAUcvmnPkBEGP5DdadgOE2cvs089_mvXzaTqt-qEkyd4-rc3Ac6sUBP-2rk_7HUxEpxF33kN4ACQPigZsZRwt9DvSM_SZ22n5o3tDZeDDJdEqhIbSwh4oB7VFgB939mTCbpPxhSpdngwGcpf2zqtyp0u07DrLCKaiCK9SvY7WpnK0Ei9FP_ZU6HnAW2h8sg-cNBI8kJdK1Buh7onbF6yx-cdHd4Pfj7iS4VIGwfKZuHSVEbcCO8I27Zil9ANKALVjcGkC49x45E1VciE6CexI7h08GE9d6pt1S-FGO_miA1SXFI6zyhlMg2lh189vCMbIUdaNWFF3pRAgqcNbIgH8SI2gP1gNOQS93SQXM29Xdkubx22L-xQR3ptAH5KG7AwvKCFNrxQDQBAMDZk26l9X4YFlj9qSxUn52bRECNREBIZ8k7rTI6b0wXgEwSl-g3-ep6j_gxwoRekrdlyoHEWTWthQL_JYMBGJBY96-PAJn6Z3NM_jT3NomrMomtjYvNzRaIEo_KvLpvV7fvycEAOsfevx7vHFX5joXKFx_LBjzjJ9A7fl7PgCPxuvq3b55yiuVxLZ9w1i0yR-MkU0UareLOoLnxTf0RCUm_dOnbQ3Hq5wUnlIUsCO4Y125czqjQTat3a_waEh3yjDix4asMUij-7sCpkRT0vYN1-WA7l4yy1S4P8Yr5JcV61Qf6zUjQWII64BcLJu3IDIhY9V17eQ5Z4C3NRlvGXgqK4qR3bf_BcJgZtfiFGoLyd6zovQccqKafyxclX6Acgq5a9CiV-1GGTZNj5nO78KUqbQLEprt1jKgkg5d3ukC9jqUiwhIY0yT1Zo5QxfU8rLJyg6Rj-MZyZW7F6-1Gl61eIcfV4dTLZZaP1kl6fNQ9mTXJvg3eCrpOsLwtOt2V-KW6JvAxy8LiWSVyEpkKIMnT70BvIzvTkEzgxzLbw3VkjMN8dDvJ8zsEeBJX2sZvlYRA-6G57gJZ5W5jSyhXvYHeYUsVDWvIjA7mtM04tieIW_8-65k3sD1sDeiW7H_AXipNNMkC_yluLN1Q_uVebXw1W9e3iiAdpgixVsad5CBD1wT4LtmM5Tc1kqEhRjzUZyuGd5vEajNovEWg2TP6TpO_GUgxuWqaLy6E5RtI0dRIWYqbfGCJbF8M5vdaFhiDMRH0kF1aIIrvCrC5U3GFz1uydYzTZDXw0h0eWrSWSFeO0kovG_4F1p2RA6nO6l1b4B-DN17cMWWpxUbd06VBqL6yMLm-IQ2WBlICxDTupEjaSXjpOuDSuYEVhu188wVoEf7GBIsE3yt5I_sk7I1vW0Pa_bzR4ouiKOqDqLP_blwHDoPl0JyRzmKLRVQhJ-OOixon3Vhx2bQ9IgVrKhJ8Kva3dEdyaVGvtNoRVfqikPgeZ6Y5Xc8ftWinVRFfIJXSklI7WlguqNPYlvbCVTAlnBCatY_r_5CJPjJ5iJLnir5WFFO3CKxMBswpa6rmQyfnsXv7UMyx7cXCSjUZO4XBHntWawvFCI1hvWUWu3X2mmyFNZI8U9vLGr1Hnf1Mm9Hw_WqzzPRO5O41fKFyzXdfbpQVugKB5xHl4jgMEDyB0xfsxUIb5HBXSPjsSeXyc_8nCVjNnXlMfKLejcxpy3718I9ibl0f8HquAn8KEA38JfbySui7zhissmNS172SAvkOhe_NMGb_VKlKHUvxLGVgTCFjQBe4RlcysQn1YvJQuWbLkjlAGaYN6Q16pTZ3p3W1vF-Df3BZUYN0AZeOtVlh2u04ZvUWPIu1mNxIdFBoUxhVI1eLCx6Ngu9jd6Cny9lf56nnE-12ovd_7kRFQJyhwT9NN8q0H14VFTSqtN_8SLmy0bpYvyKpfNSmc0BeXIctKbWgpHEG5gC7ubNkIPUXWmwtw1EMo0Vvf6LwwgOOzl2VDNW0JJkm3EzEXEtESca8MWNX41kX10RIMW2e0-Zxsrj4ZjR8ESHPjZURcmXeO6RRSAIOn4Pad2jOpzZYvPn-wBefGwyqfUO14PlGkhqKRSVB8O3xA1cr5O8oKzJSkwAXOk79Icd3gujsBSgNF7Wm5kNUgFSYp2HLb54wNAwJO8-kFNY0CE9QZyceU8TLYKZbbnXZi7da_t1VZNTYBmLs_bEaFRGn4b9ISUSK1Dc7CEKlL1p22CbdfHmdsPnLIvvnz0SOxQO_7qAzNWGO5qxK6c_h2c7-AZsm7gOQjZ9s79OVSPA_o1GkkGhMV9lwQk-aomIZxFO_f54s3J-7TLYMN8C3JfYoH4xoQTjxIEykzGxCFovDsGouhcSSKBgl5WJx8Dpk9BuYek-EmTg3H1UWVFA6VUnBJqjNz_wcxA_g-tzbB6NxjqEVqpPP93xiUNB4Z6SoGcEhSxA1B5AhooB1Xk41gFFhjfitdXqGKiR3svqRmaxZ4A20ol-rq2zH-ZzBUU0kKPIq_g_kdgAyDdhzR6otSaR1uy_xMW8HMCWX8BXnQRXXj8R9onwJq8od3486kBYYwkabaazLYrzkyVBW-xnauE8XrpwcBupYDYs03KE2C3j-JYlA2LoVmCY2ZZHXkCRa5d0CD7ZPa1UQIPspKai3Fw_imrj4QaJI1HUkV8vFZVnN44XRnZ_n7PQ9V312iYN6cw3S5-Rl3DJol97e3180PBe7dp_vFLOGrmivgt2935-9KzNJ-J8xN0rQrNtK_HDCZF5Rb0qAgsTcedOobtkkzeL6QxXnknU4WEcAAJr4_REm8nWymPj1N5NdGmphmyZ1OLV4O3r9l5S1831lE5SxpzYi6iORYc9Lg4C-hLKx0L41fqBxJTmd2joQOCa_xjo9-wIv4_m6COMjtNDtgR4yaHaJhIhVPEIKc7TxumsFxBShMVwExCupBsL_6ZpPuC_YJu2oihMaYSHvj1WRvzrmmlol9DpJePQwH5VzMPRDIjOu90Vj35pckPhwClkKY6gKS5F_k4o5g8h4BoyciwuqqW3hx-1PQBwYuG6bV0V7dtFyTbSM4sqxy7mZtPM-q0w3LpqDekMga6iqPSuhU5F3lfoFi1iuIjd-PlQO-iKvhw2FLtTZ1bXgvvUKrn0SzI1gmQtPSPz6IzYwFe4d7TvU6SSp3T1N3ovllo5B0nvK9iMqmQiOL1qD3Rxe6aRaRcTsdITyLtMN9Hm9oh9IVX2LHZRxYRTe2xoJTHrrnKNNDvuShHXQ9I0KlgrEeat-eeKIKuKkus52xLG_UE4fG91IBO5_YXbskXb23TSRICWbxn4RWScbUSISWsTq53bHzn2dZ70Gj2nrG05zgrUXGMS1CWceVIC3JEtN6t6NJM6b6E_ppoLejBeuWJMd4732G8MlTpaFy90HtBL_HKuRqACYBLMAU0rgaSCB9ajsaGOA-qAflWfspJalQwNt-Mvz3dEHaHTjqR8kQPDAVLWVlEMOZDw1MLFGfAh5ObdU1plH1KGNfcRi8e8sv1PlFFXQzwg8MJ3K7td2I6YIfrzkb7KN4e-dqwYKRg617K0yM6o_5S0rAsXmBApFJSU5pGBpt9bRIe_r49dh9WUyb21I1IFHe2XJqKNkK14YY50XeZwiTlNLan5Uy-wjWTTzzzfSYsAgsyXb37liWRZToydNjmIVBIH685bJD22xcslAKzlPUQ9IQZpxPAEwLz5oPZO7yaZN6Jp6qaqrDj5XyHDDngEzXqwfvbwf1AtQJRCv-sSHsEWscP5YCC83rjrsWpxX3iOJcPKPPuthqiD30SgCb9mOgYu7ehqqZ1JeiYM8HFATooMTYFzInG05Mjr-9NEzIm6x3ieS32EJChUcZu9DW8axHw6T3EM1BdDTkDe2zrYD_acNblECAVn-5XgNwlNWxOZZPl6U0XbP1KHYcVWJ9obTBkn3HnXOdkj3Apx8qeFbTUHh9Qj9wnc3elM04xfZ0JNtogyelZBIHIA6hLl7ds5O5SJsEHuKIZT2G1i10laoKzSHu5IRExfoFpvYW6VobUEWqngqSJw-Mt9YzoSrQJ3o_QEX6ZH9Ps763nyts2dVK0oDQRNNnT3VrSm3R-OBjH119yeXCvmPwOkupy3s8eLdBoMzfakyTGA55pqpQK_Abfl0xi1Y_UbzwZdnjTet598ODitAtO49exoOvpGI3Tp9AVCk1daqWI2L2j1sdKCXGq4VqCRcWOLeQy8MBSMQjSfvhFiFc5yKK2rpeh-vOaRG0J6Il-kH_JVRLcELzTi31nO40TVfqmAEcGYzCPwzo3JD0Kzbg4SNtgh_Vh7Gu2YptPNAaciDl1kojuanDzvKDi3Malytn8-YKPKkNMItII-relEdfI2bB8Vgdl8up50qT9gyyROWM11W4_LofiDwlx0lof54QN42WI7C51l0-Rl0x8WuDKzQiqRdP5DGUjlk1xe9KpqWKIDmEiLsZMbByb2UshdG_QwGGrXNtw2jdsvXJvpWKvNiq9ChOch5USkoLGH-M1yGAP2AVVrgNxbgHX4oH2sZUinA7NhG3wxBelUKPTs2m7RYys7SArROZ8mPqskkJPuPPtiTEGu4s8dXvvFPMcBaEgtAvSqs2ZyhfaS4JACAObNXZAcVgOWY4SSAhAFESRu6snZLI97M0bu4PudqZU_QJmqO1wfl0lhDauDOCOZn9dYn5QrAr3ywfRxCv1_Au4fI357I2atGUUI9YexYq13fMADH4fOJY_udhKH80bH2IYbwodiC7-iUtlZ8IqRmxi1RjDvtI7GumnlVH1DOEjy7l1h5qvCA_4SMi1cavwjaiTclMsUKX30OZcXh_U4tJsw6TxaU-j9YbQQQIBgP7I0PKq36fw57-HDcDTBLOPVkJZNb0_zF1cOd6KOknFJ0PaU8NQRaL9b9X1BgbSLFnYs6YjFCtMzhRw8qp10Si1txUizzIoPsceM2JLxHVbP_5Eu8QINH3GSzYvGJMoIKrEBTeohzwdeLF9sKn_jlUdA0JZP0CqdMCxzMs-czd3tEJefhcLSc12C50EDbF5owwF5X7AR8hUoUXINlRJCFs5Gw-yzaNBb90_e4-UnIJCDLiLAOvJE8zSkhQRONnNXufdf6UN5M_G3uCKUXDtMOMHOKn0wrogtZ6lhffNzU3kdOdJONAL36jyIPz2WUHCr3QhBggO-ad972_EVqSIow_HI6yajzFJ961-DiWlbZtGfOajIck8fYH3xZPNImv6kASd6MLlbcU8ReyAYreW2N0QcJmloKypJF1qz5TM5-YgnSd90vUrSUZzzRu0rXtS2F4o6oaO0h4oPnnkefXJHDpJHfNt4vNlpFjzxUVDeYt6n0h69YDcSQTEL0WuUvr_XHf_56Kit0sQ91sIAxxb4IOj1i6_4KUPICLJig5gJcKr83sk-EiOMmF7AQ4iIjTPoiXv7SF_ITGkSWZGwGMCBPEn3e51j-f7MOOgLK_jkzNAeRRzPsJW5sXCAsB-Ll5qOeZ4QbuSi1ppC0eryaKzA3s7B7O4GXfLAacNMp0WoX1bQUP61GUJPCMLi9dsUJ0_U7Zly7KXwqeKQcYgLpGeYWN9KvRV00BIbFQOEdfKR_Jqb8n-rVYkhg9Xb4gOcaw4QhN5hueeTHTt8lYhZdMnl6NwDYgs1pAt2lMjMgK1HhXLMhltyXwayVj4WkOQBs-yqegU4qvqOtsvpymPsW-2DwKpzJMyN3xTZFqIYDUSOalMwYpneqnSdyhwqQJXKB8o-zw2yjHKvcvYkmOHh2Cmq3kazTnK4KCtr0_EEJzHPbDVfms9qYh60ipHU_zLGkMSwnDYRpO7Fo-hMKJWvf8bCwGlpmCWPjq618yF7gyWI9wuUNI1lLfN2eFGwLZ6YQwv8jA-qeFEX3ZwEczChrjdtfcMvc_rCLSMRY6fCdtb6tYzaemwsCuVPz5OWf2--uAnmfv71oG6RpmL4ZpvgKsKAV_XsvflHavFPjW0F-Gl0iZzVKaJj9UzBXUvFw8A-cTIttdUzB6N8HKAmWFI9lq1PEVHaOTdv8kcSzh2vOgMnl_M6fopj8AsERb2bF2ywFIwxasvPlC4yWNyVMcp7yDTJpjCowg3yECe_Ud3msEkny8ecViFH8WS1CxoR6BTgNWnE5K-p4xpWrXLH6AnJC1u0PijblfT_hFcIRoiTA4u8MdEoqRg_-i_XJzzJRIrTLiEo1tU6mDtr2KX-w62na9tkOGR12m34ciN9tKetdZxi8wlWUIjjx0oCEKm5jxF5VgM4carLZtZBARPifTVZmJxJ2anz4Yd9k2X_TYg57Z_H_KuA-EPC3lxMK2U41du_EPoe-16oyk1PNpO-tMDLx_InQLiyt_EpSmwZxrOP9rCqYHNNcT32Z_Kkdiz_V0-SD1xCl_V36nm5TX_5sWMQivVI0GBlNTbF2Y-ULxRTolYd_Douvzda3j3r8zJgMTuZgHUwcXz7JmjL9LZ52k1hMN1uvLhuOYLQS8RQukkXKgkfmU21WTq733SFDW9YriwjFP_VZdI_Yp_uQ-KxlVBMO38BxCCqxDcsUZ_BmJr0MU1Cv8nwy4UrZyxW_EthCK-CxKF-HhJ9IoIl_mwRKnnQ8PXznbrjLP1YzcAJIwbw5JsXJgkQXIhLP5wCUuK75cJ98WDBWbvtz7RXSXXM8eK-f00VyQ3s-mxhTeZXeholKRpQwAbbgEjCqw5stxQIz-JcexI-SavH9z53Zc1d4AKg1ti-yGapBlJZ4vmK2Pdi7qmgPS_iRRvsA9teMEcW22kdJ3CHDFp1J4gEVqLj-AZ0AMral7IVGBedYzPr2nlJygyLZkT0MKU9rn2kKsyRO9Jk3Qs8P7dylXdbdz15-_vSx-8NKD9eAlVtxy92jM6zixetRCu4Wb5cOjT-Ryhdt01QH3Uyo6GYHdEasL93vBgEEO97BI3FxtCapgE43-hBbAquTe-yJfGy46T0uU_iicRHeU7C8ptIWEHzOtR5PrI4WAwYpHqGf5dif-E2VK08ay5rsy07XOdqXZCDDOkjIgd2rra52Qlk8Ix_d_Fui-kOrAFwDBACed04ZysoCjx66qR9sjIHFMOJ6iQWmkH-qmQFE9ZVVdKPyt_WV_MFy3Tg5GjvMC1wTxMebcY1P6m-HyxE4YIuylG7re1PWXcqamriMpOrT9Mn8TtgSfzTGgDiFtN2WwyVw2KEJ2VkGP9ik6bc6ISHmPn8zpTbQizfNq14zNuBkzuPExYnKU0csBb87tCX7vvpzVf4-7ELK3saLTeIkWGt7P1VJSuPv_VK4oUJiE2y-eslp8h4ZgoWbrdIH2DjBbyQlqNziTzZdex3ADxMpklFki-lxH5aJrDx9Kk5XkNYp8TbIcHLmJp4Rm4vTCDhG_riWJ9laZIY10DU3RhirzJiXZvqg_xqvqtfVzvHooqSATQeuBtYA-1uAL4j_fBgWj-zWIX2mMB2HnjzGLWbKSt5FLkx8gNz8yj6Dt04gBo2Zy0MPJw9VB4rbj3sobK_haFIim85yhMiK_UmamcSXrqpDGbJH9sqW6utknpzLi94APArWv1SNqXIvNPPNgtUfAy-TWcDhFApwpCysSN6VQsCW38uQFe4U-R-_2rpTRZqSlkLFSQwwJKjo3VMh3VfbIZ6oHbxCG_elBCTaoGObdf5IaOk9SClN-mOoiVHZmN-n_qvwt3Iz9vU1vhdxrM-kvXdkKFrTf_89u7_JZ6YTGknViJUCfGHWZOMZ74r3axzE5ToGJwde5I0iVCGAUszaK0pz6tGp6CUigbjLUf_cFkvGYocHyQcujT0Qqi6kNvIWGn50Gj8CG7pgMMHOI5VEdT5q6-jNT0GB23GjY1yORPbrYkrgWyntjKdEOO3Shcrjax8zt0AyBKMz_k18ShGIlgKmbVgAFJpGd8U6uaN1By0avRypCsbLkToiKPVYXgI9JiGFtB_0GARLGmi7zXpKR3ryx7KJVwJCWq5fKakzq9dPwkV3_e8DavXnU4z8J-yNbVSidAxjbyDuq_diQn1gt-O321MFj6yXWptnwk-k_JXfHSFdmWw4O_wHs1OazuFQxUrK4eV2qvLLvXJWd-eKCvHwWwVVP3Hz4l7AAHxVb9mWTU_Zrxv_5GX3C_7V-Pak08O5r1Znay6JhXLk2ZvEmX0kc9_ybx6hwQxja-JWg_eeo6p6Ti1qIY-9vJKKP-fwx59u7yYYjvLAk15_YLdQ3UAyKzuFXuZSp_a4MYFSP3u90WjCCpDZz0wyU13yJcz-hqz1z2HsHk67-0HFHl0aDMqbzY99f3ZnFyLToVoSpGq5XIUFoxuSXnilFrdcKWc--llUSVaIaxv4EsxmNnjIZo7bl9DupNES7XmLonGZt7BTr1eDZVXpcCxK3ENnaVgPPZJz-HiNFPIjcyMJAuiN6oyhSTWUUS9NIFNqkEY7kJTg237zTK_oNMYe3KkSqnN_ZPokosKgvEjEJ3d5V-ivgrEJgHG9JUKnurKGIPMrqhf6uOPMqwl9J9QQ4_DsuczbB0UX-ol7ICTz1HhsBIVhNbvFswnn6HVBlNTRL7Y4UKSPbKDbyxq3C5XhRx7Erab1SrwrFz6ga0wj0zFh7JdLTCGlS6twTRol8B5-qrSKQurlr_voH96Go0Kydox_U4SD2PVBybA_8dNLUxoE3Sd00vhc5LgcMU_Ish02pmn8JnlRQTe8P-ihpmr7d-4vRq7ABrKpK-8D-TOvspns9eTqolQQJTwrQVgTL8k-wNfSIRTXGKN5Uq3UqrqqobyvCIfX6zG2ndN8EPO3X5HQFy0zyewRVolfDyy0i31TGK5WSkVe8ifQT1m_wY9Mnv5Xq55BCwxcW32HOAl9fOsvaHGvDlSDb4aAdJZA_ETwmhTltwYvkFEXXo_Zdd9yxUTMhUN0nNSXnirPcaGGb9IT6RSCUkeTSY-wk7PGHHFWW0RSojsklgS4Jdtq21C4v7PagbpA-bIX_5Rnpmzsjf-LPRcSBSL_7jpfJ9-DTXJgSvKBl7vyucIQ9FDLE909qgQ5mKcoeHMDwDF6aljdr2uQwJw0uLk8Ec9vuyEXMKOO895OtJmxJlHR9Cio_7uImKTG-wJq5LkNwGx-QrEBqYMff8zE68cKHO88eroZIT0gps8-0F2hSMx3c4l-r45eEiWdj96Or56QDCSYaYWSoTg-oEtj3Te47dEpbuvp_Q8808E8d5c5OXhI33qah4xM5FKIj_fWAeCxe5IcFNa_BGMMFgAdPeKkuCOXt4ixPpQ622gZeIu-F19apUkJDmL8hvrRb7WvWNZLkvWUcJ8wpTrJi_5G8LI7LIo_J8d3LHUIlCs_KukcInWYzTHdoKBBjxTc2QW0OkXb3xF-fGML18Et5RNUPJaXAQtsyqsUt0v_DidFLuiq2NADbJ7PDOzWQkeZQuLJnqhnLeeG7bNqJOO3BzjQFS67mMYJSpF2JWMCzZApWT5cAcwGJQ5wkQiIlS6jiojwUW3AktokNd_N5-Rpebr7HKaHIXaQ-5L5ZCbjPGG-uHaeBhL8A09f4qpQqkwK3EUOdcY5l8g-hc24-xZpxSX84zc74PQBySmeBGMv4nmskDXWzDfdnVUt4zSpj4BCyIzb1GpEkVLqmbI9u47MB8MjaIR6gLK7LzdzYCCeHCTUZEzYCEHIzdMu4s0flzhEBGtjUxua8WUSH5p-_OZW4p7YRe1RI-FFrjD3NBnPlLDnVZJ3OTjuXRWPSfD3xPZEzCBlPL58czrpBWzMllGv8oZUvFJ-oQMecyhY6W3DR7phpR9YPXGD2yKx0jGCAH41e62eT-uEWP0uV27b9LarclCiKV_vNBWOyHJtIbLu31RNbsxWCgLxAFXASXVdmRPN5y_aeH03CBrLSF8OXVI8tkn7KwdtmDcsmDqk-2Hd3PoF4GPoLoaBAZbfCZ6u0wuAwYUTJvEBmpIp14IEVI8ubskELN5Uqj6EG3gF9d0x3sEMGsUw0GunLCqf9paA1JtRY82DE4WRds5NeFE7_EKgr7AJ9yNZoTdIaoSX8fnv3lRIrxIXRvOjfoqTLQBpZJHMRbyiZ7-iq4DcYjUPaO9uGHKidZkcgzcDk35cg9sBME5w_VsacvapS45ppsNSIM53Y7kEX00YxgpQXbjW4jPrd-a_-bQYc1T63HtjWgv2QGJ1GkbzYDXyLCPhH3JPy747heGhDvjw4JXa0HVyVg2Ijt_sLYj-im1iIX-Fw6ntVHhTGRnEe_DXi6e3ZGPT5-mGB6iCwP80xsqlbLhEWPs4aPBPQ0f6N1VHS5AA3LDDsgNlyDe_CaAilzfQg8eES0WpFCDaJwe2obhBalZOJGuhsGnC2QL9mxI2KBeoJFBemffZwtej6ITRDALiyag_bWMz3ac3h-XuK7pndUPCYXtoiSlTtz_5D-ThaUBgnc8inNg__0D_wgV4ddI0l4wI1g7vxgcb8IA7yuCaaePkuxTa5cGrGzTZ9MZjaa3w0MKE936D124Z_5aj63p6djS1QcmrUSCgigWi0Y68eQL4bv5IVjGSl94q92KSgSp68DtzFWIkV-lWeTcDnEZnPOsSa4-NwIRCc6Zddjtn45WcKzbwneZbdmagNW24j9wbGbfEnT8vahu2fos_BFpx6G9C6nfJLwzuvsnNhy-Hb6oPev0fEy-Xve0dZR86Lyjt2kZH0GGfRa8LbzKY3HbpAYO_y41OjK2UlVgeZEz1D0J5qv2mz6JAaHALzcNIbLoaTPp_uPIgARP827w-VYqh1zKu5h0urvnO9XCW9epte6Eg2QxLVdN1Ad8lmEO9ZRhg5j02yMgQQZqm-NWOjECd-veJi9nCkfpL9y6oHVJa7dV4wlpEOh0Z1Vc-7ST1md31SKQq5xSgmnOHaNLygkiLJLpJbMW4wNFDF5k1r6PnyH4B9pLZMsywnFF-hYC0CO2g5urp_yOiGrzopV4Bf-rNeD-57uUz0TujuMDQAaDAKHfXdBBwKw5nb8sFkeNA1hyH63wDwCErv1Gjr7b0s1mNZ0mVMGs9HDW8S07leHuY-j1Xzfd_7fMc4Os9XDeejbMQND966Cy6Jl1_H3N6jNUAgjBOfQ2vwMsC_ZtF87w9tiur62WPfwvkrx6oGa6pnuYfOcRdvvoEIVibkOnspzDVcnwX3dXHKs23OEY0Pl0FtXF2quxzQtLXa74AhowCYykb2iAHtpQbpzbSZuKTbxoHoUJtYv068GcZ9JXETbflNNA_dBnSFLjQ1ldQdwdKfiE__lQlw8pHG8cTsT0hruvgz4BFdvnvNsVcnfHNKgj3LyExum3EVM8JyVC9TJFlo0RhQINRBeHAvC8k9M9koZAlsf7aXNfn7UAmK47YkXbG5gUQSFloNw7LJSAoHjJm8_iFRsv6PDkf_mn7mJ8Us7UyXu2NlyvnKaNuVEmOHHooPVeaaiT1il4XoF8nGbCebCQHzC26EhdcxM5YWzk32uIWOjlaM1n7B0x-5rfYJ5wdji8N6XMP2oQgcdfFW6rlccAwDdy3TvHiVECO1Q8nhjRJKhnHwYcUl_ElVkI2YUzvwYzPggtDeodIJzGrMiXxaLjB-MheON2s67Z5gg4MAFR16juhexdLFg7rSmI5sY4xjaoW5vvAdOR3v2O8HSQoSMphGHZG7lfx-JPaSu1OVvkogKjXcsJl9EILzuoG6NQRMH5ufUWv1Btu39oEZRBwmyC2AR_iXIUbnDmfaLlHE892bBUUFqOKeYVj5FNLISuP_T9CvDtfL2bG-qa_bQ9Fczu5JgCLzpvhbCHCsTh48_axUoHWuQlzt5T1T_xnh4Dt1qEPZFW12LIevo4g69W-_JwMe6r1hd-wT1NmbEHPdrqgzZFLYSgjPi_5iCXNLq0Vl_2rO3BRpw1Pp9fN78Tgxn68K82_J-NPyn58ZKL9WfklybfeWtQBMHw_qkWsEFl7QRwis2Uw8WpAnmato87uGYXf7Io4eLJ2gZQUh5g5eC8W7tZRE-pdgwYcS5yrfBUM-H5M0CI-2e1f73oI-of7V-S4KJiY9NwUV_GSL-18HixmVOf8r9CtuXmQXavfNWTXVFgbf3C7EwKDr2yoU7fhzDwokk0Plc2ddd1wA_EoooPEJvi7hj5FLaq8TP16ypO3Nq3wSU10v-ZOdyb257fAYjYSPx_Kc4IB5HqNfYBeChH2eUg672Vvb-eRpexmNJiHVtkePCQxxi2bhik1E709n3l7lKXEMYGlPKqPKHRxdbG2jIYpEth-aSJzZ2-sJ8WmWOM6NhtDqudc0FAId_sWCF-eXsLb3ZXgPBVST1KeMXhnxpdXV-fXgWsSzlJOo8NmY7OPjx6odbpfKM8WrDT8kZMdwYYPz1Vohz2LIpK774FiZyaq7OW1VIgvNsJ5nzmYJ5hgqkSgOq--4zfM6C0KG5hC9yIDLP1hLkSWk53-8d-QF6RC5lQCtq5cCEVYr1Jz8lAwEAlu0dS-zlp0oI5OX1m-un_Qb7JAJpO_Lu1VFkfR4YSxx4FgpPwHY72oiDjcRNXHEzLPpSYhLijlNBwC1evKm155e0xt8i2w7vBFkVdW3wF2GGUh9A69DYUiJgQr4-ESTne0pDTe51z6kMZXnjUozYfZLL_VzVtDKB6C9wYe1XafJ0qKiuyMV1LVtxQ8gsV8xV0rLP1DfGmAedjYnGyIPdf3IWS7jeXjr5_nq-GH3M-IHuRba0FrO-853eM4HKlCUuMvCmHenyZSrxTmfkA00DATA4pSoTQNqX0a1xDOZkMReymTI5Fu-G4H-Bi6KwLfjJbh0SJBJ3cTUiufdHPnrj21hNZtTlankniO3wAgc6iM4jDS_9-ITCLcCvnomb8_7NdJt01KR0EgOhpjNsnXuKXCjRkN1U8erJ5Ak5kt2yWzQvVt2je2yhD-_s2YeFvDPNbFBoz7c-3z719-gbgR_q1mIcvGCVCFB-xPRPfvQmT3Sj7V11qc5b4OW31JC8qcIiBD53UsUdx2i6RxWTnD_55F1CzSb2BpjiJYTTRTaU1BBF4ceVbajhAQHfH1jP2kxo0r3JBNIPgC-fKHdNX02I--0a_KAZKJTdgBTVSbeiSp4GV9b-zxYMJw2_5XDzSAYMYLqOR9V2L7mvGuJOReN2rr8Ox8X4kSin66ZN7kJMP6KoG0-fxWIiRazWvlfiMOFun6jS1OTovi1S0UM1xcCSH7fkICaFyg4PMHPr5ELrfgPhSpEMqCY6EJ9fhtnGO09xht3MOm6OgfKTy4Q7O3OTOx9R2SIPB5WPKBKwqynPZsUE9o0p6F0_RVnvEpRRvZ8UUIaXvcP1L4xE8DB0CXgeAS0PuF9O7g-9ZSJRBXgG3lXiwm5i_YvpYofFpwi1rwZ-IaZ2YGnLj5P3XgFJyMjbt0OwkMeHu-3cWU2BxND2Xsao4L_GBnSC6ijot8EIUl78xo-sTnGvU7M3wTI67b7jgFQXdU0aEamvpdGp_ejT1dKR7cGl-Pnj7Pl5E_8E6vJ5ltDopruprc5_k4CE028SeYd5cfCuWgxoDtzk7Ras5KAMZUkSxl_1vlc9ptENkkksMRAQISxlgYyj5-toa_-l_9E91MXBOGQ-REoJRrKpL-Hom-x0nfdWdtyuCCW-TG9OfoCPQUQnFtQl4oxz_xa5eshQ98bqnkzQLCQ5QuGG7oo_piRM2QIkuOeZSt20xCbtCkJkWagqkx-VTuyHqdj3vwOxGriGq0yG1t-qwlo5aomX0pCFhapyYm25sipjssSaC6SVUEF8EuImpnWLmZwdZKMeZAKZ-4r5_B0Us20E2wTUzcWZHD7NfZqsM7GQSN1SLh2bezV39QBlXZWf7WVYx0So2mJ_aVySy6S8rowBqZyzEagSkuTVOYqxkjHhHoWI6rHFi3E5ZwR5VjmI31Cl4VqJ1k8RwmijXlokVToOPYatNdy-MmUjtxb0qcoAKmofqThVVXnmV1SakRFDszBPLld9L4OPhrvAPmY3_83Ny_2v82Sl5qF0L2o4U7xTm44ZjC2cfrfryS4_wz_JqtQe7JokoE2X8pnHDqFdODPlfVbLE_iQL9K34c6xj_0h-k98KHaEKn_5Q2AjEbpnRzJQ-gw2vHWovWmHIe4xwv5jbZAo8-rt69M759oLpPbumpxvrgO6lFzNuZkDwtje_XDn3aIfZ4gvRy8EQf1digxw-sBZU9KD3oEs0cdzyOnIFRBNAQxKB2wfHbToO_WoCcqFB8QpwH5dTkEi9neX1n_D7noILXYj7wui2HlUPYxDujav_0TFS-xQ0zMPCviBKOh09pkFA912r0-6o2B_ZD4juzUHtBF1pU2_iXxxPsqdzsGK8zenapqvWfeiONZx5o-wK_PRORxWsQlfrjKSZ9JkNtQakhpwmzXJW5hmxD7_nCbdoDu1TgUt8Q3mL8KOTwvAURhDxBHnxnXRJ7M5HnE5SOoQTt-b8iOwexrAmInEoE4OW3X1VUSEfzMQi8-yhUQbKpF4NiJkche8OY033Ysdv1E8TvIRRmKCHSZpAb6yJWLQSvCcj-pol9n7CbpvK3LTZTX8-FwOd1Lp-P_d6Crd8XSx20LSi93wCNCuVGd-EvKczNNtlafxXJPjkpfuRMUkVQw2MtbGuupkETkCRDXI9kgGQvDenKhbe9kf42_dxgHBadY24q8VjA0T30stEMJgWtnYEaSNNYvfypdF7p8pTP4iSEe-kZbphndXjSygSRFC7TcdHJ8z5skHhNvgNqQgcsIHD97aKC8121lUcdNRjOicrw5EYsDtJmlNXyEiz9qlZu0DTo-1aDpVMOqc6S3RPL_ZmmFgb8porykLNDoJLLK--K-WmvRSLCN8oXEeaeMfUsRMdkI2CnuHgqWlCKW0Hc1B3x5cErHZgJuhTGlBapexzc8WON1ESerLwh-YYlyhfXvKKTslxFxXBRdVM-T4Lf7Rh5iMxfjR_kuaciWd-zdkZ_w0fldUnAa-CFC4qF-1iA50T5QyhcFQMG3OqjpxXOErhTbU1YFSd5YKh3QQV2let7iQgnJhyKRNnEqvkckUttkQv547j2tqEvQ-69Rnm_fOK7zU4cT0jAJAHX0FlqaDv2hu3iDU_WPIrPICvSW5UMYdPaipc2oTkbz_gukpTE1Qiroyqriv355rCyAnNQn__OpWz7X_zVAA2iFXdqzRVcOTLNh0HfccoN1bcdJ0WrYgUTza2srvdW72mbY5mNJZ7j8M4ah-lL4TZaT_PrqtYhFtAXPo43BUNuu6-I5-ciRtUURmVI20uMswLXXz8egnVuYFUn4k8zrvwlyLbjleg6SS2spoPeyapufM9DO1n-UoiJPU_emu6GjK1FINr0OTbhdOw-OnElrgw_gUe5Ox3PKi-dfEvOYwqya-ep0TUhdBSNzocgLn02BOthlmqoI1gTkEcnHx3-lwraH9eNk7EymCACm69g21I44QnEOzfxp9ldjJsq35oos4HPkeWB9XDy91FMGXsTveDONrJL5ZlwPXdhlQ0z0Cr4IS4XmNE6GTWqn-8agxxZJymFatggKM8DOfzEcBaKBa7xUwMIJNaLXgi3ebELI4PjBK7U3vnoLbHGgPmmjh6dTw1oiuw7ZPoJjEySXhxegsMhB_5k_yRF6NO0iy2tLbnYaclmfxwdUrXnFqeecI4VrE-ljgtN1097VWJe1M-DctoTJZp-gJQ2E71pvduwOc3iVDI0rI3HmIV12Bh-ycBecVxnBN2s6Ly7z6kxmUaB13Fnt6k22DwHBrqwwtM9fhPo8piAtqB2LOK93V8hkmj3suB7yHbHVYQAAvEQObGvYyJkUTO7Xi1QwPZfq0D67SklvOfdLbm-gCpmkr53iClaXn1aziBj9mIVcZYZ5Ip8qhrW46-zaJJ7HIlltmil7IPKws-oo68SmXe3Ter3rdq9Nhiuj-Xpqb3OzKDuR_BHqSzqRpb02AyYcUkE1lCThkJOQuHDvBCRYN341GEb98Y1dxk-A4Im0VWFn_RPiip_0aE2ox4-3MzdJz-tt9Pm3AtpcdFQULpPP1zzi35f_gRnAHv_3NdbEtDpr3mXZPu6vZiU1PLG1KkCsSFooztnPaohtBOpJdclDf_U_82sXFObwaaM7pdj1JL2EBh7zKu3ck_UMby0Vmwod9_MkGjqkdt6vdkeZnSOFuHj4jeuJ43UCPFJYrvZJd_cSdiKT-jjP_8POjuNEi1bldfkxTdkB4ddEQeWatfzxris82IpYKsknTehc34fqbY1Kt1QdEJTalaDF2hqwKKmW_FpqcndhWLgNOOVXd2YPqm8_4LDpV8TIFooAAzueI2Ilkn4cKuyra4GrCcRNjfNVqFFwrkwfMdh12yRq9E5KRf0ebfX8SemaKkBCrFxrHfdeP9n51-7yi-d5F95tQs9RFpaSQ9oBn710PpcBTpEDtzMFgUfHWRGpa4mLvBErXDsBEUnThkV3qmyUrNUfrVFCCc3kQygBH_4a1T1E_wdBfoUE1IDId_2mnT_UOGaGFZWwEYvbEwXmTusBcpm1txCeIhA11SJKUQRDFCwk6eR0GMBwO_JiSwBestTkHSjUFyjeiDZB9LvCbMIyYkdf2en0tGcisZR0WyYqjtHvLown6jWvsHGoVGJ3H7i_keNGLDs2zte1_CvIcuMzdy0GcUsV8VmmdG3CauFWZd7oV_SwmzEI8-xlfy6d4YjRgk7CGQkXmp5tYrz2Pn1IEHRU_eEMJf75iuW00pco4xTqZnh6XvWZmVjIgus6riFDv5tpi1fmLjs5gZhf0Q0_j-61JFgQKvZp_5r8T0nCvc7oGRR3xdp5a97eASG2Z7I5paL_-fNpg8r1v1CxP8Y5e0IrcVw9cZpgQXrS5m2Pru1-4WtUg2z7Mcp7Cl5nAOk3rYl-ZO2WAMPHkXiDaDdsxaJTUley6Q97l5jGJV13G2RfMKeAmJ8RBYcXzfPMpx_jlXyfZulMPweRTSAJRDlU8I90ToEcrdxAHYlw9-_dcrMR9eAdUfwzUDVWFAI05kf13Jcq4nDim_uHZj5aLtFIKKkOR2dcKWxRo1za946Go7DMxz39un5rZOC-zM9wUd8cWqSnSc09Jtni-zN6YXLMOifjqOLxZso9yLKAAa1JyWTqi2Gf4Ba9QpOw6NlP11pxUDTcO0dXFr88Mq_QqmxCVhm-mYTq8YgDTIptGoKqwsIBlsl-vWOnX_A6cMTcRCXJOVA07aj2Uph2iLVqODKtHH80r7mqkwgTX0kz07SCM-l7ILr0NBjnofbZ9jfuEsevyFSZHbUet2aZ1VN9sG886bWXvIyb2GKdms2t-9UCiceBw1WiWjWz-_sTy4NQrw7KQxrrUFXfuDaR5FX-tYWlTerVxcsRA9BlBsWOWPgap2pyXnT927Q4-ZvwEe74-eumwXOjJMxbFeBaMme7Vk3gXLvJ4xZxF1hvIViYBx3tLqG0meaUiA18cJzAV9G3xbKnHAG67i9fRVOUzkVA1Z-pZOCzGgn6Psc29UIBWah7RnjGavVZGf6f46qNnl2gWyvb52CaqGxwP4cMQPUw2NObqIW0xOALsVvwrWBRkB1MtKKclIVHOr5Xiyr7bEvihhfzSprGBCh05I9IBHDn1sa1pUsGhO7WOlPQcSPCWyc4Ie2rht-xiEpYwjtjmKkdSGBqbyOKddDWSMgxHcdrbFJsQ_m5dWPzcBnKq4UiT8S0jDSwBqGxSaYX-PXtR3gCduNioiHSzGFO_qCui76g963qcIToWz3xxD4xCrLp2ZqQ7BDc3r8Z_OuLDsINR2nHOn2GSTbX2J-igOhaC4neq49N1TdwOwNoB5GRrHddJQqmqpn9GLlIdYHQowzXjvpO266a1recXEKP18IjblpoETRw79XIeyc4i9-dX6oEcjb5svLUzLHt4X8gTDaxRAUrEO7TOBLLwVoqnSDAigqvPFMO7KIgm72G_O0CRwUOGghT5iRjG0AUdI6sE1sHuP3R2iuPX_df6mIsjz5maTdixCDWFx5doyAGBhnEvujt0lFqWr1CSpu6IIajh_U4txMc2cltPWjKb_KadxMdfcVzy_pW7H7bV-TpTRengrLxAS8lAZdfqLwbknl9qCcnI56vttlPQWk51lupNGA9gFUYl52PYksevrghPbnoE9Xr2NbXoHFCE0doVBiarLdfILNoYBxmLLZqP3xVLpNBoLWnv6NcRCpZGMAqHcryhcImRGNigkEBUfK2kRg9PIRMURJH-sQdIItv3ZmAWA_CRWEiGpCWF-p_Gep1MKD49Ot8QEiE4k_3t1LhqvtvQ3SQdprj23SidfpR8-D1QJLVyUrjmceoSCZusZ0LSIsmdN6wV7ahZTCoMPm8zXEJhukK2Fu-0oEbS0YLj-gCxr42k9ay6GsSHHBOt1X7zSob9c4msukA6dlMi5nHaF9RyBa50EGjw8ZvkBbyxDh9wfKGjnLgifNQd1sc4ApblAxlSPX1XO7nckCUbXF3CrSHDnBT_upMGc1RiYHm9K0Ceq9qGhEeMTShSXr7kWim7YNRtNUCUNmwVWU9dQ4uzRVcxr4EqjoRlJfXMcWE4TQWevZJWsRdpYgLnUST86eVcayvyjjgg1OGk0s4lvU1lsO-JMKhYPz9a5tYHaEf6y_2PejymmSSFBGCnvDPUhzyNerAPl4zMYdLCUoUlG5L3Z6patU0njH19rkNHI7Mi8jVtxsOeTE7wdruXEtEm0Eh_dsTtYMcPaGHUUXJW2UMcV84oWpZKvLCg-wUEgJqkNa_PPRgASyLFiM8EUxhRkqxyeyKGk42MJ9Vn763vbj_rRCdmlTsT2PwYqr544390yRNYbakCkyY6iS0jsuIdhS7UaxdGtWnd-cqLiGxLFNLrOTQ0Ud0mRI9HCNFs0pa9xqlsDTbnruhsYZC5yKycDZHr2p0rCFoiixZDnIsL2csecibswFchJkCQvT33h4ex4YeuQzLmFKAynlNyRQSO28bM9C_KMP46FDPz9bFWIKcFmSHTTOZHAftMUt9KZTVgMErXSsEks0-zwGeK9gME8QwQIcksv0iRaGR-MAVEMwY6RdLkz1Z017zyNWc-Z8UbyleM2eTLPeATFfY2njOwxHC7Fa1KYXHMqtAjYjzRLAmwF5gp4rd2XNkOYV9t9CSZgCFuzxYVZas41TehMRqluO1Nxm1XG1lHwh_oW4o5yuY2JO5PhGEyufJJTnbxiv9f5AYcIvMHuZLOTlk4odSwoUFeEvZAGGto2M-Qq0uIKvVoNnXHDkTjZPOlUfVr4uhVJJaIdbuY8oTRJhKfFiAlaKNjJruO47B5D4pAQBb_awjPFEhkWxzaSynRL6BEp3CGCDEiBpnp5XKwUb8YuEWDRKlir-AyUKCGJepISftkInXWXyJLwqTkuzTDzhZhy3S6W-HP-bEjpc4LFKsh6bQZBAK0Wlsz60vVR8fWg0ow0Z_MvUiFSIOeHTV67qQm9Q9BP2-r-TDVOcMoN_rPOkpEPeqD_6tRzGj6Ytz-IRmVY8USoZtM_92isv00Ms-4xh_DrwldkwQ2YaHmhYzt1WfG_U6P-07GR7GFoW0uSm3ClLq7gpdo853t-YQRRMKFOiFCzKUFfkqsK-qtI7OmsL7R-j1FFN0UGCqi28fjmpNgcIp7WvtaseJUMydVQc3a0wl53XpBbtH7Laspv-IEdc7MuS8Eq3_5dtIpE4sD20fGK4LRhm_DG6hY1mX4qxkeXD2ZWVdjxZ-NRbJzEIabTFMxyzcSdt5vCWNHat5JIlce9mWnDBaSH0YX430vq0Z8-Ht4sZVaGvmnoeeYAD560WlC0ACjwYY2jvhWR8F_iUfYBPqZFsEXWsDa3HV9ylqhkeGNM60qO_-0ve9YJSyDecI82X-5R2pD3r0liJk-u7WGS8eOCxqWuLedmi-3bDB_RVxq4vToerlfINjTz00gdMSbb7mPi4FyGaUBoDocCK49_Nnk3wUK-U86UFS-a81XzN5YiV2-9AkJjop_npvC8PBJAL_SQuLQvkEsC9SVv_d6hwGCWzA9Q_m5SbRAXA2hh_VZOzIAhjk1nHgCZ8Egx_KzmQ-AG8JjDrHv45ikX5GyVeP_LLoZsqJk1b86osgMG3SLomHSdqJDf16JKYkRqzZvilr6Ac5bu92PHsbQKyoDcJ1KQ-x_dHEKav1kt-NCYPUHhOWV1ohvINMnKzvW-z52Bk7PeJI_7dsGemS9Gi1Bt9G6EX4m26ZxY88_XhWP2I_aN8xpeeTbo0jZzGXsE4ykWiyEyG8U5ZW8pWA_npVrKwC7KYwW1-aJz_PAXCJmiMPgMXDVjZxwBKSL12LrX-GnxoJZstLDYcwGgF1KDc5305XhXA2GtVns5Rq8JMjR-ppOE1WhdYFVj56hNqWAlrbg4RMWsBWrg1cTXchFaJlFYn2LrSNhnH6GTpPO3wFwk8z62BW7A20PWZY3ZF_cyUzv3Zu0WFAGYyeo6X14R414q5azY005jug5k6b8X29z1ScatMeSchJIpDDdyrkHnucyTmlKVBzAN67P-Z8wla3o6n9JaYnIuhNW6iCE1dYdw8-FGXj5L42b7J3VhmgqRi4l5Husnzu-Z36hDIWkEWrkHrjTstpufeBnEhgdO7yzrmTBfvjxua4ofG4WbkvcYTR9YyGP0BTdhBAyCr8l72hVUo8aDvuBnDZQkF7_xpB_hb3qS7Jw_fEejDkifzv8ZDzfukZaIAvXdv2GEQdooY6_c0J-VDtvo9kbEXdch_NKix9k8VmywNJ7wG8zr4BBZAz4RQNZD1f1xzlgFt4CWr4aNHWyfMaRDikccANlk1L3rJRfmBQTP85C4EPVsX8qYRlt6_ZzaWq-0oVFvOvEOm3kf2RPRw19B6y5E80X7eHXZSLiB62NI4JcEBP7dm-gQxWu3sTqv9jhYF2_-jv71pPZoH6XJf6cyFFGQJeXqxwcq0l8Ekbu1aXQ_iHJVQ_HhSq-K_zqvNmTXAE-tDdFxX2uw3Wf-oMjuAPAW_Qy1XJ7iDK_xJksFJbcsve85o0QwaNrlgBIr2n24IqJe6Vvk19ai1mfbrn7uFPkpzqk0JK7MinMJ7h1NBPPoNVb1A_w3kVsSujs5qq3Ce1ntBp7aTt_TPERWCwMX1d93feRQGUQ_h5XaRarEVEaVis-QV6Gt4Z8GCHq-dn9V5181FD8kjUBYgUe_-CA_fnUaF69FY7NN7yd_gWNOFQmvIxmx9qH4WYr0kOncyl7GUiemJrkIwYjXscRGS3QFs1ScrhdH5TqqFxZ85W1jEqOyGv-FsGJ2IE7LpevpYpxkprZi5_U3ws0NTsFr9p11AHfUemBw0gpTKpB0ZUhUeyI7wtQVqGN8NUvKo2IE_97dTXqRW7olp8wufuFyfVjHRTDBZ0UwaQK6i3lPFMfHcs-LROX7vH4rLqXZW9H0YeRZSU6iMGESLbvQ7-dBI-9F1BQ7w9pMKsBT3KYwMsr4_jzWRIOZN7Wu1035p4qYH9P1Rc_d4pMJxBaJ1mHwxnNp-qcG6CbH0x4RO6nrcCiqMoRZfIfVE0j6huuP_z5EngmnMqnni3OIo90FHxpsifpREeGNG-rpBuoIraQ7xdFITCLiWWN3udXiX3Xi1Atjw9r50ZLIBS2v63_vmKVp4sh3suo7GTY4kLo4rcWsoO6iqkpXdEXlp3TKDruVUj9Lj99ozqfeqL53mUNZTQd1ofhVkq6925fCXmiImRoYWLn1A7TAo-ep-UAsjFpPUSG8U3cY5yVpA5OlBnlKkmHFKpkJAeIDx7mq3fw0OXZJygzTyMAflThDtsUSKH7KoW4TLQmjFRt_9H8NKAR3g-p9XW0VWsxjpiuz0Y9FE2liS94-Vfcn6aj26X_pOVi7a-oGHZS5QTtM2XP6L3HTdAg7jwevurAxFeL5yXmvEDnl8_pTPisznc-5Ghi5IByptf4id4-7n0N3wu6RSC07Fae1u-5TBJtcmUpZme7QPzee3MR26WuSlv1G2utYgofTEpeORb0Y2XE5FwPohzIv8jAFrEPajyEk8Z9sVVtET0YDNq7mecf9F5oDIV5qsWBWA7KcfmPdztj3hHQ_X1JFrpRbX8XXnN8_Dq6ctpTJf53KOevENTDYtTOVOKFRrjkAHc1a06eqlauoh4k27EFnLM1wkPKxRbr8orKD-sF4REwZesI4wo_wi6ytNxfjCl883AZ_R0tON_DfrUeisgyCImC0GvULpnzopE_Gea0O4EhWNdzQCzywhmSeCVVWC3KocTbJ40M17t8oZ9gTImPxA-LV5aYq1laRS-onN8Vbk3424IRp8eE3eMIFFduZ0cAESmsHPxi4VP4lzbuhVn06ROjexHoy_wEm19-sRbtnUgNr0CltqTx82TLcLTOTAcyfgQePssYuCukFEIHrA9zpcP4FWwKJxbu5_EA8gd3sxHWOp3giIs1QKJDDotgnQZB0wg9a2hfPUJJH2bH9uSVBeUfg2A450Tbzrl9Spt38tLA1u8GjA6bHf46woC_2KRF-KdZUDUEjxx8lqha0IWPNZaXVx8ZvrPK7Td0b8qKRjybUiGxE70ri4Klm9Iw== \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..1457ece --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.gitignore +Dockerfile +.dockerignore +npm-debug.log* diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.gitkeep b/frontend/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..df9fd78 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,25 @@ +# ---- Build stage ---- +FROM node:20-alpine AS build + +WORKDIR /app + +# Install dependencies first for better layer caching. +COPY package*.json ./ +RUN npm ci + +# Build the production bundle. +COPY . . +RUN npm run build + +# ---- Serve stage ---- +FROM nginx:alpine AS serve + +# SPA + API-proxy nginx config. +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Static assets from the build stage. +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..de656c6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Migration Evaluation Platform + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f96a5c0 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA fallback — let React Router handle client-side routes. + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to the FastAPI backend service. + # `backend` is expected to resolve via the container network (e.g. compose). + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d424be1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2713 @@ +{ + "name": "mep-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mep-frontend", + "version": "0.0.0", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@fontsource/roboto": "^5.2.10", + "@mui/icons-material": "^9.2.0", + "@mui/material": "^9.2.0", + "axios": "^1.18.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@fontsource/roboto": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz", + "integrity": "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.2.0.tgz", + "integrity": "sha512-+XMav+ZaXkZKUFUgzjrfMEedfyJKxxviAske2q8N8CWDMeqZdDU2lWMkiUPiB388hGaDqhwvOAwkrsc/pUyp8g==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.2.0.tgz", + "integrity": "sha512-VgBd3z7Qc3vd/thcNSMC03nHRh/U4DzMUd+1dRyJTbm/hGo7+N6N4GDuJZDNHa6LZhhwG6Cu1X3DNvrVv8sNag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^9.2.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.2.0.tgz", + "integrity": "sha512-+YTRSgGKGrrRo2XJZXs7JRA6qHoHWvNtxyqxnrRJTBmIuLOUpxxh7m4G9lF4tWberxGFY+EqkkRPgJCl+fSMJg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/core-downloads-tracker": "^9.2.0", + "@mui/system": "^9.2.0", + "@mui/types": "^9.1.1", + "@mui/utils": "^9.2.0", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.6", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^9.2.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.2.0.tgz", + "integrity": "sha512-w9wpyDxGPGnAACPB2hKhCDmILJIAvQxrfjUbIAEa0AznX1rOjaz5N+yB1uuw8ixnJcpEh/tPbD9oEe19wcWPHw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "^9.2.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.1.1.tgz", + "integrity": "sha512-neaYKdJfvEG54q8efHLJR7swpHG/gfSv9xGqW5iTSMsubD7yPCPFrhVBt284j1DOF3uZaaDJSHQL7gz6jGF21Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.2.0.tgz", + "integrity": "sha512-YvUJwKoGVtbnOm2PyPi5TvX2d1rOA6sqSpEWVs4WmXNIaFTuYmNUaVdU2o1NKUEe31URnD3E8ZVUMcsLQXwcYg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/private-theming": "^9.2.0", + "@mui/styled-engine": "^9.1.1", + "@mui/types": "^9.1.1", + "@mui/utils": "^9.2.0", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.1.1.tgz", + "integrity": "sha512-Zjt7u8wNvDg40rPTGoL+TnfkpuSKjwubsNSFRH1KAVZLcaV4I3AFNHIFbvH7p4F3alEibSbdd90xAgn5Rnfndg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.2.0.tgz", + "integrity": "sha512-OsUH5zhlSOM4xmLl53+agug1M1UyWb4zxFxWQCqwKTKUeQPvTENtg3JhrroBD2qpCLKsX5W/DYGERJ4mBUbc8g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.1.1", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.74.0.tgz", + "integrity": "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.74.0.tgz", + "integrity": "sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.74.0.tgz", + "integrity": "sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.74.0.tgz", + "integrity": "sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.74.0.tgz", + "integrity": "sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.74.0.tgz", + "integrity": "sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.74.0.tgz", + "integrity": "sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.74.0.tgz", + "integrity": "sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.74.0.tgz", + "integrity": "sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.74.0.tgz", + "integrity": "sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.74.0.tgz", + "integrity": "sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.74.0.tgz", + "integrity": "sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.74.0.tgz", + "integrity": "sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.74.0.tgz", + "integrity": "sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.74.0.tgz", + "integrity": "sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.74.0.tgz", + "integrity": "sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.74.0.tgz", + "integrity": "sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.74.0.tgz", + "integrity": "sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.74.0.tgz", + "integrity": "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oxlint": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.74.0.tgz", + "integrity": "sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.74.0", + "@oxlint/binding-android-arm64": "1.74.0", + "@oxlint/binding-darwin-arm64": "1.74.0", + "@oxlint/binding-darwin-x64": "1.74.0", + "@oxlint/binding-freebsd-x64": "1.74.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.74.0", + "@oxlint/binding-linux-arm-musleabihf": "1.74.0", + "@oxlint/binding-linux-arm64-gnu": "1.74.0", + "@oxlint/binding-linux-arm64-musl": "1.74.0", + "@oxlint/binding-linux-ppc64-gnu": "1.74.0", + "@oxlint/binding-linux-riscv64-gnu": "1.74.0", + "@oxlint/binding-linux-riscv64-musl": "1.74.0", + "@oxlint/binding-linux-s390x-gnu": "1.74.0", + "@oxlint/binding-linux-x64-gnu": "1.74.0", + "@oxlint/binding-linux-x64-musl": "1.74.0", + "@oxlint/binding-openharmony-arm64": "1.74.0", + "@oxlint/binding-win32-arm64-msvc": "1.74.0", + "@oxlint/binding-win32-ia32-msvc": "1.74.0", + "@oxlint/binding-win32-x64-msvc": "1.74.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.24.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1d6ad56 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "mep-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@fontsource/roboto": "^5.2.10", + "@mui/icons-material": "^9.2.0", + "@mui/material": "^9.2.0", + "axios": "^1.18.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2023e19 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,28 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; + +import AppLayout from './components/Layout/AppLayout'; +import Dashboard from './pages/Dashboard'; +import MigrationRuns from './pages/MigrationRuns'; +import NewMigration from './pages/NewMigration'; +import Validation from './pages/Validation'; +import Reports from './pages/Reports'; +import History from './pages/History'; +import Administration from './pages/Administration'; + +/** Top-level routing: all pages render inside the shared AppLayout shell. */ +export default function App() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..004f19e --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,33 @@ +import axios from 'axios'; + +/** + * Shared axios instance for the MEP backend (FastAPI). + * + * In dev, Vite proxies `/api` → http://localhost:8000 (see vite.config.ts), + * and in production nginx routes `/api` to the backend. We therefore use a + * relative `/api` baseURL so the same build works in both environments. + * + * `VITE_API_BASE_URL` can override this if a fully-qualified backend URL is + * required (e.g. a separately hosted API). + */ +const baseURL = import.meta.env.VITE_API_BASE_URL ?? '/api'; + +const apiClient = axios.create({ + baseURL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +/** Ping the backend health endpoint. Returns true when reachable & healthy. */ +export async function checkHealth(): Promise { + try { + const { data, status } = await apiClient.get('/health'); + return status === 200 && data?.status === 'healthy'; + } catch { + return false; + } +} + +export default apiClient; diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx new file mode 100644 index 0000000..ecb2b3c --- /dev/null +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { Box, Toolbar } from '@mui/material'; +import { Outlet } from 'react-router-dom'; + +import Sidebar from './Sidebar'; +import TopBar, { type ApiStatus } from './TopBar'; +import { checkHealth } from '../../api/client'; + +/** + * Application shell: fixed top bar + collapsible dark sidebar + light content + * area rendered via the router . Pings the backend health endpoint + * on mount to drive the API status indicator. + */ +export default function AppLayout() { + const [collapsed, setCollapsed] = useState(false); + const [apiStatus, setApiStatus] = useState('checking'); + + useEffect(() => { + let active = true; + checkHealth().then((ok) => { + if (active) setApiStatus(ok ? 'connected' : 'disconnected'); + }); + return () => { + active = false; + }; + }, []); + + return ( + + setCollapsed((c) => !c)} + apiStatus={apiStatus} + /> + + + + + + + ); +} diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx new file mode 100644 index 0000000..b4c856b --- /dev/null +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -0,0 +1,141 @@ +import { + Box, + Drawer, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + Tooltip, + Typography, +} from '@mui/material'; +import HubIcon from '@mui/icons-material/Hub'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { navItems } from './navConfig'; + +const EXPANDED_WIDTH = 240; +const COLLAPSED_WIDTH = 64; + +interface SidebarProps { + collapsed: boolean; +} + +/** + * Dark navigation drawer, Azure-Portal style. Highlights the active route and + * collapses to an icon-only rail when `collapsed` is true. + */ +export default function Sidebar({ collapsed }: SidebarProps) { + const location = useLocation(); + const navigate = useNavigate(); + const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; + + const isActive = (path: string) => + path === '/' + ? location.pathname === '/' + : location.pathname.startsWith(path); + + return ( + + + + {!collapsed && ( + + MEP + + )} + + + + + {navItems.map((item) => { + const active = isActive(item.path); + return ( + + + navigate(item.path)} + sx={{ + minHeight: 44, + justifyContent: collapsed ? 'center' : 'initial', + px: 2.5, + color: active ? '#FFFFFF' : '#C8C6C4', + borderLeft: active + ? '3px solid #2B88D8' + : '3px solid transparent', + '&.Mui-selected': { + backgroundColor: 'rgba(43,136,216,0.16)', + }, + '&.Mui-selected:hover': { + backgroundColor: 'rgba(43,136,216,0.24)', + }, + '&:hover': { + backgroundColor: 'rgba(255,255,255,0.06)', + }, + }} + > + + {item.icon} + + {!collapsed && ( + + )} + + + + ); + })} + + + + ); +} + +export { EXPANDED_WIDTH, COLLAPSED_WIDTH }; diff --git a/frontend/src/components/Layout/TopBar.tsx b/frontend/src/components/Layout/TopBar.tsx new file mode 100644 index 0000000..d039e4a --- /dev/null +++ b/frontend/src/components/Layout/TopBar.tsx @@ -0,0 +1,62 @@ +import { AppBar, Box, IconButton, Toolbar, Tooltip, Typography } from '@mui/material'; +import MenuIcon from '@mui/icons-material/Menu'; +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; + +type ApiStatus = 'checking' | 'connected' | 'disconnected'; + +interface TopBarProps { + onToggleSidebar: () => void; + apiStatus: ApiStatus; +} + +const statusMeta: Record = { + checking: { color: '#8A8886', label: 'Checking API…' }, + connected: { color: '#107C10', label: 'API Connected' }, + disconnected: { color: '#D13438', label: 'API Disconnected' }, +}; + +/** Top app bar: menu toggle, product title, and live API status indicator. */ +export default function TopBar({ onToggleSidebar, apiStatus }: TopBarProps) { + const meta = statusMeta[apiStatus]; + + return ( + t.zIndex.drawer + 1 }}> + + + + + + + Migration Evaluation Platform + + + + + + + {meta.label} + + + + + + ); +} + +export type { ApiStatus }; diff --git a/frontend/src/components/Layout/navConfig.tsx b/frontend/src/components/Layout/navConfig.tsx new file mode 100644 index 0000000..2c847fe --- /dev/null +++ b/frontend/src/components/Layout/navConfig.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import StorageIcon from '@mui/icons-material/Storage'; +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutlineOutlined'; +import FactCheckIcon from '@mui/icons-material/FactCheck'; +import AssessmentIcon from '@mui/icons-material/Assessment'; +import HistoryIcon from '@mui/icons-material/History'; +import SettingsIcon from '@mui/icons-material/Settings'; + +export interface NavItem { + label: string; + path: string; + icon: ReactNode; +} + +/** Sidebar navigation items, in display order. */ +export const navItems: NavItem[] = [ + { label: 'Dashboard', path: '/', icon: }, + { label: 'Migration Runs', path: '/migration-runs', icon: }, + { label: 'New Migration', path: '/new-migration', icon: }, + { label: 'Validation', path: '/validation', icon: }, + { label: 'Reports', path: '/reports', icon: }, + { label: 'History', path: '/history', icon: }, + { label: 'Administration', path: '/administration', icon: }, +]; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bd77e4b --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,23 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { ThemeProvider, CssBaseline } from '@mui/material'; + +import '@fontsource/roboto/300.css'; +import '@fontsource/roboto/400.css'; +import '@fontsource/roboto/500.css'; +import '@fontsource/roboto/700.css'; + +import App from './App'; +import theme from './theme'; + +createRoot(document.getElementById('root')!).render( + + + + + + + + , +); diff --git a/frontend/src/pages/Administration.tsx b/frontend/src/pages/Administration.tsx new file mode 100644 index 0000000..b377fff --- /dev/null +++ b/frontend/src/pages/Administration.tsx @@ -0,0 +1,12 @@ +import SettingsIcon from '@mui/icons-material/Settings'; +import PagePlaceholder from './PagePlaceholder'; + +export default function Administration() { + return ( + } + description="Manage connections, users, and platform configuration settings." + /> + ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..00cdb80 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,12 @@ +import DashboardIcon from '@mui/icons-material/Dashboard'; +import PagePlaceholder from './PagePlaceholder'; + +export default function Dashboard() { + return ( + } + description="An at-a-glance overview of migration activity, health, and key metrics will appear here." + /> + ); +} diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx new file mode 100644 index 0000000..c1b20d9 --- /dev/null +++ b/frontend/src/pages/History.tsx @@ -0,0 +1,12 @@ +import HistoryIcon from '@mui/icons-material/History'; +import PagePlaceholder from './PagePlaceholder'; + +export default function History() { + return ( + } + description="Audit the full history of migration and validation activity over time." + /> + ); +} diff --git a/frontend/src/pages/MigrationRuns.tsx b/frontend/src/pages/MigrationRuns.tsx new file mode 100644 index 0000000..cf91133 --- /dev/null +++ b/frontend/src/pages/MigrationRuns.tsx @@ -0,0 +1,12 @@ +import StorageIcon from '@mui/icons-material/Storage'; +import PagePlaceholder from './PagePlaceholder'; + +export default function MigrationRuns() { + return ( + } + description="Browse and monitor active and completed CSV → PostgreSQL migration runs." + /> + ); +} diff --git a/frontend/src/pages/NewMigration.tsx b/frontend/src/pages/NewMigration.tsx new file mode 100644 index 0000000..e485574 --- /dev/null +++ b/frontend/src/pages/NewMigration.tsx @@ -0,0 +1,12 @@ +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutlineOutlined'; +import PagePlaceholder from './PagePlaceholder'; + +export default function NewMigration() { + return ( + } + description="Configure and launch a new migration by uploading source data and selecting a target." + /> + ); +} diff --git a/frontend/src/pages/PagePlaceholder.tsx b/frontend/src/pages/PagePlaceholder.tsx new file mode 100644 index 0000000..94a93b6 --- /dev/null +++ b/frontend/src/pages/PagePlaceholder.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; +import { Box, Card, CardContent, Chip, Typography } from '@mui/material'; + +interface PagePlaceholderProps { + title: string; + icon: ReactNode; + description?: string; +} + +/** + * Empty-state card used by every route until real functionality lands. + * Keeps the shell looking finished while signalling work-in-progress. + */ +export default function PagePlaceholder({ + title, + icon, + description, +}: PagePlaceholderProps) { + return ( + + + {icon} + {title} + + + + + + {icon} + + + + {title} — Coming Soon + + + {description ?? + 'This section is part of the Migration Evaluation Platform and will be available in an upcoming release.'} + + + + + ); +} diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx new file mode 100644 index 0000000..5b2625e --- /dev/null +++ b/frontend/src/pages/Reports.tsx @@ -0,0 +1,12 @@ +import AssessmentIcon from '@mui/icons-material/Assessment'; +import PagePlaceholder from './PagePlaceholder'; + +export default function Reports() { + return ( + } + description="Generate and export detailed evaluation reports for each migration run." + /> + ); +} diff --git a/frontend/src/pages/Validation.tsx b/frontend/src/pages/Validation.tsx new file mode 100644 index 0000000..bad2d94 --- /dev/null +++ b/frontend/src/pages/Validation.tsx @@ -0,0 +1,12 @@ +import FactCheckIcon from '@mui/icons-material/FactCheck'; +import PagePlaceholder from './PagePlaceholder'; + +export default function Validation() { + return ( + } + description="Review data-quality checks and validation assertions against migrated datasets." + /> + ); +} diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts new file mode 100644 index 0000000..ac08916 --- /dev/null +++ b/frontend/src/theme.ts @@ -0,0 +1,81 @@ +import { createTheme } from '@mui/material/styles'; + +/** + * MEP theme — light, clean, professional. Modeled on the Azure Portal: + * Azure blue primary (#0078D4), neutral greys, subtle borders, and a + * comfortable density for an enterprise SaaS product. + */ +const theme = createTheme({ + palette: { + mode: 'light', + primary: { + main: '#0078D4', // Azure blue + dark: '#005A9E', + light: '#2B88D8', + contrastText: '#FFFFFF', + }, + secondary: { + main: '#5C2D91', + }, + success: { + main: '#107C10', + }, + error: { + main: '#D13438', + }, + background: { + default: '#F3F2F1', // Azure Portal content grey + paper: '#FFFFFF', + }, + text: { + primary: '#201F1E', + secondary: '#605E5C', + }, + divider: '#EDEBE9', + }, + typography: { + fontFamily: + '"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + h4: { fontWeight: 600 }, + h5: { fontWeight: 600 }, + h6: { fontWeight: 600 }, + subtitle1: { fontWeight: 600 }, + button: { textTransform: 'none', fontWeight: 600 }, + }, + shape: { + borderRadius: 4, + }, + components: { + MuiAppBar: { + styleOverrides: { + root: { + backgroundColor: '#FFFFFF', + color: '#201F1E', + boxShadow: 'none', + borderBottom: '1px solid #EDEBE9', + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + '&.Mui-selected': { + backgroundColor: 'rgba(255,255,255,0.12)', + }, + '&.Mui-selected:hover': { + backgroundColor: 'rgba(255,255,255,0.18)', + }, + }, + }, + }, + }, +}); + +export default theme; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e3932ee --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + // Proxy API calls to the FastAPI backend during local development. + // In production, nginx handles this routing (see Dockerfile). + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}); diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 From ea9bffb4ba1f986f346c29c4a5e8a4832d405202 Mon Sep 17 00:00:00 2001 From: MEP Agent Date: Tue, 14 Jul 2026 17:41:16 +0000 Subject: [PATCH 02/11] =?UTF-8?q?chore:=20park=20CI=20workflow=20at=20docs?= =?UTF-8?q?/ci/ci.yml.pending=20(GitHub=20App=20lacks=20workflows=20permis?= =?UTF-8?q?sion=20=E2=80=94=20move=20to=20.github/workflows/ci.yml=20via?= =?UTF-8?q?=20web=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml => docs/ci/ci.yml.pending | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/ci.yml => docs/ci/ci.yml.pending (100%) diff --git a/.github/workflows/ci.yml b/docs/ci/ci.yml.pending similarity index 100% rename from .github/workflows/ci.yml rename to docs/ci/ci.yml.pending From cc6b44ba4bbc8f1053e8f23558fe9f121322091d Mon Sep 17 00:00:00 2001 From: MEP Agent Date: Wed, 15 Jul 2026 08:41:35 +0000 Subject: [PATCH 03/11] docs: add Phase 0 setup manual for other users --- docs/Phase0-Setup-Manual.md | 786 ++++++++++++++++++++++++++++++++++++ 1 file changed, 786 insertions(+) create mode 100644 docs/Phase0-Setup-Manual.md diff --git a/docs/Phase0-Setup-Manual.md b/docs/Phase0-Setup-Manual.md new file mode 100644 index 0000000..d00f7c4 --- /dev/null +++ b/docs/Phase0-Setup-Manual.md @@ -0,0 +1,786 @@ +# Migration Evaluation Platform (MEP) — Phase 0 Setup Manual + +> **Purpose:** This manual documents every step taken to transform the existing `PostgreDataMigrationApp` (a CLI-based CSV-to-PostgreSQL migration engine) into an enterprise-grade web application foundation. +> +> **Audience:** Developers, QA engineers, or anyone replicating this setup on a similar project. +> +> **Outcome:** A running 3-tier application (React + FastAPI + PostgreSQL) with CI/CD, documentation, and a clean Git workflow — ready for feature development. + +--- + +## Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [Step 1 — Repository Assessment](#2-step-1--repository-assessment) +3. [Step 2 — Repository Restructure](#3-step-2--repository-restructure) +4. [Step 3 — FastAPI Backend Setup](#4-step-3--fastapi-backend-setup) +5. [Step 4 — React + TypeScript Frontend Setup](#5-step-4--react--typescript-frontend-setup) +6. [Step 5 — Docker Compose Orchestration](#6-step-5--docker-compose-orchestration) +7. [Step 6 — GitHub Actions CI/CD Pipeline](#7-step-6--github-actions-cicd-pipeline) +8. [Step 7 — Documentation](#8-step-7--documentation) +9. [Step 8 — Git Commit, Push & Pull Request](#9-step-8--git-commit-push--pull-request) +10. [Post-Setup Verification](#10-post-setup-verification) +11. [Known Issues & Workarounds](#11-known-issues--workarounds) +12. [What's Next — Phase 1](#12-whats-next--phase-1) + +--- + +## 1. Prerequisites + +Before starting, ensure you have: + +| Tool | Version | Purpose | +|------|---------|---------| +| Git | 2.30+ | Version control | +| Docker | 20.10+ | Containerisation | +| Docker Compose | 2.0+ | Multi-container orchestration | +| Python | 3.11+ | Backend development | +| Node.js | 20+ | Frontend development | +| npm | 9+ | Frontend package management | +| GitHub account | — | Repository hosting | + +--- + +## 2. Step 1 — Repository Assessment + +**Goal:** Understand the existing codebase before making any changes. + +### What we assessed + +We cloned the existing repository and documented: + +```bash +git clone https://github.com/amar-python/PostgreDataMigrationApp.git +cd PostgreDataMigrationApp +``` + +### Key findings + +| Area | Details | +|------|---------| +| **Migration engine** | Bash + Python pipeline: `build/csv/validator.py` validates CSVs, `build/csv_loader.sh` routes to engine-specific loaders, `build/csv/loader_postgresql.sh` uses PostgreSQL `COPY` for high-performance loading | +| **Database support** | PostgreSQL (primary), MariaDB, SQLite, InfluxDB, Redis, Teradata | +| **Testing** | 85 SQL assertions across 5 suites, 9 Python test modules, 23 CSV edge-case eval scenarios | +| **Infrastructure** | Dockerfile (python:3.11-slim), Terraform configs for Azure, GitHub Actions CI | +| **Configuration** | Environment-variable driven via `config.local.env` (gitignored) | +| **Web framework** | None — purely CLI/script-based | + +### Output + +A detailed assessment was saved to `repo_assessment.md` for reference. + +### Why this matters + +- Prevents accidentally deleting or breaking existing functionality +- Identifies what can be reused (the migration engine) vs. what needs to be built (web layer) +- Informs the restructuring strategy + +--- + +## 3. Step 2 — Repository Restructure + +**Goal:** Reorganise the repository into an enterprise monorepo layout while preserving all existing code. + +### 2.1 Create a feature branch + +```bash +git checkout -b feature/foundation-setup +``` + +> **Rule:** Never work directly on `main`. Every change goes through a feature branch → PR → review → merge. + +### 2.2 Move existing code into `backend/migration/` + +```bash +# Preserve git history with git mv +git mv build/ backend/migration/build/ +git mv evals/ backend/migration/evals/ +git mv infra/ backend/migration/infra/ +git mv tests/ backend/migration/tests/ +``` + +> **Important:** We used `git mv` (not `mv`) to preserve file history in Git. + +### 2.3 Create the new directory structure + +```bash +# Backend directories +mkdir -p backend/api/routes +mkdir -p backend/services +mkdir -p backend/evaluation +mkdir -p backend/reports +mkdir -p backend/database + +# Frontend, Docker, Docs, Uploads +mkdir -p frontend +mkdir -p docker +mkdir -p docs/ADR +mkdir -p uploads +``` + +### 2.4 Add `.gitkeep` files + +Empty directories are not tracked by Git. Place a `.gitkeep` file in each: + +```bash +touch frontend/.gitkeep +touch docker/.gitkeep +touch uploads/.gitkeep +touch backend/services/.gitkeep +touch backend/evaluation/.gitkeep +touch backend/reports/.gitkeep +touch backend/database/.gitkeep +``` + +### 2.5 Create `backend/migration/README.md` + +Document that this directory contains the original engine: + +```markdown +# Original Migration Engine + +This directory contains the original PostgreDataMigrationApp codebase, +preserved and relocated here as part of the MEP v2 restructure. + +The FastAPI backend (backend/api/, backend/services/) wraps and +orchestrates this engine rather than reimplementing it. +``` + +### 2.6 Update root `README.md` + +Update to introduce MEP v2 with a link to the original engine at `backend/migration/`. + +### 2.7 Update `.gitignore` + +Add entries for: + +```gitignore +# Frontend +node_modules/ +frontend/node_modules/ +frontend/build/ +frontend/dist/ + +# Python +__pycache__/ +*.py[cod] +.env +.env.* + +# Uploads +uploads/* +!uploads/.gitkeep +``` + +### Final directory structure after this step + +``` +PostgreDataMigrationApp/ +├── backend/ +│ ├── api/ ← new (empty placeholder) +│ ├── services/ ← new (empty placeholder) +│ ├── migration/ ← existing code moved here +│ │ ├── build/ +│ │ ├── evals/ +│ │ ├── infra/ +│ │ └── tests/ +│ ├── evaluation/ ← new (empty placeholder) +│ ├── reports/ ← new (empty placeholder) +│ └── database/ ← new (empty placeholder) +├── frontend/ ← new (empty placeholder) +├── docker/ ← new (empty placeholder) +├── docs/ ← new (empty placeholder) +├── uploads/ ← new (empty placeholder) +├── .github/workflows/ ← existing (preserved) +├── .gitignore ← updated +└── README.md ← updated +``` + +--- + +## 4. Step 3 — FastAPI Backend Setup + +**Goal:** Create a working API server with a health endpoint that proves the stack is functional. + +### 3.1 Create the backend files + +#### `backend/config.py` — Application settings + +```python +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql://mep_user:mep_password@db:5432/mep_db" + APP_ENV: str = "development" + DEBUG: bool = True + + class Config: + env_file = ".env" + +settings = Settings() +``` + +#### `backend/database/connection.py` — Database layer + +```python +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from config import settings + +engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +def check_db_connection() -> bool: + try: + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + return True + except Exception: + return False +``` + +#### `backend/database/models.py` — Base ORM model + +```python +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass +``` + +#### `backend/api/routes/health.py` — Health check endpoint + +```python +from fastapi import APIRouter +from config import settings +from database.connection import check_db_connection + +router = APIRouter() + +@router.get("/health") +def health_check(): + return { + "status": "healthy", + "version": "0.1.0", + "environment": settings.APP_ENV, + "database": "connected" if check_db_connection() else "disconnected" + } +``` + +#### `backend/main.py` — Application entrypoint + +```python +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from api.routes.health import router as health_router + +app = FastAPI(title="Migration Evaluation Platform", version="0.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(health_router, prefix="/api") + +@app.get("/") +def root(): + return {"message": "MEP API is running", "version": "0.1.0"} +``` + +### 3.2 Create `backend/requirements.txt` + +``` +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +sqlalchemy>=2.0.0 +alembic>=1.13.0 +pydantic-settings>=2.0.0 +python-multipart +python-dotenv +psycopg2-binary +pytest +httpx +pytest-asyncio +``` + +### 3.3 Create `backend/Dockerfile` + +```dockerfile +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +### 3.4 Create backend tests + +#### `backend/tests/test_health.py` + +```python +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +def test_root(): + response = client.get("/") + assert response.status_code == 200 + assert "version" in response.json() + +def test_health(): + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json()["status"] == "healthy" +``` + +### 3.5 Verify + +```bash +cd backend +pip install -r requirements.txt +pytest tests/ -v +``` + +Expected output: +``` +tests/test_health.py::test_root PASSED +tests/test_health.py::test_health PASSED +``` + +--- + +## 5. Step 4 — React + TypeScript Frontend Setup + +**Goal:** Create a professional UI shell that looks like Azure Portal — enterprise-grade navigation with no business logic yet. + +### 4.1 Scaffold the project + +```bash +cd frontend +npm create vite@latest . -- --template react-ts +npm install +``` + +### 4.2 Install dependencies + +```bash +npm install @mui/material @emotion/react @emotion/styled @mui/icons-material +npm install react-router-dom +npm install axios +npm install @fontsource/roboto +``` + +### 4.3 Create the application structure + +``` +frontend/src/ +├── main.tsx ← Entry point (ThemeProvider + BrowserRouter) +├── App.tsx ← Top-level routing +├── theme.ts ← MUI theme (Azure Blue #0078D4) +├── components/ +│ └── Layout/ +│ ├── AppLayout.tsx ← Sidebar + TopBar + content area +│ ├── Sidebar.tsx ← Collapsible nav drawer +│ └── TopBar.tsx ← App bar with API status indicator +├── pages/ +│ ├── Dashboard.tsx ← Placeholder page +│ ├── MigrationRuns.tsx ← Placeholder page +│ ├── NewMigration.tsx ← Placeholder page +│ ├── Validation.tsx ← Placeholder page +│ ├── Reports.tsx ← Placeholder page +│ ├── History.tsx ← Placeholder page +│ └── Administration.tsx ← Placeholder page +└── api/ + └── client.ts ← Axios instance → http://localhost:8000 +``` + +### 4.4 Key design decisions + +| Decision | Detail | +|----------|--------| +| **Primary colour** | `#0078D4` (Azure Blue) | +| **Typography** | Segoe UI / Roboto | +| **Sidebar** | Dark background, collapsible to icon-only rail | +| **Navigation items** | Dashboard, Migration Runs, New Migration, Validation, Reports, History, Administration | +| **API status** | Green/red dot in TopBar — pings `GET /api/health` on load | +| **Routing** | React Router v6 with nested routes | + +### 4.5 Configure Vite proxy + +#### `frontend/vite.config.ts` + +```typescript +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + } + } + } +}) +``` + +### 4.6 Create `frontend/Dockerfile` (multi-stage) + +```dockerfile +# Build stage +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Serve stage +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +``` + +### 4.7 Create `frontend/nginx.conf` + +```nginx +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location /api { + proxy_pass http://backend:8000; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +### 4.8 Verify + +```bash +cd frontend +npm run build +``` + +Expected: Build completes successfully with no errors. + +--- + +## 6. Step 5 — Docker Compose Orchestration + +**Goal:** One command (`docker compose up`) starts the entire application. + +### 5.1 Create `docker-compose.yml` (repo root) + +```yaml +version: '3.8' + +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_DB: mep_db + POSTGRES_USER: mep_user + POSTGRES_PASSWORD: mep_password + ports: + - "5432:5432" + volumes: + - mep_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mep_user -d mep_db"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: ./backend + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql://mep_user:mep_password@db:5432/mep_db + APP_ENV: development + depends_on: + db: + condition: service_healthy + volumes: + - ./uploads:/app/uploads + + frontend: + build: ./frontend + ports: + - "3000:80" + depends_on: + - backend + +volumes: + mep_pgdata: +``` + +### 5.2 Create `.env.example` (repo root) + +```env +DATABASE_URL=postgresql://mep_user:mep_password@db:5432/mep_db +APP_ENV=development +DEBUG=true +POSTGRES_DB=mep_db +POSTGRES_USER=mep_user +POSTGRES_PASSWORD=mep_password +``` + +### 5.3 Verify + +```bash +docker compose up --build +``` + +Expected results: +- PostgreSQL starts and passes health check +- FastAPI starts on port 8000 +- React/Nginx starts on port 3000 +- Visit `http://localhost:3000` → see the MEP UI shell +- Visit `http://localhost:8000/api/health` → see health JSON + +--- + +## 7. Step 6 — GitHub Actions CI/CD Pipeline + +**Goal:** Automated testing on every push and pull request. + +### 6.1 Create `.github/workflows/ci.yml` + +```yaml +name: MEP CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + backend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: pip install -r backend/requirements.txt + - run: pytest backend/tests/ -v + + frontend-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: cd frontend && npm ci && npm run build + + docker-build: + runs-on: ubuntu-latest + needs: [backend-test, frontend-build] + steps: + - uses: actions/checkout@v4 + - run: docker compose build +``` + +### 6.2 Pipeline flow + +``` +Push / PR + ├── backend-test → Python 3.11 → pytest + ├── frontend-build → Node 20 → npm build + └── docker-build → docker compose build (runs after both pass) +``` + +> **Note:** If using the Abacus AI GitHub App to push, the `workflows` permission may not be available. In that case, the CI file should be placed at `docs/ci/ci.yml.pending` and manually moved to `.github/workflows/ci.yml` via the GitHub web UI (see [Known Issues](#11-known-issues--workarounds)). + +--- + +## 8. Step 7 — Documentation + +**Goal:** Professional engineering documentation that demonstrates architectural thinking. + +### 7.1 Files created + +| File | Purpose | +|------|---------| +| `docs/Architecture.md` | 3-tier architecture overview (React → FastAPI → PostgreSQL), existing engine location, design decisions | +| `docs/DeveloperGuide.md` | How to run locally, prerequisites, environment setup, branch strategy, how to run tests | +| `docs/API.md` | Current API endpoints (`GET /` and `GET /api/health`) with request/response examples | +| `docs/ADR/ADR-001-Technology-Choices.md` | Why React + MUI, FastAPI, PostgreSQL were chosen | +| `docs/ADR/ADR-002-Repository-Structure.md` | Why monorepo layout was chosen, how existing engine was preserved | + +### 7.2 Documentation conventions + +- **Architecture Decision Records (ADRs):** Numbered sequentially (ADR-001, ADR-002, ...). Each new major decision gets its own ADR. +- **Keep documentation alongside code:** All docs live in the `docs/` directory within the repo. +- **Update as you go:** Each phase should add/update relevant docs. + +--- + +## 9. Step 8 — Git Commit, Push & Pull Request + +**Goal:** Get all changes onto GitHub with a proper PR workflow. + +### 8.1 Stage and commit + +```bash +git add -A +git commit -m "feat(foundation): Phase 0 complete — MEP project foundation + +- Restructured repo: existing engine preserved in backend/migration/ +- FastAPI backend skeleton with health endpoint +- React + TypeScript frontend shell with Azure Portal UI +- Docker Compose stack (React + FastAPI + PostgreSQL) +- GitHub Actions CI/CD pipeline +- Documentation (Architecture, DeveloperGuide, API, ADRs)" +``` + +### 8.2 Push the branch + +```bash +git push -u origin feature/foundation-setup +``` + +### 8.3 Create a Pull Request + +On GitHub, create a PR: +- **From:** `feature/foundation-setup` +- **To:** `main` +- **Title:** `feat(foundation): Phase 0 — MEP project foundation` +- **Description:** Include what's in the PR, acceptance criteria, and any manual steps needed + +### 8.4 Review and merge + +> **Rule:** Never merge your own PRs without review. On a team, another developer would review. For a solo project, at minimum review the diff yourself on GitHub before merging. + +--- + +## 10. Post-Setup Verification + +After merging, verify everything works: + +### Checklist + +- [ ] `git clone` the repo fresh → all files present +- [ ] `docker compose up --build` → all 3 services start +- [ ] `http://localhost:3000` → frontend loads with navigation +- [ ] `http://localhost:8000` → API root responds with version +- [ ] `http://localhost:8000/api/health` → health check with DB status +- [ ] `pytest backend/tests/ -v` → 2 tests pass +- [ ] `cd frontend && npm run build` → build succeeds +- [ ] GitHub Actions CI triggers on push/PR and passes + +--- + +## 11. Known Issues & Workarounds + +### Issue 1: GitHub App cannot push workflow files + +**Problem:** The Abacus AI GitHub App (or similar CI tools) may lack the `workflows` permission, causing a 403 error when pushing `.github/workflows/ci.yml`. + +**Error message:** +``` +remote rejected — refusing to allow a GitHub App to create or update workflow +without `workflows` permission +``` + +**Workaround:** +1. Place the CI file at `docs/ci/ci.yml.pending` in your commit +2. Push the branch (this will succeed) +3. Manually create `.github/workflows/ci.yml` via the GitHub web UI: + - Navigate to your branch on GitHub + - Click **Add file → Create new file** + - Name: `.github/workflows/ci.yml` + - Paste the CI content + - Commit directly to the branch + +### Issue 2: Database shows "disconnected" in health check + +**Expected behaviour** when running the backend without Docker Compose. The health endpoint returns 200 regardless — it reports DB status but doesn't fail if the DB is unreachable. Run `docker compose up` to start PostgreSQL alongside the backend. + +--- + +## 12. What's Next — Phase 1 + +Phase 0 provides the **foundation**. No business logic exists yet. + +### Phase 1: CSV Upload (Complete Vertical Slice) + +The next phase will deliver: + +| Layer | Feature | +|-------|---------| +| **Frontend** | Drag-and-drop file upload, multi-file support (10+ CSVs), progress indicators, file removal | +| **Backend** | Upload API endpoint, file storage, metadata persistence, validation | +| **Database** | `MigrationRun` and `UploadedFile` tables | +| **Tests** | API tests, UI tests (Playwright), integration tests | +| **CI** | Extended pipeline with migration-specific tests | + +**Acceptance Criteria for Phase 1:** +A user can create a migration run, upload 10 CSV files, see file metadata, delete files, and refresh the page without losing uploaded file information. + +### Remaining Phases + +| Phase | Feature | Tag | +|-------|---------|-----| +| 2 | Schema Discovery | v0.2 | +| 3 | Mapping Engine | v0.3 | +| 4 | Validation Engine | v0.4 | +| 5 | Migration Engine (wrap existing code) | v0.5 | +| 6 | Evaluation Engine | v0.6 | +| 7 | Reports (PDF/HTML/JSON) | v0.7 | +| 8 | Dashboard & History | v1.0 | + +--- + +## Appendix: Git Branch Strategy + +``` +main ← production-ready, tagged releases + │ + ├── develop ← integration branch + │ │ + │ ├── feature/foundation-setup (Phase 0) + │ ├── feature/upload-ui (Phase 1) + │ ├── feature/upload-api (Phase 1) + │ ├── feature/schema-engine (Phase 2) + │ ├── feature/mapping-engine (Phase 3) + │ ├── feature/evaluation-engine (Phase 6) + │ └── feature/dashboard (Phase 8) +``` + +**Every feature:** +- Has its own branch +- Has its own pull request +- Passes CI +- Is merged only after review + +--- + +*Document generated: 15 July 2026* +*Project: Migration Evaluation Platform (MEP) v0.1.0* +*Repository: https://github.com/amar-python/PostgreDataMigrationApp* From 0ada84377920b13545aceec4042774f5b63f17f2 Mon Sep 17 00:00:00 2001 From: MEP Agent Date: Wed, 15 Jul 2026 08:53:34 +0000 Subject: [PATCH 04/11] =?UTF-8?q?feat(upload):=20Phase=201=20=E2=80=94=20C?= =?UTF-8?q?SV=20upload=20complete=20vertical=20slice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Database: MigrationRun and UploadedFile models (auto-created on startup) - API: CRUD endpoints for migration runs + multi-file upload/list/delete - Service: migration_service.py with CSV metadata parsing (row/column counts) - Frontend: New Migration page with 3-step wizard (Configure → Upload → Summary) - Drag-and-drop zone with multi-file support - Upload progress bar - File list table with size, rows, columns, delete action - Frontend: Migration Runs page with live data table - Tests: 17 new API tests (19 total) using SQLite test DB - All tests passing, frontend build successful --- .abacus.donotdelete | 2 +- .gitignore | 2 + backend/api/routes/migrations.py | 136 ++++++++ backend/api/schemas.py | 74 +++++ backend/database/connection.py | 6 +- backend/database/models.py | 96 +++++- backend/main.py | 9 +- backend/services/__init__.py | 1 - backend/services/migration_service.py | 172 ++++++++++ backend/tests/test_migrations.py | 224 +++++++++++++ frontend/src/api/migrations.ts | 95 ++++++ frontend/src/pages/MigrationRuns.tsx | 177 +++++++++- frontend/src/pages/NewMigration.tsx | 461 +++++++++++++++++++++++++- frontend/src/utils/format.ts | 13 + 14 files changed, 1442 insertions(+), 26 deletions(-) create mode 100644 backend/api/routes/migrations.py create mode 100644 backend/api/schemas.py create mode 100644 backend/services/migration_service.py create mode 100644 backend/tests/test_migrations.py create mode 100644 frontend/src/api/migrations.ts create mode 100644 frontend/src/utils/format.ts diff --git a/.abacus.donotdelete b/.abacus.donotdelete index c7002ee..40b3e08 100644 --- a/.abacus.donotdelete +++ b/.abacus.donotdelete @@ -1 +1 @@ -gAAAAABqViYyd3d0F4BEcAOUTgo-sXGPwqHKtcb9FIxeSulmTeVeKePkx8d3lxX6sbX2PEVoMK53ZC9G318UpxdHLvc0e7gPjlw-4P0Jl_5nRhv4YNyLV7RhIMcXCBmHQhgo-GvkU_ksMGQ2S1AxYPXKEXTyi4bsGp4ftIMxd4c6HoHZzDqe9z4Shhx8CCA6jFQYBf4ilqVM0dBBxCSTOLabaI5L4kilHP6LhWIEiYy0Gv0CBmoJYosziTGrq0XzgHxnQNO1HJeDOAj3e9EFmGKKUwSRKIpVUM6Sg1yOG4qQyZcAZFT9glBAiTMn9jI0VCAkVux9dPsLmSHF-PVsfCc1Ru6cOI7_0f9OoYMxyJzhuSbYwf-6RZxxsdPmYJyxv-BoCd9rlMgBZutrq7HtJRWuS-qfwS6VWXDg_koCCjhPxTGp0kv6A4f-yzRcTN9MIGDLpyqDzU0QSmSQzoJyv7_uopFSGJZinQrN77JehXHmzZ-LtA-kPup4BfzuasrnW0iWGVwSOrBSrRmL_0y5M939TiHX7WPshaeCaDC_alhyCr7OpEwYp_WAmIh9ePPU6eUQI8WuE7YPzJTfcAbX5_M0R7GIaWAToQxpb4ZoOsK8fvo3mOzUnA8QMlPA7QcMP7MhXjszLi8DbzaroNDGT0xMA0njBBuS2EsnszllcvhH4M-3OMI7TlDQVAiudt8GjtXWaBB636jhKyKXKCY5fhfbCXmA81XAHZYLTkGBKlg7BwVkkQMK_4L-PCAbJNO3s_hgSUDw1F3PFmETH_kNS9tB4tGT-ZwbLizSy4QC5hCH7R8eCZM7nWuG-BXRUy5rAJ5DI_mHx2Q_yZS3KHckbglraPs6TDrFZ5JtaJFJpa5oSYwjCBBxrFu9s4-sLoPn8373oh3443mhGslnC_T_OCYKjfYF-nMzaQBmqm3FMwx59yWPdNzFeG95t86jVL7MTwgeGdU4qdKzm7Oz7bukrcfKHuPZUydiIvjShPmKd6HOJR438jyvBxWEUjkuwFvhDjBavTjlRjaDJrFTPooRhiY10i4XoATB8UdjFaz7kOSGHJ6Dm_uKga-fH1Y2-1ZHapRe3v-CH5VzxGDqlZJqdFYCtG7PvsJLgUouOBrg0dP2yxjaqlff0vSgiDqWyLauHqrW5xZlX1RTrQ64r6av6i2IGdsUTkgguAXdzm4UIMNYsNvY-e6GV9nAVZDatXAptcjSWlSGLg4UyZZF1L5bDzXHTSfvLxwg30Sw5UjoVF-rdZjLZvmydto3LMBe4WiyydV0fpXtjn-ZF-w13y3fOBHlfkhI32j3VsROfOiWzjuYRKm2d6IJrf0NfkwcNKeB2FOZAdYFDnD9qOxL0fkfks6viwhByJAaTni6836pY_6ZMJgh3lKwXRcnMUxZxbNR_6qLKSnR_yUMlixVl6a2ZU1g0uW5EmVjITUKVsLe1uDuFG-yM6nYSWcsunXmGQ-2CavyXIrUtePWq-MVkvvlk-FAHleXQpvAEXuy1IwumEMuRC29Io7YEkOIrWAzVRBtwunPQMv8wyaSaj6iQPuiFB4Jit5hhZiTE83bFGf2noUNZjY2XK1b3f8t9GkZEaHOKria22UD4sthbO90_XsIMb7BuLdaWeaNA0jAkYbeu6a5_GlSgRSKpAAxJPAJsiSpfvI9ImJVu6QcQeFm_7usbFmFEpze9PwdZZxQ3UawLEriUYkZ0fAtz8UN1-Ybx-3fvvEPkHQEmglP_1aTbccdYgyccRyzb6fctoAS0U4AbSWrw4UViNbYcZxlOCotEDFWn-PHOq673kjl-hRXLSpIhBvQzc10LlYliY4Ki1alh7L87mZdL2auR5HIgAkg4Rw9HjrAskxLMJ1v22fNnc-Txf5ZoT3-7gxSMAI3i3a41NvydackXA377AqO4dnYalkQqhSe1TL7N_7tMZpsxf7ET_kMcLt5pptSiE0Pgn0N44xpKQM9W07cuXtEBLK9-5SnOCMjdQc-HL7zXHMNybeFJwNvTFLhoCbswqurPw0Np4ueCu66JhNjOtJSWS0w5Y2aAr1VzyNImO-TbUqRG6aE4SJEgEfgOFutROdXPd3_fNGNJepw0qnqkcPDzH9v9MleA0xfK5zt3iNO5M5KSkvnG7y4R-88d_v4nzw0Lvu0tEpmhe1u169QXHnvzhPbb9tV2TlNig2JCPSiY8dU72Kakmzr1DApIB_nSdYqncUit1Lmr7JqIK5ZvUbCEi37E5WOnRsp_lYQefJNLV3ZTiYh3VmaeoVlyh3xRK9QJ4pad8bY6C4YsCDxrahRkISsloK2XdTnsEbGP2zP0rHDWCcmNHjSVEXUDsIn6duuRZ0lcLrm3l73yXy6ZWe6ABFTS9Ffaub_PsPHxTG6AoG7-0QEmNaMvF8C-Vxu6IzISLkwjRLy1BRVBFt2q8zrZ0YZf77BYQ8zD9sqMmaCqudbyNRqAdfwu7E6fF353VVz6UMw8KhvIRp-Aho7iJSg073zbBsysAjZsc42ux2c10mvx02afjfX3D-y4KB7ox6JvfE-eEyxZxAmjYqxAT2hGntPSxvKtwgh0pD7gaVx07jgpyL6vmXWeyCVH3EmGBZaxRyOq4ITE87rxOA-rGd56gQsOfQPyTjtMwSjnwj-qtAlTMJsE_D9SzorWzHd6yw8_CCZPnMT7GcYDpK1bfbNNfaz0kRIhn3GRxlt95Ivy-RK71d-W3SW1QSkOrfhAx6OmJafO-awpgysT6w5BeEFhuXHCoYY5SGq0bwboxOp6WqGHMhNfdMVt4o5bi6hQBF0dMRVKOWxK4u-bf6VA4nzzAMN5R1mKxkJ0YW3UR3eOx6WlYqjgEmlGnufXH_19xJ6yMIbGC1MzK07Z7DijREJ0m931dQtl7jTXCYYAM5AhVtkMtvNrXuanDskARsXVl2XRlbhSrsgxTkusFCBCSP7-qeZGQkGiOuilT-FqdrsHsUkKKzSAnJfmRCKQMbNNm2AszLcslOjbCFGQzXlBBTcXXLcDWhM8_hs3MNuixx_bwQDDjmZ4-ZhMzSz6vyJashL9Gm0k74CT9blEiWgxg1FhUMaWtZ19T_4zxVEKOlXk63m9e9Z3311aSKVvQhrt0wweLsaMB5-ViqhCluTHqSdcDNHiosytnmNeXuzjPfZeXfJWKM8t1qdwOwz7kYKaYSrtioXcIMOvOBw4OOgFDEwKaQHR9quB1VWXTfKUI7S2DRyx2vf9lio04g-zfVir_VarobVgWvZV_hlL9nzR2c8z5wYb2eki9eAvUvURpH0rDKBKdGe5mc7ojdZKK7ZoKX_UQBEHwxI1oy_1dhzXfTORu82el1RLvb3ypvJK07hNFkPVXRjLtlEV6fM_iMsGZCT9p3xNaIAChi0QhKDX_17aZRtc_GuBvLJMTqbHFMixkH6nv18REzJBFwumMAZ_YYesiNPsm4esUBKqAtE6YhT6BCoIlLJXaFKyWg7zAPDwJfOk1qIQQdL-h8EslG-JODQd19t5TaUDNX7xhkU3OeAjzgyNCRvZuJBo8BIeWzBxuJW2WSARU8F0aLsGCGMicsmx92U6-ucjSWGE7D7_tmMdWuNXXq9H1ANBHlRyr6WvPJzgsZN7Twj6CWoUKxKptYhGohC1wckASy7OFGc_7_OJM_Sd_Ts3Zgy630nyK3UzpbTvtgbWl2x485RFEQS0qw4CVLJGlk2iCYvkrau2gfPmGhe9UyZZCSZ3xU6sM-gdLJg6cXUrk8h_JPcr6iawFG1NqG6f9hjFX_rme1W21TT21BqisBRZVJJcSgLL0SVZEsGAeRQJqFlnNqUygp_glO8pbrKFHKQ3-8l8o8tCcWi-wv8mIkcuA2c6rMKthXyGuMkZ46f8V81Furq7XSCHMfdy2mvGgPgmRgIKjiquFiQzYx2MrAd775SGpfNdJgY-U4ZGGp_uYe_YTI1h7j3VuhJxMsqc6FWnmE-AYVE7VlnMbvvHb6WM5N2AvkbmHemmqJth5atAEHkjXlQjx01H_RjEndL2wiL_1b-l2bjRB0Zh186KiVsoYokNpVWh5jFd2Yp-jR-BQVAnr2sbKXHywaaC7eDfmC8PXm6KWnnQj_74p-iekN2UAZIEOWuvyi3KmHxnwc4tkqps2TnwGcWwphP2bGCls43KjDBq8VCXZ4uCsqNoIGMhZGroJ3ux9NDwxYFki-rU3o7wMw8kRSaF1S7Y0o-11ZJJderke6yq9XNqho18VRms8ZpzrASBcaDNbbmgULPrfSRXTBvyT2mVlOjxFrELa87A4d2j07TXi336DDlpr4rHz5d-wI8zTfBekekgYwCBRJIdUdlfwDcD20sslGGf7vF-TY-EyNLnN_WOzd9gy-Gm39lZPwj9FstRfH0lJ4-GYzTWf6qIIQmPHxdBBgi22PO7HWHBIDGUhwYB2fUD3LUdQtMWG5RBSKZdoGga4AER18WOfgXBOegtGi3sw0PPy_ySQPZ9dA2sIRZ3iisMhFTZdrOfSvgrC4P8D-smj-YDkLRP5rjCmlQYKtGq34i1xHraazNfvydgHl4m4j2HgU4f4iDUxHjqkvSJk_oQAc8Wc6eFLj1Iw8gor1exTRZPM21_-DtMPMsDw22kV_tREJ0NlF9m_8BiQpCPhD_DkFvmqaxhCpOGB0NxRLL1qlmPjwfsSzMn5BL8zhLmu_OrSsfsZKeQ7EyzXK9h_wBaePhm6KbYIqnefSy1Gv629J5lp6Uicd0swZtJpEdnm_Rzoei4i2W1iMCd6sbD5QfVEuBH73ezBfVB0-UdRkEWhTXJckJ9QWhpGlN3MlJm7cfDmMnSZSy4hmO8gYppNYvWRGrWnPxCdb2LZk-LTxQqDRgJmIxhGamN0gOAjipk4pu-TDY0Mj-8KPUMy6_H4_n5GsEqKNGONmGX4Eh8xLJR4GsCu6qEnG66CCtYO2fbyIQDV_fr-nXfOKc1CqyCbqhpSRmzJXz6jueJHaxl5gLcpqoRtmjyAt_c0AFqazFjGOI8vYIaJnPePWDQT27WABHTnjTHvFonD4uVxRZE3d8DxmYlelxENAfk1AJiCAKiXvxlbJYfgavWxSC37v5x3JUDc1DV2GGGW9uVe7boC1Bs3Qk5kyRpfKnJAvdKcrVZPF-qzneGRTHJq_WkQC9ItXPxizN0hCb1ZQbtFcg4Cp62fikYYAbiYTpAD9EQFmcIHsffxbxFgvSQnpjSRA7qR8j_8vfpkQB7Qm7IFD25DBWWm50x6xB9jeK5-SAWFlmFfnHLy1az4idiJwyjkIQ5v3tltdutAU4Z_epSrUfCXvVYhSZXcLUJkiZHRb-KcrSqtBC1NHDdEo9ne36rofi4qfM020FT00FG0PmJYtpXTlZIkGY3n3XTvWUHCRBbJUez0fBlTrlVgtbTDBLuvK_nXUsJjgBScFR7ZmXHNeBDhQ8R3foyO-vQnkwSzpkOy39sIzsQcAKf2OxfRmIye15pV3c0M-bKG9TmZImvQUkvA8IetdN3JIR__LDO-e6xC2M9kqr0fPm2SPAO11Y8cjxSVVmdkVKjTlMluIKTEk29i42gjrFZox3Zgf_IzVm-At9hMzeazPIJCxRAHnlb-1v_oHzf7MNat_bBI-gOeWyGAcdl03jJ6MwrfBrTAnSXZ6QCuAo_aE_CebHS5kkg2lEQD_4GHGPCGNJBTMTa-lTS14ZtHqC_u5st9moD71QzkWo4dbmheIjb0GCiPxNfERRxX76WyfLn3XZMsJTygOk3K4c-Jg14JSELAyP3XPkYy5kcF-67a-XLNfMZrU0B25SYxgesiREUphE2tBMo8MsXe2aGOdgmoUBggb5YvcLEtBQZZrZald6UtMBkp7ASUGRadWtC2yTo1mv-_hxt29pO4PjO-GLA81Bn-Qb3vNqDgcqZNEp9q5SxzrAEoht2BZwSdKM9trSpF1jQVSbyyIDhVBaXPusRT1N7htOWx_4kJkl6-bjWGbLtg2WozNyt7v0upVBBOnbkwV-HkrK7hu-ACltYXlxSchJLiHlP_2ysnZhUmQqZ2QDfHYmf7gqg_U83qVCoapibJC6sBcvgGpPC7lB4axQ047gbZM36gcU1YN8YLQQv3bUTD9plZqzXhv5pinNhQGhxoTwQCBqYc0jq6kwJS5Mhh58GnCD08F06SAb_Yxd-9CiBo2hCjCFlfDZyu52sFV3qoFsPHoHTyYfFQioPHT2TeWbFKJJNfHtdI5gTHmp3-SDywLtaiOXt2JAgLLRcevrUOYSlQAw1abTp-A3qY0jQ5rYsKeJzAPLOSwmMy6k-WBHAmZKB9xubuwyrEfDzavBR1FRKWWYhOKVuolq5WRYpi8qPaCDu8oPKBGYAt3yUTI3tWLQwy1qAslqmlLRDGhdUDvHuSeIqbgsOFthyachxdDq7SQQPwomrcHKoMyvr8YLfT0ypcIBOxgKqlTKrvot6Vj9HSnCMu5EdJnIJ4SLL_OBWlGJuPdNwrQ-BRfqV1XoMWeTvuHCRquBoX4ZF8kwG6apKl2qPFkRPysB2Tx9QMjG7fD0O8fbd4HmASBX-dz7PBsyyzUXnUHX9VH5CV59NUULtztZ3NxSJVc_rsEL3LdFEvwNSZQdXTfCQMEtYuuNkmfG8i3KXdSx9UPZhCN9TcPObfhU36h6tXL-QtZ0U_eckngLjQGOvo2mg373o0023UYrbtJwK0oWCXSo5DzDWPXq2mwpdhjSkgtYGqOXCpfxN_EyM6JDBIsN4-8gstY7qrrtxpBvF38CcicUaC3e1E0-id06Ur_VdEtV2Ii2u96FMfqTjtyZb6r7OduTiwYzG7DFETCIj1ajVEUjBpOgSrftrljW2D8uVJXaFkchDSDogZZ95QfCN1LdtSzWMsR4udXSuBY_LhDbr2g2ixELEVQ1iZOem5CRUrenx6uTAWvfzlBQkBFc5pB4Q_H_AuezyXF97J0mA0DfkeIVoCqvni4pn5qlmtyBBPAPop0vbA7TTZzLL2WEgAYHKY1I14bzC5WdF-c6KX4o9v3dzXkGydoJJv9AfAZ4ljz4X8YIaMEDRDaR5NPZ9hN0aYjvJUxux3JmjXuqz2C7Y5fpSecJYYE3yrePYcwHMLYAUkhhcstIjUW-Eun6JviVtTfFlao__IBTefVdtKpstm8Qq_izMFv0cN8G-iWYRyZHaEzZ6PgGVuQv96KJl0yJ0ARJviYG1igmQ3xlWKTuDgAoyug2YnwguCefKRxhTck9EbGRgsJlgfbWTcBbv4A9jxCsF3OaWRbmK53Ffjawolxq-VhbMITpzYmpEFJSnj5Sa2__SN0GopTvQetyI2FBYPXCk_bQ-imRBZPwgcKLWko8dp0EKZdQ1CYvkY01puOEBS-QFN50iHhriMFCcXyAxJNTFscNu2FFzeWjWP_rH7WOv0z45FukQNzsu_zOSzmVzg618Sm5NCUxoW2FVNBuedWbeYtWNJTpheY-L_vZ5b6xUCoXJkR3duX8gJXNfWNa5lDY-bYmxudpUC_uAW0jLTHYvlGIsqF9PlHZ8rqWMx0d_F2Gc8kEVpM5aSvPjdSoVAUaExvX7et_UQe2rMHS-U2zO_Yx3mrXBvesKc2yafyObUTsUUqAEV8MIcoRp2A-dRME7NFFjVbIKT3bcO3POziYOvEo-1H0okuA8SBdei8oQ3Jp7HvlVJ0sWIs84DCvJsizFHYwRZg7sV4TiB7F1AmpzAyPVIvngeIZTKGA40FtudfZHl0RhJdWtFlyykdq2yVC0dmtlYmDv1XC6KSSR7Hzdl_890fZkgvozVsQbGupWXG4nQ7q68UAiPqr_O-GfnAZX-HduqZknWA9g3EdwjpJ0keLUO6Zxg3W3sCUfus_gFlP7QCXh6VbzAj2OT_t7hxSTo-eHVlz09vib1ouahU1au6eOWxi1Rsc2enQMiD8SXQ7jmam1I3aGsBQTSG3Z02H8D2rkcRt88eEQq17RgGR3th1dI1IWnxI7dQDkHsib7xNQCWkDcZZ8HOFXKft2JkjXwFfOrus-aye2NUTkeObWjkKkIl5LpIt_8iPcruDDhoQWrG0GJKryTL29_JA85B-Sb2ETQSm4LDpBPPf-OYheibpyf-aofNs-guR416GdzulY_fdfav2t_2-q-eEA-ntGVOEN-uoADw_ljyUDf1cSe7Ngnl_K1DkAAuruFgFsJxIudJs8sSxtUhMIrFYYHtYmZ-Kat-_iqX59IKpUjWuz7278379423EDCwYFdJjHyXPnRw1AW_XfxEn6DQT6M-u_85mLiRUvlbkXjga96efzLJBVfEG8f8I-rPvg0jHbrlsMvVQSKUoFbqGXNOJEXjKRtBFvR3cnRNGcGUDcnCggJdBRnHAyRljYANjSBD2KY30TDYZ6aBZEJn6_r3PIugAEnRrzhkcA2Us5nKx6C6Ztz-E7znTS2v_IUQ_2KLFiOBR0CAV8WhI1JvxCo2aO-Y5HLvyyBSl0Rt2vjpB6hKQl8omv-DbEcuQob-sUP8PzWBVNsiUFJICn1CKxqqTILnBaCIrxIp0gLnaHukiG7nqFNcHToSZN7kqHr9YOE7fkR_AgKF7GEMJjbWhE_K04kaH0sS5ZznH2-elUaZUcYYLT__qN-_5t8FhXv2z-F_kjmVpMJ3N9MRAZQW3s6FqRxZDfoMw1ZXJFDYexuUwyqwNjiQTtErc424zVCa3_kANQ6010jChk0ksvc68ZK-0WqjlWrSmqvPhQGRLRjGZsWfftG8dx-6J0rZkO-FChUKxkMc5JG2zkTEnuLHQuyihhUTx-TR0DUJqJMdhuI6YMZrR4rmN74Cc7oklje27WvW4S-1RfQ2um8uC_xT21tGHuVlUNphS25awmpc48y_m-uUh5koAt9zHwZEIIBeweOKyf4sExdEfuL9pYpu1ixMLKxj0-oJ2HwKRDzZbGRRtrL1S5uQbCSOHHjSxVvO1O__wNCjO3G8ELbgC9Pfib87o5o5h3rF5vCcLJoL62MPUpH0dsZpcXjBAx6T5bXRUCJXpgiCK9SfQDygl-M1VEcNPe6WZbYbtS7EnNGAdzdGdXsMNRUo6QWT92xpkq8vaV9Z0KWBP7mJUMoJKYe6i10mJvY7Nnb35DBIKPEak5t-ce8PKNDJ6PA-91GxUphqz_98LlEVWGStHyRaverVf6VZcqZbpQyu-sKq2U1B3-c1_7kCPkJ7PMpmryeEEjOl8tXPo4T4HhO1AIg_qePMxUJdXavvqdDGMPq-xp1dA_xScz15GqcL0U-_xCKwiCNZCa2b-7Q_hX2JfOAKvASNb596abj8tf6rHToCflwnzZa6CuovfU4Cv7NzNQ5-MOXdIeybg6jReKDbjilsIl63rasUEbM0MAISu4kafUTpKmUHAYsAUopSAWrBnLFNmaNiCw_RE4CVtqEESz6mo5gelkB8kvgyPbVhbDKXM4zTzrUGx4rI9N4knJ_4IdUUvj-eUttVaFWEMl9YyhyiKMsYbp4N8KU6H1darFZV_69Lo610AAhOlW1erWFyVJvOYfr7ocK-jeSLRi_g1FD6tHRbHkxVVuWDibM2O3SmrdWXaQVMeh3797c3SvBHAkvYYlhX1NHa42EOq-Pi7YLalhjhEEMQBkGCsNC2tg9DKoUyO1eWX0otFbJRw0VHZseNUk5N8kZml6A9VeFbmwaXfR36vaEW91dmMK_HiGE4bpXGcbObGb7LDtpAIuTIgKUvJmmQOQZSZ8kOO2vmCgClGN0l9_4nFLbPhjvt3S6MrNb2XTlAjzRSZ-cnh3HApAriZxR1cmm_mM5VlGzMM9nk7i9FCU-VYd6onz7Por9rp0OU-depU4gfPn2AbVeIKmBIX51cNy6q0L-g1Khj_FO2tiQcxeKtqQra_wzsyk-IBNtfWbpdy18mEQP-Ikr-Dxokeuhr5p-3AIZG6F5g1SJaCE6Hx4rfTHZPvpjk97stbkxLJRuLvWekfm6uuu-TOja-NtKHwHhTsRaQV7S6oz1Cbn_kPuOVA34G_Fyc25SqiDNDXgEIQQ0Qw1kEb0psu0tNeR_5FZ3tgZE-dP5YEZ_qhzLbkprNrd4Tmo3tZ6YLZYaJ69u4yhzZsonz1f6vMeJbvwE1Bk38ZASjE6MVLzkbSMH4-puDodUaxZiyPPZQYIOBPJVEX2WmIhI8iLkSEIFEzUBUb2Afg2fHNUHF7ROCyNcV3tXrDpcGojgLk4istw-ZPr6UbVX7xJPqBz33F8imb-qN-4tLaB7UE-KsgbIoXoK4D5vfb1fMiK_nkeCdHaFjBkbiwxYLe-bKDN3zgla-_I_QOymPEirx5r-jpRiDyr6KnH_KIgQPFCGVzwOvlk_5Da0KWRi1obXDjKcBkIdHAez4U4Gp8Tqb7NNcm87w3bheLDsEUC-TNOB0RdH2_-nkRBBImUfdxbsfzJ4KZ2aT_Mb-_6YZUyKSvnemPKgr-CEiQ1SI9p9a2JcI2Q9bNCC9I9AENfP1Lo6s0_v296BfZ3F9Rx8pyxX5xf-v8esFMX0fsZ_thRhQpSxxd1gnK0h-dNp2bReXu_TvVk-Ci0yriaV4yrrrxk_ygQuOVgmH11K5UV7FJ9axqAtOiZHQ6rPHsp81XHw40L912zzmFoyudRU95joPwchMTafhPXQYrmrQ-kACafWN-SrxOVhMpIcN3JAwiXLSSF3rUfkbGMIag0Qq4BHRK6RifT59FCcswFBhCFOQWPGXvAdb03d3kvSb8TYZJVsdejdFuznw2MuQ0_F8okLwdwAq5Afn5nBvLrdLcoF8o24BpntWLwKJj9rskGifM1QzVOaFEz6ei7VKzS7VKdjWFBO4pR98O_KLvkaenzQSNgHaxXR2IOR4KYNVfQHtHao8GMCoYH5-CQLXHL0woA_c9h42qP7t1Ydcriz9jR4NJQhPEtpXR8H5DJxN78J2NCRwaUjwB8Hd_oEqI8ua-55wUglpp0drQ4yLAZ9KPORWJ792rDE-rQwrWlyq3yEZZb0k0a6PypB6tUEviJ0JdL7G2hS-OUi-DCFWYnS7-HvSPZprDLp1eGWptXhPg7oyVrbCUIverD9bHNLlJQrkaDyAcFM5TM5QN9oUy9OaZl7cMl7B49x-PxTfW44rDmPlV2ce6idFfnH0F1_mCXU5TT2NTScnGHCNYq6DF6cIAP083Sk3YVnng5W-vKYHeJnZnIVIG21Ja__xXJCrk7EjhBaAXjbAxpLpxeMbhSHITu5RsV8q7mAVKMwhr8rcdcjanLoDSnqfT4WpK74KZ1ImK4WmwpUDuP0ZYMuijNMDJ3oQ-Jlr_Kp2E-L9yu2pzbwxLoUoeaJtnFvNU_-W3mPYpkb4_I9hzwDPomhtPyqWtKcPywne8PtmsYKEP3fAalxx_zY0leLdQRtfYn_Igx3nyZRkM9SiHvs5_nmFs4zXaE2NGVMiNKXMcy6qtJZIcTyzjjl2rTXDkdiMlqNJ5-k2rVc7--iAduqXgKSuYhxOwMHpjbZV7dSiNAWgDDKD21c5WCV5ll5silYmWFVykg1Ta5goCg71NjhC-r-VSwUtJDDLHU6UnLWaOQ77FmsA7kmvNnYekYmOOml3S1tMVRDY58ojrukVxDIeramclTw766SPJCDB7rm4o3OexfXbB_I0ZXIa5O2hxI-Az3LMEQ5twd6QQ4LnpLzZjGJEoDklDEXDn6Ld_K-71kKN4KmpBgD4ZRj2izFqP3Nen1D_v-OgDdHd97bPsEQlFkVtcIq0FuHqZLSZpDWZzlrDhXxPzJI-1AXKDuH8LY61RZzcYyNVUzd2v8RRQj3m0vaRSazCWj_J8XHtXR14yuFs-kIXxaYEafXIZQJ3fMLRRA8P8hI3XGmN11sza5Ka80VvPKUvfXitn2dtrP8o83g9rWaQ2FerLxdtsA5D9GGDf51lYzESKzIfd3Q00z-yzuMcStUTjekoxVqwuH9b4frvrogeIcxARh36145UkLwfM8DQodB8X4Yc0FoAYiFWLMpP--xGH3oX7xEhBQwoW6x7MGXkwUXPSNn1FskdEtvSBPKh8f_8hNw6yVXgYhmjr0ZjvkDrhtl8P47IRHnSTjfGXR9eaLZKUoge_Dz_p1Nd4-KoYO7_OtoPERfzo48UeNb7JD_KLO53oqJXjeeRi8iZu75YUApiZE0uVomDjRyCaN114uSkN3FTQaqoxPVZs8K_5I8Q1XisYFaD_QfRB6Z5MSnyZpwLbo1e5rm5sEK7FMJcCosNlzYPzhcoG1lF_n847kNcFQokUQDI-DJdz8KzBfs1xJO96mg1efD0R8tkDY3HD715pcfujjW1sBDFrMYpnwfDNo0kqQR1ar8QbPd3n9nV1zO0utri_pBMoHyOpBgwl0QSRPrHD4c5Nud4EuygIjNhezb38UdIs2hep_okovPQQGffxfSYYNJjRVGh1AbXmAYXoZpdZhywVEUZxzWgTnjjrGNiNpomGOO3hC-f0jaUcnMS9YCKHkMJ7sQKp0jP2OLpYnC9sLTvWavv2ZzlDMAjyw6hgrZddfcwEcy8m_su7vg1-WYP4zDgA23j5GXOLEexLTAC0Ofha7tDC5mO11ijU57FTph9p1-DWGEbzcI4ytHBQR4VFDWV9lqpwQDKUZrFtWAIU0MDerHTLNA2Dqg0fvcNS5XsKwvCgCtrpF-IlNXJ57iGbdTCo8RuvngLEETNJmgMw0PhCDl1GgbZ4OI1UVcUifYqgYciKpD1qzQtALve0TEFasVelchOQSfp9Lv_ywcgPjk6og_jaa_MJ1d4i9KfvauuwC-eFAqZWwvvpkW8rkObzJAEULolcGoIWibVbFRtn7d6jOcKEt3R5ppHQwS6mwss3yElPlB2BgHVNKyiUM9TU_QARF-muxf2n_eL2EXYzDPfXqYAdLDjxUON-ChUUIIJRAXCWvpeXwXQJ6uKuTBZptN3lJcKN0G6Whllgi38KdR4YD2cg2LSwyXmd6myqAK27eh3pA9x_57Gjy3xzz2f-DW9nYV-gaqIuEU3ScmALDbx7d6kTnp8zAnQAULsrraH5u2Q5OuYDpng3ax4ZFbhp6odIDXczXfFDpIjqitw6UmPYNIohnDXpoAJKk0edpXyKEV-uVs3xCOQLdUp7aukF6Z_8BfV1ejwZ9lTiZsO0Gg46IqsqFoblQQhnW_3fx1BR2yKKHq-4YbxLX7N46kTnFYdLD_KOFouPcgpcNO64h_jOFXW3wd1jqW42EVuKq4OD41ygkcoTdi9dLBWIEFSlpBZD8sz-zU8OvJ5sYuPOBQ6UeCsrNzLqHjgkhCSGbaOAExS1QIAKiAfMShFe9qbGJs67Ws_zvNNBhlWvp325yGT7jiJkZUrOySMNRa-9qJ8-CGZjTJKiqG-fuiH7mjNdUoS55nIqbGlVjUBONjLLxkG_DTOgTF5c4PDeTsOfnfajqjTVp0PSbl85UyC868h3szcHnb-UQXLDfg1bPQe9laADMLA7bPIPheipA72MhLrj5OO3HfHhSb1v0hvvqUb1DY3EJRcVdoa7sYT89qdgzZdESP2Aa3XvH6OPCjNJnuyygiEUxExPiO0CId1uXrr_yVTKwQtzvmAwzH-RYcioedYClrdDfC0hNp8Cx1neqFoRCcBouIhc6Am69toT1Ituv2fDuhkBPGeuW-2d01UxKLueofEJCh9nVfOVsG1CyzFzulJ5p20SeEHBPjzxUSzYhdKtaAnSq8bjrJARyIe1bJG6ozMMJjzzJks-Sp2HkTgL92CHL8DZ3bq3fDjALSqOLKWI-3jzGf-kMog-DFPpFI72YmAWke55q2zXPSB0IlYWRafddH68xOlArB59Z_jTLBP60Oh2UhI9F-w4kt3Ws-RvxfHKIUyRkelS96N8vQc7AAtAjdLwizDBGy1AgFR-exeWbaHJviyMRZ1GrpEQ3H4XJ80OUS5_zDgNozWULBWgamzN49j1ajsVNqd2X9an7xcJL06LUCLh9I0mkbtuTkhRlfnx-zkizIMVN9HJLr7fb-mWnonNFWfplAoWD_eAc7aAqJBqKYpW4YDP3fPAVtr3Mffb4o97LOFC3JmyOdPJDoUQcJt6NcJut_H_24Vf_hzp_WdDnaW6Y5GNViMom-fgvRYC-WCkzIg4e6Z8duAkFZ9pDFkthVrxt8j4hfoBSW_p7STlN75KDSWb4t_XscK5gpi9CjIV55JB8yHAuhgITF1AXXqseafoXDTPkmN97TV21fS5rQ97eadFxoaQiTFxTcY9YtAjWYph-IDQ_VJO_Ym1I2HD34K1qz6Xnu2RqcsWm7D-iqzxXO49BwGEJShYInW4TAlhknHZxxNDByUqMg3qmiNnVWBy3lutTshFLabz8ZTF-gi9jJK7x8QBsUuFc1kwScdAjLqAgFgpR0P6JP8jfITLw6n1VDBob8ko0fpxwMzjXBngZ3w9SbLZ5sRRNdLSNSezjilrG33Yh_EPNpXwiLxf8eDGPUOmaJ_YvLb26x3_bcni9yAP9wbgbEQj7kjByBGhajLN4yOdaqb5RogaXPkJ1KC0Adn_mNL6jgSB9GMirLYi7f-lBjyWVLQ6eflKUdyOzpQBNdW1PWYBQyFvZ-ZlkvWndwByUQopyt8pemuLGM8Z20tkT7NkYMBuapQI_lOCVBGaTBCBaKhgJ6580_2t-Vxef0YPZBeRwy8CmxHDiNcDHNVzkuz-J8TxFH94PBFQeDpdnwxQQ1T2ck-NYBrIG-bgNyASWDR3nxwwvXKEHVhh5XLGjY2mQXi_sO8zdSX46H2U9l3AYhLouxjuw7KRhLMSV1lMflLfm23JaNODYY58bmCIPhzFxT0FYZu5FC0uKBLl1JxTENEXMCBPhXZmGwBHLXnLgaDkiaRdSyxaopLUptGiemhVsgoGuSYMidj63dCfsWYB9KBS7EJ1YQpY9ym8g07wiq_a7toWh1SDz3oG5Yiccso3GHncGMGblh8vx8nJFN-Q44hENB-BGxMouJsHKnvqau9zVRUpc6V84er3nmxxYtfjUV7xB8j09wXiy4heU5MBDtF0GjBKzrhi7Kkvt2op5cXa5xvtAO5Joq_29w5I4p60yUfXta_HimvrkdjZdXlBlLA5yNbA7AdFywdITE4Wc7XAiJ4GOC9QyK0CkAi0SU2IVEbE-JyB7G4hnMVD5fpf37y_oIzck9abnVkeALN5Gbf-sbv5GhH3Ge3nDYFULr1zq_LuqfxDv4a7Mwoc8Gl3geYVuoRKzcUgTOqyfCVztgxy0QzMiUoY58ipFgo56tbREQvEO_NhMboMVYEZNdbWH0QdK34femht2x2ICmdkSla9J4x8ezxeXg2xbA78LQ_bOGwupykHkOGfZXHvA_Zxsyruonbvc4Sn1UKShBewSXE0rP_JkrTjSd9GGixvy_UzQGozEz4bJikWbpkMVMAocOOgCDuvrPzxquTV0_zy4oT87PJqeXMaF9sR3fQQPShsgwNbZ2jFWNGg0mYIEHGasd7pTJ9rrALMIf_X7YhrFxPBZ6dMZilCRbRLlb_sk0_CVlJBdq4xQvBOMKJJjEE67dJwpgVugBMN8Zh8y8kQfeTgYv3QOXjm2QyqwdEMpFf8ZuKEAeioHkmfa00yAnyOpjviFwM-XSWnqs2G-yRRftmcf_COhcNZ-4vlbfQ19eczuzwRYzbW-rtRWRpGwurqpcs4GZ2z9tMu4jVZR_D31JVAuVXjAOEQq6WCfUD_gh98racMYrfZ6FTcc5pyLP9fOpqmkCiaVgLlZHDlugBWNFxytFutA4uBruTlfasC4Y7XVIHmfTMSCdJd42qXGHvKFibDUgIG50seZosOs5HKLQMt8IfYtc47AIRHqSHN2e_N3vk5beErp4Q-QrX_0Okcp5NeP_q9iC1-_BYK-wAGUPr-nRxd0GT5yeZYmZQnDvPe8Xz-Io1i93gPJscf36YmD5TIhZN-4AtD_nZA_X0yf3i_hJpNKPQEvezW-XstPV0OfPaknB8btDXxfkXAAjyXICK_J6VCd0REP5i-CBOjPGQo-O8uhQxp1uv9tWtrI6VNyBqgl6VAvUKmNFjHpKL9h8rZQZAz2KKQj2EYfSWg4uTjSMeOZDNOgKT7TJZ5a-68Wl9k1YwkgPkbEQyxk-fLz-l4KRPNaeNWFGfa0j0Bwsf_dU7OWCzMSI2xBo7Mqcd_sx6Ovp1HZ0u-t8hs1MtDmVaQUPsRsbz2vBxifQqNRMlx2jHh7GmX-Q37VHFTBYGzWD9nNAvAV4GbLcXxhm_iPPzweuCml4Y7ox7k5m4lzejajYbjuYSn7Zep3eLG47qZwZhL_01ASURm10Op1_Ub2N23d1vACz9ngHwsTbjxPpPDMCTVhVNRIfBIouiiVNJBudGpUb5p4hzOVw1YQoZHTQNnjs4bxFpC0cz2Yzaa1CuQGf4TD9R4N8ICcS6dR9l2LjdNFr3bDqG8fV80WrL2XZ6VKdcKUYRtKR5aRHlidzYnahEtSQztBIQN91qcfSp9z9u9p3E3zNkSYgmGnR1qs1mpOnCT-k7zagMjDHOPoQ-i_ZygzvI3BUKrL91RV2MM6lc5L4lcNbCrybDMMzqnSYRBNJ7Hwi8nm49FEHTogr33JawcUxffIyPivUydrjaJ78sxikAEWyzRoW2fURTk0jgZu2E-Cj94Bdil2EkTWqjQE_eeTMVAIpZHYpzbP3XFxccFF2Ks40Ja_nNOgldRCKv5Tn8ZZzluzUBJkBEpZ7JwNiYIiarApPJrQlsiTBN8BXbEl7GpHeyl28iEkQGeE9segp5j_pJNLNWMvCkmfhvzhQpOp3XYMdF4lNyIHsXt6V3IY9a9Cu4-ncHiUhdx6poc1VupEa8QpReo2-i62cPUi0n3hQIDfZke81FrW7Kn8DrxfxaS0n0HkEMIdzhKUM_ZlXV0sGWfOddeTRaZ8F0Koc8CgeRB3mRtFWj4Kp0eBss3P4DHsCfI45d3WBI0ucz1uWnpppykiLMnZsg6XpgTqIbNm6EI8eKHsVzhUVDaK2RGO5LEknZNlGzV785U8-mFPamY869JOF_ZTaA2oXiO6o-zs7V-I6-AM35K4BlVaoZOySboynzazo_t3-uolUwpEIdA3FetzgYTcT2YVm1Sp1YL0-Qqvp7sHIWeYiIV2OU1UkqalwS8QTOWpy0zMNkTI0TMCjJ57VmW61FvGrzk_TDrUVMNi34SPXjaX4WJa5OMF7NCXpvXa3PUkitICb5ME_MtsqxuhoHX2-PDT8mGccmEpuVaDuzJqHf_SW-1Bq2STuEH22SZn-odPnH_6s2sewL2CblzuBs_qxSPshoySG-k1S8ITEC1_PJHSO_MCsdQcM8B0y7jw0dRtqOLT58xhG9NYbGqyaR3Q3_wpvM_ml9g3fgJfjJOq4IXVp6sxtGGyB2CM9DrIaqa5g4vd3nUkC-g2LbO5PfjUq_Q21hFZum1JwJv3lpDrJsDdoIjuzoVwPNAEtRwM8EyRD3iNcHJAvlyN_neCEYBk8VifMr-Zr35ksNhchkNiMHqZrfcuy00PVDFH19Yl2lW33Iv7vtGNXZAvGcd6v48keQUMBf_N-coop4-4kVwRhAktwXvIx4dz_zeiIfnFQwYgq9HtoOmUXPdLkP1366y5RrFAtA39fazs1gDhSlXYIC46L9AkOlOA3RvN085IRAsFldpEa-7f1W0cfh4QdECJCR8BkxhLbf1kC-TDfrRkaUm \ No newline at end of file +gAAAAABqVimXKV5fwke5usxa_bRH2rMElweNu3cTp4TIOWGpycgRbRNwKQA6IbwlXtPe4iRbcigc-vuflsahfVVj87tz-g_DZUEkLB6PBICbojeroo9sw9v1x_1Ff7yY6jQynInL4EoAximDm02JtewOueORKdwQhMiCq1OEbEzwlQ_-Zw1_S-QHJbvJWnk2Oe7ZM5qf-rnkrPaJswMKCDn8B86uuS21nP792zXdCzxBR-5Ykakkq6-syhNLRSyk5LGIDxSOVR4c_FenaWOQmX2FcvWEQW-SGbbWhsCgnWD6iiyP7YDOFUaHKgPtYHhEKQUv2Kr5gOx-n8ZHHR325qykIXr4qo-p2d9qjOQDCCoIxfzyXxlyRdbElBA2rMKfovKkou-EmCzJUq5SaM8n81OO5lUUMZVxBnvc1YgQ2zcekbO8o2gHSwLSOCNnjOhDHaerbltUZ8WjTveDE2Nnls9slu1KawxJtrbPVmsKI_HvXNCxE03SJogXII9OuY51YrfZxt457Z5WM2oU1IvhEjYH1fLdhEZsbAOCuXCywuBBfwHUcJhAZaYRDpxokMfQTM9djBjQI909OtQNx1qe1OB9bNLJ2v_RWv6HndsgAj-NsxggWfEn9mnZYlp742-toHRxy0uyGAYl5uNlZNUJmeHB9lMHnKq_1pIUm0ui7W4C913Z3w17_TZzKILcvRK41o-khUp3mrJhA5qX9pfR8PZmXb0NjQvDCs8qfaKeL3Z5FI-ObnZGZxLHPiHzuh50E61l_z7FYx9JReeAMMfjjsthMFIiRIhUR6VRzqmJ7cubrorLIbILrEi-h8Na7qUa1jIj_1t-AATC8acn1qY4vk8NQiEKBy8iV6ThL4lbCWZ_wL5lBQv4Dj_3bQw1JPxqBs-66xLze095kAplxqiL_d26ApUNdhctsITtgA6FgRMY14gcTiOPz6OnPTsNkNIwP_62IJmZG_FqEWXnRzkO58mr3ivi0PBvpbykQk3jcgE8wCDnjHjlt70uChHwa2wtWiWNSC5Md4ZH23IbJyECKGHjN9jU7mz6dfw9GfGB9jN4nUy6u05tgakCZhgENH86l0-QRwomQRGjF0X1ohCr_12OSukE0Avj7AWWftCVe5okXF_XAu2d6P-WeOxJSKGrVPNTV2qLYT-Bz-QY65BxZfSI0I6erhjSsZIgvnG7dm3itvqpNQQGVR1AYCmBnCDlyauuVIWdH3Opx-86PV9BSHu8kzJWOcWLOufNe8CdsaNKEecGxYTjxa46bquyI5RO3V_XU0uTbbrNvcJ1q7kZqJGUf2FfRujrZDOrN8Yfh387a5bIhEXWXqy2EynEeC4l9GYk7A4TLGZkbhzF1rJfFw7kaJGCFmzsMI35X4-ojy4zsaOHl6oygM7nuzxCWPjhHavhamFgEFagepNLe7LH3Cd2Cezoo90JJHQfOsFj6DuOD2WtbqYj7V6EVzZl6M67Q3D-pI1S0RWYW0Oadj_8int50yK7mCE3aTE3IzcTMChnqnnkEOSJjioUq1ird4dh-h7YZs5ErvkB_ht8_u0PnHp9GRd04X3BRk0gll6pJ50puRAvjkT5j67ox_kvisP96tfClhljrzKnSc6RjHOa0G7YoKjRpRVuFnE3eRnnQPfNhCUbjXDAj32_SSY9r2jNrabWakr7pXd4UNuJ-CB1YSWy6RCbj74smjPYaektDPczo-_pg_a6tUfBWcqnCH04gUnCMAFugh5kFLIyZlqj4-o6gVSxxluSgUBeFEffYwm9_TWs73GTPrTynrsRJHyK5nKcwLDaWbOiXhsbqBmKm5pyWdr83QvneMGzUn3llnxbriZH7WBU8AbVRQCHKdQmnr2X9Dd7TlSVDBW8FuCM7j4rCr7fG9g9oMPxtt1IPl8DjTFQ_vlYsr6wTHdpOq8W9dMQ8h_NRXOKpsD_0kkbyAkn3juOjrgUY0IgG1dNdlshWofpTI8Da_rC26kNEqVgiKHHVuMnkcLPSU32zQ-KJBsGfZZtnMaCw1VwoogpNNzvplplPS7tYsfoZWOSq7ABWQeHmXk1I6MnozDJ_72OvWVBvlEo-F_syhMADjbVwODHJ9VZG8Pu_W5rDCqhsj9vQH7o-5fZkk6lol3GCbSO6irnHSzBTJjcai9czsIPS7654uaQRUorPhtmS8gBb-3wjpA7GLsW3Af7llvmaGehsr-wNUDmZemjBevxQoDGEW66OXOtNbSTpD0fl_la1N4fV6rSDm-vDocfbRsqqLM05cWcSvhqFICvXCJNUv8dyMJMtstyMd898HJ9Ya2Zd_fPhl5UJWsW6ux2SPJBibazOVjjHZ0cSyuquAVY-5NodSDf1XU2vttUacx8fV50EODJZJOxXB27jtYh7kyj7IZzQkzmjM16P523HnCQUCNScqmBhvBuLkqe56Bfl0d-uiRZf15LArSR1UNWvljBerFPP_1l2QHTrdJZe-yE1W12Xrn0hYcd3PVr26VG6rzU0JTS8jrL4m-sKAvohd7jvw047fj-SjWReP2Mp4r_pBMsdAAtUNYrKoGEyyUa8Kx_q1DyviNDvoT2Mm7XtgVpAlTWGHgZ7FIT7s09rwpOoJwlnGAkrlnj2Qn04MWkBlcu0RmzsW_t_5rzh0L4fErGJdaIU2GPqGU8kyHq7J919WE3xCod06oNPoPtFO7uA7CkfykJ9OsQetXhEIZL59pUppOVPF_FJGNiZWvbIJO4JsY96Xja1Kv5jTUyWwJqduvmVVtjtYq_kiLZYfKlAbSfEl-W66XbxpQc2_i-RNrVWQ8wB2pSgGhjhvN1h-bm7xMq_Q4f9X36ZRGaBzED4QM9cBCgsH5ogNjE-j8PFRiLToi0rtX56fmApMj4F85cMsD0kusw42zcAqCdZXylR_JvBfEkZ6V8m4_NOzZIB-GgnrwHlue5ty7sUlfH5AnJYx46DA0bGU8CBkwx1cPidH_B6Z7fDzbyJ8fkwO46ow7TtzRlZ6klDxL1TysdBPbcXdnTc3Icddg_1vjp7Qhhxjx4pDaJx_H3gTgvRP9hvUv0gLVI2jNvY4jkx6IUfNgrGw7LQUwXZ_xsuzjeG7UWZe3fJrsuJjnqgr13a8TRhUney0m2eMZnwyJyl92ucvpD4ledfA1gQ4q2Vfpls7nk0L0OHziwOMeo2LK5p9OBUSatuduj_4WzfVALqeX_TNZNynK5M7HbNlApvm7jlTsqE1esQH2M07Wa8FpPOCNOGgoxzVEA6-kp-947BY480EFME4vuBXGnhqGL9kbrZRk5MiGuW78wH_kr5FTnY5iC_px5FCDjh89yLCh0T-rz9nLT44_kDBmKAtizGTkenzrXRQ-mNpGs1IzWP_Z0Furs5bjoSad-mKf3GRSADrbqskA-I0_o1gQxzmeB0iUEhB_8MpoOQpVK8x_WpJChsggKX2H4mXfwu_3rcYuAeLgnfH8EYS8xqKKRXmvKaaAqwcPjH18i9nKp2ErgGdzQ9lx_gvc3XeOoFFgmUHN_hvf8Jfq6ZZFmVhtZ0IZJ88U5LLh4lmqVk1Lm-UqH2CP4-wlEZUDxI7zLair7siL3XLA_C982A15mLjPrFiCxrC_HfNINY8xCjrK8SUtnPyPlMD1eIkGX-4E_HOFqoWNyG88fJKF8BzO3Fj8Ggmuf30aqJgYHbECHLdMXRATtRquCKC-D0QFEwWUD9snkyhwV6MX2C2p-gHLf4-p_DU6M1WnMo1Xj1m05zwVe1gXTascBXu2VX6MHDqnGZl1uIF6em-lLDrNKtMGQ2yiA8rVAUpub7xZqMCS-lykUCeyq5cTrNhu1272kc3pjtBe_FEDo3O7EdYjhfqhToAkV7vgQwFnyCmF2skUy_4vWIjJi6_cVim4-T8eu-xr92IMwgYEM6TSadMUd4fW_22dM-TGTSvUETcyESY5uYvScfO8HzgQ6POUw-mvA1drOAllU31uj60OtyC0ByTs1Cd6qU1CoO1du_ehokSxt0ykqSLPuqooCUq-_Ebzr3LHpyEchD8DWbpARUxDKOhc5A9tqEiiCjaBlrzG1BlaizoHLBK45FJRg9jQaJ4wRixSaj_bqRvSKKIFKWXCHpkSK-YbZ_XoJ_djfwUrk8cyI8kSw3T_YZlVt9CBsp8PW756ZyaZIofM0kqSXr8FixcW3P5sl_foOSm4M-WVjTBcykl3jEJxDaiaOlwlhHoxORYRLhKlKcG6HlYgrll7KtVsWQEhyASE_JNnu0SMVfZmLJMuvkl1CCmadyuwUNwB5B3-8tfyALHH_Kw6RP6cJ20r4SIq9pV6_XYHPOH_h22TAHS02r6GjJxeZxM1WKhZR8l0KhxUOPlvNEW-jFsqSxg_EhR5Zq-JktBicYU9y7s2P5YXgECNOLWnnntCRRu1u0mXBlxZVxNXkiwz6SqVEtI_RLcPZS38E_aS6lEOuVT1php8aptHJf1Ncgi7hopklSP6tkHFm9lRCAlAonDukbkIHmFdwTXw1nfcATBesvxnSdAGQNany6PR0R8VO-asRva2KFIV7bj1D7J5Vt7yajxd1IKqml5NYoiH7rcTS72_QuCfFWBKsomsKVx4gJ1pwm_BWwvOjdw_u229zftmYzy_YES95se9KiqY1oHDA9IYK6d4QqmyiTOykbn3WL8rMbzv-gBay8-4ynAXRDF_2qmA0PWHzJRcm_Nenrrmlb-g99GcU70qig0brkZttGXQRgNnIbtX4iQ5nz3d4fk2ojqNVxICF6ttOeHJ9PFBrNf-79vs058UgrXicu8SNYKxuqdWdP-dHy10jZ45Qms1RxFy7_x0ev3M7Q4rE3XfkQ36N-25FTfAjXfgcLRbBsoNkmpUVHQ1O21btjCOMDA4yRT8lP5e4KVq1wHm8X9l5v-SWnYQv4uTp3ADeMA1pzAEZdwJeceI0X8TOqWx8VUYHqiejTA1WyKj6hd8_yvKk6RJO6PHjWQpepMO7zawC5PgNnZnepydSRyryKoOZomoaDgyvhySWscGYc6fb79FqFProw-BTEKzt4UevOEuLEdqnq2zH7qzm8R-YjMXOi9srAltqIWaS-IQDIQZxCD8Kwox_UkD-SfFBo6YIt8Jvu1Q_gpsWAp9vatC81ZL4jKbC6ME5BXfwljxk6nk-As17e1Z-eD7q9WAtMUl6i1MbyzHydhAZHfsPEJAiq8kszL-y4eV4l-zNoWIv9698ctjT5d0efhXZcYRp9Q3KJnXibxUYx_TCzHP4bVndSLDQMT07-uVZkSavLF6GvkZNXG0y__B_hq5S2IZrN3ZrHljMua87wIpbQ-3N2dxGN12TA6zs-u6nFMsFAVdEr5bGIlH4srFSoiUTVuy_2T9lzMMANsCE6FXKIm5AAJAg9LenTWqH2Oy36FIuF1_4rFypT1sHg_NMgPtWwl_brrSqucd9QJ6cg9H10YBCWKR3ZGxlr-mdOFpU2lE-WBYo-DSO9rRgZrqxmORMGlEGc-SSMWnDinclMFkXJETwZg4Xve3g--pX8--dXK05v-8d4wL1zOyvvuZjNS0Y966ambQBu9TgI55VnqCDQSGVJzRSgqyqzbu7KP-_xXnM6LtwGYaQ5_tQePrb-Sp5ka4j2LeqcUWOTZTcutb0WEZHC2P2M84I-Bguzy9FgTQUiOeG3Z2gfCAPcHSoum9NwSROT64qP9WKWt-zUs4PH-6BUd7ojGTjgUtbcfyEijBLL7CDmc2pudguXOxZNtU6RtS_Q7vumZtvc4k0_qH8OlPxiX-IABVea5w2wWa8AuGNy4Q-pA_Zo7BeZgn7n9IEVAruBPD6WTvS9sB1xCk06r4N_SmFNUNUTYHAoNRkQ5OXtHYJUDx4m1NY4FTYIM4V1MKjzRS7ORG20MbXaxvgZX6mRlB-fUFam9KdtSLIFm4cYloPk50_Gs3rXaM77tPIc9dLZ8hwUzkPuyX9WXUkJzExp7o4Nz0Wcmw2VUGKtk99H8o4-bQlc9F4h3ene4bJCCpXQqtwk1SDWFOQuN4CiI9fcKGhpz-dU-lJlndpPIMKDphfoPb_FKTo_fIl5FJke8ji-VlwngP8RVfnOLf3_1K0cBuTiklKCdeVuBToZPKmWPUTv5Mkwh5oy7OdKuXqPTL-H02vSY80PxXriUjxEo-Bwf4X64A0GF1ox6BLz44xAWkDREiWHS26XoV94JQ4Mrwz5IL-MiwAhYpBiATtOgUuJiPhynzK2vKaJP2InN_fsFmEH7UJIe73fIC2yC0hpvP-xCpDm6Bn18gEWTo8uh69mdXm0U2EciBHU70f6qvhIoMvFagrB27uPa1fDccDUvSk6O_AfhS-t61D-jPzYQ9EJCz5qtojlIXH8UY727TFTz4BWcm7VSFq7xFWbvS8idMvb4VwvnbhwuwnJPpUV9cqKKIbna2U-V88Y51BKwibmIG8R-3D_VfCjBv9qoSeuY0AUoZqrtG8vFj3IB1iOfzZxjRzy70bhCHD_WxfjuD2lf9pbx5wdzslY_pZy5m71TjnCBVTI919WBNZrpYGOdF6pmi9WVnQ5KsfbEUotpafIImjy9RyAh40zReZYyQhGN3Ymyi1KXT_mmaUsm0qXHcfDAoCOkRstAvLkiWNpX5OvnmmNkHDDfuhIwpknnymchhviKBT_0fEnxir0x6vtDVyKJvTujDqxuyGzW3PtjHN9gZA5m6uaJqSIKAZ8TQK7hM_njq-fhgH_9vMDvfyC6O6Sds1UNcsSQ87nbk2t_uGsvsba-UJ54XbO0k9ygzOJv7lh78S8tMP4aP9MuDgZV1P-2gI3E0syG1bKbcfwN1-NLYOpOjAXZLZuiPq4oV4_gyEaEbRB_jK-alBLdnRXsGJzJrIVJ756gdquYfq9iomvK3zhyG4tUUJs1ELlsZWaTgP6GFIyu_cQGuiBkgV0ldDZPmRfQIim0HB_jKkOGifAUAYDhgvK0384Kt2w3J820tCJSHvkeIYoEyfZYP6cTIajaT69QCz3Q1SQdSGqol3llWljbbx4SrOO7r6y6T7PwiMErwVGI2pSgw6415pCqgQlk4_64RGL-e_VHzyIWgEnA00nDkkKqbcPgYKq7IYpHOdCvvpJPx2KIo4_wshvnCwdj54SH2Am8bWQhxi1p5Sy2rV5c3SPtZH5EmhsBS66Y9vmA7wEqZJG0IDYGG1v5BU9hu7nK2pRx3SohN7rAgHciSPctbh3OkVwFANlBQ3emHUO22ADBOD3MFC8RMybxTD_KG2pBvPmJZZPfc-PVD05hKm1LRTZRPxUwiznDAkV05TtTurTEoyy3jUoO666ZBhk6N7JvYxzLcmwXFcqxr_tYXxIDAWMuwvAqDrs9t3AOlwL8NMfkzdoYHn59r3zjicOzo8GGog9lw1Kv448WntVkw1r202GmXOL_0O57MxIQ8tdVkrfTLQak6rh-XKBlOu4teuygrwKTYufHci2oEMD2-S3HmcJ4MDXZbpleQ2RhxxY_1d6OESq45wphgrT5P6tq-tY4EsJ0ycZXTnBlIi0mtlHU-bqRm9gd3iNmXVgsh8vSluyrYzPC6rI7qeNtPk1I1AeJOY_D8iQRwVu1n_cqJSxHCSz1dLwqRPSX1mrSxwvJEH2TkkPjoRoVPe_S_bp00vs0Aed6t8KcaTSwIcOSa7WbQpC3kQ2OtuPn51CgKdQ1ZcFI-nLkFnUq0Bm3041aYTDa1nfoRReEwR5s3LucGSKmJ8Hucj_mUUn_Q_iyrzhbKromcmL2dOuxcTJc2NUg0Jj2X3UnblvZrqISftne83zFOYBzZ-m1rDeHMK-PYrTYPwaV2HC65cx5RaGM5NmIaDR9jhcJRTCtHMAxJacGJO4JmzvE6JcmgxegSAQDrm_2Xz08X2Lq7H7NpglNB0zkp_Mz_i3v0LfiXosU4PLZUU-DU_79_QpRZE2D9RtmBC4tz0y1scU3MH0oxUQcPAW2RplSOlHBAB-xrbeySW1jnIopjebpM2PHl-PSwF9-Qjo4tSw2zJFTcIhAG-4TpM1hWJBR5_LTkZXgkiwCFpMx0t0ZMZidYhatIB7rSq8MXxbbiZeZG3EPb5JGzB-bafWwWcvp4tSrcXY3bKf-uaatfrJ0PhCAWRb3relrLLtmOd4jd-GpvM5dBdCnzRK-Q1E5h3-x_Dw0vVI0IvOZqmjUotmO0AsQte7BKv-DKnhKO7PyFWSPE1fTDKsrxoyLA_Hb50jWNtI1X_VNEG7igCbmEzKfGZSy-t83ej6khqIKRwNAj4RBPppQpvivLHOz7zJ0DP1LuYrly6XvuYp9kZdZpSHpS4cPUPonhndUZ8bwVnVgVa7bHjJRl4k_M3JfRLIw3_mEp6wVDdnr_XG-9Dw9IhXmnWEFeSh0bbjIGb3K8NbFIVzjMFHALfmlyW6j-0FaubIJQQtZOYcielhL5fapBolZVtTRPThRalpOc8AcU5ozxGoYDrNSrTlzwMStihdA9ChxkIARlA8Yc4bLRRVY6d6r60UK984isxQ3lkAmZGLvMHsmldzZBFAF03a1FBxtO69oxee2pr79qtwvq-0nFjuLlThTHO0Nfz65EPx_nYXY4L9XzgmJAQj661lpiB8PrWROxW3vvI4Dziaw03gu74jSjQwX7T13RRyukiZ1IuCOXYeoaQJGb5Yh5YZ4I5e2w2wf7BDfHiVz3eflGvCzhP2ScAoN09QDlC0G52mpzj20tzJvZ2N5NEEyiXkEiWxRVNaLt-iDXrDYSnY07kJNKZlpttA_zwdFWoYLv65vwQ22gx3AWGwfiHhg2xFN_9wdWKYspQHGbwif_JSlzUL1ERLnVsI06jWgGtgjN0n0GoOQ-GRkFj6H4FOh6MxfD6DtZb-J61GEd9P76_GSkFFBOjpfPjVQSnoIcLdDW3FpkO__S-fnfBXsU1PZ37N6SfpU9NRuTaBgqzVujCI9CScXs6vE_ye1nK--dHXy4nw95iQuqL4N__CNmqem2Fc1tLujmSvXLtY36j58nhXX2vjFlmA6T0QxCsB53vdrfdL7i6ivjDzGK_NB2MwAZcBndzF5uj46_3TyKvJczW60XS5yc6ochRG3IJwPtwx23EGcrJaX7ga4dzSoar7zbtkfXYVBEwaiVaMzYfK1R7FdaRjJmMberzc9nf0WQhaqV1KKcZVwvAKSIlbCSEdB9nILGl-K-ZNrOfs_bpaSccvpx86M4GE7qzgyeRCgGvkO8dTpkAOLCBu5g81Mt813GRi5XlXJHHjjw6Le1XlvHZXxmDZ6qhlRJc3knUjE3EhwWw_DtCuMdqAaDMU61Kcoj5bvwWQ3PqlRV48uMSCrzQAK4Q4znUTx0VoXkB8kjeebp7GssotwjWLq4vmVcrjroniLwsdp_By66kTD0WG-E7zlk6l9mIZPpNas519AgbTYolQSlcAWZcQS8eI8d5pKuIjwf3m3Q0ErewP5Unc6UOnPEvSiLjTiqY_E-VY2_DptZYagKkSvUUFhBV-gnO6dr97DEJFhQM1Os_Ma1lM0iF_4D1yWsFVuZFJ4J8lU-8zyQzP5IcPU3GQqLxJ9fl14TPQ1gxGLPDqNXMRVF0ocqxapnAVihLphe2yE3kk2BQo4-ld_1th579e-R555bITasUkht4sF5FxTgFns8gQkY9JGz7Pw71voLX3YgchDCA79H32HXA7RoJjIFbZx1ne7cgPhv4uhk5vbWpC-T0k_Z5NMzM0lO8ivlGNxRnlu9RzdvQ5oreEET0mdcbkyL_C-LM13-Hf4h5SMqMXo_3tVkEwETzmEKNlC1najkffLkVGWImopyFHNIJNn5J2veOCbx39B4hx8na2v85OevvnttiU_i-wLfpvPRtToNuDy7PwMHz2UsmCgVjZDZmCE1scYm_-CxAzIXP8IUP_0xiILYhJkUvxH-pKaRfLFQqG1nP8I_usA4jOigOzoVUp7OaWhcAJkhvnxV3owHUpjH3eHPp2WL_noQ9YByQcqIMDkxfrIQRZIGCT8K9Aw0kcSjK_OgM3THozGHR7d0Ww0EHDDuRcQXDcB6um0MtxwazGuW3oX3ya2czmQC-u34J_yvRzoPynKIgRkIr9cgEEuVcSYElYh9XEybGgiSb3edxa2qdxS1_vZS2-5xC5NRauooC47V1CsW0B03_QZzr5WeVb_dMKaPpjbh_uI5VDxkwMLkk_m71PHGo1G83-wgPfSVzwTUbxci0FeYTj9Vywd4oY-Nmw170EjoeledxrV_3eeBUgzLXP4c4lkhWXMngsDmNwpHqjPEIZ-ClV2qL5AB41w8huydm3gx43W5DRdeUWAWrmmUTmF_NS5QCNoAVBibkK7Fdy1bw7KIZ3noyKpq-mtrXM1NB0f6erAetDuP1vtLwJWaWadPe2ytVnB3liW0hEvaK12FV6EwiMyOPkAndSE208i6FqpNrlwCAQ8o_210MHO1LqsuHxjlaXMwad5XsCElcDXiDU_IHlVPcD8bYdhTLvAVH9RVa4CLk0Br0ZqWyrzun1FoRG6IIICIF6mHZ-qKWhTRy1gODaapN16RwP9mCQMdMeGs1yXHpMfwfgNgwfdPNTT0cTaKW2y7p5yNdH_zvAv0XsifOgSBO5PfHlMwxxo7xLZlFMU8IAJsq8X64LYt1mmiyJZSfQCS50FxYpovaGGTIfNkm5TxI0qyNqUrFHCV0bZEaKoRX1FtEaIbKjph0Hw9j3NO6Sqh8mzP6llcfLihDIJ8dfWyrnOFLQ7pAOMdZgsfKkFh5RX6QGKa1pJ-QoGSIYs9hbetGKnT3t4wM9J9jgMwMxr_AsTiG6dBsRifym_wL1tJBpDhNZG_SK6dp6-jMDl1JAp5iZfn3Rao0NSwAvDCxF3BQwtQsh_gLtQxshybc6zG1afbMgVWjcPvRaHd3JeUuDmmsMrVclPZv1PYTp0BnU6UUGaSEGNYpoUqbH6d5K_G4hiGBjiWc3KWKDlKBDKYR9urE2ZZt7NLUtrJXJ7nZBWw5E1VeFr4-vSaTZzkBHQpTY86sZ0FtQd6IuFgydTE71NpRfS3tYbcIe4IlRfQzUBnB8XCR8IXpHCNisiDiNihdyAV6tQrMh46Zd7xc2fLZLN4mUiEWMn4iQQAIJ-xr9sdBk3HfasrEN_rqvvDRD3_mMjxtFt0dkAZeNebz8rAy37c3kfxviD-0Y-yQieVkgRkN_onpejl1fan7QyY_rAJDyJY8nhLBwyCRJMPekP1UxzVztWhlK46WT29w3vIGqJ75K9kB7WP7f9OprX6zq9dmU7M8tDZ2ptbqqpcHEZgHXOcI_MnWa4weE4N_N8EPtz48uMHiZjcrsbiBRZeJ1vja2OZKl9j3yXJ9SADJduK7t_AMsL_0E-Jk1zycHBJc-SL2E8rndu8ImfIfsqfi8jTtVHojo496vDSlnBQrAQ5_xQbr7WcZDzkrld5xYVtMgE6aAg2AjMVfoyTeTxc4QyDM5NFz-1bn_ArXT30H4b7bSvZX0IybwvxOafost6e5BzeyRYE6Uk2-MUU4Bll4INP24vGKA5t7s-6T1qFiz82oSD8wenXmu7ZbSr6mE46euYpDS9xhGrRDgjFgJ6Og94hmYuxdiuOjy7kXOksR-UuW6Q9QBOuHQson0GaYA3FyvnbH1bDXajRMiBgjLwBE4u9TMeXKZnastl0gyOLZJmsE-MN12CzPzCZvYB6n8FZ7O_9NXgZDEBf8dInh9br9axWQ_wq7f6F_nt3s4LX9z6JSZR9RY8zsUlrGDxcyg6y35Ii2fDKU4kW75Elr1Y6qHiUjSAeuZy4CVQ2cfeiQBNjef8H_JjZzsal-xj-5zSWH5exdVQQCm4B01KlG8VOPFtrDHvAMr5eklWVXUNNNE6UfMFqbt1McImChEcNx8uFqiqdAvx9TgPiHKrXnuwkdOAMVS9BzUhtRIc_B0tXJkxSfDC1asCazFWRFnAxZ3_OHq41RPV04yD1ZzdZA6baw2zSeBuyM2PcUNg54eqmKSrzs6EcER64ViV_q_Y3PpUHWZPlg7anELU6XgYf23uRDcx4VX-qbLJy_WrpJViTL8HyZDs6h_iju8O9qMr_8P963McYZLN7VEz1HTC0zKQ81_SD1OTGPI_WtdQvAbKXXc_Zcf8XOUGeEi46KBzbjG74C5HxX1ScBx-QiUxnhGYJuTdMsV2hVtNab5H2Bw5J9KBKNmOFCuM8Kx5Eg8BT8wOQljzOzExvRzUbKW9MD737YIByoi0WsbPoyz0inoabW8PDa9zlsiSkxaxDVdxtdCQGPQVmwDhT2uj2lH_sgMhD1zLbsN_S8SPz1e-p7gwP8rb0DDTNdF0bQ1-zMJdLfx41j0NgVoBoKGjQf9hcaTA1Qj-iuS4CRIhRKV8qcqCu8pD2qHpGsCH7gz_JZo8opqBtl_mNoxFygZG2KBxWpUY6CZRUrZssICNdtm1itu8XEINsQ2UW-V1wm6bulfVUa8HLfLCHV2Gln0l-xkDIP3AIc_Dm_86nL1ZiwGNUB9SQZD9bIWE6Sa-I-wFD5eq3pzkoazxMhe47wyMStGhqy4CUsce6bT5Qoq_WaPfimM4orBLakN23wp_N315c6aNLiaxryDUS3tMD7k6GMwusZQMOHIxDN0oN3XgiTR3v3EQHObB8BiLa9vwZXHy69ODTUATTkpnLMXwVIhAkBJG-fUj7IKT_0IM6vzcNmVwS_a28d9rgBCvIs___g3eqntut9k8cwYQ2IOcrf0v6agFtmv2qo-a3WaUhuEMWNcyva93Dy3frISPGxvxcFqdA-FbQqLuPnVw4ccK25FSqzzQ7yhOoEL42IyDIu9yxrMnExqu3p302uOmmWwEwOp37xPv2mgLXo8qsI4uVG3y33PNozR5ks_CPsDroYhDrTzgEXQyyDaAZLEkfKthiOH15Mxv9eHmd9T4F0uwrZphaBUHewM2Oni1aB5P60hhCF9zC2rAuvU3yCOdF4xX1f4XE1LaNYF-T1MRvlGcBsZQP6iNBAdijsW77dfCwFG1p8voMl-emY_JmHNgzH_y5eRIxkCqnBSBFgKENHkuz4sqNQw4XREaDkY4ynDOVgSWvtxrKugcrN9-pCytu0G7U-g2ksP7OZ6vnfZDNvhgw4KDM0D7HXvOr_SgialSAEQe9dlmzQInJJ5XTTq20t2wnIB5mTiMFJog4bkahXvQwEhF9rJIEYFl7DYz3Nmwh_BeqKxGr65_cNiL443oGpH1q-YSyRZJaW9iPeNP0cE3Uh9v7U3pDp_W-t-qFuK7x8KnEt6mKNurV4B5hyqxIBoOIy4gYH7b6MhTIOd2_1CZaaqNT5CJOQn4ecrJWqMcPQV8ZQknQGAGP3BczO1gST9SE8a_vZ5AFzg6WESveFf7czhtHauIlIPfCmyLLcOi_nhByJCKXIya0Vs3I_8Eizmn6_1n4uxQvJKfS6xAi4ozijFZCk77TGvNeBM1L09HqSQfAXH3btr6dgYAZH1JoLMzbEg2ZNKcppQa2gQQ-XEmZ-VWKuDovUqU4RkcJvFHOM5xaU3u99zwGS2fcgC08e3l6V784sHq2b1qCL8yqXUbIZZOi69xhp7aWovjucRSFRSWvECUse6UjYBhyU6nMVkIAh8sfRkPGl4fPBBBQ3turbn4-vSVoXCXas85z6OMRGglCR80pLTPFXyFM0KjY_d-tvzK_x_Eu5mrlDDxOz04NB-KQHsJH_O-3hhshKQ3JQ5xzU0XqFkWIjjhpE7rC33LkKeJ7iF3_Sps3RDyJjC0PvSxsq3wBS0BmQ-EC_8i2UhtoIBAe3vGwQfQ1hBtKuO0XxxVlM0jI4DiJyshh--YFXrACu_uA06XkO1DLHYiT1I6a70lc1DZI2YrHbrMiBcStWDRVXt_4uglhwUr_Qi-ti_oLJh51fKh0CqFTCodm6Sq7vnjdeGciEoI4co8WrnaqTKG1dsyhTeVxStcQKX7rFSk4x2m1YEMaRzlBFIKUrWM781lrBK-mmFsXIQvY_9nHswN4Sdkt0om1WPkGaPt3x5kjBGDhnlcjqlsuxV2HFFUNLfKTWcL7uzVPamOdgfy7RrYNFRtz3c8yK6e1VguuP81sJgMvQ6OVi5UiwsniCxrUan4nfDO1eGE7vIk-eMXFlUyqYdrWppVt4ayos7-NWOfmCQnVAjGsTGjkg0lydIFldgECxjdL1R4BhiSYnZL0phfYNOZr_azFzmvG8NSWe3UiErLynMqb5yfsj5eC9gyJflbZzFTco3sPg-xQWt93Fuw-fbXkoWOhGwGSh6NSf1m6ngSJFYMbjezH9Buo65QknILkSR9VImG39i9uOwwfBvrfZiWcWIDy6Xp75Qcm5N9q3_O4OLXHCFRN8VueY1y_0_Kuj_9gbb0qQLSpiwwqmJtO7BjFvESlhxaItAVhteut3JmcLuu0wc3cWx7lQvjNXuUG_k5d94DcDnTh5HHNHYP3KHKRH1avCE5DYyMoYEXckr_N4Ee6o51CJ-ctWrKJDrinKgL98Vckl8gXKNiFJu3G5I4D_XOrdbx2sMRHyzhE4xZaFgFSnjW4W9X_qrCtqTW7aW7mSi1YmD7-qLk_0Gb1_JmqOOw1uYAeCn-BNc0KDmsCNfT25UYiyJBbtVQgAbxEk8WIvElV8sGUfslkPxcbSrHx49YaiVKV1bHCmPeqzDajvvRaWoVFM7Hl4LxeF2fernHMechho5T1ODdI5UJBByrRCRMvkLk6xVJq8P3COKM1iPxUraAmbjmu8JnAZiKBOla7dd-NYKD5DNMqA349Ecnszl2e25BfsrtmlyzMQOBeuQrM7b1ZZ6Qi23GVIrY4TTod_PSQPRq0V8xNAnnUxywyIV-Qs4XNuLLsgq794fPA7fYz_uiq2vtTAHJVQJrghr1urFXxbSx3CbbxEIRCPSS37ejuCxOqcwZ3s_46OqThROvXulCag9bc38k49eHf949Cy8yCQ1OYX6JTdnG2cle_4LS1gig5czMRC5uXZKkRoXKTf3XvipNCWvAga3BeIQ2ZCpwWQyLEhnWQoweVc8Bhnd9JYwHezSE-oQoBAx1RYOe2AL0BAbN5kKtFlHLe3LeLDa9HTnzhDM2xEZrEEqieRg4ZMX_B8fAk7LgJCdxjVrN0k8iCIDY7nCkK9yWsUZYLyMl8AFPrLSEsPrD6R0I_K_TX3zLprj6MVlZQzxBQX7rNdwSavAl2y7OKLHrpk1iV-04K4Qta8FuxqOx71-6WxpfR02rqPRI5EWJT8-k38RUofA2FMlPsaoq2QeY4SVW9mJzQCarXQvcSQaSsCf1CYiqZfjUvX6uKiinA635WNB61p-dhwALTtrb47o94sP6mSwFQMS1KqTxGukiAI3WsWuEERNePDPy0nmaalaJZvD9w1jGR8c5PSL1R62i2qn6p5biY9PsaWRt5lOp0vVRdq7VV_D4ADJePYgUU5A61QwG2pl9ieCPAiuPbOun_aePpC1k3RNI6Ntnz0iLwpYEfR7uEh2alEDkjPkN3QQzTz9t9b_ij-nD_-sdIAd6MxljzC8vkOhcqjePcABqziSELfGQPgRhKcJ5naGy1SMRTYY1woOn417L-IdZIj6wd-zRCBurvkTD6yk8XcLvEDxaXue1tWBCpPsleBSFOeRkeYXdhgnqj3dmC-xMX5bN9PtngxNWYVqQn9unslHGTznZmABETYsRC6KkN5y-LXzu5lBc4_sJPq7FQ8JIcSsmIW0X5Za2WqQXDUjQtKGGXY79_ctqgssaouMyxrQwtKTHd2F8_jjI-8TJqXqz4p1d6ajHPxOMMA0edPg5CoWndluSmexBapdXzIEUHWWl27Eu0HrhtVSASI_PNLIGgh8QZXOiw3hRmBOdBOOcD8ILx4ILDLE6VDnA92QgozliRN5aSnnZJSUdSeTgRMDo08n7Wu9Xfr5dP6kkcIlJYw5UDZOZ-1vQUjh97yv9cDhQB3yuTPBrUDqdVnEu3KajV2PD07jRoqwyYZehJ0HXmloS06P6VWbTz6O6uczQA0meLC1N1KeAaJRZTm7l50tRu6NqOpu0zvoNci_5sfrMeG1VJT02ihpDUgOrHIkmYKkEkO8oq5cUQgY3AusmYK_ysOGfC64v2Ld3v7Fwtv9-kflj4-WufULC0k9xdzl6gmSWg3O5-KnqINLk0vBDLM3fH9_JZWFnlrIbdFIBUuRLC80WUwJrQJ51w4LJpUPKeELZ537UgTqGG4JmVAmmUVd7RIkRdfCbxDOZrQLdjd9BeRPR80FgF8ry5qdiq_IC_8Nyfr6nrcd_6fDMjLJhYmmK2OmbMVCi8ZYmbf5uSaytFxxv7YsWOa2t9kvW6pPgx2iVLTm2CyFuX0qesRSth7bBYTUtTucTa6BFBbCArKvxpcaX_9-Fp9Ta4zXvLRaF4s9dyKZe3L6HEQXmjujbd0PtU85SgoaaPXo2b98tI_gBgBZHsAqD29EwCp_ugBl-Q9WWRmI_dZQW5vmfknWX1ZXDYOSmkDJU3InqVd_irMR1WKQGTuXWAc80Vr4vvwZJo5GN98Tr3LqRrZTvU3MCHH94iFHnchTzZNMvFo7GnNQ8cWFbwHYEKb5zc079RonSfoCG0fzyPa-zVIwRWekKWge_aFWuovzdgpY_P-ff2cPPnzWzNxmoCstmNU_qFkNh-4LehgEqlF6Js63DGPICQ5z3ZWtMMcnX_KSFCnoWN-5eI3NLnWOxWFLw5ycFk6jyf7ejy_dK3aIYT0ksjBQO0-MFf0lRooH_NR_iTgcxPyZN4uag6cWpTTydPDdfsGDV3YuB6GqcigvVpfkw6-xbItlMRqOMYg_qiR_gkClJAsyPSfO3SRsleIXR579xfhtNhUsOoDyN64JomS2PMTJyUx8gqlXGSCMBVk1nOHGqvGaDBvift2ZYXjHtdwOas3HysnYGSGGUiej4iMgQxUcL_WBysB_K_CK_wDZDH_1UmbnDHNckGpMenKNp7TFFgDhq51SBWUudm9PQvp6A4X-lwYFYwNiM_D8pfO7lqmA_5ZjJOTxNAXIiYMQ-qSGCpYxcwYDe9z-8Fj9psfSFWbvnyVn49RvjCRWNc30C1sKPjslXsHZjh9N_b5giQc__FbDETzCXrcJWd8591UsDJ0ftNXWDPpOVW7XIgUTOqesbUiuxeNq3IeMutErV0XqBnkjJz2kGBmEKVCBRGSJ5MPcGFR_iOdTDGGzlN5OvJOJQzBuI0KXnM3T4bcPiEN4GTLfkBeePGAdDQPi-0BxdDD4qyCJWpkIkjG2oI7raB8vIfARfFAaLGQharQzflHxMxNlGgCNdt8JLSbgQEDvajWRWQPltP-Qd2GMlFlqXNYxWFFO_kS8Wa9OuhDLUE2BKTlREGakgGhKnGC2puVP5qPoTDC066ffIShE26AILduTbZN6GuYkCKnGRsoFTxlu2i59OxWkxMqcYVUcnXGBqOoivjhxdr2biVzenaWwJTjZ6frXHqGekqoL1AtoC1_tMCgly7KOseElf5td8WWNUz2SejzBCfIjt794AguYXLFkn_MY3genXy5ctXJ5jlFGZZqr3GjuTf9eRdpesZv69nNAnmBxcIG0eRxrf29N-jw_QfFA0wOQwVLkCbZtZslspt_VbsUxWWk-n4O8gFRAn5k8kvSAuPH2Mh3ZuTv5Hw8g7uwc2WoCK_eEd-QLA7UlW4G2ST8G5xK2iE6Ln3hxg9qapK17TkfW1wVcLpafM88HtsTwaCSbBQBav_3120JDFLEstUHfnWoLeWLjz5Ozcu316QV9txjwpONqofo5J1xKzM3lFIrDvxqGwPzqpV_HEptX9wGvwzJV3dMu9PF6LheBrkT1KEjPa59KHpQUrm4amOMJheh_dlHzzeofnkF_MQm0c2v2XlC9vSRLi8OiOtc1kTbR6QsbO1akLa0oXR9HJZ6L0ZIvFGh-X8yZkolGFBgump9yPGeBa4phd5plEa9wSik-IW0nJ-nmWWHx8JFKPS7UCKIqtJHYoBzdSprIki3LjBMIDldhNFworlWTOEWjcs03QYraEygw7nob8Ziwc0f3gPWa2Ex8blR7ylHWLtaRejxm9KLjphmaC0aLXH2m97RjkaG3-8A3GHGor2xSk4F7qyXQ1JNW119tfrmP_Gb_FJbS7RE_2ihvj--9ru_npsaQx7L3lsY_4FDPq-SiWvlvsTIH-Grnz88IME5uTGRD4Pmd8go5z_IeL75KdyJVxldsS2-EaXWxKR7N0L9wOZwQA4lU_xyW6Pr454HDbYAIN9WbkoI1HM_0VY1qQZD2HEYh7cDrAf2V3RT69hvo192BArJSSqfECVo2O8iy0tYweK6Oog_aqkMnxzIGS_dLOcTdbuJy02C_hhf3d8VBU4ZN4uYVamyHB70aC0dzQMN-XOE13Sh8zCmS4XeKzI2VLmDTjUP9_SAY_71Im9oJQTFtljH1kAp1y4KkcdHnkPJwLRaMGrGhWbbbHBlbVluXZJimghbNrN_n72ZlDC_C3OWqz689KI9glZPLSaQiU7YWku6uooVt9cbf3qsKoSz_3UX7SVfekBO8AUmATNjevuT6S-tC0XEDF3oUPxShE4Jd_vMofLbXhNOV16HJzQbULFDm8gU6-NIrPW63wtKteoww0wrf_CbCIaG4a17HGvZyBDduRE44N_FzkOyaG97t_LniU6A_B3QNaAgwx5JoaRkVFfhS-3nJlJVusI-RIpZCsRvFTTpKkruczPtAs6jv6mbfWsotQaOnBkgn0qBGY9eqn0sQk_VSOmI0yvJyDSTRWlOxZQhMrLQ5OJJnWwwskITwoNERg6HbeTPM2OApItaDwWt_h-SvUCHHU7kyGZVct9lQcBMw8Ip6gH9-_7AP86zLWJ5AuPf2VAYv6y7yFjnIS6ZGIHu9H0GUf0kF9zodfK-wiyKENVHf3100J2D63t9LjGphGPmH42WFkczNEa8CTLFRMxRPeGyKLgFSQJkYodGZODiA1tr3wQngcHTFC-tOZqPjJI0sRcoQG1gPmh0CQsmS8Dn6P96dxbrdYX0Hw_FYBBBesnQdb97lgb3LowC84GkyYRjvanl4eSbp1A50iHOaq3DQvaSzsb0fHRAVIwPEXQb2FgfOFmEnt0jBgZS_yXm_VQFarjOXG5UfkV-dNzs3OyeQi7a2wWUb5Q_QtHEujpCZugMhpSm8WFt3sfvNG5JEUMeOa7cgdPH4gflw1tje9KLWvcruuaE18dZhLpofwz9B6_1TJWDbRb07ns1fXSEsoEz090nTqfHT3yCToAyT8mm6eq2elQJyfmY6siYmpToVPXj1rLiEbuLYecmB_FprFrs_JhFTQuan2B9yDORs8ZV6te9v5abZFckMW4_2CVMgXStYT2YXgoAheCjToBtBpROwNF7mFfps5cOsYaIoarOD90k6u589gMe4-mVBS3v6vttnOn9vE-8jv8qqrEx3b1w1WZCxsafZ6IKVxlwK261XWOZ6uEJ8yLO6xbMYUvOmPsXbupCVjlBHJg5xsCT0xb6s8Frn3De59KtDUQshPA4pGZ2PSUPZFOKi6tvmofOZ67OoEnIHIwJ1sjqi4QMNUFgMVmIJFxPkGfS_OOj_zsBVOofUy1-8idevOilU0XvJ40Bu5je8TOFf02sBSq0leXPE06CZyJaxFKn5NEeIv4uv9dhb4Y2cxyPZu-Js7Q7AyWQkpXJy07_nub56OC2_GWFg2xAuUsUa5BOShVtrpY0p8cym83yKYBvA4fqJkPDoehA22UHHmHxzyBDRWVQ966j6pDJzywNd8e2aALam5TCC7McEThyRsuPlhU7eKOS1BuUQ8SxNlROnBFIQV72iK4M3xIV-GZh4F_yOPAZn6cmgre1BnrR24CmYdxeIT3iSruIJ95rFAAClaFFtrQHHuezNaAyIWCNlv3xwXsoHeNub6CwcWBSChbjm8zuuiTd0vr0mpayCWD-Tzh6bQ828JXXMPI5zRtpjXuBMReMslPRSHkBsf3FOidqH9zA3j_7CC76MHHcY6GCJV1GWf7gdGh48NLL-BjxaL1uB7oSAKvH8-5eNCnxH6tnIdXvwv6zr5aCzd_lzCiFbj2oEuVXV7U0QtX8llkRqO2VobaKWnBnxSJtwnOiky8NYlUnO2Gc4swmZ-4hj2WKPqqqMMSYGGHU_MA5OQRNyCsi9hwhMFtbfgA_Nl2hgf2OQgzAAPInXAMNwfL7w7Xn29LKoNe8Nllg4OWL29LiWcL8MBWUuSS0bivm_VT39YsKd70t1r0isL02_Bm3cYoUzBQd-kfsJC0c7iWkyNp0MltpTSTUipmI50uTMa3dazlT5rnVlvXR10o-3EMMJkHjJncp4kEJQw6zQrPi49svmLPqY8TzRZy113mYeh4XUj3TlMXHZp_rzhz1WbCEPedun4CW-ahWGqOghWEsXJ_tVxAgbxwzqcbTmBEvbm9PPsHV4tFT1-9yK24f2bgvLPXyLAkNQJ-dvNageQiULsGYmq1IoUK-p7MvjQgXzTx5bxSYfUaEju0UjEjbhXqiEE2X2f64jp6MyO4VfnsxBsMHJMimSzr4TVX_YWhTqvLHWjEiMbD4KJ_Kxwjyt7YBJjeaVp69n-L_ILW603op_2UBa0gyajtCj0ylPxvdsrcxByAFjkXUt0IQGvhGW9FlMvUT8vmZUCO_ApHgaAC8VMDag_tY8aYRc_qAMkbYRtkYb24RDhTOhXwWgS1oRUIuaIADyUg4MGjHHDn4PgpbN5E5Q-73z6lV2y6zb8bglZrd8JxMdTB3hm6j_hQVMm6u10A0Z7BafcKINcsHir9y42xjvf9Jnz-xVQDeO4OKBNHJ-0VR3d864vfRET9pPzSzZO0aQL4L7yW2DNR2L_3NpD-FFswC7spa8liQoDnUhZfkAmjRA4MYbjdeHMhrx-uAeQUsr9-rjnn4grwTu3mZl88KkXkPa73_9l8kapquwesPp5pgZtobihQcZXKbdrhbz8f7xRXIRK_2B5fxr86Ry9EccOOvFG1NykHBow4KO3hH8-Tdj2hPs9Ns2Hm60EJtVjlsHbHdoz_NYDZPv9_YoeoqV8SRbInoAD8uw-1xDWKEvUaJXKRlzvA8q4eOl7q3bfNWUPDGfEeGhHCeNQ0e1XY21L3RIwn3t0BkHchyEG1u3mMSbNHcPF7ngEKakXDUscBJw5MuI3Js7EKE0niiRPBZKmCKTs9s7imv53F-6JHICAHI-sYcgSkklQmqAQd53hyR7oT7cgHGMUvqPDrIcvBdFCqdkGJmhg1bXw1eOfKKBakNUszpe5kwVQuH0G85hDtLSxmYD8Coph_wRxdatHVSrqamEKs4KduOhip-NGIeMC9Tgtmtc_XiITaSCycQh8CYF6wehrKXzrRiaWCheFX_nD94yAhG9xisxK5p5LIUmuvbf5lc4V7-zQBV2PuHQt6eQvi8JGPEVWDuV_CEcNpEycISa9z855CEOsGPcYI3uMfNPT5bm1xGUejtU3WOgrw8Wwf-pyyT9mEFxxPy0Fi1SRDyLfw7a4FplfS5DSxfolM0hSpgTneADPvrz9c32_BJoBQmzVcK02C9L4M8gyd07tWiQqE8xpk6Izo6NN8APIC-7sV_r6iWWjfJQir6ERQzcShPeIJeJ0kmaIPT6-mL9VJqvWpjbYeCEON_HZi8OphkNRHLCmDUXyq-sAiT-dSM0FSAvDJTEQPL6ax8CnbBQ79Qi-tP8QhJtqDyafKsVNhRapl7-v5PxOl8_LC1oyQY5aOBIUMDFnboG8EwY-dkSqlGWj1cnDqAZHKyMI3TGOardTcYWZ1L68g02DI_jrM_EWavWAuzkR4u6SK23-WqHrKOnOIJ9y8yCEb61yFnk643pMaE2hBPXcMzU-p1sDrRV7K46L38eJBLlG_f5zCUvvGkKDdwKhf6q24mgv49sMioe9Sh2cmsH_FtieDUcGNAMewEUgL9MMUeCYnT2OFoUwOhES630Q_PKBPljxfr64pupkKxpBACuZ6LZP8KB4LZVi1wNj1CmNF0DsxwxLIAAXEkh9WPeuYnDYa5Ou5ILvwU_UNz9N4gk-00XLWcCamrwtLdOhftxW5qmWdAQhgo8t626EvlY0IZZ8l8avQha-29mk6P0_-mRcJnTp5vSCgtyZIFrFAy4JZkHynuvyCuDJ2Uonui_Wq_IBEgpY7ZsFbWfto_ki2tjrsWNrVni7e79dKi52x_H1jDVxUVnEey_kzqJJF98BlGcnJ6IL6c_FZO0Q1ZbxfwFJmceub6z3F0F29ji20kEax5EZBG98yHPD1EEBu-v3uIRETJknw9KwB5_P1Bs3ISv4txDDC_wHEJtoc1WmQYFeLuNg2a129R2Ear_zGLHN14AoUTCt8o6FzWqmJ7K4F6TETMu-QHB8Zgau6fATVMjXiJxjN514llQKAgwwDCtn1gH4bzoVOiMuIXAFmQ6Y8DyoayP8TMZ5VCC1qEu0cYZslI9hzwDmUwhTmp-zD02cWsrcZpZeywINmsOxwGbqvaDZjuKSPefKr6zXPo_Wc2fVN6R-VBPxx9kRY1j-ltdz2UEAPUnwtyEmxWKW9O4nokyaNSWfJbVJyr1kIDo9PRm4KSvZ-33Z_QUaE2XvEKOXzUHgrpYw56RoFhrylnTEccUtZe3XAv6rzxxZs5CY1Y_oTh8jaDBcNgAThjGxrzfkRU1G4ArQgrN0q_g8PcM7gQ6Np86UBIl3aFp5hDNHgtAt0Va0yOexTd20Jhb-1Caj4Qf6yluJvDeh66wkzv9Zmgjlb6ak9lY40KIE-4ayxK4zPfa5Bc529wDcrbOXU_8tTLePN9bmNNtNZb0DCvJTin1pxiK6e4uxehrjgYfQEPcTWYKjJPL7w6yNh4yBPflKko6Gial7rZpBbZ-GfZxbNUzmyT1OdhpH9kZ5mfglK-Fy-DbxAFbNtJyo-N_K7Zmr1QmVNb9KLsvgMFI2e_HA2g2LxPfzuknmrmo7JOlL6SpDb_am1wc8CJDXN5DxyM0FEoZe_CjUrwH3UGQsXVmgv2uIc_tZCMGS3_vXjHKz_z0iM-oHpLiEax5QmkTdxnVF1vD6GD2mPsuuweXolw1acv_a0dL4I1o6cDm1nCbmP-gxvRj0gBROpCL4L2s7CrV-HbMdUg2GMuokwHAzyWJf-Kwc2T2Oh83O6iX_D3Kg3lbJ6QxWcfiDYcEh2A68UO371LDpIdMq4ZWELJ3jja8ATd31-Fz49D4ItiO1q8emwI8WeSIRY7BfGdo-ZEb5Xz72cUe22bHTnGuNKX0HwcxKlJ67lH_qvGo2W7mJNroLUP5zXvP2SBM0roHs5OEzNVJkiUCkwjT-AWTI3uWD5UFXaaT5EnwnVndjzzkCdNZ-j1cc_aWZwZHUuTwFJgLYTYUSrsS8M8w5jU75wc5Y9PLfgeXNFH2jFyyr1BoJf-JslzigGJj3n89kA4v51tnA6VD9Cdu0KnH5re4czSxNcFtWzqlHLiZK7XS3UBVBp466r9nACC0HCkJ4zg2yeag8FeZ_TrVGEjfkFo7tt5pt9doTfwpcYoznqSQHIhpsTR-K4N3C5Rdr3UkV2Fo0AOFnNwiirzEuZzJ6q-NqAozuW7zS6Of7-tiyYoApV9VuMIvKYFPRsHJoYXASu7ELl9ovDN1prKfCHNL0D8a-n5BfPX91w1BEHYPBBjIs2tu72i20e3xQ8jDOXungvjVhLuXsIzqw1DuTbX244oqTtmJg9_QWntwayoA19WFfdN4c2USUUvLAA1BjqQW9XAX2sArk5Nd0IJJcLwuRH1-9lHDE0I7g77CDbgdxoH6cQg6WomaqM0F8Jwm3kyQBTE9_qAczPfSvYmVkuXaDvKFVBP-18GEH9dbrk8nz5UwfRkhfx0uj2AmPVeKCAA35ekakTEbT1ggfvEMqo1nTfyUJ1Sq7iKwm2u9jL44sWQgxkGJAleTuXT_hZRRt8i5QjqBX-FTJsql1-_wlMDtPwByr3TPUzLdug-UVYsZflzAdb_UdQwobmAuoPHiLgxDK5QD3rA5S28R6Mi-7CPYuYQJUEZ0nOmDvI6fHRXV__VfhVfnBpQSO2L282Ln2d1UUf2hqVOYmwJhah2FqbGy9YAljv6VLGaGIb3eNuhrKXBsUbEaod1nHyL6wJPNd7sXxcPwVs2IwUI07I9_HHUWH1rdagp2pBDykxgPkepPypGbjnCbU0_1Dka_jG7fnjc-wGAqhn9wL3mB--ipiOn37HdZgJd88USw5QJfIVCu2CWQ4GxUfaqDLNSRV-Bc17PJtOgb2R4vq91gP7vz6eHSAfUU3NR2wVoKCLVG3rjNgeQsZdYKTMcvYuUu4DCxAcAjJjc-_Eww9G8BQtnXoRcZTTZD52QVXNsvkO8EoFNI4KI0_cNiQQZxcmr9AkEOG0O8C6BtEs2ZHkN8XpKUgNjDVFbj1gWEEUq4EbUCtcliecH0LqkBIfOXaXy0VwLFf6p78cctEOJz5quMkD_KNJGi8ZiRxJEby9xclTbh1zIchovBAEIBEuDSB6jPFz2RC8mhQcUJ9OHM7g7b3SNOufZELFl8BwJ0bdpITHuPa2ftczR4ANIuQ8C6X1ghJEJHIzJ1wZYj1S8osIN3t0XXbtdynFTzVCUpSmduLcS73n8Uyl8DeOjeNLbJcnuu_3FLK9TkfGv3-j2NqABX4P_Woq4-0UrfA3IcOW5_3MWzNMhJ0nk6F3Bto_3qNXGNbxbr18DwPIybKGL5mJOsk9OqreDXxXPXL0sDBHUPaD1IDeHHJ6NEO-HihCF24HxjOzE7DeyQwWg3CAY_lLzFVZuLkrZKAVuy2JBsUpDmbWrTFRrkW_nR7xnOhCQa8DcHyooyvtNykuktkt26uBCcVA_eFCRneH6g05H9tOy7mPmhYsOIZbaDvJpjJsqEXmzOqwJWZEOy-jPnNAtj7zZ6DRIlYRb_bXRDxN0BpKzFGIZUuUAKIx3vN9ppHZl6XvOxmIZhR-fn6TPIojWoN2VJ9jKdFrcNkWYqXjcYjgxgZRPzUdI4OtUpFYJMAGZdU5zuYTfuk9MpBbhheM3aKlJNlILx10PteQ2x94PY3EODLhnkY_rG4v7BDZuyFizICgOe95X14qe0Qxo20Se55RpFRmeCMq7tg8y4q3X0gx8F1-7SaQ-JSNbPQkBA_yPsFPI6gs3i3--W2HJmM2jPV2mbGANC8BuKI_vcyvOH5uGqTXmSeDOobTprBuG_U1v1nLjgnm9U4eFE6KbMFkco-PgdVjgrjf4nFiV1b94Acx4BELLQWH1e8lCzPF3s1d9fJTguJ-u5F6VFX_9K0Lfg6llKZWQdNFuQu3AYBSGsdQ3Rq1dR8rE7Qa-be1OV8iW_1VSSU5f5xu1IVBsurHvfNOf3lWvpAUsd3KWZCLacOaAF0N_jSJx3HtgzjBIDRcYETtlxF8BlIgbLU6xbJkVMirpzrAMXtqda76QsQTo_KlnOw09Bsl5eLrn3vpLsqQ-kgzOu75viIqCHFc26ZJmcaeb-JPoyONIY0jSZ0_GHiACMwAbv2GiLCuosmafmC4yia5Sa0G4i4hfYPbS53_734PXTp1nnh69oXIlAB3k2GmurAajMWx_c3vsIZmsJytoJ5LW97lsaV4W9sSkTk9f6tIv6me8Tc1fwdZzyBHTLK1E0_UM9Mm5UXb0t7ILzcYyaHJ3e5oZB3x11P3Pj_FUFETQYMxWGaz9-zVkPEeKhI3dfykV5dVj_iQuvmuPnG7eRYeMlk7eEn9OF2L4HXfuFiP-zcOiEYO1TtatMKKl54KC3_T8W-WlIhb-L30uJ8CTJQLaY026K5Op39kOZQyyVghqCtOV2u6dYeMzrChYPyyVA5SiyPzFD8HdYLAdbtFg02w0XLfKXAjVz7sAYMjvsqmaXZR-lyLCb65vncNDlwkWscyEl_hoZuI9vveCVeDqV0RT8_xcevWAedCMrgF9WtP1N0djyg3zgIehh92viu9dTPy3xgOh6dpq5gyMAJPYFcPoPB_7pu6nLomQnjZlxuKoiD6XH_KXUnhgdoD1i4OArvEjjo8LPBP-CWzuX25AbFqaE1IyL8_x85s-LZPdbL5OoW6_mLqB3VsOiakRB_mUu4ei8VC7SRsnIJiZ5JCMF-AIk1XVwgE-8AvD-Zljqgq2gX4qQUhI_qROJ_qTQHVhxJLcArr6W0KyWvYNXlqNZxap0V_tfAPYzcNZsJT9VCqQ29UdCwb9U832XhiEWNzSls2Lj6OJ9QIkMjzAI8Dq-34I1poD0izhnEQ14UNlHZo9T7453wOOpdt5K00CJt0dgU4Idj86_O9tDXReSRNm9vJfEVMY72XaD-An8aklfLosUwNJ8jfXgFsgKn9eekltgbsY-0YqSW2_VSjfIVf-hNk0VYtu2QdWgw_dfXqU7H4GNLE8IirZv_3BJh4g02h29tFKadKdtWmFRs6r_oPHmpGc3pIKLypqHkMZ2PyItQ73KTy4OyUeCLM8v-2CD6VFKwnTFMeLU8w6jrWexkeLiUuXGobwVb8uGe1HO1tMxgtw-A3-hNWK2Z6ucONHny4KVnH6VWaLYhcLwkV7fP2wxHLyqkG2kZhbgSDC31sJ-shJI6paLQOSGcposBmEbFHj3P1OJ8xZ0WIsHnIK9HASc3dVqT7OGqGzCy9Slaxc319pEpvrjFmQMM4g0HmUS1VwHIHpiIfrLAoVeH72hKjjlGMpFOuAGB2li1jUzzUqysDwQA5EfUItO-H6XzvkAjB_or0OSr1YYR6ebtXbP9ppF5SqJKLiFLamqMXtgfJVIFWcXSUtke3ANAIlk4dQVoGqSehwgQheQa1AVC2pviDIon9-oN7dtTO9VEhNGM4_h15qJgeS3PtcAy4YUeFBKhz__Ki1Oth-0EUaleHmNxl1lenXGv2VvU6KAuD5EcA3EheyEGj_nurx4ii4CHJZ4xNwEB4_XCp00BStLJCLPXCsSWCRnEzZ-5kM7uUIlQ213wpGmWuipr5poZPDHV5acz1GkqWTXMwu6dwgbs2qvDAN6fm4QCiP8tk8fxVf1djvnBOYNB5igZQH9sEjMeP-8GSAGQCUBWFdarLe7RMck5OoZADUmvsDOO-jqlZ0S0cUS0VBZc5YE2DwJCr99ChWRJSaYNUzb4pdZ2D-21UNqYpOJloNZAuqeQ20nh_K0rK_1xfprusj9XHEyeEUEiMCLQmazniUgukaobIST-uGd7N1Ny6DtjkeFYgZPQpkdcspYAAEBlJEeqh9xESOelR4nnhsG0GOg_GA7vuiIe38sIzs66eY_q4EAifjjd6fLuVFIk7jgEg5AVD8KVImSots28GpbuYsGoOPmI-xqpCHaBbsJ3WN_yXyb-5TOMiHChzES7pXj-YscC08M3C1le6c5Jl9LzE1yIqxh5YBlDjlsRbhn2p37qNN3ao4-ilv4xOcXyrTgen1Hyy1I6b09pBzMOjPcX7nWVxnJrfOlLOvlgtF8Hjy3nBA-K_1jWDQVNCqmtHgxzvfBfaoeK3VzkWgj-XAT2rc2KgJsQAvSOKA2e8KVu400SgHvQSTUfgElTgxo42D7Dz25TPDQJCizT_zzCzWVLyAiKnEneU8LIHtewwYelPWuXAFKDOj_4XpPxo2x2XrZQDLl9TwrP8W6Pp85RtqBo9Bl38CJQ0MzRyZ3TNBI1eNB2JquQTl_FO7JSAZoI0JvAGtwTNCauQa_qbOPMLyc4Y4HNIr4T14uPdJl-88tsX-Jkopw6FTpeit03Yuy6LrmHraGW5ZGMghzOvp4gw0vAw_SjLi0QxzlgweHZ1qPYI0XIPuyYrCYKP6FcByigvezpYs-W-BxC2-GcbuuaiSxnaViBWQ17R_euFLt06rwkns-Xzi2MgfEGBBd2ADWqCjpr4VKbYG-XWysGW4JPELfYjZ0ttcFZzzX5_SxZQEGuMTDZ9oIOMLcrEz21U-vTdfch0W0VO3t1I0ZvhcTlxIZWnOBazc1l2f2GVaVwjOJhGnC4DNWVP_84ab6djvzsavdBVlS9_6VGxIlA-3XlODvSmwI64430HVreA0pOdzN2WXh8Fs0BquOQRBTR2xf0X8cVXGcPfiVFpw_Bbhmiv8Nw-V5U20dmT_mj0RII8iEZR32nzQfoEAMpQ5PMHivOb7hJPyMBA_iWsVlVA6FjntWX1naLnzgDdsCrpmOPIztcu-1tlyxMBqhvyh0XVsfHhvv2mt3t0NICqlBbzSQyZK8v2AMB2NSTU4CbvE-iukFQ-JR2oOxcMh8WTiLTz7QQmsCb1HHm5K2xbtp9zw60Kvwbxqa9K8xp4-ssVpv3mishFAo6I8t81MO2aMWqy_PKUl7nGaLbjL4VaApkWZXljeuGKkPPP2dXl-83QRRSNHV_r_GlH0tpD8WNXnEfil9RpSsYfJuPXs_zIADivBwPd2SxTT0rtNupj01w1lJjDDdbUXZUMJka2XnLC43cS61c8wGYdCwIcwPEcO1FkvY753vRUH21py9ki3elCzCU3lL7rZcAN3TTMHVzXiDAswy0gTAS5b-Zj4koeTlsxncJ8gZwYuhaLcVwIb_PJ9fGY_HjDlX_zdNiw15JC02EBXCSmRLUR2Ub0cwWvfDoMrwxZ_Mskrd3r_k6LcaUitKbxz0qH_JwQUHUDzhC9KMEm4ls1oh-Ys0bZrvf_Smh5FnoALScmOLaMjs623uK99DvoV8aFW7IVfXmMdlPN081sf0Tjb7B9IIc7jRwkFQ0_iXO5XfO76RrZ7O0V3YY-8NfgJRFwhG9GPLqem7-tG-SBV4CehItfqBwsvsDUaa1i3Z3Ot78ahJdkwGeJCEg7NMyMiuZxEl6sfRIbxt7aWOKIX_pUUvio2iFB1-F2IojJDqvQriw9reDoVMgj7n75uaaH738IzAwDfvUFiAH9F1YTL4-v83iXnZlsEBg9kPcQUHksqh1y9fKvGxeYeSn4nRI-4XHPPshyGKZMS64Arvg29Eq28F34AB3lxc_iWhSed9-YOH-YnFI2QdMAc-Umh5zNy4eDFz36GDptL591WAP3Xx2Fs14fwwUyoO6NzE8HkjzKNxSCCL4KM9SuaiDEAS42kYvC2-HAgYghAMO_Z6gezb0HVNNQKp86EeL2rUY5adOdrzwW9871mFj0MhNrh1ix5mzHaaMIxUe6XhNR-J-CfIgRHrNj6k961jIXb1o104EEdYVyAQxp-HzLbLwzMW-fHcap5loq22uCStESQxlqb9N4R4UhwB21HzVw65ecFTLm7k9OppWdK6XTReLRTN3rhFJAV7upCHwNak2T41wFc4fFGkLCi9kaq50uA1KI3byQ3SBrhvYFVX69KtDxIEC7kfVMe-hkgQHvevpVuBkAM8Q== \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6c865d2..7d80516 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,5 @@ uploads/* *.tfplan tfplan .terraform.lock.hcl +docs/Phase0-Setup-Manual.docx +docs/Phase0-Setup-Manual.pdf diff --git a/backend/api/routes/migrations.py b/backend/api/routes/migrations.py new file mode 100644 index 0000000..9e283f5 --- /dev/null +++ b/backend/api/routes/migrations.py @@ -0,0 +1,136 @@ +"""API routes for migration runs and CSV file uploads. + +Phase 1 endpoints: + POST /api/migrations – create a new run + GET /api/migrations – list all runs + GET /api/migrations/{id} – get run detail + DELETE /api/migrations/{id} – delete a run and its files + POST /api/migrations/{id}/files – upload one or more CSV files + GET /api/migrations/{id}/files – list files for a run + DELETE /api/migrations/files/{id} – delete a single file +""" +from typing import List + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from sqlalchemy.orm import Session + +from api.schemas import ( + DeleteResponse, + MigrationRunCreate, + MigrationRunListResponse, + MigrationRunResponse, + UploadedFileListResponse, + UploadedFileResponse, +) +from database.connection import get_db +from services import migration_service + +router = APIRouter(prefix="/migrations", tags=["migrations"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _run_to_response(run) -> MigrationRunResponse: + """Convert an ORM MigrationRun to a response schema.""" + files = run.files or [] + return MigrationRunResponse( + id=run.id, + name=run.name, + environment=run.environment, + description=run.description, + status=run.status.value if hasattr(run.status, "value") else run.status, + created_at=run.created_at, + updated_at=run.updated_at, + file_count=len(files), + total_size=sum(f.file_size for f in files), + ) + + +# --------------------------------------------------------------------------- +# Migration Run endpoints +# --------------------------------------------------------------------------- + +@router.post("", response_model=MigrationRunResponse, status_code=201) +def create_migration_run(body: MigrationRunCreate, db: Session = Depends(get_db)): + """Create a new migration run.""" + run = migration_service.create_run( + db, name=body.name, environment=body.environment, description=body.description + ) + return _run_to_response(run) + + +@router.get("", response_model=MigrationRunListResponse) +def list_migration_runs(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)): + """List migration runs, newest first.""" + runs, total = migration_service.list_runs(db, skip=skip, limit=limit) + return MigrationRunListResponse( + runs=[_run_to_response(r) for r in runs], + total=total, + ) + + +@router.get("/{run_id}", response_model=MigrationRunResponse) +def get_migration_run(run_id: int, db: Session = Depends(get_db)): + """Get a single migration run by ID.""" + run = migration_service.get_run(db, run_id) + if not run: + raise HTTPException(status_code=404, detail=f"Migration run {run_id} not found") + return _run_to_response(run) + + +@router.delete("/{run_id}", response_model=DeleteResponse) +def delete_migration_run(run_id: int, db: Session = Depends(get_db)): + """Delete a migration run and all its files.""" + deleted = migration_service.delete_run(db, run_id) + if not deleted: + raise HTTPException(status_code=404, detail=f"Migration run {run_id} not found") + return DeleteResponse(detail="Migration run deleted", id=run_id) + + +# --------------------------------------------------------------------------- +# File Upload endpoints +# --------------------------------------------------------------------------- + +@router.post("/{run_id}/files", response_model=List[UploadedFileResponse], status_code=201) +async def upload_files( + run_id: int, + files: List[UploadFile] = File(..., description="One or more CSV files"), + db: Session = Depends(get_db), +): + """Upload one or more CSV files to a migration run.""" + # Validate the run exists + run = migration_service.get_run(db, run_id) + if not run: + raise HTTPException(status_code=404, detail=f"Migration run {run_id} not found") + + results = [] + for file in files: + uploaded = await migration_service.upload_file(db, run_id, file) + if uploaded: + results.append(UploadedFileResponse.model_validate(uploaded)) + + return results + + +@router.get("/{run_id}/files", response_model=UploadedFileListResponse) +def list_uploaded_files(run_id: int, db: Session = Depends(get_db)): + """List all files uploaded for a migration run.""" + run = migration_service.get_run(db, run_id) + if not run: + raise HTTPException(status_code=404, detail=f"Migration run {run_id} not found") + files = migration_service.list_files(db, run_id) + return UploadedFileListResponse( + files=[UploadedFileResponse.model_validate(f) for f in files], + total=len(files), + ) + + +@router.delete("/files/{file_id}", response_model=DeleteResponse) +def delete_uploaded_file(file_id: int, db: Session = Depends(get_db)): + """Delete a single uploaded file.""" + deleted = migration_service.delete_file(db, file_id) + if not deleted: + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + return DeleteResponse(detail="File deleted", id=file_id) diff --git a/backend/api/schemas.py b/backend/api/schemas.py new file mode 100644 index 0000000..7a9677b --- /dev/null +++ b/backend/api/schemas.py @@ -0,0 +1,74 @@ +"""Pydantic schemas for the MEP REST API. + +These models define request bodies, response payloads, and validation rules. +They are intentionally separate from the SQLAlchemy ORM models in +``database/models.py``. +""" +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Migration Run +# --------------------------------------------------------------------------- + +class MigrationRunCreate(BaseModel): + """Request body for creating a new migration run.""" + name: str = Field(..., min_length=1, max_length=255, examples=["Q3 Customer Migration"]) + environment: str = Field("development", max_length=100, examples=["development", "staging", "production"]) + description: Optional[str] = Field(None, examples=["Migrate Q3 customer data from legacy CRM"]) + + +class MigrationRunResponse(BaseModel): + """Response payload representing a migration run.""" + id: int + name: str + environment: str + description: Optional[str] + status: str + created_at: datetime + updated_at: datetime + file_count: int = 0 + total_size: int = 0 + + model_config = {"from_attributes": True} + + +class MigrationRunListResponse(BaseModel): + """Paginated list of migration runs.""" + runs: list["MigrationRunResponse"] + total: int + + +# --------------------------------------------------------------------------- +# Uploaded File +# --------------------------------------------------------------------------- + +class UploadedFileResponse(BaseModel): + """Response payload representing a single uploaded CSV file.""" + id: int + migration_run_id: int + original_filename: str + stored_filename: str + file_size: int + content_type: Optional[str] + row_count: Optional[int] + column_count: Optional[int] + columns: Optional[str] + uploaded_at: datetime + + model_config = {"from_attributes": True} + + +class UploadedFileListResponse(BaseModel): + """List of uploaded files for a migration run.""" + files: list[UploadedFileResponse] + total: int + + +class DeleteResponse(BaseModel): + """Generic deletion confirmation.""" + detail: str + id: int diff --git a/backend/database/connection.py b/backend/database/connection.py index 03ad1c3..5314b1c 100644 --- a/backend/database/connection.py +++ b/backend/database/connection.py @@ -5,7 +5,6 @@ ``check_db_connection`` helper used by the health endpoint. """ import logging -from contextlib import contextmanager from typing import Generator from sqlalchemy import create_engine, text @@ -33,12 +32,11 @@ ) -@contextmanager def get_db() -> Generator[Session, None, None]: """Yield a database session, ensuring it is closed afterwards. - Usable both as a context manager (``with get_db() as db:``) and as a - FastAPI dependency (``db: Session = Depends(get_db)``). + Usable as a FastAPI dependency: ``db: Session = Depends(get_db)``. + Also works with ``contextmanager`` wrapping when used outside FastAPI. """ db = SessionLocal() try: diff --git a/backend/database/models.py b/backend/database/models.py index 51ff44d..8be991e 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -1,12 +1,100 @@ """SQLAlchemy declarative base and ORM models for MEP. -This module currently only exposes the declarative ``Base``. MEP metadata -models (e.g. ``MigrationRun``, ``UploadedFile``) will be added in later steps. +Phase 1 adds MigrationRun and UploadedFile models to track migration +sessions and their associated CSV uploads. """ -from sqlalchemy.orm import DeclarativeBase +import enum +from datetime import datetime, timezone + +from sqlalchemy import ( + Column, + DateTime, + Enum, + ForeignKey, + Integer, + BigInteger, + String, + Text, +) +from sqlalchemy.orm import DeclarativeBase, relationship class Base(DeclarativeBase): """Base declarative class for all MEP ORM models.""" - pass + + +class RunStatus(str, enum.Enum): + """Lifecycle states for a migration run.""" + CREATED = "created" + UPLOADING = "uploading" + VALIDATING = "validating" + MIGRATING = "migrating" + COMPLETED = "completed" + FAILED = "failed" + + +class MigrationRun(Base): + """A single migration session — groups uploaded files and tracks status.""" + + __tablename__ = "migration_runs" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + environment = Column(String(100), nullable=False, default="development") + description = Column(Text, nullable=True) + status = Column( + Enum(RunStatus, name="run_status"), + nullable=False, + default=RunStatus.CREATED, + ) + created_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + updated_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Relationship: one run → many uploaded files + files = relationship( + "UploadedFile", back_populates="migration_run", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +class UploadedFile(Base): + """Metadata for a single CSV file uploaded as part of a migration run.""" + + __tablename__ = "uploaded_files" + + id = Column(Integer, primary_key=True, autoincrement=True) + migration_run_id = Column( + Integer, + ForeignKey("migration_runs.id", ondelete="CASCADE"), + nullable=False, + ) + original_filename = Column(String(500), nullable=False) + stored_filename = Column(String(500), nullable=False) + file_size = Column(BigInteger, nullable=False, default=0) + content_type = Column(String(100), nullable=True, default="text/csv") + row_count = Column(Integer, nullable=True) + column_count = Column(Integer, nullable=True) + columns = Column(Text, nullable=True) # JSON string of column names + uploaded_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + # Relationship back to the parent run + migration_run = relationship("MigrationRun", back_populates="files") + + def __repr__(self) -> str: + return f"" diff --git a/backend/main.py b/backend/main.py index cb559a7..e2bf7ca 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,9 +10,10 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from api.routes import health +from api.routes import health, migrations from config import settings -from database.connection import check_db_connection +from database.connection import check_db_connection, engine +from database.models import Base logging.basicConfig(level=logging.INFO) logger = logging.getLogger("mep") @@ -32,6 +33,7 @@ # Mount routers under the /api prefix. app.include_router(health.router, prefix="/api") +app.include_router(migrations.router, prefix="/api") @app.on_event("startup") @@ -41,6 +43,9 @@ def on_startup() -> None: logger.info("Environment: %s | Debug: %s", settings.APP_ENV, settings.DEBUG) if check_db_connection(): logger.info("Database connection: OK") + # Auto-create tables in dev (use Alembic migrations in production) + Base.metadata.create_all(bind=engine) + logger.info("Database tables ensured") else: logger.warning("Database connection: UNAVAILABLE") diff --git a/backend/services/__init__.py b/backend/services/__init__.py index ce0902b..e69de29 100644 --- a/backend/services/__init__.py +++ b/backend/services/__init__.py @@ -1 +0,0 @@ -"""Services package (business logic) for MEP.""" diff --git a/backend/services/migration_service.py b/backend/services/migration_service.py new file mode 100644 index 0000000..445a5a0 --- /dev/null +++ b/backend/services/migration_service.py @@ -0,0 +1,172 @@ +"""Business logic for migration runs and file uploads. + +Keeps route handlers thin: they delegate to functions here, which interact +with the ORM models and the filesystem. +""" +import csv +import io +import json +import os +import uuid +from typing import Optional + +from fastapi import UploadFile +from sqlalchemy.orm import Session + +from database.models import MigrationRun, RunStatus, UploadedFile + +# Upload directory — relative to the backend working dir, mounted via Docker +UPLOAD_DIR = os.environ.get("MEP_UPLOAD_DIR", os.path.join(os.getcwd(), "uploads")) + + +def _ensure_upload_dir(run_id: int) -> str: + """Create and return the upload directory for a specific run.""" + run_dir = os.path.join(UPLOAD_DIR, str(run_id)) + os.makedirs(run_dir, exist_ok=True) + return run_dir + + +# --------------------------------------------------------------------------- +# Migration Run CRUD +# --------------------------------------------------------------------------- + +def create_run(db: Session, name: str, environment: str, description: Optional[str] = None) -> MigrationRun: + """Create a new migration run and persist it.""" + run = MigrationRun( + name=name, + environment=environment, + description=description, + status=RunStatus.CREATED, + ) + db.add(run) + db.commit() + db.refresh(run) + return run + + +def get_run(db: Session, run_id: int) -> Optional[MigrationRun]: + """Return a migration run by ID, or None.""" + return db.query(MigrationRun).filter(MigrationRun.id == run_id).first() + + +def list_runs(db: Session, skip: int = 0, limit: int = 50) -> tuple[list[MigrationRun], int]: + """Return a paginated list of runs (newest first) and the total count.""" + total = db.query(MigrationRun).count() + runs = ( + db.query(MigrationRun) + .order_by(MigrationRun.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return runs, total + + +def delete_run(db: Session, run_id: int) -> bool: + """Delete a run and its associated files from DB and disk. Returns True if found.""" + run = get_run(db, run_id) + if not run: + return False + # Remove uploaded files from disk + run_dir = os.path.join(UPLOAD_DIR, str(run_id)) + if os.path.isdir(run_dir): + import shutil + shutil.rmtree(run_dir, ignore_errors=True) + db.delete(run) + db.commit() + return True + + +# --------------------------------------------------------------------------- +# File Upload +# --------------------------------------------------------------------------- + +def _parse_csv_metadata(content: bytes, filename: str) -> dict: + """Quickly parse CSV content to extract row count, column count, and column names.""" + result = {"row_count": None, "column_count": None, "columns": None} + try: + text = content.decode("utf-8-sig") # handles BOM + reader = csv.reader(io.StringIO(text)) + headers = next(reader, None) + if headers: + result["columns"] = json.dumps([h.strip() for h in headers]) + result["column_count"] = len(headers) + # Count data rows (excluding header) + row_count = sum(1 for _ in reader) + result["row_count"] = row_count + except Exception: + pass # non-fatal — metadata is optional + return result + + +async def upload_file(db: Session, run_id: int, file: UploadFile) -> Optional[UploadedFile]: + """Save an uploaded file to disk and create a DB record. + + Returns the UploadedFile object, or None if the run doesn't exist. + """ + run = get_run(db, run_id) + if not run: + return None + + # Read file content + content = await file.read() + file_size = len(content) + + # Generate a unique stored filename to avoid collisions + ext = os.path.splitext(file.filename or "file.csv")[1] + stored_name = f"{uuid.uuid4().hex}{ext}" + + # Save to disk + run_dir = _ensure_upload_dir(run_id) + file_path = os.path.join(run_dir, stored_name) + with open(file_path, "wb") as f: + f.write(content) + + # Parse CSV metadata + meta = _parse_csv_metadata(content, file.filename or "unknown.csv") + + # Persist to DB + uploaded = UploadedFile( + migration_run_id=run_id, + original_filename=file.filename or "unknown.csv", + stored_filename=stored_name, + file_size=file_size, + content_type=file.content_type or "text/csv", + row_count=meta["row_count"], + column_count=meta["column_count"], + columns=meta["columns"], + ) + db.add(uploaded) + + # Update run status to UPLOADING if still CREATED + if run.status == RunStatus.CREATED: + run.status = RunStatus.UPLOADING + + db.commit() + db.refresh(uploaded) + return uploaded + + +def list_files(db: Session, run_id: int) -> list[UploadedFile]: + """Return all files for a given run.""" + return ( + db.query(UploadedFile) + .filter(UploadedFile.migration_run_id == run_id) + .order_by(UploadedFile.uploaded_at) + .all() + ) + + +def delete_file(db: Session, file_id: int) -> bool: + """Delete a single uploaded file from DB and disk. Returns True if found.""" + uploaded = db.query(UploadedFile).filter(UploadedFile.id == file_id).first() + if not uploaded: + return False + # Remove from disk + run_dir = os.path.join(UPLOAD_DIR, str(uploaded.migration_run_id)) + file_path = os.path.join(run_dir, uploaded.stored_filename) + if os.path.exists(file_path): + os.remove(file_path) + db.delete(uploaded) + db.commit() + return True diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..ad87551 --- /dev/null +++ b/backend/tests/test_migrations.py @@ -0,0 +1,224 @@ +"""Tests for migration run and file upload API endpoints. + +Uses an in-memory SQLite database so tests run without PostgreSQL. +""" +import io +import os +import shutil +import tempfile + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from database.models import Base +from database.connection import get_db +from main import app + +# --------------------------------------------------------------------------- +# Test database setup — in-memory SQLite +# --------------------------------------------------------------------------- + +SQLALCHEMY_TEST_URL = "sqlite:///./test_mep.db" +test_engine = create_engine(SQLALCHEMY_TEST_URL, connect_args={"check_same_thread": False}) +TestSession = sessionmaker(bind=test_engine, autoflush=False, autocommit=False) + +# Temporary upload directory for tests +TEST_UPLOAD_DIR = tempfile.mkdtemp(prefix="mep_test_uploads_") + + +def override_get_db(): + db = TestSession() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(autouse=True) +def setup_database(): + """Create tables before each test, drop after.""" + Base.metadata.create_all(bind=test_engine) + # Point upload service to temp directory + os.environ["MEP_UPLOAD_DIR"] = TEST_UPLOAD_DIR + yield + Base.metadata.drop_all(bind=test_engine) + # Clean up upload files + if os.path.isdir(TEST_UPLOAD_DIR): + shutil.rmtree(TEST_UPLOAD_DIR, ignore_errors=True) + os.makedirs(TEST_UPLOAD_DIR, exist_ok=True) + + +client = TestClient(app) + + +# --------------------------------------------------------------------------- +# Migration Run Tests +# --------------------------------------------------------------------------- + +class TestMigrationRuns: + """Tests for migration run CRUD endpoints.""" + + def test_create_run(self): + resp = client.post("/api/migrations", json={ + "name": "Test Migration", + "environment": "development", + "description": "A test run" + }) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Test Migration" + assert data["environment"] == "development" + assert data["status"] == "created" + assert data["file_count"] == 0 + + def test_create_run_minimal(self): + """Only name is required.""" + resp = client.post("/api/migrations", json={"name": "Minimal Run"}) + assert resp.status_code == 201 + assert resp.json()["name"] == "Minimal Run" + + def test_create_run_empty_name_rejected(self): + resp = client.post("/api/migrations", json={"name": ""}) + assert resp.status_code == 422 + + def test_list_runs_empty(self): + resp = client.get("/api/migrations") + assert resp.status_code == 200 + assert resp.json()["runs"] == [] + assert resp.json()["total"] == 0 + + def test_list_runs(self): + client.post("/api/migrations", json={"name": "Run 1"}) + client.post("/api/migrations", json={"name": "Run 2"}) + resp = client.get("/api/migrations") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + assert len(data["runs"]) == 2 + + def test_get_run(self): + create_resp = client.post("/api/migrations", json={"name": "Get Me"}) + run_id = create_resp.json()["id"] + resp = client.get(f"/api/migrations/{run_id}") + assert resp.status_code == 200 + assert resp.json()["name"] == "Get Me" + + def test_get_run_not_found(self): + resp = client.get("/api/migrations/9999") + assert resp.status_code == 404 + + def test_delete_run(self): + create_resp = client.post("/api/migrations", json={"name": "Delete Me"}) + run_id = create_resp.json()["id"] + resp = client.delete(f"/api/migrations/{run_id}") + assert resp.status_code == 200 + assert resp.json()["id"] == run_id + # Confirm it's gone + assert client.get(f"/api/migrations/{run_id}").status_code == 404 + + def test_delete_run_not_found(self): + resp = client.delete("/api/migrations/9999") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# File Upload Tests +# --------------------------------------------------------------------------- + +class TestFileUploads: + """Tests for CSV file upload endpoints.""" + + def _create_run(self, name: str = "Upload Test") -> int: + resp = client.post("/api/migrations", json={"name": name}) + return resp.json()["id"] + + def _csv_content(self, headers: str = "id,name,email", rows: int = 3) -> bytes: + lines = [headers] + for i in range(1, rows + 1): + lines.append(f"{i},User {i},user{i}@example.com") + return "\n".join(lines).encode("utf-8") + + def test_upload_single_file(self): + run_id = self._create_run() + csv_data = self._csv_content() + resp = client.post( + f"/api/migrations/{run_id}/files", + files=[("files", ("customers.csv", io.BytesIO(csv_data), "text/csv"))], + ) + assert resp.status_code == 201 + data = resp.json() + assert len(data) == 1 + assert data[0]["original_filename"] == "customers.csv" + assert data[0]["row_count"] == 3 + assert data[0]["column_count"] == 3 + assert data[0]["file_size"] > 0 + + def test_upload_multiple_files(self): + run_id = self._create_run() + files = [ + ("files", ("customers.csv", io.BytesIO(self._csv_content()), "text/csv")), + ("files", ("orders.csv", io.BytesIO(self._csv_content("order_id,amount,date", 5)), "text/csv")), + ] + resp = client.post(f"/api/migrations/{run_id}/files", files=files) + assert resp.status_code == 201 + assert len(resp.json()) == 2 + + def test_upload_to_nonexistent_run(self): + resp = client.post( + "/api/migrations/9999/files", + files=[("files", ("test.csv", io.BytesIO(b"a,b\n1,2"), "text/csv"))], + ) + assert resp.status_code == 404 + + def test_list_files(self): + run_id = self._create_run() + client.post( + f"/api/migrations/{run_id}/files", + files=[("files", ("data.csv", io.BytesIO(self._csv_content()), "text/csv"))], + ) + resp = client.get(f"/api/migrations/{run_id}/files") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["files"][0]["original_filename"] == "data.csv" + + def test_list_files_nonexistent_run(self): + resp = client.get("/api/migrations/9999/files") + assert resp.status_code == 404 + + def test_delete_file(self): + run_id = self._create_run() + upload_resp = client.post( + f"/api/migrations/{run_id}/files", + files=[("files", ("delete_me.csv", io.BytesIO(self._csv_content()), "text/csv"))], + ) + file_id = upload_resp.json()[0]["id"] + resp = client.delete(f"/api/migrations/files/{file_id}") + assert resp.status_code == 200 + assert resp.json()["id"] == file_id + # Confirm file list is now empty + list_resp = client.get(f"/api/migrations/{run_id}/files") + assert list_resp.json()["total"] == 0 + + def test_delete_file_not_found(self): + resp = client.delete("/api/migrations/files/9999") + assert resp.status_code == 404 + + def test_run_file_count_updates(self): + """After uploading files, the run's file_count should reflect them.""" + run_id = self._create_run() + client.post( + f"/api/migrations/{run_id}/files", + files=[ + ("files", ("a.csv", io.BytesIO(self._csv_content()), "text/csv")), + ("files", ("b.csv", io.BytesIO(self._csv_content()), "text/csv")), + ], + ) + run_resp = client.get(f"/api/migrations/{run_id}") + assert run_resp.json()["file_count"] == 2 + assert run_resp.json()["total_size"] > 0 diff --git a/frontend/src/api/migrations.ts b/frontend/src/api/migrations.ts new file mode 100644 index 0000000..c89222f --- /dev/null +++ b/frontend/src/api/migrations.ts @@ -0,0 +1,95 @@ +/** + * API client for migration run and file upload endpoints. + */ +import apiClient from './client'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface MigrationRun { + id: number; + name: string; + environment: string; + description: string | null; + status: string; + created_at: string; + updated_at: string; + file_count: number; + total_size: number; +} + +export interface MigrationRunCreate { + name: string; + environment?: string; + description?: string; +} + +export interface UploadedFile { + id: number; + migration_run_id: number; + original_filename: string; + stored_filename: string; + file_size: number; + content_type: string | null; + row_count: number | null; + column_count: number | null; + columns: string | null; + uploaded_at: string; +} + +// --------------------------------------------------------------------------- +// Migration Runs +// --------------------------------------------------------------------------- + +export async function createMigrationRun(data: MigrationRunCreate): Promise { + const resp = await apiClient.post('/migrations', data); + return resp.data; +} + +export async function listMigrationRuns(): Promise<{ runs: MigrationRun[]; total: number }> { + const resp = await apiClient.get('/migrations'); + return resp.data; +} + +export async function getMigrationRun(runId: number): Promise { + const resp = await apiClient.get(`/migrations/${runId}`); + return resp.data; +} + +export async function deleteMigrationRun(runId: number): Promise { + await apiClient.delete(`/migrations/${runId}`); +} + +// --------------------------------------------------------------------------- +// File Uploads +// --------------------------------------------------------------------------- + +export async function uploadFiles( + runId: number, + files: File[], + onProgress?: (percent: number) => void, +): Promise { + const formData = new FormData(); + files.forEach((f) => formData.append('files', f)); + + const resp = await apiClient.post(`/migrations/${runId}/files`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120_000, // 2 min for large uploads + onUploadProgress: (e) => { + if (onProgress && e.total) { + onProgress(Math.round((e.loaded * 100) / e.total)); + } + }, + }); + return resp.data; +} + +export async function listFiles(runId: number): Promise<{ files: UploadedFile[]; total: number }> { + const resp = await apiClient.get(`/migrations/${runId}/files`); + return resp.data; +} + +export async function deleteFile(fileId: number): Promise { + await apiClient.delete(`/migrations/files/${fileId}`); +} diff --git a/frontend/src/pages/MigrationRuns.tsx b/frontend/src/pages/MigrationRuns.tsx index cf91133..7dc7695 100644 --- a/frontend/src/pages/MigrationRuns.tsx +++ b/frontend/src/pages/MigrationRuns.tsx @@ -1,12 +1,175 @@ -import StorageIcon from '@mui/icons-material/Storage'; -import PagePlaceholder from './PagePlaceholder'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Alert, + Box, + Button, + Chip, + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import RefreshIcon from '@mui/icons-material/Refresh'; + +import { + listMigrationRuns, + deleteMigrationRun, + type MigrationRun, +} from '../api/migrations'; +import { formatFileSize, formatDate } from '../utils/format'; + +const STATUS_COLORS: Record = { + created: 'default', + uploading: 'info', + validating: 'warning', + migrating: 'info', + completed: 'success', + failed: 'error', +}; export default function MigrationRuns() { + const navigate = useNavigate(); + const [runs, setRuns] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const fetchRuns = async () => { + setLoading(true); + setError(''); + try { + const resp = await listMigrationRuns(); + setRuns(resp.runs); + setTotal(resp.total); + } catch { + setError('Failed to load migration runs'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchRuns(); + }, []); + + const handleDelete = async (runId: number) => { + try { + await deleteMigrationRun(runId); + setRuns((prev) => prev.filter((r) => r.id !== runId)); + setTotal((prev) => prev - 1); + } catch { + setError('Failed to delete migration run'); + } + }; + return ( - } - description="Browse and monitor active and completed CSV → PostgreSQL migration runs." - /> + + + + Migration Runs + + + + + + + + + + + + {error && ( + + {error} + + )} + + {!loading && runs.length === 0 ? ( + + + No migration runs yet. + + + + ) : ( + + + + + ID + Name + Environment + Status + Files + Size + Created + Actions + + + + {runs.map((run) => ( + + {run.id} + + {run.name} + + + + + + + + {run.file_count} + {formatFileSize(run.total_size)} + {formatDate(run.created_at)} + + + handleDelete(run.id)} + > + + + + + + ))} + +
+
+ )} + + {total > 0 && ( + + Showing {runs.length} of {total} migration runs + + )} +
); } diff --git a/frontend/src/pages/NewMigration.tsx b/frontend/src/pages/NewMigration.tsx index e485574..537252c 100644 --- a/frontend/src/pages/NewMigration.tsx +++ b/frontend/src/pages/NewMigration.tsx @@ -1,12 +1,459 @@ -import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutlineOutlined'; -import PagePlaceholder from './PagePlaceholder'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + IconButton, + LinearProgress, + Paper, + Stack, + Step, + StepLabel, + Stepper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + MenuItem, + Typography, + Tooltip, +} from '@mui/material'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; +import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined'; +import AddCircleOutlinedIcon from '@mui/icons-material/AddCircleOutlined'; + +import { + createMigrationRun, + uploadFiles, + listFiles, + deleteFile as apiDeleteFile, + type MigrationRun, + type UploadedFile, +} from '../api/migrations'; +import { formatFileSize, formatDate } from '../utils/format'; + +const ENVIRONMENTS = ['development', 'staging', 'production']; +const STEPS = ['Configure', 'Upload Files', 'Summary']; export default function NewMigration() { + // --- Stepper --- + const [activeStep, setActiveStep] = useState(0); + + // --- Step 1: Configure --- + const [name, setName] = useState(''); + const [environment, setEnvironment] = useState('development'); + const [description, setDescription] = useState(''); + const [run, setRun] = useState(null); + const [createError, setCreateError] = useState(''); + + // --- Step 2: Upload --- + const [pendingFiles, setPendingFiles] = useState([]); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadError, setUploadError] = useState(''); + const [dragActive, setDragActive] = useState(false); + const inputRef = useRef(null); + + // --- Step 1 handlers --- + const handleCreateRun = async () => { + if (!name.trim()) return; + setCreateError(''); + try { + const created = await createMigrationRun({ + name: name.trim(), + environment, + description: description.trim() || undefined, + }); + setRun(created); + setActiveStep(1); + } catch (err: any) { + setCreateError(err?.response?.data?.detail || 'Failed to create migration run'); + } + }; + + // --- Step 2 handlers --- + const addFiles = (incoming: FileList | File[]) => { + const arr = Array.from(incoming); + // Avoid duplicate filenames in the pending list + setPendingFiles((prev) => { + const existing = new Set(prev.map((f) => f.name)); + const novel = arr.filter((f) => !existing.has(f.name)); + return [...prev, ...novel]; + }); + }; + + const removePendingFile = (name: string) => { + setPendingFiles((prev) => prev.filter((f) => f.name !== name)); + }; + + const handleUpload = async () => { + if (!run || pendingFiles.length === 0) return; + setUploadError(''); + setUploading(true); + setUploadProgress(0); + try { + const results = await uploadFiles(run.id, pendingFiles, setUploadProgress); + setUploadedFiles((prev) => [...prev, ...results]); + setPendingFiles([]); + } catch (err: any) { + setUploadError(err?.response?.data?.detail || 'Upload failed'); + } finally { + setUploading(false); + } + }; + + const handleDeleteFile = async (fileId: number) => { + try { + await apiDeleteFile(fileId); + setUploadedFiles((prev) => prev.filter((f) => f.id !== fileId)); + } catch { + // Silently fail — user can retry + } + }; + + // Refresh uploaded file list when entering step 2 + useEffect(() => { + if (run && activeStep === 1) { + listFiles(run.id).then((resp) => setUploadedFiles(resp.files)).catch(() => {}); + } + }, [run, activeStep]); + + // --- Drag-and-drop handlers --- + const onDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(true); + }, []); + const onDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + }, []); + const onDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files); + }, []); + + // --------------------------------------------------------------------------- + // Render helpers + // --------------------------------------------------------------------------- + + const renderConfigureStep = () => ( + + + + Migration Details + + + setName(e.target.value)} + placeholder="e.g. Q3 Customer Migration" + /> + setEnvironment(e.target.value)} + > + {ENVIRONMENTS.map((env) => ( + + {env.charAt(0).toUpperCase() + env.slice(1)} + + ))} + + setDescription(e.target.value)} + placeholder="Describe the purpose of this migration..." + /> + {createError && {createError}} + + + + + ); + + const renderUploadStep = () => ( + + {/* Drop zone */} + inputRef.current?.click()} + sx={{ + p: 5, + textAlign: 'center', + cursor: 'pointer', + borderStyle: 'dashed', + borderWidth: 2, + borderColor: dragActive ? 'primary.main' : 'divider', + backgroundColor: dragActive ? 'action.hover' : 'background.paper', + transition: 'all 0.2s ease', + '&:hover': { borderColor: 'primary.light', backgroundColor: 'action.hover' }, + }} + > + + + Drag CSV files here + + + or click to browse — supports multiple files + + { + if (e.target.files) addFiles(e.target.files); + e.target.value = ''; + }} + /> + + + {/* Pending files */} + {pendingFiles.length > 0 && ( + + + Ready to upload ({pendingFiles.length} file{pendingFiles.length > 1 ? 's' : ''}) + + + {pendingFiles.map((f) => ( + + + + {f.name} + + ({formatFileSize(f.size)}) + + + removePendingFile(f.name)}> + + + + ))} + + + + + + + )} + + {/* Upload progress */} + {uploading && ( + + + + Uploading... {uploadProgress}% + + + )} + + {uploadError && ( + + {uploadError} + + )} + + {/* Uploaded files table */} + {uploadedFiles.length > 0 && ( + + + Uploaded Files ({uploadedFiles.length}) + + + + + + Filename + Size + Rows + Columns + Uploaded + Actions + + + + {uploadedFiles.map((f) => ( + + + + + {f.original_filename} + + + {formatFileSize(f.file_size)} + {f.row_count?.toLocaleString() ?? '—'} + {f.column_count ?? '—'} + {formatDate(f.uploaded_at)} + + + handleDeleteFile(f.id)}> + + + + + + ))} + +
+
+
+ )} + + {/* Navigation */} + + + + +
+ ); + + const renderSummaryStep = () => { + const totalSize = uploadedFiles.reduce((sum, f) => sum + f.file_size, 0); + const totalRows = uploadedFiles.reduce((sum, f) => sum + (f.row_count ?? 0), 0); + + return ( + + + + + Migration Ready + + + + + Migration Name + + {run?.name} + + + + Environment + + + + {run?.description && ( + + + Description + + {run.description} + + )} + + + + Files + + {uploadedFiles.length} + + + + Total Size + + {formatFileSize(totalSize)} + + + + Total Rows + + {totalRows.toLocaleString()} + + + + Upload complete. In future phases, you'll proceed to Schema Discovery → Validation → Migration from here. + + + + + + ); + }; + return ( - } - description="Configure and launch a new migration by uploading source data and selecting a target." - /> + + + New Migration + + + {STEPS.map((label) => ( + + {label} + + ))} + + + {activeStep === 0 && renderConfigureStep()} + {activeStep === 1 && renderUploadStep()} + {activeStep === 2 && renderSummaryStep()} + ); } diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..79e9174 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,13 @@ +/** Format a byte count into a human-readable string (e.g. 1.2 MB). */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const val = bytes / Math.pow(1024, i); + return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +/** Format an ISO date string for display. */ +export function formatDate(iso: string): string { + return new Date(iso).toLocaleString(); +} From 41b976752119807be08e12a43ac51d25d0122c0d Mon Sep 17 00:00:00 2001 From: MEP Agent Date: Wed, 15 Jul 2026 09:27:53 +0000 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20complete=20MEP=20core=20=E2=80=94?= =?UTF-8?q?=20schema=20discovery,=20validation,=20execution,=20evaluation,?= =?UTF-8?q?=20reports,=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 — Schema Discovery + Validation (Phases 2 & 4): - Schema inference: integer, decimal, date, boolean, text detection - Nullability, uniqueness, sample values per column - Validation: duplicate columns, empty headers, null checks, duplicate rows, mixed types - POST /api/migrations/{id}/validate endpoint - Validation page with expandable per-file schema table + issues Slice 2 — Migration + Evaluation (Phases 5 & 6): - Staging table creation with inferred PostgreSQL types - Bulk CSV loading via parameterized INSERT - Quality evaluation: row count match, null %, duplicate detection - Quality score (0-100) with PASS/FAIL verdict - POST /api/migrations/{id}/execute and /evaluate endpoints - Execute/Evaluate actions on Migration Runs page with result dialogs Slice 3 — Reports + Dashboard (Phases 7 & 8): - JSON and HTML report generation with validation/evaluation data - Self-contained styled HTML reports - Report download endpoint - Dashboard with stat cards, status breakdown, recent runs table - GET /api/dashboard endpoint Also: - Shared test conftest.py (fixes cross-test DB conflicts) - 38 backend tests passing (19 new) - Frontend builds clean (630 KB) - Updated API.md documentation - History page with full chronological run log - MigrationRuns page with execute/evaluate action buttons --- .gitignore | 5 + backend/api/routes/dashboard.py | 17 ++ backend/api/routes/execution.py | 30 +++ backend/api/routes/reports.py | 57 +++++ backend/api/routes/validation.py | 22 ++ backend/api/schemas.py | 98 ++++++++ backend/main.py | 6 +- backend/services/dashboard_service.py | 62 +++++ backend/services/evaluation_service.py | 222 +++++++++++++++++ backend/services/execution_service.py | 194 +++++++++++++++ backend/services/report_service.py | 235 ++++++++++++++++++ backend/services/schema_service.py | 317 ++++++++++++++++++++++++ backend/tests/conftest.py | 61 +++++ backend/tests/test_migrations.py | 107 ++------ backend/tests/test_schema_validation.py | 197 +++++++++++++++ docs/API.md | 185 +++++++++++--- frontend/src/api/validation.ts | 159 ++++++++++++ frontend/src/pages/Dashboard.tsx | 168 ++++++++++++- frontend/src/pages/History.tsx | 97 +++++++- frontend/src/pages/MigrationRuns.tsx | 207 ++++++++++++---- frontend/src/pages/NewMigration.tsx | 4 +- frontend/src/pages/Reports.tsx | 137 +++++++++- frontend/src/pages/Validation.tsx | 235 +++++++++++++++++- 23 files changed, 2629 insertions(+), 193 deletions(-) create mode 100644 backend/api/routes/dashboard.py create mode 100644 backend/api/routes/execution.py create mode 100644 backend/api/routes/reports.py create mode 100644 backend/api/routes/validation.py create mode 100644 backend/services/dashboard_service.py create mode 100644 backend/services/evaluation_service.py create mode 100644 backend/services/execution_service.py create mode 100644 backend/services/report_service.py create mode 100644 backend/services/schema_service.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_schema_validation.py create mode 100644 frontend/src/api/validation.ts diff --git a/.gitignore b/.gitignore index 7d80516..04cf0d6 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,9 @@ __pycache__/ # CSV upload staging (keep the directory, ignore its contents) uploads/* !uploads/.gitkeep +reports/ +*.db +test_*.db # Terraform plan artifacts (binary, environment-specific, never commit) *.tfplan @@ -75,3 +78,5 @@ tfplan .terraform.lock.hcl docs/Phase0-Setup-Manual.docx docs/Phase0-Setup-Manual.pdf +*.docx +*.pdf diff --git a/backend/api/routes/dashboard.py b/backend/api/routes/dashboard.py new file mode 100644 index 0000000..2d858d3 --- /dev/null +++ b/backend/api/routes/dashboard.py @@ -0,0 +1,17 @@ +"""API route for dashboard statistics. + +GET /api/dashboard — aggregate stats for the dashboard view +""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from database.connection import get_db +from services import dashboard_service + +router = APIRouter(tags=["dashboard"]) + + +@router.get("/dashboard") +def get_dashboard(db: Session = Depends(get_db)): + """Return aggregate dashboard statistics.""" + return dashboard_service.get_dashboard_stats(db) diff --git a/backend/api/routes/execution.py b/backend/api/routes/execution.py new file mode 100644 index 0000000..c5caf45 --- /dev/null +++ b/backend/api/routes/execution.py @@ -0,0 +1,30 @@ +"""API routes for migration execution and evaluation. + +POST /api/migrations/{id}/execute — load CSVs into PostgreSQL staging tables +POST /api/migrations/{id}/evaluate — run quality checks against loaded data +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from database.connection import get_db +from services import execution_service, evaluation_service + +router = APIRouter(prefix="/migrations", tags=["execution"]) + + +@router.post("/{run_id}/execute") +def execute_migration(run_id: int, db: Session = Depends(get_db)): + """Execute migration — create staging tables and load CSV data.""" + result = execution_service.execute_migration(db, run_id) + if result is None: + raise HTTPException(status_code=404, detail=f"Migration run {run_id} not found") + return result + + +@router.post("/{run_id}/evaluate") +def evaluate_migration(run_id: int, db: Session = Depends(get_db)): + """Evaluate data quality of migrated data vs source CSVs.""" + result = evaluation_service.evaluate_run(db, run_id) + if result is None: + raise HTTPException(status_code=404, detail=f"Migration run {run_id} not found") + return result diff --git a/backend/api/routes/reports.py b/backend/api/routes/reports.py new file mode 100644 index 0000000..176eec8 --- /dev/null +++ b/backend/api/routes/reports.py @@ -0,0 +1,57 @@ +"""API routes for report generation and download. + +POST /api/reports/{id}/generate?format=json|html — generate report +GET /api/reports/{id}/download/{format} — download generated report +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from database.connection import get_db +from services import report_service, schema_service, execution_service, evaluation_service + +router = APIRouter(prefix="/reports", tags=["reports"]) + + +@router.post("/{run_id}/generate") +def generate_report( + run_id: int, + format: str = Query("json", pattern="^(json|html)$"), + db: Session = Depends(get_db), +): + """Generate a report for a migration run. + + Automatically gathers validation, migration, and evaluation data. + """ + # Gather available data + validation_data = schema_service.validate_run(db, run_id) + if validation_data is None: + raise HTTPException(status_code=404, detail=f"Migration run {run_id} not found") + + # Try evaluation (may fail if migration hasn't been run) + evaluation_data = None + try: + evaluation_data = evaluation_service.evaluate_run(db, run_id) + except Exception: + pass + + result = report_service.generate_report( + db, run_id, format, + validation_data=validation_data, + evaluation_data=evaluation_data, + ) + if result is None: + raise HTTPException(status_code=400, detail="Invalid format") + return result + + +@router.get("/{run_id}/download/{format}") +def download_report(run_id: int, format: str): + """Download a previously generated report.""" + path = report_service.get_report_path(run_id, format) + if not path: + raise HTTPException(status_code=404, detail="Report not found. Generate it first.") + + media_type = "application/json" if format == "json" else "text/html" + filename = f"mep_report_{run_id}.{format}" + return FileResponse(path, media_type=media_type, filename=filename) diff --git a/backend/api/routes/validation.py b/backend/api/routes/validation.py new file mode 100644 index 0000000..59bbfd1 --- /dev/null +++ b/backend/api/routes/validation.py @@ -0,0 +1,22 @@ +"""API routes for schema discovery and validation. + +POST /api/migrations/{id}/validate — run validation on all files +GET /api/migrations/{id}/schema — get schema for all files (without re-validating) +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from api.schemas import ValidationResponse +from database.connection import get_db +from services import schema_service + +router = APIRouter(prefix="/migrations", tags=["validation"]) + + +@router.post("/{run_id}/validate", response_model=ValidationResponse) +def validate_migration_run(run_id: int, db: Session = Depends(get_db)): + """Discover schema and run validation checks on all files in a run.""" + result = schema_service.validate_run(db, run_id) + if result is None: + raise HTTPException(status_code=404, detail=f"Migration run {run_id} not found") + return result diff --git a/backend/api/schemas.py b/backend/api/schemas.py index 7a9677b..36406ec 100644 --- a/backend/api/schemas.py +++ b/backend/api/schemas.py @@ -72,3 +72,101 @@ class DeleteResponse(BaseModel): """Generic deletion confirmation.""" detail: str id: int + + +# --------------------------------------------------------------------------- +# Schema Discovery & Validation +# --------------------------------------------------------------------------- + +class ColumnSchema(BaseModel): + """Inferred schema for a single CSV column.""" + name: str + inferred_type: str + nullable: bool + unique: bool + sample_values: list[str] + null_count: int + total_count: int + + +class ValidationIssue(BaseModel): + """A single validation finding.""" + severity: str # error | warning | info + check: str + column: Optional[str] + message: str + + +class FileValidationResult(BaseModel): + """Schema + validation results for one uploaded file.""" + file_id: int + filename: str + schema_info: list[ColumnSchema] = Field(default_factory=list, alias="schema") + issues: list[ValidationIssue] + + model_config = {"populate_by_name": True} + + +class ValidationSummary(BaseModel): + """Aggregate counts across all files.""" + total_files: int + errors: int + warnings: int + passed: bool + + +class ValidationResponse(BaseModel): + """Full validation response for a migration run.""" + run_id: int + status: str + files: list[FileValidationResult] + summary: ValidationSummary + + +# --------------------------------------------------------------------------- +# Migration Execution +# --------------------------------------------------------------------------- + +class MigrationExecuteResponse(BaseModel): + """Response after executing migration.""" + run_id: int + status: str + files: list[dict] + summary: dict + + +# --------------------------------------------------------------------------- +# Evaluation +# --------------------------------------------------------------------------- + +class EvaluationResponse(BaseModel): + """Evaluation quality-check response.""" + run_id: int + status: str + files: list[dict] + summary: dict + + +# --------------------------------------------------------------------------- +# Reports +# --------------------------------------------------------------------------- + +class ReportResponse(BaseModel): + """Report metadata.""" + run_id: int + format: str + download_url: str + + +# --------------------------------------------------------------------------- +# Dashboard +# --------------------------------------------------------------------------- + +class DashboardStats(BaseModel): + """Dashboard summary statistics.""" + total_runs: int + runs_by_status: dict[str, int] + total_files: int + total_rows: int + total_size: int + recent_runs: list[MigrationRunResponse] diff --git a/backend/main.py b/backend/main.py index e2bf7ca..d8db875 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,7 +10,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from api.routes import health, migrations +from api.routes import health, migrations, validation, execution, reports, dashboard from config import settings from database.connection import check_db_connection, engine from database.models import Base @@ -34,6 +34,10 @@ # Mount routers under the /api prefix. app.include_router(health.router, prefix="/api") app.include_router(migrations.router, prefix="/api") +app.include_router(validation.router, prefix="/api") +app.include_router(execution.router, prefix="/api") +app.include_router(reports.router, prefix="/api") +app.include_router(dashboard.router, prefix="/api") @app.on_event("startup") diff --git a/backend/services/dashboard_service.py b/backend/services/dashboard_service.py new file mode 100644 index 0000000..ee9261d --- /dev/null +++ b/backend/services/dashboard_service.py @@ -0,0 +1,62 @@ +"""Dashboard statistics service. + +Provides aggregate stats across all migration runs for the dashboard view. +""" +from sqlalchemy import func +from sqlalchemy.orm import Session + +from database.models import MigrationRun, RunStatus, UploadedFile + + +def get_dashboard_stats(db: Session) -> dict: + """Return aggregate statistics for the dashboard.""" + # Total runs + total_runs = db.query(func.count(MigrationRun.id)).scalar() or 0 + + # Runs by status + status_counts = ( + db.query(MigrationRun.status, func.count(MigrationRun.id)) + .group_by(MigrationRun.status) + .all() + ) + runs_by_status = { + (s.value if hasattr(s, "value") else str(s)): c + for s, c in status_counts + } + + # File stats + total_files = db.query(func.count(UploadedFile.id)).scalar() or 0 + total_rows = db.query(func.coalesce(func.sum(UploadedFile.row_count), 0)).scalar() or 0 + total_size = db.query(func.coalesce(func.sum(UploadedFile.file_size), 0)).scalar() or 0 + + # Recent runs (last 10) + recent = ( + db.query(MigrationRun) + .order_by(MigrationRun.created_at.desc()) + .limit(10) + .all() + ) + + recent_runs = [] + for run in recent: + files = run.files or [] + recent_runs.append({ + "id": run.id, + "name": run.name, + "environment": run.environment, + "description": run.description, + "status": run.status.value if hasattr(run.status, "value") else str(run.status), + "created_at": run.created_at.isoformat() if run.created_at else None, + "updated_at": run.updated_at.isoformat() if run.updated_at else None, + "file_count": len(files), + "total_size": sum(f.file_size for f in files), + }) + + return { + "total_runs": total_runs, + "runs_by_status": runs_by_status, + "total_files": total_files, + "total_rows": int(total_rows), + "total_size": int(total_size), + "recent_runs": recent_runs, + } diff --git a/backend/services/evaluation_service.py b/backend/services/evaluation_service.py new file mode 100644 index 0000000..53299ea --- /dev/null +++ b/backend/services/evaluation_service.py @@ -0,0 +1,222 @@ +"""Evaluation engine — compare source CSVs against loaded staging tables. + +Checks: row counts, null percentages, duplicate detection, and computes +an overall quality score with PASS/FAIL verdict. +""" +import csv +import hashlib +import os +from typing import Any, Optional + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from database.models import MigrationRun, UploadedFile +from services.migration_service import UPLOAD_DIR +from services.execution_service import _safe_table_name, _safe_col_name +from services.schema_service import discover_schema + + +def _source_row_count(file_path: str) -> int: + """Count data rows in CSV (excluding header).""" + with open(file_path, "r", encoding="utf-8-sig") as fh: + reader = csv.reader(fh) + next(reader, None) # skip header + return sum(1 for _ in reader) + + +def _source_row_hashes(file_path: str) -> list[str]: + """Return a hash per row for duplicate detection.""" + hashes = [] + with open(file_path, "r", encoding="utf-8-sig") as fh: + reader = csv.reader(fh) + next(reader, None) + for row in reader: + h = hashlib.md5("|".join(row).encode()).hexdigest() + hashes.append(h) + return hashes + + +def evaluate_run(db: Session, run_id: int) -> Optional[dict[str, Any]]: + """Evaluate data quality for a completed migration run. + + For each file, compares: + - Source vs target row count + - Null percentage per column in target + - Duplicate rows in target + - Overall quality score (0-100) + + Returns evaluation dict or None if run not found. + """ + run = db.query(MigrationRun).filter(MigrationRun.id == run_id).first() + if not run: + return None + + files = ( + db.query(UploadedFile) + .filter(UploadedFile.migration_run_id == run_id) + .all() + ) + + if not files: + return { + "run_id": run_id, + "status": "no_files", + "files": [], + "summary": { + "total_files": 0, + "overall_score": 0, + "verdict": "FAIL", + "total_source_rows": 0, + "total_target_rows": 0, + }, + } + + file_results = [] + total_source = 0 + total_target = 0 + scores = [] + + for f in files: + file_path = os.path.join(UPLOAD_DIR, str(run_id), f.stored_filename) + table_name = _safe_table_name(run_id, f.original_filename) + + # Check if staging table exists + try: + result = db.execute( + text(f"SELECT COUNT(*) FROM \"{table_name}\"") + ) + target_count = result.scalar() + except Exception: + file_results.append({ + "file_id": f.id, + "filename": f.original_filename, + "status": "error", + "error": f"Staging table '{table_name}' not found. Run migration first.", + "score": 0, + }) + scores.append(0) + continue + + # Source row count + if os.path.exists(file_path): + source_count = _source_row_count(file_path) + else: + source_count = f.row_count or 0 + + total_source += source_count + total_target += target_count + + # --- Quality checks --- + checks = [] + file_score = 100.0 + + # 1. Row count match + if source_count == target_count: + checks.append({ + "check": "row_count_match", + "status": "pass", + "detail": f"Source: {source_count}, Target: {target_count}", + }) + else: + diff = abs(source_count - target_count) + pct_diff = round(diff / max(source_count, 1) * 100, 1) + checks.append({ + "check": "row_count_match", + "status": "fail", + "detail": f"Source: {source_count}, Target: {target_count} (diff: {diff}, {pct_diff}%)", + }) + file_score -= min(30, pct_diff) + + # 2. Null percentage in target + try: + schema = discover_schema(file_path) if os.path.exists(file_path) else [] + col_names = [_safe_col_name(c["name"]) for c in schema] + null_pcts = [] + for col in col_names: + r = db.execute( + text(f'SELECT COUNT(*) FILTER (WHERE "{col}" IS NULL) FROM "{table_name}"') + ) + null_count = r.scalar() + pct = round(null_count / max(target_count, 1) * 100, 1) + null_pcts.append({"column": col, "null_pct": pct}) + + avg_null = sum(n["null_pct"] for n in null_pcts) / max(len(null_pcts), 1) + checks.append({ + "check": "null_percentage", + "status": "pass" if avg_null < 20 else "warning", + "detail": f"Average null%: {round(avg_null, 1)}%", + "columns": null_pcts, + }) + if avg_null >= 20: + file_score -= min(20, avg_null - 20) + except Exception: + checks.append({ + "check": "null_percentage", + "status": "skipped", + "detail": "Could not analyze null percentages", + }) + + # 3. Duplicate rows in target + try: + if col_names: + cols_joined = ", ".join(f'"{c}"' for c in col_names) + dup_query = f""" + SELECT COUNT(*) FROM ( + SELECT {cols_joined}, COUNT(*) as cnt + FROM "{table_name}" + GROUP BY {cols_joined} + HAVING COUNT(*) > 1 + ) dupes + """ + r = db.execute(text(dup_query)) + dup_groups = r.scalar() + if dup_groups == 0: + checks.append({ + "check": "duplicate_rows", + "status": "pass", + "detail": "No duplicate rows found in target.", + }) + else: + checks.append({ + "check": "duplicate_rows", + "status": "warning", + "detail": f"{dup_groups} groups of duplicate rows found in target.", + }) + file_score -= min(10, dup_groups) + except Exception: + checks.append({ + "check": "duplicate_rows", + "status": "skipped", + "detail": "Could not check for duplicates", + }) + + file_score = max(0, min(100, round(file_score))) + scores.append(file_score) + + file_results.append({ + "file_id": f.id, + "filename": f.original_filename, + "table_name": table_name, + "source_rows": source_count, + "target_rows": target_count, + "score": file_score, + "checks": checks, + "status": "pass" if file_score >= 70 else "fail", + }) + + overall_score = round(sum(scores) / max(len(scores), 1)) + verdict = "PASS" if overall_score >= 70 else "FAIL" + + return { + "run_id": run_id, + "status": "evaluated", + "files": file_results, + "summary": { + "total_files": len(files), + "overall_score": overall_score, + "verdict": verdict, + "total_source_rows": total_source, + "total_target_rows": total_target, + }, + } diff --git a/backend/services/execution_service.py b/backend/services/execution_service.py new file mode 100644 index 0000000..abf468b --- /dev/null +++ b/backend/services/execution_service.py @@ -0,0 +1,194 @@ +"""Migration execution — load validated CSVs into PostgreSQL staging tables. + +Creates a staging table per CSV file (named staging_{run_id}_{safe_filename}), +uses bulk INSERT, and tracks per-file status. +""" +import csv +import hashlib +import io +import os +import re +from typing import Any, Optional + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from database.models import MigrationRun, RunStatus, UploadedFile +from services.migration_service import UPLOAD_DIR +from services.schema_service import discover_schema + +# Type map: inferred CSV types → PostgreSQL column types +_PG_TYPE_MAP = { + "integer": "BIGINT", + "decimal": "DOUBLE PRECISION", + "boolean": "BOOLEAN", + "date": "TIMESTAMP", + "text": "TEXT", +} + + +def _safe_table_name(run_id: int, filename: str) -> str: + """Generate a safe PostgreSQL table name from run ID and filename.""" + base = os.path.splitext(filename)[0] + safe = re.sub(r"[^a-zA-Z0-9]", "_", base).lower().strip("_")[:40] + return f"staging_{run_id}_{safe}" + + +def _safe_col_name(name: str) -> str: + """Sanitize a column name for PostgreSQL.""" + safe = re.sub(r"[^a-zA-Z0-9_]", "_", name).lower().strip("_") + if not safe or safe[0].isdigit(): + safe = "col_" + safe + return safe[:63] + + +def _create_staging_table(db: Session, table_name: str, schema: list[dict]) -> str: + """Create a staging table with columns matching the CSV schema. Returns the DDL.""" + cols = [] + for col in schema: + pg_type = _PG_TYPE_MAP.get(col["inferred_type"], "TEXT") + col_name = _safe_col_name(col["name"]) + cols.append(f'"{col_name}" {pg_type}') + + cols_sql = ", ".join(cols) + ddl = f'DROP TABLE IF EXISTS "{table_name}"; CREATE TABLE "{table_name}" ({cols_sql});' + db.execute(text(ddl)) + db.commit() + return ddl + + +def _load_csv_into_table(db: Session, table_name: str, file_path: str, schema: list[dict]) -> int: + """Bulk-insert CSV rows into the staging table. Returns row count loaded.""" + col_names = [_safe_col_name(c["name"]) for c in schema] + placeholders = ", ".join([f":{c}" for c in col_names]) + quoted_cols = ", ".join([f'"{c}"' for c in col_names]) + insert_sql = f'INSERT INTO "{table_name}" ({quoted_cols}) VALUES ({placeholders})' + + with open(file_path, "r", encoding="utf-8-sig") as fh: + reader = csv.DictReader(fh) + batch = [] + total = 0 + for row in reader: + mapped = {} + for orig_col, safe_col in zip([c["name"] for c in schema], col_names): + val = row.get(orig_col, "").strip() + mapped[safe_col] = val if val else None + batch.append(mapped) + if len(batch) >= 500: + db.execute(text(insert_sql), batch) + total += len(batch) + batch = [] + if batch: + db.execute(text(insert_sql), batch) + total += len(batch) + + db.commit() + return total + + +def execute_migration(db: Session, run_id: int) -> Optional[dict[str, Any]]: + """Execute migration for all files in a run. + + 1. Discovers schema per file + 2. Creates staging tables + 3. Bulk-loads data + 4. Updates run status + + Returns summary dict or None if run not found. + """ + run = db.query(MigrationRun).filter(MigrationRun.id == run_id).first() + if not run: + return None + + run.status = RunStatus.MIGRATING + db.commit() + + files = ( + db.query(UploadedFile) + .filter(UploadedFile.migration_run_id == run_id) + .all() + ) + + if not files: + run.status = RunStatus.CREATED + db.commit() + return { + "run_id": run_id, + "status": "no_files", + "files": [], + "summary": {"total_files": 0, "total_rows_loaded": 0, "tables_created": 0, "success": False}, + } + + file_results = [] + total_rows = 0 + tables_created = 0 + has_error = False + + for f in files: + file_path = os.path.join(UPLOAD_DIR, str(run_id), f.stored_filename) + if not os.path.exists(file_path): + file_results.append({ + "file_id": f.id, + "filename": f.original_filename, + "status": "error", + "error": "File not found on disk", + "table_name": None, + "rows_loaded": 0, + }) + has_error = True + continue + + try: + schema = discover_schema(file_path) + if not schema: + file_results.append({ + "file_id": f.id, + "filename": f.original_filename, + "status": "error", + "error": "Empty schema — no columns found", + "table_name": None, + "rows_loaded": 0, + }) + has_error = True + continue + + table_name = _safe_table_name(run_id, f.original_filename) + _create_staging_table(db, table_name, schema) + tables_created += 1 + + rows = _load_csv_into_table(db, table_name, file_path, schema) + total_rows += rows + + file_results.append({ + "file_id": f.id, + "filename": f.original_filename, + "status": "loaded", + "table_name": table_name, + "rows_loaded": rows, + "error": None, + }) + except Exception as exc: + has_error = True + file_results.append({ + "file_id": f.id, + "filename": f.original_filename, + "status": "error", + "error": str(exc), + "table_name": None, + "rows_loaded": 0, + }) + + run.status = RunStatus.COMPLETED if not has_error else RunStatus.FAILED + db.commit() + + return { + "run_id": run_id, + "status": "completed" if not has_error else "failed", + "files": file_results, + "summary": { + "total_files": len(files), + "total_rows_loaded": total_rows, + "tables_created": tables_created, + "success": not has_error, + }, + } diff --git a/backend/services/report_service.py b/backend/services/report_service.py new file mode 100644 index 0000000..72b25ad --- /dev/null +++ b/backend/services/report_service.py @@ -0,0 +1,235 @@ +"""Report generation — JSON and HTML reports for migration runs. + +Aggregates validation, migration, and evaluation data into downloadable reports. +""" +import json +import os +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy.orm import Session + +from database.models import MigrationRun, UploadedFile +from services.migration_service import UPLOAD_DIR + +REPORT_DIR = os.environ.get("MEP_REPORT_DIR", os.path.join(os.getcwd(), "reports")) + + +def _ensure_report_dir(run_id: int) -> str: + d = os.path.join(REPORT_DIR, str(run_id)) + os.makedirs(d, exist_ok=True) + return d + + +def generate_report( + db: Session, + run_id: int, + fmt: str, + validation_data: Optional[dict] = None, + migration_data: Optional[dict] = None, + evaluation_data: Optional[dict] = None, +) -> Optional[dict[str, Any]]: + """Generate a report for a migration run. + + Args: + fmt: "json" or "html" + validation_data, migration_data, evaluation_data: pre-computed results + + Returns report metadata dict or None if run not found. + """ + run = db.query(MigrationRun).filter(MigrationRun.id == run_id).first() + if not run: + return None + + files = ( + db.query(UploadedFile) + .filter(UploadedFile.migration_run_id == run_id) + .all() + ) + + report_data = { + "report_generated_at": datetime.now(timezone.utc).isoformat(), + "run": { + "id": run.id, + "name": run.name, + "environment": run.environment, + "description": run.description, + "status": run.status.value if hasattr(run.status, "value") else str(run.status), + "created_at": run.created_at.isoformat() if run.created_at else None, + "updated_at": run.updated_at.isoformat() if run.updated_at else None, + }, + "files": [ + { + "id": f.id, + "filename": f.original_filename, + "file_size": f.file_size, + "row_count": f.row_count, + "column_count": f.column_count, + } + for f in files + ], + "validation": validation_data, + "migration": migration_data, + "evaluation": evaluation_data, + } + + report_dir = _ensure_report_dir(run_id) + + if fmt == "json": + path = os.path.join(report_dir, "report.json") + with open(path, "w") as fh: + json.dump(report_data, fh, indent=2, default=str) + return { + "run_id": run_id, + "format": "json", + "path": path, + "download_url": f"/api/reports/{run_id}/download/json", + } + + elif fmt == "html": + path = os.path.join(report_dir, "report.html") + html = _render_html_report(report_data) + with open(path, "w") as fh: + fh.write(html) + return { + "run_id": run_id, + "format": "html", + "path": path, + "download_url": f"/api/reports/{run_id}/download/html", + } + + return None + + +def get_report_path(run_id: int, fmt: str) -> Optional[str]: + """Return the path to a generated report file, or None.""" + ext = "json" if fmt == "json" else "html" + path = os.path.join(REPORT_DIR, str(run_id), f"report.{ext}") + return path if os.path.exists(path) else None + + +def _render_html_report(data: dict) -> str: + """Render a self-contained HTML report from report data.""" + run = data.get("run", {}) + files = data.get("files", []) + validation = data.get("validation") + migration = data.get("migration") + evaluation = data.get("evaluation") + + def _badge(status: str) -> str: + colors = { + "pass": "#107C10", "passed": "#107C10", "completed": "#107C10", + "fail": "#D13438", "failed": "#D13438", "error": "#D13438", + "warning": "#FFB900", "info": "#0078D4", + } + bg = colors.get(status.lower(), "#605E5C") + return f'{status.upper()}' + + # Build file rows + file_rows = "" + for f in files: + file_rows += f""" + {f['filename']} + {f.get('row_count', '—')} + {f.get('column_count', '—')} + {f.get('file_size', 0):,} B + """ + + # Validation section + val_section = "" + if validation and validation.get("files"): + val_rows = "" + for vf in validation["files"]: + issues = vf.get("issues", []) + errors = sum(1 for i in issues if i.get("severity") == "error") + warnings = sum(1 for i in issues if i.get("severity") == "warning") + status = "pass" if errors == 0 else "fail" + val_rows += f""" + {vf['filename']} + {errors}{warnings} + {_badge(status)} + """ + val_summary = validation.get("summary", {}) + val_section = f""" +

Validation

+

Overall: {_badge(val_summary.get('status', validation.get('status', '—')))}

+ + {val_rows}
FileErrorsWarningsStatus
""" + + # Migration section + mig_section = "" + if migration and migration.get("files"): + mig_rows = "" + for mf in migration["files"]: + mig_rows += f""" + {mf['filename']} + {mf.get('table_name', '—')} + {mf.get('rows_loaded', 0):,} + {_badge(mf.get('status', '—'))} + """ + mig_summary = migration.get("summary", {}) + mig_section = f""" +

Migration

+

Tables created: {mig_summary.get('tables_created', 0)} | Rows loaded: {mig_summary.get('total_rows_loaded', 0):,}

+ + {mig_rows}
FileTableRowsStatus
""" + + # Evaluation section + eval_section = "" + if evaluation and evaluation.get("files"): + eval_rows = "" + for ef in evaluation["files"]: + eval_rows += f""" + {ef['filename']} + {ef.get('source_rows', '—')} + {ef.get('target_rows', '—')} + {ef.get('score', '—')} + {_badge(ef.get('status', '—'))} + """ + eval_summary = evaluation.get("summary", {}) + eval_section = f""" +

Evaluation

+

Overall Score: {eval_summary.get('overall_score', 0)}/100 + — Verdict: {_badge(eval_summary.get('verdict', '—'))}

+ + {eval_rows}
FileSource RowsTarget RowsScoreStatus
""" + + return f""" + + + +MEP Report — {run.get('name', 'Migration')} + + + +

Migration Report — {run.get('name', '')}

+

+ Environment: {run.get('environment', '—')} | + Status: {_badge(run.get('status', '—'))} | + Generated: {data.get('report_generated_at', '—')} +

+ {f'

{run.get("description", "")}

' if run.get('description') else ''} + +

Uploaded Files ({len(files)})

+ + + {file_rows} +
FilenameRowsColumnsSize
+ + {val_section} + {mig_section} + {eval_section} + +
+

Migration Evaluation Platform (MEP) — Automated Report

+ +""" diff --git a/backend/services/schema_service.py b/backend/services/schema_service.py new file mode 100644 index 0000000..e333556 --- /dev/null +++ b/backend/services/schema_service.py @@ -0,0 +1,317 @@ +"""Schema discovery and validation for uploaded CSV files. + +Analyses CSV content to infer column data types, detect nullability, +uniqueness, and sample values. Then runs validation checks to surface +data-quality issues before migration. +""" +import csv +import io +import json +import os +import re +from collections import Counter +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy.orm import Session + +from database.models import MigrationRun, RunStatus, UploadedFile +from services.migration_service import UPLOAD_DIR + +# --------------------------------------------------------------------------- +# Type-inference helpers +# --------------------------------------------------------------------------- + +_INT_RE = re.compile(r"^-?\d+$") +_FLOAT_RE = re.compile(r"^-?\d+\.\d+$") +_DATE_PATTERNS = [ + "%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d", + "%Y-%m-%d %H:%M:%S", "%m/%d/%Y %H:%M:%S", + "%d-%b-%Y", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ", +] +_BOOL_VALUES = {"true", "false", "yes", "no", "1", "0", "t", "f", "y", "n"} + + +def _infer_type(values: list[str]) -> str: + """Infer the most likely data type from a sample of non-empty string values.""" + if not values: + return "text" + + # Check boolean + if all(v.lower() in _BOOL_VALUES for v in values): + return "boolean" + + # Check integer + if all(_INT_RE.match(v) for v in values): + return "integer" + + # Check decimal/float + if all(_FLOAT_RE.match(v) or _INT_RE.match(v) for v in values): + return "decimal" + + # Check date + for fmt in _DATE_PATTERNS: + try: + for v in values: + datetime.strptime(v.strip(), fmt) + return "date" + except (ValueError, TypeError): + continue + + return "text" + + +# --------------------------------------------------------------------------- +# Schema discovery +# --------------------------------------------------------------------------- + +def discover_schema(file_path: str) -> list[dict[str, Any]]: + """Read a CSV file and return a list of column descriptors. + + Each descriptor: + { + "name": "column_name", + "inferred_type": "integer" | "decimal" | "date" | "boolean" | "text", + "nullable": true/false, + "unique": true/false, + "sample_values": ["v1", "v2", "v3"], + "null_count": 5, + "total_count": 100, + } + """ + with open(file_path, "r", encoding="utf-8-sig") as fh: + reader = csv.reader(fh) + headers = next(reader, None) + if not headers: + return [] + + # Collect all values per column + col_values: list[list[str]] = [[] for _ in headers] + row_count = 0 + for row in reader: + row_count += 1 + for i, val in enumerate(row): + if i < len(headers): + col_values[i].append(val) + + columns = [] + for i, name in enumerate(headers): + raw_vals = col_values[i] if i < len(col_values) else [] + non_empty = [v.strip() for v in raw_vals if v.strip()] + null_count = len(raw_vals) - len(non_empty) + + # Uniqueness + unique = len(set(non_empty)) == len(non_empty) and len(non_empty) > 0 + + # Infer type from a sample (first 200 non-empty values) + sample = non_empty[:200] + inferred = _infer_type(sample) + + # Pick up to 5 distinct sample values + sample_vals = list(dict.fromkeys(non_empty[:50]))[:5] + + columns.append({ + "name": name.strip(), + "inferred_type": inferred, + "nullable": null_count > 0, + "unique": unique, + "sample_values": sample_vals, + "null_count": null_count, + "total_count": row_count, + }) + + return columns + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +def validate_file(file_path: str, schema: list[dict]) -> list[dict[str, Any]]: + """Run validation checks against a CSV file given its discovered schema. + + Returns a list of issue dicts: + { + "severity": "error" | "warning" | "info", + "check": "check_name", + "column": "col_name" or null, + "message": "description", + } + """ + issues: list[dict] = [] + + if not schema: + issues.append({ + "severity": "error", + "check": "empty_schema", + "column": None, + "message": "CSV file has no headers or is empty.", + }) + return issues + + # Check 1: duplicate column names + col_names = [c["name"] for c in schema] + dupes = [name for name, cnt in Counter(col_names).items() if cnt > 1] + for d in dupes: + issues.append({ + "severity": "error", + "check": "duplicate_column", + "column": d, + "message": f"Column '{d}' appears multiple times in the header.", + }) + + # Check 2: empty column names + for i, name in enumerate(col_names): + if not name or not name.strip(): + issues.append({ + "severity": "error", + "check": "empty_column_name", + "column": f"Column {i + 1}", + "message": f"Column at position {i + 1} has an empty or blank name.", + }) + + # Check 3: null violations per column + for col in schema: + if col["null_count"] > 0: + pct = round(col["null_count"] / max(col["total_count"], 1) * 100, 1) + severity = "error" if pct > 50 else "warning" if pct > 10 else "info" + issues.append({ + "severity": severity, + "check": "null_values", + "column": col["name"], + "message": f"{col['null_count']} null/empty values ({pct}% of {col['total_count']} rows).", + }) + + # Check 4: duplicate rows — read the file again for full-row dupe check + try: + with open(file_path, "r", encoding="utf-8-sig") as fh: + reader = csv.reader(fh) + next(reader, None) # skip header + row_hashes: list[str] = [] + for row in reader: + row_hashes.append("|".join(row)) + total_rows = len(row_hashes) + unique_rows = len(set(row_hashes)) + dupe_rows = total_rows - unique_rows + if dupe_rows > 0: + issues.append({ + "severity": "warning", + "check": "duplicate_rows", + "column": None, + "message": f"{dupe_rows} duplicate row(s) detected out of {total_rows} total rows.", + }) + except Exception: + pass + + # Check 5: type consistency — look for mixed-type columns + for col in schema: + if col["inferred_type"] == "text" and col["total_count"] > 0 and col["null_count"] < col["total_count"]: + # See if a sizable portion look numeric + sample = col["sample_values"] + numeric_count = sum(1 for v in sample if _INT_RE.match(v) or _FLOAT_RE.match(v)) + if 0 < numeric_count < len(sample) and len(sample) >= 3: + issues.append({ + "severity": "warning", + "check": "mixed_types", + "column": col["name"], + "message": f"Column appears to contain mixed data types (some numeric, some text).", + }) + + # If no issues, add a success info + if not issues: + issues.append({ + "severity": "info", + "check": "all_passed", + "column": None, + "message": "All validation checks passed.", + }) + + return issues + + +# --------------------------------------------------------------------------- +# Orchestration — run schema + validation for a migration run +# --------------------------------------------------------------------------- + +def validate_run(db: Session, run_id: int) -> Optional[dict]: + """Discover schema and validate all files in a migration run. + + Updates the run status to VALIDATING then back to CREATED (or FAILED). + + Returns a summary dict or None if run not found. + """ + run = db.query(MigrationRun).filter(MigrationRun.id == run_id).first() + if not run: + return None + + run.status = RunStatus.VALIDATING + db.commit() + + files = ( + db.query(UploadedFile) + .filter(UploadedFile.migration_run_id == run_id) + .all() + ) + + if not files: + run.status = RunStatus.CREATED + db.commit() + return { + "run_id": run_id, + "status": "no_files", + "files": [], + "summary": {"total_files": 0, "errors": 0, "warnings": 0, "passed": True}, + } + + file_results = [] + total_errors = 0 + total_warnings = 0 + + for f in files: + file_path = os.path.join(UPLOAD_DIR, str(run_id), f.stored_filename) + if not os.path.exists(file_path): + file_results.append({ + "file_id": f.id, + "filename": f.original_filename, + "schema": [], + "issues": [{ + "severity": "error", + "check": "file_missing", + "column": None, + "message": "Uploaded file not found on disk.", + }], + }) + total_errors += 1 + continue + + schema = discover_schema(file_path) + issues = validate_file(file_path, schema) + + errors = sum(1 for i in issues if i["severity"] == "error") + warnings = sum(1 for i in issues if i["severity"] == "warning") + total_errors += errors + total_warnings += warnings + + file_results.append({ + "file_id": f.id, + "filename": f.original_filename, + "schema": schema, + "issues": issues, + }) + + passed = total_errors == 0 + # Revert to CREATED status (validation is a pre-check, not a final state) + run.status = RunStatus.CREATED if passed else RunStatus.CREATED + db.commit() + + return { + "run_id": run_id, + "status": "passed" if passed else "failed", + "files": file_results, + "summary": { + "total_files": len(files), + "errors": total_errors, + "warnings": total_warnings, + "passed": passed, + }, + } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..9c85974 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,61 @@ +"""Shared test fixtures for all MEP backend tests. + +Uses an in-memory SQLite database so tests run without PostgreSQL. +All test modules share the same engine/session/client to avoid +conflicting dependency overrides on the FastAPI app. +""" +import os +import shutil +import tempfile + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from database.models import Base +from database.connection import get_db +from main import app + +# --------------------------------------------------------------------------- +# Single shared test database +# --------------------------------------------------------------------------- + +SQLALCHEMY_TEST_URL = "sqlite:///./test_mep_shared.db" +test_engine = create_engine(SQLALCHEMY_TEST_URL, connect_args={"check_same_thread": False}) +TestSession = sessionmaker(bind=test_engine, autoflush=False, autocommit=False) + +# Shared temporary upload directory +TEST_UPLOAD_DIR = tempfile.mkdtemp(prefix="mep_test_shared_") + + +def _override_get_db(): + db = TestSession() + try: + yield db + finally: + db.close() + + +# Apply once — all test modules use this override +app.dependency_overrides[get_db] = _override_get_db + + +@pytest.fixture(autouse=True) +def setup_database(): + """Create tables before each test, drop after.""" + Base.metadata.create_all(bind=test_engine) + os.environ["MEP_UPLOAD_DIR"] = TEST_UPLOAD_DIR + os.environ["MEP_REPORT_DIR"] = os.path.join(TEST_UPLOAD_DIR, "reports") + yield + Base.metadata.drop_all(bind=test_engine) + # Clean up upload/report files + if os.path.isdir(TEST_UPLOAD_DIR): + shutil.rmtree(TEST_UPLOAD_DIR, ignore_errors=True) + os.makedirs(TEST_UPLOAD_DIR, exist_ok=True) + + +@pytest.fixture +def client(): + """Return a TestClient for the FastAPI app.""" + return TestClient(app) diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index ad87551..91f175f 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -1,59 +1,6 @@ -"""Tests for migration run and file upload API endpoints. - -Uses an in-memory SQLite database so tests run without PostgreSQL. -""" +"""Tests for migration run and file upload API endpoints.""" import io -import os -import shutil -import tempfile - import pytest -from fastapi.testclient import TestClient -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker - -from database.models import Base -from database.connection import get_db -from main import app - -# --------------------------------------------------------------------------- -# Test database setup — in-memory SQLite -# --------------------------------------------------------------------------- - -SQLALCHEMY_TEST_URL = "sqlite:///./test_mep.db" -test_engine = create_engine(SQLALCHEMY_TEST_URL, connect_args={"check_same_thread": False}) -TestSession = sessionmaker(bind=test_engine, autoflush=False, autocommit=False) - -# Temporary upload directory for tests -TEST_UPLOAD_DIR = tempfile.mkdtemp(prefix="mep_test_uploads_") - - -def override_get_db(): - db = TestSession() - try: - yield db - finally: - db.close() - - -app.dependency_overrides[get_db] = override_get_db - - -@pytest.fixture(autouse=True) -def setup_database(): - """Create tables before each test, drop after.""" - Base.metadata.create_all(bind=test_engine) - # Point upload service to temp directory - os.environ["MEP_UPLOAD_DIR"] = TEST_UPLOAD_DIR - yield - Base.metadata.drop_all(bind=test_engine) - # Clean up upload files - if os.path.isdir(TEST_UPLOAD_DIR): - shutil.rmtree(TEST_UPLOAD_DIR, ignore_errors=True) - os.makedirs(TEST_UPLOAD_DIR, exist_ok=True) - - -client = TestClient(app) # --------------------------------------------------------------------------- @@ -61,9 +8,8 @@ def setup_database(): # --------------------------------------------------------------------------- class TestMigrationRuns: - """Tests for migration run CRUD endpoints.""" - def test_create_run(self): + def test_create_run(self, client): resp = client.post("/api/migrations", json={ "name": "Test Migration", "environment": "development", @@ -76,23 +22,22 @@ def test_create_run(self): assert data["status"] == "created" assert data["file_count"] == 0 - def test_create_run_minimal(self): - """Only name is required.""" + def test_create_run_minimal(self, client): resp = client.post("/api/migrations", json={"name": "Minimal Run"}) assert resp.status_code == 201 assert resp.json()["name"] == "Minimal Run" - def test_create_run_empty_name_rejected(self): + def test_create_run_empty_name_rejected(self, client): resp = client.post("/api/migrations", json={"name": ""}) assert resp.status_code == 422 - def test_list_runs_empty(self): + def test_list_runs_empty(self, client): resp = client.get("/api/migrations") assert resp.status_code == 200 assert resp.json()["runs"] == [] assert resp.json()["total"] == 0 - def test_list_runs(self): + def test_list_runs(self, client): client.post("/api/migrations", json={"name": "Run 1"}) client.post("/api/migrations", json={"name": "Run 2"}) resp = client.get("/api/migrations") @@ -101,27 +46,26 @@ def test_list_runs(self): assert data["total"] == 2 assert len(data["runs"]) == 2 - def test_get_run(self): + def test_get_run(self, client): create_resp = client.post("/api/migrations", json={"name": "Get Me"}) run_id = create_resp.json()["id"] resp = client.get(f"/api/migrations/{run_id}") assert resp.status_code == 200 assert resp.json()["name"] == "Get Me" - def test_get_run_not_found(self): + def test_get_run_not_found(self, client): resp = client.get("/api/migrations/9999") assert resp.status_code == 404 - def test_delete_run(self): + def test_delete_run(self, client): create_resp = client.post("/api/migrations", json={"name": "Delete Me"}) run_id = create_resp.json()["id"] resp = client.delete(f"/api/migrations/{run_id}") assert resp.status_code == 200 assert resp.json()["id"] == run_id - # Confirm it's gone assert client.get(f"/api/migrations/{run_id}").status_code == 404 - def test_delete_run_not_found(self): + def test_delete_run_not_found(self, client): resp = client.delete("/api/migrations/9999") assert resp.status_code == 404 @@ -131,9 +75,8 @@ def test_delete_run_not_found(self): # --------------------------------------------------------------------------- class TestFileUploads: - """Tests for CSV file upload endpoints.""" - def _create_run(self, name: str = "Upload Test") -> int: + def _create_run(self, client, name: str = "Upload Test") -> int: resp = client.post("/api/migrations", json={"name": name}) return resp.json()["id"] @@ -143,8 +86,8 @@ def _csv_content(self, headers: str = "id,name,email", rows: int = 3) -> bytes: lines.append(f"{i},User {i},user{i}@example.com") return "\n".join(lines).encode("utf-8") - def test_upload_single_file(self): - run_id = self._create_run() + def test_upload_single_file(self, client): + run_id = self._create_run(client) csv_data = self._csv_content() resp = client.post( f"/api/migrations/{run_id}/files", @@ -158,8 +101,8 @@ def test_upload_single_file(self): assert data[0]["column_count"] == 3 assert data[0]["file_size"] > 0 - def test_upload_multiple_files(self): - run_id = self._create_run() + def test_upload_multiple_files(self, client): + run_id = self._create_run(client) files = [ ("files", ("customers.csv", io.BytesIO(self._csv_content()), "text/csv")), ("files", ("orders.csv", io.BytesIO(self._csv_content("order_id,amount,date", 5)), "text/csv")), @@ -168,15 +111,15 @@ def test_upload_multiple_files(self): assert resp.status_code == 201 assert len(resp.json()) == 2 - def test_upload_to_nonexistent_run(self): + def test_upload_to_nonexistent_run(self, client): resp = client.post( "/api/migrations/9999/files", files=[("files", ("test.csv", io.BytesIO(b"a,b\n1,2"), "text/csv"))], ) assert resp.status_code == 404 - def test_list_files(self): - run_id = self._create_run() + def test_list_files(self, client): + run_id = self._create_run(client) client.post( f"/api/migrations/{run_id}/files", files=[("files", ("data.csv", io.BytesIO(self._csv_content()), "text/csv"))], @@ -187,12 +130,12 @@ def test_list_files(self): assert data["total"] == 1 assert data["files"][0]["original_filename"] == "data.csv" - def test_list_files_nonexistent_run(self): + def test_list_files_nonexistent_run(self, client): resp = client.get("/api/migrations/9999/files") assert resp.status_code == 404 - def test_delete_file(self): - run_id = self._create_run() + def test_delete_file(self, client): + run_id = self._create_run(client) upload_resp = client.post( f"/api/migrations/{run_id}/files", files=[("files", ("delete_me.csv", io.BytesIO(self._csv_content()), "text/csv"))], @@ -201,17 +144,15 @@ def test_delete_file(self): resp = client.delete(f"/api/migrations/files/{file_id}") assert resp.status_code == 200 assert resp.json()["id"] == file_id - # Confirm file list is now empty list_resp = client.get(f"/api/migrations/{run_id}/files") assert list_resp.json()["total"] == 0 - def test_delete_file_not_found(self): + def test_delete_file_not_found(self, client): resp = client.delete("/api/migrations/files/9999") assert resp.status_code == 404 - def test_run_file_count_updates(self): - """After uploading files, the run's file_count should reflect them.""" - run_id = self._create_run() + def test_run_file_count_updates(self, client): + run_id = self._create_run(client) client.post( f"/api/migrations/{run_id}/files", files=[ diff --git a/backend/tests/test_schema_validation.py b/backend/tests/test_schema_validation.py new file mode 100644 index 0000000..702a64d --- /dev/null +++ b/backend/tests/test_schema_validation.py @@ -0,0 +1,197 @@ +"""Tests for schema discovery, validation, and dashboard endpoints.""" +import io +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _create_run_and_upload(client, csv_content: bytes, filename: str = "test.csv") -> int: + resp = client.post("/api/migrations", json={"name": "Schema Test"}) + run_id = resp.json()["id"] + client.post( + f"/api/migrations/{run_id}/files", + files=[("files", (filename, io.BytesIO(csv_content), "text/csv"))], + ) + return run_id + + +# --------------------------------------------------------------------------- +# Schema Discovery Tests +# --------------------------------------------------------------------------- + +class TestSchemaDiscovery: + + def test_integer_columns_detected(self, client): + csv = b"id,count\n1,100\n2,200\n3,300" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + assert resp.status_code == 200 + schema = resp.json()["files"][0]["schema"] + assert schema[0]["inferred_type"] == "integer" + assert schema[1]["inferred_type"] == "integer" + + def test_decimal_columns_detected(self, client): + csv = b"price,amount\n10.5,20.3\n30.1,40.7" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + schema = resp.json()["files"][0]["schema"] + assert schema[0]["inferred_type"] == "decimal" + + def test_date_columns_detected(self, client): + csv = b"created\n2024-01-15\n2024-02-20\n2024-03-25" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + schema = resp.json()["files"][0]["schema"] + assert schema[0]["inferred_type"] == "date" + + def test_boolean_columns_detected(self, client): + csv = b"active,verified\ntrue,yes\nfalse,no\ntrue,yes" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + schema = resp.json()["files"][0]["schema"] + assert schema[0]["inferred_type"] == "boolean" + assert schema[1]["inferred_type"] == "boolean" + + def test_text_columns_default(self, client): + csv = b"name,city\nAlice,NYC\nBob,London" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + schema = resp.json()["files"][0]["schema"] + assert schema[0]["inferred_type"] == "text" + + def test_nullable_detected(self, client): + csv = b"id,name\n1,Alice\n2,\n3,Charlie" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + schema = resp.json()["files"][0]["schema"] + name_col = schema[1] + assert name_col["nullable"] is True + assert name_col["null_count"] == 1 + + def test_uniqueness_detected(self, client): + csv = b"id,category\n1,A\n2,A\n3,B" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + schema = resp.json()["files"][0]["schema"] + assert schema[0]["unique"] is True + assert schema[1]["unique"] is False + + def test_sample_values_provided(self, client): + csv = b"color\nred\nblue\ngreen\nyellow\npurple\norange\npink" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + schema = resp.json()["files"][0]["schema"] + assert len(schema[0]["sample_values"]) == 5 + + +# --------------------------------------------------------------------------- +# Validation Tests +# --------------------------------------------------------------------------- + +class TestValidation: + + def test_clean_csv_passes(self, client): + csv = b"id,name,email\n1,Alice,a@b.com\n2,Bob,b@c.com" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + data = resp.json() + assert data["summary"]["passed"] is True + assert data["summary"]["errors"] == 0 + + def test_null_values_warning(self, client): + csv = b"id,name\n1,Alice\n2,\n3,\n4,Dave" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + issues = resp.json()["files"][0]["issues"] + null_issues = [i for i in issues if i["check"] == "null_values"] + assert len(null_issues) > 0 + + def test_duplicate_rows_warning(self, client): + csv = b"id,name\n1,Alice\n1,Alice\n2,Bob" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + issues = resp.json()["files"][0]["issues"] + dup_issues = [i for i in issues if i["check"] == "duplicate_rows"] + assert len(dup_issues) == 1 + + def test_duplicate_column_error(self, client): + csv = b"id,name,name\n1,Alice,Smith\n2,Bob,Jones" + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + data = resp.json() + issues = data["files"][0]["issues"] + dup_col = [i for i in issues if i["check"] == "duplicate_column"] + assert len(dup_col) == 1 + assert data["summary"]["errors"] >= 1 + assert data["summary"]["passed"] is False + + def test_validate_nonexistent_run(self, client): + resp = client.post("/api/migrations/9999/validate") + assert resp.status_code == 404 + + def test_validate_run_no_files(self, client): + resp = client.post("/api/migrations", json={"name": "Empty"}) + run_id = resp.json()["id"] + resp = client.post(f"/api/migrations/{run_id}/validate") + assert resp.status_code == 200 + assert resp.json()["status"] == "no_files" + + def test_multiple_files_validated(self, client): + resp = client.post("/api/migrations", json={"name": "Multi"}) + run_id = resp.json()["id"] + client.post( + f"/api/migrations/{run_id}/files", + files=[ + ("files", ("a.csv", io.BytesIO(b"x,y\n1,2\n3,4"), "text/csv")), + ("files", ("b.csv", io.BytesIO(b"p,q,r\na,b,c"), "text/csv")), + ], + ) + resp = client.post(f"/api/migrations/{run_id}/validate") + data = resp.json() + assert data["summary"]["total_files"] == 2 + assert len(data["files"]) == 2 + + def test_validation_summary_counts(self, client): + csv = b"id,name,name\n1,,\n2,,\n3,," + run_id = _create_run_and_upload(client, csv) + resp = client.post(f"/api/migrations/{run_id}/validate") + summary = resp.json()["summary"] + assert summary["errors"] >= 1 + assert summary["total_files"] == 1 + + +# --------------------------------------------------------------------------- +# Dashboard Tests +# --------------------------------------------------------------------------- + +class TestDashboard: + + def test_empty_dashboard(self, client): + resp = client.get("/api/dashboard") + assert resp.status_code == 200 + data = resp.json() + assert data["total_runs"] == 0 + assert data["total_files"] == 0 + + def test_dashboard_with_runs(self, client): + client.post("/api/migrations", json={"name": "Run 1"}) + client.post("/api/migrations", json={"name": "Run 2"}) + resp = client.get("/api/dashboard") + data = resp.json() + assert data["total_runs"] == 2 + assert len(data["recent_runs"]) == 2 + + def test_dashboard_file_stats(self, client): + resp = client.post("/api/migrations", json={"name": "Upload Run"}) + run_id = resp.json()["id"] + client.post( + f"/api/migrations/{run_id}/files", + files=[("files", ("data.csv", io.BytesIO(b"a,b\n1,2\n3,4"), "text/csv"))], + ) + resp = client.get("/api/dashboard") + data = resp.json() + assert data["total_files"] == 1 + assert data["total_rows"] == 2 + assert data["total_size"] > 0 diff --git a/docs/API.md b/docs/API.md index fbe7e32..a65c46d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,59 +1,174 @@ # MEP API Reference -Base URL (local): `http://localhost:8000` +**Base URL:** `http://localhost:8000` -The API is a FastAPI service. Interactive docs are available at `/docs` -(Swagger UI) and `/redoc` when the backend is running. +Interactive docs: [Swagger UI](/docs) | [ReDoc](/redoc) -> **Status:** Phase 0 foundation. Only the root and health endpoints exist -> today; migration, evaluation, and reporting endpoints are added in later phases. +--- -## `GET /` +## Foundation -Root route confirming the API is running. +### `GET /` -**Request** +Root route — confirms the API is running. -```http -GET / HTTP/1.1 -Host: localhost:8000 -``` +### `GET /api/health` + +Health check — returns database connection status. + +--- + +## Migration Runs (Phase 1) + +### `POST /api/migrations` + +Create a new migration run. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| name | string | ✓ | Migration name (1–255 chars) | +| environment | string | | `development` / `staging` / `production` | +| description | string | | Optional description | + +### `GET /api/migrations` + +List all migration runs (newest first). Query params: `skip`, `limit`. + +### `GET /api/migrations/{id}` + +Get a single migration run by ID. + +### `DELETE /api/migrations/{id}` + +Delete a run and all its files. + +### `POST /api/migrations/{id}/files` + +Upload one or more CSV files (multipart/form-data, field: `files`). + +### `GET /api/migrations/{id}/files` + +List all files for a migration run. + +### `DELETE /api/migrations/files/{id}` + +Delete a single uploaded file. + +--- + +## Schema Discovery & Validation (Phase 2) + +### `POST /api/migrations/{id}/validate` -**Response** — `200 OK` +Discover column schemas and run validation checks on all files in a run. +**Response:** ```json { - "message": "MEP API is running", - "version": "0.1.0" + "run_id": 1, + "status": "passed", + "files": [ + { + "file_id": 1, + "filename": "customers.csv", + "schema": [ + { + "name": "id", + "inferred_type": "integer", + "nullable": false, + "unique": true, + "sample_values": ["1", "2", "3"], + "null_count": 0, + "total_count": 100 + } + ], + "issues": [ + { + "severity": "warning", + "check": "null_values", + "column": "email", + "message": "5 null/empty values (5.0% of 100 rows)." + } + ] + } + ], + "summary": { + "total_files": 1, + "errors": 0, + "warnings": 1, + "passed": true + } } ``` -## `GET /api/health` +**Schema types inferred:** `integer`, `decimal`, `date`, `boolean`, `text` -Service health check. Always returns `200 OK`. If the database is unreachable, -the response still succeeds but reports `"database": "disconnected"`. +**Validation checks:** +- Duplicate column names (error) +- Empty column names (error) +- Null/empty values per column (error/warning/info by threshold) +- Duplicate rows (warning) +- Mixed data types (warning) -**Request** +--- -```http -GET /api/health HTTP/1.1 -Host: localhost:8000 -``` +## Migration Execution (Phase 5) + +### `POST /api/migrations/{id}/execute` + +Load validated CSVs into PostgreSQL staging tables. + +- Creates `staging_{run_id}_{filename}` tables +- Bulk-inserts rows with inferred column types +- Updates run status to `completed` or `failed` + +**Requires:** PostgreSQL (not available with SQLite test DB). + +--- + +## Evaluation (Phase 6) + +### `POST /api/migrations/{id}/evaluate` + +Run quality checks comparing source CSVs against loaded staging tables. + +**Checks:** +- Row count match (source vs target) +- Null percentage per column in target +- Duplicate rows in target + +**Returns:** Per-file quality score (0–100) and overall `PASS`/`FAIL` verdict. -**Response** — `200 OK` +**Requires:** Migration must be executed first. +--- + +## Reports (Phase 7) + +### `POST /api/reports/{id}/generate?format=json|html` + +Generate a migration report. Automatically gathers validation and evaluation data. + +### `GET /api/reports/{id}/download/{format}` + +Download a previously generated report file. + +--- + +## Dashboard (Phase 8) + +### `GET /api/dashboard` + +Aggregate statistics for the dashboard view. + +**Response:** ```json { - "status": "healthy", - "version": "0.1.0", - "environment": "development", - "database": "connected" + "total_runs": 5, + "runs_by_status": {"created": 2, "completed": 3}, + "total_files": 12, + "total_rows": 50000, + "total_size": 2048000, + "recent_runs": [...] } ``` - -| Field | Type | Description | -| ------------- | ------ | -------------------------------------------------- | -| `status` | string | Always `"healthy"` when the service responds. | -| `version` | string | API version. | -| `environment` | string | Value of `APP_ENV`. | -| `database` | string | `"connected"` or `"disconnected"`. | diff --git a/frontend/src/api/validation.ts b/frontend/src/api/validation.ts new file mode 100644 index 0000000..53bfe44 --- /dev/null +++ b/frontend/src/api/validation.ts @@ -0,0 +1,159 @@ +/** + * API client for schema discovery, validation, execution, evaluation, + * reports, and dashboard endpoints. + */ +import apiClient from './client'; + +// --------------------------------------------------------------------------- +// Validation / Schema types +// --------------------------------------------------------------------------- + +export interface ColumnSchema { + name: string; + inferred_type: string; + nullable: boolean; + unique: boolean; + sample_values: string[]; + null_count: number; + total_count: number; +} + +export interface ValidationIssue { + severity: 'error' | 'warning' | 'info'; + check: string; + column: string | null; + message: string; +} + +export interface FileValidationResult { + file_id: number; + filename: string; + schema: ColumnSchema[]; + issues: ValidationIssue[]; +} + +export interface ValidationSummary { + total_files: number; + errors: number; + warnings: number; + passed: boolean; +} + +export interface ValidationResponse { + run_id: number; + status: string; + files: FileValidationResult[]; + summary: ValidationSummary; +} + +// --------------------------------------------------------------------------- +// Execution types +// --------------------------------------------------------------------------- + +export interface MigrationFileResult { + file_id: number; + filename: string; + status: string; + table_name: string | null; + rows_loaded: number; + error: string | null; +} + +export interface ExecutionResponse { + run_id: number; + status: string; + files: MigrationFileResult[]; + summary: { + total_files: number; + total_rows_loaded: number; + tables_created: number; + success: boolean; + }; +} + +// --------------------------------------------------------------------------- +// Evaluation types +// --------------------------------------------------------------------------- + +export interface EvalCheck { + check: string; + status: string; + detail: string; + columns?: { column: string; null_pct: number }[]; +} + +export interface EvalFileResult { + file_id: number; + filename: string; + table_name: string; + source_rows: number; + target_rows: number; + score: number; + checks: EvalCheck[]; + status: string; +} + +export interface EvaluationResponse { + run_id: number; + status: string; + files: EvalFileResult[]; + summary: { + total_files: number; + overall_score: number; + verdict: string; + total_source_rows: number; + total_target_rows: number; + }; +} + +// --------------------------------------------------------------------------- +// Dashboard types +// --------------------------------------------------------------------------- + +export interface DashboardStats { + total_runs: number; + runs_by_status: Record; + total_files: number; + total_rows: number; + total_size: number; + recent_runs: { + id: number; + name: string; + environment: string; + description: string | null; + status: string; + created_at: string; + updated_at: string; + file_count: number; + total_size: number; + }[]; +} + +// --------------------------------------------------------------------------- +// API functions +// --------------------------------------------------------------------------- + +export async function validateRun(runId: number): Promise { + const resp = await apiClient.post(`/migrations/${runId}/validate`); + return resp.data; +} + +export async function executeMigration(runId: number): Promise { + const resp = await apiClient.post(`/migrations/${runId}/execute`, null, { timeout: 120_000 }); + return resp.data; +} + +export async function evaluateRun(runId: number): Promise { + const resp = await apiClient.post(`/migrations/${runId}/evaluate`, null, { timeout: 60_000 }); + return resp.data; +} + +export async function generateReport(runId: number, format: 'json' | 'html' = 'json'): Promise<{ run_id: number; format: string; download_url: string }> { + const resp = await apiClient.post(`/reports/${runId}/generate?format=${format}`); + return resp.data; +} + +export async function getDashboardStats(): Promise { + const resp = await apiClient.get('/dashboard'); + return resp.data; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 00cdb80..250a436 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,12 +1,168 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Card, + CardContent, + Chip, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; import DashboardIcon from '@mui/icons-material/Dashboard'; -import PagePlaceholder from './PagePlaceholder'; +import StorageIcon from '@mui/icons-material/Storage'; +import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; +import TableRowsIcon from '@mui/icons-material/TableRows'; +import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck'; + +import { getDashboardStats, type DashboardStats } from '../api/validation'; +import { formatFileSize, formatDate } from '../utils/format'; + +const statusColor = (s: string): 'default' | 'primary' | 'success' | 'error' | 'warning' | 'info' => { + switch (s) { + case 'completed': return 'success'; + case 'failed': return 'error'; + case 'migrating': + case 'validating': return 'warning'; + case 'uploading': return 'info'; + default: return 'default'; + } +}; + +interface StatCardProps { + title: string; + value: string | number; + icon: React.ReactNode; + color: string; +} + +function StatCard({ title, value, icon, color }: StatCardProps) { + return ( + + + + {icon} + + + {title} + {value} + + + + ); +} export default function Dashboard() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getDashboardStats() + .then(setStats) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( + + Loading dashboard… + + ); + } + + if (!stats) { + return ( + + + Unable to load dashboard data. + + ); + } + return ( - } - description="An at-a-glance overview of migration activity, health, and key metrics will appear here." - /> + + + Dashboard + + + Overview of all migration runs, files, and data quality. + + + {/* Stat cards */} + + } color="#0078D4" /> + } color="#5C2D91" /> + } color="#107C10" /> + } color="#D83B01" /> + + + {/* Status breakdown */} + {Object.keys(stats.runs_by_status).length > 0 && ( + + + Runs by Status + + {Object.entries(stats.runs_by_status).map(([status, count]) => ( + + ))} + + + + )} + + {/* Recent runs table */} + Recent Migration Runs + {stats.recent_runs.length === 0 ? ( + + + No migration runs yet. Go to New Migration to get started. + + + ) : ( + + + + + ID + Name + Environment + Status + Files + Size + Created + + + + {stats.recent_runs.map((run) => ( + + {run.id} + {run.name} + + + + + + + {run.file_count} + {formatFileSize(run.total_size)} + {formatDate(run.created_at)} + + ))} + +
+
+ )} +
); } diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index c1b20d9..e46adb0 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -1,12 +1,97 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Chip, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; import HistoryIcon from '@mui/icons-material/History'; -import PagePlaceholder from './PagePlaceholder'; + +import { listMigrationRuns, type MigrationRun } from '../api/migrations'; +import { formatFileSize, formatDate } from '../utils/format'; + +const STATUS_COLORS: Record = { + created: 'default', + uploading: 'info', + validating: 'warning', + migrating: 'info', + completed: 'success', + failed: 'error', +}; export default function History() { + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + listMigrationRuns() + .then((r) => setRuns(r.runs)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( + + Loading history… + + ); + } + return ( - } - description="Audit the full history of migration and validation activity over time." - /> + + + Migration History + + + Complete chronological log of all migration runs. + + + {runs.length === 0 ? ( + + + No migration history yet. + + ) : ( + + + + + ID + Name + Environment + Status + Files + Size + Created + Updated + + + + {runs.map((run) => ( + + {run.id} + {run.name} + + + + + {run.file_count} + {formatFileSize(run.total_size)} + {formatDate(run.created_at)} + {formatDate(run.updated_at)} + + ))} + +
+
+ )} +
); } diff --git a/frontend/src/pages/MigrationRuns.tsx b/frontend/src/pages/MigrationRuns.tsx index 7dc7695..5ca667f 100644 --- a/frontend/src/pages/MigrationRuns.tsx +++ b/frontend/src/pages/MigrationRuns.tsx @@ -5,8 +5,15 @@ import { Box, Button, Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, IconButton, + LinearProgress, Paper, + Stack, Table, TableBody, TableCell, @@ -19,12 +26,17 @@ import { import AddIcon from '@mui/icons-material/Add'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import RefreshIcon from '@mui/icons-material/Refresh'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import AssessmentIcon from '@mui/icons-material/Assessment'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; import { listMigrationRuns, deleteMigrationRun, type MigrationRun, } from '../api/migrations'; +import { executeMigration, evaluateRun, type ExecutionResponse, type EvaluationResponse } from '../api/validation'; import { formatFileSize, formatDate } from '../utils/format'; const STATUS_COLORS: Record = { @@ -42,6 +54,13 @@ export default function MigrationRuns() { const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [actionRunId, setActionRunId] = useState(null); + const [actionLoading, setActionLoading] = useState(false); + + // Result dialog + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogTitle, setDialogTitle] = useState(''); + const [dialogResult, setDialogResult] = useState(null); const fetchRuns = async () => { setLoading(true); @@ -57,9 +76,7 @@ export default function MigrationRuns() { } }; - useEffect(() => { - fetchRuns(); - }, []); + useEffect(() => { fetchRuns(); }, []); const handleDelete = async (runId: number) => { try { @@ -71,44 +88,126 @@ export default function MigrationRuns() { } }; + const handleExecute = async (runId: number) => { + setActionRunId(runId); + setActionLoading(true); + setError(''); + try { + const res = await executeMigration(runId); + setDialogTitle('Migration Result'); + setDialogResult(res); + setDialogOpen(true); + fetchRuns(); // refresh statuses + } catch (err: any) { + setError(err?.response?.data?.detail || 'Migration execution failed'); + } finally { + setActionLoading(false); + setActionRunId(null); + } + }; + + const handleEvaluate = async (runId: number) => { + setActionRunId(runId); + setActionLoading(true); + setError(''); + try { + const res = await evaluateRun(runId); + setDialogTitle('Evaluation Result'); + setDialogResult(res); + setDialogOpen(true); + } catch (err: any) { + setError(err?.response?.data?.detail || 'Evaluation failed'); + } finally { + setActionLoading(false); + setActionRunId(null); + } + }; + + const renderResultDialog = () => { + if (!dialogResult) return null; + const summary = dialogResult.summary as any; + const isEval = 'overall_score' in summary; + + return ( + setDialogOpen(false)} maxWidth="sm" fullWidth> + {dialogTitle} + + {isEval ? ( + + + {summary.verdict === 'PASS' + ? + : } + + {summary.overall_score}/100 + + + + + Source rows: {summary.total_source_rows?.toLocaleString()} → + Target rows: {summary.total_target_rows?.toLocaleString()} + + {dialogResult.files.map((f: any) => ( + + {f.filename} + + Score: {f.score}/100 — {f.source_rows} → {f.target_rows} rows + + + ))} + + ) : ( + + + {summary.success + ? + : } + + + {summary.tables_created} table(s) created, {summary.total_rows_loaded?.toLocaleString()} rows loaded + + + + {dialogResult.files.map((f: any) => ( + + {f.filename} + + Table: {f.table_name || '—'} — {f.rows_loaded?.toLocaleString()} rows — {f.status} + + {f.error && {f.error}} + + ))} + + )} + + + + + + ); + }; + return ( - - Migration Runs - + Migration Runs - - - + - - {error && ( - - {error} - - )} + {error && {error}} + {actionLoading && } {!loading && runs.length === 0 ? ( - - No migration runs yet. - - @@ -131,32 +230,44 @@ export default function MigrationRuns() { {runs.map((run) => ( {run.id} + {run.name} + - {run.name} - - - - - - + {run.file_count} {formatFileSize(run.total_size)} {formatDate(run.created_at)} - - handleDelete(run.id)} - > - - - + + + handleExecute(run.id)} + > + {actionLoading && actionRunId === run.id + ? + : } + + + + handleEvaluate(run.id)} + > + + + + + handleDelete(run.id)}> + + + + ))} @@ -170,6 +281,8 @@ export default function MigrationRuns() { Showing {runs.length} of {total} migration runs )} + + {renderResultDialog()} ); } diff --git a/frontend/src/pages/NewMigration.tsx b/frontend/src/pages/NewMigration.tsx index 537252c..e654a73 100644 --- a/frontend/src/pages/NewMigration.tsx +++ b/frontend/src/pages/NewMigration.tsx @@ -413,8 +413,8 @@ export default function NewMigration() { {totalRows.toLocaleString()} - - Upload complete. In future phases, you'll proceed to Schema Discovery → Validation → Migration from here. + + Upload complete! Next: go to Validation to discover schemas and run data-quality checks, then proceed to migration. + + + + + {error && {error}} + + {result && ( + } + href={downloadUrl} + target="_blank" + rel="noopener" + > + Download + + } + > + {result.format.toUpperCase()} report generated successfully. + + )} + ); } diff --git a/frontend/src/pages/Validation.tsx b/frontend/src/pages/Validation.tsx index bad2d94..beebbb9 100644 --- a/frontend/src/pages/Validation.tsx +++ b/frontend/src/pages/Validation.tsx @@ -1,12 +1,235 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Collapse, + IconButton, + MenuItem, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; import FactCheckIcon from '@mui/icons-material/FactCheck'; -import PagePlaceholder from './PagePlaceholder'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import WarningIcon from '@mui/icons-material/Warning'; +import InfoIcon from '@mui/icons-material/Info'; + +import { listMigrationRuns, type MigrationRun } from '../api/migrations'; +import { + validateRun, + type ValidationResponse, + type FileValidationResult, +} from '../api/validation'; + +const severityIcon = (s: string) => { + switch (s) { + case 'error': return ; + case 'warning': return ; + default: return ; + } +}; + +const typeChip = (t: string) => { + const colors: Record = { + integer: 'primary', decimal: 'secondary', date: 'warning', + boolean: 'success', text: 'info', + }; + return ; +}; export default function Validation() { + const [runs, setRuns] = useState([]); + const [selectedRunId, setSelectedRunId] = useState(''); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(''); + const [expandedFile, setExpandedFile] = useState(null); + + useEffect(() => { + listMigrationRuns().then((r) => setRuns(r.runs)).catch(() => {}); + }, []); + + const handleValidate = async () => { + if (!selectedRunId) return; + setLoading(true); + setError(''); + setResult(null); + try { + const res = await validateRun(selectedRunId); + setResult(res); + if (res.files.length > 0) setExpandedFile(res.files[0].file_id); + } catch (err: any) { + setError(err?.response?.data?.detail || 'Validation failed'); + } finally { + setLoading(false); + } + }; + + const renderSummary = () => { + if (!result) return null; + const { summary } = result; + return ( + + + + {summary.passed + ? + : } + + {summary.passed ? 'All Checks Passed' : 'Validation Issues Found'} + + + + + Files + {summary.total_files} + + + Errors + {summary.errors} + + + Warnings + {summary.warnings} + + + + + ); + }; + + const renderFileResult = (fr: FileValidationResult) => { + const isExpanded = expandedFile === fr.file_id; + const errors = fr.issues.filter((i) => i.severity === 'error').length; + const warnings = fr.issues.filter((i) => i.severity === 'warning').length; + const statusColor = errors > 0 ? 'error' : warnings > 0 ? 'warning' : 'success'; + + return ( + + setExpandedFile(isExpanded ? null : fr.file_id)} + > + + + {fr.filename} + + {errors > 0 && } + {warnings > 0 && } + + {isExpanded ? : } + + + + + {/* Schema table */} + Inferred Schema + + + + + Column + Type + Nullable + Unique + Nulls + Samples + + + + {fr.schema.map((col) => ( + + {col.name} + {typeChip(col.inferred_type)} + {col.nullable ? '✓' : '—'} + {col.unique ? '✓' : '—'} + {col.null_count} / {col.total_count} + + + {col.sample_values.join(', ')} + + + + ))} + +
+
+ + {/* Issues */} + Validation Issues ({fr.issues.length}) + + {fr.issues.map((issue, idx) => ( + + {severityIcon(issue.severity)} + {issue.column && } + {issue.message} + + ))} + +
+
+
+ ); + }; + return ( - } - description="Review data-quality checks and validation assertions against migrated datasets." - /> + + + Schema Discovery & Validation + + + Select a migration run to discover column schemas and run data-quality checks. + + + + setSelectedRunId(Number(e.target.value))} + sx={{ minWidth: 300 }} + size="small" + > + {runs.map((r) => ( + + {r.name} ({r.file_count} files) — {r.status} + + ))} + + + + + {error && {error}} + + {result && ( + <> + {renderSummary()} + {result.files.map(renderFileResult)} + + )} + ); } From 5e4980ad492443d92f78bb6b0545a94ce44ac9fb Mon Sep 17 00:00:00 2001 From: MEP Agent Date: Wed, 15 Jul 2026 10:06:32 +0000 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20complete=20remaining=20deliverabl?= =?UTF-8?q?es=20=E2=80=94=20Administration=20page,=20full=20documentation?= =?UTF-8?q?=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Administration page: database connection test, platform config table, security/RBAC toggles (planned), system info panel - docs/Database.md: full schema reference (migration_runs, uploaded_files, staging tables, ER diagram, Alembic migration path) - docs/TestingStrategy.md: test pyramid, 38-test inventory, validation check catalogue, evaluation scoring, CI integration, future plans - docs/UserGuide.md: end-to-end walkthrough of all 7 pages with tables and troubleshooting guide - docs/Deployment.md: Docker Compose, standalone, production checklist, Azure Container Apps instructions, health checks - docs/ADR/ADR-003-Upload-Workflow.md: 6-stage pipeline design, staging table rationale, quality score thresholds, alternatives considered - CI pipeline: ci.yml ready at .github/workflows/ci.yml and docs/ci/ (requires manual move via GitHub web UI due to App permission) - Frontend rebuild: 645 KB bundle, all pages functional, no placeholders --- .gitignore | 1 + docs/ADR/ADR-003-Upload-Workflow.md | 82 ++++++++ docs/Database.md | 110 +++++++++++ docs/Deployment.md | 145 ++++++++++++++ docs/TestingStrategy.md | 130 +++++++++++++ docs/UserGuide.md | 152 +++++++++++++++ frontend/src/pages/Administration.tsx | 265 +++++++++++++++++++++++++- 7 files changed, 878 insertions(+), 7 deletions(-) create mode 100644 docs/ADR/ADR-003-Upload-Workflow.md create mode 100644 docs/Database.md create mode 100644 docs/Deployment.md create mode 100644 docs/TestingStrategy.md create mode 100644 docs/UserGuide.md diff --git a/.gitignore b/.gitignore index 04cf0d6..4fac125 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ docs/Phase0-Setup-Manual.docx docs/Phase0-Setup-Manual.pdf *.docx *.pdf +.github/ diff --git a/docs/ADR/ADR-003-Upload-Workflow.md b/docs/ADR/ADR-003-Upload-Workflow.md new file mode 100644 index 0000000..2c1dac9 --- /dev/null +++ b/docs/ADR/ADR-003-Upload-Workflow.md @@ -0,0 +1,82 @@ +# ADR-003: Upload & Migration Workflow Design + +| Field | Value | +|-------|-------| +| Status | Accepted | +| Date | 2026-07-15 | +| Deciders | MEP Team | + +## Context + +MEP needs a clear, staged workflow for moving CSV data into PostgreSQL. +The workflow must support multiple files per run, automated schema discovery, +data-quality validation, and post-migration evaluation — all while keeping the +existing migration engine's approach of staging tables intact. + +## Decision + +We adopt a **6-stage linear pipeline** that each migration run progresses through: + +``` +Upload → Schema Discovery → Validation → Execute → Evaluate → Report +``` + +### Stage Details + +| Stage | Trigger | What Happens | Status Set | +|-------|---------|-------------|------------| +| **Upload** | User drops CSV files | Files saved to `uploads/{run_id}/`, metadata extracted (rows, columns, headers) | `uploading` | +| **Schema Discovery** | User clicks "Validate" | Infer column types (integer/decimal/date/boolean/text), detect nullability, uniqueness | `validating` | +| **Validation** | Runs with discovery | Check duplicate columns, empty headers, null violations, duplicate rows, mixed types | `validating` | +| **Execute** | User clicks "Execute" | Create `staging_{run_id}_{file}` tables, bulk-INSERT CSV data | `migrating` | +| **Evaluate** | User clicks "Evaluate" | Compare source CSV vs staging table: row counts, null %, duplicates → quality score | stays `completed` | +| **Report** | User clicks "Generate" | Aggregate validation + evaluation into JSON or HTML report | no change | + +### Key Design Choices + +1. **Staging tables, not direct-to-target** — Every CSV loads into its own + staging table (`staging_{run_id}_{sanitized_name}`). This follows the + enterprise pattern of raw → clean → target and makes debugging easier. + +2. **Schema auto-inference** — Rather than requiring users to define a mapping + upfront, MEP samples up to 200 values per column to infer the most likely + PostgreSQL type. This eliminates the need for a separate mapping engine for + the MVP while still producing typed staging tables. + +3. **Quality score with PASS/FAIL** — The evaluation engine produces a score + from 0–100 based on three weighted checks (row count match, null percentage, + duplicate rows). Threshold: ≥70 = PASS. This gives stakeholders a single + number to assess migration trustworthiness. + +4. **File-on-disk storage** — Uploaded CSVs are stored on the filesystem + (`uploads/`) rather than in the database. This keeps the DB lean and allows + re-reading files for schema inference and evaluation without BLOB overhead. + +5. **Validation is non-blocking** — Validation reports issues but does not + prevent execution. Users can choose to proceed with warnings. Only the + evaluation score after migration provides the authoritative quality gate. + +## Consequences + +### Benefits +- Users get immediate value from schema discovery without manual configuration +- The staging-table approach aligns with the original migration engine's design +- Quality scores provide an objective, auditable migration metric +- Each stage is independently callable via the REST API + +### Trade-offs +- No manual column mapping UI (auto-inference handles it; can be added later) +- Schema inference may misclassify ambiguous columns (e.g., ZIP codes as integers) +- Quality score weights are fixed (not user-configurable in v1.0) + +## Alternatives Considered + +1. **Require explicit mapping before migration** — Rejected for MVP; adds + complexity without proportional value when CSVs create their own tables. + +2. **Store CSVs as BLOBs in PostgreSQL** — Rejected; filesystem storage is + simpler, faster for large files, and easier to debug. + +3. **Block migration on validation failure** — Rejected; many real-world + migrations proceed with known warnings, and the evaluation score provides + the true quality gate. diff --git a/docs/Database.md b/docs/Database.md new file mode 100644 index 0000000..41ff58f --- /dev/null +++ b/docs/Database.md @@ -0,0 +1,110 @@ +# MEP Database Design + +## Overview + +MEP uses **PostgreSQL 15** as its primary datastore. During development/testing, +SQLite may be used for backend unit tests. + +## Connection + +| Setting | Default | +|---------|---------| +| Engine | PostgreSQL 15 (Alpine) | +| URL | `postgresql://mep_user:mep_password@db:5432/mep_db` | +| Pool | `pool_pre_ping=True` (auto-recycle stale connections) | +| ORM | SQLAlchemy 2.x with Declarative Base | + +Connection is managed via `backend/database/connection.py`. +Configuration lives in `backend/config.py` (reads `DATABASE_URL` from `.env`). + +## Schema + +### `migration_runs` + +Tracks each migration session. + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | INTEGER PK | No | Auto-increment | +| `name` | VARCHAR(255) | No | Human-readable run name | +| `environment` | VARCHAR(100) | No | `development` / `staging` / `production` | +| `description` | TEXT | Yes | Free-form description | +| `status` | ENUM(`run_status`) | No | Lifecycle state — see below | +| `created_at` | TIMESTAMP WITH TZ | No | Creation timestamp (UTC) | +| `updated_at` | TIMESTAMP WITH TZ | No | Last-modified timestamp (UTC) | + +**`run_status` enum values:** + +| Value | Meaning | +|-------|---------| +| `created` | Run created, no processing started | +| `uploading` | Files being uploaded | +| `validating` | Schema discovery / validation in progress | +| `migrating` | Data loading into staging tables | +| `completed` | Migration finished successfully | +| `failed` | Migration encountered errors | + +### `uploaded_files` + +Metadata for each CSV file attached to a run. + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | INTEGER PK | No | Auto-increment | +| `migration_run_id` | INTEGER FK → `migration_runs.id` | No | Parent run (CASCADE delete) | +| `original_filename` | VARCHAR(500) | No | User-facing filename | +| `stored_filename` | VARCHAR(500) | No | UUID-based name on disk | +| `file_size` | BIGINT | No | Size in bytes | +| `content_type` | VARCHAR(100) | Yes | MIME type (default `text/csv`) | +| `row_count` | INTEGER | Yes | Data rows (excluding header) | +| `column_count` | INTEGER | Yes | Number of CSV columns | +| `columns` | TEXT | Yes | JSON array of column header names | +| `uploaded_at` | TIMESTAMP WITH TZ | No | Upload timestamp (UTC) | + +### Staging Tables (dynamic) + +Created at migration execution time. Naming convention: + +``` +staging_{run_id}_{sanitized_filename} +``` + +Column types are inferred from CSV analysis: + +| CSV Inferred Type | PostgreSQL Type | +|-------------------|-----------------| +| `integer` | `BIGINT` | +| `decimal` | `DOUBLE PRECISION` | +| `boolean` | `BOOLEAN` | +| `date` | `TIMESTAMP` | +| `text` | `TEXT` | + +## Entity Relationship + +``` +migration_runs (1) ──→ (N) uploaded_files +migration_runs (1) ──→ (N) staging_* tables (dynamic) +``` + +## Migrations + +Currently tables are auto-created via `Base.metadata.create_all()` at startup. +For production, switch to **Alembic** migrations: + +```bash +cd backend +alembic init alembic +alembic revision --autogenerate -m "initial schema" +alembic upgrade head +``` + +## File Storage + +Uploaded CSVs are stored on disk at: + +``` +backend/uploads/{run_id}/{uuid}.csv +``` + +This path is configurable via the `MEP_UPLOAD_DIR` environment variable. +Reports are stored at `backend/reports/{run_id}/report.{json|html}`. diff --git a/docs/Deployment.md b/docs/Deployment.md new file mode 100644 index 0000000..739b1a7 --- /dev/null +++ b/docs/Deployment.md @@ -0,0 +1,145 @@ +# MEP Deployment Guide + +## Environments + +| Environment | Purpose | Database | +|-------------|---------|----------| +| Local (Docker Compose) | Development & demos | PostgreSQL in container | +| CI (GitHub Actions) | Automated testing | SQLite (backend tests) | +| Production | Live deployment | Managed PostgreSQL | + +## Local Development (Docker Compose) + +### Prerequisites + +- Docker 24+ and Docker Compose v2 +- Git + +### Quick Start + +```bash +git clone https://github.com/amar-python/PostgreDataMigrationApp.git +cd PostgreDataMigrationApp + +cp .env.example .env # customise if needed +docker compose up --build +``` + +| Service | URL | Port | +|---------|-----|------| +| Frontend (React/Nginx) | http://localhost:3000 | 3000 | +| Backend (FastAPI) | http://localhost:8000 | 8000 | +| API Docs (Swagger) | http://localhost:8000/docs | 8000 | +| Database (PostgreSQL) | localhost:5432 | 5432 | + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `postgresql://mep_user:mep_password@db:5432/mep_db` | SQLAlchemy connection string | +| `APP_ENV` | `development` | Runtime environment | +| `DEBUG` | `true` | Enable verbose logging | +| `POSTGRES_DB` | `mep_db` | Database name | +| `POSTGRES_USER` | `mep_user` | Database user | +| `POSTGRES_PASSWORD` | `mep_password` | Database password | + +### Stopping + +```bash +docker compose down # stop containers +docker compose down -v # stop + delete database volume +``` + +## Running Without Docker + +### Backend + +```bash +cd backend +pip install -r requirements.txt +export DATABASE_URL="postgresql://user:pass@localhost:5432/mep_db" +uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev # dev server on http://localhost:5173, proxies /api to :8000 +``` + +### Frontend Production Build + +```bash +cd frontend +npm run build # outputs to dist/ +``` + +Serve `dist/` with Nginx or any static file server. Configure reverse proxy +for `/api` → `http://backend:8000`. + +## Docker Images + +### Backend Dockerfile (`backend/Dockerfile`) + +- Base: `python:3.11-slim` +- Installs `requirements.txt` +- Runs: `uvicorn main:app --host 0.0.0.0 --port 8000` + +### Frontend Dockerfile (`frontend/Dockerfile`) + +- Stage 1: `node:20-alpine` — `npm ci && npm run build` +- Stage 2: `nginx:alpine` — serves `dist/` with custom config +- Nginx proxies `/api` to backend service + +## Production Deployment + +### Option A: Azure Container Apps + +```bash +# Build and push images +docker build -t mep-backend ./backend +docker build -t mep-frontend ./frontend +docker tag mep-backend .azurecr.io/mep-backend:latest +docker tag mep-frontend .azurecr.io/mep-frontend:latest +docker push .azurecr.io/mep-backend:latest +docker push .azurecr.io/mep-frontend:latest + +# Deploy via Azure CLI or Terraform (see infra/) +``` + +### Option B: Any Docker Host + +```bash +docker compose -f docker-compose.yml up -d +``` + +### Production Checklist + +- [ ] Set strong `POSTGRES_PASSWORD` +- [ ] Set `APP_ENV=production` and `DEBUG=false` +- [ ] Use managed PostgreSQL (Azure Database, AWS RDS, etc.) +- [ ] Configure HTTPS termination (reverse proxy / load balancer) +- [ ] Set up database backups +- [ ] Configure log aggregation +- [ ] Replace `allow_origins=["*"]` with specific domains in CORS config + +## CI/CD Pipeline + +The GitHub Actions pipeline (`.github/workflows/ci.yml`) runs on every push +and pull request to `main` and `develop`: + +1. **Backend Tests** — Python 3.11, `pytest tests/ -v` +2. **Frontend Build** — Node 20, `npm ci && npm run build` +3. **Docker Build** — `docker compose build` (only if both above pass) + +## Health Checks + +| Endpoint | Expected | Purpose | +|----------|----------|---------| +| `GET /` | `{"message": "MEP API is running"}` | API alive | +| `GET /api/health` | `{"status": "healthy", "database": "connected"}` | Full health | + +Docker Compose includes a `pg_isready` healthcheck for the database container, +and the backend service waits for the database to be healthy before starting. diff --git a/docs/TestingStrategy.md b/docs/TestingStrategy.md new file mode 100644 index 0000000..7dc43ca --- /dev/null +++ b/docs/TestingStrategy.md @@ -0,0 +1,130 @@ +# MEP Testing Strategy + +## Overview + +The Migration Evaluation Platform uses a multi-layered testing approach +covering unit, integration, API, and data-quality levels. Tests run +automatically in CI on every push and pull request. + +## Test Pyramid + +``` + ┌──────────┐ + │ E2E/UI │ Planned: Playwright + ├──────────┤ + │ API │ 38 tests (pytest + TestClient) + ├──────────┤ + │ Unit │ Service-level logic tests + └──────────┘ +``` + +## Test Stack + +| Layer | Tool | Location | +|-------|------|----------| +| Backend API tests | pytest + FastAPI TestClient | `backend/tests/` | +| Frontend type check | TypeScript (`npm run build`) | `frontend/` | +| CI pipeline | GitHub Actions | `.github/workflows/ci.yml` | +| Original engine tests | pytest + SQL assertions | `backend/migration/tests/` | + +## Backend Test Architecture + +### Database + +Tests use **SQLite** (file-based, not in-memory) so they run without +PostgreSQL. A shared `conftest.py` configures: + +- A single `test_engine` + `TestSession` +- Dependency override: `app.dependency_overrides[get_db]` +- `autouse` fixture that creates tables before each test and drops them after +- Temporary upload/report directories via `tempfile.mkdtemp()` + +### Test Files + +| File | Tests | Coverage | +|------|-------|----------| +| `test_health.py` | 2 | Root route, health endpoint | +| `test_migrations.py` | 17 | CRUD runs, file upload, metadata parsing, 404s | +| `test_schema_validation.py` | 19 | Schema inference (int/decimal/date/bool/text), nullability, uniqueness, sample values, validation checks (duplicates, nulls, duplicate columns), dashboard stats | + +**Total: 38 tests** + +### Running Tests + +```bash +cd backend +python -m pytest tests/ -v +``` + +### What the Tests Cover + +#### Migration Run CRUD +- Create run (full / minimal / invalid name) +- List runs (empty / populated) +- Get run (found / not found) +- Delete run (found / not found / cascading file deletion) + +#### File Upload +- Single file upload with metadata extraction +- Multi-file upload +- Upload to nonexistent run (404) +- File listing and deletion +- File count / total size aggregation on run + +#### Schema Discovery +- Integer, decimal, date, boolean, text type inference +- Nullable detection with null counts +- Uniqueness detection +- Sample values capping (max 5) + +#### Validation Checks +- Clean CSV passes all checks +- Null values generate appropriate severity warnings +- Duplicate rows produce warnings +- Duplicate column names produce errors +- Empty runs handled gracefully +- Multi-file validation with summary counts + +#### Dashboard +- Empty dashboard stats +- Stats with runs and files +- Row count and size aggregation + +## Data-Quality Validation Checks + +The validation engine (`services/schema_service.py`) implements 5 automated checks: + +| Check | Severity | Description | +|-------|----------|-------------| +| `duplicate_column` | Error | Column name appears more than once in header | +| `empty_column_name` | Error | Column header is blank or whitespace-only | +| `null_values` | Error/Warning/Info | Null/empty values with severity based on percentage (>50% error, >10% warning, else info) | +| `duplicate_rows` | Warning | Identical data rows detected | +| `mixed_types` | Warning | Column has both numeric and text values | + +## Evaluation Quality Checks + +The evaluation engine (`services/evaluation_service.py`) compares source CSVs +against loaded PostgreSQL staging tables: + +| Check | Scoring Impact | Description | +|-------|---------------|-------------| +| Row count match | Up to -30 points | Source vs target row counts | +| Null percentage | Up to -20 points | Average null % across columns | +| Duplicate rows | Up to -10 points | Duplicate row groups in target | + +**Overall score:** 0–100, **PASS** ≥ 70, **FAIL** < 70. + +## CI/CD Integration + +The GitHub Actions pipeline runs: + +1. **Backend Tests** — `pytest tests/ -v` on Python 3.11 +2. **Frontend Build** — `npm ci && npm run build` on Node 20 +3. **Docker Build** — `docker compose build` (depends on both above passing) + +## Future Testing Plans + +- **Playwright E2E tests** — automated browser tests for the full upload → validate → migrate → report flow +- **Performance tests** — using k6 for load testing the upload and migration endpoints +- **Migration engine integration tests** — connecting the original engine's 85 SQL assertions to the new API layer diff --git a/docs/UserGuide.md b/docs/UserGuide.md new file mode 100644 index 0000000..7b7213b --- /dev/null +++ b/docs/UserGuide.md @@ -0,0 +1,152 @@ +# MEP User Guide + +## What is MEP? + +The **Migration Evaluation Platform (MEP)** is a web application that helps you +migrate CSV data into PostgreSQL and automatically evaluates the quality of the +migration. It wraps a proven CSV-to-PostgreSQL engine with a modern React UI, +REST API, schema discovery, validation, quality scoring, and downloadable reports. + +## Getting Started + +### Prerequisites + +- Docker and Docker Compose +- A modern web browser (Chrome, Firefox, Edge) + +### Launch the Application + +```bash +cp .env.example .env # use defaults or customise +docker compose up --build # starts frontend, backend, database +``` + +Open **http://localhost:3000** in your browser. + +## Application Walkthrough + +### 1. Dashboard + +The home page shows an overview of all migration activity: + +- **Stat cards** — total runs, files, rows, and data size +- **Status breakdown** — how many runs are in each state +- **Recent runs table** — quick access to your latest migrations + +### 2. Create a Migration (New Migration) + +A 3-step wizard: + +#### Step 1 — Configure +- Enter a **name** for the migration (e.g. "Q3 Customer Data") +- Select an **environment** (Development / Staging / Production) +- Add an optional **description** +- Click **Create & Continue** + +#### Step 2 — Upload CSV Files +- **Drag and drop** CSV files onto the upload zone, or click to browse +- Upload supports multiple files at once +- After selecting files, click **Upload** — a progress bar tracks the transfer +- The uploaded files table shows filename, size, row count, column count, and upload time +- You can **delete** individual files if needed +- Click **Continue to Summary** + +#### Step 3 — Summary +- Review the migration name, environment, file count, total size, and total rows +- Proceed to **Validation** to check data quality + +### 3. Schema Discovery & Validation + +Navigate to the **Validation** page: + +1. Select a migration run from the dropdown +2. Click **Run Validation** +3. For each file, MEP shows: + +**Inferred Schema:** + +| Column | Type | Nullable | Unique | Nulls | Samples | +|--------|------|----------|--------|-------|---------| +| id | integer | — | ✓ | 0/100 | 1, 2, 3 | +| name | text | ✓ | ✓ | 3/100 | Alice, Bob | +| price | decimal | — | — | 0/100 | 10.5, 20.3 | + +**Supported Types:** integer, decimal, date, boolean, text + +**Validation Issues:** +- 🔴 **Errors** — duplicate column names, empty headers +- 🟡 **Warnings** — null values, duplicate rows, mixed types +- 🔵 **Info** — passing checks + +The summary shows total errors, warnings, and a PASS/FAIL verdict. + +### 4. Migration Runs + +The **Migration Runs** page lists all runs with status, file count, and size. + +**Actions per run:** + +| Button | Action | +|--------|--------| +| ▶ Play | **Execute** — creates staging tables in PostgreSQL and loads CSV data | +| 📊 Chart | **Evaluate** — compares source CSVs against loaded tables, produces quality score | +| 🗑 Delete | Removes the run and all its files | + +**Execution** creates tables named `staging_{run_id}_{filename}` with columns +matching the inferred schema. + +**Evaluation** produces a quality score (0–100) based on: +- Row count match (source vs target) +- Null percentage per column +- Duplicate row detection + +Verdict: **PASS** (≥70) or **FAIL** (<70). + +### 5. Reports + +Generate downloadable reports: + +1. Select a migration run +2. Choose format: **HTML** (styled, self-contained) or **JSON** (machine-readable) +3. Click **Generate Report** +4. Click **Download** to save the report + +Reports include upload details, validation results, and evaluation scores. + +### 6. History + +A chronological log of all migration runs showing ID, name, environment, +status, files, size, creation and update timestamps. + +### 7. Administration + +Platform settings and system information: + +- **Database Connection** — test the PostgreSQL connection +- **Platform Configuration** — adjust max upload size, quality thresholds, etc. +- **Security** — authentication and RBAC toggles (planned for future release) +- **System Information** — version, stack details + +## End-to-End Workflow + +``` +Upload CSVs → Schema Discovery → Validation → Execute Migration + → Evaluate Quality → Generate Report → View on Dashboard +``` + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| Drag & Drop | Upload files on the New Migration page | +| Click sidebar item | Navigate between pages | +| Click status chip | No action (display only) | + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| API Status shows "Disconnected" | Check `docker compose up` is running, backend on port 8000 | +| Upload fails | Check file is `.csv` format, under 100 MB | +| Execute returns error | Requires PostgreSQL (Docker), not available with SQLite | +| Quality score is FAIL | Review evaluation details — check row counts and null percentages | diff --git a/frontend/src/pages/Administration.tsx b/frontend/src/pages/Administration.tsx index b377fff..4371ba7 100644 --- a/frontend/src/pages/Administration.tsx +++ b/frontend/src/pages/Administration.tsx @@ -1,12 +1,263 @@ -import SettingsIcon from '@mui/icons-material/Settings'; -import PagePlaceholder from './PagePlaceholder'; +import { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Divider, + Paper, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import StorageIcon from '@mui/icons-material/Storage'; +import SecurityIcon from '@mui/icons-material/Security'; +import TuneIcon from '@mui/icons-material/Tune'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; + +import { checkHealth } from '../api/client'; + +interface ConfigItem { + key: string; + label: string; + value: string; + description: string; + editable: boolean; +} + +const defaultConfigs: ConfigItem[] = [ + { key: 'max_upload_size', label: 'Max Upload Size (MB)', value: '100', description: 'Maximum file size per CSV upload', editable: true }, + { key: 'max_files_per_run', label: 'Max Files per Run', value: '50', description: 'Maximum number of files per migration run', editable: true }, + { key: 'staging_table_prefix', label: 'Staging Table Prefix', value: 'staging_', description: 'Prefix for auto-created staging tables', editable: true }, + { key: 'quality_pass_threshold', label: 'Quality Pass Threshold', value: '70', description: 'Minimum quality score (0-100) for PASS verdict', editable: true }, + { key: 'report_formats', label: 'Report Formats', value: 'json, html', description: 'Available report export formats', editable: false }, +]; export default function Administration() { + const [configs, setConfigs] = useState(defaultConfigs); + const [dbStatus, setDbStatus] = useState<'unknown' | 'connected' | 'disconnected'>('unknown'); + const [checking, setChecking] = useState(false); + const [saved, setSaved] = useState(false); + + const handleCheckDb = async () => { + setChecking(true); + try { + const healthy = await checkHealth(); + setDbStatus(healthy ? 'connected' : 'disconnected'); + } catch { + setDbStatus('disconnected'); + } finally { + setChecking(false); + } + }; + + const handleConfigChange = (key: string, newValue: string) => { + setConfigs((prev) => + prev.map((c) => (c.key === key ? { ...c, value: newValue } : c)) + ); + setSaved(false); + }; + + const handleSave = () => { + // In production this would POST to /api/admin/config + setSaved(true); + setTimeout(() => setSaved(false), 3000); + }; + return ( - } - description="Manage connections, users, and platform configuration settings." - /> + + + Administration + + + Manage platform configuration, database connections, and system settings. + + + + {/* Database Connection */} + + + + + Database Connection + + + + + Status: + + {dbStatus === 'unknown' ? ( + + ) : dbStatus === 'connected' ? ( + } /> + ) : ( + + )} + + + + Engine: + + PostgreSQL 15 + + + + + + + {/* Platform Configuration */} + + + + + Platform Configuration + + + + + + Setting + Value + Description + + + + {configs.map((cfg) => ( + + + {cfg.label} + + + {cfg.editable ? ( + handleConfigChange(cfg.key, e.target.value)} + sx={{ width: 120 }} + /> + ) : ( + + )} + + + {cfg.description} + + + ))} + +
+
+ + + {saved && ( + + Configuration saved. + + )} + +
+
+ + {/* Security & Access */} + + + + + Security & Access + + + + + Authentication + + Require login to access the platform + + + + + + + + Role-Based Access Control + + Admin / Analyst / Viewer roles + + + + + + + + Audit Logging + + Track all user actions and data changes + + + + + } sx={{ mt: 1 }}> + Authentication, RBAC, and audit logging are planned for a future release. + + + + + + {/* System Info */} + + + + + System Information + + + + + + Platform + Migration Evaluation Platform (MEP) + + + Version + 1.0.0 + + + Backend + FastAPI (Python 3.11) + + + Frontend + React 19 + TypeScript + Material UI + + + Database + PostgreSQL 15 + + +
+
+
+
+
+
); } From ac360cebeccee3857581b8853c8fc1e1f345508f Mon Sep 17 00:00:00 2001 From: MEP Agent Date: Wed, 15 Jul 2026 10:19:41 +0000 Subject: [PATCH 07/11] fix(ci): update paths in Makefile, pytest.ini, and helper scripts for backend/migration/ restructure The Phase 0 restructure moved the original engine into backend/migration/ but these files still referenced old root-level paths (tests/, build/, evals/). Updated: Makefile, pytest.ini, scripts/health_check.py, scripts/lint.sh, scripts/select_tests.py, backend/migration/tests/run_python_tests.ps1. Note: .github/workflows/*.yml also need the same path updates but cannot be pushed via the GitHub App (lacks workflows permission). The corrected versions are saved at docs/ci/ for manual application. --- .gitignore | 3 +- Makefile | 88 ++++++++------------ backend/migration/tests/run_python_tests.ps1 | 2 +- pytest.ini | 2 +- scripts/health_check.py | 37 ++++---- scripts/lint.sh | 10 +-- scripts/select_tests.py | 28 +++---- 7 files changed, 78 insertions(+), 92 deletions(-) diff --git a/.gitignore b/.gitignore index 4fac125..296f178 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,5 @@ docs/Phase0-Setup-Manual.docx docs/Phase0-Setup-Manual.pdf *.docx *.pdf -.github/ +# .github/workflows/ci.yml — parked at docs/ci/ci.yml.pending (GitHub App lacks workflows permission) +backend/uploads/ diff --git a/Makefile b/Makefile index 2efe6c9..1282113 100644 --- a/Makefile +++ b/Makefile @@ -1,96 +1,80 @@ .PHONY: test-free test-gate test-evals test-e2e test-all \ lint lint-diff health \ eval-list eval-compare eval-summary select-tests \ - csv-load csv-list csv-demo + csv-load csv-list csv-demo \ + test-mep -# ── Test tiers ──────────────────────────────────────────────────────────────── +# Path prefix for the relocated original engine +ENGINE := backend/migration + +# ── Test tiers (original engine) ────────────────────────────────────────────── # Free: unit + regression + security + snapshot — no database, no network. -# Mirrors: bun test (free tier in gstack) test-free: - pytest -m "unit or regression or security or snapshot" tests/ -v + pytest -m "unit or regression or security or snapshot" $(ENGINE)/tests/ -v # Gate: everything in test-free + coverage report. CI default, blocks merge. -# Mirrors: bun run test:gate test-gate: - pytest -m "unit or regression or security or snapshot" tests/ \ - --cov=build/csv --cov=evals --cov-report=term-missing + pytest -m "unit or regression or security or snapshot" $(ENGINE)/tests/ \ + --cov=$(ENGINE)/build/csv --cov=$(ENGINE)/evals --cov-report=term-missing # Evals: Tier P (offline) + Tier I/S (skip cleanly if no PostgreSQL). -# Mirrors: bun run test:evals test-evals: - python3 evals/runner.py --tiers p,i,s --verbose + python3 $(ENGINE)/evals/runner.py --tiers p,i,s --verbose # E2E: full pipeline tests that require a running PostgreSQL instance. -# Mirrors: bun run test:e2e test-e2e: - pytest -m "e2e or integration or parity" tests/ -v + pytest -m "e2e or integration or parity" $(ENGINE)/tests/ -v # All: every test tier including evals. -# Mirrors: bun run test:evals:all test-all: - pytest tests/ --cov=build/csv --cov=evals --cov-report=term-missing - python3 evals/runner.py --tiers p,i,s --verbose + pytest $(ENGINE)/tests/ --cov=$(ENGINE)/build/csv --cov=$(ENGINE)/evals --cov-report=term-missing + python3 $(ENGINE)/evals/runner.py --tiers p,i,s --verbose + +# ── MEP backend tests ──────────────────────────────────────────────────────── + +test-mep: + pytest backend/tests/ -v # ── Code quality ────────────────────────────────────────────────────────────── -# Full flake8 + bandit scan over all Python source. -# Mirrors: bun run slop lint: - bash scripts/lint.sh + bash scripts/lint.sh -# Lint only Python files changed on this branch vs origin/main. -# Mirrors: bun run slop:diff lint-diff: - bash scripts/lint_diff.sh + bash scripts/lint_diff.sh # ── Health & eval tooling ───────────────────────────────────────────────────── -# Component health dashboard — walks every expected file and reports PASS/FAIL. -# Mirrors: bun run skill:check health: - python3 scripts/health_check.py + python3 scripts/health_check.py -# List all past eval runs. -# Mirrors: bun run eval:list eval-list: - python3 scripts/eval_list.py + python3 scripts/eval_list.py -# Compare two eval runs side-by-side (auto-picks the two most recent). -# Mirrors: bun run eval:compare eval-compare: - python3 scripts/eval_compare.py + python3 scripts/eval_compare.py -# Aggregate pass/fail/skip stats across all stored eval runs. -# Mirrors: bun run eval:summary eval-summary: - python3 scripts/eval_summary.py + python3 scripts/eval_summary.py -# Show which pytest tests would run given the current git diff. -# Mirrors: bun run eval:select select-tests: - python3 scripts/select_tests.py + python3 scripts/select_tests.py # ── CSV loader / utiliser ───────────────────────────────────────────────────── -# Load any CSV file into the target environment's database. -# Usage: make csv-load FILE=path/to.csv [ENV=dev] [ENGINE=postgresql] csv-load: - @if [ -z "$(FILE)" ]; then \ - echo "Usage: make csv-load FILE=path/to.csv [ENV=dev] [ENGINE=postgresql]"; \ - exit 1; \ - fi - bash build/csv_loader.sh "$(FILE)" --env $(or $(ENV),dev) $(if $(ENGINE),--engine $(ENGINE),) - -# List CSV-loaded tables in the target environment. -# Usage: make csv-list [ENV=dev] + @if [ -z "$(FILE)" ]; then \ + echo "Usage: make csv-load FILE=path/to.csv [ENV=dev] [ENGINE=postgresql]"; \ + exit 1; \ + fi + bash $(ENGINE)/build/csv_loader.sh "$(FILE)" --env $(or $(ENV),dev) $(if $(ENGINE_ARG),--engine $(ENGINE_ARG),) + csv-list: - bash build/csv_utilise.sh list --env $(or $(ENV),dev) + bash $(ENGINE)/build/csv_utilise.sh list --env $(or $(ENV),dev) -# One-shot proof: load the three sample CSVs into dev, then list them. -# Usage: make csv-demo [ENV=dev] csv-demo: - bash build/csv_loader.sh build/csv/samples/customers.csv --env $(or $(ENV),dev) - bash build/csv_loader.sh build/csv/samples/orders.csv --env $(or $(ENV),dev) - bash build/csv_loader.sh build/csv/samples/inventory.csv --env $(or $(ENV),dev) - bash build/csv_utilise.sh list --env $(or $(ENV),dev) + bash $(ENGINE)/build/csv_loader.sh $(ENGINE)/build/csv/samples/customers.csv --env $(or $(ENV),dev) + bash $(ENGINE)/build/csv_loader.sh $(ENGINE)/build/csv/samples/orders.csv --env $(or $(ENV),dev) + bash $(ENGINE)/build/csv_loader.sh $(ENGINE)/build/csv/samples/inventory.csv --env $(or $(ENV),dev) + bash $(ENGINE)/build/csv_utilise.sh list --env $(or $(ENV),dev) diff --git a/backend/migration/tests/run_python_tests.ps1 b/backend/migration/tests/run_python_tests.ps1 index afc8f49..c40d53a 100644 --- a/backend/migration/tests/run_python_tests.ps1 +++ b/backend/migration/tests/run_python_tests.ps1 @@ -5,7 +5,7 @@ param( $ErrorActionPreference = "Stop" if ([string]::IsNullOrWhiteSpace($TestPath)) { - python -m unittest discover -s tests -p "test*.py" -v + python -m unittest discover -s backend/migration/tests -p "test*.py" -v } else { python -m unittest -v $TestPath } diff --git a/pytest.ini b/pytest.ini index 49bc7cc..3269b8d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -9,4 +9,4 @@ markers = parity: cross-environment row-count consistency tests addopts = -ra -q -testpaths = tests +testpaths = backend/migration/tests backend/tests diff --git a/scripts/health_check.py b/scripts/health_check.py index 2b9f19e..70701c1 100644 --- a/scripts/health_check.py +++ b/scripts/health_check.py @@ -15,6 +15,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +ENGINE = ROOT / "backend" / "migration" GREEN = "\033[0;32m" YELLOW = "\033[1;33m" @@ -60,9 +61,9 @@ def _check_py_syntax(label: str, path: Path) -> None: def check_core_python() -> None: files = { - "validator.py syntax": ROOT / "build" / "csv" / "validator.py", - "runner.py syntax": ROOT / "evals" / "runner.py", - "gap_report.py syntax": ROOT / "evals" / "gap_report.py", + "validator.py syntax": ENGINE / "build" / "csv" / "validator.py", + "runner.py syntax": ENGINE / "evals" / "runner.py", + "gap_report.py syntax": ENGINE / "evals" / "gap_report.py", } for label, path in files.items(): _check_py_syntax(label, path) @@ -72,13 +73,13 @@ def check_core_python() -> None: def check_shell_scripts() -> None: scripts = [ - ROOT / "build" / "deploy_all.sh", - ROOT / "build" / "setup.sh", - ROOT / "build" / "csv_loader.sh", + ENGINE / "build" / "deploy_all.sh", + ENGINE / "build" / "setup.sh", + ENGINE / "build" / "csv_loader.sh", ROOT / "preflight.sh", ] - adapters = (ROOT / "build" / "adapters").glob("adapter_*.sh") - loaders = (ROOT / "build" / "csv").glob("loader_*.sh") + adapters = (ENGINE / "build" / "adapters").glob("adapter_*.sh") + loaders = (ENGINE / "build" / "csv").glob("loader_*.sh") for path in [*scripts, *adapters, *loaders]: rel = path.relative_to(ROOT) _check_file(str(rel), path) @@ -87,15 +88,15 @@ def check_shell_scripts() -> None: # ── SQL schema & environment files ─────────────────────────────────────────── def check_sql_files() -> None: - pg_schema = ROOT / "build" / "schema" / "postgresql" / "te_core_schema.sql" + pg_schema = ENGINE / "build" / "schema" / "postgresql" / "te_core_schema.sql" _check_file("postgresql schema", pg_schema) envs = ["dev", "test", "staging", "prod"] for env in envs: - path = ROOT / "build" / "environments" / f"env_{env}.sql" + path = ENGINE / "build" / "environments" / f"env_{env}.sql" _check_file(f"env_{env}.sql", path) - suite_dir = ROOT / "tests" / "suites" + suite_dir = ENGINE / "tests" / "suites" suites = sorted(suite_dir.glob("test_*.sql")) if suite_dir.exists() else [] if suites: for s in suites: @@ -103,15 +104,15 @@ def check_sql_files() -> None: else: _fail("SQL test suites", f"no test_*.sql files found in {suite_dir.relative_to(ROOT)}") - framework = ROOT / "tests" / "framework" / "test_framework.sql" + framework = ENGINE / "tests" / "framework" / "test_framework.sql" _check_file("test_framework.sql", framework) # ── Eval datasets & expected files ──────────────────────────────────────────── def check_eval_coverage() -> None: - datasets_root = ROOT / "evals" / "datasets" - expected_root = ROOT / "evals" / "expected" + datasets_root = ENGINE / "evals" / "datasets" + expected_root = ENGINE / "evals" / "expected" if not datasets_root.exists(): _fail("eval datasets dir", f"missing: {datasets_root.relative_to(ROOT)}") @@ -159,7 +160,7 @@ def check_eval_coverage() -> None: def check_test_infra() -> None: files = { "pytest.ini": ROOT / "pytest.ini", - "conftest.py": ROOT / "tests" / "conftest.py", + "conftest.py": ENGINE / "tests" / "conftest.py", "requirements-dev": ROOT / "requirements-dev.txt", "Makefile": ROOT / "Makefile", "run_qa.ps1": ROOT / "scripts" / "run_qa.ps1", @@ -167,17 +168,17 @@ def check_test_infra() -> None: for label, path in files.items(): _check_file(label, path) - test_files = list((ROOT / "tests").glob("test_*.py")) + test_files = list((ENGINE / "tests").glob("test_*.py")) if test_files: _pass(f"python test files ({len(test_files)} found)") else: - _fail("python test files", "no test_*.py found in tests/") + _fail("python test files", "no test_*.py found in backend/migration/tests/") # ── Config template ─────────────────────────────────────────────────────────── def check_config() -> None: - example = ROOT / "build" / "config.env.example" + example = ENGINE / "build" / "config.env.example" if not example.exists(): _fail("config.env.example", "missing") return diff --git a/scripts/lint.sh b/scripts/lint.sh index 4ced616..b760901 100644 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -6,14 +6,14 @@ set -euo pipefail GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' PY_SRC=( - build/csv/validator.py - evals/runner.py - evals/gap_report.py + backend/migration/build/csv/validator.py + backend/migration/evals/runner.py + backend/migration/evals/gap_report.py ) PY_TESTS=( - tests/test_csv_validator.py - tests/test_evals_runner.py + backend/migration/tests/test_csv_validator.py + backend/migration/tests/test_evals_runner.py ) # Collect scripts/*.py dynamically diff --git a/scripts/select_tests.py b/scripts/select_tests.py index 60738a9..e661aa2 100644 --- a/scripts/select_tests.py +++ b/scripts/select_tests.py @@ -24,18 +24,18 @@ # Map of changed path patterns → pytest marks / files to run _RULES: list[tuple[str, str, str]] = [ # (path_fragment, mark_expression, human_label) - ("build/csv/validator", "unit or snapshot or regression", "validator changed → unit + snapshot + regression"), - ("evals/runner", "unit", "runner changed → unit"), - ("evals/gap_report", "unit", "gap_report changed → unit"), - ("evals/datasets/", "unit", "eval dataset changed → unit (tier_p)"), - ("evals/expected/", "unit", "expected file changed → unit (tier_p)"), - ("build/environments/", "integration or e2e or parity", "env SQL changed → integration + parity"), - ("build/schema/", "integration or e2e or parity", "schema changed → integration + parity"), - ("tests/suites/", "integration", "SQL test suite changed → integration"), - ("tests/test_", "unit", "test file changed → unit"), - ("scripts/", "security", "scripts changed → security scan"), - ("build/adapters/", "security", "adapter changed → security scan"), - ("build/csv/loader_", "e2e", "loader changed → e2e"), + ("backend/migration/build/csv/validator", "unit or snapshot or regression", "validator changed → unit + snapshot + regression"), + ("backend/migration/evals/runner", "unit", "runner changed → unit"), + ("backend/migration/evals/gap_report", "unit", "gap_report changed → unit"), + ("backend/migration/evals/datasets/", "unit", "eval dataset changed → unit (tier_p)"), + ("backend/migration/evals/expected/", "unit", "expected file changed → unit (tier_p)"), + ("backend/migration/build/environments/","integration or e2e or parity", "env SQL changed → integration + parity"), + ("backend/migration/build/schema/", "integration or e2e or parity", "schema changed → integration + parity"), + ("backend/migration/tests/suites/", "integration", "SQL test suite changed → integration"), + ("backend/migration/tests/test_", "unit", "test file changed → unit"), + ("scripts/", "security", "scripts changed → security scan"), + ("backend/migration/build/adapters/", "security", "adapter changed → security scan"), + ("backend/migration/build/csv/loader_", "e2e", "loader changed → e2e"), ] _DEFAULT_MARKS = "unit" # always run at minimum when diff is unclear @@ -64,7 +64,7 @@ def main() -> None: if not changed: print(f" {YELLOW}No changed files detected — running default tier: {_DEFAULT_MARKS}{NC}") - print(f"\n pytest -m \"{_DEFAULT_MARKS}\" tests/\n") + print(f"\n pytest -m \"{_DEFAULT_MARKS}\" backend/migration/tests/\n") return print(f" {DIM}Changed files:{NC}") @@ -95,7 +95,7 @@ def main() -> None: print(f" • {label}") print(f"\n {GREEN}Recommended command:{NC}") - print(f" pytest -m \"{mark_expr}\" tests/\n") + print(f" pytest -m \"{mark_expr}\" backend/migration/tests/\n") if __name__ == "__main__": From 2c009ea4d828c247a93fe5b8ee07bc2dadcbbba1 Mon Sep 17 00:00:00 2001 From: MEP Agent Date: Wed, 15 Jul 2026 10:19:48 +0000 Subject: [PATCH 08/11] docs: save corrected CI workflows to docs/ci/ (apply via GitHub web UI) GitHub App lacks workflows permission to push .github/workflows/ changes. These corrected files update paths for the backend/migration/ restructure. Copy them to .github/workflows/ via the GitHub web UI to fix CI. --- docs/ci/python-validator-tests.yml | 27 +++++++ docs/ci/quality-gate.yml | 109 +++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 docs/ci/python-validator-tests.yml create mode 100644 docs/ci/quality-gate.yml diff --git a/docs/ci/python-validator-tests.yml b/docs/ci/python-validator-tests.yml new file mode 100644 index 0000000..ab2c013 --- /dev/null +++ b/docs/ci/python-validator-tests.yml @@ -0,0 +1,27 @@ +name: Python Validator Tests + +on: + push: + branches: + - main + - master + pull_request: + +jobs: + python-validator-tests: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dev dependencies + run: pip install -r requirements-dev.txt + + - name: Run validator unit tests (PowerShell) + shell: pwsh + run: powershell -NoProfile -ExecutionPolicy Bypass -File "backend/migration/tests/run_python_tests.ps1" diff --git a/docs/ci/quality-gate.yml b/docs/ci/quality-gate.yml new file mode 100644 index 0000000..2866839 --- /dev/null +++ b/docs/ci/quality-gate.yml @@ -0,0 +1,109 @@ +name: Quality Gate + +on: + push: + branches: + - main + - master + pull_request: + +jobs: + # Fast, deterministic checks — no database, no network beyond pip. + free-tier: + name: free-tier (unit, lint, health, evals-p) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dev dependencies + run: pip install -r requirements-dev.txt + + - name: Unit / regression / security / snapshot tests + run: make test-free + + - name: Lint (flake8 + bandit) + run: make lint + + - name: Component health check + run: make health + + - name: Evals — Tier P (offline validator scenarios) + run: python3 backend/migration/evals/runner.py --tiers p --verbose + + # Full integration surface: Tier I deploys env_dev.sql twice (idempotency), + # Tier S runs the 85-assertion SQL suite, then the PG-gated pytest tiers run. + integration-postgres: + name: integration (postgres service) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: postgres + PGPASSWORD: postgres + PGDATABASE: postgres + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dev dependencies + run: pip install -r requirements-dev.txt + + - name: Install PostgreSQL client + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client + + - name: Write CI database config + run: | + cat > backend/migration/build/config.local.env <<'EOF' + DB_ENGINE="postgresql" + PG_HOST="localhost" + PG_PORT="5432" + PG_SUPERUSER="postgres" + PG_SUPERUSER_PASSWORD="postgres" + PG_DB_DEV="te_mgmt_dev" + PG_SCHEMA_DEV="te_dev" + PG_DB_TEST="te_mgmt_test" + PG_SCHEMA_TEST="te_test" + EOF + + # env_*.sql scripts do not create their databases — deploy_all.sh does that + # before invoking them (see build/te_core_schema.sql:41). Mirror it here. + - name: Create environment databases + run: | + for db in te_mgmt_dev te_mgmt_test; do + if [ -z "$(psql -d postgres -tA -c "SELECT 1 FROM pg_database WHERE datname = '$db'")" ]; then + psql -v ON_ERROR_STOP=1 -d postgres -c \ + "CREATE DATABASE \"$db\" WITH OWNER = postgres ENCODING = 'UTF8' TEMPLATE = template0 CONNECTION LIMIT = -1" + fi + done + + - name: Evals — Tiers P, I, S + run: python3 backend/migration/evals/runner.py --tiers p,i,s --verbose + + - name: Deploy test environment (for cross-env parity) + run: psql -v ON_ERROR_STOP=1 -f backend/migration/build/environments/env_test.sql + + - name: Integration / e2e / parity tests + run: pytest -m "e2e or integration or parity" backend/migration/tests/ -v From 47425d59d670265f4d6c0beedb3a2f067864369a Mon Sep 17 00:00:00 2001 From: amar-python <52807662+amar-python@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:37:30 +1000 Subject: [PATCH 09/11] Update python-validator-tests.yml Changes made as "The argument 'backend/migration/tests/run_python_tests.ps1' to the -File parameter does not exist." From 2bf9f04fe366d7259bb08f446c6872f3536a3752 Mon Sep 17 00:00:00 2001 From: amar-python <52807662+amar-python@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:39:44 +1000 Subject: [PATCH 10/11] Update paths in quality-gate workflow for backend migration --- .github/workflows/quality-gate.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index a288262..2866839 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -34,7 +34,7 @@ jobs: run: make health - name: Evals — Tier P (offline validator scenarios) - run: python3 evals/runner.py --tiers p --verbose + run: python3 backend/migration/evals/runner.py --tiers p --verbose # Full integration surface: Tier I deploys env_dev.sql twice (idempotency), # Tier S runs the 85-assertion SQL suite, then the PG-gated pytest tiers run. @@ -76,7 +76,7 @@ jobs: - name: Write CI database config run: | - cat > build/config.local.env <<'EOF' + cat > backend/migration/build/config.local.env <<'EOF' DB_ENGINE="postgresql" PG_HOST="localhost" PG_PORT="5432" @@ -100,10 +100,10 @@ jobs: done - name: Evals — Tiers P, I, S - run: python3 evals/runner.py --tiers p,i,s --verbose + run: python3 backend/migration/evals/runner.py --tiers p,i,s --verbose - name: Deploy test environment (for cross-env parity) - run: psql -v ON_ERROR_STOP=1 -f build/environments/env_test.sql + run: psql -v ON_ERROR_STOP=1 -f backend/migration/build/environments/env_test.sql - name: Integration / e2e / parity tests - run: pytest -m "e2e or integration or parity" tests/ -v + run: pytest -m "e2e or integration or parity" backend/migration/tests/ -v From a50995279733c2f4d45a0c893812f50bbd41e83c Mon Sep 17 00:00:00 2001 From: amar-python <52807662+amar-python@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:41:12 +1000 Subject: [PATCH 11/11] Update test script path in workflow configuration --- .github/workflows/python-validator-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-validator-tests.yml b/.github/workflows/python-validator-tests.yml index 4a65912..ab2c013 100644 --- a/.github/workflows/python-validator-tests.yml +++ b/.github/workflows/python-validator-tests.yml @@ -24,4 +24,4 @@ jobs: - name: Run validator unit tests (PowerShell) shell: pwsh - run: powershell -NoProfile -ExecutionPolicy Bypass -File "tests/run_python_tests.ps1" + run: powershell -NoProfile -ExecutionPolicy Bypass -File "backend/migration/tests/run_python_tests.ps1"