diff --git a/.gitignore b/.gitignore index 5a970b4c..5bca149a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ node_modules/ *.db *.sqlite3 +# Module-managed runtime state (e.g. uploaded dataset files, +# default storage_dir for SM_DATASETS_STORAGE_DIR). +var/ + # file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH). uploads/ diff --git a/framework/hosting/simple_module_hosting/i18n_manifest.py b/framework/hosting/simple_module_hosting/i18n_manifest.py index 37b01243..ec1e1a44 100644 --- a/framework/hosting/simple_module_hosting/i18n_manifest.py +++ b/framework/hosting/simple_module_hosting/i18n_manifest.py @@ -75,15 +75,12 @@ def emit_frontend_types(registry: I18nRegistry, project_root: Path) -> None: logger.exception("Failed to write generated-resources.ts — frontend types will be stale") -_RESOURCES_HEADER = """\ -// AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. -// Regenerate by booting the host in development mode. -""" +_RESOURCES_HEADER = ( + "// AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand.\n" + "// Regenerate by booting the host in development mode." +) -_KEYS_HEADER = """\ -// AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. -// Regenerate by booting the host in development mode. -""" +_KEYS_HEADER = _RESOURCES_HEADER _PLURAL_SUFFIXES: tuple[str, ...] = tuple(f"_{c}" for c in PLURAL_CATEGORIES) @@ -125,7 +122,7 @@ def _render_resources(keys: list[str]) -> str: def _render_keys(keys: list[str]) -> str: """Render the nested ``keys`` constant tree.""" tree = _build_key_tree(keys) - return f"{_KEYS_HEADER}\nexport const keys = {_serialize(tree, indent=0)} as const;\n" + return f"{_KEYS_HEADER}\n\nexport const keys = {_serialize(tree, indent=0)} as const;\n" def _build_key_tree(flat_keys: list[str]) -> dict[str, Any]: diff --git a/framework/hosting/tests/test_health.py b/framework/hosting/tests/test_health.py index b1413d5e..0192b553 100644 --- a/framework/hosting/tests/test_health.py +++ b/framework/hosting/tests/test_health.py @@ -76,7 +76,12 @@ async def check_broken(): assert "connection refused" in data["checks"]["broken_service"]["detail"] async def test_ready_no_checks_is_healthy(self, client: httpx.AsyncClient): + # Modules may register their own health checks (e.g. gis_datasets + # probes its storage dir). Assert the aggregate is healthy and + # every reported check is healthy, rather than requiring zero + # checks — which would break whenever a new module joins boot. resp = await client.get("/health/ready") data = resp.json() assert data["status"] == "healthy" - assert data["checks"] == {} + for name, payload in data["checks"].items(): + assert payload["status"] == "healthy", f"{name} not healthy: {payload}" diff --git a/host/migrations/versions/dad9a134290f_add_datasets_table.py b/host/migrations/versions/dad9a134290f_add_datasets_table.py new file mode 100644 index 00000000..8114b9bb --- /dev/null +++ b/host/migrations/versions/dad9a134290f_add_datasets_table.py @@ -0,0 +1,69 @@ +"""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/pyproject.toml b/host/pyproject.toml index 4e29e6d3..2b74ec5f 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "auth", "dashboard", "products", + "datasets", "permissions", "background-tasks", "file-storage", @@ -21,6 +22,7 @@ simple-module-hosting = { workspace = true } auth = { workspace = true } products = { workspace = true } dashboard = { workspace = true } +datasets = { workspace = true } permissions = { workspace = true } background-tasks = { workspace = true } file-storage = { workspace = true } diff --git a/modules/datasets/datasets/__init__.py b/modules/datasets/datasets/__init__.py new file mode 100644 index 00000000..fb7083d7 --- /dev/null +++ b/modules/datasets/datasets/__init__.py @@ -0,0 +1 @@ +"""Datasets module.""" diff --git a/modules/datasets/datasets/constants.py b/modules/datasets/datasets/constants.py new file mode 100644 index 00000000..584604a4 --- /dev/null +++ b/modules/datasets/datasets/constants.py @@ -0,0 +1,146 @@ +"""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 new file mode 100644 index 00000000..8889bec9 --- /dev/null +++ b/modules/datasets/datasets/contracts/__init__.py @@ -0,0 +1,51 @@ +"""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 new file mode 100644 index 00000000..19f4c5c9 --- /dev/null +++ b/modules/datasets/datasets/contracts/events.py @@ -0,0 +1,28 @@ +"""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 new file mode 100644 index 00000000..48e84609 --- /dev/null +++ b/modules/datasets/datasets/contracts/files.py @@ -0,0 +1,88 @@ +"""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 new file mode 100644 index 00000000..d0d902ad --- /dev/null +++ b/modules/datasets/datasets/contracts/schemas.py @@ -0,0 +1,64 @@ +"""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 new file mode 100644 index 00000000..680424d9 --- /dev/null +++ b/modules/datasets/datasets/contracts/urls.py @@ -0,0 +1,26 @@ +"""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 new file mode 100644 index 00000000..25270e4b --- /dev/null +++ b/modules/datasets/datasets/deps.py @@ -0,0 +1,97 @@ +"""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 new file mode 100644 index 00000000..e69de29b diff --git a/modules/datasets/datasets/endpoints/api.py b/modules/datasets/datasets/endpoints/api.py new file mode 100644 index 00000000..64661211 --- /dev/null +++ b/modules/datasets/datasets/endpoints/api.py @@ -0,0 +1,222 @@ +"""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}, + ) + + +@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: + 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.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 new file mode 100644 index 00000000..e4b7f3a6 --- /dev/null +++ b/modules/datasets/datasets/endpoints/views.py @@ -0,0 +1,78 @@ +"""Inertia view endpoints for the Datasets module.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends +from inertia import InertiaResponse +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.permissions import RequiresPermission + +from datasets import constants +from datasets.deps import get_dataset_service +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.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")}) diff --git a/modules/datasets/datasets/extractors.py b/modules/datasets/datasets/extractors.py new file mode 100644 index 00000000..97e573da --- /dev/null +++ b/modules/datasets/datasets/extractors.py @@ -0,0 +1,188 @@ +"""Best-effort metadata extraction for uploaded datasets. + +The stdlib JSON path covers GeoJSON without any extra 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 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 = _features(doc) + bbox = doc.get("bbox") if isinstance(doc, dict) else None + if not bbox: + bbox = _compute_bbox(features) + + if bbox is None: + 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 _features(doc: object) -> list[dict]: + if isinstance(doc, dict): + if doc.get("type") == "FeatureCollection": + features = doc.get("features", []) + return [f for f in features if isinstance(f, dict)] + if doc.get("type") == "Feature": + return [doc] + return [] + + +def _compute_bbox(features: list[dict]) -> tuple[float, float, float, float] | None: + min_x = min_y = float("inf") + max_x = max_y = float("-inf") + for feature in features: + geom = feature.get("geometry") if isinstance(feature, dict) else None + if not isinstance(geom, dict): + continue + for x, y in _iter_coords(geom): + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x) + max_y = max(max_y, y) + if min_x == float("inf"): + return None + return min_x, min_y, max_x, max_y + + +def _iter_coords(geom: dict): + coords = geom.get("coordinates") + geom_type = geom.get("type") + if geom_type == "GeometryCollection": + for sub in geom.get("geometries", []): + if isinstance(sub, dict): + yield from _iter_coords(sub) + return + yield from _walk(coords) + + +def _walk(node: object): + if ( + isinstance(node, list) + and len(node) >= 2 + and all(isinstance(v, (int, float)) for v in node[:2]) + ): + yield float(node[0]), float(node[1]) + return + if isinstance(node, list): + for child in node: + yield from _walk(child) + + +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 new file mode 100644 index 00000000..b49266d1 --- /dev/null +++ b/modules/datasets/datasets/locales/en.json @@ -0,0 +1,67 @@ +{ + "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 new file mode 100644 index 00000000..8c19e5cd --- /dev/null +++ b/modules/datasets/datasets/models.py @@ -0,0 +1,49 @@ +"""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 new file mode 100644 index 00000000..faf4a860 --- /dev/null +++ b/modules/datasets/datasets/module.py @@ -0,0 +1,144 @@ +"""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: + from datasets.services import DatasetsServices + from datasets.settings import DatasetsSettings + + app.state.datasets = DatasetsServices(settings=DatasetsSettings()) + + 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 " + "``SM_DATASETS_MAX_UPLOAD_MB`` env default 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 new file mode 100644 index 00000000..95d8140b --- /dev/null +++ b/modules/datasets/datasets/pages/Browse.tsx @@ -0,0 +1,165 @@ +import { Link, router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { Badge } from '@simple-module/ui/components/ui/badge'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { Card } from '@simple-module/ui/components/ui/card'; +import { + Empty, + EmptyDescription, + EmptyMedia, + EmptyTitle, +} from '@simple-module/ui/components/ui/empty'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simple-module/ui/components/ui/table'; +import { usePermissions } from '@simple-module/ui/hooks/use-permissions'; +import { AuthenticatedLayout } from '@simple-module/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(`/api/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 && ( + handleDelete(dataset)} + > + + + )} + + + ))} + {datasets.length === 0 && ( + + + + + + + {t(keys.datasets.browse.empty_title)} + {t(keys.datasets.browse.empty_description)} + {canUpload && ( + + {t(keys.datasets.browse.create_button)} + + )} + + + + )} + + + + + ); +} + +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 new file mode 100644 index 00000000..0c52c02a --- /dev/null +++ b/modules/datasets/datasets/pages/Create.tsx @@ -0,0 +1,144 @@ +import { Link, router } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { Card, CardContent } from '@simple-module/ui/components/ui/card'; +import { Input } from '@simple-module/ui/components/ui/input'; +import { Label } from '@simple-module/ui/components/ui/label'; +import { Textarea } from '@simple-module/ui/components/ui/textarea'; +import { AuthenticatedLayout } from '@simple-module/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('/api/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)} + + } + > + + + + + + {t(keys.datasets.form.name_label)} * + + setName(e.target.value)} + placeholder={t(keys.datasets.form.name_placeholder)} + maxLength={200} + required + /> + + + + {t(keys.datasets.form.kind_label)} + setKind(e.target.value)} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + > + {KINDS.map((k) => ( + + {k || t(keys.datasets.form.kind_auto)} + + ))} + + + + + + {t(keys.datasets.form.file_label)} * + + setFile(e.target.files?.[0] ?? null)} + required + /> + + + + {t(keys.datasets.form.description_label)} + setDescription(e.target.value)} + rows={4} + placeholder={t(keys.datasets.form.description_placeholder)} + maxLength={2000} + /> + + + + + {submitting + ? t(keys.datasets.create.submitting_button) + : t(keys.datasets.create.submit_button)} + + + {t(keys.datasets.form.cancel_button)} + + + + + + + ); +} + +Create.layout = (page: React.ReactNode) => {page}; +export default Create; diff --git a/modules/datasets/datasets/pages/Edit.tsx b/modules/datasets/datasets/pages/Edit.tsx new file mode 100644 index 00000000..743c6b50 --- /dev/null +++ b/modules/datasets/datasets/pages/Edit.tsx @@ -0,0 +1,145 @@ +import { Link, router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { Card, CardContent } from '@simple-module/ui/components/ui/card'; +import { Input } from '@simple-module/ui/components/ui/input'; +import { Label } from '@simple-module/ui/components/ui/label'; +import { Textarea } from '@simple-module/ui/components/ui/textarea'; +import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface Dataset { + id: number; + name: string; + kind: string; + description: string | null; + crs: string | null; +} + +interface Props { + dataset: Dataset; +} + +const KINDS = [ + 'vector_geojson', + 'vector_shapefile', + 'vector_kml', + 'raster_geotiff', + 'tabular_csv', + 'other', +]; + +function Edit() { + const { dataset } = usePage<{ props: Props }>().props as unknown as Props; + const { t } = useT(); + const [name, setName] = useState(dataset.name); + const [description, setDescription] = useState(dataset.description ?? ''); + const [kind, setKind] = useState(dataset.kind); + const [crs, setCrs] = useState(dataset.crs ?? ''); + const [submitting, setSubmitting] = useState(false); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + router.patch( + `/api/datasets/${dataset.id}`, + { name, description: description || null, kind, crs: crs || null }, + { + onSuccess: () => { + toast.success(t(keys.datasets.toasts.updated)); + router.visit('/datasets'); + }, + onError: (errs) => { + const first = Object.values(errs)[0]; + if (first) toast.error(String(first)); + }, + onFinish: () => setSubmitting(false), + }, + ); + } + + return ( + + {t(keys.datasets.edit.back_button)} + + } + > + + + + + + {t(keys.datasets.form.name_label)} * + + setName(e.target.value)} + maxLength={200} + required + /> + + + + {t(keys.datasets.form.kind_label)} + setKind(e.target.value)} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + > + {KINDS.map((k) => ( + + {k} + + ))} + + + + + {t(keys.datasets.form.crs_label)} + setCrs(e.target.value)} + placeholder="EPSG:4326" + maxLength={64} + /> + + + + {t(keys.datasets.form.description_label)} + setDescription(e.target.value)} + rows={4} + maxLength={2000} + /> + + + + + {submitting + ? t(keys.datasets.edit.submitting_button) + : t(keys.datasets.edit.submit_button)} + + + {t(keys.datasets.form.cancel_button)} + + + + + + + ); +} + +Edit.layout = (page: React.ReactNode) => {page}; +export default Edit; diff --git a/modules/datasets/datasets/pages/Show.tsx b/modules/datasets/datasets/pages/Show.tsx new file mode 100644 index 00000000..601ff61c --- /dev/null +++ b/modules/datasets/datasets/pages/Show.tsx @@ -0,0 +1,112 @@ +import { Link, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { Badge } from '@simple-module/ui/components/ui/badge'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { Card, CardContent } from '@simple-module/ui/components/ui/card'; +import { usePermissions } from '@simple-module/ui/hooks/use-permissions'; +import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; +import { Download, Pencil } from 'lucide-react'; + +interface Dataset { + id: number; + name: string; + slug: string; + kind: string; + description: string | null; + original_filename: string; + mime_type: string | null; + 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; + feature_count: number | null; + band_count: number | null; + extraction_status: string; + created_at: string | null; +} + +interface Props { + dataset: Dataset; +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + {label} + {children} + + ); +} + +function Show() { + const { dataset } = usePage<{ props: Props }>().props as unknown as Props; + const { t } = useT(); + const { can } = usePermissions(); + + const bbox = + dataset.bbox_min_x !== null && + dataset.bbox_min_y !== null && + dataset.bbox_max_x !== null && + dataset.bbox_max_y !== null + ? `${dataset.bbox_min_x}, ${dataset.bbox_min_y}, ${dataset.bbox_max_x}, ${dataset.bbox_max_y}` + : null; + + return ( + + + + + {t(keys.datasets.show.download)} + + + {can('datasets.edit') && ( + + + + {t(keys.datasets.show.edit)} + + + )} + + } + > + + + + + {dataset.kind} + + + {dataset.original_filename} + + {dataset.size_bytes} bytes + {dataset.mime_type ?? '—'} + {dataset.crs ?? '—'} + + {bbox ? {bbox} : '—'} + + {dataset.feature_count !== null && ( + {dataset.feature_count} + )} + {dataset.band_count !== null && ( + {dataset.band_count} + )} + + {dataset.extraction_status} + + + + + + ); +} + +Show.layout = (page: React.ReactNode) => {page}; +export default Show; diff --git a/modules/datasets/datasets/py.typed b/modules/datasets/datasets/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/modules/datasets/datasets/service.py b/modules/datasets/datasets/service.py new file mode 100644 index 00000000..e25a224a --- /dev/null +++ b/modules/datasets/datasets/service.py @@ -0,0 +1,228 @@ +"""Dataset service: orchestrates DB rows, backend IO, and metadata extraction. + +Bytes live in a ``file_storage.StorageBackend`` — the datasets module does +not own any filesystem of its own. That hands S3 / GCS / Azure support to +every consumer for free the moment the host swaps ``SM_FILE_STORAGE_BACKEND``. +""" + +from __future__ import annotations + +import contextlib +import re +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +from file_storage.contracts.service import StorageBackend, StorageNotFoundError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from datasets import constants +from datasets.contracts.files import DatasetFile +from datasets.contracts.schemas import DatasetOut, DatasetUpdate +from datasets.extractors import kind_for_filename +from datasets.models import Dataset + + +@dataclass +class UploadInput: + """Inputs for ``DatasetService.register_upload``. + + The endpoint streams the upload to ``temp_path`` first so this layer + can extract metadata from a local file (fiona/rasterio need a path), + then upload the bytes to the storage backend and persist the row. + """ + + name: str + original_filename: str + temp_path: Path + size_bytes: int + mime_type: str | None + description: str | None = None + kind: str | None = None + + +_SLUG_RE = re.compile(r"[^a-z0-9]+") +_UNSAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+") + + +_DEFAULT_SLUG: Final = "dataset" + + +def slugify(value: str) -> str: + slug = _SLUG_RE.sub("-", value.lower()).strip("-") + return slug or _DEFAULT_SLUG + + +def safe_filename(name: str) -> str: + """Return a filename safe to log / surface in URLs.""" + fallback = constants.DEFAULT_FALLBACK_FILENAME + base = Path(name).name or fallback + cleaned = _UNSAFE_NAME_RE.sub("_", base).strip("._") or fallback + return cleaned[:200] + + +def _storage_key(dataset_id: int, original_filename: str) -> str: + """Build a backend key scoped under the datasets namespace. + + The module-owned prefix (see ``constants.STORAGE_KEY_PREFIX``) keeps + dataset bytes discoverable alongside other file_storage tenants + without namespace collisions. A uuid suffix guarantees uniqueness + even when a dataset is re-uploaded under the same id. + """ + suffix = Path(original_filename).suffix.lower() + return f"{constants.STORAGE_KEY_PREFIX}{dataset_id}/{uuid.uuid4().hex}{suffix}" + + +async def _file_stream( + path: Path, chunk_size: int = constants.DEFAULT_UPLOAD_CHUNK_SIZE +) -> AsyncIterator[bytes]: + with path.open("rb") as fp: + while True: + chunk = fp.read(chunk_size) + if not chunk: + break + yield chunk + + +class DatasetService: + """CRUD + upload orchestration for datasets. + + Downstream modules obtain an instance via + ``datasets.deps.DatasetServiceDep`` and type-hint against this class + directly — there's no Protocol abstraction since there's only one + implementation. + """ + + def __init__(self, db: AsyncSession, backend: StorageBackend) -> None: + self.db = db + self.backend = backend + + # ── Lookups ────────────────────────────────────────────────────── + + async def get_all(self) -> list[DatasetOut]: + result = await self.db.execute(select(Dataset).order_by(Dataset.id.desc())) + return [DatasetOut.model_validate(row) for row in result.scalars()] + + async def get_by_id(self, dataset_id: int) -> DatasetOut | None: + entity = await self.db.get(Dataset, dataset_id) + if entity is None: + return None + return DatasetOut.model_validate(entity) + + async def get_by_slug(self, slug: str) -> DatasetOut | None: + result = await self.db.execute(select(Dataset).where(Dataset.slug == slug)) + entity = result.scalar_one_or_none() + if entity is None: + return None + return DatasetOut.model_validate(entity) + + async def list_by_kind(self, kind: str, *, limit: int | None = None) -> list[DatasetOut]: + stmt = select(Dataset).where(Dataset.kind == kind).order_by(Dataset.id.desc()) + if limit is not None: + stmt = stmt.limit(limit) + result = await self.db.execute(stmt) + return [DatasetOut.model_validate(row) for row in result.scalars()] + + # ── File access ────────────────────────────────────────────────── + + async def get_file(self, dataset_id: int) -> DatasetFile | None: + entity = await self.db.get(Dataset, dataset_id) + if entity is None: + return None + return DatasetFile( + metadata=DatasetOut.model_validate(entity), + storage_key=entity.storage_key, + original_filename=entity.original_filename, + mime_type=entity.mime_type, + _backend=self.backend, + ) + + # ── Upload ─────────────────────────────────────────────────────── + + async def register_upload(self, payload: UploadInput) -> DatasetOut: + """Persist a dataset row and upload the bytes to the storage + backend. Metadata extraction is deferred — the row lands with + ``extraction_status="pending"`` and the caller is expected to + enqueue ``datasets.extract_metadata`` via Celery so the worker + runs the (potentially slow) fiona/rasterio parse off the + request path.""" + kind = payload.kind or kind_for_filename(payload.original_filename) + + slug = await self._unique_slug(slugify(payload.name)) + entity = Dataset( + name=payload.name, + slug=slug, + kind=kind, + description=payload.description, + original_filename=safe_filename(payload.original_filename), + mime_type=payload.mime_type, + size_bytes=payload.size_bytes, + storage_key="", # filled in once we have entity.id + extraction_status=constants.ExtractionStatus.PENDING, + ) + self.db.add(entity) + await self.db.flush() # assigns entity.id + + storage_key = _storage_key(entity.id, payload.original_filename) + try: + await self.backend.put( + storage_key, + _file_stream(payload.temp_path), + content_type=payload.mime_type or constants.DEFAULT_MIME_TYPE, + size=payload.size_bytes, + ) + except Exception: + await self.db.delete(entity) + await self.db.flush() + raise + + entity.storage_key = storage_key + await self.db.flush() + await self.db.refresh(entity) + return DatasetOut.model_validate(entity) + + # ── Mutations ──────────────────────────────────────────────────── + + async def update(self, dataset_id: int, data: DatasetUpdate) -> DatasetOut | None: + entity = await self.db.get(Dataset, dataset_id) + if entity is None: + return None + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(entity, field, value) + await self.db.flush() + await self.db.refresh(entity) + return DatasetOut.model_validate(entity) + + async def delete(self, dataset_id: int) -> bool: + entity = await self.db.get(Dataset, dataset_id) + if entity is None: + return False + storage_key = entity.storage_key + await self.db.delete(entity) + await self.db.flush() + # Delete the object last so a DB rollback can't orphan metadata + # pointing at already-removed bytes. Missing objects are fine. + if storage_key: + with contextlib.suppress(StorageNotFoundError): + await self.backend.delete(storage_key) + return True + + # ── Internal ───────────────────────────────────────────────────── + + async def _unique_slug(self, base: str) -> str: + candidate = base + suffix = 2 + while await self._slug_exists(candidate): + candidate = f"{base}-{suffix}" + suffix += 1 + return candidate + + async def _slug_exists(self, slug: str) -> bool: + result = await self.db.execute(select(Dataset.id).where(Dataset.slug == slug)) + return result.scalar_one_or_none() is not None + + +__all__ = ["DatasetService", "UploadInput", "safe_filename", "slugify"] diff --git a/modules/datasets/datasets/services.py b/modules/datasets/datasets/services.py new file mode 100644 index 00000000..ec220725 --- /dev/null +++ b/modules/datasets/datasets/services.py @@ -0,0 +1,22 @@ +"""Module-scoped state container for the Datasets module. + +Stored as ``app.state.datasets`` by :meth:`DatasetsModule.register_settings`. +The storage backend is not held here — it lives on +``app.state.file_storage.backend`` (owned by the ``file_storage`` module) +and is fetched lazily per request. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datasets.settings import DatasetsSettings + + +@dataclass +class DatasetsServices: + """Datasets module singletons.""" + + settings: DatasetsSettings diff --git a/modules/datasets/datasets/settings.py b/modules/datasets/datasets/settings.py new file mode 100644 index 00000000..0c86136b --- /dev/null +++ b/modules/datasets/datasets/settings.py @@ -0,0 +1,29 @@ +"""Datasets module settings loaded from SM_DATASETS_* environment variables. + +Bytes storage is delegated to the ``file_storage`` module — its +``SM_FILE_STORAGE_*`` variables (backend, FS root, S3 bucket, etc.) are +the single source of truth. Datasets owns only its catalog-specific knobs. +""" + +from __future__ import annotations + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from datasets import constants + + +class DatasetsSettings(BaseSettings): + """Configuration for the datasets catalog.""" + + model_config = SettingsConfigDict( + env_prefix=constants.ENV_PREFIX, + env_file=".env", + extra="ignore", + ) + + max_upload_mb: int = Field( + default=constants.DEFAULT_MAX_UPLOAD_MB, + gt=0, + description="Maximum upload size per dataset in megabytes.", + ) diff --git a/modules/datasets/datasets/tasks.py b/modules/datasets/datasets/tasks.py new file mode 100644 index 00000000..682438b4 --- /dev/null +++ b/modules/datasets/datasets/tasks.py @@ -0,0 +1,140 @@ +"""Celery tasks for the Datasets module. + +Autodiscovered by ``background_tasks.celery_app``: any module that ships a +``tasks.py`` at its top level is picked up with no extra registration. + +The upload endpoint leaves newly-created datasets with +``extraction_status="pending"``; this module's +:func:`extract_metadata_task` does the actual work in a worker. + +Why this is async: +* JSON parsing is cheap but shapefile (``fiona``) and raster + (``rasterio``) metadata reads can block for tens of seconds on a + large upload — far too long to hold an HTTP request open. +* S3/remote backends make even streaming bytes to a temp file an IO + wait we'd rather not burn request-handler time on. + +The task updates the dataset row directly via the background_tasks +sync session — the Celery worker runs in its own process with no +access to ``app.state``. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import tempfile +from pathlib import Path + +from background_tasks.sync_db import sync_session +from celery import shared_task + +from datasets import constants +from datasets.extractors import extract_metadata +from datasets.models import Dataset + +logger = logging.getLogger(__name__) + + +@shared_task(name=constants.TASK_EXTRACT_METADATA) +def extract_metadata_task(dataset_id: int) -> dict[str, str | int | None]: + """Fetch the dataset's bytes from file_storage, extract metadata, + and update the Dataset row. + + Always returns a status summary (never raises), so a partial + extraction failure shows up in the worker UI as a successful + execution whose ``result.status`` is ``"failed"`` rather than as a + crashed task. The row itself reflects the outcome in + ``extraction_status``. + """ + storage_key, kind, original_filename = _load_source(dataset_id) + if storage_key is None: + return {"dataset_id": dataset_id, "status": constants.ExtractionStatus.NOT_FOUND} + + try: + local_path = _download_to_tempfile(storage_key, original_filename) + except Exception: + # Catching broad Exception is intentional: any backend IO failure + # (network, auth, missing key) is recorded as ``failed`` and + # surfaces in the worker UI rather than crashing the Celery task. + logger.exception("Download failed for dataset %s (key=%s)", dataset_id, storage_key) + _mark_status(dataset_id, status=constants.ExtractionStatus.FAILED) + return {"dataset_id": dataset_id, "status": constants.ExtractionStatus.FAILED} + + try: + meta = extract_metadata(local_path, kind) + finally: + local_path.unlink(missing_ok=True) + + _apply_metadata(dataset_id, meta) + return { + "dataset_id": dataset_id, + "status": meta.status, + "feature_count": meta.feature_count, + "crs": meta.crs, + } + + +def _load_source(dataset_id: int) -> tuple[str | None, str, str]: + """Fetch (storage_key, kind, original_filename) for the dataset. + + Returns ``(None, "", "")`` if the row is missing — the caller maps + that to a no-op. + """ + with sync_session() as session: + row = session.get(Dataset, dataset_id) + if row is None: + return None, "", "" + return row.storage_key, row.kind, row.original_filename + + +def _download_to_tempfile(storage_key: str, original_filename: str) -> Path: + """Stream ``storage_key`` from the configured file_storage backend into + a local tempfile. The file_storage backend is async, so we drive it + from a short-lived asyncio loop inside the sync Celery task.""" + from file_storage.backends import build_backend + from file_storage.settings import FileStorageSettings + + backend = build_backend(FileStorageSettings()) + suffix = Path(original_filename).suffix + fd, tmp_path = tempfile.mkstemp(suffix=suffix) + + async def _download() -> None: + stream = await backend.get(storage_key) + async for chunk in stream: + os.write(fd, chunk) + + try: + asyncio.run(_download()) + finally: + os.close(fd) + return Path(tmp_path) + + +def _apply_metadata(dataset_id: int, meta) -> None: + with sync_session() as session: + row = session.get(Dataset, dataset_id) + if row is None: + return + row.crs = meta.crs + row.bbox_min_x = meta.bbox_min_x + row.bbox_min_y = meta.bbox_min_y + row.bbox_max_x = meta.bbox_max_x + row.bbox_max_y = meta.bbox_max_y + row.feature_count = meta.feature_count + row.band_count = meta.band_count + row.extraction_status = meta.status + + +def _mark_status(dataset_id: int, *, status: str) -> None: + with sync_session() as session: + row = session.get(Dataset, dataset_id) + if row is None: + return + row.extraction_status = status + + +# Re-exported for callers that want to refer to the task by name without +# importing ``constants`` directly. +EXTRACT_METADATA_TASK: str = constants.TASK_EXTRACT_METADATA diff --git a/modules/datasets/package.json b/modules/datasets/package.json new file mode 100644 index 00000000..2373e9d5 --- /dev/null +++ b/modules/datasets/package.json @@ -0,0 +1,16 @@ +{ + "name": "@simple-module/datasets", + "version": "0.1.0", + "private": true, + "description": "Frontend assets for the Datasets module", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@inertiajs/react": "^2.0.0", + "@simple-module/ui": "*" + }, + "devDependencies": { + "@simple-module/tsconfig": "*" + }, + "dependencies": {} +} diff --git a/modules/datasets/pyproject.toml b/modules/datasets/pyproject.toml new file mode 100644 index 00000000..1247d6ca --- /dev/null +++ b/modules/datasets/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "datasets" +version = "0.1.0" +description = "The Datasets module" +authors = [] +requires-python = ">=3.12" +dependencies = [ + "simple-module-core", + "simple-module-db", + "simple-module-hosting", + "file-storage", + "background-tasks", + "celery>=5.4", +] + +[project.entry-points.simple_module] +datasets = "datasets.module:DatasetsModule" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# Ship the module-root package.json inside the wheel so the host can +# discover JS deps via importlib.resources after a pip install. +[tool.hatch.build.targets.wheel.force-include] +"package.json" = "datasets/package.json" + +[tool.uv.sources] +simple-module-core = { workspace = true } +simple-module-db = { workspace = true } +simple-module-hosting = { workspace = true } +file-storage = { workspace = true } +background-tasks = { workspace = true } diff --git a/modules/datasets/tests/conftest.py b/modules/datasets/tests/conftest.py new file mode 100644 index 00000000..4106443a --- /dev/null +++ b/modules/datasets/tests/conftest.py @@ -0,0 +1,24 @@ +"""Shared fixtures for the datasets test suite. + +``BackgroundTasksModule.on_startup`` builds a live Celery app wired to +Redis. The datasets upload endpoint enqueues via ``send_task`` — against +a real Celery that'd require a broker in CI, which we don't want. Stub +it with a MagicMock so ``send_task`` is a recordable no-op; tests that +want to verify extraction drive ``extract_metadata_task`` directly. + +This mirrors the pattern used in +``modules/background_tasks/tests/test_admin_api.py``. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +async def _stub_celery(app) -> None: + celery = MagicMock(name="Celery") + celery.send_task.return_value.id = "mocked-celery-id" + app.state.background_tasks.celery = celery diff --git a/modules/datasets/tests/test_datasets.py b/modules/datasets/tests/test_datasets.py new file mode 100644 index 00000000..6c45577d --- /dev/null +++ b/modules/datasets/tests/test_datasets.py @@ -0,0 +1,285 @@ +"""Tests for the Datasets module.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest +from datasets.contracts.schemas import DatasetUpdate +from datasets.extractors import extract_metadata, kind_for_filename +from datasets.service import DatasetService, UploadInput, safe_filename, slugify +from file_storage.backends.filesystem import FilesystemBackend +from pydantic import ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +GEOJSON_SAMPLE = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [10.5, 20.5]}, + "properties": {"name": "A"}, + }, + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-5.0, 0.0]}, + "properties": {"name": "B"}, + }, + ], +} + + +def _backend(tmp_path: Path) -> FilesystemBackend: + root = tmp_path / "store" + root.mkdir(parents=True, exist_ok=True) + return FilesystemBackend(root=root) + + +def _make_temp_geojson(tmp_path: Path, name: str = "sample.geojson") -> tuple[Path, int]: + path = tmp_path / name + payload = json.dumps(GEOJSON_SAMPLE).encode() + path.write_bytes(payload) + return path, len(payload) + + +# ── safe_filename ─────────────────────────────────────────────────── + + +class TestSafeFilename: + def test_strips_path_components(self): + assert safe_filename("../../etc/passwd") == "passwd" + + def test_replaces_unsafe_chars(self): + assert safe_filename("my dataset (v2).geojson") == "my_dataset_v2_.geojson" + + def test_empty_falls_back_to_default(self): + assert safe_filename("") == "upload.bin" + + +# ── slugify ────────────────────────────────────────────────────────── + + +class TestSlugify: + def test_basic(self): + assert slugify("My Dataset") == "my-dataset" + + def test_collapses_punctuation(self): + assert slugify("foo!@#bar") == "foo-bar" + + def test_empty_falls_back(self): + assert slugify("!!!") == "dataset" + + +# ── extractors ────────────────────────────────────────────────────── + + +class TestExtractors: + def test_kind_for_filename(self): + assert kind_for_filename("a.geojson") == "vector_geojson" + assert kind_for_filename("a.shp") == "vector_shapefile" + assert kind_for_filename("a.tif") == "raster_geotiff" + assert kind_for_filename("a.csv") == "tabular_csv" + assert kind_for_filename("a.xyz") == "other" + + def test_geojson_bbox_and_count(self, tmp_path: Path): + path = tmp_path / "sample.geojson" + path.write_text(json.dumps(GEOJSON_SAMPLE)) + meta = extract_metadata(path, "vector_geojson") + assert meta.status == "ok" + assert meta.feature_count == 2 + assert meta.bbox_min_x == -5.0 + assert meta.bbox_max_x == 10.5 + assert meta.bbox_min_y == 0.0 + assert meta.bbox_max_y == 20.5 + assert meta.crs == "EPSG:4326" + + def test_invalid_geojson_marks_failed(self, tmp_path: Path): + path = tmp_path / "broken.geojson" + path.write_text("{not json") + meta = extract_metadata(path, "vector_geojson") + assert meta.status == "failed" + + def test_unknown_kind_returns_manual(self, tmp_path: Path): + path = tmp_path / "x.bin" + path.write_bytes(b"\x00\x01") + meta = extract_metadata(path, "other") + assert meta.status == "manual" + + +# ── DatasetService ────────────────────────────────────────────────── + + +class TestDatasetService: + async def test_register_upload_lands_bytes_and_marks_pending( + self, db_session: AsyncSession, tmp_path: Path + ): + """register_upload persists the row + bytes but does NOT extract + metadata — that's the Celery worker's job. The row lands with + ``extraction_status="pending"`` and no bbox/CRS/feature_count. + """ + svc = DatasetService(db_session, _backend(tmp_path)) + temp, size = _make_temp_geojson(tmp_path) + out = await svc.register_upload( + UploadInput( + name="My Dataset", + original_filename="sample.geojson", + temp_path=temp, + size_bytes=size, + mime_type="application/geo+json", + ) + ) + assert out.id is not None + assert out.kind == "vector_geojson" + assert out.extraction_status == "pending" + assert out.feature_count is None + assert out.crs is None + assert out.bbox_min_x is None + assert out.slug == "my-dataset" + + # Bytes landed in the backend and are readable. + handle = await svc.get_file(out.id) + assert handle is not None + assert await handle.exists() + + async def test_unique_slug_collision(self, db_session: AsyncSession, tmp_path: Path): + svc = DatasetService(db_session, _backend(tmp_path)) + a_path, a_size = _make_temp_geojson(tmp_path, "a.geojson") + b_path, b_size = _make_temp_geojson(tmp_path, "b.geojson") + first = await svc.register_upload( + UploadInput( + name="Same Name", + original_filename="a.geojson", + temp_path=a_path, + size_bytes=a_size, + mime_type=None, + ) + ) + second = await svc.register_upload( + UploadInput( + name="Same Name", + original_filename="b.geojson", + temp_path=b_path, + size_bytes=b_size, + mime_type=None, + ) + ) + assert first.slug == "same-name" + assert second.slug == "same-name-2" + + async def test_delete_removes_file_from_backend(self, db_session: AsyncSession, tmp_path: Path): + backend = _backend(tmp_path) + svc = DatasetService(db_session, backend) + path, size = _make_temp_geojson(tmp_path) + out = await svc.register_upload( + UploadInput( + name="Doomed", + original_filename="sample.geojson", + temp_path=path, + size_bytes=size, + mime_type=None, + ) + ) + handle = await svc.get_file(out.id) + assert handle is not None + assert await backend.exists(handle.storage_key) + + assert await svc.delete(out.id) is True + assert not await backend.exists(handle.storage_key) + + async def test_update_validation(self): + # crs longer than 64 chars should be rejected. + with pytest.raises(ValidationError): + DatasetUpdate(crs="x" * 200) + + +# ── API endpoints ─────────────────────────────────────────────────── + + +class TestDatasetsAPI: + async def test_list_empty(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/datasets/") + assert resp.status_code == 200 + assert resp.json() == [] + + async def test_upload_returns_pending_and_enqueues_extraction( + self, app, authenticated_client: httpx.AsyncClient + ): + from datasets.tasks import EXTRACT_METADATA_TASK + + payload = json.dumps(GEOJSON_SAMPLE).encode() + files = {"file": ("sample.geojson", payload, "application/geo+json")} + data = {"name": "My GeoJSON"} + resp = await authenticated_client.post("/api/datasets/", data=data, files=files) + assert resp.status_code == 201, resp.text + body = resp.json() + # Extraction is deferred — the HTTP response returns before the + # worker has touched the row. + assert body["kind"] == "vector_geojson" + assert body["extraction_status"] == "pending" + assert body["feature_count"] is None + + # The endpoint enqueued the right Celery task. + app.state.background_tasks.celery.send_task.assert_called_once() + name, kwargs = ( + app.state.background_tasks.celery.send_task.call_args.args[0], + app.state.background_tasks.celery.send_task.call_args.kwargs, + ) + assert name == EXTRACT_METADATA_TASK + assert kwargs["args"] == [body["id"]] + + # Bytes are downloadable even before the worker runs. + download = await authenticated_client.get(f"/api/datasets/{body['id']}/download") + assert download.status_code == 200 + assert download.content == payload + + async def test_upload_unknown_kind_rejected(self, authenticated_client: httpx.AsyncClient): + files = {"file": ("a.geojson", b"{}", "application/json")} + resp = await authenticated_client.post( + "/api/datasets/", + data={"name": "x", "kind": "not_a_kind"}, + files=files, + ) + assert resp.status_code == 422 + + async def test_delete(self, authenticated_client: httpx.AsyncClient): + files = {"file": ("doomed.geojson", b'{"type":"FeatureCollection","features":[]}', None)} + resp = await authenticated_client.post( + "/api/datasets/", data={"name": "Doomed"}, files=files + ) + assert resp.status_code == 201 + item_id = resp.json()["id"] + delete = await authenticated_client.delete(f"/api/datasets/{item_id}") + assert delete.status_code == 204 + gone = await authenticated_client.get(f"/api/datasets/{item_id}") + assert gone.status_code == 404 + + async def test_patch_metadata(self, authenticated_client: httpx.AsyncClient): + files = {"file": ("a.geojson", b'{"type":"FeatureCollection","features":[]}', None)} + create = await authenticated_client.post( + "/api/datasets/", data={"name": "Original"}, files=files + ) + item_id = create.json()["id"] + patch = await authenticated_client.patch( + f"/api/datasets/{item_id}", + json={"name": "Renamed", "description": "Notes"}, + ) + assert patch.status_code == 200 + assert patch.json()["name"] == "Renamed" + assert patch.json()["description"] == "Notes" + + +# ── Module wiring ─────────────────────────────────────────────────── + + +class TestDatasetsModule: + def test_meta(self): + from datasets.module import DatasetsModule + + mod = DatasetsModule() + assert mod.meta.name == "Datasets" + assert mod.meta.route_prefix == "/api/datasets" + assert mod.meta.view_prefix == "/datasets" + assert "FileStorage" in mod.meta.depends_on + assert "BackgroundTasks" in mod.meta.depends_on diff --git a/modules/datasets/tests/test_public_surface.py b/modules/datasets/tests/test_public_surface.py new file mode 100644 index 00000000..e5265cc8 --- /dev/null +++ b/modules/datasets/tests/test_public_surface.py @@ -0,0 +1,194 @@ +"""Contract tests for the Datasets module's public surface. + +Anything asserted here is something downstream modules depend on. Break +it and consuming modules break — treat every failure as an API-version +concern, not a cosmetic one. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from datasets.service import DatasetService, UploadInput +from file_storage.backends.filesystem import FilesystemBackend +from sqlalchemy.ext.asyncio import AsyncSession + +GEOJSON_SAMPLE = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [10.5, 20.5]}, + "properties": {"name": "A"}, + }, + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-5.0, 0.0]}, + "properties": {"name": "B"}, + }, + ], +} + + +def _backend(tmp_path: Path) -> FilesystemBackend: + root = tmp_path / "store" + root.mkdir(parents=True, exist_ok=True) + return FilesystemBackend(root=root) + + +def _make_temp_geojson(tmp_path: Path, name: str = "sample.geojson") -> tuple[Path, int]: + path = tmp_path / name + payload = json.dumps(GEOJSON_SAMPLE).encode() + path.write_bytes(payload) + return path, len(payload) + + +class TestContractsReexports: + def test_all_public_names_importable(self): + from datasets.contracts import ( # noqa: F401 — import check + API_PREFIX, + KIND_VALUES, + VIEW_PREFIX, + DatasetDeleted, + DatasetFile, + DatasetKind, + DatasetOut, + DatasetUpdate, + DatasetUploaded, + detail_url, + download_url, + show_url, + ) + + def test_url_helpers(self): + from datasets.contracts import API_PREFIX, VIEW_PREFIX, detail_url, download_url, show_url + + assert download_url(7) == "/api/datasets/7/download" + assert detail_url(7) == "/api/datasets/7" + assert show_url(7) == "/datasets/7" + assert API_PREFIX == "/api/datasets" + assert VIEW_PREFIX == "/datasets" + + def test_dep_alias_is_importable(self): + from datasets.deps import DatasetServiceDep # noqa: F401 + + +class TestEventShape: + def test_uploaded_carries_slug_and_kind(self): + from datasets.contracts import DatasetUploaded + + up = DatasetUploaded(dataset_id=1, name="Borders", slug="borders", kind="vector_geojson") + assert up.slug == "borders" + assert up.kind == "vector_geojson" + + def test_deleted_carries_slug(self): + from datasets.contracts import DatasetDeleted + + down = DatasetDeleted(dataset_id=1, slug="borders") + assert down.slug == "borders" + + +class TestLookupsForConsumers: + async def test_get_by_slug(self, db_session: AsyncSession, tmp_path: Path): + svc = DatasetService(db_session, _backend(tmp_path)) + path, size = _make_temp_geojson(tmp_path) + created = await svc.register_upload( + UploadInput( + name="World Borders", + original_filename="sample.geojson", + temp_path=path, + size_bytes=size, + mime_type=None, + ) + ) + found = await svc.get_by_slug(created.slug) + assert found is not None + assert found.id == created.id + assert await svc.get_by_slug("no-such-slug") is None + + async def test_list_by_kind(self, db_session: AsyncSession, tmp_path: Path): + svc = DatasetService(db_session, _backend(tmp_path)) + p1, s1 = _make_temp_geojson(tmp_path, "a.geojson") + p2, s2 = _make_temp_geojson(tmp_path, "b.geojson") + await svc.register_upload( + UploadInput( + name="A", + original_filename="a.geojson", + temp_path=p1, + size_bytes=s1, + mime_type=None, + ) + ) + await svc.register_upload( + UploadInput( + name="B", + original_filename="b.geojson", + temp_path=p2, + size_bytes=s2, + mime_type=None, + ) + ) + vectors = await svc.list_by_kind("vector_geojson") + assert len(vectors) == 2 + # Ordering is id desc — most recent first. + assert vectors[0].name == "B" + assert await svc.list_by_kind("raster_geotiff") == [] + + limited = await svc.list_by_kind("vector_geojson", limit=1) + assert len(limited) == 1 + + +class TestFileHandle: + async def test_get_file_returns_streamable_handle( + self, db_session: AsyncSession, tmp_path: Path + ): + from datasets.contracts import DatasetFile + + svc = DatasetService(db_session, _backend(tmp_path)) + path, size = _make_temp_geojson(tmp_path) + created = await svc.register_upload( + UploadInput( + name="Opener", + original_filename="sample.geojson", + temp_path=path, + size_bytes=size, + mime_type=None, + ) + ) + handle = await svc.get_file(created.id) + assert isinstance(handle, DatasetFile) + assert await handle.exists() + assert handle.metadata.id == created.id + body = await handle.read() + assert body.startswith(b"{") + assert len(body) == size + + async def test_materialize_to_tempfile_gives_local_path( + self, db_session: AsyncSession, tmp_path: Path + ): + svc = DatasetService(db_session, _backend(tmp_path)) + path, size = _make_temp_geojson(tmp_path) + created = await svc.register_upload( + UploadInput( + name="LocalUsage", + original_filename="sample.geojson", + temp_path=path, + size_bytes=size, + mime_type=None, + ) + ) + handle = await svc.get_file(created.id) + assert handle is not None + + local_path = await handle.materialize_to_tempfile() + try: + assert local_path.is_file() + assert local_path.suffix == ".geojson" + assert local_path.read_bytes().startswith(b"{") + finally: + local_path.unlink(missing_ok=True) + + async def test_get_file_missing_id(self, db_session: AsyncSession, tmp_path: Path): + svc = DatasetService(db_session, _backend(tmp_path)) + assert await svc.get_file(9999) is None diff --git a/modules/datasets/tests/test_tasks.py b/modules/datasets/tests/test_tasks.py new file mode 100644 index 00000000..7996b4da --- /dev/null +++ b/modules/datasets/tests/test_tasks.py @@ -0,0 +1,139 @@ +"""Unit tests for ``datasets.tasks.extract_metadata_task``. + +The task runs in a Celery worker — its DB goes through +``background_tasks.sync_db`` (sync engine reading ``SM_DATABASE_URL``) +and its bytes come from ``file_storage.build_backend`` (which re-reads +``SM_FILE_STORAGE_*`` env). Both are independent of the async test +``app`` fixture, so each test points them at tmp paths for isolation. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +GEOJSON_SAMPLE = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [10.5, 20.5]}, + "properties": {"name": "A"}, + }, + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-5.0, 0.0]}, + "properties": {"name": "B"}, + }, + ], +} + + +def _isolate_worker_env( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> tuple[Path, Path]: + """Configure ``sync_db`` + file_storage backend to point at tmp paths. + + Returns ``(sync_db_file, fs_root)``. + """ + from background_tasks import sync_db + from datasets.models import Dataset as _Dataset + + db_file = tmp_path / "worker.db" + fs_root = tmp_path / "fs" + fs_root.mkdir() + + monkeypatch.setenv("SM_DATABASE_URL", f"sqlite:///{db_file}") + monkeypatch.setenv("SM_FILE_STORAGE_BACKEND", "filesystem") + monkeypatch.setenv("SM_FILE_STORAGE_FS_ROOT_PATH", str(fs_root)) + + # Reset the process-global sync engine so the new URL is picked up. + sync_db._engine = None + sync_db._session_factory = None + + factory = sync_db.get_sync_session_factory() + _Dataset.metadata.create_all(factory.kw["bind"]) + return db_file, fs_root + + +def _teardown_sync_engine() -> None: + from background_tasks import sync_db + + if sync_db._engine is not None: + sync_db._engine.dispose() + sync_db._engine = None + sync_db._session_factory = None + + +def test_task_fills_in_bbox_and_feature_count(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from background_tasks import sync_db + from datasets.models import Dataset + from datasets.tasks import extract_metadata_task + from file_storage.backends.filesystem import FilesystemBackend + + _, fs_root = _isolate_worker_env(tmp_path, monkeypatch) + + payload = json.dumps(GEOJSON_SAMPLE).encode() + backend = FilesystemBackend(root=fs_root) + storage_key = "datasets/1/sample.geojson" + + async def _seed_bytes(): + async def stream(): + yield payload + + await backend.put( + storage_key, stream(), content_type="application/geo+json", size=len(payload) + ) + + asyncio.run(_seed_bytes()) + + factory = sync_db.get_sync_session_factory() + with factory() as session: + row = Dataset( + name="Worker Test", + slug="worker-test", + kind="vector_geojson", + original_filename="sample.geojson", + mime_type="application/geo+json", + size_bytes=len(payload), + storage_key=storage_key, + extraction_status="pending", + ) + session.add(row) + session.commit() + session.refresh(row) + dataset_id = row.id + + try: + result = extract_metadata_task(dataset_id) + assert result["status"] == "ok" + assert result["feature_count"] == 2 + assert result["crs"] == "EPSG:4326" + + with factory() as session: + row = session.get(Dataset, dataset_id) + assert row is not None + assert row.extraction_status == "ok" + assert row.feature_count == 2 + assert row.crs == "EPSG:4326" + assert row.bbox_min_x == -5.0 + assert row.bbox_max_x == 10.5 + finally: + _teardown_sync_engine() + + +def test_task_on_missing_row_is_noop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """A dataset deleted before the worker runs should collapse to + ``not_found``, never crash the task.""" + from datasets.tasks import extract_metadata_task + + _isolate_worker_env(tmp_path, monkeypatch) + try: + result = extract_metadata_task(999_999_999) + assert result["status"] == "not_found" + finally: + _teardown_sync_engine() diff --git a/modules/datasets/tests/test_wiring.py b/modules/datasets/tests/test_wiring.py new file mode 100644 index 00000000..29948401 --- /dev/null +++ b/modules/datasets/tests/test_wiring.py @@ -0,0 +1,131 @@ +"""Tests for the datasets module's feature flags + settings wiring.""" + +from __future__ import annotations + +import json + +import httpx +import pytest +from datasets import constants +from simple_module_core.feature_flags import FeatureFlagRegistry + +GEOJSON_PAYLOAD = json.dumps({"type": "FeatureCollection", "features": []}).encode() + + +class TestFeatureFlags: + """Verify the module registered the flags it advertises and that the + upload endpoint honours the flag toggles.""" + + async def test_flags_registered(self, app): + registry: FeatureFlagRegistry = app.state.sm.feature_flags + names = {flag.name for flag in registry.all_flags} + assert constants.FLAG_AUTO_EXTRACT in names + assert constants.FLAG_ALLOW_RASTER_UPLOADS in names + + async def test_auto_extract_default_on_enqueues_task( + self, app, authenticated_client: httpx.AsyncClient + ): + resp = await authenticated_client.post( + "/api/datasets/", + data={"name": "Flag On"}, + files={"file": ("a.geojson", GEOJSON_PAYLOAD, "application/geo+json")}, + ) + assert resp.status_code == 201, resp.text + app.state.background_tasks.celery.send_task.assert_called_once() + + async def test_disabling_auto_extract_skips_task( + self, app, authenticated_client: httpx.AsyncClient + ): + """With the flag off, the upload still succeeds but no Celery task + is enqueued — rows stay ``pending`` until an admin retriggers + extraction.""" + registry: FeatureFlagRegistry = app.state.sm.feature_flags + registry._flags[constants.FLAG_AUTO_EXTRACT] = registry._flags[ + constants.FLAG_AUTO_EXTRACT + ].__class__( + name=constants.FLAG_AUTO_EXTRACT, + description="...", + default_enabled=False, + ) + try: + resp = await authenticated_client.post( + "/api/datasets/", + data={"name": "Flag Off"}, + files={"file": ("a.geojson", GEOJSON_PAYLOAD, "application/geo+json")}, + ) + assert resp.status_code == 201 + app.state.background_tasks.celery.send_task.assert_not_called() + assert resp.json()["extraction_status"] == constants.ExtractionStatus.PENDING + finally: + # Restore the default-on definition for other tests. + from simple_module_core.feature_flags import FeatureFlagDefinition + + registry._flags[constants.FLAG_AUTO_EXTRACT] = FeatureFlagDefinition( + name=constants.FLAG_AUTO_EXTRACT, + description="...", + default_enabled=True, + ) + + async def test_raster_uploads_rejected_when_flag_off( + self, app, authenticated_client: httpx.AsyncClient + ): + from simple_module_core.feature_flags import FeatureFlagDefinition + + registry: FeatureFlagRegistry = app.state.sm.feature_flags + registry._flags[constants.FLAG_ALLOW_RASTER_UPLOADS] = FeatureFlagDefinition( + name=constants.FLAG_ALLOW_RASTER_UPLOADS, + description="...", + default_enabled=False, + ) + try: + resp = await authenticated_client.post( + "/api/datasets/", + data={"name": "Big Raster", "kind": constants.DatasetKind.RASTER_GEOTIFF}, + files={"file": ("a.tif", b"\x00" * 128, "image/tiff")}, + ) + assert resp.status_code == 422 + assert "raster" in resp.json()["detail"].lower() + finally: + registry._flags[constants.FLAG_ALLOW_RASTER_UPLOADS] = FeatureFlagDefinition( + name=constants.FLAG_ALLOW_RASTER_UPLOADS, + description="...", + default_enabled=True, + ) + + +class TestSettings: + """Settings module registers runtime-tunable knobs at ``on_startup``.""" + + async def test_max_upload_mb_registered(self, app): + """The settings module may not be installed in every deployment; + when it is, the ``datasets.max_upload_mb`` key shows up in the + registry with the env default as its fallback.""" + settings_state = getattr(app.state, "settings", None) + if settings_state is None: + pytest.skip("settings module not installed") + registry = settings_state.registry + definition = registry.get(constants.SETTING_MAX_UPLOAD_MB) + assert definition is not None + assert definition.default == str(constants.DEFAULT_MAX_UPLOAD_MB) + + async def test_default_kind_registered(self, app): + settings_state = getattr(app.state, "settings", None) + if settings_state is None: + pytest.skip("settings module not installed") + registry = settings_state.registry + definition = registry.get(constants.SETTING_DEFAULT_KIND) + assert definition is not None + + +class TestPermissionsMapping: + """``register_permissions`` maps the ``user`` role to view+upload so + plain users can self-serve without becoming admins.""" + + async def test_user_role_includes_view_and_upload(self, app): + registry = app.state.sm.permissions + perms = set(registry.role_map.get(constants.ROLE_USER, [])) + assert constants.PERM_DATASETS_VIEW in perms + assert constants.PERM_DATASETS_UPLOAD in perms + # Edit / delete stay admin-only. + assert constants.PERM_DATASETS_EDIT not in perms + assert constants.PERM_DATASETS_DELETE not in perms diff --git a/modules/datasets/tsconfig.json b/modules/datasets/tsconfig.json new file mode 100644 index 00000000..073fa543 --- /dev/null +++ b/modules/datasets/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@simple-module/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./datasets/*"], + "@simple-module/ui/*": ["../../packages/ui/src/*"] + } + }, + "include": ["datasets/**/*.ts", "datasets/**/*.tsx"] +} diff --git a/package-lock.json b/package-lock.json index e57e4d46..637e6760 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,6 +104,19 @@ "react-dom": "^19.0.0" } }, + "modules/datasets": { + "name": "@simple-module/datasets", + "version": "0.1.0", + "devDependencies": { + "@simple-module/tsconfig": "*" + }, + "peerDependencies": { + "@inertiajs/react": "^2.0.0", + "@simple-module/ui": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/feature_flags": { "name": "@simple-module/feature_flags", "version": "0.1.0", @@ -4443,6 +4456,10 @@ "resolved": "modules/dashboard", "link": true }, + "node_modules/@simple-module/datasets": { + "resolved": "modules/datasets", + "link": true + }, "node_modules/@simple-module/feature_flags": { "resolved": "modules/feature_flags", "link": true diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index bc96d81a..aaa7822f 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -1,6 +1,5 @@ // AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. // Regenerate by booting the host in development mode. - export default { translation: { 'auth.errors.missing_permission': '', @@ -59,6 +58,53 @@ export default { 'dashboard.home.title': '', 'dashboard.home.welcome_card_title': '', 'dashboard.home.welcome_message': '', + 'datasets.browse.create_button': '', + 'datasets.browse.description': '', + 'datasets.browse.empty_description': '', + 'datasets.browse.empty_title': '', + 'datasets.browse.new_button': '', + 'datasets.browse.title': '', + 'datasets.create.description': '', + 'datasets.create.submit_button': '', + 'datasets.create.submitting_button': '', + 'datasets.create.title': '', + 'datasets.edit.back_button': '', + 'datasets.edit.description': '', + 'datasets.edit.submit_button': '', + 'datasets.edit.submitting_button': '', + 'datasets.edit.title': '', + 'datasets.errors.not_found': '', + 'datasets.form.cancel_button': '', + 'datasets.form.crs_label': '', + 'datasets.form.description_label': '', + 'datasets.form.description_placeholder': '', + 'datasets.form.file_label': '', + 'datasets.form.kind_auto': '', + 'datasets.form.kind_label': '', + 'datasets.form.name_label': '', + 'datasets.form.name_placeholder': '', + 'datasets.show.bands': '', + 'datasets.show.bbox': '', + 'datasets.show.crs': '', + 'datasets.show.download': '', + 'datasets.show.edit': '', + 'datasets.show.extraction_status': '', + 'datasets.show.features': '', + 'datasets.show.kind': '', + 'datasets.show.mime_type': '', + 'datasets.show.original_file': '', + 'datasets.show.size': '', + 'datasets.table.actions': '', + 'datasets.table.bbox': '', + 'datasets.table.crs': '', + 'datasets.table.kind': '', + 'datasets.table.name': '', + 'datasets.table.size': '', + 'datasets.toasts.created': '', + 'datasets.toasts.deleted': '', + 'datasets.toasts.updated': '', + 'datasets.validation.file_required': '', + 'datasets.validation.name_required': '', 'feature_flags.browse.back_to_system': '', 'feature_flags.browse.count_one': '', 'feature_flags.browse.count_other': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 819a380f..5c3f479a 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -87,6 +87,73 @@ export const keys = { welcome_message: 'dashboard.home.welcome_message', }, }, + datasets: { + browse: { + create_button: 'datasets.browse.create_button', + description: 'datasets.browse.description', + empty_description: 'datasets.browse.empty_description', + empty_title: 'datasets.browse.empty_title', + new_button: 'datasets.browse.new_button', + title: 'datasets.browse.title', + }, + create: { + description: 'datasets.create.description', + submit_button: 'datasets.create.submit_button', + submitting_button: 'datasets.create.submitting_button', + title: 'datasets.create.title', + }, + edit: { + back_button: 'datasets.edit.back_button', + description: 'datasets.edit.description', + submit_button: 'datasets.edit.submit_button', + submitting_button: 'datasets.edit.submitting_button', + title: 'datasets.edit.title', + }, + errors: { + not_found: 'datasets.errors.not_found', + }, + form: { + cancel_button: 'datasets.form.cancel_button', + crs_label: 'datasets.form.crs_label', + description_label: 'datasets.form.description_label', + description_placeholder: 'datasets.form.description_placeholder', + file_label: 'datasets.form.file_label', + kind_auto: 'datasets.form.kind_auto', + kind_label: 'datasets.form.kind_label', + name_label: 'datasets.form.name_label', + name_placeholder: 'datasets.form.name_placeholder', + }, + show: { + bands: 'datasets.show.bands', + bbox: 'datasets.show.bbox', + crs: 'datasets.show.crs', + download: 'datasets.show.download', + edit: 'datasets.show.edit', + extraction_status: 'datasets.show.extraction_status', + features: 'datasets.show.features', + kind: 'datasets.show.kind', + mime_type: 'datasets.show.mime_type', + original_file: 'datasets.show.original_file', + size: 'datasets.show.size', + }, + table: { + actions: 'datasets.table.actions', + bbox: 'datasets.table.bbox', + crs: 'datasets.table.crs', + kind: 'datasets.table.kind', + name: 'datasets.table.name', + size: 'datasets.table.size', + }, + toasts: { + created: 'datasets.toasts.created', + deleted: 'datasets.toasts.deleted', + updated: 'datasets.toasts.updated', + }, + validation: { + file_required: 'datasets.validation.file_required', + name_required: 'datasets.validation.name_required', + }, + }, feature_flags: { browse: { back_to_system: 'feature_flags.browse.back_to_system', diff --git a/packages/ui/src/components/NavIcon.tsx b/packages/ui/src/components/NavIcon.tsx index 783a5232..3037b7e7 100644 --- a/packages/ui/src/components/NavIcon.tsx +++ b/packages/ui/src/components/NavIcon.tsx @@ -126,6 +126,22 @@ const ICONS: Record = { /> ), + layers: ( + + + + ), 'shield-check': (