Skip to content

Commit 3b840f1

Browse files
Partial refactor
1 parent 1f48b72 commit 3b840f1

File tree

7 files changed

+108
-225
lines changed

7 files changed

+108
-225
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ package-lock.json
1111
package.json
1212
.specstory
1313
.cursorrules
14+
.cursor

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ package-mode = false
77
authors = [
88
{name = "Christopher Carroll Smith", email = "[email protected]"},
99
]
10-
requires-python = "<4.0,>=3.12"
10+
requires-python = "<4.0,>=3.13"
1111
dependencies = [
1212
"sqlmodel<1.0.0,>=0.0.22",
1313
"pyjwt<3.0.0,>=2.10.1",
@@ -32,4 +32,8 @@ dev = [
3232
"notebook<8.0.0,>=7.2.2",
3333
"pytest<9.0.0,>=8.3.3",
3434
"sqlalchemy-schemadisplay<3.0,>=2.0",
35+
"perplexity-cli",
3536
]
37+
38+
[tool.uv.sources]
39+
perplexity-cli = { git = "https://github.com/chriscarrollsmith/perplexity-cli.git" }

routers/account.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from fastapi import APIRouter, Depends, Form
2+
from fastapi.responses import RedirectResponse
3+
from sqlmodel import Session
4+
from utils.models import User, AccountBase, DataIntegrityError
5+
from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError, get_password_hash
6+
7+
router = APIRouter(prefix="/account", tags=["account"])
8+
9+
class DeleteAccount(AccountBase):
10+
@classmethod
11+
async def as_form(
12+
cls,
13+
email: str = Form(...),
14+
password: str = Form(...),
15+
):
16+
hashed_password = get_password_hash(password)
17+
18+
return cls(email=email, hashed_password=hashed_password)
19+
20+
21+
@router.post("/delete", response_class=RedirectResponse)
22+
async def delete_account(
23+
user_delete_account: DeleteAccount = Depends(
24+
DeleteAccount.as_form),
25+
user: User = Depends(get_authenticated_user),
26+
session: Session = Depends(get_session)
27+
):
28+
if not user.password:
29+
raise DataIntegrityError(
30+
resource="User password"
31+
)
32+
33+
if not verify_password(
34+
user_delete_account.confirm_delete_password,
35+
user.password.hashed_password
36+
):
37+
raise PasswordValidationError(
38+
field="confirm_delete_password",
39+
message="Password is incorrect"
40+
)
41+
42+
# Delete the user
43+
session.delete(user)
44+
session.commit()
45+
46+
# Log out the user
47+
return RedirectResponse(url="/auth/logout", status_code=303)

routers/user.py

Lines changed: 12 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
from fastapi import APIRouter, Depends, Form, UploadFile, File
22
from fastapi.responses import RedirectResponse, Response
3-
from pydantic import BaseModel, EmailStr
43
from sqlmodel import Session
54
from typing import Optional
6-
from utils.models import User, DataIntegrityError
7-
from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError
5+
from utils.models import User, UserBase, DataIntegrityError
6+
from utils.auth import get_session, get_authenticated_user
87
from utils.images import validate_and_process_image
98

109
router = APIRouter(prefix="/user", tags=["user"])
@@ -13,16 +12,12 @@
1312
# --- Server Request and Response Models ---
1413

1514

16-
class UpdateProfile(BaseModel):
15+
class UpdateUser(UserBase):
1716
"""Request model for updating user profile information"""
18-
name: str
19-
avatar_file: Optional[bytes] = None
20-
avatar_content_type: Optional[str] = None
21-
2217
@classmethod
2318
async def as_form(
2419
cls,
25-
name: str = Form(...),
20+
name: Optional[str] = Form(None),
2621
avatar_file: Optional[UploadFile] = File(None),
2722
):
2823
avatar_data = None
@@ -34,81 +29,41 @@ async def as_form(
3429

3530
return cls(
3631
name=name,
37-
avatar_file=avatar_data,
32+
avatar_data=avatar_data,
3833
avatar_content_type=avatar_content_type
3934
)
4035

4136

42-
class UserDeleteAccount(BaseModel):
43-
confirm_delete_password: str
44-
45-
@classmethod
46-
async def as_form(
47-
cls,
48-
confirm_delete_password: str = Form(...),
49-
):
50-
return cls(confirm_delete_password=confirm_delete_password)
51-
52-
5337
# --- Routes ---
5438

5539

56-
@router.post("/update_profile", response_class=RedirectResponse)
40+
@router.post("/update", response_class=RedirectResponse)
5741
async def update_profile(
58-
user_profile: UpdateProfile = Depends(UpdateProfile.as_form),
42+
user_profile: UpdateUser = Depends(UpdateUser.as_form),
5943
user: User = Depends(get_authenticated_user),
6044
session: Session = Depends(get_session)
6145
):
6246
# Handle avatar update
63-
if user_profile.avatar_file:
47+
if user_profile.avatar_data:
6448
processed_image, content_type = validate_and_process_image(
65-
user_profile.avatar_file,
49+
user_profile.avatar_data,
6650
user_profile.avatar_content_type
6751
)
68-
user_profile.avatar_file = processed_image
52+
user_profile.avatar_data = processed_image
6953
user_profile.avatar_content_type = content_type
7054

7155
# Update user details
7256
user.name = user_profile.name
7357

74-
if user_profile.avatar_file:
75-
user.avatar_data = user_profile.avatar_file
58+
if user_profile.avatar_data:
59+
user.avatar_data = user_profile.avatar_data
7660
user.avatar_content_type = user_profile.avatar_content_type
7761

7862
session.commit()
7963
session.refresh(user)
8064
return RedirectResponse(url="/profile", status_code=303)
8165

8266

83-
@router.post("/delete_account", response_class=RedirectResponse)
84-
async def delete_account(
85-
user_delete_account: UserDeleteAccount = Depends(
86-
UserDeleteAccount.as_form),
87-
user: User = Depends(get_authenticated_user),
88-
session: Session = Depends(get_session)
89-
):
90-
if not user.password:
91-
raise DataIntegrityError(
92-
resource="User password"
93-
)
94-
95-
if not verify_password(
96-
user_delete_account.confirm_delete_password,
97-
user.password.hashed_password
98-
):
99-
raise PasswordValidationError(
100-
field="confirm_delete_password",
101-
message="Password is incorrect"
102-
)
103-
104-
# Delete the user
105-
session.delete(user)
106-
session.commit()
107-
108-
# Log out the user
109-
return RedirectResponse(url="/auth/logout", status_code=303)
110-
111-
11267
@router.get("/avatar")
11368
async def get_avatar(
11469
user: User = Depends(get_authenticated_user),

utils/models.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from datetime import datetime, UTC, timedelta
55
from typing import Optional, List, Union
66
from fastapi import HTTPException
7+
from pydantic import EmailStr
78
from sqlmodel import SQLModel, Field, Relationship
89
from sqlalchemy import Column, Enum as SQLAlchemyEnum, LargeBinary
910
from sqlalchemy.orm import Mapped
@@ -35,6 +36,23 @@ def __init__(
3536
)
3637

3738

39+
# ---- Base Models ----
40+
41+
class AccountBase(SQLModel):
42+
email: EmailStr = Field(index=True, unique=True)
43+
hashed_password: str
44+
45+
46+
class UserBase(SQLModel):
47+
name: Optional[str] = None
48+
avatar_data: Optional[bytes] = Field(
49+
default=None, sa_column=Column(LargeBinary)
50+
)
51+
avatar_content_type: Optional[str] = Field(
52+
default=None
53+
)
54+
55+
3856
# --- Database models ---
3957

4058

@@ -180,13 +198,15 @@ def is_expired(self) -> bool:
180198
return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC)
181199

182200

183-
class UserPassword(SQLModel, table=True):
201+
class Account(AccountBase, table=True):
184202
id: Optional[int] = Field(default=None, primary_key=True)
203+
created_at: datetime = Field(default_factory=utc_time)
204+
updated_at: datetime = Field(default_factory=utc_time)
205+
185206
user_id: Optional[int] = Field(foreign_key="user.id", unique=True)
186-
hashed_password: str
187207

188208
user: Mapped[Optional["User"]] = Relationship(
189-
back_populates="password",
209+
back_populates="account",
190210
sa_relationship_kwargs={
191211
"cascade": "all, delete-orphan",
192212
"single_parent": True
@@ -195,13 +215,9 @@ class UserPassword(SQLModel, table=True):
195215

196216

197217
# TODO: Prevent deleting a user who is sole owner of an organization
198-
class User(SQLModel, table=True):
218+
# TODO: Automate change of updated_at when user is updated
219+
class User(UserBase, table=True):
199220
id: Optional[int] = Field(default=None, primary_key=True)
200-
name: str
201-
email: str = Field(index=True, unique=True)
202-
avatar_data: Optional[bytes] = Field(
203-
default=None, sa_column=Column(LargeBinary))
204-
avatar_content_type: Optional[str] = None
205221
created_at: datetime = Field(default_factory=utc_time)
206222
updated_at: datetime = Field(default_factory=utc_time)
207223

@@ -221,7 +237,7 @@ class User(SQLModel, table=True):
221237
"cascade": "all, delete-orphan"
222238
}
223239
)
224-
password: Mapped[Optional[UserPassword]] = Relationship(
240+
account: Mapped[Optional[Account]] = Relationship(
225241
back_populates="user"
226242
)
227243

0 commit comments

Comments
 (0)