Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
186c879
Initial plan
Copilot Feb 10, 2026
96285b2
Add UsageInfo database model and update fetch_daily_billable_usage co…
Copilot Feb 10, 2026
1815ed8
Add unit tests for database insertion and removal functionality
Copilot Feb 10, 2026
5e2c5d0
Fix test assertions to use Decimal for database value comparisons
Copilot Feb 10, 2026
960f7b6
Remove committed database file and update gitignore
Copilot Feb 10, 2026
7b070eb
Fix trailing whitespace in code files
Copilot Feb 10, 2026
f1ac8f7
Address code review feedback: improve related_name, add logging for e…
Copilot Feb 10, 2026
6762430
Rename UsageInfo to AllocationDailyBillableUsage and use TimeStampedM…
Copilot Feb 10, 2026
92b4898
Update migration to use AllocationDailyBillableUsage with TimeStamped…
Copilot Feb 10, 2026
b162bb1
Remove confusing UsageInfoModel alias in tests
Copilot Feb 10, 2026
c282a7b
Clean up redundant import alias in tests
Copilot Feb 10, 2026
befbe1c
Address PR feedback: simplify code and use UsageInfo alias
Copilot Feb 10, 2026
75a1203
Keep both UsageInfo classes explicit to avoid confusion
Copilot Feb 10, 2026
6e68b5d
Add explicit UsageInfo import to avoid unnecessary changes
Copilot Feb 10, 2026
b88e458
Refactor tests: combine insertion/removal and add update test
Copilot Feb 10, 2026
06449fb
Added methods to retrieve daily billable usage information.
jimmysway May 26, 2026
01edadc
Set PYTHONPATH to 'src' in CI scripts for functional and unit tests
jimmysway May 27, 2026
3a6eee1
Refactor code for consistency and readability by simplifying filter q…
jimmysway May 27, 2026
f4e950e
Merge remote-tracking branch 'upstream/main' into copilot/add-usage-i…
jimmysway May 27, 2026
6bb794e
merged into upstream/main
jimmysway May 27, 2026
0553fa8
fixed imports
jimmysway May 27, 2026
8781083
fixed ruff
jimmysway May 27, 2026
7aaa12e
Merge remote-tracking branch 'upstream/main' into copilot/add-usage-i…
jimmysway May 28, 2026
392a176
fix linter errors
jimmysway May 28, 2026
7f67ab7
Rewrote seeding to avoid Command
jimmysway Jun 9, 2026
5809ec7
Add seeding script for daily billable usage and remove legacy command
jimmysway Jun 15, 2026
1bd7bba
Streamlined tests, removed redundant type error checks.
jimmysway Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,4 @@ dmypy.json
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
*.db

Copy link
Copy Markdown
Contributor

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 .db file, but this doesn't seem nessecary

20 changes: 20 additions & 0 deletions scripts/README.md
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
```
97 changes: 97 additions & 0 deletions scripts/seed_sample_daily_billable_usage_shell.py

@QuanMPhm QuanMPhm Jun 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 coldfront-nerc, shouldn't this file be added in that PR instead? I'm not familiar with the UI code or how testing will be done with it yet, but that's my reaction. It's odd seeing a file added in a PR that doesn't use it.

@jimmysway Is this only way to test the UI graphs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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]}")
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ install_requires =
kubernetes>=36.0.0
openshift
coldfront >= 1.1.0
django-model-utils
python-cinderclient
python-keystoneclient
python-novaclient
Expand Down
95 changes: 95 additions & 0 deletions src/coldfront_plugin_cloud/billable_usage.py
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would su_type be empty, and do we want to have empty su_types ("")

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
Expand Up @@ -12,6 +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,
)

import boto3
from django.core.management.base import BaseCommand
Expand Down Expand Up @@ -85,10 +88,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()

Expand Down Expand Up @@ -122,6 +135,8 @@ def handle(self, *args, **options):
)
continue

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)
Expand Down Expand Up @@ -302,3 +317,32 @@ 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():
AllocationDailyBillableUsage.objects.update_or_create(
allocation=allocation,
date=date,
su_type=su_type,
defaults={"value": value},
)

@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, _ = AllocationDailyBillableUsage.objects.filter(
date=date
).delete()
logger.info(f"Removed {deleted_count} usage entries for date {date}")
95 changes: 95 additions & 0 deletions src/coldfront_plugin_cloud/migrations/0001_initial.py
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")},
),
]
Empty file.
1 change: 1 addition & 0 deletions src/coldfront_plugin_cloud/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import coldfront_plugin_cloud.models.daily_billable_usage # noqa: F401
Loading
Loading