From 4973895feb99e6b0c238c9cb2d106d654b1b6230 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Thu, 19 Oct 2023 10:38:20 -0400 Subject: [PATCH] feat: adds a LicenseTransferJob model and admin Also installs django-autocomplete-light for help to support advanced admin autocomplete field filtering, and therefore runs make upgrade to pull in some other new package versions. --- .annotation_safe_list.yml | 2 + docs/decisions/0015-license-transfer-job.rst | 54 +++++ license_manager/apps/subscriptions/admin.py | 64 ++++++ license_manager/apps/subscriptions/forms.py | 37 +++- .../0062_add_license_transfer_job.py | 70 +++++++ license_manager/apps/subscriptions/models.py | 189 +++++++++++++++++- .../apps/subscriptions/tests/test_models.py | 181 +++++++++++++++++ .../apps/subscriptions/urls_admin.py | 33 +++ license_manager/apps/subscriptions/utils.py | 2 +- license_manager/settings/base.py | 3 + .../static/filtered_subscription_admin.js | 12 ++ license_manager/urls.py | 2 + requirements/base.in | 4 + requirements/base.txt | 23 ++- requirements/dev.txt | 37 ++-- requirements/doc.txt | 27 ++- requirements/pip.txt | 2 +- requirements/production.txt | 24 ++- requirements/quality.txt | 26 ++- requirements/test.txt | 26 ++- requirements/validation.txt | 35 ++-- 21 files changed, 771 insertions(+), 82 deletions(-) create mode 100644 docs/decisions/0015-license-transfer-job.rst create mode 100644 license_manager/apps/subscriptions/migrations/0062_add_license_transfer_job.py create mode 100644 license_manager/apps/subscriptions/urls_admin.py create mode 100644 license_manager/static/filtered_subscription_admin.js diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index 92d07da5..d6073540 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -39,6 +39,8 @@ subscriptions.HistoricalCustomerAgreement: ".. no_pii:": "This model has no PII" subscriptions.HistoricalLicense: ".. no_pii:": "This model has no PII" +subscriptions.HistoricalLicenseTransferJob: + ".. no_pii:": "This model has no PII" subscriptions.HistoricalNotification: ".. no_pii:": "This model has no PII" subscriptions.HistoricalSubscriptionPlan: diff --git a/docs/decisions/0015-license-transfer-job.rst b/docs/decisions/0015-license-transfer-job.rst new file mode 100644 index 00000000..48fa57f9 --- /dev/null +++ b/docs/decisions/0015-license-transfer-job.rst @@ -0,0 +1,54 @@ +15. License Transfer Jobs +######################### + +Status +****** +Accepted (October 2023) + +Context +******* +There are some customer agreements for which we want to support transferring +licenses between Subscription Plans, particularly in the following scenario: + +* A learner is assigned (and activates) a license in Plan A. +* By some threshold date for Plan A, like a "lock" or "cutoff" time, + the plan is closed (meaning no more licenses will be assigned from that plan). +* There’s a new, rolling Subscription Plan B that starts directly + after the lock time of Plan A. + +In this scenario, We want to give the learner an opportunity to +continue learning via a subscription license under Plan B. +Furthermore, we want the enrollment records to continue to be associated +with the original license, but for the license to now be associated with plan B +(which may be necessary for back-office reporting purposes). + +Decision +******** +We've introuduced a ``LicenseTransferJob`` model that, given a set of +activated or assigned license UUIDs, will transfer the licenses from +an old plan to a new plan via a ``process()`` method. This method +has several important properties: + +1. It support dry-runs, so that we can see which licenses **would** be + transferred without actually transferring them. +2. It's idempotent: calling ``process()`` twice on the same input + will leave the licenses in the same output state (provided no other + rouge process has mutated the licenses outside of these ``process()`` calls.). +3. It's reversible: If you transfer licenses from plan A to plan B, you + can reverse that action by creating a new job to transfer from plan B + back to plan A. + +The Django Admin site supports creation, management, and processing of +``LicenseTransferJobs``. + +Consequences +************ +Supporting the scenario above via LicenseTransferJobs allows us +to some degree to satisfy agreements for rolling-window subscription access; +that is, subscriptions where the license expiration time is determined +from the perspective of the license's activation time, **not** the plan's +effective date. + +Alternatives Considered +*********************** +None in particular. diff --git a/license_manager/apps/subscriptions/admin.py b/license_manager/apps/subscriptions/admin.py index a78bdb86..0738717c 100644 --- a/license_manager/apps/subscriptions/admin.py +++ b/license_manager/apps/subscriptions/admin.py @@ -19,6 +19,7 @@ from license_manager.apps.subscriptions.exceptions import CustomerAgreementError from license_manager.apps.subscriptions.forms import ( CustomerAgreementAdminForm, + LicenseTransferJobAdminForm, ProductForm, SubscriptionPlanForm, SubscriptionPlanRenewalForm, @@ -26,6 +27,7 @@ from license_manager.apps.subscriptions.models import ( CustomerAgreement, License, + LicenseTransferJob, Notification, PlanType, Product, @@ -657,3 +659,65 @@ class NotificationAdmin(admin.ModelAdmin): def has_change_permission(self, request, obj=None): return False + + +@admin.register(LicenseTransferJob) +class LicenseTransferJobAdmin(admin.ModelAdmin): + form = LicenseTransferJobAdminForm + + list_display = ( + 'id', + 'customer_agreement', + 'old_subscription_plan', + 'new_subscription_plan', + 'completed_at', + 'is_dry_run', + ) + + list_filter = ( + 'is_dry_run', + ) + + search_fields = ( + 'customer_agreement__enterprise_customer_uuid__startswith', + 'customer_agreement__enterprise_customer_slug__startswith', + 'customer_agreement__enterprise_customer_name__startswith', + 'old_subscription_plan', + 'new_subscription_plan', + ) + + sortable_by = ( + 'id', + 'completed_at', + 'is_dry_run', + 'customer_agreement', + ) + + actions = ['process_transfer_jobs'] + + def get_readonly_fields(self, request, obj=None): + """ + Makes all fields except ``notes`` read-only + when ``completed_at`` is not null. + """ + if obj and obj.completed_at: + return list( + set(self.form.base_fields) - {'notes'} + ) + else: + return [ + 'completed_at', + 'processed_results', + ] + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + 'customer_agreement', + 'old_subscription_plan', + 'new_subscription_plan', + ) + + @admin.action(description="Process selected license transfer jobs") + def process_transfer_jobs(self, request, queryset): + for transfer_job in queryset: + transfer_job.process() diff --git a/license_manager/apps/subscriptions/forms.py b/license_manager/apps/subscriptions/forms.py index 13028a88..30fe3e24 100644 --- a/license_manager/apps/subscriptions/forms.py +++ b/license_manager/apps/subscriptions/forms.py @@ -1,9 +1,9 @@ """ Forms to be used in the subscriptions django app. """ - import logging +from dal import autocomplete from django import forms from django.conf import settings from django.utils.translation import gettext as _ @@ -22,6 +22,7 @@ ) from license_manager.apps.subscriptions.models import ( CustomerAgreement, + LicenseTransferJob, Product, SubscriptionPlan, SubscriptionPlanRenewal, @@ -401,3 +402,37 @@ def is_valid(self): return False return True + + +class LicenseTransferJobAdminForm(forms.ModelForm): + class Meta: + model = LicenseTransferJob + fields = '__all__' + # Use django-autocomplete-light to filter the available + # subscription_plan choices to only those related to + # the selected customer agreement. Works for both + # records that don't yet exist (on transfer job creation) + # and for modification of existing transfer job records. + # See urls_admin.py for the view that does this filtering, + # and see static/filtered_subscription_admin.js for + # the jQuery code that clears subscription plan selections + # when the selected customer agreement is changed. + widgets = { + 'old_subscription_plan': autocomplete.ModelSelect2( + url='filtered_subscription_plan_admin', + # forward the customer_agreement field's value + # into our custom autocomplete field in urls_admin.py + forward=['customer_agreement'], + ), + 'new_subscription_plan': autocomplete.ModelSelect2( + url='filtered_subscription_plan_admin', + # forward the customer_agreement field's value + # into our custom autocomplete field in urls_admin.py + forward=['customer_agreement'], + ), + } + + class Media: + js = ( + 'filtered_subscription_admin.js', + ) diff --git a/license_manager/apps/subscriptions/migrations/0062_add_license_transfer_job.py b/license_manager/apps/subscriptions/migrations/0062_add_license_transfer_job.py new file mode 100644 index 00000000..3706677a --- /dev/null +++ b/license_manager/apps/subscriptions/migrations/0062_add_license_transfer_job.py @@ -0,0 +1,70 @@ +# Generated by Django 4.2.6 on 2023-10-24 15:52 + +from django.conf import settings +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('subscriptions', '0061_auto_20230927_1119'), + ] + + operations = [ + migrations.CreateModel( + name='LicenseTransferJob', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('completed_at', models.DateTimeField(blank=True, help_text='The time at which the job was successfully processed.', null=True)), + ('notes', models.TextField(blank=True, help_text='Optionally, say something about why the licenses are being transferred.', null=True)), + ('is_dry_run', models.BooleanField(default=False, help_text='If true, will report which licenses will be transferred in processed_results, without actually transferring them.')), + ('delimiter', models.CharField(choices=[('newline', 'Newline character'), ('comma', 'Comma character'), ('pipe', 'Pipe character')], default='newline', max_length=8)), + ('license_uuids_raw', models.TextField(help_text='Delimitted (with newlines by default) list of license_uuids to transfer')), + ('processed_results', models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, help_text='Raw results of what licenses were changed, either in dry-run form, or actual form.', null=True)), + ('customer_agreement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='license_transfer_jobs', to='subscriptions.customeragreement')), + ('new_subscription_plan', models.ForeignKey(help_text='SubscriptionPlan to which licenses will be transferred.', on_delete=django.db.models.deletion.CASCADE, related_name='license_transfer_jobs_new', to='subscriptions.subscriptionplan')), + ('old_subscription_plan', models.ForeignKey(help_text='SubscriptionPlan from which licenses will be transferred.', on_delete=django.db.models.deletion.CASCADE, related_name='license_transfer_jobs_old', to='subscriptions.subscriptionplan')), + ], + options={ + 'verbose_name': 'License Transfer Job', + 'verbose_name_plural': 'License Transfer Jobs', + }, + ), + migrations.CreateModel( + name='HistoricalLicenseTransferJob', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('completed_at', models.DateTimeField(blank=True, help_text='The time at which the job was successfully processed.', null=True)), + ('notes', models.TextField(blank=True, help_text='Optionally, say something about why the licenses are being transferred.', null=True)), + ('is_dry_run', models.BooleanField(default=False, help_text='If true, will report which licenses will be transferred in processed_results, without actually transferring them.')), + ('delimiter', models.CharField(choices=[('newline', 'Newline character'), ('comma', 'Comma character'), ('pipe', 'Pipe character')], default='newline', max_length=8)), + ('license_uuids_raw', models.TextField(help_text='Delimitted (with newlines by default) list of license_uuids to transfer')), + ('processed_results', models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, help_text='Raw results of what licenses were changed, either in dry-run form, or actual form.', null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('customer_agreement', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.customeragreement')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('new_subscription_plan', models.ForeignKey(blank=True, db_constraint=False, help_text='SubscriptionPlan to which licenses will be transferred.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.subscriptionplan')), + ('old_subscription_plan', models.ForeignKey(blank=True, db_constraint=False, help_text='SubscriptionPlan from which licenses will be transferred.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.subscriptionplan')), + ], + options={ + 'verbose_name': 'historical License Transfer Job', + 'verbose_name_plural': 'historical License Transfer Jobs', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/license_manager/apps/subscriptions/models.py b/license_manager/apps/subscriptions/models.py index 896f80b0..8469d484 100644 --- a/license_manager/apps/subscriptions/models.py +++ b/license_manager/apps/subscriptions/models.py @@ -7,8 +7,9 @@ from uuid import uuid4 from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import MinLengthValidator -from django.db import models +from django.db import models, transaction from django.db.models import Q from django.db.models.signals import post_delete, post_save from django.dispatch import receiver @@ -56,6 +57,7 @@ LicenseActivationMissingError, LicenseToActivateIsRevokedError, ) +from .utils import chunks logger = getLogger(__name__) @@ -169,8 +171,8 @@ def __str__(self): Return human-readable string representation. """ return ( - "".format( - slug=self.enterprise_customer_slug, + "".format( + self.enterprise_customer_slug or self.enterprise_customer_name ) ) @@ -475,6 +477,16 @@ def enterprise_customer_uuid(self): """ return self.customer_agreement.enterprise_customer_uuid + @property + def enterprise_customer_slug(self): + """ + A link to the customer slug of this plan's customer agreement + + Returns: + str + """ + return self.customer_agreement.enterprise_customer_slug + @property def unassigned_licenses(self): """ @@ -734,12 +746,13 @@ def __str__(self): Return human-readable string representation. """ return ( - "".format( title=self.title, enterprise_customer_uuid=self.enterprise_customer_uuid, - internal_use=' (for internal use only)' if self.for_internal_use_only else '', + slug=self.enterprise_customer_slug, + internal_use='(for internal use only)' if self.for_internal_use_only else '', ) ) @@ -1291,6 +1304,170 @@ def _clean_up_duplicate_licenses(cls, duplicate_licenses): return sorted_licenses[0] +class LicenseTransferJob(TimeStampedModel): + """ + A record to help run a job that "physically" transfers + a batch of licenses' SubscriptionPlan FKs from one plan + to another plan. + + .. no_pii: This model has no PII + """ + CHUNK_SIZE = 100 + + customer_agreement = models.ForeignKey( + CustomerAgreement, + related_name='license_transfer_jobs', + on_delete=models.CASCADE, + null=False, + blank=False, + ) + old_subscription_plan = models.ForeignKey( + SubscriptionPlan, + on_delete=models.CASCADE, + null=False, + blank=False, + related_name='license_transfer_jobs_old', + help_text=_("SubscriptionPlan from which licenses will be transferred."), + ) + new_subscription_plan = models.ForeignKey( + SubscriptionPlan, + on_delete=models.CASCADE, + null=False, + blank=False, + related_name='license_transfer_jobs_new', + help_text=_("SubscriptionPlan to which licenses will be transferred."), + ) + completed_at = models.DateTimeField( + blank=True, + null=True, + help_text=_("The time at which the job was successfully processed."), + ) + notes = models.TextField( + null=True, + blank=True, + help_text=_("Optionally, say something about why the licenses are being transferred."), + ) + is_dry_run = models.BooleanField( + default=False, + help_text=_( + "If true, will report which licenses will be transferred in processed_results, " + "without actually transferring them." + ), + ) + delimiter = models.CharField( + max_length=8, + choices=( + ('newline', _('Newline character')), + ('comma', _('Comma character')), + ('pipe', _('Pipe character')), + ), + null=False, + default='newline', + ) + license_uuids_raw = models.TextField( + null=False, + blank=False, + help_text=_("Delimitted (with newlines by default) list of license_uuids to transfer"), + ) + processed_results = models.JSONField( + null=True, + blank=True, + encoder=DjangoJSONEncoder, + help_text=_("Raw results of what licenses were changed, either in dry-run form, or actual form."), + ) + + history = HistoricalRecords() + + class Meta: + verbose_name = _("License Transfer Job") + verbose_name_plural = _("License Transfer Jobs") + + def __str__(self): + return f'{self.id}' + + @property + def delimiter_char(self): + return { + 'newline': '\n', + 'comma': ',', + 'pipe': '|', + }.get(self.delimiter, '\n') + + def clean(self): + """ + Validates that old and new subscription plans share the same customer agreement. + """ + super().clean() + if self.old_subscription_plan.customer_agreement != self.new_subscription_plan.customer_agreement: + raise ValidationError( + 'LicenseTransferJob: Old and new subscription plans must have same customer_agreement.' + ) + + def get_customer_agreement(self): + try: + return self.customer_agreement + except CustomerAgreement.DoesNotExist: + return None + + def get_license_uuids(self): + return [ + raw_license_uuid.strip() for raw_license_uuid in + self.license_uuids_raw.split(self.delimiter_char) + ] + + def get_licenses_to_transfer(self): + """ + Yields successive chunked querysets of License records to transfer. + The licenses are from self.old_subscription_plan and will + only be in the (activated, assigned) statuses. + """ + for license_uuid_chunk in chunks(self.get_license_uuids(), self.CHUNK_SIZE): + yield License.objects.filter( + subscription_plan=self.old_subscription_plan, + status__in=[ACTIVATED, ASSIGNED], + uuid__in=license_uuid_chunk, + ) + + def process(self): + """ + Processes this job, moving activated and assigned licenses + from the job's old subscription plan to the new subscription plan. + Is ``self.is_dry_run``, the licenses are not actually moved, but we + report via ``self.processed_results`` which licenses would have + been moved during this processing. + """ + if self.completed_at: + logger.info(f'{self} was already processed on {self.completed_at}') + return + + processed_license_uuids = [] + with transaction.atomic(): + for license_queryset in self.get_licenses_to_transfer(): + licenses = list(license_queryset) + + if not self.is_dry_run: + for _license in licenses: + _license.subscription_plan = self.new_subscription_plan + License.bulk_update(licenses, ['subscription_plan']) + + processed_license_uuids.extend([str(_lic.uuid) for _lic in licenses]) + + time_completed_at = localized_utcnow() + if not self.is_dry_run: + self.completed_at = time_completed_at + + if not self.processed_results: + self.processed_results = [] + self.processed_results.append( + { + 'is_dry_run': self.is_dry_run, + 'modified_licenses': processed_license_uuids, + 'completed_at': time_completed_at, + } + ) + self.save() + + class SubscriptionLicenseSourceType(TimeStampedModel): """ Subscription License Source Type diff --git a/license_manager/apps/subscriptions/tests/test_models.py b/license_manager/apps/subscriptions/tests/test_models.py index 2acc58e2..3e941dcc 100644 --- a/license_manager/apps/subscriptions/tests/test_models.py +++ b/license_manager/apps/subscriptions/tests/test_models.py @@ -19,6 +19,7 @@ from license_manager.apps.subscriptions.exceptions import CustomerAgreementError from license_manager.apps.subscriptions.models import ( License, + LicenseTransferJob, Notification, SubscriptionLicenseSourceType, ) @@ -449,3 +450,183 @@ def test_license_source_creation_with_invalid_souce_id(self): exception_message = ['Ensure this value has at least 18 characters (it has 9).'] assert raised_exception.value.args[0]['source_id'][0].messages == exception_message + + +@ddt.ddt +class LicenseTransferJobTests(TestCase): + """ + Tests for the `LicenseTransferJob` model. + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.enterprise_customer_uuid = uuid.uuid4() + cls.customer_agreement = CustomerAgreementFactory.create( + enterprise_customer_uuid=cls.enterprise_customer_uuid, + ) + + cls.old_plan = SubscriptionPlanFactory.create( + customer_agreement=cls.customer_agreement, + is_active=True, + start_date=localized_datetime(2021, 1, 1), + expiration_date=localized_datetime_from_datetime(datetime.now() + timedelta(days=365)), + ) + cls.new_plan = SubscriptionPlanFactory.create( + customer_agreement=cls.customer_agreement, + is_active=True, + start_date=localized_datetime(2021, 1, 1), + expiration_date=localized_datetime_from_datetime(datetime.now() + timedelta(days=365)), + ) + + def tearDown(self): + super().tearDown() + License.objects.all().delete() + + def _create_transfer_job(self, license_uuids_raw, **kwargs): + return LicenseTransferJob.objects.create( + customer_agreement=self.customer_agreement, + old_subscription_plan=self.old_plan, + new_subscription_plan=self.new_plan, + license_uuids_raw=license_uuids_raw, + **kwargs, + ) + + def test_get_licenses_to_transfer(self): + """ + Tests that we only operate on activated or assigned licenses from the old plan + of a transfer job. + """ + old_assigned_licenses = LicenseFactory.create_batch( + 3, subscription_plan=self.old_plan, assigned_date=localized_utcnow(), status=ASSIGNED, + ) + old_activated_licenses = LicenseFactory.create_batch( + 3, subscription_plan=self.old_plan, assigned_date=localized_utcnow(), status=ACTIVATED, + ) + # old unassigned licenses + LicenseFactory.create_batch( + 3, subscription_plan=self.old_plan, + ) + # new_licenses + LicenseFactory.create_batch( + 3, subscription_plan=self.new_plan, assigned_date=localized_utcnow(), status=ACTIVATED, + ) + + job = self._create_transfer_job( + license_uuids_raw='\n'.join([str(_license.uuid) for _license in self.old_plan.licenses.all()]), + ) + + expected_licenses = { + _license.uuid: _license + for _license in old_assigned_licenses + old_activated_licenses + } + actual_licenses = { + _license.uuid: _license + for license_batch in job.get_licenses_to_transfer() + for _license in license_batch + } + self.assertEqual(expected_licenses, actual_licenses) + + def test_transfer_dry_run_processing(self): + """ + Tests that a dry-run process doesn't actually modify the + otherwise impacted licenses. + """ + old_activated_licenses = LicenseFactory.create_batch( + 3, subscription_plan=self.old_plan, assigned_date=localized_utcnow(), status=ACTIVATED, + ) + + job = self._create_transfer_job( + license_uuids_raw='\n'.join([str(_license.uuid) for _license in old_activated_licenses]), + is_dry_run=True, + ) + job.process() + + for _license in old_activated_licenses: + _license.refresh_from_db() + self.assertEqual(_license.subscription_plan, self.old_plan) + + self.assertCountEqual( + job.processed_results[0]['modified_licenses'], + [str(_license.uuid) for _license in old_activated_licenses] + ) + self.assertTrue(job.processed_results[0]['is_dry_run']) + self.assertAlmostEqual( + job.processed_results[0]['completed_at'], + localized_utcnow(), + delta=timedelta(seconds=2), + ) + self.assertIsNone(job.completed_at) + + def test_transfer_idempotent_processing(self): + """ + Tests that the `process()` method is idempotent. + """ + old_activated_licenses = LicenseFactory.create_batch( + 3, subscription_plan=self.old_plan, assigned_date=localized_utcnow(), status=ACTIVATED, + ) + + job = self._create_transfer_job( + license_uuids_raw='\n'.join([str(_license.uuid) for _license in old_activated_licenses]), + is_dry_run=False, + ) + job.process() + + for _license in old_activated_licenses: + _license.refresh_from_db() + self.assertEqual(_license.subscription_plan, self.new_plan) + + self.assertCountEqual( + job.processed_results[0]['modified_licenses'], + [str(_license.uuid) for _license in old_activated_licenses] + ) + self.assertFalse(job.processed_results[0]['is_dry_run']) + original_completed_at = job.completed_at + self.assertAlmostEqual( + original_completed_at, + localized_utcnow(), + delta=timedelta(seconds=2), + ) + + # now process the same job again, nothing should change + job.process() + for _license in old_activated_licenses: + _license.refresh_from_db() + self.assertEqual(_license.subscription_plan, self.new_plan) + self.assertEqual(len(job.processed_results), 1) + self.assertEqual(job.completed_at, original_completed_at) + + def test_transfer_reversable_processing(self): + """ + Tests that we can transfer licenses one way, then create a second + job to transfer them in the reverse direction. + """ + old_activated_licenses = LicenseFactory.create_batch( + 3, subscription_plan=self.old_plan, assigned_date=localized_utcnow(), status=ACTIVATED, + ) + + raw_license_uuids = '\n'.join([str(_license.uuid) for _license in old_activated_licenses]) + job = self._create_transfer_job( + license_uuids_raw=raw_license_uuids, + is_dry_run=False, + ) + job.process() + + for _license in old_activated_licenses: + _license.refresh_from_db() + self.assertEqual(_license.subscription_plan, self.new_plan) + + reverse_job = LicenseTransferJob.objects.create( + customer_agreement=self.customer_agreement, + old_subscription_plan=self.new_plan, + new_subscription_plan=self.old_plan, + license_uuids_raw=raw_license_uuids, + is_dry_run=False, + ) + + reverse_job.process() + + for _license in old_activated_licenses: + _license.refresh_from_db() + self.assertEqual(_license.subscription_plan, self.old_plan) diff --git a/license_manager/apps/subscriptions/urls_admin.py b/license_manager/apps/subscriptions/urls_admin.py new file mode 100644 index 00000000..339e8655 --- /dev/null +++ b/license_manager/apps/subscriptions/urls_admin.py @@ -0,0 +1,33 @@ +from dal import autocomplete +from django.urls import re_path as url + +from .models import SubscriptionPlan + + +class FilteredSubscriptionPlanView(autocomplete.Select2QuerySetView): + """ + Supports filtering of LicenseTransferJob SubscriptionPlan + choices to only those plans associated with the selected + customer agreement. + + This is used by the LicenseTransferJobAdminForm.Meta.widgets + property, which forwards the customer_agreement identifier + into this view, so that it can filter the queryset of + available subscription plans to only those plans + associated with the selected customer agreement. + """ + def get_queryset(self): + queryset = super().get_queryset() + customer_agreement = self.forwarded.get('customer_agreement', None) + if customer_agreement: + queryset = queryset.filter(customer_agreement=customer_agreement) + return queryset + + +urlpatterns = [ + url( + 'filtered-subscription-plan-admin/$', + FilteredSubscriptionPlanView.as_view(model=SubscriptionPlan), + name='filtered_subscription_plan_admin', + ), +] diff --git a/license_manager/apps/subscriptions/utils.py b/license_manager/apps/subscriptions/utils.py index 2fa5b772..bbef85dd 100644 --- a/license_manager/apps/subscriptions/utils.py +++ b/license_manager/apps/subscriptions/utils.py @@ -60,7 +60,7 @@ def hours_until(effective_date): def chunks(a_list, chunk_size): """ - Helper to break a list up into chunks. Returns a list of lists + Helper to break a list up into chunks. Returns a generator of lists. """ for i in range(0, len(a_list), chunk_size): yield a_list[i:i + chunk_size] diff --git a/license_manager/settings/base.py b/license_manager/settings/base.py index 65e3df42..6492316a 100644 --- a/license_manager/settings/base.py +++ b/license_manager/settings/base.py @@ -29,6 +29,9 @@ # Application definition INSTALLED_APPS = ( + # These have to be installed before the core django admin app + 'dal', + 'dal_select2', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', diff --git a/license_manager/static/filtered_subscription_admin.js b/license_manager/static/filtered_subscription_admin.js new file mode 100644 index 00000000..cfbf34df --- /dev/null +++ b/license_manager/static/filtered_subscription_admin.js @@ -0,0 +1,12 @@ +// Execute custom JS for django-autocomplete-light +// after django.jQuery is defined. +// Clears subscription plan selections when the selected +// customer agreement is changed in the LicenseTransferJobAdminForm. +window.addEventListener("load", function() { + (function($) { + $(':input[name$=customer_agreement]').on('change', function() { + $(':input[name=old_subscription_plan]').val(null).trigger('change'); + $(':input[name=new_subscription_plan]').val(null).trigger('change'); + }); + })(django.jQuery); +}); diff --git a/license_manager/urls.py b/license_manager/urls.py index a30c55dc..e2e5e289 100644 --- a/license_manager/urls.py +++ b/license_manager/urls.py @@ -25,6 +25,7 @@ from license_manager.apps.api import urls as api_urls from license_manager.apps.core import views as core_views +from license_manager.apps.subscriptions import urls_admin as subs_url_admin admin.autodiscover() @@ -41,6 +42,7 @@ path('', include(oauth2_urlpatterns)), path('', include('csrf.urls')), # Include csrf urls from edx-drf-extensions path('admin/', admin.site.urls), + path('admin-custom/subscriptions/', include(subs_url_admin)), path('api/', include(api_urls)), path('api-docs/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('auto_auth/', core_views.AutoAuth.as_view(), name='auto_auth'), diff --git a/requirements/base.in b/requirements/base.in index dd70c396..ad5f7f8b 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,3 +1,4 @@ + # Core requirements for using this application -c constraints.txt @@ -6,6 +7,9 @@ analytics-python backoff celery Django +# https://django-autocomplete-light.readthedocs.io/en/master/ +# Supports admin field choices that depend on other fields +django-autocomplete-light django-celery-results django-cors-headers django-durationwidget diff --git a/requirements/base.txt b/requirements/base.txt index cce7f985..74247318 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -23,9 +23,9 @@ backports-zoneinfo[tzdata]==0.2.1 # kombu billiard==4.1.0 # via celery -boto3==1.28.60 +boto3==1.28.66 # via django-ses -botocore==1.31.60 +botocore==1.31.66 # via # boto3 # s3transfer @@ -70,6 +70,7 @@ django==4.2.6 # via # -c requirements/constraints.txt # -r requirements/base.in + # django-autocomplete-light # django-celery-results # django-cors-headers # django-crum @@ -93,9 +94,11 @@ django==4.2.6 # edx-toggles # jsonfield # social-auth-app-django +django-autocomplete-light==3.9.7 + # via -r requirements/base.in django-celery-results==2.5.1 # via -r requirements/base.in -django-cors-headers==4.2.0 +django-cors-headers==4.3.0 # via -r requirements/base.in django-crum==0.7.9 # via @@ -158,7 +161,7 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/base.in # edx-rbac @@ -166,7 +169,7 @@ edx-opaque-keys==2.5.1 # via edx-drf-extensions edx-rbac==1.8.0 # via -r requirements/base.in -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via -r requirements/base.in edx-toggles==5.1.0 # via -r requirements/base.in @@ -204,7 +207,7 @@ ply==3.11 # via djangoql prompt-toolkit==3.0.39 # via click-repl -psutil==5.9.5 +psutil==5.9.6 # via edx-django-utils pycparser==2.21 # via cffi @@ -214,6 +217,7 @@ pyjwt[crypto]==2.8.0 # edx-auth-backends # edx-drf-extensions # edx-rest-api-client + # pyjwt # social-auth-core pymongo==3.13.0 # via edx-opaque-keys @@ -256,18 +260,19 @@ s3transfer==0.7.0 # via boto3 semantic-version==2.10.0 # via edx-drf-extensions -simplejson==3.19.1 +simplejson==3.19.2 # via -r requirements/base.in six==1.16.0 # via # analytics-python + # django-autocomplete-light # djangorestframework-csv # edx-auth-backends # edx-rbac # python-dateutil slumber==0.7.1 # via edx-rest-api-client -social-auth-app-django==5.3.0 +social-auth-app-django==5.4.0 # via edx-auth-backends social-auth-core==4.4.2 # via @@ -295,7 +300,7 @@ unicodecsv==0.14.1 # via djangorestframework-csv uritemplate==4.1.1 # via drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # botocore # requests diff --git a/requirements/dev.txt b/requirements/dev.txt index 08b1abf1..918dd9c0 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -10,7 +10,7 @@ amqp==5.1.1 # kombu analytics-python==1.4.post1 # via -r requirements/validation.txt -annotated-types==0.5.0 +annotated-types==0.6.0 # via pydantic asgiref==3.7.2 # via @@ -33,6 +33,7 @@ backoff==1.10.0 backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/validation.txt + # backports-zoneinfo # celery # django # kombu @@ -40,11 +41,11 @@ billiard==4.1.0 # via # -r requirements/validation.txt # celery -boto3==1.28.60 +boto3==1.28.66 # via # -r requirements/validation.txt # django-ses -botocore==1.31.60 +botocore==1.31.66 # via # -r requirements/validation.txt # boto3 @@ -108,6 +109,7 @@ code-annotations==1.5.0 coverage[toml]==7.3.2 # via # -r requirements/validation.txt + # coverage # pytest-cov cryptography==41.0.4 # via @@ -135,6 +137,7 @@ django==4.2.6 # via # -c requirements/constraints.txt # -r requirements/validation.txt + # django-autocomplete-light # django-celery-results # django-cors-headers # django-crum @@ -160,9 +163,11 @@ django==4.2.6 # edx-toggles # jsonfield # social-auth-app-django +django-autocomplete-light==3.9.7 + # via -r requirements/validation.txt django-celery-results==2.5.1 # via -r requirements/validation.txt -django-cors-headers==4.2.0 +django-cors-headers==4.3.0 # via -r requirements/validation.txt django-crum==0.7.9 # via @@ -236,11 +241,11 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/validation.txt # edx-rbac -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via -r requirements/validation.txt edx-lint==5.2.5 # via @@ -252,7 +257,7 @@ edx-opaque-keys==2.5.1 # edx-drf-extensions edx-rbac==1.8.0 # via -r requirements/validation.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via -r requirements/validation.txt edx-toggles==5.1.0 # via -r requirements/validation.txt @@ -262,7 +267,7 @@ exceptiongroup==1.1.3 # pytest factory-boy==3.3.0 # via -r requirements/validation.txt -faker==19.6.2 +faker==19.11.0 # via # -r requirements/validation.txt # factory-boy @@ -319,6 +324,10 @@ lazy-object-proxy==1.9.0 # via # -r requirements/validation.txt # astroid +lxml==4.9.3 + # via + # -r requirements/validation.txt + # edx-i18n-tools markupsafe==2.1.3 # via # -r requirements/validation.txt @@ -383,11 +392,11 @@ prompt-toolkit==3.0.39 # via # -r requirements/validation.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/validation.txt # edx-django-utils -pycodestyle==2.11.0 +pycodestyle==2.11.1 # via -r requirements/validation.txt pycparser==2.21 # via @@ -408,6 +417,7 @@ pyjwt[crypto]==2.8.0 # edx-auth-backends # edx-drf-extensions # edx-rest-api-client + # pyjwt # social-auth-core pylint==2.14.5 # via @@ -506,12 +516,13 @@ semantic-version==2.10.0 # via # -r requirements/validation.txt # edx-drf-extensions -simplejson==3.19.1 +simplejson==3.19.2 # via -r requirements/validation.txt six==1.16.0 # via # -r requirements/validation.txt # analytics-python + # django-autocomplete-light # djangorestframework-csv # edx-auth-backends # edx-lint @@ -526,7 +537,7 @@ snowballstemmer==2.2.0 # via # -r requirements/validation.txt # pydocstyle -social-auth-app-django==5.3.0 +social-auth-app-django==5.4.0 # via # -r requirements/validation.txt # edx-auth-backends @@ -590,7 +601,7 @@ uritemplate==4.1.1 # via # -r requirements/validation.txt # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/validation.txt # botocore diff --git a/requirements/doc.txt b/requirements/doc.txt index f9c4cef1..87841b09 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -39,6 +39,7 @@ backoff==1.10.0 backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test.txt + # backports-zoneinfo # celery # django # kombu @@ -48,11 +49,11 @@ billiard==4.1.0 # via # -r requirements/test.txt # celery -boto3==1.28.60 +boto3==1.28.66 # via # -r requirements/test.txt # django-ses -botocore==1.31.60 +botocore==1.31.66 # via # -r requirements/test.txt # boto3 @@ -110,6 +111,7 @@ code-annotations==1.5.0 coverage[toml]==7.3.2 # via # -r requirements/test.txt + # coverage # pytest-cov cryptography==41.0.4 # via @@ -131,6 +133,7 @@ django==4.2.6 # via # -c requirements/constraints.txt # -r requirements/test.txt + # django-autocomplete-light # django-celery-results # django-cors-headers # django-crum @@ -154,9 +157,11 @@ django==4.2.6 # edx-toggles # jsonfield # social-auth-app-django +django-autocomplete-light==3.9.7 + # via -r requirements/test.txt django-celery-results==2.5.1 # via -r requirements/test.txt -django-cors-headers==4.2.0 +django-cors-headers==4.3.0 # via -r requirements/test.txt django-crum==0.7.9 # via @@ -235,7 +240,7 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/test.txt # edx-rbac @@ -249,7 +254,7 @@ edx-opaque-keys==2.5.1 # edx-drf-extensions edx-rbac==1.8.0 # via -r requirements/test.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via -r requirements/test.txt edx-toggles==5.1.0 # via -r requirements/test.txt @@ -259,7 +264,7 @@ exceptiongroup==1.1.3 # pytest factory-boy==3.3.0 # via -r requirements/test.txt -faker==19.6.2 +faker==19.11.0 # via # -r requirements/test.txt # factory-boy @@ -359,7 +364,7 @@ prompt-toolkit==3.0.39 # via # -r requirements/test.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/test.txt # edx-django-utils @@ -383,6 +388,7 @@ pyjwt[crypto]==2.8.0 # edx-auth-backends # edx-drf-extensions # edx-rest-api-client + # pyjwt # social-auth-core pylint==2.14.5 # via @@ -480,12 +486,13 @@ semantic-version==2.10.0 # via # -r requirements/test.txt # edx-drf-extensions -simplejson==3.19.1 +simplejson==3.19.2 # via -r requirements/test.txt six==1.16.0 # via # -r requirements/test.txt # analytics-python + # django-autocomplete-light # djangorestframework-csv # edx-auth-backends # edx-lint @@ -497,7 +504,7 @@ slumber==0.7.1 # edx-rest-api-client snowballstemmer==2.2.0 # via sphinx -social-auth-app-django==5.3.0 +social-auth-app-django==5.4.0 # via # -r requirements/test.txt # edx-auth-backends @@ -576,7 +583,7 @@ uritemplate==4.1.1 # via # -r requirements/test.txt # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/test.txt # botocore diff --git a/requirements/pip.txt b/requirements/pip.txt index 3e7d8f4a..2154d29f 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -8,7 +8,7 @@ wheel==0.41.2 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==23.2.1 +pip==23.3 # via -r requirements/pip.in setuptools==68.2.2 # via -r requirements/pip.in diff --git a/requirements/production.txt b/requirements/production.txt index ccb763a2..a60a6852 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -25,6 +25,7 @@ backoff==1.10.0 backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/base.txt + # backports-zoneinfo # celery # django # kombu @@ -32,11 +33,11 @@ billiard==4.1.0 # via # -r requirements/base.txt # celery -boto3==1.28.60 +boto3==1.28.66 # via # -r requirements/base.txt # django-ses -botocore==1.31.60 +botocore==1.31.66 # via # -r requirements/base.txt # boto3 @@ -98,6 +99,7 @@ django==4.2.6 # via # -c requirements/constraints.txt # -r requirements/base.txt + # django-autocomplete-light # django-celery-results # django-cors-headers # django-crum @@ -121,9 +123,11 @@ django==4.2.6 # edx-toggles # jsonfield # social-auth-app-django +django-autocomplete-light==3.9.7 + # via -r requirements/base.txt django-celery-results==2.5.1 # via -r requirements/base.txt -django-cors-headers==4.2.0 +django-cors-headers==4.3.0 # via -r requirements/base.txt django-crum==0.7.9 # via @@ -191,7 +195,7 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/base.txt # edx-rbac @@ -201,7 +205,7 @@ edx-opaque-keys==2.5.1 # edx-drf-extensions edx-rbac==1.8.0 # via -r requirements/base.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via -r requirements/base.txt edx-toggles==5.1.0 # via -r requirements/base.txt @@ -272,7 +276,7 @@ prompt-toolkit==3.0.39 # via # -r requirements/base.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/base.txt # edx-django-utils @@ -287,6 +291,7 @@ pyjwt[crypto]==2.8.0 # edx-auth-backends # edx-drf-extensions # edx-rest-api-client + # pyjwt # social-auth-core pymemcache==4.0.0 # via -r requirements/production.in @@ -351,12 +356,13 @@ semantic-version==2.10.0 # via # -r requirements/base.txt # edx-drf-extensions -simplejson==3.19.1 +simplejson==3.19.2 # via -r requirements/base.txt six==1.16.0 # via # -r requirements/base.txt # analytics-python + # django-autocomplete-light # djangorestframework-csv # edx-auth-backends # edx-rbac @@ -366,7 +372,7 @@ slumber==0.7.1 # via # -r requirements/base.txt # edx-rest-api-client -social-auth-app-django==5.3.0 +social-auth-app-django==5.4.0 # via # -r requirements/base.txt # edx-auth-backends @@ -408,7 +414,7 @@ uritemplate==4.1.1 # via # -r requirements/base.txt # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/base.txt # botocore diff --git a/requirements/quality.txt b/requirements/quality.txt index 69b41aa2..976ebf07 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -30,6 +30,7 @@ backoff==1.10.0 backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/base.txt + # backports-zoneinfo # celery # django # kombu @@ -37,11 +38,11 @@ billiard==4.1.0 # via # -r requirements/base.txt # celery -boto3==1.28.60 +boto3==1.28.66 # via # -r requirements/base.txt # django-ses -botocore==1.31.60 +botocore==1.31.66 # via # -r requirements/base.txt # boto3 @@ -110,6 +111,7 @@ django==4.2.6 # via # -c requirements/constraints.txt # -r requirements/base.txt + # django-autocomplete-light # django-celery-results # django-cors-headers # django-crum @@ -133,9 +135,11 @@ django==4.2.6 # edx-toggles # jsonfield # social-auth-app-django +django-autocomplete-light==3.9.7 + # via -r requirements/base.txt django-celery-results==2.5.1 # via -r requirements/base.txt -django-cors-headers==4.2.0 +django-cors-headers==4.3.0 # via -r requirements/base.txt django-crum==0.7.9 # via @@ -203,7 +207,7 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/base.txt # edx-rbac @@ -217,7 +221,7 @@ edx-opaque-keys==2.5.1 # edx-drf-extensions edx-rbac==1.8.0 # via -r requirements/base.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via -r requirements/base.txt edx-toggles==5.1.0 # via -r requirements/base.txt @@ -291,11 +295,11 @@ prompt-toolkit==3.0.39 # via # -r requirements/base.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/base.txt # edx-django-utils -pycodestyle==2.11.0 +pycodestyle==2.11.1 # via -r requirements/quality.in pycparser==2.21 # via @@ -310,6 +314,7 @@ pyjwt[crypto]==2.8.0 # edx-auth-backends # edx-drf-extensions # edx-rest-api-client + # pyjwt # social-auth-core pylint==2.14.5 # via @@ -384,12 +389,13 @@ semantic-version==2.10.0 # via # -r requirements/base.txt # edx-drf-extensions -simplejson==3.19.1 +simplejson==3.19.2 # via -r requirements/base.txt six==1.16.0 # via # -r requirements/base.txt # analytics-python + # django-autocomplete-light # djangorestframework-csv # edx-auth-backends # edx-lint @@ -401,7 +407,7 @@ slumber==0.7.1 # edx-rest-api-client snowballstemmer==2.2.0 # via pydocstyle -social-auth-app-django==5.3.0 +social-auth-app-django==5.4.0 # via # -r requirements/base.txt # edx-auth-backends @@ -449,7 +455,7 @@ uritemplate==4.1.1 # via # -r requirements/base.txt # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/base.txt # botocore diff --git a/requirements/test.txt b/requirements/test.txt index 7c9feafd..836aa2d5 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -30,6 +30,7 @@ backoff==1.10.0 backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/base.txt + # backports-zoneinfo # celery # django # kombu @@ -37,11 +38,11 @@ billiard==4.1.0 # via # -r requirements/base.txt # celery -boto3==1.28.60 +boto3==1.28.66 # via # -r requirements/base.txt # django-ses -botocore==1.31.60 +botocore==1.31.66 # via # -r requirements/base.txt # boto3 @@ -117,6 +118,7 @@ django==4.2.6 # via # -c requirements/constraints.txt # -r requirements/base.txt + # django-autocomplete-light # django-celery-results # django-cors-headers # django-crum @@ -140,9 +142,11 @@ django==4.2.6 # edx-toggles # jsonfield # social-auth-app-django +django-autocomplete-light==3.9.7 + # via -r requirements/base.txt django-celery-results==2.5.1 # via -r requirements/base.txt -django-cors-headers==4.2.0 +django-cors-headers==4.3.0 # via -r requirements/base.txt django-crum==0.7.9 # via @@ -212,7 +216,7 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/base.txt # edx-rbac @@ -226,7 +230,7 @@ edx-opaque-keys==2.5.1 # edx-drf-extensions edx-rbac==1.8.0 # via -r requirements/base.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via -r requirements/base.txt edx-toggles==5.1.0 # via -r requirements/base.txt @@ -234,7 +238,7 @@ exceptiongroup==1.1.3 # via pytest factory-boy==3.3.0 # via -r requirements/test.in -faker==19.6.2 +faker==19.11.0 # via factory-boy freezegun==1.2.2 # via -r requirements/test.in @@ -311,7 +315,7 @@ prompt-toolkit==3.0.39 # via # -r requirements/base.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/base.txt # edx-django-utils @@ -326,6 +330,7 @@ pyjwt[crypto]==2.8.0 # edx-auth-backends # edx-drf-extensions # edx-rest-api-client + # pyjwt # social-auth-core pylint==2.14.5 # via @@ -410,12 +415,13 @@ semantic-version==2.10.0 # via # -r requirements/base.txt # edx-drf-extensions -simplejson==3.19.1 +simplejson==3.19.2 # via -r requirements/base.txt six==1.16.0 # via # -r requirements/base.txt # analytics-python + # django-autocomplete-light # djangorestframework-csv # edx-auth-backends # edx-lint @@ -425,7 +431,7 @@ slumber==0.7.1 # via # -r requirements/base.txt # edx-rest-api-client -social-auth-app-django==5.3.0 +social-auth-app-django==5.4.0 # via # -r requirements/base.txt # edx-auth-backends @@ -477,7 +483,7 @@ uritemplate==4.1.1 # via # -r requirements/base.txt # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/base.txt # botocore diff --git a/requirements/validation.txt b/requirements/validation.txt index 6ab38dda..06dd3e8d 100644 --- a/requirements/validation.txt +++ b/requirements/validation.txt @@ -39,6 +39,7 @@ backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/quality.txt # -r requirements/test.txt + # backports-zoneinfo # celery # django # kombu @@ -47,12 +48,12 @@ billiard==4.1.0 # -r requirements/quality.txt # -r requirements/test.txt # celery -boto3==1.28.60 +boto3==1.28.66 # via # -r requirements/quality.txt # -r requirements/test.txt # django-ses -botocore==1.31.60 +botocore==1.31.66 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -121,6 +122,7 @@ code-annotations==1.5.0 coverage[toml]==7.3.2 # via # -r requirements/test.txt + # coverage # pytest-cov cryptography==41.0.4 # via @@ -146,6 +148,7 @@ django==4.2.6 # -c requirements/constraints.txt # -r requirements/quality.txt # -r requirements/test.txt + # django-autocomplete-light # django-celery-results # django-cors-headers # django-crum @@ -170,11 +173,15 @@ django==4.2.6 # edx-toggles # jsonfield # social-auth-app-django +django-autocomplete-light==3.9.7 + # via + # -r requirements/quality.txt + # -r requirements/test.txt django-celery-results==2.5.1 # via # -r requirements/quality.txt # -r requirements/test.txt -django-cors-headers==4.2.0 +django-cors-headers==4.3.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -279,12 +286,12 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/quality.txt # -r requirements/test.txt # edx-rbac -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via -r requirements/validation.in edx-lint==5.2.5 # via @@ -300,7 +307,7 @@ edx-rbac==1.8.0 # via # -r requirements/quality.txt # -r requirements/test.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -314,7 +321,7 @@ exceptiongroup==1.1.3 # pytest factory-boy==3.3.0 # via -r requirements/test.txt -faker==19.6.2 +faker==19.11.0 # via # -r requirements/test.txt # factory-boy @@ -365,6 +372,8 @@ lazy-object-proxy==1.9.0 # -r requirements/quality.txt # -r requirements/test.txt # astroid +lxml==4.9.3 + # via edx-i18n-tools markupsafe==2.1.3 # via # -r requirements/quality.txt @@ -431,12 +440,12 @@ prompt-toolkit==3.0.39 # -r requirements/quality.txt # -r requirements/test.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/quality.txt # -r requirements/test.txt # edx-django-utils -pycodestyle==2.11.0 +pycodestyle==2.11.1 # via -r requirements/quality.txt pycparser==2.21 # via @@ -453,6 +462,7 @@ pyjwt[crypto]==2.8.0 # edx-auth-backends # edx-drf-extensions # edx-rest-api-client + # pyjwt # social-auth-core pylint==2.14.5 # via @@ -564,7 +574,7 @@ semantic-version==2.10.0 # -r requirements/quality.txt # -r requirements/test.txt # edx-drf-extensions -simplejson==3.19.1 +simplejson==3.19.2 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -573,6 +583,7 @@ six==1.16.0 # -r requirements/quality.txt # -r requirements/test.txt # analytics-python + # django-autocomplete-light # djangorestframework-csv # edx-auth-backends # edx-lint @@ -588,7 +599,7 @@ snowballstemmer==2.2.0 # via # -r requirements/quality.txt # pydocstyle -social-auth-app-django==5.3.0 +social-auth-app-django==5.4.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -654,7 +665,7 @@ uritemplate==4.1.1 # -r requirements/quality.txt # -r requirements/test.txt # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/quality.txt # -r requirements/test.txt