diff --git a/.gitignore b/.gitignore index 4516af1..d929d58 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,4 @@ docs/tasking-mvp/tasking-mvp.postman_environment.json docs/tasking-mvp/_enrich_postman.py docs/tasking-mvp/feature-coverage.md .idea/ +data/ \ No newline at end of file diff --git a/README.md b/README.md index 7b905d3..06ba988 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,28 @@ uvx pyright --pythonpath .venv/bin/python api tests uv run black api tests && uv run isort api tests ``` +## Development with local environment + +Use the file `docker-compose.local.yml` to build and deploy local code changes. This allows you to run the entire system at once instead of +connecting to existing Databases. + +### Initial setup. +- On first launch, rails-worker will fail because migrations are not done +- Go to the `osm-rails` `/bin/sh` and execute `bundle exec rails db:migrate` +- The above script runs the migration code for osm-rails +- The rails-worker will be able to run +- The backend code will fail first time becase `workspaces-tasks-local` database is not available +- Login to postgresql container and run the following commands + `psql --username postgres` + `create database "workspaces-tasks-local";` + `psql --username postgres --dbname "workspaces-tasks-local";` + `create extension if not exists postgis;` +- Run the backend code now and it should be able to run + +### Commands to start and stop the docker compose + +`docker compose --file docker-compose.local.yml up --build -d` + +`docker compose --file docker-compose.local.yml down` + +Backend code will be available at `http://localhost:8000` \ No newline at end of file diff --git a/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py new file mode 100644 index 0000000..6f1bc64 --- /dev/null +++ b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py @@ -0,0 +1,35 @@ +"""custom_imagery_and_description + +Revision ID: a92361f527ef +Revises: f3a7b9c1d2e4 +Create Date: 2026-07-14 15:08:42.142553 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +# revision identifiers, used by Alembic. +revision: str = "a92361f527ef" +down_revision: Union[str, None] = "f3a7b9c1d2e4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tasking_projects", + sa.Column("custom_imagery", JSONB(astext_type=sa.Text()), nullable=True), + ) + op.add_column( + "tasking_projects", + sa.Column("description", sa.String(length=10000), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("tasking_projects", "custom_imagery") + op.drop_column("tasking_projects", "description") diff --git a/api/core/config.py b/api/core/config.py index 04693ac..c6554c7 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -96,6 +96,7 @@ def cors_origins_list(self) -> list[str]: model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", + extra="ignore", # ignore unknown environment variables ) diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index 488e981..01057a8 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -47,6 +47,8 @@ class ProjectCreateRequest(WireModel): lock_timeout_hours: int = PydField(default=8, ge=1, le=720) aoi: Optional[AoiInput] = None role_assignments: list[ProjectRoleAssignment] = PydField(default_factory=list) + custom_imagery: Optional[dict[str, Any]] = PydField(default=None) + description: Optional[str] = PydField(default=None, max_length=10_000) @field_validator("name") @classmethod @@ -68,6 +70,8 @@ class ProjectUpdateRequest(WireModel): instructions: Optional[str] = PydField(default=None, max_length=10_000) lock_timeout_hours: Optional[int] = PydField(default=None, ge=1, le=720) review_required: Optional[bool] = None + custom_imagery: Optional[dict[str, Any]] = PydField(default=None) + description: Optional[str] = PydField(default=None, max_length=10_000) class ProjectResponse(WireModel): @@ -87,6 +91,8 @@ class ProjectResponse(WireModel): created_by_name: Optional[str] = None created_at: datetime updated_at: datetime + custom_imagery: Optional[Any] = None + description: Optional[str] = None class ProjectListItem(WireModel): @@ -114,6 +120,10 @@ class ProjectListResponse(WireModel): pagination: Pagination +class ProjectNameValidationResponse(WireModel): + exists: bool + + # --------------------------------------------------------------------------- # AOI response shape (canonical Feature wrapping a MultiPolygon) # --------------------------------------------------------------------------- @@ -184,6 +194,7 @@ class SelfProjectRolesResponse(WireModel): "ProjectCreateRequest", "ProjectListItem", "ProjectListResponse", + "ProjectNameValidationResponse", "ProjectResponse", "ProjectRoleAddRequest", "ProjectRoleAssignment", diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 44bbc42..95875c1 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -243,6 +243,8 @@ def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectRespons created_by_name=project.created_by_name, created_at=project.created_at, updated_at=project.updated_at, + custom_imagery=project.custom_imagery, # type: ignore[arg-type] + description=project.description, # type: ignore[arg-type] ) async def _provision_users_from_tdei( @@ -468,6 +470,22 @@ async def list_projects( pagination=Pagination(page=page, page_size=page_size, total=total), ) + async def project_name_exists(self, workspace_id: int, name: str) -> bool: + result = await self.session.execute( + select(func.count()) + .select_from(TaskingProject) + .where( + (TaskingProject.workspace_id == workspace_id) + & (TaskingProject.name == name) + & ( + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) + ) + ) + return int(result.scalar() or 0) > 0 + async def create( self, workspace_id: int, @@ -531,6 +549,8 @@ async def create( lock_timeout_hours=body.lock_timeout_hours, created_by=current_user.user_uuid, created_by_name=current_user.user_name, + custom_imagery=body.custom_imagery, # type: ignore[arg-type] + description=body.description, # type: ignore[arg-type] ) if body.aoi is not None: geom = _aoi_to_shapely(body.aoi) @@ -632,6 +652,10 @@ async def patch( updates["lock_timeout_hours"] = body.lock_timeout_hours if body.review_required is not None: updates["review_required"] = body.review_required + if body.custom_imagery is not None: + updates["custom_imagery"] = body.custom_imagery # type: ignore[arg-type] + if body.description is not None: + updates["description"] = body.description if updates: updates["updated_at"] = datetime.now() diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index d4979b0..4d531d0 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -12,6 +12,7 @@ AoiFeature, ProjectCreateRequest, ProjectListResponse, + ProjectNameValidationResponse, ProjectResponse, ProjectRoleAddRequest, ProjectRoleItem, @@ -128,6 +129,26 @@ async def create_project( ) +@router.get("/validate-name", response_model=ProjectNameValidationResponse) +async def validate_project_name( + workspace_id: int, + name: str = Query(..., min_length=1, max_length=255), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + normalized_name = name.strip() + if not normalized_name: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="name cannot be blank", + ) + return ProjectNameValidationResponse( + exists=await project_repo.project_name_exists(workspace_id, normalized_name) + ) + + @router.get("/{project_id}", response_model=ProjectResponse) async def get_project( workspace_id: int, diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index 901d5f6..85fc0b2 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -10,6 +10,7 @@ from pydantic import Field as PydField from sqlalchemy import Column from sqlalchemy import Enum as SAEnum +from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import Field, SQLModel # --------------------------------------------------------------------------- @@ -94,6 +95,13 @@ class TaskingProject(SQLModel, table=True): ) deleted_at: Optional[datetime] = None + # Custom Imagery data for the project. Stored as JSONB in the database and converted to / from a Pydantic model in the repository layer. + custom_imagery: Optional[Any] = Field( + default=None, sa_column=Column(JSONB, nullable=True, default=None) + ) + + description: Optional[str] = Field(default=None, nullable=True) + # --------------------------------------------------------------------------- # Project role enum + table diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..8bfeae3 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,71 @@ +services: + database: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: testing + POSTGRES_DB: workspaces-osm-local + ports: + - 5432:5432 + volumes: + - ./data/db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d workspaces-osm-local"] + interval: 10s + timeout: 5s + retries: 5 + osm-cgimap: + image: opensidewalksdev.azurecr.io/workspaces-osm-cgimap:dev + environment: + CGIMAP_INSTANCES: 10 + CGIMAP_HOST: database + CGIMAP_USERNAME: postgres + CGIMAP_PASSWORD: testing + CGIMAP_DBNAME: workspaces-osm-local + CGIMAP_MAX_CHANGESET_ELEMENTS: 100000000 # max features per import or save + CGIMAP_MAX_PAYLOAD: 1000000000 # max size of dataset uploads + CGIMAP_MAP_NODES: 100000000 # max number of nodes per requeset + CGIMAP_MAP_AREA: 1 # max area per request in square degrees + ports: + - 8000 + osm-rails: + image: opensidewalksdev.azurecr.io/workspaces-osm-rails:dev + environment: + RAILS_ENV: production + SECRET_KEY_BASE: osm_secret_key + WS_OSM_DB_HOST: database + WS_OSM_DB_USER: postgres + WS_OSM_DB_PASS: testing + WS_OSM_DB_NAME: workspaces-osm-local + stdin_open: true + tty: true + ports: + - 3000 + command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"] + + osm-rails-worker: + image: opensidewalksdev.azurecr.io/workspaces-osm-rails:dev + environment: + RAILS_ENV: production + SECRET_KEY_BASE: osm_secret_key + WS_OSM_DB_HOST: database + WS_OSM_DB_USER: postgres + WS_OSM_DB_PASS: testing + WS_OSM_DB_NAME: workspaces-osm-local + stdin_open: true + tty: true + command: ["bundle", "exec", "rake", "jobs:work"] + + backend: + build: . + container_name: workspaces-backend + volumes: + - .:/app + ports: + - 8000:8000 + environment: + TDEI_BACKEND_URL: ${TDEI_BACKEND_URL} + TDEI_OIDC_REALM: ${TDEI_OIDC_REALM} + TDEI_OIDC_URL: ${TDEI_OIDC_URL} + OSM_DATABASE_URL: postgresql+asyncpg://postgres:testing@database:5432/workspaces-osm-local + TASK_DATABASE_URL: postgresql+asyncpg://postgres:testing@database:5432/workspaces-tasks-local \ No newline at end of file diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py index c1575de..088c614 100644 --- a/tests/integration/test_projects_flow.py +++ b/tests/integration/test_projects_flow.py @@ -206,6 +206,38 @@ async def test_outsider_404s_on_list( assert r.status_code == 404 +class TestProjectNameValidation: + async def test_validate_name_false_then_true( + self, client, as_lead, seeded_workspace_id + ): + """validate-name reports false before create and true after create for same workspace.""" + path = f"{API.format(wid=seeded_workspace_id)}/validate-name" + + r = await client.get(path, params={"name": "name-check"}) + assert r.status_code == 200, r.text + assert r.json() == {"exists": False} + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "name-check"}, + ) + assert r.status_code == 201, r.text + + r = await client.get(path, params={"name": "name-check"}) + assert r.status_code == 200, r.text + assert r.json() == {"exists": True} + + async def test_validate_name_outsider_404( + self, client, as_outsider, seeded_workspace_id + ): + """Outsider receives 404 from tenancy gate on validate-name.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "anything"}, + ) + assert r.status_code == 404 + + # --------------------------------------------------------------------------- # Workflow 3b — error mapping (constraint violations → precise HTTP status). # --------------------------------------------------------------------------- diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 20ac3f5..e9fc3ad 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -175,6 +175,14 @@ async def get(self, workspace_id: int, project_id: int): raise NotFoundException(f"Project {project_id} not found") return self._response(p) + async def project_name_exists(self, workspace_id: int, name: str) -> bool: + return any( + p["workspace_id"] == workspace_id + and p["name"] == name + and p["deleted_at"] is None + for p in self._projects.values() + ) + async def patch(self, workspace_id, project_id, body, current_user): p_resp = await self.get(workspace_id, project_id) p = self._projects[project_id] diff --git a/tests/unit/test_project_routes.py b/tests/unit/test_project_routes.py index 1620aba..792103a 100644 --- a/tests/unit/test_project_routes.py +++ b/tests/unit/test_project_routes.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + API = "/api/v1/workspaces/{wid}/tasking/projects" @@ -68,6 +70,17 @@ async def test_create_blank_name_422( ) assert r.status_code == 422 + @pytest.mark.parametrize("bad_value", ["not-an-object", 123, True, ["x"]]) + async def test_create_rejects_non_object_custom_imagery_422( + self, client, as_lead, seeded_workspace_id, fake_repos, bad_value + ): + """custom_imagery must be a JSON object; scalar/array values are rejected (422).""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "Pilot", "custom_imagery": bad_value}, + ) + assert r.status_code == 422 + async def test_get_404_when_missing( self, client, as_lead, seeded_workspace_id, fake_repos ): @@ -105,6 +118,24 @@ async def test_patch_name(self, client, as_lead, seeded_workspace_id, fake_repos assert r.status_code == 200 assert r.json()["name"] == "after" + @pytest.mark.parametrize("bad_value", ["not-an-object", 123, False, ["x"]]) + async def test_patch_rejects_non_object_custom_imagery_422( + self, client, as_lead, seeded_workspace_id, fake_repos, bad_value + ): + """PATCH rejects custom_imagery when it is not a JSON object (422).""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "before"}, + ) + ).json()["id"] + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}", + json={"custom_imagery": bad_value}, + ) + assert r.status_code == 422 + async def test_soft_delete_204_then_404( self, client, as_lead, seeded_workspace_id, fake_repos ): @@ -137,6 +168,55 @@ async def test_duplicate_name_409( assert r.status_code == 409 +class TestProjectNameValidation: + async def test_validate_name_returns_false_when_missing( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Validation returns exists=false when no active project uses the name.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "new-project"}, + ) + assert r.status_code == 200 + assert r.json() == {"exists": False} + + async def test_validate_name_returns_true_when_exists( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Validation returns exists=true when an active project with same name exists.""" + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "pilot-check"}, + ) + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "pilot-check"}, + ) + assert r.status_code == 200 + assert r.json() == {"exists": True} + + async def test_validate_name_blank_rejected_422( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Whitespace-only name is rejected with 422.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": " "}, + ) + assert r.status_code == 422 + + async def test_validate_name_outsider_404( + self, client, as_outsider, seeded_workspace_id, fake_repos + ): + """Tenancy gate hides workspace existence for outsiders.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "pilot-check"}, + ) + assert r.status_code == 404 + + # --------------------------------------------------------------------------- # Lifecycle gates # ---------------------------------------------------------------------------