Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 33 additions & 1 deletion submit-api/src/submit_api/resources/proponent/proponent.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@
from flask_cors import cross_origin
from flask_restx import Namespace, Resource

from submit_api.auth import auth
from submit_api.exceptions import ResourceNotFoundError
from submit_api.resources.apihelper import Api as ApiHelper
from submit_api.schemas.proponent import ProponentSchema
from submit_api.schemas.proponent import ProponentSchema, EnableProponentProjectsSchema
from submit_api.services.proponent_service import ProponentService
from submit_api.utils.roles import EpicSubmitRole
from submit_api.utils.util import allowedorigins, cors_preflight


Expand All @@ -31,6 +33,9 @@
proponent_model = ApiHelper.convert_ma_schema_to_restx_model(
API, ProponentSchema(), "Proponent"
)
post_proponent_account_projects_model = ApiHelper.convert_ma_schema_to_restx_model(
API, EnableProponentProjectsSchema(), "EnableProjects"
)


@cors_preflight("GET, OPTIONS")
Expand Down Expand Up @@ -87,3 +92,30 @@ def get(proponent_id):
if not proponent:
raise ResourceNotFoundError(f"Proponent with id {proponent_id} not found")
return proponent, HTTPStatus.OK


@cors_preflight("POST, OPTIONS")
@API.route(
"/<int:proponent_id>/projects",
methods=["POST", "OPTIONS"],
)
class ProponentProject(Resource):
"""Resource for managing proponent projects."""

@staticmethod
@ApiHelper.swagger_decorators(API, endpoint_description="Enable proponent project in EPIC.submit")
@API.expect(post_proponent_account_projects_model)
@API.response(
code=HTTPStatus.CREATED, model=proponent_model, description="Enable proponent project in EPIC.submit"
)
@API.response(HTTPStatus.BAD_REQUEST, "Bad Request")
@API.response(HTTPStatus.NOT_FOUND, "Not Found")
@auth.require
@auth.has_one_of_staff_roles([EpicSubmitRole.EAO_CREATE.value])
@cross_origin(origins=allowedorigins())
def post(proponent_id):
"""Create new account_project(s) for proponent."""
payload = EnableProponentProjectsSchema().load(request.json)
ProponentService.add_eligible_account_projects(proponent_id, payload)
proponent = ProponentService.get_proponent(proponent_id, True, True)
return proponent, HTTPStatus.CREATED
11 changes: 11 additions & 0 deletions submit-api/src/submit_api/schemas/proponent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,14 @@ class Meta: # pylint: disable=too-few-public-methods
is_deleted = fields.Bool(data_key="is_deleted", allow_none=False)
invitations = fields.List(fields.Int(), data_key="invitations", required=False, default=[])
projects = fields.List(fields.Int(), data_key="projects", required=False, default=[])


class EnableProponentProjectsSchema(Schema):
"""Schema for adding account_projects to proponent."""

class Meta: # pylint: disable=too-few-public-methods
"""Exclude unknown fields in the deserialized output."""

unknown = EXCLUDE

projects = fields.List(fields.Int(), data_key="projects")
3 changes: 3 additions & 0 deletions submit-api/src/submit_api/services/invitation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ def accept_invitation(token, payload):
roles.append(role)
InvitationsModel.mark_used(token, account_user.user_id, session)

# Update proponent status
InvitationService._update_proponent_status_by_account(invitation.account_id, ProponentStatus.ONBOARDED)
Comment on lines +211 to +212
Copy link
Collaborator Author

@leodube-aot leodube-aot Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we mark a proponent as "ONBOARDED" anywhere in the invitation process. Do we agree it makes sense that, after accept_invitation is run, we can mark the proponent as ONBOARDED?


return {
"message": "User access granted successfully",
"user_id": account_user.user_id,
Expand Down
24 changes: 24 additions & 0 deletions submit-api/src/submit_api/services/proponent_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
"""Service for proponent management."""
from submit_api.exceptions import BadRequestError, ResourceNotFoundError
from submit_api.enums.proponent_status import ProponentStatus
from submit_api.models.account_project import AccountProject
from submit_api.models.account import Account
from submit_api.models.db import session_scope
from submit_api.models.proponent import Proponent


Expand All @@ -21,3 +26,22 @@ def get_all_proponents(cls, include_deleted=False, approved_conditions_only=None
include_deleted=include_deleted,
approved_conditions_only=approved_conditions_only
)

@classmethod
def add_eligible_account_projects(cls, proponent_id, proponent_data):
"""Add eligible projects for proponent id."""
project_ids = proponent_data.get("projects")

proponent = Proponent.find_by_id(proponent_id)

if not proponent:
raise ResourceNotFoundError(f"Proponent with id {proponent_id} not found")
if proponent.status is not ProponentStatus.ONBOARDED:
raise BadRequestError("Can only enable projects for onboarded proponents.")

account = Account.get_by_proponent_id(proponent_id)

with session_scope() as session:
for pid in project_ids:
AccountProject.create_account_project(account_id=account.id, project_id=pid)
session.flush()
81 changes: 79 additions & 2 deletions submit-api/tests/unit/resources/test_proponent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
from http import HTTPStatus

from submit_api.enums.proponent_status import ProponentStatus
from tests.utilities.factory_scenarios import TestJwtClaims
from tests.utilities.factory_utils import (
factory_account_model, factory_invitation_model, factory_project_with_proponent,
factory_proponent_model)
factory_account_model, factory_auth_header, factory_invitation_model,
factory_project_model, factory_project_with_proponent,
factory_proponent_model, factory_user_model)


def test_get_all_proponents_with_approved_conditions(client, session):
Expand Down Expand Up @@ -158,3 +160,78 @@ def test_get_all_proponents_empty(client, session):
data = response.get_json()
assert isinstance(data, list)
assert len(data) == 0


def test_enable_proponent_projects_success(client, session, jwt):
"""Test successfully enabling projects for an onboarded proponent."""
auth_guid = TestJwtClaims.staff_admin_role['sub']
factory_user_model(auth_guid=auth_guid)

proponent = factory_proponent_model(
id=1234,
name="Onboarded Proponent",
status=ProponentStatus.ONBOARDED,
is_deleted=False
)
factory_account_model(proponent_id=proponent.id)

project1 = factory_project_model(name="Project 1", proponent_id=proponent.id)
project2 = factory_project_model(name="Project 2", proponent_id=proponent.id)

payload = {
"projects": [project1.id, project2.id]
}

headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role)
response = client.post(f"/api/proponents/{proponent.id}/projects", json=payload, headers=headers)

assert response.status_code == HTTPStatus.CREATED
data = response.get_json()
assert data["id"] == proponent.id
assert data["name"] == "Onboarded Proponent"
assert "account_projects" in data
assert len(data["account_projects"]) == 2

# Verify the account_projects were created with correct IDs
account_project_ids = [ap["project_id"] for ap in data["account_projects"]]
assert project1.id in account_project_ids
assert project2.id in account_project_ids


def test_enable_proponent_projects_not_found(client, session, jwt):
"""Test enabling projects for a non-existent proponent."""
auth_guid = TestJwtClaims.staff_admin_role['sub']
factory_user_model(auth_guid=auth_guid)

payload = {
"projects": [1, 2, 3]
}

headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role)
response = client.post("/api/proponents/99999/projects", json=payload, headers=headers)

assert response.status_code == HTTPStatus.NOT_FOUND


def test_enable_proponent_projects_not_onboarded(client, session, jwt):
"""Test enabling projects for a proponent that is not onboarded."""
auth_guid = TestJwtClaims.staff_admin_role['sub']
factory_user_model(auth_guid=auth_guid)

proponent = factory_proponent_model(
id=2222,
name="Eligible Proponent",
status=ProponentStatus.ELIGIBLE,
is_deleted=False
)
factory_account_model(proponent_id=proponent.id)
project = factory_project_model(name="Project", proponent_id=proponent.id)

payload = {
"projects": [project.id]
}

headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role)
response = client.post(f"/api/proponents/{proponent.id}/projects", json=payload, headers=headers)

assert response.status_code == HTTPStatus.BAD_REQUEST
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { LoadingButton } from "@/components/Shared/LoadingButton";
import ConfirmationModal from "@/components/Shared/Modals/ConfirmationModal";
import { useModal } from "@/components/Shared/Modals/modalStore";
import { notify } from "@/components/Shared/Snackbar/snackbarStore";
import { useEnableProponentProject } from "@/hooks/api/useProponents";
import { useProponentStore } from "@/store/proponentStore";
import { List, ListItem, ListItemText, Typography } from "@mui/material";
import { useParams } from "@tanstack/react-router";
import { BCDesignTokens } from "epic.theme";

type EnableProjectsButtonProps = {
onEnableProjects: () => void;
};

export const EnableProjectsButton = ({
onEnableProjects
}: EnableProjectsButtonProps) => {
const { proponentId } = useParams({
from: "/staff/_staffLayout/proponents/$proponentId",
});
const {
setOpen: setOpenModal,
setClose: setCloseModal,
} = useModal();

const proponent = useProponentStore((state) => state.proponent);
const selectedProjectsIds = useProponentStore((state) => state.selectedProjectsIds);
const eligibleProjects = useProponentStore((state) => state.eligibleProjects);

const { mutate: enableProjects, isPending: isEnablingProjects } =
useEnableProponentProject({
onSuccess: () => {
onEnableProjects();
notify.success("Enabled project(s) successfully");
},
onError: () => {
notify.error("Error enabling project(s)");
},
});

const openConfirmationModal = () => {
setOpenModal(
<ConfirmationModal
onConfirm={() => {
enableProjects({
proponentId: proponentId,
projectIds: selectedProjectsIds,
});
}}
title="Enable Project/Work in EPIC.submit"
description={
<>
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
You will be enabling the following Project(s)/Work(s) in EPIC.submit:
</Typography>
<List>
{eligibleProjects
.filter(project => selectedProjectsIds.includes(project.id))
.map(project => (
<ListItem
key={project.id}
sx={{ m: 0, py: 0 }}
>
<ListItemText
primary={"- " + project.name}
primaryTypographyProps={{
fontWeight: 'bold',
lineHeight: 1.2,
}}
/>
</ListItem>
))}
</List>
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
When you click the Confirm button below, the Account Administrator for {proponent?.name} will receive an email notification and assigned users will be able to submit documents in EPIC.submit.
</Typography>
</>
}
/>,
);
};

const openErrorModal = () => {
setOpenModal(
<ConfirmationModal
onConfirm={() => {
setCloseModal();
}}
title="Select Project(s)/Works(s)"
description="Please select the Project(s)/Work(s) you want to enable in EPIC.submit."
confirmText="Close"
hideSecondary
/>,
);
};

const handleClick = () => {
if (selectedProjectsIds.length === 0) {
openErrorModal();
return;
}
openConfirmationModal();
};

return (
<LoadingButton
variant="contained"
color="primary"
loading={isEnablingProjects}
onClick={handleClick}
sx={{
whiteSpace: "nowrap",
my: BCDesignTokens.layoutMarginXlarge
}}
>
Enable in EPIC.submit
</LoadingButton>
);
};
Loading