diff --git a/.gitignore b/.gitignore index 9efcf43f4..9ad03aedd 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ webpack-stats.json # Installed Widgets public/widget/**/* +public/site_img/**/* spec/widgets/* # Any widget zip packages diff --git a/app/api/serializers.py b/app/api/serializers.py index f4936e6dd..510f23272 100644 --- a/app/api/serializers.py +++ b/app/api/serializers.py @@ -15,6 +15,8 @@ Lti, Notification, ObjectPermission, + SiteImage, + SiteMessage, UserExtraAttempts, UserSettings, Widget, @@ -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}" @@ -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"] diff --git a/app/api/tests/test_site_views.py b/app/api/tests/test_site_views.py new file mode 100644 index 000000000..a3fab5689 --- /dev/null +++ b/app/api/tests/test_site_views.py @@ -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()) diff --git a/app/api/urls/api_urls.py b/app/api/urls/api_urls.py index aadd51bfb..5a5a57a81 100644 --- a/app/api/urls/api_urls.py +++ b/app/api/urls/api_urls.py @@ -7,6 +7,7 @@ playstorage, scores, sessions, + site, users, widget_instances, widgets, @@ -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)), diff --git a/app/api/views/site.py b/app/api/views/site.py new file mode 100644 index 000000000..b647696b1 --- /dev/null +++ b/app/api/views/site.py @@ -0,0 +1,77 @@ +from api.permissions import IsSuperuser +from api.serializers import SiteImageSerializer, SiteMessageSerializer +from core.models import SiteImage, SiteMessage +from django.db.models import Q +from django.utils import timezone +from rest_framework import status, viewsets +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + + +class SiteImageViewSet(viewsets.ModelViewSet): + + queryset = SiteImage.objects.all() + serializer_class = SiteImageSerializer + http_method_names = ["get", "post", "delete"] + + def get_permissions(self): + if self.action in ["list", "retrieve"]: + permission_classes = [AllowAny] + else: + permission_classes = [IsSuperuser] + return [permission() for permission in permission_classes] + + def get_queryset(self): + queryset = SiteImage.objects.all() + image_type = self.request.query_params.get("type", None) + + if image_type: + queryset = queryset.filter(image_type=image_type) + + return queryset + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + +class SiteMessageViewSet(viewsets.ModelViewSet): + + queryset = SiteMessage.objects.all() + serializer_class = SiteMessageSerializer + http_method_names = ["get", "post", "delete"] + + def get_permissions(self): + if self.action in ["list", "retrieve"]: + permission_classes = [AllowAny] + else: + permission_classes = [IsSuperuser] + return [permission() for permission in permission_classes] + + def get_queryset(self): + queryset = SiteMessage.objects.all() + msg_type = self.request.query_params.get("type", None) + msg_types = self.request.query_params.get("types", None) + include_expired = self.request.query_params.get("include_expired", "false") + + if msg_types: + queryset = queryset.filter(message_type__in=msg_types.split(",")) + + if msg_type: + queryset = queryset.filter(message_type=msg_type) + + include_expired = str(include_expired).lower() in ["1", "true"] + if not include_expired: + now = timezone.now() + queryset = queryset.filter( + (Q(start_at__isnull=True) | Q(start_at__lte=now)) + & (Q(end_at__isnull=True) | Q(end_at__gte=now)) + ) + + return queryset diff --git a/app/api/views/users.py b/app/api/views/users.py index b07c86f27..f32e1764f 100644 --- a/app/api/views/users.py +++ b/app/api/views/users.py @@ -152,7 +152,7 @@ def me(self, request): serializer = self.get_serializer(request.user) return Response(serializer.data) - @action(detail=True, methods=["put"]) + @action(detail=True, methods=["patch"]) def profile_fields(self, request, pk=None): user = self.get_object() serializer = UserMetadataSerializer(data=request.data) @@ -165,11 +165,6 @@ def profile_fields(self, request, pk=None): for key, value in validated.items(): profile_fields[key] = value - # if key == "darkMode": - # cache_key = f'user_dark_mode_{request.user.id}' - # logger.error(f"located darkMode key for user {request.user.id} and deleting cache !!!") - # cache.delete(cache_key) - user_profile.profile_fields = profile_fields user_profile.save() diff --git a/app/api/views/widget_instances.py b/app/api/views/widget_instances.py index 5d3b41193..58f713f63 100644 --- a/app/api/views/widget_instances.py +++ b/app/api/views/widget_instances.py @@ -635,4 +635,12 @@ def undelete(self, request, pk=None): instance.is_deleted = False instance.save() + for shared_user_perm in instance.permissions.all(): + Notification.create_instance_notification( + from_user=self.request.user, + to_user=shared_user_perm.user, + instance=instance, + mode="restored", + ) + return Response({"success": True}) diff --git a/app/core/migrations/0029_siteimage.py b/app/core/migrations/0029_siteimage.py new file mode 100644 index 000000000..65e64fd6d --- /dev/null +++ b/app/core/migrations/0029_siteimage.py @@ -0,0 +1,125 @@ +# Generated by Django 5.0.1 on 2026-04-30 18:28 + +import os +import shutil + +from django.conf import settings +from django.db import migrations, models + + +def populate_default_profile_images(apps, schema_editor): + """ + Adds default profile images to the SiteImage ORM + """ + SiteImage = apps.get_model("core", "SiteImage") + + base_dir = settings.APP_PATH + source_dir = os.path.join( + base_dir, "staticfiles", "img", "default", "profile_images" + ) + dest_dir = os.path.join(base_dir, "staticfiles", "site_img") + + os.makedirs(dest_dir, exist_ok=True) + + default_images = [ + "aura_kogneato.png", + "dm_kogneato.png", + "gambit_kogneato.png", + "in_kogneato.png", + "kogneato_the_riveter.png", + "metal_kogneato.png", + "morpheus_kogneato.png", + "shakespeare_kogneato.png", + ] + + images_to_create = [] + + for filename in default_images: + source_path = os.path.join(source_dir, filename) + dest_path = os.path.join(dest_dir, filename) + image_path = f"/site_img/{filename}" + + if SiteImage.objects.filter(image_path=image_path).exists(): + continue + + if not os.path.exists(dest_path): + if os.path.exists(source_path): + shutil.copy2(source_path, dest_path) + else: + continue + + images_to_create.append( + SiteImage(image_type="PROFILE_IMAGE", image_path=image_path) + ) + + if images_to_create: + SiteImage.objects.bulk_create(images_to_create) + + +def remove_default_profile_images(apps, schema_editor): + """ + Removes default images from the staticfiles/site_img directory. + """ + base_dir = settings.APP_PATH + dest_dir = os.path.join(base_dir, "staticfiles", "site_img") + + default_images = [ + "aura_kogneato.png", + "dm_kogneato.png", + "gambit_kogneato.png", + "in_kogneato.png", + "kogneato_the_riveter.png", + "metal_kogneato.png", + "morpheus_kogneato.png", + "shakespeare_kogneato.png", + ] + + for filename in default_images: + dest_path = os.path.join(dest_dir, filename) + if os.path.exists(dest_path): + try: + os.remove(dest_path) + except FileNotFoundError: + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0028_remove_logplay_log_play_is_complete_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SiteImage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "image_type", + models.CharField( + blank=True, + choices=[ + ("NO_TYPE", "No Type"), + ("PROFILE_IMAGE", "Profile Image"), + ("CATALOG_BANNER", "Catalog Banner"), + ], + default="NO_TYPE", + max_length=26, + null=True, + ), + ), + ("image_path", models.CharField(max_length=255)), + ], + ), + migrations.RunPython( + populate_default_profile_images, remove_default_profile_images + ), + ] diff --git a/app/core/migrations/0030_sitemessage.py b/app/core/migrations/0030_sitemessage.py new file mode 100644 index 000000000..0e518ed81 --- /dev/null +++ b/app/core/migrations/0030_sitemessage.py @@ -0,0 +1,46 @@ +# Generated by Django 5.0.1 on 2026-06-09 15:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0029_siteimage"), + ] + + operations = [ + migrations.CreateModel( + name="SiteMessage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "message_type", + models.CharField( + blank=True, + choices=[ + ("NO_TYPE", "No Type"), + ("SITE_NOTIFICATION", "Site Notification"), + ("SITE_ALERT", "Site Alert"), + ("CATALOG_HEADER", "Catalog Header"), + ("CATALOG_TEXT", "Catalog Text"), + ], + default="NO_TYPE", + max_length=26, + null=True, + ), + ), + ("message_text", models.TextField()), + ("start_at", models.DateTimeField(default=None, null=True)), + ("end_at", models.DateTimeField(default=None, null=True)), + ], + ), + ] diff --git a/app/core/mixins.py b/app/core/mixins.py index 96737b44c..1af293522 100644 --- a/app/core/mixins.py +++ b/app/core/mixins.py @@ -5,6 +5,7 @@ from core.utils.context_util import ContextUtil from django.contrib.auth.mixins import AccessMixin from django.http import Http404, HttpRequest, HttpResponse +from pylti1p3.exception import LtiException logger = logging.getLogger(__name__) @@ -118,7 +119,14 @@ def dispatch(self, request, *args, **kwargs): if not instance: raise Http404("A widget instance with this ID does not exist.") is_embedded = kwargs.get("is_embed", False) - validation = self.get_validation(request, instance) + try: + validation = self.get_validation(request, instance) + except LtiException: + from core.services.widget_play_services import ( + WidgetPlayValidationService, + ) + + validation = WidgetPlayValidationService.INVALID_RECOVERY_TOKEN request._widget_play_state = { "instance": instance, diff --git a/app/core/models.py b/app/core/models.py index 79f752d6b..5dcdd4244 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -707,7 +707,12 @@ def save(self, *args, **kwargs): ) # Send email, if not sent already - self.send_email() + try: + self.send_email() + except Exception: + logger.error( + "Failed to send email for user %s:", self.to_id.id, exc_info=True + ) @classmethod def create_instance_notification( @@ -762,6 +767,8 @@ def create_instance_notification( f"The widget is currently being used within a course in your LMS." ) action = "access_request" + case "restored": + content = f"{user_link} restored {widget_type} widget '{widget_name}'." case _: return None @@ -888,6 +895,48 @@ class Meta: db_table = "question" +class SiteImage(models.Model): + + class ImageType(models.TextChoices): + NO_TYPE = "NO_TYPE", gettext_lazy("No Type") + PROFILE_IMAGE = "PROFILE_IMAGE", gettext_lazy("Profile Image") + # LIBRARY_BANNER = "LIBRARY_BANNER", gettext_lazy("Library Banner") + CATALOG_BANNER = "CATALOG_BANNER", gettext_lazy("Catalog Banner") + + image_type = models.CharField( + max_length=26, + blank=True, + null=True, + choices=ImageType.choices, + default=ImageType.NO_TYPE, + ) + + image_path = models.CharField(max_length=255) + + +class SiteMessage(models.Model): + + class MessageType(models.TextChoices): + NO_TYPE = "NO_TYPE", gettext_lazy("No Type") + SITE_NOTIFICATION = "SITE_NOTIFICATION", gettext_lazy("Site Notification") + SITE_ALERT = "SITE_ALERT", gettext_lazy("Site Alert") + CATALOG_HEADER = "CATALOG_HEADER", gettext_lazy("Catalog Header") + CATALOG_TEXT = "CATALOG_TEXT", gettext_lazy("Catalog Text") + + message_type = models.CharField( + max_length=26, + blank=True, + null=True, + choices=MessageType.choices, + default=MessageType.NO_TYPE, + ) + + message_text = models.TextField() + + start_at = models.DateTimeField(default=None, null=True) + end_at = models.DateTimeField(default=None, null=True) + + class UserExtraAttempts(models.Model): @staticmethod def get_cur_semester(): @@ -1537,10 +1586,39 @@ def get_profile_fields(self): self.profile_fields = updated_fields self.save() + profile_images = SiteImage.objects.filter( + image_type=SiteImage.ImageType.PROFILE_IMAGE + ) + + if "profileImage" not in self.profile_fields or self.profile_fields[ + "profileImage" + ] not in profile_images.values_list("id", flat=True): + + random_profile_image = profile_images.order_by("?").first() + self.profile_fields["profileImage"] = ( + random_profile_image.id if random_profile_image else -1 + ) + self.save() + return self.profile_fields def initialize_profile_fields(self): - self.profile_fields = {**self.DEFAULT_PROFILE_FIELDS} + + random_profile_image_id = ( + SiteImage.objects.filter(image_type=SiteImage.ImageType.PROFILE_IMAGE) + .order_by("?") + .values_list("id", flat=True) + .first() + ) + + if random_profile_image_id is None: + random_profile_image_id = -1 + + self.profile_fields = { + **self.DEFAULT_PROFILE_FIELDS, + "profileImage": random_profile_image_id, + } + self.save() @@ -1553,4 +1631,8 @@ def create_user_settings(sender, instance, created, **kwargs): @receiver(post_save, sender=User) def save_user_settings(sender, instance, **kwargs): - instance.profile_settings.save() + try: + instance.profile_settings.save() + except UserSettings.DoesNotExist: + settings = UserSettings.objects.create(user=instance) + settings.initialize_profile_fields() diff --git a/app/core/services/play_data_exporter_service.py b/app/core/services/play_data_exporter_service.py index 1bbbedcfd..a216028c5 100644 --- a/app/core/services/play_data_exporter_service.py +++ b/app/core/services/play_data_exporter_service.py @@ -281,10 +281,16 @@ def _export_full_event_log( csv_questions.append(csv_question) # Grab out the keys of options, add them if not already in the list - for key in question_row_data["options"].keys(): - if key in csv_options: - continue - csv_options.append(key) + try: + for key in question_row_data["options"].keys(): + if key in csv_options: + continue + csv_options.append(key) + except AttributeError: + # If a widget engine sets a question's 'options' to a list, it'll + # be difficult to predictably traverse for keys like we do with + # objects above - just ignore them for now, revisit if need be + pass # Grab out each individual answer for answer in question_row_data["answers"]: diff --git a/app/core/services/user_service.py b/app/core/services/user_service.py index b515c0442..d5b064789 100644 --- a/app/core/services/user_service.py +++ b/app/core/services/user_service.py @@ -11,7 +11,14 @@ def get_avatar_url(user: User) -> str: profile_settings = user.profile_settings use_gravatar = profile_settings.get_profile_fields().get("useGravatar", False) if not use_gravatar: - return f"{settings.STATIC_URL}img/default-avatar.jpg" + profile_id = profile_settings.get_profile_fields().get("profileImage") + if not profile_id or profile_id == -1: + return f"{settings.STATIC_URL}img/default-avatar.jpg" + + from core.models import SiteImage + + avatar = SiteImage.objects.filter(id=profile_id).first() + return avatar.image_path clean_email = user.email.strip().lower().encode("utf-8") hash_email = hashlib.md5(clean_email).hexdigest() diff --git a/app/core/services/widget_play_services.py b/app/core/services/widget_play_services.py index 524832fbf..4f23db41a 100644 --- a/app/core/services/widget_play_services.py +++ b/app/core/services/widget_play_services.py @@ -82,6 +82,7 @@ class WidgetPlayValidationService: INVALID_DRAFT_NOT_PLAYABLE = "draft_not_playable" INVALID_RETIRED_WIDGET = "widget_retired" INVALID_NO_ATTEMPTS = "no_attempts" + INVALID_RECOVERY_TOKEN = "bad_recovery_token" VALID_WITH_PRE_EMBED = "pre_embed" VALID = "valid" diff --git a/app/core/views/admin.py b/app/core/views/admin.py index f0da39d38..4a3d999af 100644 --- a/app/core/views/admin.py +++ b/app/core/views/admin.py @@ -1,10 +1,10 @@ import os +from core.utils.context_util import ContextUtil +from core.utils.validator_util import ValidatorUtil from django.conf import settings from django.contrib.auth.decorators import login_required, user_passes_test from django.shortcuts import render -from core.utils.context_util import ContextUtil -from core.utils.validator_util import ValidatorUtil @login_required @@ -51,6 +51,19 @@ def user(request): return render(request, "react.html", context) +@login_required +@user_passes_test(lambda u: u.is_superuser) +def site(request): + context = ContextUtil.create( + title="Site Admin", + js_resources=settings.JS_GROUPS["site_admin"], + css_resources=settings.CSS_GROUPS["site_admin"], + request=request, + ) + + return render(request, "react.html", context) + + # class AdminViews(TemplateView): # def widget(request): # context = {"title": "Welcome to Materia", "bundle_name": "catalog"} diff --git a/app/core/views/widget.py b/app/core/views/widget.py index 95fd8fc97..ed4634434 100644 --- a/app/core/views/widget.py +++ b/app/core/views/widget.py @@ -129,8 +129,11 @@ def get_validation(self, request, instance): ) elif LTILaunchService.is_recovery_launch(request): - play = LogPlay.objects.get(pk=request.GET.get("token")) - context_id = play.context_id + play = LogPlay.objects.filter(pk=request.GET.get("token")).first() + if play: + context_id = play.context_id + else: + raise LtiException("Invalid token for context id recovery") # Check if this instance is a guest/demo instance has_guest_access = instance.guest_access @@ -475,6 +478,9 @@ def _create_player_context( is_preview: bool = False, is_embedded: bool = False, ): + if validation == WidgetPlayValidationService.INVALID_RECOVERY_TOKEN: + return _create_lti_error_page(request, "error_recovery_token") + # Check if embed only widget if validation == WidgetPlayValidationService.INVALID_EMBEDDED_ONLY: return _create_embedded_only_page(request, instance) @@ -696,6 +702,20 @@ def _create_lti_success_page( ) +def _create_lti_error_page(request: HttpRequest, error_type: str): + return ContextUtil.create( + title="Widget Embed Error", + page_type="lti-error", + js_globals={ + "TITLE": "There was a problem with this embedded content.", + "ERROR_TYPE": error_type, + }, + js_resources=settings.JS_GROUPS["lti-error"], + css_resources=settings.CSS_GROUPS["lti"], + request=request, + ) + + # Utils functions def _generate_widget_login_messages(user: User, instance: WidgetInstance) -> dict: instance_availability = instance.availability_status() diff --git a/app/lti/services/launch.py b/app/lti/services/launch.py index 467e1b147..f43928bbe 100644 --- a/app/lti/services/launch.py +++ b/app/lti/services/launch.py @@ -278,7 +278,7 @@ def get_launch_from_play(play_id: str) -> LtiPlayState: """ Returns the associated LtiPlayState model instance for a given play ID. """ - play = LogPlay.objects.get(pk=play_id) + play = LogPlay.objects.filter(pk=play_id).first() if play: launch = LtiPlayState.objects.filter(play_id=play.id).first() return launch diff --git a/app/lti/views/init.py b/app/lti/views/init.py index 42fe227de..907ce53d4 100644 --- a/app/lti/views/init.py +++ b/app/lti/views/init.py @@ -2,12 +2,26 @@ from django.conf import settings from lti_tool.views import OIDCLoginInitView +from pylti1p3.exception import OIDCException logger = logging.getLogger(__name__) class MateriaOIDCLoginInitView(OIDCLoginInitView): + def get(self, request, *args, **kwargs): + """ + Overrides OIDCLoginInitView's `get` method to intercept and handle OIDCExceptions. + The intended behavior is to handle situations where a LTI registration has been disabled. + """ + registration_uuid = kwargs.get("registration_uuid") + try: + return self.get_oidc_response(request, registration_uuid, request.GET) + except OIDCException: + from lti.views.lti import error_page as lti_error_page + + return lti_error_page(request, "error_registration_disabled") + def get_redirect_url(self, target_link_uri: str) -> str: """ Overrides OIDCLoginInitView's `get_redirect_url` method, as we only have one whitelisted launch URI: /ltilaunch/ diff --git a/app/materia/settings/base.py b/app/materia/settings/base.py index 899d9e198..f59447e81 100644 --- a/app/materia/settings/base.py +++ b/app/materia/settings/base.py @@ -35,6 +35,7 @@ "media_uploads": os.path.realpath( os.path.join(APP_PATH, "media", "uploads") ), # + os.sep, + "site_images": os.path.realpath(os.path.join(APP_PATH, "staticfiles", "site_img")), "widgets": os.path.realpath( os.path.join(APP_PATH, "staticfiles", "widget") ), # + os.sep diff --git a/app/materia/settings/css.py b/app/materia/settings/css.py index 28890c76a..5868326a9 100644 --- a/app/materia/settings/css.py +++ b/app/materia/settings/css.py @@ -19,6 +19,7 @@ "media": [CSS_BASEURL + "media.css"], "support": [CSS_BASEURL + "support.css"], "user_admin": [CSS_BASEURL + "user-admin.css"], + "site_admin": [CSS_BASEURL + "site-admin.css"], "no-permission": [CSS_BASEURL + "no-permission.css"], "pre-embed": [CSS_BASEURL + "pre-embed-placeholder.css"], "lti": [ diff --git a/app/materia/settings/js.py b/app/materia/settings/js.py index c27678de5..06e949883 100644 --- a/app/materia/settings/js.py +++ b/app/materia/settings/js.py @@ -22,6 +22,7 @@ "media": [JS_BASEURL + "media.js"], "widget_admin": [JS_BASEURL + "widget-admin.js"], "user_admin": [JS_BASEURL + "user-admin.js"], + "site_admin": [JS_BASEURL + "site-admin.js"], "instance_admin": [JS_BASEURL + "support.js"], "no-permission": [JS_BASEURL + "no-permission.js"], "no-attempts": [JS_BASEURL + "no-attempts.js"], diff --git a/app/materia/urls.py b/app/materia/urls.py index 6fa480a5e..8f6a24970 100644 --- a/app/materia/urls.py +++ b/app/materia/urls.py @@ -20,6 +20,7 @@ from core.views import main as core_views from core.views import profile as profile_views from core.views.admin import instance as instance_admin +from core.views.admin import site as site_admin from core.views.admin import user as user_admin from core.views.admin import widget as widget_admin from core.views.catalog import CatalogView @@ -156,6 +157,7 @@ path("users/login", login_views.login, name="login"), path("login/", login_views.login, name="login"), path("admin/widget", widget_admin, name="widget admin"), + path("admin/site", site_admin, name="site admin"), path("admin/instance", instance_admin, name="instance admin"), path("admin/user", user_admin, name="user admin"), path("admin/", admin.site.urls), diff --git a/app/requirements.txt b/app/requirements.txt index d4ca72825..8ac68ba06 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -13,7 +13,7 @@ jmespath==1.0.1 mimetypes-magic==0.4.30 mysqlclient==2.2.6 openai==1.65.2 -pillow==11.1.0 +pillow==12.2.0 python-dateutil==2.9.0 pytz==2024.1 PyYAML==6.0.1 @@ -22,7 +22,7 @@ sentry-sdk[django]~=2.50.0 setuptools==69.0.3 six==1.17.0 sqlparse==0.4.4 -urllib3==2.3.0 +urllib3==2.7.0 wheel==0.42.0 whitenoise==6.9.0 django-sendgrid-v5==1.3.0 diff --git a/docker/config/nginx/sites-enabled/site.dev.conf b/docker/config/nginx/sites-enabled/site.dev.conf index b208b9a2a..1be195103 100644 --- a/docker/config/nginx/sites-enabled/site.dev.conf +++ b/docker/config/nginx/sites-enabled/site.dev.conf @@ -69,4 +69,8 @@ server { location /widget/ { try_files $uri =404; } + + location /site_img/ { + try_files $uri =404; + } } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 23bc49288..ae4dd29a9 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,6 +15,7 @@ services: - ../public:/var/www/html/staticfiles:ro - ../theme/img:/var/www/html/staticfiles/theme/img:ro - ../public/widget:/var/www/html/staticfiles/widget/:ro + - ../public/site_img:/var/www/html/staticfiles/site_img/:ro - ./config/nginx/sites-enabled/site.dev.conf:/etc/nginx/conf.d/site.conf:ro networks: - frontend @@ -47,6 +48,8 @@ services: - ../app:/var/www/html - uploaded_media:/var/www/html/media/ - ../public/widget:/var/www/html/staticfiles/widget/:rw + - ../public/img/default/profile_images:/var/www/html/staticfiles/img/default/profile_images/:rw + - ../public/site_img:/var/www/html/staticfiles/site_img/:rw command: /wait_for_it.sh mysql:3306 -t 15 -- gunicorn materia.wsgi:application --bind 0.0.0.0:8001 --reload --workers 4 --threads 2 --timeout 150 --log-level info --access-logfile - --error-logfile - mysql: @@ -78,7 +81,7 @@ services: - backend redis: - image: redis:3.2 + image: redis:8.8.0 ports: - "6379:6379" networks: diff --git a/package.json b/package.json index ea0b5f76b..e53055fa7 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "uuid": "^14.0.0" }, "devDependencies": { - "@babel/core": "^7.10.4", + "@babel/core": "^7.29.6", "@babel/preset-env": "^7.10.4", "@babel/preset-react": "^7.10.4", "@cfaester/enzyme-adapter-react-18": "^0.8.0", @@ -66,7 +66,7 @@ "webpack": "^5.104.1", "webpack-bundle-tracker": "^0.4.3", "webpack-cli": "^5.0.1", - "webpack-dev-server": "^5.2.1", + "webpack-dev-server": "^5.2.5", "webpack-manifest-plugin": "^5.0.0", "webpack-remove-empty-scripts": "1.0.1", "webpack-strip-block": "^0.3.0" diff --git a/public/img/default/profile_images/aura_kogneato.png b/public/img/default/profile_images/aura_kogneato.png new file mode 100644 index 000000000..3cb68bb75 Binary files /dev/null and b/public/img/default/profile_images/aura_kogneato.png differ diff --git a/public/img/default/profile_images/dm_kogneato.png b/public/img/default/profile_images/dm_kogneato.png new file mode 100644 index 000000000..805599307 Binary files /dev/null and b/public/img/default/profile_images/dm_kogneato.png differ diff --git a/public/img/default/profile_images/gambit_kogneato.png b/public/img/default/profile_images/gambit_kogneato.png new file mode 100644 index 000000000..ece5627f3 Binary files /dev/null and b/public/img/default/profile_images/gambit_kogneato.png differ diff --git a/public/img/default/profile_images/in_kogneato.png b/public/img/default/profile_images/in_kogneato.png new file mode 100644 index 000000000..d6154c44e Binary files /dev/null and b/public/img/default/profile_images/in_kogneato.png differ diff --git a/public/img/default/profile_images/kogneato_the_riveter.png b/public/img/default/profile_images/kogneato_the_riveter.png new file mode 100644 index 000000000..89f2270d4 Binary files /dev/null and b/public/img/default/profile_images/kogneato_the_riveter.png differ diff --git a/public/img/default/profile_images/metal_kogneato.png b/public/img/default/profile_images/metal_kogneato.png new file mode 100644 index 000000000..21b7d900c Binary files /dev/null and b/public/img/default/profile_images/metal_kogneato.png differ diff --git a/public/img/default/profile_images/morpheus_kogneato.png b/public/img/default/profile_images/morpheus_kogneato.png new file mode 100644 index 000000000..7604aa7ef Binary files /dev/null and b/public/img/default/profile_images/morpheus_kogneato.png differ diff --git a/public/img/default/profile_images/shakespeare_kogneato.png b/public/img/default/profile_images/shakespeare_kogneato.png new file mode 100644 index 000000000..48d2203a7 Binary files /dev/null and b/public/img/default/profile_images/shakespeare_kogneato.png differ diff --git a/requirements-dev.txt b/requirements-dev.txt index b28adc17b..6f29f2d20 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,18 +1,19 @@ -black==24.4.2 +black==26.3.1 cfgv==3.4.0 click==8.1.7 distlib==0.3.8 -filelock==3.15.4 +filelock==3.29.0 flake8==7.1.0 identify==2.6.0 mccabe==0.7.0 mypy-extensions==1.0.0 nodeenv==1.9.1 packaging==24.1 -pathspec==0.12.1 +pathspec==1.1.1 platformdirs==4.2.2 pre-commit==3.8.0 pycodestyle==2.12.0 pyflakes==3.2.0 +pytokens==0.4.1 PyYAML==6.0.1 -virtualenv==20.26.3 +virtualenv==20.36.1 diff --git a/src/components/catalog.jsx b/src/components/catalog.jsx index 557488726..9348f4d36 100644 --- a/src/components/catalog.jsx +++ b/src/components/catalog.jsx @@ -50,7 +50,7 @@ const Catalog = ({widgets = [], isLoading = true}) => { results = results.filter(w => { const {features, supported_data, accessibility_keyboard, accessibility_reader} = w.meta_data return state.activeFilters.every(f =>{ - if (features.includes(f) || supported_data.includes(f)) return true + if (features?.includes(f) || supported_data?.includes(f)) return true if (accessibility_keyboard && f === 'Keyboard Accessible') return true if (accessibility_reader && f === 'Screen Reader Accessible') return true diff --git a/src/components/guide-page.jsx b/src/components/guide-page.jsx index bb0e84c86..354ce2ab6 100644 --- a/src/components/guide-page.jsx +++ b/src/components/guide-page.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect} from 'react' +import React, { useState, useEffect, useRef } from 'react' import Header from './header' import './guide-page.scss' import { waitForWindow } from '../util/wait-for-window' @@ -11,6 +11,8 @@ const GuidePage = () => { const [hasCreatorGuide, setHasCreatorGuide] = useState(null) const [docPath, setDocPath] = useState(null) + const iframeRef = useRef(null) + useEffect(() => { waitForWindow(['NAME', 'TYPE', 'HAS_PLAYER_GUIDE', 'HAS_CREATOR_GUIDE', 'DOC_PATH']).then(() => { setName(window.NAME) @@ -18,6 +20,12 @@ const GuidePage = () => { setHasPlayerGuide(window.HAS_PLAYER_GUIDE) setHasCreatorGuide(window.HAS_CREATOR_GUIDE) setDocPath(window.DOC_PATH) + + if (document.body.classList.contains('darkMode') && iframeRef.current) { + iframeRef.current.addEventListener('load', () => { + iframeRef.current.contentWindow.document.body.classList.add('darkMode') + }) + } }) }) @@ -35,7 +43,7 @@ const GuidePage = () => {
- +
) diff --git a/src/components/header.jsx b/src/components/header.jsx index e077f8353..6e417a8cd 100644 --- a/src/components/header.jsx +++ b/src/components/header.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react' import { useQuery } from 'react-query' -import { apiGetUser, apiUserVerify } from '../util/api' +import { apiGetUser, apiUserVerify, apiGetSiteMessages } from '../util/api' import Notifications from './notifications' const Header = ({ @@ -13,6 +13,9 @@ const Header = ({ const [verified, setVerified] = useState(false) const [permLevel, setPermLevel] = useState('anonymous') + const [headerNotification, setHeaderNotification] = useState(null) + const [headerAlert, setHeaderAlert] = useState(null) + const { data: userPerms } = useQuery({ queryKey: 'isLoggedIn', queryFn: apiUserVerify, @@ -26,6 +29,22 @@ const Header = ({ enabled: !!verified }) + const {data: siteMessages } = useQuery({ + queryKey: ['site-messages', 'notification', 'alert'], + queryFn: () => apiGetSiteMessages(['SITE_NOTIFICATION', 'SITE_ALERT']), + staleTime: Infinity, + retry: false + }) + + useEffect(() => { + if (siteMessages != undefined) { + siteMessages.forEach((msg) => { + if (msg.message_type == 'SITE_NOTIFICATION') setHeaderNotification(msg.message_text) + else if (msg.message_type == 'SITE_ALERT') setHeaderAlert(msg.message_text) + }) + } + },[siteMessages]) + useEffect(() => { if (userData != undefined) { setUser(userData) @@ -56,7 +75,7 @@ const Header = ({ let elevatedPermsNavRender = null if (permLevel == 'super_user') { elevatedPermsNavRender = ( -
  • +
  • Admin