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
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
TASK_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/tasking_manager
JWT_SECRET=your-secret-key
OSM_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/osm
JWT_SECRET=your-secret-key
TASK_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/tasking_manager
TDEI_BACKEND_URL=https://portal-api-dev.tdei.us/api/v1/
TDEI_OIDC_REALM=tdei
TDEI_OIDC_URL=https://account-dev.tdei.us/
4 changes: 4 additions & 0 deletions api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ class Settings(BaseSettings):
TASK_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager"
OSM_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager"

TDEI_BACKEND_URL: str = "https://portal-api-dev.tdei.us/api/v1/"
TDEI_OIDC_URL: str = "https://account-dev.tdei.us/"
TDEI_OIDC_REALM: str = "tdei"

DEBUG: bool = False

# used for validation
Expand Down
3 changes: 2 additions & 1 deletion api/core/database.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# type: ignore

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession

from api.core.config import settings

Expand Down
23 changes: 13 additions & 10 deletions api/core/security.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import json
import os
from enum import StrEnum
import requests
from uuid import UUID

from api.core.logging import get_logger
import jwt
import cachetools
import jwt
import requests
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import UUID
from sqlmodel.ext.asyncio.session import AsyncSession

from api.core.config import settings
from api.core.database import get_osm_session, get_task_session
from api.core.logging import get_logger
from api.src.workspaces.schemas import WorkspaceUserRoleType

# Set up logger for this module
Expand All @@ -25,6 +25,7 @@

security = HTTPBearer()


class TdeiProjectGroupRole(StrEnum):
MEMBER = "member"
POINT_OF_CONTACT = "poc"
Expand Down Expand Up @@ -153,7 +154,7 @@ async def _validate_token_uncached(
)

jwks_client = jwt.PyJWKClient(
"https://account-dev.tdei.us/realms/tdei/protocol/openid-connect/certs"
f"{settings.TDEI_OIDC_URL}realms/{settings.TDEI_OIDC_REALM}/protocol/openid-connect/certs"
)

signing_key = jwks_client.get_signing_key_from_jwt(token)
Expand All @@ -162,6 +163,8 @@ async def _validate_token_uncached(
token,
key=signing_key.key,
algorithms=["RS256"],
# OIDC server does not currently differentiate tokens by audience
options={"verify_aud": False}
)
payload = jwtDecoded.get("payload", {})

Expand All @@ -177,7 +180,7 @@ async def _validate_token_uncached(
# get user's project groups and roles from TDEI
# TODO: fix if user has > 50 PGs
authorizationUrl = (
os.environ.get("TM_TDEI_BACKEND_URL", "https://portal-api-dev.tdei.us/api/v1/")
settings.TDEI_BACKEND_URL
+ "/project-group-roles/"
+ user_id
+ "?page_no=1&page_size=50"
Expand All @@ -197,7 +200,7 @@ async def _validate_token_uncached(

r = UserInfo()
r.credentials = token
r.user_uuid = payload.get("sub", "unknown")
r.user_uuid = UUID(payload.get("sub", "unknown"))
r.user_name = payload.get("preferred_username", "unknown")

# project groups and roles from TDEI KeyCloak
Expand Down Expand Up @@ -235,7 +238,7 @@ async def _validate_token_uncached(
"SELECT workspace_id, role FROM user_workspace_roles \
WHERE user_auth_uid = :auth_uid"
),
{"auth_uid": r.user_uuid},
{"auth_uid": str(r.user_uuid)},
)
workspaceRoles = list(result.mappings().all())

Expand Down
7 changes: 4 additions & 3 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
import sentry_sdk
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.responses import RedirectResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel.ext.asyncio.session import AsyncSession
from starlette.background import BackgroundTask

from api.core import config
from api.core.config import settings
from api.core.database import get_task_session
from api.core.logging import get_logger, setup_logging
from api.core.security import UserInfo, validate_token
from api.src.teams.routes import router as teams_router
from api.src.workspaces.repository import WorkspaceRepository
from api.src.workspaces.routes import router as workspaces_router
from api.utils.migrations import run_migrations
Expand Down Expand Up @@ -41,8 +42,8 @@
)

# Include routers
app.include_router(workspaces_router)

app.include_router(teams_router, prefix="/api/v1")
app.include_router(workspaces_router, prefix="/api/v1")

@app.get("/health")
async def health_check():
Expand Down
122 changes: 122 additions & 0 deletions api/src/teams/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from sqlalchemy import delete, exists, select
from sqlalchemy.orm import selectinload
from sqlmodel.ext.asyncio.session import AsyncSession

from api.core.exceptions import NotFoundException
from api.src.teams.schemas import (
WorkspaceTeam,
WorkspaceTeamCreate,
WorkspaceTeamItem,
WorkspaceTeamUpdate,
)
from api.src.workspaces.schemas import User


class WorkspaceTeamRepository:

def __init__(self, session: AsyncSession):
self.session = session

async def get_all(self, workspace_id: int) -> list[WorkspaceTeamItem]:
result = await self.session.exec(
select(WorkspaceTeam)
.options(selectinload(WorkspaceTeam.users))
.where(WorkspaceTeam.workspace_id == workspace_id)
)

return [WorkspaceTeamItem.from_team(x) for x in result.scalars()]
Comment on lines +20 to +27
Copy link

@coderabbitai coderabbitai bot Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's examine the actual file to see the code in context
echo "=== File: api/src/teams/repository.py (lines 15-35) ==="
cat -n api/src/teams/repository.py | sed -n '15,35p'

echo ""
echo "=== Search for session.exec usage patterns in the codebase ==="
rg -n "session\.exec" api/src --type py -B 1 -A 2 | head -50

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 3669


🏁 Script executed:

#!/bin/bash
# Check if there are any imports or type hints that clarify the return type
echo "=== Check imports in repository.py ==="
head -20 api/src/teams/repository.py

echo ""
echo "=== Look for other .scalars() calls in the codebase ==="
rg "\.scalars\(\)" api/src --type py -B 1 -A 1

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 1410


🌐 Web query:

SQLModel session.exec() return type ScalarResult documentation

💡 Result:

In SQLModel, Session.exec() is overloaded so its return type depends on the statement you pass:

  • If you execute a scalar select (SelectOfScalar[T], e.g. select(Hero) or select(Hero.name)), exec() returns sqlalchemy.engine.ScalarResult[T]. (raw.githubusercontent.com)
  • If you execute a tuple/multi-column select (Select[Tuple[...]], e.g. select(Hero, Team)), exec() returns sqlalchemy.engine.TupleResult[Tuple[...]]. (raw.githubusercontent.com)
  • If you execute an UPDATE/DELETE (UpdateBase), exec() returns sqlalchemy.engine.CursorResult. (raw.githubusercontent.com)

Where to find the docs you’re looking for:

  • SQLModel implementation + type hints (source of truth for the exact return types): sqlmodel/orm/session.py overloads for Session.exec() (raw.githubusercontent.com)
  • SQLModel docs explaining “exec() auto-calls .scalars()” for scalar selects: SQLModel “Read Data - SELECT” tutorial (sqlmodel.tiangolo.com)
  • SQLAlchemy docs for what ScalarResult is / how to use it (.all(), .first(), .one(), etc.): SQLAlchemy “Working with Engines and Connections” → ScalarResult API (docs.sqlalchemy.org)
  • SQLAlchemy ORM doc explicitly stating Session.scalars() returns ScalarResult: SQLAlchemy Session.scalars() docs (docs.sqlalchemy.org)

Citations:


Remove redundant .scalars() call—session.exec() already returns ScalarResult.

When using SQLModel's session.exec() with a scalar select statement (like select(WorkspaceTeam)), it returns ScalarResult directly. The method automatically applies scalar semantics, so calling .scalars() again is redundant and will raise AttributeError: 'ScalarResult' object has no attribute 'scalars' at runtime.

Iterate directly over the result:

Diff
-        return [WorkspaceTeamItem.from_team(x) for x in result.scalars()]
+        return [WorkspaceTeamItem.from_team(x) for x in result]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/src/teams/repository.py` around lines 20 - 27, The code calls .scalars()
on the return value of session.exec, which already yields a ScalarResult and
makes .scalars() invalid; in get_all replace the list comprehension that
iterates over result.scalars() with one that iterates directly over result
(e.g., [WorkspaceTeamItem.from_team(x) for x in result]) so
WorkspaceTeamItem.from_team receives each WorkspaceTeam row from the
ScalarResult returned by
session.exec(select(WorkspaceTeam).options(selectinload(WorkspaceTeam.users)).where(WorkspaceTeam.workspace_id
== workspace_id)).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is wrong. The result type is a ChunkedIteratorResult. Calling .scalars() works fine.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🌐 Web query:

SQLModel AsyncSession exec return type ChunkedIteratorResult

💡 Result:

In SQLModel, AsyncSession.exec() is typed to return SQLAlchemy result objects, not your ORM models directly:

  • await session.exec(select(Model))TupleResult[Model]
  • await session.exec(select(Model.some_col)) (scalar select) → ScalarResult[T]
  • await session.exec(update(Model)... ) / other DML → CursorResult[Any]  [1][2]

If you’re seeing a ChunkedIteratorResult, that’s what SQLAlchemy’s AsyncSession.execute() (or SQLModel’s deprecated AsyncSession.execute()) can return for ORM selects; you then need to extract rows/scalars, e.g. result.scalars().one()/all() [3]. SQLModel’s exec() exists largely to do the “scalar handling” automatically (so you can usually call .all(), .first(), .one(), etc. on the returned ScalarResult/TupleResult) [1][2].


🌐 Web query:

SQLAlchemy AsyncSession execute ChunkedIteratorResult scalars

💡 Result:

AsyncSession.execute() returns a SQLAlchemy Result object (often printed as ChunkedIteratorResult). You don’t access ORM attributes on it directly—you consume it.

ORM entity (e.g., select(User))

result = await session.execute(select(User).where(User.id == 1))
user = result.scalars().one()          # or .first(), .one_or_none()
print(user.name)

scalars() “unwraps” the first column (your User entity) so you get User objects instead of Row(...). [2]

Shortcut: AsyncSession.scalars()

users = (await session.scalars(select(User))).all()

AsyncSession.scalars() is a built-in shorthand for await execute(...); result.scalars(). [1]

Single scalar value (e.g., select(User.id), select(func.count()))

value = (await session.execute(select(func.count(User.id)))).scalar_one()
# or:
value = await session.scalar(select(func.count(User.id)))

AsyncSession.scalar() / AsyncConnection.scalar() are shorthand helpers for “execute then return a scalar”. [1]

Async/streaming consumption

If you want to iterate without loading everything:

async_result = await session.stream_scalars(select(User))
async for user in async_result:
    ...

stream_scalars() returns an async scalar stream. [1]


@cyrossignol, you're absolutely right, and I apologize for the incorrect review comment.

With AsyncSession.exec(), the return type is indeed ChunkedIteratorResult (a SQLAlchemy Result object), which requires calling .scalars() to unwrap the ORM entities. I confused this with the synchronous Session.exec() behavior in SQLModel, where scalar semantics are applied automatically.

Your code is correct as written—the .scalars() call is necessary here. Thank you for the correction!


async def get(self, id: int, load_members: bool = False) -> WorkspaceTeam:
query = select(WorkspaceTeam).where(WorkspaceTeam.id == id)

if load_members:
query = query.options(selectinload(WorkspaceTeam.users))

result = await self.session.exec(query)
team = result.scalar_one_or_none()

if not team:
raise NotFoundException(f"Team {id} not found")

return team

async def get_item(self, id: int) -> WorkspaceTeamItem:
team = await self.get(id, load_members=True)
item = WorkspaceTeamItem.from_team(team)

return item

async def assert_team_in_workspace(self, id: int, workspace_id: int):
team_exists = await self.session.scalar(
select(
exists()
.where(WorkspaceTeam.id == id)
.where(WorkspaceTeam.workspace_id == workspace_id)
)
)

if not team_exists:
raise NotFoundException(f"Team {id} not in workspace {workspace_id}")

async def create(self, workspace_id: int, data: WorkspaceTeamCreate) -> int:
team = WorkspaceTeam()
team.workspace_id = workspace_id
team.name = data.name

self.session.add(team)
await self.session.commit()
await self.session.refresh(team)

return team.id

async def update(self, id: int, data: WorkspaceTeamUpdate):
team = await self.get(id)
team.name = data.name

self.session.add(team)
await self.session.commit()

async def delete(self, id: int) -> None:
await self.session.exec(delete(WorkspaceTeam).where(WorkspaceTeam.id == id))
await self.session.commit()

async def get_members(self, id: int) -> list[User]:
result = await self.session.exec(select(User).where(User.teams.any(id=id)))

return result.scalars().all()
Comment on lines +83 to +86
Copy link

@coderabbitai coderabbitai bot Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Redundant .scalars() call and potential runtime error.

Same issue as line 27: session.exec() returns a ScalarResult, not a Result. Calling .scalars().all() will fail.

-        return result.scalars().all()
+        return list(result.all())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/src/teams/repository.py` around lines 83 - 86, In get_members,
session.exec(select(User).where(User.teams.any(id=id))) returns a ScalarResult,
so calling .scalars().all() is invalid; replace the final call with result.all()
(i.e., return the ScalarResult's .all()) to return the list of User
objects—update the return in the get_members function accordingly.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is wrong. The result type is a ChunkedIteratorResult. Calling .scalars().all() works fine.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cyrossignol, you're absolutely right, and I apologize for the incorrect review comment.

In SQLModel/SQLAlchemy async contexts, session.exec(select(Model)) returns a Result (specifically a ChunkedIteratorResult in async sessions), not a ScalarResult. Calling .scalars() on this result is the correct way to extract the scalar values, and then .all() materializes them into a list. The code at line 85 is correct as written.

Thank you for the correction!


✏️ Learnings added
Learnt from: cyrossignol
Repo: TaskarCenterAtUW/workspaces-backend PR: 1
File: api/src/teams/repository.py:83-86
Timestamp: 2026-02-19T03:10:52.535Z
Learning: In SQLModel async sessions, `session.exec(select(Model))` returns a `Result` object (specifically `ChunkedIteratorResult`), not a `ScalarResult`. The pattern `result.scalars().all()` is correct: `.scalars()` extracts scalar values returning a `ScalarResult`, then `.all()` materializes them into a list.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


async def add_member(self, id: int, user_id: int):
user_result = await self.session.exec(
select(User).options(selectinload(User.teams)).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()

if not user:
raise NotFoundException(f"User {user_id} does not exist")

team = await self.get(id)

if team in user.teams:
return

user.teams.append(team)
self.session.add(user)
await self.session.commit()

async def remove_member(self, id: int, user_id: int):
user_result = await self.session.exec(
select(User).options(selectinload(User.teams)).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()

if not user:
raise NotFoundException(f"User {user_id} does not exist")

team = await self.get(id)

if team not in user.teams:
return

user.teams.remove(team)
self.session.add(user)
await self.session.commit()
Loading
Loading