Skip to content

Commit 3a10a46

Browse files
authored
Merge pull request #275 from jimmysway/feature-274/updating-invoicing-schema
Add billable override to nonbillable projects
2 parents b5667ec + ac65c50 commit 3a10a46

8 files changed

Lines changed: 80 additions & 10 deletions

File tree

process_report/invoices/invoice.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
NONBILLABLE_PROJECT_NAME = "Project Name"
2929
NONBILLABLE_CLUSTER_NAME = "Cluster"
3030
NONBILLABLE_IS_TIMED = "Timed"
31+
NONBILLABLE_IS_BILLABLE_OVERRIDE = "Is Billable Override"
3132

3233
### Invoice field names
3334
INVOICE_DATE_FIELD = "Invoice Month"

process_report/loader.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,12 @@ def get_nonbillable_pis(self) -> list[str]:
120120
def get_nonbillable_projects(self) -> pandas.DataFrame:
121121
"""
122122
Returns dataframe of nonbillable projects for current invoice month
123-
The dataframe has 3 columns: Project Name, Cluster, Is Timed
123+
The dataframe has 4 columns: Project Name, Cluster, Is Timed, Is Billable Override
124124
1. Project Name: Name of the nonbillable project
125125
2. Cluster: Name of the cluster for which the project is nonbillable, or None meaning all clusters
126126
3. Is Timed: Boolean indicating if the nonbillable status is time-bound
127+
4. Is Billable Override: Optional boolean override from projects.yaml
128+
indicating whether matching projects should be treated as billable
127129
"""
128130

129131
def _is_in_time_range(timed_object) -> bool:
@@ -140,33 +142,41 @@ def _is_in_time_range(timed_object) -> bool:
140142
for project in projects_dict:
141143
project_name = project["name"]
142144
cluster_list = project.get("clusters")
145+
is_billable = project.get("is_billable", False)
143146

144147
if project.get("start"):
145148
if not _is_in_time_range(project):
146149
continue
147150

148151
if cluster_list:
149152
for cluster in cluster_list:
150-
project_list.append((project_name, cluster["name"], True))
153+
project_list.append(
154+
(project_name, cluster["name"], True, is_billable)
155+
)
151156
else:
152-
project_list.append((project_name, None, True))
157+
project_list.append((project_name, None, True, is_billable))
153158
elif cluster_list:
154159
for cluster in cluster_list:
155160
cluster_start_time = cluster.get("start")
156161
if cluster_start_time:
157162
if _is_in_time_range(cluster):
158-
project_list.append((project_name, cluster["name"], True))
163+
project_list.append(
164+
(project_name, cluster["name"], True, is_billable)
165+
)
159166
elif not cluster_start_time:
160-
project_list.append((project_name, cluster["name"], False))
167+
project_list.append(
168+
(project_name, cluster["name"], False, is_billable)
169+
)
161170
else:
162-
project_list.append((project_name, None, False))
171+
project_list.append((project_name, None, False, is_billable))
163172

164173
return pandas.DataFrame(
165174
project_list,
166175
columns=[
167176
invoice.NONBILLABLE_PROJECT_NAME,
168177
invoice.NONBILLABLE_CLUSTER_NAME,
169178
invoice.NONBILLABLE_IS_TIMED,
179+
invoice.NONBILLABLE_IS_BILLABLE_OVERRIDE,
170180
],
171181
)
172182

process_report/processors/validate_billable_pi_processor.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ def _apply_lowercase(data: pandas.DataFrame, col) -> pandas.DataFrame:
7878
nonbillable_cluster_mask = ~merged_data[invoice.CLUSTER_NAME_FIELD].isin(
7979
NONBILLABLE_CLUSTERS
8080
)
81-
return cluster_agnostic_mask & cluster_specific_mask & nonbillable_cluster_mask
81+
billable_override_mask = merged_data[invoice.NONBILLABLE_IS_BILLABLE_OVERRIDE]
82+
return (
83+
cluster_agnostic_mask & cluster_specific_mask & nonbillable_cluster_mask
84+
) | billable_override_mask
8285

8386

8487
@dataclass

process_report/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class Settings(BaseSettings):
2424
prepay_debits_remote_filepath: str = "Prepay/prepay_debits.csv"
2525

2626
# Local input files
27-
nonbillable_pis_filepath: str = "pi.txt"
27+
nonbillable_pis_filepath: str = "pi.yaml"
2828
nonbillable_projects_filepath: str = "projects.yaml"
2929
prepay_projects_filepath: str = "prepaid_projects.csv"
3030
prepay_credits_filepath: str = "prepaid_credits.csv"

process_report/tests/unit/processors/test_coldfront_fetch_processor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def test_coldfront_project_not_found(self, mock_get_allocation_data):
113113
"Project Name": ["P3"],
114114
"Cluster": [None],
115115
"Is Timed": [False],
116+
"Is Billable Override": [False],
116117
}
117118
)
118119
test_invoice = self._get_test_invoice(
@@ -189,6 +190,7 @@ def test_is_course_default_false(self, mock_get_allocation_data):
189190
"Project Name": ["P3"],
190191
"Cluster": [None],
191192
"Is Timed": [False],
193+
"Is Billable Override": [False],
192194
}
193195
)
194196
test_coldfront_fetch_proc = test_utils.new_coldfront_fetch_processor(

process_report/tests/unit/processors/test_validate_billable_pi_processor.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def test_remove_nonbillables(self):
4343
"ocp-prod",
4444
], # P1 is cluster-agnostic, P8-bm should be nonbillable, P9 should be billable because its on bm cluster in test invoice
4545
"Is Timed": [False, False, False],
46+
"Is Billable Override": [False, False, False],
4647
}
4748
)
4849
institutions = ["Test University"] * len(pis)
@@ -67,6 +68,34 @@ def test_remove_nonbillables(self):
6768
output = validate_billable_pi_proc.data
6869
assert output[output["Is Billable"]].equals(data.iloc[[3, 4, 5, 9]])
6970

71+
def test_billable_override_marks_project_billable(self):
72+
test_data = pandas.DataFrame(
73+
{
74+
"Manager (PI)": ["PI1"],
75+
"Project - Allocation": ["ProjectA"],
76+
"Cluster Name": ["stack"],
77+
"Institution": ["Test University"],
78+
"Is Course": [False],
79+
}
80+
)
81+
nonbillable_projects = pandas.DataFrame(
82+
{
83+
"Project Name": ["ProjectA"],
84+
"Cluster": ["stack"],
85+
"Is Timed": [False],
86+
"Is Billable Override": [True],
87+
}
88+
)
89+
90+
validate_billable_pi_proc = test_utils.new_validate_billable_pi_processor(
91+
data=test_data,
92+
nonbillable_projects=nonbillable_projects,
93+
)
94+
validate_billable_pi_proc.process()
95+
output = validate_billable_pi_proc.data
96+
97+
assert output["Is Billable"].tolist() == [True]
98+
7099
def test_empty_pi_name(self):
71100
test_data = pandas.DataFrame(
72101
{

process_report/tests/unit/test_util.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def setUp(self):
8484
},
8585
{
8686
"name": "ProjectD",
87+
"is_billable": True,
8788
"clusters": [
8889
{"name": "Cluster1", "start": "2023-05", "end": "2023-09"},
8990
{"name": "Cluster2", "start": "2023-05", "end": "2023-11"},
@@ -114,6 +115,30 @@ def test_timed_projects(self):
114115
]
115116
assert excluded_projects == expected_projects
116117

118+
def test_get_nonbillable_projects_loads_yaml_into_expected_dataframe(self):
119+
# This verifies the loader translates the YAML fixture into the
120+
# internal dataframe shape, including the Is Billable Override column.
121+
nonbillable_projects = loader.get_nonbillable_projects()
122+
123+
expected_projects = pandas.DataFrame(
124+
[
125+
("ProjectA", "Cluster1", False, False),
126+
("ProjectA", "Cluster2", False, False),
127+
("ProjectB", "Cluster1", True, False),
128+
("ProjectD", "Cluster1", True, True),
129+
("ProjectD", "Cluster2", True, True),
130+
("ProjectE", None, False, False),
131+
],
132+
columns=[
133+
"Project Name",
134+
"Cluster",
135+
"Timed",
136+
"Is Billable Override",
137+
],
138+
)
139+
140+
assert nonbillable_projects.equals(expected_projects)
141+
117142

118143
class TestValidateRequiredEnvVars(TestCase):
119144
@mock.patch.dict(

process_report/tests/util.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def new_coldfront_fetch_processor(
6868
data = pandas.DataFrame()
6969
if nonbillable_projects is None:
7070
nonbillable_projects = pandas.DataFrame(
71-
columns=["Project Name", "Cluster", "Is Timed"]
71+
columns=["Project Name", "Cluster", "Is Timed", "Is Billable Override"]
7272
)
7373
return coldfront_fetch_processor.ColdfrontFetchProcessor(
7474
invoice_month, data, name, nonbillable_projects, coldfront_data_filepath
@@ -110,7 +110,7 @@ def new_validate_billable_pi_processor(
110110
nonbillable_pis = []
111111
if nonbillable_projects is None:
112112
nonbillable_projects = pandas.DataFrame(
113-
columns=["Project Name", "Cluster", "Is Timed"]
113+
columns=["Project Name", "Cluster", "Is Timed", "Is Billable Override"]
114114
)
115115

116116
return validate_billable_pi_processor.ValidateBillablePIsProcessor(

0 commit comments

Comments
 (0)