Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3a5e4ea
feat: add portfolio funding summary batch endpoint with filtering opt…
weimiao67 Jan 3, 2026
152e63b
feat: update portfolio list page UI
weimiao67 Jan 3, 2026
6252a84
feat: wip implement portfolio summary card
weimiao67 Jan 6, 2026
b3e8c39
Merge branch 'main' into OPS-4144/new_portfolio_list_page
weimiao67 Jan 6, 2026
4a62898
Merge branch 'main' into OPS-4144/new_portfolio_list_page
weimiao67 Jan 7, 2026
57b24f4
Merge branch 'main' into OPS-4144/new_portfolio_list_page
weimiao67 Jan 8, 2026
9b71a9b
feat: enhance portfolio display and sorting logic
weimiao67 Jan 8, 2026
fa89a91
test: update unit tests
weimiao67 Jan 8, 2026
341e434
feat: add portfolio export functionality with loading state
weimiao67 Jan 8, 2026
4e20b47
chore: resolve ux feedback
weimiao67 Jan 9, 2026
297316e
feat: improve portfolio legend readability and enhance tag styling
weimiao67 Jan 10, 2026
7605b67
feat: pass fiscal year from portfolio list page to detail page
weimiao67 Jan 10, 2026
2900b9c
test: update e2e test for portfolio list
weimiao67 Jan 10, 2026
ca29288
Merge branch 'main' into OPS-4144/new_portfolio_list_page
weimiao67 Jan 10, 2026
896a49e
test: fix unit test failures
weimiao67 Jan 10, 2026
81cdcf6
chore: fix lint error
weimiao67 Jan 10, 2026
9bd3eee
chore: fix lint error
weimiao67 Jan 10, 2026
55172ae
test: update e2e test
weimiao67 Jan 10, 2026
5223904
Merge branch 'main' into OPS-4144/new_portfolio_list_page
weimiao67 Jan 12, 2026
f6f7706
chore: resolve copilot review comments
weimiao67 Jan 12, 2026
b64abc9
test: add unit test coverage
weimiao67 Jan 12, 2026
3272a61
chore: fix lint error
weimiao67 Jan 12, 2026
4f36209
fix: handle null filter values in usePortfolioList hook gracefully
weimiao67 Jan 14, 2026
0516c71
chore: remove doubleByDivision function and related tests
weimiao67 Jan 14, 2026
421357d
chore: resolve review comment from claude
weimiao67 Jan 14, 2026
99c4569
chore: formatting
weimiao67 Jan 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions backend/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1799,6 +1799,135 @@ paths:
"purpose": ""
}
]
/portfolio-funding-summary/:
get:
tags:
- Portfolio Funding Summary
summary: Get funding summary for multiple portfolios with optional filters
operationId: getPortfolioFundingSummaryBatch
description: Retrieve funding summaries for all portfolios or a filtered subset based on fiscal year, portfolio IDs, budget range, and available budget percentage.
parameters:
- $ref: "#/components/parameters/simulatedError"
- name: fiscal_year
in: query
description: Fiscal year for which the funding summary is requested
schema:
type: integer
example: 2026
- name: portfolio_ids
in: query
description: List of portfolio IDs to filter by (optional)
schema:
type: array
items:
type: integer
style: form
explode: true
example: [1, 2, 3]
- name: budget_min
in: query
description: Minimum budget amount to filter by (optional)
schema:
type: number
format: float
example: 1000000
- name: budget_max
in: query
description: Maximum budget amount to filter by (optional)
schema:
type: number
format: float
example: 50000000
- name: available_pct
in: query
description: |
Available budget percentage ranges to filter by (optional).
Valid values: over90, 75-90, 50-75, 25-50, under25
schema:
type: array
items:
type: string
enum:
- over90
- 75-90
- 50-75
- 25-50
- under25
style: form
explode: true
example: ["over90", "75-90"]
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
portfolios:
type: array
items:
allOf:
- type: object
properties:
id:
type: integer
name:
type: string
abbreviation:
type: string
division_id:
type: integer
division:
type: object
properties:
id:
type: integer
name:
type: string
abbreviation:
type: string
- $ref: "#/components/schemas/PortfolioFundingSummary"
example:
portfolios:
- id: 1
name: "Child Care Research"
abbreviation: "CCR"
division_id: 1
division:
id: 1
name: "Division of Child and Family Development"
abbreviation: "DCFD"
total_funding:
amount: 10000000.00
percent: "Total"
carry_forward_funding:
amount: 2000000.00
percent: "Carry-Forward"
draft_funding:
amount: 500000.00
percent: "5"
planned_funding:
amount: 3000000.00
percent: "30"
obligated_funding:
amount: 4000000.00
percent: "40"
in_execution_funding:
amount: 1000000.00
percent: "10"
available_funding:
amount: 1500000.00
percent: "15"
new_funding:
amount: 8000000.00
percent: "New"
"400":
description: Bad Request - Invalid parameters
"401":
description: Unauthorized
"500":
description: Internal Server Error
/portfolio-funding-summary/{portfolio_id}:
get:
tags:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ def get(self, id: int) -> Response:
/portfolios/<int:id>/calcFunding/
"""
schema = RequestSchema()
data = schema.load(request.args)
data = schema.load(request.args.to_dict(flat=False))

# Extract fiscal_year from list (Flask query params with flat=False wraps everything in lists)
fiscal_year_list = data.get("fiscal_year")
fiscal_year = fiscal_year_list[0] if fiscal_year_list and len(fiscal_year_list) > 0 else None

portfolio = self._get_item(id)
total_funding = get_total_funding(portfolio, data.get("fiscal_year"))
total_funding = get_total_funding(portfolio, fiscal_year)
return jsonify(total_funding)
141 changes: 133 additions & 8 deletions backend/ops_api/ops/resources/portfolio_funding_summary.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,156 @@
from flask import Response, request
from flask import Response, current_app, request
from sqlalchemy import select

from models import Portfolio
from models.base import BaseModel
from ops_api.ops.auth.auth_types import Permission, PermissionType
from ops_api.ops.auth.decorators import is_authorized
from ops_api.ops.base_views import BaseItemAPI
from ops_api.ops.schemas.portfolio_funding_summary import RequestSchema, ResponseSchema
from ops_api.ops.base_views import BaseItemAPI, BaseListAPI
from ops_api.ops.schemas.cans import DivisionSchema
from ops_api.ops.schemas.portfolio_funding_summary import (
RequestSchema,
ResponseListSchema,
ResponseSchema,
)
from ops_api.ops.utils.fiscal_year import get_current_fiscal_year
from ops_api.ops.utils.portfolios import get_total_funding
from ops_api.ops.utils.response import make_response_with_headers


def _extract_first_or_default(value_list, default=None):
"""
Extract the first value from a list, or return default if list is empty/None.

This helper handles the flat=False query parameter parsing where all values
come as lists, even single-value parameters.

Args:
value_list: List of values or None
default: Default value to return if list is empty/None

Returns:
First value from list, or default
"""
return value_list[0] if value_list and len(value_list) > 0 else default


class PortfolioFundingSummaryItemAPI(BaseItemAPI):
def __init__(self, model: BaseModel):
super().__init__(model)

@is_authorized(PermissionType.GET, Permission.PORTFOLIO)
def get(self, id: int) -> Response:
"""
/portfolio-funding-summary/<int:id>
/portfolio-funding-summary/<int:id>?fiscal_year=2026
"""
schema = RequestSchema()
data = schema.load(request.args)
data = schema.load(request.args.to_dict(flat=False))

fiscal_year = _extract_first_or_default(data.get("fiscal_year"), get_current_fiscal_year())

portfolio = self._get_item(id)

response_schema = ResponseSchema()
portfolio_funding_summary = response_schema.dump(
get_total_funding(portfolio, data.get("fiscal_year", get_current_fiscal_year()))
)
portfolio_funding_summary = response_schema.dump(get_total_funding(portfolio, fiscal_year))
return make_response_with_headers(portfolio_funding_summary)


class PortfolioFundingSummaryListAPI(BaseListAPI):
def __init__(self, model: BaseModel):
super().__init__(model)

def _parse_request_params(self, data: dict) -> tuple:
"""Extract and parse request parameters from loaded schema data."""
fiscal_year = _extract_first_or_default(data.get("fiscal_year"), get_current_fiscal_year())
portfolio_ids = data.get("portfolio_ids", [])
budget_min = _extract_first_or_default(data.get("budget_min"))
budget_max = _extract_first_or_default(data.get("budget_max"))
available_pct_ranges = data.get("available_pct", [])

return fiscal_year, portfolio_ids, budget_min, budget_max, available_pct_ranges

def _matches_budget_range(self, total_amount: float, budget_min: float, budget_max: float) -> bool:
"""Check if total amount is within budget range."""
if budget_min is not None and total_amount < budget_min:
return False
if budget_max is not None and total_amount > budget_max:
return False
return True

def _matches_available_pct_range(self, available_pct: float, range_code: str) -> bool:
"""Check if available percentage matches the given range code."""
if range_code == "over90":
return available_pct >= 90
elif range_code == "75-90":
return 75 <= available_pct < 90
elif range_code == "50-75":
return 50 <= available_pct < 75
elif range_code == "25-50":
return 25 <= available_pct < 50
elif range_code == "under25":
return available_pct < 25
return False

def _apply_available_pct_filter(self, funding: dict, available_pct_ranges: list) -> bool:
"""Check if portfolio matches available percentage filter. Returns True if it matches."""
if not available_pct_ranges:
return True

available_amount = funding["available_funding"]["amount"]
total_amount = funding["total_funding"]["amount"]

if total_amount == 0:
return False

available_pct = (available_amount / total_amount) * 100
return any(self._matches_available_pct_range(available_pct, code) for code in available_pct_ranges)

def _build_portfolio_summary(self, portfolio: Portfolio, funding: dict) -> dict:
"""Build portfolio summary dict with division info and funding data."""
division_schema = DivisionSchema(
only=["id", "name", "abbreviation", "division_director_id", "deputy_division_director_id"]
)
return {
"id": portfolio.id,
"name": portfolio.name,
"abbreviation": portfolio.abbreviation,
"division_id": portfolio.division_id,
"division": division_schema.dump(portfolio.division) if portfolio.division else None,
**funding,
}

@is_authorized(PermissionType.GET, Permission.PORTFOLIO)
def get(self) -> Response:
"""
GET /portfolio-funding-summary/?fiscal_year=2026&portfolio_ids=1&portfolio_ids=2&budget_min=1000000&budget_max=5000000&available_pct=over90&available_pct=75-90
Returns filtered portfolios with their funding summaries
"""
schema = RequestSchema()
data = schema.load(request.args.to_dict(flat=False))

fiscal_year, portfolio_ids, budget_min, budget_max, available_pct_ranges = self._parse_request_params(data)

# Get all portfolios (or filtered by IDs if specified)
if portfolio_ids:
stmt = select(Portfolio).where(Portfolio.id.in_(portfolio_ids))
portfolios = current_app.db_session.execute(stmt).scalars().all()
else:
portfolios = self._get_all_items()

# Build response with funding for each portfolio
portfolio_summaries = []
for portfolio in portfolios:
funding = get_total_funding(portfolio, fiscal_year)

# Apply filters
total_amount = funding["total_funding"]["amount"]
if not self._matches_budget_range(total_amount, budget_min, budget_max):
continue

if not self._apply_available_pct_filter(funding, available_pct_ranges):
continue

portfolio_summaries.append(self._build_portfolio_summary(portfolio, funding))

response_schema = ResponseListSchema()
return make_response_with_headers(response_schema.dump({"portfolios": portfolio_summaries}))
47 changes: 45 additions & 2 deletions backend/ops_api/ops/schemas/portfolio_funding_summary.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
from marshmallow import Schema, fields
from marshmallow import Schema, ValidationError, fields, validates

from ops_api.ops.schemas.cans import DivisionSchema


class RequestSchema(Schema):
fiscal_year = fields.Integer(allow_none=True)
# All fields are wrapped in List due to Flask query param parsing with flat=False
fiscal_year = fields.List(fields.Integer(), allow_none=True)
portfolio_ids = fields.List(fields.Integer(), allow_none=True)
budget_min = fields.List(fields.Float(), allow_none=True)
budget_max = fields.List(fields.Float(), allow_none=True)
available_pct = fields.List(fields.String(), allow_none=True)

@validates("available_pct")
def validate_available_pct(self, value, **kwargs):
"""Validate that available_pct contains only valid range codes."""
if value:
valid_ranges = {"over90", "75-90", "50-75", "25-50", "under25"}
invalid = set(value) - valid_ranges
if invalid:
raise ValidationError(
f"Invalid available_pct range codes: {invalid}. "
f"Valid codes are: {', '.join(sorted(valid_ranges))}"
)


class FundingLineItem(Schema):
Expand All @@ -19,3 +38,27 @@ class ResponseSchema(Schema):
available_funding = fields.Nested(FundingLineItem)
draft_funding = fields.Nested(FundingLineItem)
new_funding = fields.Nested(FundingLineItem)


class PortfolioFundingSummaryItem(Schema):
"""Schema for a single portfolio with funding summary"""

id = fields.Integer(required=True)
name = fields.String(required=True)
abbreviation = fields.String(allow_none=True)
division_id = fields.Integer(required=True)
division = fields.Nested(DivisionSchema, allow_none=True)
total_funding = fields.Nested(FundingLineItem)
carry_forward_funding = fields.Nested(FundingLineItem)
planned_funding = fields.Nested(FundingLineItem)
obligated_funding = fields.Nested(FundingLineItem)
in_execution_funding = fields.Nested(FundingLineItem)
available_funding = fields.Nested(FundingLineItem)
draft_funding = fields.Nested(FundingLineItem)
new_funding = fields.Nested(FundingLineItem)


class ResponseListSchema(Schema):
"""Schema for list of portfolios with funding summaries"""

portfolios = fields.List(fields.Nested(PortfolioFundingSummaryItem))
5 changes: 5 additions & 0 deletions backend/ops_api/ops/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
PORTFOLIO_CALCULATE_FUNDING_API_VIEW_FUNC,
PORTFOLIO_CANS_API_VIEW_FUNC,
PORTFOLIO_FUNDING_SUMMARY_ITEM_API_VIEW_FUNC,
PORTFOLIO_FUNDING_SUMMARY_LIST_API_VIEW_FUNC,
PORTFOLIO_ITEM_API_VIEW_FUNC,
PORTFOLIO_LIST_API_VIEW_FUNC,
PORTFOLIO_STATUS_ITEM_API_VIEW_FUNC,
Expand Down Expand Up @@ -196,6 +197,10 @@ def register_api(api_bp: Blueprint) -> None:
)

api_bp.add_url_rule("/can-history/", view_func=CAN_HISTORY_LIST_API_VIEW_FUNC)
api_bp.add_url_rule(
"/portfolio-funding-summary/",
view_func=PORTFOLIO_FUNDING_SUMMARY_LIST_API_VIEW_FUNC,
)
api_bp.add_url_rule(
"/portfolio-funding-summary/<int:id>",
view_func=PORTFOLIO_FUNDING_SUMMARY_ITEM_API_VIEW_FUNC,
Expand Down
Loading