diff --git a/backend/app/alembic/versions/8725df286943_unique_constraint_on_project_name_and_.py b/backend/app/alembic/versions/8725df286943_unique_constraint_on_project_name_and_.py new file mode 100644 index 00000000..342751fb --- /dev/null +++ b/backend/app/alembic/versions/8725df286943_unique_constraint_on_project_name_and_.py @@ -0,0 +1,31 @@ +"""unique constraint on project_name and org id + +Revision ID: 8725df286943 +Revises: 38f0e8c8dc92 +Create Date: 2025-08-27 12:22:36.633904 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = "8725df286943" +down_revision = "38f0e8c8dc92" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint( + "uq_project_name_org_id", "project", ["name", "organization_id"] + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("uq_project_name_org_id", "project", type_="unique") + # ### end Alembic commands ### diff --git a/backend/app/crud/project.py b/backend/app/crud/project.py index 9c7e7153..a876ae03 100644 --- a/backend/app/crud/project.py +++ b/backend/app/crud/project.py @@ -11,6 +11,17 @@ def create_project(*, session: Session, project_create: ProjectCreate) -> Project: + project = get_project_by_name( + session=session, + organization_id=project_create.organization_id, + project_name=project_create.name, + ) + if project: + logger.error( + f"[create_project] Project already exists | 'project_id': {project.id}, 'name': {project.name}" + ) + raise HTTPException(409, "Project already exists") + db_project = Project.model_validate(project_create) db_project.inserted_at = now() db_project.updated_at = now() diff --git a/backend/app/models/onboarding.py b/backend/app/models/onboarding.py index 3b35896a..49214067 100644 --- a/backend/app/models/onboarding.py +++ b/backend/app/models/onboarding.py @@ -29,7 +29,7 @@ class OnboardingRequest(SQLModel): ) project_name: str = Field( description="Name of the project under the organization", - min_length=3, + min_length=1, max_length=100, ) email: EmailStr | None = Field( diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 442b740a..43c0012e 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -1,6 +1,6 @@ from datetime import datetime from typing import Optional, List -from sqlmodel import Field, Relationship, SQLModel +from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint from app.core.util import now @@ -26,6 +26,10 @@ class ProjectUpdate(SQLModel): # Database model for Project class Project(ProjectBase, table=True): + __table_args__ = ( + UniqueConstraint("name", "organization_id", name="uq_project_name_org_id"), + ) + id: int = Field(default=None, primary_key=True) organization_id: int = Field( foreign_key="organization.id", index=True, nullable=False, ondelete="CASCADE" diff --git a/backend/app/tests/crud/test_project.py b/backend/app/tests/crud/test_project.py index 06aa38ac..9698b466 100644 --- a/backend/app/tests/crud/test_project.py +++ b/backend/app/tests/crud/test_project.py @@ -34,6 +34,22 @@ def test_create_project(db: Session) -> None: assert project.organization_id == organization.id +def test_create_project_duplicate_name(db: Session) -> None: + """Test creating a project with a duplicate name.""" + organization = create_test_organization(db) + + project_name = random_lower_string() + project_data = ProjectCreate( + name=project_name, + description="Test description", + is_active=True, + organization_id=organization.id, + ) + project = create_project(session=db, project_create=project_data) + with pytest.raises(HTTPException, match="Project already exists"): + create_project(session=db, project_create=project_data) + + def test_get_project_by_id(db: Session) -> None: """Test retrieving a project by ID.""" project = create_test_project(db)