Skip to content

Commit f0edd91

Browse files
committed
Merge remote-tracking branch 'origin/OPS-5073/procurement-dashboard-initial-layout' into OPS-5077/procurement-dashboard-details
2 parents 5612ccb + 5885955 commit f0edd91

20 files changed

Lines changed: 1782 additions & 103 deletions

File tree

.github/workflows/security_semgrep.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
name: Semgrep Analyze
1010
runs-on: ubuntu-latest
1111
container:
12-
image: returntocorp/semgrep@sha256:7810f1d7884974ab6dda7bef8f4a2c8e165ea2142fd8260515d380e4f1407263 # 1.47.0
12+
image: returntocorp/semgrep@sha256:326e5f41cc972bb423b764a14febbb62bbad29ee1c01820805d077dd868fea48 # 1.47.0
1313
steps:
1414
- name: Checkout
1515
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

backend/openapi.yml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3564,9 +3564,23 @@ paths:
35643564
agreement_names:
35653565
type: array
35663566
items:
3567-
type: string
3568-
description: List of agreement names and nicknames associated with projects (sorted alphabetically, no duplicates)
3569-
example: ["Agreement 001", "Contract Alpha", "Grant Beta"]
3567+
type: object
3568+
required:
3569+
- id
3570+
- name
3571+
properties:
3572+
id:
3573+
type: integer
3574+
name:
3575+
type: string
3576+
description: List of agreement names and nicknames associated with projects (sorted alphabetically by name, no duplicates)
3577+
example:
3578+
- id: 1
3579+
name: "Agreement 001"
3580+
- id: 2
3581+
name: "Contract Alpha"
3582+
- id: 3
3583+
name: "Grant Beta"
35703584
"401":
35713585
description: Unauthorized
35723586
"500":

backend/ops_api/ops/schemas/projects.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ class ProjectListFilterOptionResponseSchema(Schema):
208208
portfolios = fields.List(fields.Dict(keys=fields.String(), values=fields.Raw()), required=True)
209209
project_titles = fields.List(fields.Dict(keys=fields.String(), values=fields.Raw()), required=True)
210210
project_types = fields.List(fields.String(), required=True)
211-
agreement_names = fields.List(fields.String(), required=True)
211+
agreement_names = fields.List(fields.Nested(AgreementNameListItem), required=True)
212212

213213

214214
class ProjectFundingRequestSchema(Schema):

backend/ops_api/ops/services/projects.py

Lines changed: 110 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
User,
2121
)
2222
from ops_api.ops.services.ops_service import OpsService, ResourceNotFoundError, ValidationError
23-
from ops_api.ops.utils.query_helpers import QueryHelper
2423

2524

2625
@dataclass
@@ -227,12 +226,10 @@ def _get_research_projects_query(filters: ProjectFilters):
227226
Returns:
228227
SQLAlchemy select statement
229228
"""
229+
# Base query with all necessary eager loading
230230
stmt = (
231231
select(ResearchProject)
232232
.distinct(ResearchProject.id)
233-
.join(Agreement, isouter=True)
234-
.join(BudgetLineItem, isouter=True)
235-
.join(CAN, isouter=True)
236233
.options(
237234
selectinload(ResearchProject.agreements).selectinload(Agreement.services_components),
238235
selectinload(ResearchProject.agreements)
@@ -244,35 +241,63 @@ def _get_research_projects_query(filters: ProjectFilters):
244241
)
245242
)
246243

247-
query_helper = QueryHelper(stmt)
244+
# For filtering, we use EXISTS subqueries to ensure each filter can match different
245+
# related records. This prevents the issue where a single joined row must satisfy
246+
# multiple conditions across different tables (e.g., one agreement for fiscal year
247+
# and a different agreement for agreement name).
248+
where_clauses = []
248249

249-
# Apply portfolio filter (OR logic - match any portfolio)
250+
# Apply portfolio filter using EXISTS subquery
250251
if filters.portfolio_id:
251-
query_helper.add_column_in_list(CAN.portfolio_id, filters.portfolio_id)
252+
portfolio_subquery = (
253+
select(1)
254+
.select_from(Agreement)
255+
.join(BudgetLineItem)
256+
.join(CAN)
257+
.where(Agreement.project_id == ResearchProject.id)
258+
.where(CAN.portfolio_id.in_(filters.portfolio_id))
259+
.exists()
260+
)
261+
where_clauses.append(portfolio_subquery)
252262

253-
# Apply fiscal year filter (OR logic - match any fiscal year)
263+
# Apply fiscal year filter using EXISTS subquery
254264
if filters.fiscal_year:
255-
if len(filters.fiscal_year) == 1:
256-
fiscal_year = filters.fiscal_year[0]
257-
query_helper.add_column_equals(BudgetLineItem.fiscal_year, fiscal_year)
258-
else:
259-
# Multiple fiscal years - use IN clause
260-
query_helper.add_column_in_list(BudgetLineItem.fiscal_year, filters.fiscal_year)
265+
fy_subquery = (
266+
select(1)
267+
.select_from(Agreement)
268+
.join(BudgetLineItem)
269+
.where(Agreement.project_id == ResearchProject.id)
270+
.where(BudgetLineItem.fiscal_year.in_(filters.fiscal_year))
271+
.exists()
272+
)
273+
where_clauses.append(fy_subquery)
261274

262275
# Apply project search filter on project title (OR logic, exact match on title/short title)
263276
if filters.project_search:
264-
query_helper.where_clauses.append(
265-
or_(Project.title.in_(filters.project_search), Project.short_title.in_(filters.project_search))
277+
where_clauses.append(
278+
or_(
279+
ResearchProject.title.in_(filters.project_search),
280+
ResearchProject.short_title.in_(filters.project_search),
281+
)
266282
)
267283

268-
# Apply agreement search filter on agreement name and nick_name (exact match - OR logic)
269-
# Projects are returned if any agreement has name OR nick_name matching any search term
284+
# Apply agreement search filter using EXISTS subquery
270285
if filters.agreement_search:
271-
query_helper.where_clauses.append(
272-
or_(Agreement.name.in_(filters.agreement_search), Agreement.nick_name.in_(filters.agreement_search))
286+
agreement_subquery = (
287+
select(1)
288+
.select_from(Agreement)
289+
.where(Agreement.project_id == ResearchProject.id)
290+
.where(
291+
or_(Agreement.name.in_(filters.agreement_search), Agreement.nick_name.in_(filters.agreement_search))
292+
)
293+
.exists()
273294
)
295+
where_clauses.append(agreement_subquery)
296+
297+
# Apply all where clauses
298+
if where_clauses:
299+
stmt = stmt.where(*where_clauses)
274300

275-
stmt = query_helper.get_stmt()
276301
logger.debug(f"SQL: {stmt}")
277302

278303
return stmt
@@ -287,55 +312,78 @@ def _get_administrative_and_support_projects_query(filters: ProjectFilters):
287312
Returns:
288313
SQLAlchemy select statement
289314
"""
315+
# Base query with all necessary eager loading
290316
stmt = (
291317
select(AdministrativeAndSupportProject)
292318
.distinct(AdministrativeAndSupportProject.id)
293-
.join(Agreement, isouter=True)
294-
.join(BudgetLineItem, isouter=True)
295-
.join(CAN, isouter=True)
296319
.options(
297-
selectinload(ResearchProject.agreements).selectinload(Agreement.services_components),
298-
selectinload(ResearchProject.agreements)
320+
selectinload(AdministrativeAndSupportProject.agreements).selectinload(Agreement.services_components),
321+
selectinload(AdministrativeAndSupportProject.agreements)
299322
.selectinload(Agreement.budget_line_items)
300323
.selectinload(BudgetLineItem.can),
301-
selectinload(ResearchProject.agreements).selectinload(Agreement.special_topics),
302-
selectinload(ResearchProject.agreements).selectinload(Agreement.research_methodologies),
303-
selectinload(ResearchProject.agreements).selectinload(Agreement.team_members),
324+
selectinload(AdministrativeAndSupportProject.agreements).selectinload(Agreement.special_topics),
325+
selectinload(AdministrativeAndSupportProject.agreements).selectinload(Agreement.research_methodologies),
326+
selectinload(AdministrativeAndSupportProject.agreements).selectinload(Agreement.team_members),
304327
)
305328
)
306329

307-
query_helper = QueryHelper(stmt)
330+
# For filtering, we use EXISTS subqueries to ensure each filter can match different
331+
# related records. This prevents the issue where a single joined row must satisfy
332+
# multiple conditions across different tables (e.g., one agreement for fiscal year
333+
# and a different agreement for agreement name).
334+
where_clauses = []
308335

309-
# Apply portfolio filter (OR logic - match any portfolio)
336+
# Apply portfolio filter using EXISTS subquery
310337
if filters.portfolio_id:
311-
query_helper.add_column_in_list(CAN.portfolio_id, filters.portfolio_id)
338+
portfolio_subquery = (
339+
select(1)
340+
.select_from(Agreement)
341+
.join(BudgetLineItem)
342+
.join(CAN)
343+
.where(Agreement.project_id == AdministrativeAndSupportProject.id)
344+
.where(CAN.portfolio_id.in_(filters.portfolio_id))
345+
.exists()
346+
)
347+
where_clauses.append(portfolio_subquery)
312348

313-
# Apply fiscal year filter (OR logic - match any fiscal year)
349+
# Apply fiscal year filter using EXISTS subquery
314350
if filters.fiscal_year:
315-
if len(filters.fiscal_year) == 1:
316-
fiscal_year = filters.fiscal_year[0]
317-
query_helper.add_column_equals(BudgetLineItem.fiscal_year, fiscal_year)
318-
else:
319-
# Multiple fiscal years - use IN clause
320-
query_helper.add_column_in_list(BudgetLineItem.fiscal_year, filters.fiscal_year)
351+
fy_subquery = (
352+
select(1)
353+
.select_from(Agreement)
354+
.join(BudgetLineItem)
355+
.where(Agreement.project_id == AdministrativeAndSupportProject.id)
356+
.where(BudgetLineItem.fiscal_year.in_(filters.fiscal_year))
357+
.exists()
358+
)
359+
where_clauses.append(fy_subquery)
321360

322-
# Apply project search filter on project title (AND logic - must match all search terms)
361+
# Apply project search filter on project title (OR logic, exact match on title/short title)
323362
if filters.project_search:
324-
query_helper.where_clauses.append(
363+
where_clauses.append(
325364
or_(
326-
Project.title.in_(filters.project_search),
327-
Project.short_title.in_(filters.project_search),
365+
AdministrativeAndSupportProject.title.in_(filters.project_search),
366+
AdministrativeAndSupportProject.short_title.in_(filters.project_search),
328367
)
329368
)
330369

331-
# Apply agreement search filter on agreement name and nick_name (exact match - OR logic)
332-
# Projects are returned if any agreement has name OR nick_name matching any search term
370+
# Apply agreement search filter using EXISTS subquery
333371
if filters.agreement_search:
334-
query_helper.where_clauses.append(
335-
or_(Agreement.name.in_(filters.agreement_search), Agreement.nick_name.in_(filters.agreement_search))
372+
agreement_subquery = (
373+
select(1)
374+
.select_from(Agreement)
375+
.where(Agreement.project_id == AdministrativeAndSupportProject.id)
376+
.where(
377+
or_(Agreement.name.in_(filters.agreement_search), Agreement.nick_name.in_(filters.agreement_search))
378+
)
379+
.exists()
336380
)
381+
where_clauses.append(agreement_subquery)
382+
383+
# Apply all where clauses
384+
if where_clauses:
385+
stmt = stmt.where(*where_clauses)
337386

338-
stmt = query_helper.get_stmt()
339387
logger.debug(f"SQL: {stmt}")
340388

341389
return stmt
@@ -626,24 +674,27 @@ def get_filter_options(self) -> dict[str, Any]:
626674
)
627675
project_types = sorted([pt.name for pt in self.db_session.scalars(project_types_query).all()])
628676

629-
# Step 6: Agreement names and nick_names - Query both and create a sorted list
677+
# Step 6: Agreement names and nick_names - Query both and create a sorted list of dicts
630678
agreement_names_query = (
631679
select(Agreement.id, Agreement.name, Agreement.nick_name)
632680
.join(Project, Agreement.project_id == Project.id)
633681
.where(Project.id.in_(project_ids_subquery))
634682
.where(Agreement.name.isnot(None))
635683
)
636684

637-
# Collect all names and nick_names into a single sorted list
638-
# Don't need ids because the match query matches directly on name and nick_name.
639-
agreement_values = set() # Use set to avoid duplicates
640-
for _, a_name, a_nick_name in self.db_session.execute(agreement_names_query).all():
641-
if a_name:
642-
agreement_values.add(a_name)
643-
if a_nick_name:
644-
agreement_values.add(a_nick_name)
645-
646-
agreement_names = sorted(list(agreement_values))
685+
# Collect all names and nick_names into a list of dicts with ids
686+
# Use a dict keyed by name to avoid duplicates while preserving id
687+
agreement_values_dict = {} # Key: name, Value: id
688+
for a_id, a_name, a_nick_name in self.db_session.execute(agreement_names_query).all():
689+
if a_name and a_name not in agreement_values_dict:
690+
agreement_values_dict[a_name] = a_id
691+
if a_nick_name and a_nick_name not in agreement_values_dict:
692+
agreement_values_dict[a_nick_name] = a_id
693+
694+
# Convert to list of dicts and sort by name
695+
agreement_names = sorted(
696+
[{"id": a_id, "name": name} for name, a_id in agreement_values_dict.items()], key=lambda x: x["name"]
697+
)
647698

648699
# Build response
649700
filters = {

backend/ops_api/tests/ops/project/test_project.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,35 @@ def test_project_type_filter_single_type(auth_client, loaded_db):
346346
assert len(response_research.json["data"]) + len(response_admin.json["data"]) == len(response_all.json["data"])
347347

348348

349+
def test_agreement_and_fiscal_year_filter(auth_client, loaded_db):
350+
"""Test filtering by agreement and fiscal year at the same time"""
351+
response_research = auth_client.get(
352+
url_for(
353+
"api.projects-group",
354+
agreement_search=["AA #1: Fathers and Continuous Learning (FCL)"],
355+
fiscal_year=[2044],
356+
limit=50,
357+
)
358+
)
359+
360+
assert response_research.status_code == 200
361+
projects = [p for p in response_research.json["data"]]
362+
assert len(projects) == 1
363+
364+
response_2 = auth_client.get(
365+
url_for(
366+
"api.projects-group",
367+
agreement_search=["AA #1: Fathers and Continuous Learning (FCL)"],
368+
fiscal_year=[2045],
369+
limit=50,
370+
)
371+
)
372+
373+
assert response_2.status_code == 200
374+
projects = [p for p in response_2.json["data"]]
375+
assert len(projects) == 0
376+
377+
349378
def test_projects_get_all_includes_summary(auth_client, loaded_db):
350379
"""Test that GET /projects/ includes summary with total_projects, projects_by_type, and amounts_by_type."""
351380
response = auth_client.get(url_for("api.projects-group"))
@@ -1716,12 +1745,18 @@ def test_filter_options_agreement_types_sorted(self, auth_client, app_ctx):
17161745
assert project_types == sorted(project_types)
17171746

17181747
def test_filter_options_agreement_names_sorted_by_name(self, auth_client, app_ctx):
1719-
"""Agreement names should be sorted alphabetically by name."""
1748+
"""Agreement names should be sorted alphabetically by name and returned as list of dicts with id and name."""
17201749
response = auth_client.get(url_for("api.projects-filters"))
17211750
assert response.status_code == 200
17221751

17231752
agreement_names = response.json["agreement_names"]
1724-
names = [a for a in agreement_names]
1753+
assert len(agreement_names) > 0
1754+
# Verify structure: each item should have id and name
1755+
for agreement in agreement_names:
1756+
assert "id" in agreement
1757+
assert "name" in agreement
1758+
# Verify sorted by name
1759+
names = [a["name"] for a in agreement_names]
17251760
assert names == sorted(names)
17261761

17271762

0 commit comments

Comments
 (0)