diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 88972f33c1..e3ddf6a78b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.9.4] +-------- + +feat: Add model for integrated channel API request log table (ENT-8018) + [4.9.3] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index eae2d43e45..665440b6f8 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.9.3" +__version__ = "4.9.4" diff --git a/enterprise/api_client/enterprise_catalog.py b/enterprise/api_client/enterprise_catalog.py index a27f69d6c7..6a3285cf65 100644 --- a/enterprise/api_client/enterprise_catalog.py +++ b/enterprise/api_client/enterprise_catalog.py @@ -316,7 +316,7 @@ def enterprise_contains_content_items(self, enterprise_uuid, content_ids): return response.json()['contains_content_items'] @UserAPIClient.refresh_token - def get_content_metadata_content_identifier(self, enterprise_uuid, content_id): # pylint: disable=inconsistent-return-statements + def get_content_metadata_content_identifier(self, enterprise_uuid, content_id): """ Return all content metadata contained in the catalogs associated with the given EnterpriseCustomer and content_id. diff --git a/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py b/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py new file mode 100644 index 0000000000..35a3c66b68 --- /dev/null +++ b/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.23 on 2024-01-15 07:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0030_integratedchannelapirequestlogs'), + ('cornerstone', '0030_auto_20231010_1654'), + ] + + operations = [ + migrations.CreateModel( + name='CornerstoneAPIRequestLogs', + fields=[ + ('integratedchannelapirequestlogs_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='integrated_channel.integratedchannelapirequestlogs')), + ('user_agent', models.CharField(max_length=255)), + ('user_ip', models.GenericIPAddressField(blank=True, null=True)), + ], + bases=('integrated_channel.integratedchannelapirequestlogs',), + ), + ] diff --git a/integrated_channels/cornerstone/models.py b/integrated_channels/cornerstone/models.py index 88b0f24e93..46592bc4ea 100644 --- a/integrated_channels/cornerstone/models.py +++ b/integrated_channels/cornerstone/models.py @@ -19,6 +19,7 @@ from integrated_channels.cornerstone.transmitters.learner_data import CornerstoneLearnerTransmitter from integrated_channels.integrated_channel.models import ( EnterpriseCustomerPluginConfiguration, + IntegratedChannelAPIRequestLogs, LearnerDataTransmissionAudit, ) from integrated_channels.utils import is_valid_url @@ -318,3 +319,36 @@ class CornerstoneCourseKey(models.Model): class Meta: app_label = 'cornerstone' + + +class CornerstoneAPIRequestLogs(IntegratedChannelAPIRequestLogs): + """ + A model to track basic information about every API call we make from the integrated channels. + """ + user_agent = models.CharField(max_length=255) + user_ip = models.GenericIPAddressField(blank=True, null=True) + + class Meta: + app_label = 'cornerstone' + + def __str__(self): + """ + Return a human-readable string representation of the object. + """ + return ( + f'' + f', endpoint: {self.endpoint}' + f', time_taken: {self.time_taken}' + f', user_agent: {self.user_agent}' + f', user_ip: {self.user_ip}' + f', api_record.body: {self.api_record.body}' + f', api_record.status_code: {self.api_record.status_code}' + ) + + def __repr__(self): + """ + Return uniquely identifying string representation. + """ + return self.__str__() diff --git a/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py new file mode 100644 index 0000000000..db8f99c3e5 --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.23 on 2024-01-12 07:24 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0197_auto_20231130_2239'), + ('integrated_channel', '0029_genericenterprisecustomerpluginconfiguration_show_course_price'), + ] + + operations = [ + migrations.CreateModel( + name='IntegratedChannelAPIRequestLogs', + 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')), + ('enterprise_customer_configuration_id', models.IntegerField(help_text='ID from the EnterpriseCustomerConfiguration model')), + ('endpoint', models.TextField()), + ('payload', models.TextField()), + ('time_taken', models.DurationField()), + ('api_record', models.OneToOneField(blank=True, help_text='Data pertaining to the transmissions API request response.', null=True, on_delete=django.db.models.deletion.CASCADE, to='integrated_channel.apiresponserecord')), + ('enterprise_customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='enterprise.enterprisecustomer')), + ], + ), + ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index 4e20d2a43f..0d2a26f4e3 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -879,3 +879,47 @@ class Meta: on_delete=models.CASCADE, ) resolved = models.BooleanField(default=False) + + +class IntegratedChannelAPIRequestLogs(TimeStampedModel): + """ + A model to track basic information about every API call we make from the integrated channels. + """ + enterprise_customer = models.ForeignKey( + EnterpriseCustomer, on_delete=models.CASCADE) + enterprise_customer_configuration_id = models.IntegerField( + blank=False, null=False, help_text='ID from the EnterpriseCustomerConfiguration model') + endpoint = models.TextField(blank=False, null=False) + payload = models.TextField(blank=False, null=False) + time_taken = models.DurationField(blank=False, null=False) + api_record = models.OneToOneField( + ApiResponseRecord, + blank=True, + null=True, + on_delete=models.CASCADE, + help_text=_( + 'Data pertaining to the transmissions API request response.') + ) + + class Meta: + app_label = 'integrated_channel' + + def __str__(self): + """ + Return a human-readable string representation of the object. + """ + return ( + f'' + f', endpoint: {self.endpoint}' + f', time_taken: {self.time_taken}' + f', api_record.body: {self.api_record.body}' + f', api_record.status_code: {self.api_record.status_code}' + ) + + def __repr__(self): + """ + Return uniquely identifying string representation. + """ + return self.__str__() diff --git a/tests/test_integrated_channels/test_integrated_channel/test_models.py b/tests/test_integrated_channels/test_integrated_channel/test_models.py index e182b1ff7e..49c4c21bc9 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_models.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_models.py @@ -9,7 +9,11 @@ from pytest import mark from enterprise.utils import get_content_metadata_item_id, localized_utcnow -from integrated_channels.integrated_channel.models import ApiResponseRecord, ContentMetadataItemTransmission +from integrated_channels.integrated_channel.models import ( + ApiResponseRecord, + ContentMetadataItemTransmission, + IntegratedChannelAPIRequestLogs, +) from test_utils import factories from test_utils.fake_catalog_api import FAKE_COURSE_RUN, get_fake_catalog, get_fake_content_metadata from test_utils.fake_enterprise_api import EnterpriseMockMixin @@ -251,3 +255,51 @@ def test_offset_naive_error(self): first_timestamp = localized_utcnow() self.config.update_content_synced_at(first_timestamp, True) assert self.config.last_sync_attempted_at == first_timestamp + + +@mark.django_db +class TestIntegratedChannelAPIRequestLogs(unittest.TestCase, EnterpriseMockMixin): + """ + Tests for the ``IntegratedChannelAPIRequestLogs`` model. + """ + + def setUp(self): + self.enterprise_customer = factories.EnterpriseCustomerFactory() + with mock.patch('enterprise.signals.EnterpriseCatalogApiClient'): + self.enterprise_customer_catalog = factories.EnterpriseCustomerCatalogFactory( + enterprise_customer=self.enterprise_customer, + ) + self.pk = 1 + self.enterprise_customer_configuration_id = 1 + self.endpoint = 'https://example.com/endpoint' + self.payload = "{}" + self.time_taken = 500 + api_record = ApiResponseRecord(status_code=200, body='SUCCESS') + api_record.save() + self.api_record = api_record + super().setUp() + + def test_content_meta_data_string_representation(self): + """ + Test the string representation of the model. + """ + expected_string = ( + f'' + f', endpoint: {self.endpoint}' + f', time_taken: {self.time_taken}' + f', api_record.body: {self.api_record.body}' + f', api_record.status_code: {self.api_record.status_code}' + ) + + request_log = IntegratedChannelAPIRequestLogs( + id=1, + enterprise_customer=self.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_customer_configuration_id, + endpoint=self.endpoint, + payload=self.payload, + time_taken=self.time_taken, + api_record=self.api_record + ) + assert expected_string == repr(request_log) diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 070a1e817a..92edcc185d 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -137,6 +137,7 @@ def setUp(self): "auth_org_id", "active", "country", + "integratedchannelapirequestlogs", "invite_keys", "hide_course_original_price", "site",