Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
susrisha marked this conversation as resolved.
Dismissed
down_revision: Union[str, None] = "f3a7b9c1d2e4"
Comment thread
susrisha marked this conversation as resolved.
Dismissed
branch_labels: Union[str, Sequence[str], None] = None
Comment thread
susrisha marked this conversation as resolved.
Dismissed
depends_on: Union[str, Sequence[str], None] = None
Comment thread
susrisha marked this conversation as resolved.
Dismissed


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")
1 change: 1 addition & 0 deletions api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


Expand Down
11 changes: 11 additions & 0 deletions api/src/tasking/projects/dtos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -114,6 +120,10 @@ class ProjectListResponse(WireModel):
pagination: Pagination


class ProjectNameValidationResponse(WireModel):
exists: bool


# ---------------------------------------------------------------------------
# AOI response shape (canonical Feature wrapping a MultiPolygon)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -184,6 +194,7 @@ class SelfProjectRolesResponse(WireModel):
"ProjectCreateRequest",
"ProjectListItem",
"ProjectListResponse",
"ProjectNameValidationResponse",
"ProjectResponse",
"ProjectRoleAddRequest",
"ProjectRoleAssignment",
Expand Down
24 changes: 24 additions & 0 deletions api/src/tasking/projects/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if body.description is not None:
updates["description"] = body.description

if updates:
updates["updated_at"] = datetime.now()
Expand Down
21 changes: 21 additions & 0 deletions api/src/tasking/projects/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
AoiFeature,
ProjectCreateRequest,
ProjectListResponse,
ProjectNameValidationResponse,
ProjectResponse,
ProjectRoleAddRequest,
ProjectRoleItem,
Expand Down Expand Up @@ -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)
)


Comment thread
susrisha marked this conversation as resolved.
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
workspace_id: int,
Expand Down
8 changes: 8 additions & 0 deletions api/src/tasking/projects/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
susrisha marked this conversation as resolved.
32 changes: 32 additions & 0 deletions tests/integration/test_projects_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
# ---------------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)

Comment thread
susrisha marked this conversation as resolved.
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]
Expand Down
Loading
Loading