From 7e3514dbcea2a9148f820205743d4899b35e8084 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 13 Nov 2024 17:18:38 -0800 Subject: [PATCH 01/15] feat: internal users can view access requests Signed-off-by: SeSo --- bc_obps/registration/api/v2/user_operators.py | 31 +++++++- .../registration/schema/v2/user_operator.py | 39 +++++++++- .../user_operator_service.py | 4 + bc_obps/service/user_operator_service_v2.py | 27 ++++++- .../userOperators/userOperatorColumns.ts | 75 +++++++++++++++++++ .../userOperators/userOperatorGroupColumns.ts | 59 +++++++++++++++ .../userOperators/UserOperatorDataGrid.tsx | 53 +++++++++++++ .../userOperators/UserOperatorsPage.tsx | 35 +++++++++ .../cells/UserOperatorStatusCell.tsx | 37 +++++++++ .../userOperators/getUserOperatorsPageData.ts | 22 ++++++ .../app/components/userOperators/types.ts | 19 ++++- .../page.tsx | 16 ++++ .../page.tsx | 16 ++++ 13 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 bciers/apps/administration/app/components/datagrid/models/userOperators/userOperatorColumns.ts create mode 100644 bciers/apps/administration/app/components/datagrid/models/userOperators/userOperatorGroupColumns.ts create mode 100644 bciers/apps/administration/app/components/userOperators/UserOperatorDataGrid.tsx create mode 100644 bciers/apps/administration/app/components/userOperators/UserOperatorsPage.tsx create mode 100644 bciers/apps/administration/app/components/userOperators/cells/UserOperatorStatusCell.tsx create mode 100644 bciers/apps/administration/app/components/userOperators/getUserOperatorsPageData.ts create mode 100644 bciers/apps/administration/app/idir/cas_admin/operator-administrators-and-access-requests/page.tsx create mode 100644 bciers/apps/administration/app/idir/cas_analyst/operator-administrators-and-access-requests/page.tsx diff --git a/bc_obps/registration/api/v2/user_operators.py b/bc_obps/registration/api/v2/user_operators.py index c219ef1172..905070268b 100644 --- a/bc_obps/registration/api/v2/user_operators.py +++ b/bc_obps/registration/api/v2/user_operators.py @@ -1,16 +1,41 @@ +from django.db.models import QuerySet +from ninja import Query from common.permissions import authorize from common.api.utils import get_current_user_guid from django.http import HttpRequest from registration.decorators import handle_http_errors +from registration.models import UserOperator from registration.schema.generic import Message from registration.api.router import router from service.error_service.custom_codes_4xx import custom_codes_4xx -from typing import Literal, Tuple - +from typing import Literal, Tuple, Optional, List from registration.constants import USER_OPERATOR_TAGS_V2 from registration.schema.v2.operator import OperatorIn -from registration.schema.v2.user_operator import UserOperatorOperatorOut +from registration.schema.v2.user_operator import UserOperatorOperatorOut, UserOperatorListOut, UserOperatorFilterSchema from service.user_operator_service_v2 import UserOperatorServiceV2 +from ninja.pagination import paginate, PageNumberPagination + +## GET +@router.get( + "/v2/user-operators", + response={200: List[UserOperatorListOut], custom_codes_4xx: Message}, + tags=USER_OPERATOR_TAGS_V2, + description="""Retrieves a paginated list of user operators. + The endpoint allows authorized IRC roles to view user operators, sorted by various fields such as creation date, + user details, and operator legal name.""", + auth=authorize("authorized_irc_user"), +) +@handle_http_errors() +@paginate(PageNumberPagination) +def list_user_operators_v2( + request: HttpRequest, + filters: UserOperatorFilterSchema = Query(...), + sort_field: Optional[str] = "created_at", + sort_order: Optional[Literal["desc", "asc"]] = "desc", + paginate_result: bool = Query(True, description="Whether to paginate the results"), +) -> QuerySet[UserOperator]: + # NOTE: PageNumberPagination raises an error if we pass the response as a tuple (like 200, ...) + return UserOperatorServiceV2.list_user_operators_v2(sort_field, sort_order, filters) ## POST diff --git a/bc_obps/registration/schema/v2/user_operator.py b/bc_obps/registration/schema/v2/user_operator.py index f5e1c18d22..3510c30b05 100644 --- a/bc_obps/registration/schema/v2/user_operator.py +++ b/bc_obps/registration/schema/v2/user_operator.py @@ -1,7 +1,44 @@ -from ninja import Schema +from typing import Optional +from django.db.models import Q +from ninja import Schema, FilterSchema, Field, ModelSchema from uuid import UUID +from registration.models import UserOperator class UserOperatorOperatorOut(Schema): operator_id: UUID user_operator_id: UUID + + +class UserOperatorFilterSchema(FilterSchema): + user_friendly_id: Optional[str] = Field(None, json_schema_extra={'q': 'user_friendly_id__icontains'}) + status: Optional[str] = None + user__first_name: Optional[str] = Field(None, json_schema_extra={'q': 'user__first_name__icontains'}) + user__last_name: Optional[str] = Field(None, json_schema_extra={'q': 'user__last_name__icontains'}) + user__email: Optional[str] = Field(None, json_schema_extra={'q': 'user__email__icontains'}) + user__bceid_business_name: Optional[str] = Field( + None, json_schema_extra={'q': 'user__bceid_business_name__icontains'} + ) + operator__legal_name: Optional[str] = Field(None, json_schema_extra={'q': 'operator__legal_name__icontains'}) + + @staticmethod + def filter_status(value: Optional[str]) -> Q: + # Override the default filter_status method to handle the special case of 'admin' and 'access' + # The value in the frontend is 'admin access' but the value in the database is 'approved' + if value: + if value.lower() in "admin access": + value = "approved" + return Q(status__icontains=value) + return Q() + + +class UserOperatorListOut(ModelSchema): + user__first_name: str = Field(..., alias="user.first_name") + user__last_name: str = Field(..., alias="user.last_name") + user__email: str = Field(..., alias="user.email") + user__bceid_business_name: str = Field(..., alias="user.bceid_business_name") + operator__legal_name: str = Field(..., alias="operator.legal_name") + + class Meta: + model = UserOperator + fields = ['id', 'user_friendly_id', 'status'] diff --git a/bc_obps/service/data_access_service/user_operator_service.py b/bc_obps/service/data_access_service/user_operator_service.py index 24caa4b174..fc5ffe97d6 100644 --- a/bc_obps/service/data_access_service/user_operator_service.py +++ b/bc_obps/service/data_access_service/user_operator_service.py @@ -56,3 +56,7 @@ def get_approved_user_operator(cls, user: User) -> Optional[UserOperator]: Based on the Constraint, there should only be one UserOperator associated with a user and operator. """ return user.user_operators.only("operator_id").filter(status=UserOperator.Statuses.APPROVED).first() + + @classmethod + def get_all_admin_user_operators(cls) -> QuerySet[UserOperator]: + return UserOperator.objects.select_related("user", "operator").filter(role=UserOperator.Roles.ADMIN) diff --git a/bc_obps/service/user_operator_service_v2.py b/bc_obps/service/user_operator_service_v2.py index 63daec6f1c..96edd6c95a 100644 --- a/bc_obps/service/user_operator_service_v2.py +++ b/bc_obps/service/user_operator_service_v2.py @@ -1,7 +1,12 @@ -from typing import Dict +from typing import Dict, Optional from uuid import UUID from django.db import transaction +from django.db.models import QuerySet +from django.db.models.functions import Lower +from ninja import Query + +from registration.schema.v2.user_operator import UserOperatorFilterSchema from registration.utils import update_model_instance from registration.models import Operator, UserOperator from service.data_access_service.user_operator_service import UserOperatorDataAccessService @@ -71,3 +76,23 @@ def create_operator_and_user_operator(cls, user_guid: UUID, payload: OperatorIn) OperatorServiceV2.update_operator(user_guid, payload) return {"user_operator_id": user_operator.id, 'operator_id': user_operator.operator.id} + + @classmethod + def list_user_operators_v2( + cls, sort_field: Optional[str], sort_order: Optional[str], filters: UserOperatorFilterSchema = Query(...) + ) -> QuerySet[UserOperator]: + # Used to show internal users the list of user_operators to approve/deny + base_qs = UserOperatorDataAccessService.get_all_admin_user_operators() + + # `created_at` and `user_friendly_id` are not case-insensitive fields and Lower() cannot be applied to them + if sort_field in ['created_at', 'user_friendly_id']: + sort_direction = "-" if sort_order == "desc" else "" + return filters.filter(base_qs).order_by(f"{sort_direction}{sort_field}") + + # Use Lower for case-insensitive ordering + lower_sort_field = Lower(sort_field) + if sort_order == "desc": + # Apply descending order + return filters.filter(base_qs).order_by(lower_sort_field.desc()) + # Apply ascending order + return filters.filter(base_qs).order_by(lower_sort_field) diff --git a/bciers/apps/administration/app/components/datagrid/models/userOperators/userOperatorColumns.ts b/bciers/apps/administration/app/components/datagrid/models/userOperators/userOperatorColumns.ts new file mode 100644 index 0000000000..88f2f5ff8a --- /dev/null +++ b/bciers/apps/administration/app/components/datagrid/models/userOperators/userOperatorColumns.ts @@ -0,0 +1,75 @@ +import { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import UserOperatorStatusCell from "@/administration/app/components/userOperators/cells/UserOperatorStatusCell"; + +const userOperatorColumns = ( + ActionCell: (params: GridRenderCellParams) => JSX.Element, +) => { + const columns: GridColDef[] = [ + { + field: "user_friendly_id", + headerName: "Request ID", + align: "center", + headerAlign: "center", + width: 120, + }, + { + field: "user__first_name", + headerName: "First Name", + align: "center", + headerAlign: "center", + width: 150, + }, + { + field: "user__last_name", + headerName: "Last Name", + align: "center", + headerAlign: "center", + width: 150, + }, + { + field: "user__email", + headerName: "Email", + align: "center", + headerAlign: "center", + width: 200, + }, + { + field: "user__bceid_business_name", + headerName: "BCeID Business Name", + align: "center", + headerAlign: "center", + minWidth: 200, + flex: 1, + }, + { + field: "operator__legal_name", + headerName: "Operator", + align: "center", + headerAlign: "center", + minWidth: 200, + flex: 1, + }, + { + field: "status", + headerName: "Status", + renderCell: UserOperatorStatusCell, + align: "center", + headerAlign: "center", + width: 150, + }, + { + field: "actions", + headerName: "Actions", + sortable: false, + renderCell: ActionCell, + align: "center", + headerAlign: "center", + minWidth: 220, + flex: 1, + }, + ]; + + return columns; +}; + +export default userOperatorColumns; diff --git a/bciers/apps/administration/app/components/datagrid/models/userOperators/userOperatorGroupColumns.ts b/bciers/apps/administration/app/components/datagrid/models/userOperators/userOperatorGroupColumns.ts new file mode 100644 index 0000000000..9eda3e2434 --- /dev/null +++ b/bciers/apps/administration/app/components/datagrid/models/userOperators/userOperatorGroupColumns.ts @@ -0,0 +1,59 @@ +import { GridColumnGroupHeaderParams } from "@mui/x-data-grid"; +import EmptyGroupCell from "@bciers/components/datagrid/cells/EmptyGroupCell"; + +const userOperatorGroupColumns = ( + SearchCell: (params: GridColumnGroupHeaderParams) => JSX.Element, +) => { + return [ + { + groupId: "user_friendly_id", + headerName: "Request ID", + renderHeaderGroup: SearchCell, + children: [{ field: "user_friendly_id" }], + }, + { + groupId: "user__first_name", + headerName: "First Name", + renderHeaderGroup: SearchCell, + children: [{ field: "user__first_name" }], + }, + { + groupId: "user__last_name", + headerName: "Last Name", + renderHeaderGroup: SearchCell, + children: [{ field: "user__last_name" }], + }, + { + groupId: "user__email", + headerName: "Email", + renderHeaderGroup: SearchCell, + children: [{ field: "user__email" }], + }, + { + groupId: "user__bceid_business_name", + headerName: "BCeID Business Name", + renderHeaderGroup: SearchCell, + children: [{ field: "user__bceid_business_name" }], + }, + { + groupId: "operator__legal_name", + headerName: "Operator", + renderHeaderGroup: SearchCell, + children: [{ field: "operator__legal_name" }], + }, + { + groupId: "status", + headerName: "Status", + renderHeaderGroup: SearchCell, + children: [{ field: "status" }], + }, + { + groupId: "action", + headerName: "Actions", + renderHeaderGroup: EmptyGroupCell, + children: [{ field: "action" }], + }, + ]; +}; + +export default userOperatorGroupColumns; diff --git a/bciers/apps/administration/app/components/userOperators/UserOperatorDataGrid.tsx b/bciers/apps/administration/app/components/userOperators/UserOperatorDataGrid.tsx new file mode 100644 index 0000000000..0a0e712cb6 --- /dev/null +++ b/bciers/apps/administration/app/components/userOperators/UserOperatorDataGrid.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { UserOperatorDataGridRow } from "apps/administration/app/components/userOperators/types"; +import DataGrid from "@bciers/components/datagrid/DataGrid"; +import { useMemo, useState } from "react"; +import userOperatorColumns from "@/administration/app/components/datagrid/models/userOperators/userOperatorColumns"; +import ActionCellFactory from "@bciers/components/datagrid/cells/ActionCellFactory"; +import { GridRenderCellParams } from "@mui/x-data-grid"; +import HeaderSearchCell from "@bciers/components/datagrid/cells/HeaderSearchCell"; +import userOperatorGroupColumns from "@/administration/app/components/datagrid/models/userOperators/userOperatorGroupColumns"; +import getUserOperatorsPageData from "@/administration/app/components/userOperators/getUserOperatorsPageData"; + +const UserOperatorsActionCell = ActionCellFactory({ + generateHref: (params: GridRenderCellParams) => { + return "TBD"; // Will be implemented in a future ticket + }, + cellText: "View Details", +}); + +const UserOperatorDataGrid = ({ + initialData, +}: { + initialData: { + rows: UserOperatorDataGridRow[]; + row_count: number; + }; +}) => { + const [lastFocusedField, setLastFocusedField] = useState(null); + + const SearchCell = useMemo( + () => HeaderSearchCell({ lastFocusedField, setLastFocusedField }), + [lastFocusedField, setLastFocusedField], + ); + const ActionCell = useMemo(() => UserOperatorsActionCell, []); + + const columns = useMemo(() => userOperatorColumns(ActionCell), []); + const columnGroup = useMemo( + () => userOperatorGroupColumns(SearchCell), + [SearchCell], + ); + + return ( + + ); +}; + +export default UserOperatorDataGrid; diff --git a/bciers/apps/administration/app/components/userOperators/UserOperatorsPage.tsx b/bciers/apps/administration/app/components/userOperators/UserOperatorsPage.tsx new file mode 100644 index 0000000000..05649b11f0 --- /dev/null +++ b/bciers/apps/administration/app/components/userOperators/UserOperatorsPage.tsx @@ -0,0 +1,35 @@ +import Note from "@bciers/components/layout/Note"; +import { Suspense } from "react"; +import Loading from "@bciers/components/loading/SkeletonGrid"; +import { + UserOperatorDataGridRow, + UserOperatorsSearchParams, +} from "@/administration/app/components/userOperators/types"; +import getUserOperatorsPageData from "@/administration/app/components/userOperators/getUserOperatorsPageData"; +import UserOperatorDataGrid from "@/administration/app/components/userOperators/UserOperatorDataGrid"; + +export default async function UserOperatorsPage({ + searchParams, +}: { + searchParams: UserOperatorsSearchParams; +}) { + const userOperatorData: + | { rows: UserOperatorDataGridRow[]; row_count: number } + | { error: string } = await getUserOperatorsPageData(searchParams); + + if (!userOperatorData || "error" in userOperatorData) + throw new Error("Failed to retrieve admin requests."); + + return ( + <> + + Note: Once "Approved", the user will have access to their + operator dashboard with full admin permissions,and can grant access and + designate permissions to other authorized users there. + + }> + + + + ); +} diff --git a/bciers/apps/administration/app/components/userOperators/cells/UserOperatorStatusCell.tsx b/bciers/apps/administration/app/components/userOperators/cells/UserOperatorStatusCell.tsx new file mode 100644 index 0000000000..93e17be276 --- /dev/null +++ b/bciers/apps/administration/app/components/userOperators/cells/UserOperatorStatusCell.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Status } from "@bciers/utils/src/enums"; +import { Chip, ChipOwnProps } from "@mui/material"; +import { GridRenderCellParams } from "@mui/x-data-grid"; + +export default function UserOperatorStatusCell(params: GridRenderCellParams) { + const colorMap = new Map([ + [Status.PENDING, "primary"], + [Status.APPROVED, "success"], + [Status.DECLINED, "error"], + ]); + const status = + params.value === Status.APPROVED ? "Admin Access" : params.value; + const statusColor = colorMap.get(params.value) || "primary"; + const isMultiLineStatus = status === Status.APPROVED; + + // Adjust the font size for multi-line statuses so it will fit in the chip + const fontSize = isMultiLineStatus ? "14px" : "16px"; + return ( + + {status} + + } + variant="outlined" + color={statusColor} + sx={{ + width: 100, + height: 40, + borderRadius: "20px", + }} + /> + ); +} diff --git a/bciers/apps/administration/app/components/userOperators/getUserOperatorsPageData.ts b/bciers/apps/administration/app/components/userOperators/getUserOperatorsPageData.ts new file mode 100644 index 0000000000..2ab1972be1 --- /dev/null +++ b/bciers/apps/administration/app/components/userOperators/getUserOperatorsPageData.ts @@ -0,0 +1,22 @@ +import { actionHandler } from "@bciers/actions"; +import { UserOperatorsSearchParams } from "@/administration/app/components/userOperators/types"; +import buildQueryParams from "@bciers/utils/src/buildQueryParams"; + +export default async function getUserOperatorsPageData( + searchParams: UserOperatorsSearchParams, +) { + try { + const queryParams = buildQueryParams(searchParams); + const pageData = await actionHandler( + `registration/v2/user-operators${queryParams}`, + "GET", + "", + ); + return { + rows: pageData.items, + row_count: pageData.count, + }; + } catch (error) { + throw error; + } +} diff --git a/bciers/apps/administration/app/components/userOperators/types.ts b/bciers/apps/administration/app/components/userOperators/types.ts index 1f9539dbac..90ab8c44cf 100644 --- a/bciers/apps/administration/app/components/userOperators/types.ts +++ b/bciers/apps/administration/app/components/userOperators/types.ts @@ -1,4 +1,4 @@ -import { Status } from "@bciers/utils/src/enums"; +import { Status, UserOperatorStatus } from "@bciers/utils/src/enums"; import { GridRenderCellParams } from "@mui/x-data-grid/models/params/gridCellParams"; import { ButtonOwnProps } from "@mui/material/Button"; import { ReactNode } from "react"; @@ -65,3 +65,20 @@ export interface AccessRequest { legal_name: string; }; } + +export interface UserOperatorsSearchParams { + [key: string]: string | number | undefined; + sort_field?: string; + sort_order?: string; +} + +export interface UserOperatorDataGridRow { + id: number; + user_friendly_id: string; + status: string; + user__first_name: string; + user__last_name: string; + user__email: string; + user__bceid_business_name: string; + operator__legal_name: string; +} diff --git a/bciers/apps/administration/app/idir/cas_admin/operator-administrators-and-access-requests/page.tsx b/bciers/apps/administration/app/idir/cas_admin/operator-administrators-and-access-requests/page.tsx new file mode 100644 index 0000000000..38fc101e3f --- /dev/null +++ b/bciers/apps/administration/app/idir/cas_admin/operator-administrators-and-access-requests/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from "react"; +import { OperatorsSearchParams } from "@/app/components/userOperators/types"; +import Loading from "@bciers/components/loading/SkeletonGrid"; +import UserOperatorsPage from "@/administration/app/components/userOperators/UserOperatorsPage"; + +export default async function Page({ + searchParams, +}: { + searchParams: OperatorsSearchParams; +}) { + return ( + }> + + + ); +} diff --git a/bciers/apps/administration/app/idir/cas_analyst/operator-administrators-and-access-requests/page.tsx b/bciers/apps/administration/app/idir/cas_analyst/operator-administrators-and-access-requests/page.tsx new file mode 100644 index 0000000000..38fc101e3f --- /dev/null +++ b/bciers/apps/administration/app/idir/cas_analyst/operator-administrators-and-access-requests/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from "react"; +import { OperatorsSearchParams } from "@/app/components/userOperators/types"; +import Loading from "@bciers/components/loading/SkeletonGrid"; +import UserOperatorsPage from "@/administration/app/components/userOperators/UserOperatorsPage"; + +export default async function Page({ + searchParams, +}: { + searchParams: OperatorsSearchParams; +}) { + return ( + }> + + + ); +} From 58bae19dd390382632156138a98266312135ebd7 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 13 Nov 2024 17:19:00 -0800 Subject: [PATCH 02/15] test: update endpoint permission test Signed-off-by: SeSo --- bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py b/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py index ad3d3d8869..2d62f0661f 100644 --- a/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py +++ b/bc_obps/common/tests/endpoints/auth/test_endpoint_permissions.py @@ -170,6 +170,7 @@ class TestEndpointPermissions(TestCase): ], "authorized_irc_user": [ {"method": "get", "endpoint_name": "list_user_operators"}, + {"method": "get", "endpoint_name": "list_user_operators_v2"}, {"method": "put", "endpoint_name": "update_operator_status", "kwargs": {"operator_id": mock_uuid}}, { "method": "put", From 1afdbb6bbfe5dad6b7d8c1536505c4462ac44c6a Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 13 Nov 2024 17:19:27 -0800 Subject: [PATCH 03/15] chore: update dashboard tiles Signed-off-by: SeSo --- .../fixtures/dashboard/administration/internal.json | 8 +------- .../fixtures/dashboard/administration/internal_admin.json | 8 +------- bc_obps/common/fixtures/dashboard/bciers/internal.json | 4 ++-- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/bc_obps/common/fixtures/dashboard/administration/internal.json b/bc_obps/common/fixtures/dashboard/administration/internal.json index 0df86b7323..f5f76114db 100644 --- a/bc_obps/common/fixtures/dashboard/administration/internal.json +++ b/bc_obps/common/fixtures/dashboard/administration/internal.json @@ -23,13 +23,7 @@ "title": "Operator Administrators and Access Requests", "icon": "Inbox", "content": "View all operator administrators and administrator access requests here.", - "href": "/administration/tbd" - }, - { - "title": "Operation Administrations", - "icon": "Layers", - "content": "View the administration of operations here.", - "href": "/administration/tbd" + "href": "/administration/operator-administrators-and-access-requests" }, { "title": "Contacts", diff --git a/bc_obps/common/fixtures/dashboard/administration/internal_admin.json b/bc_obps/common/fixtures/dashboard/administration/internal_admin.json index 032fc26fb2..1a31400114 100644 --- a/bc_obps/common/fixtures/dashboard/administration/internal_admin.json +++ b/bc_obps/common/fixtures/dashboard/administration/internal_admin.json @@ -23,13 +23,7 @@ "title": "Operator Administrators and Access Requests", "icon": "Inbox", "content": "View all operator administrators and administrator access requests here.", - "href": "/administration/tbd" - }, - { - "title": "Operation Administrations", - "icon": "File", - "content": "View the administration of operations here.", - "href": "/administration/tbd" + "href": "/administration/operator-administrators-and-access-requests" }, { "title": "Contacts", diff --git a/bc_obps/common/fixtures/dashboard/bciers/internal.json b/bc_obps/common/fixtures/dashboard/bciers/internal.json index 325ad18692..a399988814 100644 --- a/bc_obps/common/fixtures/dashboard/bciers/internal.json +++ b/bc_obps/common/fixtures/dashboard/bciers/internal.json @@ -22,8 +22,8 @@ "href": "/administration/operations" }, { - "title": "Operator Admins and Access Requests", - "href": "/administration/tbd" + "title": "Operator Administrators and Access Requests", + "href": "/administration/operator-administrators-and-access-requests" }, { "title": "Contacts", From 230a9800e98b790995a600deb1ab239225ed07b8 Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 13 Nov 2024 17:20:03 -0800 Subject: [PATCH 04/15] chore: update fixtures to have better data in the grid Signed-off-by: SeSo --- bc_obps/registration/fixtures/mock/admin/user_operator.json | 6 +++--- .../fixtures/mock/admin/user_operator_approved.json | 6 +++--- bc_obps/registration/fixtures/mock/user_operator.json | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bc_obps/registration/fixtures/mock/admin/user_operator.json b/bc_obps/registration/fixtures/mock/admin/user_operator.json index 9f2c98f8b0..c6a989e444 100644 --- a/bc_obps/registration/fixtures/mock/admin/user_operator.json +++ b/bc_obps/registration/fixtures/mock/admin/user_operator.json @@ -112,7 +112,7 @@ "fields": { "user": "00000000-0000-0000-0000-000000000007", "operator": "5712ee05-5f3b-4822-825d-6fffddafda4c", - "role": "pending", + "role": "admin", "status": "Pending", "verified_at": null, "verified_by": null, @@ -196,8 +196,8 @@ "fields": { "user": "00000000-0000-0000-0000-000000000014", "operator": "5712ee05-5f3b-4822-825d-6fffddafda4c", - "role": "pending", - "status": "Pending", + "role": "admin", + "status": "Declined", "verified_at": null, "verified_by": null, "user_friendly_id": 17 diff --git a/bc_obps/registration/fixtures/mock/admin/user_operator_approved.json b/bc_obps/registration/fixtures/mock/admin/user_operator_approved.json index 93b49839e2..5939e35cfe 100644 --- a/bc_obps/registration/fixtures/mock/admin/user_operator_approved.json +++ b/bc_obps/registration/fixtures/mock/admin/user_operator_approved.json @@ -112,7 +112,7 @@ "fields": { "user": "00000000-0000-0000-0000-000000000007", "operator": "5712ee05-5f3b-4822-825d-6fffddafda4c", - "role": "pending", + "role": "admin", "status": "Pending", "verified_at": null, "verified_by": null, @@ -196,8 +196,8 @@ "fields": { "user": "00000000-0000-0000-0000-000000000014", "operator": "5712ee05-5f3b-4822-825d-6fffddafda4c", - "role": "pending", - "status": "Pending", + "role": "admin", + "status": "Declined", "verified_at": null, "verified_by": null, "user_friendly_id": 17 diff --git a/bc_obps/registration/fixtures/mock/user_operator.json b/bc_obps/registration/fixtures/mock/user_operator.json index 6f92792327..a20801f15d 100644 --- a/bc_obps/registration/fixtures/mock/user_operator.json +++ b/bc_obps/registration/fixtures/mock/user_operator.json @@ -112,7 +112,7 @@ "fields": { "user": "00000000-0000-0000-0000-000000000007", "operator": "5712ee05-5f3b-4822-825d-6fffddafda4c", - "role": "pending", + "role": "admin", "status": "Pending", "verified_at": null, "verified_by": null, @@ -196,8 +196,8 @@ "fields": { "user": "00000000-0000-0000-0000-000000000014", "operator": "5712ee05-5f3b-4822-825d-6fffddafda4c", - "role": "pending", - "status": "Pending", + "role": "admin", + "status": "Declined", "verified_at": null, "verified_by": null, "user_friendly_id": 17 From 06c1312d8c2e33d1612d0ae2be958f3a59676f21 Mon Sep 17 00:00:00 2001 From: SeSo Date: Thu, 14 Nov 2024 09:53:47 -0800 Subject: [PATCH 05/15] chore: more informative errors when debugging Signed-off-by: SeSo --- bc_obps/service/error_service/handle_exception.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bc_obps/service/error_service/handle_exception.py b/bc_obps/service/error_service/handle_exception.py index 2003df518c..801764939f 100644 --- a/bc_obps/service/error_service/handle_exception.py +++ b/bc_obps/service/error_service/handle_exception.py @@ -2,10 +2,11 @@ Module: handle_exception.py Description: This module handles http exceptions. """ - +import traceback from typing import Dict, Literal, Optional, Tuple from django.http import Http404 from django.core.exceptions import ValidationError, ObjectDoesNotExist +from bc_obps.settings import DEBUG from registration.utils import generate_useful_error from registration.constants import UNAUTHORIZED_MESSAGE @@ -14,6 +15,11 @@ def handle_exception(error: Exception) -> Tuple[Literal[400, 401, 403, 404, 422] """ This function handles exceptions for BCEIRS. Returns a 4xx status. """ + if DEBUG == "True": + # Print the error in the console for easier debugging + print("---------------------------------------------ERROR START-----------------------------------------------") + print(traceback.format_exc()) + print("---------------------------------------------ERROR END-------------------------------------------------") if error.args and error.args[0] == UNAUTHORIZED_MESSAGE: return 401, {"message": UNAUTHORIZED_MESSAGE} if isinstance(error, (Http404, ObjectDoesNotExist)): From 987087e86d34115fb5e0a3541d6060810983cdb5 Mon Sep 17 00:00:00 2001 From: SeSo Date: Thu, 14 Nov 2024 09:54:42 -0800 Subject: [PATCH 06/15] chore: fix eslint errors Signed-off-by: SeSo --- .../app/components/userOperators/UserOperatorDataGrid.tsx | 5 ++--- .../app/components/userOperators/UserOperatorsPage.tsx | 6 +++--- .../administration/app/components/userOperators/types.ts | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/bciers/apps/administration/app/components/userOperators/UserOperatorDataGrid.tsx b/bciers/apps/administration/app/components/userOperators/UserOperatorDataGrid.tsx index 0a0e712cb6..4ee164d774 100644 --- a/bciers/apps/administration/app/components/userOperators/UserOperatorDataGrid.tsx +++ b/bciers/apps/administration/app/components/userOperators/UserOperatorDataGrid.tsx @@ -5,14 +5,13 @@ import DataGrid from "@bciers/components/datagrid/DataGrid"; import { useMemo, useState } from "react"; import userOperatorColumns from "@/administration/app/components/datagrid/models/userOperators/userOperatorColumns"; import ActionCellFactory from "@bciers/components/datagrid/cells/ActionCellFactory"; -import { GridRenderCellParams } from "@mui/x-data-grid"; import HeaderSearchCell from "@bciers/components/datagrid/cells/HeaderSearchCell"; import userOperatorGroupColumns from "@/administration/app/components/datagrid/models/userOperators/userOperatorGroupColumns"; import getUserOperatorsPageData from "@/administration/app/components/userOperators/getUserOperatorsPageData"; const UserOperatorsActionCell = ActionCellFactory({ - generateHref: (params: GridRenderCellParams) => { - return "TBD"; // Will be implemented in a future ticket + generateHref: () => { + return "TBD"; // Will be implemented in a future ticket by using `params: GridRenderCellParams` }, cellText: "View Details", }); diff --git a/bciers/apps/administration/app/components/userOperators/UserOperatorsPage.tsx b/bciers/apps/administration/app/components/userOperators/UserOperatorsPage.tsx index 05649b11f0..710ff7620e 100644 --- a/bciers/apps/administration/app/components/userOperators/UserOperatorsPage.tsx +++ b/bciers/apps/administration/app/components/userOperators/UserOperatorsPage.tsx @@ -23,9 +23,9 @@ export default async function UserOperatorsPage({ return ( <> - Note: Once "Approved", the user will have access to their - operator dashboard with full admin permissions,and can grant access and - designate permissions to other authorized users there. + Note: Once "Approved", the user will have access to + their operator dashboard with full admin permissions,and can grant + access and designate permissions to other authorized users there. }> diff --git a/bciers/apps/administration/app/components/userOperators/types.ts b/bciers/apps/administration/app/components/userOperators/types.ts index 90ab8c44cf..4be8603d9d 100644 --- a/bciers/apps/administration/app/components/userOperators/types.ts +++ b/bciers/apps/administration/app/components/userOperators/types.ts @@ -75,7 +75,7 @@ export interface UserOperatorsSearchParams { export interface UserOperatorDataGridRow { id: number; user_friendly_id: string; - status: string; + status: UserOperatorStatus; user__first_name: string; user__last_name: string; user__email: string; From ae61bb72382ca7dad1fb8d1be1fadc4df37579a9 Mon Sep 17 00:00:00 2001 From: SeSo Date: Thu, 14 Nov 2024 17:27:25 -0800 Subject: [PATCH 07/15] chore: limit service access to irc users Signed-off-by: SeSo --- bc_obps/service/user_operator_service_v2.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bc_obps/service/user_operator_service_v2.py b/bc_obps/service/user_operator_service_v2.py index 96edd6c95a..69fce39975 100644 --- a/bc_obps/service/user_operator_service_v2.py +++ b/bc_obps/service/user_operator_service_v2.py @@ -6,11 +6,13 @@ from django.db.models.functions import Lower from ninja import Query +from registration.constants import UNAUTHORIZED_MESSAGE from registration.schema.v2.user_operator import UserOperatorFilterSchema from registration.utils import update_model_instance from registration.models import Operator, UserOperator from service.data_access_service.user_operator_service import UserOperatorDataAccessService from registration.schema.v2.operator import OperatorIn +from service.data_access_service.user_service import UserDataAccessService from service.operator_service_v2 import OperatorServiceV2 @@ -79,10 +81,20 @@ def create_operator_and_user_operator(cls, user_guid: UUID, payload: OperatorIn) @classmethod def list_user_operators_v2( - cls, sort_field: Optional[str], sort_order: Optional[str], filters: UserOperatorFilterSchema = Query(...) + cls, + user_guid: UUID, + sort_field: Optional[str], + sort_order: Optional[str], + filters: UserOperatorFilterSchema = Query(...), ) -> QuerySet[UserOperator]: + + user = UserDataAccessService.get_by_guid(user_guid) + # This service is only available to IRC users + if not user.is_irc_user(): + raise Exception(UNAUTHORIZED_MESSAGE) + # Used to show internal users the list of user_operators to approve/deny - base_qs = UserOperatorDataAccessService.get_all_admin_user_operators() + base_qs = UserOperatorDataAccessService.get_admin_user_operator_requests_for_irc_users() # `created_at` and `user_friendly_id` are not case-insensitive fields and Lower() cannot be applied to them if sort_field in ['created_at', 'user_friendly_id']: From a12ba25a0fc35454aa9eea4c82cd2a577546e12d Mon Sep 17 00:00:00 2001 From: SeSo Date: Thu, 14 Nov 2024 17:27:44 -0800 Subject: [PATCH 08/15] chore: pass user guid to service Signed-off-by: SeSo --- bc_obps/registration/api/v2/user_operators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bc_obps/registration/api/v2/user_operators.py b/bc_obps/registration/api/v2/user_operators.py index 905070268b..e9fea22982 100644 --- a/bc_obps/registration/api/v2/user_operators.py +++ b/bc_obps/registration/api/v2/user_operators.py @@ -35,7 +35,7 @@ def list_user_operators_v2( paginate_result: bool = Query(True, description="Whether to paginate the results"), ) -> QuerySet[UserOperator]: # NOTE: PageNumberPagination raises an error if we pass the response as a tuple (like 200, ...) - return UserOperatorServiceV2.list_user_operators_v2(sort_field, sort_order, filters) + return UserOperatorServiceV2.list_user_operators_v2(get_current_user_guid(request), sort_field, sort_order, filters) ## POST From 3981d17e2bdc7df4f21fe64fa5470d79a78764aa Mon Sep 17 00:00:00 2001 From: SeSo Date: Thu, 14 Nov 2024 17:28:23 -0800 Subject: [PATCH 09/15] chore: modify data access service and add tests for it Signed-off-by: SeSo --- .../user_operator_service.py | 23 +++- .../test_data_access_user_operator_service.py | 125 ++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 bc_obps/service/tests/data_access_service/test_data_access_user_operator_service.py diff --git a/bc_obps/service/data_access_service/user_operator_service.py b/bc_obps/service/data_access_service/user_operator_service.py index fc5ffe97d6..9a725ae943 100644 --- a/bc_obps/service/data_access_service/user_operator_service.py +++ b/bc_obps/service/data_access_service/user_operator_service.py @@ -3,7 +3,7 @@ from service.data_access_service.user_service import UserDataAccessService from registration.models import Operator, User, UserOperator from django.db import transaction -from django.db.models import QuerySet +from django.db.models import QuerySet, Q, OuterRef, Exists class UserOperatorDataAccessService: @@ -58,5 +58,22 @@ def get_approved_user_operator(cls, user: User) -> Optional[UserOperator]: return user.user_operators.only("operator_id").filter(status=UserOperator.Statuses.APPROVED).first() @classmethod - def get_all_admin_user_operators(cls) -> QuerySet[UserOperator]: - return UserOperator.objects.select_related("user", "operator").filter(role=UserOperator.Roles.ADMIN) + def get_admin_user_operator_requests_for_irc_users(cls) -> QuerySet[UserOperator]: + # Base query excluding operators with status 'Declined' + qs = UserOperator.objects.select_related("user", "operator").exclude( + operator__status=UserOperator.Statuses.DECLINED + ) + # Subquery to check if an approved admin user exists for the operator + approved_admin_operator_exists = UserOperator.objects.filter( + operator=OuterRef('operator'), role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.APPROVED + ) + # Condition 1: Include all `Admin` roles with any status (Approved, Declined, or Pending) + admin_condition = Q(role=UserOperator.Roles.ADMIN) + # Condition 2: Include `Pending` roles only if the operator doesn't have an approved admin user operator + pending_condition = Q(role=UserOperator.Roles.PENDING) & ~Exists(approved_admin_operator_exists) + + # Condition 3: Exclude all `Reporter` roles regardless of status + reporter_exclusion = Q(role=UserOperator.Roles.REPORTER) + + # Include all Admin roles OR Include Pending roles only if no approved admin exists for operator, Exclude Reporter roles + return qs.filter(admin_condition | pending_condition).exclude(reporter_exclusion) diff --git a/bc_obps/service/tests/data_access_service/test_data_access_user_operator_service.py b/bc_obps/service/tests/data_access_service/test_data_access_user_operator_service.py new file mode 100644 index 0000000000..5d98a534c4 --- /dev/null +++ b/bc_obps/service/tests/data_access_service/test_data_access_user_operator_service.py @@ -0,0 +1,125 @@ +import pytest +from model_bakery import baker +from registration.models import Operator +from registration.models.user_operator import UserOperator +from service.data_access_service.user_operator_service import UserOperatorDataAccessService + +pytestmark = pytest.mark.django_db + + +class TestDataAccessUserOperatorService: + @staticmethod + def test_get_admin_user_operator_requests_for_irc_users(): + # Prepare operators + declined_operator = baker.make_recipe('utils.operator', status=Operator.Statuses.DECLINED) + approved_operator = baker.make_recipe('utils.operator', status=Operator.Statuses.APPROVED) + + user_operators_with_declined_operator = [] + approved_admin_user_operators = [] + pending_admin_user_operators_for_approved_operator = [] + declined_admin_user_operators = [] + declined_pending_user_operators = [] + pending_user_operators_with_pending_status = [] + + # Prepare user operators for various roles and statuses + # Declined operator with user operators (should be excluded in final result) + for _ in range(5): + user_operators_with_declined_operator.append( + baker.make_recipe( + 'utils.user_operator', + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.PENDING, + operator=declined_operator, + ) + ) + + # Approved admin user operators (should be included in final result) + approved_admin_user_operators.append( + baker.make_recipe( + 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.APPROVED + ) + ) + + # Pending admin user operators for approved operator (should be included in final result) + pending_admin_user_operators_for_approved_operator.append( + baker.make_recipe( + 'utils.user_operator', + operator=approved_operator, + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.PENDING, + ) + ) + + # Declined admin user operators (should be included in final result) + declined_admin_user_operators.append( + baker.make_recipe( + 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.DECLINED + ) + ) + + # Declined pending user operators (should be included in final result only if no approved admin exists) + declined_pending_user_operators.append( + baker.make_recipe( + 'utils.user_operator', role=UserOperator.Roles.PENDING, status=UserOperator.Statuses.DECLINED + ) + ) + + # Pending user operators for the approved operator, with a PENDING status (should be excluded due to approved admin user) + pending_user_operators_with_pending_status.append( + baker.make_recipe( + 'utils.user_operator', + role=UserOperator.Roles.PENDING, + status=UserOperator.Statuses.PENDING, + operator=approved_operator, + ) + ) + + # Add approved admin user for the approved operator (to prevent showing pending user operators for this operator) + approved_user_operator_for_approved_operator = baker.make_recipe( + 'utils.user_operator', + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.APPROVED, + operator=approved_operator, + ) + + # Run the service method under test + user_operator_requests = UserOperatorDataAccessService.get_admin_user_operator_requests_for_irc_users() + + # Assertions + # Assert that the number of user operators returned is valid + # 5 approved_admin_user_operators + + # 5 pending_admin_user_operators_for_approved_operator + + # 5 declined_admin_user_operators + + # 5 declined_pending_user_operators + + # 1 approved admin user operator + expected_valid_count = 21 + assert ( + len(user_operator_requests) == expected_valid_count + ), f"Expected {expected_valid_count} user operators, but got {len(user_operator_requests)}." + + # Check that user operators with a declined operator are excluded + for user_operator in user_operators_with_declined_operator: + assert user_operator not in user_operator_requests + + # Check that approved admin user operators are included + for user_operator in approved_admin_user_operators: + assert user_operator in user_operator_requests + + # Check that pending admin user operators are included for approved operator + for user_operator in pending_admin_user_operators_for_approved_operator: + assert user_operator in user_operator_requests + + # Check that declined admin user operators are included + for user_operator in declined_admin_user_operators: + assert user_operator in user_operator_requests + + # Check that declined pending user operators are included if no approved admin exists + for user_operator in declined_pending_user_operators: + assert user_operator in user_operator_requests + + # Check that pending user operators with a pending role are excluded if an approved admin user exists for the operator + for user_operator in pending_user_operators_with_pending_status: + assert user_operator not in user_operator_requests + + # Check that the approved admin user operator for the approved operator is included + assert approved_user_operator_for_approved_operator in user_operator_requests From 98cac1f75a5cc822d59dc2b6ad156f5512b4306e Mon Sep 17 00:00:00 2001 From: SeSo Date: Tue, 19 Nov 2024 10:32:28 -0800 Subject: [PATCH 10/15] test: add service and endpoint tests Signed-off-by: SeSo --- .../tests/endpoints/v2/test_user_operators.py | 56 ++++++++++++ .../tests/test_user_operator_service_v2.py | 88 ++++++++++++++++++- 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/bc_obps/registration/tests/endpoints/v2/test_user_operators.py b/bc_obps/registration/tests/endpoints/v2/test_user_operators.py index 766ee30927..153b2e5236 100644 --- a/bc_obps/registration/tests/endpoints/v2/test_user_operators.py +++ b/bc_obps/registration/tests/endpoints/v2/test_user_operators.py @@ -1,4 +1,7 @@ from typing import Any, Dict + +from model_bakery import baker + from registration.models.operator import Operator from registration.models.user_operator import UserOperator @@ -269,3 +272,56 @@ def _assert_post_success(self, post_response): assert user_operator is not None assert user_operator.role == UserOperator.Roles.ADMIN assert user_operator.status == UserOperator.Statuses.APPROVED + + +class TestListUserOperators(CommonTestSetup): + def test_list_user_operators_v2_returns_valid_data(self): + approved_admin_user_operators = [] + for _ in range(2): + # add 2 approved admin user operators to test the endpoint + approved_admin_user_operators.append( + baker.make_recipe( + 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.APPROVED + ) + ) + response = TestUtils.mock_get_with_auth_role(self, "cas_admin", custom_reverse_lazy("list_user_operators_v2")) + + assert response.status_code == 200 + response_data = response.json() + assert len(response_data) == 2 + # assert one of the approved admin user operators is in the response + approved_admin_user_operator_to_check = approved_admin_user_operators[0] + approved_admin_user_operator_in_response = next( + ( + user_operator + for user_operator in response_data["items"] + if user_operator["id"] == str(approved_admin_user_operator_to_check.id) + ), + None, + ) + assert approved_admin_user_operator_in_response is not None + assert approved_admin_user_operator_in_response["id"] == str(approved_admin_user_operator_to_check.id) + assert ( + approved_admin_user_operator_in_response["user_friendly_id"] + == approved_admin_user_operator_to_check.user_friendly_id + ) + assert approved_admin_user_operator_in_response["status"] == approved_admin_user_operator_to_check.status + assert ( + approved_admin_user_operator_in_response["user__first_name"] + == approved_admin_user_operator_to_check.user.first_name + ) + assert ( + approved_admin_user_operator_in_response["user__last_name"] + == approved_admin_user_operator_to_check.user.last_name + ) + assert ( + approved_admin_user_operator_in_response["user__email"] == approved_admin_user_operator_to_check.user.email + ) + assert ( + approved_admin_user_operator_in_response["user__bceid_business_name"] + == approved_admin_user_operator_to_check.user.bceid_business_name + ) + assert ( + approved_admin_user_operator_in_response["operator__legal_name"] + == approved_admin_user_operator_to_check.operator.legal_name + ) diff --git a/bc_obps/service/tests/test_user_operator_service_v2.py b/bc_obps/service/tests/test_user_operator_service_v2.py index b2a3803e44..45bf706774 100644 --- a/bc_obps/service/tests/test_user_operator_service_v2.py +++ b/bc_obps/service/tests/test_user_operator_service_v2.py @@ -1,7 +1,11 @@ import pytest from model_bakery import baker -from registration.models import Operator, User + +from registration.constants import UNAUTHORIZED_MESSAGE +from registration.models import Operator, User, UserOperator from registration.schema.v2.operator import OperatorIn +from registration.schema.v2.user_operator import UserOperatorFilterSchema +from service.user_operator_service_v2 import UserOperatorServiceV2 pytestmark = pytest.mark.django_db @@ -9,7 +13,6 @@ class TestUserOperatorServiceV2: @staticmethod def test_save_operator(): - from service.user_operator_service_v2 import UserOperatorServiceV2 user = baker.make(User) payload = OperatorIn( @@ -39,3 +42,84 @@ def test_save_operator(): assert Operator.objects.first().cra_business_number == payload.cra_business_number assert Operator.objects.first().bc_corporate_registry_number == payload.bc_corporate_registry_number assert Operator.objects.first().status == Operator.Statuses.APPROVED + + @staticmethod + def test_list_user_operators_v2(): + filters_1 = UserOperatorFilterSchema( + user_friendly_id="1", + status="pending", + user__first_name="john", + user__last_name="doe", + user__email="john.doe@test.com", + user__bceid_business_name="test business name", + operator__legal_name="test legal name", + ) + + # make sure only irc user can access this + industry_user = baker.make_recipe('utils.industry_operator_user') + with pytest.raises(Exception, match=UNAUTHORIZED_MESSAGE): + UserOperatorServiceV2.list_user_operators_v2( + user_guid=industry_user.user_guid, filters=filters_1, sort_field="created_at", sort_order="asc" + ) + + # add some user operators + for _ in range(5): + baker.make_recipe( + 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.APPROVED + ) + baker.make_recipe( + 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.DECLINED + ) + baker.make_recipe( + 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.PENDING + ) + + assert UserOperator.objects.count() == 15 + + # Check filter status (we only care about status) + filters_2 = UserOperatorFilterSchema( + user_friendly_id="", + status="admin", + user__first_name="", + user__last_name="", + user__email="", + user__bceid_business_name="", + operator__legal_name="", + ) + irc_user = baker.make_recipe('utils.irc_user') + user_operators_with_admin_access_status = UserOperatorServiceV2.list_user_operators_v2( + user_guid=irc_user.user_guid, filters=filters_2, sort_field="created_at", sort_order="asc" + ) + assert user_operators_with_admin_access_status.count() == 5 + assert user_operators_with_admin_access_status.filter(status=UserOperator.Statuses.APPROVED).count() == 5 + + # Check sorting + filters_3 = filters_2.model_copy( + update={"status": ""} + ) # making a copy of filters_2 and updating status to empty string + user_operators_sorted_by_created_at = UserOperatorServiceV2.list_user_operators_v2( + user_guid=irc_user.user_guid, filters=filters_3, sort_field="created_at", sort_order="asc" + ) + assert ( + user_operators_sorted_by_created_at.first().created_at + < user_operators_sorted_by_created_at.last().created_at + ) + user_operators_sorted_by_created_at_desc = UserOperatorServiceV2.list_user_operators_v2( + user_guid=irc_user.user_guid, filters=filters_3, sort_field="created_at", sort_order="desc" + ) + assert ( + user_operators_sorted_by_created_at_desc.first().created_at + > user_operators_sorted_by_created_at_desc.last().created_at + ) + user_operators_sorted_by_user_friendly_id = UserOperatorServiceV2.list_user_operators_v2( + user_guid=irc_user.user_guid, filters=filters_3, sort_field="user_friendly_id", sort_order="asc" + ) + assert ( + user_operators_sorted_by_user_friendly_id.first().user_friendly_id + < user_operators_sorted_by_user_friendly_id.last().user_friendly_id + ) + user_operators_sorted_by_status = UserOperatorServiceV2.list_user_operators_v2( + user_guid=irc_user.user_guid, filters=filters_3, sort_field="status", sort_order="asc" + ) + assert user_operators_sorted_by_status.first().status == UserOperator.Statuses.APPROVED + assert user_operators_sorted_by_status.last().status == UserOperator.Statuses.PENDING From c16565628b44bd28a91fea05d866367c2fc30276 Mon Sep 17 00:00:00 2001 From: SeSo Date: Tue, 19 Nov 2024 15:52:10 -0800 Subject: [PATCH 11/15] test: add frontend tests Signed-off-by: SeSo --- .../UserOperatorDataGrid.test.tsx | 116 ++++++++++++++++++ .../userOperators/UserOperatorsPage.test.tsx | 46 +++++++ bciers/libs/testConfig/src/mocks.ts | 2 + 3 files changed, 164 insertions(+) create mode 100644 bciers/apps/administration/tests/components/userOperators/UserOperatorDataGrid.test.tsx create mode 100644 bciers/apps/administration/tests/components/userOperators/UserOperatorsPage.test.tsx diff --git a/bciers/apps/administration/tests/components/userOperators/UserOperatorDataGrid.test.tsx b/bciers/apps/administration/tests/components/userOperators/UserOperatorDataGrid.test.tsx new file mode 100644 index 0000000000..b397c29162 --- /dev/null +++ b/bciers/apps/administration/tests/components/userOperators/UserOperatorDataGrid.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, within } from "@testing-library/react"; +import { useRouter, useSearchParams } from "@bciers/testConfig/mocks"; +import { UserOperatorStatus } from "@bciers/utils/src/enums"; +import UserOperatorDataGrid from "@/administration/app/components/userOperators/UserOperatorDataGrid"; +import { expect } from "vitest"; + +useRouter.mockReturnValue({ + query: {}, + replace: vi.fn(), +}); + +useSearchParams.mockReturnValue({ + get: vi.fn(), +}); + +const mockResponse = { + rows: [ + { + id: 1, + user_friendly_id: "1", + status: UserOperatorStatus.APPROVED, + user__first_name: "John", + user__last_name: "Doe", + user__email: "john.doe@example.com", + user__bceid_business_name: "John Doe Inc.", + operator__legal_name: "FakeOperator 1", + }, + { + id: 2, + user_friendly_id: "2", + status: UserOperatorStatus.PENDING, + user__first_name: "Jane", + user__last_name: "Smith", + user__email: "jane.smith@example.com", + user__bceid_business_name: "Jane Smith Inc.", + operator__legal_name: "FakeOperator 2", + }, + { + id: 3, + user_friendly_id: "3", + status: UserOperatorStatus.DECLINED, + user__first_name: "Alice", + user__last_name: "Brown", + user__email: "alice.brown@example.com", + user__bceid_business_name: "Alice Brown Inc.", + operator__legal_name: "FakeOperator 3", + }, + { + id: 4, + user_friendly_id: "4", + status: UserOperatorStatus.APPROVED, + user__first_name: "Bob", + user__last_name: "White", + user__email: "bob.white@example.com", + user__bceid_business_name: "Bob White Inc.", + operator__legal_name: "FakeOperator 4", + }, + ], + row_count: 4, +}; + +describe("UserOperatorDataGrid component", () => { + beforeEach(async () => { + vi.clearAllMocks(); + }); + it("renders the UserOperatorDataGrid grid", async () => { + render(); + + // correct headers + expect( + screen.getByRole("columnheader", { name: "Request ID" }), + ).toBeVisible(); + expect( + screen.getByRole("columnheader", { name: "First Name" }), + ).toBeVisible(); + expect( + screen.getByRole("columnheader", { name: "Last Name" }), + ).toBeVisible(); + expect(screen.getByRole("columnheader", { name: "Email" })).toBeVisible(); + expect( + screen.getByRole("columnheader", { name: "BCeID Business Name" }), + ).toBeVisible(); + expect( + screen.getByRole("columnheader", { name: "Operator" }), + ).toBeVisible(); + expect(screen.getByRole("columnheader", { name: "Status" })).toBeVisible(); + + expect(screen.getByRole("columnheader", { name: "Actions" })).toBeVisible(); + expect(screen.queryAllByPlaceholderText(/Search/i)).toHaveLength(7); + + // Check data displays + const allRows = screen.getAllByRole("row"); + expect(allRows).toHaveLength(6); // 4 rows + 1 header + 1 filter row + const firstUserOperatorRow = allRows[2]; // first row of data + expect(within(firstUserOperatorRow).getByText("1")).toBeVisible(); + expect(within(firstUserOperatorRow).getByText("John")).toBeVisible(); + expect(within(firstUserOperatorRow).getByText("Doe")).toBeVisible(); + expect( + within(firstUserOperatorRow).getByText(/admin access/i), + ).toBeVisible(); + expect( + within(firstUserOperatorRow).getByText(/john.doe@example.com/i), + ).toBeVisible(); + expect( + within(firstUserOperatorRow).getByText(/John Doe Inc./i), + ).toBeVisible(); + expect( + within(firstUserOperatorRow).getByText(/FakeOperator 1/i), + ).toBeVisible(); + expect(screen.getAllByText(/view details/i)).toHaveLength(4); + expect(screen.getAllByText(/view details/i)[0]).toHaveAttribute( + "href", + "TBD", + ); + }); +}); diff --git a/bciers/apps/administration/tests/components/userOperators/UserOperatorsPage.test.tsx b/bciers/apps/administration/tests/components/userOperators/UserOperatorsPage.test.tsx new file mode 100644 index 0000000000..5bba41ebeb --- /dev/null +++ b/bciers/apps/administration/tests/components/userOperators/UserOperatorsPage.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { + getUserOperatorsPageData, + useSearchParams, +} from "@bciers/testConfig/mocks"; +import UserOperatorsPage from "@/administration/app/components/userOperators/UserOperatorsPage"; +import { expect } from "vitest"; + +useSearchParams.mockReturnValue({ + get: vi.fn(), +}); + +vi.mock( + "@/administration/app/components/userOperators/getUserOperatorsPageData", + () => ({ + default: getUserOperatorsPageData, + }), +); + +describe("User Operators (External Access Requests) Page", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders UserOperatorsPage with the note on top of the page", async () => { + getUserOperatorsPageData.mockReturnValueOnce({ + data: [], + row_count: 0, + }); + render(await UserOperatorsPage({ searchParams: {} })); + expect(screen.getByTestId("note")).toBeVisible(); + expect( + screen.getByText( + /once "approved", the user will have access to their operator dashboard with full admin permissions,and can grant access and designate permissions to other authorized users there\./i, + ), + ).toBeVisible(); + expect(screen.queryByRole("grid")).toBeInTheDocument(); + expect(screen.getByText(/No records found/i)).toBeVisible(); + }); + it("renders the appropriate error component when getUserOperatorsPageData fails", async () => { + getUserOperatorsPageData.mockReturnValueOnce(undefined); + expect(async () => + render(await UserOperatorsPage({ searchParams: {} })), + ).rejects.toThrow("Failed to retrieve admin requests."); + }); +}); diff --git a/bciers/libs/testConfig/src/mocks.ts b/bciers/libs/testConfig/src/mocks.ts index 794169476c..a1e1f221ec 100644 --- a/bciers/libs/testConfig/src/mocks.ts +++ b/bciers/libs/testConfig/src/mocks.ts @@ -36,6 +36,7 @@ const auth = vi.fn(); const fetchOperationsPageData = vi.fn(); const fetchOperatorsPageData = vi.fn(); const fetchTransferEventsPageData = vi.fn(); +const getUserOperatorsPageData = vi.fn(); export { actionHandler, @@ -49,6 +50,7 @@ export { useSession, fetchOperationsPageData, fetchOperatorsPageData, + getUserOperatorsPageData, notFound, fetchTransferEventsPageData, }; From fa798468f8826f8ae1101b724acbe5b5ffde8af4 Mon Sep 17 00:00:00 2001 From: SeSo Date: Tue, 19 Nov 2024 15:53:15 -0800 Subject: [PATCH 12/15] chore: use the related data model for filtering status Signed-off-by: SeSo --- bc_obps/service/data_access_service/user_operator_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bc_obps/service/data_access_service/user_operator_service.py b/bc_obps/service/data_access_service/user_operator_service.py index 9a725ae943..76feddf87f 100644 --- a/bc_obps/service/data_access_service/user_operator_service.py +++ b/bc_obps/service/data_access_service/user_operator_service.py @@ -61,7 +61,7 @@ def get_approved_user_operator(cls, user: User) -> Optional[UserOperator]: def get_admin_user_operator_requests_for_irc_users(cls) -> QuerySet[UserOperator]: # Base query excluding operators with status 'Declined' qs = UserOperator.objects.select_related("user", "operator").exclude( - operator__status=UserOperator.Statuses.DECLINED + operator__status=Operator.Statuses.DECLINED ) # Subquery to check if an approved admin user exists for the operator approved_admin_operator_exists = UserOperator.objects.filter( From e6186b63e4d9a1fc12c845d25e7fa8e3f9a81c0a Mon Sep 17 00:00:00 2001 From: SeSo Date: Tue, 19 Nov 2024 15:54:03 -0800 Subject: [PATCH 13/15] chore: non-related - make admin table for user and user-operator prettier Signed-off-by: SeSo --- bc_obps/registration/admin.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bc_obps/registration/admin.py b/bc_obps/registration/admin.py index a462a719a7..208396bf52 100644 --- a/bc_obps/registration/admin.py +++ b/bc_obps/registration/admin.py @@ -25,9 +25,7 @@ admin.site.register(AppRole) admin.site.register(NaicsCode) -admin.site.register(User) admin.site.register(Operator) -admin.site.register(UserOperator) admin.site.register(ParentOperator) admin.site.register(RegulatedProduct) admin.site.register(Activity) @@ -103,3 +101,27 @@ class DocumentAdmin(admin.ModelAdmin): @staticmethod def type_name(obj: Document) -> str: return obj.type.name + + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + list_display = ('user_guid', 'first_name', 'last_name', 'email', 'position_title', 'role') + search_fields = ('user_guid', 'first_name', 'last_name') + + @staticmethod + def role(obj: User) -> str: + return obj.app_role.role_name + + +@admin.register(UserOperator) +class UserOperatorAdmin(admin.ModelAdmin): + list_display = ('id', 'user_full_name', 'operator_legal_name', 'role', 'status') + ordering = ('-created_at',) + + @staticmethod + def user_full_name(obj: UserOperator) -> str: + return obj.user.first_name + ' ' + obj.user.last_name + + @staticmethod + def operator_legal_name(obj: UserOperator) -> str: + return obj.operator.legal_name From a8e0aff6f90b2f068cfc552f60bf7fed8459e6ff Mon Sep 17 00:00:00 2001 From: SeSo Date: Wed, 20 Nov 2024 10:01:41 -0800 Subject: [PATCH 14/15] test: add more endpoint tests Signed-off-by: SeSo --- .../tests/endpoints/v2/test_user_operators.py | 121 ++++++++++++++++-- 1 file changed, 111 insertions(+), 10 deletions(-) diff --git a/bc_obps/registration/tests/endpoints/v2/test_user_operators.py b/bc_obps/registration/tests/endpoints/v2/test_user_operators.py index 153b2e5236..15192415c4 100644 --- a/bc_obps/registration/tests/endpoints/v2/test_user_operators.py +++ b/bc_obps/registration/tests/endpoints/v2/test_user_operators.py @@ -1,16 +1,10 @@ from typing import Any, Dict - from model_bakery import baker - +from bc_obps.settings import NINJA_PAGINATION_PER_PAGE from registration.models.operator import Operator from registration.models.user_operator import UserOperator - -from registration.models import ( - BusinessStructure, -) -from registration.tests.utils.bakers import ( - operator_baker, -) +from registration.models import BusinessStructure +from registration.tests.utils.bakers import operator_baker from registration.tests.utils.helpers import CommonTestSetup, TestUtils from registration.utils import custom_reverse_lazy @@ -275,6 +269,113 @@ def _assert_post_success(self, post_response): class TestListUserOperators(CommonTestSetup): + url = custom_reverse_lazy("list_user_operators_v2") + + def test_list_user_operators_v2_paginated(self): + for _ in range(50): + baker.make_recipe( + 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.APPROVED + ) + # Get the default page 1 response + response = TestUtils.mock_get_with_auth_role(self, "cas_admin", self.url) + assert response.status_code == 200 + response_items_1 = response.json().get('items') + response_count_1 = response.json().get('count') + # save the id of the first paginated response item + page_1_response_id = response_items_1[0].get('id') + assert len(response_items_1) == NINJA_PAGINATION_PER_PAGE + assert response_count_1 == 50 # total count of user operators + # Get the page 2 response + response = TestUtils.mock_get_with_auth_role( + self, + "cas_admin", + self.url + "?page=2&sort_field=created_at&sort_order=desc", + ) + assert response.status_code == 200 + response_items_2 = response.json().get('items') + response_count_2 = response.json().get('count') + # save the id of the first paginated response item + page_2_response_id = response_items_2[0].get('id') + assert len(response_items_2) == NINJA_PAGINATION_PER_PAGE + # assert that the first item in the page 1 response is not the same as the first item in the page 2 response + assert page_1_response_id != page_2_response_id + assert response_count_2 == response_count_1 # total count should be the same + + # Get the page 2 response but with a different sort order + response = TestUtils.mock_get_with_auth_role( + self, + "cas_admin", + self.url + "?page=2&sort_field=created_at&sort_order=asc", + ) + assert response.status_code == 200 + response_items_2_reverse = response.json().get('items') + # save the id of the first paginated response item + page_2_response_id_reverse = response_items_2_reverse[0].get('id') + assert len(response_items_2_reverse) == NINJA_PAGINATION_PER_PAGE + # assert that the first item in the page 2 response is not the same as the first item in the page 2 response with reversed order + assert page_2_response_id != page_2_response_id_reverse + + # make sure sorting is working + page_2_first_user_operator = UserOperator.objects.get(pk=page_2_response_id) + page_2_first_user_operator_reverse = UserOperator.objects.get(pk=page_2_response_id_reverse) + assert page_2_first_user_operator.created_at > page_2_first_user_operator_reverse.created_at + + def test_list_user_operators_v2_with_filter(self): + baker.make_recipe( + 'utils.user_operator', + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.APPROVED, + user=baker.make_recipe('utils.industry_operator_user', first_name="Jane", last_name="Doe"), + ) + baker.make_recipe( + 'utils.user_operator', + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.APPROVED, + user=baker.make_recipe('utils.industry_operator_user', first_name="Bob", last_name="Smith"), + ) + baker.make_recipe( + 'utils.user_operator', + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.DECLINED, + user=baker.make_recipe('utils.industry_operator_user', first_name="John", last_name="Doe"), + ) + baker.make_recipe( + 'utils.user_operator', + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.DECLINED, + user=baker.make_recipe('utils.industry_operator_user', first_name="Henry", last_name="Ives"), + ) + + # Get the default page 1 response + response = TestUtils.mock_get_with_auth_role( + self, "cas_admin", self.url + "?user__first_name=Jane" + ) # filtering user__first_name with Jane + assert response.status_code == 200 + response_items_1 = response.json().get('items') + assert response_items_1[0].get('user__first_name') == "Jane" + # Test with a type filter that doesn't exist + response = TestUtils.mock_get_with_auth_role( + self, "cas_admin", self.url + "?user__first_name=John&user__last_name=Smith" + ) + assert response.status_code == 200 + assert response.json().get('count') == 0 + + # Test with a first_name and last_name filter + first_name_to_filter, last_name_to_filter = response_items_1[0].get('user__first_name'), response_items_1[ + 0 + ].get('user__last_name') + response = TestUtils.mock_get_with_auth_role( + self, + "cas_admin", + self.url + f"?user__first_name={first_name_to_filter}&user__last_name={last_name_to_filter}", + ) + assert response.status_code == 200 + response_items_2 = response.json().get('items') + assert len(response_items_2) == 1 + assert response.json().get('count') == 1 + assert response_items_2[0].get('user__first_name') == first_name_to_filter + assert response_items_2[0].get('user__last_name') == last_name_to_filter + def test_list_user_operators_v2_returns_valid_data(self): approved_admin_user_operators = [] for _ in range(2): @@ -284,7 +385,7 @@ def test_list_user_operators_v2_returns_valid_data(self): 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.APPROVED ) ) - response = TestUtils.mock_get_with_auth_role(self, "cas_admin", custom_reverse_lazy("list_user_operators_v2")) + response = TestUtils.mock_get_with_auth_role(self, "cas_admin", self.url) assert response.status_code == 200 response_data = response.json() From 5f1c36c4e647df9bb53a0e8d549a5939a3d47397 Mon Sep 17 00:00:00 2001 From: SeSo Date: Thu, 21 Nov 2024 13:56:19 -0800 Subject: [PATCH 15/15] chore: implement PR review suggestions Signed-off-by: SeSo --- .../tests/endpoints/v2/test_user_operators.py | 14 ++- .../test_data_access_user_operator_service.py | 99 +++++++++++-------- .../tests/test_user_operator_service_v2.py | 38 ++++--- .../userOperators/UserOperatorsPage.test.tsx | 34 +++++++ 4 files changed, 132 insertions(+), 53 deletions(-) diff --git a/bc_obps/registration/tests/endpoints/v2/test_user_operators.py b/bc_obps/registration/tests/endpoints/v2/test_user_operators.py index 15192415c4..32f6d73f34 100644 --- a/bc_obps/registration/tests/endpoints/v2/test_user_operators.py +++ b/bc_obps/registration/tests/endpoints/v2/test_user_operators.py @@ -353,7 +353,6 @@ def test_list_user_operators_v2_with_filter(self): assert response.status_code == 200 response_items_1 = response.json().get('items') assert response_items_1[0].get('user__first_name') == "Jane" - # Test with a type filter that doesn't exist response = TestUtils.mock_get_with_auth_role( self, "cas_admin", self.url + "?user__first_name=John&user__last_name=Smith" ) @@ -390,7 +389,7 @@ def test_list_user_operators_v2_returns_valid_data(self): assert response.status_code == 200 response_data = response.json() assert len(response_data) == 2 - # assert one of the approved admin user operators is in the response + # assert one of the approved admin user operators (as a sample) is in the response and has proper data approved_admin_user_operator_to_check = approved_admin_user_operators[0] approved_admin_user_operator_in_response = next( ( @@ -426,3 +425,14 @@ def test_list_user_operators_v2_returns_valid_data(self): approved_admin_user_operator_in_response["operator__legal_name"] == approved_admin_user_operator_to_check.operator.legal_name ) + # make sure keys are what we expect based on the schema (using sorted to ensure order doesn't matter) + assert set(approved_admin_user_operator_in_response.keys()) == { + "id", + "user_friendly_id", + "status", + "user__first_name", + "user__last_name", + "user__email", + "user__bceid_business_name", + "operator__legal_name", + } diff --git a/bc_obps/service/tests/data_access_service/test_data_access_user_operator_service.py b/bc_obps/service/tests/data_access_service/test_data_access_user_operator_service.py index 5d98a534c4..2b50145a01 100644 --- a/bc_obps/service/tests/data_access_service/test_data_access_user_operator_service.py +++ b/bc_obps/service/tests/data_access_service/test_data_access_user_operator_service.py @@ -1,3 +1,5 @@ +from itertools import cycle + import pytest from model_bakery import baker from registration.models import Operator @@ -23,58 +25,75 @@ def test_get_admin_user_operator_requests_for_irc_users(): # Prepare user operators for various roles and statuses # Declined operator with user operators (should be excluded in final result) - for _ in range(5): - user_operators_with_declined_operator.append( - baker.make_recipe( - 'utils.user_operator', - role=UserOperator.Roles.ADMIN, - status=UserOperator.Statuses.PENDING, - operator=declined_operator, - ) + user_operators_with_declined_operator.extend( + baker.make_recipe( + 'utils.user_operator', + user=cycle(baker.make_recipe('utils.industry_operator_user', _quantity=5)), + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.PENDING, + operator=declined_operator, + _quantity=5, ) + ) - # Approved admin user operators (should be included in final result) - approved_admin_user_operators.append( - baker.make_recipe( - 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.APPROVED - ) + # Approved admin user operators (should be included in final result) + approved_admin_user_operators.extend( + baker.make_recipe( + 'utils.user_operator', + user=cycle(baker.make_recipe('utils.industry_operator_user', _quantity=5)), + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.APPROVED, + _quantity=5, ) + ) - # Pending admin user operators for approved operator (should be included in final result) - pending_admin_user_operators_for_approved_operator.append( - baker.make_recipe( - 'utils.user_operator', - operator=approved_operator, - role=UserOperator.Roles.ADMIN, - status=UserOperator.Statuses.PENDING, - ) + # Pending(status) admin user operators for approved operator (should be included in final result) + pending_admin_user_operators_for_approved_operator.extend( + baker.make_recipe( + 'utils.user_operator', + user=cycle(baker.make_recipe('utils.industry_operator_user', _quantity=5)), + operator=approved_operator, + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.PENDING, + _quantity=5, ) + ) - # Declined admin user operators (should be included in final result) - declined_admin_user_operators.append( - baker.make_recipe( - 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.DECLINED - ) + # Declined admin user operators (should be included in final result) + declined_admin_user_operators.extend( + baker.make_recipe( + 'utils.user_operator', + user=cycle(baker.make_recipe('utils.industry_operator_user', _quantity=5)), + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.DECLINED, + _quantity=5, ) + ) - # Declined pending user operators (should be included in final result only if no approved admin exists) - declined_pending_user_operators.append( - baker.make_recipe( - 'utils.user_operator', role=UserOperator.Roles.PENDING, status=UserOperator.Statuses.DECLINED - ) + # Declined pending (role) user operators (should be included in final result only if no approved admin exists) + declined_pending_user_operators.extend( + baker.make_recipe( + 'utils.user_operator', + user=cycle(baker.make_recipe('utils.industry_operator_user', _quantity=5)), + role=UserOperator.Roles.PENDING, + status=UserOperator.Statuses.DECLINED, + _quantity=5, ) + ) - # Pending user operators for the approved operator, with a PENDING status (should be excluded due to approved admin user) - pending_user_operators_with_pending_status.append( - baker.make_recipe( - 'utils.user_operator', - role=UserOperator.Roles.PENDING, - status=UserOperator.Statuses.PENDING, - operator=approved_operator, - ) + # Pending (role/status) user operators for the approved operator(should be excluded due to approved admin user) + pending_user_operators_with_pending_status.extend( + baker.make_recipe( + 'utils.user_operator', + user=cycle(baker.make_recipe('utils.industry_operator_user', _quantity=5)), + role=UserOperator.Roles.PENDING, + status=UserOperator.Statuses.PENDING, + operator=approved_operator, + _quantity=5, ) + ) - # Add approved admin user for the approved operator (to prevent showing pending user operators for this operator) + # Add approved admin user for the approved operator (to prevent showing pending(status) user operators for this operator) approved_user_operator_for_approved_operator = baker.make_recipe( 'utils.user_operator', role=UserOperator.Roles.ADMIN, diff --git a/bc_obps/service/tests/test_user_operator_service_v2.py b/bc_obps/service/tests/test_user_operator_service_v2.py index 45bf706774..7af51735b0 100644 --- a/bc_obps/service/tests/test_user_operator_service_v2.py +++ b/bc_obps/service/tests/test_user_operator_service_v2.py @@ -1,3 +1,5 @@ +from itertools import cycle + import pytest from model_bakery import baker @@ -44,7 +46,7 @@ def test_save_operator(): assert Operator.objects.first().status == Operator.Statuses.APPROVED @staticmethod - def test_list_user_operators_v2(): + def test_list_user_operators_v2_industry_users_are_not_authorized(): filters_1 = UserOperatorFilterSchema( user_friendly_id="1", status="pending", @@ -62,17 +64,31 @@ def test_list_user_operators_v2(): user_guid=industry_user.user_guid, filters=filters_1, sort_field="created_at", sort_order="asc" ) + @staticmethod + def test_list_user_operators_v2(): + # add some user operators - for _ in range(5): - baker.make_recipe( - 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.APPROVED - ) - baker.make_recipe( - 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.DECLINED - ) - baker.make_recipe( - 'utils.user_operator', role=UserOperator.Roles.ADMIN, status=UserOperator.Statuses.PENDING - ) + baker.make_recipe( + 'utils.user_operator', + user=cycle(baker.make_recipe('utils.industry_operator_user', _quantity=5)), + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.APPROVED, + _quantity=5, + ) + baker.make_recipe( + 'utils.user_operator', + user=cycle(baker.make_recipe('utils.industry_operator_user', _quantity=5)), + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.DECLINED, + _quantity=5, + ) + baker.make_recipe( + 'utils.user_operator', + user=cycle(baker.make_recipe('utils.industry_operator_user', _quantity=5)), + role=UserOperator.Roles.ADMIN, + status=UserOperator.Statuses.PENDING, + _quantity=5, + ) assert UserOperator.objects.count() == 15 diff --git a/bciers/apps/administration/tests/components/userOperators/UserOperatorsPage.test.tsx b/bciers/apps/administration/tests/components/userOperators/UserOperatorsPage.test.tsx index 5bba41ebeb..756fe4cc0d 100644 --- a/bciers/apps/administration/tests/components/userOperators/UserOperatorsPage.test.tsx +++ b/bciers/apps/administration/tests/components/userOperators/UserOperatorsPage.test.tsx @@ -5,6 +5,7 @@ import { } from "@bciers/testConfig/mocks"; import UserOperatorsPage from "@/administration/app/components/userOperators/UserOperatorsPage"; import { expect } from "vitest"; +import { UserOperatorStatus } from "@bciers/utils/src/enums"; useSearchParams.mockReturnValue({ get: vi.fn(), @@ -17,6 +18,32 @@ vi.mock( }), ); +const mockResponse = { + rows: [ + { + id: 1, + user_friendly_id: "1", + status: UserOperatorStatus.APPROVED, + user__first_name: "John", + user__last_name: "Doe", + user__email: "john.doe@example.com", + user__bceid_business_name: "John Doe Inc.", + operator__legal_name: "FakeOperator 1", + }, + { + id: 2, + user_friendly_id: "2", + status: UserOperatorStatus.PENDING, + user__first_name: "Jane", + user__last_name: "Smith", + user__email: "jane.smith@example.com", + user__bceid_business_name: "Jane Smith Inc.", + operator__legal_name: "FakeOperator 2", + }, + ], + row_count: 2, +}; + describe("User Operators (External Access Requests) Page", () => { beforeEach(() => { vi.clearAllMocks(); @@ -37,6 +64,13 @@ describe("User Operators (External Access Requests) Page", () => { expect(screen.queryByRole("grid")).toBeInTheDocument(); expect(screen.getByText(/No records found/i)).toBeVisible(); }); + it("renders UserOperatorsPage that correctly handle a non-empty data array", async () => { + getUserOperatorsPageData.mockReturnValueOnce(mockResponse); + render(await UserOperatorsPage({ searchParams: {} })); + expect(screen.queryByRole("grid")).toBeInTheDocument(); + const allRows = screen.getAllByRole("row"); + expect(allRows).toHaveLength(4); // 2 rows + 1 header + 1 filter row + }); it("renders the appropriate error component when getUserOperatorsPageData fails", async () => { getUserOperatorsPageData.mockReturnValueOnce(undefined); expect(async () =>