Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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
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
145 changes: 145 additions & 0 deletions src/coldfront_plugin_cloud/billable_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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:
TypeError: If rows is None.
ValueError: TypeError: If rows is None, or if any element is not an AllocationDailyBillableUsage..

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.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_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:
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.

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 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,
).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}")
Loading
Loading