From 7687396973f25d7b9d222e737550ac2725f08e96 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 24 Jun 2026 21:48:17 -0400 Subject: [PATCH 01/19] add filtering to /v1/proposals endpoint --- src/nsls2api/api/v1/proposal_api.py | 44 +++++++++++++++++++++++ src/nsls2api/services/proposal_service.py | 4 +++ 2 files changed, 48 insertions(+) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index 1e7eb793..929729c3 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -27,6 +27,7 @@ from nsls2api.services import ( proposal_service, slack_service, + facility_service ) from nsls2api.services.slack_service import get_conversation_details @@ -107,7 +108,22 @@ async def get_proposals( page_size: int = Query(10, ge=1, le=200), page: int = Query(1, ge=1), include_directories: bool = False, + #--- optional filters ----- + username: str | None = Query(None, description="Filter proposals by username (must be in proposal users)"), + current_cycle_only: bool = Query(False, description="Restrict to current operating cycle"), + saf_status: list[str] = Query(default=[] , description="Filter SAFs by status e.g. approved, expired"), + ): + current_cycle = None + if current_cycle_only: + + current_cycle = await facility_service.current_operating_cycle(FacilityName.nsls2) + if not current_cycle: + raise HTTPException(status_code=404, detail="No current operating cycle found") + if not cycle: + cycle = [current_cycle] + + proposal_list = await proposal_service.fetch_proposals( proposal_id=proposal_id, beamline=beamline, @@ -116,7 +132,35 @@ async def get_proposals( page_size=page_size, page=page, include_directories=include_directories, + username=username, ) + + # Post-filter block runs if optional params are provided + if username or current_cycle_only or saf_status: + + allowed_statuses = {s.strip().lower() for s in saf_status} + allowed_instruments = {i.upper() for i in beamline} + filtered = [] + + for proposal in proposal_list: + # Current cycle strict display + if current_cycle_only and current_cycle: + proposal.cycles = [current_cycle] + + # SAF filter + if allowed_statuses or allowed_instruments: + proposal.safs = [ + s for s in (proposal.safs or []) + if s.saf_id + and (not allowed_statuses or (s.status or "").strip().lower() in allowed_statuses) + and (not allowed_instruments or any( + i.upper() in allowed_instruments for i in (s.instruments or []) + )) + ] + + filtered.append(proposal) + + proposal_list = filtered response_model = { "proposals": proposal_list, diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index 2d197cf7..e5d380e4 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -348,6 +348,7 @@ async def fetch_proposals( page_size: int = 10, page: int = 1, include_directories: bool = False, + username: str | None = None, ) -> Optional[list[ProposalFullDetails]]: query = [] @@ -361,6 +362,9 @@ async def fetch_proposals( if proposal_id: query.append(In(Proposal.proposal_id, proposal_id)) + if username: + query.append(ElemMatch(Proposal.users, {"username": username.strip()})) + if len(query) == 0: proposals = ( await Proposal.find_many() From 903af7e3e6dfe20f5a2d083c3b040833bd22bad2 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 24 Jun 2026 22:42:31 -0400 Subject: [PATCH 02/19] override cycle param with current_cycle --- src/nsls2api/api/v1/proposal_api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index 929729c3..e375df58 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -120,8 +120,7 @@ async def get_proposals( current_cycle = await facility_service.current_operating_cycle(FacilityName.nsls2) if not current_cycle: raise HTTPException(status_code=404, detail="No current operating cycle found") - if not cycle: - cycle = [current_cycle] + cycle = [current_cycle] proposal_list = await proposal_service.fetch_proposals( @@ -144,7 +143,7 @@ async def get_proposals( for proposal in proposal_list: # Current cycle strict display - if current_cycle_only and current_cycle: + if current_cycle_only: proposal.cycles = [current_cycle] # SAF filter From ce3fc3dd1395854d345d0ba9b4a0e7249c6ec292 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 24 Jun 2026 22:53:03 -0400 Subject: [PATCH 03/19] normalize username before to avoid empty username --- src/nsls2api/services/proposal_service.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index e5d380e4..771b8933 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -361,9 +361,11 @@ async def fetch_proposals( if proposal_id: query.append(In(Proposal.proposal_id, proposal_id)) - + + if username is not None: + username = username.strip() if username: - query.append(ElemMatch(Proposal.users, {"username": username.strip()})) + query.append(ElemMatch(Proposal.users, {"username": username})) if len(query) == 0: proposals = ( From 10e81e6087b2712e5e27cfc5f70b3e099534962c Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 24 Jun 2026 23:27:02 -0400 Subject: [PATCH 04/19] unit tests for username filter --- .../tests/services/test_proposal_service.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/nsls2api/tests/services/test_proposal_service.py b/src/nsls2api/tests/services/test_proposal_service.py index 850a47a2..f3e96d0e 100644 --- a/src/nsls2api/tests/services/test_proposal_service.py +++ b/src/nsls2api/tests/services/test_proposal_service.py @@ -190,3 +190,56 @@ async def test_data_sessions_invalid_beamline(admin_api_key): body = resp.json() assert body["count"] == 0 assert body["proposals"] == [] + +@pytest.mark.anyio +async def test_fetch_proposals_by_username_exists(): + """Test filtering proposals by username that exists in test data""" + proposal = await proposal_service.proposal_by_id(test_proposal_id) + if proposal.users and len(proposal.users) > 0: + test_username = proposal.users[0].username + + results = await proposal_service.fetch_proposals(username=test_username) + + assert len(results) > 0 + + for p in results: + usernames = [u.username for u in p.users] + assert test_username in usernames + + +@pytest.mark.anyio +async def test_fetch_proposals_by_username_not_exists(): + """Test filtering by username that doesn't exist""" + results = await proposal_service.fetch_proposals(username="nonexistent_user_xyz") + + assert len(results) == 0 + + +@pytest.mark.anyio +async def test_fetch_proposals_username_whitespace(): + """Test username filtering with whitespace edge cases""" + results_whitespace = await proposal_service.fetch_proposals(username=" ") + + results_no_filter = await proposal_service.fetch_proposals() + + assert len(results_whitespace) == len(results_no_filter) + + +@pytest.mark.anyio +async def test_fetch_proposals_username_with_pagination(): + """Test username filter works correctly with pagination""" + proposal = await proposal_service.proposal_by_id(test_proposal_id) + if proposal.users and len(proposal.users) > 0: + test_username = proposal.users[0].username + + page_1 = await proposal_service.fetch_proposals( + username=test_username, + page_size=5, + page=1 + ) + + assert len(page_1) > 0 + + for p in page_1: + usernames = [u.username for u in p.users] + assert test_username in usernames From f2341685556635d3eeaa5603f528df69fe22d730 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Thu, 25 Jun 2026 10:29:55 -0400 Subject: [PATCH 05/19] refactored filter applying logic --- src/nsls2api/api/v1/proposal_api.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index e375df58..ffd0f7ac 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -135,18 +135,17 @@ async def get_proposals( ) # Post-filter block runs if optional params are provided - if username or current_cycle_only or saf_status: + if username or current_cycle_only or saf_status or beamline: allowed_statuses = {s.strip().lower() for s in saf_status} allowed_instruments = {i.upper() for i in beamline} filtered = [] for proposal in proposal_list: - # Current cycle strict display - if current_cycle_only: + + if current_cycle_only and current_cycle and proposal.cycles: proposal.cycles = [current_cycle] - # SAF filter if allowed_statuses or allowed_instruments: proposal.safs = [ s for s in (proposal.safs or []) @@ -156,6 +155,8 @@ async def get_proposals( i.upper() in allowed_instruments for i in (s.instruments or []) )) ] + if (allowed_statuses or allowed_instruments) and not proposal.safs: + continue filtered.append(proposal) From 124776afba4c0495bee4586a2d291dc375befb87 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Fri, 26 Jun 2026 11:50:41 -0400 Subject: [PATCH 06/19] add saf_status as db query --- src/nsls2api/services/proposal_service.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index 771b8933..0e5faba2 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -349,6 +349,7 @@ async def fetch_proposals( page: int = 1, include_directories: bool = False, username: str | None = None, + saf_status: list[str] | None = None, ) -> Optional[list[ProposalFullDetails]]: query = [] @@ -384,6 +385,13 @@ async def fetch_proposals( .to_list() ) + if saf_status is None: + saf_status = [] + allowed_statuses = {s.strip().lower() for s in saf_status if s and s.strip()} + if allowed_statuses: + query.append(ElemMatch(Proposal.safs, {"status": {"$in": list(allowed_statuses)}})) + + # Add directories field to each proposal if include_directories: detailed_proposals = [] From 6c0a0ec017b86d1627c4f9044b922b203df0af1b Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Fri, 26 Jun 2026 13:40:37 -0400 Subject: [PATCH 07/19] add saf and instrument filtering to service layer --- src/nsls2api/services/proposal_service.py | 33 ++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index 0e5faba2..05e97b21 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -368,6 +368,16 @@ async def fetch_proposals( if username: query.append(ElemMatch(Proposal.users, {"username": username})) + allowed_statuses = { + s.strip().upper() + for s in (saf_status or []) + if s and s.strip() + } + if allowed_statuses: + query.append( + ElemMatch(Proposal.safs, {"status": {"$in": list(allowed_statuses)}}) + ) + if len(query) == 0: proposals = ( await Proposal.find_many() @@ -385,12 +395,23 @@ async def fetch_proposals( .to_list() ) - if saf_status is None: - saf_status = [] - allowed_statuses = {s.strip().lower() for s in saf_status if s and s.strip()} - if allowed_statuses: - query.append(ElemMatch(Proposal.safs, {"status": {"$in": list(allowed_statuses)}})) - + #SAF and instrument filtering + allowed_saf_instruments = {i.upper() for i in beamline} + if allowed_statuses or allowed_saf_instruments: + filtered_proposals = [] + for proposal in proposals: + proposal.safs = [ + saf + for saf in (proposal.safs or []) + if saf.saf_id + and (saf.status or "").strip().upper() in allowed_statuses + and (not allowed_saf_instruments or any( + i.upper() in allowed_saf_instruments for i in (saf.instruments or []) + )) + ] + if proposal.safs: + filtered_proposals.append(proposal) + proposals = filtered_proposals # Add directories field to each proposal if include_directories: From 4b75b4a6e013a77d52c0f7c8b6692c92e7d7d3e4 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Fri, 26 Jun 2026 13:47:21 -0400 Subject: [PATCH 08/19] removed post filter block --- src/nsls2api/api/v1/proposal_api.py | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index ffd0f7ac..f3d5f9ce 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -132,35 +132,9 @@ async def get_proposals( page=page, include_directories=include_directories, username=username, + saf_status=saf_status, ) - # Post-filter block runs if optional params are provided - if username or current_cycle_only or saf_status or beamline: - - allowed_statuses = {s.strip().lower() for s in saf_status} - allowed_instruments = {i.upper() for i in beamline} - filtered = [] - - for proposal in proposal_list: - - if current_cycle_only and current_cycle and proposal.cycles: - proposal.cycles = [current_cycle] - - if allowed_statuses or allowed_instruments: - proposal.safs = [ - s for s in (proposal.safs or []) - if s.saf_id - and (not allowed_statuses or (s.status or "").strip().lower() in allowed_statuses) - and (not allowed_instruments or any( - i.upper() in allowed_instruments for i in (s.instruments or []) - )) - ] - if (allowed_statuses or allowed_instruments) and not proposal.safs: - continue - - filtered.append(proposal) - - proposal_list = filtered response_model = { "proposals": proposal_list, From 227bcb3caa4a0477e6def565787b89c0c90da66b Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Fri, 26 Jun 2026 13:48:46 -0400 Subject: [PATCH 09/19] remove current_cycle_only boolean filter --- src/nsls2api/api/v1/proposal_api.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index f3d5f9ce..fc7eae12 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -110,17 +110,9 @@ async def get_proposals( include_directories: bool = False, #--- optional filters ----- username: str | None = Query(None, description="Filter proposals by username (must be in proposal users)"), - current_cycle_only: bool = Query(False, description="Restrict to current operating cycle"), saf_status: list[str] = Query(default=[] , description="Filter SAFs by status e.g. approved, expired"), ): - current_cycle = None - if current_cycle_only: - - current_cycle = await facility_service.current_operating_cycle(FacilityName.nsls2) - if not current_cycle: - raise HTTPException(status_code=404, detail="No current operating cycle found") - cycle = [current_cycle] proposal_list = await proposal_service.fetch_proposals( From 77785af9531a40544fadeaef526f33f735fe6b98 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Fri, 26 Jun 2026 14:52:15 -0400 Subject: [PATCH 10/19] refactored current_cycle_only logic --- src/nsls2api/api/v1/proposal_api.py | 3 ++- src/nsls2api/services/proposal_service.py | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index fc7eae12..8b1e6580 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -111,7 +111,7 @@ async def get_proposals( #--- optional filters ----- username: str | None = Query(None, description="Filter proposals by username (must be in proposal users)"), saf_status: list[str] = Query(default=[] , description="Filter SAFs by status e.g. approved, expired"), - + current_cycle_only: bool = Query(False, description="Restrict to current operating cycle"), ): @@ -125,6 +125,7 @@ async def get_proposals( include_directories=include_directories, username=username, saf_status=saf_status, + current_cycle_only=current_cycle_only, ) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index 05e97b21..0a58a5f6 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -350,9 +350,19 @@ async def fetch_proposals( include_directories: bool = False, username: str | None = None, saf_status: list[str] | None = None, + current_cycle_only: bool = False, ) -> Optional[list[ProposalFullDetails]]: query = [] + current_cycle = None + if current_cycle_only: + facility_name = facility[0] if facility else FacilityName.nsls2 + current_cycle = await facility_service.current_operating_cycle(facility_name) + if not current_cycle: + raise LookupError("No current operating cycle found") + cycle = [current_cycle] + + if beamline: beamline_upper = [beamline_name.upper() for beamline_name in beamline] query.append(In(Proposal.instruments, beamline_upper)) @@ -396,7 +406,7 @@ async def fetch_proposals( ) #SAF and instrument filtering - allowed_saf_instruments = {i.upper() for i in beamline} + allowed_saf_instruments = {i.upper() for i in (beamline or [])} if allowed_statuses or allowed_saf_instruments: filtered_proposals = [] for proposal in proposals: @@ -412,6 +422,12 @@ async def fetch_proposals( if proposal.safs: filtered_proposals.append(proposal) proposals = filtered_proposals + + #current cycle filtering + if current_cycle_only and current_cycle: + for proposal in proposals: + proposal.cycles = [current_cycle] + # Add directories field to each proposal if include_directories: From e14559cede8bfdd884b8f78e10b59501163cb9c5 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Fri, 26 Jun 2026 15:00:06 -0400 Subject: [PATCH 11/19] saf trim logic fix --- src/nsls2api/services/proposal_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index 0a58a5f6..ec8eabce 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -414,7 +414,7 @@ async def fetch_proposals( saf for saf in (proposal.safs or []) if saf.saf_id - and (saf.status or "").strip().upper() in allowed_statuses + and (not allowed_statuses or (saf.status or "").strip().upper() in allowed_statuses) and (not allowed_saf_instruments or any( i.upper() in allowed_saf_instruments for i in (saf.instruments or []) )) @@ -422,7 +422,7 @@ async def fetch_proposals( if proposal.safs: filtered_proposals.append(proposal) proposals = filtered_proposals - + #current cycle filtering if current_cycle_only and current_cycle: for proposal in proposals: From c96617543de9f82bd24b9f295a2164613bcd00ef Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Tue, 30 Jun 2026 11:48:13 -0400 Subject: [PATCH 12/19] refactor saf instrument and saf status logic --- src/nsls2api/api/v1/proposal_api.py | 2 +- src/nsls2api/services/proposal_service.py | 38 ++++++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index 8b1e6580..7f2d544d 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -110,7 +110,7 @@ async def get_proposals( include_directories: bool = False, #--- optional filters ----- username: str | None = Query(None, description="Filter proposals by username (must be in proposal users)"), - saf_status: list[str] = Query(default=[] , description="Filter SAFs by status e.g. approved, expired"), + saf_status: list[str] | None = Query(default=None, description="Filter SAFs by status e.g. approved, expired"), current_cycle_only: bool = Query(False, description="Restrict to current operating cycle"), ): diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index ec8eabce..3c223d13 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -383,11 +383,32 @@ async def fetch_proposals( for s in (saf_status or []) if s and s.strip() } - if allowed_statuses: + + # when SAF status filtering is requested, + # use beamline values for SAF instrument matching. Beamline-only requests + # (without SAF filters) keep proposal-level semantics unchanged. + allowed_saf_instruments = set() + if allowed_statuses and beamline: + allowed_saf_instruments = { + beamline_name.strip().upper() + for beamline_name in beamline + if beamline_name and beamline_name.strip() + } + + if allowed_statuses and allowed_saf_instruments: + query.append( + ElemMatch( + Proposal.safs, + { + "status": {"$in": list(allowed_statuses)}, + "instruments": {"$in": list(allowed_saf_instruments)}, + }, + ) + ) + elif allowed_statuses: query.append( ElemMatch(Proposal.safs, {"status": {"$in": list(allowed_statuses)}}) ) - if len(query) == 0: proposals = ( await Proposal.find_many() @@ -405,8 +426,7 @@ async def fetch_proposals( .to_list() ) - #SAF and instrument filtering - allowed_saf_instruments = {i.upper() for i in (beamline or [])} + # SAF filtering: query narrows matching proposals, this block trims nested SAFs. if allowed_statuses or allowed_saf_instruments: filtered_proposals = [] for proposal in proposals: @@ -419,11 +439,15 @@ async def fetch_proposals( i.upper() in allowed_saf_instruments for i in (saf.instruments or []) )) ] - if proposal.safs: - filtered_proposals.append(proposal) + + if not proposal.safs: + continue + + filtered_proposals.append(proposal) + proposals = filtered_proposals - #current cycle filtering + # current cycle filtering if current_cycle_only and current_cycle: for proposal in proposals: proposal.cycles = [current_cycle] From a826ff0c291abf07519a9b7cd7109230fe7c4958 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 1 Jul 2026 10:03:04 -0400 Subject: [PATCH 13/19] remove current_cycle_only param --- src/nsls2api/api/v1/proposal_api.py | 2 -- src/nsls2api/services/proposal_service.py | 14 -------------- 2 files changed, 16 deletions(-) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index 7f2d544d..ff7dcd54 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -111,7 +111,6 @@ async def get_proposals( #--- optional filters ----- username: str | None = Query(None, description="Filter proposals by username (must be in proposal users)"), saf_status: list[str] | None = Query(default=None, description="Filter SAFs by status e.g. approved, expired"), - current_cycle_only: bool = Query(False, description="Restrict to current operating cycle"), ): @@ -125,7 +124,6 @@ async def get_proposals( include_directories=include_directories, username=username, saf_status=saf_status, - current_cycle_only=current_cycle_only, ) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index 3c223d13..ef89001c 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -350,18 +350,9 @@ async def fetch_proposals( include_directories: bool = False, username: str | None = None, saf_status: list[str] | None = None, - current_cycle_only: bool = False, ) -> Optional[list[ProposalFullDetails]]: query = [] - current_cycle = None - if current_cycle_only: - facility_name = facility[0] if facility else FacilityName.nsls2 - current_cycle = await facility_service.current_operating_cycle(facility_name) - if not current_cycle: - raise LookupError("No current operating cycle found") - cycle = [current_cycle] - if beamline: beamline_upper = [beamline_name.upper() for beamline_name in beamline] @@ -447,11 +438,6 @@ async def fetch_proposals( proposals = filtered_proposals - # current cycle filtering - if current_cycle_only and current_cycle: - for proposal in proposals: - proposal.cycles = [current_cycle] - # Add directories field to each proposal if include_directories: From 7934b587009e51f617cefb1f13bb8c77b96a7d7f Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 8 Jul 2026 10:46:32 -0400 Subject: [PATCH 14/19] removed unused facility_service import --- src/nsls2api/api/v1/proposal_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index ff7dcd54..088b259a 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -27,7 +27,6 @@ from nsls2api.services import ( proposal_service, slack_service, - facility_service ) from nsls2api.services.slack_service import get_conversation_details From 8e126fc15a54ce515f3f745a01eeda2a9b3923c4 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 8 Jul 2026 11:24:01 -0400 Subject: [PATCH 15/19] changed descriptions --- src/nsls2api/api/v1/proposal_api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index 088b259a..b9f3cd68 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -104,12 +104,11 @@ async def get_proposals( beamline: Annotated[list[str], Query()] = [], cycle: Annotated[list[str], Query()] = [], facility: Annotated[list[FacilityName], Query()] = [FacilityName.nsls2], + username: str | None = Query(None, description="Filter proposals by username"), + saf_status: list[str] | None = Query(default=None, description="Filter proposals and SAF's by SAF status"), page_size: int = Query(10, ge=1, le=200), page: int = Query(1, ge=1), include_directories: bool = False, - #--- optional filters ----- - username: str | None = Query(None, description="Filter proposals by username (must be in proposal users)"), - saf_status: list[str] | None = Query(default=None, description="Filter SAFs by status e.g. approved, expired"), ): From e2a1283d0532302c61b82a403b32364b335a2478 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 8 Jul 2026 11:25:37 -0400 Subject: [PATCH 16/19] apply grouping --- src/nsls2api/api/v1/proposal_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index b9f3cd68..3022022b 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -117,11 +117,11 @@ async def get_proposals( beamline=beamline, cycle=cycle, facility=facility, + username=username, + saf_status=saf_status, page_size=page_size, page=page, include_directories=include_directories, - username=username, - saf_status=saf_status, ) From c5246adfeb7c7ac67137442a309a52e99a8531e5 Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 8 Jul 2026 11:28:36 -0400 Subject: [PATCH 17/19] apply grouping & remove unnecessary normalisation of saf_status --- src/nsls2api/services/proposal_service.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index ef89001c..050abc28 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -345,11 +345,12 @@ async def fetch_proposals( beamline: list[str] | None = None, cycle: list[str] | None = None, facility: list[str] | None = None, + username: str | None = None, + saf_status: list[str] | None = None, page_size: int = 10, page: int = 1, include_directories: bool = False, - username: str | None = None, - saf_status: list[str] | None = None, + ) -> Optional[list[ProposalFullDetails]]: query = [] @@ -369,12 +370,6 @@ async def fetch_proposals( if username: query.append(ElemMatch(Proposal.users, {"username": username})) - allowed_statuses = { - s.strip().upper() - for s in (saf_status or []) - if s and s.strip() - } - # when SAF status filtering is requested, # use beamline values for SAF instrument matching. Beamline-only requests # (without SAF filters) keep proposal-level semantics unchanged. From a756e0d73a7d7f87b4f557385dd0880be1f14c8e Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 8 Jul 2026 11:46:08 -0400 Subject: [PATCH 18/19] remove extra saf & beamline filtering --- src/nsls2api/services/proposal_service.py | 33 +++-------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index 050abc28..8cc16045 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -370,31 +370,6 @@ async def fetch_proposals( if username: query.append(ElemMatch(Proposal.users, {"username": username})) - # when SAF status filtering is requested, - # use beamline values for SAF instrument matching. Beamline-only requests - # (without SAF filters) keep proposal-level semantics unchanged. - allowed_saf_instruments = set() - if allowed_statuses and beamline: - allowed_saf_instruments = { - beamline_name.strip().upper() - for beamline_name in beamline - if beamline_name and beamline_name.strip() - } - - if allowed_statuses and allowed_saf_instruments: - query.append( - ElemMatch( - Proposal.safs, - { - "status": {"$in": list(allowed_statuses)}, - "instruments": {"$in": list(allowed_saf_instruments)}, - }, - ) - ) - elif allowed_statuses: - query.append( - ElemMatch(Proposal.safs, {"status": {"$in": list(allowed_statuses)}}) - ) if len(query) == 0: proposals = ( await Proposal.find_many() @@ -413,16 +388,16 @@ async def fetch_proposals( ) # SAF filtering: query narrows matching proposals, this block trims nested SAFs. - if allowed_statuses or allowed_saf_instruments: + if saf_status or beamline: filtered_proposals = [] for proposal in proposals: proposal.safs = [ saf for saf in (proposal.safs or []) if saf.saf_id - and (not allowed_statuses or (saf.status or "").strip().upper() in allowed_statuses) - and (not allowed_saf_instruments or any( - i.upper() in allowed_saf_instruments for i in (saf.instruments or []) + and (not saf_status or (saf.status or "").strip().upper() in {s.strip().upper() for s in saf_status}) + and (not beamline or any( + i.upper() in {b.strip().upper() for b in beamline} for i in (saf.instruments or []) )) ] From fd1257204fcc346a78f668f3b9af0f4ff2bd6a4a Mon Sep 17 00:00:00 2001 From: Aditi Chikkali Date: Wed, 8 Jul 2026 12:23:19 -0400 Subject: [PATCH 19/19] remove extra beamline filtering which is preventing tests to pass --- src/nsls2api/services/proposal_service.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index 8cc16045..cdf48f96 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -388,7 +388,7 @@ async def fetch_proposals( ) # SAF filtering: query narrows matching proposals, this block trims nested SAFs. - if saf_status or beamline: + if saf_status: filtered_proposals = [] for proposal in proposals: proposal.safs = [ @@ -396,9 +396,6 @@ async def fetch_proposals( for saf in (proposal.safs or []) if saf.saf_id and (not saf_status or (saf.status or "").strip().upper() in {s.strip().upper() for s in saf_status}) - and (not beamline or any( - i.upper() in {b.strip().upper() for b in beamline} for i in (saf.instruments or []) - )) ] if not proposal.safs: