From a10e50bd32be83c039d5b5ead69ddd1c21d4404c Mon Sep 17 00:00:00 2001 From: Dinesh <97143739+dinesh-aot@users.noreply.github.com> Date: Sun, 2 Jun 2024 23:21:32 -0700 Subject: [PATCH] workplan card data issues (#2300) * workplan card data issues * Make work inactive when the end states has been choosen --- .../src/api/actions/set_work_state.py | 14 +- epictrack-api/src/api/models/__init__.py | 2 +- epictrack-api/src/api/models/work.py | 63 ++++++- .../src/api/schemas/response/work_response.py | 6 + .../src/api/services/common_service.py | 41 +++++ epictrack-api/src/api/services/event.py | 83 ++++----- epictrack-api/src/api/services/work.py | 4 + epictrack-api/src/api/services/work_phase.py | 62 +++++-- .../components/myWorkplans/Card/CardBody.tsx | 160 ++++++++++++------ .../src/components/myWorkplans/Card/type.ts | 10 +- epictrack-web/src/models/work.ts | 9 + epictrack-web/src/models/workplan.tsx | 4 + 12 files changed, 333 insertions(+), 125 deletions(-) create mode 100644 epictrack-api/src/api/services/common_service.py diff --git a/epictrack-api/src/api/actions/set_work_state.py b/epictrack-api/src/api/actions/set_work_state.py index 97f53c49a..ff105f516 100644 --- a/epictrack-api/src/api/actions/set_work_state.py +++ b/epictrack-api/src/api/actions/set_work_state.py @@ -2,7 +2,7 @@ from api.actions.base import ActionFactory from api.models import db -from api.models.work import Work, WorkStateEnum +from api.models.work import Work, WorkStateEnum, EndingWorkStateEnum from .change_phase_end_event import ChangePhaseEndEvent @@ -12,15 +12,21 @@ class SetWorkState(ActionFactory): def run(self, source_event, params) -> None: """Sets the work as per action configuration""" work_state = params.get("work_state") - if work_state in [WorkStateEnum.TERMINATED.value, WorkStateEnum.WITHDRAWN.value]: + if work_state in [ + WorkStateEnum.TERMINATED.value, + WorkStateEnum.WITHDRAWN.value, + ]: change_phase_end_event = ChangePhaseEndEvent() change_phase_end_event_param = { "phase_name": source_event.event_configuration.work_phase.name, "work_type_id": source_event.work.work_type_id, "ea_act_id": source_event.work.ea_act_id, - "event_name": source_event.event_configuration.name + "event_name": source_event.event_configuration.name, } change_phase_end_event.run(source_event, change_phase_end_event_param) + is_active = True + if work_state in [state.value for state in EndingWorkStateEnum]: + is_active = False db.session.query(Work).filter(Work.id == source_event.work_id).update( - {Work.work_state: work_state} + {Work.work_state: work_state, Work.is_active: is_active} ) diff --git a/epictrack-api/src/api/models/__init__.py b/epictrack-api/src/api/models/__init__.py index fb3901f36..a5f468a3f 100644 --- a/epictrack-api/src/api/models/__init__.py +++ b/epictrack-api/src/api/models/__init__.py @@ -65,7 +65,7 @@ from .task_event_responsibility import TaskEventResponsibility from .task_template import TaskTemplate from .types import Type -from .work import Work, WorkStateEnum +from .work import Work, WorkStateEnum, EndingWorkStateEnum from .work_calendar_event import WorkCalendarEvent from .work_issue_updates import WorkIssueUpdates from .work_issues import WorkIssues diff --git a/epictrack-api/src/api/models/work.py b/epictrack-api/src/api/models/work.py index dbf746bee..c3810b68c 100644 --- a/epictrack-api/src/api/models/work.py +++ b/epictrack-api/src/api/models/work.py @@ -18,7 +18,20 @@ import enum from typing import List, Tuple -from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String, Text, and_, exists, func, or_ +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Enum, + ForeignKey, + Integer, + String, + Text, + and_, + exists, + func, + or_ +) from sqlalchemy.orm import relationship from sqlalchemy.ext.hybrid import hybrid_property @@ -42,6 +55,15 @@ class WorkStateEnum(enum.Enum): COMPLETED = "COMPLETED" +class EndingWorkStateEnum(enum.Enum): + """Ending states for work""" + + WITHDRAWN = "WITHDRAWN" + TERMINATED = "TERMINATED" + CLOSED = "CLOSED" + COMPLETED = "COMPLETED" + + class Work(BaseModelVersioned): """Model class for Work.""" @@ -143,10 +165,7 @@ def fetch_all_works( search_filters: WorkplanDashboardSearchOptions = None ) -> Tuple[List[Work], int]: """Fetch all active works.""" - query = cls.query.filter_by(is_active=True, is_deleted=False) - - query = cls.filter_by_search_criteria(query, search_filters) - + query = cls.filter_by_search_criteria(cls.query, search_filters) query = query.order_by(Work.start_date.desc()) no_pagination_options = not pagination_options or not pagination_options.page or not pagination_options.size @@ -233,5 +252,37 @@ def _filter_by_env_regions(cls, query, env_regions): @classmethod def _filter_by_work_states(cls, query, work_states): if work_states: - query = query.filter(Work.work_state.in_(work_states)) + ending_states = [state.value for state in EndingWorkStateEnum] + filtered_ending_states = [work_state for work_state in work_states if work_state in ending_states] + filtered_non_ending_states = [ + work_state + for work_state in work_states + if work_state not in ending_states + ] + ending_state_query = None + if filtered_ending_states: + ending_state_query = query.filter( + and_( + Work.work_state.in_(filtered_ending_states), + Work.is_active.is_(False), + Work.is_deleted.is_(False), + ) + ) + if filtered_non_ending_states: + query = query.filter( + and_( + Work.work_state.in_(filtered_non_ending_states), + Work.is_active.is_(True), + Work.is_deleted.is_(False), + ) + ) + if ending_state_query: + if ( + not filtered_non_ending_states + ): # if non ending state query is not pressent + query = ending_state_query + else: # if both state queries are present + query = query.union(ending_state_query) + else: + query = query.filter_by(is_active=True, is_deleted=False) return query diff --git a/epictrack-api/src/api/schemas/response/work_response.py b/epictrack-api/src/api/schemas/response/work_response.py index 96a56ed23..8f86bc82e 100644 --- a/epictrack-api/src/api/schemas/response/work_response.py +++ b/epictrack-api/src/api/schemas/response/work_response.py @@ -166,7 +166,13 @@ class WorkPhaseAdditionalInfoResponseSchema(Schema): ) current_milestone = fields.Str(metadata={"description": "Current milestone in the phase"}) next_milestone = fields.Str(metadata={"description": "Next milestone in the phase"}) + next_milestone_date = fields.DateTime(metadata={"description": "Anticipated date of the next milestone"}) milestone_progress = fields.Number(metadata={"description": "Milestone progress"}) + decision_milestone = fields.Str(metadata={"description": "Last completed decision milestone in the phase"}) + decision = fields.Str(metadata={"description": "Decision taken in the decision milestone"}) + decision_milestone_date = fields.DateTime( + metadata={"description": "Actual date of last completed decision milestone in the phase"} + ) is_last_phase = fields.Number(metadata={"description": "Indicate if this the last phase of the work"}) days_left = fields.Number( metadata={"description": "Number of days left in the phase"} diff --git a/epictrack-api/src/api/services/common_service.py b/epictrack-api/src/api/services/common_service.py new file mode 100644 index 000000000..b245653a1 --- /dev/null +++ b/epictrack-api/src/api/services/common_service.py @@ -0,0 +1,41 @@ +# 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. +"""Service to manage ActSection.""" +from datetime import datetime + + +def find_event_date(event) -> datetime: + """Returns the event actual or anticipated date""" + return event.actual_date if event.actual_date else event.anticipated_date + + +def event_compare_func(event_x, event_y): + """Compare function for event sort""" + # if ( + # event_x.event_position == EventPositionEnum.START.value + # or event_y.event_position == EventPositionEnum.END.value + # ): + # return -1 + # if ( + # event_y.event_position == EventPositionEnum.START.value + # or event_x.event_position == EventPositionEnum.END.value + # ): + # return 1 + if (find_event_date(event_x).date() - find_event_date(event_y).date()).days == 0: + return -1 if event_x.id < event_y.id else 1 + if (find_event_date(event_x).date() - find_event_date(event_y).date()).days < 0: + return -1 + if (find_event_date(event_x).date() - find_event_date(event_y).date()).days > 0: + return 1 + return 0 diff --git a/epictrack-api/src/api/services/event.py b/epictrack-api/src/api/services/event.py index 4061bd95a..bb4326fac 100644 --- a/epictrack-api/src/api/services/event.py +++ b/epictrack-api/src/api/services/event.py @@ -51,6 +51,7 @@ from ..utils.roles import Role as KeycloakRole from . import authorisation from .event_configuration import EventConfigurationService +from .common_service import find_event_date, event_compare_func # pylint:disable=not-callable, too-many-lines @@ -212,7 +213,7 @@ def _validate_event_effect_on_dates( all_work_events, current_work_phase.id ) if legislated_phase_end_push_can_happen: - if cls._find_event_date(event) >= current_work_phase.end_date: + if find_event_date(event) >= current_work_phase.end_date: end_event = next( filter( lambda x: x.event_configuration.event_position.value @@ -244,7 +245,7 @@ def _validate_event_effect_on_dates( ) days_diff = ( work_phase.end_date.date() - - cls._find_event_date(each_event).date() + - find_event_date(each_event).date() ).days if days_diff < 0: result["phase_end_push_required"] = True @@ -384,7 +385,7 @@ def _push_subsequent_events( ] ): end_event_index = None - if cls._find_event_date(event) >= current_work_phase.end_date: + if find_event_date(event) >= current_work_phase.end_date: end_event = next( filter( lambda x: x.event_configuration.event_position.value @@ -392,9 +393,7 @@ def _push_subsequent_events( phase_events, ) ) - end_event_index = util.find_index_in_array( - phase_events, end_event - ) + end_event_index = util.find_index_in_array(phase_events, end_event) # In this case, we will be extensing the end event as well # Index is decremented by one to include the end event also to be pushed if end_event_index > 0: @@ -435,7 +434,10 @@ def _push_subsequent_events( @classmethod def _validate_dates( - cls, event: Event, current_work_phase: WorkPhase, all_work_phases: List[WorkPhase] + cls, + event: Event, + current_work_phase: WorkPhase, + all_work_phases: List[WorkPhase], ): """Perform date validations for the min and max dates for events""" if event.actual_date: @@ -461,7 +463,10 @@ def _validate_dates( @classmethod def _find_anticipated_date_min( - cls, event: Event, current_work_phase: WorkPhase, all_work_phases: List[WorkPhase] + cls, + event: Event, + current_work_phase: WorkPhase, + all_work_phases: List[WorkPhase], ): """Return the min date of anticipated date""" anticipated_date_min = ( @@ -474,7 +479,10 @@ def _find_anticipated_date_min( @classmethod def _find_actual_date_min( - cls, event: Event, current_work_phase: WorkPhase, all_work_phases: List[WorkPhase] + cls, + event: Event, + current_work_phase: WorkPhase, + all_work_phases: List[WorkPhase], ): """Return the min date of actual date""" actual_date_min = ( @@ -522,7 +530,7 @@ def _handle_work_phase_for_start_event( ) -> None: """Update the work phase's start date if the start event's date changed""" if event.event_position == EventPositionEnum.START.value: - current_work_phase.start_date = cls._find_event_date(event) + current_work_phase.start_date = find_event_date(event) # Adjust the phase end date when START event's date changed in a legislated phase # This section executes if the user opt not to push the events if current_work_phase.legislated and not push_events: @@ -675,33 +683,6 @@ def _handle_work_phase_for_extension_without_push_events( current_work_phase.as_dict(recursive=False), commit=False ) - @classmethod - def event_compare_func(cls, event_x, event_y): - """Compare function for event sort""" - # if ( - # event_x.event_position == EventPositionEnum.START.value - # or event_y.event_position == EventPositionEnum.END.value - # ): - # return -1 - # if ( - # event_y.event_position == EventPositionEnum.START.value - # or event_x.event_position == EventPositionEnum.END.value - # ): - # return 1 - if ( - cls._find_event_date(event_x).date() - cls._find_event_date(event_y).date() - ).days == 0: - return -1 if event_x.id < event_y.id else 1 - if ( - cls._find_event_date(event_x).date() - cls._find_event_date(event_y).date() - ).days < 0: - return -1 - if ( - cls._find_event_date(event_x).date() - cls._find_event_date(event_y).date() - ).days > 0: - return 1 - return 0 - @classmethod def find_event_index( cls, all_work_events: List[Event], event: Event, current_work_phase: WorkPhase @@ -714,7 +695,7 @@ def find_event_index( ) ) all_phase_events = sorted( - all_phase_events, key=functools.cmp_to_key(cls.event_compare_func) + all_phase_events, key=functools.cmp_to_key(event_compare_func) ) event_index_if_existing = cls._find_event_index_in_array( all_phase_events, event @@ -724,7 +705,8 @@ def find_event_index( copy_of_all_phase_events: List[Event] = copy.copy(all_phase_events) copy_of_all_phase_events.append(event) copy_of_all_phase_events = sorted( - copy_of_all_phase_events, key=functools.cmp_to_key(cls.event_compare_func) + copy_of_all_phase_events, + key=functools.cmp_to_key(event_compare_func), ) return cls._find_event_index_in_array(copy_of_all_phase_events, event) @@ -746,8 +728,8 @@ def _get_number_of_days_to_be_pushed( """Returns the number of days to be pushed""" delta = ( ( - cls._find_event_date(event).date() - - cls._find_event_date(event_old).date() + find_event_date(event).date() + - find_event_date(event_old).date() ).days if event_old else 0 @@ -797,7 +779,9 @@ def _push_events( for event_to_update in phase_events: if event_to_update.id != event.id: event_from_db = Event.find_by_id(event_to_update.id) - if not event_from_db.actual_date: # do not modify already locked milestones + if ( + not event_from_db.actual_date + ): # do not modify already locked milestones event_from_db.anticipated_date = ( event_from_db.anticipated_date + timedelta(days=number_of_days_to_be_pushed) @@ -867,7 +851,7 @@ def _find_work_phase_events( ) ) phase_events = sorted( - phase_events, key=functools.cmp_to_key(cls.event_compare_func) + phase_events, key=functools.cmp_to_key(event_compare_func) ) return phase_events @@ -916,7 +900,7 @@ def _previous_event_acutal_date_rule( # pylint: disable=too-many-arguments all_work_events, event.event_configuration.work_phase_id ) phase_events = sorted( - phase_events, key=functools.cmp_to_key(cls.event_compare_func) + phase_events, key=functools.cmp_to_key(event_compare_func) ) previous_event = phase_events[event_index - 1] if ( @@ -946,7 +930,7 @@ def _handle_child_events( ) ) for c_event_conf in child_configurations: - c_event_start_date = cls._find_event_date(event) + timedelta( + c_event_start_date = find_event_date(event) + timedelta( days=cls._find_start_at_value( c_event_conf.start_at, event.number_of_days ) @@ -996,7 +980,9 @@ def _handle_child_events( ) if existing_event: existing_event.anticipated_date = c_event_start_date - existing_event.update(existing_event.as_dict(recursive=False), commit=False) + existing_event.update( + existing_event.as_dict(recursive=False), commit=False + ) else: Event.flush( Event( @@ -1056,11 +1042,6 @@ def find_events( return results_scoped return results - @classmethod - def _find_event_date(cls, event) -> datetime: - """Returns the event actual or anticipated date""" - return event.actual_date if event.actual_date else event.anticipated_date - @classmethod def _find_start_at_value(cls, start_at: str, number_of_days: int) -> int: """Calculate the start at value""" diff --git a/epictrack-api/src/api/services/work.py b/epictrack-api/src/api/services/work.py index 2eae76622..dda067e43 100644 --- a/epictrack-api/src/api/services/work.py +++ b/epictrack-api/src/api/services/work.py @@ -152,6 +152,10 @@ def _serialize_work(work, work_staffs, works_statuses, work_phase): "total_number_of_days", "current_milestone", "next_milestone", + "next_milestone_date", + "decision_milestone", + "decision", + "decision_milestone_date", "milestone_progress", "days_left", "work_phase.id", diff --git a/epictrack-api/src/api/services/work_phase.py b/epictrack-api/src/api/services/work_phase.py index 628bf1897..83d24586c 100644 --- a/epictrack-api/src/api/services/work_phase.py +++ b/epictrack-api/src/api/services/work_phase.py @@ -20,10 +20,12 @@ from api.models import PhaseCode, WorkPhase, PRIMARY_CATEGORIES, db from api.models.event_type import EventTypeEnum +from api.models.event_category import EventCategoryEnum from api.schemas.work_v2 import WorkPhaseSchema from api.models.phase_code import PhaseVisibilityEnum from api.services.event import EventService from api.services.task_template import TaskTemplateService +from .common_service import event_compare_func class WorkPhaseService: # pylint: disable=too-few-public-methods @@ -133,7 +135,9 @@ def find_work_phases_by_work_ids(cls, work_ids): .all() ) total_work_phases = len(work_phases_dict) - work_phases_dict = list(filter(lambda x: x[1].is_active is True, work_phases_dict)) + work_phases_dict = list( + filter(lambda x: x[1].is_active is True, work_phases_dict) + ) result_dict = defaultdict(list) for work_id, work_phase in work_phases_dict: result_dict[work_id].append(work_phase) @@ -157,11 +161,13 @@ def _find_work_phase_status(cls, work_id, work_phase_id, work_phases): suspended_days = functools.reduce( lambda x, y: x + y, map( - lambda x: x.number_of_days - if x.event_configuration.event_type_id - == EventTypeEnum.TIME_LIMIT_RESUMPTION.value - and x.actual_date is not None - else 0, + lambda x: ( + x.number_of_days + if x.event_configuration.event_type_id + == EventTypeEnum.TIME_LIMIT_RESUMPTION.value + and x.actual_date is not None + else 0 + ), work_phase_events, ), ) @@ -179,14 +185,46 @@ def _find_work_phase_status(cls, work_id, work_phase_id, work_phases): @classmethod def _get_milestone_information(cls, work_phase_events): result = {} + completed_milestone_events = [ + event for event in work_phase_events if event.actual_date + ] + result["current_milestone"] = ( + completed_milestone_events[-1].name if completed_milestone_events else None + ) + + remaining_milestone_events = [ + event for event in work_phase_events if event.actual_date is None + ] + result["next_milestone"] = ( + remaining_milestone_events[0].name if remaining_milestone_events else None + ) + result["next_milestone_date"] = ( + remaining_milestone_events[0].anticipated_date + if remaining_milestone_events + else None + ) - completed_milestone_events = [event for event in work_phase_events if event.actual_date] - result["current_milestone"] = completed_milestone_events[-1].name if completed_milestone_events else None + decision_milestones = [ + event + for event in work_phase_events + if event.event_configuration.event_category_id + == EventCategoryEnum.DECISION.value + and event.actual_date + ] - remaining_milestone_events = [event for event in work_phase_events if event.actual_date is None] - result["next_milestone"] = remaining_milestone_events[0].name if remaining_milestone_events else None + result["decision_milestone"] = ( + decision_milestones[-1].name if decision_milestones else None + ) + result["decision"] = ( + decision_milestones[-1].outcome.name if decision_milestones else None + ) + result["decision_milestone_date"] = ( + decision_milestones[-1].actual_date if decision_milestones else None + ) - result["milestone_progress"] = cls._calculate_milestone_progress(work_phase_events) + result["milestone_progress"] = cls._calculate_milestone_progress( + work_phase_events + ) return result @@ -203,7 +241,7 @@ def _filter_sort_events(cls, events, work_phase): x for x in events if x.event_configuration.work_phase_id == work_phase.id ] work_phase_events = sorted( - work_phase_events, key=functools.cmp_to_key(EventService.event_compare_func) + work_phase_events, key=functools.cmp_to_key(event_compare_func) ) return work_phase_events diff --git a/epictrack-web/src/components/myWorkplans/Card/CardBody.tsx b/epictrack-web/src/components/myWorkplans/Card/CardBody.tsx index 4782045f6..bf2e8e5e5 100644 --- a/epictrack-web/src/components/myWorkplans/Card/CardBody.tsx +++ b/epictrack-web/src/components/myWorkplans/Card/CardBody.tsx @@ -1,21 +1,89 @@ -import { useMemo } from "react"; +import { useContext, useMemo } from "react"; import { Box, Divider, Grid, Stack, Tooltip } from "@mui/material"; import { Palette } from "../../../styles/theme"; import { ETCaption1, ETCaption2, ETHeading4, ETParagraph } from "../../shared"; import Icons from "../../icons"; import { IconProps } from "../../icons/type"; -import { CardProps } from "./type"; +import { + CardProps, + MilestoneInfoSectionEnum, + MilestoneInfoSectionProps, +} from "./type"; import WorkState from "../../workPlan/WorkState"; import dayjs from "dayjs"; -import { MONTH_DAY_YEAR } from "../../../constants/application-constant"; +import { + DATE_FORMAT, + DISPLAY_DATE_FORMAT, + MONTH_DAY_YEAR, +} from "../../../constants/application-constant"; import { isStatusOutOfDate } from "../../workPlan/status/shared"; import { Status } from "../../../models/status"; -import { When } from "react-if"; +import { Else, If, Then, When } from "react-if"; import { daysLeft } from "./util"; +import { dateUtils } from "utils"; +import { WorkStateEnum } from "models/work"; +import { MyWorkplansContext } from "../MyWorkPlanContext"; const IndicatorSmallIcon: React.FC = Icons["IndicatorSmallIcon"]; const ClockIcon: React.FC = Icons["ClockIcon"]; - +const decisionWorkStates = [ + WorkStateEnum.CLOSED, + WorkStateEnum.COMPLETED, + WorkStateEnum.TERMINATED, + WorkStateEnum.WITHDRAWN, +]; +const MilestoneInfoSection = (props: MilestoneInfoSectionProps) => { + let dateTitle, name, date; + if (props.infoType === MilestoneInfoSectionEnum.DECISION) { + dateTitle = "DECISION TAKEN"; + name = props.phaseInfo?.decision; + date = props.phaseInfo?.decision_milestone_date; + } else { + dateTitle = "UPCOMING MILESTONE"; + name = props.phaseInfo?.next_milestone; + date = props.phaseInfo?.next_milestone_date; + } + return ( + <> + {!!props.phaseInfo && ( + <> + + + + {`${dateTitle} `} + {dateUtils.formatDate(String(date), DISPLAY_DATE_FORMAT)} + + + + + + + {name} + + + + + )} + + ); +}; const CardBody = ({ workplan }: CardProps) => { const phase_color = Palette.primary.main; const statusOutOfDate = @@ -30,7 +98,7 @@ const CardBody = ({ workplan }: CardProps) => { }`; const currentWorkPhaseInfo = useMemo(() => { - if (!workplan.phase_info) return null; + if (!workplan.phase_info) return undefined; const currentPhaseInfo = workplan.phase_info.filter( (p) => p.work_phase.id === workplan.current_work_phase_id ); @@ -126,60 +194,52 @@ const CardBody = ({ workplan }: CardProps) => { )} - {!!currentWorkPhaseInfo && ( - <> - - - - {`UPCOMING MILESTONE ${dayjs(new Date()) - .add(currentWorkPhaseInfo?.days_left, "days") - .format(MONTH_DAY_YEAR) - .toUpperCase()}`} - - - - - - - {currentWorkPhaseInfo?.next_milestone} - - - - - )} + + + + + + + + - - - LAST STATUS UPDATE - - + + + LAST STATUS UPDATE + + {lastStatusUpdate} + + + {statusOutOfDate && } + + - - {statusOutOfDate && } -