-
Notifications
You must be signed in to change notification settings - Fork 14
Add AllocationDailyBillableUsage model and improve tests #317
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
186c879
96285b2
1815ed8
5e2c5d0
960f7b6
7b070eb
f1ac8f7
6762430
92b4898
b162bb1
c282a7b
befbe1c
75a1203
6e68b5d
b88e458
06449fb
01edadc
3a6eee1
f4e950e
6bb794e
0553fa8
8781083
7aaa12e
392a176
7f67ab7
5809ec7
1bd7bba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -134,3 +134,4 @@ dmypy.json | |
| [._]s[a-rt-v][a-z] | ||
| [._]ss[a-gi-z] | ||
| [._]sw[a-p] | ||
| *.db | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Sample Seeding Script for Daily Billable Usage | ||
|
|
||
| To run the script to populate the test DB use: | ||
|
|
||
| ``` | ||
| python manage.py shell < /mnt/MOC/coldfront-plugin-cloud/scripts/seed_sample_daily_billable_usage_shell.py | ||
| ``` | ||
|
|
||
| Configure the `seed_sample_daily_billable_usage_shell.py` file before running the script. | ||
|
|
||
| ``` | ||
| # --- configure before running --- | ||
| ALLOCATION_ID = 4 | ||
|
|
||
| # Pick one date mode (set the others to None / False): | ||
| SINGLE_DATE = None # YYYY-MM-DD, or None | ||
| MONTH = None # YYYY-MM, or None | ||
| CURRENT_MONTH = True # seed every day in the current calendar month | ||
| THROUGH_TODAY = True # with CURRENT_MONTH, skip future days | ||
| ``` |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this file is intended to inject test data that will be used to test the UI graphs in @jimmysway Is this only way to test the UI graphs?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well this file tests the injection pattern that the UI charts will get their data from, injecting sample data into a database tests the entire flow rather than just mocking it at a higher level. I can for sure add this to another PR but it is very dependent on the contents of this PR so it was the easiest way for me to write a script to write some sample data into the db |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| """Seed AllocationDailyBillableUsage rows for local UI development. | ||
|
|
||
| Run from a ColdFront checkout with NERC local_settings: | ||
|
|
||
| python manage.py shell < path/to/seed_daily_billable_usage_shell.py | ||
|
|
||
| Or from django shell: | ||
|
|
||
| exec(open("path/to/seed_daily_billable_usage_shell.py").read()) | ||
| """ | ||
|
|
||
| import calendar | ||
| from datetime import date as date_type | ||
| from decimal import Decimal | ||
|
|
||
| from coldfront.core.allocation.models import Allocation | ||
| from coldfront_plugin_cloud.models.daily_billable_usage import ( | ||
| AllocationDailyBillableUsage, | ||
| ) | ||
|
|
||
| # --- configure before running --- | ||
| ALLOCATION_ID = 4 | ||
|
|
||
| # Pick one date mode (set the others to None / False): | ||
| SINGLE_DATE = None # YYYY-MM-DD, or None | ||
| MONTH = None # YYYY-MM, or None | ||
| CURRENT_MONTH = True # seed every day in the current calendar month | ||
| THROUGH_TODAY = True # with CURRENT_MONTH, skip future days | ||
|
|
||
| RAMP = True # increase each SU type slightly per day (chart-friendly) | ||
|
|
||
| DEFAULT_USAGE = { | ||
| "OpenStack CPU": Decimal("100.00"), | ||
| "OpenStack V100 GPU": Decimal("50.00"), | ||
| "Storage": Decimal("30.12"), | ||
| } | ||
|
|
||
| _RAMP_STEP = { | ||
| "OpenStack CPU": Decimal("5"), | ||
| "OpenStack V100 GPU": Decimal("2"), | ||
| "Storage": Decimal("0.5"), | ||
| } | ||
|
|
||
|
|
||
| def dates_in_month(month: str) -> list[str]: | ||
| 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)] | ||
|
|
||
|
|
||
| def usage_for_day( | ||
| base: dict[str, Decimal], day_index: int, ramp: bool | ||
| ) -> dict[str, Decimal]: | ||
| if not ramp: | ||
| return dict(base) | ||
| return { | ||
| su_type: amount + _RAMP_STEP.get(su_type, Decimal("0")) * day_index | ||
| for su_type, amount in base.items() | ||
| } | ||
|
|
||
|
|
||
| def resolve_dates() -> list[str]: | ||
| if SINGLE_DATE: | ||
| return [SINGLE_DATE] | ||
| if CURRENT_MONTH: | ||
| today = date_type.today() | ||
| month_str = f"{today.year}-{today.month:02d}" | ||
| dates = dates_in_month(month_str) | ||
| if THROUGH_TODAY: | ||
| today_str = today.isoformat() | ||
| dates = [d for d in dates if d <= today_str] | ||
| return dates | ||
| if MONTH: | ||
| return dates_in_month(MONTH) | ||
| raise ValueError("Set SINGLE_DATE, MONTH, or CURRENT_MONTH=True") | ||
|
|
||
|
|
||
| allocation = Allocation.objects.get(pk=ALLOCATION_ID) | ||
| dates = resolve_dates() | ||
| total_rows = 0 | ||
|
|
||
| for day_index, day in enumerate(dates): | ||
| for su_type, value in usage_for_day(DEFAULT_USAGE, day_index, RAMP).items(): | ||
| AllocationDailyBillableUsage.objects.update_or_create( | ||
| allocation=allocation, | ||
| date=day, | ||
| su_type=su_type, | ||
| defaults={"value": value}, | ||
| ) | ||
| total_rows += 1 | ||
|
|
||
| print( | ||
| f"Seeded {len(DEFAULT_USAGE)} SU type(s) × {len(dates)} day(s) " | ||
| f"= {total_rows} row(s) for allocation {allocation.id}" | ||
| ) | ||
| if len(dates) > 1: | ||
| print(f" dates: {dates[0]} … {dates[-1]}") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| 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.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: | ||
| ValueError: If any 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') | ||
| """ | ||
| usage_info = UsageInfo({}) | ||
| for row in rows: | ||
| if not row.su_type: | ||
| raise ValueError(f"usage row id={row.pk} has empty su_type") | ||
|
Comment on lines
+37
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When would |
||
| usage_info.root[row.su_type] = row.value | ||
| return usage_info | ||
|
|
||
|
|
||
| def get_daily_billable_usage_by_date( | ||
| allocation: Allocation, start_date: str, end_date: str | ||
| ) -> dict[str, UsageInfo]: | ||
| """Load billable usage grouped by day 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: | ||
| Mapping of ``YYYY-MM-DD`` date strings to UsageInfo. Dates with no | ||
| usage rows are omitted. | ||
|
|
||
| Raises: | ||
| ValueError: If allocation is unsaved, a date is invalid, or | ||
| start_date is after end_date. | ||
|
|
||
| Example: | ||
| >>> usage_by_date = get_daily_billable_usage_by_date( | ||
| ... allocation, "2025-11-01", "2025-11-30" | ||
| ... ) | ||
| >>> usage_by_date["2025-11-15"].root | ||
| {'OpenStack CPU': Decimal('100.00'), 'Storage': Decimal('30.12')} | ||
| """ | ||
| 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, | ||
| ).order_by("date", "su_type") | ||
|
|
||
| usage_by_date: dict[str, UsageInfo] = {} | ||
| for row in rows: | ||
| day = row.date.isoformat() if hasattr(row.date, "isoformat") else str(row.date) | ||
| usage_by_date.setdefault(day, UsageInfo({})) | ||
| if not row.su_type: | ||
| raise ValueError(f"usage row id={row.pk} has empty su_type") | ||
| usage_by_date[day].root[row.su_type] = row.value | ||
|
|
||
| return usage_by_date | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| # Generated manually for coldfront_plugin_cloud | ||
|
|
||
| from django.db import migrations, models | ||
| import django.db.models.deletion | ||
| import django.utils.timezone | ||
| import model_utils.fields | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| initial = True | ||
|
|
||
| dependencies = [ | ||
| ("allocation", "__first__"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.CreateModel( | ||
| 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", | ||
| ), | ||
| ), | ||
| ], | ||
| options={ | ||
| "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" | ||
| ), | ||
| ), | ||
| migrations.AddIndex( | ||
| model_name="allocationdailybillableusage", | ||
| index=models.Index(fields=["date"], name="coldfront_p_date_3e8a9e_idx"), | ||
| ), | ||
| migrations.AlterUniqueTogether( | ||
| name="allocationdailybillableusage", | ||
| unique_together={("allocation", "date", "su_type")}, | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| import coldfront_plugin_cloud.models.daily_billable_usage # noqa: F401 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this should be here. I don't think we'll ever need to commit a
.dbfile, but this doesn't seem nessecary