-
Notifications
You must be signed in to change notification settings - Fork 30
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
1 parent
36c620e
commit 4973895
Showing
21 changed files
with
771 additions
and
82 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
70 changes: 70 additions & 0 deletions
70
license_manager/apps/subscriptions/migrations/0062_add_license_transfer_job.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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), | ||
), | ||
] |
Oops, something went wrong.