Skip to content

Commit

Permalink
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
Browse files Browse the repository at this point in the history
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.
iloveagent57 committed Oct 24, 2023
1 parent 36c620e commit 4973895
Showing 21 changed files with 771 additions and 82 deletions.
2 changes: 2 additions & 0 deletions .annotation_safe_list.yml
Original file line number Diff line number Diff line change
@@ -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:
54 changes: 54 additions & 0 deletions docs/decisions/0015-license-transfer-job.rst
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.
64 changes: 64 additions & 0 deletions license_manager/apps/subscriptions/admin.py
Original file line number Diff line number Diff line change
@@ -19,13 +19,15 @@
from license_manager.apps.subscriptions.exceptions import CustomerAgreementError
from license_manager.apps.subscriptions.forms import (
CustomerAgreementAdminForm,
LicenseTransferJobAdminForm,
ProductForm,
SubscriptionPlanForm,
SubscriptionPlanRenewalForm,
)
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()
37 changes: 36 additions & 1 deletion license_manager/apps/subscriptions/forms.py
Original file line number Diff line number Diff line change
@@ -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',
)
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),
),
]
Loading

0 comments on commit 4973895

Please sign in to comment.