Skip to content

Commit 32e17de

Browse files
patrick91tiangolo
andauthored
✨ Add owner id to team (#135)
* Add owner to team * Check if user owns any team * Move to is_personal_team on team * Remove unneeded delete * Remove redundant args * Update things after merge * Fix delete * Use hybrid property * Update backend/app/models.py Co-authored-by: Sebastián Ramírez <[email protected]> * Lint --------- Co-authored-by: Sebastián Ramírez <[email protected]>
1 parent da02fd0 commit 32e17de

File tree

10 files changed

+146
-27
lines changed

10 files changed

+146
-27
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Add created_at to team
2+
3+
Revision ID: a63c538f8962
4+
Revises: 89c9a00d523e
5+
Create Date: 2024-07-23 11:08:12.380446
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = 'a63c538f8962'
15+
down_revision = '89c9a00d523e'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.add_column('team', sa.Column('owner_id', sa.Uuid(), nullable=False))
23+
op.add_column('team', sa.Column('is_personal_team', sa.Boolean(), nullable=False))
24+
op.create_foreign_key(None, 'team', 'user', ['owner_id'], ['id'], ondelete='CASCADE')
25+
op.drop_constraint('user_personal_team_id_fkey', 'user', type_='foreignkey')
26+
op.drop_column('user', 'personal_team_id')
27+
# ### end Alembic commands ###
28+
29+
30+
def downgrade():
31+
# ### commands auto generated by Alembic - please adjust! ###
32+
op.add_column('user', sa.Column('personal_team_id', sa.UUID(), autoincrement=False, nullable=True))
33+
op.create_foreign_key('user_personal_team_id_fkey', 'user', 'team', ['personal_team_id'], ['id'])
34+
op.drop_constraint(None, 'team', type_='foreignkey')
35+
op.drop_column('team', 'is_personal_team')
36+
op.drop_column('team', 'owner_id')
37+
# ### end Alembic commands ###

backend/app/api/routes/teams.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ def create_team(
7878
"""
7979
team_slug = verify_and_generate_slug_name(team_in.name, session)
8080

81-
team = Team.model_validate(team_in, update={"slug": team_slug})
81+
team = Team.model_validate(
82+
team_in, update={"slug": team_slug, "owner_id": current_user.id}
83+
)
8284
user_team = UserTeamLink(user=current_user, team=team, role=Role.admin)
8385
session.add(user_team)
8486
session.commit()
@@ -145,7 +147,9 @@ def delete_team(
145147

146148
team = link.team
147149

148-
if team.id == current_user.personal_team_id:
150+
assert current_user.personal_team
151+
152+
if team.id == current_user.personal_team.id:
149153
raise HTTPException(
150154
status_code=400, detail="You cannot delete your personal team"
151155
)

backend/app/api/routes/users.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
127127
"""
128128
Delete own user.
129129
"""
130+
if len(current_user.owned_teams) > 1:
131+
raise HTTPException(
132+
status_code=400,
133+
detail="You cannot delete your account while you have more than one team",
134+
)
135+
136+
session.delete(current_user.personal_team)
130137
session.delete(current_user)
131138
session.commit()
132139
email_data = generate_account_deletion_email(email_to=current_user.email)
@@ -199,11 +206,10 @@ def verify_email_token(session: SessionDep, payload: EmailVerificationToken) ->
199206
raise HTTPException(status_code=400, detail="Email already verified")
200207

201208
team_slug = verify_and_generate_slug_name(session=session, name=user.username)
202-
team = Team(name=user.full_name, slug=team_slug)
209+
team = Team(name=user.full_name, slug=team_slug, owner=user, is_personal_team=True)
203210
user_team_link = UserTeamLink(team=team, user=user, role=Role.admin)
204211

205212
user.is_verified = True
206-
user.personal_team = team
207213

208214
session.add(user)
209215
session.add(user_team_link)

backend/app/core/db.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ def init_db(session: Session) -> None:
3232
full_name=settings.FIRST_SUPERUSER_FULL_NAME,
3333
)
3434
user = crud.create_user(session=session, user_create=user_in, is_verified=True)
35-
team = Team(name=user.full_name, slug=user.username)
35+
team = Team(
36+
name=user.full_name, slug=user.username, owner=user, is_personal_team=True
37+
)
3638
user_team_link = UserTeamLink(user=user, team=team, role=Role.admin)
37-
user.personal_team = team
3839
session.add(user_team_link)
3940
session.commit()

backend/app/models.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import uuid
22
from datetime import datetime
33
from enum import Enum
4-
from typing import Optional
54

6-
from pydantic import EmailStr
5+
from pydantic import EmailStr, computed_field
6+
from sqlalchemy.ext.hybrid import hybrid_property
77
from sqlmodel import Field, Relationship, SQLModel
88

99
from app.utils import get_datetime_utc
@@ -73,17 +73,19 @@ class User(UserBase, table=True):
7373
username: str = Field(unique=True, index=True, max_length=255)
7474
is_verified: bool = False
7575

76-
personal_team_id: uuid.UUID | None = Field(
77-
foreign_key="team.id", nullable=True, default=None
78-
)
79-
personal_team: Optional["Team"] = Relationship()
76+
owned_teams: list["Team"] = Relationship(back_populates="owner")
8077

8178
team_links: list[UserTeamLink] = Relationship(back_populates="user")
8279
invitations_sent: list["Invitation"] = Relationship(
8380
back_populates="sender",
8481
sa_relationship_kwargs={"foreign_keys": "[Invitation.invited_by_id]"},
8582
)
8683

84+
@computed_field
85+
@hybrid_property
86+
def personal_team(self) -> "Team | None":
87+
return next((team for team in self.owned_teams if team.is_personal_team), None)
88+
8789

8890
# Used for the current user
8991
class UserMePublic(UserBase):
@@ -144,6 +146,10 @@ class Team(TeamBase, table=True):
144146
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
145147
slug: str = Field(unique=True, index=True, max_length=255)
146148

149+
owner_id: uuid.UUID = Field(foreign_key="user.id", ondelete="CASCADE")
150+
owner: User = Relationship(back_populates="owned_teams")
151+
is_personal_team: bool = False
152+
147153
user_links: list[UserTeamLink] = Relationship(back_populates="team")
148154
invitations: list["Invitation"] = Relationship(back_populates="team")
149155

backend/app/tests/api/routes/test_teams.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from app.crud import add_user_to_team
99
from app.models import Role, Team, UserTeamLink
1010
from app.tests.utils.team import create_random_team
11-
from app.tests.utils.user import create_user, user_authentication_headers
11+
from app.tests.utils.user import (
12+
create_random_user,
13+
create_user,
14+
user_authentication_headers,
15+
)
1216

1317

1418
def test_read_teams(client: TestClient, db: Session) -> None:
@@ -164,13 +168,20 @@ def test_create_team(client: TestClient, db: Session) -> None:
164168

165169

166170
def test_create_team_name_exists_new_created(client: TestClient, db: Session) -> None:
171+
user = create_random_user(db)
172+
167173
user_auth_headers = user_authentication_headers(
168174
client=client,
169175
email=settings.FIRST_SUPERUSER,
170176
password=settings.FIRST_SUPERUSER_PASSWORD,
171177
)
172178

173-
team = Team(name="test copy", description="test description", slug="test-copy")
179+
team = Team(
180+
name="test copy",
181+
description="test description",
182+
slug="test-copy",
183+
owner_id=user.id,
184+
)
174185
db.add(team)
175186
db.commit()
176187

@@ -393,23 +404,25 @@ def test_delete_team_not_enough_permissions(client: TestClient, db: Session) ->
393404

394405

395406
def test_delete_personal_team_forbidden(client: TestClient, db: Session) -> None:
396-
team = create_random_team(db)
397407
user = create_user(
398408
session=db,
399409
400410
password="test12345",
401411
full_name="default-org",
402412
is_verified=True,
403413
)
404-
add_user_to_team(session=db, user=user, team=team, role=Role.admin)
405-
user.personal_team_id = team.id
406414
db.add(user)
407415
db.commit()
408416

409417
user_auth_headers = user_authentication_headers(
410418
client=client, email="[email protected]", password="test12345"
411419
)
412420

421+
assert user.personal_team is not None
422+
423+
team = user.personal_team
424+
add_user_to_team(session=db, user=user, team=team, role=Role.admin)
425+
413426
response = client.delete(
414427
f"{settings.API_V1_STR}/teams/{team.slug}",
415428
headers=user_auth_headers,

backend/app/tests/api/routes/test_users.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,15 +256,14 @@ def test_register_user_empty_full_name(client: TestClient) -> None:
256256
assert data["detail"][0]["msg"] == "String should have at least 3 characters" # type: ignore
257257

258258

259-
def test_delete_user_me(client: TestClient, db: Session) -> None:
259+
def test_delete_user_me_only_personal_team(client: TestClient, db: Session) -> None:
260260
username = random_email()
261261
password = random_lower_string()
262262
full_name = random_lower_string()
263-
team = create_random_team(db)
264263
user_in = UserCreate(email=username, password=password, full_name=full_name)
265264
user = crud.create_user(session=db, user_create=user_in, is_verified=True)
265+
create_random_team(db, owner_id=user.id, is_personal_team=True)
266266
user_id = user.id
267-
user.personal_team = team
268267
db.add(user)
269268
db.commit()
270269

@@ -288,6 +287,40 @@ def test_delete_user_me(client: TestClient, db: Session) -> None:
288287
assert result is None
289288

290289

290+
def test_delete_user_me_owns_more_teams(client: TestClient, db: Session) -> None:
291+
username = random_email()
292+
password = random_lower_string()
293+
full_name = random_lower_string()
294+
user_in = UserCreate(email=username, password=password, full_name=full_name)
295+
user = crud.create_user(session=db, user_create=user_in, is_verified=True)
296+
create_random_team(db, owner_id=user.id, is_personal_team=True)
297+
db.add(user)
298+
db.commit()
299+
300+
# adding another team owned by the user
301+
create_random_team(db, owner_id=user.id)
302+
303+
login_data = {
304+
"username": username,
305+
"password": password,
306+
}
307+
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
308+
tokens = r.json()
309+
a_token = tokens["access_token"]
310+
headers = {"Authorization": f"Bearer {a_token}"}
311+
312+
r = client.delete(
313+
f"{settings.API_V1_STR}/users/me",
314+
headers=headers,
315+
)
316+
assert r.status_code == 400
317+
deleted_user = r.json()
318+
assert (
319+
deleted_user["detail"]
320+
== "You cannot delete your account while you have more than one team"
321+
)
322+
323+
291324
def test_verify_email(client: TestClient, db: Session) -> None:
292325
email = random_email()
293326
password = random_lower_string()
@@ -316,4 +349,5 @@ def test_verify_email(client: TestClient, db: Session) -> None:
316349
assert team_link.team.name == user.full_name
317350
assert team_link.team.slug == user.username
318351

319-
assert user.personal_team_id == team_link.team.id
352+
assert user.personal_team
353+
assert user.personal_team.id == team_link.team.id

backend/app/tests/utils/team.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
import secrets
2+
import uuid
23

34
from sqlmodel import Session
45

56
from app.models import Team
67
from app.tests.utils.utils import random_lower_string
78

89

9-
def create_random_team(db: Session) -> Team:
10+
def create_random_team(
11+
db: Session, owner_id: uuid.UUID | None = None, is_personal_team: bool = False
12+
) -> Team:
13+
from app.tests.utils.user import create_random_user
14+
15+
owner_id = owner_id or create_random_user(db).id
1016
name = random_lower_string()
1117
slug = f"{name}-{secrets.token_hex(4)}"
1218
description = random_lower_string()
13-
team_in = Team(name=name, description=description, slug=slug)
19+
team_in = Team(
20+
name=name,
21+
description=description,
22+
slug=slug,
23+
owner_id=owner_id,
24+
is_personal_team=is_personal_team,
25+
)
1426
db.add(team_in)
1527
db.commit()
1628
db.refresh(team_in)

backend/app/tests/utils/user.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@ def create_user(
2828
session=session, user_create=user_in, is_verified=is_verified
2929
)
3030

31-
team = create_random_team(session)
31+
create_random_team(session, owner_id=user.id, is_personal_team=True)
3232

33-
user.personal_team = team
3433
session.add(user)
3534
session.commit()
3635

@@ -63,10 +62,9 @@ def authentication_token_from_email(
6362
session=db, user_create=user_in_create, is_verified=True
6463
)
6564

66-
team = create_random_team(db)
65+
team = create_random_team(db, owner_id=user.id, is_personal_team=True)
6766
crud.add_user_to_team(session=db, user=user, team=team, role=Role.admin)
6867

69-
user.personal_team = team
7068
db.add(user)
7169
db.commit()
7270
else:

frontend/src/components/Common/SidebarItems.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
FaHome,
2222
FaQuestionCircle,
2323
FaTools,
24+
FaObjectGroup,
2425
} from "react-icons/fa"
2526

2627
import { useCurrentUser } from "../../hooks/useAuth"
@@ -69,6 +70,13 @@ const getSidebarItems = ({ team }: { team: string }): Array<Item> => {
6970
params: { team },
7071
}),
7172
},
73+
{
74+
icon: FaObjectGroup,
75+
title: "Teams",
76+
...link({
77+
to: "/teams/all",
78+
}),
79+
},
7280
{
7381
icon: FaCog,
7482
title: "Settings",

0 commit comments

Comments
 (0)