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..079e1a508 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), +) -> APIResponse[List[OrganizationPublic]]: 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 @@ -44,7 +49,9 @@ def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100): 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) @@ -55,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): +def read_organization( + *, session: SessionDep, org_id: int +) -> APIResponse[OrganizationPublic]: """ Retrieve an organization by ID. """ @@ -75,7 +84,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( @@ -103,7 +112,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/api/routes/project.py b/backend/app/api/routes/project.py index 8a114930d..c8c50738b 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__) @@ -36,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 @@ -112,3 +115,18 @@ 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 +) -> 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..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("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_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..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] @@ -94,3 +120,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 == 403 diff --git a/backend/app/tests/crud/test_org.py b/backend/app/tests/crud/test_org.py index 77ee7f319..db5bb2f2c 100644 --- a/backend/app/tests/crud/test_org.py +++ b/backend/app/tests/crud/test_org.py @@ -1,6 +1,12 @@ +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 +38,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)