Skip to content

Store learner photos in private storage, not public#711

Merged
payamnj merged 2 commits into
masterfrom
feature/private-learner-photos
Jul 10, 2026
Merged

Store learner photos in private storage, not public#711
payamnj merged 2 commits into
masterfrom
feature/private-learner-photos

Conversation

@payamnj

@payamnj payamnj commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #709Learner.photo (including photos imported during Google Workspace group enrollment) was saved via Django's default public media storage, so anyone with the URL could view a learner's photo without authentication.

  • Learner.photo now uses storage=get_private_file_storage (django_email_learning/models/enrollments.py), matching the existing AssignmentSubmission.file_submission pattern — private storage if PRIVATE_FILE_STORAGE_ALIAS/PRIVATE_FILE_STORAGE_LOCATION is configured, otherwise a local private_files/ folder.
  • GoogleGroupEnrollmentHandler._get_user() now saves the downloaded Google Directory profile photo via PRIVATE_FILE_STORAGE instead of default_storage.
  • Added Learner.private_photo_url (mirrors AssignmentSubmission.private_file_url) and a shared build_private_file_url() helper in services/utils.py. LearnerResponse.photo (platform/api/serializers/common.py) now serializes to this signed, access-controlled URL instead of calling .url directly on the (now-private) ImageField.
  • Broadened PrivateFileView's role check (platform/views/misc.py) from admin/instructor to admin/editor/instructor/viewer, matching who can already see the Learners page. This intentionally also affects its other current use — assignment submission files are now viewable by editor/viewer roles too, since PrivateFileView is a shared, generic private-file endpoint, not learner-photo-specific.

Decisions made (see discussion on #709)

  • Scope: Learner.photo only. OrganizationUser.photo has the identical gap but is left for a separate follow-up.
  • Existing photos: left untouched. Only new photos (going forward) are saved to private storage — no data migration moving already-public files, to avoid touching production file storage in a migration.
  • Access control: broadened the existing generic PrivateFileView rather than building a parallel endpoint, since duplicating the signed-URL + role-check logic for a second view seemed worse than accepting the (deliberate, and arguably correct) side effect of loosening submission-file access to match who already sees the Learners page.

Test plan

  • pytest -q — 779 passed, 81.56% coverage
  • mypy — no issues across 164 source files
  • python manage.py makemigrations --check — no drift
  • ruff check / ruff format — clean
  • New/updated tests cover: Learner.photo saving to PRIVATE_FILE_STORAGE, Learner.private_photo_url, LearnerResponse.photo returning a private (not /media/) URL from the learners list API, GoogleGroupEnrollmentHandler._get_user() saving the downloaded photo to private storage, and PrivateFileView's updated role matrix (editor/viewer now allowed; non-org-members still rejected)

Closes #709

🤖 Generated with Claude Code

payamnj and others added 2 commits July 10, 2026 11:32
Learner.photo (including photos imported during Google Workspace group
enrollment) was saved via Django's default public media storage, so
anyone with the URL could view a learner's photo without authentication.

- Learner.photo now uses get_private_file_storage, matching the existing
  AssignmentSubmission.file_submission pattern.
- GoogleGroupEnrollmentHandler._get_user() saves the downloaded profile
  photo via PRIVATE_FILE_STORAGE instead of default_storage.
- Added Learner.private_photo_url and a shared build_private_file_url()
  helper, and switched LearnerResponse.photo to serve that signed,
  access-controlled URL instead of a raw public media URL.
- Broadened PrivateFileView's role check from admin/instructor to
  admin/editor/instructor/viewer, matching who can already see the
  Learners page — this also affects its other current use (assignment
  submission files), intentionally.

Existing photos already sitting in public storage from past imports are
left as-is; only new photos go to private storage going forward.

Closes #709

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Learner.photo now defaults to private storage, but existing photos
saved before this change still physically live in public storage since
they weren't migrated. serialize_photo/private_photo_url previously
always built a private-storage URL regardless, which would 404 via
PrivateFileView for every pre-existing photo.

Add resolve_private_or_public_file_url(): checks PRIVATE_FILE_STORAGE
first (where new photos land), falls back to public storage (where
un-migrated existing photos still are), and returns None only if the
file exists in neither.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@payamnj payamnj force-pushed the feature/private-learner-photos branch from 01a9a5b to 9f8b1dc Compare July 10, 2026 07:34
@payamnj payamnj merged commit ade9da0 into master Jul 10, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Learner photos (e.g. from Google Workspace group enrollment) are saved to public storage

1 participant