Skip to content

Commit

Permalink
Merge branch 'master' into bmtcril/pii_annotations
Browse files Browse the repository at this point in the history
  • Loading branch information
bmtcril authored Jan 16, 2025
2 parents aaf7087 + 9779f3e commit e26d23b
Show file tree
Hide file tree
Showing 24 changed files with 1,352 additions and 1,011 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/mysql8-migrations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache pip dependencies
id: cache-dependencies
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache-dir.outputs.dir }}
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/pip_tools.txt') }}
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,39 @@ Unreleased
----------
* nothing unreleased

[5.6.1]
--------
* fix: Log all learner transmission records.

[5.6.0]
--------
* feat: Adds sorting support to enterprise-customer-members endpoint

[5.5.2]
--------
* feat: Add page_size support to enterprise-customer-members endpoint

[5.5.1]
--------
* fix: Fixed the query fetching enterprise customer members

[5.5.0]
-------
* feat: introduce Waffle flag for enabling the Learner Portal BFF API.

[5.4.2]
--------
* feat: Added a management command to update the Social Auth UID's for an enterprise.

[5.4.1]
-------
* fix: The default enrollment ``learner_status`` view now considers intended courses
from which the requested user has unenrolled as no-longer realizable.

[5.4.0]
-------
* chore: Update python requirements.

[5.3.1]
-------
* fix: rely on single constant to define course mode priority order (i.e., ensure all enrollable modes are considered; previously missing honor mode in `enroll_learners_in_courses`).
Expand Down
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Your project description goes here.
"""

__version__ = "5.3.1"
__version__ = "5.6.1"
18 changes: 17 additions & 1 deletion enterprise/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1922,7 +1922,22 @@ def get_role_assignments(self, obj):
return None


class EnterpriseMembersSerializer(serializers.Serializer):
class EnterpriseCustomerMembersRequestQuerySerializer(serializers.Serializer):
"""
Serializer for the Enterprise Customer Members endpoint query filter
"""
user_query = serializers.CharField(required=False, max_length=250)
sort_by = serializers.ChoiceField(
choices=[
('name', 'name'),
('joined_org', 'joined_org'),
],
required=False,
)
is_reversed = serializers.BooleanField(required=False, default=False)


class EnterpriseMembersSerializer(serializers.ModelSerializer):
"""
Serializer for EnterpriseCustomerUser model with additions.
"""
Expand Down Expand Up @@ -1954,6 +1969,7 @@ def get_enterprise_customer_user(self, obj):
"""
if user := obj:
return {
"user_id": user[0],
"email": user[1],
"joined_org": user[2].strftime("%b %d, %Y"),
"name": user[3],
Expand Down
11 changes: 11 additions & 0 deletions enterprise/api/v1/views/default_enterprise_enrollments.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,17 @@ def _partition_default_enrollment_intentions_by_enrollment_status(
already_enrolled.append((intention, non_audit_enrollment))
continue

if non_audit_enrollment and non_audit_enrollment.unenrolled:
# Learner has un-enrolled in non-audit mode for this course run,
# so don't consider this as an enrollable intention.
# Note that that we don't consider the case of an unenrolled *audit* enrollment,
# because default enrollments should be "exactly once" per (user, enterprise), if possible.
# If only an (unenrolled) audit enrollment exists, it means the user likely
# never had a default intention realized for them in the given course,
# so we'd still like to consider it as enrollable.
needs_enrollment_not_enrollable.append((intention, non_audit_enrollment))
continue

if not intention.is_course_run_enrollable:
# Course run is not enrollable
needs_enrollment_not_enrollable.append((intention, audit_enrollment))
Expand Down
34 changes: 31 additions & 3 deletions enterprise/api/v1/views/enterprise_customer_members.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from rest_framework import permissions, response, status
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response

from django.core.exceptions import ValidationError
from django.db import connection
Expand All @@ -22,6 +23,7 @@ class EnterpriseCustomerMembersPaginator(PageNumberPagination):
"""Custom paginator for the enterprise customer members."""

page_size = 10
page_size_query_param = 'page_size'

def get_paginated_response(self, data):
"""Return a paginated style `Response` object for the given output data."""
Expand Down Expand Up @@ -62,13 +64,31 @@ class EnterpriseCustomerMembersViewSet(EnterpriseReadOnlyModelViewSet):
def get_members(self, request, *args, **kwargs):
"""
Get all members associated with that enterprise customer
Request Arguments:
- ``enterprise_uuid`` (URL location, required): The uuid of the enterprise from which learners should be listed.
Optional query params:
- ``user_query`` (string, optional): Filter the returned members by user name and email with a provided
sub-string
- ``sort_by`` (string, optional): Specify how the returned members should be ordered. Supported sorting values
are `joined_org`, `name`, and `enrollments`.
- ``is_reversed`` (bool, optional): Include to reverse the records in descending order. By default, the results
returned are in ascending order.
"""
query_params = self.request.query_params
param_serializers = serializers.EnterpriseCustomerMembersRequestQuerySerializer(
data=query_params
)
if not param_serializers.is_valid():
return Response(param_serializers.errors, status=400)
enterprise_uuid = kwargs.get("enterprise_uuid", None)
# Raw sql is picky about uuid format
uuid_no_dashes = str(enterprise_uuid).replace("-", "")
users = []
user_query = self.request.query_params.get("user_query", None)

user_query = param_serializers.validated_data.get('user_query')
is_reversed = param_serializers.validated_data.get('is_reversed', False)
sort_by = param_serializers.validated_data.get('sort_by')
# On logistration, the name field of auth_userprofile is populated, but if it's not
# filled in, we check the auth_user model for it's first/last name fields
# https://2u-internal.atlassian.net/wiki/spaces/ENGAGE/pages/747143186/Use+of+full+name+in+edX#Data-on-Name-Field
Expand All @@ -78,7 +98,7 @@ def get_members(self, request, *args, **kwargs):
au.id,
au.email,
au.date_joined,
coalesce(NULLIF(aup.name, ''), (au.first_name || ' ' || au.last_name)) as full_name
coalesce(NULLIF(aup.name, ''), au.username) as full_name
FROM enterprise_enterprisecustomeruser ecu
INNER JOIN auth_user as au on ecu.user_id = au.id
LEFT JOIN auth_userprofile as aup on au.id = aup.user_id
Expand Down Expand Up @@ -108,6 +128,14 @@ def get_members(self, request, *args, **kwargs):
status=status.HTTP_404_NOT_FOUND,
)

if sort_by:
lambda_keys = {
# 3 and 2 are indices in the tuple associated to a user row (uuid, email, joined_org, name)
'name': lambda t: t[3],
'joined_org': lambda t: t[2],
}
users = sorted(users, key=lambda_keys.get(sort_by), reverse=is_reversed)

# paginate the queryset
users_page = self.paginator.paginate_queryset(users, request, view=self)

Expand Down
165 changes: 165 additions & 0 deletions enterprise/management/commands/update_enterprise_social_auth_uids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""
Django management command to update the social auth records UID
"""

import csv
import logging

from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand
from django.db import transaction

try:
from social_django.models import UserSocialAuth
except ImportError:
UserSocialAuth = None

logger = logging.getLogger(__name__)


class CSVUpdateError(Exception):
"""Custom exception for CSV update process."""
pass # pylint: disable=unnecessary-pass


class Command(BaseCommand):
"""
Update the enterprise related social auth records UID to the new one.
Example usage:
./manage.py lms update_enterprise_social_auth_uids csv_file_path
./manage.py lms update_enterprise_social_auth_uids csv_file_path --old-prefix="slug:" --new-prefix="slug:x|{}@xyz"
./manage.py lms update_enterprise_social_auth_uids csv_file_path --no-dry-run
"""

help = 'Records update from CSV with console logging'

def add_arguments(self, parser):
parser.add_argument('csv_file', type=str, help='Path to the CSV file')
parser.add_argument(
'--old_prefix',
type=str,
default=None,
help='Optional old prefix for old UID. If not provided, uses CSV value.'
)
parser.add_argument(
'--new_prefix',
type=str,
default=None,
help='Optional new prefix for new UID. If not provided, uses CSV value.'
)
parser.add_argument(
'--no-dry-run',
action='store_false',
dest='dry_run',
default=True,
help='Actually save changes instead of simulating'
)

def handle(self, *args, **options):
logger.info("Command has started...")
csv_path = options['csv_file']
dry_run = options['dry_run']
old_prefix = options['old_prefix']
new_prefix = options['new_prefix']

total_processed = 0
total_updated = 0
total_errors = 0

try:
with open(csv_path, 'r') as csvfile:
reader = csv.DictReader(csvfile)

for row_num, row in enumerate(reader, start=1):
total_processed += 1

try:
with transaction.atomic():
if self.update_record(row, dry_run, old_prefix, new_prefix):
total_updated += 1

except Exception as row_error: # pylint: disable=broad-except
total_errors += 1
error_msg = f"Row {row_num} update failed: {row} - Error: {str(row_error)}"
logger.error(error_msg, exc_info=True)

summary_msg = (
f"CSV Update Summary:\n"
f"Total Records Processed: {total_processed}\n"
f"Records Successfully Updated: {total_updated}\n"
f"Errors Encountered: {total_errors}\n"
f"Dry Run Mode: {'Enabled' if dry_run else 'Disabled'}"
)
logger.info(summary_msg)
except IOError as io_error:
logger.critical(f"File I/O error: {str(io_error)}")

except Exception as e: # pylint: disable=broad-except
logger.critical(f"Critical error in CSV processing: {str(e)}")

def update_record(self, row, dry_run=True, old_prefix=None, new_prefix=None):
"""
Update a single record, applying optional prefixes to UIDs if provided.
Args:
row (dict): CSV row data
dry_run (bool): Whether to simulate or actually save changes
old_prefix (str): Prefix to apply to the old UID
new_prefix (str): Prefix to apply to the new UID
Returns:
bool: Whether the update was successful
"""
try:
old_uid = row.get('old-uid')
new_uid = row.get('new-uid')

# Validating that both values are present
if not old_uid or not new_uid:
raise CSVUpdateError("Missing required UID fields")

# Construct dynamic UIDs
old_uid_with_prefix = f'{old_prefix}{old_uid}' if old_prefix else old_uid
new_uid_with_prefix = (
new_prefix.format(new_uid) if new_prefix and '{}' in new_prefix
else f"{new_prefix}{new_uid}" if new_prefix
else new_uid
)

instance_with_old_uid = UserSocialAuth.objects.filter(uid=old_uid_with_prefix).first()

if not instance_with_old_uid:
raise CSVUpdateError(f"No record found with old UID {old_uid_with_prefix}")

instance_with_new_uid = UserSocialAuth.objects.filter(uid=new_uid_with_prefix).first()
if instance_with_new_uid:
log_entry = f"Warning: Existing record with new UID {new_uid_with_prefix} is deleting."
logger.info(log_entry)
if not dry_run:
instance_with_new_uid.delete()

if not dry_run:
instance_with_old_uid.uid = new_uid_with_prefix
instance_with_old_uid.save()

log_entry = f"Successfully updated record: Old UID {old_uid_with_prefix} → New UID {new_uid_with_prefix}"
logger.info(log_entry)

return True

except ValidationError as ve:
error_msg = f"Validation error: {ve}"
logger.error(error_msg)
raise

except CSVUpdateError as update_error:
error_msg = f"Update processing error: {update_error}"
logger.error(error_msg)
raise

except Exception as e:
error_msg = f"Unexpected error during record update: {e}"
logger.error(error_msg, exc_info=True)
raise
2 changes: 1 addition & 1 deletion enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4703,7 +4703,7 @@ def _get_filtered_ecu_ids(self, user_query):
with users as (
select ecu.id,
au.email,
coalesce(NULLIF(aup.name, ''), (au.first_name || ' ' || au.last_name)) as full_name
coalesce(NULLIF(aup.name, ''), au.username) as full_name
from enterprise_enterprisecustomeruser ecu
inner join auth_user au on ecu.user_id = au.id
left join auth_userprofile aup on au.id = aup.user_id
Expand Down
20 changes: 20 additions & 0 deletions enterprise/toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@
ENTERPRISE_LOG_PREFIX,
)

# .. toggle_name: enterprise.enterprise_learner_bff_enabled
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: Enables the enterprise learner BFF
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2024-12-16
ENTERPRISE_LEARNER_BFF_ENABLED = WaffleFlag(
f'{ENTERPRISE_NAMESPACE}.learner_bff_enabled',
__name__,
ENTERPRISE_LOG_PREFIX,
)


def top_down_assignment_real_time_lcm():
"""
Expand Down Expand Up @@ -103,6 +115,13 @@ def enterprise_customer_support_tool():
return ENTERPRISE_CUSTOMER_SUPPORT_TOOL.is_enabled()


def enterprise_learner_bff_enabled():
"""
Returns whether the enterprise learner BFF is enabled.
"""
return ENTERPRISE_LEARNER_BFF_ENABLED.is_enabled()


def enterprise_features():
"""
Returns a dict of enterprise Waffle-based feature flags.
Expand All @@ -113,4 +132,5 @@ def enterprise_features():
'enterprise_groups_v1': enterprise_groups_v1(),
'enterprise_customer_support_tool': enterprise_customer_support_tool(),
'enterprise_groups_v2': enterprise_groups_v2(),
'enterprise_learner_bff_enabled': enterprise_learner_bff_enabled(),
}
Loading

0 comments on commit e26d23b

Please sign in to comment.