Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
247716d
fixed ruff
jimmysway May 27, 2026
81cf3d6
deleted models.py
jimmysway May 27, 2026
f008248
remved redundancy
jimmysway May 27, 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
openshift
coldfront >= 1.1.0
django-model-utils
python-cinderclient
python-keystoneclient
python-novaclient
Expand Down
1 change: 1 addition & 0 deletions src/coldfront_plugin_cloud/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
142 changes: 142 additions & 0 deletions src/coldfront_plugin_cloud/daily_billable_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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: 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)
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}")
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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.models 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
Loading
Loading