Skip to content
Draft
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
233 changes: 233 additions & 0 deletions lms/djangoapps/discussion/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"""
Django Admin configuration for discussion moderation models.

Following edX best practices:
- Read-only for most users (view-only audit logs)
- Write access restricted to superusers
- Staff can view but not modify
"""

from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _

from forum.backends.mysql.models import (
DiscussionBan,
DiscussionBanException,
)


class ReadOnlyForNonSuperuserMixin:
"""
Mixin to make admin read-only for non-superusers.

Superusers can add/change/delete, but regular staff can only view.
This is useful for audit/compliance access while preventing accidental changes.
"""

def has_add_permission(self, request):
"""Only superusers can add new records."""
if request.user.is_superuser:
return super().has_add_permission(request)
return False

def has_change_permission(self, request, obj=None):
"""Only superusers can modify records. Staff can view."""
if request.user.is_superuser:
return super().has_change_permission(request, obj)
# Staff users can view (needed for list view) but fields will be readonly
return request.user.is_staff

def has_delete_permission(self, request, obj=None):
"""Only superusers can delete records."""
if request.user.is_superuser:
return super().has_delete_permission(request, obj)
return False

def get_readonly_fields(self, request, obj=None):
"""Make all fields readonly for non-superusers."""
if not request.user.is_superuser:
# Return all fields as readonly for staff (non-superuser)
return [field.name for field in self.model._meta.fields]
return super().get_readonly_fields(request, obj)


@admin.register(DiscussionBan)
class DiscussionBanAdmin(ReadOnlyForNonSuperuserMixin, admin.ModelAdmin):
"""
Admin interface for Discussion Bans.

Permissions:
- Superusers: Full access (view, add, change, delete)
- Staff: View-only (for audit/support purposes)
- Others: No access
"""

list_display = [
'id',
'user_link',
'scope',
'course_or_org',
'is_active',
'banned_at',
'banned_by_link',
'reason_preview',
]

list_filter = [
'scope',
'is_active',
'banned_at',
]

search_fields = [
'user__username',
'user__email',
'course_id',
'org_key',
'reason',
'banned_by__username',
]

readonly_fields = [
'banned_at',
'unbanned_at',
'created',
'modified',
]

fieldsets = (
(_('Ban Information'), {
'fields': (
'user',
'scope',
'course_id',
'org_key',
'is_active',
)
}),
(_('Moderation Details'), {
'fields': (
'banned_by',
'reason',
'banned_at',
'unbanned_by',
'unbanned_at',
)
}),
(_('Timestamps'), {
'fields': (
'created',
'modified',
),
'classes': ('collapse',),
}),
)

date_hierarchy = 'banned_at'

def user_link(self, obj):
"""Display user with link to user admin."""
if obj.user:
from django.urls import reverse
url = reverse('admin:auth_user_change', args=[obj.user.id])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
return '-'
user_link.short_description = _('User')

def banned_by_link(self, obj):
"""Display moderator with link to user admin."""
if obj.banned_by:
from django.urls import reverse
url = reverse('admin:auth_user_change', args=[obj.banned_by.id])
return format_html('<a href="{}">{}</a>', url, obj.banned_by.username)
return '-'
banned_by_link.short_description = _('Banned By')

def course_or_org(self, obj):
"""Display either course_id or organization based on scope."""
if obj.scope == 'course':
return obj.course_id or '-'
else:
return obj.org_key or '-'
course_or_org.short_description = _('Course/Org')

def reason_preview(self, obj):
"""Display truncated reason."""
if obj.reason:
return obj.reason[:100] + '...' if len(obj.reason) > 100 else obj.reason
return '-'
reason_preview.short_description = _('Reason')


@admin.register(DiscussionBanException)
class DiscussionBanExceptionAdmin(ReadOnlyForNonSuperuserMixin, admin.ModelAdmin):
"""
Admin interface for Ban Exceptions.

Allows viewing course-specific exceptions to organization-level bans.
"""

list_display = [
'id',
'ban_link',
'course_id',
'unbanned_by_link',
'created',
]

list_filter = [
'created',
]

search_fields = [
'ban__user__username',
'course_id',
'unbanned_by__username',
'reason',
]

readonly_fields = [
'created',
'modified',
]

fieldsets = (
(_('Exception Information'), {
'fields': (
'ban',
'course_id',
'unbanned_by',
'reason',
)
}),
(_('Timestamps'), {
'fields': (
'created',
'modified',
),
'classes': ('collapse',),
}),
)

date_hierarchy = 'created'

def ban_link(self, obj):
"""Display link to parent ban."""
if obj.ban:
from django.urls import reverse
url = reverse('admin:discussion_discussionban_change', args=[obj.ban.id])
return format_html(
'<a href="{}">Ban #{} - {}</a>', url, obj.ban.id, obj.ban.user.username
)
return '-'
ban_link.short_description = _('Parent Ban')

def unbanned_by_link(self, obj):
"""Display unbanner with link."""
if obj.unbanned_by:
from django.urls import reverse
url = reverse('admin:auth_user_change', args=[obj.unbanned_by.id])
return format_html('<a href="{}">{}</a>', url, obj.unbanned_by.username)
return '-'
unbanned_by_link.short_description = _('Unbanned By')
Empty file.
36 changes: 35 additions & 1 deletion lms/djangoapps/discussion/rest_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@
from lms.djangoapps.courseware.courses import get_course_with_access
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
from lms.djangoapps.discussion.rate_limit import is_content_creation_rate_limited
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE, ONLY_VERIFIED_USERS_CAN_POST
from lms.djangoapps.discussion.toggles import (
ENABLE_DISCUSSIONS_MFE,
ONLY_VERIFIED_USERS_CAN_POST,
ENABLE_DISCUSSION_BAN
)
from lms.djangoapps.discussion.views import is_privileged_user
from openedx.core.djangoapps.discussions.models import (
DiscussionsConfiguration,
Expand Down Expand Up @@ -102,6 +106,7 @@
has_discussion_privileges,
is_commentable_divided
)
from forum.backends.mysql.models import DiscussionBan
from .exceptions import CommentNotFoundError, DiscussionBlackOutException, DiscussionDisabledError, ThreadNotFoundError
from .forms import CommentActionsForm, ThreadActionsForm, UserOrdering
from .pagination import DiscussionAPIPagination
Expand Down Expand Up @@ -389,6 +394,8 @@ def _format_datetime(dt):
"is_email_verified": request.user.is_active,
"only_verified_users_can_post": ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key),
"content_creation_rate_limited": is_content_creation_rate_limited(request, course_key, increment=False),
"is_user_banned": DiscussionBan.is_user_banned(request.user, course_key),
"enable_discussion_ban": ENABLE_DISCUSSION_BAN.is_enabled(course_key),
}


Expand Down Expand Up @@ -1496,6 +1503,10 @@ def create_thread(request, thread_data):
if not discussion_open_for_user(course, user):
raise DiscussionBlackOutException

# Check if user is banned from discussions
if DiscussionBan.is_user_banned(user, course_key):
raise PermissionDenied("You are banned from posting in this course's discussions.")

notify_all_learners = thread_data.pop("notify_all_learners", False)

context = get_context(course, request)
Expand Down Expand Up @@ -1552,6 +1563,10 @@ def create_comment(request, comment_data):
if not discussion_open_for_user(course, request.user):
raise DiscussionBlackOutException

# Check if user is banned from discussions
if DiscussionBan.is_user_banned(request.user, course.id):
raise PermissionDenied("You are banned from posting in this course's discussions.")

# if a thread is closed; no new comments could be made to it
if cc_thread["closed"]:
raise PermissionDenied
Expand Down Expand Up @@ -1939,6 +1954,25 @@ def get_course_discussion_user_stats(

course_stats_response = get_course_user_stats(course_key, params)

# Exclude banned users from the learners list
# Get all active bans for this course
organization = course_key.org
banned_usernames = set(
DiscussionBan.objects.filter(
Q(course_id=course_key) | Q(org_key=organization, scope='organization'),
is_active=True
).values_list('user__username', flat=True)
)

# Filter out banned users from the stats
if banned_usernames:
course_stats_response["user_stats"] = [
stats for stats in course_stats_response["user_stats"]
if stats.get('username') not in banned_usernames
]
# Update count to reflect filtered results
course_stats_response["count"] = len(course_stats_response["user_stats"])

if comma_separated_usernames:
updated_course_stats = add_stats_for_users_with_no_discussion_content(
course_stats_response["user_stats"],
Expand Down
Loading
Loading