Skip to content

Commit ad15d5b

Browse files
authored
Merge pull request #5492 from HHS/OPS-5077/procurement-dashboard-details
Ops 5077/procurement dashboard details
2 parents 0d409bb + d3336d4 commit ad15d5b

35 files changed

Lines changed: 2223 additions & 58 deletions

backend/data_tools/data/agreements_and_blin_data.json5

Lines changed: 691 additions & 0 deletions
Large diffs are not rendered by default.

backend/data_tools/src/import_static_data/import_data.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,15 @@ def load_new_data(
122122
with Session(conn) as session:
123123
if "id" in data_without_associations:
124124
seq_needs_reset = True
125-
obj = model(**data_without_associations)
125+
# Resolve polymorphic subclass when discriminator is present in data
126+
obj_model = model
127+
mapper = model.__mapper__
128+
if mapper.polymorphic_on is not None:
129+
discriminator_key = mapper.polymorphic_on.key
130+
identity = data_without_associations.get(discriminator_key)
131+
if identity is not None:
132+
obj_model = mapper.polymorphic_map.get(identity, mapper).class_
133+
obj = obj_model(**data_without_associations)
126134
session.add(obj)
127135
session.commit()
128136
insert_associated_data(data_with_associations, obj, session)

backend/ops_api/ops/resources/agreements.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ def get(self) -> Response:
207207
"totals": metadata["totals"],
208208
"procurement_overview": metadata["procurement_overview"],
209209
"procurement_step_summary": metadata["procurement_step_summary"],
210+
"procurement_days_in_step": metadata["procurement_days_in_step"],
210211
}
211212

212213
return make_response_with_headers(response_data)

backend/ops_api/ops/services/agreements.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,12 +474,14 @@ def get_list(
474474
# Calculate procurement overview and step summary before pagination (only when requested)
475475
procurement_overview = None
476476
procurement_step_summary = None
477+
procurement_days_in_step = None
477478
if include_procurement:
478479
overview_fiscal_year = (
479480
filters.fiscal_year[0] if filters.fiscal_year and len(filters.fiscal_year) == 1 else None
480481
)
481482
procurement_overview = _compute_procurement_overview(all_results, overview_fiscal_year)
482483
procurement_step_summary = _compute_procurement_step_summary(all_results, overview_fiscal_year)
484+
procurement_days_in_step = _compute_days_in_procurement_step(all_results)
483485

484486
# Apply pagination slicing
485487
if filters.limit is not None and filters.offset is not None:
@@ -498,6 +500,7 @@ def get_list(
498500
"totals": totals,
499501
"procurement_overview": procurement_overview,
500502
"procurement_step_summary": procurement_step_summary,
503+
"procurement_days_in_step": procurement_days_in_step,
501504
}
502505

503506
return paginated_results, metadata
@@ -1016,6 +1019,56 @@ def _compute_procurement_step_summary(all_results: list[Agreement], fiscal_year:
10161019
}
10171020

10181021

1022+
def _compute_days_in_procurement_step(
1023+
all_results: list[Agreement],
1024+
) -> dict[int, dict[int, int]]:
1025+
"""Compute days in procurement step for each agreement's active step.
1026+
1027+
For each agreement with an active procurement tracker:
1028+
- Finds the active step
1029+
- If the step is completed, days = step_completed_date - step_start_date
1030+
- If the step is not completed, days = today - step_start_date
1031+
1032+
Returns a nested map: { step_number: { agreement_id: days_in_step } }
1033+
"""
1034+
today = date.today()
1035+
days_in_step: dict[int, dict[int, int]] = {}
1036+
1037+
for agreement in all_results:
1038+
tracker = next(
1039+
(
1040+
tracker
1041+
for tracker in agreement.procurement_trackers
1042+
if tracker.status == ProcurementTrackerStatus.ACTIVE and tracker.active_step_number is not None
1043+
),
1044+
None,
1045+
)
1046+
if tracker is None:
1047+
continue
1048+
1049+
step_number = tracker.active_step_number
1050+
if step_number < 1 or step_number > 6:
1051+
continue
1052+
1053+
active_step = next(
1054+
(step for step in tracker.steps if step.step_number == step_number),
1055+
None,
1056+
)
1057+
if active_step is None or active_step.step_start_date is None:
1058+
continue
1059+
1060+
if active_step.step_completed_date:
1061+
diff_days = (active_step.step_completed_date - active_step.step_start_date).days
1062+
else:
1063+
diff_days = (today - active_step.step_start_date).days
1064+
1065+
if step_number not in days_in_step:
1066+
days_in_step[step_number] = {}
1067+
days_in_step[step_number][agreement.id] = diff_days
1068+
1069+
return days_in_step
1070+
1071+
10191072
def _get_agreements(
10201073
session: Session,
10211074
agreement_cls: Type[Agreement],

backend/ops_api/tests/ops/services/test_agreements.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from ops_api.ops.services.agreements import (
2525
AgreementsService,
2626
_compute_agreement_totals,
27+
_compute_days_in_procurement_step,
2728
_compute_procurement_overview,
2829
_compute_procurement_step_summary,
2930
)
@@ -1810,3 +1811,152 @@ def test_zero_division_guard(self):
18101811
result = _compute_procurement_step_summary([], fiscal_year=2025)
18111812
for step in result["step_data"]:
18121813
assert step["agreements_percent"] == 0.0
1814+
1815+
1816+
def _make_mock_step(step_number, step_start_date=None, step_completed_date=None):
1817+
"""Helper to create a mock procurement tracker step."""
1818+
step = MagicMock()
1819+
step.step_number = step_number
1820+
step.step_start_date = step_start_date
1821+
step.step_completed_date = step_completed_date
1822+
return step
1823+
1824+
1825+
def _make_mock_tracker_with_steps(active_step_number, steps, status=None):
1826+
"""Helper to create a mock procurement tracker with steps."""
1827+
from models.procurement_tracker import ProcurementTrackerStatus
1828+
1829+
tracker = MagicMock()
1830+
tracker.active_step_number = active_step_number
1831+
tracker.status = status if status is not None else ProcurementTrackerStatus.ACTIVE
1832+
tracker.steps = steps
1833+
return tracker
1834+
1835+
1836+
class TestComputeDaysInProcurementStep:
1837+
def test_empty_list(self):
1838+
result = _compute_days_in_procurement_step([])
1839+
assert result == {}
1840+
1841+
def test_agreement_without_tracker(self):
1842+
ag = _make_mock_procurement_agreement(trackers=[])
1843+
result = _compute_days_in_procurement_step([ag])
1844+
assert result == {}
1845+
1846+
def test_completed_step_uses_completed_date(self):
1847+
step = _make_mock_step(3, step_start_date=date(2025, 1, 1), step_completed_date=date(2025, 1, 11))
1848+
tracker = _make_mock_tracker_with_steps(3, steps=[step])
1849+
ag = _make_mock_procurement_agreement(trackers=[tracker], agreement_id=10)
1850+
1851+
result = _compute_days_in_procurement_step([ag])
1852+
1853+
assert result == {3: {10: 10}}
1854+
1855+
@patch("ops_api.ops.services.agreements.date")
1856+
def test_incomplete_step_uses_today(self, mock_date):
1857+
mock_date.today.return_value = date(2025, 3, 1)
1858+
mock_date.side_effect = lambda *args, **kwargs: date(*args, **kwargs)
1859+
1860+
step = _make_mock_step(2, step_start_date=date(2025, 1, 1), step_completed_date=None)
1861+
tracker = _make_mock_tracker_with_steps(2, steps=[step])
1862+
ag = _make_mock_procurement_agreement(trackers=[tracker], agreement_id=5)
1863+
1864+
result = _compute_days_in_procurement_step([ag])
1865+
1866+
assert result == {2: {5: 59}}
1867+
1868+
def test_skips_inactive_tracker(self):
1869+
from models.procurement_tracker import ProcurementTrackerStatus
1870+
1871+
step = _make_mock_step(1, step_start_date=date(2025, 1, 1))
1872+
tracker = _make_mock_tracker_with_steps(1, steps=[step], status=ProcurementTrackerStatus.INACTIVE)
1873+
ag = _make_mock_procurement_agreement(trackers=[tracker], agreement_id=1)
1874+
1875+
result = _compute_days_in_procurement_step([ag])
1876+
assert result == {}
1877+
1878+
def test_skips_completed_tracker(self):
1879+
from models.procurement_tracker import ProcurementTrackerStatus
1880+
1881+
step = _make_mock_step(1, step_start_date=date(2025, 1, 1))
1882+
tracker = _make_mock_tracker_with_steps(1, steps=[step], status=ProcurementTrackerStatus.COMPLETED)
1883+
ag = _make_mock_procurement_agreement(trackers=[tracker], agreement_id=1)
1884+
1885+
result = _compute_days_in_procurement_step([ag])
1886+
assert result == {}
1887+
1888+
def test_skips_step_out_of_range(self):
1889+
step = _make_mock_step(7, step_start_date=date(2025, 1, 1))
1890+
tracker = _make_mock_tracker_with_steps(7, steps=[step])
1891+
ag = _make_mock_procurement_agreement(trackers=[tracker], agreement_id=1)
1892+
1893+
result = _compute_days_in_procurement_step([ag])
1894+
assert result == {}
1895+
1896+
def test_skips_step_zero(self):
1897+
step = _make_mock_step(0, step_start_date=date(2025, 1, 1))
1898+
tracker = _make_mock_tracker_with_steps(0, steps=[step])
1899+
ag = _make_mock_procurement_agreement(trackers=[tracker], agreement_id=1)
1900+
1901+
result = _compute_days_in_procurement_step([ag])
1902+
assert result == {}
1903+
1904+
def test_skips_step_without_start_date(self):
1905+
step = _make_mock_step(3, step_start_date=None)
1906+
tracker = _make_mock_tracker_with_steps(3, steps=[step])
1907+
ag = _make_mock_procurement_agreement(trackers=[tracker], agreement_id=1)
1908+
1909+
result = _compute_days_in_procurement_step([ag])
1910+
assert result == {}
1911+
1912+
def test_skips_when_active_step_not_in_steps_list(self):
1913+
step = _make_mock_step(1, step_start_date=date(2025, 1, 1))
1914+
tracker = _make_mock_tracker_with_steps(4, steps=[step]) # active_step_number=4 but only step 1 exists
1915+
ag = _make_mock_procurement_agreement(trackers=[tracker], agreement_id=1)
1916+
1917+
result = _compute_days_in_procurement_step([ag])
1918+
assert result == {}
1919+
1920+
def test_multiple_agreements_same_step(self):
1921+
step1 = _make_mock_step(2, step_start_date=date(2025, 1, 1), step_completed_date=date(2025, 1, 6))
1922+
tracker1 = _make_mock_tracker_with_steps(2, steps=[step1])
1923+
ag1 = _make_mock_procurement_agreement(trackers=[tracker1], agreement_id=10)
1924+
1925+
step2 = _make_mock_step(2, step_start_date=date(2025, 1, 1), step_completed_date=date(2025, 1, 21))
1926+
tracker2 = _make_mock_tracker_with_steps(2, steps=[step2])
1927+
ag2 = _make_mock_procurement_agreement(trackers=[tracker2], agreement_id=20)
1928+
1929+
result = _compute_days_in_procurement_step([ag1, ag2])
1930+
1931+
assert result == {2: {10: 5, 20: 20}}
1932+
1933+
def test_multiple_agreements_different_steps(self):
1934+
step1 = _make_mock_step(1, step_start_date=date(2025, 1, 1), step_completed_date=date(2025, 1, 4))
1935+
tracker1 = _make_mock_tracker_with_steps(1, steps=[step1])
1936+
ag1 = _make_mock_procurement_agreement(trackers=[tracker1], agreement_id=1)
1937+
1938+
step2 = _make_mock_step(5, step_start_date=date(2025, 2, 1), step_completed_date=date(2025, 2, 15))
1939+
tracker2 = _make_mock_tracker_with_steps(5, steps=[step2])
1940+
ag2 = _make_mock_procurement_agreement(trackers=[tracker2], agreement_id=2)
1941+
1942+
result = _compute_days_in_procurement_step([ag1, ag2])
1943+
1944+
assert result == {1: {1: 3}, 5: {2: 14}}
1945+
1946+
def test_uses_active_tracker_ignores_completed(self):
1947+
from models.procurement_tracker import ProcurementTrackerStatus
1948+
1949+
step_completed = _make_mock_step(2, step_start_date=date(2025, 1, 1), step_completed_date=date(2025, 1, 5))
1950+
completed_tracker = _make_mock_tracker_with_steps(
1951+
2, steps=[step_completed], status=ProcurementTrackerStatus.COMPLETED
1952+
)
1953+
1954+
step_active = _make_mock_step(4, step_start_date=date(2025, 3, 1), step_completed_date=date(2025, 3, 11))
1955+
active_tracker = _make_mock_tracker_with_steps(4, steps=[step_active])
1956+
1957+
ag = _make_mock_procurement_agreement(trackers=[completed_tracker, active_tracker], agreement_id=7)
1958+
1959+
result = _compute_days_in_procurement_step([ag])
1960+
1961+
assert result == {4: {7: 10}}
1962+
assert 2 not in result

frontend/src/api/opsAPI.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ export const opsApi = createApi({
174174
offset: response.offset,
175175
totals: response.totals ?? null,
176176
procurement_overview: response.procurement_overview ?? null,
177-
procurement_step_summary: response.procurement_step_summary ?? null
177+
procurement_step_summary: response.procurement_step_summary ?? null,
178+
procurement_days_in_step: response.procurement_days_in_step ?? null
178179
};
179180
}
180181
// Backward compatibility with old "agreements" key
@@ -186,7 +187,8 @@ export const opsApi = createApi({
186187
offset: response.offset,
187188
totals: response.totals ?? null,
188189
procurement_overview: response.procurement_overview ?? null,
189-
procurement_step_summary: response.procurement_step_summary ?? null
190+
procurement_step_summary: response.procurement_step_summary ?? null,
191+
procurement_days_in_step: response.procurement_days_in_step ?? null
190192
};
191193
}
192194
// Legacy array format (no pagination)
@@ -197,7 +199,8 @@ export const opsApi = createApi({
197199
offset: 0,
198200
totals: null,
199201
procurement_overview: null,
200-
procurement_step_summary: null
202+
procurement_step_summary: null,
203+
procurement_days_in_step: null
201204
};
202205
},
203206
providesTags: ["Agreements", "BudgetLineItems"]

frontend/src/api/opsAPI.test.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,8 @@ describe("opsAPI - Agreements Pagination", () => {
214214
offset: 0,
215215
totals: null,
216216
procurement_overview: null,
217-
procurement_step_summary: null
217+
procurement_step_summary: null,
218+
procurement_days_in_step: null
218219
});
219220
});
220221

@@ -262,7 +263,8 @@ describe("opsAPI - Agreements Pagination", () => {
262263
offset: 0,
263264
totals: null,
264265
procurement_overview: null,
265-
procurement_step_summary: null
266+
procurement_step_summary: null,
267+
procurement_days_in_step: null
266268
});
267269
});
268270

@@ -303,7 +305,8 @@ describe("opsAPI - Agreements Pagination", () => {
303305
offset: 0,
304306
totals: null,
305307
procurement_overview: null,
306-
procurement_step_summary: null
308+
procurement_step_summary: null,
309+
procurement_days_in_step: null
307310
});
308311
});
309312

@@ -345,7 +348,8 @@ describe("opsAPI - Agreements Pagination", () => {
345348
offset: 0,
346349
totals: null,
347350
procurement_overview: null,
348-
procurement_step_summary: null
351+
procurement_step_summary: null,
352+
procurement_days_in_step: null
349353
});
350354
});
351355

@@ -735,7 +739,8 @@ describe("opsAPI - Agreements Pagination", () => {
735739
offset: 0,
736740
totals: null,
737741
procurement_overview: null,
738-
procurement_step_summary: null
742+
procurement_step_summary: null,
743+
procurement_days_in_step: null
739744
});
740745
});
741746
});

frontend/src/components/UI/FiscalYear/FiscalYear.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const FiscalYear = ({ fiscalYear, handleChangeFiscalYear, fiscalYears = [], show
1414

1515
return (
1616
<div
17-
className="display-flex flex-justify flex-align-center"
17+
className="display-flex flex-justify flex-align-center flex-align-baseline"
1818
style={{ width: "10.625rem" }}
1919
>
2020
<label

frontend/src/components/UI/Table/table.module.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,8 @@
1212
vertical-align: top;
1313
padding-bottom: 0 !important;
1414
}
15+
16+
.procurementDetailsTable td:nth-child(2),
17+
.procurementDetailsTable th:nth-child(2) {
18+
padding-left: 2rem !important;
19+
}

0 commit comments

Comments
 (0)