From 14d5c5924814e618726c267735a9c7c672e01e2d Mon Sep 17 00:00:00 2001 From: leodube-aot Date: Mon, 9 Feb 2026 09:30:31 -0800 Subject: [PATCH 1/3] Add functionality to manage onboarded and eligible projects --- .../resources/proponent/proponent.py | 33 ++++- .../src/submit_api/schemas/proponent.py | 10 ++ .../submit_api/services/invitation_service.py | 4 + .../submit_api/services/proponent_service.py | 24 +++ .../EnableProjectsButton.tsx | 119 +++++++++++++++ .../ProjectsTable/EligibleProjectsTable.tsx | 78 ++++++++++ .../ProjectsTable/OnboardedProjectsTable.tsx | 30 ++++ .../ProjectsTable/ProjectsTable.tsx | 16 +- .../App/Proponents/ProjectsTable/index.tsx | 3 + .../RegistrationUrl/RegistrationUrl.tsx | 13 +- submit-web/src/hooks/api/useProponents.ts | 31 +++- .../_staffLayout/proponents/$proponentId.tsx | 137 +++++++----------- submit-web/src/store/proponentStore.ts | 74 ++++++++++ 13 files changed, 475 insertions(+), 97 deletions(-) create mode 100644 submit-web/src/components/App/Proponents/EnableProjectsButton/EnableProjectsButton.tsx create mode 100644 submit-web/src/components/App/Proponents/ProjectsTable/EligibleProjectsTable.tsx create mode 100644 submit-web/src/components/App/Proponents/ProjectsTable/OnboardedProjectsTable.tsx create mode 100644 submit-web/src/components/App/Proponents/ProjectsTable/index.tsx create mode 100644 submit-web/src/store/proponentStore.ts diff --git a/submit-api/src/submit_api/resources/proponent/proponent.py b/submit-api/src/submit_api/resources/proponent/proponent.py index a589264f..cb7f6072 100644 --- a/submit-api/src/submit_api/resources/proponent/proponent.py +++ b/submit-api/src/submit_api/resources/proponent/proponent.py @@ -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 @@ -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") @@ -87,3 +92,29 @@ 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( + "//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 diff --git a/submit-api/src/submit_api/schemas/proponent.py b/submit-api/src/submit_api/schemas/proponent.py index e71f0cbf..b33f21db 100644 --- a/submit-api/src/submit_api/schemas/proponent.py +++ b/submit-api/src/submit_api/schemas/proponent.py @@ -21,3 +21,13 @@ 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") diff --git a/submit-api/src/submit_api/services/invitation_service.py b/submit-api/src/submit_api/services/invitation_service.py index c4b9c06d..c2b70508 100644 --- a/submit-api/src/submit_api/services/invitation_service.py +++ b/submit-api/src/submit_api/services/invitation_service.py @@ -208,6 +208,10 @@ 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) + return { "message": "User access granted successfully", "user_id": account_user.user_id, diff --git a/submit-api/src/submit_api/services/proponent_service.py b/submit-api/src/submit_api/services/proponent_service.py index 5f399bd2..da34e43e 100644 --- a/submit-api/src/submit_api/services/proponent_service.py +++ b/submit-api/src/submit_api/services/proponent_service.py @@ -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 @@ -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 not proponent.status is 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() diff --git a/submit-web/src/components/App/Proponents/EnableProjectsButton/EnableProjectsButton.tsx b/submit-web/src/components/App/Proponents/EnableProjectsButton/EnableProjectsButton.tsx new file mode 100644 index 00000000..48b53e74 --- /dev/null +++ b/submit-web/src/components/App/Proponents/EnableProjectsButton/EnableProjectsButton.tsx @@ -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( + { + enableProjects({ + proponentId: proponentId, + projectIds: selectedProjectsIds, + }); + }} + title="Enable Project/Work in EPIC.submit" + description={ + <> + + You will be enabling the following Project(s)/Work(s) in EPIC.submit: + + + {eligibleProjects + .filter(project => selectedProjectsIds.includes(project.id)) + .map(project => ( + + + + ))} + + + 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. + + + } + />, + ); + }; + + const openErrorModal = () => { + setOpenModal( + { + 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 ( + + Enable in EPIC.submit + + ); +}; diff --git a/submit-web/src/components/App/Proponents/ProjectsTable/EligibleProjectsTable.tsx b/submit-web/src/components/App/Proponents/ProjectsTable/EligibleProjectsTable.tsx new file mode 100644 index 00000000..91e1955e --- /dev/null +++ b/submit-web/src/components/App/Proponents/ProjectsTable/EligibleProjectsTable.tsx @@ -0,0 +1,78 @@ +import { BarBlueTitle } from "@/components/Shared/Text/BarTitle"; +import { useProponentStore } from "@/store/proponentStore"; +import { InfoOutlined } from "@mui/icons-material"; +import { Box, IconButton, Tooltip, Typography } from "@mui/material"; +import { BCDesignTokens } from "epic.theme"; +import { ProjectsTable } from "./ProjectsTable"; + +export const EligibleProjectsTable = () => { + const proponent = useProponentStore((state) => state.proponent); + const eligibleProjects = useProponentStore((state) => state.eligibleProjects); + const pendingInvitation = useProponentStore((state) => state.pendingInvitation); + const isLoading = useProponentStore((state) => state.isLoading); + const isError = useProponentStore((state) => state.isError); + + return ( + <> + + + + + + } + /> + {proponent?.status == "ONBOARDED" && eligibleProjects.length > 0 && ( + + {`Select the Eligible Project(s)/Work(s) you want to enable for ${proponent?.name} and click the "Enable in EPIC.submit" button. + + The Account Administrator(s) for ${proponent?.name} will receive an email to inform them they can now access and submit documents for this project.`} + + )} + {proponent?.status == "INELIGIBLE" || eligibleProjects.length == 0 ? ( + + + No other Project/Work for this Proponent/Holder is currently + eligible to be onboarded in EPIC.submit + + + ) : ( + + )} + + ); +}; \ No newline at end of file diff --git a/submit-web/src/components/App/Proponents/ProjectsTable/OnboardedProjectsTable.tsx b/submit-web/src/components/App/Proponents/ProjectsTable/OnboardedProjectsTable.tsx new file mode 100644 index 00000000..eba14b98 --- /dev/null +++ b/submit-web/src/components/App/Proponents/ProjectsTable/OnboardedProjectsTable.tsx @@ -0,0 +1,30 @@ +import { BarBlueTitle } from "@/components/Shared/Text/BarTitle"; +import { ProjectsTable } from "./ProjectsTable"; +import { BCDesignTokens } from "epic.theme"; +import { useProponentStore } from "@/store/proponentStore"; + +export const OnboardedProjectsTable = () => { + const onboardedProjects = useProponentStore((state) => state.onboardedProjects); + const isLoading = useProponentStore((state) => state.isLoading); + const isError = useProponentStore((state) => state.isError); + + const onboardedProjectsIds = onboardedProjects?.map(op => op.id); + + return ( + <> + + + + ); +}; diff --git a/submit-web/src/components/App/Proponents/ProjectsTable/ProjectsTable.tsx b/submit-web/src/components/App/Proponents/ProjectsTable/ProjectsTable.tsx index 82d775b4..8e1ba0a2 100644 --- a/submit-web/src/components/App/Proponents/ProjectsTable/ProjectsTable.tsx +++ b/submit-web/src/components/App/Proponents/ProjectsTable/ProjectsTable.tsx @@ -3,14 +3,15 @@ import { notify } from "@/components/Shared/Snackbar/snackbarStore"; import { Project } from "@/models/Project"; import { TableProps } from "@mui/material"; import { useEffect } from "react"; +import { useProponentStore } from "@/store/proponentStore"; type ProjectsTableProps = TableProps & { projects?: Project[]; pendingProjectIds?: number[]; isLoading?: boolean; isError?: boolean; - selectedProjectsIds: (string | number)[]; - onSelectionChange: (selected: (string | number)[]) => void; + selectedProjectsIds?: (string | number)[]; // Can be overridden for read-only tables + readonly?: boolean; }; export const ProjectsTable = ({ @@ -18,10 +19,17 @@ export const ProjectsTable = ({ pendingProjectIds = [], isLoading = false, isError = false, - selectedProjectsIds, - onSelectionChange, + selectedProjectsIds: externalSelectedProjectsIds, + readonly = false, ...tableProps }: ProjectsTableProps) => { + // Use store for selection unless explicitly overridden (for read-only tables like OnboardedProjectsTable) + const storeSelectedProjectsIds = useProponentStore((state) => state.selectedProjectsIds); + const setSelectedProjectsIds = useProponentStore((state) => state.setSelectedProjectsIds); + + const selectedProjectsIds = externalSelectedProjectsIds ?? storeSelectedProjectsIds; + const onSelectionChange = readonly ? undefined : setSelectedProjectsIds; + useEffect(() => { if (isError) { notify.error("Error fetching projects"); diff --git a/submit-web/src/components/App/Proponents/ProjectsTable/index.tsx b/submit-web/src/components/App/Proponents/ProjectsTable/index.tsx new file mode 100644 index 00000000..58fa7f02 --- /dev/null +++ b/submit-web/src/components/App/Proponents/ProjectsTable/index.tsx @@ -0,0 +1,3 @@ +export { EligibleProjectsTable } from "./EligibleProjectsTable"; +export { OnboardedProjectsTable } from "./OnboardedProjectsTable"; +export { ProjectsTable } from "./ProjectsTable"; diff --git a/submit-web/src/components/App/Proponents/RegistrationUrl/RegistrationUrl.tsx b/submit-web/src/components/App/Proponents/RegistrationUrl/RegistrationUrl.tsx index 2f311af4..cd4525ea 100644 --- a/submit-web/src/components/App/Proponents/RegistrationUrl/RegistrationUrl.tsx +++ b/submit-web/src/components/App/Proponents/RegistrationUrl/RegistrationUrl.tsx @@ -3,8 +3,8 @@ import ConfirmationModal from "@/components/Shared/Modals/ConfirmationModal"; import { useModal } from "@/components/Shared/Modals/modalStore"; import { notify } from "@/components/Shared/Snackbar/snackbarStore"; import { useCreateNewAccountProjectInvitation } from "@/hooks/api/useInvitations"; -import { Invitation } from "@/models/Invitation"; import { USER_MANAGEMENT_ROLE } from "@/models/Role"; +import { useProponentStore } from "@/store/proponentStore"; import { AppConfig } from "@/utils/config"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { Grid, IconButton, TextField, Tooltip } from "@mui/material"; @@ -13,17 +13,17 @@ import { BCDesignTokens } from "epic.theme"; import { useState } from "react"; type RegistrationUrlProps = { - pendingInvitation?: Invitation; - selectedProjectsIds: (string | number)[]; onInvitationCreated: () => void; }; export const RegistrationUrl = ({ - pendingInvitation, - selectedProjectsIds, onInvitationCreated }: RegistrationUrlProps) => { const [tooltipText, setTooltipText] = useState("Copy"); + + const pendingInvitation = useProponentStore((state) => state.pendingInvitation); + const selectedProjectsIds = useProponentStore((state) => state.selectedProjectsIds); + const { proponentId } = useParams({ from: "/staff/_staffLayout/proponents/$proponentId", }); @@ -83,7 +83,8 @@ export const RegistrationUrl = ({ container spacing={2} sx={{ - mb: BCDesignTokens.layoutMarginXxlarge + mb: BCDesignTokens.layoutMarginXxlarge, + mt: BCDesignTokens.layoutMarginXlarge }} > diff --git a/submit-web/src/hooks/api/useProponents.ts b/submit-web/src/hooks/api/useProponents.ts index d5152fc3..0b7d3d83 100644 --- a/submit-web/src/hooks/api/useProponents.ts +++ b/submit-web/src/hooks/api/useProponents.ts @@ -1,7 +1,7 @@ import { Proponent } from "@/models/Proponent"; -import { submitRequest } from "@/utils/axiosUtils"; +import { OnErrorType, submitRequest } from "@/utils/axiosUtils"; import { QUERY_KEY } from "./constants"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; const getProponents = () => { return submitRequest({ @@ -76,3 +76,30 @@ export const useGetProponent = ( gcTime: 30 * 60 * 1000, // 30 minutes }); }; + +const enableProponentProjects = ({ + proponentId, + projectIds, +}: { + proponentId: number; + projectIds: (string | number)[]; +}) => { + return submitRequest({ + url: `proponents/${proponentId}/projects`, + method: "post", + data: { + projects: projectIds + }, + }); +}; + +type EnableProponentProjectOptions = { + onSuccess?: (data: Proponent) => void; + onError?: OnErrorType; +}; +export const useEnableProponentProject = (options: EnableProponentProjectOptions) => { + return useMutation({ + mutationFn: enableProponentProjects, + ...options, + }); +}; diff --git a/submit-web/src/routes/staff/_staffLayout/proponents/$proponentId.tsx b/submit-web/src/routes/staff/_staffLayout/proponents/$proponentId.tsx index 613a28ed..1589ee7f 100644 --- a/submit-web/src/routes/staff/_staffLayout/proponents/$proponentId.tsx +++ b/submit-web/src/routes/staff/_staffLayout/proponents/$proponentId.tsx @@ -1,22 +1,20 @@ -import { ProjectsTable } from "@/components/App/Proponents/ProjectsTable/ProjectsTable"; +import { EnableProjectsButton } from "@/components/App/Proponents/EnableProjectsButton/EnableProjectsButton"; +import { EligibleProjectsTable, OnboardedProjectsTable } from "@/components/App/Proponents/ProjectsTable"; import { RegistrationUrl } from "@/components/App/Proponents/RegistrationUrl/RegistrationUrl"; import { ProponentStatusChip } from "@/components/App/ProponentStatusChip"; import { ContentBox } from "@/components/Shared/Layouts/ContentBox"; import { ContentBoxSkeleton } from "@/components/Shared/Layouts/ContentBox/ContentBoxSkeleton"; import { PageGrid } from "@/components/Shared/PageGrid"; import { notify } from "@/components/Shared/Snackbar/snackbarStore"; -import { BarBlueTitle } from "@/components/Shared/Text/BarTitle"; import { getProponentOptions } from "@/hooks/api/useProponents"; -import { InvitationStatus } from "@/models/Invitation"; +import { useProponentStore } from "@/store/proponentStore"; import { HTTP_STATUS } from "@/utils/constants"; -import { InfoOutlined } from "@mui/icons-material"; -import { Grid, IconButton, Tooltip, Typography } from "@mui/material"; +import { Grid, Typography } from "@mui/material"; import { useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute, notFound, useParams } from "@tanstack/react-router"; import { isAxiosError } from "axios"; import { BCDesignTokens } from "epic.theme"; -import { useEffect, useState } from "react"; -import { Box } from "@mui/material"; +import { useEffect } from "react"; export const Route = createFileRoute( "/staff/_staffLayout/proponents/$proponentId", @@ -57,12 +55,10 @@ export const Route = createFileRoute( }); function ProponentPage() { - const [selectedProjectsIds, setSelectedProjectsIds] = useState< - (string | number)[] - >([]); const { proponentId } = useParams({ from: "/staff/_staffLayout/proponents/$proponentId", }); + const { data: proponent, isPending, @@ -75,19 +71,37 @@ function ProponentPage() { }), ); - // Ideally there is only 1 pending invitation per proponent, but just in case we grab the most recent pending invite. - const pendingInvitation = proponent?.invitations - ?.filter((invitation) => invitation.status === InvitationStatus.PENDING) - .sort( - (a, b) => - new Date(b.expiry_date).getTime() - new Date(a.expiry_date).getTime(), - )[0]; + // Zustand store actions + const { + eligibleProjects, + setProponent, + setIsLoading, + setIsError, + reset + } = useProponentStore(); + // Sync query data to store useEffect(() => { + if (proponent) { + setProponent(proponent); + } + }, [proponent, setProponent]); + + useEffect(() => { + setIsLoading(isPending); + }, [isPending, setIsLoading]); + + useEffect(() => { + setIsError(isError); if (isError) { notify.error("Error fetching proponent"); } - }, [isError]); + }, [isError, setIsError]); + + // Reset store on unmount + useEffect(() => { + return () => reset(); + }, [reset]); return ( @@ -98,75 +112,30 @@ function ProponentPage() { sx={{ width: "100%", minHeight: "43.75em" }} contentBoxVariant="secondary" > - - {`1. Select the project(s)/Work(s) you want to enable in EPIC.submit - 2. Generate an invite link - 3. Send it to the Proponent/Holder - - Once they create their account, those Project(s)/Work(s) will be ready for submissions.`} - - - - - - - } - /> - {proponent?.status == "INELIGIBLE" || - proponent?.projects?.length == 0 ? ( - + ) : ( + - - No other Project/Work for this Proponent/Holder is currently - eligible to be onboarded in EPIC.submit - - - ) : ( - <> - - - + {`1. Select the project(s)/Work(s) you want to enable in EPIC.submit + 2. Generate an invite link + 3. Send it to the Proponent/Holder + + Once they create their account, those Project(s)/Work(s) will be ready for submissions.`} + + )} + + {proponent?.status == "ONBOARDED" && eligibleProjects.length > 0 && ( + + )} + {proponent?.status != "ONBOARDED" && eligibleProjects.length > 0 && ( + )} diff --git a/submit-web/src/store/proponentStore.ts b/submit-web/src/store/proponentStore.ts new file mode 100644 index 00000000..ca6bfbb7 --- /dev/null +++ b/submit-web/src/store/proponentStore.ts @@ -0,0 +1,74 @@ +import { Invitation, InvitationStatus } from "@/models/Invitation"; +import { Project } from "@/models/Project"; +import { Proponent } from "@/models/Proponent"; +import { create } from "zustand"; + +interface ProponentState { + proponent: Proponent | null; + selectedProjectsIds: (string | number)[]; + isLoading: boolean; + isError: boolean; + + // Computed/derived properties + onboardedProjects: Project[]; + eligibleProjects: Project[]; + pendingInvitation: Invitation | undefined; + + // Actions + setProponent: (proponent: Proponent | null) => void; + setSelectedProjectsIds: (ids: (string | number)[]) => void; + setIsLoading: (loading: boolean) => void; + setIsError: (error: boolean) => void; + reset: () => void; +} + +const initialState = { + proponent: null, + selectedProjectsIds: [], + isLoading: false, + isError: false, + pendingInvitation: undefined, + onboardedProjects: [], + eligibleProjects: [], +}; + +export const useProponentStore = create((set, get) => ({ + ...initialState, + + setProponent: (proponent) => { + set({ proponent }); + + if (proponent) { + // Ideally there is only 1 pending invitation per proponent, but just in case we grab the most recent pending invite. + const pendingInvitation = proponent.invitations + ?.filter((invitation) => invitation.status === InvitationStatus.PENDING) + .sort( + (a, b) => + new Date(b.expiry_date).getTime() - new Date(a.expiry_date).getTime(), + )[0]; + + const accountProjectIds = new Set( + proponent.account_projects?.map(ap => ap.project_id) || [] + ); + + const onboardedProjects = (proponent.projects || []) + .filter(project => accountProjectIds.has(project.id)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const eligibleProjects = proponent.status != "ONBOARDED" + ? proponent.projects + : (proponent.projects || []) + .filter(project => !accountProjectIds.has(project.id)) + .sort((a, b) => a.name.localeCompare(b.name)); + + set({ pendingInvitation, onboardedProjects, eligibleProjects }); + } else { + set({ onboardedProjects: [], eligibleProjects: [] }); + } + }, + + setSelectedProjectsIds: (selectedProjectsIds) => set({ selectedProjectsIds }), + setIsLoading: (isLoading) => set({ isLoading }), + setIsError: (isError) => set({ isError }), + reset: () => set(initialState), +})); \ No newline at end of file From b425d9d10ee4f32be5e3fc9171f7ac3892594af8 Mon Sep 17 00:00:00 2001 From: leodube-aot Date: Mon, 9 Feb 2026 10:01:29 -0800 Subject: [PATCH 2/3] Lint and added unit tests --- .../resources/proponent/proponent.py | 1 + .../src/submit_api/schemas/proponent.py | 1 + .../submit_api/services/invitation_service.py | 1 - .../submit_api/services/proponent_service.py | 2 +- .../tests/unit/resources/test_proponent.py | 81 ++++++++++++++++++- submit-web/src/store/proponentStore.ts | 2 +- 6 files changed, 83 insertions(+), 5 deletions(-) diff --git a/submit-api/src/submit_api/resources/proponent/proponent.py b/submit-api/src/submit_api/resources/proponent/proponent.py index cb7f6072..82f32012 100644 --- a/submit-api/src/submit_api/resources/proponent/proponent.py +++ b/submit-api/src/submit_api/resources/proponent/proponent.py @@ -93,6 +93,7 @@ def get(proponent_id): raise ResourceNotFoundError(f"Proponent with id {proponent_id} not found") return proponent, HTTPStatus.OK + @cors_preflight("POST, OPTIONS") @API.route( "//projects", diff --git a/submit-api/src/submit_api/schemas/proponent.py b/submit-api/src/submit_api/schemas/proponent.py index b33f21db..18bf9370 100644 --- a/submit-api/src/submit_api/schemas/proponent.py +++ b/submit-api/src/submit_api/schemas/proponent.py @@ -22,6 +22,7 @@ class Meta: # pylint: disable=too-few-public-methods 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.""" diff --git a/submit-api/src/submit_api/services/invitation_service.py b/submit-api/src/submit_api/services/invitation_service.py index c2b70508..09d6539e 100644 --- a/submit-api/src/submit_api/services/invitation_service.py +++ b/submit-api/src/submit_api/services/invitation_service.py @@ -208,7 +208,6 @@ 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) diff --git a/submit-api/src/submit_api/services/proponent_service.py b/submit-api/src/submit_api/services/proponent_service.py index da34e43e..8f3fd5ca 100644 --- a/submit-api/src/submit_api/services/proponent_service.py +++ b/submit-api/src/submit_api/services/proponent_service.py @@ -36,7 +36,7 @@ def add_eligible_account_projects(cls, proponent_id, proponent_data): if not proponent: raise ResourceNotFoundError(f"Proponent with id {proponent_id} not found") - if not proponent.status is ProponentStatus.ONBOARDED: + if proponent.status is not ProponentStatus.ONBOARDED: raise BadRequestError("Can only enable projects for onboarded proponents.") account = Account.get_by_proponent_id(proponent_id) diff --git a/submit-api/tests/unit/resources/test_proponent.py b/submit-api/tests/unit/resources/test_proponent.py index dc8e1a5f..253aca07 100644 --- a/submit-api/tests/unit/resources/test_proponent.py +++ b/submit-api/tests/unit/resources/test_proponent.py @@ -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): @@ -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 + ) + account = 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 + ) + account = 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 diff --git a/submit-web/src/store/proponentStore.ts b/submit-web/src/store/proponentStore.ts index ca6bfbb7..9662fda2 100644 --- a/submit-web/src/store/proponentStore.ts +++ b/submit-web/src/store/proponentStore.ts @@ -32,7 +32,7 @@ const initialState = { eligibleProjects: [], }; -export const useProponentStore = create((set, get) => ({ +export const useProponentStore = create((set) => ({ ...initialState, setProponent: (proponent) => { From f9b4ad17bda2467c16695a520ad8b4f4062adc63 Mon Sep 17 00:00:00 2001 From: leodube-aot Date: Mon, 9 Feb 2026 10:04:54 -0800 Subject: [PATCH 3/3] Lint backend --- submit-api/tests/unit/resources/test_proponent.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/submit-api/tests/unit/resources/test_proponent.py b/submit-api/tests/unit/resources/test_proponent.py index 253aca07..19585941 100644 --- a/submit-api/tests/unit/resources/test_proponent.py +++ b/submit-api/tests/unit/resources/test_proponent.py @@ -173,7 +173,7 @@ def test_enable_proponent_projects_success(client, session, jwt): status=ProponentStatus.ONBOARDED, is_deleted=False ) - account = factory_account_model(proponent_id=proponent.id) + 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) @@ -191,7 +191,7 @@ def test_enable_proponent_projects_success(client, session, jwt): 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 @@ -224,7 +224,7 @@ def test_enable_proponent_projects_not_onboarded(client, session, jwt): status=ProponentStatus.ELIGIBLE, is_deleted=False ) - account = factory_account_model(proponent_id=proponent.id) + factory_account_model(proponent_id=proponent.id) project = factory_project_model(name="Project", proponent_id=proponent.id) payload = {