diff --git a/license_manager/apps/api_client/enterprise.py b/license_manager/apps/api_client/enterprise.py index 300cb614..98008367 100644 --- a/license_manager/apps/api_client/enterprise.py +++ b/license_manager/apps/api_client/enterprise.py @@ -20,6 +20,7 @@ class EnterpriseApiClient(BaseOAuthClient): course_enrollments_revoke_endpoint = api_base_url + 'licensed-enterprise-course-enrollment/license_revoke/' bulk_licensed_enrollments_expiration_endpoint = api_base_url \ + 'licensed-enterprise-course-enrollment/bulk_licensed_enrollments_expiration/' + unlink_users_endpoint = api_base_url + 'enterprise-customer/unlink_users/' def get_enterprise_customer_data(self, enterprise_customer_uuid): """ @@ -189,3 +190,10 @@ def bulk_enroll_enterprise_learners(self, enterprise_id, options): """ enrollment_url = '{}{}/enroll_learners_in_courses/'.format(self.enterprise_customer_endpoint, enterprise_id) return self.client.post(enrollment_url, json=options, timeout=settings.BULK_ENROLL_REQUEST_TIMEOUT_SECONDS) + + def bulk_unlink_enterprise_users(self, enterprise_uuid, options): + """ + Calls the Enterprise `unlink_users` API to unlink learners for an enterprise. + """ + enrollment_url = '{}{}/unlink_users/'.format(self.unlink_users_endpoint, enterprise_uuid) + return self.client.post(enrollment_url, json=options, timeout=settings.BULK_ENROLL_REQUEST_TIMEOUT_SECONDS) diff --git a/license_manager/apps/subscriptions/constants.py b/license_manager/apps/subscriptions/constants.py index 8782e9c7..2785de7e 100644 --- a/license_manager/apps/subscriptions/constants.py +++ b/license_manager/apps/subscriptions/constants.py @@ -151,3 +151,5 @@ class SegmentEvents: } ENTERPRISE_BRAZE_ALIAS_LABEL = 'Enterprise' # Do Not change this, this is consistent with other uses across edX repos. + +EXPIRED_LICENSE_PROCESSED = 'edx.server.license-manager.expired.license.processed' diff --git a/license_manager/apps/subscriptions/management/commands/process_expired_licenses.py b/license_manager/apps/subscriptions/management/commands/process_expired_licenses.py new file mode 100644 index 00000000..1672bb63 --- /dev/null +++ b/license_manager/apps/subscriptions/management/commands/process_expired_licenses.py @@ -0,0 +1,156 @@ + +import logging + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.core.paginator import Paginator +from django.db.models import Exists, OuterRef + +from license_manager.apps.api_client.enterprise import EnterpriseApiClient +from license_manager.apps.subscriptions.constants import ( + ACTIVATED, + ASSIGNED, + EXPIRED_LICENSE_PROCESSED, +) +from license_manager.apps.subscriptions.models import ( + CustomerAgreement, + License, + LicenseEvent, + SubscriptionPlan, +) +from license_manager.apps.subscriptions.utils import localized_utcnow + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = ( + 'Process expired licenses.' + ) + + def add_arguments(self, parser): + """ + Entry point to add arguments. + """ + parser.add_argument( + '--dry-run', + action='store_true', + dest='dry_run', + default=False, + help='Dry Run, print log messages without firing the segment event.', + ) + + def expired_licenses(self, enterprise_customer_uuid): + """ + Get expired licenses. + """ + now = localized_utcnow() + expired_subscription_plan_uuids = [] + + customer_agreement = CustomerAgreement.objects.get(enterprise_customer_uuid=enterprise_customer_uuid) + + expired_subscription_plans = list( + SubscriptionPlan.objects.filter( + customer_agreement=customer_agreement, + expiration_date__lt=now, + ).select_related( + 'customer_agreement' + ).prefetch_related( + 'licenses' + ) + ) + + for expired_subscription_plan in expired_subscription_plans: + # exclude subscription plan if there is a renewal + if expired_subscription_plan.get_renewal(): + continue + + expired_subscription_plan_uuids.append(expired_subscription_plan.uuid) + + # include prior renewals + for prior_renewal in expired_subscription_plan.prior_renewals: + expired_subscription_plan_uuids.append(prior_renewal.prior_subscription_plan.uuid) + + queryset = License.objects.filter( + status__in=[ASSIGNED, ACTIVATED], + renewed_to=None, + subscription_plan__uuid__in=expired_subscription_plan_uuids, + ).select_related( + 'subscription_plan', + ).values('uuid', 'lms_user_id', 'user_email') + + # Subquery to check for the existence of `EXPIRED_LICENSE_PROCESSED` + event_exists_subquery = LicenseEvent.objects.filter( + license=OuterRef('pk'), + event_name=EXPIRED_LICENSE_PROCESSED + ).values('pk') + + # Exclude previously processed licenses. + queryset = queryset.exclude(Exists(event_exists_subquery)) + + return queryset + + def handle(self, *args, **options): + """ + Process expired licenses and un-link learners. + """ + unlink = not options['dry_run'] + + log_prefix = '[PROCESS_EXPIRED_LICENSES]' + if not unlink: + log_prefix = '[DRY RUN]' + + logger.info('%s Command started.', log_prefix) + + enterprise_customer_uuids = settings.CUSTOMERS_WITH_EXPIRED_LICENSES_UNLINKING_ENABLED + for enterprise_customer_uuid in enterprise_customer_uuids: + logger.info('%s Processing started for licenses. Enterprise: [%s]', log_prefix, enterprise_customer_uuid) + self.process_expired_licenses(enterprise_customer_uuid, log_prefix, unlink) + logger.info('%s Processing completed for licenses. Enterprise: [%s]', log_prefix, enterprise_customer_uuid) + + logger.info('%s Command completed.', log_prefix) + + def process_expired_licenses(self, enterprise_customer_uuid, log_prefix, unlink): + """ + Process expired licenses and un-link learners. + """ + expired_licenses = self.expired_licenses(enterprise_customer_uuid) + + if not expired_licenses: + logger.info( + '%s No expired licenses were found for enterprise: [%s].', + log_prefix, enterprise_customer_uuid + ) + return + + paginator = Paginator(expired_licenses, 100) + for page_number in paginator.page_range: + licenses = paginator.page(page_number) + + license_uuids = [license.get('uuid') for license in licenses] + user_emails = [license.get('user_email') for license in licenses] + + logger.info( + "%s learners unlinked for licenses. Enterprise: [%s], LicenseUUIDs: [%s].", + log_prefix, + enterprise_customer_uuid, + license_uuids + ) + + if unlink: + # EnterpriseApiClient.bulk_unlink_enterprise_users( + # enterprise_customer_uuid, + # { + # 'user_emails': user_emails, + # 'is_relinkable': False + # }, + + # ) + + # Create license events for unlinked licenses to avoid processing them again. + unlinked_license_events = [ + LicenseEvent(license_id=license_uuid, event_name=EXPIRED_LICENSE_PROCESSED) + for license_uuid in license_uuids + ] + LicenseEvent.objects.bulk_create(unlinked_license_events, batch_size=100) diff --git a/license_manager/settings/base.py b/license_manager/settings/base.py index 808d78c4..e63b5f5d 100644 --- a/license_manager/settings/base.py +++ b/license_manager/settings/base.py @@ -477,3 +477,4 @@ ] CUSTOMERS_WITH_CUSTOM_LICENSE_EVENTS = ['00000000-1111-2222-3333-444444444444'] +CUSTOMERS_WITH_EXPIRED_LICENSES_UNLINKING_ENABLED = ['00000000-1111-2222-3333-444444444444']