diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e..d4232289 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,8 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils + +from app.api.routes import items, login, private, users, utils,project,organization + from app.core.config import settings api_router = APIRouter() @@ -8,6 +10,8 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(organization.router) +api_router.include_router(project.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py new file mode 100644 index 00000000..a3b198b4 --- /dev/null +++ b/backend/app/api/routes/organization.py @@ -0,0 +1,76 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func +from sqlmodel import Session, select + +from app.models import Organization, OrganizationCreate, OrganizationUpdate, OrganizationPublic +from app.api.deps import ( + CurrentUser, + SessionDep, + get_current_active_superuser, +) +from app.crud.organization import create_organization, get_organization_by_id +from app.utils import APIResponse + +router = APIRouter(prefix="/organizations", tags=["organizations"]) + + +# Retrieve organizations +@router.get("/", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[List[OrganizationPublic]]) +def read_organizations(session: SessionDep, skip: int = 0, limit: int = 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) + +# Create a new organization +@router.post("/", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[OrganizationPublic]) +def create_new_organization(*, session: SessionDep, org_in: OrganizationCreate): + new_org = create_organization(session=session, org_create=org_in) + return APIResponse.success_response(new_org) + + +@router.get("/{org_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[OrganizationPublic]) +def read_organization(*, session: SessionDep, org_id: int): + """ + Retrieve an organization by ID. + """ + org = get_organization_by_id(session=session, org_id=org_id) + if org is None: + raise HTTPException(status_code=404, detail="Organization not found") + return APIResponse.success_response(org) + + +# Update an organization +@router.patch("/{org_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[OrganizationPublic]) +def update_organization(*, session: SessionDep, org_id: int, org_in: OrganizationUpdate): + org = get_organization_by_id(session=session, org_id=org_id) + if org is None: + raise HTTPException(status_code=404, detail="Organization not found") + + org_data = org_in.model_dump(exclude_unset=True) + org = org.model_copy(update=org_data) + + + session.add(org) + session.commit() + session.flush() + + return APIResponse.success_response(org) + + +# Delete an organization +@router.delete("/{org_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[None]) +def delete_organization(session: SessionDep, org_id: int): + org = get_organization_by_id(session=session, org_id=org_id) + if org is None: + raise HTTPException(status_code=404, detail="Organization not found") + + session.delete(org) + session.commit() + + return APIResponse.success_response(None) diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py new file mode 100644 index 00000000..1e068c64 --- /dev/null +++ b/backend/app/api/routes/project.py @@ -0,0 +1,74 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func +from sqlmodel import Session, select + +from app.models import Project, ProjectCreate, ProjectUpdate, ProjectPublic +from app.api.deps import ( + CurrentUser, + SessionDep, + get_current_active_superuser, +) +from app.crud.project import create_project, get_project_by_id, get_projects_by_organization +from app.utils import APIResponse + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +# Retrieve projects +@router.get("/",dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[List[ProjectPublic]]) +def read_projects(session: SessionDep, skip: int = 0, limit: int = 100): + count_statement = select(func.count()).select_from(Project) + count = session.exec(count_statement).one() + + statement = select(Project).offset(skip).limit(limit) + projects = session.exec(statement).all() + + return APIResponse.success_response(projects) + + +# Create a new project +@router.post("/", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) +def create_new_project(*, session: SessionDep, project_in: ProjectCreate): + project = create_project(session=session, project_create=project_in) + return APIResponse.success_response(project) + +@router.get("/{project_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) +def read_project(*, session: SessionDep, project_id: int) : + """ + Retrieve a project by ID. + """ + project = get_project_by_id(session=session, project_id=project_id) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + return APIResponse.success_response(project) + + +# Update a project +@router.patch("/{project_id}",dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) +def update_project(*, session: SessionDep, project_id: int, project_in: ProjectUpdate): + project = get_project_by_id(session=session, project_id=project_id) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + + project_data = project_in.model_dump(exclude_unset=True) + project = project.model_copy(update=project_data) + + session.add(project) + session.commit() + session.flush() + return APIResponse.success_response(project) + + +# Delete a project +@router.delete("/{project_id}",dependencies=[Depends(get_current_active_superuser)]) +def delete_project(session: SessionDep, project_id: int): + project = get_project_by_id(session=session, project_id=project_id) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + + session.delete(project) + session.commit() + + return APIResponse.success_response(None) diff --git a/backend/app/crud/organization.py b/backend/app/crud/organization.py new file mode 100644 index 00000000..0a4213c1 --- /dev/null +++ b/backend/app/crud/organization.py @@ -0,0 +1,21 @@ +from typing import Any, Optional + +from sqlmodel import Session, select + +from app.models import Organization, OrganizationCreate + +def create_organization(*, session: Session, org_create: OrganizationCreate) -> Organization: + db_org = Organization.model_validate(org_create) + session.add(db_org) + session.commit() + session.refresh(db_org) + return db_org + + +def get_organization_by_id(*, session: Session, org_id: int) -> Optional[Organization]: + statement = select(Organization).where(Organization.id == org_id) + return session.exec(statement).first() + +def get_organization_by_name(*, session: Session, name: str) -> Optional[Organization]: + statement = select(Organization).where(Organization.name == name) + return session.exec(statement).first() diff --git a/backend/app/crud/project.py b/backend/app/crud/project.py new file mode 100644 index 00000000..116c6ec8 --- /dev/null +++ b/backend/app/crud/project.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from sqlmodel import Session, select + +from app.models import Project, ProjectCreate + + +def create_project(*, session: Session, project_create: ProjectCreate) -> Project: + db_project = Project.model_validate(project_create) + session.add(db_project) + session.commit() + session.refresh(db_project) + return db_project + +def get_project_by_id(*, session: Session, project_id: int) -> Optional[Project]: + statement = select(Project).where(Project.id == project_id) + return session.exec(statement).first() + +def get_projects_by_organization(*, session: Session, org_id: int) -> List[Project]: + statement = select(Project).where(Project.organization_id == org_id) + return session.exec(statement).all() \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5a03fa56..7e975470 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,4 +11,18 @@ UserUpdateMe, NewPassword, UpdatePassword, +) +from .organization import ( + Organization, + OrganizationCreate, + OrganizationPublic, + OrganizationsPublic, + OrganizationUpdate, +) +from .project import ( + Project, + ProjectCreate, + ProjectPublic, + ProjectsPublic, + ProjectUpdate, ) \ No newline at end of file diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py new file mode 100644 index 00000000..c7fe4a5d --- /dev/null +++ b/backend/app/models/organization.py @@ -0,0 +1,33 @@ +from sqlmodel import Field, Relationship, SQLModel + + +# Shared properties for an Organization +class OrganizationBase(SQLModel): + name: str = Field(unique=True, index=True, max_length=255) + is_active: bool = True + + +# Properties to receive via API on creation +class OrganizationCreate(OrganizationBase): + pass + + +# Properties to receive via API on update, all are optional +class OrganizationUpdate(SQLModel): + name: str | None = Field(default=None, max_length=255) + is_active: bool | None = Field(default=None) + + +# Database model for Organization +class Organization(OrganizationBase, table=True): + id: int = Field(default=None, primary_key=True) + + +# Properties to return via API +class OrganizationPublic(OrganizationBase): + id: int + + +class OrganizationsPublic(SQLModel): + data: list[OrganizationPublic] + count: int \ No newline at end of file diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 00000000..eba80708 --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,37 @@ +from sqlmodel import Field, Relationship, SQLModel + + +# Shared properties for a Project +class ProjectBase(SQLModel): + name: str = Field(index=True, max_length=255) + description: str | None = Field(default=None, max_length=500) + is_active: bool = True + + +# Properties to receive via API on creation +class ProjectCreate(ProjectBase): + organization_id: int + + +# Properties to receive via API on update, all are optional +class ProjectUpdate(SQLModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=500) + is_active: bool | None = Field(default=None) + + +# Database model for Project +class Project(ProjectBase, table=True): + id: int = Field(default=None, primary_key=True) + organization_id: int = Field(foreign_key="organization.id") + + +# Properties to return via API +class ProjectPublic(ProjectBase): + id: int + organization_id: int + + +class ProjectsPublic(SQLModel): + data: list[ProjectPublic] + count: int \ No newline at end of file diff --git a/backend/app/tests/api/routes/test_org.py b/backend/app/tests/api/routes/test_org.py new file mode 100644 index 00000000..bfb9021d --- /dev/null +++ b/backend/app/tests/api/routes/test_org.py @@ -0,0 +1,77 @@ +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from app import crud +from app.core.config import settings +from app.core.security import verify_password +from app.models import User, UserCreate +from app.tests.utils.utils import random_email, random_lower_string +from app.models import Organization, OrganizationCreate, OrganizationUpdate +from app.api.deps import get_db +from app.main import app +from app.crud.organization import create_organization, get_organization_by_id + +client = TestClient(app) + +@pytest.fixture +def test_organization(db: Session, superuser_token_headers: dict[str, str]): + unique_name = f"TestOrg-{random_lower_string()}" + org_data = OrganizationCreate(name=unique_name, is_active=True) + organization = create_organization(session=db, org_create=org_data) + db.commit() + return organization + +# Test retrieving organizations +def test_read_organizations(db: Session, superuser_token_headers: dict[str, str]): + response = client.get(f"{settings.API_V1_STR}/organizations/", headers=superuser_token_headers) + assert response.status_code == 200 + response_data = response.json() + assert "data" in response_data + assert isinstance(response_data["data"], list) + +# Test creating an organization +def test_create_organization(db: Session, superuser_token_headers: dict[str, str]): + unique_name = f"Org-{random_lower_string()}" + org_data = {"name": unique_name, "is_active": True} + response = client.post( + f"{settings.API_V1_STR}/organizations/", json=org_data, headers=superuser_token_headers + ) + + assert 200 <= response.status_code < 300 + created_org = response.json() + assert "data" in created_org # Make sure there's a 'data' field + created_org_data = created_org["data"] + org = get_organization_by_id(session=db, org_id=created_org_data["id"]) + assert org is not None # The organization should be found in the DB + assert org.name == created_org_data["name"] + assert org.is_active == created_org_data["is_active"] + + +def test_update_organization(db: Session, test_organization: Organization, superuser_token_headers: dict[str, str]): + unique_name = f"UpdatedOrg-{random_lower_string()}" # Ensure a unique name + update_data = {"name": unique_name, "is_active": False} + + response = client.patch( + f"{settings.API_V1_STR}/organizations/{test_organization.id}", + json=update_data, + headers=superuser_token_headers, + ) + + assert response.status_code == 200 + updated_org = response.json()["data"] + assert "name" in updated_org + assert updated_org["name"] == update_data["name"] + assert "is_active" in updated_org + assert updated_org["is_active"] == update_data["is_active"] + + +# Test deleting an organization +def test_delete_organization(db: Session, test_organization: Organization, superuser_token_headers: dict[str, str]): + response = client.delete( + f"{settings.API_V1_STR}/organizations/{test_organization.id}", headers=superuser_token_headers + ) + assert response.status_code == 200 + response = client.get(f"{settings.API_V1_STR}/organizations/{test_organization.id}", headers=superuser_token_headers) + assert response.status_code == 404 + \ No newline at end of file diff --git a/backend/app/tests/api/routes/test_project.py b/backend/app/tests/api/routes/test_project.py new file mode 100644 index 00000000..157dde3d --- /dev/null +++ b/backend/app/tests/api/routes/test_project.py @@ -0,0 +1,89 @@ +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.main import app +from app.core.config import settings +from app.models import Project, ProjectCreate, ProjectUpdate +from app.models import Organization, OrganizationCreate, ProjectUpdate +from app.api.deps import get_db +from app.tests.utils.utils import random_lower_string, random_email +from app.crud.project import create_project, get_project_by_id +from app.crud.organization import create_organization + +client = TestClient(app) + +@pytest.fixture +def test_project(db: Session, superuser_token_headers: dict[str, str]): + unique_org_name = f"TestOrg-{random_lower_string()}" + org_data = OrganizationCreate(name=unique_org_name, is_active=True) + organization = create_organization(session=db, org_create=org_data) + db.commit() + + unique_project_name = f"TestProject-{random_lower_string()}" + project_description = "This is a test project description." + project_data = ProjectCreate(name=unique_project_name, description=project_description, is_active=True, organization_id=organization.id) + project = create_project(session=db, project_create=project_data) + db.commit() + + return project + +#Test retrieving projects +def test_read_projects(db: Session, superuser_token_headers: dict[str, str]): + response = client.get(f"{settings.API_V1_STR}/projects/", headers=superuser_token_headers) + assert response.status_code == 200 + response_data = response.json() + assert "data" in response_data + assert isinstance(response_data["data"], list) + +# Test creating a project +def test_create_new_project(db: Session, superuser_token_headers: dict[str, str]): + unique_org_name = f"TestOrg-{random_lower_string()}" + org_data = OrganizationCreate(name=unique_org_name, is_active=True) + organization = create_organization(session=db, org_create=org_data) + db.commit() + + unique_project_name = f"TestProject-{random_lower_string()}" + project_description = "This is a test project description." + project_data = ProjectCreate(name=unique_project_name, description=project_description, is_active=True, organization_id=organization.id) + + response = client.post( + f"{settings.API_V1_STR}/projects/", json=project_data.dict(), headers=superuser_token_headers + ) + + assert response.status_code == 200 + created_project = response.json() + + # Adjusted for a nested structure, if needed + assert "data" in created_project # Check if response contains a 'data' field + assert created_project["data"]["name"] == unique_project_name # Now checking 'name' inside 'data' + assert created_project["data"]["description"] == project_description + assert created_project["data"]["organization_id"] == organization.id + + +# Test updating a project +def test_update_project(db: Session, test_project: Project, superuser_token_headers: dict[str, str]): + update_data = {"name": "Updated Project Name", "is_active": False} + + response = client.patch( + f"{settings.API_V1_STR}/projects/{test_project.id}", + json=update_data, + headers=superuser_token_headers, + ) + + assert response.status_code == 200 + updated_project = response.json()["data"] + assert "name" in updated_project + assert updated_project["name"] == update_data["name"] + assert "is_active" in updated_project + assert updated_project["is_active"] == update_data["is_active"] + + +# Test deleting a project +def test_delete_project(db: Session, test_project: Project, superuser_token_headers: dict[str, str]): + response = client.delete( + f"{settings.API_V1_STR}/projects/{test_project.id}", headers=superuser_token_headers + ) + assert response.status_code == 200 + response = client.get(f"{settings.API_V1_STR}/projects/{test_project.id}", headers=superuser_token_headers) + assert response.status_code == 404 \ No newline at end of file diff --git a/backend/app/tests/crud/test_org.py b/backend/app/tests/crud/test_org.py new file mode 100644 index 00000000..0b7eaded --- /dev/null +++ b/backend/app/tests/crud/test_org.py @@ -0,0 +1,34 @@ +from sqlmodel import Session + +from app.crud.organization import create_organization, get_organization_by_id +from app.models import Organization, OrganizationCreate +from app.tests.utils.utils import random_lower_string + + +def test_create_organization(db: Session) -> None: + """Test creating an organization.""" + name = random_lower_string() + org_in = OrganizationCreate(name=name) + org = create_organization(session=db, org_create=org_in) + + assert org.name == name + assert org.id is not None + assert org.is_active is True # Default should be active + + +def test_get_organization_by_id(db: Session) -> None: + """Test retrieving an organization by ID.""" + name = random_lower_string() + org_in = OrganizationCreate(name=name) + org = create_organization(session=db, org_create=org_in) + + fetched_org = get_organization_by_id(session=db, org_id=org.id) + assert fetched_org + assert fetched_org.id == org.id + assert fetched_org.name == org.name + + +def test_get_non_existent_organization(db: Session) -> None: + """Test retrieving a non-existent organization should return None.""" + fetched_org = get_organization_by_id(session=db, org_id=999) # Assuming ID 999 does not exist + assert fetched_org is None \ No newline at end of file diff --git a/backend/app/tests/crud/test_project.py b/backend/app/tests/crud/test_project.py new file mode 100644 index 00000000..9f3a543d --- /dev/null +++ b/backend/app/tests/crud/test_project.py @@ -0,0 +1,65 @@ +import pytest +from sqlmodel import SQLModel, Session, create_engine + +from app.models import Project, ProjectCreate, Organization +from app.crud.project import create_project, get_project_by_id, get_projects_by_organization +from app.tests.utils.utils import random_lower_string + +def test_create_project(db: Session) -> None: + """Test creating a project linked to an organization.""" + org = Organization(name=random_lower_string()) + db.add(org) + db.commit() + db.refresh(org) + + project_name = random_lower_string() + project_data = ProjectCreate(name=project_name, description="Test description", is_active=True, organization_id=org.id) + + project = create_project(session=db, project_create=project_data) + + assert project.id is not None + assert project.name == project_name + assert project.description == "Test description" + assert project.organization_id == org.id + + +def test_get_project_by_id(db: Session) -> None: + """Test retrieving a project by ID.""" + org = Organization(name=random_lower_string()) + db.add(org) + db.commit() + db.refresh(org) + + project_name = random_lower_string() + project_data = ProjectCreate(name=project_name, description="Test", organization_id=org.id) + + project = create_project(session=db, project_create=project_data) + + fetched_project = get_project_by_id(session=db, project_id=project.id) + assert fetched_project is not None + assert fetched_project.id == project.id + assert fetched_project.name == project.name + + + +def test_get_projects_by_organization(db: Session) -> None: + """Test retrieving all projects for an organization.""" + org = Organization(name=random_lower_string()) + db.add(org) + db.commit() + db.refresh(org) + + project_1 = create_project(session=db, project_create=ProjectCreate(name=random_lower_string(), organization_id=org.id)) + project_2 = create_project(session=db, project_create=ProjectCreate(name=random_lower_string(), organization_id=org.id)) + + projects = get_projects_by_organization(session=db, org_id=org.id) + + assert len(projects) == 2 + assert project_1 in projects + assert project_2 in projects + + +def test_get_non_existent_project(db: Session) -> None: + """Test retrieving a non-existent project should return None.""" + fetched_project = get_project_by_id(session=db, project_id=999) + assert fetched_project is None diff --git a/backend/app/utils.py b/backend/app/utils.py index da9e1d11..6724640b 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -138,4 +138,4 @@ def verify_password_reset_token(token: str) -> str | None: ) return str(decoded_token["sub"]) except InvalidTokenError: - return None + return None \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a4d21539..64cf8867 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -168,4 +168,4 @@ volumes: networks: traefik-public: # Allow setting it to false for testing - external: true + external: true \ No newline at end of file