diff --git a/epictrack-api/src/api/resources/work_issues.py b/epictrack-api/src/api/resources/work_issues.py index c3ad1cf38..3bc6f7bc3 100644 --- a/epictrack-api/src/api/resources/work_issues.py +++ b/epictrack-api/src/api/resources/work_issues.py @@ -17,9 +17,10 @@ from flask import jsonify from flask_restx import Namespace, Resource, cors +from api.schemas import request as req from api.schemas import response as res from api.services import WorkIssuesService -from api.utils import auth +from api.utils import auth, profiletime from api.utils.util import cors_preflight API = Namespace("work-issues", description="Work Issues") @@ -37,3 +38,65 @@ def get(work_id): """Return all active works.""" works = WorkIssuesService.find_all_work_issues(work_id) return jsonify(res.WorkIssuesResponseSchema(many=True).dump(works)), HTTPStatus.OK + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def post(work_id): + """Create new work status""" + request_dict = req.WorkIssuesParameterSchema().load(API.payload) + work_issues = WorkIssuesService.create_work_issue_and_updates(work_id, request_dict) + return res.WorkIssuesResponseSchema().dump(work_issues), HTTPStatus.CREATED + + +@API.route("/", methods=["PUT"]) +class IssueUpdateEdits(Resource): + """Endpoint resource to manage updates/edits for a specific issue and its description.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def put(work_id, issue_id): + """Create a new update for the specified issue.""" + request_dict = req.WorkIssuesUpdateSchema().load(API.payload) + work_issues = WorkIssuesService.edit_issue_update(work_id, issue_id, request_dict) + return res.WorkIssuesResponseSchema().dump(work_issues), HTTPStatus.CREATED + + +@API.route("//issue_update", methods=["POST"]) +class WorkIssueUpdate(Resource): + """Endpoint resource to manage updates for a specific issue.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def post(work_id, issue_id): + """Create a new update for the specified issue.""" + description_data = API.payload.get('description_data', None) + if not description_data: + return jsonify({'error': 'description_data is required'}), HTTPStatus.BAD_REQUEST + work_issues = WorkIssuesService.add_work_issue_update(work_id, issue_id, description_data) + return res.WorkIssuesResponseSchema().dump(work_issues), HTTPStatus.CREATED + + +@cors_preflight("PATCH") +@API.route("//approve", methods=["PATCH", "OPTIONS"]) +class ApproveIssues(Resource): + """Endpoint resource to manage approving of work status.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def patch(work_id, issue_id): + """Approve a work status.""" + existing_work_issues = WorkIssuesService.find_work_issue_by_id(work_id, issue_id) + if existing_work_issues is None: + return {"message": "Work issues not found"}, HTTPStatus.NOT_FOUND + + approved_work_issues = WorkIssuesService.approve_work_issues(existing_work_issues) + + return res.WorkIssuesResponseSchema().dump(approved_work_issues), HTTPStatus.OK diff --git a/epictrack-api/src/api/resources/work_status.py b/epictrack-api/src/api/resources/work_status.py index faf5c841f..3305b3c82 100644 --- a/epictrack-api/src/api/resources/work_status.py +++ b/epictrack-api/src/api/resources/work_status.py @@ -17,9 +17,10 @@ from flask import jsonify from flask_restx import Namespace, Resource, cors +from api.schemas import request as req from api.schemas import response as res from api.services import WorkStatusService -from api.utils import auth +from api.utils import auth, profiletime from api.utils.util import cors_preflight API = Namespace("work-statuses", description="Work Statuses") @@ -35,5 +36,56 @@ class WorkStatus(Resource): @auth.require def get(work_id): """Return all active works.""" - works = WorkStatusService.find_all_works_tatus(work_id) + works = WorkStatusService.find_all_work_status(work_id) return jsonify(res.WorkStatusResponseSchema(many=True).dump(works)), HTTPStatus.OK + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def post(work_id): + """Create new work status""" + request_dict = req.WorkStatusParameterSchema().load(API.payload) + work_status = WorkStatusService.create_work_status(work_id, request_dict) + return res.WorkStatusResponseSchema().dump(work_status), HTTPStatus.CREATED + + +@cors_preflight("GET, PUT") +@API.route("/", methods=["GET", "PUT", "OPTIONS"]) +class Status(Resource): + """Endpoint resource to manage a work status.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def put(work_id, status_id): + """Update work status""" + request_dict = req.WorkStatusParameterSchema().load(API.payload) + existing_work_status = WorkStatusService.find_work_status_by_id(work_id, status_id) + if existing_work_status is None: + return {"message": "Work status not found"}, HTTPStatus.NOT_FOUND + + updated_work_status = WorkStatusService.update_work_status(existing_work_status, request_dict) + + return res.WorkStatusResponseSchema().dump(updated_work_status), HTTPStatus.OK + + +@cors_preflight("PATCH") +@API.route("//approve", methods=["PATCH", "OPTIONS"]) +class ApproveStatus(Resource): + """Endpoint resource to manage approving of work status.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def patch(work_id, status_id): + """Approve a work status.""" + existing_work_status = WorkStatusService.find_work_status_by_id(work_id, status_id) + if existing_work_status is None: + return {"message": "Work status not found"}, HTTPStatus.NOT_FOUND + + approved_work_status = WorkStatusService.approve_work_status(existing_work_status) + + return res.WorkStatusResponseSchema().dump(approved_work_status), HTTPStatus.OK diff --git a/epictrack-api/src/api/schemas/request/__init__.py b/epictrack-api/src/api/schemas/request/__init__.py index efced20c4..e4629ca53 100644 --- a/epictrack-api/src/api/schemas/request/__init__.py +++ b/epictrack-api/src/api/schemas/request/__init__.py @@ -48,5 +48,6 @@ from .work_request import ( WorkBodyParameterSchema, WorkExistenceQueryParamSchema, WorkFirstNationImportBodyParamSchema, WorkFirstNationNotesBodySchema, WorkIdPathParameterSchema, WorkIdPhaseIdPathParameterSchema, - WorkPlanDownloadQueryParamSchema, WorkTypeIdQueryParamSchema) + WorkPlanDownloadQueryParamSchema, WorkTypeIdQueryParamSchema, WorkStatusParameterSchema, + WorkIssuesParameterSchema, WorkIssuesUpdateSchema) from .act_section_request import ActSectionQueryParameterSchema diff --git a/epictrack-api/src/api/schemas/request/work_request.py b/epictrack-api/src/api/schemas/request/work_request.py index b44dde03f..e191998f1 100644 --- a/epictrack-api/src/api/schemas/request/work_request.py +++ b/epictrack-api/src/api/schemas/request/work_request.py @@ -185,3 +185,83 @@ class WorkFirstNationImportBodyParamSchema(RequestBodyParameterSchema): required=True, validate=validate.Length(min=1), ) + + +class WorkStatusParameterSchema(RequestBodyParameterSchema): + """Work status request body schema""" + + description = fields.Str( + metadata={"description": "description of status"}, + validate=validate.Length(max=500), + required=True, + ) + + notes = fields.Str( + metadata={"description": "Notes for the work status "}, + allow_none=True + ) + + posted_date = fields.DateTime( + metadata={"description": "posted date for the work status"}, required=False + ) + + +class WorkIssuesParameterSchema(RequestBodyParameterSchema): + """Work status request body schema""" + + title = fields.Str( + metadata={"description": "Title Of the issue"}, + validate=validate.Length(max=50), + required=True, + ) + + is_active: bool = fields.Bool( + default=True, + description="Flag indicating whether the issue is active", + ) + + is_high_priority: bool = fields.Bool( + default=False, + description="Flag indicating whether the issue is of high priority", + ) + + start_date = fields.DateTime( + metadata={"description": "Start date for the issue"}, required=False + ) + + expected_resolution_date = fields.DateTime( + metadata={"description": "Expected Resolution date for the issue"}, required=False + ) + + updates = fields.List( + fields.Str, + metadata={"description": "List of updates for the issue"}, + required=False, + ) + + +class WorkIssuesUpdateSchema(WorkIssuesParameterSchema): + """Work status update request body schema for PUT requests""" + + title = fields.Str( + metadata={"description": "Title Of the issue"}, + validate=validate.Length(max=50), + required=False, + ) + + updates = fields.List( + fields.Dict( + description=fields.Str( + metadata={"description": "Description of the update"}, + required=True + ), + id=fields.Int( + metadata={"description": "ID of the update"}, + required=True + ), + metadata={"description": "List of updates for the issue with IDs"}, + required=True + ), + metadata={"description": "List of updates for the issue with IDs"}, + required=False, + ) diff --git a/epictrack-api/src/api/services/work_issues.py b/epictrack-api/src/api/services/work_issues.py index afe3b8f5a..02600b156 100644 --- a/epictrack-api/src/api/services/work_issues.py +++ b/epictrack-api/src/api/services/work_issues.py @@ -12,8 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. """Service to manage Work status.""" +from typing import Dict, List +from api.models import WorkIssueUpdates as WorkIssueUpdatesModel +from api.exceptions import ResourceNotFoundError from api.models import WorkIssues as WorkIssuesModel +from api.utils import TokenInfo class WorkIssuesService: # pylint: disable=too-many-public-methods @@ -26,3 +30,91 @@ def find_all_work_issues(cls, work_id): """Find all issues related to a work""" work_issues = WorkIssuesModel.find_by_params({"work_id": work_id}) return work_issues + + @classmethod + def find_work_issue_by_id(cls, work_id, issue_id): + """Find all status related to a work""" + results = WorkIssuesModel.find_by_params({"work_id": work_id, "id": issue_id}) + return results[0] if results else None + + @classmethod + def create_work_issue_and_updates(cls, work_id, issue_data: Dict): + """Create a new work issue and its updates.""" + updates = issue_data.pop('updates', []) + + new_work_issue = WorkIssuesModel(**issue_data, + work_id=work_id + ) + new_work_issue.save() + + for update_description in updates: + new_update = WorkIssueUpdatesModel(description=update_description) + new_update.work_issue_id = new_work_issue.id + new_update.save() + + return new_work_issue + + @classmethod + def add_work_issue_update(cls, work_id, issue_id, description_data: List[str]): + """Add a new description to the existing Issue.""" + work_issues = WorkIssuesModel.find_by_params({"work_id": work_id, + "id": issue_id}) + + if not work_issues: + raise ResourceNotFoundError("Work Issues not found") + + for description in description_data: + new_update = WorkIssueUpdatesModel(description=description) + new_update.work_issue_id = work_issues[0].id + new_update.save() + + return WorkIssuesModel.find_by_id(issue_id) + + @classmethod + def approve_work_issues(cls, work_issues): + """Approve a work status.""" + if work_issues.is_approved: + return work_issues + + work_issues.is_approved = True + work_issues.approved_by = TokenInfo.get_username() + + work_issues.save() + + return work_issues + + @classmethod + def edit_issue_update(cls, work_id, issue_id, issue_data): + """Update an existing work issue, and save it only if there are changes.""" + work_issue = WorkIssuesService.find_work_issue_by_id(work_id, issue_id) + + if not work_issue: + raise ResourceNotFoundError("Work issue doesnt exist") + + updates = issue_data.pop('updates', []) + + # Create a flag to track changes on work_issues + has_changes_to_work_issue = False + + for key, value in issue_data.items(): + if getattr(work_issue, key) != value: + setattr(work_issue, key, value) + has_changes_to_work_issue = True + + if updates: + for update_description in updates: + description_id = update_description.get('id') + if not description_id: + raise ResourceNotFoundError("Issue Description doesnt exist") + if (description_id := update_description.get('id')) is not None: + issue_update_model: WorkIssueUpdatesModel = WorkIssueUpdatesModel.find_by_id(description_id) + if not issue_update_model: + raise ResourceNotFoundError("Issue Description doesnt exist") + issue_update_model.description = update_description.get('description') + issue_update_model.flush() + + if has_changes_to_work_issue: + work_issue.save() # Save the updated work issue only if there are changes + else: + issue_update_model.commit() + return work_issue diff --git a/epictrack-api/src/api/services/work_status.py b/epictrack-api/src/api/services/work_status.py index 664ca21b3..b5a2d62b1 100644 --- a/epictrack-api/src/api/services/work_status.py +++ b/epictrack-api/src/api/services/work_status.py @@ -12,8 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. """Service to manage Work status.""" +from http import HTTPStatus +from typing import Dict +from api.exceptions import BusinessError from api.models import WorkStatus as WorkStatusModel +from api.utils import TokenInfo class WorkStatusService: # pylint: disable=too-many-public-methods @@ -22,6 +26,51 @@ class WorkStatusService: # pylint: disable=too-many-public-methods # pylint: disable=too-few-public-methods, @classmethod - def find_all_works_tatus(cls, work_id): + def find_all_work_status(cls, work_id): """Find all status related to a work""" return WorkStatusModel.find_by_params({"work_id": work_id}) + + @classmethod + def find_work_status_by_id(cls, work_id, status_id): + """Find all status related to a work""" + results = WorkStatusModel.find_by_params({"work_id": work_id, "id": status_id}) + return results[0] if results else None + + @classmethod + def create_work_status(cls, work_id, work_status: Dict): + """Creates a work status.""" + work_status = WorkStatusModel( + **work_status, + posted_by=TokenInfo.get_username(), + work_id=work_id + ) + work_status.save() + + return work_status + + @classmethod + def update_work_status(cls, work_status: WorkStatusModel, work_status_data: dict): + """Update an existing work status.""" + # TODO Add Super user check + if work_status.is_approved: + if not TokenInfo.is_super_user(): + raise BusinessError("Access Denied", HTTPStatus.UNAUTHORIZED) + + work_status.update(work_status_data) + + work_status.save() + + return work_status + + @classmethod + def approve_work_status(cls, work_status): + """Approve a work status.""" + if work_status.is_approved: + return work_status + + work_status.is_approved = True + work_status.approved_by = TokenInfo.get_username() + + work_status.save() + + return work_status diff --git a/epictrack-api/src/api/utils/token_info.py b/epictrack-api/src/api/utils/token_info.py index b5271698c..f09177008 100644 --- a/epictrack-api/src/api/utils/token_info.py +++ b/epictrack-api/src/api/utils/token_info.py @@ -41,3 +41,17 @@ def get_user_data(): 'groups': [group[1:len(group)] for group in token_info.get('groups')] } return user_data + + @staticmethod + def get_username(): + """Return the user name.""" + username = g.token_info.get("preferred_username", None) + if username is None: + username = g.jwt_oidc_token_info.get("email") + return username + + @staticmethod + def is_super_user() -> bool: + """Return True if the user is staff user.""" + # TODO Implement this method + return True