diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 933e6fc20..7149b83cc 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -17,6 +17,10 @@ Unreleased
----------
* nothing unreleased
+[4.20.12]
+---------
+* feat: added support for testing sftp connection inside ``EnterpriseCustomerReportingConfiguration`` instance.
+
[4.20.11]
---------
* fix: setting existing user group membership statuses
diff --git a/enterprise/__init__.py b/enterprise/__init__.py
index 71625b1ce..2ce26c585 100644
--- a/enterprise/__init__.py
+++ b/enterprise/__init__.py
@@ -2,4 +2,4 @@
Your project description goes here.
"""
-__version__ = "4.20.11"
+__version__ = "4.20.12"
diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py
index 743dce963..e4849bbaa 100644
--- a/enterprise/admin/__init__.py
+++ b/enterprise/admin/__init__.py
@@ -4,13 +4,14 @@
import logging
+import paramiko
from config_models.admin import ConfigurationModelAdmin
from django_object_actions import DjangoObjectActions
from edx_rbac.admin import UserRoleAssignmentAdmin
from simple_history.admin import SimpleHistoryAdmin
from django.conf import settings
-from django.contrib import admin, auth
+from django.contrib import admin, auth, messages
from django.core.paginator import Paginator
from django.db import connection
from django.db.models import Q
@@ -975,6 +976,56 @@ def get_fields(self, request, obj=None):
return fields
+ def get_readonly_fields(self, request, obj=None):
+ """
+ Conditionally add the test_sftp_server to the readonly fields.
+ """
+ readonly_fields = list(super().get_readonly_fields(request, obj))
+ if obj and obj.delivery_method == models.EnterpriseCustomerReportingConfiguration.DELIVERY_METHOD_SFTP:
+ readonly_fields.append('test_sftp_server')
+ return readonly_fields
+
+ def test_sftp_server(self, obj):
+ """
+ Add a button to test the SFTP server connection.
+ """
+ return format_html(
+ 'Test SFTP Server Connection',
+ reverse('admin:test_sftp_connection', args=[obj.pk]),
+ )
+
+ test_sftp_server.short_description = 'Test SFTP Server'
+ test_sftp_server.allow_tags = True
+
+ def get_urls(self):
+ """
+ Extend the admin URLs to include the custom test server URL.
+ """
+ urls = super().get_urls()
+ custom_urls = [
+ path('test-sftp-connection//', self.admin_site.admin_view(self.test_sftp_connection),
+ name='test_sftp_connection'),
+ ]
+ return custom_urls + urls
+
+ def test_sftp_connection(self, request, pk):
+ """
+ Custom admin view to test the SFTP server connection.
+ """
+ config = self.get_object(request, pk)
+ if config:
+ try:
+ transport = paramiko.Transport((config.sftp_hostname, config.sftp_port))
+ transport.connect(username=config.sftp_username, password=config.decrypted_sftp_password)
+ sftp = paramiko.SFTPClient.from_transport(transport)
+ sftp.close()
+ transport.close()
+ self.message_user(request, "Successfully connected to the SFTP server.")
+ except Exception as e: # pylint: disable=broad-except
+ self.message_user(request, f"Failed to connect to the SFTP server: {e}", level=messages.ERROR)
+
+ return HttpResponseRedirect(reverse('admin:enterprise_enterprisecustomerreportingconfiguration_changelist'))
+
class BigTableMysqlPaginator(Paginator):
"""
diff --git a/requirements/base.in b/requirements/base.in
index b54f17646..d0b454396 100644
--- a/requirements/base.in
+++ b/requirements/base.in
@@ -31,6 +31,7 @@ edx-tincan-py35
edx-toggles
jsondiff
jsonfield
+paramiko
path.py
pillow
python-dateutil
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 349e71358..4d227e0f6 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -80,6 +80,12 @@ backports-zoneinfo[tzdata]==0.2.1 ; python_version < "3.9"
# celery
# django
# kombu
+bcrypt==4.1.3
+ # via
+ # -r requirements/doc.txt
+ # -r requirements/test-master.txt
+ # -r requirements/test.txt
+ # paramiko
beautifulsoup4==4.12.3
# via
# -r requirements/doc.txt
@@ -181,6 +187,7 @@ cryptography==42.0.8
# -r requirements/test.txt
# django-fernet-fields-v2
# jwcrypto
+ # paramiko
# pgpy
# pyjwt
# pyopenssl
@@ -193,7 +200,7 @@ defusedxml==0.7.1
# -r requirements/test-master.txt
# -r requirements/test.txt
# djangorestframework-xml
-diff-cover==9.0.0
+diff-cover==9.1.0
# via -r requirements/test.txt
dill==0.3.8
# via pylint
@@ -393,7 +400,7 @@ factory-boy==3.3.0
# -c requirements/constraints.txt
# -r requirements/doc.txt
# -r requirements/test.txt
-faker==25.9.1
+faker==26.0.0
# via
# -r requirements/doc.txt
# -r requirements/test.txt
@@ -480,7 +487,7 @@ kombu==5.3.7
# -r requirements/test-master.txt
# -r requirements/test.txt
# celery
-lxml[html-clean,html_clean]==5.2.2
+lxml[html_clean]==5.2.2
# via
# edx-i18n-tools
# lxml-html-clean
@@ -538,6 +545,11 @@ packaging==24.1
# snowflake-connector-python
# sphinx
# tox
+paramiko==3.4.0
+ # via
+ # -r requirements/doc.txt
+ # -r requirements/test-master.txt
+ # -r requirements/test.txt
path==16.11.0
# via
# -r requirements/doc.txt
@@ -644,7 +656,7 @@ pyjwt[crypto]==2.8.0
# edx-drf-extensions
# edx-rest-api-client
# snowflake-connector-python
-pylint==3.2.3
+pylint==3.2.4
# via
# edx-lint
# pylint-celery
@@ -670,6 +682,7 @@ pynacl==1.5.0
# -r requirements/test-master.txt
# -r requirements/test.txt
# edx-django-utils
+ # paramiko
pyopenssl==24.1.0
# via
# -r requirements/doc.txt
diff --git a/requirements/doc.txt b/requirements/doc.txt
index 7698b633c..890f55f99 100644
--- a/requirements/doc.txt
+++ b/requirements/doc.txt
@@ -53,6 +53,10 @@ backports-zoneinfo[tzdata]==0.2.1 ; python_version < "3.9"
# celery
# django
# kombu
+bcrypt==4.1.3
+ # via
+ # -r requirements/test-master.txt
+ # paramiko
beautifulsoup4==4.12.3
# via pydata-sphinx-theme
billiard==4.2.0
@@ -111,6 +115,7 @@ cryptography==42.0.8
# -r requirements/test-master.txt
# django-fernet-fields-v2
# jwcrypto
+ # paramiko
# pgpy
# pyjwt
# pyopenssl
@@ -237,7 +242,7 @@ factory-boy==3.3.0
# via
# -c requirements/constraints.txt
# -r requirements/doc.in
-faker==25.9.1
+faker==26.0.0
# via factory-boy
filelock==3.14.0
# via
@@ -312,6 +317,8 @@ packaging==24.1
# pytest
# snowflake-connector-python
# sphinx
+paramiko==3.4.0
+ # via -r requirements/test-master.txt
path==16.11.0
# via
# -r requirements/test-master.txt
@@ -374,6 +381,7 @@ pynacl==1.5.0
# via
# -r requirements/test-master.txt
# edx-django-utils
+ # paramiko
pyopenssl==24.1.0
# via
# -r requirements/test-master.txt
diff --git a/requirements/edx-platform-constraints.txt b/requirements/edx-platform-constraints.txt
index 2d90ac899..5cc952dde 100644
--- a/requirements/edx-platform-constraints.txt
+++ b/requirements/edx-platform-constraints.txt
@@ -434,7 +434,7 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.20.8
+edx-enterprise==4.20.11
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
@@ -730,7 +730,7 @@ openedx-events==9.10.0
# edx-event-bus-redis
# event-tracking
# ora2
-openedx-filters==1.8.1
+openedx-filters==1.9.0
# via
# -r requirements/edx/kernel.in
# lti-consumer-xblock
diff --git a/requirements/test-master.txt b/requirements/test-master.txt
index 22b538c82..781479937 100644
--- a/requirements/test-master.txt
+++ b/requirements/test-master.txt
@@ -39,6 +39,8 @@ backports-zoneinfo[tzdata]==0.2.1 ; python_version < "3.9"
# celery
# django
# kombu
+bcrypt==4.1.3
+ # via paramiko
billiard==4.2.0
# via celery
bleach==6.1.0
@@ -92,6 +94,7 @@ cryptography==42.0.8
# -r requirements/base.in
# django-fernet-fields-v2
# jwcrypto
+ # paramiko
# pgpy
# pyjwt
# pyopenssl
@@ -307,6 +310,8 @@ packaging==24.1
# -c requirements/edx-platform-constraints.txt
# drf-yasg
# snowflake-connector-python
+paramiko==3.4.0
+ # via -r requirements/base.in
path==16.11.0
# via
# -c requirements/edx-platform-constraints.txt
@@ -360,6 +365,7 @@ pynacl==1.5.0
# via
# -c requirements/edx-platform-constraints.txt
# edx-django-utils
+ # paramiko
pyopenssl==24.1.0
# via
# -c requirements/edx-platform-constraints.txt
diff --git a/requirements/test.txt b/requirements/test.txt
index 71e9078b6..0ee58874d 100644
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -45,6 +45,10 @@ backports-zoneinfo[tzdata]==0.2.1 ; python_version < "3.9"
# celery
# django
# kombu
+bcrypt==4.1.3
+ # via
+ # -r requirements/test-master.txt
+ # paramiko
# via
# -r requirements/test-master.txt
# celery
@@ -100,6 +104,7 @@ cryptography==42.0.8
# -r requirements/test-master.txt
# django-fernet-fields-v2
# jwcrypto
+ # paramiko
# pgpy
# pyjwt
# pyopenssl
@@ -110,7 +115,7 @@ defusedxml==0.7.1
# via
# -r requirements/test-master.txt
# djangorestframework-xml
-diff-cover==9.0.0
+diff-cover==9.1.0
# via -r requirements/test.in
# via
# -c requirements/common_constraints.txt
@@ -220,7 +225,7 @@ factory-boy==3.3.0
# via
# -c requirements/constraints.txt
# -r requirements/test.in
-faker==25.9.1
+faker==26.0.0
# via factory-boy
filelock==3.14.0
# via
@@ -292,6 +297,8 @@ packaging==24.1
# drf-yasg
# pytest
# snowflake-connector-python
+paramiko==3.4.0
+ # via -r requirements/test-master.txt
path==16.11.0
# via
# -r requirements/test-master.txt
@@ -348,6 +355,7 @@ pynacl==1.5.0
# via
# -r requirements/test-master.txt
# edx-django-utils
+ # paramiko
pyopenssl==24.1.0
# via
# -r requirements/test-master.txt