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
126 changes: 126 additions & 0 deletions common/djangoapps/student/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
BulkChangeEnrollmentConfiguration,
BulkUnenrollConfiguration,
CourseAccessRole,
CourseAccessRoleHistory,
CourseEnrollment,
CourseEnrollmentAllowed,
CourseEnrollmentCelebration,
Expand Down Expand Up @@ -229,6 +230,131 @@ def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)


@admin.register(CourseAccessRoleHistory)
class CourseAccessRoleHistoryAdmin(admin.ModelAdmin):
"""Admin panel for the Course Access Role History."""
list_display = (
'id', 'user', 'org', 'course_id', 'role', 'action_type', 'changed_by', 'created'
)
list_filter = (
'action_type', 'org', 'role'
)
search_fields = (
'user__username', 'org', 'course_id', 'role', 'action_type', 'changed_by__username'
)
readonly_fields = (
'user', 'org', 'course_id', 'role', 'action_type', 'changed_by', 'created', 'modified'
)
actions = ['revert_selected_history', 'delete_selected_history_entries']

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False

def revert_selected_history(self, request, queryset):
"""
Admin action to revert selected CourseAccessRoleHistory entries.
"""
if not request.user.has_perm('student.can_revert_course_access_role'):
self.message_user(request, "You do not have permission to revert course access roles.", level='ERROR')
return

reverted_count = 0
for history_record in queryset:
try:
with transaction.atomic():
if history_record.action_type == 'created':
CourseAccessRole.objects.filter(
user=history_record.user,
org=history_record.org,
course_id=history_record.course_id,
role=history_record.role
).delete()
self.message_user(
request, f"Successfully reverted creation of role for "
f"{history_record.user.username} in {history_record.course_id}"
)
elif history_record.action_type == 'updated':
if history_record.old_values:
CourseAccessRole.objects.update_or_create(
user_id=history_record.old_values['user_id'],
org=history_record.old_values['org'],
course_id=history_record.old_values['course_id'],
defaults={'role': history_record.old_values['role']}
)
self.message_user(
request, f"Successfully reverted update of role for "
f"{history_record.user.username} to {history_record.old_values['role']} "
f"in {history_record.course_id}"
)
else:
self.message_user(
request, f"Cannot revert update for record {history_record.id}: "
f"old_values not found.", level='WARNING'
)
elif history_record.action_type == 'deleted':
CourseAccessRole.objects.update_or_create(
user=history_record.user,
org=history_record.org,
course_id=history_record.course_id,
role=history_record.role
)
self.message_user(
request, f"Successfully reverted deletion of role for "
f"{history_record.user.username} in {history_record.course_id}"
)
reverted_count += 1
except Exception as e: # lint-amnesty, pylint: disable=broad-except
self.message_user(request, f"Error reverting record {history_record.id}: {e}", level='ERROR')

if reverted_count > 0:
self.message_user(
request,
ngettext(
"Successfully reverted %(count)d selected history entry.",
"Successfully reverted %(count)d selected history entries.",
reverted_count
) % {'count': reverted_count},
)
revert_selected_history.short_description = "Revert selected history entries"

def delete_selected_history_entries(self, request, queryset):
"""
Admin action to delete selected CourseAccessRoleHistory entries.
"""
if not request.user.has_perm('student.can_delete_course_access_role_history'):
self.message_user(
request, "You do not have permission to delete course access role history entries.",
level='ERROR'
)
return

deleted_count = 0
for history_record in queryset:
try:
history_record.delete()
deleted_count += 1
except Exception as e: # lint-amnesty, pylint: disable=broad-except
self.message_user(request, f"Error deleting record {history_record.id}: {e}", level='ERROR')

if deleted_count > 0:
self.message_user(
request,
ngettext(
"Successfully deleted %(count)d selected history entry.",
"Successfully deleted %(count)d selected history entries.",
deleted_count
) % {'count': deleted_count},
level='SUCCESS',
)
delete_selected_history_entries.short_description = "Delete selected history entries"


@admin.register(LinkedInAddToProfileConfiguration)
class LinkedInAddToProfileConfigurationAdmin(admin.ModelAdmin):
"""Admin interface for the LinkedIn Add to Profile configuration. """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 4.2.23 on 2025-08-22 10:13

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import opaque_keys.edx.django.models


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('student', '0046_alter_userprofile_phone_number'),
]

operations = [
migrations.CreateModel(
name='CourseAccessRoleHistory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('org', models.CharField(blank=True, db_index=True, max_length=64)),
('course_id', opaque_keys.edx.django.models.CourseKeyField(blank=True, db_index=True, max_length=255)),
('role', models.CharField(db_index=True, max_length=64)),
('action_type', models.CharField(choices=[('created', 'Created'), ('updated', 'Updated'), ('deleted', 'Deleted')], db_index=True, max_length=10)),
('old_values', models.JSONField(blank=True, help_text="Stores old values of fields for 'updated' actions.", null=True)),
('changed_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='courseaccessrole_history_changer', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'permissions': (('can_revert_course_access_role', 'Can revert course access role changes'), ('can_delete_course_access_role_history', 'Can delete course access role history')),
},
),
]
92 changes: 91 additions & 1 deletion common/djangoapps/student/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from django.core.validators import FileExtensionValidator, RegexValidator
from django.db import IntegrityError, models
from django.db.models import Q
from django.db.models.signals import post_save, pre_save
from django.db.models.signals import post_save, pre_save, post_delete
from django.db.utils import ProgrammingError
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -1103,6 +1103,41 @@ def __str__(self):
return f"[CourseAccessRole] user: {self.user.username} role: {self.role} org: {self.org} course: {self.course_id}" # lint-amnesty, pylint: disable=line-too-long


class CourseAccessRoleHistory(TimeStampedModel):
"""
Stores the change history for CourseAccessRole objects.
"""
ACTION_CHOICES = (
('created', 'Created'),
('updated', 'Updated'),
('deleted', 'Deleted'),
)

user = models.ForeignKey(User, on_delete=models.CASCADE)
org = models.CharField(max_length=64, db_index=True, blank=True)
course_id = CourseKeyField(max_length=255, db_index=True, blank=True)
role = models.CharField(max_length=64, db_index=True)
action_type = models.CharField(max_length=10, choices=ACTION_CHOICES, db_index=True)
changed_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='courseaccessrole_history_changer',
)
old_values = models.JSONField(null=True, blank=True, help_text="Stores old values of fields for 'updated' actions.")

class Meta:
permissions = (("can_revert_course_access_role", "Can revert course access role changes"),
("can_delete_course_access_role_history", "Can delete course access role history"),)

def __str__(self):
return (
f"[CourseAccessRoleHistory] user: {self.user.username} role: {self.role} "
f"org: {self.org} course: {self.course_id} action: {self.action_type} "
f"changed_by: {self.changed_by.username if self.changed_by else 'N/A'} at {self.created}"
)


#### Helper methods for use from python manage.py shell and other classes.

def strip_if_string(value):
Expand Down Expand Up @@ -1879,3 +1914,58 @@ class Meta:

def __str__(self):
return self.comment


@receiver(pre_save, sender=CourseAccessRole)
def pre_save_course_access_role(sender, instance, **kwargs):
"""
Captures the current state of a CourseAccessRole before it is saved for update tracking.
"""
if instance.pk:
try:
old_instance = sender.objects.get(pk=instance.pk)
# pylint: disable=protected-access
instance._old_values = {
'user_id': old_instance.user_id,
'org': old_instance.org,
'course_id': str(old_instance.course_id) if old_instance.course_id else None,
'role': old_instance.role,
}
except sender.DoesNotExist:
# pylint: disable=protected-access
instance._old_values = None


@receiver(post_save, sender=CourseAccessRole)
def create_course_access_role_history_on_save(sender, instance, created, **kwargs):
"""
Handle create and update actions for CourseAccessRole objects.
"""
action_type = 'created' if created else 'updated'
current_user = crum.get_current_user()
old_values = getattr(instance, '_old_values', None) if not created else None
CourseAccessRoleHistory.objects.create(
user=instance.user,
org=instance.org,
course_id=instance.course_id,
role=instance.role,
action_type=action_type,
changed_by=current_user if current_user and current_user.is_authenticated else None,
old_values=old_values
)


@receiver(post_delete, sender=CourseAccessRole)
def create_course_access_role_history_on_delete(sender, instance, **kwargs):
"""
Handle delete actions for CourseAccessRole objects.
"""
current_user = crum.get_current_user()
CourseAccessRoleHistory.objects.create(
user=instance.user,
org=instance.org,
course_id=instance.course_id,
role=instance.role,
action_type='deleted',
changed_by=current_user if current_user and current_user.is_authenticated else None
)
Loading
Loading