From 186c879310f0c4dd2c32676e3f066d24f2b492bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:05:29 +0000 Subject: [PATCH 01/23] Initial plan From 96285b2d8d5d472ad795eb2e4b8aee3e5b2bfa3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:09:00 +0000 Subject: [PATCH 02/23] Add UsageInfo database model and update fetch_daily_billable_usage command Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .../commands/fetch_daily_billable_usage.py | 54 +++++++++++++++++-- .../migrations/0001_initial.py | 44 +++++++++++++++ .../migrations/__init__.py | 0 src/coldfront_plugin_cloud/models.py | 39 ++++++++++++++ 4 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/coldfront_plugin_cloud/migrations/0001_initial.py create mode 100644 src/coldfront_plugin_cloud/migrations/__init__.py create mode 100644 src/coldfront_plugin_cloud/models.py diff --git a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py index 18d4c9ac..c1dff843 100644 --- a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py @@ -10,8 +10,9 @@ from coldfront_plugin_cloud import attributes from coldfront.core.utils.common import import_from_settings from coldfront_plugin_cloud import usage_models -from coldfront_plugin_cloud.usage_models import UsageInfo, validate_date_str +from coldfront_plugin_cloud.usage_models import validate_date_str from coldfront_plugin_cloud import utils +from coldfront_plugin_cloud.models import UsageInfo as UsageInfoModel import boto3 from django.core.management.base import BaseCommand @@ -85,10 +86,20 @@ def add_arguments(self, parser): parser.add_argument( "--date", type=str, default=self.previous_day_string, help="Date." ) + parser.add_argument( + "--remove", + action="store_true", + help="Remove usage entries for the specified date instead of fetching.", + ) def handle(self, *args, **options): date = options["date"] validate_date_str(date) + remove = options.get("remove", False) + + if remove: + self.handle_remove(date) + return allocations = self.get_allocations_for_daily_billing() @@ -122,6 +133,9 @@ def handle(self, *args, **options): ) continue + # Store usage information in the database + self.store_usage_in_database(allocation, date, new_usage) + # Only update the latest value if the processed date is newer or same date. if not previous_total or date >= previous_total.date: new_total = TotalByDate(date, new_usage.total_charges) @@ -212,7 +226,7 @@ def load_service_invoice(self, resource: str, date_str: str) -> DataFrameGroupBy def get_allocation_usage( self, resource: str, date_str: str, allocation_id - ) -> UsageInfo: + ) -> usage_models.UsageInfo: """Loads the service invoice and parse UsageInfo for a specific allocation.""" invoice = self.load_service_invoice(resource, date_str) @@ -222,9 +236,9 @@ def get_allocation_usage( ] except KeyError: logger.debug(f"No usage for allocation {allocation_id}.") - return UsageInfo({}) + return usage_models.UsageInfo({}) - return UsageInfo( + return usage_models.UsageInfo( df.set_index(INVOICE_COLUMN_SU_TYPE)[INVOICE_COLUMN_COST].to_dict() ) @@ -300,3 +314,35 @@ def send_alert_email(cls, allocation: Allocation, resource: Resource, alert_valu if x != allocation.project.pi.email ], ) + + @staticmethod + def store_usage_in_database(allocation: Allocation, date: str, usage_info): + """Store usage information in the database for each SU type. + + Args: + allocation: The allocation to store usage for + date: The date string in YYYY-MM-DD format + usage_info: UsageInfo pydantic model instance with SU type charges + """ + for su_type, value in usage_info.root.items(): + UsageInfoModel.objects.update_or_create( + allocation=allocation, + date=date, + su_type=su_type, + defaults={'value': value} + ) + logger.info( + f"Stored usage data for allocation {allocation.id} on {date}: " + f"{len(usage_info.root)} SU types" + ) + + @staticmethod + def handle_remove(date: str): + """Remove all usage entries for the specified date. + + Args: + date: The date string in YYYY-MM-DD format for which to remove entries + """ + deleted_count, _ = UsageInfoModel.objects.filter(date=date).delete() + logger.info(f"Removed {deleted_count} usage entries for date {date}") + print(f"Removed {deleted_count} usage entries for date {date}") diff --git a/src/coldfront_plugin_cloud/migrations/0001_initial.py b/src/coldfront_plugin_cloud/migrations/0001_initial.py new file mode 100644 index 00000000..2e883322 --- /dev/null +++ b/src/coldfront_plugin_cloud/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated manually for coldfront_plugin_cloud + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('allocation', '__first__'), + ] + + operations = [ + migrations.CreateModel( + name='UsageInfo', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(help_text='The date for which this usage was recorded')), + ('su_type', models.CharField(help_text='The type of Service Unit (e.g., OpenStack CPU, OpenStack V100 GPU)', max_length=255)), + ('value', models.DecimalField(decimal_places=2, help_text='The usage value/cost for this SU type on this date', max_digits=12)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('allocation', models.ForeignKey(help_text='The allocation this usage belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='usage_info', to='allocation.allocation')), + ], + options={ + 'db_table': 'coldfront_plugin_cloud_usageinfo', + 'ordering': ['-date', 'allocation', 'su_type'], + }, + ), + migrations.AddIndex( + model_name='usageinfo', + index=models.Index(fields=['allocation', 'date'], name='coldfront_p_allocat_5c8e3d_idx'), + ), + migrations.AddIndex( + model_name='usageinfo', + index=models.Index(fields=['date'], name='coldfront_p_date_3e8a9e_idx'), + ), + migrations.AlterUniqueTogether( + name='usageinfo', + unique_together={('allocation', 'date', 'su_type')}, + ), + ] diff --git a/src/coldfront_plugin_cloud/migrations/__init__.py b/src/coldfront_plugin_cloud/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/coldfront_plugin_cloud/models.py b/src/coldfront_plugin_cloud/models.py new file mode 100644 index 00000000..d5389d3a --- /dev/null +++ b/src/coldfront_plugin_cloud/models.py @@ -0,0 +1,39 @@ +from django.db import models +from coldfront.core.allocation.models import Allocation + + +class UsageInfo(models.Model): + """Stores daily billable usage for allocations by SU type.""" + + allocation = models.ForeignKey( + Allocation, + on_delete=models.CASCADE, + related_name='usage_info', + help_text='The allocation this usage belongs to' + ) + date = models.DateField( + help_text='The date for which this usage was recorded' + ) + su_type = models.CharField( + max_length=255, + help_text='The type of Service Unit (e.g., OpenStack CPU, OpenStack V100 GPU)' + ) + value = models.DecimalField( + max_digits=12, + decimal_places=2, + help_text='The usage value/cost for this SU type on this date' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'coldfront_plugin_cloud_usageinfo' + unique_together = [['allocation', 'date', 'su_type']] + indexes = [ + models.Index(fields=['allocation', 'date']), + models.Index(fields=['date']), + ] + ordering = ['-date', 'allocation', 'su_type'] + + def __str__(self): + return f"{self.allocation.id} - {self.date} - {self.su_type}: {self.value}" From 1815ed840265b4f9d979b4f879f1a1cce277b5ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:09:40 +0000 Subject: [PATCH 03/23] Add unit tests for database insertion and removal functionality Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .../unit/test_fetch_daily_billable_usage.py | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py index 9b43217d..4480d84a 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py @@ -238,3 +238,186 @@ def test_send_alert_email(self): receiver_list=[allocation_1.project.pi.email], cc=[manager.email], ) + + @patch( + "coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage.RESOURCES_DAILY_ENABLED", + ["FakeProd"], + ) + @patch( + "coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage.Command.get_allocation_usage" + ) + def test_database_insertion(self, mock_get_allocation_usage): + """Test that usage data is stored in the database.""" + from coldfront_plugin_cloud.models import UsageInfo as UsageInfoModel + + mock_get_allocation_usage.side_effect = [ + usage_models.UsageInfo({"OpenStack CPU": "100.00", "OpenStack GPU": "50.00"}), + usage_models.UsageInfo({"Storage": "30.12"}), + ] + + fakeprod = self.new_openstack_resource( + name="FakeProd", internal_name="FakeProd" + ) + prod_project = self.new_project() + allocation_1 = self.new_allocation( + project=prod_project, resource=fakeprod, quantity=1, status="Active" + ) + utils.set_attribute_on_allocation( + allocation_1, attributes.ALLOCATION_PROJECT_ID, "test-allocation-1" + ) + + # Verify no entries before running command + self.assertEqual(UsageInfoModel.objects.count(), 0) + + call_command("fetch_daily_billable_usage", date="2025-11-15") + + # Verify database entries were created + usage_entries = UsageInfoModel.objects.filter( + allocation=allocation_1, date="2025-11-15" + ) + self.assertEqual(usage_entries.count(), 3) + + # Check individual SU types + cpu_usage = usage_entries.get(su_type="OpenStack CPU") + self.assertEqual(cpu_usage.value, 100.00) + + gpu_usage = usage_entries.get(su_type="OpenStack GPU") + self.assertEqual(gpu_usage.value, 50.00) + + storage_usage = usage_entries.get(su_type="Storage") + self.assertEqual(storage_usage.value, 30.12) + + # Test update_or_create by running again with different values + mock_get_allocation_usage.side_effect = [ + usage_models.UsageInfo({"OpenStack CPU": "110.00", "OpenStack GPU": "55.00"}), + usage_models.UsageInfo({"Storage": "35.00"}), + ] + call_command("fetch_daily_billable_usage", date="2025-11-15") + + # Should still have 3 entries + usage_entries = UsageInfoModel.objects.filter( + allocation=allocation_1, date="2025-11-15" + ) + self.assertEqual(usage_entries.count(), 3) + + # Check updated values + cpu_usage = usage_entries.get(su_type="OpenStack CPU") + self.assertEqual(cpu_usage.value, 110.00) + + @patch( + "coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage.RESOURCES_DAILY_ENABLED", + ["FakeProd"], + ) + @patch( + "coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage.Command.get_allocation_usage" + ) + def test_remove_parameter(self, mock_get_allocation_usage): + """Test that --remove parameter deletes usage entries for a given date.""" + from coldfront_plugin_cloud.models import UsageInfo as UsageInfoModel + + mock_get_allocation_usage.side_effect = [ + usage_models.UsageInfo({"OpenStack CPU": "100.00"}), + usage_models.UsageInfo({"Storage": "30.12"}), + ] + + fakeprod = self.new_openstack_resource( + name="FakeProd", internal_name="FakeProd" + ) + prod_project = self.new_project() + allocation_1 = self.new_allocation( + project=prod_project, resource=fakeprod, quantity=1, status="Active" + ) + utils.set_attribute_on_allocation( + allocation_1, attributes.ALLOCATION_PROJECT_ID, "test-allocation-1" + ) + + # First, add some data + call_command("fetch_daily_billable_usage", date="2025-11-15") + + # Add data for another date + mock_get_allocation_usage.side_effect = [ + usage_models.UsageInfo({"OpenStack CPU": "120.00"}), + usage_models.UsageInfo({"Storage": "40.00"}), + ] + call_command("fetch_daily_billable_usage", date="2025-11-16") + + # Verify data exists + self.assertEqual( + UsageInfoModel.objects.filter(date="2025-11-15").count(), 2 + ) + self.assertEqual( + UsageInfoModel.objects.filter(date="2025-11-16").count(), 2 + ) + + # Remove data for 2025-11-15 + call_command("fetch_daily_billable_usage", date="2025-11-15", remove=True) + + # Verify data for 2025-11-15 is deleted + self.assertEqual( + UsageInfoModel.objects.filter(date="2025-11-15").count(), 0 + ) + + # Verify data for 2025-11-16 still exists + self.assertEqual( + UsageInfoModel.objects.filter(date="2025-11-16").count(), 2 + ) + + @patch( + "coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage.RESOURCES_DAILY_ENABLED", + ["FakeProd"], + ) + @patch( + "coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage.Command.get_allocation_usage" + ) + def test_multiple_allocations_same_date(self, mock_get_allocation_usage): + """Test that multiple allocations can store usage for the same date.""" + from coldfront_plugin_cloud.models import UsageInfo as UsageInfoModel + + fakeprod = self.new_openstack_resource( + name="FakeProd", internal_name="FakeProd" + ) + prod_project1 = self.new_project() + prod_project2 = self.new_project() + + allocation_1 = self.new_allocation( + project=prod_project1, resource=fakeprod, quantity=1, status="Active" + ) + allocation_2 = self.new_allocation( + project=prod_project2, resource=fakeprod, quantity=1, status="Active" + ) + + utils.set_attribute_on_allocation( + allocation_1, attributes.ALLOCATION_PROJECT_ID, "test-allocation-1" + ) + utils.set_attribute_on_allocation( + allocation_2, attributes.ALLOCATION_PROJECT_ID, "test-allocation-2" + ) + + # Mock returns for both allocations + mock_get_allocation_usage.side_effect = [ + usage_models.UsageInfo({"OpenStack CPU": "100.00"}), + usage_models.UsageInfo({"Storage": "30.00"}), + usage_models.UsageInfo({"OpenStack CPU": "200.00"}), + usage_models.UsageInfo({"Storage": "50.00"}), + ] + + call_command("fetch_daily_billable_usage", date="2025-11-15") + + # Verify both allocations have data + alloc1_entries = UsageInfoModel.objects.filter( + allocation=allocation_1, date="2025-11-15" + ) + alloc2_entries = UsageInfoModel.objects.filter( + allocation=allocation_2, date="2025-11-15" + ) + + self.assertEqual(alloc1_entries.count(), 2) + self.assertEqual(alloc2_entries.count(), 2) + + # Verify values are correct for each allocation + self.assertEqual( + alloc1_entries.get(su_type="OpenStack CPU").value, 100.00 + ) + self.assertEqual( + alloc2_entries.get(su_type="OpenStack CPU").value, 200.00 + ) From 5e2c5d033cb19942b0515431fe12da4d8fd5e3ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:16:20 +0000 Subject: [PATCH 04/23] Fix test assertions to use Decimal for database value comparisons Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- src/coldfront.db | Bin 0 -> 872448 bytes .../unit/test_fetch_daily_billable_usage.py | 13 +++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 src/coldfront.db diff --git a/src/coldfront.db b/src/coldfront.db new file mode 100644 index 0000000000000000000000000000000000000000..63400939b3555b26fd52e8a2b7b5bc649a3c4738 GIT binary patch literal 872448 zcmeI*3zTE`eb{$k_JMuP&PtR7>p|-h60L=~B=LTbA}MedB%z(baA#*kj_CFE#l^+U zB{9J4#l=1()AAiIMM-wjI<4(QwPQDFZMTVCr*WO8i5=TbYB#QHCw1DK)Ar8oP=qB?A`% z!C>HXk`xF8(&B%AulK)TAP~%o$H&G0oPWe?Q~yZ^p1S$<6QbCKrJuJ8|J|j3z4R}a z{@K$1we*jc{^8R9y!1aT{hg)XUHV%~e`D#dF8$@DU+fl|+A0MQKmY**5I_I{1Q0*~ z0R#{@vI75=RTsJaHLEIq{^!=~;^%*2mBr7Wwd{@gzioY5{QRo*Ve#{A>%91>TjEFH z`}f3;SM544Y>6MSlKAnNXT*>{cQBiKR@%!XBuZNo&G;g|Lp1Or!SuR$ESYb z)Xu4kp?@6u)zHmQ=H$OT`Ku>yp3I#1e@^_;iRy_@i-!3J0R#~EHVQm{=|tesXA9Lg zmBw~cz7dPXbF!(l-jZ8wRn=N8S#MOe+lg2r^JFaXWIQG%V$a0m&m_`MWpb%pKK=X? zgY~oB`f9!1GBrb1>f-0<`tfu=^~(2(`fL9FGjdDa(W>pbCi*rt(S22}DyAmCbxSd} zN4Atr$8zb+$oi>NHWPpTQv>y5d8d9n8;^-+BFWMH zCvu5Y=K08A{hV7r6H6pgBL2ka{u2o?5n_*t`k(ZTUMwq%ckpkFj5wZ8W-`x9gFR;4 z9@DX0EG@@#@$BgO*?1!H{3i$Ni<#FQv3NR`6=#(AH9F#WA};#>?!o%0zWOndaVj~o z|5!SoPCWnUVEv@OeoXYA93v(sR4VrTCkE@gGtLe?Df-VOQlt7$=Q8PZI{9%?|8c)~ zT%5YPsVnu7m9ptqp#$DS=H z^?FlPOug9<0k~tBh>L%+ay%szN34UXTq>4HeJ(uQf;)``kx{ zTkys)FDlz-ZYn=2hPWV2r$6`M;TF7+%y(yEDjgr$LPDGg`SgcG3y=3ZOdN<gxfZn?Y-%9&2A(SOQv5wFB;kGHDa%g-mI-FvrJmiP&yNj=U!SA z4PA2@5{snQkbMqJo&0t?F>x)qa!#~!-DxM=Yv-m?*M(2hXvs>oDr<((G~``zN1$wL zqlr>tXmW+KqOp?GSVlIq8*Sat#6{1(mYep4wui)Ta=SDYrCi+Ja> z?f})B_BzpM*GH@_BBx{~7mt1Jv}nT_>zFtZN7yv?%+ZM?#jH*(pAs!NV;vJGqSu19 ztLXKK>xu{?kxs{wuY^P+&I%<~fNmq+T$^du#Kq+@^UBE+fyZAK%caL?D+=G;=1rGZ zwtYK|j^vGt5z3cNoCt(POx0c8Ugnar@Dw9s-06(7qT0)!T&pYFvbxjM#XaE2reXm|$z(isOf(@@oL&;~zeD^^{j)sIdK(i>UGRBgZs< zu^qi7-p$cfrS8=gA4wQ`rEO~V`v#E@9=kHF!gpObmi3qMntJTqvC`-xZYPg_U|isC z5j&0#o;mi!sor~ua_{%z>0^(bvbX$l=iRn*;bWidKDFN#`rvU

xoCXXPWujwK!E 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 b(G~&-Ab Date: Tue, 10 Feb 2026 20:16:37 +0000 Subject: [PATCH 05/23] Remove committed database file and update gitignore Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .gitignore | 1 + src/coldfront.db | Bin 872448 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 src/coldfront.db diff --git a/.gitignore b/.gitignore index 25ff9bd7..c2b206d1 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,4 @@ dmypy.json [._]s[a-rt-v][a-z] [._]ss[a-gi-z] [._]sw[a-p] +*.db diff --git a/src/coldfront.db b/src/coldfront.db deleted file mode 100644 index 63400939b3555b26fd52e8a2b7b5bc649a3c4738..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 872448 zcmeI*3zTE`eb{$k_JMuP&PtR7>p|-h60L=~B=LTbA}MedB%z(baA#*kj_CFE#l^+U zB{9J4#l=1()AAiIMM-wjI<4(QwPQDFZMTVCr*WO8i5=TbYB#QHCw1DK)Ar8oP=qB?A`% z!C>HXk`xF8(&B%AulK)TAP~%o$H&G0oPWe?Q~yZ^p1S$<6QbCKrJuJ8|J|j3z4R}a z{@K$1we*jc{^8R9y!1aT{hg)XUHV%~e`D#dF8$@DU+fl|+A0MQKmY**5I_I{1Q0*~ z0R#{@vI75=RTsJaHLEIq{^!=~;^%*2mBr7Wwd{@gzioY5{QRo*Ve#{A>%91>TjEFH z`}f3;SM544Y>6MSlKAnNXT*>{cQBiKR@%!XBuZNo&G;g|Lp1Or!SuR$ESYb z)Xu4kp?@6u)zHmQ=H$OT`Ku>yp3I#1e@^_;iRy_@i-!3J0R#~EHVQm{=|tesXA9Lg zmBw~cz7dPXbF!(l-jZ8wRn=N8S#MOe+lg2r^JFaXWIQG%V$a0m&m_`MWpb%pKK=X? zgY~oB`f9!1GBrb1>f-0<`tfu=^~(2(`fL9FGjdDa(W>pbCi*rt(S22}DyAmCbxSd} zN4Atr$8zb+$oi>NHWPpTQv>y5d8d9n8;^-+BFWMH zCvu5Y=K08A{hV7r6H6pgBL2ka{u2o?5n_*t`k(ZTUMwq%ckpkFj5wZ8W-`x9gFR;4 z9@DX0EG@@#@$BgO*?1!H{3i$Ni<#FQv3NR`6=#(AH9F#WA};#>?!o%0zWOndaVj~o z|5!SoPCWnUVEv@OeoXYA93v(sR4VrTCkE@gGtLe?Df-VOQlt7$=Q8PZI{9%?|8c)~ zT%5YPsVnu7m9ptqp#$DS=H z^?FlPOug9<0k~tBh>L%+ay%szN34UXTq>4HeJ(uQf;)``kx{ zTkys)FDlz-ZYn=2hPWV2r$6`M;TF7+%y(yEDjgr$LPDGg`SgcG3y=3ZOdN<gxfZn?Y-%9&2A(SOQv5wFB;kGHDa%g-mI-FvrJmiP&yNj=U!SA z4PA2@5{snQkbMqJo&0t?F>x)qa!#~!-DxM=Yv-m?*M(2hXvs>oDr<((G~``zN1$wL zqlr>tXmW+KqOp?GSVlIq8*Sat#6{1(mYep4wui)Ta=SDYrCi+Ja> z?f})B_BzpM*GH@_BBx{~7mt1Jv}nT_>zFtZN7yv?%+ZM?#jH*(pAs!NV;vJGqSu19 ztLXKK>xu{?kxs{wuY^P+&I%<~fNmq+T$^du#Kq+@^UBE+fyZAK%caL?D+=G;=1rGZ zwtYK|j^vGt5z3cNoCt(POx0c8Ugnar@Dw9s-06(7qT0)!T&pYFvbxjM#XaE2reXm|$z(isOf(@@oL&;~zeD^^{j)sIdK(i>UGRBgZs< zu^qi7-p$cfrS8=gA4wQ`rEO~V`v#E@9=kHF!gpObmi3qMntJTqvC`-xZYPg_U|isC z5j&0#o;mi!sor~ua_{%z>0^(bvbX$l=iRn*;bWidKDFN#`rvU

xoCXXPWujwK!E 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 b(G~&-Ab Date: Tue, 10 Feb 2026 20:17:17 +0000 Subject: [PATCH 06/23] Fix trailing whitespace in code files Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- src/coldfront_plugin_cloud/models.py | 2 +- .../tests/unit/test_fetch_daily_billable_usage.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/coldfront_plugin_cloud/models.py b/src/coldfront_plugin_cloud/models.py index d5389d3a..459eccfb 100644 --- a/src/coldfront_plugin_cloud/models.py +++ b/src/coldfront_plugin_cloud/models.py @@ -4,7 +4,7 @@ class UsageInfo(models.Model): """Stores daily billable usage for allocations by SU type.""" - + allocation = models.ForeignKey( Allocation, on_delete=models.CASCADE, diff --git a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py index b1736476..f382ac78 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py @@ -334,7 +334,7 @@ def test_remove_parameter(self, mock_get_allocation_usage): # First, add some data call_command("fetch_daily_billable_usage", date="2025-11-15") - + # Add data for another date mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo({"OpenStack CPU": "120.00"}), @@ -379,14 +379,14 @@ def test_multiple_allocations_same_date(self, mock_get_allocation_usage): ) prod_project1 = self.new_project() prod_project2 = self.new_project() - + allocation_1 = self.new_allocation( project=prod_project1, resource=fakeprod, quantity=1, status="Active" ) allocation_2 = self.new_allocation( project=prod_project2, resource=fakeprod, quantity=1, status="Active" ) - + utils.set_attribute_on_allocation( allocation_1, attributes.ALLOCATION_PROJECT_ID, "test-allocation-1" ) @@ -411,10 +411,10 @@ def test_multiple_allocations_same_date(self, mock_get_allocation_usage): alloc2_entries = UsageInfoModel.objects.filter( allocation=allocation_2, date="2025-11-15" ) - + self.assertEqual(alloc1_entries.count(), 2) self.assertEqual(alloc2_entries.count(), 2) - + # Verify values are correct for each allocation self.assertEqual( alloc1_entries.get(su_type="OpenStack CPU").value, Decimal("100.00") From f1ac8f7f2138dd38b7b67616e6e98faa58911d4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:19:03 +0000 Subject: [PATCH 07/23] Address code review feedback: improve related_name, add logging for empty usage, remove duplicate print Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .../commands/fetch_daily_billable_usage.py | 14 ++++++++++---- src/coldfront_plugin_cloud/models.py | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py index c1dff843..b2a97d05 100644 --- a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py @@ -318,12 +318,19 @@ def send_alert_email(cls, allocation: Allocation, resource: Resource, alert_valu @staticmethod def store_usage_in_database(allocation: Allocation, date: str, usage_info): """Store usage information in the database for each SU type. - + Args: allocation: The allocation to store usage for date: The date string in YYYY-MM-DD format usage_info: UsageInfo pydantic model instance with SU type charges """ + num_types = len(usage_info.root) + if num_types == 0: + logger.info( + f"No usage data to store for allocation {allocation.id} on {date}" + ) + return + for su_type, value in usage_info.root.items(): UsageInfoModel.objects.update_or_create( allocation=allocation, @@ -333,16 +340,15 @@ def store_usage_in_database(allocation: Allocation, date: str, usage_info): ) logger.info( f"Stored usage data for allocation {allocation.id} on {date}: " - f"{len(usage_info.root)} SU types" + f"{num_types} SU types" ) @staticmethod def handle_remove(date: str): """Remove all usage entries for the specified date. - + Args: date: The date string in YYYY-MM-DD format for which to remove entries """ deleted_count, _ = UsageInfoModel.objects.filter(date=date).delete() logger.info(f"Removed {deleted_count} usage entries for date {date}") - print(f"Removed {deleted_count} usage entries for date {date}") diff --git a/src/coldfront_plugin_cloud/models.py b/src/coldfront_plugin_cloud/models.py index 459eccfb..a3613c2f 100644 --- a/src/coldfront_plugin_cloud/models.py +++ b/src/coldfront_plugin_cloud/models.py @@ -8,7 +8,7 @@ class UsageInfo(models.Model): allocation = models.ForeignKey( Allocation, on_delete=models.CASCADE, - related_name='usage_info', + related_name='daily_usage_records', help_text='The allocation this usage belongs to' ) date = models.DateField( From 6762430abd22c484690944143fe928e798fc3a56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:27:41 +0000 Subject: [PATCH 08/23] Rename UsageInfo to AllocationDailyBillableUsage and use TimeStampedModel Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- setup.cfg | 1 + .../commands/fetch_daily_billable_usage.py | 6 +-- .../migrations/0002_rename_usageinfo.py | 44 +++++++++++++++++++ src/coldfront_plugin_cloud/models.py | 8 ++-- .../unit/test_fetch_daily_billable_usage.py | 6 +-- 5 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 src/coldfront_plugin_cloud/migrations/0002_rename_usageinfo.py diff --git a/setup.cfg b/setup.cfg index d88cf8bf..f7160a85 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ install_requires = kubernetes openshift coldfront >= 1.1.0 + django-model-utils python-cinderclient python-keystoneclient python-novaclient diff --git a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py index b2a97d05..a299728b 100644 --- a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py @@ -12,7 +12,7 @@ from coldfront_plugin_cloud import usage_models from coldfront_plugin_cloud.usage_models import validate_date_str from coldfront_plugin_cloud import utils -from coldfront_plugin_cloud.models import UsageInfo as UsageInfoModel +from coldfront_plugin_cloud.models import AllocationDailyBillableUsage import boto3 from django.core.management.base import BaseCommand @@ -332,7 +332,7 @@ def store_usage_in_database(allocation: Allocation, date: str, usage_info): return for su_type, value in usage_info.root.items(): - UsageInfoModel.objects.update_or_create( + AllocationDailyBillableUsage.objects.update_or_create( allocation=allocation, date=date, su_type=su_type, @@ -350,5 +350,5 @@ def handle_remove(date: str): Args: date: The date string in YYYY-MM-DD format for which to remove entries """ - deleted_count, _ = UsageInfoModel.objects.filter(date=date).delete() + deleted_count, _ = AllocationDailyBillableUsage.objects.filter(date=date).delete() logger.info(f"Removed {deleted_count} usage entries for date {date}") diff --git a/src/coldfront_plugin_cloud/migrations/0002_rename_usageinfo.py b/src/coldfront_plugin_cloud/migrations/0002_rename_usageinfo.py new file mode 100644 index 00000000..9fdc1811 --- /dev/null +++ b/src/coldfront_plugin_cloud/migrations/0002_rename_usageinfo.py @@ -0,0 +1,44 @@ +# Generated migration for renaming UsageInfo to AllocationDailyBillableUsage + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('coldfront_plugin_cloud', '0001_initial'), + ] + + operations = [ + # Rename the model + migrations.RenameModel( + old_name='UsageInfo', + new_name='AllocationDailyBillableUsage', + ), + # Rename the table + migrations.AlterModelTable( + name='allocationdailybillableusage', + table='coldfront_plugin_cloud_allocationdailybillableusage', + ), + # Remove the old created_at and updated_at fields + migrations.RemoveField( + model_name='allocationdailybillableusage', + name='created_at', + ), + migrations.RemoveField( + model_name='allocationdailybillableusage', + name='updated_at', + ), + # Add the TimeStampedModel fields (created and modified) + migrations.AddField( + model_name='allocationdailybillableusage', + name='created', + field=models.DateTimeField(auto_now_add=True, default=None), + preserve_default=False, + ), + migrations.AddField( + model_name='allocationdailybillableusage', + name='modified', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/src/coldfront_plugin_cloud/models.py b/src/coldfront_plugin_cloud/models.py index a3613c2f..c5a86147 100644 --- a/src/coldfront_plugin_cloud/models.py +++ b/src/coldfront_plugin_cloud/models.py @@ -1,8 +1,9 @@ from django.db import models +from model_utils.models import TimeStampedModel from coldfront.core.allocation.models import Allocation -class UsageInfo(models.Model): +class AllocationDailyBillableUsage(TimeStampedModel): """Stores daily billable usage for allocations by SU type.""" allocation = models.ForeignKey( @@ -23,11 +24,9 @@ class UsageInfo(models.Model): decimal_places=2, help_text='The usage value/cost for this SU type on this date' ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) class Meta: - db_table = 'coldfront_plugin_cloud_usageinfo' + db_table = 'coldfront_plugin_cloud_allocationdailybillableusage' unique_together = [['allocation', 'date', 'su_type']] indexes = [ models.Index(fields=['allocation', 'date']), @@ -37,3 +36,4 @@ class Meta: def __str__(self): return f"{self.allocation.id} - {self.date} - {self.su_type}: {self.value}" + diff --git a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py index f382ac78..d64f2fdb 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py @@ -249,7 +249,7 @@ def test_send_alert_email(self): ) def test_database_insertion(self, mock_get_allocation_usage): """Test that usage data is stored in the database.""" - from coldfront_plugin_cloud.models import UsageInfo as UsageInfoModel + from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as UsageInfoModel mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo({"OpenStack CPU": "100.00", "OpenStack GPU": "50.00"}), @@ -314,7 +314,7 @@ def test_database_insertion(self, mock_get_allocation_usage): ) def test_remove_parameter(self, mock_get_allocation_usage): """Test that --remove parameter deletes usage entries for a given date.""" - from coldfront_plugin_cloud.models import UsageInfo as UsageInfoModel + from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as UsageInfoModel mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo({"OpenStack CPU": "100.00"}), @@ -372,7 +372,7 @@ def test_remove_parameter(self, mock_get_allocation_usage): ) def test_multiple_allocations_same_date(self, mock_get_allocation_usage): """Test that multiple allocations can store usage for the same date.""" - from coldfront_plugin_cloud.models import UsageInfo as UsageInfoModel + from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as UsageInfoModel fakeprod = self.new_openstack_resource( name="FakeProd", internal_name="FakeProd" From 92b48989e22bf48bf55030182e14d6e1679cae6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:31:45 +0000 Subject: [PATCH 09/23] Update migration to use AllocationDailyBillableUsage with TimeStampedModel from the start Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .../migrations/0001_initial.py | 18 ++++---- .../migrations/0002_rename_usageinfo.py | 44 ------------------- 2 files changed, 10 insertions(+), 52 deletions(-) delete mode 100644 src/coldfront_plugin_cloud/migrations/0002_rename_usageinfo.py diff --git a/src/coldfront_plugin_cloud/migrations/0001_initial.py b/src/coldfront_plugin_cloud/migrations/0001_initial.py index 2e883322..d11160af 100644 --- a/src/coldfront_plugin_cloud/migrations/0001_initial.py +++ b/src/coldfront_plugin_cloud/migrations/0001_initial.py @@ -2,6 +2,8 @@ from django.db import migrations, models import django.db.models.deletion +import django.utils.timezone +import model_utils.fields class Migration(migrations.Migration): @@ -14,31 +16,31 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='UsageInfo', + name='AllocationDailyBillableUsage', 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')), ('date', models.DateField(help_text='The date for which this usage was recorded')), ('su_type', models.CharField(help_text='The type of Service Unit (e.g., OpenStack CPU, OpenStack V100 GPU)', max_length=255)), ('value', models.DecimalField(decimal_places=2, help_text='The usage value/cost for this SU type on this date', max_digits=12)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('allocation', models.ForeignKey(help_text='The allocation this usage belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='usage_info', to='allocation.allocation')), + ('allocation', models.ForeignKey(help_text='The allocation this usage belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='daily_usage_records', to='allocation.allocation')), ], options={ - 'db_table': 'coldfront_plugin_cloud_usageinfo', + 'db_table': 'coldfront_plugin_cloud_allocationdailybillableusage', 'ordering': ['-date', 'allocation', 'su_type'], }, ), migrations.AddIndex( - model_name='usageinfo', + model_name='allocationdailybillableusage', index=models.Index(fields=['allocation', 'date'], name='coldfront_p_allocat_5c8e3d_idx'), ), migrations.AddIndex( - model_name='usageinfo', + model_name='allocationdailybillableusage', index=models.Index(fields=['date'], name='coldfront_p_date_3e8a9e_idx'), ), migrations.AlterUniqueTogether( - name='usageinfo', + name='allocationdailybillableusage', unique_together={('allocation', 'date', 'su_type')}, ), ] diff --git a/src/coldfront_plugin_cloud/migrations/0002_rename_usageinfo.py b/src/coldfront_plugin_cloud/migrations/0002_rename_usageinfo.py deleted file mode 100644 index 9fdc1811..00000000 --- a/src/coldfront_plugin_cloud/migrations/0002_rename_usageinfo.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated migration for renaming UsageInfo to AllocationDailyBillableUsage - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('coldfront_plugin_cloud', '0001_initial'), - ] - - operations = [ - # Rename the model - migrations.RenameModel( - old_name='UsageInfo', - new_name='AllocationDailyBillableUsage', - ), - # Rename the table - migrations.AlterModelTable( - name='allocationdailybillableusage', - table='coldfront_plugin_cloud_allocationdailybillableusage', - ), - # Remove the old created_at and updated_at fields - migrations.RemoveField( - model_name='allocationdailybillableusage', - name='created_at', - ), - migrations.RemoveField( - model_name='allocationdailybillableusage', - name='updated_at', - ), - # Add the TimeStampedModel fields (created and modified) - migrations.AddField( - model_name='allocationdailybillableusage', - name='created', - field=models.DateTimeField(auto_now_add=True, default=None), - preserve_default=False, - ), - migrations.AddField( - model_name='allocationdailybillableusage', - name='modified', - field=models.DateTimeField(auto_now=True), - ), - ] From b162bb118141ede1560f0722551495e3e4289d6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:33:40 +0000 Subject: [PATCH 10/23] Remove confusing UsageInfoModel alias in tests Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .../unit/test_fetch_daily_billable_usage.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py index d64f2fdb..8b42e26c 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py @@ -249,7 +249,7 @@ def test_send_alert_email(self): ) def test_database_insertion(self, mock_get_allocation_usage): """Test that usage data is stored in the database.""" - from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as UsageInfoModel + from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as AllocationDailyBillableUsage mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo({"OpenStack CPU": "100.00", "OpenStack GPU": "50.00"}), @@ -268,12 +268,12 @@ def test_database_insertion(self, mock_get_allocation_usage): ) # Verify no entries before running command - self.assertEqual(UsageInfoModel.objects.count(), 0) + self.assertEqual(AllocationDailyBillableUsage.objects.count(), 0) call_command("fetch_daily_billable_usage", date="2025-11-15") # Verify database entries were created - usage_entries = UsageInfoModel.objects.filter( + usage_entries = AllocationDailyBillableUsage.objects.filter( allocation=allocation_1, date="2025-11-15" ) self.assertEqual(usage_entries.count(), 3) @@ -296,7 +296,7 @@ def test_database_insertion(self, mock_get_allocation_usage): call_command("fetch_daily_billable_usage", date="2025-11-15") # Should still have 3 entries - usage_entries = UsageInfoModel.objects.filter( + usage_entries = AllocationDailyBillableUsage.objects.filter( allocation=allocation_1, date="2025-11-15" ) self.assertEqual(usage_entries.count(), 3) @@ -314,7 +314,7 @@ def test_database_insertion(self, mock_get_allocation_usage): ) def test_remove_parameter(self, mock_get_allocation_usage): """Test that --remove parameter deletes usage entries for a given date.""" - from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as UsageInfoModel + from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as AllocationDailyBillableUsage mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo({"OpenStack CPU": "100.00"}), @@ -344,10 +344,10 @@ def test_remove_parameter(self, mock_get_allocation_usage): # Verify data exists self.assertEqual( - UsageInfoModel.objects.filter(date="2025-11-15").count(), 2 + AllocationDailyBillableUsage.objects.filter(date="2025-11-15").count(), 2 ) self.assertEqual( - UsageInfoModel.objects.filter(date="2025-11-16").count(), 2 + AllocationDailyBillableUsage.objects.filter(date="2025-11-16").count(), 2 ) # Remove data for 2025-11-15 @@ -355,12 +355,12 @@ def test_remove_parameter(self, mock_get_allocation_usage): # Verify data for 2025-11-15 is deleted self.assertEqual( - UsageInfoModel.objects.filter(date="2025-11-15").count(), 0 + AllocationDailyBillableUsage.objects.filter(date="2025-11-15").count(), 0 ) # Verify data for 2025-11-16 still exists self.assertEqual( - UsageInfoModel.objects.filter(date="2025-11-16").count(), 2 + AllocationDailyBillableUsage.objects.filter(date="2025-11-16").count(), 2 ) @patch( @@ -372,7 +372,7 @@ def test_remove_parameter(self, mock_get_allocation_usage): ) def test_multiple_allocations_same_date(self, mock_get_allocation_usage): """Test that multiple allocations can store usage for the same date.""" - from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as UsageInfoModel + from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as AllocationDailyBillableUsage fakeprod = self.new_openstack_resource( name="FakeProd", internal_name="FakeProd" @@ -405,10 +405,10 @@ def test_multiple_allocations_same_date(self, mock_get_allocation_usage): call_command("fetch_daily_billable_usage", date="2025-11-15") # Verify both allocations have data - alloc1_entries = UsageInfoModel.objects.filter( + alloc1_entries = AllocationDailyBillableUsage.objects.filter( allocation=allocation_1, date="2025-11-15" ) - alloc2_entries = UsageInfoModel.objects.filter( + alloc2_entries = AllocationDailyBillableUsage.objects.filter( allocation=allocation_2, date="2025-11-15" ) From c282a7b88b8e1e9276d1a1c0f16259b10a86413d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:34:21 +0000 Subject: [PATCH 11/23] Clean up redundant import alias in tests Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .../tests/unit/test_fetch_daily_billable_usage.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py index 8b42e26c..3f1e5ff2 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py @@ -249,7 +249,7 @@ def test_send_alert_email(self): ) def test_database_insertion(self, mock_get_allocation_usage): """Test that usage data is stored in the database.""" - from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as AllocationDailyBillableUsage + from coldfront_plugin_cloud.models import AllocationDailyBillableUsage mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo({"OpenStack CPU": "100.00", "OpenStack GPU": "50.00"}), @@ -314,7 +314,7 @@ def test_database_insertion(self, mock_get_allocation_usage): ) def test_remove_parameter(self, mock_get_allocation_usage): """Test that --remove parameter deletes usage entries for a given date.""" - from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as AllocationDailyBillableUsage + from coldfront_plugin_cloud.models import AllocationDailyBillableUsage mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo({"OpenStack CPU": "100.00"}), @@ -372,7 +372,7 @@ def test_remove_parameter(self, mock_get_allocation_usage): ) def test_multiple_allocations_same_date(self, mock_get_allocation_usage): """Test that multiple allocations can store usage for the same date.""" - from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as AllocationDailyBillableUsage + from coldfront_plugin_cloud.models import AllocationDailyBillableUsage fakeprod = self.new_openstack_resource( name="FakeProd", internal_name="FakeProd" From befbe1ccccf661ed34ce54ecc55f349daafc10a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:45:08 +0000 Subject: [PATCH 12/23] Address PR feedback: simplify code and use UsageInfo alias Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .../commands/fetch_daily_billable_usage.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py index a299728b..09942bf8 100644 --- a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py @@ -12,7 +12,7 @@ from coldfront_plugin_cloud import usage_models from coldfront_plugin_cloud.usage_models import validate_date_str from coldfront_plugin_cloud import utils -from coldfront_plugin_cloud.models import AllocationDailyBillableUsage +from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as UsageInfo import boto3 from django.core.management.base import BaseCommand @@ -133,7 +133,6 @@ def handle(self, *args, **options): ) continue - # Store usage information in the database self.store_usage_in_database(allocation, date, new_usage) # Only update the latest value if the processed date is newer or same date. @@ -324,24 +323,13 @@ def store_usage_in_database(allocation: Allocation, date: str, usage_info): date: The date string in YYYY-MM-DD format usage_info: UsageInfo pydantic model instance with SU type charges """ - num_types = len(usage_info.root) - if num_types == 0: - logger.info( - f"No usage data to store for allocation {allocation.id} on {date}" - ) - return - for su_type, value in usage_info.root.items(): - AllocationDailyBillableUsage.objects.update_or_create( + UsageInfo.objects.update_or_create( allocation=allocation, date=date, su_type=su_type, defaults={'value': value} ) - logger.info( - f"Stored usage data for allocation {allocation.id} on {date}: " - f"{num_types} SU types" - ) @staticmethod def handle_remove(date: str): @@ -350,5 +338,5 @@ def handle_remove(date: str): Args: date: The date string in YYYY-MM-DD format for which to remove entries """ - deleted_count, _ = AllocationDailyBillableUsage.objects.filter(date=date).delete() + deleted_count, _ = UsageInfo.objects.filter(date=date).delete() logger.info(f"Removed {deleted_count} usage entries for date {date}") From 75a1203be08dbc348196c02055843aa0a81bb644 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:51:08 +0000 Subject: [PATCH 13/23] Keep both UsageInfo classes explicit to avoid confusion Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .../management/commands/fetch_daily_billable_usage.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py index 09942bf8..62aca371 100644 --- a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py @@ -12,7 +12,7 @@ from coldfront_plugin_cloud import usage_models from coldfront_plugin_cloud.usage_models import validate_date_str from coldfront_plugin_cloud import utils -from coldfront_plugin_cloud.models import AllocationDailyBillableUsage as UsageInfo +from coldfront_plugin_cloud.models import AllocationDailyBillableUsage import boto3 from django.core.management.base import BaseCommand @@ -324,7 +324,7 @@ def store_usage_in_database(allocation: Allocation, date: str, usage_info): usage_info: UsageInfo pydantic model instance with SU type charges """ for su_type, value in usage_info.root.items(): - UsageInfo.objects.update_or_create( + AllocationDailyBillableUsage.objects.update_or_create( allocation=allocation, date=date, su_type=su_type, @@ -338,5 +338,5 @@ def handle_remove(date: str): Args: date: The date string in YYYY-MM-DD format for which to remove entries """ - deleted_count, _ = UsageInfo.objects.filter(date=date).delete() + deleted_count, _ = AllocationDailyBillableUsage.objects.filter(date=date).delete() logger.info(f"Removed {deleted_count} usage entries for date {date}") From 6e68b5dff760135d464194012ae483f38af68640 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:57:44 +0000 Subject: [PATCH 14/23] Add explicit UsageInfo import to avoid unnecessary changes Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .../management/commands/fetch_daily_billable_usage.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py index 62aca371..2710d991 100644 --- a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py @@ -10,7 +10,7 @@ from coldfront_plugin_cloud import attributes from coldfront.core.utils.common import import_from_settings from coldfront_plugin_cloud import usage_models -from coldfront_plugin_cloud.usage_models import validate_date_str +from coldfront_plugin_cloud.usage_models import UsageInfo, validate_date_str from coldfront_plugin_cloud import utils from coldfront_plugin_cloud.models import AllocationDailyBillableUsage @@ -225,7 +225,7 @@ def load_service_invoice(self, resource: str, date_str: str) -> DataFrameGroupBy def get_allocation_usage( self, resource: str, date_str: str, allocation_id - ) -> usage_models.UsageInfo: + ) -> UsageInfo: """Loads the service invoice and parse UsageInfo for a specific allocation.""" invoice = self.load_service_invoice(resource, date_str) @@ -235,9 +235,9 @@ def get_allocation_usage( ] except KeyError: logger.debug(f"No usage for allocation {allocation_id}.") - return usage_models.UsageInfo({}) + return UsageInfo({}) - return usage_models.UsageInfo( + return UsageInfo( df.set_index(INVOICE_COLUMN_SU_TYPE)[INVOICE_COLUMN_COST].to_dict() ) From b88e458c7f093deea08ee8bd1476a3893176af76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:20:45 +0000 Subject: [PATCH 15/23] Refactor tests: combine insertion/removal and add update test Co-authored-by: knikolla <4123322+knikolla@users.noreply.github.com> --- .../unit/test_fetch_daily_billable_usage.py | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py index 3f1e5ff2..260573bc 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py @@ -247,8 +247,8 @@ def test_send_alert_email(self): @patch( "coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage.Command.get_allocation_usage" ) - def test_database_insertion(self, mock_get_allocation_usage): - """Test that usage data is stored in the database.""" + def test_database_insertion_and_removal(self, mock_get_allocation_usage): + """Test database insertion, updates, and removal of usage entries.""" from coldfront_plugin_cloud.models import AllocationDailyBillableUsage mock_get_allocation_usage.side_effect = [ @@ -270,6 +270,7 @@ def test_database_insertion(self, mock_get_allocation_usage): # Verify no entries before running command self.assertEqual(AllocationDailyBillableUsage.objects.count(), 0) + # Test initial insertion call_command("fetch_daily_billable_usage", date="2025-11-15") # Verify database entries were created @@ -288,14 +289,14 @@ def test_database_insertion(self, mock_get_allocation_usage): storage_usage = usage_entries.get(su_type="Storage") self.assertEqual(storage_usage.value, Decimal("30.12")) - # Test update_or_create by running again with different values + # Test update_or_create by running again with different values for same date mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo({"OpenStack CPU": "110.00", "OpenStack GPU": "55.00"}), usage_models.UsageInfo({"Storage": "35.00"}), ] call_command("fetch_daily_billable_usage", date="2025-11-15") - # Should still have 3 entries + # Should still have 3 entries (not duplicates) usage_entries = AllocationDailyBillableUsage.objects.filter( allocation=allocation_1, date="2025-11-15" ) @@ -305,52 +306,22 @@ def test_database_insertion(self, mock_get_allocation_usage): cpu_usage = usage_entries.get(su_type="OpenStack CPU") self.assertEqual(cpu_usage.value, Decimal("110.00")) - @patch( - "coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage.RESOURCES_DAILY_ENABLED", - ["FakeProd"], - ) - @patch( - "coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage.Command.get_allocation_usage" - ) - def test_remove_parameter(self, mock_get_allocation_usage): - """Test that --remove parameter deletes usage entries for a given date.""" - from coldfront_plugin_cloud.models import AllocationDailyBillableUsage - - mock_get_allocation_usage.side_effect = [ - usage_models.UsageInfo({"OpenStack CPU": "100.00"}), - usage_models.UsageInfo({"Storage": "30.12"}), - ] - - fakeprod = self.new_openstack_resource( - name="FakeProd", internal_name="FakeProd" - ) - prod_project = self.new_project() - allocation_1 = self.new_allocation( - project=prod_project, resource=fakeprod, quantity=1, status="Active" - ) - utils.set_attribute_on_allocation( - allocation_1, attributes.ALLOCATION_PROJECT_ID, "test-allocation-1" - ) - - # First, add some data - call_command("fetch_daily_billable_usage", date="2025-11-15") - - # Add data for another date + # Add data for another date to test selective removal mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo({"OpenStack CPU": "120.00"}), usage_models.UsageInfo({"Storage": "40.00"}), ] call_command("fetch_daily_billable_usage", date="2025-11-16") - # Verify data exists + # Verify data exists for both dates self.assertEqual( - AllocationDailyBillableUsage.objects.filter(date="2025-11-15").count(), 2 + AllocationDailyBillableUsage.objects.filter(date="2025-11-15").count(), 3 ) self.assertEqual( AllocationDailyBillableUsage.objects.filter(date="2025-11-16").count(), 2 ) - # Remove data for 2025-11-15 + # Test removal - remove data for 2025-11-15 call_command("fetch_daily_billable_usage", date="2025-11-15", remove=True) # Verify data for 2025-11-15 is deleted @@ -422,3 +393,39 @@ def test_multiple_allocations_same_date(self, mock_get_allocation_usage): self.assertEqual( alloc2_entries.get(su_type="OpenStack CPU").value, Decimal("200.00") ) + + # Test updating with same date but different values - should update, not error + mock_get_allocation_usage.side_effect = [ + usage_models.UsageInfo({"OpenStack CPU": "150.00"}), + usage_models.UsageInfo({"Storage": "35.00"}), + usage_models.UsageInfo({"OpenStack CPU": "250.00"}), + usage_models.UsageInfo({"Storage": "55.00"}), + ] + + # This should not raise any errors and should update existing values + call_command("fetch_daily_billable_usage", date="2025-11-15") + + # Verify counts remain the same (no duplicates) + alloc1_entries = AllocationDailyBillableUsage.objects.filter( + allocation=allocation_1, date="2025-11-15" + ) + alloc2_entries = AllocationDailyBillableUsage.objects.filter( + allocation=allocation_2, date="2025-11-15" + ) + + self.assertEqual(alloc1_entries.count(), 2) + self.assertEqual(alloc2_entries.count(), 2) + + # Verify values were updated + self.assertEqual( + alloc1_entries.get(su_type="OpenStack CPU").value, Decimal("150.00") + ) + self.assertEqual( + alloc1_entries.get(su_type="Storage").value, Decimal("35.00") + ) + self.assertEqual( + alloc2_entries.get(su_type="OpenStack CPU").value, Decimal("250.00") + ) + self.assertEqual( + alloc2_entries.get(su_type="Storage").value, Decimal("55.00") + ) From 06449fb37a5da197c4f55a3648ee6d203f081ee3 Mon Sep 17 00:00:00 2001 From: Jimmy Sui Date: Tue, 26 May 2026 17:12:53 -0400 Subject: [PATCH 16/23] Added methods to retrieve daily billable usage information. Added tests to validate ORM layer and fetching layer that interacts with ORM --- .../daily_billable_usage.py | 142 +++++++ .../commands/seed_daily_billable_usage.py | 152 +++++++ .../tests/unit/test_daily_billable_usage.py | 372 ++++++++++++++++++ 3 files changed, 666 insertions(+) create mode 100644 src/coldfront_plugin_cloud/daily_billable_usage.py create mode 100644 src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py create mode 100644 src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py diff --git a/src/coldfront_plugin_cloud/daily_billable_usage.py b/src/coldfront_plugin_cloud/daily_billable_usage.py new file mode 100644 index 00000000..190027ab --- /dev/null +++ b/src/coldfront_plugin_cloud/daily_billable_usage.py @@ -0,0 +1,142 @@ +from collections.abc import Iterable + +from coldfront.core.allocation.models import Allocation +from coldfront_plugin_cloud.models import AllocationDailyBillableUsage +from coldfront_plugin_cloud.usage_models import UsageInfo, validate_date_str + + +def _rows_to_usage_info(rows: Iterable[AllocationDailyBillableUsage]) -> UsageInfo: + """Build a UsageInfo from ORM rows (one row per SU type). + + Args: + rows: AllocationDailyBillableUsage instances for a single allocation + and date (or any set of rows to collapse into one dict). + + Returns: + UsageInfo mapping SU type names to charge values. Empty when rows + is empty. + + Raises: + TypeError: If rows is None. + ValueError: If a row has an empty su_type. + + Example: + >>> rows = [ + ... AllocationDailyBillableUsage(su_type="OpenStack CPU", value=100), + ... AllocationDailyBillableUsage(su_type="Storage", value=30.12), + ... ] + >>> info = _rows_to_usage_info(rows) + >>> info.root["OpenStack CPU"] + Decimal('100') + >>> info.total_charges + Decimal('130.12') + """ + if rows is None: + raise TypeError("rows must not be None") + + usage_info = UsageInfo({}) + for row in rows: + if not isinstance(row, AllocationDailyBillableUsage): + raise TypeError( + f"each row must be AllocationDailyBillableUsage, got {type(row).__name__}" + ) + if not row.su_type: + raise ValueError(f"usage row id={row.pk} has empty su_type") + usage_info.root[row.su_type] = row.value + return usage_info + + +def get_daily_billable_usage(allocation: Allocation, date: str) -> UsageInfo: + """Load billable usage for one allocation on one day. + + Args: + allocation: ColdFront allocation whose daily_usage_records to read. + date: Calendar day in ``YYYY-MM-DD`` format. + + Returns: + UsageInfo for that allocation and date. Empty dict when no rows exist + (no usage recorded yet for that day). + + Raises: + TypeError: If allocation or date has the wrong type. + ValueError: If allocation is unsaved, date is invalid, or empty. + + Example: + >>> from coldfront_plugin_cloud.daily_billable_usage import get_daily_billable_usage + >>> usage = get_daily_billable_usage(allocation, "2025-11-15") + >>> usage.root.get("OpenStack CPU") + Decimal('100.00') + >>> usage.total_charges # sum of all SU types that day + Decimal('180.12') + """ + if not isinstance(allocation, Allocation): + raise TypeError( + f"allocation must be Allocation, got {type(allocation).__name__}" + ) + if allocation.pk is None: + raise ValueError("allocation must be saved (have a primary key)") + if not isinstance(date, str) or not date.strip(): + raise ValueError("date must be a non-empty YYYY-MM-DD string") + date = validate_date_str(date) + + rows = AllocationDailyBillableUsage.objects.filter( + allocation=allocation, date=date + ) + return _rows_to_usage_info(rows) + + +def get_daily_billable_usage_range( + allocation: Allocation, start_date: str, end_date: str +) -> UsageInfo: + """Load billable usage rows for an allocation across an inclusive date range. + + Args: + allocation: ColdFront allocation to read. + start_date: First day (inclusive), ``YYYY-MM-DD``. + end_date: Last day (inclusive), ``YYYY-MM-DD``. + + Returns: + A single UsageInfo built from all matching rows. Each SU type appears + at most once; if the same SU type exists on multiple days, the last + row processed wins (queryset has no explicit ordering). + + Raises: + TypeError: If allocation or either date has the wrong type. + ValueError: If allocation is unsaved, a date is invalid, or + start_date is after end_date. + + Note: + For per-day breakdowns, call get_daily_billable_usage once per date + or add a separate helper that returns a date-keyed structure + (e.g. CumulativeChargesDict). + + Example: + >>> usage = get_daily_billable_usage_range( + ... allocation, "2025-11-01", "2025-11-30" + ... ) + >>> usage.root # merged across the whole range, not per-day + {'OpenStack CPU': Decimal('110.00'), 'Storage': Decimal('35.00')} + """ + if not isinstance(allocation, Allocation): + raise TypeError( + f"allocation must be Allocation, got {type(allocation).__name__}" + ) + if allocation.pk is None: + raise ValueError("allocation must be saved (have a primary key)") + if not isinstance(start_date, str) or not start_date.strip(): + raise ValueError("start_date must be a non-empty YYYY-MM-DD string") + if not isinstance(end_date, str) or not end_date.strip(): + raise ValueError("end_date must be a non-empty YYYY-MM-DD string") + start_date = validate_date_str(start_date) + end_date = validate_date_str(end_date) + if start_date > end_date: + raise ValueError( + f"start_date {start_date} must be on or before end_date {end_date}" + ) + + rows = AllocationDailyBillableUsage.objects.filter( + allocation=allocation, + date__gte=start_date, + date__lte=end_date, + ) + return _rows_to_usage_info(rows) diff --git a/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py new file mode 100644 index 00000000..60357da5 --- /dev/null +++ b/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py @@ -0,0 +1,152 @@ +import calendar +from datetime import date + +from django.core.management.base import BaseCommand, CommandError + +from coldfront.core.allocation.models import Allocation +from coldfront_plugin_cloud import usage_models +from coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage import ( + Command as FetchCommand, +) + +DEFAULT_USAGE = { + "OpenStack CPU": "100.00", + "OpenStack V100 GPU": "50.00", + "Storage": "30.12", +} + + +def dates_in_month(month: str) -> list[str]: + """Return YYYY-MM-DD strings for every day in month (YYYY-MM).""" + usage_models.validate_date_str(f"{month}-01") + year, mon = map(int, month.split("-", 1)) + _, last_day = calendar.monthrange(year, mon) + return [f"{year}-{mon:02d}-{day:02d}" for day in range(1, last_day + 1)] + + +_RAMP_STEP = { + "OpenStack CPU": 5, + "OpenStack V100 GPU": 2, + "Storage": 0.5, +} + + +def usage_for_day(base: dict[str, str], day_index: int, ramp: bool) -> usage_models.UsageInfo: + if not ramp: + return usage_models.UsageInfo(base) + ramped = {} + for su_type, amount in base.items(): + step = _RAMP_STEP.get(su_type, 0) + ramped[su_type] = str(float(amount) + day_index * step) + return usage_models.UsageInfo(ramped) + + +def current_month() -> str: + today = date.today() + return f"{today.year}-{today.month:02d}" + + +class Command(BaseCommand): + help = ( + "Insert test AllocationDailyBillableUsage rows for local development. " + "Run from a ColdFront checkout with NERC local_settings (Option B)." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--allocation-id", + type=int, + required=True, + help="ColdFront allocation primary key", + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--date", + help="Single day to seed (YYYY-MM-DD)", + ) + group.add_argument( + "--month", + help="Seed every day in this month (YYYY-MM)", + ) + group.add_argument( + "--current-month", + action="store_true", + help=( + "Seed every day in the current calendar month with 3 default SU types " + "(OpenStack CPU, OpenStack V100 GPU, Storage)" + ), + ) + parser.add_argument( + "--su", + action="append", + metavar="NAME=AMOUNT", + help="SU type and charge; repeat to override the 3 defaults", + ) + parser.add_argument( + "--ramp", + action="store_true", + help="Increase each SU type slightly per day (chart-friendly)", + ) + parser.add_argument( + "--through-today", + action="store_true", + help="With --current-month, only seed days 1 through today (not future days)", + ) + + def handle(self, *args, **options): + try: + allocation = Allocation.objects.get(pk=options["allocation_id"]) + except Allocation.DoesNotExist as exc: + raise CommandError( + f"allocation id={options['allocation_id']} not found" + ) from exc + + base_usage = self._parse_usage(options["su"]) + if options.get("date"): + dates = [usage_models.validate_date_str(options["date"])] + elif options.get("current_month"): + month = current_month() + dates = dates_in_month(month) + if options["through_today"]: + today = date.today().isoformat() + dates = [d for d in dates if d <= today] + else: + dates = dates_in_month(options["month"]) + + total_rows = 0 + for day_index, day in enumerate(dates): + usage_info = usage_for_day(base_usage, day_index, options["ramp"]) + FetchCommand.store_usage_in_database(allocation, day, usage_info) + total_rows += len(usage_info.root) + + su_count = len(base_usage) + self.stdout.write( + self.style.SUCCESS( + f"Seeded {su_count} SU type(s) × {len(dates)} day(s) " + f"= {total_rows} row(s) for allocation {allocation.id}" + ) + ) + if len(dates) > 1: + self.stdout.write(f" dates: {dates[0]} … {dates[-1]}") + for su_type in base_usage: + self.stdout.write(f" {su_type}") + if len(dates) == 1: + for su_type, value in usage_for_day( + base_usage, 0, options["ramp"] + ).root.items(): + self.stdout.write(f" {dates[0]} {su_type}: {value}") + + @staticmethod + def _parse_usage(su_args: list[str] | None) -> dict[str, str]: + if not su_args: + return dict(DEFAULT_USAGE) + usage = {} + for item in su_args: + if "=" not in item: + raise CommandError( + f"expected NAME=AMOUNT, got {item!r} " + "(e.g. 'OpenStack CPU=100.00')" + ) + name, amount = item.split("=", 1) + usage[name.strip()] = amount.strip() + return usage diff --git a/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py new file mode 100644 index 00000000..7e07b0d1 --- /dev/null +++ b/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py @@ -0,0 +1,372 @@ +import time +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.core.management import call_command +from django.db import IntegrityError + +from coldfront.core.allocation.models import Allocation +from coldfront_plugin_cloud.daily_billable_usage import ( + _rows_to_usage_info, + get_daily_billable_usage, + get_daily_billable_usage_range, +) +from coldfront_plugin_cloud.models import AllocationDailyBillableUsage +from coldfront_plugin_cloud.tests import base + + +class TestRowsToUsageInfo(base.TestBase): + def _new_allocation(self): + resource = self.new_openstack_resource() + project = self.new_project() + return self.new_allocation(project=project, resource=resource, quantity=1) + + def test_empty_iterable(self): + # Verify the helper returns a valid empty UsageInfo for "no rows" cases. + usage = _rows_to_usage_info([]) + self.assertEqual(usage.root, {}) + self.assertEqual(usage.total_charges, Decimal("0")) + + def test_multiple_rows(self): + # Verify multiple ORM rows collapse into a single SU->Decimal mapping with a correct total. + rows = [ + AllocationDailyBillableUsage(su_type="OpenStack CPU", value=Decimal("100")), + AllocationDailyBillableUsage(su_type="Storage", value=Decimal("30.12")), + ] + usage = _rows_to_usage_info(rows) + self.assertEqual(usage.root["OpenStack CPU"], Decimal("100")) + self.assertEqual(usage.root["Storage"], Decimal("30.12")) + self.assertEqual(usage.total_charges, Decimal("130.12")) + + def test_rows_is_none(self): + # Defensive programming: None is a bug at the call site and should fail loudly. + with self.assertRaises(TypeError): + _rows_to_usage_info(None) + + def test_non_model_row(self): + # Ensure we don't silently accept unexpected row objects (helps catch query/fixture mistakes). + with self.assertRaises(TypeError) as ctx: + _rows_to_usage_info(["not-a-row"]) + self.assertIn("AllocationDailyBillableUsage", str(ctx.exception)) + + def test_empty_su_type(self): + # Enforce that every row has a usable key; empty SU type would corrupt the usage dict. + allocation = self._new_allocation() + row = AllocationDailyBillableUsage.objects.create( + allocation=allocation, + date="2025-11-15", + su_type="", + value=Decimal("1.00"), + ) + with self.assertRaises(ValueError) as ctx: + _rows_to_usage_info([row]) + self.assertIn(f"id={row.pk}", str(ctx.exception)) + + +class TestGetDailyBillableUsage(base.TestBase): + def _new_allocation(self): + resource = self.new_openstack_resource() + project = self.new_project() + return self.new_allocation(project=project, resource=resource, quantity=1) + + def _create_usage_row(self, allocation, date, su_type, value): + return AllocationDailyBillableUsage.objects.create( + allocation=allocation, + date=date, + su_type=su_type, + value=Decimal(value), + ) + + def test_happy_path_via_seed_command(self): + # Integration sanity check: seed rows via command, then read them back through the API. + allocation = self._new_allocation() + call_command( + "seed_daily_billable_usage", + allocation_id=allocation.id, + date="2025-11-15", + ) + + usage = get_daily_billable_usage(allocation, "2025-11-15") + + self.assertEqual(usage.root["OpenStack CPU"], Decimal("100.00")) + self.assertEqual(usage.root["OpenStack V100 GPU"], Decimal("50.00")) + self.assertEqual(usage.root["Storage"], Decimal("30.12")) + self.assertEqual(usage.total_charges, Decimal("180.12")) + + def test_no_rows_returns_empty_usage_info(self): + # A missing day should return an empty UsageInfo rather than raising or returning None. + allocation = self._new_allocation() + usage = get_daily_billable_usage(allocation, "2025-11-15") + self.assertEqual(usage.root, {}) + self.assertEqual(usage.total_charges, Decimal("0")) + + def test_wrong_allocation_type(self): + # Guardrails: callers passing the wrong object type get a clear, early error. + with self.assertRaises(TypeError) as ctx: + get_daily_billable_usage("not-an-allocation", "2025-11-15") + self.assertIn("Allocation", str(ctx.exception)) + + def test_unsaved_allocation(self): + # Unsaved allocations can't be queried reliably, so we reject them explicitly. + allocation = Allocation() + with self.assertRaises(ValueError) as ctx: + get_daily_billable_usage(allocation, "2025-11-15") + self.assertIn("primary key", str(ctx.exception)) + + def test_empty_date(self): + # Empty date strings are ambiguous and should be rejected before hitting the ORM. + allocation = self._new_allocation() + with self.assertRaises(ValueError): + get_daily_billable_usage(allocation, "") + + def test_whitespace_date(self): + # Whitespace-only dates are treated as empty input and rejected. + allocation = self._new_allocation() + with self.assertRaises(ValueError): + get_daily_billable_usage(allocation, " ") + + def test_invalid_date(self): + # Invalid calendar dates should fail validation rather than executing a query. + allocation = self._new_allocation() + with self.assertRaises(ValueError): + get_daily_billable_usage(allocation, "2025-13-01") + + def test_excludes_other_allocation_and_date(self): + # Verify the query is correctly scoped by allocation AND date (no accidental cross-talk). + allocation = self._new_allocation() + other_allocation = self._new_allocation() + self._create_usage_row(allocation, "2025-11-15", "OpenStack CPU", "100.00") + self._create_usage_row(allocation, "2025-11-16", "OpenStack CPU", "200.00") + self._create_usage_row(other_allocation, "2025-11-15", "OpenStack CPU", "999.00") + + usage = get_daily_billable_usage(allocation, "2025-11-15") + + self.assertEqual(usage.root, {"OpenStack CPU": Decimal("100.00")}) + self.assertEqual(usage.total_charges, Decimal("100.00")) + + +class TestGetDailyBillableUsageRange(base.TestBase): + def _new_allocation(self): + resource = self.new_openstack_resource() + project = self.new_project() + return self.new_allocation(project=project, resource=resource, quantity=1) + + def _create_usage_row(self, allocation, date, su_type, value): + return AllocationDailyBillableUsage.objects.create( + allocation=allocation, + date=date, + su_type=su_type, + value=Decimal(value), + ) + + def test_inclusive_range(self): + # Range queries should include both endpoints and exclude rows outside the window. + allocation = self._new_allocation() + self._create_usage_row(allocation, "2025-11-01", "OpenStack CPU", "10.00") + self._create_usage_row(allocation, "2025-11-15", "Storage", "20.00") + self._create_usage_row(allocation, "2025-11-30", "OpenStack V100 GPU", "30.00") + self._create_usage_row(allocation, "2025-12-01", "OpenStack CPU", "99.00") + + usage = get_daily_billable_usage_range( + allocation, "2025-11-01", "2025-11-30" + ) + + self.assertEqual(usage.root["OpenStack CPU"], Decimal("10.00")) + self.assertEqual(usage.root["Storage"], Decimal("20.00")) + self.assertEqual(usage.root["OpenStack V100 GPU"], Decimal("30.00")) + self.assertEqual(usage.total_charges, Decimal("60.00")) + + def test_empty_when_no_matching_rows(self): + # A valid range with no data should return an empty UsageInfo, not an error. + allocation = self._new_allocation() + usage = get_daily_billable_usage_range( + allocation, "2025-11-01", "2025-11-30" + ) + self.assertEqual(usage.root, {}) + self.assertEqual(usage.total_charges, Decimal("0")) + + def test_start_date_after_end_date(self): + # Detect inverted ranges early to avoid confusing empty results. + allocation = self._new_allocation() + with self.assertRaises(ValueError) as ctx: + get_daily_billable_usage_range( + allocation, "2025-11-30", "2025-11-01" + ) + self.assertIn("start_date", str(ctx.exception)) + self.assertIn("end_date", str(ctx.exception)) + + def test_duplicate_su_type_last_row_wins(self): + # Document current merge semantics: duplicate SU types collapse to one value based on iteration order. + allocation = self._new_allocation() + self._create_usage_row(allocation, "2025-11-01", "OpenStack CPU", "100.00") + self._create_usage_row(allocation, "2025-11-15", "OpenStack CPU", "200.00") + + usage = get_daily_billable_usage_range( + allocation, "2025-11-01", "2025-11-15" + ) + + self.assertEqual(usage.root["OpenStack CPU"], Decimal("100.00")) + + def test_unsaved_allocation(self): + # Same guardrail as single-day reads: unsaved allocations can't be queried. + allocation = Allocation() + with self.assertRaises(ValueError): + get_daily_billable_usage_range( + allocation, "2025-11-01", "2025-11-30" + ) + + def test_invalid_start_date(self): + # Validate both endpoints before querying so bad input fails consistently. + allocation = self._new_allocation() + with self.assertRaises(ValueError): + get_daily_billable_usage_range( + allocation, "2025-13-01", "2025-11-30" + ) + + def test_invalid_end_date(self): + # Validate both endpoints before querying so bad input fails consistently. + allocation = self._new_allocation() + with self.assertRaises(ValueError): + get_daily_billable_usage_range( + allocation, "2025-11-01", "not-a-date" + ) + + +class TestAllocationDailyBillableUsageModel(base.TestBase): + def _new_allocation(self): + resource = self.new_openstack_resource() + project = self.new_project() + return self.new_allocation(project=project, resource=resource, quantity=1) + + def _create_usage_row(self, allocation, date, su_type, value): + return AllocationDailyBillableUsage.objects.create( + allocation=allocation, + date=date, + su_type=su_type, + value=Decimal(value), + ) + + def test_str(self): + # A stable __str__ helps debugging/logging/admin views when inspecting usage rows. + allocation = self._new_allocation() + row = self._create_usage_row( + allocation, "2025-11-15", "OpenStack CPU", "100.00" + ) + self.assertEqual( + str(row), + f"{allocation.id} - 2025-11-15 - OpenStack CPU: 100.00", + ) + + def test_unique_together_raises_on_duplicate(self): + # Enforce one row per (allocation, date, su_type) so upserts don't create duplicates. + allocation = self._new_allocation() + self._create_usage_row( + allocation, "2025-11-15", "OpenStack CPU", "100.00" + ) + with self.assertRaises(IntegrityError): + AllocationDailyBillableUsage.objects.create( + allocation=allocation, + date="2025-11-15", + su_type="OpenStack CPU", + value=Decimal("200.00"), + ) + + def test_allocation_delete_cascades_to_usage_rows(self): + # Foreign key cascade prevents orphaned usage rows when an allocation is deleted. + allocation = self._new_allocation() + self._create_usage_row( + allocation, "2025-11-15", "OpenStack CPU", "100.00" + ) + self._create_usage_row(allocation, "2025-11-16", "Storage", "30.00") + self.assertEqual(AllocationDailyBillableUsage.objects.count(), 2) + + allocation.delete() + + self.assertEqual(AllocationDailyBillableUsage.objects.count(), 0) + + def test_meta_ordering(self): + # Ordering affects deterministic reads/merges; this locks the contract to the model definition. + self.assertEqual( + AllocationDailyBillableUsage._meta.ordering, + ["-date", "allocation", "su_type"], + ) + allocation_a = self._new_allocation() + allocation_b = self._new_allocation() + self._create_usage_row(allocation_a, "2025-11-01", "Storage", "10.00") + self._create_usage_row( + allocation_a, "2025-11-15", "OpenStack CPU", "20.00" + ) + self._create_usage_row( + allocation_b, "2025-11-15", "OpenStack CPU", "30.00" + ) + + rows = list(AllocationDailyBillableUsage.objects.all()) + self.assertEqual(len(rows), 3) + self.assertEqual(rows[0].date.isoformat(), "2025-11-15") + self.assertEqual(rows[1].date.isoformat(), "2025-11-15") + self.assertEqual(rows[2].date.isoformat(), "2025-11-01") + # Same date: ordering uses Allocation model default ordering via FK. + nov_15_allocation_ids = {rows[0].allocation_id, rows[1].allocation_id} + self.assertEqual(nov_15_allocation_ids, {allocation_a.id, allocation_b.id}) + + def test_value_stores_two_decimal_places(self): + # Value is money-like and must round-trip without float precision loss. + allocation = self._new_allocation() + row = self._create_usage_row( + allocation, "2025-11-15", "Storage", "30.12" + ) + row.refresh_from_db() + self.assertEqual(row.value, Decimal("30.12")) + + def test_value_rejects_overflow_beyond_max_digits(self): + # Schema protection: reject invoice values that exceed the declared DecimalField size. + allocation = self._new_allocation() + row = AllocationDailyBillableUsage( + allocation=allocation, + date="2025-11-15", + su_type="OpenStack CPU", + value=Decimal("10000000000.00"), + ) + with self.assertRaises(ValidationError): + row.full_clean() + + def test_timestamps_set_on_create_and_modified_on_update(self): + # TimeStampedModel should set created/modified and advance modified on updates. + allocation = self._new_allocation() + row = self._create_usage_row( + allocation, "2025-11-15", "OpenStack CPU", "100.00" + ) + created_at = row.created + modified_at = row.modified + self.assertIsNotNone(created_at) + self.assertIsNotNone(modified_at) + + time.sleep(0.02) + row.value = Decimal("150.00") + row.save() + row.refresh_from_db() + + self.assertEqual(row.created, created_at) + self.assertGreater(row.modified, modified_at) + + def test_related_name_daily_usage_records(self): + # Reverse relation is the primary way callers will traverse allocation -> daily usage rows. + allocation = self._new_allocation() + other_allocation = self._new_allocation() + self._create_usage_row( + allocation, "2025-11-15", "OpenStack CPU", "100.00" + ) + self._create_usage_row(allocation, "2025-11-16", "Storage", "30.00") + self._create_usage_row( + other_allocation, "2025-11-15", "OpenStack CPU", "999.00" + ) + + self.assertEqual(allocation.daily_usage_records.count(), 2) + self.assertEqual( + allocation.daily_usage_records.filter(date="2025-11-15").count(), + 1, + ) + self.assertEqual( + allocation.daily_usage_records.get(date="2025-11-15").value, + Decimal("100.00"), + ) From 01edadc0f95a6884e88587bb12fb453e547c8224 Mon Sep 17 00:00:00 2001 From: Jimmy Sui Date: Wed, 27 May 2026 12:28:34 -0400 Subject: [PATCH 17/23] Set PYTHONPATH to 'src' in CI scripts for functional and unit tests --- ci/run_functional_tests_openshift.sh | 1 + ci/run_functional_tests_openstack.sh | 1 + ci/run_unit_tests.sh | 1 + ci/setup.sh | 2 ++ 4 files changed, 5 insertions(+) diff --git a/ci/run_functional_tests_openshift.sh b/ci/run_functional_tests_openshift.sh index 2aa9125d..876b60db 100755 --- a/ci/run_functional_tests_openshift.sh +++ b/ci/run_functional_tests_openshift.sh @@ -13,6 +13,7 @@ if [[ ! "${CI}" == "true" ]]; then fi export DJANGO_SETTINGS_MODULE="local_settings" +export PYTHONPATH=src export FUNCTIONAL_TESTS="True" export OS_API_URL="https://onboarding-onboarding.cluster.local:6443" export PYTHONWARNINGS="ignore:Unverified HTTPS request" diff --git a/ci/run_functional_tests_openstack.sh b/ci/run_functional_tests_openstack.sh index b6aa1c67..5b76f29c 100755 --- a/ci/run_functional_tests_openstack.sh +++ b/ci/run_functional_tests_openstack.sh @@ -17,6 +17,7 @@ export OPENSTACK_ESI_APPLICATION_CREDENTIAL_ID=$OPENSTACK_DEVSTACK_APPLICATION_C export OPENSTACK_PUBLIC_NETWORK_ID=$(microstack.openstack network show external -f value -c id) export DJANGO_SETTINGS_MODULE="local_settings" +export PYTHONPATH=src export FUNCTIONAL_TESTS="True" export OS_AUTH_URL="https://localhost:5000" export PYTHONWARNINGS="ignore:Unverified HTTPS request" diff --git a/ci/run_unit_tests.sh b/ci/run_unit_tests.sh index 26de492f..56cbd11d 100755 --- a/ci/run_unit_tests.sh +++ b/ci/run_unit_tests.sh @@ -7,6 +7,7 @@ if [[ ! "${CI}" == "true" ]]; then fi export DJANGO_SETTINGS_MODULE="local_settings" +export PYTHONPATH=src coverage run --source="." -m django test coldfront_plugin_cloud.tests.unit coverage report diff --git a/ci/setup.sh b/ci/setup.sh index 76d242a5..e8cf3667 100755 --- a/ci/setup.sh +++ b/ci/setup.sh @@ -2,6 +2,8 @@ set -xe +export PYTHONPATH=src + # If running on Github actions, don't create a virtualenv if [[ ! "${CI}" == "true" ]]; then virtualenv -p python3 /tmp/coldfront_venv From 3a6eee1d5ab9597bf32b6d671b09367f660f29de Mon Sep 17 00:00:00 2001 From: Jimmy Sui Date: Wed, 27 May 2026 12:33:04 -0400 Subject: [PATCH 18/23] Refactor code for consistency and readability by simplifying filter queries and standardizing string formatting in models and tests. --- .../daily_billable_usage.py | 4 +- .../commands/fetch_daily_billable_usage.py | 6 +- .../commands/seed_daily_billable_usage.py | 7 +- .../migrations/0001_initial.py | 85 +++++++++++++++---- src/coldfront_plugin_cloud/models.py | 23 +++-- .../tests/unit/test_daily_billable_usage.py | 56 ++++-------- .../unit/test_fetch_daily_billable_usage.py | 16 ++-- 7 files changed, 110 insertions(+), 87 deletions(-) diff --git a/src/coldfront_plugin_cloud/daily_billable_usage.py b/src/coldfront_plugin_cloud/daily_billable_usage.py index 190027ab..a9ff7b70 100644 --- a/src/coldfront_plugin_cloud/daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/daily_billable_usage.py @@ -79,9 +79,7 @@ def get_daily_billable_usage(allocation: Allocation, date: str) -> UsageInfo: raise ValueError("date must be a non-empty YYYY-MM-DD string") date = validate_date_str(date) - rows = AllocationDailyBillableUsage.objects.filter( - allocation=allocation, date=date - ) + rows = AllocationDailyBillableUsage.objects.filter(allocation=allocation, date=date) return _rows_to_usage_info(rows) diff --git a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py index 2710d991..a6685145 100644 --- a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py @@ -328,7 +328,7 @@ def store_usage_in_database(allocation: Allocation, date: str, usage_info): allocation=allocation, date=date, su_type=su_type, - defaults={'value': value} + defaults={"value": value}, ) @staticmethod @@ -338,5 +338,7 @@ def handle_remove(date: str): Args: date: The date string in YYYY-MM-DD format for which to remove entries """ - deleted_count, _ = AllocationDailyBillableUsage.objects.filter(date=date).delete() + deleted_count, _ = AllocationDailyBillableUsage.objects.filter( + date=date + ).delete() logger.info(f"Removed {deleted_count} usage entries for date {date}") diff --git a/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py index 60357da5..f59bffd5 100644 --- a/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py @@ -31,7 +31,9 @@ def dates_in_month(month: str) -> list[str]: } -def usage_for_day(base: dict[str, str], day_index: int, ramp: bool) -> usage_models.UsageInfo: +def usage_for_day( + base: dict[str, str], day_index: int, ramp: bool +) -> usage_models.UsageInfo: if not ramp: return usage_models.UsageInfo(base) ramped = {} @@ -144,8 +146,7 @@ def _parse_usage(su_args: list[str] | None) -> dict[str, str]: for item in su_args: if "=" not in item: raise CommandError( - f"expected NAME=AMOUNT, got {item!r} " - "(e.g. 'OpenStack CPU=100.00')" + f"expected NAME=AMOUNT, got {item!r} (e.g. 'OpenStack CPU=100.00')" ) name, amount = item.split("=", 1) usage[name.strip()] = amount.strip() diff --git a/src/coldfront_plugin_cloud/migrations/0001_initial.py b/src/coldfront_plugin_cloud/migrations/0001_initial.py index d11160af..abdd5d21 100644 --- a/src/coldfront_plugin_cloud/migrations/0001_initial.py +++ b/src/coldfront_plugin_cloud/migrations/0001_initial.py @@ -7,40 +7,89 @@ class Migration(migrations.Migration): - initial = True dependencies = [ - ('allocation', '__first__'), + ("allocation", "__first__"), ] operations = [ migrations.CreateModel( - name='AllocationDailyBillableUsage', + name="AllocationDailyBillableUsage", 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')), - ('date', models.DateField(help_text='The date for which this usage was recorded')), - ('su_type', models.CharField(help_text='The type of Service Unit (e.g., OpenStack CPU, OpenStack V100 GPU)', max_length=255)), - ('value', models.DecimalField(decimal_places=2, help_text='The usage value/cost for this SU type on this date', max_digits=12)), - ('allocation', models.ForeignKey(help_text='The allocation this usage belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='daily_usage_records', to='allocation.allocation')), + ( + "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", + ), + ), + ( + "date", + models.DateField( + help_text="The date for which this usage was recorded" + ), + ), + ( + "su_type", + models.CharField( + help_text="The type of Service Unit (e.g., OpenStack CPU, OpenStack V100 GPU)", + max_length=255, + ), + ), + ( + "value", + models.DecimalField( + decimal_places=2, + help_text="The usage value/cost for this SU type on this date", + max_digits=12, + ), + ), + ( + "allocation", + models.ForeignKey( + help_text="The allocation this usage belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="daily_usage_records", + to="allocation.allocation", + ), + ), ], options={ - 'db_table': 'coldfront_plugin_cloud_allocationdailybillableusage', - 'ordering': ['-date', 'allocation', 'su_type'], + "db_table": "coldfront_plugin_cloud_allocationdailybillableusage", + "ordering": ["-date", "allocation", "su_type"], }, ), migrations.AddIndex( - model_name='allocationdailybillableusage', - index=models.Index(fields=['allocation', 'date'], name='coldfront_p_allocat_5c8e3d_idx'), + model_name="allocationdailybillableusage", + index=models.Index( + fields=["allocation", "date"], name="coldfront_p_allocat_5c8e3d_idx" + ), ), migrations.AddIndex( - model_name='allocationdailybillableusage', - index=models.Index(fields=['date'], name='coldfront_p_date_3e8a9e_idx'), + model_name="allocationdailybillableusage", + index=models.Index(fields=["date"], name="coldfront_p_date_3e8a9e_idx"), ), migrations.AlterUniqueTogether( - name='allocationdailybillableusage', - unique_together={('allocation', 'date', 'su_type')}, + name="allocationdailybillableusage", + unique_together={("allocation", "date", "su_type")}, ), ] diff --git a/src/coldfront_plugin_cloud/models.py b/src/coldfront_plugin_cloud/models.py index c5a86147..b71c7b47 100644 --- a/src/coldfront_plugin_cloud/models.py +++ b/src/coldfront_plugin_cloud/models.py @@ -9,31 +9,28 @@ class AllocationDailyBillableUsage(TimeStampedModel): allocation = models.ForeignKey( Allocation, on_delete=models.CASCADE, - related_name='daily_usage_records', - help_text='The allocation this usage belongs to' - ) - date = models.DateField( - help_text='The date for which this usage was recorded' + related_name="daily_usage_records", + help_text="The allocation this usage belongs to", ) + date = models.DateField(help_text="The date for which this usage was recorded") su_type = models.CharField( max_length=255, - help_text='The type of Service Unit (e.g., OpenStack CPU, OpenStack V100 GPU)' + help_text="The type of Service Unit (e.g., OpenStack CPU, OpenStack V100 GPU)", ) value = models.DecimalField( max_digits=12, decimal_places=2, - help_text='The usage value/cost for this SU type on this date' + help_text="The usage value/cost for this SU type on this date", ) class Meta: - db_table = 'coldfront_plugin_cloud_allocationdailybillableusage' - unique_together = [['allocation', 'date', 'su_type']] + db_table = "coldfront_plugin_cloud_allocationdailybillableusage" + unique_together = [["allocation", "date", "su_type"]] indexes = [ - models.Index(fields=['allocation', 'date']), - models.Index(fields=['date']), + models.Index(fields=["allocation", "date"]), + models.Index(fields=["date"]), ] - ordering = ['-date', 'allocation', 'su_type'] + ordering = ["-date", "allocation", "su_type"] def __str__(self): return f"{self.allocation.id} - {self.date} - {self.su_type}: {self.value}" - diff --git a/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py index 7e07b0d1..4161c128 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py @@ -137,7 +137,9 @@ def test_excludes_other_allocation_and_date(self): other_allocation = self._new_allocation() self._create_usage_row(allocation, "2025-11-15", "OpenStack CPU", "100.00") self._create_usage_row(allocation, "2025-11-16", "OpenStack CPU", "200.00") - self._create_usage_row(other_allocation, "2025-11-15", "OpenStack CPU", "999.00") + self._create_usage_row( + other_allocation, "2025-11-15", "OpenStack CPU", "999.00" + ) usage = get_daily_billable_usage(allocation, "2025-11-15") @@ -167,9 +169,7 @@ def test_inclusive_range(self): self._create_usage_row(allocation, "2025-11-30", "OpenStack V100 GPU", "30.00") self._create_usage_row(allocation, "2025-12-01", "OpenStack CPU", "99.00") - usage = get_daily_billable_usage_range( - allocation, "2025-11-01", "2025-11-30" - ) + usage = get_daily_billable_usage_range(allocation, "2025-11-01", "2025-11-30") self.assertEqual(usage.root["OpenStack CPU"], Decimal("10.00")) self.assertEqual(usage.root["Storage"], Decimal("20.00")) @@ -179,9 +179,7 @@ def test_inclusive_range(self): def test_empty_when_no_matching_rows(self): # A valid range with no data should return an empty UsageInfo, not an error. allocation = self._new_allocation() - usage = get_daily_billable_usage_range( - allocation, "2025-11-01", "2025-11-30" - ) + usage = get_daily_billable_usage_range(allocation, "2025-11-01", "2025-11-30") self.assertEqual(usage.root, {}) self.assertEqual(usage.total_charges, Decimal("0")) @@ -189,9 +187,7 @@ def test_start_date_after_end_date(self): # Detect inverted ranges early to avoid confusing empty results. allocation = self._new_allocation() with self.assertRaises(ValueError) as ctx: - get_daily_billable_usage_range( - allocation, "2025-11-30", "2025-11-01" - ) + get_daily_billable_usage_range(allocation, "2025-11-30", "2025-11-01") self.assertIn("start_date", str(ctx.exception)) self.assertIn("end_date", str(ctx.exception)) @@ -201,9 +197,7 @@ def test_duplicate_su_type_last_row_wins(self): self._create_usage_row(allocation, "2025-11-01", "OpenStack CPU", "100.00") self._create_usage_row(allocation, "2025-11-15", "OpenStack CPU", "200.00") - usage = get_daily_billable_usage_range( - allocation, "2025-11-01", "2025-11-15" - ) + usage = get_daily_billable_usage_range(allocation, "2025-11-01", "2025-11-15") self.assertEqual(usage.root["OpenStack CPU"], Decimal("100.00")) @@ -211,25 +205,19 @@ def test_unsaved_allocation(self): # Same guardrail as single-day reads: unsaved allocations can't be queried. allocation = Allocation() with self.assertRaises(ValueError): - get_daily_billable_usage_range( - allocation, "2025-11-01", "2025-11-30" - ) + get_daily_billable_usage_range(allocation, "2025-11-01", "2025-11-30") def test_invalid_start_date(self): # Validate both endpoints before querying so bad input fails consistently. allocation = self._new_allocation() with self.assertRaises(ValueError): - get_daily_billable_usage_range( - allocation, "2025-13-01", "2025-11-30" - ) + get_daily_billable_usage_range(allocation, "2025-13-01", "2025-11-30") def test_invalid_end_date(self): # Validate both endpoints before querying so bad input fails consistently. allocation = self._new_allocation() with self.assertRaises(ValueError): - get_daily_billable_usage_range( - allocation, "2025-11-01", "not-a-date" - ) + get_daily_billable_usage_range(allocation, "2025-11-01", "not-a-date") class TestAllocationDailyBillableUsageModel(base.TestBase): @@ -260,9 +248,7 @@ def test_str(self): def test_unique_together_raises_on_duplicate(self): # Enforce one row per (allocation, date, su_type) so upserts don't create duplicates. allocation = self._new_allocation() - self._create_usage_row( - allocation, "2025-11-15", "OpenStack CPU", "100.00" - ) + self._create_usage_row(allocation, "2025-11-15", "OpenStack CPU", "100.00") with self.assertRaises(IntegrityError): AllocationDailyBillableUsage.objects.create( allocation=allocation, @@ -274,9 +260,7 @@ def test_unique_together_raises_on_duplicate(self): def test_allocation_delete_cascades_to_usage_rows(self): # Foreign key cascade prevents orphaned usage rows when an allocation is deleted. allocation = self._new_allocation() - self._create_usage_row( - allocation, "2025-11-15", "OpenStack CPU", "100.00" - ) + self._create_usage_row(allocation, "2025-11-15", "OpenStack CPU", "100.00") self._create_usage_row(allocation, "2025-11-16", "Storage", "30.00") self.assertEqual(AllocationDailyBillableUsage.objects.count(), 2) @@ -293,12 +277,8 @@ def test_meta_ordering(self): allocation_a = self._new_allocation() allocation_b = self._new_allocation() self._create_usage_row(allocation_a, "2025-11-01", "Storage", "10.00") - self._create_usage_row( - allocation_a, "2025-11-15", "OpenStack CPU", "20.00" - ) - self._create_usage_row( - allocation_b, "2025-11-15", "OpenStack CPU", "30.00" - ) + self._create_usage_row(allocation_a, "2025-11-15", "OpenStack CPU", "20.00") + self._create_usage_row(allocation_b, "2025-11-15", "OpenStack CPU", "30.00") rows = list(AllocationDailyBillableUsage.objects.all()) self.assertEqual(len(rows), 3) @@ -312,9 +292,7 @@ def test_meta_ordering(self): def test_value_stores_two_decimal_places(self): # Value is money-like and must round-trip without float precision loss. allocation = self._new_allocation() - row = self._create_usage_row( - allocation, "2025-11-15", "Storage", "30.12" - ) + row = self._create_usage_row(allocation, "2025-11-15", "Storage", "30.12") row.refresh_from_db() self.assertEqual(row.value, Decimal("30.12")) @@ -353,9 +331,7 @@ def test_related_name_daily_usage_records(self): # Reverse relation is the primary way callers will traverse allocation -> daily usage rows. allocation = self._new_allocation() other_allocation = self._new_allocation() - self._create_usage_row( - allocation, "2025-11-15", "OpenStack CPU", "100.00" - ) + self._create_usage_row(allocation, "2025-11-15", "OpenStack CPU", "100.00") self._create_usage_row(allocation, "2025-11-16", "Storage", "30.00") self._create_usage_row( other_allocation, "2025-11-15", "OpenStack CPU", "999.00" diff --git a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py index 260573bc..7dc6c57b 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py @@ -252,7 +252,9 @@ def test_database_insertion_and_removal(self, mock_get_allocation_usage): from coldfront_plugin_cloud.models import AllocationDailyBillableUsage mock_get_allocation_usage.side_effect = [ - usage_models.UsageInfo({"OpenStack CPU": "100.00", "OpenStack GPU": "50.00"}), + usage_models.UsageInfo( + {"OpenStack CPU": "100.00", "OpenStack GPU": "50.00"} + ), usage_models.UsageInfo({"Storage": "30.12"}), ] @@ -291,7 +293,9 @@ def test_database_insertion_and_removal(self, mock_get_allocation_usage): # Test update_or_create by running again with different values for same date mock_get_allocation_usage.side_effect = [ - usage_models.UsageInfo({"OpenStack CPU": "110.00", "OpenStack GPU": "55.00"}), + usage_models.UsageInfo( + {"OpenStack CPU": "110.00", "OpenStack GPU": "55.00"} + ), usage_models.UsageInfo({"Storage": "35.00"}), ] call_command("fetch_daily_billable_usage", date="2025-11-15") @@ -420,12 +424,8 @@ def test_multiple_allocations_same_date(self, mock_get_allocation_usage): self.assertEqual( alloc1_entries.get(su_type="OpenStack CPU").value, Decimal("150.00") ) - self.assertEqual( - alloc1_entries.get(su_type="Storage").value, Decimal("35.00") - ) + self.assertEqual(alloc1_entries.get(su_type="Storage").value, Decimal("35.00")) self.assertEqual( alloc2_entries.get(su_type="OpenStack CPU").value, Decimal("250.00") ) - self.assertEqual( - alloc2_entries.get(su_type="Storage").value, Decimal("55.00") - ) + self.assertEqual(alloc2_entries.get(su_type="Storage").value, Decimal("55.00")) From 6bb794edd8279b1a0a5304b2c831f681a545b15b Mon Sep 17 00:00:00 2001 From: Jimmy Sui Date: Wed, 27 May 2026 13:59:11 -0400 Subject: [PATCH 19/23] merged into upstream/main reconfigured imports --- src/coldfront_plugin_cloud/apps.py | 1 + .../daily_billable_usage.py | 2 +- .../commands/fetch_daily_billable_usage.py | 2 +- .../models/daily_billable_usage.py | 36 +++++++++++++++++++ .../tests/unit/test_daily_billable_usage.py | 2 +- .../unit/test_fetch_daily_billable_usage.py | 4 +-- 6 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 src/coldfront_plugin_cloud/models/daily_billable_usage.py diff --git a/src/coldfront_plugin_cloud/apps.py b/src/coldfront_plugin_cloud/apps.py index 78c58972..645bacd7 100644 --- a/src/coldfront_plugin_cloud/apps.py +++ b/src/coldfront_plugin_cloud/apps.py @@ -6,3 +6,4 @@ class OpenStackConfig(AppConfig): def ready(self): import coldfront_plugin_cloud.signals # noqa: F401 + import coldfront_plugin_cloud.models.daily_billable_usage # noqa: F401 diff --git a/src/coldfront_plugin_cloud/daily_billable_usage.py b/src/coldfront_plugin_cloud/daily_billable_usage.py index a9ff7b70..b4782e3f 100644 --- a/src/coldfront_plugin_cloud/daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/daily_billable_usage.py @@ -1,7 +1,7 @@ from collections.abc import Iterable from coldfront.core.allocation.models import Allocation -from coldfront_plugin_cloud.models import AllocationDailyBillableUsage +from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage from coldfront_plugin_cloud.usage_models import UsageInfo, validate_date_str diff --git a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py index f66e520c..ef94c511 100644 --- a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py @@ -12,7 +12,7 @@ from coldfront_plugin_cloud.models import usage_models from coldfront_plugin_cloud.models.usage_models import UsageInfo, validate_date_str from coldfront_plugin_cloud import utils -from coldfront_plugin_cloud.models import AllocationDailyBillableUsage +from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage import boto3 from django.core.management.base import BaseCommand diff --git a/src/coldfront_plugin_cloud/models/daily_billable_usage.py b/src/coldfront_plugin_cloud/models/daily_billable_usage.py new file mode 100644 index 00000000..b71c7b47 --- /dev/null +++ b/src/coldfront_plugin_cloud/models/daily_billable_usage.py @@ -0,0 +1,36 @@ +from django.db import models +from model_utils.models import TimeStampedModel +from coldfront.core.allocation.models import Allocation + + +class AllocationDailyBillableUsage(TimeStampedModel): + """Stores daily billable usage for allocations by SU type.""" + + allocation = models.ForeignKey( + Allocation, + on_delete=models.CASCADE, + related_name="daily_usage_records", + help_text="The allocation this usage belongs to", + ) + date = models.DateField(help_text="The date for which this usage was recorded") + su_type = models.CharField( + max_length=255, + help_text="The type of Service Unit (e.g., OpenStack CPU, OpenStack V100 GPU)", + ) + value = models.DecimalField( + max_digits=12, + decimal_places=2, + help_text="The usage value/cost for this SU type on this date", + ) + + class Meta: + db_table = "coldfront_plugin_cloud_allocationdailybillableusage" + unique_together = [["allocation", "date", "su_type"]] + indexes = [ + models.Index(fields=["allocation", "date"]), + models.Index(fields=["date"]), + ] + ordering = ["-date", "allocation", "su_type"] + + def __str__(self): + return f"{self.allocation.id} - {self.date} - {self.su_type}: {self.value}" diff --git a/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py index 4161c128..8998169f 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py @@ -11,7 +11,7 @@ get_daily_billable_usage, get_daily_billable_usage_range, ) -from coldfront_plugin_cloud.models import AllocationDailyBillableUsage +from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage from coldfront_plugin_cloud.tests import base diff --git a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py index 9b8fd819..59dba188 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py @@ -251,7 +251,7 @@ def test_send_alert_email(self): ) def test_database_insertion_and_removal(self, mock_get_allocation_usage): """Test database insertion, updates, and removal of usage entries.""" - from coldfront_plugin_cloud.models import AllocationDailyBillableUsage + from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo( @@ -349,7 +349,7 @@ def test_database_insertion_and_removal(self, mock_get_allocation_usage): ) def test_multiple_allocations_same_date(self, mock_get_allocation_usage): """Test that multiple allocations can store usage for the same date.""" - from coldfront_plugin_cloud.models import AllocationDailyBillableUsage + from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage fakeprod = self.new_openstack_resource( name="FakeProd", internal_name="FakeProd" From 0553fa8365c9fa4d51341fcb0b40192d7eec55ec Mon Sep 17 00:00:00 2001 From: Jimmy Sui Date: Wed, 27 May 2026 14:39:32 -0400 Subject: [PATCH 20/23] fixed imports --- src/coldfront_plugin_cloud/daily_billable_usage.py | 2 +- .../management/commands/seed_daily_billable_usage.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/coldfront_plugin_cloud/daily_billable_usage.py b/src/coldfront_plugin_cloud/daily_billable_usage.py index b4782e3f..850d9927 100644 --- a/src/coldfront_plugin_cloud/daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/daily_billable_usage.py @@ -2,7 +2,7 @@ from coldfront.core.allocation.models import Allocation from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage -from coldfront_plugin_cloud.usage_models import UsageInfo, validate_date_str +from coldfront_plugin_cloud.models.usage_models import UsageInfo, validate_date_str def _rows_to_usage_info(rows: Iterable[AllocationDailyBillableUsage]) -> UsageInfo: diff --git a/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py index f59bffd5..38e3d348 100644 --- a/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/seed_daily_billable_usage.py @@ -4,7 +4,7 @@ from django.core.management.base import BaseCommand, CommandError from coldfront.core.allocation.models import Allocation -from coldfront_plugin_cloud import usage_models +from coldfront_plugin_cloud.models import usage_models from coldfront_plugin_cloud.management.commands.fetch_daily_billable_usage import ( Command as FetchCommand, ) From 247716dbb69ae69cb2d03ec0081fd5feac9e0f40 Mon Sep 17 00:00:00 2001 From: Jimmy Sui Date: Wed, 27 May 2026 14:44:10 -0400 Subject: [PATCH 21/23] fixed ruff --- src/coldfront_plugin_cloud/daily_billable_usage.py | 4 +++- .../management/commands/fetch_daily_billable_usage.py | 4 +++- .../tests/unit/test_daily_billable_usage.py | 4 +++- .../tests/unit/test_fetch_daily_billable_usage.py | 8 ++++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/coldfront_plugin_cloud/daily_billable_usage.py b/src/coldfront_plugin_cloud/daily_billable_usage.py index 850d9927..7bfc5c15 100644 --- a/src/coldfront_plugin_cloud/daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/daily_billable_usage.py @@ -1,7 +1,9 @@ from collections.abc import Iterable from coldfront.core.allocation.models import Allocation -from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage +from coldfront_plugin_cloud.models.daily_billable_usage import ( + AllocationDailyBillableUsage, +) from coldfront_plugin_cloud.models.usage_models import UsageInfo, validate_date_str diff --git a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py index ef94c511..b7d48b77 100644 --- a/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/management/commands/fetch_daily_billable_usage.py @@ -12,7 +12,9 @@ from coldfront_plugin_cloud.models import usage_models from coldfront_plugin_cloud.models.usage_models import UsageInfo, validate_date_str from coldfront_plugin_cloud import utils -from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage +from coldfront_plugin_cloud.models.daily_billable_usage import ( + AllocationDailyBillableUsage, +) import boto3 from django.core.management.base import BaseCommand diff --git a/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py index 8998169f..f3d44d93 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_daily_billable_usage.py @@ -11,7 +11,9 @@ get_daily_billable_usage, get_daily_billable_usage_range, ) -from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage +from coldfront_plugin_cloud.models.daily_billable_usage import ( + AllocationDailyBillableUsage, +) from coldfront_plugin_cloud.tests import base diff --git a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py index 59dba188..04c73f8d 100644 --- a/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py +++ b/src/coldfront_plugin_cloud/tests/unit/test_fetch_daily_billable_usage.py @@ -251,7 +251,9 @@ def test_send_alert_email(self): ) def test_database_insertion_and_removal(self, mock_get_allocation_usage): """Test database insertion, updates, and removal of usage entries.""" - from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage + from coldfront_plugin_cloud.models.daily_billable_usage import ( + AllocationDailyBillableUsage, + ) mock_get_allocation_usage.side_effect = [ usage_models.UsageInfo( @@ -349,7 +351,9 @@ def test_database_insertion_and_removal(self, mock_get_allocation_usage): ) def test_multiple_allocations_same_date(self, mock_get_allocation_usage): """Test that multiple allocations can store usage for the same date.""" - from coldfront_plugin_cloud.models.daily_billable_usage import AllocationDailyBillableUsage + from coldfront_plugin_cloud.models.daily_billable_usage import ( + AllocationDailyBillableUsage, + ) fakeprod = self.new_openstack_resource( name="FakeProd", internal_name="FakeProd" From 81cf3d61f85d93c31ddc37977dd6bd5abd3c9df5 Mon Sep 17 00:00:00 2001 From: Jimmy Sui Date: Wed, 27 May 2026 15:00:28 -0400 Subject: [PATCH 22/23] deleted models.py --- src/coldfront_plugin_cloud/models.py | 36 ---------------------------- 1 file changed, 36 deletions(-) delete mode 100644 src/coldfront_plugin_cloud/models.py diff --git a/src/coldfront_plugin_cloud/models.py b/src/coldfront_plugin_cloud/models.py deleted file mode 100644 index b71c7b47..00000000 --- a/src/coldfront_plugin_cloud/models.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.db import models -from model_utils.models import TimeStampedModel -from coldfront.core.allocation.models import Allocation - - -class AllocationDailyBillableUsage(TimeStampedModel): - """Stores daily billable usage for allocations by SU type.""" - - allocation = models.ForeignKey( - Allocation, - on_delete=models.CASCADE, - related_name="daily_usage_records", - help_text="The allocation this usage belongs to", - ) - date = models.DateField(help_text="The date for which this usage was recorded") - su_type = models.CharField( - max_length=255, - help_text="The type of Service Unit (e.g., OpenStack CPU, OpenStack V100 GPU)", - ) - value = models.DecimalField( - max_digits=12, - decimal_places=2, - help_text="The usage value/cost for this SU type on this date", - ) - - class Meta: - db_table = "coldfront_plugin_cloud_allocationdailybillableusage" - unique_together = [["allocation", "date", "su_type"]] - indexes = [ - models.Index(fields=["allocation", "date"]), - models.Index(fields=["date"]), - ] - ordering = ["-date", "allocation", "su_type"] - - def __str__(self): - return f"{self.allocation.id} - {self.date} - {self.su_type}: {self.value}" From f008248960163d496b2aa2b801568818781262ba Mon Sep 17 00:00:00 2001 From: Jimmy Sui Date: Wed, 27 May 2026 15:30:51 -0400 Subject: [PATCH 23/23] remved redundancy --- ci/run_functional_tests_openshift.sh | 1 - ci/run_functional_tests_openstack.sh | 1 - ci/run_unit_tests.sh | 1 - ci/setup.sh | 2 -- 4 files changed, 5 deletions(-) diff --git a/ci/run_functional_tests_openshift.sh b/ci/run_functional_tests_openshift.sh index c7c3face..15a06d1e 100755 --- a/ci/run_functional_tests_openshift.sh +++ b/ci/run_functional_tests_openshift.sh @@ -15,7 +15,6 @@ fi microshift_addr=$(sudo docker inspect microshift --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}') export DJANGO_SETTINGS_MODULE="local_settings" -export PYTHONPATH=src export FUNCTIONAL_TESTS="True" export OS_API_URL="https://$microshift_addr:6443" export PYTHONWARNINGS="ignore:Unverified HTTPS request" diff --git a/ci/run_functional_tests_openstack.sh b/ci/run_functional_tests_openstack.sh index 5b76f29c..b6aa1c67 100755 --- a/ci/run_functional_tests_openstack.sh +++ b/ci/run_functional_tests_openstack.sh @@ -17,7 +17,6 @@ export OPENSTACK_ESI_APPLICATION_CREDENTIAL_ID=$OPENSTACK_DEVSTACK_APPLICATION_C export OPENSTACK_PUBLIC_NETWORK_ID=$(microstack.openstack network show external -f value -c id) export DJANGO_SETTINGS_MODULE="local_settings" -export PYTHONPATH=src export FUNCTIONAL_TESTS="True" export OS_AUTH_URL="https://localhost:5000" export PYTHONWARNINGS="ignore:Unverified HTTPS request" diff --git a/ci/run_unit_tests.sh b/ci/run_unit_tests.sh index 56cbd11d..26de492f 100755 --- a/ci/run_unit_tests.sh +++ b/ci/run_unit_tests.sh @@ -7,7 +7,6 @@ if [[ ! "${CI}" == "true" ]]; then fi export DJANGO_SETTINGS_MODULE="local_settings" -export PYTHONPATH=src coverage run --source="." -m django test coldfront_plugin_cloud.tests.unit coverage report diff --git a/ci/setup.sh b/ci/setup.sh index 434caa1d..6a5ff0c5 100755 --- a/ci/setup.sh +++ b/ci/setup.sh @@ -2,8 +2,6 @@ set -xe -export PYTHONPATH=src - # If running on Github actions, don't create a virtualenv # Else install postgres if [[ ! "${CI}" == "true" ]]; then