Skip to content

Commit c9eaad9

Browse files
committed
Projects that belong to courses are now non-billable
Leveraging the private field `Is Course?` in Coldfront [1], if this field is set to `Yes`, the project/allocation is considered part of a course. For now, any project that is a part of a course and belongs to Boston University is non-billable. [2] If the field is not set, it's assumed to be not part of a course [1] https://github.com/ubccr/coldfront/blob/a4b6b4c1d18535f61de5aa4bbfe509d7d8ca4b0f/coldfront/core/allocation/management/commands/add_allocation_defaults.py#L56 [2] CCI-MOC/non-billable-projects#66 (comment)
1 parent fec354d commit c9eaad9

File tree

5 files changed

+131
-13
lines changed

5 files changed

+131
-13
lines changed

process_report/invoices/invoice.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
PROJECT_NAME_FIELD = "Project"
5757
GROUP_MANAGED_FIELD = "MGHPCC Managed"
5858
CLUSTER_NAME_FIELD = "Cluster Name"
59+
IS_COURSE_FIELD = "Is Course"
5960
###
6061

6162

process_report/processors/coldfront_fetch_processor.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
CF_ATTR_ALLOCATED_PROJECT_NAME = "Allocated Project Name"
2020
CF_ATTR_ALLOCATED_PROJECT_ID = "Allocated Project ID"
2121
CF_ATTR_INSTITUTION_SPECIFIC_CODE = "Institution-Specific Code"
22+
CF_ATTR_IS_COURSE = "Is Course?"
2223

2324

2425
@dataclass
@@ -96,10 +97,15 @@ def _get_allocation_data(self, coldfront_api_data):
9697
institute_code = project_dict["attributes"].get(
9798
CF_ATTR_INSTITUTION_SPECIFIC_CODE, "N/A"
9899
)
100+
is_course = (
101+
project_dict["attributes"].get(CF_ATTR_IS_COURSE, "No").lower()
102+
== "yes"
103+
)
99104
allocation_data[project_id] = {
100105
invoice.PROJECT_FIELD: project_name,
101106
invoice.PI_FIELD: pi_name,
102107
invoice.INSTITUTION_ID_FIELD: institute_code,
108+
invoice.IS_COURSE_FIELD: is_course,
103109
}
104110
except KeyError:
105111
continue
@@ -120,13 +126,15 @@ def _validate_allocation_data(self, allocation_data):
120126
)
121127

122128
def _apply_allocation_data(self, allocation_data):
129+
self.data[invoice.IS_COURSE_FIELD] = False
123130
for project_id, data in allocation_data.items():
124131
mask = self.data[invoice.PROJECT_ID_FIELD] == project_id
125132
self.data.loc[mask, invoice.PROJECT_FIELD] = data[invoice.PROJECT_FIELD]
126133
self.data.loc[mask, invoice.PI_FIELD] = data[invoice.PI_FIELD]
127134
self.data.loc[mask, invoice.INSTITUTION_ID_FIELD] = data[
128135
invoice.INSTITUTION_ID_FIELD
129136
]
137+
self.data.loc[mask, invoice.IS_COURSE_FIELD] = data[invoice.IS_COURSE_FIELD]
130138

131139
def _process(self):
132140
api_data = self._get_coldfront_api_data()

process_report/processors/validate_billable_pi_processor.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313

1414
NONBILLABLE_CLUSTERS = ["ocp-test"]
15+
NONBILLABLE_COURSE_INSTITUTIONS = ["Boston University"]
1516

1617

1718
@dataclass
@@ -56,6 +57,10 @@ def _str_to_lowercase(data):
5657
.apply(_str_to_lowercase)
5758
.isin(nonbillable_projects_lowercase)
5859
& ~data[invoice.CLUSTER_NAME_FIELD].isin(NONBILLABLE_CLUSTERS)
60+
& ~(
61+
data[invoice.IS_COURSE_FIELD]
62+
& data[invoice.INSTITUTION_FIELD].isin(NONBILLABLE_COURSE_INSTITUTIONS)
63+
)
5964
)
6065

6166
def _process(self):

process_report/tests/unit/processors/test_coldfront_fetch_processor.py

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def _get_test_invoice(
1313
pi=None,
1414
institute_code=None,
1515
cluster_name=None,
16+
is_course=None,
1617
):
1718
if not pi:
1819
pi = [""] * len(allocation_project_id)
@@ -26,31 +27,40 @@ def _get_test_invoice(
2627
if not cluster_name:
2728
cluster_name = [""] * len(allocation_project_id)
2829

30+
if not is_course:
31+
is_course = [None] * len(allocation_project_id)
32+
2933
return pandas.DataFrame(
3034
{
3135
"Manager (PI)": pi,
3236
"Project - Allocation": allocation_project_name,
3337
"Project - Allocation ID": allocation_project_id,
3438
"Institution - Specific Code": institute_code,
3539
"Cluster Name": cluster_name,
40+
"Is Course": is_course,
3641
}
3742
)
3843

39-
def _get_mock_allocation_data(self, project_id_list, pi_list, institute_code_list):
44+
def _get_mock_allocation_data(
45+
self, project_id_list, pi_list, institute_code_list, is_course_list=None
46+
):
4047
mock_data = []
4148
for i, project in enumerate(project_id_list):
42-
mock_data.append(
43-
{
44-
"project": {
45-
"pi": pi_list[i],
46-
},
47-
"attributes": {
48-
"Allocated Project ID": project,
49-
"Allocated Project Name": f"{project}-name",
50-
"Institution-Specific Code": institute_code_list[i],
51-
},
52-
}
53-
)
49+
mock_project_dict = {
50+
"project": {
51+
"pi": pi_list[i],
52+
},
53+
"attributes": {
54+
"Allocated Project ID": project,
55+
"Allocated Project Name": f"{project}-name",
56+
"Institution-Specific Code": institute_code_list[i],
57+
},
58+
}
59+
60+
if is_course_list:
61+
mock_project_dict["attributes"]["Is Course?"] = is_course_list[i]
62+
63+
mock_data.append(mock_project_dict)
5464

5565
return mock_data
5666

@@ -69,6 +79,7 @@ def test_coldfront_fetch(self, mock_get_allocation_data):
6979
["P1-name", "P1-name", "P2-name", "P3-name", "P4-name"],
7080
["PI1", "PI1", "PI1", "", "PI12"],
7181
["IC1", "IC1", "", "", "IC2"],
82+
is_course=[False] * 5,
7283
)
7384
test_coldfront_fetch_proc = test_utils.new_coldfront_fetch_processor(
7485
data=test_invoice
@@ -122,6 +133,64 @@ def test_nonbillable_clusters(self, mock_get_allocation_data):
122133
["PI1", "PI1", "", ""],
123134
["IC1", "IC2", "", ""],
124135
["ocp-prod", "stack", "ocp-test", "ocp-test"],
136+
[False] * 4,
137+
)
138+
test_coldfront_fetch_proc = test_utils.new_coldfront_fetch_processor(
139+
data=test_invoice
140+
)
141+
test_coldfront_fetch_proc.process()
142+
output_invoice = test_coldfront_fetch_proc.data
143+
assert output_invoice.equals(answer_invoice)
144+
145+
@mock.patch(
146+
"process_report.processors.coldfront_fetch_processor.ColdfrontFetchProcessor._fetch_coldfront_allocation_api",
147+
)
148+
def test_is_course_default_false(self, mock_get_allocation_data):
149+
"""If 'Is Course?' is not set in the API data, the output 'Is Course' column is False"""
150+
mock_get_allocation_data.return_value = self._get_mock_allocation_data(
151+
["P1", "P2"],
152+
["PI1", "PI2"],
153+
["IC1", "IC2"],
154+
)
155+
test_invoice = self._get_test_invoice(
156+
["P1", "P2", "P3"]
157+
) # P3 not in Coldfront API data
158+
answer_invoice = self._get_test_invoice(
159+
["P1", "P2", "P3"],
160+
["P1-name", "P2-name", ""],
161+
["PI1", "PI2", ""],
162+
["IC1", "IC2", ""],
163+
["", "", ""],
164+
[False, False, False],
165+
)
166+
test_coldfront_fetch_proc = test_utils.new_coldfront_fetch_processor(
167+
data=test_invoice, nonbillable_projects=["P3"]
168+
)
169+
test_coldfront_fetch_proc.process()
170+
output_invoice = test_coldfront_fetch_proc.data
171+
print(output_invoice)
172+
print(answer_invoice)
173+
assert output_invoice.equals(answer_invoice)
174+
175+
@mock.patch(
176+
"process_report.processors.coldfront_fetch_processor.ColdfrontFetchProcessor._fetch_coldfront_allocation_api",
177+
)
178+
def test_is_course_values(self, mock_get_allocation_data):
179+
"""If 'Is Course?' is set in the API data, the output 'Is Course' column reflects True/False"""
180+
mock_get_allocation_data.return_value = self._get_mock_allocation_data(
181+
["P1", "P2", "P3"],
182+
["PI1", "PI2", "PI3"],
183+
["IC1", "IC2", "IC3"],
184+
is_course_list=["Yes", "No", "yes"],
185+
)
186+
test_invoice = self._get_test_invoice(["P1", "P2", "P3"])
187+
answer_invoice = self._get_test_invoice(
188+
["P1", "P2", "P3"],
189+
["P1-name", "P2-name", "P3-name"],
190+
["PI1", "PI2", "PI3"],
191+
["IC1", "IC2", "IC3"],
192+
["", "", ""],
193+
[True, False, True],
125194
)
126195
test_coldfront_fetch_proc = test_utils.new_coldfront_fetch_processor(
127196
data=test_invoice

process_report/tests/unit/processors/test_validate_billable_pi_processor.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ def test_remove_nonbillables(self):
1212
projects = [uuid.uuid4().hex for _ in range(10)]
1313
cluster_names = [uuid.uuid4().hex for _ in range(10)]
1414
cluster_names[6:8] = ["ocp-test"] * 2 # Test that ocp-test is not billable
15+
institutions = ["Test University"] * len(pis)
16+
is_course = [False] * len(pis)
1517
nonbillable_pis = pis[:3]
1618
nonbillable_projects = [
1719
project.upper() for project in projects[7:]
@@ -23,6 +25,8 @@ def test_remove_nonbillables(self):
2325
"Manager (PI)": pis,
2426
"Project - Allocation": projects,
2527
"Cluster Name": cluster_names,
28+
"Is Course": is_course,
29+
"Institution": institutions,
2630
}
2731
)
2832

@@ -51,6 +55,8 @@ def test_empty_pi_name(self):
5155
"ProjectE",
5256
],
5357
"Cluster Name": ["test-cluster"] * 5,
58+
"Institution": ["Test University"] * 5,
59+
"Is Course": [False] * 5,
5460
}
5561
)
5662
assert len(test_data[pandas.isna(test_data["Manager (PI)"])]) == 1
@@ -61,3 +67,32 @@ def test_empty_pi_name(self):
6167
output_data = validate_billable_pi_proc.data
6268
output_data = output_data[~output_data["Missing PI"]]
6369
assert len(output_data[pandas.isna(output_data["Manager (PI)"])]) == 0
70+
71+
def test_is_course_marks_nonbillable(self):
72+
"""Rows with Is Course == True should be marked nonbillable; False should be billable."""
73+
pis = [uuid.uuid4().hex for _ in range(4)]
74+
projects = [uuid.uuid4().hex for _ in range(4)]
75+
cluster_names = ["test-cluster"] * 4
76+
# Only first project should be nonbillable
77+
is_course = [True, False, True, False]
78+
institutions = ["Boston University", "Boston University", "test", "test"]
79+
80+
test_data = pandas.DataFrame(
81+
{
82+
"Manager (PI)": pis,
83+
"Project - Allocation": projects,
84+
"Cluster Name": cluster_names,
85+
"Is Course": is_course,
86+
"Institution": institutions,
87+
}
88+
)
89+
90+
validate_proc = test_utils.new_validate_billable_pi_processor(
91+
data=test_data, nonbillable_pis=[], nonbillable_projects=[]
92+
)
93+
validate_proc.process()
94+
output = validate_proc.data
95+
96+
expected_billable = [False, True, True, True]
97+
actual_billable = output["Is Billable"].tolist()
98+
assert actual_billable == expected_billable

0 commit comments

Comments
 (0)