Skip to content
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
3 changes: 3 additions & 0 deletions core/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,8 @@ def __call__(self, value):
# 1 MB max file size
max_file_size_validator = MaxFileSizeValidator(max_size=1 * 1024 * 1024)

# 5 MB max file size, for downscalable images
downscale_image_file_size_validator = MaxFileSizeValidator(max_size=5 * 1024 * 1024)

# 50 MB allowed for certain large files - to be used on staff-only fields
large_file_max_size_validator = MaxFileSizeValidator(max_size=50 * 1024 * 1024)
2 changes: 1 addition & 1 deletion templates/v3/includes/_button.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
- icon-library (uses btn-icon-library styles; ignores btn + btn-* classes)
- disabled (optional): if truthy, adds disabled attribute to button
- alpine_disabled (optional): Alpine.js expression for dynamic disabled binding (e.g. "hasErrors")
- alpine_click (optional): Alpine.js expression run on click (e.g. "save()")
- alpine_click (optional): Alpine.js expression for a click handler (e.g. "save()"); button must sit inside an x-data scope
- extra_classes (optional) : Any extra classes that the button should have, as a string
- aria_label (optional): An optional label for assistive technology. Defaults to the button label
- js_disabled (optional, boolean): If this value is true, the button is hidden unless Javascript is disabled on the page
Expand Down
31 changes: 27 additions & 4 deletions templates/v3/user_profile_edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@
</span>
{# Invisible input, toggles the edit box appearing #}
<input type="checkbox" id="user-profile__avatar-row-checkbox" name="avatar-row-checkbox" class="badge-button__radio" />
{# File input, shown in the empty state #}
{% include 'v3/includes/_field_file.html' with name=user_profile_form.avatar.name extra_class='user-profile__empty-avatar-input' accept='image/png,image/jpeg' only %}
{% include 'v3/includes/_field_file.html' with id="field-avatar-modal-new" name=user_profile_form.avatar.name extra_class='user-profile__empty-avatar-input' accept='image/png,image/jpeg' only %}
<div class="user-profile__default-space-col user-profile__default-avatar-buttons">
{# Delete and replace buttons, shown in filled state #}
<label id="user-profile__avatar-replace-button" for="user-profile__avatar-row-checkbox" class="btn btn-secondary">Replace Avatar<span class="btn-icon" aria-hidden>{% include 'includes/icon.html' with icon_name='pixel-pencil' icon_size=16 icon_viewbox='0 0 16 16' only %}</span></label>
Expand Down Expand Up @@ -420,6 +419,12 @@
const SLACK_PROFILE_URL_PREFIX = '{{ SLACK_PROFILE_URL_PREFIX|escapejs }}';
const SLACK_MEMBER_ID_PATTERN = /^[A-Z0-9]{9,11}$/i;

const avatarInput = document.querySelector("input[id='field-avatar-modal']")
const avatarInputNew = document.querySelector("input[id='field-avatar-modal-new']")
const avatarDelete = document.querySelector('#user-profile__avatar-delete-checkbox')
const avatarComponent = document.querySelector('#user-profile-editable-avatar')
const avatarImg = avatarComponent.querySelector('img')

return {
saving: false,
saveStatus: '', // '' | 'saved' | 'error' (button shows "saving" via :disabled)
Expand Down Expand Up @@ -500,6 +505,7 @@

async save() {
if (this.saving) return;
const formData = new FormData()

// Validate all links up front; block the save if any fail.
let hasError = false;
Expand All @@ -509,18 +515,31 @@
if (msg) hasError = true;
});
if (hasError) return;
formData.append('profile_links', JSON.stringify(this.collectLinks()))


let avatar = avatarInput.value
if(avatarDelete.checked) {
avatar = null;
}

if(!avatarDelete.checked) {
if(avatarInput.files.length > 0) formData.append('profile_image', avatarInput.files[0])
else if(avatarInputNew.files.length > 0) formData.append('profile_image', avatarInputNew.files[0])
}
formData.append('delete_profile_image', avatarDelete.checked)

this.saving = true;
this.saveStatus = '';
this.saveError = '';

try {
const res = await fetch('/api/v1/users/me/', {
method: 'PATCH',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json',
},
body: JSON.stringify({ profile_links: this.collectLinks() }),
body: formData,
});
if (!res.ok) {
let data = {};
Expand All @@ -543,6 +562,10 @@
throw new Error(fieldErrors.length ? fieldErrors.join(' ') : `Save failed (${res.status})`);
}
this.saveStatus = 'saved';
if(avatarDelete.checked) {
avatarDelete.checked = false;
avatarImg.src = "";
}
} catch (err) {
console.error('Profile save failed:', err);
this.saveStatus = 'error';
Expand Down
7 changes: 6 additions & 1 deletion users/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from allauth.account.forms import ResetPasswordKeyForm, SignupForm

from .models import Preferences
from core.validators import max_file_size_validator
from news.models import NEWS_MODELS
from news.acl import can_approve

Expand Down Expand Up @@ -131,7 +132,11 @@ class CustomClearableFileInput(forms.ClearableFileInput):


class UserProfilePhotoForm(forms.ModelForm):
profile_image = forms.FileField(widget=CustomClearableFileInput, required=False)
profile_image = forms.FileField(
widget=CustomClearableFileInput,
required=False,
validators=[max_file_size_validator],
)

class Meta:
model = User
Expand Down
29 changes: 29 additions & 0 deletions users/migrations/0023_alter_user_profile_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 6.0.2 on 2026-07-21 20:48

import core.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("users", "0022_user_profile_links"),
]

operations = [
migrations.AlterField(
model_name="user",
name="profile_image",
field=models.FileField(
blank=True,
null=True,
upload_to="profile-images",
validators=[
core.validators.FileTypeValidator(
extensions=[".jpg", ".jpeg", ".png"]
),
core.validators.MaxFileSizeValidator(max_size=5242880),
],
),
),
]
4 changes: 2 additions & 2 deletions users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@

from core.validators import (
image_validator,
max_file_size_validator,
large_file_max_size_validator,
downscale_image_file_size_validator,
)
from core.templatetags.custom_static import large_static

Expand Down Expand Up @@ -209,7 +209,7 @@ class User(BaseUser):
upload_to="profile-images",
null=True,
blank=True,
validators=[image_validator, max_file_size_validator],
validators=[image_validator, downscale_image_file_size_validator],
)
image_thumbnail = ImageSpecField(
source="profile_image",
Expand Down
67 changes: 66 additions & 1 deletion users/serializers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import re
import os
import uuid
from urllib.parse import urlparse

from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from rest_framework import serializers

from core.validators import downscale_image_file_size_validator
from news.utils import downsize_uploaded_image

from .forms import SLACK_PROFILE_URL_PREFIX, V3ProfileLinkChoices
from .models import User

Expand Down Expand Up @@ -57,6 +64,10 @@ class CurrentUserSerializer(serializers.ModelSerializer):
User serializer for the currently logged in user
"""

profile_image = serializers.ImageField(
validators=[downscale_image_file_size_validator]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Should we add required=False here to be consistent?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually automagically handled by Django rest framework: https://www.django-rest-framework.org/api-guide/fields/#required

)

def validate_profile_links(self, value):
if not isinstance(value, dict):
raise serializers.ValidationError(
Expand Down Expand Up @@ -85,6 +96,61 @@ def validate_profile_links(self, value):
raise serializers.ValidationError(field_errors)
return value

def validate_profile_image(self, value):
file_name = value.name
root, ext = os.path.splitext(file_name)
value.name = str(uuid.uuid4()) + ext
if value.size > settings.DOWNSCALE_IMAGE_THRESHOLD:
return downsize_uploaded_image(value)
return value

def validate(self, data):
user = self.instance
if not user.can_update_image and "profile_image" in data:
raise serializers.ValidationError(
"You do not have permission to update your profile photo."
)

return super().validate(data)

def update(self, instance: User, validated_data):
# Pop the image and apply the file-lifecycle steps ourselves. If we left
# it in validated_data, the parent update() would assign and save it a
# second time, writing a duplicate file to storage and orphaning one.
new_image_data = validated_data.pop("profile_image", None)
has_new_upload = isinstance(new_image_data, UploadedFile)

if new_image_data:
# If the user is uploading a new image file, we need to do a few special steps
# 1. We need to set the image_uploaded flag correctly, to prevent automatic overwrites.
# 2. We need to delete the old image from file storage, since it is not stored in memory.
# 3. We need to delete their thumbnail to regenerate a new one.
old_image = instance.profile_image
old_image_name = old_image.name if old_image else None

# Save the new image
if not old_image:
# reset image on image delete checked
instance.image_uploaded = False
elif has_new_upload and old_image_name:
# Delete the old file directly from storage (not via FieldFile.delete(),
# which closes file handles and interferes with the pending upload)
old_image.storage.delete(old_image_name)

if has_new_upload:
instance.profile_image = new_image_data
instance.image_uploaded = True

# Applies the remaining fields and persists everything set above
# (image + flags) in a single save.
instance = super().update(instance, validated_data)

# Invalidate the cached thumbnail so ImageKit regenerates it
if has_new_upload:
instance.delete_cached_thumbnail()

return instance

class Meta:
model = User
fields = (
Expand All @@ -99,7 +165,6 @@ class Meta:
read_only_fields = (
"id",
"email", # Users shouldn't change their email this way
"profile_image",
"date_joined",
)

Expand Down
4 changes: 2 additions & 2 deletions users/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,15 @@ def test_user_model_image_file_size(user):
Test that the `image` field rejects files larger than a specific size.
"""
valid_image = SimpleUploadedFile(
"test.jpg", b"a" * (1 * 1024 * 1024 - 1), content_type="image/jpeg"
"test.jpg", b"a" * (5 * 1024 * 1024 - 1), content_type="image/jpeg"
)
user.profile_image = valid_image
# This should not raise any errors
user.full_clean()

# This should fail (just over 1MB)
invalid_image = SimpleUploadedFile(
"too_large.jpg", b"a" * (1 * 1024 * 1024 + 1), content_type="image/jpeg"
"too_large.jpg", b"a" * (5 * 1024 * 1024 + 1), content_type="image/jpeg"
)
user.profile_image = invalid_image
# This should raise a ValidationError for file size
Expand Down
11 changes: 11 additions & 0 deletions users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ class CurrentUserAPIView(generics.RetrieveUpdateAPIView):
serializer_class = CurrentUserSerializer
permission_classes = [IsAuthenticated]

def perform_update(self, serializer: CurrentUserSerializer):
instance: User = serializer.save()
# Only allow for image deletion if the user is allowed to update their image.
if (
self.request.POST.get("delete_profile_image", "").lower() == "true"
and instance.can_update_image
):
instance.profile_image.delete()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After deleting the avatar, I found the image_uploaded field to still be True. CurrentUserSerializer and the old UserProfilePhotoForm handle this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, updated.

instance.image_uploaded = False
instance.save()
Comment on lines +81 to +90

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'm just curious, is there a reason why we're having just the deletion handled in this view, and not in the serializers.py's update() method where we're handling all the other image updates?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is handled here because the serializer only has access to its field data in update. While we could add the field to the serializer to give it access to the delete_profile_image data, this is less clear to me since it is a model serializer and that is not a field on the model.


def get_object(self):
return self.request.user

Expand Down
Loading