Skip to content

Commit 4c9624a

Browse files
authored
Merge branch 'main' into fix/180-suppress-warning
2 parents 6e29578 + 54019b2 commit 4c9624a

13 files changed

Lines changed: 235 additions & 74 deletions

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v4.5.0
3+
rev: v5.0.0
44
hooks:
55
- id: trailing-whitespace
66
- id: check-merge-conflict
@@ -10,7 +10,7 @@ repos:
1010
- id: detect-private-key
1111

1212
- repo: https://github.com/astral-sh/ruff-pre-commit
13-
rev: v0.2.1
13+
rev: v0.11.10
1414
hooks:
1515
- id: ruff
1616
- id: ruff-format

process_report/institute_list.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,6 @@
9999
- display_name: The Synergist
100100
domains:
101101
- thesynergist.org
102+
- display_name: CipherSonic Labs
103+
domains:
104+
- ciphersoniclabs.io

process_report/invoices/NERC_total_invoice.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,6 @@ def output_s3_archive_key(self):
4949
return f"Invoices/{self.invoice_month}/Archive/NERC-{self.invoice_month}-Total-Invoice {util.get_iso8601_time()}.csv"
5050

5151
def _prepare_export(self):
52-
def _lower_col(data):
53-
if data:
54-
return str.lower(data)
55-
5652
included_institutions = list()
5753
institute_list = util.load_institute_list()
5854
for institute_info in institute_list.root:
@@ -64,5 +60,5 @@ def _lower_col(data):
6460
]
6561
self.export_data = self.export_data[
6662
self.export_data[invoice.INSTITUTION_FIELD].isin(included_institutions)
67-
| (self.export_data[invoice.GROUP_MANAGED_FIELD].apply(_lower_col) == "yes")
63+
| self.export_data[invoice.GROUP_MANAGED_FIELD]
6864
]

process_report/invoices/invoice.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
PI_BALANCE_FIELD = "PI Balance"
5656
PROJECT_NAME_FIELD = "Project"
5757
GROUP_MANAGED_FIELD = "MGHPCC Managed"
58+
CLUSTER_NAME_FIELD = "Cluster Name"
5859
###
5960

6061

process_report/processors/add_institution_processor.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ def _add_institution(self):
3434
if pandas.isna(pi_name):
3535
logger.info(f"Project {row[invoice.PROJECT_FIELD]} has no PI")
3636
else:
37-
self.data.at[
38-
i, invoice.INSTITUTION_FIELD
39-
] = util.get_institution_from_pi(institute_map, pi_name)
37+
self.data.at[i, invoice.INSTITUTION_FIELD] = (
38+
util.get_institution_from_pi(institute_map, pi_name)
39+
)
4040

4141
def _process(self):
4242
self._add_institution()

process_report/processors/coldfront_fetch_processor.py

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
import requests
88

99
from process_report.invoices import invoice
10-
from process_report.processors import processor
10+
from process_report.processors import processor, validate_billable_pi_processor
1111

1212
logger = logging.getLogger(__name__)
1313
logging.basicConfig(level=logging.INFO)
1414

1515

1616
CF_ATTR_ALLOCATED_PROJECT_NAME = "Allocated Project Name"
17+
CF_ATTR_ALLOCATED_PROJECT_ID = "Allocated Project ID"
18+
CF_ATTR_INSTITUTION_SPECIFIC_CODE = "Institution-Specific Code"
1719

1820

1921
@dataclass
@@ -49,58 +51,69 @@ def coldfront_client(self):
4951
session.headers.update(headers)
5052
return session
5153

52-
def _get_project_list(self):
53-
return self.data[invoice.PROJECT_FIELD].unique()
54+
def _get_project_id_list(self):
55+
"""Returns list of project IDs from billable clusters"""
56+
nonbillable_cluster_mask = ~self.data[invoice.CLUSTER_NAME_FIELD].isin(
57+
validate_billable_pi_processor.NONBILLABLE_CLUSTERS
58+
)
59+
return self.data[nonbillable_cluster_mask][invoice.PROJECT_ID_FIELD].unique()
5460

55-
def _fetch_coldfront_allocation_api(self, allocation_list):
61+
def _fetch_coldfront_allocation_api(self):
5662
coldfront_api_url = os.environ.get(
5763
"COLDFRONT_URL", "https://coldfront.mss.mghpcc.org/api/allocations"
5864
)
59-
api_query_str = "&".join(
60-
[
61-
f"attr_{CF_ATTR_ALLOCATED_PROJECT_NAME}={project}"
62-
for project in allocation_list
63-
]
64-
)
65-
r = self.coldfront_client.get(f"{coldfront_api_url}?{api_query_str}")
65+
r = self.coldfront_client.get(f"{coldfront_api_url}?all=true")
6666

6767
return r.json()
6868

6969
def _get_allocation_data(self, coldfront_api_data):
70+
"""Returns a mapping of project IDs to a dict of project name, PI name, and institution code."""
7071
allocation_data = {}
71-
for project, project_dict in coldfront_api_data.items():
72-
allocation_data[project] = {
73-
invoice.PI_FIELD: project_dict["project"]["pi"],
74-
invoice.INSTITUTION_ID_FIELD: project_dict["attributes"][
75-
"Institution-Specific Code"
76-
],
77-
}
72+
for project_dict in coldfront_api_data:
73+
try:
74+
# Allow allocation to not have institute code
75+
project_id = project_dict["attributes"][CF_ATTR_ALLOCATED_PROJECT_ID]
76+
project_name = project_dict["attributes"][
77+
CF_ATTR_ALLOCATED_PROJECT_NAME
78+
]
79+
pi_name = project_dict["project"]["pi"]
80+
institute_code = project_dict["attributes"].get(
81+
CF_ATTR_INSTITUTION_SPECIFIC_CODE, "N/A"
82+
)
83+
allocation_data[project_id] = {
84+
invoice.PROJECT_FIELD: project_name,
85+
invoice.PI_FIELD: pi_name,
86+
invoice.INSTITUTION_ID_FIELD: institute_code,
87+
}
88+
except KeyError:
89+
continue
90+
7891
return allocation_data
7992

8093
def _validate_allocation_data(self, allocation_data):
8194
missing_projects = (
82-
set(self._get_project_list())
95+
set(self._get_project_id_list())
8396
- set(allocation_data.keys())
8497
- set(self.nonbillable_projects)
8598
)
8699
missing_projects = list(missing_projects)
87100
missing_projects.sort() # Ensures order for testing purposes
88101
if missing_projects:
89-
logger.warning(
102+
raise ValueError(
90103
f"Projects {missing_projects} not found in Coldfront and are billable! Please check the project names"
91104
)
92105

93106
def _apply_allocation_data(self, allocation_data):
94-
for project, data in allocation_data.items():
95-
mask = self.data[invoice.PROJECT_FIELD] == project
107+
for project_id, data in allocation_data.items():
108+
mask = self.data[invoice.PROJECT_ID_FIELD] == project_id
109+
self.data.loc[mask, invoice.PROJECT_FIELD] = data[invoice.PROJECT_FIELD]
96110
self.data.loc[mask, invoice.PI_FIELD] = data[invoice.PI_FIELD]
97111
self.data.loc[mask, invoice.INSTITUTION_ID_FIELD] = data[
98112
invoice.INSTITUTION_ID_FIELD
99113
]
100114

101115
def _process(self):
102-
project_allocations_list = self._get_project_list()
103-
api_data = self._fetch_coldfront_allocation_api(project_allocations_list)
116+
api_data = self._fetch_coldfront_allocation_api()
104117
allocation_data = self._get_allocation_data(api_data)
105118
self._validate_allocation_data(allocation_data)
106119
self._apply_allocation_data(allocation_data)

process_report/processors/prepayment_processor.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ def _get_prepay_group_dict(self):
6666
for _, group_info in self.prepay_contacts.iterrows():
6767
group_name = group_info[invoice.PREPAY_GROUP_NAME_FIELD]
6868
prepay_group_dict[group_name] = dict()
69-
prepay_group_dict[group_name][
70-
invoice.PREPAY_GROUP_CONTACT_FIELD
71-
] = group_info[invoice.PREPAY_GROUP_CONTACT_FIELD]
72-
prepay_group_dict[group_name][invoice.PREPAY_MANAGED_FIELD] = group_info[
73-
invoice.PREPAY_MANAGED_FIELD
74-
]
69+
prepay_group_dict[group_name][invoice.PREPAY_GROUP_CONTACT_FIELD] = (
70+
group_info[invoice.PREPAY_GROUP_CONTACT_FIELD]
71+
)
72+
prepay_group_dict[group_name][invoice.PREPAY_MANAGED_FIELD] = (
73+
group_info[invoice.PREPAY_MANAGED_FIELD].lower() == "yes"
74+
)
7575
prepay_group_dict[group_name][invoice.GROUP_BALANCE_FIELD] = 0
7676
prepay_group_dict[group_name][invoice.PREPAY_PROJECT_FIELD] = []
7777

process_report/processors/validate_billable_pi_processor.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,18 @@
1010
logging.basicConfig(level=logging.INFO)
1111

1212

13+
NONBILLABLE_CLUSTERS = ["ocp-test"]
14+
15+
1316
@dataclass
1417
class ValidateBillablePIsProcessor(processor.Processor):
18+
"""
19+
This processor validates the billable PIs and projects in the data,
20+
and determines if a project is billable or not.
21+
22+
Every project belonging to ocp-test is nonbillable.
23+
"""
24+
1525
nonbillable_pis: list[str]
1626
nonbillable_projects: list[str]
1727

@@ -37,9 +47,13 @@ def _str_to_lowercase(data):
3747
nonbillable_projects_lowercase = [
3848
project.lower() for project in nonbillable_projects
3949
]
40-
return ~data[invoice.PI_FIELD].isin(nonbillable_pis) & ~data[
41-
invoice.PROJECT_FIELD
42-
].apply(_str_to_lowercase).isin(nonbillable_projects_lowercase)
50+
return (
51+
~data[invoice.PI_FIELD].isin(nonbillable_pis)
52+
& ~data[invoice.PROJECT_FIELD]
53+
.apply(_str_to_lowercase)
54+
.isin(nonbillable_projects_lowercase)
55+
& ~data[invoice.CLUSTER_NAME_FIELD].isin(NONBILLABLE_CLUSTERS)
56+
)
4357

4458
def _process(self):
4559
self.data[invoice.IS_BILLABLE_FIELD] = self._get_billables(
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from unittest import TestCase, mock
2+
import pandas
3+
4+
from process_report.tests import util as test_utils
5+
from process_report.institute_list_models import InstituteList
6+
7+
8+
class TestNERCTotalInvoice(TestCase):
9+
def _get_test_invoice(
10+
self,
11+
institutions,
12+
is_billable=None,
13+
missing_pi=None,
14+
group_managed=None,
15+
):
16+
if not is_billable:
17+
is_billable = [True for _ in range(len(institutions))]
18+
19+
if not missing_pi:
20+
missing_pi = [False for _ in range(len(institutions))]
21+
22+
if not group_managed:
23+
group_managed = [True for _ in range(len(institutions))]
24+
25+
return pandas.DataFrame(
26+
{
27+
"Institution": institutions,
28+
"Is Billable": is_billable,
29+
"Missing PI": missing_pi,
30+
"MGHPCC Managed": group_managed,
31+
}
32+
)
33+
34+
@mock.patch("process_report.util.load_institute_list")
35+
def test_prepare_export(self, mock_load_institute_list):
36+
"""Basic test for coverage of the _prepare_export method."""
37+
mock_load_institute_list.return_value = InstituteList.model_validate(
38+
[
39+
{
40+
"display_name": "Institution A",
41+
"domains": ["a.edu"],
42+
"include_in_nerc_total_invoice": True,
43+
},
44+
{
45+
"display_name": "Institution B",
46+
"domains": ["b.edu"],
47+
"include_in_nerc_total_invoice": True,
48+
},
49+
{
50+
"display_name": "Institution C",
51+
"domains": ["c.edu"],
52+
"include_in_nerc_total_invoice": False,
53+
},
54+
]
55+
)
56+
57+
test_invoice = self._get_test_invoice(
58+
institutions=["Institution A", "Institution B", "Institution C"],
59+
group_managed=[False] * 3, # Institution C should not be included
60+
)
61+
answer_invoice = test_invoice.copy()[0:2]
62+
63+
test_inv = test_utils.new_nerc_total_invoice(
64+
data=test_invoice,
65+
invoice_month="2025-01",
66+
)
67+
test_inv._prepare_export()
68+
output_invoice = test_inv.export_data
69+
self.assertTrue(output_invoice.equals(answer_invoice))

0 commit comments

Comments
 (0)