From f9367ed1a9e1586e8401652dd290d749031d9cf4 Mon Sep 17 00:00:00 2001 From: muhammad-ammar Date: Fri, 15 Sep 2023 12:33:21 +0500 Subject: [PATCH] feat: add models to associate an opportunity id with a subscription license --- .annotation_safe_list.yml | 2 + .../migrations/0057_auto_20230915_0722.py | 44 ++++++++++++ ...nsesource_subscriptionlicensesourcetype.py | 44 ++++++++++++ ...0059_add_subscriptionlicensesourcetypes.py | 34 ++++++++++ ...060_historicalsubscriptionlicensesource.py | 42 ++++++++++++ license_manager/apps/subscriptions/models.py | 68 +++++++++++++++++++ .../apps/subscriptions/tests/factories.py | 15 ++++ .../apps/subscriptions/tests/test_models.py | 65 +++++++++++++++++- 8 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 license_manager/apps/subscriptions/migrations/0057_auto_20230915_0722.py create mode 100644 license_manager/apps/subscriptions/migrations/0058_subscriptionlicensesource_subscriptionlicensesourcetype.py create mode 100644 license_manager/apps/subscriptions/migrations/0059_add_subscriptionlicensesourcetypes.py create mode 100644 license_manager/apps/subscriptions/migrations/0060_historicalsubscriptionlicensesource.py diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index 3434654c..92d07da5 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -53,3 +53,5 @@ waffle.Sample: ".. no_pii:": "This model has no PII" waffle.Switch: ".. no_pii:": "This model has no PII" +subscriptions.HistoricalSubscriptionLicenseSource: + ".. no_pii:": "This model has no PII" diff --git a/license_manager/apps/subscriptions/migrations/0057_auto_20230915_0722.py b/license_manager/apps/subscriptions/migrations/0057_auto_20230915_0722.py new file mode 100644 index 00000000..b938f09e --- /dev/null +++ b/license_manager/apps/subscriptions/migrations/0057_auto_20230915_0722.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.21 on 2023-09-15 07:22 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscriptions', '0056_auto_20230530_1901'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalsubscriptionplan', + name='salesforce_opportunity_id', + field=models.CharField(blank=True, help_text='Deprecated -- 18 character value, derived from Salesforce Opportunity record.', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), + ), + migrations.AlterField( + model_name='historicalsubscriptionplan', + name='salesforce_opportunity_line_item', + field=models.CharField(blank=True, help_text='18 character value -- Locate the appropriate Salesforce Opportunity Line Item record and copy it here.', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), + ), + migrations.AlterField( + model_name='historicalsubscriptionplanrenewal', + name='salesforce_opportunity_id', + field=models.CharField(help_text='Locate the appropriate Salesforce Opportunity record and copy the Opportunity ID field (18 characters). Note that this is not the same Salesforce Opportunity ID associated with the linked subscription.', max_length=18, validators=[django.core.validators.MinLengthValidator(18)], verbose_name='Salesforce Opportunity Line Item'), + ), + migrations.AlterField( + model_name='subscriptionplan', + name='salesforce_opportunity_id', + field=models.CharField(blank=True, help_text='Deprecated -- 18 character value, derived from Salesforce Opportunity record.', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), + ), + migrations.AlterField( + model_name='subscriptionplan', + name='salesforce_opportunity_line_item', + field=models.CharField(blank=True, help_text='18 character value -- Locate the appropriate Salesforce Opportunity Line Item record and copy it here.', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), + ), + migrations.AlterField( + model_name='subscriptionplanrenewal', + name='salesforce_opportunity_id', + field=models.CharField(help_text='Locate the appropriate Salesforce Opportunity record and copy the Opportunity ID field (18 characters). Note that this is not the same Salesforce Opportunity ID associated with the linked subscription.', max_length=18, validators=[django.core.validators.MinLengthValidator(18)], verbose_name='Salesforce Opportunity Line Item'), + ), + ] diff --git a/license_manager/apps/subscriptions/migrations/0058_subscriptionlicensesource_subscriptionlicensesourcetype.py b/license_manager/apps/subscriptions/migrations/0058_subscriptionlicensesource_subscriptionlicensesourcetype.py new file mode 100644 index 00000000..14fa5127 --- /dev/null +++ b/license_manager/apps/subscriptions/migrations/0058_subscriptionlicensesource_subscriptionlicensesourcetype.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.21 on 2023-09-18 04:32 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscriptions', '0057_auto_20230915_0722'), + ] + + operations = [ + migrations.CreateModel( + name='SubscriptionLicenseSourceType', + 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')), + ('name', models.CharField(max_length=64)), + ('slug', models.SlugField(max_length=30, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SubscriptionLicenseSource', + 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')), + ('source_id', models.CharField(help_text='18 character value -- Salesforce Opportunity ID', max_length=18, validators=[django.core.validators.MinLengthValidator(18)])), + ('license', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='source', to='subscriptions.license')), + ('source_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='subscriptions.subscriptionlicensesourcetype')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/license_manager/apps/subscriptions/migrations/0059_add_subscriptionlicensesourcetypes.py b/license_manager/apps/subscriptions/migrations/0059_add_subscriptionlicensesourcetypes.py new file mode 100644 index 00000000..f63a7c86 --- /dev/null +++ b/license_manager/apps/subscriptions/migrations/0059_add_subscriptionlicensesourcetypes.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.21 on 2023-09-18 04:32 + +from django.db import migrations + + +LICENSE_SOURCE_TYPES = { + 'Application Management Technology': 'AMT' +} + + +def add_license_source_types(apps, schema_editor): + license_source_type_model = apps.get_model('subscriptions', 'SubscriptionLicenseSourceType') + for name, slug in LICENSE_SOURCE_TYPES.items(): + license_source_type_model.objects.update_or_create(name=name, slug=slug) + + +def delete_license_source_types(apps, schema_editor): + license_source_type_model = apps.get_model('subscriptions', 'SubscriptionLicenseSourceType') + license_source_type_model.objects.filter(name__in=LICENSE_SOURCE_TYPES).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscriptions', '0058_subscriptionlicensesource_subscriptionlicensesourcetype'), + ] + + operations = [ + migrations.RunPython( + code=add_license_source_types, + reverse_code=delete_license_source_types + ) + + ] diff --git a/license_manager/apps/subscriptions/migrations/0060_historicalsubscriptionlicensesource.py b/license_manager/apps/subscriptions/migrations/0060_historicalsubscriptionlicensesource.py new file mode 100644 index 00000000..29236a8b --- /dev/null +++ b/license_manager/apps/subscriptions/migrations/0060_historicalsubscriptionlicensesource.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.21 on 2023-09-19 06:38 + +from django.conf import settings +import django.core.validators +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', '0059_add_subscriptionlicensesourcetypes'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalSubscriptionLicenseSource', + 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')), + ('source_id', models.CharField(help_text='18 character value -- Salesforce Opportunity ID', max_length=18, validators=[django.core.validators.MinLengthValidator(18)])), + ('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)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('license', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.license')), + ('source_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.subscriptionlicensesourcetype')), + ], + options={ + 'verbose_name': 'historical subscription license source', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/license_manager/apps/subscriptions/models.py b/license_manager/apps/subscriptions/models.py index 8b4302f6..896f80b0 100644 --- a/license_manager/apps/subscriptions/models.py +++ b/license_manager/apps/subscriptions/models.py @@ -1291,6 +1291,74 @@ def _clean_up_duplicate_licenses(cls, duplicate_licenses): return sorted_licenses[0] +class SubscriptionLicenseSourceType(TimeStampedModel): + """ + Subscription License Source Type + + .. no_pii: This model has no PII + """ + + AMT = 'AMT' + + name = models.CharField(max_length=64) + slug = models.SlugField(max_length=30, unique=True) + + @classmethod + def get_source_type(cls, source_slug): + """ + Retrieve the source type based on the slug. + """ + try: + return cls.objects.get(slug=source_slug) + except SubscriptionLicenseSourceType.DoesNotExist: + return None + + def __str__(self): + """ + String representation of source type. + """ + return "SubscriptionLicenseSourceType: Name: {name}, Slug: {slug}".format(name=self.name, slug=self.slug) + + +class SubscriptionLicenseSource(TimeStampedModel): + """ + Subscription License Source + + .. no_pii: This model has no PII + """ + + license = models.OneToOneField( + License, + related_name='source', + on_delete=models.CASCADE, + ) + source_id = models.CharField( + max_length=SALESFORCE_ID_LENGTH, + validators=[MinLengthValidator(SALESFORCE_ID_LENGTH)], + help_text=_("18 character value -- Salesforce Opportunity ID") + ) + source_type = models.ForeignKey(SubscriptionLicenseSourceType, on_delete=models.CASCADE) + + history = HistoricalRecords() + + def save(self, *args, **kwargs): + """ + Override to ensure that model.clean is always called. + """ + self.full_clean() + return super().save(*args, **kwargs) + + def __str__(self): + """ + String representation of source. + """ + return "SubscriptionLicenseSource: LicenseID: {license}, SourceID: {source}, SourceType: {source_type}".format( + license=self.license.uuid, + source=self.source_id, + source_type=self.source_type.slug, + ) + + class SubscriptionsFeatureRole(UserRole): """ User role definitions specific to subscriptions. diff --git a/license_manager/apps/subscriptions/tests/factories.py b/license_manager/apps/subscriptions/tests/factories.py index d64dc0b8..7517fbaa 100644 --- a/license_manager/apps/subscriptions/tests/factories.py +++ b/license_manager/apps/subscriptions/tests/factories.py @@ -16,6 +16,8 @@ License, PlanType, Product, + SubscriptionLicenseSource, + SubscriptionLicenseSourceType, SubscriptionPlan, SubscriptionPlanRenewal, ) @@ -160,3 +162,16 @@ class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User + + +class SubscriptionLicenseSourceFactory(factory.django.DjangoModelFactory): + """ + Test factory for the `SubscriptionLicenseSource` model. + """ + + license = factory.SubFactory(LicenseFactory) + source_id = factory.LazyFunction(get_random_salesforce_id) + source_type = factory.Iterator(SubscriptionLicenseSourceType.objects.all()) + + class Meta: + model = SubscriptionLicenseSource diff --git a/license_manager/apps/subscriptions/tests/test_models.py b/license_manager/apps/subscriptions/tests/test_models.py index 05949474..2acc58e2 100644 --- a/license_manager/apps/subscriptions/tests/test_models.py +++ b/license_manager/apps/subscriptions/tests/test_models.py @@ -4,6 +4,7 @@ import ddt import freezegun +import pytest from django.forms import ValidationError from django.test import TestCase from requests.exceptions import HTTPError @@ -16,10 +17,15 @@ SegmentEvents, ) from license_manager.apps.subscriptions.exceptions import CustomerAgreementError -from license_manager.apps.subscriptions.models import License, Notification +from license_manager.apps.subscriptions.models import ( + License, + Notification, + SubscriptionLicenseSourceType, +) from license_manager.apps.subscriptions.tests.factories import ( CustomerAgreementFactory, LicenseFactory, + SubscriptionLicenseSourceFactory, SubscriptionPlanFactory, SubscriptionPlanRenewalFactory, ) @@ -386,3 +392,60 @@ def test_net_days_until_expiration(self): with freezegun.freeze_time(today): expected_days = (self.subscription_plan_b.expiration_date - today).days assert self.customer_agreement.net_days_until_expiration == expected_days + + +@ddt.ddt +class SubscriptionLicenseSourceModelTests(TestCase): + """ + Tests for the `SubscriptionLicenseSource` model. + """ + + def setUp(self): + super().setUp() + + self.user_email = 'bob@example.com' + self.enterprise_customer_uuid = uuid.uuid4() + self.customer_agreement = CustomerAgreementFactory.create( + enterprise_customer_uuid=self.enterprise_customer_uuid, + ) + + self.active_current_plan = SubscriptionPlanFactory.create( + customer_agreement=self.customer_agreement, + is_active=True, + start_date=localized_datetime(2021, 1, 1), + expiration_date=localized_datetime_from_datetime(datetime.now() + timedelta(days=365)), + ) + + self.active_current_license = LicenseFactory.create( + user_email=self.user_email, + subscription_plan=self.active_current_plan, + ) + + def test_license_source_creation(self): + """ + Tests license souce model object creation. + """ + license_source = SubscriptionLicenseSourceFactory( + license=self.active_current_license, + source_id='000000000000000000', + source_type=SubscriptionLicenseSourceType.get_source_type(SubscriptionLicenseSourceType.AMT) + ) + str_repr = 'SubscriptionLicenseSource: LicenseID: {license_uuid}, SourceID: {source_id}, SourceType: AMT' + assert str(license_source) == str_repr.format( + license_uuid=self.active_current_license.uuid, + source_id='000000000000000000', + ) + + def test_license_source_creation_with_invalid_souce_id(self): + """ + Verify that SubscriptionLicenseSource model raises exception if source id format is wrong. + """ + with pytest.raises(ValidationError) as raised_exception: + SubscriptionLicenseSourceFactory( + license=self.active_current_license, + source_id='000000000', + source_type=SubscriptionLicenseSourceType.get_source_type(SubscriptionLicenseSourceType.AMT) + ) + + 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