Skip to content

Commit 3314004

Browse files
committed
added profile image upload func
1 parent edbba8c commit 3314004

8 files changed

Lines changed: 262 additions & 12 deletions

File tree

config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,7 @@ class Settings(BaseSettings):
1111
algorithm: str = "HS256"
1212
access_token_expire_minutes: int = 30
1313

14+
max_upload_size_bytes: int = 5 * 1024 * 1024
15+
1416

1517
settings = Settings() # Loaded from .env file

image_utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from fileinput import filename
2+
from pickletools import optimize
3+
from termios import IMAXBEL
4+
import uuid
5+
from io import BytesIO
6+
from pathlib import Path
7+
from PIL import Image, ImageOps
8+
9+
PROFILE_PICS_DIR = Path("media/profile_pics")
10+
11+
def process_profile_image(content: bytes) -> str:
12+
with Image.open(BytesIO(content)) as original:
13+
img = ImageOps.exif_transpose(original)
14+
img = ImageOps.fit(img, (300, 300), method=Image.Resampling.LANCZOS)
15+
16+
if img.mode in ("RGBA", "LA", "P"):
17+
img = img.convert("RGB")
18+
19+
filename = f"{uuid.uuid4().hex}.jpg"
20+
filepath = PROFILE_PICS_DIR / filename
21+
PROFILE_PICS_DIR.mkdir(parents=True, exist_ok=True)
22+
img.save(filepath, "JPEG", quality=85, optimize=True)
23+
24+
return filename
25+
26+
27+
def delete_profile_image(filename: str | None) -> None:
28+
if filename is None:
29+
return
30+
31+
filepath = PROFILE_PICS_DIR / filename
32+
if filepath.exists():
33+
filepath.unlink()
19.8 KB
Loading

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
"aiosqlite>=0.22.1",
99
"fastapi[standard]>=0.128.1",
1010
"greenlet>=3.3.1",
11+
"pillow>=12.1.1",
1112
"pwdlib[argon2]>=0.3.0",
1213
"pydantic-settings>=2.12.0",
1314
"pyjwt>=2.11.0",

routers/users.py

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
from collections import UserDict
21
from typing import Annotated
32
from datetime import timedelta
4-
from unittest import result
53

6-
from fastapi import APIRouter, Depends, HTTPException, status
4+
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile
75
from fastapi.security import OAuth2PasswordRequestForm
8-
from sqlalchemy import select, func
6+
from pydantic import HttpUrl
7+
from sqlalchemy import exc, select, func
98
from sqlalchemy.ext.asyncio import AsyncSession
109
from sqlalchemy.orm import selectinload
10+
from PIL import UnidentifiedImageError
11+
from starlette.concurrency import run_in_threadpool
1112

1213
from config import settings
1314
import models
1415
from database import get_db
1516
from schemas import PostResponse, UserCreate, UserPrivate, UserPublic, UserUpdate, Token
16-
1717
from auth import create_access_token, hash_password, verify_password, CurrentUser
18+
from image_utils import delete_profile_image, process_profile_image
1819

1920
router = APIRouter()
2021

@@ -165,8 +166,6 @@ async def update_user(
165166
user.username = user_update.username
166167
if user_update.email is not None:
167168
user.email = user_update.email.lower()
168-
if user_update.image_file is not None:
169-
user.image_file = user_update.image_file
170169

171170
await db.commit()
172171
await db.refresh(user)
@@ -192,5 +191,79 @@ async def delete_user(
192191
detail="User not found",
193192
)
194193

194+
old_filename = user.image_file
195+
195196
await db.delete(user)
196-
await db.commit()
197+
await db.commit()
198+
199+
if old_filename:
200+
delete_profile_image(old_filename)
201+
202+
203+
@router.patch("/{user_id}/picture", response_model=UserPrivate)
204+
async def upload_profile_picture(
205+
user_id: int,
206+
file: UploadFile,
207+
current_user: CurrentUser,
208+
db: Annotated[AsyncSession, Depends(get_db)],
209+
):
210+
if current_user.id != user_id:
211+
raise HTTPException(
212+
status_code=status.HTTP_403_FORBIDDEN,
213+
detail="Not authorized to update this user's picture",
214+
)
215+
216+
content = await file.read()
217+
218+
if len(content) > settings.max_upload_size_bytes:
219+
raise HTTPException(
220+
status_code=status.HTTP_400_BAD_REQUEST,
221+
detail=f"File too large. Maximum size is {settings.max_upload_size_bytes // (1024 * 1024)}MB",
222+
)
223+
224+
try:
225+
new_filename = await run_in_threadpool(process_profile_image, content)
226+
except UnidentifiedImageError as err:
227+
raise HTTPException(
228+
status_code=status.HTTP_400_BAD_REQUEST,
229+
detail="Invalid image file. Please upload a valid image (JPEG, PNG, GIF, WebP).",
230+
) from err
231+
232+
old_filename = current_user.image_file
233+
234+
current_user.image_file = new_filename
235+
await db.commit()
236+
await db.refresh(current_user)
237+
238+
if old_filename:
239+
delete_profile_image(old_filename)
240+
241+
return current_user
242+
243+
@router.delete("/{user_id}/picture", response_model=UserPrivate)
244+
async def delete_user_picture(
245+
user_id: int,
246+
current_user: CurrentUser,
247+
db: Annotated[AsyncSession, Depends(get_db)],
248+
):
249+
if current_user.id != user_id:
250+
raise HTTPException(
251+
status_code=status.HTTP_403_FORBIDDEN,
252+
detail="Not authorized to delete this user's picture",
253+
)
254+
255+
old_filename = current_user.image_file
256+
257+
if old_filename is None:
258+
raise HTTPException(
259+
status_code=status.HTTP_400_BAD_REQUEST,
260+
detail="No profile picture to delete",
261+
)
262+
263+
current_user.image_file = None
264+
await db.commit()
265+
await db.refresh(current_user)
266+
267+
delete_profile_image(old_filename)
268+
269+
return current_user

schemas.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ class UserCreate(UserBase):
1313
class UserUpdate(BaseModel):
1414
username: str | None = Field(default=None, min_length=1, max_length=50)
1515
email: EmailStr | None = Field(default=None, max_length=120)
16-
image_file: str | None = Field(default=None, min_length=1, max_length=200)
1716

1817
class Token(BaseModel):
1918
access_token: str

templates/account.html

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,26 @@ <h5>Update Profile</h5>
3737
</form>
3838
</div>
3939
<hr>
40-
<!-- Profile Picture Section (Placeholder) -->
40+
<!-- Profile Picture Section -->
4141
<div class="mb-4">
4242
<h5>Profile Picture</h5>
43-
<p class="text-body-secondary">File upload coming in a future tutorial.</p>
44-
<input type="file" class="form-control" disabled>
43+
<!-- Preview container -->
44+
<div class="mb-3">
45+
<img id="imagePreview"
46+
class="rounded-circle d-none object-fit-cover"
47+
alt="Image preview"
48+
width="150"
49+
height="150">
50+
</div>
51+
<!-- File input and upload button -->
52+
<div class="d-flex gap-2 align-items-start">
53+
<input type="file"
54+
class="form-control file-input-sm"
55+
id="pictureInput"
56+
accept="image/*">
57+
<button type="button" class="btn btn-primary" id="uploadPictureBtn" disabled>Upload</button>
58+
</div>
59+
<small class="text-body-secondary">Maximum file size: 5MB. Supported formats: JPEG, PNG, GIF, WebP.</small>
4560
</div>
4661
<hr>
4762
<!-- Password Reset Section (Placeholder) -->
@@ -141,6 +156,98 @@ <h5 class="modal-title" id="deleteAccountModalLabel">Delete Account?</h5>
141156
document.getElementById('email').value = user.email;
142157
}
143158

159+
// Image Preview Handler
160+
const pictureInput = document.getElementById('pictureInput');
161+
const imagePreview = document.getElementById('imagePreview');
162+
const uploadBtn = document.getElementById('uploadPictureBtn');
163+
164+
pictureInput.addEventListener('change', (event) => {
165+
const file = event.target.files[0];
166+
if (file) {
167+
const reader = new FileReader();
168+
reader.onload = (e) => {
169+
imagePreview.src = e.target.result;
170+
imagePreview.classList.remove('d-none');
171+
};
172+
reader.readAsDataURL(file);
173+
174+
uploadBtn.disabled = false;
175+
} else {
176+
imagePreview.classList.add('d-none');
177+
uploadBtn.disabled = true;
178+
}
179+
});
180+
181+
// Upload Profile Picture Handler
182+
uploadBtn.addEventListener('click', async () => {
183+
const token = getToken();
184+
if (!token) {
185+
window.location.href = '/login';
186+
return;
187+
}
188+
189+
const file = pictureInput.files[0];
190+
if (!file) {
191+
return;
192+
}
193+
194+
// Use FormData for file uploads (not JSON)
195+
const formData = new FormData();
196+
formData.append('file', file);
197+
198+
uploadBtn.disabled = true;
199+
uploadBtn.textContent = 'Uploading...';
200+
201+
try {
202+
const response = await fetch(`/api/users/${currentUserId}/picture`, {
203+
method: 'PATCH',
204+
headers: {
205+
// Don't set Content-Type — browser sets multipart boundary automatically
206+
'Authorization': `Bearer ${token}`,
207+
},
208+
body: formData,
209+
});
210+
211+
if (response.status === 401) {
212+
window.location.href = '/login';
213+
return;
214+
}
215+
216+
if (response.status === 403) {
217+
document.getElementById('errorMessage').textContent =
218+
'You are not authorized to update this profile picture.';
219+
showModal('errorModal');
220+
return;
221+
}
222+
223+
if (response.ok) {
224+
const data = await response.json();
225+
226+
clearUserCache();
227+
228+
document.getElementById('profileImage').src = data.image_path;
229+
230+
pictureInput.value = '';
231+
imagePreview.classList.add('d-none');
232+
233+
document.getElementById('successMessage').textContent =
234+
'Profile picture updated successfully!';
235+
showModal('successModal');
236+
} else {
237+
const error = await response.json();
238+
document.getElementById('errorMessage').textContent = getErrorMessage(error);
239+
showModal('errorModal');
240+
}
241+
} catch (error) {
242+
document.getElementById('errorMessage').textContent =
243+
'Network error. Please check your connection and try again.';
244+
showModal('errorModal');
245+
} finally {
246+
uploadBtn.disabled = pictureInput.files.length === 0;
247+
uploadBtn.textContent = 'Upload';
248+
}
249+
});
250+
144251
// Update Profile Form Handler
145252
const updateForm = document.getElementById('updateProfileForm');
146253
updateForm.addEventListener('submit', async (event) => {

uv.lock

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)