Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds a LicenseTransferJob model and admin #535

Merged
merged 1 commit into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .annotation_safe_list.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
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.
65 changes: 65 additions & 0 deletions license_manager/apps/subscriptions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -657,3 +659,66 @@ 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(
# pylint: disable=no-member
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 _
Expand All @@ -22,6 +22,7 @@
)
from license_manager.apps.subscriptions.models import (
CustomerAgreement,
LicenseTransferJob,
Product,
SubscriptionPlan,
SubscriptionPlanRenewal,
Expand Down Expand Up @@ -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
Loading