Skip to content

Commit

Permalink
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.
  • Loading branch information
iloveagent57 committed Oct 19, 2023
1 parent 9335ec3 commit 7afdfc0
Show file tree
Hide file tree
Showing 18 changed files with 487 additions and 80 deletions.
42 changes: 42 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,43 @@ 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',
)

def get_queryset(self, request):
return super().get_queryset(request).select_related(
'customer_agreement',
'old_subscription_plan',
'new_subscription_plan',
)
49 changes: 49 additions & 0 deletions license_manager/apps/subscriptions/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

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 +23,7 @@
)
from license_manager.apps.subscriptions.models import (
CustomerAgreement,
LicenseTransferJob,
Product,
SubscriptionPlan,
SubscriptionPlanRenewal,
Expand Down Expand Up @@ -401,3 +403,50 @@ 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'],
),
}

# def __init__(self, *args, **kwargs):
# """
# When an instance already exists, filter old and new sub plan choices
# to only those associated with the selected customer agreement.
# """
# super().__init__(*args, **kwargs)
# if self.instance and self.instance.get_customer_agreement():
# queryset = SubscriptionPlan.objects.filter(
# customer_agreement=self.instance.customer_agreement,
# )
# self.fields['old_subscription_plan'] = queryset
# self.fields['new_subscription_plan'] = queryset

class Media:
js = (
'filtered_subscription_admin.js',
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Generated by Django 4.2.6 on 2023-10-18 20:13

from django.conf import settings
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='Say something about why the licenses are being transferred.', null=True)),
('is_dry_run', models.BooleanField(default=False)),
('delimtter', models.CharField(choices=[('\n', 'Newline (\n)'), (',', 'Comma (,)'), ('|', 'Pipe (|)')], default='\n', max_length=8)),
('license_uuids_raw', models.TextField(help_text='Delimitted list of license_uuids to transfer')),
('dry_run_results', models.TextField(editable=False, help_text='Raw results of what licenses would have changed if this was not a dry run', 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.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='license_transfer_jobs_new', to='subscriptions.subscriptionplan')),
('old_subscription_plan', models.OneToOneField(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='Say something about why the licenses are being transferred.', null=True)),
('is_dry_run', models.BooleanField(default=False)),
('delimtter', models.CharField(choices=[('\n', 'Newline (\n)'), (',', 'Comma (,)'), ('|', 'Pipe (|)')], default='\n', max_length=8)),
('license_uuids_raw', models.TextField(help_text='Delimitted list of license_uuids to transfer')),
('dry_run_results', models.TextField(editable=False, help_text='Raw results of what licenses would have changed if this was not a dry run', 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, 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, 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),
),
]
148 changes: 144 additions & 4 deletions license_manager/apps/subscriptions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from django.conf import settings
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
Expand Down Expand Up @@ -56,6 +56,7 @@
LicenseActivationMissingError,
LicenseToActivateIsRevokedError,
)
from .utils import chunks


logger = getLogger(__name__)
Expand Down Expand Up @@ -475,6 +476,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):
"""
Expand Down Expand Up @@ -734,12 +745,13 @@ def __str__(self):
Return human-readable string representation.
"""
return (
"<SubscriptionPlan with Title '{title}' "
"for EnterpriseCustomer '{enterprise_customer_uuid}'"
"<SubscriptionPlan title='{title}' "
"for customer '{enterprise_customer_uuid}', slug={slug} "
"{internal_use}>".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 '',
)
)

Expand Down Expand Up @@ -1291,6 +1303,134 @@ 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.
"""
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.OneToOneField(
SubscriptionPlan,
on_delete=models.CASCADE,
null=False,
blank=False,
related_name='license_transfer_jobs_old',
)
new_subscription_plan = models.OneToOneField(
SubscriptionPlan,
on_delete=models.CASCADE,
null=False,
blank=False,
related_name='license_transfer_jobs_new',
)
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=_('Say something about why the licenses are being transferred.'),
)
is_dry_run = models.BooleanField(
default=False,
)
delimtter = models.CharField(
max_length=8,
choices=(
('\n', 'Newline (\n)'),
(',', 'Comma (,)'),
('|', 'Pipe (|)'),
),
null=False,
default='\n',
)
license_uuids_raw = models.TextField(
null=False,
blank=False,
help_text=_("Delimitted list of license_uuids to transfer"),
)
dry_run_results = models.TextField(
null=True,
editable=False,
help_text=_("Raw results of what licenses would have changed if this was not a dry run"),
)

history = HistoricalRecords()

class Meta:
verbose_name = _("License Transfer Job")
verbose_name_plural = _("License Transfer Jobs")

def __str__(self):
return f'{self.id}'

Check warning on line 1376 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1376

Added line #L1376 was not covered by tests

def clean(self):
"""
Validates that old and new subscription plans share the same customer agreement.
"""
super().clean()

Check warning on line 1382 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1382

Added line #L1382 was not covered by tests
if self.old_subscription_plan.customer_agreement != self.new_subscription_plan.customer_agreement:
raise ValidationError(

Check warning on line 1384 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1384

Added line #L1384 was not covered by tests
'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

Check warning on line 1392 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1389-L1392

Added lines #L1389 - L1392 were not covered by tests

def get_license_uuids(self):
return self.license_uuids_raw.split(self.delimiter)

Check warning on line 1395 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1395

Added line #L1395 was not covered by tests

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(

Check warning on line 1404 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1404

Added line #L1404 was not covered by tests
subscription_plan=self.old_subscription_plan,
status__in=[ACTIVATED, ASSIGNED],
uuid__in=[license_uuid_chunk],
)

def process(self):
if self.completed_at:
logger.info(f'{self} was already processed on {self.completed_at}')
return

Check warning on line 1413 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1412-L1413

Added lines #L1412 - L1413 were not covered by tests

dry_run_results = ''
with transaction.atomic():

Check warning on line 1416 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1415-L1416

Added lines #L1415 - L1416 were not covered by tests
for license_queryset in self.get_licenses_to_transfer():
licenses = list(license_queryset)

Check warning on line 1418 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1418

Added line #L1418 was not covered by tests

if not self.is_dry_run:
for _license in licenses:
_license.subscription_plan = self.new_subscription_plan
License.bulk_update(licenses, ['subscription_plan'])

Check warning on line 1423 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1422-L1423

Added lines #L1422 - L1423 were not covered by tests
else:
dry_run_results += self.delimiter.join([_lic.uuid for _lic in licenses])

if self.is_dry_run:
self.dry_run_results = dry_run_results

Check warning on line 1428 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1428

Added line #L1428 was not covered by tests
else:
self.completed_at = localized_utcnow()
self.save()

Check warning on line 1431 in license_manager/apps/subscriptions/models.py

View check run for this annotation

Codecov / codecov/patch

license_manager/apps/subscriptions/models.py#L1430-L1431

Added lines #L1430 - L1431 were not covered by tests


class SubscriptionLicenseSourceType(TimeStampedModel):
"""
Subscription License Source Type
Expand Down
Loading

0 comments on commit 7afdfc0

Please sign in to comment.