diff --git a/epictrack-api/src/api/models/queries/__init__.py b/epictrack-api/src/api/models/queries/__init__.py new file mode 100644 index 000000000..e0f07fa10 --- /dev/null +++ b/epictrack-api/src/api/models/queries/__init__.py @@ -0,0 +1,18 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This exports all of query modules""" + + +from .work_issue_queries import WorkIssueQuery diff --git a/epictrack-api/src/api/models/queries/work_issue_queries.py b/epictrack-api/src/api/models/queries/work_issue_queries.py new file mode 100644 index 000000000..7270116a3 --- /dev/null +++ b/epictrack-api/src/api/models/queries/work_issue_queries.py @@ -0,0 +1,38 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Model to handle all complex operations related to Work Issues.""" +from typing import List +from sqlalchemy import and_ +from api.models import WorkIssues, db + + +# pylint: disable=too-few-public-methods +class WorkIssueQuery: + """Query module for complex work issue queries""" + + @classmethod + def find_work_issues_by_work_ids(cls, work_ids: List[int]) -> List[WorkIssues]: + """Find work issues by work ids""" + results = ( + db.session.query(WorkIssues) + .filter( + and_( + WorkIssues.work_id.in_(work_ids), + WorkIssues.is_active.is_(True), + WorkIssues.is_deleted.is_(False), + ) + ) + .all() + ) + return results diff --git a/epictrack-api/src/api/reports/thirty_sixty_ninety_report.py b/epictrack-api/src/api/reports/thirty_sixty_ninety_report.py index 6e81fce45..72db921b1 100644 --- a/epictrack-api/src/api/reports/thirty_sixty_ninety_report.py +++ b/epictrack-api/src/api/reports/thirty_sixty_ninety_report.py @@ -4,6 +4,7 @@ from io import BytesIO from typing import Dict, List +from operator import attrgetter from pytz import utc from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER @@ -21,7 +22,10 @@ from api.models.event_type import EventTypeEnum from api.models.special_field import EntityEnum from api.models.work import WorkStateEnum +from api.models.work_issues import WorkIssues from api.services.special_field import SpecialFieldService +from api.services.work_issues import WorkIssuesService +from api.schemas import response as res from .report_factory import ReportFactory @@ -157,6 +161,7 @@ def _fetch_data(self, report_date): def _format_data(self, data): data = super()._format_data(data) + data = self._update_work_issues(data) response = { "30": [], "60": [], @@ -207,6 +212,32 @@ def _format_data(self, data): response["90"].append(work) return response + def _update_work_issues(self, data) -> list[WorkIssues]: + """Combine the result with work issues""" + work_ids = set((work["work_id"] for work in data)) + work_issues = WorkIssuesService.find_work_issues_by_work_ids(work_ids) + for result_item in data: + issue_per_work = [ + issue + for issue in work_issues + if issue.work_id == result_item["work_id"] + and issue.is_high_priority is True + ] + for issue in issue_per_work: + latest_update = max( + ( + issue_update + for issue_update in issue.updates + if issue_update.is_approved + ), + key=attrgetter("posted_date"), + ) + setattr(issue, "latest_update", latest_update) + result_item["work_issues"] = res.WorkIssuesLatestUpdateResponseSchema(many=True).dump( + issue_per_work + ) + return data + def generate_report( self, report_date: datetime, return_type ): # pylint: disable=too-many-locals diff --git a/epictrack-api/src/api/schemas/response/__init__.py b/epictrack-api/src/api/schemas/response/__init__.py index 26311a5fb..d643db92e 100644 --- a/epictrack-api/src/api/schemas/response/__init__.py +++ b/epictrack-api/src/api/schemas/response/__init__.py @@ -15,10 +15,16 @@ from .act_section_response import ActSectionResponseSchema from .action_template_response import ActionTemplateResponseSchema from .event_configuration_response import EventConfigurationResponseSchema -from .event_response import EventDateChangePosibilityCheckResponseSchema, EventResponseSchema +from .event_response import ( + EventDateChangePosibilityCheckResponseSchema, + EventResponseSchema, +) from .event_template_response import EventTemplateResponseSchema -from .indigenous_nation_response import IndigenousResponseNationSchema, WorkIndigenousNationResponseSchema, \ - IndigenousNationConsultationResponseSchema +from .indigenous_nation_response import ( + IndigenousResponseNationSchema, + WorkIndigenousNationResponseSchema, + IndigenousNationConsultationResponseSchema, +) from .list_type_response import ListTypeResponseSchema from .outcome_configuration_response import OutcomeConfigurationResponseSchema from .outcome_template_response import OutcomeTemplateResponseSchema @@ -30,12 +36,25 @@ from .special_field_response import SpecialFieldResponseSchema from .staff_response import StaffResponseSchema from .staff_work_role_response import StaffWorkRoleResponseSchema -from .task_response import TaskEventResponseSchema, TaskResponseSchema, TaskTemplateResponseSchema, \ - TaskEventByStaffResponseSchema +from .task_response import ( + TaskEventResponseSchema, + TaskResponseSchema, + TaskTemplateResponseSchema, + TaskEventByStaffResponseSchema, +) from .types_response import SubTypeResponseSchema, TypeResponseSchema from .user_group_response import UserGroupResponseSchema from .user_response import UserResponseSchema from .work_response import ( - WorkIssuesResponseSchema, WorkIssueUpdatesResponseSchema, WorkPhaseAdditionalInfoResponseSchema, - WorkPhaseResponseSchema, WorkPhaseTemplateAvailableResponse, WorkResourceResponseSchema, WorkResponseSchema, - WorkStaffRoleReponseSchema, WorkStatusResponseSchema, WorkPhaseByIdResponseSchema) + WorkIssuesResponseSchema, + WorkIssueUpdatesResponseSchema, + WorkPhaseAdditionalInfoResponseSchema, + WorkPhaseResponseSchema, + WorkPhaseTemplateAvailableResponse, + WorkResourceResponseSchema, + WorkResponseSchema, + WorkStaffRoleReponseSchema, + WorkStatusResponseSchema, + WorkPhaseByIdResponseSchema, + WorkIssuesLatestUpdateResponseSchema, +) diff --git a/epictrack-api/src/api/schemas/response/work_response.py b/epictrack-api/src/api/schemas/response/work_response.py index 62b2dadab..96a56ed23 100644 --- a/epictrack-api/src/api/schemas/response/work_response.py +++ b/epictrack-api/src/api/schemas/response/work_response.py @@ -223,3 +223,18 @@ class Meta(AutoSchemaBase.Meta): unknown = EXCLUDE updates = fields.Nested(WorkIssueUpdatesResponseSchema(many=True), dump_default=[]) + + +class WorkIssuesLatestUpdateResponseSchema( + AutoSchemaBase +): # pylint: disable=too-many-ancestors,too-few-public-methods + """Work Issues model schema class""" + + class Meta(AutoSchemaBase.Meta): + """Meta information""" + + model = WorkIssues + include_fk = True + unknown = EXCLUDE + + latest_update = fields.Nested(WorkIssueUpdatesResponseSchema(), dump_default=[]) diff --git a/epictrack-api/src/api/services/work_issues.py b/epictrack-api/src/api/services/work_issues.py index e161f6511..3ccbc1152 100644 --- a/epictrack-api/src/api/services/work_issues.py +++ b/epictrack-api/src/api/services/work_issues.py @@ -20,6 +20,7 @@ from api.utils import TokenInfo from api.utils.roles import Role as KeycloakRole, Membership from api.services import authorisation +from api.models.queries import WorkIssueQuery class WorkIssuesService: # pylint: disable=too-many-public-methods @@ -39,6 +40,12 @@ def find_work_issue_by_id(cls, work_id, issue_id): results = WorkIssuesModel.find_by_params({"work_id": work_id, "id": issue_id}) return results[0] if results else None + @classmethod + def find_work_issues_by_work_ids(cls, work_ids): + """Find all work issues by work ids""" + results = WorkIssueQuery.find_work_issues_by_work_ids(work_ids) + return results + @classmethod def create_work_issue_and_updates(cls, work_id, issue_data: Dict): """Create a new work issue and its updates.""" diff --git a/epictrack-web/src/components/reports/30-60-90Report/ThirtySixtyNinety.tsx b/epictrack-web/src/components/reports/30-60-90Report/ThirtySixtyNinety.tsx index 04c19fcd6..d8270cc5f 100644 --- a/epictrack-web/src/components/reports/30-60-90Report/ThirtySixtyNinety.tsx +++ b/epictrack-web/src/components/reports/30-60-90Report/ThirtySixtyNinety.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback, useMemo } from "react"; import { Container } from "@mui/system"; import { Accordion, @@ -6,6 +6,8 @@ import { AccordionSummary, Alert, Box, + Chip, + Divider, Grid, Skeleton, Tab, @@ -22,11 +24,17 @@ import { RESULT_STATUS, REPORT_TYPE, DISPLAY_DATE_FORMAT, + StalenessEnum, + REPORT_STALENESS_THRESHOLD, } from "../../../constants/application-constant"; import { dateUtils } from "../../../utils"; +import Icons from "../../icons"; +import { IconProps } from "../../icons/type"; import ReportHeader from "../shared/report-header/ReportHeader"; import { ETPageContainer } from "../../shared"; +import { staleLevel } from "utils/uiUtils"; +const IndicatorIcon: React.FC = Icons["IndicatorIcon"]; export default function ThirtySixtyNinety() { const [reports, setReports] = React.useState({}); const [showReportDateBanner, setShowReportDateBanner] = @@ -34,7 +42,6 @@ export default function ThirtySixtyNinety() { const [selectedTab, setSelectedTab] = React.useState(0); const [reportDate, setReportDate] = React.useState(); const [resultStatus, setResultStatus] = React.useState(); - const FILENAME_PREFIX = "30_60_90_Report"; React.useEffect(() => { const diff = dateUtils.diff( @@ -55,9 +62,20 @@ export default function ThirtySixtyNinety() { ); setResultStatus(RESULT_STATUS.LOADED); if (reportData.status === 200) { - setReports((reportData.data as never)["data"]); + const result = (reportData.data as never)["data"]; + Object.keys(result).forEach((key) => { + (result[key] as []).forEach((resultItem: any) => { + (resultItem.work_issues as []).forEach((workIssue: any) => { + if (stalenessLevel(workIssue) === StalenessEnum.CRITICAL) + workIssue["staleness"] = StalenessEnum.CRITICAL; + else if (stalenessLevel(workIssue) === StalenessEnum.WARN) + workIssue["staleness"] = StalenessEnum.WARN; + else workIssue["staleness"] = StalenessEnum.GOOD; + }); + }); + }); + setReports(result); } - if (reportData.status === 204) { setResultStatus(RESULT_STATUS.NO_RECORD); } @@ -65,6 +83,27 @@ export default function ThirtySixtyNinety() { setResultStatus(RESULT_STATUS.ERROR); } }, [reportDate]); + + const isStaleIndicatorRequired = (reportItem: any) => { + return (reportItem["work_issues"] as []).some( + (workIssue) => stalenessLevel(workIssue) === StalenessEnum.CRITICAL + ); + }; + const stalenessLevel = (workIssue: any) => { + const stalenessThreshold = + REPORT_STALENESS_THRESHOLD[REPORT_TYPE.REPORT_30_60_90]; + const diffDays = dateUtils.diff( + reportDate || "", + workIssue["latest_update"]["posted_date"], + "days" + ); + if (diffDays > stalenessThreshold[StalenessEnum.CRITICAL]) + return StalenessEnum.CRITICAL; + else if (diffDays > stalenessThreshold[StalenessEnum.WARN]) + return StalenessEnum.WARN; + else return StalenessEnum.GOOD; + }; + const downloadPDFReport = React.useCallback(async () => { try { fetchReportData(); @@ -171,6 +210,21 @@ export default function ThirtySixtyNinety() { + + Issues + {isStaleIndicatorRequired(item) && ( + + )} + + } + /> @@ -211,6 +265,54 @@ export default function ThirtySixtyNinety() { {item["work_status_text"]} + + {(item["work_issues"] as []).map((issue) => ( + + + + + + {dateUtils.formatDate( + issue["latest_update"][ + "posted_date" + ], + DISPLAY_DATE_FORMAT + )} + + + } + /> + + + {issue["title"]} + + + {issue["latest_update"]["description"]} + + + + + ))} + + + {item["decision_information"]} diff --git a/epictrack-web/src/components/reports/eaReferral/AnticipatedEAOSchedule.tsx b/epictrack-web/src/components/reports/eaReferral/AnticipatedEAOSchedule.tsx index 94391d7c7..6f8d176d4 100644 --- a/epictrack-web/src/components/reports/eaReferral/AnticipatedEAOSchedule.tsx +++ b/epictrack-web/src/components/reports/eaReferral/AnticipatedEAOSchedule.tsx @@ -27,13 +27,13 @@ import { REPORT_TYPE, DISPLAY_DATE_FORMAT, MILESTONE_TYPES, - StalenessEnum, } from "../../../constants/application-constant"; import { dateUtils } from "../../../utils"; import moment from "moment"; import ReportHeader from "../shared/report-header/ReportHeader"; import { ETPageContainer } from "../../shared"; import { Palette } from "styles/theme"; +import { staleLevel } from "utils/uiUtils"; export default function AnticipatedEAOSchedule() { const [reports, setReports] = React.useState({}); @@ -113,24 +113,24 @@ export default function AnticipatedEAOSchedule() { setSelectedTab(newValue); }; - const staleLevel = React.useCallback( - (staleness: string) => { - if (staleness == StalenessEnum.CRITICAL) { - return { - background: Palette.error.main, - }; - } else if (staleness == StalenessEnum.WARN) { - return { - background: Palette.secondary.main, - }; - } else { - return { - background: Palette.success.main, - }; - } - }, - [reportDate] - ); + // const staleLevel = React.useCallback( + // (staleness: string) => { + // if (staleness == StalenessEnum.CRITICAL) { + // return { + // background: Palette.error.main, + // }; + // } else if (staleness == StalenessEnum.WARN) { + // return { + // background: Palette.secondary.main, + // }; + // } else { + // return { + // background: Palette.success.main, + // }; + // } + // }, + // [reportDate] + // ); interface TabPanelProps { children?: React.ReactNode; diff --git a/epictrack-web/src/constants/application-constant.ts b/epictrack-web/src/constants/application-constant.ts index a32657493..7073facd1 100644 --- a/epictrack-web/src/constants/application-constant.ts +++ b/epictrack-web/src/constants/application-constant.ts @@ -131,3 +131,14 @@ export enum StalenessEnum { WARN = "WARN", GOOD = "GOOD", } + +export const REPORT_STALENESS_THRESHOLD = { + [REPORT_TYPE.EA_REFERRAL]: { + [StalenessEnum.CRITICAL]: 10, + [StalenessEnum.WARN]: 5, + }, + [REPORT_TYPE.REPORT_30_60_90]: { + [StalenessEnum.CRITICAL]: 10, + [StalenessEnum.WARN]: 7, + }, +}; diff --git a/epictrack-web/src/utils/uiUtils.ts b/epictrack-web/src/utils/uiUtils.ts new file mode 100644 index 000000000..ed61dad95 --- /dev/null +++ b/epictrack-web/src/utils/uiUtils.ts @@ -0,0 +1,21 @@ +import { StalenessEnum } from "constants/application-constant"; +import { Palette } from "styles/theme"; + +const staleLevel = (staleness: string) => { + console.log(staleness); + if (staleness == StalenessEnum.CRITICAL) { + return { + background: Palette.error.main, + }; + } else if (staleness == StalenessEnum.WARN) { + return { + background: Palette.secondary.main, + }; + } else { + return { + background: Palette.success.main, + }; + } +}; + +export { staleLevel };