diff --git a/forum/__init__.py b/forum/__init__.py index 255a215f..1257036d 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.1" +__version__ = "0.4.2" diff --git a/forum/ai_moderation.py b/forum/ai_moderation.py index 5e9233e8..0f7a1816 100644 --- a/forum/ai_moderation.py +++ b/forum/ai_moderation.py @@ -9,10 +9,13 @@ import requests from django.conf import settings from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from opaque_keys.edx.keys import CourseKey +from rest_framework.serializers import ValidationError from forum.backends.mysql.models import ModerationAuditLog +from forum.utils import ForumV2RequestError User = get_user_model() log = logging.getLogger(__name__) @@ -218,6 +221,7 @@ def moderate_and_flag_content( # pylint: disable=import-outside-toplevel from forum.toggles import ( is_ai_moderation_enabled, + is_ai_auto_delete_spam_enabled, ) course_key = CourseKey.from_string(course_id) if course_id else None @@ -246,16 +250,24 @@ def moderate_and_flag_content( ) if is_spam: + # Flag content as spam and abuse first try: content_instance["is_spam"] = True - self._mark_as_spam_and_flag_abuse(content_instance, backend) - + self._mark_as_spam_and_moderate(content_instance, backend) result["actions_taken"] = ["flagged"] result["flagged"] = True except (AttributeError, ValueError, TypeError) as e: log.error(f"Failed to flag content as spam: {e}") result["actions_taken"] = ["no_action"] + + # Only attempt deletion if flagging succeeded + if is_ai_auto_delete_spam_enabled(course_key) and result["flagged"]: # type: ignore[no-untyped-call] + try: + self._delete_content(content_instance) + result["actions_taken"] = result["actions_taken"] + ["soft_deleted"] # type: ignore[operator] + except (ForumV2RequestError, ObjectDoesNotExist, ValidationError) as e: + log.error(f"Failed to delete content after flagging: {e}") else: result["actions_taken"] = ["no_action"] @@ -269,7 +281,7 @@ def moderate_and_flag_content( ) return result - def _mark_as_spam_and_flag_abuse(self, content_instance: Any, backend: Any) -> None: + def _mark_as_spam_and_moderate(self, content_instance: Any, backend: Any) -> None: """Flag content as abuse using backend methods.""" content_id = str(content_instance.get("_id")) content_type = str(content_instance.get("_type")) @@ -278,15 +290,45 @@ def _mark_as_spam_and_flag_abuse(self, content_instance: Any, backend: Any) -> N "CommentThread" if content_type == "CommentThread" else "Comment" ) } - try: - if not self.ai_moderation_user_id: - raise ValueError("AI_MODERATION_USER_ID setting is not configured.") - backend.flag_content_as_spam(content_type, content_id) - backend.flag_as_abuse( - str(self.ai_moderation_user_id), content_id, **extra_data - ) - except (AttributeError, ValueError, TypeError, ImportError) as e: - log.error(f"Failed to flag content via backend: {e}") + if not self.ai_moderation_user_id: + raise ValueError("AI_MODERATION_USER_ID setting is not configured.") + backend.flag_content_as_spam(content_type, content_id) + backend.flag_as_abuse(str(self.ai_moderation_user_id), content_id, **extra_data) + + def _delete_content(self, content_instance: Any) -> None: + """ + Soft delete content using API layer delete functions. + + Uses the API layer which handles all business logic including: + - Content validation + - Soft deletion + - Stats updates + - Subscription cleanup (for threads) + - Anonymous content handling + + Args: + content_instance: Dict containing content data including _id, _type, and course_id + """ + # Import here to avoid circular dependency (api modules import from ai_moderation) + # pylint: disable=import-outside-toplevel,cyclic-import + from forum.api.comments import delete_comment + from forum.api.threads import delete_thread + + content_id = str(content_instance.get("_id")) + content_type = str(content_instance.get("_type")) + course_id = content_instance.get("course_id") + deleted_by = ( + str(self.ai_moderation_user_id) if self.ai_moderation_user_id else None + ) + + # Use API layer functions which handle all business logic + # Exceptions propagate to caller for proper error handling + if content_type == "CommentThread": + delete_thread(content_id, course_id=course_id, deleted_by=deleted_by) + log.info(f"AI Moderation Deleted CommentThread: {content_id}") + elif content_type == "Comment": + delete_comment(content_id, course_id=course_id, deleted_by=deleted_by) + log.info(f"AI Moderation Deleted Comment: {content_id}") # Global instance diff --git a/forum/toggles.py b/forum/toggles.py index 013810f6..d4f031dc 100644 --- a/forum/toggles.py +++ b/forum/toggles.py @@ -29,6 +29,24 @@ f"{DISCUSSION_WAFFLE_FLAG_NAMESPACE}.enable_ai_moderation", __name__ ) +# .. toggle_name: discussions.enable_ai_auto_delete_spam +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable AI auto delete spam for discussions. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2026-02-05 +# .. toggle_target_removal_date: 2026-06-29 +ENABLE_AI_AUTO_DELETE_SPAM = CourseWaffleFlag( + f"{DISCUSSION_WAFFLE_FLAG_NAMESPACE}.enable_ai_auto_delete_spam", __name__ +) + + +def is_ai_auto_delete_spam_enabled(course_key): # type: ignore[no-untyped-def] + """ + Check if AI auto delete spam is enabled for the given course. + """ + return ENABLE_AI_AUTO_DELETE_SPAM.is_enabled(course_key) + def is_ai_moderation_enabled(course_key): # type: ignore[no-untyped-def] """ diff --git a/tests/test_ai_moderation.py b/tests/test_ai_moderation.py new file mode 100644 index 00000000..508e6f2c --- /dev/null +++ b/tests/test_ai_moderation.py @@ -0,0 +1,485 @@ +"""Tests for AI moderation functionality.""" + +import sys +from typing import Any +from unittest.mock import Mock, MagicMock, patch + +import pytest +from django.contrib.auth import get_user_model + +from forum.ai_moderation import AIModerationService, moderate_and_flag_spam +from forum.backends.mysql.models import ModerationAuditLog +from forum.utils import ForumV2RequestError + +User = get_user_model() + +pytestmark = pytest.mark.django_db + + +# Mock openedx module to prevent import errors +if "openedx" not in sys.modules: + # Create a mock CourseWaffleFlag class + class MockCourseWaffleFlag: + """Mock implementation of openedx CourseWaffleFlag for testing.""" + + def __init__(self, flag_name: str, module_name: str) -> None: + self.flag_name = flag_name + self.module_name = module_name + + def is_enabled(self, _course_key: Any) -> bool: + # This will be overridden by our fixture patches + return False + + mock_openedx = MagicMock() + mock_waffle_utils = MagicMock() + mock_waffle_utils.CourseWaffleFlag = MockCourseWaffleFlag + + sys.modules["openedx"] = mock_openedx + sys.modules["openedx.core"] = MagicMock() + sys.modules["openedx.core.djangoapps"] = MagicMock() + sys.modules["openedx.core.djangoapps.waffle_utils"] = mock_waffle_utils + + +@pytest.fixture +def mock_ai_moderation_settings() -> Any: + """Mock AI moderation settings.""" + with patch("forum.ai_moderation.settings") as mock_settings: + mock_settings.AI_MODERATION_API_URL = "http://test-api.example.com" + mock_settings.AI_MODERATION_API_KEY = "test-api-key" + mock_settings.AI_MODERATION_USER_ID = "999" + yield mock_settings + + +@pytest.fixture +def mock_waffle_flags() -> Any: + """Mock waffle flags for AI moderation.""" + # Now we can safely import forum.toggles since openedx is mocked + import forum.toggles # pylint: disable=import-outside-toplevel + + mock_enabled = Mock(return_value=True) + mock_auto_delete = Mock(return_value=True) + + with patch.object( + forum.toggles, "is_ai_moderation_enabled", mock_enabled + ), patch.object(forum.toggles, "is_ai_auto_delete_spam_enabled", mock_auto_delete): + yield {"enabled": mock_enabled, "auto_delete": mock_auto_delete} + + +@pytest.fixture +def ai_service( + mock_ai_moderation_settings: Any, # pylint: disable=redefined-outer-name,unused-argument +) -> AIModerationService: + """Create an AI moderation service instance.""" + return AIModerationService() # type: ignore[no-untyped-call] + + +@pytest.fixture +def sample_thread_content() -> dict[str, Any]: + """Create sample thread content for testing.""" + return { + "_id": "thread123", + "_type": "CommentThread", + "course_id": "course-v1:edX+DemoX+Demo", + "title": "Test Thread", + "body": "This is test content", + "author_id": "1", + "author_username": "testuser", + } + + +@pytest.fixture +def sample_comment_content() -> dict[str, Any]: + """Create sample comment content for testing.""" + return { + "_id": "comment456", + "_type": "Comment", + "course_id": "course-v1:edX+DemoX+Demo", + "body": "This is a test comment", + "author_id": "1", + "author_username": "testuser", + "comment_thread_id": "thread123", + } + + +class TestAIModerationAutoDelete: # pylint: disable=redefined-outer-name,unused-argument + """Tests for AI moderation auto-delete functionality.""" + + def test_auto_delete_triggered_when_enabled( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that auto-delete is triggered when waffle flag is enabled.""" + # Mock API response indicating spam + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "This content is spam", "confidence_score": 0.95}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, "_delete_content" + ) as mock_delete: + + result = ai_service.moderate_and_flag_content( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Verify auto-delete was called + mock_delete.assert_called_once_with(sample_thread_content) + + # Verify actions_taken includes both flagged and soft_deleted + assert "flagged" in result["actions_taken"] + assert "soft_deleted" in result["actions_taken"] + assert result["is_spam"] is True + + def test_auto_delete_not_triggered_when_disabled( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that auto-delete is NOT triggered when waffle flag is disabled.""" + # Disable auto-delete flag + mock_waffle_flags["auto_delete"].return_value = False + + # Mock API response indicating spam + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "This content is spam", "confidence_score": 0.95}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, "_delete_content" + ) as mock_delete: + + result = ai_service.moderate_and_flag_content( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Verify auto-delete was NOT called + mock_delete.assert_not_called() + + # Verify actions_taken includes only flagged + assert "flagged" in result["actions_taken"] + assert "soft_deleted" not in result["actions_taken"] + assert result["is_spam"] is True + + def test_auto_delete_not_triggered_for_non_spam( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that auto-delete is NOT triggered for non-spam content.""" + # Mock API response indicating NOT spam + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "not_spam", ' + '"reasoning": "This is legitimate content", ' + '"confidence_score": 0.9}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, "_delete_content" + ) as mock_delete: + + result = ai_service.moderate_and_flag_content( + "legitimate content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Verify auto-delete was NOT called + mock_delete.assert_not_called() + + # Verify no actions taken + assert result["actions_taken"] == ["no_action"] + assert result["is_spam"] is False + + def test_actions_taken_reflects_flagged_only_when_delete_disabled( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + ) -> None: + """Test that actions_taken correctly reflects flagging without deletion.""" + # Disable auto-delete + mock_waffle_flags["auto_delete"].return_value = False + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response): + result = ai_service.moderate_and_flag_content( + "spam content", + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + assert result["actions_taken"] == ["flagged"] + assert result["flagged"] is True + + def test_actions_taken_reflects_both_when_delete_enabled( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + ) -> None: + """Test that actions_taken correctly reflects both flagging and deletion.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, "_delete_content" + ): + + result = ai_service.moderate_and_flag_content( + "spam content", + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + assert "flagged" in result["actions_taken"] + assert "soft_deleted" in result["actions_taken"] + assert len(result["actions_taken"]) == 2 + + +class TestAIModerationErrorHandling: # pylint: disable=redefined-outer-name,unused-argument + """Tests for error handling in AI moderation auto-delete.""" + + def test_deletion_failure_after_successful_flagging( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that flagging succeeds even if deletion fails.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, + "_delete_content", + side_effect=ForumV2RequestError("Delete failed"), + ): + + result = ai_service.moderate_and_flag_content( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Flagging should still succeed + assert result["is_spam"] is True + assert "flagged" in result["actions_taken"] + # soft_deleted should not be in actions since deletion failed + assert "soft_deleted" not in result["actions_taken"] + + def test_flagging_failure_prevents_deletion( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that if flagging fails, deletion is not attempted.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + backend.flag_content_as_spam.side_effect = ValueError("Flag failed") + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, "_delete_content" + ) as mock_delete: + + result = ai_service.moderate_and_flag_content( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Delete should not be called if flagging fails + mock_delete.assert_not_called() + assert result["actions_taken"] == ["no_action"] + + +class TestDeleteContentMethod: # pylint: disable=redefined-outer-name,protected-access + """Tests for the _delete_content method.""" + + def test_delete_thread_calls_api_correctly( + self, + ai_service: AIModerationService, + sample_thread_content: dict[str, Any], + ) -> None: + """Test that deleting a thread calls the API layer correctly.""" + with patch("forum.api.threads.delete_thread") as mock_delete_thread: + ai_service._delete_content(sample_thread_content) + + mock_delete_thread.assert_called_once_with( + "thread123", + course_id="course-v1:edX+DemoX+Demo", + deleted_by="999", + ) + + def test_delete_comment_calls_api_correctly( + self, + ai_service: AIModerationService, + sample_comment_content: dict[str, Any], + ) -> None: + """Test that deleting a comment calls the API layer correctly.""" + with patch("forum.api.comments.delete_comment") as mock_delete_comment: + ai_service._delete_content(sample_comment_content) + + mock_delete_comment.assert_called_once_with( + "comment456", + course_id="course-v1:edX+DemoX+Demo", + deleted_by="999", + ) + + def test_delete_handles_api_errors( + self, + ai_service: AIModerationService, + sample_thread_content: dict[str, Any], + ) -> None: + """Test that deletion errors propagate to caller.""" + with patch("forum.api.threads.delete_thread") as mock_delete_thread: + mock_delete_thread.side_effect = ForumV2RequestError("API Error") + + # Should raise exception to caller + with pytest.raises(ForumV2RequestError): + ai_service._delete_content(sample_thread_content) + + +class TestModerateAndFlagSpamFunction: # pylint: disable=redefined-outer-name + """Tests for the module-level moderate_and_flag_spam function.""" + + def test_moderate_and_flag_spam_with_auto_delete( # pylint: disable=unused-argument + self, + mock_ai_moderation_settings: Any, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test the module-level function with auto-delete enabled.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + # Create instance with mocked settings already active + test_service: AIModerationService = AIModerationService() # type: ignore[no-untyped-call] + + with patch("requests.post", return_value=mock_response), patch( + "forum.api.threads.delete_thread" + ), patch("forum.ai_moderation.ai_moderation_service", test_service): + + result = moderate_and_flag_spam( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + assert result["is_spam"] is True + assert "flagged" in result["actions_taken"] + assert "soft_deleted" in result["actions_taken"] + + +class TestAuditLogging: # pylint: disable=redefined-outer-name,unused-argument + """Tests for audit logging with auto-delete.""" + + def test_audit_log_created_for_auto_deleted_content( + self, + ai_service: AIModerationService, + mock_ai_moderation_settings: Any, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that audit log is created with correct actions for auto-deleted content.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + user = User.objects.create(username="testuser") + sample_thread_content["author_id"] = str(user.pk) + + with patch("requests.post", return_value=mock_response), patch( + "forum.api.threads.delete_thread" + ): + + ai_service.moderate_and_flag_content( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Verify audit log was created + audit_logs = ModerationAuditLog.objects.filter(body="This is test content") + assert audit_logs.exists() + + audit_log = audit_logs.first() + assert audit_log is not None + assert "flagged" in audit_log.actions_taken + assert "soft_deleted" in audit_log.actions_taken