Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion forum/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Openedx forum app.
"""

__version__ = "0.4.1"
__version__ = "0.4.2"
66 changes: 54 additions & 12 deletions forum/ai_moderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]

Expand All @@ -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"))
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions forum/toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
"""
Expand Down
Loading
Loading