diff --git a/app/api/serializers.py b/app/api/serializers.py index eddeda1dd..ae13456b6 100644 --- a/app/api/serializers.py +++ b/app/api/serializers.py @@ -8,7 +8,9 @@ import phpserialize from core.models import ( Asset, + CommunityLibraryEntry, DateRange, + LibraryReport, Log, LogPlay, LogStorage, @@ -16,6 +18,7 @@ Notification, ObjectPermission, UserExtraAttempts, + UserLike, UserSettings, Widget, WidgetInstance, @@ -328,6 +331,13 @@ class WidgetInstanceSerializer(serializers.ModelSerializer): embed_url = serializers.CharField(read_only=True, allow_null=True) is_embedded = serializers.BooleanField(read_only=True) qset = QuestionSetSerializer(required=False) + copied_from_entry_id = serializers.SerializerMethodField() + + def get_copied_from_entry_id(self, instance): + entry = instance.copied_from_entry + if entry and entry.instance.is_shared: + return entry.id + return None # remove sensitive info if context flag set def get_fields(self): @@ -377,6 +387,24 @@ def update(self, widget_instance, validated_data): id = serializers.CharField( required=False ) # Model's save function will auto-generate an ID if it is empty + library_entry = serializers.SerializerMethodField() + + def get_library_entry(self, instance): + entry = CommunityLibraryEntry.objects.filter(instance=instance).first() + if entry is None: + return None + return { + "id": entry.id, + "category": entry.category, + "category_display": entry.get_category_display(), + "course_level": entry.course_level, + "course_level_display": entry.get_course_level_display(), + "featured": entry.featured, + "is_banned": entry.is_banned, + "report_count": entry.report_count, + "copy_count": entry.copy_count, + "like_count": entry.like_count, + } class Meta: model = WidgetInstance @@ -393,6 +421,9 @@ class Meta: "attempts", "is_deleted", "embedded_only", + "is_shared", + "copied_from_entry_id", + "library_entry", "widget", "widget_id", "preview_url", @@ -405,6 +436,7 @@ class Meta: "id", "user_id", "is_student_made", + "copied_from_entry_id", "widget", "widget_id", "is_embedded", @@ -873,3 +905,79 @@ class PlayStorageSaveSerializer(serializers.Serializer): queryset=LogPlay.objects.all(), required=True ) logs = serializers.JSONField() + + +class CommunityLibraryEntrySerializer(serializers.ModelSerializer): + instance_id = serializers.CharField(source="instance.id", read_only=True) + instance_name = serializers.SerializerMethodField() + widget = WidgetSerializer(source="instance.widget", read_only=True) + owner_display_name = serializers.SerializerMethodField() + category_display = serializers.CharField( + source="get_category_display", read_only=True + ) + course_level_display = serializers.CharField( + source="get_course_level_display", read_only=True + ) + latest_snapshot_id = serializers.SerializerMethodField() + user_has_liked = serializers.SerializerMethodField() + last_reported_at = serializers.SerializerMethodField() + + class Meta: + model = CommunityLibraryEntry + fields = [ + "id", + "instance_id", + "instance_name", + "widget", + "owner_display_name", + "category", + "category_display", + "course_level", + "course_level_display", + "featured", + "copy_count", + "like_count", + "report_count", + "is_banned", + "latest_snapshot_id", + "user_has_liked", + "created_at", + "last_reported_at", + ] + + def get_instance_name(self, entry): + snapshot = entry.snapshots.order_by("-created_at").first() + return snapshot.name + + def get_latest_snapshot_id(self, entry): + snapshot = entry.snapshots.order_by("-created_at").first() + return snapshot.id + + def get_owner_display_name(self, entry): + user = entry.instance.user + first = user.first_name or "" + last = user.last_name or "" + return f"{first} {last}".strip() + + def get_last_reported_at(self, entry): + latest_report = entry.reports.order_by("-created_at").first() + return latest_report.created_at if latest_report else None + + def get_user_has_liked(self, entry): + request = self.context.get("request") + return UserLike.objects.filter(user=request.user, entry=entry).exists() + + +class LibraryReportSerializer(serializers.ModelSerializer): + class Meta: + model = LibraryReport + fields = ["reason", "details"] + + +class PublishToLibrarySerializer(serializers.Serializer): + category = serializers.ChoiceField(choices=CommunityLibraryEntry.CATEGORY_CHOICES) + course_level = serializers.ChoiceField( + choices=[("", "")] + CommunityLibraryEntry.COURSE_LEVEL_CHOICES, + required=False, + default="", + ) diff --git a/app/api/tests/test_community_library_views.py b/app/api/tests/test_community_library_views.py new file mode 100644 index 000000000..af7b009bc --- /dev/null +++ b/app/api/tests/test_community_library_views.py @@ -0,0 +1,1076 @@ +from unittest.mock import patch + +from core.models import ( + CommunityLibraryEntry, + LibraryReport, + LibrarySnapshot, + Notification, + ObjectPermission, + UserLike, + Widget, + WidgetInstance, + WidgetQset, +) +from django.contrib.auth.models import Group, User +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + + +class CommunityLibraryViewSetTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.author_group, _ = Group.objects.get_or_create(name="basic_author") + cls.support_group, _ = Group.objects.get_or_create(name="support_user") + + cls.author_user = User.objects.create_user( + username="author", + first_name="Jane", + last_name="Doe", + email="author@example.com", + password="testpass123", + ) + cls.author_user.groups.add(cls.author_group) + + cls.another_author = User.objects.create_user( + username="another_author", + first_name="John", + last_name="Smith", + email="another_author@example.com", + password="testpass123", + ) + cls.another_author.groups.add(cls.author_group) + + cls.regular_user = User.objects.create_user( + username="regular", + email="regular@example.com", + password="testpass123", + ) + + cls.support_user = User.objects.create_user( + username="support", + email="support@example.com", + password="testpass123", + ) + cls.support_user.groups.add(cls.support_group) + + cls.superuser = User.objects.create_superuser( + username="admin", + email="admin@example.com", + password="testpass123", + ) + + cls.widget = Widget.objects.create( + name="Test Widget", + clean_name="test-widget", + is_editable=True, + is_playable=True, + is_scorable=True, + ) + + cls.another_widget = Widget.objects.create( + name="Another Widget", + clean_name="another-widget", + is_editable=True, + is_playable=True, + ) + + cls.shared_instance = WidgetInstance.objects.create( + id="shared001", + widget=cls.widget, + user=cls.author_user, + name="Shared Instance", + is_draft=False, + is_shared=True, + ) + ObjectPermission.objects.create( + user=cls.author_user, + content_object=cls.shared_instance, + permission=ObjectPermission.PERMISSION_FULL, + ) + WidgetQset.objects.create( + instance=cls.shared_instance, + data="eyJ0ZXN0IjogImRhdGEifQ==", + version="1", + ) + + cls.library_entry = CommunityLibraryEntry.objects.create( + instance=cls.shared_instance, + category="math", + course_level="introductory", + ) + cls.library_snapshot = LibrarySnapshot.objects.create( + entry=cls.library_entry, + name="Shared Instance", + qset_data="eyJ0ZXN0IjogImRhdGEifQ==", + qset_version="1", + ) + + cls.shared_instance_2 = WidgetInstance.objects.create( + id="shared002", + widget=cls.another_widget, + user=cls.another_author, + name="Alpha Instance", + is_draft=False, + is_shared=True, + ) + ObjectPermission.objects.create( + user=cls.another_author, + content_object=cls.shared_instance_2, + permission=ObjectPermission.PERMISSION_FULL, + ) + WidgetQset.objects.create( + instance=cls.shared_instance_2, + data="eyJ0ZXN0IjogImRhdGEifQ==", + version="1", + ) + + cls.library_entry_2 = CommunityLibraryEntry.objects.create( + instance=cls.shared_instance_2, + category="science", + course_level="advanced", + copy_count=10, + like_count=5, + ) + LibrarySnapshot.objects.create( + entry=cls.library_entry_2, + name="Alpha Instance", + qset_data="eyJ0ZXN0IjogImRhdGEifQ==", + qset_version="1", + ) + + cls.unshared_instance = WidgetInstance.objects.create( + id="unshare1", + widget=cls.widget, + user=cls.author_user, + name="Unshared Instance", + is_draft=False, + is_shared=False, + ) + ObjectPermission.objects.create( + user=cls.author_user, + content_object=cls.unshared_instance, + permission=ObjectPermission.PERMISSION_FULL, + ) + WidgetQset.objects.create( + instance=cls.unshared_instance, + data="eyJ0ZXN0IjogImRhdGEifQ==", + version="1", + ) + + def setUp(self): + self.client = APIClient() + + +class TestCommunityLibraryList(CommunityLibraryViewSetTestCase): + """Tests for GET /api/community-library/""" + + def test_unauthenticated_returns_403(self): + response = self.client.get("/api/community-library/") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_authenticated_returns_published_entries(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + instance_ids = [r["instance_id"] for r in response.data["results"]] + self.assertIn(self.shared_instance.id, instance_ids) + self.assertIn(self.shared_instance_2.id, instance_ids) + + def test_excludes_unshared_instance(self): + """Entries whose instance.is_shared=False should not appear.""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/") + instance_ids = [r["instance_id"] for r in response.data["results"]] + self.assertNotIn(self.unshared_instance.id, instance_ids) + + def test_excludes_banned_entries(self): + self.library_entry.is_banned = True + self.library_entry.save(update_fields=["is_banned"]) + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/") + instance_ids = [r["instance_id"] for r in response.data["results"]] + self.assertNotIn(self.shared_instance.id, instance_ids) + + self.library_entry.is_banned = False + self.library_entry.save(update_fields=["is_banned"]) + + def test_excludes_deleted_instance(self): + """If the instance is soft-deleted, entry should not appear.""" + self.shared_instance.is_deleted = True + self.shared_instance.save(update_fields=["is_deleted"]) + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/") + instance_ids = [r["instance_id"] for r in response.data["results"]] + self.assertNotIn(self.shared_instance.id, instance_ids) + + self.shared_instance.is_deleted = False + self.shared_instance.save(update_fields=["is_deleted"]) + + def test_search_by_name(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/", {"search": "Shared"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + instance_ids = [r["instance_id"] for r in response.data["results"]] + self.assertIn(self.shared_instance.id, instance_ids) + self.assertNotIn(self.shared_instance_2.id, instance_ids) + + def test_filter_by_widget_id(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get( + "/api/community-library/", {"widget_id": self.another_widget.id} + ) + instance_ids = [r["instance_id"] for r in response.data["results"]] + self.assertIn(self.shared_instance_2.id, instance_ids) + self.assertNotIn(self.shared_instance.id, instance_ids) + + def test_filter_by_category(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/", {"category": "math"}) + instance_ids = [r["instance_id"] for r in response.data["results"]] + self.assertIn(self.shared_instance.id, instance_ids) + self.assertNotIn(self.shared_instance_2.id, instance_ids) + + def test_filter_by_course_level(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get( + "/api/community-library/", {"course_level": "advanced"} + ) + instance_ids = [r["instance_id"] for r in response.data["results"]] + self.assertIn(self.shared_instance_2.id, instance_ids) + self.assertNotIn(self.shared_instance.id, instance_ids) + + def test_filter_featured(self): + self.library_entry.featured = True + self.library_entry.save(update_fields=["featured"]) + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/", {"featured": "true"}) + instance_ids = [r["instance_id"] for r in response.data["results"]] + self.assertIn(self.shared_instance.id, instance_ids) + self.assertNotIn(self.shared_instance_2.id, instance_ids) + + self.library_entry.featured = False + self.library_entry.save(update_fields=["featured"]) + + def test_sort_alphabetical(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/", {"sort": "alphabetical"}) + names = [r["instance_name"] for r in response.data["results"]] + self.assertEqual(names, sorted(names)) + + def test_sort_most_copied(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/", {"sort": "most_copied"}) + results = response.data["results"] + self.assertEqual(results[0]["instance_id"], self.shared_instance_2.id) + + def test_sort_most_liked(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/", {"sort": "most_liked"}) + results = response.data["results"] + self.assertEqual(results[0]["instance_id"], self.shared_instance_2.id) + + def test_response_includes_expected_fields(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/") + entry = next( + r + for r in response.data["results"] + if r["instance_id"] == self.shared_instance.id + ) + expected_fields = [ + "id", + "instance_id", + "instance_name", + "widget", + "owner_display_name", + "category", + "category_display", + "course_level", + "course_level_display", + "featured", + "copy_count", + "like_count", + "latest_snapshot_id", + "user_has_liked", + "created_at", + ] + for field in expected_fields: + self.assertIn(field, entry) + + def test_owner_display_name_format(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/") + entry = next( + r + for r in response.data["results"] + if r["instance_id"] == self.shared_instance.id + ) + self.assertEqual(entry["owner_display_name"], "Jane Doe") + + def test_user_has_liked_false_by_default(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/") + entry = next( + r + for r in response.data["results"] + if r["instance_id"] == self.shared_instance.id + ) + self.assertFalse(entry["user_has_liked"]) + + def test_user_has_liked_true_when_liked(self): + UserLike.objects.create(user=self.regular_user, entry=self.library_entry) + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/") + entry = next( + r + for r in response.data["results"] + if r["instance_id"] == self.shared_instance.id + ) + self.assertTrue(entry["user_has_liked"]) + + UserLike.objects.filter( + user=self.regular_user, entry=self.library_entry + ).delete() + + def test_instance_name_uses_snapshot_name(self): + """Library list should show the snapshot name, not the current instance name.""" + self.shared_instance.name = "Renamed Instance" + self.shared_instance.save(update_fields=["name"]) + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/") + entry = next( + r + for r in response.data["results"] + if r["instance_id"] == self.shared_instance.id + ) + self.assertEqual(entry["instance_name"], "Shared Instance") + + self.shared_instance.name = "Shared Instance" + self.shared_instance.save(update_fields=["name"]) + + def test_latest_snapshot_id_is_returned(self): + """List should return the latest snapshot ID for each entry.""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/community-library/") + entry = next( + r + for r in response.data["results"] + if r["instance_id"] == self.shared_instance.id + ) + snapshot = self.library_entry.snapshots.order_by("-created_at").first() + self.assertEqual(entry["latest_snapshot_id"], snapshot.id) + + +class TestCommunityLibraryCopy(CommunityLibraryViewSetTestCase): + """Tests for POST /api/community-library/{id}/copy/""" + + def test_unauthenticated_returns_403(self): + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/copy/" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_authenticated_user_can_copy(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/copy/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["name"], "Shared Instance") + self.assertNotEqual(response.data["id"], self.shared_instance.id) + + def test_copy_creates_instance_with_correct_owner(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/copy/" + ) + new_instance = WidgetInstance.objects.get(pk=response.data["id"]) + self.assertEqual(new_instance.user, self.regular_user) + + def test_copied_instance_is_not_shared(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/copy/" + ) + new_instance = WidgetInstance.objects.get(pk=response.data["id"]) + self.assertFalse(new_instance.is_shared) + + def test_copy_increments_copy_count(self): + original_count = self.library_entry.copy_count + self.client.force_authenticate(user=self.regular_user) + self.client.post(f"/api/community-library/{self.library_entry.id}/copy/") + self.library_entry.refresh_from_db() + self.assertEqual(self.library_entry.copy_count, original_count + 1) + + def test_copy_uses_snapshot_data(self): + """Copy should create from snapshot data, with correct widget type.""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/copy/" + ) + new_instance = WidgetInstance.objects.get(pk=response.data["id"]) + self.assertEqual(new_instance.widget_id, self.shared_instance.widget_id) + self.assertEqual(new_instance.name, "Shared Instance") + + def test_copy_has_qset(self): + """Copied instance should have a qset from the snapshot data.""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/copy/" + ) + new_instance = WidgetInstance.objects.get(pk=response.data["id"]) + qset = new_instance.get_latest_qset() + self.assertIsNotNone(qset) + + def test_copy_sets_copied_from_entry(self): + """Copied instance should have copied_from_entry pointing to the source entry.""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/copy/" + ) + new_instance = WidgetInstance.objects.get(pk=response.data["id"]) + self.assertEqual(new_instance.copied_from_entry, self.library_entry) + + def test_copy_returns_copied_from_entry_id(self): + """Serialized response should include copied_from_entry_id.""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/copy/" + ) + self.assertEqual(response.data["copied_from_entry_id"], self.library_entry.id) + + def test_copy_of_copy_does_not_inherit_copied_from_entry(self): + """Duplicating a copy via the instance copy action should not inherit copied_from_entry.""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/copy/" + ) + copied_instance = WidgetInstance.objects.get(pk=response.data["id"]) + ObjectPermission.objects.create( + user=self.regular_user, + content_object=copied_instance, + permission=ObjectPermission.PERMISSION_FULL, + ) + dupe = copied_instance.duplicate(owner=self.regular_user, new_name="Dupe of Copy") + self.assertIsNone(dupe.copied_from_entry) + + +class TestPullFromLibrary(CommunityLibraryViewSetTestCase): + """Tests for PUT /api/instances/{id}/pull_from_library/""" + + def setUp(self): + super().setUp() + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/copy/" + ) + self.copied_instance = WidgetInstance.objects.get(pk=response.data["id"]) + + def test_unauthenticated_returns_403(self): + self.client.logout() + response = self.client.put( + f"/api/instances/{self.copied_instance.id}/pull_from_library/" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_pull_updates_name_and_qset(self): + """Pull should overwrite name and qset from the latest snapshot.""" + self.copied_instance.name = "My Custom Name" + self.copied_instance.save(update_fields=["name"]) + + response = self.client.put( + f"/api/instances/{self.copied_instance.id}/pull_from_library/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.copied_instance.refresh_from_db() + snapshot = self.library_entry.snapshots.order_by("-created_at").first() + self.assertEqual(self.copied_instance.name, snapshot.name) + + def test_pull_uses_latest_snapshot(self): + """If a newer snapshot exists, pull should use it.""" + new_snapshot = LibrarySnapshot.objects.create( + entry=self.library_entry, + name="Updated Snapshot Name", + qset_data="eyJ1cGRhdGVkIjogdHJ1ZX0=", + qset_version="2", + ) + response = self.client.put( + f"/api/instances/{self.copied_instance.id}/pull_from_library/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.copied_instance.refresh_from_db() + self.assertEqual(self.copied_instance.name, "Updated Snapshot Name") + qset = self.copied_instance.get_latest_qset() + self.assertEqual(qset.data, new_snapshot.qset_data) + self.assertEqual(qset.version, "2") + + new_snapshot.delete() + + def test_pull_on_non_library_copy_returns_400(self): + """Pulling from a widget that was not copied from the library should fail.""" + self.client.force_authenticate(user=self.author_user) + response = self.client.put( + f"/api/instances/{self.unshared_instance.id}/pull_from_library/" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_pull_on_unpublished_entry_returns_400(self): + """Pulling from an unpublished entry should fail.""" + self.shared_instance.is_shared = False + self.shared_instance.save(update_fields=["is_shared"]) + + response = self.client.put( + f"/api/instances/{self.copied_instance.id}/pull_from_library/" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.shared_instance.is_shared = True + self.shared_instance.save(update_fields=["is_shared"]) + + def test_copied_from_entry_id_null_when_unpublished(self): + """Serializer should return null for copied_from_entry_id when entry is unpublished.""" + self.shared_instance.is_shared = False + self.shared_instance.save(update_fields=["is_shared"]) + + response = self.client.get( + f"/api/instances/{self.copied_instance.id}/" + ) + self.assertIsNone(response.data["copied_from_entry_id"]) + + self.shared_instance.is_shared = True + self.shared_instance.save(update_fields=["is_shared"]) + + def test_copied_from_entry_id_null_when_entry_deleted(self): + """If the library entry is deleted, copied_from_entry_id should be null.""" + temp_instance = WidgetInstance.objects.create( + widget=self.widget, + user=self.author_user, + name="Temp Instance", + is_draft=False, + is_shared=True, + ) + ObjectPermission.objects.create( + user=self.author_user, + content_object=temp_instance, + permission=ObjectPermission.PERMISSION_FULL, + ) + WidgetQset.objects.create( + instance=temp_instance, + data="eyJ0ZXN0IjogImRhdGEifQ==", + version="1", + ) + temp_entry = CommunityLibraryEntry.objects.create( + instance=temp_instance, + category="math", + ) + LibrarySnapshot.objects.create( + entry=temp_entry, + name="Temp Instance", + qset_data="eyJ0ZXN0IjogImRhdGEifQ==", + qset_version="1", + ) + response = self.client.post( + f"/api/community-library/{temp_entry.id}/copy/" + ) + temp_copy = WidgetInstance.objects.get(pk=response.data["id"]) + self.assertEqual(temp_copy.copied_from_entry, temp_entry) + + temp_entry.delete() + temp_copy.refresh_from_db() + self.assertIsNone(temp_copy.copied_from_entry_id) + + +class TestCommunityLibraryLike(CommunityLibraryViewSetTestCase): + """Tests for POST /api/community-library/{id}/like/""" + + def test_unauthenticated_returns_403(self): + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/like/" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_first_like_creates_like(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/like/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["liked"]) + self.assertTrue( + UserLike.objects.filter( + user=self.regular_user, entry=self.library_entry + ).exists() + ) + + def test_first_like_increments_like_count(self): + original_count = self.library_entry.like_count + self.client.force_authenticate(user=self.regular_user) + self.client.post(f"/api/community-library/{self.library_entry.id}/like/") + self.library_entry.refresh_from_db() + self.assertEqual(self.library_entry.like_count, original_count + 1) + + def test_second_like_unlikes(self): + UserLike.objects.create(user=self.regular_user, entry=self.library_entry) + original_count = self.library_entry.like_count + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/like/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.data["liked"]) + self.assertFalse( + UserLike.objects.filter( + user=self.regular_user, entry=self.library_entry + ).exists() + ) + self.library_entry.refresh_from_db() + self.assertEqual(self.library_entry.like_count, original_count - 1) + + def test_like_count_in_response(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/like/" + ) + self.assertIn("like_count", response.data) + + +class TestCommunityLibraryReport(CommunityLibraryViewSetTestCase): + """Tests for POST /api/community-library/{id}/report/""" + + def test_unauthenticated_returns_403(self): + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/report/", + {"reason": "spam"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_valid_report_creates_report(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/report/", + {"reason": "spam", "details": "This is spam"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["success"]) + self.assertTrue( + LibraryReport.objects.filter( + user=self.regular_user, entry=self.library_entry + ).exists() + ) + + def test_duplicate_report_returns_400(self): + LibraryReport.objects.create( + user=self.regular_user, + entry=self.library_entry, + reason="spam", + ) + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/report/", + {"reason": "inappropriate"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_report_increments_report_count(self): + original_count = self.library_entry.report_count + self.client.force_authenticate(user=self.regular_user) + self.client.post( + f"/api/community-library/{self.library_entry.id}/report/", + {"reason": "spam"}, + format="json", + ) + self.library_entry.refresh_from_db() + self.assertEqual(self.library_entry.report_count, original_count + 1) + + @patch("api.views.community_library.REPORT_THRESHOLD", 1) + @patch("core.models.Notification.send_email") + def test_report_at_threshold_auto_bans(self, mock_send_email): + self.client.force_authenticate(user=self.regular_user) + self.client.post( + f"/api/community-library/{self.library_entry.id}/report/", + {"reason": "spam"}, + format="json", + ) + self.library_entry.refresh_from_db() + self.assertTrue(self.library_entry.is_banned) + + self.library_entry.is_banned = False + self.library_entry.report_count = 0 + self.library_entry.save(update_fields=["is_banned", "report_count"]) + + @patch("api.views.community_library.REPORT_THRESHOLD", 1) + @patch("core.models.Notification.send_email") + def test_report_at_threshold_creates_admin_notifications(self, mock_send_email): + self.client.force_authenticate(user=self.regular_user) + self.client.post( + f"/api/community-library/{self.library_entry.id}/report/", + {"reason": "spam"}, + format="json", + ) + admin_notifications = Notification.objects.filter( + action="library_report", + item_id=self.shared_instance.id, + ) + admin_user_ids = set(admin_notifications.values_list("to_id", flat=True)) + self.assertIn(self.superuser.id, admin_user_ids) + self.assertIn(self.support_user.id, admin_user_ids) + + admin_notifications.delete() + self.library_entry.is_banned = False + self.library_entry.report_count = 0 + self.library_entry.save(update_fields=["is_banned", "report_count"]) + + def test_missing_reason_returns_400(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/report/", + {}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_invalid_reason_returns_400(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f"/api/community-library/{self.library_entry.id}/report/", + {"reason": "not_a_valid_reason"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class TestCommunityLibraryModerate(CommunityLibraryViewSetTestCase): + """Tests for PATCH /api/community-library/{id}/moderate/""" + + def test_unauthenticated_returns_403(self): + response = self.client.patch( + f"/api/community-library/{self.library_entry.id}/moderate/", + {"featured": True}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_regular_user_returns_403(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.patch( + f"/api/community-library/{self.library_entry.id}/moderate/", + {"featured": True}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_superuser_can_toggle_featured(self): + self.client.force_authenticate(user=self.superuser) + response = self.client.patch( + f"/api/community-library/{self.library_entry.id}/moderate/", + {"featured": True}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.library_entry.refresh_from_db() + self.assertTrue(self.library_entry.featured) + + self.library_entry.featured = False + self.library_entry.save(update_fields=["featured"]) + + def test_support_user_can_toggle_is_banned(self): + self.client.force_authenticate(user=self.support_user) + response = self.client.patch( + f"/api/community-library/{self.library_entry.id}/moderate/", + {"is_banned": True}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.library_entry.refresh_from_db() + self.assertTrue(self.library_entry.is_banned) + + self.library_entry.is_banned = False + self.library_entry.save(update_fields=["is_banned"]) + + def test_unrecognized_fields_are_ignored(self): + self.client.force_authenticate(user=self.superuser) + response = self.client.patch( + f"/api/community-library/{self.library_entry.id}/moderate/", + {"copy_count": 9999, "featured": True}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.library_entry.refresh_from_db() + self.assertNotEqual(self.library_entry.copy_count, 9999) + self.assertTrue(self.library_entry.featured) + + self.library_entry.featured = False + self.library_entry.save(update_fields=["featured"]) + + +class TestPublishToLibrary(CommunityLibraryViewSetTestCase): + """Tests for PUT /api/instances/{id}/publish_to_library/""" + + def test_unauthenticated_returns_403(self): + response = self.client.put( + f"/api/instances/{self.unshared_instance.id}/publish_to_library/", + {"category": "math"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_owner_can_publish(self): + self.client.force_authenticate(user=self.author_user) + response = self.client.put( + f"/api/instances/{self.unshared_instance.id}/publish_to_library/", + {"category": "science", "course_level": "intermediate"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["success"]) + self.unshared_instance.refresh_from_db() + self.assertTrue(self.unshared_instance.is_shared) + entry = CommunityLibraryEntry.objects.get(instance=self.unshared_instance) + self.assertEqual(entry.category, "science") + self.assertEqual(entry.course_level, "intermediate") + + entry.delete() + self.unshared_instance.is_shared = False + self.unshared_instance.save(update_fields=["is_shared"]) + + def test_publish_creates_snapshot(self): + """Publishing should create a LibrarySnapshot record.""" + self.client.force_authenticate(user=self.author_user) + self.client.put( + f"/api/instances/{self.unshared_instance.id}/publish_to_library/", + {"category": "math"}, + format="json", + ) + entry = CommunityLibraryEntry.objects.get(instance=self.unshared_instance) + snapshot = entry.snapshots.first() + self.assertIsNotNone(snapshot) + self.assertEqual(snapshot.name, self.unshared_instance.name) + + entry.delete() + self.unshared_instance.is_shared = False + self.unshared_instance.save(update_fields=["is_shared"]) + + def test_snapshot_has_correct_qset(self): + """Snapshot should have a copy of the instance's qset data.""" + self.client.force_authenticate(user=self.author_user) + self.client.put( + f"/api/instances/{self.unshared_instance.id}/publish_to_library/", + {"category": "math"}, + format="json", + ) + entry = CommunityLibraryEntry.objects.get(instance=self.unshared_instance) + snapshot = entry.snapshots.first() + source_qset = self.unshared_instance.get_latest_qset() + self.assertIsNotNone(snapshot) + self.assertEqual(snapshot.qset_data, source_qset.data) + self.assertEqual(snapshot.qset_version, source_qset.version) + + entry.delete() + self.unshared_instance.is_shared = False + self.unshared_instance.save(update_fields=["is_shared"]) + + def test_entry_instance_points_to_original(self): + """Entry's instance should point to the original widget.""" + self.client.force_authenticate(user=self.author_user) + self.client.put( + f"/api/instances/{self.unshared_instance.id}/publish_to_library/", + {"category": "math"}, + format="json", + ) + entry = CommunityLibraryEntry.objects.get(instance=self.unshared_instance) + self.assertEqual(entry.instance.id, self.unshared_instance.id) + + entry.delete() + self.unshared_instance.is_shared = False + self.unshared_instance.save(update_fields=["is_shared"]) + + def test_publish_sets_is_shared_true(self): + self.client.force_authenticate(user=self.author_user) + self.client.put( + f"/api/instances/{self.unshared_instance.id}/publish_to_library/", + {"category": "math"}, + format="json", + ) + self.unshared_instance.refresh_from_db() + self.assertTrue(self.unshared_instance.is_shared) + + CommunityLibraryEntry.objects.filter(instance=self.unshared_instance).delete() + self.unshared_instance.is_shared = False + self.unshared_instance.save(update_fields=["is_shared"]) + + def test_banned_user_gets_403(self): + settings = self.author_user.profile_settings + settings.library_banned = True + settings.save(update_fields=["library_banned"]) + self.client.force_authenticate(user=self.author_user) + response = self.client.put( + f"/api/instances/{self.unshared_instance.id}/publish_to_library/", + {"category": "math"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + settings.library_banned = False + settings.save(update_fields=["library_banned"]) + + def test_missing_category_returns_400(self): + self.client.force_authenticate(user=self.author_user) + response = self.client.put( + f"/api/instances/{self.unshared_instance.id}/publish_to_library/", + {}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_invalid_category_returns_400(self): + self.client.force_authenticate(user=self.author_user) + response = self.client.put( + f"/api/instances/{self.unshared_instance.id}/publish_to_library/", + {"category": "not_a_category"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_republish_updates_entry(self): + """Re-publishing should update the existing entry's category and create new snapshot.""" + self.client.force_authenticate(user=self.author_user) + old_snapshot_count = self.library_entry.snapshots.count() + response = self.client.put( + f"/api/instances/{self.shared_instance.id}/publish_to_library/", + {"category": "history", "course_level": "advanced"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.library_entry.refresh_from_db() + self.assertEqual(self.library_entry.category, "history") + self.assertEqual(self.library_entry.course_level, "advanced") + self.assertEqual(self.library_entry.snapshots.count(), old_snapshot_count + 1) + + self.library_entry.category = "math" + self.library_entry.course_level = "introductory" + self.library_entry.save(update_fields=["category", "course_level"]) + self.library_entry.snapshots.order_by("-created_at").first().delete() + + def test_no_orphan_instances_created(self): + """Publishing should NOT create any new WidgetInstance records.""" + instance_count_before = WidgetInstance.objects.count() + self.client.force_authenticate(user=self.author_user) + self.client.put( + f"/api/instances/{self.unshared_instance.id}/publish_to_library/", + {"category": "math"}, + format="json", + ) + instance_count_after = WidgetInstance.objects.count() + self.assertEqual(instance_count_before, instance_count_after) + + CommunityLibraryEntry.objects.filter(instance=self.unshared_instance).delete() + self.unshared_instance.is_shared = False + self.unshared_instance.save(update_fields=["is_shared"]) + + +class TestUpdateInLibrary(CommunityLibraryViewSetTestCase): + """Tests for PUT /api/instances/{id}/update_in_library/""" + + def test_unauthenticated_returns_403(self): + response = self.client.put( + f"/api/instances/{self.shared_instance.id}/update_in_library/" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_owner_can_update(self): + self.client.force_authenticate(user=self.author_user) + response = self.client.put( + f"/api/instances/{self.shared_instance.id}/update_in_library/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["success"]) + + self.library_entry.snapshots.order_by("-created_at").first().delete() + + def test_update_creates_new_snapshot(self): + """Updating should create a new LibrarySnapshot with current data.""" + old_snapshot_count = self.library_entry.snapshots.count() + self.shared_instance.name = "Updated Name" + self.shared_instance.save(update_fields=["name"]) + self.client.force_authenticate(user=self.author_user) + self.client.put(f"/api/instances/{self.shared_instance.id}/update_in_library/") + self.library_entry.refresh_from_db() + new_snapshot = self.library_entry.snapshots.order_by("-created_at").first() + self.assertEqual(self.library_entry.snapshots.count(), old_snapshot_count + 1) + self.assertEqual(new_snapshot.name, "Updated Name") + + self.shared_instance.name = "Shared Instance" + self.shared_instance.save(update_fields=["name"]) + new_snapshot.delete() + + def test_update_preserves_entry_stats(self): + """Updating should not reset copy_count, like_count, etc.""" + self.library_entry.copy_count = 42 + self.library_entry.like_count = 10 + self.library_entry.save(update_fields=["copy_count", "like_count"]) + self.client.force_authenticate(user=self.author_user) + self.client.put(f"/api/instances/{self.shared_instance.id}/update_in_library/") + self.library_entry.refresh_from_db() + self.assertEqual(self.library_entry.copy_count, 42) + self.assertEqual(self.library_entry.like_count, 10) + + self.shared_instance.is_shared = True + self.shared_instance.save(update_fields=["is_shared"]) + + def test_unpublish_preserves_entry_and_snapshots(self): + """Unpublishing should keep the entry and snapshots, only set is_shared=False.""" + entry_id = self.library_entry.id + self.client.force_authenticate(user=self.author_user) + self.client.put( + f"/api/instances/{self.shared_instance.id}/unpublish_from_library/" + ) + self.assertTrue(CommunityLibraryEntry.objects.filter(id=entry_id).exists()) + self.assertTrue(LibrarySnapshot.objects.filter(entry_id=entry_id).exists()) + self.shared_instance.refresh_from_db() + self.assertFalse(self.shared_instance.is_shared) + + self.shared_instance.is_shared = True + self.shared_instance.save(update_fields=["is_shared"]) + + def test_unpublished_instance_not_in_library_list(self): + self.client.force_authenticate(user=self.author_user) + self.client.put( + f"/api/instances/{self.shared_instance.id}/unpublish_from_library/" + ) + response = self.client.get("/api/community-library/") + instance_ids = [r["instance_id"] for r in response.data["results"]] + self.assertNotIn(self.shared_instance.id, instance_ids) + + self.shared_instance.is_shared = True + self.shared_instance.save(update_fields=["is_shared"]) + + +class TestSnapshotEndpoints(CommunityLibraryViewSetTestCase): + """Tests for GET /api/community-library/{id}/snapshot_instance/ and snapshot_qset/""" + + def test_snapshot_instance_returns_data(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get( + f"/api/community-library/{self.library_entry.id}/snapshot_instance/{self.library_snapshot.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["name"], "Shared Instance") + self.assertIn("widget", response.data) + + def test_snapshot_qset_returns_data(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get( + f"/api/community-library/{self.library_entry.id}/snapshot_qset/{self.library_snapshot.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("data", response.data) + + def test_snapshot_instance_unauthenticated_returns_403(self): + response = self.client.get( + f"/api/community-library/{self.library_entry.id}/snapshot_instance/{self.library_snapshot.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/app/api/urls/api_urls.py b/app/api/urls/api_urls.py index aadd51bfb..431e5185d 100644 --- a/app/api/urls/api_urls.py +++ b/app/api/urls/api_urls.py @@ -1,5 +1,6 @@ from api.views import ( assets, + community_library, extra_attempts, generation, notifications, @@ -26,6 +27,7 @@ ) router.register(r"notifications", notifications.NotificationsViewSet) router.register(r"extra-attempts", extra_attempts.UserExtraAttemptsViewSet) +router.register(r"community-library", community_library.CommunityLibraryViewSet) urlpatterns = [ path("", include(router.urls)), diff --git a/app/api/views/community_library.py b/app/api/views/community_library.py new file mode 100644 index 000000000..834fefe2b --- /dev/null +++ b/app/api/views/community_library.py @@ -0,0 +1,288 @@ +import logging + +from api.permissions import IsSuperOrSupportUser +from api.serializers import ( + CommunityLibraryEntrySerializer, + LibraryReportSerializer, + WidgetInstanceSerializer, +) +from core.models import ( + CommunityLibraryEntry, + LibraryReport, + Notification, + UserLike, + WidgetInstance, +) +from core.services.user_service import UserService +from core.utils.b64_util import Base64Util +from core.utils.validator_util import ValidatorUtil +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.db.models import F +from rest_framework import mixins, viewsets +from rest_framework.decorators import action +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +logger = logging.getLogger(__name__) + +REPORT_THRESHOLD = 5 + + +class CommunityLibraryPagination(PageNumberPagination): + page_size = 80 + page_size_query_param = "page_size" + max_page_size = 80 + + +class CommunityLibraryViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): + queryset = CommunityLibraryEntry.objects.none() + serializer_class = CommunityLibraryEntrySerializer + pagination_class = CommunityLibraryPagination + + def get_queryset(self): + moderation = ValidatorUtil.validate_bool( + self.request.query_params.get("moderation") + ) + + if moderation: + qs = ( + CommunityLibraryEntry.objects.all() + .select_related( + "instance", + "instance__widget", + "instance__user", + ) + .prefetch_related("snapshots") + ) + status = self.request.query_params.get("status") + if status == "banned": + qs = qs.filter(is_banned=True).order_by("-report_count", "-created_at") + elif status == "reported": + qs = qs.filter(report_count__gt=0).order_by( + "-report_count", "-created_at" + ) + else: + qs = qs.order_by("-report_count", "-created_at") + else: + qs = ( + CommunityLibraryEntry.objects.filter( + instance__is_shared=True, + instance__is_deleted=False, + instance__is_draft=False, + is_banned=False, + ) + .select_related( + "instance", + "instance__widget", + "instance__user", + ) + .prefetch_related("snapshots") + ) + + # Search by latest snapshot name + search = self.request.query_params.get("search") + if search: + qs = qs.filter(snapshots__name__icontains=search).distinct() + + # Filter by widget type + widget_id = self.request.query_params.get("widget_id") + if widget_id: + qs = qs.filter(instance__widget_id=widget_id) + + # Filter by category + category = self.request.query_params.get("category") + if category: + qs = qs.filter(category=category) + + # Filter by course level + course_level = self.request.query_params.get("course_level") + if course_level: + qs = qs.filter(course_level=course_level) + + # Filter featured only + featured = ValidatorUtil.validate_bool( + self.request.query_params.get("featured") + ) + if featured: + qs = qs.filter(featured=True) + + # Sorting + sort = self.request.query_params.get("sort", "newest") + if sort == "most_copied": + qs = qs.order_by("-copy_count", "-created_at") + elif sort == "most_liked": + qs = qs.order_by("-like_count", "-created_at") + elif sort == "alphabetical": + qs = qs.order_by("snapshots__name") + else: + qs = qs.order_by("-created_at") + + return qs + + def get_permissions(self): + if self.action == "list": + moderation = ValidatorUtil.validate_bool( + self.request.query_params.get("moderation") + ) + if moderation: + permission_classes = [IsSuperOrSupportUser] + else: + permission_classes = [IsAuthenticated] + elif self.action in ( + "copy", + "like", + "report", + "snapshot_instance", + "snapshot_qset", + ): + permission_classes = [IsAuthenticated] + elif self.action == "moderate": + permission_classes = [IsSuperOrSupportUser] + else: + permission_classes = [IsAuthenticated] + + return [permission() for permission in permission_classes] + + @action(detail=True, methods=["post"]) + def copy(self, request, pk=None): + entry = self.get_object() + snapshot = entry.snapshots.order_by("-created_at").first() + new_instance = entry.instance.duplicate( + owner=request.user, new_name=snapshot.name + ) + + new_instance.copied_from_entry = entry + new_instance.save(update_fields=["copied_from_entry"]) + + latest_qset = new_instance.get_latest_qset() + latest_qset.data = snapshot.qset_data + latest_qset.version = snapshot.qset_version + latest_qset.save(update_fields=["data", "version"]) + + CommunityLibraryEntry.objects.filter(pk=entry.pk).update( + copy_count=F("copy_count") + 1 + ) + + return Response(WidgetInstanceSerializer(new_instance).data) + + @action(detail=True, methods=["post"]) + def like(self, request, pk=None): + entry = self.get_object() + like, created = UserLike.objects.get_or_create(user=request.user, entry=entry) + + if created: + CommunityLibraryEntry.objects.filter(pk=entry.pk).update( + like_count=F("like_count") + 1 + ) + entry.refresh_from_db() + return Response({"liked": True, "like_count": entry.like_count}) + else: + like.delete() + CommunityLibraryEntry.objects.filter(pk=entry.pk).update( + like_count=F("like_count") - 1 + ) + entry.refresh_from_db() + return Response({"liked": False, "like_count": entry.like_count}) + + @action(detail=True, methods=["post"]) + def report(self, request, pk=None): + entry = self.get_object() + + if LibraryReport.objects.filter(user=request.user, entry=entry).exists(): + return Response( + {"error": "You have already reported this item."}, status=400 + ) + + serializer = LibraryReportSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + LibraryReport.objects.create( + user=request.user, + entry=entry, + reason=serializer.validated_data["reason"], + details=serializer.validated_data.get("details", ""), + ) + + CommunityLibraryEntry.objects.filter(pk=entry.pk).update( + report_count=F("report_count") + 1 + ) + entry.refresh_from_db() + + if entry.report_count >= REPORT_THRESHOLD and not entry.is_banned: + entry.is_banned = True + entry.save(update_fields=["is_banned"]) + self._notify_admins_of_ban(entry, request.user) + + return Response({"success": True}) + + @action(detail=True, methods=["patch"]) + def moderate(self, request, pk=None): + entry = CommunityLibraryEntry.objects.get(pk=pk) + allowed_fields = ["featured", "is_banned", "category", "course_level"] + + for field, value in request.data.items(): + if field in allowed_fields: + setattr(entry, field, value) + + entry.save() + serializer = CommunityLibraryEntrySerializer( + entry, context={"request": request} + ) + return Response(serializer.data) + + @action( + detail=True, + methods=["get"], + url_path="snapshot_instance/(?P[^/.]+)", + ) + def snapshot_instance(self, request, pk=None, snapshot_id=None): + entry = self.get_object() + snapshot = entry.snapshots.filter(pk=snapshot_id).first() + if not snapshot: + return Response({"error": "Snapshot not found."}, status=404) + data = WidgetInstanceSerializer(entry.instance).data + data["name"] = snapshot.name + return Response(data) + + @action( + detail=True, methods=["get"], url_path="snapshot_qset/(?P[^/.]+)" + ) + def snapshot_qset(self, request, pk=None, snapshot_id=None): + entry = self.get_object() + snapshot = entry.snapshots.filter(pk=snapshot_id).first() + if not snapshot: + return Response({"error": "Snapshot not found."}, status=404) + + return Response( + { + "data": ( + Base64Util.decode(snapshot.qset_data) if snapshot.qset_data else {} + ), + "version": snapshot.qset_version, + } + ) + + def _notify_admins_of_ban(self, entry, reporting_user): + """Send notifications to all superusers and support users when an entry is auto-banned.""" + admin_users = User.objects.filter(is_superuser=True) | User.objects.filter( + groups__name="support_user" + ) + admin_users = admin_users.distinct() + + avatar = UserService.get_avatar_url(reporting_user) + instance = entry.instance + + for admin_user in admin_users: + notification = Notification.objects.create( + from_id=reporting_user, + to_id=admin_user, + item_type=ContentType.objects.get_for_model(WidgetInstance).id, + item_id=instance.id, + is_email_sent=False, + subject=f'Community Library item "{instance.name}" was auto-hidden after receiving {REPORT_THRESHOLD} reports.', + avatar=avatar, + action="library_report", + ) + notification.send_email() diff --git a/app/api/views/scores.py b/app/api/views/scores.py index 835c92733..592b9eaf5 100644 --- a/app/api/views/scores.py +++ b/app/api/views/scores.py @@ -7,9 +7,10 @@ ScoresForUserSerializer, ) from core.message_exception import MsgExpired, MsgNoPerm -from core.models import LogPlay, WidgetInstance +from core.models import CommunityLibraryEntry, LogPlay, WidgetInstance from core.services.perm_service import PermService from core.services.semester_service import SemesterService +from core.utils.b64_util import Base64Util from django.utils import timezone from lti.services.auth import LTIAuthService from lti.services.launch import LTILaunchService @@ -146,17 +147,48 @@ def get(self, request): if len(preview_logs) == 0: raise MsgExpired() + snapshot_id = request.query_params.get("snapshot_id") + entry_id = request.query_params.get("entry_id") + snapshot = None + qset_override = None + + if snapshot_id and entry_id: + entry = CommunityLibraryEntry.objects.filter(pk=entry_id).first() + snapshot = ( + entry.snapshots.filter(pk=snapshot_id).first() + if entry + else None + ) + if not snapshot: + return Response({"error": "Snapshot not found."}, status=404) + + qset_override = preview_inst.get_latest_qset() + qset_override.data = snapshot.qset_data + qset_override.version = snapshot.qset_version + module = ScoreModuleFactory.create_score_module_for_preview( instance=preview_inst, preview_id=validated.get("play_id"), logs=preview_logs, user=request.user, + qset_override=qset_override, ) response = module.get_score_report() - response["qset"] = QuestionSetSerializer( - preview_inst.get_latest_qset() - ).data + + if snapshot: + response["qset"] = { + "data": ( + Base64Util.decode(snapshot.qset_data) + if snapshot.qset_data + else {} + ), + "version": snapshot.qset_version, + } + else: + response["qset"] = QuestionSetSerializer( + preview_inst.get_latest_qset() + ).data return Response(response) diff --git a/app/api/views/widget_instances.py b/app/api/views/widget_instances.py index 26c485df1..8047c14a7 100644 --- a/app/api/views/widget_instances.py +++ b/app/api/views/widget_instances.py @@ -15,6 +15,7 @@ ObjectPermissionSerializer, PermsUpdateRequestListSerializer, PlayIdSerializer, + PublishToLibrarySerializer, QuestionSetSerializer, ScoreSummarySerializer, WidgetInstanceCopyRequestSerializer, @@ -22,10 +23,13 @@ ) from core.message_exception import MsgFailure, MsgInvalidInput, MsgNoPerm from core.models import ( + CommunityLibraryEntry, + LibrarySnapshot, LogActivity, LogPlay, Notification, ObjectPermission, + UserSettings, WidgetInstance, WidgetQset, ) @@ -92,6 +96,14 @@ def get_permissions(self): elif self.action == "copy": permission_classes = [HasFullPerms | IsSuperOrSupportUser] + elif self.action in ( + "publish_to_library", + "unpublish_from_library", + "update_in_library", + "pull_from_library", + ): + permission_classes = [HasFullPerms | IsSuperOrSupportUser] + elif self.action == "export_playdata": permission_classes = [HasAnyPerms | IsSuperOrSupportUser] @@ -560,6 +572,118 @@ def copy(self, request, pk=None): return Response(WidgetInstanceSerializer(duplicate).data) + @action(detail=True, methods=["put"]) + def publish_to_library(self, request, pk=None): + instance = self.get_object() + + user_settings = UserSettings.objects.get(user=request.user) + if user_settings.library_banned: + return Response( + {"error": "You are not allowed to publish to the Community Library."}, + status=403, + ) + + serializer = PublishToLibrarySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + category = serializer.validated_data["category"] + course_level = serializer.validated_data.get("course_level", "") + + latest_qset = instance.get_latest_qset() + qset_data = latest_qset.data + qset_version = latest_qset.version + + existing_entry = getattr(instance, "library_entry", None) + + if existing_entry: + # Re-publishing: update entry and create new snapshot + existing_entry.category = category + existing_entry.course_level = course_level + existing_entry.save(update_fields=["category", "course_level"]) + LibrarySnapshot.objects.create( + entry=existing_entry, + name=instance.name, + qset_data=qset_data, + qset_version=qset_version, + ) + else: + # New publish: create entry and snapshot + entry = CommunityLibraryEntry.objects.create( + instance=instance, + category=category, + course_level=course_level, + ) + LibrarySnapshot.objects.create( + entry=entry, + name=instance.name, + qset_data=qset_data, + qset_version=qset_version, + ) + + instance.is_shared = True + instance.save(update_fields=["is_shared"]) + + return Response({"success": True}) + + @action(detail=True, methods=["put"]) + def update_in_library(self, request, pk=None): + instance = self.get_object() + + entry = getattr(instance, "library_entry", None) + if not entry: + return Response( + {"error": "This widget is not published to the library."}, + status=400, + ) + + latest_qset = instance.get_latest_qset() + + LibrarySnapshot.objects.create( + entry=entry, + name=instance.name, + qset_data=latest_qset.data, + qset_version=latest_qset.version, + ) + + return Response({"success": True}) + + @action(detail=True, methods=["put"]) + def unpublish_from_library(self, request, pk=None): + instance = self.get_object() + + instance.is_shared = False + instance.save(update_fields=["is_shared"]) + return Response({"success": True}) + + @action(detail=True, methods=["put"]) + def pull_from_library(self, request, pk=None): + instance = self.get_object() + + entry = instance.copied_from_entry + if not entry: + return Response( + {"error": "This widget was not copied from the Community Library."}, + status=400, + ) + + if not entry.instance.is_shared: + return Response( + {"error": "This library entry is no longer published."}, + status=400, + ) + + snapshot = entry.snapshots.order_by("-created_at").first() + + instance.name = snapshot.name + instance.save(update_fields=["name"]) + + latest_qset = instance.get_latest_qset() + latest_qset.data = snapshot.qset_data + latest_qset.version = snapshot.qset_version + latest_qset.save(update_fields=["data", "version"]) + + return Response(WidgetInstanceSerializer(instance).data) + # WAS /data/export/ # This endpoint can be visited directly and the file will download, or can be called like a normal API endpoint @action(detail=True, methods=["get"]) diff --git a/app/core/migrations/0029_usersettings_library_banned_widgetinstance_is_shared_and_more.py b/app/core/migrations/0029_usersettings_library_banned_widgetinstance_is_shared_and_more.py new file mode 100644 index 000000000..c745f50b7 --- /dev/null +++ b/app/core/migrations/0029_usersettings_library_banned_widgetinstance_is_shared_and_more.py @@ -0,0 +1,101 @@ +# Generated by Django 5.0.1 on 2026-04-22 18:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0028_remove_logplay_log_play_is_complete_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='usersettings', + name='library_banned', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='widgetinstance', + name='is_shared', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='CommunityLibraryEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(choices=[('math', 'Math'), ('science', 'Science'), ('english', 'English'), ('history', 'History'), ('art', 'Art'), ('music', 'Music'), ('language', 'World Languages'), ('cs', 'Computer Science'), ('health', 'Health & PE'), ('business', 'Business'), ('education', 'Education'), ('other', 'Other')], max_length=50)), + ('course_level', models.CharField(blank=True, choices=[('introductory', 'Introductory'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced')], default='', max_length=50)), + ('featured', models.BooleanField(default=False)), + ('copy_count', models.IntegerField(default=0)), + ('like_count', models.IntegerField(default=0)), + ('report_count', models.IntegerField(default=0)), + ('is_banned', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('instance', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='library_entry', to='core.widgetinstance')), + ], + ), + migrations.AddField( + model_name='widgetinstance', + name='copied_from_entry', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='copies', to='core.communitylibraryentry'), + ), + migrations.CreateModel( + name='LibraryReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reason', models.CharField(choices=[('inappropriate', 'Inappropriate content'), ('incorrect', 'Incorrect content'), ('spam', 'Spam'), ('other', 'Other')], max_length=50)), + ('details', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='core.communitylibraryentry')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='library_reports', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='LibrarySnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('qset_data', models.TextField(default='')), + ('qset_version', models.CharField(default='1', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='core.communitylibraryentry')), + ], + ), + migrations.CreateModel( + name='UserLike', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='core.communitylibraryentry')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='library_likes', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddIndex( + model_name='communitylibraryentry', + index=models.Index(fields=['-created_at'], name='idx_entry_newest'), + ), + migrations.AddIndex( + model_name='communitylibraryentry', + index=models.Index(fields=['-copy_count', '-created_at'], name='idx_entry_most_copied'), + ), + migrations.AddIndex( + model_name='communitylibraryentry', + index=models.Index(fields=['-like_count', '-created_at'], name='idx_entry_most_liked'), + ), + migrations.AlterUniqueTogether( + name='libraryreport', + unique_together={('user', 'entry')}, + ), + migrations.AddIndex( + model_name='librarysnapshot', + index=models.Index(fields=['entry', '-created_at'], name='idx_snapshot_entry_latest'), + ), + migrations.AlterUniqueTogether( + name='userlike', + unique_together={('user', 'entry')}, + ), + ] diff --git a/app/core/models.py b/app/core/models.py index 79f752d6b..79cea8833 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -1063,6 +1063,14 @@ class WidgetInstance(models.Model): is_student_made = models.BooleanField(default=False) updated_at = models.DateTimeField(default=None, null=True) embedded_only = models.BooleanField(default=False) + is_shared = models.BooleanField(default=False) + copied_from_entry = models.ForeignKey( + "CommunityLibraryEntry", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="copies", + ) published_by = models.ForeignKey( User, related_name="published_instances", @@ -1230,6 +1238,8 @@ def duplicate( # These fields should default to False for new instances (since the new instance won't have any play history) dupe.embedded_only = False + dupe.is_shared = False + dupe.copied_from_entry = None # Manually update created_at dupe.created_at = timezone.now() @@ -1520,6 +1530,7 @@ class UserSettings(models.Model): User, on_delete=models.CASCADE, related_name="profile_settings" ) profile_fields = models.JSONField(default=dict) + library_banned = models.BooleanField(default=False) def set_profile_fields(self, key, value): self.profile_fields[key] = value @@ -1544,6 +1555,113 @@ def initialize_profile_fields(self): self.save() +class CommunityLibraryEntry(models.Model): + CATEGORY_CHOICES = [ + ("math", "Math"), + ("science", "Science"), + ("english", "English"), + ("history", "History"), + ("art", "Art"), + ("music", "Music"), + ("language", "World Languages"), + ("cs", "Computer Science"), + ("health", "Health & PE"), + ("business", "Business"), + ("education", "Education"), + ("other", "Other"), + ] + + COURSE_LEVEL_CHOICES = [ + ("introductory", "Introductory"), + ("intermediate", "Intermediate"), + ("advanced", "Advanced"), + ] + + instance = models.OneToOneField( + WidgetInstance, + on_delete=models.CASCADE, + related_name="library_entry", + ) + category = models.CharField(max_length=50, choices=CATEGORY_CHOICES) + course_level = models.CharField( + max_length=50, choices=COURSE_LEVEL_CHOICES, blank=True, default="" + ) + featured = models.BooleanField(default=False) + copy_count = models.IntegerField(default=0) + like_count = models.IntegerField(default=0) + report_count = models.IntegerField(default=0) + is_banned = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=["-created_at"], name="idx_entry_newest"), + models.Index( + fields=["-copy_count", "-created_at"], name="idx_entry_most_copied" + ), + models.Index( + fields=["-like_count", "-created_at"], name="idx_entry_most_liked" + ), + ] + + def __str__(self): + return f"Library: {self.instance.name}" + + +class UserLike(models.Model): + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="library_likes" + ) + entry = models.ForeignKey( + CommunityLibraryEntry, on_delete=models.CASCADE, related_name="likes" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("user", "entry") + + +class LibraryReport(models.Model): + REASON_CHOICES = [ + ("inappropriate", "Inappropriate content"), + ("incorrect", "Incorrect content"), + ("spam", "Spam"), + ("other", "Other"), + ] + + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="library_reports" + ) + entry = models.ForeignKey( + CommunityLibraryEntry, on_delete=models.CASCADE, related_name="reports" + ) + reason = models.CharField(max_length=50, choices=REASON_CHOICES) + details = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("user", "entry") + + +class LibrarySnapshot(models.Model): + entry = models.ForeignKey( + CommunityLibraryEntry, + on_delete=models.CASCADE, + related_name="snapshots", + ) + name = models.CharField(max_length=100) + qset_data = models.TextField(default="") + qset_version = models.CharField(max_length=10, default="1") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index( + fields=["entry", "-created_at"], name="idx_snapshot_entry_latest" + ), + ] + + @receiver(post_save, sender=User) def create_user_settings(sender, instance, created, **kwargs): if created: diff --git a/app/core/views/community_library.py b/app/core/views/community_library.py new file mode 100644 index 000000000..ad33a4e68 --- /dev/null +++ b/app/core/views/community_library.py @@ -0,0 +1,17 @@ +from core.utils.context_util import ContextUtil +from django.conf import settings +from django.shortcuts import render +from django.views.generic import TemplateView + + +class CommunityLibraryView(TemplateView): + @staticmethod + def index(request): + context = ContextUtil.create( + title="Community Library", + js_resources=settings.JS_GROUPS["community-library"], + css_resources=settings.CSS_GROUPS["community-library"], + request=request, + ) + + return render(request, "react.html", context) diff --git a/app/core/views/widget.py b/app/core/views/widget.py index 95fd8fc97..b30576a1b 100644 --- a/app/core/views/widget.py +++ b/app/core/views/widget.py @@ -6,7 +6,15 @@ MateriaLoginNeeded, MateriaWidgetPlayProcessor, ) -from core.models import LogPlay, Lti, LtiPlayState, User, Widget, WidgetInstance +from core.models import ( + LibrarySnapshot, + LogPlay, + Lti, + LtiPlayState, + User, + Widget, + WidgetInstance, +) from core.services.perm_service import PermService from core.services.widget_play_services import ( WidgetPlayInitService, @@ -367,6 +375,37 @@ def before_play_init(self, instance): return {"play_id": preview, "lti_token": None} +@method_decorator(never_cache, name="dispatch") +class SnapshotPreviewView(MateriaLoginMixin, TemplateView): + template_name = "react.html" + login_title = "Login to preview this widget" + login_message = "Login to preview this widget" + + def get_context_data(self, snapshot_id): + try: + snapshot = LibrarySnapshot.objects.select_related( + "entry", "entry__instance", "entry__instance__widget" + ).get(pk=snapshot_id) + except LibrarySnapshot.DoesNotExist: + raise Http404 + + widget = snapshot.entry.instance.widget + return ContextUtil.create( + title="Community Library Preview", + js_resources=settings.JS_GROUPS["player"], + css_resources=settings.CSS_GROUPS["player"], + page_type="widget", + js_globals={ + "SNAPSHOT_ID": snapshot.id, + "SNAPSHOT_ENTRY_ID": snapshot.entry.id, + "WIDGET_WIDTH": widget.width, + "WIDGET_HEIGHT": widget.height, + "MEDIA_URL": settings.URLS["MEDIA_URL"], + }, + request=self.request, + ) + + @method_decorator(never_cache, name="dispatch") class WidgetCreatorView(MateriaLoginMixin, PermissionRequiredMixin, TemplateView): template_name = "react.html" diff --git a/app/materia/settings/css.py b/app/materia/settings/css.py index 28890c76a..c998204da 100644 --- a/app/materia/settings/css.py +++ b/app/materia/settings/css.py @@ -9,6 +9,7 @@ "my-widgets": [CSS_BASEURL + "my-widgets.css"], "help": [CSS_BASEURL + "help.css"], "catalog": [CSS_BASEURL + "catalog.css"], + "community-library": [CSS_BASEURL + "community-library.css"], "detail": [CSS_BASEURL + "detail.css"], "player": [CSS_BASEURL + "player-page.css"], "creator": [CSS_BASEURL + "creator-page.css"], diff --git a/app/materia/settings/js.py b/app/materia/settings/js.py index c27678de5..428a37880 100644 --- a/app/materia/settings/js.py +++ b/app/materia/settings/js.py @@ -9,6 +9,7 @@ "my-widgets": [JS_BASEURL + "my-widgets.js"], "help": [JS_BASEURL + "help.js"], "catalog": [JS_BASEURL + "catalog.js"], + "community-library": [JS_BASEURL + "community-library.js"], "detail": [JS_BASEURL + "detail.js"], "player": [JS_BASEURL + "player-page.js"], "creator": [JS_BASEURL + "creator-page.js"], diff --git a/app/materia/urls.py b/app/materia/urls.py index 6fa480a5e..8d8364f87 100644 --- a/app/materia/urls.py +++ b/app/materia/urls.py @@ -23,10 +23,12 @@ from core.views.admin import user as user_admin from core.views.admin import widget as widget_admin from core.views.catalog import CatalogView +from core.views.community_library import CommunityLibraryView from core.views.media import MediaImportView, MediaRender, MediaUpload from core.views.my_widgets import MyWidgetsView from core.views.scores import ScoresView, ScoresViewSingle from core.views.widget import ( + SnapshotPreviewView, WidgetCreatorView, WidgetDemoView, WidgetDetailView, @@ -48,6 +50,7 @@ path("help/", core_views.help, name="help"), # Widgets path("widgets/", CatalogView.index, name="widget catalog"), + path("community-library/", CommunityLibraryView.index, name="community library"), path( "widgets//", WidgetDetailView.as_view(), name="widget detail" ), @@ -83,6 +86,11 @@ {"is_embed": True}, name="widget embed", ), + path( + "preview/snapshot//", + SnapshotPreviewView.as_view(), + name="snapshot preview", + ), path( "preview//", WidgetPreviewView.as_view(), diff --git a/app/scoring/module_factory.py b/app/scoring/module_factory.py index 883d1f18e..7d098421d 100644 --- a/app/scoring/module_factory.py +++ b/app/scoring/module_factory.py @@ -4,11 +4,10 @@ from pathlib import Path from typing import Optional, Type -from django.utils import timezone - -from core.models import Log, LogPlay, User, WidgetInstance -from scoring.module import ScoreModule, EmptyScoreModule +from core.models import Log, LogPlay, User, WidgetInstance, WidgetQset from core.services.semester_service import SemesterService +from django.utils import timezone +from scoring.module import EmptyScoreModule, ScoreModule logger = logging.getLogger(__name__) @@ -60,6 +59,7 @@ def create_score_module_for_preview( preview_id: str, logs: list, user: User, + qset_override: Optional[WidgetQset] = None, ) -> Optional[ScoreModule]: """ Since previews don't use Logs and LogPlays from the ORM, @@ -77,12 +77,16 @@ def create_score_module_for_preview( created_at=timezone.now(), user=user, elapsed=0, - qset=instance.get_latest_qset(), + qset=qset_override if qset_override else instance.get_latest_qset(), auth="", semester=SemesterService.get_current_semester(), ) module = cls.create_score_module(instance=instance, play=synthetic_play) + + if qset_override: + module.questions = [] + synthetic_logs = [] for log in logs: synthetic_log = Log( diff --git a/src/community-library.js b/src/community-library.js new file mode 100644 index 000000000..72348cccf --- /dev/null +++ b/src/community-library.js @@ -0,0 +1,14 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider, QueryCache } from 'react-query' +import CommunityLibraryPage from './components/community-library-page' + +const queryCache = new QueryCache() +export const queryClient = new QueryClient({ queryCache }) + +const root = createRoot(document.getElementById('app')) +root.render( + + + , +) diff --git a/src/components/community-library-card.jsx b/src/components/community-library-card.jsx new file mode 100644 index 000000000..475dbadfa --- /dev/null +++ b/src/components/community-library-card.jsx @@ -0,0 +1,90 @@ +import React from 'react' +import { iconUrl } from '../util/icon-url' + +const HEART_FILLED = + 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' +const HEART_OUTLINE = + 'M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z' +const FLAG_ICON = 'M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6z' + +const CommunityLibraryCard = ({ entry, onCopy, onLike, onReport, copySuccess = false }) => { + const { + instance_id, + instance_name, + widget, + owner_display_name, + category_display, + course_level_display, + copy_count, + like_count, + user_has_liked, + featured, + latest_snapshot_id, + } = entry + + return ( +
+
+
+ {widget?.name} +
+
+

{instance_name}

+ {widget?.name} + by {owner_display_name} +
+
+ +
+ {category_display && {category_display}} + {course_level_display && {course_level_display}} +
+ +
+ + + {copy_count} {copy_count === 1 ? 'copy' : 'copies'} + +
+ +
+ + Preview + + + +
+
+ ) +} + +export default CommunityLibraryCard diff --git a/src/components/community-library-page.jsx b/src/components/community-library-page.jsx new file mode 100644 index 000000000..45deac2a8 --- /dev/null +++ b/src/components/community-library-page.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import { useQuery } from 'react-query' +import { apiGetWidget } from '../util/api' +import Header from './header' +import CommunityLibrary from './community-library' + +const CommunityLibraryPage = () => { + const { data: widgets } = useQuery({ + queryKey: 'catalog-widgets', + queryFn: () => apiGetWidget([], 'catalog'), + staleTime: Infinity, + }) + + return ( + <> +
+ + + ) +} + +export default CommunityLibraryPage diff --git a/src/components/community-library-publish-dialog.jsx b/src/components/community-library-publish-dialog.jsx new file mode 100644 index 000000000..4a72f8fd1 --- /dev/null +++ b/src/components/community-library-publish-dialog.jsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react' +import Modal from './modal' +import { usePublishToLibrary } from './hooks/useCommunityLibrary' +import './community-library-publish-dialog.scss' + +const CATEGORIES = [ + { value: 'math', label: 'Math' }, + { value: 'science', label: 'Science' }, + { value: 'english', label: 'English' }, + { value: 'history', label: 'History' }, + { value: 'art', label: 'Art' }, + { value: 'music', label: 'Music' }, + { value: 'language', label: 'World Languages' }, + { value: 'cs', label: 'Computer Science' }, + { value: 'health', label: 'Health & PE' }, + { value: 'business', label: 'Business' }, + { value: 'education', label: 'Education' }, + { value: 'other', label: 'Other' }, +] + +const COURSE_LEVELS = [ + { value: '', label: 'Not specified' }, + { value: 'introductory', label: 'Introductory' }, + { value: 'intermediate', label: 'Intermediate' }, + { value: 'advanced', label: 'Advanced' }, +] + +const CommunityLibraryPublishDialog = ({ inst, onClose, onSuccess }) => { + const [category, setCategory] = useState('') + const [courseLevel, setCourseLevel] = useState('') + const [errorText, setErrorText] = useState('') + + const publishMutation = usePublishToLibrary() + + const handlePublish = () => { + if (!category) { + setErrorText('Please select a category.') + return + } + + setErrorText('') + + publishMutation.mutate( + { + instId: inst.id, + data: { category, course_level: courseLevel }, + }, + { + onSuccess: () => { + if (onSuccess) onSuccess() + }, + onError: (err) => { + setErrorText(err?.data?.error || 'Failed to publish. Please try again.') + }, + }, + ) + } + + return ( + +
+

Share to Community Library

+

+ Share "{inst.name}" so other teachers can discover and use it. +

+ + + + + + {errorText &&

{errorText}

} + +
+ + +
+
+
+ ) +} + +export default CommunityLibraryPublishDialog diff --git a/src/components/community-library-publish-dialog.scss b/src/components/community-library-publish-dialog.scss new file mode 100644 index 000000000..61542df25 --- /dev/null +++ b/src/components/community-library-publish-dialog.scss @@ -0,0 +1,95 @@ +@import 'include.scss'; + +.publish-dialog { + background: #fff; + border-radius: 8px; + padding: 24px; + max-width: 480px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + + h2 { + margin: 0 0 8px; + font-size: 20px; + } + + .dialog-subtitle { + margin: 0 0 16px; + color: #666; + font-size: 14px; + } + + label { + display: block; + margin-bottom: 12px; + font-size: 13px; + font-weight: 600; + color: #333; + + .required { + color: #e53935; + } + + select, + input[type='text'] { + display: block; + width: 100%; + margin-top: 4px; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #3690e6; + } + } + } + + .error-text { + color: #e53935; + font-size: 13px; + margin: 8px 0; + } + + .dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + + .btn { + padding: 8px 20px; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + border: none; + + &.cancel { + background: #f1f3f4; + color: #333; + + &:hover { + background: #e8eaed; + } + } + + &.publish { + background: #1a73e8; + color: #fff; + + &:hover { + background: #1557b0; + } + + &:disabled { + opacity: 0.7; + cursor: default; + } + } + } + } +} diff --git a/src/components/community-library-report-dialog.jsx b/src/components/community-library-report-dialog.jsx new file mode 100644 index 000000000..89809b54d --- /dev/null +++ b/src/components/community-library-report-dialog.jsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react' +import Modal from './modal' +import { useReportEntry } from './hooks/useCommunityLibrary' + +const REASONS = [ + { value: 'inappropriate', label: 'Inappropriate content' }, + { value: 'incorrect', label: 'Incorrect content' }, + { value: 'spam', label: 'Spam' }, + { value: 'other', label: 'Other' }, +] + +const CommunityLibraryReportDialog = ({ entry, onClose, onSuccess }) => { + const [reason, setReason] = useState('') + const [details, setDetails] = useState('') + const [errorText, setErrorText] = useState('') + + const reportMutation = useReportEntry() + + const handleSubmit = () => { + if (!reason) { + setErrorText('Please select a reason.') + return + } + + setErrorText('') + + reportMutation.mutate( + { + entryId: entry.id, + data: { reason, details }, + }, + { + onSuccess: () => { + if (onSuccess) onSuccess() + }, + onError: (err) => { + setErrorText(err?.data?.error || 'Failed to submit report. Please try again.') + }, + }, + ) + } + + return ( + +
+

Report Widget

+

+ Report "{entry.instance_name}" for review. +

+ +
+ {REASONS.map((r) => ( + + ))} +
+ +