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
29 changes: 29 additions & 0 deletions alembic_task/versions/d4e8f1a92b56_add_workspace_updated_at.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Add updatedAt column to workspaces table

Revision ID: d4e8f1a92b56
Revises: b3f8a2c91e04
Create Date: 2026-08-10 00:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "d4e8f1a92b56"
down_revision: Union[str, None] = "b3f8a2c91e04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# Nullable, no backfill: rows written before this column existed read
# back as None, and WorkspaceResponse.from_workspace (api/src/workspaces/
# schemas.py) falls back to createdAt for those. New/updated rows get a
# real value from the model's default=/onupdate=datetime.now.
op.add_column("workspaces", sa.Column("updatedAt", sa.DateTime(), nullable=True))


def downgrade() -> None:
op.drop_column("workspaces", "updatedAt")
21 changes: 21 additions & 0 deletions api/src/tasking/projects/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,27 @@ async def project_name_exists(self, workspace_id: int, name: str) -> bool:
)
return int(result.scalar() or 0) > 0

async def get_projects_counts(self, workspace_ids: list[int]) -> dict[int, int]:
if not workspace_ids:
return {}
query = (
select( # pyright: ignore[reportCallIssue]
TaskingProject.workspace_id, # pyright: ignore[reportArgumentType]
func.count(),
)
.where(
TaskingProject.workspace_id.in_( # pyright: ignore[reportAttributeAccessIssue]
workspace_ids
)
& TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess]
None
)
)
.group_by(TaskingProject.workspace_id)
)
result = await self.session.execute(query)
return {wid: int(c) for wid, c in result.all()}

async def create(
self,
workspace_id: int,
Expand Down
20 changes: 19 additions & 1 deletion api/src/users/repository.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from uuid import UUID

from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlmodel.ext.asyncio.session import AsyncSession

Expand Down Expand Up @@ -45,6 +45,24 @@ async def get_privileged_workspace_members(
for user, role in result.all()
]

async def get_member_counts(self, workspace_ids: list[int]) -> dict[int, int]:
if not workspace_ids:
return {}
query = (
select( # pyright: ignore[reportCallIssue]
WorkspaceUserRole.workspace_id, # pyright: ignore[reportArgumentType]
func.count(),
)
.where(
WorkspaceUserRole.workspace_id.in_( # pyright: ignore[reportAttributeAccessIssue]
workspace_ids
)
)
.group_by(WorkspaceUserRole.workspace_id)
)
result = await self.session.execute(query)
return {wid: int(c) for wid, c in result.all()}

async def get_current_user(self, current_user: UserInfo) -> User:
result = await self.session.exec( # pyright: ignore[reportCallIssue]
select(User).where(
Expand Down
2 changes: 1 addition & 1 deletion api/src/workspaces/repository.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from sqlalchemy import delete, select, text, update
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlmodel.ext.asyncio.session import AsyncSession

Expand Down
27 changes: 26 additions & 1 deletion api/src/workspaces/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from api.core.security import UserInfo, evict_user_from_cache, validate_token
from api.src.osm.repository import OSMRepository
from api.src.osm.routes import get_osm_repo
from api.src.tasking.projects.repository import TaskingProjectRepository
from api.src.users.repository import UserRepository
from api.src.users.schemas import WorkspaceUserRoleType
from api.src.workspaces.repository import WorkspaceRepository
Expand Down Expand Up @@ -46,6 +47,12 @@ def get_user_repository(
return UserRepository(session)


def get_project_repository(
session: AsyncSession = Depends(get_osm_session),
) -> TaskingProjectRepository:
return TaskingProjectRepository(session)


# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs
# @test: Test that this method properly calls the repository method to fetch the workspace and that the repository method properly fetches the workspace from the database
# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same
Expand All @@ -59,11 +66,29 @@ def get_user_repository(
@router.get("/mine", response_model=list[WorkspaceResponse])
async def get_my_workspaces(
repository: WorkspaceRepository = Depends(get_workspace_repository),
user_repo: UserRepository = Depends(get_user_repository),
project_repo: TaskingProjectRepository = Depends(get_project_repository),
current_user: UserInfo = Depends(validate_token),
) -> list[WorkspaceResponse]:
try:
workspaces = await repository.getAll(current_user)
return [WorkspaceResponse.from_workspace(ws, current_user) for ws in workspaces]
workspace_ids = [ws.id for ws in workspaces if ws.id is not None]

projects_counts = await project_repo.get_projects_counts(workspace_ids)
members_counts = await user_repo.get_member_counts(workspace_ids)

responses = []
for ws in workspaces:
assert ws.id is not None # persisted workspace always has an id
responses.append(
WorkspaceResponse.from_workspace(
ws,
current_user,
projects_count=projects_counts.get(ws.id, 0),
members_count=members_counts.get(ws.id, 0),
)
)
return responses
except Exception as e:
logger.error(f"Failed to fetch workspaces: {str(e)}")
raise
Expand Down
16 changes: 16 additions & 0 deletions api/src/workspaces/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,13 @@ class WorkspaceResponse(SQLModel):
createdAt: datetime
createdBy: UUID
createdByName: str
updatedAt: datetime
externalAppAccess: ExternalAppsDefinitionType
kartaViewToken: Optional[str] = None
autoFlagReview: bool = False
role: str
projectsCount: int = 0
membersCount: int = 0
# Included in single-workspace GET for mobile app consumption. TODO: remove
# this when the app fetches these from dedicated endpoints:
longFormQuestDef: Optional[Any] = None
Expand All @@ -236,6 +239,8 @@ def from_workspace(
*,
imagery_list_def: Any = None,
long_form_quest_def: Any = None,
projects_count: int = 0,
members_count: int = 0,
) -> Self:
assert workspace.id is not None # persisted workspace always has an id
return cls(
Expand All @@ -250,10 +255,13 @@ def from_workspace(
createdAt=workspace.createdAt,
createdBy=workspace.createdBy,
createdByName=workspace.createdByName,
updatedAt=workspace.updatedAt or workspace.createdAt,
externalAppAccess=workspace.externalAppAccess,
kartaViewToken=workspace.kartaViewToken,
autoFlagReview=workspace.autoFlagReview,
role=user.effective_role(workspace.id),
projectsCount=projects_count,
membersCount=members_count,
imageryListDef=imagery_list_def,
longFormQuestDef=long_form_quest_def,
)
Expand Down Expand Up @@ -286,6 +294,14 @@ class Workspace(SQLModel, table=True):
createdBy: UUID
createdByName: str

# Nullable so that adding this column never requires a data backfill: rows
# written before this column existed simply read back as None, and
# WorkspaceResponse.from_workspace falls back to createdAt for those.
updatedAt: Optional[datetime] = Field(
default=None,
sa_column=Column(nullable=True, default=datetime.now, onupdate=datetime.now),
)

geometry: Optional[Any] = Field(
default=None, sa_column=Column(Geometry("MULTIPOLYGON", srid=4326))
)
Expand Down
26 changes: 21 additions & 5 deletions tests/integration/test_workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,20 @@ def evictions(monkeypatch):
# === GET /mine =============================================================


async def test_list_my_workspaces(client, login, task_session):
async def test_list_my_workspaces(client, login, task_session, osm_session):
login()
task_session.queue(
fakes.rows(
factories.make_workspace(id=1, title="One"),
factories.make_workspace(id=2, title="Two"),
)
),
)
# tasking_projects and user_workspace_roles both live in the OSM DB, so
# both batched counts are queued on osm_session, in the order the route
# calls them: projects counts, then member counts.
osm_session.queue(
fakes.rows((1, 4), (2, 2)), # get_projects_counts
fakes.rows((1, 3), (2, 1)), # get_member_counts
)

response = await client.get(f"{API}/mine")
Expand All @@ -67,6 +74,11 @@ async def test_list_my_workspaces(client, login, task_session):
body = response.json()
assert [w["id"] for w in body] == [1, 2]
assert body[0]["role"] == "contributor"
assert body[0]["projectsCount"] == 4
assert body[0]["membersCount"] == 3
assert body[1]["projectsCount"] == 2
assert body[1]["membersCount"] == 1
assert "updatedAt" in body[0]


async def test_list_my_workspaces_empty(client, login, task_session):
Expand All @@ -86,14 +98,18 @@ async def test_list_my_workspaces_unexpected_error_500(
assert response.status_code == 500


async def test_list_matches_get_by_id(client, login, task_session):
async def test_list_matches_get_by_id(client, login, task_session, osm_session):
# The same workspace serialized via /mine and via /{id} agree on shared fields.
login(
factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]})
)
task_session.queue(
fakes.rows(factories.make_workspace(id=1, title="Shared")),
fakes.rows(factories.make_workspace(id=1, title="Shared")),
fakes.rows(factories.make_workspace(id=1, title="Shared")), # /mine getAll
fakes.rows(factories.make_workspace(id=1, title="Shared")), # /1 getById
)
osm_session.queue(
fakes.rows((1, 0)), # get_projects_counts for /mine
fakes.rows((1, 0)), # get_member_counts for /mine
)

listed = (await client.get(f"{API}/mine")).json()[0]
Expand Down
3 changes: 2 additions & 1 deletion tests/support/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,15 @@ def make_workspace(
created_by_name: str = "Test User",
**extra,
) -> Workspace:
extra.setdefault("createdAt", datetime(2026, 1, 1))
extra.setdefault("updatedAt", datetime(2026, 1, 1))
return Workspace(
id=id,
title=title,
type=type,
tdeiProjectGroupId=UUID(tdei_project_group_id),
createdBy=UUID(created_by),
createdByName=created_by_name,
createdAt=datetime(2026, 1, 1),
**extra,
)

Expand Down
44 changes: 44 additions & 0 deletions tests/unit/test_tasking_project_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Unit tests for TaskingProjectRepository against a fake session.

See tests/unit/test_workspace_repository.py for the pattern this follows:
queue the rows the DB "would" return, then assert on the repository's
behavior. TaskingProjectRepository runs on the OSM DB session, since
tasking_projects lives there (see CLAUDE.md).
"""

from typing import cast

from sqlmodel.ext.asyncio.session import AsyncSession

from api.src.tasking.projects.repository import TaskingProjectRepository
from tests.support import fakes


def _repo(session: fakes.FakeSession) -> TaskingProjectRepository:
return TaskingProjectRepository(cast(AsyncSession, session))


async def test_get_projects_counts_returns_map():
session = fakes.FakeSession(fakes.rows((1, 3), (2, 1)))

result = await _repo(session).get_projects_counts([1, 2])

assert result == {1: 3, 2: 1}


async def test_get_projects_counts_empty_ids_short_circuits():
# A queued exception proves the session is never touched for an empty id list.
session = fakes.FakeSession(fakes.raises(RuntimeError("should not query")))

result = await _repo(session).get_projects_counts([])

assert result == {}


async def test_get_projects_counts_omits_ids_with_no_projects():
session = fakes.FakeSession(fakes.rows((1, 2)))

result = await _repo(session).get_projects_counts([1, 2])

assert result == {1: 2}
assert result.get(2, 0) == 0
42 changes: 42 additions & 0 deletions tests/unit/test_user_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Unit tests for UserRepository against a fake session.

See tests/unit/test_workspace_repository.py for the pattern this follows:
queue the rows the DB "would" return, then assert on the repository's
behavior.
"""

from typing import cast

from sqlmodel.ext.asyncio.session import AsyncSession

from api.src.users.repository import UserRepository
from tests.support import fakes


def _repo(session: fakes.FakeSession) -> UserRepository:
return UserRepository(cast(AsyncSession, session))


async def test_get_member_counts_returns_map():
session = fakes.FakeSession(fakes.rows((1, 2), (2, 1)))

result = await _repo(session).get_member_counts([1, 2])

assert result == {1: 2, 2: 1}


async def test_get_member_counts_empty_ids_short_circuits():
session = fakes.FakeSession(fakes.raises(RuntimeError("should not query")))

result = await _repo(session).get_member_counts([])

assert result == {}


async def test_get_member_counts_omits_ids_with_no_members():
session = fakes.FakeSession(fakes.rows((1, 3)))

result = await _repo(session).get_member_counts([1, 2])

assert result == {1: 3}
assert result.get(2, 0) == 0
Loading
Loading