class SubscriptionPlanFactory(factory.django.DjangoModelFactory): """ Test factory for the `SubscriptionPlan` model. Creates a subscription purchased and starting today by default. """ class Meta: model = SubscriptionPlan # Make the title sufficiently random to avoid violating # the unique constraint on (customer_agreement, title) title = factory.LazyAttribute( lambda p: '{} {}'.format(FAKER.word(), random.randint(0, 1000000))) uuid = factory.LazyFunction(uuid4) is_active = True start_date = localized_utcnow() # Make the subscription expire in roughly a year and a day expiration_date = localized_utcnow() + timedelta(days=366) expiration_processed = False customer_agreement = factory.SubFactory(CustomerAgreementFactory) enterprise_catalog_uuid = factory.LazyFunction(uuid4) salesforce_opportunity_id = factory.LazyFunction(get_random_salesforce_id) product = factory.SubFactory(ProductFactory) can_freeze_unused_licenses = False should_auto_apply_licenses = False
def make_bound_subscription_form( title=faker.pystr(min_chars=1, max_chars=127), start_date=localized_utcnow(), expiration_date=localized_utcnow() + timedelta(days=366), enterprise_catalog_uuid=faker.uuid4(), enterprise_customer_uuid=faker.uuid4(), salesforce_opportunity_id=get_random_salesforce_id(), num_licenses=0, is_active=False, for_internal_use_only=False, has_product=True, is_sf_id_required=False, has_customer_agreement=True, customer_agreement_has_default_catalog=True, change_reason="new"): """ Builds a bound SubscriptionPlanForm """ if customer_agreement_has_default_catalog: customer_agreement = CustomerAgreementFactory( enterprise_customer_uuid=enterprise_customer_uuid) else: customer_agreement = CustomerAgreementFactory( enterprise_customer_uuid=enterprise_customer_uuid, default_enterprise_catalog_uuid=None) product = ProductFactory(plan_type=PlanTypeFactory( sf_id_required=is_sf_id_required)) form_data = { 'title': title, 'start_date': start_date, 'expiration_date': expiration_date, 'enterprise_catalog_uuid': enterprise_catalog_uuid, 'product': product.id if has_product else None, 'salesforce_opportunity_id': salesforce_opportunity_id, 'num_licenses': num_licenses, 'is_active': is_active, 'for_internal_use_only': for_internal_use_only, 'customer_agreement': str(customer_agreement.uuid) if has_customer_agreement else None, 'change_reason': change_reason } return SubscriptionPlanForm(form_data)
def get_context_from_subscription_plan_by_activation_key(request): """ Helper function to return the permission context (i.e., enterprise customer uuid) from active subscription plan associated with the license identified by the ``activation_key`` query param on a request and the ``email`` provided in the request's JWT. Params: ``request`` - A DRF Request object. Returns: The ``enterprise_customer_uuid`` associated with the user's license. """ today = localized_utcnow() activation_key = get_activation_key_from_request(request) try: user_license = License.objects.get( activation_key=activation_key, user_email=get_email_from_request(request), subscription_plan__is_active=True, subscription_plan__start_date__lte=today, subscription_plan__expiration_date__gte=today, ) except License.DoesNotExist as exc: decoded_jwt = get_decoded_jwt(request) lms_user_id = get_key_from_jwt(decoded_jwt, 'user_id') logger.exception( 'License not found for activation key %s for user %s', activation_key, lms_user_id ) raise Http404('No License matches the given query.') from exc return user_license.subscription_plan.customer_agreement.enterprise_customer_uuid
def is_valid(self): # Perform original validation and return if false if not super().is_valid(): return False # Subscription dates should follow this ordering: # subscription start date <= subscription expiration date <= subscription renewal effective date <= # subscription renewal expiration date form_effective_date = self.cleaned_data.get('effective_date') form_renewed_expiration_date = self.cleaned_data.get( 'renewed_expiration_date') if form_effective_date < localized_utcnow(): self.add_error( 'effective_date', 'A subscription renewal can not be scheduled to become effective in the past.', ) return False if form_renewed_expiration_date < form_effective_date: self.add_error( 'renewed_expiration_date', 'A subscription renewal can not expire before it becomes effective.', ) return False subscription = self.instance.prior_subscription_plan if form_effective_date < subscription.expiration_date: self.add_error( 'effective_date', 'A subscription renewal can not take effect before a subscription expires.', ) return False return True
def for_user_and_customer( cls, user_email, lms_user_id, enterprise_customer_uuid, active_plans_only=False, current_plans_only=False, ): """ Returns all licenses asssociated with the given user email or lms_user_id that are associated with a particular customer's SubscrptionPlan. The optional ``active_plans_only`` and ``current_plans_only`` allow the caller to filter for licenses whose plans are marked ``active`` or that are current (the current time is within the plan's start/end range), respectively. """ queryset = cls.by_user_email_or_lms_user_id(user_email, lms_user_id) kwargs = { 'subscription_plan__customer_agreement__enterprise_customer_uuid': enterprise_customer_uuid, } if active_plans_only: kwargs['subscription_plan__is_active'] = True if current_plans_only: now = localized_utcnow() kwargs['subscription_plan__start_date__lte'] = now kwargs['subscription_plan__expiration_date__gte'] = now return queryset.filter(**kwargs)
def get_licenses_exceeding_purge_duration(cls, date_field_to_compare, **kwargs): """ Returns all licenses with non-null ``user_email`` values that have exceeded the purge duration specified by the related plan's ``CustomerAgreement.license_duration_before_purge`` value. The ``date_field_to_compare`` argument is compared to this value to determine if the duration has been exceeded. It can be the name of any valid field for a queryset that filters on ``License`` or a related ``SubscriptionPlan`` or ``CustomerAgreement`` - for example: 'activation_date' 'revoked_date' 'subscription_plan__expiration_date' 'subscription_plan__start_date' """ duration_before_purge_field = 'subscription_plan__customer_agreement__license_duration_before_purge' date_field = date_field_to_compare + '__lt' date_field_is_null = date_field_to_compare + '__isnull' kwargs.update({ 'user_email__isnull': False, date_field_is_null: False, date_field: localized_utcnow() - models.F(duration_before_purge_field), }) return License.objects.filter(**kwargs).select_related( 'subscription_plan', 'subscription_plan__customer_agreement', )
def revoke(self): """ Performs all field updates required to revoke a License """ self.status = REVOKED self.revoked_date = localized_utcnow() self.save()
def handle(self, *args, **options): now = localized_utcnow() renewal_processing_window_cutoff = now + timedelta(hours=int(options['processing_window_length_hours'])) renewals_to_be_processed = SubscriptionPlanRenewal.objects.filter( effective_date__gte=now, effective_date__lte=renewal_processing_window_cutoff, processed=False ).select_related( 'prior_subscription_plan', 'prior_subscription_plan__customer_agreement', 'renewed_subscription_plan' ) subscription_uuids = [str(renewal.prior_subscription_plan.uuid) for renewal in renewals_to_be_processed] if not options['dry_run']: logger.info('Processing {} renewals for subscriptions with uuids: {}'.format( len(subscription_uuids), subscription_uuids)) renewed_subscription_uuids = [] for renewal in renewals_to_be_processed: subscription_uuid = str(renewal.prior_subscription_plan.uuid) try: renew_subscription(renewal, is_auto_renewed=True) renewed_subscription_uuids.append(subscription_uuid) except RenewalProcessingError: logger.error('Could not automatically process renewal with id: {}'.format( renewal.id), exc_info=True) logger.info('Processed {} renewals for subscriptions with uuids: {}'.format( len(renewed_subscription_uuids), renewed_subscription_uuids)) else: logger.info('Dry-run result subscriptions that would be renewed: {} '.format( subscription_uuids))
def test_is_locked_for_renewal_processing(self, is_locked_for_renewal_processing): today = localized_utcnow() with freezegun.freeze_time(today): renewed_subscription_plan = SubscriptionPlanFactory.create(expiration_date=today) renewal_kwargs = {'prior_subscription_plan': renewed_subscription_plan} if is_locked_for_renewal_processing: renewal_kwargs.update({'effective_date': renewed_subscription_plan.expiration_date}) SubscriptionPlanRenewalFactory.create(**renewal_kwargs) self.assertEqual(renewed_subscription_plan.is_locked_for_renewal_processing, is_locked_for_renewal_processing)
def test_invalid_start_date_before_today(self): prior_subscription_plan = SubscriptionPlanFactory.create() form = make_bound_subscription_plan_renewal_form( prior_subscription_plan=prior_subscription_plan, effective_date=localized_utcnow() - timedelta(1), renewed_expiration_date=prior_subscription_plan.expiration_date + timedelta(366), ) assert not form.is_valid()
def post(self, request): """ Activates a license, given an ``activation_key`` query param (which should be a UUID). Route: /api/v1/license-activation?activation_key=your-key Returns: * 400 Bad Request - if the ``activation_key`` query parameter is malformed or missing, or if the user's email could not be found in the jwt. * 401 Unauthorized - if the requesting user is not authenticated. * 403 Forbidden - if the requesting user is not allowed to access the associated license's subscription plan. * 404 Not Found - if the email found in the request's JWT and the provided ``activation_key`` do not match those of any existing license in an activate subscription plan. * 204 No Content - if such a license was found, and if the license is currently ``assigned``, it is updated with a status of ``activated``, its ``activation_date`` is set, and its ``lms_user_id`` is updated to the value found in the request's JWT. If the license is already ``activated``, no update is made to it. * 422 Unprocessable Entity - if we find a license, but it's status is not currently ``assigned`` or ``activated``, we do nothing and return a 422 with a message indicating that the license cannot be activated. """ activation_key_uuid = utils.get_activation_key_from_request(request) try: user_license = License.objects.get( activation_key=activation_key_uuid, user_email=self.user_email, subscription_plan__is_active=True, ) except License.DoesNotExist: msg = 'No license exists for the email {} with activation key {}'.format( self.user_email, activation_key_uuid, ) return Response(msg, status=status.HTTP_404_NOT_FOUND) if user_license.status not in (constants.ASSIGNED, constants.ACTIVATED): return Response( f'Cannot activate a license with a status of {user_license.status}', status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) if user_license.status == constants.ASSIGNED: user_license.status = constants.ACTIVATED user_license.activation_date = localized_utcnow() user_license.lms_user_id = self.lms_user_id user_license.save() # Following successful license activation, send learner an email # to help them get started using the Enterprise Learner Portal. send_onboarding_email_task.delay( user_license.subscription_plan.enterprise_customer_uuid, user_license.user_email, ) return Response(status=status.HTTP_204_NO_CONTENT)
def test_retire_old_licenses(self, _): """ Verify that the command retires the correct licenses appropriately and logs messages about the retirement. """ with freeze_time( localized_utcnow()), self.assertLogs(level='INFO') as log: call_command(self.command_name) # Verify all expired licenses that were ready for retirement have been retired correctly expired_licenses = self.expired_subscription_plan.licenses.all() assert_date_fields_correct(expired_licenses, ['revoked_date'], True) for expired_license in expired_licenses: expired_license.refresh_from_db() assert_pii_cleared(expired_license) assert expired_license.status == REVOKED assert_historical_pii_cleared(expired_license) message = 'Retired {} expired licenses with uuids: {}'.format( expired_licenses.count(), sorted([ expired_license.uuid for expired_license in expired_licenses ]), ) assert message in log.output[0] # Verify all revoked licenses that were ready for retirement have been retired correctly for revoked_license in self.revoked_licenses_ready_for_retirement: revoked_license.refresh_from_db() assert_pii_cleared(revoked_license) assert_historical_pii_cleared(revoked_license) message = 'Retired {} revoked licenses with uuids: {}'.format( self.num_revoked_licenses_to_retire, sorted([ revoked_license.uuid for revoked_license in self.revoked_licenses_ready_for_retirement ]), ) assert message in log.output[1] # Verify all assigned licenses that were ready for retirement have been retired correctly for assigned_license in self.assigned_licenses_ready_for_retirement: assigned_license.refresh_from_db() assert_license_fields_cleared(assigned_license) assert_pii_cleared(assigned_license) assert_historical_pii_cleared(assigned_license) assert assigned_license.activation_key is None assert assigned_license.status == UNASSIGNED message = 'Retired {} assigned licenses that exceeded their inactivation duration with uuids: {}'.format( self.num_assigned_licenses_to_retire, sorted([ assigned_license.uuid for assigned_license in self.assigned_licenses_ready_for_retirement ]), ) assert message in log.output[2]
def add_arguments(self, parser): parser.add_argument( '--expired-after', action='store', dest='expiration_date_from', help= 'The oldest expiration date for subscriptions to be processed, can be used with --expired-before to ' 'set a date range formatted as %Y-%m-%dT%H:%M:%S', default=(localized_utcnow() - timedelta(days=1)).strftime(DATE_FORMAT)) parser.add_argument( '--expired-before', action='store', dest='expiration_date_to', help= 'The most recent expiration date for subscriptions to be processed, can be used with --expired-after ' 'to set a date range formatted as %Y-%m-%dT%H:%M:%S', default=localized_utcnow().strftime(DATE_FORMAT)) parser.add_argument( '--subscription-uuids', action='store', dest='subscription_uuids', help= 'Delimited subscription uuids used to specify which subscription plans should be expired.', type=lambda s: [str(uuid) for uuid in s.split(',')]) parser.add_argument( '--dry-run', action='store', dest='dry_run', help= 'Used to see which subscriptions would be processed by running this command without making changes', default=False) parser.add_argument( '--force', action='store_true', dest='force', default=False, )
def revoke(self): """ Performs all field updates required to revoke a License """ self.status = REVOKED self.revoked_date = localized_utcnow() self.save() event_properties = get_license_tracking_properties(self) track_event(self.lms_user_id, SegmentEvents.LICENSE_REVOKED, event_properties)
def set_date_fields_to_now(licenses, date_field_names): """ Helper function to bulk set the field given by `date_field_name` on a group of licenses to now. Args: licenses (iterable): The licenses to set the field to now on. date_field_name (list of str): The names of the date field to set to now. """ for subscription_license in licenses: for field_name in date_field_names: setattr(subscription_license, field_name, localized_utcnow()) License.bulk_update(licenses, date_field_names)
def auto_applicable_subscription(self): """ Get which subscription on CustomerAgreement is auto-applicable. """ now = localized_utcnow() plan = self.subscriptions.filter( should_auto_apply_licenses=True, is_active=True, start_date__lte=now, expiration_date__gte=now ).first() return plan
def assert_date_fields_correct(licenses, date_field_names, should_be_updated): """ Helper that verifies that all of the given licenses have had the given date fields updated if applicable. If they should not have been updated, then it checks that the fields given by `date_field_names` is still None. """ for license_obj in licenses: license_obj.refresh_from_db() if should_be_updated: for field_name in date_field_names: assert getattr(license_obj, field_name) == localized_utcnow() else: for field_name in date_field_names: assert getattr(license_obj, field_name) is None
def test_auto_applied_licenses_count_since(self): """ Tests that the correct auto-applied license count is returned. """ subscription_plan = SubscriptionPlanFactory.create(should_auto_apply_licenses=True) timestamp_1 = localized_utcnow() LicenseFactory.create_batch(1, subscription_plan=subscription_plan, auto_applied=True, activation_date=timestamp_1) LicenseFactory.create_batch(3, subscription_plan=subscription_plan, auto_applied=False, activation_date=timestamp_1) self.assertEqual(subscription_plan.auto_applied_licenses_count_since(), 1) timestamp_2 = timestamp_1 + timedelta(seconds=1) self.assertEqual(subscription_plan.auto_applied_licenses_count_since(timestamp_2), 0) LicenseFactory.create_batch(5, subscription_plan=subscription_plan, auto_applied=True, activation_date=timestamp_2) self.assertEqual(subscription_plan.auto_applied_licenses_count_since(timestamp_2), 5)
def test_track_event_via_braze_alias(mock_braze_client): test_email = '*****@*****.**' assigned_license = LicenseFactory.create( subscription_plan=SubscriptionPlanFactory.create(), lms_user_id=5, user_email=test_email, status=ASSIGNED, auto_applied=True) test_event_name = 'test-event-name' test_event_properties = { 'license_uuid': str(assigned_license.uuid), 'enterprise_customer_slug': assigned_license.subscription_plan.customer_agreement. enterprise_customer_slug, } expected_user_alias = { "alias_name": test_email, "alias_label": ENTERPRISE_BRAZE_ALIAS_LABEL, } expected_attributes = { "user_alias": expected_user_alias, "email": test_email, "is_enterprise_learner": True, "_update_existing_only": False, "license_uuid": str(assigned_license.uuid), "enterprise_customer_slug": assigned_license.subscription_plan.customer_agreement. enterprise_customer_slug, } expected_event = { "user_alias": expected_user_alias, "name": test_event_name, "time": _iso_8601_format_string(localized_utcnow()), "properties": test_event_properties, "_update_existing_only": False, } _track_event_via_braze_alias(test_email, test_event_name, test_event_properties) mock_braze_client().create_braze_alias.assert_any_call( [test_email], ENTERPRISE_BRAZE_ALIAS_LABEL) mock_braze_client().track_user.assert_any_call( attributes=[expected_attributes], events=[expected_event])
def send_initial_utilization_email_task(subscription_uuid): """ Sends email to admins detailing license utilization for a subscription plan after the initial week. Arguments: subscription_uuid (str): The subscription plan's uuid """ subscription = SubscriptionPlan.objects.get(uuid=subscription_uuid) # check if the email has been sent for the subscription plan already # we only want to send this email once for any given plan has_email_been_sent_before = Notification.objects.filter( subscripton_plan_id=subscription.uuid, notification_type=NotificationChoices.PERIODIC_INFORMATIONAL).count( ) > 0 if has_email_been_sent_before: return # get the date when the subscription was last turned on auto_apply_licenses_turned_on_at = subscription.auto_apply_licenses_turned_on_at now = localized_utcnow() is_email_ready = (now - auto_apply_licenses_turned_on_at ).days >= DAYS_BEFORE_INITIAL_UTILIZATION_EMAIL_SENT if not is_email_ready: return admin_users = _get_admin_users_for_enterprise( subscription.customer_agreement.enterprise_customer_uuid) with transaction.atomic(): for admin_user in admin_users: notification = Notification.objects.create( enterprise_customer_uuid=subscription.customer_agreement. enterprise_customer_uuid, enterprise_customer_user_uuid=admin_user['ecu_id'], subscripton_plan_id=subscription.uuid, notification_type=NotificationChoices.PERIODIC_INFORMATIONAL) notification.save() # will raise exception and roll back changes if error occurs _send_license_utilization_email( subscription=subscription, campaign_id=settings.INITIAL_LICENSE_UTILIZATION_CAMPAIGN, users=admin_users)
class SubscriptionPlanRenewalFactory(factory.django.DjangoModelFactory): """ Test factory for the `SubscriptionPlanRenewal` model. Creates a subscription plan renewal with randomized data """ class Meta: model = SubscriptionPlanRenewal prior_subscription_plan = factory.SubFactory(SubscriptionPlanFactory) renewed_subscription_plan = None salesforce_opportunity_id = factory.LazyFunction(get_random_salesforce_id) number_of_licenses = 5 effective_date = localized_utcnow() + timedelta(days=366) renewed_expiration_date = effective_date + timedelta(days=366) processed = False disable_auto_apply_licenses = False
def handle(self, *args, **options): now = localized_utcnow() subscriptions = SubscriptionPlan.objects.filter( should_auto_apply_licenses=True, is_active=True, start_date__lte=now, expiration_date__gte=now).select_related('customer_agreement') if not subscriptions: logger.info( 'No subscriptions with auto-applied licenses found, skipping license-utilization emails.' ) return for subscription in subscriptions: send_initial_utilization_email_task.delay(subscription.uuid)
def send_revocation_cap_notification_email_task(subscription_uuid): """ Sends revocation cap email notification to ECS asynchronously. Arguments: subscription_uuid (str): UUID (string representation) of the subscription that has reached its recovation cap. """ subscription_plan = SubscriptionPlan.objects.get(uuid=subscription_uuid) enterprise_api_client = EnterpriseApiClient() enterprise_customer = enterprise_api_client.get_enterprise_customer_data( subscription_plan.enterprise_customer_uuid) enterprise_name = enterprise_customer.get('name') now = localized_utcnow() revocation_date = datetime.strftime(now, "%B %d, %Y, %I:%M%p %Z") braze_campaign_id = settings.BRAZE_REVOKE_CAP_EMAIL_CAMPAIGN braze_trigger_properties = { 'SUBSCRIPTION_TITLE': subscription_plan.title, 'NUM_REVOCATIONS_APPLIED': subscription_plan.num_revocations_applied, 'ENTERPRISE_NAME': enterprise_name, 'REVOKED_LIMIT_REACHED_DATE': revocation_date, } recipient = _aliased_recipient_object_from_email( settings.CUSTOMER_SUCCESS_EMAIL_ADDRESS) try: braze_client_instance = BrazeApiClient() braze_client_instance.create_braze_alias( [settings.CUSTOMER_SUCCESS_EMAIL_ADDRESS], ENTERPRISE_BRAZE_ALIAS_LABEL, ) braze_client_instance.send_campaign_message( braze_campaign_id, recipients=[recipient], trigger_properties=braze_trigger_properties, ) except BrazeClientError as exc: message = 'Revocation cap notification email sending received an exception.' logger.exception(message) raise exc
def _expire_subscription_plan(self, expired_subscription_plan): """ Expires a single subscription plan. """ expired_licenses = [] for lcs in expired_subscription_plan.licenses.iterator(): if lcs.status in [ASSIGNED, ACTIVATED]: expired_licenses.append(lcs) any_failures = False # Terminate the licensed course enrollments for license_chunk in chunks(expired_licenses, LICENSE_EXPIRATION_BATCH_SIZE): try: license_chunk_uuids = [str(lcs.uuid) for lcs in license_chunk] # We might be running this command against a plan that expired further in the past to fix bad data. We don't # want to modify a course enrollment if it's been modified after the plan expiration because a user might have upgraded # the course. ignore_enrollments_modified_after = expired_subscription_plan.expiration_date.isoformat() \ if expired_subscription_plan.expiration_date < localized_utcnow() - timedelta(days=1) else None license_expiration_task(license_chunk_uuids, ignore_enrollments_modified_after= ignore_enrollments_modified_after) except Exception: # pylint: disable=broad-except any_failures = True msg = 'Failed to terminate course enrollments for learners in subscription: {}'.format( expired_subscription_plan.uuid) logger.exception(msg) if not any_failures: message = 'Terminated course enrollments for learners in subscription: {}'.format( expired_subscription_plan.uuid) logger.info(message) expired_subscription_plan.expiration_processed = True expired_subscription_plan.save( update_fields=['expiration_processed'])
def create_subscription_plan(self, customer_agreement, num_licenses=1): """ Creates a SubscriptionPlan for a customer. """ timestamp = localized_utcnow() new_plan = SubscriptionPlan( title='Seed Generated Plan from {} {}'.format(customer_agreement, timestamp), customer_agreement=customer_agreement, enterprise_catalog_uuid=customer_agreement.default_enterprise_catalog_uuid, start_date=timestamp, expiration_date=timestamp + timedelta(days=365), is_active=True, for_internal_use_only=True, salesforce_opportunity_id=123456789123456789, product=Product.objects.get(name="B2B Paid") ) with transaction.atomic(): new_plan.save() new_plan.increase_num_licenses( num_licenses ) return new_plan
def populate_subscription_for_auto_applied_licenses_choices( self, instance): """ Populates the choice field used to choose which plan is used for auto-applied licenses in a Customer Agreement. """ now = localized_utcnow() active_plans = SubscriptionPlan.objects.filter( customer_agreement=instance, is_active=True, start_date__lte=now, expiration_date__gte=now) current_plan = instance.auto_applicable_subscription empty_choice = ('', '------') choices = [empty_choice] + [(plan.uuid, plan.title) for plan in active_plans] choice_field = forms.ChoiceField( choices=choices, required=False, initial=empty_choice if not current_plan else (current_plan.uuid, current_plan.title)) self.fields['subscription_for_auto_applied_licenses'] = choice_field
def setUpTestData(cls): super().setUpTestData() subscription_plan = SubscriptionPlanFactory() day_before_retirement_deadline = localized_utcnow() - timedelta( DAYS_TO_RETIRE + 1) cls.expired_subscription_plan = SubscriptionPlanFactory( expiration_date=day_before_retirement_deadline) # Set up a bunch of licenses that should not be retired LicenseFactory.create_batch( 3, status=ACTIVATED, subscription_plan=subscription_plan, revoked_date=None, ) LicenseFactory.create_batch( 4, status=REVOKED, subscription_plan=subscription_plan, revoked_date=localized_utcnow(), ) LicenseFactory.create_batch( 5, status=REVOKED, subscription_plan=subscription_plan, revoked_date=localized_utcnow(), ) LicenseFactory.create_batch( 5, status=REVOKED, subscription_plan=subscription_plan, revoked_date=day_before_retirement_deadline, user_email= None, # The user_email is None to represent already retired licenses ) LicenseFactory.create_batch( 2, status=ASSIGNED, subscription_plan=subscription_plan, assigned_date=localized_utcnow(), revoked_date=None, ) # Set up licenses that should be retired as either there subscription plan has been expired for long enough # for retirement, or they were assigned/revoked a day before the current date to retire. cls.num_revoked_licenses_to_retire = 6 cls.revoked_licenses_ready_for_retirement = LicenseFactory.create_batch( cls.num_revoked_licenses_to_retire, status=REVOKED, subscription_plan=subscription_plan, revoked_date=day_before_retirement_deadline, ) for revoked_license in cls.revoked_licenses_ready_for_retirement: revoked_license.lms_user_id = faker.random_int() revoked_license.user_email = faker.email() revoked_license.save() cls.num_assigned_licenses_to_retire = 7 cls.assigned_licenses_ready_for_retirement = LicenseFactory.create_batch( cls.num_assigned_licenses_to_retire, status=ASSIGNED, subscription_plan=subscription_plan, assigned_date=day_before_retirement_deadline, ) for assigned_license in cls.assigned_licenses_ready_for_retirement: assigned_license.lms_user_id = faker.random_int() assigned_license.user_email = faker.email() assigned_license.save() # Create licenses of different statuses that should be retired from association with an old expired subscription LicenseFactory.create( status=ACTIVATED, subscription_plan=cls.expired_subscription_plan, lms_user_id=faker.random_int(), user_email=faker.email(), ) LicenseFactory.create( status=ASSIGNED, subscription_plan=cls.expired_subscription_plan, lms_user_id=faker.random_int(), user_email=faker.email(), ) LicenseFactory.create( status=REVOKED, lms_user_id=faker.random_int(), user_email=faker.email(), subscription_plan=cls.expired_subscription_plan, )
def assign(self, request, subscription_uuid=None): """ Given a list of emails, assigns a license to those user emails and sends an activation email. This endpoint allows assigning licenses to users who have previously had a license revoked, by removing their association to the revoked licenses and then assigning them to unassigned licenses. """ # Validate the user_emails and text sent in the data self._validate_data(request.data) # Dedupe all lowercase emails before turning back into a list for indexing user_emails = list( {email.lower() for email in request.data.get('user_emails', [])}) subscription_plan = self._get_subscription_plan() # Find any emails that have already been associated with a non-revoked license in the subscription # and remove from user_emails list already_associated_licenses = subscription_plan.licenses.filter( user_email__in=user_emails, status__in=[constants.ASSIGNED, constants.ACTIVATED], ) if already_associated_licenses: already_associated_emails = list( already_associated_licenses.values_list('user_email', flat=True)) for email in already_associated_emails: user_emails.remove(email.lower()) # Get the revoked licenses that are attempting to be assigned to revoked_licenses_for_assignment = subscription_plan.licenses.filter( status=constants.REVOKED, user_email__in=user_emails, ) # Make sure there are enough licenses that we can assign to num_user_emails = len(user_emails) num_unassigned_licenses = subscription_plan.unassigned_licenses.count() # Since we flip the status of revoked licenses when admins attempt to re-assign that learner to a new # license, we check that there are enough unassigned licenses when combined with the revoked licenses that # will have their status change num_potential_unassigned_licenses = num_unassigned_licenses + revoked_licenses_for_assignment.count( ) if num_user_emails > num_potential_unassigned_licenses: msg = ( 'There are not enough licenses that can be assigned to complete your request.' 'You attempted to assign {} licenses, but there are only {} potentially available.' ).format(num_user_emails, num_potential_unassigned_licenses) return Response(msg, status=status.HTTP_400_BAD_REQUEST) # Flip all revoked licenses that were associated with emails that we are assigning to unassigned, and clear # all the old data on the license. for revoked_license in revoked_licenses_for_assignment: revoked_license.reset_to_unassigned() License.bulk_update( revoked_licenses_for_assignment, [ 'status', 'user_email', 'lms_user_id', 'last_remind_date', 'activation_date', 'activation_key', 'assigned_date', 'revoked_date', ], ) # Get a queryset of only the number of licenses we need to assign unassigned_licenses = subscription_plan.unassigned_licenses[: num_user_emails] for unassigned_license, email in zip(unassigned_licenses, user_emails): # Assign each email to a license and mark the license as assigned unassigned_license.user_email = email unassigned_license.status = constants.ASSIGNED activation_key = str(uuid4()) unassigned_license.activation_key = activation_key unassigned_license.assigned_date = localized_utcnow() unassigned_license.last_remind_date = localized_utcnow() License.bulk_update( unassigned_licenses, [ 'user_email', 'status', 'activation_key', 'assigned_date', 'last_remind_date' ], ) # Create async chains of the pending learners and activation emails tasks with each batch of users # The task signatures are immutable, hence the `si()` - we don't want the result of the # link_learners_to_enterprise_task passed to the "child" activation_email_task. for pending_learner_batch in chunks( user_emails, constants.PENDING_ACCOUNT_CREATION_BATCH_SIZE): chain( link_learners_to_enterprise_task.si( pending_learner_batch, subscription_plan.enterprise_customer_uuid, ), activation_email_task.si( self._get_custom_text(request.data), pending_learner_batch, subscription_uuid, )).apply_async() # Pass email assignment data back to frontend for display response_data = { 'num_successful_assignments': len(user_emails), 'num_already_associated': len(already_associated_licenses) } return Response(data=response_data, status=status.HTTP_200_OK)
class ProcessRenewalsCommandTests(TestCase): command_name = 'process_renewals' now = localized_utcnow() def tearDown(self): """ Deletes all renewals, licenses, and subscription after each test method is run. """ super().tearDown() License.objects.all().delete() SubscriptionPlan.objects.all().delete() SubscriptionPlanRenewal.objects.all().delete() def create_subscription_with_renewal(self, effective_date, processed=False): prior_subscription_plan = SubscriptionPlanFactory.create( start_date=self.now - timedelta(days=7), expiration_date=self.now, ) renewed_subscription_plan = SubscriptionPlanFactory.create( start_date=self.now, expiration_date=self.now + timedelta(days=7), ) SubscriptionPlanRenewalFactory.create( prior_subscription_plan=prior_subscription_plan, renewed_subscription_plan=renewed_subscription_plan, effective_date=effective_date, processed=processed) return (prior_subscription_plan) @mock.patch( 'license_manager.apps.subscriptions.management.commands.process_renewals.renew_subscription' ) def test_no_upcoming_renewals(self, mock_renew_subscription): """ Verify that only unprocessed renewals within their processing window are processed """ with self.assertLogs(level='INFO') as log, freezegun.freeze_time( self.now): # renewal far in the future self.create_subscription_with_renewal(self.now + timedelta( hours=settings.SUBSCRIPTION_PLAN_RENEWAL_LOCK_PERIOD_HOURS) + timedelta(seconds=1)) # renewal in the past self.create_subscription_with_renewal(self.now - timedelta(seconds=1)) # renewal that has already been processed self.create_subscription_with_renewal(self.now + timedelta(seconds=1), processed=True) call_command(self.command_name) assert mock_renew_subscription.call_count == 0 assert 'Processing 0 renewals for subscriptions with uuids: []' in log.output[ 0] assert 'Processed 0 renewals for subscriptions with uuids: []' in log.output[ 1] @mock.patch( 'license_manager.apps.subscriptions.management.commands.process_renewals.renew_subscription' ) def test_upcoming_renewals(self, mock_renew_subscription): """ Verify that unprocessed renewals within their processing window are processed """ subscription_plan_1 = self.create_subscription_with_renewal( self.now + timedelta( hours=settings.SUBSCRIPTION_PLAN_RENEWAL_LOCK_PERIOD_HOURS)) subscription_plan_2 = self.create_subscription_with_renewal( self.now + timedelta(seconds=1)) with self.assertLogs(level='INFO') as log, freezegun.freeze_time( self.now): call_command(self.command_name) assert mock_renew_subscription.call_count == 2 assert "Processing 2 renewals for subscriptions with uuids: ['{}', '{}']".format( subscription_plan_1.uuid, subscription_plan_2.uuid) in log.output[0] assert "Processed 2 renewals for subscriptions with uuids: ['{}', '{}']".format( subscription_plan_1.uuid, subscription_plan_2.uuid) in log.output[1] @mock.patch( 'license_manager.apps.subscriptions.management.commands.process_renewals.renew_subscription' ) def test_renew_subscription_exception(self, mock_renew_subscription): """ Verify that an exception when processing a renewal will not stop other renewals from being processed """ mock_renew_subscription.side_effect = [RenewalProcessingError, None] subscription_plan_1 = self.create_subscription_with_renewal( self.now + timedelta( hours=settings.SUBSCRIPTION_PLAN_RENEWAL_LOCK_PERIOD_HOURS)) subscription_plan_2 = self.create_subscription_with_renewal( self.now + timedelta(seconds=1)) with self.assertLogs(level='INFO') as log, freezegun.freeze_time( self.now): call_command(self.command_name) assert mock_renew_subscription.call_count == 2 assert "Processing 2 renewals for subscriptions with uuids: ['{}', '{}']".format( subscription_plan_1.uuid, subscription_plan_2.uuid) in log.output[0] assert "Could not automatically process renewal with id: {}".format( subscription_plan_1.renewal.id) in log.output[1] assert "Processed 1 renewals for subscriptions with uuids: ['{}']".format( subscription_plan_2.uuid) in log.output[2]
from license_manager.apps.api import tasks from license_manager.apps.api.v1.tests.test_views import ( LicenseViewSetActionMixin, LicenseViewTestMixin, ) from license_manager.apps.subscriptions import api, constants, utils from license_manager.apps.subscriptions.tests.factories import ( LicenseFactory, SubscriptionPlanFactory, SubscriptionPlanRenewalFactory, ) logger = logging.getLogger(__name__) NOW = utils.localized_utcnow() class EventTestCaseBase(TestCase): """ Mocks the call to ``track_license_changes_task.delay()`` to just be a simple python invocation of ``track_license_changes()``. """ def setUp(self): super().setUp() self.mock_track_license_changes_mocker = mock.patch( 'license_manager.apps.api.v1.views.track_license_changes_task.delay', wraps=tasks.track_license_changes_task, ) self.mock_track_license_changes_mocker.start()