diff --git a/backend/app/api/main.py b/backend/app/api/main.py index dd87252a0e..ea2e6f6729 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import invitations, login, teams, users, utils +from app.api.routes import apps, invitations, login, teams, users, utils api_router = APIRouter() api_router.include_router(login.router, tags=["login"]) @@ -10,3 +10,4 @@ api_router.include_router( invitations.router, prefix="/invitations", tags=["invitations"] ) +api_router.include_router(apps.router, prefix="/apps", tags=["apps"]) diff --git a/backend/app/api/routes/apps.py b/backend/app/api/routes/apps.py new file mode 100644 index 0000000000..854ba32a88 --- /dev/null +++ b/backend/app/api/routes/apps.py @@ -0,0 +1,37 @@ +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app.api.deps import CurrentUser, SessionDep +from app.api.utils.teams import generate_app_slug_name +from app.crud import get_user_team_link_by_user_id_and_team_slug +from app.models import ( + App, + AppCreate, + AppPublic, +) + +router = APIRouter() + + +@router.post("/", response_model=AppPublic) +def create_app( + session: SessionDep, current_user: CurrentUser, app_in: AppCreate +) -> Any: + """ + Create a new app with the provided details. + """ + user_team_link = get_user_team_link_by_user_id_and_team_slug( + session=session, user_id=current_user.id, team_slug=app_in.team_slug + ) + if not user_team_link: + raise HTTPException( + status_code=404, detail="Team not found for the current user" + ) + team = user_team_link.team + app_slug = generate_app_slug_name(app_in.name, session) + app = App.model_validate(app_in, update={"slug": app_slug, "team_id": team.id}) + session.add(app) + session.commit() + session.refresh(app) + return app diff --git a/backend/app/api/routes/teams.py b/backend/app/api/routes/teams.py index 2e240e9ecc..edf4f73230 100644 --- a/backend/app/api/routes/teams.py +++ b/backend/app/api/routes/teams.py @@ -6,7 +6,7 @@ from sqlmodel import col, func, select from app.api.deps import CurrentUser, SessionDep, get_current_user -from app.api.utils.teams import verify_and_generate_slug_name +from app.api.utils.teams import generate_team_slug_name from app.models import ( Message, Role, @@ -92,7 +92,7 @@ def create_team( """ Create a new team with the provided details. """ - team_slug = verify_and_generate_slug_name(team_in.name, session) + team_slug = generate_team_slug_name(team_in.name, session) team = Team.model_validate( team_in, update={"slug": team_slug, "owner_id": current_user.id} diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 688250ea8c..b48059f882 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -7,7 +7,7 @@ from app import crud from app.api.deps import CurrentUser, SessionDep, get_first_superuser -from app.api.utils.teams import verify_and_generate_slug_name +from app.api.utils.teams import generate_team_slug_name from app.core.security import get_password_hash, verify_password from app.models import ( EmailVerificationToken, @@ -205,7 +205,7 @@ def verify_email_token(session: SessionDep, payload: EmailVerificationToken) -> if user.is_verified: raise HTTPException(status_code=400, detail="Email already verified") - team_slug = verify_and_generate_slug_name(session=session, name=user.username) + team_slug = generate_team_slug_name(session=session, name=user.username) team = Team(name=user.full_name, slug=team_slug, owner=user, is_personal_team=True) user_team_link = UserTeamLink(team=team, user=user, role=Role.admin) diff --git a/backend/app/api/utils/teams.py b/backend/app/api/utils/teams.py index 389ec4d6f0..3321fd284c 100644 --- a/backend/app/api/utils/teams.py +++ b/backend/app/api/utils/teams.py @@ -2,13 +2,21 @@ from sqlmodel import Session, select -from app.models import Team +from app.models import App, Team from app.utils import slugify -def verify_and_generate_slug_name(name: str, session: Session) -> str: +def generate_team_slug_name(name: str, session: Session) -> str: slug_name = slugify(name) while session.exec(select(Team).where(Team.slug == slug_name)).first(): slug_name = f"{slug_name}-{secrets.token_hex(4)}" return slug_name + + +def generate_app_slug_name(name: str, session: Session) -> str: + slug_name = slugify(name) + while session.exec(select(App).where(App.slug == slug_name)).first(): + slug_name = f"{slug_name}-{secrets.token_hex(4)}" + + return slug_name diff --git a/backend/app/models.py b/backend/app/models.py index daafe31694..6e51c0cd18 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -179,7 +179,7 @@ class TeamUpdate(SQLModel): class TeamPublic(TeamBase): id: uuid.UUID - slug: str = Field(default=None, max_length=255) + slug: str = Field(max_length=255) is_personal_team: bool owner_id: uuid.UUID @@ -255,9 +255,12 @@ class Invitation(InvitationBase, table=True): team: Team = Relationship(back_populates="invitations") -class App(SQLModel, table=True): - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) +class AppBase(SQLModel): name: str = Field(max_length=255, min_length=1) + + +class App(AppBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) team_id: uuid.UUID = Field(foreign_key="team.id") team: Team = Relationship(back_populates="apps") slug: str = Field(max_length=255, unique=True) @@ -268,6 +271,18 @@ class App(SQLModel, table=True): ) +class AppCreate(AppBase): + team_slug: str + + +class AppPublic(AppBase): + id: uuid.UUID + team_id: uuid.UUID + slug: str + created_at: datetime + updated_at: datetime + + class DeploymentStatus(str, Enum): waiting_upload = "waiting_upload" building = "building" diff --git a/backend/app/tests/api/routes/test_apps.py b/backend/app/tests/api/routes/test_apps.py new file mode 100644 index 0000000000..91db2ea531 --- /dev/null +++ b/backend/app/tests/api/routes/test_apps.py @@ -0,0 +1,143 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from app.core.config import settings +from app.crud import add_user_to_team +from app.models import App, Role +from app.tests.utils.team import create_random_team +from app.tests.utils.user import create_user, user_authentication_headers +from app.tests.utils.utils import random_email + + +def test_create_app_admin(client: TestClient, db: Session) -> None: + user = create_user( + session=db, + email=random_email(), + password="password12345", + full_name="Admin User", + is_verified=True, + ) + team = create_random_team(db) + add_user_to_team(session=db, user=user, team=team, role=Role.admin) + + user_auth_headers = user_authentication_headers( + client=client, + email=user.email, + password="password12345", + ) + + app_in = {"name": "test app", "team_slug": team.slug} + response = client.post( + f"{settings.API_V1_STR}/apps/", + headers=user_auth_headers, + json=app_in, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] + assert data["name"] == app_in["name"] + assert data["team_id"] == str(team.id) + + app_query = select(App).where(App.id == data["id"]) + app_db = db.exec(app_query).first() + assert app_db + assert app_db.name == app_in["name"] + assert app_db.team_id == team.id + + +def test_create_app_member(client: TestClient, db: Session) -> None: + user = create_user( + session=db, + email=random_email(), + password="password12345", + full_name="Member User", + is_verified=True, + ) + team = create_random_team(db) + add_user_to_team(session=db, user=user, team=team, role=Role.member) + + user_auth_headers = user_authentication_headers( + client=client, + email=user.email, + password="password12345", + ) + + app_in = {"name": "test app", "team_slug": team.slug} + response = client.post( + f"{settings.API_V1_STR}/apps/", + headers=user_auth_headers, + json=app_in, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] + assert data["name"] == app_in["name"] + assert data["team_id"] == str(team.id) + + app_query = select(App).where(App.id == data["id"]) + app_db = db.exec(app_query).first() + assert app_db + assert app_db.name == app_in["name"] + assert app_db.team_id == team.id + + +def test_create_app_with_empty_name(client: TestClient, db: Session) -> None: + user = create_user( + session=db, + email=random_email(), + password="password12345", + full_name="Test User", + is_verified=True, + ) + team = create_random_team(db, owner_id=user.id) + + user_auth_headers = user_authentication_headers( + client=client, + email=user.email, + password="password12345", + ) + + app_in = {"name": "", "team_slug": team.slug} + response = client.post( + f"{settings.API_V1_STR}/apps/", + headers=user_auth_headers, + json=app_in, + ) + + assert response.status_code == 422 + + data = response.json() + + assert data["detail"][0]["loc"] == ["body", "name"] + assert data["detail"][0]["msg"] == "String should have at least 1 character" + + +def test_create_project_user_not_in_team(client: TestClient, db: Session) -> None: + user = create_user( + session=db, + email=random_email(), + password="password12345", + full_name="Test User", + is_verified=True, + ) + team = create_random_team(db) + + user_auth_headers = user_authentication_headers( + client=client, + email=user.email, + password="password12345", + ) + + app_in = {"name": "test app", "team_slug": team.slug} + response = client.post( + f"{settings.API_V1_STR}/apps/", + headers=user_auth_headers, + json=app_in, + ) + + assert response.status_code == 404 + + data = response.json() + assert data["detail"] == "Team not found for the current user" diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 5a5c077153..40f8dd8d0a 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -10,7 +10,7 @@ from app.core.config import settings from app.core.db import engine, init_db from app.main import app -from app.models import Invitation, Team, User, UserTeamLink +from app.models import App, Deployment, Invitation, Team, User, UserTeamLink from app.tests.utils.user import authentication_token_from_email from app.tests.utils.utils import get_superuser_token_headers @@ -22,14 +22,10 @@ def db() -> Generator[Session, None, None]: try: yield session finally: - statement = delete(UserTeamLink) - session.exec(statement) # type: ignore - statement = delete(Invitation) - session.exec(statement) # type: ignore - statement = delete(User) - session.exec(statement) # type: ignore - statement = delete(Team) - session.exec(statement) # type: ignore + models = [UserTeamLink, Invitation, Deployment, App, User, Team] + for model in models: + statement = delete(model) + session.exec(statement) # type: ignore session.commit()