Skip to content

Commit 81c7ef3

Browse files
authored
Merge pull request #277 from QuanMPhm/270/billing_attr
Add attributes to tracking allocation usage
2 parents 1b7b62a + 5bb6ffe commit 81c7ef3

File tree

4 files changed

+157
-0
lines changed

4 files changed

+157
-0
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ boto3
33
kubernetes
44
openshift
55
coldfront >= 1.1.0
6+
pydantic
67
python-cinderclient # TODO: Set version for OpenStack Clients
78
python-keystoneclient
89
python-novaclient

src/coldfront_plugin_cloud/attributes.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ class CloudAllocationAttribute:
5656
ALLOCATION_PROJECT_ID = "Allocated Project ID"
5757
ALLOCATION_PROJECT_NAME = "Allocated Project Name"
5858
ALLOCATION_INSTITUTION_SPECIFIC_CODE = "Institution-Specific Code"
59+
ALLOCATION_CUMULATIVE_CHARGES = "Cumulative Daily Charges for Month"
60+
ALLOCATION_PREVIOUS_CHARGES = "Previous Charges"
61+
ALLOCATION_ALERT = "Monthly Allocation Cost Alert"
5962

6063
ALLOCATION_ATTRIBUTES = [
6164
CloudAllocationAttribute(
@@ -67,6 +70,19 @@ class CloudAllocationAttribute:
6770
CloudAllocationAttribute(
6871
name=ALLOCATION_INSTITUTION_SPECIFIC_CODE, type="Text", is_changeable=True
6972
),
73+
CloudAllocationAttribute(
74+
name=ALLOCATION_CUMULATIVE_CHARGES,
75+
type="Text",
76+
is_private=True,
77+
is_changeable=True,
78+
),
79+
CloudAllocationAttribute(
80+
name=ALLOCATION_PREVIOUS_CHARGES,
81+
type="Text",
82+
is_private=True,
83+
is_changeable=True,
84+
),
85+
CloudAllocationAttribute(name=ALLOCATION_ALERT, type="Int", is_changeable=True),
7086
]
7187

7288
###########################################################
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from decimal import Decimal
2+
from pydantic import ValidationError
3+
4+
from coldfront_plugin_cloud import usage_models
5+
from coldfront_plugin_cloud.tests import base
6+
7+
8+
class TestUsageModels(base.TestBase):
9+
def test_usage_info(self):
10+
# valid: values coerced to Decimal
11+
ui = usage_models.UsageInfo(
12+
root={"su-a": Decimal("1.5"), "su-b": 2, "su-c": "3.25"}
13+
)
14+
self.assertIsInstance(ui.root, dict)
15+
self.assertEqual(ui.root["su-a"], Decimal("1.5"))
16+
self.assertEqual(ui.root["su-b"], Decimal("2"))
17+
self.assertEqual(ui.root["su-c"], Decimal("3.25"))
18+
19+
# invalid: non-numeric string should raise ValidationError
20+
with self.assertRaises(ValidationError):
21+
usage_models.UsageInfo(root={"su-x": "not-a-number"})
22+
23+
def test_daily_charges_dict(self):
24+
# Valid CumulativeChargesDict with YYYY-MM-DD keys
25+
data = {
26+
"2025-11-29": {"su1": Decimal("1.0")},
27+
"2025-11-30": {"su1": Decimal("3.5"), "su2": Decimal("2.0")},
28+
}
29+
daily = usage_models.CumulativeChargesDict(root=data)
30+
# total_charges sums across all dates and SUs
31+
self.assertEqual(daily.total_charges, Decimal("5.5"))
32+
33+
# Empty dict -> totals should be zero/empty
34+
empty = usage_models.CumulativeChargesDict(root={})
35+
self.assertEqual(empty.total_charges, Decimal("0.0"))
36+
37+
# Invalid date key format should raise ValidationError
38+
with self.assertRaises(ValidationError):
39+
usage_models.CumulativeChargesDict(root={"2025-13-01": {"su": 1.0}})
40+
41+
with self.assertRaises(ValidationError):
42+
usage_models.CumulativeChargesDict(root={"2025-01": {"su": 1.0}})
43+
44+
# Different months should raise ValidationError
45+
with self.assertRaises(ValidationError):
46+
usage_models.CumulativeChargesDict(
47+
root={"2025-12-01": {"su": 1.0}, "2026-12-01": {"su": 1.0}}
48+
)
49+
50+
def test_previous_charges_dict(self):
51+
# Monthly (PreviousChargesDict) requires YYYY-MM keys
52+
prev_data = {
53+
"2025-11": {"suA": Decimal("5.0")},
54+
"2025-12": {"suA": Decimal("2.5"), "suB": Decimal("1.0")},
55+
}
56+
prev = usage_models.PreviousChargesDict(root=prev_data)
57+
self.assertEqual(
58+
prev.total_charges_by_su,
59+
{"suA": Decimal("7.5"), "suB": Decimal("1.0")},
60+
)
61+
self.assertEqual(prev.total_charges, Decimal("8.5"))
62+
63+
# Invalid month format should raise ValidationError
64+
with self.assertRaises(ValidationError):
65+
usage_models.PreviousChargesDict(root={"2025-11-01": {"su": 1.0}})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import datetime
2+
import functools
3+
from decimal import Decimal
4+
from typing import Annotated, TypeVar
5+
6+
import pydantic
7+
8+
9+
def validate_date_str(v: str) -> str:
10+
datetime.datetime.strptime(v, "%Y-%m-%d")
11+
return v
12+
13+
14+
def validate_month_str(v: str) -> str:
15+
datetime.datetime.strptime(v, "%Y-%m")
16+
return v
17+
18+
19+
DateField = Annotated[str, pydantic.AfterValidator(validate_date_str)]
20+
MonthField = Annotated[str, pydantic.AfterValidator(validate_month_str)]
21+
22+
23+
class UsageInfo(pydantic.RootModel[dict[str, Decimal]]):
24+
pass
25+
26+
27+
T = TypeVar("T", bound=str)
28+
29+
30+
class ChargesDict(pydantic.RootModel[dict[T, UsageInfo]]):
31+
@functools.cached_property
32+
def most_recent_date(self) -> DateField:
33+
"""Leverage lexical ordering of YYYY-MM-DD and YYYY-MM strings."""
34+
return max(self.root.keys()) if self.root else ""
35+
36+
37+
class CumulativeChargesDict(ChargesDict[DateField]):
38+
@pydantic.model_validator(mode="after")
39+
def check_month(self):
40+
# Ensure all keys are in the same month
41+
if self.root:
42+
months = set()
43+
for date_str in self.root.keys():
44+
months.add(
45+
datetime.datetime.strptime(date_str, "%Y-%m-%d").strftime("%Y-%m")
46+
)
47+
48+
if len(months) != 1:
49+
raise ValueError("All dates must be within the same month")
50+
return self
51+
52+
@functools.cached_property
53+
def total_charges(self) -> Decimal:
54+
total = Decimal("0.00")
55+
if most_recent_charges := self.root.get(self.most_recent_date):
56+
for su_charge in most_recent_charges.root.values():
57+
total += su_charge
58+
return total
59+
60+
61+
class PreviousChargesDict(ChargesDict[MonthField]):
62+
@functools.cached_property
63+
def total_charges_by_su(self) -> dict[str, Decimal]:
64+
total = {}
65+
for usage_info in self.root.values():
66+
for su_name, charge in usage_info.root.items():
67+
total[su_name] = total.get(su_name, Decimal("0.00")) + charge
68+
return total
69+
70+
@functools.cached_property
71+
def total_charges(self) -> Decimal:
72+
total = Decimal("0.00")
73+
for su_charge in self.total_charges_by_su.values():
74+
total += su_charge
75+
return total

0 commit comments

Comments
 (0)