Skip to content

Commit 06264e7

Browse files
authored
feat: Update social_user uid using csv from admin panel (#35048)
1 parent e7daa04 commit 06264e7

2 files changed

Lines changed: 128 additions & 5 deletions

File tree

common/djangoapps/third_party_auth/admin.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
"""
22
Admin site configuration for third party authentication
33
"""
4-
4+
import csv
55

66
from config_models.admin import KeyedConfigurationModelAdmin
77
from django import forms
8-
from django.contrib import admin
8+
from django.contrib import admin, messages
99
from django.db import transaction
10-
from django.urls import reverse
10+
from django.http import Http404, HttpResponseRedirect
11+
from django.urls import path, reverse
1112
from django.utils.html import format_html
1213
from django.utils.translation import gettext_lazy as _
14+
from django.views.decorators.csrf import csrf_exempt
1315

1416
from .models import (
1517
_PSA_OAUTH2_BACKENDS,
@@ -21,7 +23,7 @@
2123
SAMLProviderConfig,
2224
SAMLProviderData
2325
)
24-
from .tasks import fetch_saml_metadata
26+
from .tasks import fetch_saml_metadata, update_saml_users_social_auth_uid
2527

2628

2729
class OAuth2ProviderConfigForm(forms.ModelForm):
@@ -72,7 +74,7 @@ def get_list_display(self, request):
7274
""" Don't show every single field in the admin change list """
7375
return (
7476
'name_with_update_link', 'enabled', 'site', 'entity_id', 'metadata_source',
75-
'has_data', 'mode', 'saml_configuration', 'change_date', 'changed_by',
77+
'has_data', 'mode', 'saml_configuration', 'change_date', 'changed_by', 'csv_uuid_update_button',
7678
)
7779

7880
list_display_links = None
@@ -135,6 +137,65 @@ def save_model(self, request, obj, form, change):
135137
super().save_model(request, obj, form, change)
136138
fetch_saml_metadata.apply_async((), countdown=2)
137139

140+
def get_urls(self):
141+
""" Extend the admin URLs to include the custom CSV upload URL. """
142+
urls = super().get_urls()
143+
custom_urls = [
144+
path('<slug:slug>/upload-csv/', self.admin_site.admin_view(self.upload_csv), name='upload_csv'),
145+
146+
]
147+
return custom_urls + urls
148+
149+
@csrf_exempt
150+
def upload_csv(self, request, slug):
151+
""" Handle CSV upload and update UserSocialAuth model. """
152+
if not request.user.is_staff:
153+
raise Http404
154+
if request.method == 'POST':
155+
csv_file = request.FILES.get('csv_file')
156+
if not csv_file or not csv_file.name.endswith('.csv'):
157+
self.message_user(request, "Please upload a valid CSV file.", level=messages.ERROR)
158+
else:
159+
try:
160+
decoded_file = csv_file.read().decode('utf-8').splitlines()
161+
reader = csv.DictReader(decoded_file)
162+
update_saml_users_social_auth_uid(reader, slug)
163+
self.message_user(request, "CSV file has been processed successfully.")
164+
except Exception as e: # pylint: disable=broad-except
165+
self.message_user(request, f"Failed to process CSV file: {e}", level=messages.ERROR)
166+
167+
# Always redirect back to the SAMLProviderConfig listing page
168+
return HttpResponseRedirect(reverse('admin:third_party_auth_samlproviderconfig_changelist'))
169+
170+
def change_view(self, request, object_slug, form_url='', extra_context=None):
171+
""" Extend the change view to include CSV upload. """
172+
extra_context = extra_context or {}
173+
extra_context['show_csv_upload'] = True
174+
return super().change_view(request, object_slug, form_url, extra_context)
175+
176+
def csv_uuid_update_button(self, obj):
177+
""" Add CSV upload button to the form. """
178+
if obj:
179+
form_url = reverse('admin:upload_csv', args=[obj.slug])
180+
return format_html(
181+
'<form method="post" enctype="multipart/form-data" action="{}">'
182+
'<input type="file" name="csv_file" accept=".csv" style="margin-bottom: 10px;">'
183+
'<button type="submit" class="button">Upload CSV</button>'
184+
'</form>',
185+
form_url
186+
)
187+
return ""
188+
189+
csv_uuid_update_button.short_description = 'UUID UPDATE CSV'
190+
csv_uuid_update_button.allow_tags = True
191+
192+
def get_readonly_fields(self, request, obj=None):
193+
""" Conditionally add csv_uuid_update_button to readonly fields. """
194+
readonly_fields = list(super().get_readonly_fields(request, obj))
195+
if obj:
196+
readonly_fields.append('csv_uuid_update_button')
197+
return readonly_fields
198+
138199
admin.site.register(SAMLProviderConfig, SAMLProviderConfigAdmin)
139200

140201

common/djangoapps/third_party_auth/tasks.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77

88
import requests
99
from celery import shared_task
10+
from django.core.exceptions import ObjectDoesNotExist
1011
from edx_django_utils.monitoring import set_code_owner_attribute
1112
from lxml import etree
1213
from requests import exceptions
14+
from social_django.models import UserSocialAuth
1315

1416
from common.djangoapps.third_party_auth.models import SAMLConfiguration, SAMLProviderConfig
1517
from common.djangoapps.third_party_auth.utils import (
@@ -127,3 +129,63 @@ def fetch_saml_metadata():
127129

128130
# Return counts for total, skipped, attempted, updated, and failed, along with any failure messages
129131
return num_total, num_skipped, num_attempted, num_updated, len(failure_messages), failure_messages
132+
133+
134+
@shared_task
135+
@set_code_owner_attribute
136+
def update_saml_users_social_auth_uid(reader, slug):
137+
"""
138+
Update the UserSocialAuth UID for users based on a CSV reader input.
139+
140+
This function reads old and new UIDs from a CSV reader, fetches the corresponding
141+
SAMLProviderConfig object using the provided slug, and updates the UserSocialAuth
142+
records accordingly.
143+
144+
Args:
145+
reader (csv.DictReader): A CSV reader object that iterates over rows containing 'old-uid' and 'new-uid'.
146+
slug (str): The slug of the SAMLProviderConfig object to be fetched.
147+
148+
Returns:
149+
None
150+
"""
151+
log_prefix = "UpdateSamlUsersAuthUID"
152+
log.info(f"{log_prefix}: Updated user UID request received with slug: {slug}")
153+
154+
try:
155+
# Fetching the SAMLProviderConfig object with slug
156+
saml_provider_config = SAMLProviderConfig.objects.current_set().get(slug=slug)
157+
except SAMLProviderConfig.DoesNotExist:
158+
log.error(f"{log_prefix}: SAMLProviderConfig with slug {slug} does not exist")
159+
return
160+
except Exception as e: # pylint: disable=broad-except
161+
log.error(f"{log_prefix}: An error occurred while fetching SAMLProviderConfig: {str(e)}")
162+
return
163+
164+
success_count = 0
165+
error_count = 0
166+
167+
for row in reader:
168+
old_uid = row.get('old-uid')
169+
new_uid = row.get('new-uid')
170+
171+
# Construct the UID using the SAML provider slug and old UID
172+
uid = f'{saml_provider_config.slug}:{old_uid}'
173+
174+
try:
175+
user_social_auth = UserSocialAuth.objects.get(uid=uid)
176+
user_social_auth.uid = f'{saml_provider_config.slug}:{new_uid}'
177+
user_social_auth.save()
178+
log.info(f"{log_prefix}: Updated UID from {old_uid} to {new_uid} for user:{user_social_auth.user.id}.")
179+
success_count += 1
180+
181+
except ObjectDoesNotExist:
182+
log.error(f"{log_prefix}: UserSocialAuth with UID {uid} does not exist for old UID {old_uid}")
183+
error_count += 1
184+
185+
except Exception as e: # pylint: disable=broad-except
186+
log.error(f"{log_prefix}: An error occurred while updating UID for old UID {old_uid}"
187+
f" to new UID {new_uid}: {str(e)}")
188+
error_count += 1
189+
190+
log.info(f"{log_prefix}: Process completed for SAML configuration with slug: {slug}, {success_count} records"
191+
f" successfully processed, {error_count} records encountered errors")

0 commit comments

Comments
 (0)