Skip to content

Commit f947dc9

Browse files
Remove URL upload of avatar
Only get avatar if user is authorized
1 parent 474ac15 commit f947dc9

File tree

5 files changed

+100
-104
lines changed

5 files changed

+100
-104
lines changed

routers/user.py

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

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

@@ -16,7 +16,6 @@ class UpdateProfile(BaseModel):
1616
"""Request model for updating user profile information"""
1717
name: str
1818
email: EmailStr
19-
avatar_url: Optional[str] = None
2019
avatar_file: Optional[bytes] = None
2120
avatar_content_type: Optional[str] = None
2221

@@ -25,21 +24,18 @@ async def as_form(
2524
cls,
2625
name: str = Form(...),
2726
email: EmailStr = Form(...),
28-
avatar_url: Optional[str] = Form(None),
2927
avatar_file: Optional[UploadFile] = File(None),
3028
):
3129
avatar_data = None
3230
avatar_content_type = None
3331

3432
if avatar_file:
35-
# Read the file content
3633
avatar_data = await avatar_file.read()
3734
avatar_content_type = avatar_file.content_type
3835

3936
return cls(
4037
name=name,
4138
email=email,
42-
avatar_url=avatar_url if not avatar_file else None,
4339
avatar_file=avatar_data,
4440
avatar_content_type=avatar_content_type
4541
)
@@ -62,74 +58,64 @@ async def as_form(
6258
@router.post("/update_profile", response_class=RedirectResponse)
6359
async def update_profile(
6460
user_profile: UpdateProfile = Depends(UpdateProfile.as_form),
65-
current_user: User = Depends(get_authenticated_user),
61+
user: User = Depends(get_authenticated_user),
6662
session: Session = Depends(get_session)
6763
):
6864
# Update user details
69-
current_user.name = user_profile.name
70-
current_user.email = user_profile.email
65+
user.name = user_profile.name
66+
user.email = user_profile.email
7167

7268
# Handle avatar update
7369
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
70+
user.avatar_data = user_profile.avatar_file
71+
user.avatar_content_type = user_profile.avatar_content_type
8172

8273
session.commit()
83-
session.refresh(current_user)
74+
session.refresh(user)
8475
return RedirectResponse(url="/profile", status_code=303)
8576

8677

8778
@router.post("/delete_account", response_class=RedirectResponse)
8879
async def delete_account(
8980
user_delete_account: UserDeleteAccount = Depends(
9081
UserDeleteAccount.as_form),
91-
current_user: User = Depends(get_authenticated_user),
82+
user: User = Depends(get_authenticated_user),
9283
session: Session = Depends(get_session)
9384
):
94-
if not current_user.password:
95-
raise HTTPException(
96-
status_code=500,
97-
detail="User password not found in database; please contact a system administrator"
85+
if not user.password:
86+
raise DataIntegrityError(
87+
resource="User password"
9888
)
9989

10090
if not verify_password(
10191
user_delete_account.confirm_delete_password,
102-
current_user.password.hashed_password
92+
user.password.hashed_password
10393
):
104-
raise HTTPException(
105-
status_code=400,
106-
detail="Password is incorrect"
94+
raise PasswordValidationError(
95+
field="confirm_delete_password",
96+
message="Password is incorrect"
10797
)
10898

10999
# Delete the user
110-
session.delete(current_user)
100+
session.delete(user)
111101
session.commit()
112102

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

116106

117-
@router.get("/avatar/{user_id}")
107+
@router.get("/avatar")
118108
async def get_avatar(
119-
user_id: int,
109+
user: User = Depends(get_authenticated_user),
120110
session: Session = Depends(get_session)
121111
):
122112
"""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
113+
if not user.avatar_data:
114+
raise DataIntegrityError(
115+
resource="User avatar"
131116
)
132-
elif user.avatar_url:
133-
return RedirectResponse(url=user.avatar_url)
134-
else:
135-
raise HTTPException(status_code=404, detail="Avatar not found")
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: 4 additions & 50 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 or user.avatar_data %}
22-
<img src="{{ url_for('get_avatar', user_id=user.id) }}" 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 %}
@@ -45,20 +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 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>
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/*">
6250
</div>
6351
<button type="submit" class="btn btn-primary">Save Changes</button>
6452
</form>
@@ -115,39 +103,5 @@ <h1 class="mb-4">User Profile</h1>
115103
editProfile.style.display = 'block';
116104
}
117105
}
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-
});
152106
</script>
153107
{% endblock %}

tests/test_user.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ def test_update_profile_unauthorized(unauth_client: TestClient):
1313
data={
1414
"name": "New Name",
1515
"email": "[email protected]",
16-
"avatar_url": "https://example.com/avatar.jpg"
16+
},
17+
files={
18+
"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")
1719
},
1820
follow_redirects=False
1921
)
@@ -23,14 +25,40 @@ def test_update_profile_unauthorized(unauth_client: TestClient):
2325

2426
def test_update_profile_authorized(auth_client: TestClient, test_user: User, session: Session):
2527
"""Test that authorized users can edit their profile"""
26-
28+
29+
# Create test image data
30+
test_image_data = b"fake image data"
31+
2732
# Update profile
2833
response: Response = auth_client.post(
2934
app.url_path_for("update_profile"),
3035
data={
3136
"name": "Updated Name",
3237
"email": "[email protected]",
33-
"avatar_url": "https://example.com/new-avatar.jpg"
38+
},
39+
files={
40+
"avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg")
41+
},
42+
follow_redirects=False
43+
)
44+
assert response.status_code == 303
45+
assert response.headers["location"] == app.url_path_for("read_profile")
46+
47+
# Verify changes in database
48+
session.refresh(test_user)
49+
assert test_user.name == "Updated Name"
50+
assert test_user.email == "[email protected]"
51+
assert test_user.avatar_data == test_image_data
52+
assert test_user.avatar_content_type == "image/jpeg"
53+
54+
55+
def test_update_profile_without_avatar(auth_client: TestClient, test_user: User, session: Session):
56+
"""Test that profile can be updated without changing the avatar"""
57+
response: Response = auth_client.post(
58+
app.url_path_for("update_profile"),
59+
data={
60+
"name": "Updated Name",
61+
"email": "[email protected]",
3462
},
3563
follow_redirects=False
3664
)
@@ -41,7 +69,6 @@ def test_update_profile_authorized(auth_client: TestClient, test_user: User, ses
4169
session.refresh(test_user)
4270
assert test_user.name == "Updated Name"
4371
assert test_user.email == "[email protected]"
44-
assert test_user.avatar_url == "https://example.com/new-avatar.jpg"
4572

4673

4774
def test_delete_account_unauthorized(unauth_client: TestClient):
@@ -62,7 +89,7 @@ def test_delete_account_wrong_password(auth_client: TestClient, test_user: User)
6289
data={"confirm_delete_password": "WrongPassword123!"},
6390
follow_redirects=False
6491
)
65-
assert response.status_code == 400
92+
assert response.status_code == 422
6693
assert "Password is incorrect" in response.text.strip()
6794

6895

@@ -81,3 +108,37 @@ def test_delete_account_success(auth_client: TestClient, test_user: User, sessio
81108
# Verify user is deleted from database
82109
user = session.get(User, test_user.id)
83110
assert user is None
111+
112+
113+
def test_get_avatar_authorized(auth_client: TestClient, test_user: User):
114+
"""Test getting user avatar"""
115+
# First upload an avatar
116+
test_image_data = b"fake image data"
117+
auth_client.post(
118+
app.url_path_for("update_profile"),
119+
data={
120+
"name": test_user.name,
121+
"email": test_user.email,
122+
},
123+
files={
124+
"avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg")
125+
},
126+
)
127+
128+
# Then try to retrieve it
129+
response = auth_client.get(
130+
app.url_path_for("get_avatar")
131+
)
132+
assert response.status_code == 200
133+
assert response.content == test_image_data
134+
assert response.headers["content-type"] == "image/jpeg"
135+
136+
137+
def test_get_avatar_unauthorized(unauth_client: TestClient):
138+
"""Test getting avatar for non-existent user"""
139+
response = unauth_client.get(
140+
app.url_path_for("get_avatar"),
141+
follow_redirects=False
142+
)
143+
assert response.status_code == 303
144+
assert response.headers["location"] == app.url_path_for("read_login")

utils/models.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ class User(SQLModel, table=True):
180180
id: Optional[int] = Field(default=None, primary_key=True)
181181
name: str
182182
email: str = Field(index=True, unique=True)
183-
avatar_url: Optional[str] = None
184183
avatar_data: Optional[bytes] = Field(
185184
default=None, sa_column=Column(LargeBinary))
186185
avatar_content_type: Optional[str] = None

0 commit comments

Comments
 (0)