Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
89 commits
Select commit Hold shift + click to select a range
c7a1c42
Bump pillow from 11.1.0 to 12.2.0 in /app
dependabot[bot] Apr 13, 2026
1d356cf
Heavy WIP, added SiteImage model, site management admin panel, and si…
clpetersonucf May 1, 2026
4dec08a
Rework to collaboration dialog
clpetersonucf May 5, 2026
acfd3cc
Improvements to collab dialog additions based on feedback
clpetersonucf May 8, 2026
71ec2ea
Bump urllib3 from 2.3.0 to 2.7.0 in /app
dependabot[bot] May 12, 2026
dc445a5
Bump black from 24.4.2 to 26.3.1
dependabot[bot] May 12, 2026
31ffc08
Bump virtualenv from 20.26.3 to 20.36.1
dependabot[bot] May 12, 2026
436c16e
Merge branch 'dev/11.1.0' into feature/collaboration-dialog-redesign
clpetersonucf May 12, 2026
0c4f1d6
Merge branch 'dev/11.1.0' into feature/collaboration-dialog-redesign
clpetersonucf May 12, 2026
5890ffa
Simplified non-user placeholder in collab content
clpetersonucf May 12, 2026
21c30df
Bump mdast-util-to-hast from 13.2.0 to 13.2.1
dependabot[bot] May 12, 2026
1c5922f
widget player setHeight sends frameResize postMessage to parent frame
clpetersonucf May 13, 2026
09c7e6a
Merge pull request #1704 from ucfopen/dependabot/pip/app/pillow-12.2.0
FrenjaminBanklin May 13, 2026
478b3e9
Bump webpack-dev-server from 5.2.1 to 5.2.4
dependabot[bot] May 20, 2026
ef669a7
Merge branch 'dev/11.1.0' into issue/1688-kogneato-image-gallery
clpetersonucf May 20, 2026
ad32c80
Bump @tootallnate/once from 2.0.0 to 2.0.1
dependabot[bot] May 22, 2026
ed98d1a
Bump qs from 6.15.1 to 6.15.2
dependabot[bot] May 23, 2026
37fb66c
Merge pull request #1724 from ucfopen/dependabot/pip/app/urllib3-2.7.0
FrenjaminBanklin May 29, 2026
9b09936
Adding additional requirements changes to resolve dependency version …
FrenjaminBanklin May 29, 2026
10ab005
Merge pull request #1725 from ucfopen/dependabot/pip/black-26.3.1
FrenjaminBanklin May 29, 2026
2fddd7b
#1736 Bumping redis container image from redis:3.2 to redis:8.8.0.
FrenjaminBanklin May 29, 2026
f8a90fd
Merge branch 'dev/11.1.0' into dependabot/pip/virtualenv-20.36.1
FrenjaminBanklin May 29, 2026
4401cc4
Functional profile picture selection in settings page
clpetersonucf May 29, 2026
b68ed21
Bumping filelock dependency to resolve version conflict.
FrenjaminBanklin May 29, 2026
6e592c0
Merge branch 'dev/11.1.0' into issue/1688-kogneato-image-gallery
clpetersonucf May 29, 2026
711eacb
Merge pull request #1726 from ucfopen/dependabot/pip/virtualenv-20.36.1
FrenjaminBanklin May 29, 2026
cd35fd2
Reorders migration 0029
clpetersonucf May 29, 2026
2ca4c62
Added data migration to populate default profile images to migration …
clpetersonucf Jun 1, 2026
4c8bcfc
Error handling in site admin panel. Added tests for site API.
clpetersonucf Jun 1, 2026
534fce3
Improves flexibility of site tests to handle default profile set if p…
clpetersonucf Jun 1, 2026
eccc8ab
Merge pull request #1718 from clpetersonucf/feature/collaboration-dia…
clpetersonucf Jun 1, 2026
8c2b47c
Merge pull request #1737 from FrenjaminBanklin/issue/1736-redis-version
clpetersonucf Jun 1, 2026
ce5e598
Console log cleanup. Fixed hover behavior on disabled profile icons.
clpetersonucf Jun 2, 2026
23cc218
Adds preemptive setHeight call upon widget init for LTI contexts
clpetersonucf Jun 4, 2026
2e4ad4c
Adds graceful fallback for invalid play IDs passed as token params
clpetersonucf Jun 4, 2026
d01ed72
Adds get method override to MateriaOIDCLoginInitView class and new er…
clpetersonucf Jun 4, 2026
45cf210
Clarifying comments
clpetersonucf Jun 4, 2026
c8d86e1
Adds fallback exception handler to save_user_settings receiver
clpetersonucf Jun 8, 2026
51a3c8a
Bump shell-quote from 1.8.3 to 1.8.4
dependabot[bot] Jun 10, 2026
5b122fa
Added siteMessage model, API, admin panel, and header additions
clpetersonucf Jun 12, 2026
2a68f53
Fixes default image population for migration 0029
clpetersonucf Jun 12, 2026
7126eed
Removes logging from 0029
clpetersonucf Jun 12, 2026
d06992b
Reverts redis diff in compose file
clpetersonucf Jun 12, 2026
4369700
Site admin restricted to superuser
clpetersonucf Jun 12, 2026
368a21a
Merge branch 'issue/1688-kogneato-image-gallery' into issue/1714-site…
clpetersonucf Jun 12, 2026
404e05a
#1749 Adds optional chaining operators to inclusion checks on widget …
FrenjaminBanklin Jun 12, 2026
4afb20c
Header CSS interaction adjustments
clpetersonucf Jun 15, 2026
6d089cb
Merge pull request #1728 from ucfopen/dependabot/npm_and_yarn/mdast-u…
FrenjaminBanklin Jun 15, 2026
0637462
Merge pull request #1747 from ucfopen/dependabot/npm_and_yarn/shell-q…
FrenjaminBanklin Jun 15, 2026
22f767b
More style fixes
clpetersonucf Jun 15, 2026
47c4c83
Reduced size of default profile images
clpetersonucf Jun 15, 2026
5216253
Reduced max dimension of profile images to 640
clpetersonucf Jun 15, 2026
d9e2584
Merge pull request #1738 from clpetersonucf/issue/1688-kogneato-image…
clpetersonucf Jun 15, 2026
0f52c58
Merge conflict resolution with dev/11.1.0
clpetersonucf Jun 15, 2026
905b00d
Merge pull request #1750 from FrenjaminBanklin/issue/1749-catalog-fil…
clpetersonucf Jun 15, 2026
e71e2de
Merge pull request #1745 from clpetersonucf/issue/1744-user-settings-…
clpetersonucf Jun 15, 2026
430c7d5
Wraps send_email in a try/catch in notification model
clpetersonucf Jun 15, 2026
bd7a671
Merge pull request #1733 from ucfopen/dependabot/npm_and_yarn/qs-6.15.2
FrenjaminBanklin Jun 15, 2026
737d68b
Merge pull request #1732 from ucfopen/dependabot/npm_and_yarn/tootall…
FrenjaminBanklin Jun 15, 2026
5932153
Merge pull request #1731 from ucfopen/dependabot/npm_and_yarn/webpack…
FrenjaminBanklin Jun 15, 2026
f002298
#1752 Wrapped question option key extraction in play data exporter in…
FrenjaminBanklin Jun 16, 2026
0f0e7db
#1740 Undeleting a widget instance now notifies all users with access…
FrenjaminBanklin Jun 17, 2026
340846d
Bump launch-editor from 2.13.2 to 2.14.1
dependabot[bot] Jun 19, 2026
90a2f4a
Bump @babel/core from 7.23.9 to 7.29.6
dependabot[bot] Jun 20, 2026
46b533d
Bump form-data from 4.0.5 to 4.0.6
dependabot[bot] Jun 21, 2026
d565d47
Merge pull request #1758 from ucfopen/dependabot/npm_and_yarn/form-da…
FrenjaminBanklin Jun 26, 2026
6eb1045
Bump http-proxy-middleware from 2.0.9 to 2.0.10
dependabot[bot] Jun 30, 2026
60a9ebf
Bump webpack-dev-server from 5.2.1 to 5.2.5
dependabot[bot] Jun 30, 2026
27e5a8e
Merging changes from target branch to resolve merge conflict.
FrenjaminBanklin Jun 30, 2026
ff2a485
Merge pull request #1756 from ucfopen/dependabot/npm_and_yarn/launch-…
FrenjaminBanklin Jun 30, 2026
e15d9ff
Merge pull request #1757 from ucfopen/dependabot/npm_and_yarn/babel/c…
FrenjaminBanklin Jun 30, 2026
74fb07e
Merged changes from target branch to resolve merge conflicts.
FrenjaminBanklin Jun 30, 2026
ba1fc8b
Merge pull request #1761 from ucfopen/dependabot/npm_and_yarn/webpack…
FrenjaminBanklin Jul 1, 2026
ca9da53
Merging changes from target branch to resolve merge conflicts.
FrenjaminBanklin Jul 1, 2026
e6533d3
Merge pull request #1760 from ucfopen/dependabot/npm_and_yarn/http-pr…
FrenjaminBanklin Jul 1, 2026
ca70c13
Merge pull request #1748 from clpetersonucf/issue/1714-site-messaging
clpetersonucf Jul 6, 2026
00fdf3d
Merge pull request #1753 from clpetersonucf/issue/1735-send-emails-fa…
clpetersonucf Jul 6, 2026
1426bfb
Merge pull request #1742 from clpetersonucf/issue/1734-fix-for-invali…
clpetersonucf Jul 6, 2026
0a07cca
#1759 Forwards dark mode styling to the HTML body tag inside guide pa…
FrenjaminBanklin Jul 6, 2026
56e7f45
Merge pull request #1754 from FrenjaminBanklin/issue/1752-question-op…
clpetersonucf Jul 8, 2026
e0e4aa0
Merge pull request #1764 from FrenjaminBanklin/issue/1759-dark-mode-g…
clpetersonucf Jul 8, 2026
3018488
Merge pull request #1729 from clpetersonucf/issue/1676-player-resizin…
clpetersonucf Jul 8, 2026
2de3ea7
Merge resolution with upstream changes to 11.1.0
clpetersonucf Jul 8, 2026
c43a5d0
Merge pull request #1743 from clpetersonucf/issue/1719-graceful-failu…
clpetersonucf Jul 8, 2026
f388f13
Merge pull request #1755 from FrenjaminBanklin/issue/1740-undelete-no…
clpetersonucf Jul 8, 2026
1f0057d
Bump js-yaml from 3.14.2 to 3.15.0
dependabot[bot] Jul 8, 2026
ed767c9
Merge pull request #1765 from ucfopen/dependabot/npm_and_yarn/js-yaml…
FrenjaminBanklin Jul 15, 2026
db8073b
Bump websocket-driver from 0.7.4 to 0.7.5
dependabot[bot] Jul 15, 2026
629f87e
Merge pull request #1770 from ucfopen/dependabot/npm_and_yarn/websock…
FrenjaminBanklin Jul 16, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ webpack-stats.json

# Installed Widgets
public/widget/**/*
public/site_img/**/*
spec/widgets/*

# Any widget zip packages
Expand Down
92 changes: 91 additions & 1 deletion app/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
Lti,
Notification,
ObjectPermission,
SiteImage,
SiteMessage,
UserExtraAttempts,
UserSettings,
Widget,
Expand Down Expand Up @@ -112,13 +114,17 @@ def validate(self, data):
if not user:
raise serializers.ValidationError("User ID invalid.")

valid_keys = ["useGravatar", "notify", "theme", "beardMode"]
valid_keys = ["useGravatar", "profileImage", "notify", "theme", "beardMode"]

for key, value in data["profile_fields"].items():
if key not in valid_keys:
raise serializers.ValidationError(
f"Invalid profile field provided: {key}"
)
if key == "profileImage" and not isinstance(value, int):
raise serializers.ValidationError(
f"Invalid value for {key}, must be integer."
)
if key == "theme" and value not in ["dark", "light", "os"]:
raise serializers.ValidationError(
f"Invalid value for darkMode: {value}"
Expand Down Expand Up @@ -875,3 +881,87 @@ class PlayStorageSaveSerializer(serializers.Serializer):
queryset=LogPlay.objects.all(), required=True
)
logs = serializers.JSONField()


class SiteImageSerializer(serializers.ModelSerializer):
image = serializers.ImageField(write_only=True, required=True)
image_type = serializers.ChoiceField(
choices=SiteImage.ImageType.choices, required=True
)

class Meta:
model = SiteImage
fields = ["id", "image_type", "image_path", "image"]
read_only_fields = ["id", "image_path"]

def create(self, validated_data):
import os
import uuid

from django.conf import settings
from PIL import Image

image_file = validated_data.pop("image")
image_type = validated_data.get("image_type")

ext = os.path.splitext(image_file.name)[1]
filename = f"{image_type.lower()}_{uuid.uuid4()}{ext}"

site_images_dir = settings.DIRS.get(
"site_images", os.path.join(settings.BASE_DIR, "staticfiles", "site_img")
)
os.makedirs(site_images_dir, exist_ok=True)

file_path = os.path.join(site_images_dir, filename)

# Resize profile images to max 960px on any side
if image_type == SiteImage.ImageType.PROFILE_IMAGE:
img = Image.open(image_file)

# Convert RGBA to RGB if necessary (for JPEG compatibility)
if img.mode in ("RGBA", "LA", "P"):
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(
img, mask=img.split()[-1] if img.mode == "RGBA" else None
)
img = background

max_dimension = 640
width, height = img.size

if width > max_dimension or height > max_dimension:
if width > height:
new_width = max_dimension
new_height = int((max_dimension / width) * height)
else:
new_height = max_dimension
new_width = int((max_dimension / height) * width)

img = img.resize((new_width, new_height), Image.LANCZOS)

img.save(file_path, quality=90, optimize=True)
else:
# Save the file as-is for non-profile images
with open(file_path, "wb+") as destination:
for chunk in image_file.chunks():
destination.write(chunk)

validated_data["image_path"] = f"/site_img/{filename}"

return super().create(validated_data)


class SiteMessageSerializer(serializers.ModelSerializer):

message_text = serializers.CharField(required=True)
message_type = serializers.ChoiceField(
choices=SiteMessage.MessageType.choices, required=True
)

start_at = serializers.DateTimeField(required=False, allow_null=True)
end_at = serializers.DateTimeField(required=False, allow_null=True)

class Meta:
model = SiteMessage
fields = ["id", "message_type", "message_text", "start_at", "end_at"]
read_only_fields = ["id"]
181 changes: 181 additions & 0 deletions app/api/tests/test_site_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import io
import tempfile

from api.tests.base import MateriaTestCase
from core.models import SiteImage
from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from PIL import Image
from rest_framework import status
from rest_framework.test import APIClient


class SiteImageViewSetTestCase(MateriaTestCase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()

cls.regular_user = User.objects.create_user(
username="regular_site",
email="regular_site@example.com",
password="testpass123",
)

cls.superuser = User.objects.create_superuser(
username="admin_site",
email="admin_site@example.com",
password="testpass123",
)

cls.profile_image = SiteImage.objects.create(
image_type=SiteImage.ImageType.PROFILE_IMAGE,
image_path="/site_img/profile_seed.png",
)
cls.catalog_banner = SiteImage.objects.create(
image_type=SiteImage.ImageType.CATALOG_BANNER,
image_path="/site_img/banner_seed.png",
)

def setUp(self):
self.client = APIClient()

@staticmethod
def make_uploaded_image(filename="test.png", size=(20, 20)):
image_stream = io.BytesIO()
image = Image.new("RGB", size, color=(73, 109, 137))
image.save(image_stream, format="PNG")
image_stream.seek(0)

return SimpleUploadedFile(
name=filename,
content=image_stream.read(),
content_type="image/png",
)


class TestSiteImageList(SiteImageViewSetTestCase):
def test_unauthenticated_can_list(self):
response = self.client.get("/api/site-images/")

self.assertEqual(response.status_code, status.HTTP_200_OK)
returned_ids = [item["id"] for item in response.data]
self.assertIn(self.profile_image.id, returned_ids)
self.assertIn(self.catalog_banner.id, returned_ids)

def test_regular_user_can_list(self):
self.client.force_authenticate(user=self.regular_user)
response = self.client.get("/api/site-images/")

self.assertEqual(response.status_code, status.HTTP_200_OK)
returned_ids = [item["id"] for item in response.data]
self.assertIn(self.profile_image.id, returned_ids)
self.assertIn(self.catalog_banner.id, returned_ids)

def test_superuser_can_list(self):
self.client.force_authenticate(user=self.superuser)
response = self.client.get("/api/site-images/")

self.assertEqual(response.status_code, status.HTTP_200_OK)
returned_ids = [item["id"] for item in response.data]
self.assertIn(self.profile_image.id, returned_ids)
self.assertIn(self.catalog_banner.id, returned_ids)

def test_list_can_filter_by_type(self):
response = self.client.get(
"/api/site-images/", {"type": SiteImage.ImageType.PROFILE_IMAGE}
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertGreater(len(response.data), 0)
self.assertTrue(
all(
item["image_type"] == SiteImage.ImageType.PROFILE_IMAGE
for item in response.data
)
)
returned_ids = [item["id"] for item in response.data]
self.assertIn(self.profile_image.id, returned_ids)
self.assertNotIn(self.catalog_banner.id, returned_ids)


class TestSiteImageCreate(SiteImageViewSetTestCase):
def setUp(self):
super().setUp()

self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)

self.original_site_images_dir = settings.DIRS.get("site_images")
settings.DIRS["site_images"] = self.temp_dir.name
self.addCleanup(self._restore_site_images_dir)

def _restore_site_images_dir(self):
if self.original_site_images_dir is None:
settings.DIRS.pop("site_images", None)
else:
settings.DIRS["site_images"] = self.original_site_images_dir

def test_superuser_can_create(self):
self.client.force_authenticate(user=self.superuser)
before_count = SiteImage.objects.count()

payload = {
"image_type": SiteImage.ImageType.PROFILE_IMAGE,
"image": self.make_uploaded_image(),
}
response = self.client.post("/api/site-images/", payload, format="multipart")

self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(SiteImage.objects.count(), before_count + 1)

def test_regular_user_cannot_create(self):
self.client.force_authenticate(user=self.regular_user)
before_count = SiteImage.objects.count()

payload = {
"image_type": SiteImage.ImageType.CATALOG_BANNER,
"image": self.make_uploaded_image(filename="banner.png"),
}
response = self.client.post("/api/site-images/", payload, format="multipart")

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(SiteImage.objects.count(), before_count)

def test_unauthenticated_cannot_create(self):
before_count = SiteImage.objects.count()

payload = {
"image_type": SiteImage.ImageType.CATALOG_BANNER,
"image": self.make_uploaded_image(filename="banner_unauth.png"),
}
response = self.client.post("/api/site-images/", payload, format="multipart")

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(SiteImage.objects.count(), before_count)


class TestSiteImageDestroy(SiteImageViewSetTestCase):
def test_superuser_can_delete(self):
target = SiteImage.objects.create(
image_type=SiteImage.ImageType.CATALOG_BANNER,
image_path="/site_img/delete_me.png",
)
self.client.force_authenticate(user=self.superuser)

response = self.client.delete(f"/api/site-images/{target.id}/")

self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(SiteImage.objects.filter(id=target.id).exists())

def test_non_privileged_user_cannot_delete(self):
target = SiteImage.objects.create(
image_type=SiteImage.ImageType.CATALOG_BANNER,
image_path="/site_img/keep_me.png",
)
self.client.force_authenticate(user=self.regular_user)

response = self.client.delete(f"/api/site-images/{target.id}/")

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertTrue(SiteImage.objects.filter(id=target.id).exists())
3 changes: 3 additions & 0 deletions app/api/urls/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
playstorage,
scores,
sessions,
site,
users,
widget_instances,
widgets,
Expand All @@ -26,6 +27,8 @@
)
router.register(r"notifications", notifications.NotificationsViewSet)
router.register(r"extra-attempts", extra_attempts.UserExtraAttemptsViewSet)
router.register(r"site-images", site.SiteImageViewSet)
router.register(r"site-messages", site.SiteMessageViewSet)

urlpatterns = [
path("", include(router.urls)),
Expand Down
Loading
Loading