From abd1fc8507ccdefd5bb53ba6ef3fcc41a2e00201 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:40:56 +0530 Subject: [PATCH 1/7] Add projects-by-org endpoint and pagination for organizations list - Add GET /projects/organization/{org_id} endpoint with org validation - Add has_more pagination to organizations list endpoint - Add Swagger docs for list_by_org --- backend/app/api/docs/organization/list.md | 2 +- backend/app/api/docs/projects/list_by_org.md | 3 +++ backend/app/api/routes/organization.py | 11 ++++++++--- backend/app/api/routes/project.py | 15 +++++++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/docs/projects/list_by_org.md diff --git a/backend/app/api/docs/organization/list.md b/backend/app/api/docs/organization/list.md index 95943bab2..0d128ac8e 100644 --- a/backend/app/api/docs/organization/list.md +++ b/backend/app/api/docs/organization/list.md @@ -1,3 +1,3 @@ List all organizations. -Returns paginated list of all organizations in the system. +Returns paginated list of all organizations in the system. The response includes a `has_more` field in `metadata` indicating whether additional pages are available. diff --git a/backend/app/api/docs/projects/list_by_org.md b/backend/app/api/docs/projects/list_by_org.md new file mode 100644 index 000000000..b227312d0 --- /dev/null +++ b/backend/app/api/docs/projects/list_by_org.md @@ -0,0 +1,3 @@ +List all projects for a given organization. + +Returns all projects belonging to the specified organization ID. The organization must exist and be active. diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index eb853921b..ca9239ab6 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -1,7 +1,7 @@ import logging from typing import List -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func from sqlmodel import select @@ -27,14 +27,19 @@ response_model=APIResponse[List[OrganizationPublic]], description=load_description("organization/list.md"), ) -def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100): +def read_organizations( + session: SessionDep, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), +): count_statement = select(func.count()).select_from(Organization) count = session.exec(count_statement).one() statement = select(Organization).offset(skip).limit(limit) organizations = session.exec(statement).all() - return APIResponse.success_response(organizations) + has_more = (skip + limit) < count + return APIResponse.success_response(organizations, metadata={"has_more": has_more}) # Create a new organization diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index 8a114930d..1d2a8d24e 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -11,7 +11,9 @@ from app.crud.project import ( create_project, get_project_by_id, + get_projects_by_organization, ) +from app.crud.organization import validate_organization from app.utils import APIResponse, load_description logger = logging.getLogger(__name__) @@ -112,3 +114,16 @@ def delete_project(session: SessionDep, project_id: int): f"[delete_project] Project deleted successfully | project_id={project_id}" ) return APIResponse.success_response(None) + + +# Get projects by organization +@router.get( + "/organization/{org_id}", + dependencies=[Depends(require_permission(Permission.SUPERUSER))], + response_model=APIResponse[List[ProjectPublic]], + description=load_description("projects/list_by_org.md"), +) +def read_projects_by_organization(session: SessionDep, org_id: int): + validate_organization(session=session, org_id=org_id) + projects = get_projects_by_organization(session=session, org_id=org_id) + return APIResponse.success_response(projects) From 84ce2e8cc077977faf69cf38e23c47e40cfa1575 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:40:10 +0530 Subject: [PATCH 2/7] Enhance organization validation: return 503 status code for inactive organizations; update read_projects_by_organization response type --- backend/app/api/routes/project.py | 4 +++- backend/app/crud/organization.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index 1d2a8d24e..8490a0a42 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -123,7 +123,9 @@ def delete_project(session: SessionDep, project_id: int): response_model=APIResponse[List[ProjectPublic]], description=load_description("projects/list_by_org.md"), ) -def read_projects_by_organization(session: SessionDep, org_id: int): +def read_projects_by_organization( + session: SessionDep, org_id: int +) -> APIResponse[List[ProjectPublic]]: validate_organization(session=session, org_id=org_id) projects = get_projects_by_organization(session=session, org_id=org_id) return APIResponse.success_response(projects) diff --git a/backend/app/crud/organization.py b/backend/app/crud/organization.py index a95c843b6..e42d3da9f 100644 --- a/backend/app/crud/organization.py +++ b/backend/app/crud/organization.py @@ -52,6 +52,6 @@ def validate_organization(session: Session, org_id: int) -> Organization: logger.error( f"[validate_organization] Organization is not active | 'org_id': {org_id}" ) - raise HTTPException("Organization is not active") + raise HTTPException(status_code=503, detail="Organization is not active") return organization From 24641d6babc78004e07de2d3f011ff8fdd61b9b3 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:44:58 +0530 Subject: [PATCH 3/7] feat: add pagination support and organization project retrieval tests --- backend/app/tests/api/routes/test_org.py | 28 ++++++++++++ backend/app/tests/api/routes/test_project.py | 46 ++++++++++++++++++++ backend/app/tests/crud/test_org.py | 31 ++++++++++++- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/backend/app/tests/api/routes/test_org.py b/backend/app/tests/api/routes/test_org.py index 699003759..d263dd6f3 100644 --- a/backend/app/tests/api/routes/test_org.py +++ b/backend/app/tests/api/routes/test_org.py @@ -90,3 +90,31 @@ def test_delete_organization( headers=superuser_token_headers, ) assert response.status_code == 404 + + +# Test pagination has_more metadata +def test_read_organizations_has_more( + db: Session, superuser_token_headers: dict[str, str] +) -> None: + # Create 2 orgs and request with limit=1 to trigger has_more=True + create_test_organization(db) + create_test_organization(db) + + response = client.get( + f"{settings.API_V1_STR}/organizations/?skip=0&limit=1", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + response_data = response.json() + assert "metadata" in response_data + assert response_data["metadata"]["has_more"] is True + + # Request all with large limit to verify has_more=False + response = client.get( + f"{settings.API_V1_STR}/organizations/?skip=0&limit=100", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + response_data = response.json() + assert "metadata" in response_data + assert response_data["metadata"]["has_more"] is False diff --git a/backend/app/tests/api/routes/test_project.py b/backend/app/tests/api/routes/test_project.py index fe2313f62..9ec73b9f7 100644 --- a/backend/app/tests/api/routes/test_project.py +++ b/backend/app/tests/api/routes/test_project.py @@ -5,6 +5,7 @@ from app.main import app from app.core.config import settings from app.models import Project, ProjectCreate +from app.models import Organization, OrganizationCreate from app.tests.utils.test_data import create_test_organization, create_test_project @@ -94,3 +95,48 @@ def test_delete_project( headers=superuser_token_headers, ) assert response.status_code == 404 + + +# Test retrieving projects by organization +def test_read_projects_by_organization( + db: Session, superuser_token_headers: dict[str, str] +) -> None: + project = create_test_project(db) + response = client.get( + f"{settings.API_V1_STR}/projects/organization/{project.organization_id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + response_data = response.json() + assert "data" in response_data + assert isinstance(response_data["data"], list) + assert len(response_data["data"]) >= 1 + assert any(p["id"] == project.id for p in response_data["data"]) + + +# Test retrieving projects by non-existent organization +def test_read_projects_by_organization_not_found( + db: Session, superuser_token_headers: dict[str, str] +) -> None: + response = client.get( + f"{settings.API_V1_STR}/projects/organization/999999", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + + +# Test retrieving projects by inactive organization +def test_read_projects_by_inactive_organization( + db: Session, superuser_token_headers: dict[str, str] +) -> None: + org = create_test_organization(db) + org.is_active = False + db.add(org) + db.commit() + db.refresh(org) + + response = client.get( + f"{settings.API_V1_STR}/projects/organization/{org.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 503 diff --git a/backend/app/tests/crud/test_org.py b/backend/app/tests/crud/test_org.py index 77ee7f319..61532906a 100644 --- a/backend/app/tests/crud/test_org.py +++ b/backend/app/tests/crud/test_org.py @@ -1,6 +1,8 @@ +import pytest +from fastapi import HTTPException from sqlmodel import Session -from app.crud.organization import create_organization, get_organization_by_id +from app.crud.organization import create_organization, get_organization_by_id, validate_organization from app.models import Organization, OrganizationCreate from app.tests.utils.utils import random_lower_string, get_non_existent_id from app.tests.utils.test_data import create_test_organization @@ -32,3 +34,30 @@ def test_get_non_existent_organization(db: Session) -> None: organization_id = get_non_existent_id(db, Organization) fetched_org = get_organization_by_id(session=db, org_id=organization_id) assert fetched_org is None + + +def test_validate_organization_success(db: Session) -> None: + """Test that a valid and active organization passes validation.""" + organization = create_test_organization(db) + + validated_org = validate_organization(session=db, org_id=organization.id) + assert validated_org.id == organization.id + + +def test_validate_organization_not_found(db: Session) -> None: + """Test that validation fails when organization does not exist.""" + non_existent_org_id = get_non_existent_id(db, Organization) + with pytest.raises(HTTPException, match="Organization not found"): + validate_organization(session=db, org_id=non_existent_org_id) + + +def test_validate_organization_inactive(db: Session) -> None: + """Test that validation fails when organization is inactive.""" + organization = create_test_organization(db) + organization.is_active = False + db.add(organization) + db.commit() + db.refresh(organization) + + with pytest.raises(HTTPException, match="Organization is not active"): + validate_organization(session=db, org_id=organization.id) From 5d75b3468a052accb107997eafe957515dab27f5 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:58:48 +0530 Subject: [PATCH 4/7] style: format import statements for better readability --- backend/app/tests/crud/test_org.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/app/tests/crud/test_org.py b/backend/app/tests/crud/test_org.py index 61532906a..db5bb2f2c 100644 --- a/backend/app/tests/crud/test_org.py +++ b/backend/app/tests/crud/test_org.py @@ -2,7 +2,11 @@ from fastapi import HTTPException from sqlmodel import Session -from app.crud.organization import create_organization, get_organization_by_id, validate_organization +from app.crud.organization import ( + create_organization, + get_organization_by_id, + validate_organization, +) from app.models import Organization, OrganizationCreate from app.tests.utils.utils import random_lower_string, get_non_existent_id from app.tests.utils.test_data import create_test_organization From 8e1432274a5ac5b080b180a5ee6c59e2c04efb46 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:04:36 +0530 Subject: [PATCH 5/7] fix: update HTTP status codes for organization validation and project retrieval tests --- backend/app/api/routes/organization.py | 10 +++++----- backend/app/crud/organization.py | 2 +- backend/app/tests/api/routes/test_project.py | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index ca9239ab6..004d324ac 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -31,7 +31,7 @@ def read_organizations( session: SessionDep, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100), -): +)-> APIResponse[List[OrganizationPublic]]: count_statement = select(func.count()).select_from(Organization) count = session.exec(count_statement).one() @@ -49,7 +49,7 @@ def read_organizations( response_model=APIResponse[OrganizationPublic], description=load_description("organization/create.md"), ) -def create_new_organization(*, session: SessionDep, org_in: OrganizationCreate): +def create_new_organization(*, session: SessionDep, org_in: OrganizationCreate) -> APIResponse[OrganizationPublic]: new_org = create_organization(session=session, org_create=org_in) return APIResponse.success_response(new_org) @@ -60,7 +60,7 @@ def create_new_organization(*, session: SessionDep, org_in: OrganizationCreate): response_model=APIResponse[OrganizationPublic], description=load_description("organization/get.md"), ) -def read_organization(*, session: SessionDep, org_id: int): +def read_organization(*, session: SessionDep, org_id: int) -> APIResponse[OrganizationPublic]: """ Retrieve an organization by ID. """ @@ -80,7 +80,7 @@ def read_organization(*, session: SessionDep, org_id: int): ) def update_organization( *, session: SessionDep, org_id: int, org_in: OrganizationUpdate -): +) -> APIResponse[OrganizationPublic]: org = get_organization_by_id(session=session, org_id=org_id) if org is None: logger.error( @@ -108,7 +108,7 @@ def update_organization( include_in_schema=False, description=load_description("organization/delete.md"), ) -def delete_organization(session: SessionDep, org_id: int): +def delete_organization(session: SessionDep, org_id: int) -> APIResponse[None]: org = get_organization_by_id(session=session, org_id=org_id) if org is None: logger.error( diff --git a/backend/app/crud/organization.py b/backend/app/crud/organization.py index e42d3da9f..e27ddc9f8 100644 --- a/backend/app/crud/organization.py +++ b/backend/app/crud/organization.py @@ -52,6 +52,6 @@ def validate_organization(session: Session, org_id: int) -> Organization: logger.error( f"[validate_organization] Organization is not active | 'org_id': {org_id}" ) - raise HTTPException(status_code=503, detail="Organization is not active") + raise HTTPException(status_code=403, detail="Organization is not active") return organization diff --git a/backend/app/tests/api/routes/test_project.py b/backend/app/tests/api/routes/test_project.py index 9ec73b9f7..bbfe55950 100644 --- a/backend/app/tests/api/routes/test_project.py +++ b/backend/app/tests/api/routes/test_project.py @@ -5,7 +5,6 @@ from app.main import app from app.core.config import settings from app.models import Project, ProjectCreate -from app.models import Organization, OrganizationCreate from app.tests.utils.test_data import create_test_organization, create_test_project @@ -139,4 +138,4 @@ def test_read_projects_by_inactive_organization( f"{settings.API_V1_STR}/projects/organization/{org.id}", headers=superuser_token_headers, ) - assert response.status_code == 503 + assert response.status_code == 403 From 3309d6c4bc3b083e5ee7357e9b71eba812c2108b Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:09:51 +0530 Subject: [PATCH 6/7] style: format function signatures for improved readability --- backend/app/api/routes/organization.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index 004d324ac..079e1a508 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -31,7 +31,7 @@ def read_organizations( session: SessionDep, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100), -)-> APIResponse[List[OrganizationPublic]]: +) -> APIResponse[List[OrganizationPublic]]: count_statement = select(func.count()).select_from(Organization) count = session.exec(count_statement).one() @@ -49,7 +49,9 @@ def read_organizations( response_model=APIResponse[OrganizationPublic], description=load_description("organization/create.md"), ) -def create_new_organization(*, session: SessionDep, org_in: OrganizationCreate) -> APIResponse[OrganizationPublic]: +def create_new_organization( + *, session: SessionDep, org_in: OrganizationCreate +) -> APIResponse[OrganizationPublic]: new_org = create_organization(session=session, org_create=org_in) return APIResponse.success_response(new_org) @@ -60,7 +62,9 @@ def create_new_organization(*, session: SessionDep, org_in: OrganizationCreate) response_model=APIResponse[OrganizationPublic], description=load_description("organization/get.md"), ) -def read_organization(*, session: SessionDep, org_id: int) -> APIResponse[OrganizationPublic]: +def read_organization( + *, session: SessionDep, org_id: int +) -> APIResponse[OrganizationPublic]: """ Retrieve an organization by ID. """ From 22f7665789fe15ff45d4346b43817cbab66f6575 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:26:09 +0530 Subject: [PATCH 7/7] feat: add pagination support and has_more metadata to project retrieval --- backend/app/api/routes/project.py | 3 ++- backend/app/tests/api/routes/test_project.py | 26 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index 8490a0a42..c8c50738b 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -38,7 +38,8 @@ def read_projects( statement = select(Project).offset(skip).limit(limit) projects = session.exec(statement).all() - return APIResponse.success_response(projects) + has_more = (skip + limit) < count + return APIResponse.success_response(projects, metadata={"has_more": has_more}) # Create a new project diff --git a/backend/app/tests/api/routes/test_project.py b/backend/app/tests/api/routes/test_project.py index bbfe55950..cdf464495 100644 --- a/backend/app/tests/api/routes/test_project.py +++ b/backend/app/tests/api/routes/test_project.py @@ -60,6 +60,32 @@ def test_read_projects(db: Session, superuser_token_headers: dict[str, str]) -> assert isinstance(response_data["data"], list) +# Test pagination has_more metadata for projects +def test_read_projects_has_more( + db: Session, superuser_token_headers: dict[str, str] +) -> None: + create_test_project(db) + create_test_project(db) + + response = client.get( + f"{settings.API_V1_STR}/projects/?skip=0&limit=1", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + response_data = response.json() + assert "metadata" in response_data + assert response_data["metadata"]["has_more"] is True + + response = client.get( + f"{settings.API_V1_STR}/projects/?skip=0&limit=100", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + response_data = response.json() + assert "metadata" in response_data + assert response_data["metadata"]["has_more"] is False + + # Test updating a project def test_update_project( db: Session, test_project: Project, superuser_token_headers: dict[str, str]