Skip to content

Commit 476f69c

Browse files
Reorganize database schema to allow 3-way user-org-role relationship
1 parent 5522fac commit 476f69c

File tree

3 files changed

+113
-55
lines changed

3 files changed

+113
-55
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ __pycache__
55
/.quarto/
66
_docs/
77
.pytest_cache/
8-
.mypy_cache/
8+
.mypy_cache/
9+
.cursorrules

routers/organization.py

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,66 @@
11
from logging import getLogger
22
from fastapi import APIRouter, Depends, HTTPException, Form
33
from fastapi.responses import RedirectResponse
4-
from pydantic import BaseModel, ConfigDict
4+
from pydantic import BaseModel, ConfigDict, field_validator
55
from sqlmodel import Session, select
66
from utils.db import get_session
7-
from utils.models import Organization
7+
from utils.auth import get_authenticated_user
8+
from utils.models import Organization, User
89
from datetime import datetime
910

1011
logger = getLogger("uvicorn.error")
1112

13+
# -- Custom Exceptions --
14+
15+
16+
class EmptyOrganizationNameError(HTTPException):
17+
def __init__(self):
18+
super().__init__(
19+
status_code=400,
20+
detail="Organization name cannot be empty"
21+
)
22+
23+
24+
class OrganizationExistsError(HTTPException):
25+
def __init__(self):
26+
super().__init__(
27+
status_code=400,
28+
detail="Organization already exists"
29+
)
30+
31+
32+
class OrganizationNotFoundError(HTTPException):
33+
def __init__(self):
34+
super().__init__(
35+
status_code=404,
36+
detail="Organization not found"
37+
)
38+
39+
40+
class OrganizationNameTakenError(HTTPException):
41+
def __init__(self):
42+
super().__init__(
43+
status_code=400,
44+
detail="Organization name already taken"
45+
)
46+
47+
1248
router = APIRouter(prefix="/organizations", tags=["organizations"])
1349

1450

51+
# -- Server Request and Response Models --
52+
53+
1554
class OrganizationCreate(BaseModel):
1655
name: str
1756

57+
@field_validator('name')
58+
@classmethod
59+
def validate_name(cls, name: str) -> str:
60+
if not name.strip():
61+
raise EmptyOrganizationNameError()
62+
return name.strip()
63+
1864
@classmethod
1965
async def as_form(cls, name: str = Form(...)):
2066
return cls(name=name)
@@ -34,26 +80,30 @@ class OrganizationUpdate(BaseModel):
3480
id: int
3581
name: str
3682

83+
@field_validator('name')
84+
@classmethod
85+
def validate_name(cls, name: str) -> str:
86+
if not name.strip():
87+
raise EmptyOrganizationNameError()
88+
return name.strip()
89+
3790
@classmethod
3891
async def as_form(cls, id: int = Form(...), name: str = Form(...)):
3992
return cls(id=id, name=name)
4093

4194

95+
# -- Routes --
96+
4297
@router.post("/", response_class=RedirectResponse)
4398
def create_organization(
4499
org: OrganizationCreate = Depends(OrganizationCreate.as_form),
100+
user: User = Depends(get_authenticated_user),
45101
session: Session = Depends(get_session)
46102
) -> RedirectResponse:
47-
# Validate organization name is not empty
48-
if not org.name.strip():
49-
raise HTTPException(
50-
status_code=400, detail="Organization name cannot be empty")
51-
52103
db_org = session.exec(select(Organization).where(
53104
Organization.name == org.name)).first()
54105
if db_org:
55-
raise HTTPException(
56-
status_code=400, detail="Organization already exists")
106+
raise OrganizationExistsError()
57107

58108
db_org = Organization(name=org.name)
59109
session.add(db_org)
@@ -64,26 +114,22 @@ def create_organization(
64114

65115

66116
@router.get("/{org_id}", response_model=OrganizationRead)
67-
def read_organization(org_id: int, session: Session = Depends(get_session)):
117+
def read_organization(org_id: int, user: User = Depends(get_authenticated_user), session: Session = Depends(get_session)):
68118
db_org = session.get(Organization, org_id)
69119
if not db_org:
70-
raise HTTPException(status_code=404, detail="Organization not found")
120+
raise OrganizationNotFoundError()
71121
return db_org
72122

73123

74124
@router.put("/{org_id}", response_class=RedirectResponse)
75125
def update_organization(
76126
org: OrganizationUpdate = Depends(OrganizationUpdate.as_form),
127+
user: User = Depends(get_authenticated_user),
77128
session: Session = Depends(get_session)
78129
) -> RedirectResponse:
79-
# Validate organization name is not empty
80-
if not org.name.strip():
81-
raise HTTPException(
82-
status_code=400, detail="Organization name cannot be empty")
83-
84130
db_org = session.get(Organization, org.id)
85131
if not db_org:
86-
raise HTTPException(status_code=404, detail="Organization not found")
132+
raise OrganizationNotFoundError()
87133

88134
# Check if new name already exists for another organization
89135
existing_org = session.exec(
@@ -92,8 +138,7 @@ def update_organization(
92138
.where(Organization.id != org.id)
93139
).first()
94140
if existing_org:
95-
raise HTTPException(
96-
status_code=400, detail="Organization name already taken")
141+
raise OrganizationNameTakenError()
97142

98143
db_org.name = org.name
99144
db_org.updated_at = datetime.utcnow()
@@ -107,11 +152,12 @@ def update_organization(
107152
@router.delete("/{org_id}", response_class=RedirectResponse)
108153
def delete_organization(
109154
org_id: int,
155+
user: User = Depends(get_authenticated_user),
110156
session: Session = Depends(get_session)
111157
) -> RedirectResponse:
112158
db_org = session.get(Organization, org_id)
113159
if not db_org:
114-
raise HTTPException(status_code=404, detail="Organization not found")
160+
raise OrganizationNotFoundError()
115161

116162
db_org.deleted = True
117163
db_org.updated_at = datetime.utcnow()

utils/models.py

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -24,28 +24,58 @@ class ValidPermissions(Enum):
2424
EDIT_ROLE = "Edit Role"
2525

2626

27+
class UserOrganizationLink(SQLModel, table=True):
28+
id: Optional[int] = Field(default=None, primary_key=True)
29+
user_id: int = Field(foreign_key="user.id")
30+
organization_id: int = Field(foreign_key="organization.id")
31+
role_id: int = Field(foreign_key="role.id")
32+
created_at: datetime = Field(default_factory=utc_time)
33+
updated_at: datetime = Field(default_factory=utc_time)
34+
35+
user: "User" = Relationship(back_populates="organization_links")
36+
organization: "Organization" = Relationship(back_populates="user_links")
37+
role: "Role" = Relationship(back_populates="user_links")
38+
39+
40+
class RolePermissionLink(SQLModel, table=True):
41+
id: Optional[int] = Field(default=None, primary_key=True)
42+
role_id: int = Field(foreign_key="role.id")
43+
permission_id: int = Field(foreign_key="permission.id")
44+
created_at: datetime = Field(default_factory=utc_time)
45+
updated_at: datetime = Field(default_factory=utc_time)
46+
47+
2748
class Organization(SQLModel, table=True):
2849
id: Optional[int] = Field(default=None, primary_key=True)
2950
name: str
3051
created_at: datetime = Field(default_factory=utc_time)
3152
updated_at: datetime = Field(default_factory=utc_time)
3253
deleted: bool = Field(default=False)
3354

34-
users: List["User"] = Relationship(back_populates="organization")
55+
user_links: List[UserOrganizationLink] = Relationship(
56+
back_populates="organization")
57+
users: List["User"] = Relationship(
58+
back_populates="organizations",
59+
link_model=UserOrganizationLink
60+
)
61+
roles: List["Role"] = Relationship(back_populates="organization")
3562

3663

3764
class Role(SQLModel, table=True):
3865
id: Optional[int] = Field(default=None, primary_key=True)
3966
name: str
40-
organization_id: Optional[int] = Field(
41-
default=None, foreign_key="organization.id")
67+
organization_id: int = Field(foreign_key="organization.id")
4268
created_at: datetime = Field(default_factory=utc_time)
4369
updated_at: datetime = Field(default_factory=utc_time)
4470
deleted: bool = Field(default=False)
4571

46-
users: List["User"] = Relationship(back_populates="role")
47-
role_permission_links: List["RolePermissionLink"] = Relationship(
72+
organization: Organization = Relationship(back_populates="roles")
73+
user_links: List[UserOrganizationLink] = Relationship(
4874
back_populates="role")
75+
permissions: List["Permission"] = Relationship(
76+
back_populates="roles",
77+
link_model=RolePermissionLink
78+
)
4979

5080

5181
class Permission(SQLModel, table=True):
@@ -56,21 +86,10 @@ class Permission(SQLModel, table=True):
5686
updated_at: datetime = Field(default_factory=utc_time)
5787
deleted: bool = Field(default=False)
5888

59-
role_permission_links: List["RolePermissionLink"] = Relationship(
60-
back_populates="permission")
61-
62-
63-
class RolePermissionLink(SQLModel, table=True):
64-
id: Optional[int] = Field(default=None, primary_key=True)
65-
role_id: Optional[int] = Field(
66-
default=None, foreign_key="role.id")
67-
permission_id: Optional[int] = Field(
68-
default=None, foreign_key="permission.id")
69-
70-
role: Optional["Role"] = Relationship(
71-
back_populates="role_permission_links")
72-
permission: Optional["Permission"] = Relationship(
73-
back_populates="role_permission_links")
89+
roles: List["Role"] = Relationship(
90+
back_populates="permissions",
91+
link_model=RolePermissionLink
92+
)
7493

7594

7695
class PasswordResetToken(SQLModel, table=True):
@@ -92,23 +111,15 @@ class User(SQLModel, table=True):
92111
email: str = Field(index=True, unique=True)
93112
hashed_password: str
94113
avatar_url: Optional[str] = None
95-
organization_id: Optional[int] = Field(
96-
default=None, foreign_key="organization.id")
97-
role_id: Optional[int] = Field(default=None, foreign_key="role.id")
98114
created_at: datetime = Field(default_factory=utc_time)
99115
updated_at: datetime = Field(default_factory=utc_time)
100116
deleted: bool = Field(default=False)
101117

102-
organization: Optional["Organization"] = Relationship(
103-
back_populates="users")
104-
role: Optional["Role"] = Relationship(back_populates="users")
118+
organization_links: List[UserOrganizationLink] = Relationship(
119+
back_populates="user")
120+
organizations: List["Organization"] = Relationship(
121+
back_populates="users",
122+
link_model=UserOrganizationLink
123+
)
105124
password_reset_tokens: List["PasswordResetToken"] = Relationship(
106125
back_populates="user")
107-
108-
109-
class UserOrganizationLink(SQLModel, table=True):
110-
id: Optional[int] = Field(default=None, primary_key=True)
111-
user_id: Optional[int] = Field(
112-
default=None, foreign_key="user.id")
113-
organization_id: Optional[int] = Field(
114-
default=None, foreign_key="organization.id")

0 commit comments

Comments
 (0)