From 9e4b2133d698812f793ce2b90b88027da07ac3c9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 1 May 2026 00:09:27 +0200 Subject: [PATCH 1/2] remove datasets and products modules, reset migration history The datasets and products example modules are removed from the workspace along with all wiring (host pyproject, root pyproject, CLI catalog) and the integration/e2e tests that exercised them as their CRUD subject. Dashboard's product-count tile and the workspace dep on simple_module_products are also dropped. To avoid leaving frozen migrations referencing deleted modules, every file under host/migrations/versions/ is replaced by a single autogenerated 77162e7b184b_initial_schema.py. The fresh schema contains tables for the remaining 8 modules (auth/users/permissions/dashboard/settings/feature_flags/ file_storage/background_tasks) and `alembic check` reports no drift. Verified: 943 Python tests pass, 8 JS tests pass, make lint clean, doctor exits 0, host boots and /health reports the new revision as current. --- README.md | 4 +- docs/e2e-testing.md | 55 +-- docs/guide/project-structure.md | 2 - docs/guide/quickstart.md | 2 +- docs/release.md | 4 - framework/cli/simple_module_cli/catalog.py | 18 +- framework/cli/simple_module_cli/cli.py | 2 +- framework/cli/simple_module_cli/new.py | 1 - framework/cli/tests/test_cli_catalog.py | 13 +- framework/cli/tests/test_cli_wizard.py | 3 +- framework/cli/tests/test_scaffolding_host.py | 20 +- framework/core/tests/test_discovery.py | 10 +- framework/db/tests/test_db_logging.py | 46 --- framework/db/tests/test_migrations.py | 4 +- framework/hosting/tests/test_app.py | 26 +- host/migrations/versions/.gitkeep | 0 .../1fe7590fc594_add_settings_tables.py | 81 ---- .../versions/2fdcd367b517_initial_schema.py | 33 -- .../versions/53819c12d603_initial_schema.py | 46 --- ...4_add_permissions_role_permission_table.py | 54 --- ...5d44218ee368_create_file_storage_tables.py | 77 ---- ...4d2_add_background_tasks_task_execution.py | 115 ------ .../versions/77162e7b184b_initial_schema.py | 382 ++++++++++++++++++ .../8c12be982a27_create_users_tables.py | 131 ------ ...6_add_permissions_user_permission_table.py | 58 --- ...74312_add_indexes_for_dashboard_queries.py | 34 -- ...8_index_permissions_role_permission_key.py | 33 -- ...c9d02_add_perf_indexes_and_fix_products.py | 133 ------ ...8bf117_add_feature_flags_override_table.py | 86 ---- .../dad9a134290f_add_datasets_table.py | 69 ---- .../versions/e3ce9754e6dc_seed_users_roles.py | 51 --- host/pyproject.toml | 4 - modules/dashboard/README.md | 2 +- modules/dashboard/dashboard/locales/en.json | 1 - modules/dashboard/dashboard/locales/es.json | 1 - modules/dashboard/dashboard/module.py | 3 +- modules/dashboard/dashboard/pages/Home.tsx | 18 +- modules/dashboard/dashboard/stats.py | 10 - modules/dashboard/pyproject.toml | 2 - modules/dashboard/tests/test_dashboard.py | 3 - modules/datasets/LICENSE | 21 - modules/datasets/README.md | 45 --- modules/datasets/datasets/__init__.py | 1 - modules/datasets/datasets/constants.py | 146 ------- .../datasets/datasets/contracts/__init__.py | 51 --- modules/datasets/datasets/contracts/events.py | 28 -- modules/datasets/datasets/contracts/files.py | 88 ---- .../datasets/datasets/contracts/schemas.py | 64 --- modules/datasets/datasets/contracts/urls.py | 26 -- modules/datasets/datasets/deps.py | 97 ----- .../datasets/datasets/endpoints/__init__.py | 0 modules/datasets/datasets/endpoints/api.py | 238 ----------- modules/datasets/datasets/endpoints/views.py | 148 ------- modules/datasets/datasets/extractors.py | 155 ------- modules/datasets/datasets/locales/en.json | 67 --- modules/datasets/datasets/models.py | 49 --- modules/datasets/datasets/module.py | 160 -------- modules/datasets/datasets/pages/Browse.tsx | 165 -------- modules/datasets/datasets/pages/Create.tsx | 144 ------- modules/datasets/datasets/pages/Edit.tsx | 145 ------- modules/datasets/datasets/pages/Show.tsx | 112 ----- modules/datasets/datasets/py.typed | 0 modules/datasets/datasets/service.py | 227 ----------- modules/datasets/datasets/services.py | 22 - modules/datasets/datasets/settings.py | 30 -- modules/datasets/datasets/tasks.py | 140 ------- modules/datasets/package.json | 16 - modules/datasets/pyproject.toml | 60 --- modules/datasets/tests/conftest.py | 24 -- modules/datasets/tests/test_datasets.py | 285 ------------- modules/datasets/tests/test_datasets_views.py | 93 ----- modules/datasets/tests/test_public_surface.py | 194 --------- modules/datasets/tests/test_tasks.py | 155 ------- modules/datasets/tests/test_wiring.py | 131 ------ modules/datasets/tsconfig.json | 11 - .../tests/test_feature_flags_api.py | 44 +- .../tests/test_feature_flags_service.py | 10 +- modules/products/LICENSE | 21 - modules/products/README.md | 44 -- modules/products/package.json | 16 - modules/products/products/__init__.py | 1 - modules/products/products/constants.py | 10 - .../products/products/contracts/__init__.py | 21 - modules/products/products/contracts/events.py | 24 -- .../products/products/contracts/schemas.py | 46 --- modules/products/products/deps.py | 20 - .../products/products/endpoints/__init__.py | 0 modules/products/products/endpoints/api.py | 88 ---- modules/products/products/endpoints/views.py | 134 ------ modules/products/products/locales/en.json | 67 --- modules/products/products/locales/es.json | 67 --- modules/products/products/models.py | 32 -- modules/products/products/module.py | 72 ---- modules/products/products/pages/Browse.tsx | 278 ------------- modules/products/products/pages/Create.tsx | 128 ------ modules/products/products/pages/Edit.tsx | 152 ------- .../pages/components/ProductsPagination.tsx | 70 ---- modules/products/products/pages/validation.ts | 38 -- modules/products/products/py.typed | 0 modules/products/products/service.py | 81 ---- modules/products/pyproject.toml | 53 --- modules/products/tests/test_products.py | 252 ------------ modules/products/tsconfig.json | 11 - package-lock.json | 5 +- packages/i18n/src/generated-resources.ts | 95 ----- packages/i18n/src/keys.generated.ts | 136 ------- pyproject.toml | 4 +- tests/e2e/test_smoke.py | 220 ---------- tests/integration/conftest.py | 17 +- tests/integration/test_i18n_flow.py | 12 +- tests/integration/test_inertia_views.py | 165 -------- .../integration/test_permission_boundaries.py | 70 ---- tests/integration/test_products_journey.py | 83 ---- tests/integration/test_soft_delete_api.py | 66 --- tests/loadtest/locustfile.py | 63 --- 115 files changed, 455 insertions(+), 7236 deletions(-) delete mode 100644 host/migrations/versions/.gitkeep delete mode 100644 host/migrations/versions/1fe7590fc594_add_settings_tables.py delete mode 100644 host/migrations/versions/2fdcd367b517_initial_schema.py delete mode 100644 host/migrations/versions/53819c12d603_initial_schema.py delete mode 100644 host/migrations/versions/5d08d8587674_add_permissions_role_permission_table.py delete mode 100644 host/migrations/versions/5d44218ee368_create_file_storage_tables.py delete mode 100644 host/migrations/versions/6df7645cc4d2_add_background_tasks_task_execution.py create mode 100644 host/migrations/versions/77162e7b184b_initial_schema.py delete mode 100644 host/migrations/versions/8c12be982a27_create_users_tables.py delete mode 100644 host/migrations/versions/9557a7c2e646_add_permissions_user_permission_table.py delete mode 100644 host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py delete mode 100644 host/migrations/versions/a35930f574d8_index_permissions_role_permission_key.py delete mode 100644 host/migrations/versions/b7e1af4c9d02_add_perf_indexes_and_fix_products.py delete mode 100644 host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py delete mode 100644 host/migrations/versions/dad9a134290f_add_datasets_table.py delete mode 100644 host/migrations/versions/e3ce9754e6dc_seed_users_roles.py delete mode 100644 modules/datasets/LICENSE delete mode 100644 modules/datasets/README.md delete mode 100644 modules/datasets/datasets/__init__.py delete mode 100644 modules/datasets/datasets/constants.py delete mode 100644 modules/datasets/datasets/contracts/__init__.py delete mode 100644 modules/datasets/datasets/contracts/events.py delete mode 100644 modules/datasets/datasets/contracts/files.py delete mode 100644 modules/datasets/datasets/contracts/schemas.py delete mode 100644 modules/datasets/datasets/contracts/urls.py delete mode 100644 modules/datasets/datasets/deps.py delete mode 100644 modules/datasets/datasets/endpoints/__init__.py delete mode 100644 modules/datasets/datasets/endpoints/api.py delete mode 100644 modules/datasets/datasets/endpoints/views.py delete mode 100644 modules/datasets/datasets/extractors.py delete mode 100644 modules/datasets/datasets/locales/en.json delete mode 100644 modules/datasets/datasets/models.py delete mode 100644 modules/datasets/datasets/module.py delete mode 100644 modules/datasets/datasets/pages/Browse.tsx delete mode 100644 modules/datasets/datasets/pages/Create.tsx delete mode 100644 modules/datasets/datasets/pages/Edit.tsx delete mode 100644 modules/datasets/datasets/pages/Show.tsx delete mode 100644 modules/datasets/datasets/py.typed delete mode 100644 modules/datasets/datasets/service.py delete mode 100644 modules/datasets/datasets/services.py delete mode 100644 modules/datasets/datasets/settings.py delete mode 100644 modules/datasets/datasets/tasks.py delete mode 100644 modules/datasets/package.json delete mode 100644 modules/datasets/pyproject.toml delete mode 100644 modules/datasets/tests/conftest.py delete mode 100644 modules/datasets/tests/test_datasets.py delete mode 100644 modules/datasets/tests/test_datasets_views.py delete mode 100644 modules/datasets/tests/test_public_surface.py delete mode 100644 modules/datasets/tests/test_tasks.py delete mode 100644 modules/datasets/tests/test_wiring.py delete mode 100644 modules/datasets/tsconfig.json delete mode 100644 modules/products/LICENSE delete mode 100644 modules/products/README.md delete mode 100644 modules/products/package.json delete mode 100644 modules/products/products/__init__.py delete mode 100644 modules/products/products/constants.py delete mode 100644 modules/products/products/contracts/__init__.py delete mode 100644 modules/products/products/contracts/events.py delete mode 100644 modules/products/products/contracts/schemas.py delete mode 100644 modules/products/products/deps.py delete mode 100644 modules/products/products/endpoints/__init__.py delete mode 100644 modules/products/products/endpoints/api.py delete mode 100644 modules/products/products/endpoints/views.py delete mode 100644 modules/products/products/locales/en.json delete mode 100644 modules/products/products/locales/es.json delete mode 100644 modules/products/products/models.py delete mode 100644 modules/products/products/module.py delete mode 100644 modules/products/products/pages/Browse.tsx delete mode 100644 modules/products/products/pages/Create.tsx delete mode 100644 modules/products/products/pages/Edit.tsx delete mode 100644 modules/products/products/pages/components/ProductsPagination.tsx delete mode 100644 modules/products/products/pages/validation.ts delete mode 100644 modules/products/products/py.typed delete mode 100644 modules/products/products/service.py delete mode 100644 modules/products/pyproject.toml delete mode 100644 modules/products/tests/test_products.py delete mode 100644 modules/products/tsconfig.json delete mode 100644 tests/e2e/test_smoke.py delete mode 100644 tests/integration/test_inertia_views.py delete mode 100644 tests/integration/test_permission_boundaries.py delete mode 100644 tests/integration/test_products_journey.py delete mode 100644 tests/integration/test_soft_delete_api.py delete mode 100644 tests/loadtest/locustfile.py diff --git a/README.md b/README.md index 1214d1f0..ae52a606 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ make migrate make dev ``` -Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard` is the authenticated home, `/products` is a fully-working example module. +Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard` is the authenticated home. ## Create a new module @@ -73,7 +73,7 @@ framework/ core/ # module system, discovery, events, diagnostics db/ # per-module Base, session, mixins, listeners hosting/ # app_builder, middleware, settings, Inertia glue -modules/ # plugin modules (auth, dashboard, products, ...) +modules/ # plugin modules (auth, dashboard, users, settings, ...) host/ main.py # FastAPI entry point routes.py # host-level routes (landing page) diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index f17bdb55..a2de1aef 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -1,22 +1,9 @@ # End-to-End Testing -The repo ships Playwright-driven smoke tests at -[tests/e2e/test_smoke.py](../tests/e2e/test_smoke.py). Four tests drive a real -Chromium browser through the core flows: - -* **`test_login_and_browse_smoke`** — landing → local email+password login → - dashboard → products browse → logout. Minimal regression guard. -* **`test_products_crud_smoke`** — same login + a full create / edit / delete - round-trip against the products module. -* **`test_password_reset_smoke`** — **skipped** (see inline comment in the - test file). `fastapi-users` `reset_password()` validates a password - fingerprint (`password_fgpt`) that is only available server-side. The - full HTTP-layer flow is covered by unit tests in - `modules/users/tests/test_api_auth.py`. -* **`test_admin_invite_smoke`** — admin invites a new user via the UI; the - invitee accepts the invite in a fresh browser context and lands on the - dashboard. Token is minted locally using the dev-default verify secret - (equivalent to what the ConsoleMailer logs). +Playwright-driven smoke tests live in [tests/e2e/](../tests/e2e/) — currently +just [`test_settings_ui.py`](../tests/e2e/test_settings_ui.py), which logs in, +navigates to `/settings/modules`, toggles a module setting, and verifies the +change hot-reloads into `app.state` without a server restart. End-to-end tests are gated behind the `e2e` pytest marker (declared in [pyproject.toml](../pyproject.toml)) and are **excluded from the default @@ -62,7 +49,7 @@ uv run pytest -m e2e tests/e2e ## Configuration -The tests read these environment variables (all optional): +When you write e2e tests, read these environment variables (all optional): | Variable | Default | Notes | | -------------- | ------------------------- | ------------------------------------------------------------ | @@ -71,38 +58,6 @@ The tests read these environment variables (all optional): | `E2E_PASSWORD` | `admin` | Password of the above admin user. | | `SM_USERS_VERIFICATION_TOKEN_SECRET` | `dev-verify-token-secret-change-me` | Must match the running server's value so locally-minted invite tokens are accepted. | -## What the smoke tests cover - -**`test_login_and_browse_smoke`** - -1. Landing page renders (`/`) with the "Get Started" CTA. -2. Local email+password login via `/users/login`. -3. Dashboard (`/dashboard/`) renders — proves session cookie + AuthMiddleware + - Inertia resolver + AuthenticatedLayout. -4. Products browse (`/products/`) renders — proves module pages resolve. -5. Logout returns the user to the public landing page. - -**`test_products_crud_smoke`** - -1. Login as admin. -2. Create a timestamped product via the Create form. -3. Edit its name and verify the new name appears in the list. -4. Delete it through the confirm dialog and verify the row disappears. - -The CRUD test relies on the admin user having the `admin` role (created -automatically by `sm-users create-admin` or the bootstrap env vars). - -**`test_admin_invite_smoke`** - -1. Admin logs in and submits the invite form at `/users/admin/invite`. -2. The test looks up the new user's UUID via the admin API. -3. A verify token is minted locally (same secret the server uses). -4. A fresh browser context navigates to `/users/invite/accept?token=…`, sets - a password, and verifies a redirect to `/dashboard`. - -These are **not** pixel-perfect regression tests — the goal is to catch broad -breakage in the auth + render + CRUD spine. - ## Debugging To see what the browser is doing, run headed with the Playwright trace diff --git a/docs/guide/project-structure.md b/docs/guide/project-structure.md index 9dbbdf6d..a13b9d55 100644 --- a/docs/guide/project-structure.md +++ b/docs/guide/project-structure.md @@ -12,11 +12,9 @@ simple_module_python/ │ ├── auth/ # session cookie, CSRF defences │ ├── background_tasks/ # Celery broker + worker integration │ ├── dashboard/ # authenticated landing page -│ ├── datasets/ # CSV / dataset uploads │ ├── feature_flags/ # admin UI for flag toggles │ ├── file_storage/ # pluggable storage backends (local, S3) │ ├── permissions/ # role/permission admin UI -│ ├── products/ # reference CRUD module (used in examples) │ ├── settings/ # DB-backed module settings + admin UI │ └── users/ # email+password auth, invites, bootstrap │ diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index d9c483ce..9e790cfb 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -29,7 +29,7 @@ The API and Vite dev servers start side by side. Visit: - `http://localhost:8000` — landing page - `http://localhost:8000/users/login` — sign-in screen -- `http://localhost:8000/products` — a fully-working example module (CRUD on a `products` table) +- `http://localhost:8000/dashboard` — the authenticated home (log in first) - `http://localhost:8000/settings/modules` — the admin settings UI (log in first) ## 4. Create an admin diff --git a/docs/release.md b/docs/release.md index e2777c42..56b98cf6 100644 --- a/docs/release.md +++ b/docs/release.md @@ -45,11 +45,9 @@ simple_module_test simple_module_auth simple_module_background_tasks simple_module_dashboard -simple_module_datasets simple_module_feature_flags simple_module_file_storage simple_module_permissions -simple_module_products simple_module_settings simple_module_users ``` @@ -223,11 +221,9 @@ Trusted Publishing is tied to the GitHub repo, not any personal account — so a | PyPI | `simple_module_auth` | [modules/auth/](../modules/auth/) | | PyPI | `simple_module_background_tasks` | [modules/background_tasks/](../modules/background_tasks/) | | PyPI | `simple_module_dashboard` | [modules/dashboard/](../modules/dashboard/) | -| PyPI | `simple_module_datasets` | [modules/datasets/](../modules/datasets/) | | PyPI | `simple_module_feature_flags` | [modules/feature_flags/](../modules/feature_flags/) | | PyPI | `simple_module_file_storage` | [modules/file_storage/](../modules/file_storage/) | | PyPI | `simple_module_permissions` | [modules/permissions/](../modules/permissions/) | -| PyPI | `simple_module_products` | [modules/products/](../modules/products/) — reference CRUD example | | PyPI | `simple_module_settings` | [modules/settings/](../modules/settings/) | | PyPI | `simple_module_users` | [modules/users/](../modules/users/) | | npm | `@simple-module-py/ui` | [packages/ui/](../packages/ui/) | diff --git a/framework/cli/simple_module_cli/catalog.py b/framework/cli/simple_module_cli/catalog.py index a2ef7d9a..43be0ba8 100644 --- a/framework/cli/simple_module_cli/catalog.py +++ b/framework/cli/simple_module_cli/catalog.py @@ -36,12 +36,11 @@ class ModuleEntry: "Permissions", requires=("auth", "users"), ), - "products": ModuleEntry("products", "simple_module_products", "Products"), "dashboard": ModuleEntry( "dashboard", "simple_module_dashboard", "Dashboard", - requires=("users", "products"), + requires=("users",), ), "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), @@ -58,26 +57,13 @@ class ModuleEntry: requires=("users",), recipe="background_tasks", ), - "datasets": ModuleEntry( - "datasets", - "simple_module_datasets", - "Datasets", - requires=("file_storage", "background_tasks"), - ), } -# Example modules — `datasets` and `products` are intentionally excluded -# from every default preset because their module names collide with custom -# modules users typically want to register themselves. Pass them via -# `--with datasets,products` (or pick the `examples` preset) to opt in. -_EXAMPLE_MODULES: frozenset[str] = frozenset({"datasets", "products"}) - PRESETS: dict[str, tuple[str, ...]] = { "minimal": ("users",), "standard": ("users", "dashboard", "permissions"), - "full": tuple(name for name in CATALOG if name not in _EXAMPLE_MODULES), - "examples": tuple(CATALOG), + "full": tuple(CATALOG), } diff --git a/framework/cli/simple_module_cli/cli.py b/framework/cli/simple_module_cli/cli.py index 54272fbb..83d6ebea 100644 --- a/framework/cli/simple_module_cli/cli.py +++ b/framework/cli/simple_module_cli/cli.py @@ -45,7 +45,7 @@ def create_host( str, typer.Option( "--with", - help="Comma-separated module names to declare as deps (e.g. Auth,Products).", + help="Comma-separated module names to declare as deps (e.g. Auth,Dashboard).", ), ] = "", ) -> None: diff --git a/framework/cli/simple_module_cli/new.py b/framework/cli/simple_module_cli/new.py index 0423d533..c9295e92 100644 --- a/framework/cli/simple_module_cli/new.py +++ b/framework/cli/simple_module_cli/new.py @@ -25,7 +25,6 @@ class Preset(StrEnum): minimal = "minimal" standard = "standard" full = "full" - examples = "examples" def new_project( diff --git a/framework/cli/tests/test_cli_catalog.py b/framework/cli/tests/test_cli_catalog.py index dd8c0188..f3eb528f 100644 --- a/framework/cli/tests/test_cli_catalog.py +++ b/framework/cli/tests/test_cli_catalog.py @@ -41,17 +41,10 @@ def test_expand_deps_pulls_in_transitive_dep() -> None: def test_expand_deps_pulls_in_chain() -> None: - resolved, added = expand_deps(["datasets"]) - assert set(resolved) == { - "datasets", - "file_storage", - "settings", - "background_tasks", - "users", - "auth", - } + resolved, added = expand_deps(["permissions"]) + assert set(resolved) == {"permissions", "users", "auth"} added_names = {a for a, _ in added} - assert added_names == {"file_storage", "settings", "background_tasks", "users", "auth"} + assert added_names == {"users", "auth"} def test_expand_deps_idempotent_when_input_already_complete() -> None: diff --git a/framework/cli/tests/test_cli_wizard.py b/framework/cli/tests/test_cli_wizard.py index 41f78099..5da208fd 100644 --- a/framework/cli/tests/test_cli_wizard.py +++ b/framework/cli/tests/test_cli_wizard.py @@ -48,12 +48,11 @@ def test_wizard_minimal_preset() -> None: def test_wizard_full_preset_includes_background_tasks() -> None: _, _, selected, _ = _drive(["", "", "3", ""]) assert "background_tasks" in selected - assert "datasets" not in selected assert len(selected) >= 7 def test_wizard_custom_picks_only_yes_answers() -> None: - answers = ["", "", "4"] + ["n"] * 8 + ["y", "n", ""] + answers = ["", "", "4"] + ["n"] * 7 + ["y", ""] _, _, selected, out = _drive(answers) assert set(selected) == {"background_tasks", "users", "auth"} assert "Added users (required by background_tasks)" in out diff --git a/framework/cli/tests/test_scaffolding_host.py b/framework/cli/tests/test_scaffolding_host.py index c256f70e..60e35150 100644 --- a/framework/cli/tests/test_scaffolding_host.py +++ b/framework/cli/tests/test_scaffolding_host.py @@ -16,8 +16,8 @@ async def test_compute_returns_existing_page_dirs(self): modules = discover_modules() result = compute_module_pages(modules) - # Products + Dashboard ship pages/; Auth is API-only (no frontend pages). - assert {"Products", "Dashboard"}.issubset(result.keys()) + # Dashboard ships pages/; Auth is API-only (no frontend pages). + assert "Dashboard" in result assert "Auth" not in result for name, path in result.items(): assert path.is_dir(), f"{name} -> {path} should exist" @@ -53,12 +53,12 @@ async def test_write_manifest_emits_json_and_ts(self, tmp_path): assert written == {"manifest": manifest, "generated": generated, "css": css} data = json.loads(manifest.read_text(encoding="utf-8")) - assert "Products" in data - assert data["Products"].endswith("pages") or data["Products"].endswith("pages/") + assert "Dashboard" in data + assert data["Dashboard"].endswith("pages") or data["Dashboard"].endswith("pages/") ts = generated.read_text(encoding="utf-8") assert "import.meta.glob" in ts - assert "Products" in ts + assert "Dashboard" in ts assert "AUTO-GENERATED" in ts or "auto-generated" in ts.lower() # Glob patterns must be relative to output_dir — Vite treats # leading-slash paths as project-root-relative and silently matches @@ -78,7 +78,7 @@ async def test_creates_expected_backend_files(self, tmp_path): from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" - create_host(dest, name="demo-host", modules=["Products", "Auth"]) + create_host(dest, name="demo-host", modules=["Dashboard", "Auth"]) for relpath in [ "pyproject.toml", @@ -126,9 +126,9 @@ async def test_declares_selected_module_deps(self, tmp_path): from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" - create_host(dest, name="demo", modules=["Products", "Auth"]) + create_host(dest, name="demo", modules=["Dashboard", "Auth"]) pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") - assert "simple_module_products" in pyproject + assert "simple_module_dashboard" in pyproject assert "simple_module_auth" in pyproject async def test_refuses_existing_non_empty_dir(self, tmp_path): @@ -161,11 +161,11 @@ async def test_cli_create_host_runs_end_to_end(self, tmp_path): runner = CliRunner() result = runner.invoke( app, - ["create-host", "smoke-host", "--dest", str(tmp_path / "out"), "--with", "Products"], + ["create-host", "smoke-host", "--dest", str(tmp_path / "out"), "--with", "Dashboard"], ) assert result.exit_code == 0, result.output assert (tmp_path / "out" / "main.py").is_file() assert (tmp_path / "out" / "pyproject.toml").is_file() - assert "simple_module_products" in (tmp_path / "out" / "pyproject.toml").read_text( + assert "simple_module_dashboard" in (tmp_path / "out" / "pyproject.toml").read_text( encoding="utf-8" ) diff --git a/framework/core/tests/test_discovery.py b/framework/core/tests/test_discovery.py index 30403d1d..8f73c6d9 100644 --- a/framework/core/tests/test_discovery.py +++ b/framework/core/tests/test_discovery.py @@ -115,9 +115,9 @@ async def test_discover_finds_installed_modules(self): """discover_modules() should find modules registered via entry_points.""" modules = discover_modules() names = [m.meta.name for m in modules] - assert "Products" in names assert "Auth" in names assert "Dashboard" in names + assert "Users" in names class TestDiscoverModulesAdvanced: @@ -221,7 +221,7 @@ async def test_discover_with_none_loads_all(self): """Passing enabled=None keeps existing behaviour (load all installed modules).""" all_mods = discover_modules(enabled=None) names = {m.meta.name for m in all_mods} - assert {"Auth", "Products", "Dashboard"}.issubset(names) + assert {"Auth", "Users", "Dashboard"}.issubset(names) async def test_discover_with_allowlist_filters(self): """Passing enabled=['Auth'] loads only Auth, even if other modules are installed.""" @@ -234,9 +234,9 @@ async def test_discover_with_empty_list_loads_none(self): assert discover_modules(enabled=[]) == [] async def test_discover_allowlist_case_insensitive(self): - """Allowlist matching ignores case so 'products' and 'Products' both work.""" - names = [m.meta.name for m in discover_modules(enabled=["products"])] - assert names == ["Products"] + """Allowlist matching ignores case so 'dashboard' and 'Dashboard' both work.""" + names = [m.meta.name for m in discover_modules(enabled=["dashboard"])] + assert names == ["Dashboard"] async def test_discover_unknown_name_logged_and_ignored(self, caplog): """Names in enabled that don't match any installed module log a warning but don't raise.""" diff --git a/framework/db/tests/test_db_logging.py b/framework/db/tests/test_db_logging.py index 901e9098..ad1a4916 100644 --- a/framework/db/tests/test_db_logging.py +++ b/framework/db/tests/test_db_logging.py @@ -4,12 +4,10 @@ import contextlib import logging -from decimal import Decimal from unittest.mock import MagicMock from _models import _TenantBase, _TenantItem from simple_module_db.deps import get_db -from sqlalchemy.ext.asyncio import AsyncSession async def _drive_get_db(db_state, populate=None): @@ -82,47 +80,3 @@ async def test_read_only_skips_commit(self, db_state, caplog): read_only = [r for r in records if r.message == "db.session.read_only"] assert len(read_only) == 1 assert read_only[0].operation == "read_only_rollback" # type: ignore[attr-defined] - - -class TestEntityListenerLogging: - async def test_create_logs_entity_created(self, db_session: AsyncSession, caplog): - """Inserting a new entity should log db.entity.created.""" - from products.models import Product - - with caplog.at_level(logging.INFO, logger="simple_module.db"): - product = Product(name="Widget", price=Decimal("9.99")) - db_session.add(product) - await db_session.flush() - - created_msgs = [ - r - for r in caplog.records - if r.name == "simple_module.db" and r.message == "db.entity.created" - ] - assert len(created_msgs) == 1 - assert created_msgs[0].entity == "Product" # type: ignore[attr-defined] - assert created_msgs[0].operation == "create" # type: ignore[attr-defined] - - async def test_update_logs_entity_updated(self, db_session: AsyncSession, caplog): - """Modifying an entity should log db.entity.updated.""" - from products.models import Product - - product = Product(name="Widget", price=Decimal("9.99")) - db_session.add(product) - await db_session.flush() - - caplog.clear() - - product.name = "Updated Widget" - with caplog.at_level(logging.INFO, logger="simple_module.db"): - await db_session.flush() - - updated_msgs = [ - r - for r in caplog.records - if r.name == "simple_module.db" and r.message == "db.entity.updated" - ] - assert len(updated_msgs) == 1 - assert updated_msgs[0].entity == "Product" # type: ignore[attr-defined] - assert updated_msgs[0].operation == "update" # type: ignore[attr-defined] - assert updated_msgs[0].entity_id is not None # type: ignore[attr-defined] diff --git a/framework/db/tests/test_migrations.py b/framework/db/tests/test_migrations.py index 1295fdc0..d06753bf 100644 --- a/framework/db/tests/test_migrations.py +++ b/framework/db/tests/test_migrations.py @@ -17,10 +17,10 @@ async def test_combined_metadata_includes_installed_module_tables(self): metadata = build_module_metadata() table_names = set(metadata.tables.keys()) - # Products ships models and must contribute at least one table. + # Users ships models and must contribute at least one table. # (Dashboard is event-driven with no models; Auth's tables are # currently not part of this workspace's ORM surface.) - assert any("product" in name.lower() for name in table_names) + assert any("user" in name.lower() for name in table_names) assert len(table_names) >= 1 async def test_combined_metadata_only_returns_module_tables(self): diff --git a/framework/hosting/tests/test_app.py b/framework/hosting/tests/test_app.py index 33fe57cc..f9867e99 100644 --- a/framework/hosting/tests/test_app.py +++ b/framework/hosting/tests/test_app.py @@ -2,8 +2,6 @@ from __future__ import annotations -from collections import defaultdict - import httpx import pytest from fastapi import FastAPI @@ -29,12 +27,11 @@ async def test_app_state_has_registries(self, app: FastAPI): async def test_modules_enabled_limits_loaded_modules(self, settings: Settings): """Host respects settings.modules_enabled — only listed modules contribute routes.""" - # Only Auth should be loaded; Products + Dashboard routes must be absent. + # Only Auth should be loaded; Dashboard routes must be absent. restricted = settings.model_copy(update={"modules_enabled": ["Auth"]}) app = create_app(restricted) paths: set[str] = {str(r.path) for r in app.routes if hasattr(r, "path")} # Auth is now contracts-only, so it has no routes — only health remains. - assert not any(p.startswith("/api/products") for p in paths) assert "/dashboard" not in paths async def test_module_static_mounts_become_app_routes( @@ -117,9 +114,6 @@ async def test_expected_routes_registered(self, app: FastAPI): assert "/health/live" in route_paths assert "/health/ready" in route_paths - assert "/api/products/" in route_paths - assert "/api/products/{product_id}" in route_paths - # Users module owns login, register, etc. Auth module is contracts-only. assert "/users/login" in route_paths @@ -130,19 +124,6 @@ async def test_expected_routes_registered(self, app: FastAPI): # Bare-prefix alias — see wire_module_routes for the X-Inertia rationale. assert "/dashboard" in route_paths - async def test_products_api_methods(self, app: FastAPI): - """Products endpoints should support the correct HTTP methods.""" - routes_by_path: dict[str, set[str]] = defaultdict(set) - for route in app.routes: - if hasattr(route, "path") and hasattr(route, "methods"): - routes_by_path[route.path].update(route.methods) - - assert "GET" in routes_by_path.get("/api/products/", set()) - assert "POST" in routes_by_path.get("/api/products/", set()) - assert "GET" in routes_by_path.get("/api/products/{product_id}", set()) - assert "PUT" in routes_by_path.get("/api/products/{product_id}", set()) - assert "DELETE" in routes_by_path.get("/api/products/{product_id}", set()) - class TestProtectedPages: async def test_dashboard_redirects_unauthenticated(self, client: httpx.AsyncClient): @@ -150,11 +131,6 @@ async def test_dashboard_redirects_unauthenticated(self, client: httpx.AsyncClie assert resp.status_code == 302 assert "/users/login" in resp.headers["location"] - async def test_products_page_redirects_unauthenticated(self, client: httpx.AsyncClient): - resp = await client.get("/products/", follow_redirects=False) - assert resp.status_code == 302 - assert "/users/login" in resp.headers["location"] - class TestSecurityHeaders: async def test_security_headers_present(self, client: httpx.AsyncClient): diff --git a/host/migrations/versions/.gitkeep b/host/migrations/versions/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/host/migrations/versions/1fe7590fc594_add_settings_tables.py b/host/migrations/versions/1fe7590fc594_add_settings_tables.py deleted file mode 100644 index a0502504..00000000 --- a/host/migrations/versions/1fe7590fc594_add_settings_tables.py +++ /dev/null @@ -1,81 +0,0 @@ -"""add settings tables - -Revision ID: 1fe7590fc594 -Revises: b7e1af4c9d02 -Create Date: 2026-04-19 07:46:59.090748 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "1fe7590fc594" -down_revision: str | None = "b7e1af4c9d02" -branch_labels: str | Sequence[str] | None = ("settings",) -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # On PostgreSQL, create the `settings` schema before creating tables. - if op.get_context().dialect.name == "postgresql": - op.execute("CREATE SCHEMA IF NOT EXISTS settings") - - op.create_table( - "settings_setting", - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_by", sa.String(length=255), nullable=True), - sa.Column("updated_by", sa.String(length=255), nullable=True), - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "scope", - sa.String(length=10), - nullable=False, - server_default=sa.text("'system'"), - ), - sa.Column( - "scope_id", - sa.String(length=255), - nullable=False, - server_default=sa.text("''"), - ), - sa.Column("key", sa.String(length=200), nullable=False), - sa.Column("value", sa.String(length=4000), nullable=False), - sa.Column( - "value_type", - sa.String(length=10), - nullable=False, - server_default=sa.text("'string'"), - ), - sa.Column("description", sa.String(length=2000), nullable=True), - sa.PrimaryKeyConstraint("id", name=op.f("pk_settings_setting")), - sa.UniqueConstraint( - "scope", "scope_id", "key", name="uq_settings_setting_scope_scope_id_key" - ), - ) - op.create_index(op.f("ix_settings_setting_scope"), "settings_setting", ["scope"], unique=False) - op.create_index( - op.f("ix_settings_setting_scope_id"), - "settings_setting", - ["scope_id"], - unique=False, - ) - op.create_index(op.f("ix_settings_setting_key"), "settings_setting", ["key"], unique=False) - - -def downgrade() -> None: - op.drop_index(op.f("ix_settings_setting_key"), table_name="settings_setting") - op.drop_index(op.f("ix_settings_setting_scope_id"), table_name="settings_setting") - op.drop_index(op.f("ix_settings_setting_scope"), table_name="settings_setting") - op.drop_table("settings_setting") - - # On PostgreSQL, drop the `settings` schema. - if op.get_context().dialect.name == "postgresql": - op.execute("DROP SCHEMA IF EXISTS settings") diff --git a/host/migrations/versions/2fdcd367b517_initial_schema.py b/host/migrations/versions/2fdcd367b517_initial_schema.py deleted file mode 100644 index c874e748..00000000 --- a/host/migrations/versions/2fdcd367b517_initial_schema.py +++ /dev/null @@ -1,33 +0,0 @@ -"""initial schema - -Revision ID: 2fdcd367b517 -Revises: 53819c12d603 -Create Date: 2026-04-15 12:06:38.726433 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "2fdcd367b517" -down_revision: str | None = "53819c12d603" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column("products_product", sa.Column("is_deleted", sa.Boolean(), nullable=False)) - op.add_column("products_product", sa.Column("deleted_at", sa.DateTime(), nullable=True)) - op.add_column("products_product", sa.Column("deleted_by", sa.String(length=255), nullable=True)) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column("products_product", "deleted_by") - op.drop_column("products_product", "deleted_at") - op.drop_column("products_product", "is_deleted") - # ### end Alembic commands ### diff --git a/host/migrations/versions/53819c12d603_initial_schema.py b/host/migrations/versions/53819c12d603_initial_schema.py deleted file mode 100644 index fa0dda4d..00000000 --- a/host/migrations/versions/53819c12d603_initial_schema.py +++ /dev/null @@ -1,46 +0,0 @@ -"""initial schema - -Revision ID: 53819c12d603 -Revises: -Create Date: 2026-04-13 20:30:12.888075 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "53819c12d603" -down_revision: str | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "products_product", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("name", sa.String(length=200), nullable=False), - sa.Column("description", sa.String(length=2000), nullable=True), - sa.Column("price", sa.Numeric(precision=10, scale=2), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.Column("created_by", sa.String(length=255), nullable=True), - sa.Column("updated_by", sa.String(length=255), nullable=True), - sa.PrimaryKeyConstraint("id", name=op.f("pk_products_product")), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("products_product") - # ### end Alembic commands ### diff --git a/host/migrations/versions/5d08d8587674_add_permissions_role_permission_table.py b/host/migrations/versions/5d08d8587674_add_permissions_role_permission_table.py deleted file mode 100644 index fdf16540..00000000 --- a/host/migrations/versions/5d08d8587674_add_permissions_role_permission_table.py +++ /dev/null @@ -1,54 +0,0 @@ -"""add permissions role permission table - -Revision ID: 5d08d8587674 -Revises: b7e1af4c9d02 -Create Date: 2026-04-19 07:58:55.443793 -""" - -from __future__ import annotations - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "5d08d8587674" -down_revision: str | None = "b7e1af4c9d02" -branch_labels: str | Sequence[str] | None = ("permissions",) -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # On PostgreSQL, create the `permissions` schema before creating tables. - if op.get_context().dialect.name == "postgresql": - op.execute("CREATE SCHEMA IF NOT EXISTS permissions") - - op.create_table( - "permissions_role_permission", - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_by", sa.String(length=255), nullable=True), - sa.Column("updated_by", sa.String(length=255), nullable=True), - sa.Column("role_name", sa.String(length=64), nullable=False), - sa.Column("permission_key", sa.String(length=128), nullable=False), - sa.Column("assigned_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("assigned_by", sa.String(length=255), nullable=True), - sa.PrimaryKeyConstraint( - "role_name", - "permission_key", - name=op.f("pk_permissions_role_permission"), - ), - ) - - -def downgrade() -> None: - op.drop_table("permissions_role_permission") - - if op.get_context().dialect.name == "postgresql": - op.execute("DROP SCHEMA IF EXISTS permissions") diff --git a/host/migrations/versions/5d44218ee368_create_file_storage_tables.py b/host/migrations/versions/5d44218ee368_create_file_storage_tables.py deleted file mode 100644 index cff58dc1..00000000 --- a/host/migrations/versions/5d44218ee368_create_file_storage_tables.py +++ /dev/null @@ -1,77 +0,0 @@ -"""create file_storage tables - -Revision ID: 5d44218ee368 -Revises: b7e1af4c9d02 -Create Date: 2026-04-19 11:20:24.247357 -""" - -from __future__ import annotations - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "5d44218ee368" -down_revision: str | None = "b7e1af4c9d02" -branch_labels: str | Sequence[str] | None = ("file_storage",) -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - if op.get_context().dialect.name == "postgresql": - op.execute("CREATE SCHEMA IF NOT EXISTS file_storage") - - op.create_table( - "file_storage_stored_file", - sa.Column("is_deleted", sa.Boolean(), nullable=False), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("deleted_by", sa.String(length=255), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_by", sa.String(length=255), nullable=True), - sa.Column("updated_by", sa.String(length=255), nullable=True), - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("key", sa.String(length=512), nullable=False), - sa.Column("filename", sa.String(length=255), nullable=False), - sa.Column("content_type", sa.String(length=128), nullable=False), - sa.Column("size_bytes", sa.Integer(), nullable=False), - sa.Column("backend", sa.String(length=32), nullable=False), - sa.Column("checksum_sha256", sa.String(length=64), nullable=False), - sa.Column("extra_metadata", sa.JSON(), nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_file_storage_stored_file")), - ) - op.create_index( - "ix_file_storage_stored_file_created_by", - "file_storage_stored_file", - ["created_by"], - unique=False, - ) - op.create_index( - "ix_file_storage_stored_file_is_deleted", - "file_storage_stored_file", - ["is_deleted"], - unique=False, - ) - op.create_index( - "ix_file_storage_stored_file_key", - "file_storage_stored_file", - ["key"], - unique=True, - ) - - -def downgrade() -> None: - op.drop_index("ix_file_storage_stored_file_key", table_name="file_storage_stored_file") - op.drop_index("ix_file_storage_stored_file_is_deleted", table_name="file_storage_stored_file") - op.drop_index("ix_file_storage_stored_file_created_by", table_name="file_storage_stored_file") - op.drop_table("file_storage_stored_file") - - if op.get_context().dialect.name == "postgresql": - op.execute("DROP SCHEMA IF EXISTS file_storage") diff --git a/host/migrations/versions/6df7645cc4d2_add_background_tasks_task_execution.py b/host/migrations/versions/6df7645cc4d2_add_background_tasks_task_execution.py deleted file mode 100644 index 920c9d68..00000000 --- a/host/migrations/versions/6df7645cc4d2_add_background_tasks_task_execution.py +++ /dev/null @@ -1,115 +0,0 @@ -"""add background_tasks_task_execution - -Revision ID: 6df7645cc4d2 -Revises: b7e1af4c9d02 -Create Date: 2026-04-19 07:44:06.824688 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "6df7645cc4d2" -down_revision: str | None = "b7e1af4c9d02" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "background_tasks_task_execution", - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_by", sa.String(length=255), nullable=True), - sa.Column("updated_by", sa.String(length=255), nullable=True), - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("celery_task_id", sa.String(length=64), nullable=True), - sa.Column("task_name", sa.String(length=255), nullable=False), - sa.Column("status", sa.String(length=20), nullable=False), - sa.Column("queue", sa.String(length=64), nullable=False), - sa.Column("args", sa.JSON(), nullable=True), - sa.Column("kwargs", sa.JSON(), nullable=True), - sa.Column("result", sa.JSON(), nullable=True), - sa.Column("traceback", sa.String(), nullable=True), - sa.Column("exception_type", sa.String(length=255), nullable=True), - sa.Column("worker", sa.String(length=255), nullable=True), - sa.Column("retries", sa.Integer(), nullable=False), - sa.Column("retried_from_id", sa.Uuid(), nullable=True), - sa.Column("queued_at", sa.DateTime(), nullable=True), - sa.Column("started_at", sa.DateTime(), nullable=True), - sa.Column("finished_at", sa.DateTime(), nullable=True), - sa.Column("heartbeat_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["retried_from_id"], - ["background_tasks_task_execution.id"], - name=op.f( - "fk_background_tasks_task_execution_retried_from_id_background_tasks_task_execution" - ), - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_background_tasks_task_execution")), - ) - op.create_index( - op.f("ix_background_tasks_task_execution_celery_task_id"), - "background_tasks_task_execution", - ["celery_task_id"], - unique=False, - ) - op.create_index( - op.f("ix_background_tasks_task_execution_retried_from_id"), - "background_tasks_task_execution", - ["retried_from_id"], - unique=False, - ) - op.create_index( - op.f("ix_background_tasks_task_execution_status"), - "background_tasks_task_execution", - ["status"], - unique=False, - ) - op.create_index( - "ix_background_tasks_task_execution_status_queued", - "background_tasks_task_execution", - ["status", "queued_at"], - unique=False, - ) - op.create_index( - op.f("ix_background_tasks_task_execution_task_name"), - "background_tasks_task_execution", - ["task_name"], - unique=False, - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index( - op.f("ix_background_tasks_task_execution_task_name"), - table_name="background_tasks_task_execution", - ) - op.drop_index( - "ix_background_tasks_task_execution_status_queued", - table_name="background_tasks_task_execution", - ) - op.drop_index( - op.f("ix_background_tasks_task_execution_status"), - table_name="background_tasks_task_execution", - ) - op.drop_index( - op.f("ix_background_tasks_task_execution_retried_from_id"), - table_name="background_tasks_task_execution", - ) - op.drop_index( - op.f("ix_background_tasks_task_execution_celery_task_id"), - table_name="background_tasks_task_execution", - ) - op.drop_table("background_tasks_task_execution") - # ### end Alembic commands ### diff --git a/host/migrations/versions/77162e7b184b_initial_schema.py b/host/migrations/versions/77162e7b184b_initial_schema.py new file mode 100644 index 00000000..a705c2e8 --- /dev/null +++ b/host/migrations/versions/77162e7b184b_initial_schema.py @@ -0,0 +1,382 @@ +"""initial schema + +Revision ID: 77162e7b184b +Revises: +Create Date: 2026-04-30 23:59:42.399469 +""" + +from collections.abc import Sequence + +import fastapi_users_db_sqlalchemy.generics +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "77162e7b184b" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "background_tasks_task_execution", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("celery_task_id", sa.String(length=64), nullable=True), + sa.Column("task_name", sa.String(length=255), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("queue", sa.String(length=64), nullable=False), + sa.Column("args", sa.JSON(), nullable=True), + sa.Column("kwargs", sa.JSON(), nullable=True), + sa.Column("result", sa.JSON(), nullable=True), + sa.Column("traceback", sa.String(), nullable=True), + sa.Column("exception_type", sa.String(length=255), nullable=True), + sa.Column("worker", sa.String(length=255), nullable=True), + sa.Column("retries", sa.Integer(), nullable=False), + sa.Column("retried_from_id", sa.Uuid(), nullable=True), + sa.Column("queued_at", sa.DateTime(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.Column("heartbeat_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["retried_from_id"], + ["background_tasks_task_execution.id"], + name=op.f( + "fk_background_tasks_task_execution_retried_from_id_background_tasks_task_execution" + ), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_background_tasks_task_execution")), + ) + op.create_index( + op.f("ix_background_tasks_task_execution_celery_task_id"), + "background_tasks_task_execution", + ["celery_task_id"], + unique=False, + ) + op.create_index( + op.f("ix_background_tasks_task_execution_retried_from_id"), + "background_tasks_task_execution", + ["retried_from_id"], + unique=False, + ) + op.create_index( + op.f("ix_background_tasks_task_execution_status"), + "background_tasks_task_execution", + ["status"], + unique=False, + ) + op.create_index( + "ix_background_tasks_task_execution_status_queued", + "background_tasks_task_execution", + ["status", "queued_at"], + unique=False, + ) + op.create_index( + op.f("ix_background_tasks_task_execution_task_name"), + "background_tasks_task_execution", + ["task_name"], + unique=False, + ) + op.create_table( + "feature_flags_override", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("scope", sa.String(length=10), nullable=False), + sa.Column("scope_id", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_feature_flags_override")), + sa.UniqueConstraint( + "scope", "scope_id", "name", name="uq_feature_flags_override_scope_scope_id_name" + ), + ) + op.create_index( + op.f("ix_feature_flags_override_name"), "feature_flags_override", ["name"], unique=False + ) + op.create_index( + op.f("ix_feature_flags_override_scope"), "feature_flags_override", ["scope"], unique=False + ) + op.create_index( + op.f("ix_feature_flags_override_scope_id"), + "feature_flags_override", + ["scope_id"], + unique=False, + ) + op.create_table( + "file_storage_stored_file", + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_by", sa.String(length=255), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("key", sa.String(length=512), nullable=False), + sa.Column("filename", sa.String(length=255), nullable=False), + sa.Column("content_type", sa.String(length=128), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("backend", sa.String(length=32), nullable=False), + sa.Column("checksum_sha256", sa.String(length=64), nullable=False), + sa.Column("extra_metadata", sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_file_storage_stored_file")), + ) + op.create_index( + "ix_file_storage_stored_file_created_by", + "file_storage_stored_file", + ["created_by"], + unique=False, + ) + op.create_index( + "ix_file_storage_stored_file_is_deleted", + "file_storage_stored_file", + ["is_deleted"], + unique=False, + ) + op.create_index( + "ix_file_storage_stored_file_key", "file_storage_stored_file", ["key"], unique=True + ) + op.create_table( + "permissions_role_permission", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("role_name", sa.String(length=64), nullable=False), + sa.Column("permission_key", sa.String(length=128), nullable=False), + sa.Column("assigned_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("assigned_by", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint( + "role_name", "permission_key", name=op.f("pk_permissions_role_permission") + ), + ) + op.create_index( + "ix_permissions_role_permission_key", + "permissions_role_permission", + ["permission_key"], + unique=False, + ) + op.create_table( + "permissions_user_permission", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("user_id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False), + sa.Column("permission_key", sa.String(length=128), nullable=False), + sa.Column("assigned_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("assigned_by", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint( + "user_id", "permission_key", name=op.f("pk_permissions_user_permission") + ), + ) + op.create_index( + "ix_permissions_user_permission_key", + "permissions_user_permission", + ["permission_key"], + unique=False, + ) + op.create_table( + "settings_setting", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("scope", sa.String(length=10), nullable=False), + sa.Column("scope_id", sa.String(length=255), nullable=False), + sa.Column("key", sa.String(length=200), nullable=False), + sa.Column("value", sa.String(length=4000), nullable=False), + sa.Column("value_type", sa.String(length=10), nullable=False), + sa.Column("description", sa.String(length=2000), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_settings_setting")), + sa.UniqueConstraint( + "scope", "scope_id", "key", name="uq_settings_setting_scope_scope_id_key" + ), + ) + op.create_index(op.f("ix_settings_setting_key"), "settings_setting", ["key"], unique=False) + op.create_index(op.f("ix_settings_setting_scope"), "settings_setting", ["scope"], unique=False) + op.create_index( + op.f("ix_settings_setting_scope_id"), "settings_setting", ["scope_id"], unique=False + ) + op.create_table( + "users_role", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False), + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_users_role")), + ) + op.create_index(op.f("ix_users_role_name"), "users_role", ["name"], unique=True) + op.create_table( + "users_user", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("hashed_password", sa.String(length=1024), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_superuser", sa.Boolean(), nullable=False), + sa.Column("is_verified", sa.Boolean(), nullable=False), + sa.Column("full_name", sa.String(length=255), nullable=True), + sa.Column("tenant_id", sa.String(length=50), nullable=True), + sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_users_user")), + ) + op.create_index(op.f("ix_users_user_email"), "users_user", ["email"], unique=True) + op.create_index( + op.f("ix_users_user_last_login_at"), "users_user", ["last_login_at"], unique=False + ) + op.create_index(op.f("ix_users_user_tenant_id"), "users_user", ["tenant_id"], unique=False) + op.create_table( + "users_access_token", + sa.Column("token", sa.String(length=43), nullable=False), + sa.Column( + "created_at", + fastapi_users_db_sqlalchemy.generics.TIMESTAMPAware(timezone=True), + nullable=False, + ), + sa.Column("user_id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users_user.id"], + name=op.f("fk_users_access_token_user_id_users_user"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("token", name=op.f("pk_users_access_token")), + ) + op.create_index( + op.f("ix_users_access_token_created_at"), "users_access_token", ["created_at"], unique=False + ) + op.create_index( + op.f("ix_users_access_token_user_id"), "users_access_token", ["user_id"], unique=False + ) + op.create_table( + "users_user_role", + sa.Column("user_id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False), + sa.Column("role_id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False), + sa.Column("assigned_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("assigned_by", sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint( + ["role_id"], + ["users_role.id"], + name=op.f("fk_users_user_role_role_id_users_role"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users_user.id"], + name=op.f("fk_users_user_role_user_id_users_user"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("user_id", "role_id", name=op.f("pk_users_user_role")), + ) + op.create_index("ix_users_user_role_role_id", "users_user_role", ["role_id"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_users_user_role_role_id", table_name="users_user_role") + op.drop_table("users_user_role") + op.drop_index(op.f("ix_users_access_token_user_id"), table_name="users_access_token") + op.drop_index(op.f("ix_users_access_token_created_at"), table_name="users_access_token") + op.drop_table("users_access_token") + op.drop_index(op.f("ix_users_user_tenant_id"), table_name="users_user") + op.drop_index(op.f("ix_users_user_last_login_at"), table_name="users_user") + op.drop_index(op.f("ix_users_user_email"), table_name="users_user") + op.drop_table("users_user") + op.drop_index(op.f("ix_users_role_name"), table_name="users_role") + op.drop_table("users_role") + op.drop_index(op.f("ix_settings_setting_scope_id"), table_name="settings_setting") + op.drop_index(op.f("ix_settings_setting_scope"), table_name="settings_setting") + op.drop_index(op.f("ix_settings_setting_key"), table_name="settings_setting") + op.drop_table("settings_setting") + op.drop_index("ix_permissions_user_permission_key", table_name="permissions_user_permission") + op.drop_table("permissions_user_permission") + op.drop_index("ix_permissions_role_permission_key", table_name="permissions_role_permission") + op.drop_table("permissions_role_permission") + op.drop_index("ix_file_storage_stored_file_key", table_name="file_storage_stored_file") + op.drop_index("ix_file_storage_stored_file_is_deleted", table_name="file_storage_stored_file") + op.drop_index("ix_file_storage_stored_file_created_by", table_name="file_storage_stored_file") + op.drop_table("file_storage_stored_file") + op.drop_index(op.f("ix_feature_flags_override_scope_id"), table_name="feature_flags_override") + op.drop_index(op.f("ix_feature_flags_override_scope"), table_name="feature_flags_override") + op.drop_index(op.f("ix_feature_flags_override_name"), table_name="feature_flags_override") + op.drop_table("feature_flags_override") + op.drop_index( + op.f("ix_background_tasks_task_execution_task_name"), + table_name="background_tasks_task_execution", + ) + op.drop_index( + "ix_background_tasks_task_execution_status_queued", + table_name="background_tasks_task_execution", + ) + op.drop_index( + op.f("ix_background_tasks_task_execution_status"), + table_name="background_tasks_task_execution", + ) + op.drop_index( + op.f("ix_background_tasks_task_execution_retried_from_id"), + table_name="background_tasks_task_execution", + ) + op.drop_index( + op.f("ix_background_tasks_task_execution_celery_task_id"), + table_name="background_tasks_task_execution", + ) + op.drop_table("background_tasks_task_execution") + # ### end Alembic commands ### diff --git a/host/migrations/versions/8c12be982a27_create_users_tables.py b/host/migrations/versions/8c12be982a27_create_users_tables.py deleted file mode 100644 index 38dc4921..00000000 --- a/host/migrations/versions/8c12be982a27_create_users_tables.py +++ /dev/null @@ -1,131 +0,0 @@ -"""create users tables - -Revision ID: 8c12be982a27 -Revises: 2fdcd367b517 -Create Date: 2026-04-15 18:02:20.074558 -""" - -from __future__ import annotations - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from fastapi_users_db_sqlalchemy.generics import GUID, TIMESTAMPAware - -# revision identifiers, used by Alembic. -revision: str = "8c12be982a27" -down_revision: str | None = "2fdcd367b517" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # On PostgreSQL, create the `users` schema before creating tables. - if op.get_context().dialect.name == "postgresql": - op.execute("CREATE SCHEMA IF NOT EXISTS users") - - op.create_table( - "users_role", - sa.Column("id", GUID(), nullable=False), - sa.Column("name", sa.String(length=64), nullable=False), - sa.Column("description", sa.String(length=255), nullable=True), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.Column("created_by", sa.String(length=255), nullable=True), - sa.Column("updated_by", sa.String(length=255), nullable=True), - sa.PrimaryKeyConstraint("id", name=op.f("pk_users_role")), - ) - op.create_index(op.f("ix_users_role_name"), "users_role", ["name"], unique=True) - - op.create_table( - "users_user", - sa.Column("full_name", sa.String(length=255), nullable=True), - sa.Column("tenant_id", sa.String(length=50), nullable=True), - sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("id", GUID(), nullable=False), - sa.Column("email", sa.String(length=320), nullable=False), - sa.Column("hashed_password", sa.String(length=1024), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("is_superuser", sa.Boolean(), nullable=False), - sa.Column("is_verified", sa.Boolean(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.Column("created_by", sa.String(length=255), nullable=True), - sa.Column("updated_by", sa.String(length=255), nullable=True), - sa.PrimaryKeyConstraint("id", name=op.f("pk_users_user")), - ) - op.create_index(op.f("ix_users_user_email"), "users_user", ["email"], unique=True) - op.create_index(op.f("ix_users_user_tenant_id"), "users_user", ["tenant_id"], unique=False) - - op.create_table( - "users_access_token", - sa.Column("user_id", GUID(), nullable=False), - sa.Column("token", sa.String(length=43), nullable=False), - sa.Column("created_at", TIMESTAMPAware(timezone=True), nullable=False), - sa.ForeignKeyConstraint( - ["user_id"], - ["users_user.id"], - name=op.f("fk_users_access_token_user_id_users_user"), - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("token", name=op.f("pk_users_access_token")), - ) - op.create_index( - op.f("ix_users_access_token_created_at"), - "users_access_token", - ["created_at"], - unique=False, - ) - - op.create_table( - "users_user_role", - sa.Column("user_id", GUID(), nullable=False), - sa.Column("role_id", GUID(), nullable=False), - sa.Column( - "assigned_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("assigned_by", sa.String(length=255), nullable=True), - sa.ForeignKeyConstraint( - ["role_id"], - ["users_role.id"], - name=op.f("fk_users_user_role_role_id_users_role"), - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users_user.id"], - name=op.f("fk_users_user_role_user_id_users_user"), - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("user_id", "role_id", name=op.f("pk_users_user_role")), - ) - - -def downgrade() -> None: - op.drop_table("users_user_role") - op.drop_index(op.f("ix_users_access_token_created_at"), table_name="users_access_token") - op.drop_table("users_access_token") - op.drop_index(op.f("ix_users_user_tenant_id"), table_name="users_user") - op.drop_index(op.f("ix_users_user_email"), table_name="users_user") - op.drop_table("users_user") - op.drop_index(op.f("ix_users_role_name"), table_name="users_role") - op.drop_table("users_role") - - # On PostgreSQL, drop the `users` schema. - if op.get_context().dialect.name == "postgresql": - op.execute("DROP SCHEMA IF EXISTS users") diff --git a/host/migrations/versions/9557a7c2e646_add_permissions_user_permission_table.py b/host/migrations/versions/9557a7c2e646_add_permissions_user_permission_table.py deleted file mode 100644 index b9b29111..00000000 --- a/host/migrations/versions/9557a7c2e646_add_permissions_user_permission_table.py +++ /dev/null @@ -1,58 +0,0 @@ -"""add permissions user permission table - -Revision ID: 9557a7c2e646 -Revises: 5d08d8587674 -Create Date: 2026-04-19 09:43:57.944179 -""" - -from __future__ import annotations - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from fastapi_users_db_sqlalchemy.generics import GUID - -# revision identifiers, used by Alembic. -revision: str = "9557a7c2e646" -down_revision: str | None = "5d08d8587674" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - op.create_table( - "permissions_user_permission", - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_by", sa.String(length=255), nullable=True), - sa.Column("updated_by", sa.String(length=255), nullable=True), - sa.Column("user_id", GUID(), nullable=False), - sa.Column("permission_key", sa.String(length=128), nullable=False), - sa.Column("assigned_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("assigned_by", sa.String(length=255), nullable=True), - sa.PrimaryKeyConstraint( - "user_id", - "permission_key", - name=op.f("pk_permissions_user_permission"), - ), - ) - op.create_index( - "ix_permissions_user_permission_key", - "permissions_user_permission", - ["permission_key"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index( - "ix_permissions_user_permission_key", - table_name="permissions_user_permission", - ) - op.drop_table("permissions_user_permission") diff --git a/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py b/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py deleted file mode 100644 index 24bc54c9..00000000 --- a/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py +++ /dev/null @@ -1,34 +0,0 @@ -"""add indexes for dashboard queries - -Revision ID: a01185374312 -Revises: e3ce9754e6dc -Create Date: 2026-04-15 23:53:08.569085 -""" - -from collections.abc import Sequence - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "a01185374312" -down_revision: str | None = "e3ce9754e6dc" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_index( - op.f("ix_products_product_is_active"), "products_product", ["is_active"], unique=False - ) - op.create_index( - op.f("ix_users_user_last_login_at"), "users_user", ["last_login_at"], unique=False - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f("ix_users_user_last_login_at"), table_name="users_user") - op.drop_index(op.f("ix_products_product_is_active"), table_name="products_product") - # ### end Alembic commands ### diff --git a/host/migrations/versions/a35930f574d8_index_permissions_role_permission_key.py b/host/migrations/versions/a35930f574d8_index_permissions_role_permission_key.py deleted file mode 100644 index ac162f5f..00000000 --- a/host/migrations/versions/a35930f574d8_index_permissions_role_permission_key.py +++ /dev/null @@ -1,33 +0,0 @@ -"""index permissions role permission key - -Revision ID: a35930f574d8 -Revises: 9557a7c2e646 -Create Date: 2026-04-19 11:52:35.013862 -""" - -from __future__ import annotations - -from collections.abc import Sequence - -from alembic import op - -revision: str = "a35930f574d8" -down_revision: str | None = "9557a7c2e646" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - op.create_index( - "ix_permissions_role_permission_key", - "permissions_role_permission", - ["permission_key"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index( - "ix_permissions_role_permission_key", - table_name="permissions_role_permission", - ) diff --git a/host/migrations/versions/b7e1af4c9d02_add_perf_indexes_and_fix_products.py b/host/migrations/versions/b7e1af4c9d02_add_perf_indexes_and_fix_products.py deleted file mode 100644 index 32a2ff8c..00000000 --- a/host/migrations/versions/b7e1af4c9d02_add_perf_indexes_and_fix_products.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Add perf indexes and drop low-cardinality boolean index. - -Creates: - * ``ix_users_user_email_lower`` — functional index on ``lower(email)`` so the - fastapi-users ``get_by_email`` query (which wraps email in ``lower()``) - can use an index instead of a seq-scan. - * ``ix_users_access_token_user_id`` — Postgres does not auto-index foreign - keys; this covers reverse lookups (e.g. revoking a user's sessions). - * ``ix_users_user_role_role_id`` — same reasoning for "who has role X?" - queries. The composite PK already covers ``user_id``-first lookups. - * ``ix_products_product_deleted`` — supports soft-delete filtering in the - product listing query. - -Drops: - * ``ix_products_product_is_active`` — a plain B-tree over a 2-value boolean - is almost never preferred by the planner over a seq-scan, yet it costs - writes on every insert/update. Replaced implicitly by the combined - filtering the ``products_product_deleted`` index covers. - -PostgreSQL path uses ``CREATE INDEX CONCURRENTLY`` via ``postgresql_concurrently`` -+ ``autocommit_block`` so index builds on large tables do not block writes. -SQLite ignores the flag (its ``CREATE INDEX`` is already fast and non-locking -for this workload). - -Revision ID: b7e1af4c9d02 -Revises: a01185374312 -Create Date: 2026-04-16 12:00:00.000000 -""" - -from __future__ import annotations - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "b7e1af4c9d02" -down_revision: str | None = "a01185374312" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -# Functional index: ``lower(email)``. Using a literal SQL expression keeps -# the syntax identical on Postgres and SQLite; SQLAlchemy's ``text()`` is -# escaped appropriately by the dialect in both cases. -_EMAIL_LOWER_EXPR = sa.text("lower(email)") - - -def upgrade() -> None: - is_postgres = op.get_context().dialect.name == "postgresql" - - # Each ``CREATE/DROP INDEX CONCURRENTLY`` auto-commits as soon as it finishes. - # ``if_exists`` / ``if_not_exists`` make the migration re-runnable after a - # partial failure mid-block: otherwise we'd have to manually clean up the - # already-committed indexes before retrying. - with op.get_context().autocommit_block(): - op.create_index( - "ix_users_user_email_lower", - "users_user", - [_EMAIL_LOWER_EXPR], - unique=False, - if_not_exists=True, - postgresql_concurrently=is_postgres, - ) - op.create_index( - "ix_users_access_token_user_id", - "users_access_token", - ["user_id"], - unique=False, - if_not_exists=True, - postgresql_concurrently=is_postgres, - ) - op.create_index( - "ix_users_user_role_role_id", - "users_user_role", - ["role_id"], - unique=False, - if_not_exists=True, - postgresql_concurrently=is_postgres, - ) - op.create_index( - "ix_products_product_is_deleted", - "products_product", - ["is_deleted"], - unique=False, - if_not_exists=True, - postgresql_concurrently=is_postgres, - ) - op.drop_index( - "ix_products_product_is_active", - table_name="products_product", - if_exists=True, - postgresql_concurrently=is_postgres, - ) - - -def downgrade() -> None: - is_postgres = op.get_context().dialect.name == "postgresql" - - with op.get_context().autocommit_block(): - op.create_index( - "ix_products_product_is_active", - "products_product", - ["is_active"], - unique=False, - if_not_exists=True, - postgresql_concurrently=is_postgres, - ) - op.drop_index( - "ix_products_product_is_deleted", - table_name="products_product", - if_exists=True, - postgresql_concurrently=is_postgres, - ) - op.drop_index( - "ix_users_user_role_role_id", - table_name="users_user_role", - if_exists=True, - postgresql_concurrently=is_postgres, - ) - op.drop_index( - "ix_users_access_token_user_id", - table_name="users_access_token", - if_exists=True, - postgresql_concurrently=is_postgres, - ) - op.drop_index( - "ix_users_user_email_lower", - table_name="users_user", - if_exists=True, - postgresql_concurrently=is_postgres, - ) diff --git a/host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py b/host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py deleted file mode 100644 index 53bb9d0d..00000000 --- a/host/migrations/versions/cf714c8bf117_add_feature_flags_override_table.py +++ /dev/null @@ -1,86 +0,0 @@ -"""add feature_flags override table - -Revision ID: cf714c8bf117 -Revises: 1fe7590fc594 -Create Date: 2026-04-19 13:58:41.316854 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "cf714c8bf117" -down_revision: str | None = "1fe7590fc594" -branch_labels: str | Sequence[str] | None = ("feature_flags",) -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # On PostgreSQL, create the `feature_flags` schema before creating tables. - if op.get_context().dialect.name == "postgresql": - op.execute("CREATE SCHEMA IF NOT EXISTS feature_flags") - - op.create_table( - "feature_flags_override", - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_by", sa.String(length=255), nullable=True), - sa.Column("updated_by", sa.String(length=255), nullable=True), - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("scope", sa.String(length=10), nullable=False, server_default="system"), - sa.Column("scope_id", sa.String(length=64), nullable=False, server_default=""), - sa.Column("name", sa.String(length=200), nullable=False), - sa.Column("enabled", sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_feature_flags_override")), - sa.UniqueConstraint( - "scope", - "scope_id", - "name", - name="uq_feature_flags_override_scope_scope_id_name", - ), - ) - op.create_index( - op.f("ix_feature_flags_override_scope"), - "feature_flags_override", - ["scope"], - unique=False, - ) - op.create_index( - op.f("ix_feature_flags_override_scope_id"), - "feature_flags_override", - ["scope_id"], - unique=False, - ) - op.create_index( - op.f("ix_feature_flags_override_name"), - "feature_flags_override", - ["name"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index( - op.f("ix_feature_flags_override_name"), - table_name="feature_flags_override", - ) - op.drop_index( - op.f("ix_feature_flags_override_scope_id"), - table_name="feature_flags_override", - ) - op.drop_index( - op.f("ix_feature_flags_override_scope"), - table_name="feature_flags_override", - ) - op.drop_table("feature_flags_override") - - # On PostgreSQL, drop the `feature_flags` schema. - if op.get_context().dialect.name == "postgresql": - op.execute("DROP SCHEMA IF EXISTS feature_flags") diff --git a/host/migrations/versions/dad9a134290f_add_datasets_table.py b/host/migrations/versions/dad9a134290f_add_datasets_table.py deleted file mode 100644 index 8114b9bb..00000000 --- a/host/migrations/versions/dad9a134290f_add_datasets_table.py +++ /dev/null @@ -1,69 +0,0 @@ -"""add datasets table - -Revision ID: dad9a134290f -Revises: b7e1af4c9d02 -Create Date: 2026-04-19 11:51:19.145277 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "dad9a134290f" -down_revision: str | None = "b7e1af4c9d02" -branch_labels: str | Sequence[str] | None = ("datasets",) -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "datasets_dataset", - sa.Column("is_deleted", sa.Boolean(), nullable=False), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("deleted_by", sa.String(length=255), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_by", sa.String(length=255), nullable=True), - sa.Column("updated_by", sa.String(length=255), nullable=True), - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(length=200), nullable=False), - sa.Column("slug", sa.String(length=200), nullable=False), - sa.Column("kind", sa.String(length=32), nullable=False), - sa.Column("description", sa.String(length=2000), nullable=True), - sa.Column("original_filename", sa.String(length=255), nullable=False), - sa.Column("mime_type", sa.String(length=127), nullable=True), - sa.Column("size_bytes", sa.Integer(), nullable=False), - sa.Column("storage_key", sa.String(length=512), nullable=False), - sa.Column("crs", sa.String(length=64), nullable=True), - sa.Column("bbox_min_x", sa.Float(), nullable=True), - sa.Column("bbox_min_y", sa.Float(), nullable=True), - sa.Column("bbox_max_x", sa.Float(), nullable=True), - sa.Column("bbox_max_y", sa.Float(), nullable=True), - sa.Column("feature_count", sa.Integer(), nullable=True), - sa.Column("band_count", sa.Integer(), nullable=True), - sa.Column("extraction_status", sa.String(length=16), nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_datasets_dataset")), - ) - op.create_index( - "ix_datasets_dataset_is_deleted", "datasets_dataset", ["is_deleted"], unique=False - ) - op.create_index(op.f("ix_datasets_dataset_kind"), "datasets_dataset", ["kind"], unique=False) - op.create_index(op.f("ix_datasets_dataset_slug"), "datasets_dataset", ["slug"], unique=True) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f("ix_datasets_dataset_slug"), table_name="datasets_dataset") - op.drop_index(op.f("ix_datasets_dataset_kind"), table_name="datasets_dataset") - op.drop_index("ix_datasets_dataset_is_deleted", table_name="datasets_dataset") - op.drop_table("datasets_dataset") - # ### end Alembic commands ### diff --git a/host/migrations/versions/e3ce9754e6dc_seed_users_roles.py b/host/migrations/versions/e3ce9754e6dc_seed_users_roles.py deleted file mode 100644 index b7969965..00000000 --- a/host/migrations/versions/e3ce9754e6dc_seed_users_roles.py +++ /dev/null @@ -1,51 +0,0 @@ -"""seed users roles - -Revision ID: e3ce9754e6dc -Revises: 8c12be982a27 -Create Date: 2026-04-15 18:10:00.000000 -""" - -from __future__ import annotations - -import uuid -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from fastapi_users_db_sqlalchemy.generics import GUID - -revision: str = "e3ce9754e6dc" -down_revision: str | None = "8c12be982a27" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -# Duplicated from modules/users/users/constants.py — migrations are run from a -# context where module imports may not resolve (alembic discovers them via -# the simple_module entry point), and tying the migration to the module's -# import path is more fragile than hardcoding the stable UUIDs. -ADMIN_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000001") -USER_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000002") - - -def upgrade() -> None: - # Use GUID — same type used by the schema migration — so the stored - # representation matches on all backends (especially SQLite, where - # sa.Uuid() serializes without dashes but GUID keeps them, breaking JOINs). - roles_table = sa.table( - "users_role", - sa.column("id", GUID()), - sa.column("name", sa.String()), - sa.column("description", sa.String()), - ) - op.bulk_insert( - roles_table, - [ - {"id": ADMIN_ROLE_ID, "name": "admin", "description": "Administrator"}, - {"id": USER_ROLE_ID, "name": "user", "description": "Standard user"}, - ], - ) - - -def downgrade() -> None: - op.execute(f"DELETE FROM users_role WHERE id IN ('{ADMIN_ROLE_ID}', '{USER_ROLE_ID}')") diff --git a/host/pyproject.toml b/host/pyproject.toml index dbae2b21..35bce23f 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -7,8 +7,6 @@ dependencies = [ "simple_module_hosting", "simple_module_auth", "simple_module_dashboard", - "simple_module_products", - "simple_module_datasets", "simple_module_permissions", "simple_module_background_tasks", "simple_module_file_storage", @@ -20,9 +18,7 @@ dependencies = [ [tool.uv.sources] simple_module_hosting = { workspace = true } simple_module_auth = { workspace = true } -simple_module_products = { workspace = true } simple_module_dashboard = { workspace = true } -simple_module_datasets = { workspace = true } simple_module_permissions = { workspace = true } simple_module_background_tasks = { workspace = true } simple_module_file_storage = { workspace = true } diff --git a/modules/dashboard/README.md b/modules/dashboard/README.md index 610db8bc..0b7148e4 100644 --- a/modules/dashboard/README.md +++ b/modules/dashboard/README.md @@ -38,7 +38,7 @@ The dashboard sidebar picks it up automatically. ## Depends on - `simple_module_core`, `simple_module_db`, `simple_module_hosting` -- `simple_module_users`, `simple_module_products` (demo content used by the default layout) +- `simple_module_users` (user counts shown on the default layout) ## License diff --git a/modules/dashboard/dashboard/locales/en.json b/modules/dashboard/dashboard/locales/en.json index 5aa421af..c422fed4 100644 --- a/modules/dashboard/dashboard/locales/en.json +++ b/modules/dashboard/dashboard/locales/en.json @@ -5,7 +5,6 @@ "stats": { "total_users": "Total Users", "active_users": "Active Users (7d)", - "products": "Products", "modules": "Modules" }, "system_info_title": "System", diff --git a/modules/dashboard/dashboard/locales/es.json b/modules/dashboard/dashboard/locales/es.json index 207975f6..117b300c 100644 --- a/modules/dashboard/dashboard/locales/es.json +++ b/modules/dashboard/dashboard/locales/es.json @@ -5,7 +5,6 @@ "stats": { "total_users": "Usuarios Totales", "active_users": "Usuarios Activos (7d)", - "products": "Productos", "modules": "Módulos" }, "system_info_title": "Sistema", diff --git a/modules/dashboard/dashboard/module.py b/modules/dashboard/dashboard/module.py index 1596d490..9923975c 100644 --- a/modules/dashboard/dashboard/module.py +++ b/modules/dashboard/dashboard/module.py @@ -9,7 +9,6 @@ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection from simple_module_core.module import ModuleBase, ModuleMeta -_MODULE_PRODUCTS = "Products" _MODULE_USERS = "Users" _URL_DASHBOARD = "/dashboard/" _ICON_DASHBOARD = "home" @@ -20,7 +19,7 @@ class DashboardModule(ModuleBase): name="Dashboard", route_prefix="/api/dashboard", view_prefix="/dashboard", - depends_on=[_MODULE_PRODUCTS, _MODULE_USERS], + depends_on=[_MODULE_USERS], ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: diff --git a/modules/dashboard/dashboard/pages/Home.tsx b/modules/dashboard/dashboard/pages/Home.tsx index 383bcacb..8bde7fba 100644 --- a/modules/dashboard/dashboard/pages/Home.tsx +++ b/modules/dashboard/dashboard/pages/Home.tsx @@ -4,9 +4,9 @@ import { PageShell } from '@simple-module-py/ui/components/PageShell'; import { Card, CardContent, CardHeader, CardTitle } from '@simple-module-py/ui/components/ui/card'; import { Table, TableBody, TableCell, TableRow } from '@simple-module-py/ui/components/ui/table'; import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; -import { Activity, Box, Heart, Package, Server, Users } from 'lucide-react'; +import { Activity, Box, Heart, Server, Users } from 'lucide-react'; -type Accent = 'primary' | 'emerald' | 'violet' | 'amber'; +type Accent = 'emerald' | 'violet' | 'amber'; const HEALTH_STATUS_COLOR: Record = { healthy: 'bg-emerald-500', @@ -33,7 +33,6 @@ interface SystemInfo { interface Props { total_users: number; active_users_7d: number; - total_products: number; module_count: number; system_info: SystemInfo; } @@ -47,7 +46,7 @@ function Home() { title={t(keys.dashboard.home.title)} description={t(keys.dashboard.home.description)} > -
+
} accent="amber" /> - } - accent="primary" - /> = { - primary: { - card: 'border-primary-200 bg-gradient-to-br from-primary-50 to-card', - icon: 'text-primary-500 bg-primary-100', - value: 'text-primary-900', - }, emerald: { card: 'border-emerald-border bg-gradient-to-br from-emerald-bg to-card', icon: 'text-emerald-icon-fg bg-emerald-icon-bg', diff --git a/modules/dashboard/dashboard/stats.py b/modules/dashboard/dashboard/stats.py index 379a9736..31b2c768 100644 --- a/modules/dashboard/dashboard/stats.py +++ b/modules/dashboard/dashboard/stats.py @@ -8,7 +8,6 @@ from datetime import UTC, datetime, timedelta from fastapi import FastAPI -from products.models import Product from simple_module_core.health import HealthCheck, HealthStatus from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -42,14 +41,12 @@ async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict: total_users = await _count_users(db) active_users_7d = await _count_active_users(db, days=7) - total_products = await _count_products(db) modules_list = _get_module_info(app) health_checks = await _run_health_checks(app) result = { "total_users": total_users, "active_users_7d": active_users_7d, - "total_products": total_products, "module_count": len(modules_list), "system_info": { "modules": modules_list, @@ -85,13 +82,6 @@ async def _count_active_users(db: AsyncSession, *, days: int) -> int: return result.scalar_one() -async def _count_products(db: AsyncSession) -> int: - result = await db.execute( - select(func.count()).select_from(Product).where(Product.is_active.is_(True)) - ) - return result.scalar_one() - - def _get_module_info(app: FastAPI) -> list[dict[str, str]]: # Reads from the module list discovered once at startup, avoiding # expensive entry-point rescans on every request. diff --git a/modules/dashboard/pyproject.toml b/modules/dashboard/pyproject.toml index 5696ecaa..aba47446 100644 --- a/modules/dashboard/pyproject.toml +++ b/modules/dashboard/pyproject.toml @@ -24,7 +24,6 @@ dependencies = [ "simple_module_core==0.0.3", "simple_module_db==0.0.3", "simple_module_hosting==0.0.3", - "simple_module_products==0.0.3", "simple_module_users==0.0.3", ] @@ -53,5 +52,4 @@ packages = ["dashboard"] simple_module_core = { workspace = true } simple_module_db = { workspace = true } simple_module_hosting = { workspace = true } -simple_module_products = { workspace = true } simple_module_users = { workspace = true } diff --git a/modules/dashboard/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py index bd3ecd84..ff2e2a62 100644 --- a/modules/dashboard/tests/test_dashboard.py +++ b/modules/dashboard/tests/test_dashboard.py @@ -24,7 +24,6 @@ async def test_module_meta(self): mod = DashboardModule() assert mod.meta.name == "Dashboard" assert mod.meta.route_prefix == "/api/dashboard" - assert "Products" in mod.meta.depends_on assert "Users" in mod.meta.depends_on @@ -42,7 +41,6 @@ async def stats(self, app): async def test_returns_expected_keys(self, stats): assert "total_users" in stats assert "active_users_7d" in stats - assert "total_products" in stats assert "module_count" in stats assert "system_info" in stats @@ -79,7 +77,6 @@ async def test_stats_returns_all_fields(self, authenticated_client: httpx.AsyncC body = resp.json() assert "total_users" in body assert "active_users_7d" in body - assert "total_products" in body assert "module_count" in body assert "system_info" in body diff --git a/modules/datasets/LICENSE b/modules/datasets/LICENSE deleted file mode 100644 index a1e1d361..00000000 --- a/modules/datasets/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Anto Subash - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/modules/datasets/README.md b/modules/datasets/README.md deleted file mode 100644 index 62a855a9..00000000 --- a/modules/datasets/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# simple_module_datasets - -Geospatial + tabular dataset upload module for [simple_module](https://github.com/antosubash/simple_module_python) apps. Users upload CSV/GeoJSON/Shapefile; the module parses, slugs a canonical name, and stores geometry using `shapely`. - -## Install - -```bash -pip install simple_module_datasets -``` - -Also needs `simple_module_file_storage` + `simple_module_background_tasks` (declared as deps). - -## What it provides - -- `POST /api/datasets` — multipart upload; the file is staged via `simple_module_file_storage`, then a Celery job parses it in the background. -- `Dataset` SQLModel record with `name`, `slug` (via `python-slugify`), `geometry_type`, `row_count`, `bbox`. -- Shapely-backed parsers for GeoJSON, CSV with lat/lon columns, and zipped Shapefiles. -- Admin UI for browsing + deleting datasets. - -## Usage - -Upload from a form: - -```bash -curl -X POST -F "file=@cities.geojson" http://localhost:8000/api/datasets -``` - -Query parsed datasets: - -```python -from datasets.service import DatasetService # type: ignore[import-not-found] - -async def list_by_bbox(svc: DatasetService = Depends(DatasetService), ...): - return await svc.intersects(bbox=(-74.1, 40.6, -73.8, 40.9)) -``` - -## Depends on - -- `simple_module_core`, `simple_module_db`, `simple_module_hosting` -- `simple_module_file_storage`, `simple_module_background_tasks` -- `shapely>=2.0`, `python-slugify>=8.0`, `celery>=5.4` - -## License - -MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE). diff --git a/modules/datasets/datasets/__init__.py b/modules/datasets/datasets/__init__.py deleted file mode 100644 index fb7083d7..00000000 --- a/modules/datasets/datasets/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Datasets module.""" diff --git a/modules/datasets/datasets/constants.py b/modules/datasets/datasets/constants.py deleted file mode 100644 index 584604a4..00000000 --- a/modules/datasets/datasets/constants.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Stable identifiers for the datasets module. - -Every string that identifies a permission, role, page, module dependency, -event, route, or config key lives here. Everything else in the module -imports from this file, so ``rg`` can prove the module has no scattered -magic strings. -""" - -from __future__ import annotations - -from typing import Final - -# ── Module identity ────────────────────────────────────────────────── -MODULE_NAME: Final = "datasets" -MODULE_PASCAL: Final = "Datasets" -MODULE_DISPLAY_NAME: Final = "Datasets" - -# ── Configuration ──────────────────────────────────────────────────── -ENV_PREFIX: Final = "SM_DATASETS_" - -# ── Routing ────────────────────────────────────────────────────────── -ROUTE_PREFIX_API: Final = "/api/datasets" -ROUTE_PREFIX_VIEW: Final = "/datasets" - -# REST sub-paths (joined to ROUTE_PREFIX_API by the router) -PATH_DOWNLOAD: Final = "/{dataset_id}/download" -PATH_DATASET: Final = "/{dataset_id}" - -# View routes (relative to ROUTE_PREFIX_VIEW, used in redirects) -REDIRECT_BROWSE: Final = "/datasets/" - -# ── Module dependencies (for ModuleMeta.depends_on) ────────────────── -MODULE_FILE_STORAGE: Final = "FileStorage" -MODULE_BACKGROUND_TASKS: Final = "BackgroundTasks" - -# ── i18n ───────────────────────────────────────────────────────────── -LOCALE_NAMESPACE: Final = MODULE_NAME - -# ── Inertia page identifiers ───────────────────────────────────────── -PAGE_BROWSE: Final = "Datasets/Browse" -PAGE_CREATE: Final = "Datasets/Create" -PAGE_EDIT: Final = "Datasets/Edit" -PAGE_SHOW: Final = "Datasets/Show" - -# ── Permissions ────────────────────────────────────────────────────── -PERM_DATASETS_VIEW: Final = "datasets.view" -PERM_DATASETS_UPLOAD: Final = "datasets.upload" -PERM_DATASETS_EDIT: Final = "datasets.edit" -PERM_DATASETS_DELETE: Final = "datasets.delete" - -PERMISSION_GROUP: Final = "Datasets" -ALL_PERMISSIONS: Final = ( - PERM_DATASETS_VIEW, - PERM_DATASETS_UPLOAD, - PERM_DATASETS_EDIT, - PERM_DATASETS_DELETE, -) - -# ── Menu ───────────────────────────────────────────────────────────── -MENU_LABEL: Final = "Datasets" -MENU_ICON: Final = "layers" -MENU_ORDER: Final = 40 - -# ── Celery tasks ───────────────────────────────────────────────────── -TASK_EXTRACT_METADATA: Final = "datasets.extract_metadata" - -# ── Feature flags ──────────────────────────────────────────────────── -FLAG_AUTO_EXTRACT: Final = "datasets.auto_extract" -FLAG_ALLOW_RASTER_UPLOADS: Final = "datasets.allow_raster_uploads" - -# ── Settings keys (registered with the settings module) ────────────── -SETTING_MAX_UPLOAD_MB: Final = "datasets.max_upload_mb" -SETTING_DEFAULT_KIND: Final = "datasets.default_kind" - -# ── Roles ──────────────────────────────────────────────────────────── -ROLE_USER: Final = "user" -ROLE_ADMIN: Final = "admin" - -# Permissions granted to plain ``user`` role via ``map_role``. ``admin`` -# inherits everything through the framework's wildcard, so we don't list -# it explicitly here. -USER_ROLE_PERMISSIONS: Final = ( - PERM_DATASETS_VIEW, - PERM_DATASETS_UPLOAD, -) - -# ── Health check ───────────────────────────────────────────────────── -HEALTH_CHECK_STORAGE: Final = "datasets.storage" - -# ── Storage ────────────────────────────────────────────────────────── -STORAGE_KEY_PREFIX: Final = "datasets/" - -# ── Database ───────────────────────────────────────────────────────── -SCHEMA_NAME: Final = "datasets" -TABLE_DATASET: Final = "datasets_dataset" - -# ── Defaults ───────────────────────────────────────────────────────── -DEFAULT_MAX_UPLOAD_MB: Final = 256 -DEFAULT_UPLOAD_CHUNK_SIZE: Final = 1024 * 1024 # 1 MB -DEFAULT_PRESIGN_TTL_SECONDS: Final = 300 # 5 minutes -DEFAULT_FALLBACK_FILENAME: Final = "upload.bin" -DEFAULT_MIME_TYPE: Final = "application/octet-stream" -DEFAULT_GEOJSON_CRS: Final = "EPSG:4326" - - -class DatasetKind: - """Built-in dataset kinds. - - The ``kind`` column is a free-form string so new providers can register - their own labels, but these are the ones the shipped extractors and - the UI know about. - """ - - VECTOR_GEOJSON: Final = "vector_geojson" - VECTOR_SHAPEFILE: Final = "vector_shapefile" - VECTOR_KML: Final = "vector_kml" - RASTER_GEOTIFF: Final = "raster_geotiff" - TABULAR_CSV: Final = "tabular_csv" - OTHER: Final = "other" - - -ALL_KINDS: Final = ( - DatasetKind.VECTOR_GEOJSON, - DatasetKind.VECTOR_SHAPEFILE, - DatasetKind.VECTOR_KML, - DatasetKind.RASTER_GEOTIFF, - DatasetKind.TABULAR_CSV, - DatasetKind.OTHER, -) - - -class ExtractionStatus: - """Values stored in ``Dataset.extraction_status``. - - ``pending`` is set by the upload endpoint; ``ok``/``partial``/``failed`` - are set by the Celery worker once extraction completes. ``manual`` is - the fallback for kinds the extractor doesn't understand; ``not_found`` - is only used as a task-result marker (never stored). - """ - - PENDING: Final = "pending" - OK: Final = "ok" - PARTIAL: Final = "partial" - FAILED: Final = "failed" - MANUAL: Final = "manual" - NOT_FOUND: Final = "not_found" diff --git a/modules/datasets/datasets/contracts/__init__.py b/modules/datasets/datasets/contracts/__init__.py deleted file mode 100644 index 8889bec9..00000000 --- a/modules/datasets/datasets/contracts/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Datasets contracts — public interface for other modules. - -Downstream modules should import from here, not from -``datasets.models`` or ``datasets.service`` internals. The SM009 -diagnostic enforces framework→plugin purity; this package is the -supported surface for plugin→plugin coupling:: - - from datasets.contracts import ( - DatasetOut, # DTO returned by service lookups - DatasetFile, # handle for stored file access - DatasetUploaded, # event subscribers listen for - download_url, # URL helper for UIs - ) - -For the FastAPI dependency, prefer -``from datasets.deps import DatasetServiceDep``. Type-hint against the -concrete ``DatasetService`` — the module is single-impl, so there's no -Protocol abstraction (matching the framework's "ship a Protocol only -for real extension points" rule). -""" - -from datasets.contracts.events import DatasetDeleted, DatasetUploaded -from datasets.contracts.files import DatasetFile -from datasets.contracts.schemas import ( - KIND_VALUES, - DatasetKind, - DatasetOut, - DatasetUpdate, -) -from datasets.contracts.urls import ( - API_PREFIX, - VIEW_PREFIX, - detail_url, - download_url, - show_url, -) - -__all__ = [ - "API_PREFIX", - "KIND_VALUES", - "VIEW_PREFIX", - "DatasetDeleted", - "DatasetFile", - "DatasetKind", - "DatasetOut", - "DatasetUpdate", - "DatasetUploaded", - "detail_url", - "download_url", - "show_url", -] diff --git a/modules/datasets/datasets/contracts/events.py b/modules/datasets/datasets/contracts/events.py deleted file mode 100644 index 19f4c5c9..00000000 --- a/modules/datasets/datasets/contracts/events.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Domain events emitted by the Datasets module. - -Events carry the handful of fields subscribers most often route on -(``slug``, ``kind``) so downstream handlers don't have to make a round -trip to ``IDatasetService`` just to decide whether the event is for -them. Anything beyond this — CRS, bbox, feature counts — is still a -``get_by_id`` away. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -from simple_module_core.events import Event - - -@dataclass -class DatasetUploaded(Event): - dataset_id: int - name: str - slug: str - kind: str - - -@dataclass -class DatasetDeleted(Event): - dataset_id: int - slug: str diff --git a/modules/datasets/datasets/contracts/files.py b/modules/datasets/datasets/contracts/files.py deleted file mode 100644 index 48e84609..00000000 --- a/modules/datasets/datasets/contracts/files.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Public-facing value types for dataset file access. - -Consumers that depend on the Datasets module import ``DatasetFile`` rather -than the private ``Dataset`` SQLModel table. It carries enough to stream -bytes, hand off to a parser library, or materialise to a local path -without a second round-trip. - -Because the datasets module delegates bytes storage to -``file_storage.StorageBackend``, a dataset's bytes may live on any -backend (local FS today, S3 or GCS tomorrow). ``DatasetFile`` hides that -difference: consumers call :meth:`stream` or :meth:`materialize_to` and -never touch backend-specific APIs. -""" - -from __future__ import annotations - -import tempfile -from collections.abc import AsyncIterator -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING - -from datasets.contracts.schemas import DatasetOut - -if TYPE_CHECKING: - from file_storage.contracts.service import StorageBackend - - -@dataclass(frozen=True) -class DatasetFile: - """Read-only handle to a stored dataset file. - - The handle is decoupled from any specific storage backend. Consumers - that need bytes use :meth:`stream` (async iterator) or :meth:`read` - (``bytes``). Consumers that need a filesystem path — e.g. to hand - the file off to ``fiona`` / ``rasterio`` / ``pandas`` that require - ``str(path)`` — use :meth:`materialize_to`. - """ - - metadata: DatasetOut - storage_key: str - original_filename: str - mime_type: str | None - # Backend injected by the service — not part of the printable repr. - _backend: StorageBackend - - async def stream(self) -> AsyncIterator[bytes]: - """Yield the file's bytes in chunks. Works on any backend.""" - return await self._backend.get(self.storage_key) - - async def read(self) -> bytes: - """Read the entire file into memory. Prefer :meth:`stream` for large files.""" - buf = bytearray() - async for chunk in await self._backend.get(self.storage_key): - buf.extend(chunk) - return bytes(buf) - - async def exists(self) -> bool: - return await self._backend.exists(self.storage_key) - - async def materialize_to(self, path: Path) -> Path: - """Download the file to ``path``. Returns the path. - - Use for libraries that require a filesystem path (``fiona``, - ``rasterio``, ``pandas.read_csv``). For the filesystem backend - this is almost free; for S3 it pulls bytes down once. Callers - are responsible for deleting the file. - """ - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("wb") as fp: - async for chunk in await self._backend.get(self.storage_key): - fp.write(chunk) - return path - - async def materialize_to_tempfile(self, suffix: str | None = None) -> Path: - """Download to a named temp file. Returns the path. - - Convenience wrapper around :meth:`materialize_to` for the common - "I need a Path for fiona/rasterio, then I'll delete it" pattern. - Caller must ``unlink`` the returned path. - """ - effective_suffix = suffix if suffix is not None else Path(self.original_filename).suffix - fd, tmp = tempfile.mkstemp(suffix=effective_suffix) - # We don't need the file descriptor — ``materialize_to`` re-opens the path. - import os - - os.close(fd) - return await self.materialize_to(Path(tmp)) diff --git a/modules/datasets/datasets/contracts/schemas.py b/modules/datasets/datasets/contracts/schemas.py deleted file mode 100644 index d0d902ad..00000000 --- a/modules/datasets/datasets/contracts/schemas.py +++ /dev/null @@ -1,64 +0,0 @@ -"""SQLModel DTOs for the Datasets module.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Literal - -from pydantic import ConfigDict -from sqlmodel import Field, SQLModel - -from datasets import constants - -DatasetKind = Literal[ - "vector_geojson", - "vector_shapefile", - "vector_kml", - "raster_geotiff", - "tabular_csv", - "other", -] - -# Duplicated from ``constants.ALL_KINDS`` because ``typing.Literal`` demands -# string literals at type-evaluation time — a tuple reference won't satisfy -# it. The runtime check against ``constants.ALL_KINDS`` is the one that -# matters; this Literal only narrows the ``DatasetUpdate.kind`` type. -KIND_VALUES: tuple[str, ...] = constants.ALL_KINDS - - -class DatasetOut(SQLModel): - """Dataset metadata returned by the API.""" - - model_config = ConfigDict(from_attributes=True) - - id: int - name: str - slug: str - kind: str - description: str | None = None - original_filename: str - mime_type: str | None = None - size_bytes: int - crs: str | None = None - bbox_min_x: float | None = None - bbox_min_y: float | None = None - bbox_max_x: float | None = None - bbox_max_y: float | None = None - feature_count: int | None = None - band_count: int | None = None - extraction_status: str - created_at: datetime | None = None - updated_at: datetime | None = None - - -class DatasetUpdate(SQLModel): - """Patchable metadata. Files are immutable after upload — re-upload to replace.""" - - name: str | None = Field(default=None, min_length=1, max_length=200) - description: str | None = Field(default=None, max_length=2000) - kind: DatasetKind | None = None - crs: str | None = Field(default=None, max_length=64) - bbox_min_x: float | None = None - bbox_min_y: float | None = None - bbox_max_x: float | None = None - bbox_max_y: float | None = None diff --git a/modules/datasets/datasets/contracts/urls.py b/modules/datasets/datasets/contracts/urls.py deleted file mode 100644 index 680424d9..00000000 --- a/modules/datasets/datasets/contracts/urls.py +++ /dev/null @@ -1,26 +0,0 @@ -"""URL builders consuming modules can use without hard-coding prefixes. - -If the Datasets module's ``route_prefix`` ever changes, downstream modules -don't have to audit every hard-coded ``/api/datasets/...`` string — they -just re-import these helpers. -""" - -from __future__ import annotations - -API_PREFIX = "/api/datasets" -VIEW_PREFIX = "/datasets" - - -def download_url(dataset_id: int) -> str: - """URL that streams the dataset's stored file back to the client.""" - return f"{API_PREFIX}/{dataset_id}/download" - - -def detail_url(dataset_id: int) -> str: - """URL of the dataset's JSON detail endpoint.""" - return f"{API_PREFIX}/{dataset_id}" - - -def show_url(dataset_id: int) -> str: - """URL of the Inertia ``Show`` page.""" - return f"{VIEW_PREFIX}/{dataset_id}" diff --git a/modules/datasets/datasets/deps.py b/modules/datasets/datasets/deps.py deleted file mode 100644 index 25270e4b..00000000 --- a/modules/datasets/datasets/deps.py +++ /dev/null @@ -1,97 +0,0 @@ -"""FastAPI dependencies for the Datasets module. - -Downstream modules that depend on ``Datasets`` import -``DatasetServiceDep`` directly:: - - from datasets.deps import DatasetServiceDep - - @router.get("/my-thing") - async def endpoint(datasets: DatasetServiceDep): - ds = await datasets.get_by_slug("world-borders") - ... - -The storage backend comes from the ``file_storage`` module's app-state -slot. That seam is what lets datasets work on local FS today and S3 -tomorrow without touching this module. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Annotated - -from fastapi import Depends, Request -from file_storage.contracts.service import StorageBackend -from simple_module_core.events import EventBus -from simple_module_db.deps import get_db -from sqlalchemy.ext.asyncio import AsyncSession - -from datasets.service import DatasetService - -if TYPE_CHECKING: - from celery import Celery - - -def get_storage_backend(request: Request) -> StorageBackend: - return request.app.state.file_storage.backend - - -async def get_dataset_service( - db: AsyncSession = Depends(get_db), - backend: StorageBackend = Depends(get_storage_backend), -) -> DatasetService: - return DatasetService(db, backend) - - -def get_event_bus(request: Request) -> EventBus: - return request.app.state.sm.event_bus - - -def get_celery(request: Request) -> Celery: - """Return the Celery app singleton owned by the background_tasks module. - - Depending on this means the datasets module won't boot unless - ``BackgroundTasks`` ran its ``on_startup`` first — which is enforced - via ``meta.depends_on``. - """ - return request.app.state.background_tasks.celery - - -def get_max_upload_bytes(request: Request) -> int: - """Resolve the max upload size. - - Prefers the runtime ``datasets.max_upload_mb`` value from the settings - module (so admins can tune it without a redeploy), falling back to the - env-var default on ``app.state.datasets.settings``. - """ - env_default = request.app.state.datasets.settings.max_upload_mb - override = _runtime_max_upload_mb(request, fallback=env_default) - return max(override, 1) * 1024 * 1024 - - -def _runtime_max_upload_mb(request: Request, *, fallback: int) -> int: - """Read the ``datasets.max_upload_mb`` SYSTEM scope setting. - - Returns ``fallback`` if the ``settings`` module isn't installed, the - registered accessor isn't available synchronously, or the stored value - can't be parsed as an int. Synchronous read happens through the - registry default when no DB row exists — we deliberately avoid doing - a DB query in a hot DI path. - """ - registry = getattr(getattr(request.app.state, "settings", None), "registry", None) - if registry is None: - return fallback - from datasets import constants - - definition = registry.get(constants.SETTING_MAX_UPLOAD_MB) - if definition is None: - return fallback - try: - return int(definition.default) - except (TypeError, ValueError): - return fallback - - -# Public type alias consumers can import directly — shortens -# ``service: DatasetService = Depends(get_dataset_service)`` to just -# ``datasets: DatasetServiceDep``. -DatasetServiceDep = Annotated[DatasetService, Depends(get_dataset_service)] diff --git a/modules/datasets/datasets/endpoints/__init__.py b/modules/datasets/datasets/endpoints/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/datasets/datasets/endpoints/api.py b/modules/datasets/datasets/endpoints/api.py deleted file mode 100644 index 03833a99..00000000 --- a/modules/datasets/datasets/endpoints/api.py +++ /dev/null @@ -1,238 +0,0 @@ -"""REST API endpoints for the Datasets module.""" - -from __future__ import annotations - -import logging -import tempfile -from pathlib import Path - -from celery import Celery -from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import RedirectResponse, StreamingResponse -from file_storage.contracts.service import NotSupportedError, StorageNotFoundError -from simple_module_core.events import EventBus -from simple_module_core.feature_flags import is_flag_enabled -from simple_module_hosting.permissions import RequiresPermission - -from datasets import constants -from datasets.contracts.events import DatasetDeleted, DatasetUploaded -from datasets.contracts.schemas import DatasetOut, DatasetUpdate -from datasets.deps import ( - get_celery, - get_dataset_service, - get_event_bus, - get_max_upload_bytes, -) -from datasets.service import DatasetService, UploadInput - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -def _enqueue_extraction(celery: Celery, dataset_id: int) -> None: - """Enqueue the extraction task, absorbing broker failures. - - A dead Redis shouldn't fail the upload — the Dataset row is already - persisted with ``extraction_status="pending"`` and can be re-extracted - later. We log the failure so the operator can see it and retry. - """ - try: - celery.send_task(constants.TASK_EXTRACT_METADATA, args=[dataset_id]) - except Exception: - logger.exception( - "Failed to enqueue %s for dataset %s — row will stay pending", - constants.TASK_EXTRACT_METADATA, - dataset_id, - ) - - -@router.get("/", response_model=list[DatasetOut]) -async def list_datasets( - service: DatasetService = Depends(get_dataset_service), -) -> list[DatasetOut]: - return await service.get_all() - - -@router.get(constants.PATH_DATASET, response_model=DatasetOut) -async def get_dataset( - dataset_id: int, - service: DatasetService = Depends(get_dataset_service), -) -> DatasetOut: - item = await service.get_by_id(dataset_id) - if item is None: - raise HTTPException(status_code=404, detail="Dataset not found") - return item - - -@router.get(constants.PATH_DOWNLOAD) -async def download_dataset( - dataset_id: int, - service: DatasetService = Depends(get_dataset_service), -): - handle = await service.get_file(dataset_id) - if handle is None: - raise HTTPException(status_code=404, detail="Dataset not found") - - # Presigned URLs let the client bypass the app entirely for S3-like - # backends — avoids proxying multi-GB files through the worker. - if service.backend.supports_presigned_url: - try: - url = await service.backend.presigned_get_url( - handle.storage_key, ttl_seconds=constants.DEFAULT_PRESIGN_TTL_SECONDS - ) - except NotSupportedError: - url = None - if url is not None: - return RedirectResponse(url, status_code=302) - - try: - stream = await handle.stream() - except StorageNotFoundError: - raise HTTPException( - status_code=410, detail="Dataset file is missing from storage" - ) from None - - disposition = f'attachment; filename="{handle.original_filename}"' - return StreamingResponse( - stream, - media_type=handle.mime_type or constants.DEFAULT_MIME_TYPE, - headers={"Content-Disposition": disposition}, - ) - - -async def perform_upload( - request: Request, - name: str, - description: str | None, - kind: str | None, - file: UploadFile, - service: DatasetService, - bus: EventBus, - celery: Celery, - max_upload_bytes: int, -) -> DatasetOut: - if kind is not None and kind not in constants.ALL_KINDS: - raise HTTPException(status_code=422, detail=f"Unknown kind: {kind}") - if kind == constants.DatasetKind.RASTER_GEOTIFF and not is_flag_enabled( - request, constants.FLAG_ALLOW_RASTER_UPLOADS - ): - raise HTTPException( - status_code=422, - detail="Raster uploads are disabled on this instance.", - ) - - original_filename = file.filename or constants.DEFAULT_FALLBACK_FILENAME - bytes_written = 0 - # Spool to a temp file first so size-validation can reject before we - # touch the storage backend, and so the service can hand a complete - # stream to backend.put(). - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp_path = Path(tmp.name) - try: - while True: - chunk = await file.read(constants.DEFAULT_UPLOAD_CHUNK_SIZE) - if not chunk: - break - bytes_written += len(chunk) - if bytes_written > max_upload_bytes: - raise HTTPException( - status_code=413, - detail=f"Upload exceeds {max_upload_bytes} bytes", - ) - tmp.write(chunk) - except HTTPException: - tmp.close() - tmp_path.unlink(missing_ok=True) - raise - - try: - dataset = await service.register_upload( - UploadInput( - name=name, - original_filename=original_filename, - temp_path=tmp_path, - size_bytes=bytes_written, - mime_type=file.content_type, - description=description, - kind=kind, - ) - ) - finally: - tmp_path.unlink(missing_ok=True) - - # Hand metadata extraction off to a Celery worker. The Dataset row is - # already ``extraction_status="pending"`` — the worker flips it to - # ok / partial / failed once the parse completes. See - # ``datasets.tasks.extract_metadata_task``. Guarded by a feature - # flag so an admin can freeze auto-extraction during incidents - # without redeploying. - if is_flag_enabled(request, constants.FLAG_AUTO_EXTRACT): - _enqueue_extraction(celery, dataset.id) - - await bus.publish( - DatasetUploaded( - dataset_id=dataset.id, - name=dataset.name, - slug=dataset.slug, - kind=dataset.kind, - ) - ) - return dataset - - -@router.post( - "/", - response_model=DatasetOut, - status_code=201, - dependencies=[Depends(RequiresPermission(constants.PERM_DATASETS_UPLOAD))], -) -async def upload_dataset( - request: Request, - name: str = Form(..., min_length=1, max_length=200), - description: str | None = Form(default=None, max_length=2000), - kind: str | None = Form(default=None), - file: UploadFile = File(...), - service: DatasetService = Depends(get_dataset_service), - bus: EventBus = Depends(get_event_bus), - celery: Celery = Depends(get_celery), - max_upload_bytes: int = Depends(get_max_upload_bytes), -) -> DatasetOut: - return await perform_upload( - request, name, description, kind, file, service, bus, celery, max_upload_bytes - ) - - -@router.patch( - constants.PATH_DATASET, - response_model=DatasetOut, - dependencies=[Depends(RequiresPermission(constants.PERM_DATASETS_EDIT))], -) -async def update_dataset( - dataset_id: int, - data: DatasetUpdate, - service: DatasetService = Depends(get_dataset_service), -) -> DatasetOut: - item = await service.update(dataset_id, data) - if item is None: - raise HTTPException(status_code=404, detail="Dataset not found") - return item - - -@router.delete( - constants.PATH_DATASET, - status_code=204, - dependencies=[Depends(RequiresPermission(constants.PERM_DATASETS_DELETE))], -) -async def delete_dataset( - dataset_id: int, - service: DatasetService = Depends(get_dataset_service), - bus: EventBus = Depends(get_event_bus), -) -> None: - # Capture the slug before deletion so subscribers can index by slug - # without a post-delete lookup (which would always miss). - existing = await service.get_by_id(dataset_id) - if existing is None: - raise HTTPException(status_code=404, detail="Dataset not found") - await service.delete(dataset_id) - await bus.publish(DatasetDeleted(dataset_id=dataset_id, slug=existing.slug)) diff --git a/modules/datasets/datasets/endpoints/views.py b/modules/datasets/datasets/endpoints/views.py deleted file mode 100644 index 69a7b63d..00000000 --- a/modules/datasets/datasets/endpoints/views.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Inertia view endpoints for the Datasets module.""" - -from __future__ import annotations - -from celery import Celery -from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile -from inertia import InertiaResponse -from simple_module_core.events import EventBus -from simple_module_hosting.inertia_deps import InertiaDep -from simple_module_hosting.permissions import RequiresPermission -from starlette.responses import RedirectResponse - -from datasets import constants -from datasets.contracts.events import DatasetDeleted -from datasets.contracts.schemas import DatasetUpdate -from datasets.deps import ( - get_celery, - get_dataset_service, - get_event_bus, - get_max_upload_bytes, -) -from datasets.endpoints.api import perform_upload -from datasets.service import DatasetService - -# Module-local Inertia page identifiers. These must be Name-only literal -# assignments (not attribute access against ``constants``) so the SM003 -# orphan-page diagnostic can resolve them — see -# ``simple_module_core.diagnostics._module._iter_render_components``. -_PAGE_BROWSE = "Datasets/Browse" -_PAGE_CREATE = "Datasets/Create" -_PAGE_SHOW = "Datasets/Show" -_PAGE_EDIT = "Datasets/Edit" - -router = APIRouter() - - -@router.get("/", response_model=None) -async def browse( - inertia: InertiaDep, - service: DatasetService = Depends(get_dataset_service), -) -> InertiaResponse: - items = await service.get_all() - return await inertia.render( - _PAGE_BROWSE, - {"datasets": [item.model_dump(mode="json") for item in items]}, - ) - - -@router.get( - "/create", - response_model=None, - dependencies=[Depends(RequiresPermission(constants.PERM_DATASETS_UPLOAD))], -) -async def create_view(inertia: InertiaDep) -> InertiaResponse: - return await inertia.render(_PAGE_CREATE) - - -@router.post( - "/", - response_model=None, - dependencies=[Depends(RequiresPermission(constants.PERM_DATASETS_UPLOAD))], -) -async def upload_view( - request: Request, - name: str = Form(..., min_length=1, max_length=200), - description: str | None = Form(default=None, max_length=2000), - kind: str | None = Form(default=None), - file: UploadFile = File(...), - service: DatasetService = Depends(get_dataset_service), - bus: EventBus = Depends(get_event_bus), - celery: Celery = Depends(get_celery), - max_upload_bytes: int = Depends(get_max_upload_bytes), -) -> RedirectResponse: - # Inertia's client-side router expects a redirect after POST. Return 303 - # so it re-issues a GET against the browse page, which replies with a - # full Inertia response. - await perform_upload( - request, name, description, kind, file, service, bus, celery, max_upload_bytes - ) - return RedirectResponse(constants.REDIRECT_BROWSE, status_code=303) - - -@router.get("/{dataset_id}", response_model=None) -async def show_view( - dataset_id: int, - inertia: InertiaDep, - service: DatasetService = Depends(get_dataset_service), -) -> InertiaResponse: - item = await service.get_by_id(dataset_id) - if item is None: - return await inertia.render( - _PAGE_BROWSE, - {"datasets": [], "error": "Dataset not found"}, - ) - return await inertia.render(_PAGE_SHOW, {"dataset": item.model_dump(mode="json")}) - - -@router.get( - "/{dataset_id}/edit", - response_model=None, - dependencies=[Depends(RequiresPermission(constants.PERM_DATASETS_EDIT))], -) -async def edit_view( - dataset_id: int, - inertia: InertiaDep, - service: DatasetService = Depends(get_dataset_service), -) -> InertiaResponse: - item = await service.get_by_id(dataset_id) - if item is None: - return await inertia.render( - _PAGE_BROWSE, - {"datasets": [], "error": "Dataset not found"}, - ) - return await inertia.render(_PAGE_EDIT, {"dataset": item.model_dump(mode="json")}) - - -@router.patch( - "/{dataset_id}", - response_model=None, - dependencies=[Depends(RequiresPermission(constants.PERM_DATASETS_EDIT))], -) -async def update_view( - dataset_id: int, - data: DatasetUpdate, - service: DatasetService = Depends(get_dataset_service), -) -> RedirectResponse: - item = await service.update(dataset_id, data) - if item is None: - raise HTTPException(status_code=404, detail="Dataset not found") - return RedirectResponse(constants.REDIRECT_BROWSE, status_code=303) - - -@router.delete( - "/{dataset_id}", - response_model=None, - dependencies=[Depends(RequiresPermission(constants.PERM_DATASETS_DELETE))], -) -async def delete_view( - dataset_id: int, - service: DatasetService = Depends(get_dataset_service), - bus: EventBus = Depends(get_event_bus), -) -> RedirectResponse: - existing = await service.get_by_id(dataset_id) - if existing is None: - raise HTTPException(status_code=404, detail="Dataset not found") - await service.delete(dataset_id) - await bus.publish(DatasetDeleted(dataset_id=dataset_id, slug=existing.slug)) - return RedirectResponse(constants.REDIRECT_BROWSE, status_code=303) diff --git a/modules/datasets/datasets/extractors.py b/modules/datasets/datasets/extractors.py deleted file mode 100644 index bfb9a9a0..00000000 --- a/modules/datasets/datasets/extractors.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Best-effort metadata extraction for uploaded datasets. - -GeoJSON is handled with ``shapely`` (a standard geospatial dependency). -Optional ``fiona`` and ``rasterio`` extras enrich extraction for -shapefiles, KML, and rasters when available. Every extractor degrades -to ``ExtractionStatus.MANUAL`` so the catalog never blocks an upload -because a parser is missing. -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path - -from shapely.geometry import shape - -from datasets import constants -from datasets.constants import DatasetKind, ExtractionStatus - - -@dataclass -class ExtractedMeta: - crs: str | None = None - bbox_min_x: float | None = None - bbox_min_y: float | None = None - bbox_max_x: float | None = None - bbox_max_y: float | None = None - feature_count: int | None = None - band_count: int | None = None - status: str = ExtractionStatus.MANUAL - - -_EXTENSION_TO_KIND: dict[str, str] = { - ".geojson": DatasetKind.VECTOR_GEOJSON, - ".json": DatasetKind.VECTOR_GEOJSON, - ".shp": DatasetKind.VECTOR_SHAPEFILE, - ".zip": DatasetKind.VECTOR_SHAPEFILE, - ".kml": DatasetKind.VECTOR_KML, - ".kmz": DatasetKind.VECTOR_KML, - ".tif": DatasetKind.RASTER_GEOTIFF, - ".tiff": DatasetKind.RASTER_GEOTIFF, - ".csv": DatasetKind.TABULAR_CSV, -} - - -def kind_for_filename(filename: str) -> str: - """Map a filename to a coarse kind label. - - The mapping is intentionally cheap — anything we don't recognise becomes - ``DatasetKind.OTHER`` so the upload still lands and the user can correct - the kind via the edit form. - """ - suffix = Path(filename).suffix.lower() - return _EXTENSION_TO_KIND.get(suffix, DatasetKind.OTHER) - - -def extract_metadata(path: Path, kind: str) -> ExtractedMeta: - """Dispatch to a kind-specific extractor. - - Never raises — extraction failures collapse to - ``ExtractionStatus.FAILED`` so the upload itself succeeds and the user - can fill the metadata in. - """ - try: - if kind == DatasetKind.VECTOR_GEOJSON: - return _extract_geojson(path) - if kind == DatasetKind.RASTER_GEOTIFF: - return _extract_raster(path) - if kind in {DatasetKind.VECTOR_SHAPEFILE, DatasetKind.VECTOR_KML}: - return _extract_via_fiona(path) - except Exception: - return ExtractedMeta(status=ExtractionStatus.FAILED) - return ExtractedMeta(status=ExtractionStatus.MANUAL) - - -def _extract_geojson(path: Path) -> ExtractedMeta: - with path.open("rb") as fp: - doc = json.load(fp) - - features: list[dict] - if isinstance(doc, dict) and doc.get("type") == "FeatureCollection": - features = [f for f in doc.get("features", []) if isinstance(f, dict)] - elif isinstance(doc, dict) and doc.get("type") == "Feature": - features = [doc] - else: - features = [] - - bbox = doc.get("bbox") if isinstance(doc, dict) else None - if not bbox: - bounds = [ - shape(f["geometry"]).bounds for f in features if isinstance(f.get("geometry"), dict) - ] - if bounds: - bbox = ( - min(b[0] for b in bounds), - min(b[1] for b in bounds), - max(b[2] for b in bounds), - max(b[3] for b in bounds), - ) - - if not bbox: - return ExtractedMeta( - crs=constants.DEFAULT_GEOJSON_CRS, - feature_count=len(features), - status=ExtractionStatus.PARTIAL, - ) - return ExtractedMeta( - crs=constants.DEFAULT_GEOJSON_CRS, - bbox_min_x=float(bbox[0]), - bbox_min_y=float(bbox[1]), - bbox_max_x=float(bbox[2]), - bbox_max_y=float(bbox[3]), - feature_count=len(features), - status=ExtractionStatus.OK, - ) - - -def _extract_via_fiona(path: Path) -> ExtractedMeta: - try: - import fiona # type: ignore[import-not-found] # ty: ignore[unresolved-import] - except ImportError: - return ExtractedMeta(status=ExtractionStatus.MANUAL) - - with fiona.open(str(path)) as src: - bounds = src.bounds - crs = src.crs.get("init") if hasattr(src.crs, "get") else str(src.crs) if src.crs else None - return ExtractedMeta( - crs=crs, - bbox_min_x=float(bounds[0]), - bbox_min_y=float(bounds[1]), - bbox_max_x=float(bounds[2]), - bbox_max_y=float(bounds[3]), - feature_count=len(src), - status=ExtractionStatus.OK, - ) - - -def _extract_raster(path: Path) -> ExtractedMeta: - try: - import rasterio # type: ignore[import-not-found] # ty: ignore[unresolved-import] - except ImportError: - return ExtractedMeta(status=ExtractionStatus.MANUAL) - - with rasterio.open(str(path)) as src: - bounds = src.bounds - return ExtractedMeta( - crs=str(src.crs) if src.crs else None, - bbox_min_x=float(bounds.left), - bbox_min_y=float(bounds.bottom), - bbox_max_x=float(bounds.right), - bbox_max_y=float(bounds.top), - band_count=src.count, - status=ExtractionStatus.OK, - ) diff --git a/modules/datasets/datasets/locales/en.json b/modules/datasets/datasets/locales/en.json deleted file mode 100644 index b49266d1..00000000 --- a/modules/datasets/datasets/locales/en.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "browse": { - "title": "Datasets", - "description": "Upload and manage datasets", - "new_button": "Upload Dataset", - "empty_title": "No datasets yet", - "empty_description": "Upload your first dataset to get started.", - "create_button": "Upload Dataset" - }, - "table": { - "name": "Name", - "kind": "Kind", - "crs": "CRS", - "bbox": "Bounding Box", - "size": "Size", - "actions": "Actions" - }, - "form": { - "name_label": "Name", - "name_placeholder": "Descriptive name for the dataset", - "description_label": "Description", - "description_placeholder": "Optional notes about the dataset", - "kind_label": "Kind", - "kind_auto": "Auto-detect from filename", - "file_label": "File", - "crs_label": "CRS", - "cancel_button": "Cancel" - }, - "create": { - "title": "Upload Dataset", - "description": "Add a dataset to the catalog", - "submit_button": "Upload", - "submitting_button": "Uploading..." - }, - "edit": { - "title": "Edit: {name}", - "description": "Update dataset metadata", - "back_button": "Back to Datasets", - "submit_button": "Save Changes", - "submitting_button": "Saving..." - }, - "show": { - "download": "Download", - "edit": "Edit", - "kind": "Kind", - "original_file": "Original filename", - "size": "Size", - "mime_type": "MIME type", - "crs": "CRS", - "bbox": "Bounding box", - "features": "Feature count", - "bands": "Band count", - "extraction_status": "Extraction status" - }, - "toasts": { - "created": "Dataset uploaded", - "updated": "Dataset updated", - "deleted": "Dataset deleted" - }, - "validation": { - "name_required": "Name is required", - "file_required": "Please choose a file to upload" - }, - "errors": { - "not_found": "Dataset not found" - } -} diff --git a/modules/datasets/datasets/models.py b/modules/datasets/datasets/models.py deleted file mode 100644 index 8c19e5cd..00000000 --- a/modules/datasets/datasets/models.py +++ /dev/null @@ -1,49 +0,0 @@ -"""SQLModel tables for the Datasets module.""" - -from __future__ import annotations - -from simple_module_db.base import create_module_base -from simple_module_db.mixins import AuditMixin, SoftDeleteMixin -from sqlalchemy import Index -from sqlmodel import Field - -from datasets import constants - -# Provider is auto-detected from SM_DATABASE_URL (falls back to SQLite). -# On PostgreSQL this gives the module its own schema; on SQLite all modules -# share one schema, so __tablename__ is prefixed for isolation. -Base = create_module_base(constants.SCHEMA_NAME) - - -class Dataset(Base, AuditMixin, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base] - """A dataset uploaded into the catalog. - - ``kind`` identifies the content type (vector GeoJSON, shapefile, - raster GeoTIFF, tabular CSV, ...) — see - :class:`datasets.constants.DatasetKind`. Geospatial fields (``crs``, - ``bbox_*``) stay null for non-geospatial kinds. - """ - - __tablename__ = constants.TABLE_DATASET - - id: int | None = Field(default=None, primary_key=True) - name: str = Field(max_length=200) - slug: str = Field(max_length=200, unique=True, index=True) - kind: str = Field(max_length=32, index=True) - description: str | None = Field(default=None, max_length=2000) - - original_filename: str = Field(max_length=255) - mime_type: str | None = Field(default=None, max_length=127) - size_bytes: int = Field(default=0) - storage_key: str = Field(max_length=512) - - crs: str | None = Field(default=None, max_length=64) - bbox_min_x: float | None = Field(default=None) - bbox_min_y: float | None = Field(default=None) - bbox_max_x: float | None = Field(default=None) - bbox_max_y: float | None = Field(default=None) - feature_count: int | None = Field(default=None) - band_count: int | None = Field(default=None) - extraction_status: str = Field(default=constants.ExtractionStatus.MANUAL, max_length=16) - - __table_args__ = (Index("ix_datasets_dataset_is_deleted", "is_deleted"),) diff --git a/modules/datasets/datasets/module.py b/modules/datasets/datasets/module.py deleted file mode 100644 index fc916d7b..00000000 --- a/modules/datasets/datasets/module.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Datasets module definition. - -Depends on ``FileStorage`` for bytes storage, ``BackgroundTasks`` for -the Celery pipeline the upload endpoint enqueues into, ``Permissions`` -for the permission system, ``FeatureFlags`` for runtime toggles, and -``Settings`` for admin-configurable limits. ``register_settings`` here -runs after those, so every ``app.state.*`` slot is populated before any -Datasets request fires. -""" - -from __future__ import annotations - -import importlib.resources -from pathlib import Path -from typing import TYPE_CHECKING - -from fastapi import APIRouter, FastAPI -from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry -from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection -from simple_module_core.module import ModuleBase, ModuleMeta -from simple_module_core.permissions import PermissionRegistry - -from datasets import constants - -if TYPE_CHECKING: - from settings.contracts.registry import SettingDefinition - - -class DatasetsModule(ModuleBase): - meta = ModuleMeta( - name=constants.MODULE_PASCAL, - route_prefix=constants.ROUTE_PREFIX_API, - view_prefix=constants.ROUTE_PREFIX_VIEW, - depends_on=[constants.MODULE_FILE_STORAGE, constants.MODULE_BACKGROUND_TASKS], - ) - - def register_settings(self, app: FastAPI) -> None: - import importlib - - from datasets.services import DatasetsServices - from datasets.settings import DatasetsSettings - - # SM009 is AST-based: a static `from settings.registration import ...` - # from a module helper is fine (plugin→plugin), but we resolve via - # importlib here to match the convention used framework-side and to - # keep the dependency direction one-way explicit. - register_module_settings = importlib.import_module( - "settings.registration" - ).register_module_settings - - register_module_settings( - app, - "datasets", - DatasetsSettings, - lambda s: DatasetsServices(settings=s), - ) - - def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: - from datasets.endpoints.api import router as api - from datasets.endpoints.views import router as views - - api_router.include_router(api) - view_router.include_router(views) - - def register_menu_items(self, registry: MenuRegistry) -> None: - registry.add( - MenuItem( - label=constants.MENU_LABEL, - url=constants.ROUTE_PREFIX_VIEW, - icon=constants.MENU_ICON, - order=constants.MENU_ORDER, - section=MenuSection.SIDEBAR, - ) - ) - - def register_permissions(self, registry: PermissionRegistry) -> None: - registry.add_group( - constants.PERMISSION_GROUP, - list(constants.ALL_PERMISSIONS), - ) - # Plain users can browse the catalog and upload their own datasets; - # edit/delete stay admin-only via the framework wildcard. - registry.map_role( - constants.ROLE_USER, - list(constants.USER_ROLE_PERMISSIONS), - ) - - def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: - registry.add( - FeatureFlagDefinition( - name=constants.FLAG_AUTO_EXTRACT, - description=( - "Enqueue the Celery metadata-extraction task on upload. " - "Turn off to skip the worker hop and leave rows as " - "``extraction_status=pending`` for manual review." - ), - default_enabled=True, - ) - ) - registry.add( - FeatureFlagDefinition( - name=constants.FLAG_ALLOW_RASTER_UPLOADS, - description=( - "Accept raster (GeoTIFF) uploads. Disable on instances " - "without ``rasterio`` installed to give users a clear " - "422 instead of a silent failed extraction." - ), - default_enabled=True, - ) - ) - - def locale_dirs(self) -> dict[str, Path]: - base = Path(str(importlib.resources.files(__package__) / "locales")) - return {constants.LOCALE_NAMESPACE: base} - - async def on_startup(self, app: FastAPI) -> None: - """Register runtime-tunable settings with the ``settings`` module. - - Registered as ``on_startup`` (not in ``register_settings``) because - the settings module's ``app.state.settings`` slot is only populated - once its own ``register_settings`` has run — ``on_startup`` fires - after every module's registration is done. - """ - # The settings module may not be installed in every deployment — - # treat its absence as a warn, not a crash. - registry = getattr(getattr(app.state, "settings", None), "registry", None) - if registry is None: - return - for definition in _setting_definitions(): - if definition.key in registry: - continue - registry.add(definition) - - -def _setting_definitions() -> list[SettingDefinition]: - """Deferred import so the module still loads if ``settings`` is absent.""" - from settings.contracts.registry import SettingDefinition - from settings.contracts.schemas import SettingValueType - - return [ - SettingDefinition( - key=constants.SETTING_MAX_UPLOAD_MB, - default=str(constants.DEFAULT_MAX_UPLOAD_MB), - description=( - "Per-dataset upload size cap in megabytes. Overrides the " - "pydantic default on ``DatasetsSettings.max_upload_mb`` at " - "runtime." - ), - value_type=SettingValueType.INT, - ), - SettingDefinition( - key=constants.SETTING_DEFAULT_KIND, - default=constants.DatasetKind.OTHER, - description=( - "Dataset kind assigned when filename-based detection comes " - "back as ``other``. Useful for instances that know they only " - "ingest one kind (e.g. always ``vector_geojson``)." - ), - ), - ] diff --git a/modules/datasets/datasets/pages/Browse.tsx b/modules/datasets/datasets/pages/Browse.tsx deleted file mode 100644 index 1072f090..00000000 --- a/modules/datasets/datasets/pages/Browse.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { Link, router, usePage } from '@inertiajs/react'; -import { keys, useT } from '@simple-module-py/i18n'; -import { PageShell } from '@simple-module-py/ui/components/PageShell'; -import { Badge } from '@simple-module-py/ui/components/ui/badge'; -import { Button } from '@simple-module-py/ui/components/ui/button'; -import { Card } from '@simple-module-py/ui/components/ui/card'; -import { - Empty, - EmptyDescription, - EmptyMedia, - EmptyTitle, -} from '@simple-module-py/ui/components/ui/empty'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@simple-module-py/ui/components/ui/table'; -import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions'; -import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; -import { Layers, Plus, Trash2 } from 'lucide-react'; - -interface Dataset { - id: number; - name: string; - slug: string; - kind: string; - size_bytes: number; - crs: string | null; - bbox_min_x: number | null; - bbox_min_y: number | null; - bbox_max_x: number | null; - bbox_max_y: number | null; - extraction_status: string; - created_at: string | null; -} - -interface Props { - datasets: Dataset[]; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} - -function formatBbox(d: Dataset): string { - if ( - d.bbox_min_x === null || - d.bbox_min_y === null || - d.bbox_max_x === null || - d.bbox_max_y === null - ) { - return '—'; - } - return `[${d.bbox_min_x.toFixed(3)}, ${d.bbox_min_y.toFixed(3)}, ${d.bbox_max_x.toFixed(3)}, ${d.bbox_max_y.toFixed(3)}]`; -} - -function Browse() { - const { datasets } = usePage<{ props: Props }>().props as unknown as Props; - const { t } = useT(); - const { can } = usePermissions(); - const canUpload = can('datasets.upload'); - const canDelete = can('datasets.delete'); - - function handleDelete(dataset: Dataset) { - router.delete(`/datasets/${dataset.id}`, { preserveScroll: true }); - } - - return ( - - - - {t(keys.datasets.browse.new_button)} - - - ) : undefined - } - > - - - - - {t(keys.datasets.table.name)} - {t(keys.datasets.table.kind)} - - {t(keys.datasets.table.crs)} - - - {t(keys.datasets.table.bbox)} - - {t(keys.datasets.table.size)} - {t(keys.datasets.table.actions)} - - - - {datasets.map((dataset) => ( - - - - {dataset.name} - - - - {dataset.kind} - - - {dataset.crs || '—'} - - - {formatBbox(dataset)} - - - {formatBytes(dataset.size_bytes)} - - - {canDelete && ( - - )} - - - ))} - {datasets.length === 0 && ( - - - - - - - {t(keys.datasets.browse.empty_title)} - {t(keys.datasets.browse.empty_description)} - {canUpload && ( - - )} - - - - )} - -
-
-
- ); -} - -Browse.layout = (page: React.ReactNode) => {page}; -export default Browse; diff --git a/modules/datasets/datasets/pages/Create.tsx b/modules/datasets/datasets/pages/Create.tsx deleted file mode 100644 index 4be2b85d..00000000 --- a/modules/datasets/datasets/pages/Create.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { Link, router } from '@inertiajs/react'; -import { keys, useT } from '@simple-module-py/i18n'; -import { PageShell } from '@simple-module-py/ui/components/PageShell'; -import { Button } from '@simple-module-py/ui/components/ui/button'; -import { Card, CardContent } from '@simple-module-py/ui/components/ui/card'; -import { Input } from '@simple-module-py/ui/components/ui/input'; -import { Label } from '@simple-module-py/ui/components/ui/label'; -import { Textarea } from '@simple-module-py/ui/components/ui/textarea'; -import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; -import { useState } from 'react'; -import { toast } from 'sonner'; - -const KINDS = [ - '', - 'vector_geojson', - 'vector_shapefile', - 'vector_kml', - 'raster_geotiff', - 'tabular_csv', - 'other', -]; - -function Create() { - const { t } = useT(); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [kind, setKind] = useState(''); - const [file, setFile] = useState(null); - const [submitting, setSubmitting] = useState(false); - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!file) { - toast.error(t(keys.datasets.validation.file_required)); - return; - } - if (!name.trim()) { - toast.error(t(keys.datasets.validation.name_required)); - return; - } - const data = new FormData(); - data.append('name', name); - if (description) data.append('description', description); - if (kind) data.append('kind', kind); - data.append('file', file); - - setSubmitting(true); - router.post('/datasets/', data, { - forceFormData: true, - onSuccess: () => toast.success(t(keys.datasets.toasts.created)), - onError: (errs) => { - const first = Object.values(errs)[0]; - if (first) toast.error(String(first)); - }, - onFinish: () => setSubmitting(false), - }); - } - - return ( - - {t(keys.datasets.form.cancel_button)} - - } - > - - -
-
- - setName(e.target.value)} - placeholder={t(keys.datasets.form.name_placeholder)} - maxLength={200} - required - /> -
- -
- - -
- -
- - setFile(e.target.files?.[0] ?? null)} - required - /> -
- -
- -