From fc9d8a570f04e1888b190b47e1ec7541c9323040 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Mon, 17 Mar 2025 14:46:15 +0530 Subject: [PATCH 01/32] trial --- backend/app/api/routes/Organization.py | 69 ++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 backend/app/api/routes/Organization.py diff --git a/backend/app/api/routes/Organization.py b/backend/app/api/routes/Organization.py new file mode 100644 index 00000000..20c2cce5 --- /dev/null +++ b/backend/app/api/routes/Organization.py @@ -0,0 +1,69 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func +from sqlmodel import Session, select +from typing import Any, List + +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 + +router = APIRouter(prefix="/organizations", tags=["organizations"]) + + +# Retrieve organizations +@router.get("/",dependencies=[Depends(get_current_active_superuser)], response_model=List[OrganizationPublic]) +def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: + 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 organizations + + +# Create a new organization +@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=OrganizationPublic) +def create_new_organization(*, session: SessionDep, org_in: OrganizationCreate) -> Any: + return create_organization(session=session, org_create=org_in) + +@router.get("/{org_id}", dependencies=[Depends(get_current_active_superuser)], response_model=OrganizationPublic) +def read_organization(*, session: SessionDep, org_id: int) -> Any: + """ + Retrieve an organization by ID. + """ + org = get_organization_by_id(session=session, org_id=org_id) + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + return org + +# Update an organization +@router.patch("/{org_id}",dependencies=[Depends(get_current_active_superuser)], response_model=OrganizationPublic) +def update_organization(*, session: SessionDep, org_id: int, org_in: OrganizationUpdate) -> Any: + org = get_organization_by_id(session=session, org_id=org_id) + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + org_data = org_in.model_dump(exclude_unset=True) + for key, value in org_data.items(): + setattr(org, key, value) + + session.add(org) + session.commit() + session.refresh(org) + return org + + +# Delete an organization +@router.delete("/{org_id}",dependencies=[Depends(get_current_active_superuser)]) +def delete_organization(session: SessionDep, org_id: int) -> None: + org = get_organization_by_id(session=session, org_id=org_id) + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + session.delete(org) + session.commit() \ No newline at end of file From 899ebdd8e0c9ed107c1803fa21abae63ccb5b70f Mon Sep 17 00:00:00 2001 From: nishika26 Date: Mon, 17 Mar 2025 14:52:02 +0530 Subject: [PATCH 02/32] pushing all --- backend/app/api/main.py | 6 +- backend/app/api/routes/Project.py | 70 +++++++++++++++++ backend/app/crud/organization.py | 22 ++++++ backend/app/crud/project.py | 24 ++++++ backend/app/tests/api/routes/test_org.py | 70 +++++++++++++++++ backend/app/tests/api/routes/test_project.py | 80 ++++++++++++++++++++ backend/app/tests/crud/test_org.py | 33 ++++++++ backend/app/tests/crud/test_project.py | 64 ++++++++++++++++ 8 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/routes/Project.py create mode 100644 backend/app/crud/organization.py create mode 100644 backend/app/crud/project.py create mode 100644 backend/app/tests/api/routes/test_org.py create mode 100644 backend/app/tests/api/routes/test_project.py create mode 100644 backend/app/tests/crud/test_org.py create mode 100644 backend/app/tests/crud/test_project.py diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e..165f3cd1 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,Organization, Project + 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/Project.py b/backend/app/api/routes/Project.py new file mode 100644 index 00000000..ca5518a0 --- /dev/null +++ b/backend/app/api/routes/Project.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func +from sqlmodel import Session, select +from typing import Any, List + +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 + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +# Retrieve projects +@router.get("/",dependencies=[Depends(get_current_active_superuser)], response_model=List[ProjectPublic]) +def read_projects(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: + 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 projects + + +# Create a new project +@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) +def create_new_project(*, session: SessionDep, project_in: ProjectCreate) -> Any: + return create_project(session=session, project_create=project_in) + +@router.get("/{project_id}", dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) +def read_project(*, session: SessionDep, project_id: int) -> Any: + """ + Retrieve a project by ID. + """ + project = get_project_by_id(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +# Update a project +@router.patch("/{project_id}",dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) +def update_project(*, session: SessionDep, project_id: int, project_in: ProjectUpdate) -> Any: + project = get_project_by_id(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + project_data = project_in.model_dump(exclude_unset=True) + for key, value in project_data.items(): + setattr(project, key, value) + + session.add(project) + session.commit() + session.refresh(project) + return project + + +# Delete a project +@router.delete("/{project_id}",dependencies=[Depends(get_current_active_superuser)],) +def delete_project(session: SessionDep, project_id: int) -> None: + project = get_project_by_id(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + session.delete(project) + session.commit() \ No newline at end of file diff --git a/backend/app/crud/organization.py b/backend/app/crud/organization.py new file mode 100644 index 00000000..ef3909af --- /dev/null +++ b/backend/app/crud/organization.py @@ -0,0 +1,22 @@ +from typing import Any, Optional +from sqlmodel import Session, select +from app.models import Organization, OrganizationCreate + + +# Create a new organization +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 + + +# Get organization by ID +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..007d6031 --- /dev/null +++ b/backend/app/crud/project.py @@ -0,0 +1,24 @@ +from typing import List, Optional +from sqlmodel import Session, select +from app.models import Project, ProjectCreate + + +# Create a new project linked to an organization +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 + + +# Get project by ID +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() + + +# Get all projects for a specific organization +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/tests/api/routes/test_org.py b/backend/app/tests/api/routes/test_org.py new file mode 100644 index 00000000..3064fa60 --- /dev/null +++ b/backend/app/tests/api/routes/test_org.py @@ -0,0 +1,70 @@ +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 + +import pytest +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 + assert isinstance(response.json(), 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() + org = get_organization_by_id(session=db, org_id=created_org["id"]) + assert org + assert org.name == created_org["name"] + +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() + 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..ce2d70c8 --- /dev/null +++ b/backend/app/tests/api/routes/test_project.py @@ -0,0 +1,80 @@ +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 + assert isinstance(response.json(), 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() + assert created_project["name"] == unique_project_name + assert created_project["description"] == project_description + assert created_project["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() + 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..8f6f653c --- /dev/null +++ b/backend/app/tests/crud/test_org.py @@ -0,0 +1,33 @@ +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..fc30380b --- /dev/null +++ b/backend/app/tests/crud/test_project.py @@ -0,0 +1,64 @@ +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 From 40c12368c4db8c0cb04357bd8a7b838f4f350377 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Mon, 17 Mar 2025 15:11:17 +0530 Subject: [PATCH 03/32] models file --- backend/app/models/__init__.py | 14 +++++++++++ backend/app/models/organization.py | 33 ++++++++++++++++++++++++++ backend/app/models/project.py | 37 ++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 backend/app/models/organization.py create mode 100644 backend/app/models/project.py 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 From 74e76ddd1199a5c26b5c47c7e1e2bb6f4ea7086d Mon Sep 17 00:00:00 2001 From: nishika26 Date: Mon, 17 Mar 2025 19:53:04 +0530 Subject: [PATCH 04/32] renaming --- backend/app/api/main.py | 7 ++++--- backend/app/api/routes/{Organization.py => oganization.py} | 0 2 files changed, 4 insertions(+), 3 deletions(-) rename backend/app/api/routes/{Organization.py => oganization.py} (100%) diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 165f3cd1..e5907abe 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,7 +1,8 @@ +from backend.app.api.routes import oganization from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils,Organization, Project +from app.api.routes import items, login, private, users, utils,project,organization from app.core.config import settings @@ -10,8 +11,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) +api_router.include_router(oganization.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/oganization.py similarity index 100% rename from backend/app/api/routes/Organization.py rename to backend/app/api/routes/oganization.py From abd3a9b8718821fb659d8068c0668afd343ce8da Mon Sep 17 00:00:00 2001 From: Nishika Yadav <89646695+nishika26@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:53:51 +0530 Subject: [PATCH 05/32] Rename Project.py to project.py --- backend/app/api/routes/{Project.py => project.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename backend/app/api/routes/{Project.py => project.py} (99%) diff --git a/backend/app/api/routes/Project.py b/backend/app/api/routes/project.py similarity index 99% rename from backend/app/api/routes/Project.py rename to backend/app/api/routes/project.py index ca5518a0..2057bce8 100644 --- a/backend/app/api/routes/Project.py +++ b/backend/app/api/routes/project.py @@ -67,4 +67,4 @@ def delete_project(session: SessionDep, project_id: int) -> None: raise HTTPException(status_code=404, detail="Project not found") session.delete(project) - session.commit() \ No newline at end of file + session.commit() From 9bd1c218d8254c556eadf61cfc579d7396af8746 Mon Sep 17 00:00:00 2001 From: Nishika Yadav <89646695+nishika26@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:54:38 +0530 Subject: [PATCH 06/32] Rename oganization.py to organization.py --- backend/app/api/routes/{oganization.py => organization.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename backend/app/api/routes/{oganization.py => organization.py} (99%) diff --git a/backend/app/api/routes/oganization.py b/backend/app/api/routes/organization.py similarity index 99% rename from backend/app/api/routes/oganization.py rename to backend/app/api/routes/organization.py index 20c2cce5..1b878f54 100644 --- a/backend/app/api/routes/oganization.py +++ b/backend/app/api/routes/organization.py @@ -66,4 +66,4 @@ def delete_organization(session: SessionDep, org_id: int) -> None: raise HTTPException(status_code=404, detail="Organization not found") session.delete(org) - session.commit() \ No newline at end of file + session.commit() From 42528cb58a0fd4297617da0d3d09721104d0ea96 Mon Sep 17 00:00:00 2001 From: Sourabh Lodha Date: Fri, 14 Mar 2025 10:56:56 +0530 Subject: [PATCH 07/32] Update README.md (#44) --- README.md | 193 +++++------------------------------------------------- 1 file changed, 18 insertions(+), 175 deletions(-) diff --git a/README.md b/README.md index afe124f3..e500f4e6 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,24 @@ -# Full Stack FastAPI Template +# AI Platform Template -Test -Coverage +## Pre-requisites -## Technology Stack and Features +- [docker](https://docs.docker.com/get-started/get-docker/) Docker +- [uv](https://docs.astral.sh/uv/) for Python package and environment management. -- โšก [**FastAPI**](https://fastapi.tiangolo.com) for the Python backend API. - - ๐Ÿงฐ [SQLModel](https://sqlmodel.tiangolo.com) for the Python SQL database interactions (ORM). - - ๐Ÿ” [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management. - - ๐Ÿ’พ [PostgreSQL](https://www.postgresql.org) as the SQL database. -- ๐Ÿš€ [React](https://react.dev) for the frontend. - - ๐Ÿ’ƒ Using TypeScript, hooks, Vite, and other parts of a modern frontend stack. - - ๐ŸŽจ [Chakra UI](https://chakra-ui.com) for the frontend components. - - ๐Ÿค– An automatically generated frontend client. - - ๐Ÿงช [Playwright](https://playwright.dev) for End-to-End testing. - - ๐Ÿฆ‡ Dark mode support. -- ๐Ÿ‹ [Docker Compose](https://www.docker.com) for development and production. -- ๐Ÿ”’ Secure password hashing by default. -- ๐Ÿ”‘ JWT (JSON Web Token) authentication. -- ๐Ÿ“ซ Email based password recovery. -- โœ… Tests with [Pytest](https://pytest.org). -- ๐Ÿ“ž [Traefik](https://traefik.io) as a reverse proxy / load balancer. -- ๐Ÿšข Deployment instructions using Docker Compose, including how to set up a frontend Traefik proxy to handle automatic HTTPS certificates. -- ๐Ÿญ CI (continuous integration) and CD (continuous deployment) based on GitHub Actions. - -### Dashboard Login - -[![API docs](img/login.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Admin - -[![API docs](img/dashboard.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Create User - -[![API docs](img/dashboard-create.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Items - -[![API docs](img/dashboard-items.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - User Settings - -[![API docs](img/dashboard-user-settings.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Dark Mode - -[![API docs](img/dashboard-dark.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Interactive API Documentation - -[![API docs](img/docs.png)](https://github.com/fastapi/full-stack-fastapi-template) - -## How To Use It +## Project Setup You can **just fork or clone** this repository and use it as is. โœจ It just works. โœจ -### How to Use a Private Repository - -If you want to have a private repository, GitHub won't allow you to simply fork it as it doesn't allow changing the visibility of forks. - -But you can do the following: - -- Create a new GitHub repo, for example `my-full-stack`. -- Clone this repository manually, set the name with the name of the project you want to use, for example `my-full-stack`: - -```bash -git clone git@github.com:fastapi/full-stack-fastapi-template.git my-full-stack -``` - -- Enter into the new directory: - -```bash -cd my-full-stack -``` - -- Set the new origin to your new repository, copy it from the GitHub interface, for example: - -```bash -git remote set-url origin git@github.com:octocat/my-full-stack.git -``` - -- Add this repo as another "remote" to allow you to get updates later: - -```bash -git remote add upstream git@github.com:fastapi/full-stack-fastapi-template.git -``` - -- Push the code to your new repository: - -```bash -git push -u origin master -``` - -### Update From the Original Template - -After cloning the repository, and after doing changes, you might want to get the latest changes from this original template. - -- Make sure you added the original repository as a remote, you can check it with: - -```bash -git remote -v - -origin git@github.com:octocat/my-full-stack.git (fetch) -origin git@github.com:octocat/my-full-stack.git (push) -upstream git@github.com:fastapi/full-stack-fastapi-template.git (fetch) -upstream git@github.com:fastapi/full-stack-fastapi-template.git (push) -``` - -- Pull the latest changes without merging: - -```bash -git pull --no-commit upstream master -``` - -This will download the latest changes from this template without committing them, that way you can check everything is right before committing. - -- If there are conflicts, solve them in your editor. +### Configure -- Once you are done, commit the changes: +Create env file using example file ```bash -git merge --continue +cp envSample .env ``` -### Configure - You can then update configs in the `.env` files to customize your configurations. Before deploying it, make sure you change at least the values for: @@ -135,10 +26,7 @@ Before deploying it, make sure you change at least the values for: - `SECRET_KEY` - `FIRST_SUPERUSER_PASSWORD` - `POSTGRES_PASSWORD` - -You can (and should) pass these as environment variables from secrets. - -Read the [deployment.md](./deployment.md) docs for more details. +```bash ### Generate Secret Keys @@ -152,73 +40,28 @@ python -c "import secrets; print(secrets.token_urlsafe(32))" Copy the content and use that as password / secret key. And run that again to generate another secure key. -## How To Use It - Alternative With Copier - -This repository also supports generating a new project using [Copier](https://copier.readthedocs.io). +## Boostrap & development mode -It will copy all the files, ask you configuration questions, and update the `.env` files with your answers. - -### Install Copier - -You can install Copier with: +This is a dockerized setup, hence start the project using below command ```bash -pip install copier +docker compose watch ``` -Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with: - -```bash -pipx install copier -``` - -**Note**: If you have `pipx`, installing copier is optional, you could run it directly. - -### Generate a Project With Copier - -Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`. - -Go to the directory that will be the parent of your project, and run the command with your project's name: +This should start all necessary services for the project and will also mount file system as volume for easy development. -```bash -copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust -``` - -If you have `pipx` and you didn't install `copier`, you can run it directly: +You verify backend running by doing health-check ```bash -pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust +curl http://[your-domain]:8000/api/v1/utils/health/ ``` -**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files. - -### Input Variables - -Copier will ask you for some data, you might want to have at hand before generating the project. - -But don't worry, you can just update any of that in the `.env` files afterwards. - -The input variables, with their default values (some auto generated) are: - -- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env). -- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env). -- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above. -- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env). -- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env). -- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env. -- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env. -- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env. -- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env. -- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above. -- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env. +or by visiting: http://[your-domain]:8000/api/v1/utils/health-check/ in the browser ## Backend Development Backend docs: [backend/README.md](./backend/README.md). -## Frontend Development - -Frontend docs: [frontend/README.md](./frontend/README.md). ## Deployment @@ -234,6 +77,6 @@ This includes using Docker Compose, custom local domains, `.env` configurations, Check the file [release-notes.md](./release-notes.md). -## License +## Credits -The Full Stack FastAPI Template is licensed under the terms of the MIT license. +This project was created using [full-stack-fastapi-template](https://github.com/fastapi/full-stack-fastapi-template). A big thank you to the team for creating and maintaining the template!!! From d96f486d8d84e6238c9e89b2fb5845000e8f056e Mon Sep 17 00:00:00 2001 From: Sourabh Lodha Date: Fri, 14 Mar 2025 11:01:54 +0530 Subject: [PATCH 08/32] changes (#45) Co-authored-by: sourabhlodha --- README.md | 2 +- .env => envSample | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename .env => envSample (86%) diff --git a/README.md b/README.md index e500f4e6..701bb0a3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AI Platform Template +# AI Platform ## Pre-requisites diff --git a/.env b/envSample similarity index 86% rename from .env rename to envSample index 1d44286e..23fe5f23 100644 --- a/.env +++ b/envSample @@ -17,7 +17,7 @@ PROJECT_NAME="Full Stack FastAPI Project" STACK_NAME=full-stack-fastapi-project # Backend -BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" +BACKEND_CORS_ORIGINS="http://localhost" SECRET_KEY=changethis FIRST_SUPERUSER=admin@example.com FIRST_SUPERUSER_PASSWORD=changethis @@ -42,4 +42,4 @@ SENTRY_DSN= # Configure these with your own Docker registry images DOCKER_IMAGE_BACKEND=backend -DOCKER_IMAGE_FRONTEND=frontend +DOCKER_IMAGE_FRONTEND=frontend \ No newline at end of file From 06782dc5aa7075cdfdbc1a6c8e53708bf7ae04ba Mon Sep 17 00:00:00 2001 From: Sourabh Lodha Date: Fri, 14 Mar 2025 11:10:45 +0530 Subject: [PATCH 09/32] Readme update (#47) rename project and stack --------- Co-authored-by: sourabhlodha --- deployment.md | 2 +- development.md | 2 +- envSample | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/deployment.md b/deployment.md index eadf76dd..b75a4213 100644 --- a/deployment.md +++ b/deployment.md @@ -1,4 +1,4 @@ -# FastAPI Project - Deployment +# AI Platform - Deployment You can deploy the project using Docker Compose to a remote server. diff --git a/development.md b/development.md index d7d41d73..412c3192 100644 --- a/development.md +++ b/development.md @@ -1,4 +1,4 @@ -# FastAPI Project - Development +# AI Platform - Development ## Docker Compose diff --git a/envSample b/envSample index 23fe5f23..44c5e78d 100644 --- a/envSample +++ b/envSample @@ -13,8 +13,10 @@ FRONTEND_HOST=http://localhost:5173 # Environment: local, staging, production ENVIRONMENT=local -PROJECT_NAME="Full Stack FastAPI Project" -STACK_NAME=full-stack-fastapi-project + +PROJECT_NAME="AI Platform" +STACK_NAME=ai-platform + # Backend BACKEND_CORS_ORIGINS="http://localhost" From daeeaf95449537028a7cd90459bbe9438831bd96 Mon Sep 17 00:00:00 2001 From: Aviraj Gour <100823015+avirajsingh7@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:18:16 +0530 Subject: [PATCH 10/32] fix create_user endpoint (#62) --- backend/app/api/routes/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 8ea7d4da..6edbfbeb 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -51,7 +51,7 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: @router.post( "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic ) -def create_user(*, session: SessionDep, user_in: UserCreate) -> Any: +def create_user_endpoint(*, session: SessionDep, user_in: UserCreate) -> Any: """ Create new user. """ From f95f5f2bebb7095f0fca194b5bcc00f154ea3753 Mon Sep 17 00:00:00 2001 From: Aviraj Gour <100823015+avirajsingh7@users.noreply.github.com> Date: Tue, 18 Mar 2025 12:09:11 +0530 Subject: [PATCH 11/32] standard api response and http exception handling (#67) --- backend/app/api/deps.py | 13 ++++++++++++- backend/app/main.py | 7 +++++-- backend/app/utils.py | 20 +++++++++++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c2b83c84..24207ad3 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -2,7 +2,8 @@ from typing import Annotated import jwt -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Request +from fastapi.responses import JSONResponse from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError from pydantic import ValidationError @@ -12,6 +13,7 @@ from app.core.config import settings from app.core.db import engine from app.models import TokenPayload, User +from app.utils import APIResponse reusable_oauth2 = OAuth2PasswordBearer( tokenUrl=f"{settings.API_V1_STR}/login/access-token" @@ -55,3 +57,12 @@ def get_current_active_superuser(current_user: CurrentUser) -> User: status_code=403, detail="The user doesn't have enough privileges" ) return current_user + +async def http_exception_handler(request: Request, exc: HTTPException): + """ + Global handler for HTTPException to return standardized response format. + """ + return JSONResponse( + status_code=exc.status_code, + content=APIResponse.failure_response(exc.detail).model_dump() | {"detail": exc.detail}, # TEMPORARY: Keep "detail" for backward compatibility + ) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801e..4f87bc80 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,13 @@ import sentry_sdk -from fastapi import FastAPI + +from fastapi import FastAPI, HTTPException from fastapi.routing import APIRoute from starlette.middleware.cors import CORSMiddleware from app.api.main import api_router +from app.api.deps import http_exception_handler from app.core.config import settings - def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.tags[0]}-{route.name}" @@ -31,3 +32,5 @@ def custom_generate_unique_id(route: APIRoute) -> str: ) app.include_router(api_router, prefix=settings.API_V1_STR) + +app.add_exception_handler(HTTPException, http_exception_handler) diff --git a/backend/app/utils.py b/backend/app/utils.py index ac029f63..da9e1d11 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any +from typing import Any, Dict, Generic, Optional, TypeVar import emails # type: ignore import jwt @@ -12,9 +12,27 @@ from app.core import security from app.core.config import settings +from typing import Generic, Optional, TypeVar +from pydantic import BaseModel + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +T = TypeVar("T") + +class APIResponse(BaseModel, Generic[T]): + success: bool + data: Optional[T] = None + error: Optional[str] = None + + @classmethod + def success_response(cls, data: T) -> "APIResponse[T]": + return cls(success=True, data=data, error=None) + + @classmethod + def failure_response(cls, error: str) -> "APIResponse[None]": + return cls(success=False, data=None, error=error) + @dataclass class EmailData: From d74252aa73e2bc8f13f7898218103c0c904ff816 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Tue, 18 Mar 2025 14:07:51 +0530 Subject: [PATCH 12/32] standardization and edits --- backend/app/api/main.py | 3 +- backend/app/api/routes/organization.py | 37 ++++++++++++++---------- backend/app/api/routes/project.py | 39 ++++++++++++++------------ 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/backend/app/api/main.py b/backend/app/api/main.py index e5907abe..d4232289 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,4 +1,3 @@ -from backend.app.api.routes import oganization from fastapi import APIRouter @@ -11,7 +10,7 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) -api_router.include_router(oganization.router) +api_router.include_router(organization.router) api_router.include_router(project.router) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index 1b878f54..ab473e4f 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -10,12 +10,13 @@ get_current_active_superuser, ) from app.crud.organization import create_organization, get_organization_by_id +from app.responses import APIResponse router = APIRouter(prefix="/organizations", tags=["organizations"]) # Retrieve organizations -@router.get("/",dependencies=[Depends(get_current_active_superuser)], response_model=List[OrganizationPublic]) +@router.get("/", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[List[OrganizationPublic]]) def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: count_statement = select(func.count()).select_from(Organization) count = session.exec(count_statement).one() @@ -23,47 +24,53 @@ def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100) -> statement = select(Organization).offset(skip).limit(limit) organizations = session.exec(statement).all() - return organizations + return APIResponse.success_response(organizations) # Create a new organization -@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=OrganizationPublic) +@router.post("/", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[OrganizationPublic]) def create_new_organization(*, session: SessionDep, org_in: OrganizationCreate) -> Any: - return create_organization(session=session, org_create=org_in) + 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=OrganizationPublic) + +@router.get("/{org_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[OrganizationPublic]) def read_organization(*, session: SessionDep, org_id: int) -> Any: """ Retrieve an organization by ID. """ org = get_organization_by_id(session=session, org_id=org_id) - if not org: + if org is None: raise HTTPException(status_code=404, detail="Organization not found") - return org + return APIResponse.success_response(org) + # Update an organization -@router.patch("/{org_id}",dependencies=[Depends(get_current_active_superuser)], response_model=OrganizationPublic) +@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) -> Any: org = get_organization_by_id(session=session, org_id=org_id) - if not org: + if org is None: raise HTTPException(status_code=404, detail="Organization not found") org_data = org_in.model_dump(exclude_unset=True) - for key, value in org_data.items(): - setattr(org, key, value) + org = org.model_copy(update=org_data) + session.add(org) session.commit() session.refresh(org) - return org + + return APIResponse.success_response(org) # Delete an organization -@router.delete("/{org_id}",dependencies=[Depends(get_current_active_superuser)]) -def delete_organization(session: SessionDep, org_id: int) -> None: +@router.delete("/{org_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[None]) +def delete_organization(session: SessionDep, org_id: int) -> Any: org = get_organization_by_id(session=session, org_id=org_id) - if not org: + 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 index 2057bce8..500424c7 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -1,7 +1,8 @@ +from typing import Any, List + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func from sqlmodel import Session, select -from typing import Any, List from app.models import Project, ProjectCreate, ProjectUpdate, ProjectPublic from app.api.deps import ( @@ -10,61 +11,63 @@ 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=List[ProjectPublic]) -def read_projects(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: +@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 projects + return APIResponse.success_response(projects) # Create a new project -@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) -def create_new_project(*, session: SessionDep, project_in: ProjectCreate) -> Any: +@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) +def create_new_project(*, session: SessionDep, project_in: ProjectCreate): return create_project(session=session, project_create=project_in) -@router.get("/{project_id}", dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) +@router.get("/{project_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) def read_project(*, session: SessionDep, project_id: int) -> Any: """ Retrieve a project by ID. """ project = get_project_by_id(session=session, project_id=project_id) - if not project: + if project is None: raise HTTPException(status_code=404, detail="Project not found") - return project + return APIResponse.success_response(project) # Update a project -@router.patch("/{project_id}",dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) -def update_project(*, session: SessionDep, project_id: int, project_in: ProjectUpdate) -> Any: +@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 not project: + if project is None: raise HTTPException(status_code=404, detail="Project not found") project_data = project_in.model_dump(exclude_unset=True) - for key, value in project_data.items(): - setattr(project, key, value) + project = project.model_copy(update=project_data) session.add(project) session.commit() session.refresh(project) - return project + 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) -> None: +@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 not project: + if project is None: raise HTTPException(status_code=404, detail="Project not found") session.delete(project) session.commit() + + return APIResponse.success_response(None) From df7086e1a9b32ff110ec31ebacf0365bdf1338f5 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Tue, 18 Mar 2025 14:13:44 +0530 Subject: [PATCH 13/32] small edits --- backend/app/api/routes/organization.py | 3 ++- backend/app/crud/organization.py | 5 ++--- backend/app/crud/project.py | 7 ++----- backend/app/tests/api/routes/test_org.py | 3 +-- backend/app/tests/crud/test_org.py | 1 + backend/app/tests/crud/test_project.py | 1 + 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index ab473e4f..936e2237 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -1,7 +1,8 @@ +from typing import Any, List + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func from sqlmodel import Session, select -from typing import Any, List from app.models import Organization, OrganizationCreate, OrganizationUpdate, OrganizationPublic from app.api.deps import ( diff --git a/backend/app/crud/organization.py b/backend/app/crud/organization.py index ef3909af..0a4213c1 100644 --- a/backend/app/crud/organization.py +++ b/backend/app/crud/organization.py @@ -1,9 +1,9 @@ from typing import Any, Optional + from sqlmodel import Session, select -from app.models import Organization, OrganizationCreate +from app.models import Organization, OrganizationCreate -# Create a new organization def create_organization(*, session: Session, org_create: OrganizationCreate) -> Organization: db_org = Organization.model_validate(org_create) session.add(db_org) @@ -12,7 +12,6 @@ def create_organization(*, session: Session, org_create: OrganizationCreate) -> return db_org -# Get organization by ID 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() diff --git a/backend/app/crud/project.py b/backend/app/crud/project.py index 007d6031..116c6ec8 100644 --- a/backend/app/crud/project.py +++ b/backend/app/crud/project.py @@ -1,9 +1,10 @@ from typing import List, Optional + from sqlmodel import Session, select + from app.models import Project, ProjectCreate -# Create a new project linked to an organization def create_project(*, session: Session, project_create: ProjectCreate) -> Project: db_project = Project.model_validate(project_create) session.add(db_project) @@ -11,14 +12,10 @@ def create_project(*, session: Session, project_create: ProjectCreate) -> Projec session.refresh(db_project) return db_project - -# Get project by ID 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() - -# Get all projects for a specific organization 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/tests/api/routes/test_org.py b/backend/app/tests/api/routes/test_org.py index 3064fa60..b3f6748c 100644 --- a/backend/app/tests/api/routes/test_org.py +++ b/backend/app/tests/api/routes/test_org.py @@ -1,3 +1,4 @@ +import pytest from fastapi.testclient import TestClient from sqlmodel import Session, select @@ -6,8 +7,6 @@ from app.core.security import verify_password from app.models import User, UserCreate from app.tests.utils.utils import random_email, random_lower_string - -import pytest from app.models import Organization, OrganizationCreate, OrganizationUpdate from app.api.deps import get_db from app.main import app diff --git a/backend/app/tests/crud/test_org.py b/backend/app/tests/crud/test_org.py index 8f6f653c..0b7eaded 100644 --- a/backend/app/tests/crud/test_org.py +++ b/backend/app/tests/crud/test_org.py @@ -1,4 +1,5 @@ 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 diff --git a/backend/app/tests/crud/test_project.py b/backend/app/tests/crud/test_project.py index fc30380b..9f3a543d 100644 --- a/backend/app/tests/crud/test_project.py +++ b/backend/app/tests/crud/test_project.py @@ -1,5 +1,6 @@ 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 From c411c57230294f2d9947c35a95db7fec464c25c8 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Tue, 18 Mar 2025 14:15:35 +0530 Subject: [PATCH 14/32] small edits --- backend/app/api/routes/organization.py | 10 +++++----- backend/app/api/routes/project.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index 936e2237..3f50522c 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -18,7 +18,7 @@ # 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) -> Any: +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() @@ -30,13 +30,13 @@ def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100) -> # 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) -> Any: +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) -> Any: +def read_organization(*, session: SessionDep, org_id: int): """ Retrieve an organization by ID. """ @@ -48,7 +48,7 @@ def read_organization(*, session: SessionDep, org_id: int) -> Any: # 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) -> Any: +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") @@ -66,7 +66,7 @@ def update_organization(*, session: SessionDep, org_id: int, org_in: Organizatio # 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) -> Any: +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") diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index 500424c7..d0b2ebb6 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -34,7 +34,7 @@ def create_new_project(*, session: SessionDep, project_in: ProjectCreate): return create_project(session=session, project_create=project_in) @router.get("/{project_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) -def read_project(*, session: SessionDep, project_id: int) -> Any: +def read_project(*, session: SessionDep, project_id: int) : """ Retrieve a project by ID. """ From 680603a484646fdfa1c0d408d44dec94a6d1f100 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Tue, 18 Mar 2025 14:19:09 +0530 Subject: [PATCH 15/32] small edits --- backend/app/api/routes/organization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index 3f50522c..5335c6c4 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -11,7 +11,7 @@ get_current_active_superuser, ) from app.crud.organization import create_organization, get_organization_by_id -from app.responses import APIResponse +from app.utils import APIResponse router = APIRouter(prefix="/organizations", tags=["organizations"]) From 659f93ce971fed34ffbf15b1375cad8820b08f14 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Wed, 19 Mar 2025 13:29:17 +0530 Subject: [PATCH 16/32] fixed project post --- backend/app/api/routes/project.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index d0b2ebb6..ddbfce3d 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -29,9 +29,10 @@ def read_projects(session: SessionDep, skip: int = 0, limit: int = 100): # Create a new project -@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) +@router.post("/", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) def create_new_project(*, session: SessionDep, project_in: ProjectCreate): - return create_project(session=session, project_create=project_in) + 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) : @@ -54,9 +55,16 @@ def update_project(*, session: SessionDep, project_id: int, project_in: ProjectU project_data = project_in.model_dump(exclude_unset=True) project = project.model_copy(update=project_data) - session.add(project) + # Re-attach the object to the session + session.merge(project) + + # Commit the changes to the database session.commit() + + # Refresh the project object with the latest data from the database session.refresh(project) + + # Return the response return APIResponse.success_response(project) From f80c3b31dfd09333b10484e63b941e42d0e2822b Mon Sep 17 00:00:00 2001 From: nishika26 Date: Mon, 17 Mar 2025 14:46:15 +0530 Subject: [PATCH 17/32] trial --- backend/app/api/routes/Organization.py | 69 ++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 backend/app/api/routes/Organization.py diff --git a/backend/app/api/routes/Organization.py b/backend/app/api/routes/Organization.py new file mode 100644 index 00000000..20c2cce5 --- /dev/null +++ b/backend/app/api/routes/Organization.py @@ -0,0 +1,69 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func +from sqlmodel import Session, select +from typing import Any, List + +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 + +router = APIRouter(prefix="/organizations", tags=["organizations"]) + + +# Retrieve organizations +@router.get("/",dependencies=[Depends(get_current_active_superuser)], response_model=List[OrganizationPublic]) +def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: + 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 organizations + + +# Create a new organization +@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=OrganizationPublic) +def create_new_organization(*, session: SessionDep, org_in: OrganizationCreate) -> Any: + return create_organization(session=session, org_create=org_in) + +@router.get("/{org_id}", dependencies=[Depends(get_current_active_superuser)], response_model=OrganizationPublic) +def read_organization(*, session: SessionDep, org_id: int) -> Any: + """ + Retrieve an organization by ID. + """ + org = get_organization_by_id(session=session, org_id=org_id) + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + return org + +# Update an organization +@router.patch("/{org_id}",dependencies=[Depends(get_current_active_superuser)], response_model=OrganizationPublic) +def update_organization(*, session: SessionDep, org_id: int, org_in: OrganizationUpdate) -> Any: + org = get_organization_by_id(session=session, org_id=org_id) + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + org_data = org_in.model_dump(exclude_unset=True) + for key, value in org_data.items(): + setattr(org, key, value) + + session.add(org) + session.commit() + session.refresh(org) + return org + + +# Delete an organization +@router.delete("/{org_id}",dependencies=[Depends(get_current_active_superuser)]) +def delete_organization(session: SessionDep, org_id: int) -> None: + org = get_organization_by_id(session=session, org_id=org_id) + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + session.delete(org) + session.commit() \ No newline at end of file From f54e388253ac2b1e66fc4cdef905f622d8caed42 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Mon, 17 Mar 2025 14:52:02 +0530 Subject: [PATCH 18/32] pushing all --- backend/app/api/main.py | 6 +- backend/app/api/routes/Project.py | 70 +++++++++++++++++ backend/app/crud/organization.py | 22 ++++++ backend/app/crud/project.py | 24 ++++++ backend/app/tests/api/routes/test_org.py | 70 +++++++++++++++++ backend/app/tests/api/routes/test_project.py | 80 ++++++++++++++++++++ backend/app/tests/crud/test_org.py | 33 ++++++++ backend/app/tests/crud/test_project.py | 64 ++++++++++++++++ 8 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/routes/Project.py create mode 100644 backend/app/crud/organization.py create mode 100644 backend/app/crud/project.py create mode 100644 backend/app/tests/api/routes/test_org.py create mode 100644 backend/app/tests/api/routes/test_project.py create mode 100644 backend/app/tests/crud/test_org.py create mode 100644 backend/app/tests/crud/test_project.py diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e..165f3cd1 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,Organization, Project + 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/Project.py b/backend/app/api/routes/Project.py new file mode 100644 index 00000000..ca5518a0 --- /dev/null +++ b/backend/app/api/routes/Project.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func +from sqlmodel import Session, select +from typing import Any, List + +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 + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +# Retrieve projects +@router.get("/",dependencies=[Depends(get_current_active_superuser)], response_model=List[ProjectPublic]) +def read_projects(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: + 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 projects + + +# Create a new project +@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) +def create_new_project(*, session: SessionDep, project_in: ProjectCreate) -> Any: + return create_project(session=session, project_create=project_in) + +@router.get("/{project_id}", dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) +def read_project(*, session: SessionDep, project_id: int) -> Any: + """ + Retrieve a project by ID. + """ + project = get_project_by_id(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +# Update a project +@router.patch("/{project_id}",dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) +def update_project(*, session: SessionDep, project_id: int, project_in: ProjectUpdate) -> Any: + project = get_project_by_id(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + project_data = project_in.model_dump(exclude_unset=True) + for key, value in project_data.items(): + setattr(project, key, value) + + session.add(project) + session.commit() + session.refresh(project) + return project + + +# Delete a project +@router.delete("/{project_id}",dependencies=[Depends(get_current_active_superuser)],) +def delete_project(session: SessionDep, project_id: int) -> None: + project = get_project_by_id(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + session.delete(project) + session.commit() \ No newline at end of file diff --git a/backend/app/crud/organization.py b/backend/app/crud/organization.py new file mode 100644 index 00000000..ef3909af --- /dev/null +++ b/backend/app/crud/organization.py @@ -0,0 +1,22 @@ +from typing import Any, Optional +from sqlmodel import Session, select +from app.models import Organization, OrganizationCreate + + +# Create a new organization +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 + + +# Get organization by ID +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..007d6031 --- /dev/null +++ b/backend/app/crud/project.py @@ -0,0 +1,24 @@ +from typing import List, Optional +from sqlmodel import Session, select +from app.models import Project, ProjectCreate + + +# Create a new project linked to an organization +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 + + +# Get project by ID +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() + + +# Get all projects for a specific organization +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/tests/api/routes/test_org.py b/backend/app/tests/api/routes/test_org.py new file mode 100644 index 00000000..3064fa60 --- /dev/null +++ b/backend/app/tests/api/routes/test_org.py @@ -0,0 +1,70 @@ +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 + +import pytest +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 + assert isinstance(response.json(), 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() + org = get_organization_by_id(session=db, org_id=created_org["id"]) + assert org + assert org.name == created_org["name"] + +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() + 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..ce2d70c8 --- /dev/null +++ b/backend/app/tests/api/routes/test_project.py @@ -0,0 +1,80 @@ +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 + assert isinstance(response.json(), 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() + assert created_project["name"] == unique_project_name + assert created_project["description"] == project_description + assert created_project["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() + 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..8f6f653c --- /dev/null +++ b/backend/app/tests/crud/test_org.py @@ -0,0 +1,33 @@ +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..fc30380b --- /dev/null +++ b/backend/app/tests/crud/test_project.py @@ -0,0 +1,64 @@ +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 From d57e7b26abc7af3951e3c8f802ee6417ee0c490f Mon Sep 17 00:00:00 2001 From: nishika26 Date: Mon, 17 Mar 2025 15:11:17 +0530 Subject: [PATCH 19/32] models file --- backend/app/models/__init__.py | 14 +++++++++++ backend/app/models/organization.py | 33 ++++++++++++++++++++++++++ backend/app/models/project.py | 37 ++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 backend/app/models/organization.py create mode 100644 backend/app/models/project.py 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 From 8c8db098ea13e6037f7a17c1c8272f47b9613825 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Mon, 17 Mar 2025 19:53:04 +0530 Subject: [PATCH 20/32] renaming --- backend/app/api/main.py | 7 ++++--- backend/app/api/routes/{Organization.py => oganization.py} | 0 2 files changed, 4 insertions(+), 3 deletions(-) rename backend/app/api/routes/{Organization.py => oganization.py} (100%) diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 165f3cd1..e5907abe 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,7 +1,8 @@ +from backend.app.api.routes import oganization from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils,Organization, Project +from app.api.routes import items, login, private, users, utils,project,organization from app.core.config import settings @@ -10,8 +11,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) +api_router.include_router(oganization.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/oganization.py similarity index 100% rename from backend/app/api/routes/Organization.py rename to backend/app/api/routes/oganization.py From a93dfa83d25bf271566e1337cd6cc4540f7682e7 Mon Sep 17 00:00:00 2001 From: Nishika Yadav <89646695+nishika26@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:53:51 +0530 Subject: [PATCH 21/32] Rename Project.py to project.py --- backend/app/api/routes/{Project.py => project.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename backend/app/api/routes/{Project.py => project.py} (99%) diff --git a/backend/app/api/routes/Project.py b/backend/app/api/routes/project.py similarity index 99% rename from backend/app/api/routes/Project.py rename to backend/app/api/routes/project.py index ca5518a0..2057bce8 100644 --- a/backend/app/api/routes/Project.py +++ b/backend/app/api/routes/project.py @@ -67,4 +67,4 @@ def delete_project(session: SessionDep, project_id: int) -> None: raise HTTPException(status_code=404, detail="Project not found") session.delete(project) - session.commit() \ No newline at end of file + session.commit() From 02ee43687c51251b3e1fc5a1f88a225533c55823 Mon Sep 17 00:00:00 2001 From: Nishika Yadav <89646695+nishika26@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:54:38 +0530 Subject: [PATCH 22/32] Rename oganization.py to organization.py --- backend/app/api/routes/{oganization.py => organization.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename backend/app/api/routes/{oganization.py => organization.py} (99%) diff --git a/backend/app/api/routes/oganization.py b/backend/app/api/routes/organization.py similarity index 99% rename from backend/app/api/routes/oganization.py rename to backend/app/api/routes/organization.py index 20c2cce5..1b878f54 100644 --- a/backend/app/api/routes/oganization.py +++ b/backend/app/api/routes/organization.py @@ -66,4 +66,4 @@ def delete_organization(session: SessionDep, org_id: int) -> None: raise HTTPException(status_code=404, detail="Organization not found") session.delete(org) - session.commit() \ No newline at end of file + session.commit() From 2bd25de1f7ff23bda40ed9599ebd9a2582c70d84 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Tue, 18 Mar 2025 14:07:51 +0530 Subject: [PATCH 23/32] standardization and edits --- backend/app/api/main.py | 3 +- backend/app/api/routes/organization.py | 37 ++++++++++++++---------- backend/app/api/routes/project.py | 39 ++++++++++++++------------ 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/backend/app/api/main.py b/backend/app/api/main.py index e5907abe..d4232289 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,4 +1,3 @@ -from backend.app.api.routes import oganization from fastapi import APIRouter @@ -11,7 +10,7 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) -api_router.include_router(oganization.router) +api_router.include_router(organization.router) api_router.include_router(project.router) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index 1b878f54..ab473e4f 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -10,12 +10,13 @@ get_current_active_superuser, ) from app.crud.organization import create_organization, get_organization_by_id +from app.responses import APIResponse router = APIRouter(prefix="/organizations", tags=["organizations"]) # Retrieve organizations -@router.get("/",dependencies=[Depends(get_current_active_superuser)], response_model=List[OrganizationPublic]) +@router.get("/", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[List[OrganizationPublic]]) def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: count_statement = select(func.count()).select_from(Organization) count = session.exec(count_statement).one() @@ -23,47 +24,53 @@ def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100) -> statement = select(Organization).offset(skip).limit(limit) organizations = session.exec(statement).all() - return organizations + return APIResponse.success_response(organizations) # Create a new organization -@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=OrganizationPublic) +@router.post("/", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[OrganizationPublic]) def create_new_organization(*, session: SessionDep, org_in: OrganizationCreate) -> Any: - return create_organization(session=session, org_create=org_in) + 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=OrganizationPublic) + +@router.get("/{org_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[OrganizationPublic]) def read_organization(*, session: SessionDep, org_id: int) -> Any: """ Retrieve an organization by ID. """ org = get_organization_by_id(session=session, org_id=org_id) - if not org: + if org is None: raise HTTPException(status_code=404, detail="Organization not found") - return org + return APIResponse.success_response(org) + # Update an organization -@router.patch("/{org_id}",dependencies=[Depends(get_current_active_superuser)], response_model=OrganizationPublic) +@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) -> Any: org = get_organization_by_id(session=session, org_id=org_id) - if not org: + if org is None: raise HTTPException(status_code=404, detail="Organization not found") org_data = org_in.model_dump(exclude_unset=True) - for key, value in org_data.items(): - setattr(org, key, value) + org = org.model_copy(update=org_data) + session.add(org) session.commit() session.refresh(org) - return org + + return APIResponse.success_response(org) # Delete an organization -@router.delete("/{org_id}",dependencies=[Depends(get_current_active_superuser)]) -def delete_organization(session: SessionDep, org_id: int) -> None: +@router.delete("/{org_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[None]) +def delete_organization(session: SessionDep, org_id: int) -> Any: org = get_organization_by_id(session=session, org_id=org_id) - if not org: + 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 index 2057bce8..500424c7 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -1,7 +1,8 @@ +from typing import Any, List + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func from sqlmodel import Session, select -from typing import Any, List from app.models import Project, ProjectCreate, ProjectUpdate, ProjectPublic from app.api.deps import ( @@ -10,61 +11,63 @@ 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=List[ProjectPublic]) -def read_projects(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: +@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 projects + return APIResponse.success_response(projects) # Create a new project -@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) -def create_new_project(*, session: SessionDep, project_in: ProjectCreate) -> Any: +@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) +def create_new_project(*, session: SessionDep, project_in: ProjectCreate): return create_project(session=session, project_create=project_in) -@router.get("/{project_id}", dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) +@router.get("/{project_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) def read_project(*, session: SessionDep, project_id: int) -> Any: """ Retrieve a project by ID. """ project = get_project_by_id(session=session, project_id=project_id) - if not project: + if project is None: raise HTTPException(status_code=404, detail="Project not found") - return project + return APIResponse.success_response(project) # Update a project -@router.patch("/{project_id}",dependencies=[Depends(get_current_active_superuser)], response_model=ProjectPublic) -def update_project(*, session: SessionDep, project_id: int, project_in: ProjectUpdate) -> Any: +@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 not project: + if project is None: raise HTTPException(status_code=404, detail="Project not found") project_data = project_in.model_dump(exclude_unset=True) - for key, value in project_data.items(): - setattr(project, key, value) + project = project.model_copy(update=project_data) session.add(project) session.commit() session.refresh(project) - return project + 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) -> None: +@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 not project: + if project is None: raise HTTPException(status_code=404, detail="Project not found") session.delete(project) session.commit() + + return APIResponse.success_response(None) From ef0ab03e3dd542ad11e92baa09e65c555093f2ab Mon Sep 17 00:00:00 2001 From: nishika26 Date: Tue, 18 Mar 2025 14:13:44 +0530 Subject: [PATCH 24/32] small edits --- backend/app/api/routes/organization.py | 3 ++- backend/app/crud/organization.py | 5 ++--- backend/app/crud/project.py | 7 ++----- backend/app/tests/api/routes/test_org.py | 3 +-- backend/app/tests/crud/test_org.py | 1 + backend/app/tests/crud/test_project.py | 1 + 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index ab473e4f..936e2237 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -1,7 +1,8 @@ +from typing import Any, List + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func from sqlmodel import Session, select -from typing import Any, List from app.models import Organization, OrganizationCreate, OrganizationUpdate, OrganizationPublic from app.api.deps import ( diff --git a/backend/app/crud/organization.py b/backend/app/crud/organization.py index ef3909af..0a4213c1 100644 --- a/backend/app/crud/organization.py +++ b/backend/app/crud/organization.py @@ -1,9 +1,9 @@ from typing import Any, Optional + from sqlmodel import Session, select -from app.models import Organization, OrganizationCreate +from app.models import Organization, OrganizationCreate -# Create a new organization def create_organization(*, session: Session, org_create: OrganizationCreate) -> Organization: db_org = Organization.model_validate(org_create) session.add(db_org) @@ -12,7 +12,6 @@ def create_organization(*, session: Session, org_create: OrganizationCreate) -> return db_org -# Get organization by ID 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() diff --git a/backend/app/crud/project.py b/backend/app/crud/project.py index 007d6031..116c6ec8 100644 --- a/backend/app/crud/project.py +++ b/backend/app/crud/project.py @@ -1,9 +1,10 @@ from typing import List, Optional + from sqlmodel import Session, select + from app.models import Project, ProjectCreate -# Create a new project linked to an organization def create_project(*, session: Session, project_create: ProjectCreate) -> Project: db_project = Project.model_validate(project_create) session.add(db_project) @@ -11,14 +12,10 @@ def create_project(*, session: Session, project_create: ProjectCreate) -> Projec session.refresh(db_project) return db_project - -# Get project by ID 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() - -# Get all projects for a specific organization 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/tests/api/routes/test_org.py b/backend/app/tests/api/routes/test_org.py index 3064fa60..b3f6748c 100644 --- a/backend/app/tests/api/routes/test_org.py +++ b/backend/app/tests/api/routes/test_org.py @@ -1,3 +1,4 @@ +import pytest from fastapi.testclient import TestClient from sqlmodel import Session, select @@ -6,8 +7,6 @@ from app.core.security import verify_password from app.models import User, UserCreate from app.tests.utils.utils import random_email, random_lower_string - -import pytest from app.models import Organization, OrganizationCreate, OrganizationUpdate from app.api.deps import get_db from app.main import app diff --git a/backend/app/tests/crud/test_org.py b/backend/app/tests/crud/test_org.py index 8f6f653c..0b7eaded 100644 --- a/backend/app/tests/crud/test_org.py +++ b/backend/app/tests/crud/test_org.py @@ -1,4 +1,5 @@ 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 diff --git a/backend/app/tests/crud/test_project.py b/backend/app/tests/crud/test_project.py index fc30380b..9f3a543d 100644 --- a/backend/app/tests/crud/test_project.py +++ b/backend/app/tests/crud/test_project.py @@ -1,5 +1,6 @@ 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 From fedba96ef0aad298ec92827b9f6da0c4fd6c531b Mon Sep 17 00:00:00 2001 From: nishika26 Date: Tue, 18 Mar 2025 14:15:35 +0530 Subject: [PATCH 25/32] small edits --- backend/app/api/routes/organization.py | 10 +++++----- backend/app/api/routes/project.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index 936e2237..3f50522c 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -18,7 +18,7 @@ # 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) -> Any: +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() @@ -30,13 +30,13 @@ def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100) -> # 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) -> Any: +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) -> Any: +def read_organization(*, session: SessionDep, org_id: int): """ Retrieve an organization by ID. """ @@ -48,7 +48,7 @@ def read_organization(*, session: SessionDep, org_id: int) -> Any: # 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) -> Any: +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") @@ -66,7 +66,7 @@ def update_organization(*, session: SessionDep, org_id: int, org_in: Organizatio # 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) -> Any: +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") diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index 500424c7..d0b2ebb6 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -34,7 +34,7 @@ def create_new_project(*, session: SessionDep, project_in: ProjectCreate): return create_project(session=session, project_create=project_in) @router.get("/{project_id}", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) -def read_project(*, session: SessionDep, project_id: int) -> Any: +def read_project(*, session: SessionDep, project_id: int) : """ Retrieve a project by ID. """ From 9b7502ad6eb3cda841c279d32e29800f333802af Mon Sep 17 00:00:00 2001 From: nishika26 Date: Tue, 18 Mar 2025 14:19:09 +0530 Subject: [PATCH 26/32] small edits --- backend/app/api/routes/organization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index 3f50522c..5335c6c4 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -11,7 +11,7 @@ get_current_active_superuser, ) from app.crud.organization import create_organization, get_organization_by_id -from app.responses import APIResponse +from app.utils import APIResponse router = APIRouter(prefix="/organizations", tags=["organizations"]) From 0e8903d523b3a2711bbb35b5cf763140c874836b Mon Sep 17 00:00:00 2001 From: nishika26 Date: Wed, 19 Mar 2025 13:29:17 +0530 Subject: [PATCH 27/32] fixed project post --- backend/app/api/routes/project.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index d0b2ebb6..ddbfce3d 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -29,9 +29,10 @@ def read_projects(session: SessionDep, skip: int = 0, limit: int = 100): # Create a new project -@router.post("/",dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) +@router.post("/", dependencies=[Depends(get_current_active_superuser)], response_model=APIResponse[ProjectPublic]) def create_new_project(*, session: SessionDep, project_in: ProjectCreate): - return create_project(session=session, project_create=project_in) + 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) : @@ -54,9 +55,16 @@ def update_project(*, session: SessionDep, project_id: int, project_in: ProjectU project_data = project_in.model_dump(exclude_unset=True) project = project.model_copy(update=project_data) - session.add(project) + # Re-attach the object to the session + session.merge(project) + + # Commit the changes to the database session.commit() + + # Refresh the project object with the latest data from the database session.refresh(project) + + # Return the response return APIResponse.success_response(project) From 88ad308d00fd9eb267765fcc44d3446a3d58489e Mon Sep 17 00:00:00 2001 From: Ishankoradia Date: Wed, 19 Mar 2025 17:23:50 +0530 Subject: [PATCH 28/32] remove these files since they were somehow pushed into this branch --- backend/app/utils.py | 141 ----------------------------------- docker-compose.yml | 171 ------------------------------------------- envSample | 46 ------------ 3 files changed, 358 deletions(-) delete mode 100644 backend/app/utils.py delete mode 100644 docker-compose.yml delete mode 100644 envSample diff --git a/backend/app/utils.py b/backend/app/utils.py deleted file mode 100644 index da9e1d11..00000000 --- a/backend/app/utils.py +++ /dev/null @@ -1,141 +0,0 @@ -import logging -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Dict, Generic, Optional, TypeVar - -import emails # type: ignore -import jwt -from jinja2 import Template -from jwt.exceptions import InvalidTokenError - -from app.core import security -from app.core.config import settings - -from typing import Generic, Optional, TypeVar -from pydantic import BaseModel - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -T = TypeVar("T") - -class APIResponse(BaseModel, Generic[T]): - success: bool - data: Optional[T] = None - error: Optional[str] = None - - @classmethod - def success_response(cls, data: T) -> "APIResponse[T]": - return cls(success=True, data=data, error=None) - - @classmethod - def failure_response(cls, error: str) -> "APIResponse[None]": - return cls(success=False, data=None, error=error) - - -@dataclass -class EmailData: - html_content: str - subject: str - - -def render_email_template(*, template_name: str, context: dict[str, Any]) -> str: - template_str = ( - Path(__file__).parent / "email-templates" / "build" / template_name - ).read_text() - html_content = Template(template_str).render(context) - return html_content - - -def send_email( - *, - email_to: str, - subject: str = "", - html_content: str = "", -) -> None: - assert settings.emails_enabled, "no provided configuration for email variables" - message = emails.Message( - subject=subject, - html=html_content, - mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL), - ) - smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT} - if settings.SMTP_TLS: - smtp_options["tls"] = True - elif settings.SMTP_SSL: - smtp_options["ssl"] = True - if settings.SMTP_USER: - smtp_options["user"] = settings.SMTP_USER - if settings.SMTP_PASSWORD: - smtp_options["password"] = settings.SMTP_PASSWORD - response = message.send(to=email_to, smtp=smtp_options) - logger.info(f"send email result: {response}") - - -def generate_test_email(email_to: str) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - Test email" - html_content = render_email_template( - template_name="test_email.html", - context={"project_name": settings.PROJECT_NAME, "email": email_to}, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - Password recovery for user {email}" - link = f"{settings.FRONTEND_HOST}/reset-password?token={token}" - html_content = render_email_template( - template_name="reset_password.html", - context={ - "project_name": settings.PROJECT_NAME, - "username": email, - "email": email_to, - "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, - "link": link, - }, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_new_account_email( - email_to: str, username: str, password: str -) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - New account for user {username}" - html_content = render_email_template( - template_name="new_account.html", - context={ - "project_name": settings.PROJECT_NAME, - "username": username, - "password": password, - "email": email_to, - "link": settings.FRONTEND_HOST, - }, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_password_reset_token(email: str) -> str: - delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) - now = datetime.now(timezone.utc) - expires = now + delta - exp = expires.timestamp() - encoded_jwt = jwt.encode( - {"exp": exp, "nbf": now, "sub": email}, - settings.SECRET_KEY, - algorithm=security.ALGORITHM, - ) - return encoded_jwt - - -def verify_password_reset_token(token: str) -> str | None: - try: - decoded_token = jwt.decode( - token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) - return str(decoded_token["sub"]) - except InvalidTokenError: - return None diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index a4d21539..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,171 +0,0 @@ -services: - - db: - image: postgres:16 - restart: always - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - retries: 5 - start_period: 30s - timeout: 10s - volumes: - - app-db-data:/var/lib/postgresql/data/pgdata - env_file: - - .env - environment: - - PGDATA=/var/lib/postgresql/data/pgdata - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - - POSTGRES_USER=${POSTGRES_USER?Variable not set} - - POSTGRES_DB=${POSTGRES_DB?Variable not set} - - adminer: - image: adminer - restart: always - networks: - - traefik-public - - default - depends_on: - - db - environment: - - ADMINER_DESIGN=pepa-linha-dark - labels: - - traefik.enable=true - - traefik.docker.network=traefik-public - - traefik.constraint-label=traefik-public - - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.rule=Host(`adminer.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.entrypoints=http - - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.middlewares=https-redirect - - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.rule=Host(`adminer.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.entrypoints=https - - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls=true - - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls.certresolver=le - - traefik.http.services.${STACK_NAME?Variable not set}-adminer.loadbalancer.server.port=8080 - - prestart: - image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' - build: - context: ./backend - networks: - - traefik-public - - default - depends_on: - db: - condition: service_healthy - restart: true - command: bash scripts/prestart.sh - env_file: - - .env - environment: - - DOMAIN=${DOMAIN} - - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} - - ENVIRONMENT=${ENVIRONMENT} - - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} - - SECRET_KEY=${SECRET_KEY?Variable not set} - - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} - - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set} - - SMTP_HOST=${SMTP_HOST} - - SMTP_USER=${SMTP_USER} - - SMTP_PASSWORD=${SMTP_PASSWORD} - - EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL} - - POSTGRES_SERVER=db - - POSTGRES_PORT=${POSTGRES_PORT} - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_USER=${POSTGRES_USER?Variable not set} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - - SENTRY_DSN=${SENTRY_DSN} - - backend: - image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' - restart: always - networks: - - traefik-public - - default - depends_on: - db: - condition: service_healthy - restart: true - prestart: - condition: service_completed_successfully - env_file: - - .env - environment: - - DOMAIN=${DOMAIN} - - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} - - ENVIRONMENT=${ENVIRONMENT} - - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} - - SECRET_KEY=${SECRET_KEY?Variable not set} - - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} - - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set} - - SMTP_HOST=${SMTP_HOST} - - SMTP_USER=${SMTP_USER} - - SMTP_PASSWORD=${SMTP_PASSWORD} - - EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL} - - POSTGRES_SERVER=db - - POSTGRES_PORT=${POSTGRES_PORT} - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_USER=${POSTGRES_USER?Variable not set} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - - SENTRY_DSN=${SENTRY_DSN} - - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"] - interval: 10s - timeout: 5s - retries: 5 - - build: - context: ./backend - labels: - - traefik.enable=true - - traefik.docker.network=traefik-public - - traefik.constraint-label=traefik-public - - - traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000 - - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=Host(`api.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.entrypoints=http - - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.rule=Host(`api.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.entrypoints=https - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls.certresolver=le - - # Enable redirection for HTTP and HTTPS - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect - - frontend: - image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}' - restart: always - networks: - - traefik-public - - default - build: - context: ./frontend - args: - - VITE_API_URL=https://api.${DOMAIN?Variable not set} - - NODE_ENV=production - labels: - - traefik.enable=true - - traefik.docker.network=traefik-public - - traefik.constraint-label=traefik-public - - - traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80 - - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=Host(`dashboard.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.entrypoints=http - - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`dashboard.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.entrypoints=https - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls=true - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le - - # Enable redirection for HTTP and HTTPS - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect -volumes: - app-db-data: - -networks: - traefik-public: - # Allow setting it to false for testing - external: true diff --git a/envSample b/envSample deleted file mode 100644 index ac1f74ad..00000000 --- a/envSample +++ /dev/null @@ -1,46 +0,0 @@ -# Domain -# This would be set to the production domain with an env var on deployment -# used by Traefik to transmit traffic and aqcuire TLS certificates -DOMAIN=localhost -# To test the local Traefik config -# DOMAIN=localhost.tiangolo.com - -# Used by the backend to generate links in emails to the frontend -FRONTEND_HOST=http://localhost:5173 -# In staging and production, set this env var to the frontend host, e.g. -# FRONTEND_HOST=https://dashboard.example.com - -# Environment: local, staging, production -ENVIRONMENT=local - - -PROJECT_NAME="AI Platform" -STACK_NAME=ai-platform - -# Backend -BACKEND_CORS_ORIGINS="http://localhost:5173" -SECRET_KEY=changethis -FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis - -# Emails -SMTP_HOST= -SMTP_USER= -SMTP_PASSWORD= -EMAILS_FROM_EMAIL=info@example.com -SMTP_TLS=True -SMTP_SSL=False -SMTP_PORT=587 - -# Postgres -POSTGRES_SERVER=localhost -POSTGRES_PORT=5432 -POSTGRES_DB=app -POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis - -SENTRY_DSN= - -# Configure these with your own Docker registry images -DOCKER_IMAGE_BACKEND=backend -DOCKER_IMAGE_FRONTEND=frontend From 4c58d15811e4fc52e8b8711ac023fa4ff90d5215 Mon Sep 17 00:00:00 2001 From: Ishankoradia Date: Wed, 19 Mar 2025 17:24:44 +0530 Subject: [PATCH 29/32] re-push the docker file --- docker-compose.yml | 171 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..64cf8867 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,171 @@ +services: + + db: + image: postgres:16 + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + volumes: + - app-db-data:/var/lib/postgresql/data/pgdata + env_file: + - .env + environment: + - PGDATA=/var/lib/postgresql/data/pgdata + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_DB=${POSTGRES_DB?Variable not set} + + adminer: + image: adminer + restart: always + networks: + - traefik-public + - default + depends_on: + - db + environment: + - ADMINER_DESIGN=pepa-linha-dark + labels: + - traefik.enable=true + - traefik.docker.network=traefik-public + - traefik.constraint-label=traefik-public + - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.rule=Host(`adminer.${DOMAIN?Variable not set}`) + - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.entrypoints=http + - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.middlewares=https-redirect + - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.rule=Host(`adminer.${DOMAIN?Variable not set}`) + - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.entrypoints=https + - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls=true + - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls.certresolver=le + - traefik.http.services.${STACK_NAME?Variable not set}-adminer.loadbalancer.server.port=8080 + + prestart: + image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' + build: + context: ./backend + networks: + - traefik-public + - default + depends_on: + db: + condition: service_healthy + restart: true + command: bash scripts/prestart.sh + env_file: + - .env + environment: + - DOMAIN=${DOMAIN} + - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} + - ENVIRONMENT=${ENVIRONMENT} + - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} + - SECRET_KEY=${SECRET_KEY?Variable not set} + - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} + - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set} + - SMTP_HOST=${SMTP_HOST} + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL} + - POSTGRES_SERVER=db + - POSTGRES_PORT=${POSTGRES_PORT} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - SENTRY_DSN=${SENTRY_DSN} + + backend: + image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' + restart: always + networks: + - traefik-public + - default + depends_on: + db: + condition: service_healthy + restart: true + prestart: + condition: service_completed_successfully + env_file: + - .env + environment: + - DOMAIN=${DOMAIN} + - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} + - ENVIRONMENT=${ENVIRONMENT} + - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} + - SECRET_KEY=${SECRET_KEY?Variable not set} + - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} + - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set} + - SMTP_HOST=${SMTP_HOST} + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL} + - POSTGRES_SERVER=db + - POSTGRES_PORT=${POSTGRES_PORT} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - SENTRY_DSN=${SENTRY_DSN} + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"] + interval: 10s + timeout: 5s + retries: 5 + + build: + context: ./backend + labels: + - traefik.enable=true + - traefik.docker.network=traefik-public + - traefik.constraint-label=traefik-public + + - traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000 + + - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=Host(`api.${DOMAIN?Variable not set}`) + - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.entrypoints=http + + - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.rule=Host(`api.${DOMAIN?Variable not set}`) + - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.entrypoints=https + - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true + - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls.certresolver=le + + # Enable redirection for HTTP and HTTPS + - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect + + frontend: + image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}' + restart: always + networks: + - traefik-public + - default + build: + context: ./frontend + args: + - VITE_API_URL=https://api.${DOMAIN?Variable not set} + - NODE_ENV=production + labels: + - traefik.enable=true + - traefik.docker.network=traefik-public + - traefik.constraint-label=traefik-public + + - traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80 + + - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=Host(`dashboard.${DOMAIN?Variable not set}`) + - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.entrypoints=http + + - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`dashboard.${DOMAIN?Variable not set}`) + - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.entrypoints=https + - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls=true + - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le + + # Enable redirection for HTTP and HTTPS + - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect +volumes: + app-db-data: + +networks: + traefik-public: + # Allow setting it to false for testing + external: true \ No newline at end of file From bd7170d8fdd510eef06115ddba6dd3f4634f3014 Mon Sep 17 00:00:00 2001 From: Ishankoradia Date: Wed, 19 Mar 2025 17:27:47 +0530 Subject: [PATCH 30/32] re-push utils file --- backend/app/utils.py | 141 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 backend/app/utils.py diff --git a/backend/app/utils.py b/backend/app/utils.py new file mode 100644 index 00000000..6724640b --- /dev/null +++ b/backend/app/utils.py @@ -0,0 +1,141 @@ +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Generic, Optional, TypeVar + +import emails # type: ignore +import jwt +from jinja2 import Template +from jwt.exceptions import InvalidTokenError + +from app.core import security +from app.core.config import settings + +from typing import Generic, Optional, TypeVar +from pydantic import BaseModel + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +T = TypeVar("T") + +class APIResponse(BaseModel, Generic[T]): + success: bool + data: Optional[T] = None + error: Optional[str] = None + + @classmethod + def success_response(cls, data: T) -> "APIResponse[T]": + return cls(success=True, data=data, error=None) + + @classmethod + def failure_response(cls, error: str) -> "APIResponse[None]": + return cls(success=False, data=None, error=error) + + +@dataclass +class EmailData: + html_content: str + subject: str + + +def render_email_template(*, template_name: str, context: dict[str, Any]) -> str: + template_str = ( + Path(__file__).parent / "email-templates" / "build" / template_name + ).read_text() + html_content = Template(template_str).render(context) + return html_content + + +def send_email( + *, + email_to: str, + subject: str = "", + html_content: str = "", +) -> None: + assert settings.emails_enabled, "no provided configuration for email variables" + message = emails.Message( + subject=subject, + html=html_content, + mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL), + ) + smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT} + if settings.SMTP_TLS: + smtp_options["tls"] = True + elif settings.SMTP_SSL: + smtp_options["ssl"] = True + if settings.SMTP_USER: + smtp_options["user"] = settings.SMTP_USER + if settings.SMTP_PASSWORD: + smtp_options["password"] = settings.SMTP_PASSWORD + response = message.send(to=email_to, smtp=smtp_options) + logger.info(f"send email result: {response}") + + +def generate_test_email(email_to: str) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - Test email" + html_content = render_email_template( + template_name="test_email.html", + context={"project_name": settings.PROJECT_NAME, "email": email_to}, + ) + return EmailData(html_content=html_content, subject=subject) + + +def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - Password recovery for user {email}" + link = f"{settings.FRONTEND_HOST}/reset-password?token={token}" + html_content = render_email_template( + template_name="reset_password.html", + context={ + "project_name": settings.PROJECT_NAME, + "username": email, + "email": email_to, + "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, + "link": link, + }, + ) + return EmailData(html_content=html_content, subject=subject) + + +def generate_new_account_email( + email_to: str, username: str, password: str +) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - New account for user {username}" + html_content = render_email_template( + template_name="new_account.html", + context={ + "project_name": settings.PROJECT_NAME, + "username": username, + "password": password, + "email": email_to, + "link": settings.FRONTEND_HOST, + }, + ) + return EmailData(html_content=html_content, subject=subject) + + +def generate_password_reset_token(email: str) -> str: + delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) + now = datetime.now(timezone.utc) + expires = now + delta + exp = expires.timestamp() + encoded_jwt = jwt.encode( + {"exp": exp, "nbf": now, "sub": email}, + settings.SECRET_KEY, + algorithm=security.ALGORITHM, + ) + return encoded_jwt + + +def verify_password_reset_token(token: str) -> str | None: + try: + decoded_token = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + return str(decoded_token["sub"]) + except InvalidTokenError: + return None \ No newline at end of file From e2c0b14def1e9b08cec20bb2a2dab922b36c3d0a Mon Sep 17 00:00:00 2001 From: Ishankoradia Date: Wed, 19 Mar 2025 17:28:49 +0530 Subject: [PATCH 31/32] re-push the file --- envSample | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 envSample diff --git a/envSample b/envSample new file mode 100644 index 00000000..764b41b2 --- /dev/null +++ b/envSample @@ -0,0 +1,46 @@ +# Domain +# This would be set to the production domain with an env var on deployment +# used by Traefik to transmit traffic and aqcuire TLS certificates +DOMAIN=localhost +# To test the local Traefik config +# DOMAIN=localhost.tiangolo.com + +# Used by the backend to generate links in emails to the frontend +FRONTEND_HOST=http://localhost:5173 +# In staging and production, set this env var to the frontend host, e.g. +# FRONTEND_HOST=https://dashboard.example.com + +# Environment: local, staging, production +ENVIRONMENT=local + + +PROJECT_NAME="AI Platform" +STACK_NAME=ai-platform + +# Backend +BACKEND_CORS_ORIGINS="http://localhost:5173" +SECRET_KEY=changethis +FIRST_SUPERUSER=admin@example.com +FIRST_SUPERUSER_PASSWORD=changethis + +# Emails +SMTP_HOST= +SMTP_USER= +SMTP_PASSWORD= +EMAILS_FROM_EMAIL=info@example.com +SMTP_TLS=True +SMTP_SSL=False +SMTP_PORT=587 + +# Postgres +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=app +POSTGRES_USER=postgres +POSTGRES_PASSWORD=changethis + +SENTRY_DSN= + +# Configure these with your own Docker registry images +DOCKER_IMAGE_BACKEND=backend +DOCKER_IMAGE_FRONTEND=frontend \ No newline at end of file From ff360a4c143ddc3c8fb2f502750e8f1633448397 Mon Sep 17 00:00:00 2001 From: nishika26 Date: Thu, 20 Mar 2025 15:25:32 +0530 Subject: [PATCH 32/32] fixing test cases --- backend/app/api/routes/organization.py | 5 ++- backend/app/api/routes/project.py | 11 ++----- backend/app/tests/api/routes/test_org.py | 34 ++++++++++++-------- backend/app/tests/api/routes/test_project.py | 23 +++++++++---- 4 files changed, 41 insertions(+), 32 deletions(-) diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index 5335c6c4..a3b198b4 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -27,7 +27,6 @@ def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100): 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): @@ -53,13 +52,13 @@ def update_organization(*, session: SessionDep, org_id: int, org_in: Organizatio if org is None: raise HTTPException(status_code=404, detail="Organization not found") - org_data = org_in.model_dump(exclude_unset=True) + org_data = org_in.model_dump(exclude_unset=True) org = org.model_copy(update=org_data) session.add(org) session.commit() - session.refresh(org) + session.flush() return APIResponse.success_response(org) diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index ddbfce3d..1e068c64 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -55,16 +55,9 @@ def update_project(*, session: SessionDep, project_id: int, project_in: ProjectU project_data = project_in.model_dump(exclude_unset=True) project = project.model_copy(update=project_data) - # Re-attach the object to the session - session.merge(project) - - # Commit the changes to the database + session.add(project) session.commit() - - # Refresh the project object with the latest data from the database - session.refresh(project) - - # Return the response + session.flush() return APIResponse.success_response(project) diff --git a/backend/app/tests/api/routes/test_org.py b/backend/app/tests/api/routes/test_org.py index b3f6748c..bfb9021d 100644 --- a/backend/app/tests/api/routes/test_org.py +++ b/backend/app/tests/api/routes/test_org.py @@ -26,23 +26,30 @@ def test_organization(db: Session, superuser_token_headers: dict[str, str]): 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 - assert isinstance(response.json(), list) + 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() - org = get_organization_by_id(session=db, org_id=created_org["id"]) - assert org - assert org.name == created_org["name"] + 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 + unique_name = f"UpdatedOrg-{random_lower_string()}" # Ensure a unique name update_data = {"name": unique_name, "is_active": False} response = client.patch( @@ -52,12 +59,13 @@ def test_update_organization(db: Session, test_organization: Organization, super ) assert response.status_code == 200 - updated_org = response.json() + 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( diff --git a/backend/app/tests/api/routes/test_project.py b/backend/app/tests/api/routes/test_project.py index ce2d70c8..157dde3d 100644 --- a/backend/app/tests/api/routes/test_project.py +++ b/backend/app/tests/api/routes/test_project.py @@ -32,7 +32,9 @@ def test_project(db: Session, superuser_token_headers: dict[str, str]): 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 - assert isinstance(response.json(), list) + 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]): @@ -44,32 +46,39 @@ def test_create_new_project(db: Session, superuser_token_headers: dict[str, str] 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() - assert created_project["name"] == unique_project_name - assert created_project["description"] == project_description - assert created_project["organization_id"] == organization.id + + # 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() + 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(