Skip to content

Avatar upload + dedicated endpoint for serving binary images from database #62

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Dec 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions docs/architecture.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,16 @@ Here are some patterns we've considered for server-side error handling:
border-collapse: collapse;
}

.styled-table th:nth-child(1) { width: 5%; }
.styled-table th:nth-child(2) { width: 50%; }
.styled-table th:nth-child(3),
.styled-table th:nth-child(4),
.styled-table th:nth-child(5) { width: 15%; }
.styled-table th:nth-child(6) { width: 10%; }
.styled-table th:nth-child(1) { width: 50%; }
.styled-table th:nth-child(2),
.styled-table th:nth-child(3),
.styled-table th:nth-child(4) { width: 15%; }
.styled-table th:nth-child(5) { width: 10%; }
</style>

<table class="styled-table">
<thead>
<tr>
<th>ID</th>
<th>Approach</th>
<th>Returns to same page</th>
<th>Preserves form inputs</th>
Expand All @@ -123,39 +121,34 @@ Here are some patterns we've considered for server-side error handling:
</thead>
<tbody>
<tr>
<td>1</td>
<td>Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" button</td>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
<td>Low</td>
</tr>
<tr>
<td>2</td>
<td>Validate in FastAPI endpoint function body, redirect to origin page with error message query param</td>
<td>Yes</td>
<td>No</td>
<td>Yes</td>
<td>Medium</td>
</tr>
<tr>
<td>3</td>
<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>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>High</td>
</tr>
<tr>
<td>4</td>
<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>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>High</td>
</tr>
<tr>
<td>5</td>
<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>
<td>Yes</td>
<td>Yes</td>
Expand Down
17 changes: 5 additions & 12 deletions docs/static/documentation.txt
Original file line number Diff line number Diff line change
Expand Up @@ -322,18 +322,16 @@ Here are some patterns we've considered for server-side error handling:
border-collapse: collapse;
}

.styled-table th:nth-child(1) { width: 5%; }
.styled-table th:nth-child(2) { width: 50%; }
.styled-table th:nth-child(3),
.styled-table th:nth-child(4),
.styled-table th:nth-child(5) { width: 15%; }
.styled-table th:nth-child(6) { width: 10%; }
.styled-table th:nth-child(1) { width: 50%; }
.styled-table th:nth-child(2),
.styled-table th:nth-child(3),
.styled-table th:nth-child(4) { width: 15%; }
.styled-table th:nth-child(5) { width: 10%; }
</style>

<table class="styled-table">
<thead>
<tr>
<th>ID</th>
<th>Approach</th>
<th>Returns to same page</th>
<th>Preserves form inputs</th>
Expand All @@ -343,39 +341,34 @@ Here are some patterns we've considered for server-side error handling:
</thead>
<tbody>
<tr>
<td>1</td>
<td>Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" button</td>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
<td>Low</td>
</tr>
<tr>
<td>2</td>
<td>Validate in FastAPI endpoint function body, redirect to origin page with error message query param</td>
<td>Yes</td>
<td>No</td>
<td>Yes</td>
<td>Medium</td>
</tr>
<tr>
<td>3</td>
<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>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>High</td>
</tr>
<tr>
<td>4</td>
<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>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>High</td>
</tr>
<tr>
<td>5</td>
<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>
<td>Yes</td>
<td>Yes</td>
Expand Down
Binary file modified docs/static/schema.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
79 changes: 57 additions & 22 deletions routers/user.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, Form
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, Depends, Form, UploadFile, File
from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel, EmailStr
from sqlmodel import Session
from utils.models import User
from utils.auth import get_session, get_authenticated_user, verify_password
from typing import Optional
from utils.models import User, DataIntegrityError
from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError

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

Expand All @@ -15,16 +16,29 @@ class UpdateProfile(BaseModel):
"""Request model for updating user profile information"""
name: str
email: EmailStr
avatar_url: str
avatar_file: Optional[bytes] = None
avatar_content_type: Optional[str] = None

@classmethod
async def as_form(
cls,
name: str = Form(...),
email: EmailStr = Form(...),
avatar_url: str = Form(...),
avatar_file: Optional[UploadFile] = File(None),
):
return cls(name=name, email=email, avatar_url=avatar_url)
avatar_data = None
avatar_content_type = None

if avatar_file:
avatar_data = await avatar_file.read()
avatar_content_type = avatar_file.content_type

return cls(
name=name,
email=email,
avatar_file=avatar_data,
avatar_content_type=avatar_content_type
)


class UserDeleteAccount(BaseModel):
Expand All @@ -44,43 +58,64 @@ async def as_form(
@router.post("/update_profile", response_class=RedirectResponse)
async def update_profile(
user_profile: UpdateProfile = Depends(UpdateProfile.as_form),
current_user: User = Depends(get_authenticated_user),
user: User = Depends(get_authenticated_user),
session: Session = Depends(get_session)
):
# Update user details
current_user.name = user_profile.name
current_user.email = user_profile.email
current_user.avatar_url = user_profile.avatar_url
user.name = user_profile.name
user.email = user_profile.email

# Handle avatar update
if user_profile.avatar_file:
user.avatar_data = user_profile.avatar_file
user.avatar_content_type = user_profile.avatar_content_type

session.commit()
session.refresh(current_user)
session.refresh(user)
return RedirectResponse(url="/profile", status_code=303)


@router.post("/delete_account", response_class=RedirectResponse)
async def delete_account(
user_delete_account: UserDeleteAccount = Depends(
UserDeleteAccount.as_form),
current_user: User = Depends(get_authenticated_user),
user: User = Depends(get_authenticated_user),
session: Session = Depends(get_session)
):
if not current_user.password:
raise HTTPException(
status_code=500,
detail="User password not found in database; please contact a system administrator"
if not user.password:
raise DataIntegrityError(
resource="User password"
)

if not verify_password(
user_delete_account.confirm_delete_password,
current_user.password.hashed_password
user.password.hashed_password
):
raise HTTPException(
status_code=400,
detail="Password is incorrect"
raise PasswordValidationError(
field="confirm_delete_password",
message="Password is incorrect"
)

# Delete the user
session.delete(current_user)
session.delete(user)
session.commit()

# Log out the user
return RedirectResponse(url="/auth/logout", status_code=303)


@router.get("/avatar")
async def get_avatar(
user: User = Depends(get_authenticated_user),
session: Session = Depends(get_session)
):
"""Serve avatar image from database"""
if not user.avatar_data:
raise DataIntegrityError(
resource="User avatar"
)

return Response(
content=user.avatar_data,
media_type=user.avatar_content_type
)
6 changes: 1 addition & 5 deletions templates/users/organization.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,7 @@ <h1 class="mb-4">{{ organization.name }}</h1>
{% for user in role.users %}
<tr>
<td class="text-center" style="width: 50px;">
{% if user.avatar_url %}
<img src="{{ user.avatar_url }}" alt="User Avatar" class="rounded-circle" width="40" height="40">
{% else %}
{{ render_silhouette(width=40, height=40) }}
{% endif %}
{{ render_silhouette(width=40, height=40) }}
</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
Expand Down
10 changes: 5 additions & 5 deletions templates/users/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ <h1 class="mb-4">User Profile</h1>
<p><strong>Email:</strong> {{ user.email }}</p>
<!-- Display user avatar or silhouette if no avatar is available -->
<div class="mb-3">
{% if user.avatar_url %}
<img src="{{ user.avatar_url }}" alt="User Avatar" class="img-thumbnail" width="150">
{% if user.avatar_data %}
<img src="{{ url_for('get_avatar') }}" alt="User Avatar" class="img-thumbnail" width="150">
{% else %}
{{ render_silhouette(width=150, height=150) }}
{% endif %}
Expand All @@ -35,7 +35,7 @@ <h1 class="mb-4">User Profile</h1>
Edit Profile
</div>
<div class="card-body">
<form action="{{ url_for('update_profile') }}" method="post">
<form action="{{ url_for('update_profile') }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" value="{{ user.name }}">
Expand All @@ -45,8 +45,8 @@ <h1 class="mb-4">User Profile</h1>
<input type="email" class="form-control" id="email" name="email" value="{{ user.email }}">
</div>
<div class="mb-3">
<label for="avatar_url" class="form-label">Avatar URL</label>
<input type="url" class="form-control" id="avatar_url" name="avatar_url" value="{{ user.avatar_url }}">
<label for="avatar_file" class="form-label">Avatar</label>
<input type="file" class="form-control" id="avatar_file" name="avatar_file" accept="image/*">
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
Expand Down
Loading
Loading