Skip to content

Commit 6e6b13b

Browse files
Merge pull request #62 from Promptly-Technologies-LLC/12-allow-upload-of-avatar-images
Avatar upload + dedicated endpoint for serving binary images from database
2 parents dcddb9d + deed7a3 commit 6e6b13b

File tree

8 files changed

+143
-63
lines changed

8 files changed

+143
-63
lines changed

docs/architecture.qmd

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,16 @@ Here are some patterns we've considered for server-side error handling:
102102
border-collapse: collapse;
103103
}
104104

105-
.styled-table th:nth-child(1) { width: 5%; }
106-
.styled-table th:nth-child(2) { width: 50%; }
107-
.styled-table th:nth-child(3),
108-
.styled-table th:nth-child(4),
109-
.styled-table th:nth-child(5) { width: 15%; }
110-
.styled-table th:nth-child(6) { width: 10%; }
105+
.styled-table th:nth-child(1) { width: 50%; }
106+
.styled-table th:nth-child(2),
107+
.styled-table th:nth-child(3),
108+
.styled-table th:nth-child(4) { width: 15%; }
109+
.styled-table th:nth-child(5) { width: 10%; }
111110
</style>
112111

113112
<table class="styled-table">
114113
<thead>
115114
<tr>
116-
<th>ID</th>
117115
<th>Approach</th>
118116
<th>Returns to same page</th>
119117
<th>Preserves form inputs</th>
@@ -123,39 +121,34 @@ Here are some patterns we've considered for server-side error handling:
123121
</thead>
124122
<tbody>
125123
<tr>
126-
<td>1</td>
127124
<td>Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" button</td>
128125
<td>No</td>
129126
<td>Yes</td>
130127
<td>Yes</td>
131128
<td>Low</td>
132129
</tr>
133130
<tr>
134-
<td>2</td>
135131
<td>Validate in FastAPI endpoint function body, redirect to origin page with error message query param</td>
136132
<td>Yes</td>
137133
<td>No</td>
138134
<td>Yes</td>
139135
<td>Medium</td>
140136
</tr>
141137
<tr>
142-
<td>3</td>
143138
<td>Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context so we can re-render page with original form inputs</td>
144139
<td>Yes</td>
145140
<td>Yes</td>
146141
<td>Yes</td>
147142
<td>High</td>
148143
</tr>
149144
<tr>
150-
<td>4</td>
151145
<td>Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputs</td>
152146
<td>Yes</td>
153147
<td>Yes</td>
154148
<td>Yes</td>
155149
<td>High</td>
156150
</tr>
157151
<tr>
158-
<td>5</td>
159152
<td>Validate in either Pydantic dependency or function endpoint body and directly return error message or error toast HTML partial in JSON, then mount error toast with HTMX or some simple layout-level Javascript</td>
160153
<td>Yes</td>
161154
<td>Yes</td>

docs/static/documentation.txt

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -322,18 +322,16 @@ Here are some patterns we've considered for server-side error handling:
322322
border-collapse: collapse;
323323
}
324324

325-
.styled-table th:nth-child(1) { width: 5%; }
326-
.styled-table th:nth-child(2) { width: 50%; }
327-
.styled-table th:nth-child(3),
328-
.styled-table th:nth-child(4),
329-
.styled-table th:nth-child(5) { width: 15%; }
330-
.styled-table th:nth-child(6) { width: 10%; }
325+
.styled-table th:nth-child(1) { width: 50%; }
326+
.styled-table th:nth-child(2),
327+
.styled-table th:nth-child(3),
328+
.styled-table th:nth-child(4) { width: 15%; }
329+
.styled-table th:nth-child(5) { width: 10%; }
331330
</style>
332331

333332
<table class="styled-table">
334333
<thead>
335334
<tr>
336-
<th>ID</th>
337335
<th>Approach</th>
338336
<th>Returns to same page</th>
339337
<th>Preserves form inputs</th>
@@ -343,39 +341,34 @@ Here are some patterns we've considered for server-side error handling:
343341
</thead>
344342
<tbody>
345343
<tr>
346-
<td>1</td>
347344
<td>Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" button</td>
348345
<td>No</td>
349346
<td>Yes</td>
350347
<td>Yes</td>
351348
<td>Low</td>
352349
</tr>
353350
<tr>
354-
<td>2</td>
355351
<td>Validate in FastAPI endpoint function body, redirect to origin page with error message query param</td>
356352
<td>Yes</td>
357353
<td>No</td>
358354
<td>Yes</td>
359355
<td>Medium</td>
360356
</tr>
361357
<tr>
362-
<td>3</td>
363358
<td>Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context so we can re-render page with original form inputs</td>
364359
<td>Yes</td>
365360
<td>Yes</td>
366361
<td>Yes</td>
367362
<td>High</td>
368363
</tr>
369364
<tr>
370-
<td>4</td>
371365
<td>Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputs</td>
372366
<td>Yes</td>
373367
<td>Yes</td>
374368
<td>Yes</td>
375369
<td>High</td>
376370
</tr>
377371
<tr>
378-
<td>5</td>
379372
<td>Validate in either Pydantic dependency or function endpoint body and directly return error message or error toast HTML partial in JSON, then mount error toast with HTMX or some simple layout-level Javascript</td>
380373
<td>Yes</td>
381374
<td>Yes</td>

docs/static/schema.png

17.1 KB
Loading

routers/user.py

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from fastapi import APIRouter, Depends, HTTPException, Form
2-
from fastapi.responses import RedirectResponse
1+
from fastapi import APIRouter, Depends, Form, UploadFile, File
2+
from fastapi.responses import RedirectResponse, Response
33
from pydantic import BaseModel, EmailStr
44
from sqlmodel import Session
5-
from utils.models import User
6-
from utils.auth import get_session, get_authenticated_user, verify_password
5+
from typing import Optional
6+
from utils.models import User, DataIntegrityError
7+
from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError
78

89
router = APIRouter(prefix="/user", tags=["user"])
910

@@ -15,16 +16,29 @@ class UpdateProfile(BaseModel):
1516
"""Request model for updating user profile information"""
1617
name: str
1718
email: EmailStr
18-
avatar_url: str
19+
avatar_file: Optional[bytes] = None
20+
avatar_content_type: Optional[str] = None
1921

2022
@classmethod
2123
async def as_form(
2224
cls,
2325
name: str = Form(...),
2426
email: EmailStr = Form(...),
25-
avatar_url: str = Form(...),
27+
avatar_file: Optional[UploadFile] = File(None),
2628
):
27-
return cls(name=name, email=email, avatar_url=avatar_url)
29+
avatar_data = None
30+
avatar_content_type = None
31+
32+
if avatar_file:
33+
avatar_data = await avatar_file.read()
34+
avatar_content_type = avatar_file.content_type
35+
36+
return cls(
37+
name=name,
38+
email=email,
39+
avatar_file=avatar_data,
40+
avatar_content_type=avatar_content_type
41+
)
2842

2943

3044
class UserDeleteAccount(BaseModel):
@@ -44,43 +58,64 @@ async def as_form(
4458
@router.post("/update_profile", response_class=RedirectResponse)
4559
async def update_profile(
4660
user_profile: UpdateProfile = Depends(UpdateProfile.as_form),
47-
current_user: User = Depends(get_authenticated_user),
61+
user: User = Depends(get_authenticated_user),
4862
session: Session = Depends(get_session)
4963
):
5064
# Update user details
51-
current_user.name = user_profile.name
52-
current_user.email = user_profile.email
53-
current_user.avatar_url = user_profile.avatar_url
65+
user.name = user_profile.name
66+
user.email = user_profile.email
67+
68+
# Handle avatar update
69+
if user_profile.avatar_file:
70+
user.avatar_data = user_profile.avatar_file
71+
user.avatar_content_type = user_profile.avatar_content_type
72+
5473
session.commit()
55-
session.refresh(current_user)
74+
session.refresh(user)
5675
return RedirectResponse(url="/profile", status_code=303)
5776

5877

5978
@router.post("/delete_account", response_class=RedirectResponse)
6079
async def delete_account(
6180
user_delete_account: UserDeleteAccount = Depends(
6281
UserDeleteAccount.as_form),
63-
current_user: User = Depends(get_authenticated_user),
82+
user: User = Depends(get_authenticated_user),
6483
session: Session = Depends(get_session)
6584
):
66-
if not current_user.password:
67-
raise HTTPException(
68-
status_code=500,
69-
detail="User password not found in database; please contact a system administrator"
85+
if not user.password:
86+
raise DataIntegrityError(
87+
resource="User password"
7088
)
7189

7290
if not verify_password(
7391
user_delete_account.confirm_delete_password,
74-
current_user.password.hashed_password
92+
user.password.hashed_password
7593
):
76-
raise HTTPException(
77-
status_code=400,
78-
detail="Password is incorrect"
94+
raise PasswordValidationError(
95+
field="confirm_delete_password",
96+
message="Password is incorrect"
7997
)
8098

8199
# Delete the user
82-
session.delete(current_user)
100+
session.delete(user)
83101
session.commit()
84102

85103
# Log out the user
86104
return RedirectResponse(url="/auth/logout", status_code=303)
105+
106+
107+
@router.get("/avatar")
108+
async def get_avatar(
109+
user: User = Depends(get_authenticated_user),
110+
session: Session = Depends(get_session)
111+
):
112+
"""Serve avatar image from database"""
113+
if not user.avatar_data:
114+
raise DataIntegrityError(
115+
resource="User avatar"
116+
)
117+
118+
return Response(
119+
content=user.avatar_data,
120+
media_type=user.avatar_content_type
121+
)

templates/users/organization.html

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,7 @@ <h1 class="mb-4">{{ organization.name }}</h1>
6363
{% for user in role.users %}
6464
<tr>
6565
<td class="text-center" style="width: 50px;">
66-
{% if user.avatar_url %}
67-
<img src="{{ user.avatar_url }}" alt="User Avatar" class="rounded-circle" width="40" height="40">
68-
{% else %}
69-
{{ render_silhouette(width=40, height=40) }}
70-
{% endif %}
66+
{{ render_silhouette(width=40, height=40) }}
7167
</td>
7268
<td>{{ user.name }}</td>
7369
<td>{{ user.email }}</td>

templates/users/profile.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ <h1 class="mb-4">User Profile</h1>
1818
<p><strong>Email:</strong> {{ user.email }}</p>
1919
<!-- Display user avatar or silhouette if no avatar is available -->
2020
<div class="mb-3">
21-
{% if user.avatar_url %}
22-
<img src="{{ user.avatar_url }}" alt="User Avatar" class="img-thumbnail" width="150">
21+
{% if user.avatar_data %}
22+
<img src="{{ url_for('get_avatar') }}" alt="User Avatar" class="img-thumbnail" width="150">
2323
{% else %}
2424
{{ render_silhouette(width=150, height=150) }}
2525
{% endif %}
@@ -35,7 +35,7 @@ <h1 class="mb-4">User Profile</h1>
3535
Edit Profile
3636
</div>
3737
<div class="card-body">
38-
<form action="{{ url_for('update_profile') }}" method="post">
38+
<form action="{{ url_for('update_profile') }}" method="post" enctype="multipart/form-data">
3939
<div class="mb-3">
4040
<label for="name" class="form-label">Name</label>
4141
<input type="text" class="form-control" id="name" name="name" value="{{ user.name }}">
@@ -45,8 +45,8 @@ <h1 class="mb-4">User Profile</h1>
4545
<input type="email" class="form-control" id="email" name="email" value="{{ user.email }}">
4646
</div>
4747
<div class="mb-3">
48-
<label for="avatar_url" class="form-label">Avatar URL</label>
49-
<input type="url" class="form-control" id="avatar_url" name="avatar_url" value="{{ user.avatar_url }}">
48+
<label for="avatar_file" class="form-label">Avatar</label>
49+
<input type="file" class="form-control" id="avatar_file" name="avatar_file" accept="image/*">
5050
</div>
5151
<button type="submit" class="btn btn-primary">Save Changes</button>
5252
</form>

0 commit comments

Comments
 (0)