Skip to content

Commit 0102582

Browse files
Avatar upload + dedicated endpoint for serving binary images from database
1 parent 0d01c92 commit 0102582

File tree

3 files changed

+111
-13
lines changed

3 files changed

+111
-13
lines changed

routers/user.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from fastapi import APIRouter, Depends, HTTPException, Form
2-
from fastapi.responses import RedirectResponse
1+
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File
2+
from fastapi.responses import RedirectResponse, Response
33
from pydantic import BaseModel, EmailStr
4-
from sqlmodel import Session
4+
from sqlmodel import Session, select
5+
from typing import Optional
56
from utils.models import User
67
from utils.auth import get_session, get_authenticated_user, verify_password
78

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

2023
@classmethod
2124
async def as_form(
2225
cls,
2326
name: str = Form(...),
2427
email: EmailStr = Form(...),
25-
avatar_url: str = Form(...),
28+
avatar_url: Optional[str] = Form(None),
29+
avatar_file: Optional[UploadFile] = File(None),
2630
):
27-
return cls(name=name, email=email, avatar_url=avatar_url)
31+
avatar_data = None
32+
avatar_content_type = None
33+
34+
if avatar_file:
35+
# Read the file content
36+
avatar_data = await avatar_file.read()
37+
avatar_content_type = avatar_file.content_type
38+
39+
return cls(
40+
name=name,
41+
email=email,
42+
avatar_url=avatar_url if not avatar_file else None,
43+
avatar_file=avatar_data,
44+
avatar_content_type=avatar_content_type
45+
)
2846

2947

3048
class UserDeleteAccount(BaseModel):
@@ -50,7 +68,17 @@ async def update_profile(
5068
# Update user details
5169
current_user.name = user_profile.name
5270
current_user.email = user_profile.email
53-
current_user.avatar_url = user_profile.avatar_url
71+
72+
# Handle avatar update
73+
if user_profile.avatar_file:
74+
current_user.avatar_url = None
75+
current_user.avatar_data = user_profile.avatar_file
76+
current_user.avatar_content_type = user_profile.avatar_content_type
77+
else:
78+
current_user.avatar_url = user_profile.avatar_url
79+
current_user.avatar_data = None
80+
current_user.avatar_content_type = None
81+
5482
session.commit()
5583
session.refresh(current_user)
5684
return RedirectResponse(url="/profile", status_code=303)
@@ -84,3 +112,24 @@ async def delete_account(
84112

85113
# Log out the user
86114
return RedirectResponse(url="/auth/logout", status_code=303)
115+
116+
117+
@router.get("/avatar/{user_id}")
118+
async def get_avatar(
119+
user_id: int,
120+
session: Session = Depends(get_session)
121+
):
122+
"""Serve avatar image from database"""
123+
user = session.exec(select(User).where(User.id == user_id)).first()
124+
if not user:
125+
raise HTTPException(status_code=404, detail="User not found")
126+
127+
if user.avatar_data:
128+
return Response(
129+
content=user.avatar_data,
130+
media_type=user.avatar_content_type
131+
)
132+
elif user.avatar_url:
133+
return RedirectResponse(url=user.avatar_url)
134+
else:
135+
raise HTTPException(status_code=404, detail="Avatar not found")

templates/users/profile.html

Lines changed: 51 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_url or user.avatar_data %}
22+
<img src="{{ url_for('get_avatar', user_id=user.id) }}" 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,20 @@ <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 class="form-label">Avatar</label>
49+
<div class="mb-2">
50+
<label for="avatar_type" class="form-label">Choose avatar type:</label>
51+
<select class="form-select" id="avatar_type" onchange="toggleAvatarInput()">
52+
<option value="url">URL</option>
53+
<option value="file">Upload File</option>
54+
</select>
55+
</div>
56+
<div id="url_input" class="mb-2">
57+
<input type="url" class="form-control" id="avatar_url" name="avatar_url" value="{{ user.avatar_url or '' }}">
58+
</div>
59+
<div id="file_input" class="mb-2" style="display: none;">
60+
<input type="file" class="form-control" id="avatar_file" name="avatar_file" accept="image/*">
61+
</div>
5062
</div>
5163
<button type="submit" class="btn btn-primary">Save Changes</button>
5264
</form>
@@ -103,5 +115,39 @@ <h1 class="mb-4">User Profile</h1>
103115
editProfile.style.display = 'block';
104116
}
105117
}
118+
119+
// Function to toggle between URL and file upload inputs
120+
function toggleAvatarInput() {
121+
var avatarType = document.getElementById('avatar_type').value;
122+
var urlInput = document.getElementById('url_input');
123+
var fileInput = document.getElementById('file_input');
124+
var urlField = document.getElementById('avatar_url');
125+
126+
if (avatarType === 'url') {
127+
urlInput.style.display = 'block';
128+
fileInput.style.display = 'none';
129+
// Clear file input
130+
document.getElementById('avatar_file').value = '';
131+
} else {
132+
urlInput.style.display = 'none';
133+
fileInput.style.display = 'block';
134+
// Clear URL input
135+
urlField.value = '';
136+
}
137+
}
138+
139+
// Add form submission validation
140+
document.querySelector('form[action="{{ url_for("update_profile") }}"]').addEventListener('submit', function(e) {
141+
var avatarType = document.getElementById('avatar_type').value;
142+
var urlField = document.getElementById('avatar_url');
143+
var fileField = document.getElementById('avatar_file');
144+
145+
// Clear the unused field before submission
146+
if (avatarType === 'url') {
147+
fileField.value = '';
148+
} else {
149+
urlField.value = '';
150+
}
151+
});
106152
</script>
107153
{% endblock %}

utils/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import Optional, List, Union
66
from fastapi import HTTPException
77
from sqlmodel import SQLModel, Field, Relationship
8-
from sqlalchemy import Column, Enum as SQLAlchemyEnum
8+
from sqlalchemy import Column, Enum as SQLAlchemyEnum, LargeBinary
99
from sqlalchemy.orm import Mapped
1010

1111
logger = getLogger("uvicorn.error")
@@ -181,6 +181,9 @@ class User(SQLModel, table=True):
181181
name: str
182182
email: str = Field(index=True, unique=True)
183183
avatar_url: Optional[str] = None
184+
avatar_data: Optional[bytes] = Field(
185+
default=None, sa_column=Column(LargeBinary))
186+
avatar_content_type: Optional[str] = None
184187
created_at: datetime = Field(default_factory=utc_time)
185188
updated_at: datetime = Field(default_factory=utc_time)
186189

0 commit comments

Comments
 (0)