Skip to content

Commit 5b1127e

Browse files
authored
Merge pull request #62 from TaskarCenterAtUW/feature-local-setup
Feature local setup
2 parents d041797 + 1129280 commit 5b1127e

12 files changed

Lines changed: 317 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,4 @@ docs/tasking-mvp/tasking-mvp.postman_environment.json
170170
docs/tasking-mvp/_enrich_postman.py
171171
docs/tasking-mvp/feature-coverage.md
172172
.idea/
173+
data/

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,28 @@ uvx pyright --pythonpath .venv/bin/python api tests
8282
uv run black api tests && uv run isort api tests
8383
```
8484

85+
## Development with local environment
86+
87+
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
88+
connecting to existing Databases.
89+
90+
### Initial setup.
91+
- On first launch, rails-worker will fail because migrations are not done
92+
- Go to the `osm-rails` `/bin/sh` and execute `bundle exec rails db:migrate`
93+
- The above script runs the migration code for osm-rails
94+
- The rails-worker will be able to run
95+
- The backend code will fail first time becase `workspaces-tasks-local` database is not available
96+
- Login to postgresql container and run the following commands
97+
`psql --username postgres`
98+
`create database "workspaces-tasks-local";`
99+
`psql --username postgres --dbname "workspaces-tasks-local";`
100+
`create extension if not exists postgis;`
101+
- Run the backend code now and it should be able to run
102+
103+
### Commands to start and stop the docker compose
104+
105+
`docker compose --file docker-compose.local.yml up --build -d`
106+
107+
`docker compose --file docker-compose.local.yml down`
108+
109+
Backend code will be available at `http://localhost:8000`
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""custom_imagery_and_description
2+
3+
Revision ID: a92361f527ef
4+
Revises: f3a7b9c1d2e4
5+
Create Date: 2026-07-14 15:08:42.142553
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
from sqlalchemy.dialects.postgresql import JSONB
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "a92361f527ef"
17+
down_revision: Union[str, None] = "f3a7b9c1d2e4"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
op.add_column(
24+
"tasking_projects",
25+
sa.Column("custom_imagery", JSONB(astext_type=sa.Text()), nullable=True),
26+
)
27+
op.add_column(
28+
"tasking_projects",
29+
sa.Column("description", sa.String(length=10000), nullable=True),
30+
)
31+
32+
33+
def downgrade() -> None:
34+
op.drop_column("tasking_projects", "custom_imagery")
35+
op.drop_column("tasking_projects", "description")

api/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def cors_origins_list(self) -> list[str]:
9696
model_config = SettingsConfigDict(
9797
env_file=".env",
9898
env_file_encoding="utf-8",
99+
extra="ignore", # ignore unknown environment variables
99100
)
100101

101102

api/src/tasking/projects/dtos.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ class ProjectCreateRequest(WireModel):
4747
lock_timeout_hours: int = PydField(default=8, ge=1, le=720)
4848
aoi: Optional[AoiInput] = None
4949
role_assignments: list[ProjectRoleAssignment] = PydField(default_factory=list)
50+
custom_imagery: Optional[dict[str, Any]] = PydField(default=None)
51+
description: Optional[str] = PydField(default=None, max_length=10_000)
5052

5153
@field_validator("name")
5254
@classmethod
@@ -68,6 +70,8 @@ class ProjectUpdateRequest(WireModel):
6870
instructions: Optional[str] = PydField(default=None, max_length=10_000)
6971
lock_timeout_hours: Optional[int] = PydField(default=None, ge=1, le=720)
7072
review_required: Optional[bool] = None
73+
custom_imagery: Optional[dict[str, Any]] = PydField(default=None)
74+
description: Optional[str] = PydField(default=None, max_length=10_000)
7175

7276

7377
class ProjectResponse(WireModel):
@@ -87,6 +91,8 @@ class ProjectResponse(WireModel):
8791
created_by_name: Optional[str] = None
8892
created_at: datetime
8993
updated_at: datetime
94+
custom_imagery: Optional[Any] = None
95+
description: Optional[str] = None
9096

9197

9298
class ProjectListItem(WireModel):
@@ -114,6 +120,10 @@ class ProjectListResponse(WireModel):
114120
pagination: Pagination
115121

116122

123+
class ProjectNameValidationResponse(WireModel):
124+
exists: bool
125+
126+
117127
# ---------------------------------------------------------------------------
118128
# AOI response shape (canonical Feature wrapping a MultiPolygon)
119129
# ---------------------------------------------------------------------------
@@ -184,6 +194,7 @@ class SelfProjectRolesResponse(WireModel):
184194
"ProjectCreateRequest",
185195
"ProjectListItem",
186196
"ProjectListResponse",
197+
"ProjectNameValidationResponse",
187198
"ProjectResponse",
188199
"ProjectRoleAddRequest",
189200
"ProjectRoleAssignment",

api/src/tasking/projects/repository.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectRespons
243243
created_by_name=project.created_by_name,
244244
created_at=project.created_at,
245245
updated_at=project.updated_at,
246+
custom_imagery=project.custom_imagery, # type: ignore[arg-type]
247+
description=project.description, # type: ignore[arg-type]
246248
)
247249

248250
async def _provision_users_from_tdei(
@@ -468,6 +470,22 @@ async def list_projects(
468470
pagination=Pagination(page=page, page_size=page_size, total=total),
469471
)
470472

473+
async def project_name_exists(self, workspace_id: int, name: str) -> bool:
474+
result = await self.session.execute(
475+
select(func.count())
476+
.select_from(TaskingProject)
477+
.where(
478+
(TaskingProject.workspace_id == workspace_id)
479+
& (TaskingProject.name == name)
480+
& (
481+
TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess]
482+
None
483+
)
484+
)
485+
)
486+
)
487+
return int(result.scalar() or 0) > 0
488+
471489
async def create(
472490
self,
473491
workspace_id: int,
@@ -531,6 +549,8 @@ async def create(
531549
lock_timeout_hours=body.lock_timeout_hours,
532550
created_by=current_user.user_uuid,
533551
created_by_name=current_user.user_name,
552+
custom_imagery=body.custom_imagery, # type: ignore[arg-type]
553+
description=body.description, # type: ignore[arg-type]
534554
)
535555
if body.aoi is not None:
536556
geom = _aoi_to_shapely(body.aoi)
@@ -632,6 +652,10 @@ async def patch(
632652
updates["lock_timeout_hours"] = body.lock_timeout_hours
633653
if body.review_required is not None:
634654
updates["review_required"] = body.review_required
655+
if body.custom_imagery is not None:
656+
updates["custom_imagery"] = body.custom_imagery # type: ignore[arg-type]
657+
if body.description is not None:
658+
updates["description"] = body.description
635659

636660
if updates:
637661
updates["updated_at"] = datetime.now()

api/src/tasking/projects/routes.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
AoiFeature,
1313
ProjectCreateRequest,
1414
ProjectListResponse,
15+
ProjectNameValidationResponse,
1516
ProjectResponse,
1617
ProjectRoleAddRequest,
1718
ProjectRoleItem,
@@ -128,6 +129,26 @@ async def create_project(
128129
)
129130

130131

132+
@router.get("/validate-name", response_model=ProjectNameValidationResponse)
133+
async def validate_project_name(
134+
workspace_id: int,
135+
name: str = Query(..., min_length=1, max_length=255),
136+
current_user: UserInfo = Depends(validate_token),
137+
workspace_repo: WorkspaceRepository = Depends(get_workspace_repo),
138+
project_repo: TaskingProjectRepository = Depends(get_project_repo),
139+
):
140+
await assert_workspace_visible(workspace_id, current_user, workspace_repo)
141+
normalized_name = name.strip()
142+
if not normalized_name:
143+
raise HTTPException(
144+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
145+
detail="name cannot be blank",
146+
)
147+
return ProjectNameValidationResponse(
148+
exists=await project_repo.project_name_exists(workspace_id, normalized_name)
149+
)
150+
151+
131152
@router.get("/{project_id}", response_model=ProjectResponse)
132153
async def get_project(
133154
workspace_id: int,

api/src/tasking/projects/schemas.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pydantic import Field as PydField
1111
from sqlalchemy import Column
1212
from sqlalchemy import Enum as SAEnum
13+
from sqlalchemy.dialects.postgresql import JSONB
1314
from sqlmodel import Field, SQLModel
1415

1516
# ---------------------------------------------------------------------------
@@ -94,6 +95,13 @@ class TaskingProject(SQLModel, table=True):
9495
)
9596
deleted_at: Optional[datetime] = None
9697

98+
# Custom Imagery data for the project. Stored as JSONB in the database and converted to / from a Pydantic model in the repository layer.
99+
custom_imagery: Optional[Any] = Field(
100+
default=None, sa_column=Column(JSONB, nullable=True, default=None)
101+
)
102+
103+
description: Optional[str] = Field(default=None, nullable=True)
104+
97105

98106
# ---------------------------------------------------------------------------
99107
# Project role enum + table

docker-compose.local.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
services:
2+
database:
3+
image: postgis/postgis:16-3.4
4+
environment:
5+
POSTGRES_USER: postgres
6+
POSTGRES_PASSWORD: testing
7+
POSTGRES_DB: workspaces-osm-local
8+
ports:
9+
- 5432:5432
10+
volumes:
11+
- ./data/db:/var/lib/postgresql/data
12+
healthcheck:
13+
test: ["CMD-SHELL", "pg_isready -U postgres -d workspaces-osm-local"]
14+
interval: 10s
15+
timeout: 5s
16+
retries: 5
17+
osm-cgimap:
18+
image: opensidewalksdev.azurecr.io/workspaces-osm-cgimap:dev
19+
environment:
20+
CGIMAP_INSTANCES: 10
21+
CGIMAP_HOST: database
22+
CGIMAP_USERNAME: postgres
23+
CGIMAP_PASSWORD: testing
24+
CGIMAP_DBNAME: workspaces-osm-local
25+
CGIMAP_MAX_CHANGESET_ELEMENTS: 100000000 # max features per import or save
26+
CGIMAP_MAX_PAYLOAD: 1000000000 # max size of dataset uploads
27+
CGIMAP_MAP_NODES: 100000000 # max number of nodes per requeset
28+
CGIMAP_MAP_AREA: 1 # max area per request in square degrees
29+
ports:
30+
- 8000
31+
osm-rails:
32+
image: opensidewalksdev.azurecr.io/workspaces-osm-rails:dev
33+
environment:
34+
RAILS_ENV: production
35+
SECRET_KEY_BASE: osm_secret_key
36+
WS_OSM_DB_HOST: database
37+
WS_OSM_DB_USER: postgres
38+
WS_OSM_DB_PASS: testing
39+
WS_OSM_DB_NAME: workspaces-osm-local
40+
stdin_open: true
41+
tty: true
42+
ports:
43+
- 3000
44+
command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
45+
46+
osm-rails-worker:
47+
image: opensidewalksdev.azurecr.io/workspaces-osm-rails:dev
48+
environment:
49+
RAILS_ENV: production
50+
SECRET_KEY_BASE: osm_secret_key
51+
WS_OSM_DB_HOST: database
52+
WS_OSM_DB_USER: postgres
53+
WS_OSM_DB_PASS: testing
54+
WS_OSM_DB_NAME: workspaces-osm-local
55+
stdin_open: true
56+
tty: true
57+
command: ["bundle", "exec", "rake", "jobs:work"]
58+
59+
backend:
60+
build: .
61+
container_name: workspaces-backend
62+
volumes:
63+
- .:/app
64+
ports:
65+
- 8000:8000
66+
environment:
67+
TDEI_BACKEND_URL: ${TDEI_BACKEND_URL}
68+
TDEI_OIDC_REALM: ${TDEI_OIDC_REALM}
69+
TDEI_OIDC_URL: ${TDEI_OIDC_URL}
70+
OSM_DATABASE_URL: postgresql+asyncpg://postgres:testing@database:5432/workspaces-osm-local
71+
TASK_DATABASE_URL: postgresql+asyncpg://postgres:testing@database:5432/workspaces-tasks-local

tests/integration/test_projects_flow.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,38 @@ async def test_outsider_404s_on_list(
206206
assert r.status_code == 404
207207

208208

209+
class TestProjectNameValidation:
210+
async def test_validate_name_false_then_true(
211+
self, client, as_lead, seeded_workspace_id
212+
):
213+
"""validate-name reports false before create and true after create for same workspace."""
214+
path = f"{API.format(wid=seeded_workspace_id)}/validate-name"
215+
216+
r = await client.get(path, params={"name": "name-check"})
217+
assert r.status_code == 200, r.text
218+
assert r.json() == {"exists": False}
219+
220+
r = await client.post(
221+
API.format(wid=seeded_workspace_id),
222+
json={"name": "name-check"},
223+
)
224+
assert r.status_code == 201, r.text
225+
226+
r = await client.get(path, params={"name": "name-check"})
227+
assert r.status_code == 200, r.text
228+
assert r.json() == {"exists": True}
229+
230+
async def test_validate_name_outsider_404(
231+
self, client, as_outsider, seeded_workspace_id
232+
):
233+
"""Outsider receives 404 from tenancy gate on validate-name."""
234+
r = await client.get(
235+
f"{API.format(wid=seeded_workspace_id)}/validate-name",
236+
params={"name": "anything"},
237+
)
238+
assert r.status_code == 404
239+
240+
209241
# ---------------------------------------------------------------------------
210242
# Workflow 3b — error mapping (constraint violations → precise HTTP status).
211243
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)