From 83b479cb8bf27cfd8d4a42b3daff0f2533135722 Mon Sep 17 00:00:00 2001 From: don-aot Date: Tue, 3 Feb 2026 14:52:25 -0800 Subject: [PATCH 1/3] SUBMIT-691:EAO - View Entity Administrator Contact Information on Proponent/Holder Detail page --- submit-api/src/submit_api/models/proponent.py | 48 +++++++++++++++- .../resources/proponent/proponent.py | 8 ++- .../submit_api/services/proponent_service.py | 11 +++- .../App/Proponents/Contacts/ContactCard.tsx | 56 +++++++++++++++++++ .../Proponents/Contacts/ContactsSection.tsx | 51 +++++++++++++++++ .../App/Proponents/Contacts/index.ts | 2 + submit-web/src/hooks/api/useProponents.ts | 6 ++ submit-web/src/models/Proponent.ts | 12 ++++ .../_staffLayout/proponents/$proponentId.tsx | 12 +++- 9 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 submit-web/src/components/App/Proponents/Contacts/ContactCard.tsx create mode 100644 submit-web/src/components/App/Proponents/Contacts/ContactsSection.tsx create mode 100644 submit-web/src/components/App/Proponents/Contacts/index.ts diff --git a/submit-api/src/submit_api/models/proponent.py b/submit-api/src/submit_api/models/proponent.py index 69231568..5cc237b8 100644 --- a/submit-api/src/submit_api/models/proponent.py +++ b/submit-api/src/submit_api/models/proponent.py @@ -8,11 +8,13 @@ from .account import Account from .account_project import AccountProject +from .account_user import AccountUser from .base_model import BaseModel from .invitations import Invitations from .project import Project from ..enums.invitation_status import InvitationStatus from ..enums.proponent_status import ProponentStatus +from ..enums.role import RoleEnum class Proponent(BaseModel): @@ -50,7 +52,13 @@ def get_all_proponents(cls, include_deleted=False, approved_conditions_only=None return query.order_by(cls.name).all() @classmethod - def get_proponent_by_id(cls, proponent_id, include_invitations=False, include_projects=False): + def get_proponent_by_id( + cls, + proponent_id, + include_invitations=False, + include_projects=False, + include_administrators=False, + ): """Get proponent by id. Args: @@ -70,7 +78,7 @@ def get_proponent_by_id(cls, proponent_id, include_invitations=False, include_pr "name": proponent.name, "status": proponent.status.value if proponent.status else None } - if not include_invitations and not include_projects: + if not include_invitations and not include_projects and not include_administrators: return proponent_dict accounts_ids = Account.query.with_entities(Account.id).filter_by(proponent_id=proponent_id).all() @@ -93,4 +101,40 @@ def get_proponent_by_id(cls, proponent_id, include_invitations=False, include_pr "project_id": account_project.project_id, } for account_project in account_projects] + if include_administrators and accounts_ids: + account_users = AccountUser.query.filter( + AccountUser.account_id.in_(accounts_ids) + ).all() + + administrators = [] + for user in account_users: + user_role = getattr(user, "role", None) + if not user_role or not user_role.active: + continue + + role_name = user_role.role.role_name + if role_name not in ( + RoleEnum.ACCOUNT_PRIMARY_ADMIN.value + ): + continue + + if not user.user_id: + # Skip users that have not completed registration. + continue + + administrators.append( + { + "id": user.id, + "first_name": user.first_name, + "last_name": user.last_name, + "full_name": user.full_name, + "position": user.position, + "company_name": user.company_name, + "work_contact_number": user.work_contact_number, + "work_email_address": user.work_email_address, + } + ) + + proponent_dict["administrators"] = administrators + return proponent_dict diff --git a/submit-api/src/submit_api/resources/proponent/proponent.py b/submit-api/src/submit_api/resources/proponent/proponent.py index a589264f..5b9f88c4 100644 --- a/submit-api/src/submit_api/resources/proponent/proponent.py +++ b/submit-api/src/submit_api/resources/proponent/proponent.py @@ -83,7 +83,13 @@ def get(proponent_id): """Get a proponent by id.""" include_invitations = request.args.get("include-invitations", "false").lower() == "true" include_projects = request.args.get("include-projects", "false").lower() == "true" - proponent = ProponentService.get_proponent(proponent_id, include_invitations, include_projects) + include_administrators = request.args.get("include-administrators", "false").lower() == "true" + proponent = ProponentService.get_proponent( + proponent_id, + include_invitations, + include_projects, + include_administrators, + ) if not proponent: raise ResourceNotFoundError(f"Proponent with id {proponent_id} not found") return proponent, HTTPStatus.OK diff --git a/submit-api/src/submit_api/services/proponent_service.py b/submit-api/src/submit_api/services/proponent_service.py index 5f399bd2..34260d52 100644 --- a/submit-api/src/submit_api/services/proponent_service.py +++ b/submit-api/src/submit_api/services/proponent_service.py @@ -6,12 +6,19 @@ class ProponentService: """Proponent management service.""" @classmethod - def get_proponent(cls, proponent_id, include_invitations=False, include_projects=False): + def get_proponent( + cls, + proponent_id, + include_invitations=False, + include_projects=False, + include_administrators=False, + ): """Get proponent by id.""" return Proponent.get_proponent_by_id( proponent_id, include_invitations=include_invitations, - include_projects=include_projects + include_projects=include_projects, + include_administrators=include_administrators, ) @classmethod diff --git a/submit-web/src/components/App/Proponents/Contacts/ContactCard.tsx b/submit-web/src/components/App/Proponents/Contacts/ContactCard.tsx new file mode 100644 index 00000000..a15b1dee --- /dev/null +++ b/submit-web/src/components/App/Proponents/Contacts/ContactCard.tsx @@ -0,0 +1,56 @@ +import { Box, Link, Typography } from "@mui/material"; +import { BCDesignTokens } from "epic.theme"; +import { ProponentAdministrator } from "@/models/Proponent"; + +type ContactCardProps = { + administrator: ProponentAdministrator; + entityName: string; + index: number; +}; + +export const ContactCard = ({ + administrator, + entityName, + index, +}: ContactCardProps) => { + + return ( + + + {`${entityName} Account Administrator ${index + 1}`} + + + + {administrator.full_name} + + {administrator.company_name && ( + {administrator.company_name} + )} + {administrator.position} + {administrator.work_contact_number} + + + {administrator.work_email_address} + + + + ); +}; + diff --git a/submit-web/src/components/App/Proponents/Contacts/ContactsSection.tsx b/submit-web/src/components/App/Proponents/Contacts/ContactsSection.tsx new file mode 100644 index 00000000..93338ee2 --- /dev/null +++ b/submit-web/src/components/App/Proponents/Contacts/ContactsSection.tsx @@ -0,0 +1,51 @@ +import { Grid, Typography } from "@mui/material"; +import { BCDesignTokens } from "epic.theme"; +import { ProponentAdministrator } from "@/models/Proponent"; +import { ContactCard } from "./ContactCard"; + +type ContactsSectionProps = { + entityName?: string; + administrators?: ProponentAdministrator[]; +}; + +export const ContactsSection = ({ + entityName, + administrators = [], +}: ContactsSectionProps) => { + if (!entityName || administrators.length === 0) { + return null; + } + + return ( + <> + + Contacts + + + {administrators.map((administrator, index) => ( + + + + ))} + + + ); +}; + diff --git a/submit-web/src/components/App/Proponents/Contacts/index.ts b/submit-web/src/components/App/Proponents/Contacts/index.ts new file mode 100644 index 00000000..d544dca1 --- /dev/null +++ b/submit-web/src/components/App/Proponents/Contacts/index.ts @@ -0,0 +1,2 @@ +export * from "./ContactsSection"; + diff --git a/submit-web/src/hooks/api/useProponents.ts b/submit-web/src/hooks/api/useProponents.ts index d5152fc3..65c40b95 100644 --- a/submit-web/src/hooks/api/useProponents.ts +++ b/submit-web/src/hooks/api/useProponents.ts @@ -35,6 +35,7 @@ export const useGetAllProponents = () => { type GetProponentOptions = { includeProjects?: boolean; includeInvitations?: boolean; + includeAdministrators?: boolean; }; const getProponent = ( @@ -48,6 +49,11 @@ const getProponent = ( if (options.includeInvitations) { params["include-invitations"] = String(Boolean(options.includeInvitations)); } + if (options.includeAdministrators) { + params["include-administrators"] = String( + Boolean(options.includeAdministrators), + ); + } return submitRequest({ url: `proponents/${proponentId}`, params, diff --git a/submit-web/src/models/Proponent.ts b/submit-web/src/models/Proponent.ts index 514643c6..64c9f44a 100644 --- a/submit-web/src/models/Proponent.ts +++ b/submit-web/src/models/Proponent.ts @@ -1,6 +1,17 @@ import { Invitation } from "./Invitation"; import { AccountProject, Project } from "./Project"; +export type ProponentAdministrator = { + id: number; + first_name: string; + last_name: string; + full_name: string; + company_name: string | null; + position: string; + work_contact_number: string; + work_email_address: string; +}; + export type ProponentStatus = "ELIGIBLE" | "INELIGIBLE" | "INVITE_GENERATED" | "PENDING_ONBOARDING" | "ONBOARDED"; export type Proponent = { @@ -11,4 +22,5 @@ export type Proponent = { invitations?: Invitation[]; projects?: Project[]; account_projects?: AccountProject[]; + administrators?: ProponentAdministrator[]; }; diff --git a/submit-web/src/routes/staff/_staffLayout/proponents/$proponentId.tsx b/submit-web/src/routes/staff/_staffLayout/proponents/$proponentId.tsx index 613a28ed..5789e91a 100644 --- a/submit-web/src/routes/staff/_staffLayout/proponents/$proponentId.tsx +++ b/submit-web/src/routes/staff/_staffLayout/proponents/$proponentId.tsx @@ -1,4 +1,5 @@ import { ProjectsTable } from "@/components/App/Proponents/ProjectsTable/ProjectsTable"; +import { ContactsSection } from "@/components/App/Proponents/Contacts"; import { RegistrationUrl } from "@/components/App/Proponents/RegistrationUrl/RegistrationUrl"; import { ProponentStatusChip } from "@/components/App/ProponentStatusChip"; import { ContentBox } from "@/components/Shared/Layouts/ContentBox"; @@ -10,13 +11,12 @@ import { getProponentOptions } from "@/hooks/api/useProponents"; import { InvitationStatus } from "@/models/Invitation"; import { HTTP_STATUS } from "@/utils/constants"; import { InfoOutlined } from "@mui/icons-material"; -import { Grid, IconButton, Tooltip, Typography } from "@mui/material"; +import { Box, Grid, IconButton, Tooltip, 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"; export const Route = createFileRoute( "/staff/_staffLayout/proponents/$proponentId", @@ -27,6 +27,7 @@ export const Route = createFileRoute( getProponentOptions(Number(proponentId), { includeProjects: true, includeInvitations: true, + includeAdministrators: true, }), ), onError: (error) => { @@ -72,6 +73,7 @@ function ProponentPage() { getProponentOptions(proponentId, { includeProjects: true, includeInvitations: true, + includeAdministrators: true, }), ); @@ -91,7 +93,7 @@ function ProponentPage() { return ( - + } @@ -168,6 +170,10 @@ function ProponentPage() { /> )} + From 17a84ae70f269cef5dbfdf6c8400c1e5571e9881 Mon Sep 17 00:00:00 2001 From: don-aot Date: Tue, 3 Feb 2026 15:03:29 -0800 Subject: [PATCH 2/3] SUBMIT-691:EAO - View Entity Administrator Contact Information on Proponent/Holder Detail page Refactoring and linting issue fixes --- submit-api/src/submit_api/models/proponent.py | 66 +++++++++---------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/submit-api/src/submit_api/models/proponent.py b/submit-api/src/submit_api/models/proponent.py index 5cc237b8..9b37dbeb 100644 --- a/submit-api/src/submit_api/models/proponent.py +++ b/submit-api/src/submit_api/models/proponent.py @@ -102,39 +102,37 @@ def get_proponent_by_id( } for account_project in account_projects] if include_administrators and accounts_ids: - account_users = AccountUser.query.filter( - AccountUser.account_id.in_(accounts_ids) - ).all() - - administrators = [] - for user in account_users: - user_role = getattr(user, "role", None) - if not user_role or not user_role.active: - continue - - role_name = user_role.role.role_name - if role_name not in ( - RoleEnum.ACCOUNT_PRIMARY_ADMIN.value - ): - continue - - if not user.user_id: - # Skip users that have not completed registration. - continue - - administrators.append( - { - "id": user.id, - "first_name": user.first_name, - "last_name": user.last_name, - "full_name": user.full_name, - "position": user.position, - "company_name": user.company_name, - "work_contact_number": user.work_contact_number, - "work_email_address": user.work_email_address, - } - ) - - proponent_dict["administrators"] = administrators + proponent_dict["administrators"] = cls._build_administrators( + AccountUser.query.filter( + AccountUser.account_id.in_(accounts_ids) + ).all() + ) return proponent_dict + + @classmethod + def _build_administrators(cls, account_users): + """Build administrators list from account users.""" + administrators = [] + for user in account_users: + user_role = getattr(user, "role", None) + if not user_role or not user_role.active: + continue + + if user_role.role.role_name != RoleEnum.ACCOUNT_PRIMARY_ADMIN.value: + continue + + if not user.user_id: + continue + + administrators.append({ + "id": user.id, + "first_name": user.first_name, + "last_name": user.last_name, + "full_name": user.full_name, + "position": user.position, + "company_name": user.company_name, + "work_contact_number": user.work_contact_number, + "work_email_address": user.work_email_address, + }) + return administrators From f6a86de33e83b0c7f48168b72f02f028da5afdfa Mon Sep 17 00:00:00 2001 From: don-aot Date: Tue, 3 Feb 2026 15:07:40 -0800 Subject: [PATCH 3/3] SUBMIT-691:EAO - View Entity Administrator Contact Information on Proponent/Holder Detail page Linting issue fixes --- submit-api/src/submit_api/resources/migration_ops.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/submit-api/src/submit_api/resources/migration_ops.py b/submit-api/src/submit_api/resources/migration_ops.py index 50a96383..9121a46f 100644 --- a/submit-api/src/submit_api/resources/migration_ops.py +++ b/submit-api/src/submit_api/resources/migration_ops.py @@ -65,8 +65,8 @@ def post(): # pylint: disable=too-many-locals,too-many-statements # Query all foreign key constraints that reference users.auth_guid current_app.logger.info("Querying foreign key constraints on users.auth_guid...") fk_query = text(""" - SELECT - tc.table_name, + SELECT + tc.table_name, tc.constraint_name, kcu.column_name FROM information_schema.table_constraints AS tc