From ac345391bf5d2bfdcdeb1c10b27ce85bbf26defb Mon Sep 17 00:00:00 2001 From: Tom Chapman <89718178+tom0827@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:02:08 -0800 Subject: [PATCH 1/4] [COMP-751][COMP-752]: System Reports (#716) * COMP-752 System Reports * COMP-752 fetch projects from track using special history --- .../models/complaint/complaint_option.py | 11 + .../src/compliance_api/models/report_enum.py | 11 + .../src/compliance_api/resources/__init__.py | 2 + .../src/compliance_api/resources/complaint.py | 2 +- .../src/compliance_api/resources/report.py | 62 ++ .../src/compliance_api/schemas/report.py | 46 ++ .../epic_track_service/track_service.py | 8 + .../services/inspection_requirement.py | 72 +- .../compliance_api/services/report/base.py | 16 + .../services/report/case_file_management.py | 629 ++++++++++++++++++ .../services/report/ceb_summary.py | 515 ++++++++++++++ .../compliance_api/services/report/report.py | 25 + .../services/report/shared_queries.py | 142 ++++ .../compliance_api/services/service_utils.py | 64 ++ .../tests/unit/services/test_ceb_summary.py | 208 ++++++ .../_Inspections/InspectionFormRight.cy.tsx | 2 +- .../_Controlled/ControlledAutoComplete.cy.tsx | 15 +- .../Controlled/ControlledAutoComplete.tsx | 42 +- .../Shared/SideNav/RouteItemsList.tsx | 8 +- compliance-web/src/hooks/useSystemReports.tsx | 22 + compliance-web/src/models/Report.ts | 13 + compliance-web/src/routeTree.gen.ts | 21 + .../ce-database/reports/index.tsx | 195 +++--- 23 files changed, 1960 insertions(+), 171 deletions(-) create mode 100644 compliance-api/src/compliance_api/models/report_enum.py create mode 100644 compliance-api/src/compliance_api/resources/report.py create mode 100644 compliance-api/src/compliance_api/schemas/report.py create mode 100644 compliance-api/src/compliance_api/services/report/base.py create mode 100644 compliance-api/src/compliance_api/services/report/case_file_management.py create mode 100644 compliance-api/src/compliance_api/services/report/ceb_summary.py create mode 100644 compliance-api/src/compliance_api/services/report/report.py create mode 100644 compliance-api/src/compliance_api/services/report/shared_queries.py create mode 100644 compliance-api/tests/unit/services/test_ceb_summary.py create mode 100644 compliance-web/src/hooks/useSystemReports.tsx create mode 100644 compliance-web/src/models/Report.ts diff --git a/compliance-api/src/compliance_api/models/complaint/complaint_option.py b/compliance-api/src/compliance_api/models/complaint/complaint_option.py index 94928219a..5dd6aa927 100644 --- a/compliance-api/src/compliance_api/models/complaint/complaint_option.py +++ b/compliance-api/src/compliance_api/models/complaint/complaint_option.py @@ -1,8 +1,19 @@ """Complaint Options.""" +from enum import Enum from ..option_base_model import OptionModel +class ComplaintSourceEnum(Enum): + """Enumeration for Complaint Sources.""" + + PUBLIC = "Public" + FIRST_NATION = "First Nation" + FIRST_NATIONS_ALLIANCE = "First Nations Alliance" + AGENCY = "Agency" + OTHER = "Other" + + class ComplaintSource(OptionModel): """ComplaintSource model.""" diff --git a/compliance-api/src/compliance_api/models/report_enum.py b/compliance-api/src/compliance_api/models/report_enum.py new file mode 100644 index 000000000..ac2fa4c2d --- /dev/null +++ b/compliance-api/src/compliance_api/models/report_enum.py @@ -0,0 +1,11 @@ +"""Report type enum.""" +import enum + + +class ReportTypeEnum(enum.Enum): + """Report type enum.""" + + PROJECT_COMPLIANCE = "project-compliance" + CEB_SUMMARY = "ceb-summary" + CASE_FILE_MANAGEMENT = "case-file-management" + FIRST_NATION = "first-nation" diff --git a/compliance-api/src/compliance_api/resources/__init__.py b/compliance-api/src/compliance_api/resources/__init__.py index bd84dc8c9..e348051c0 100644 --- a/compliance-api/src/compliance_api/resources/__init__.py +++ b/compliance-api/src/compliance_api/resources/__init__.py @@ -55,6 +55,7 @@ from .topic import API as TOPIC_API from .violation_ticket import API as VIOLATION_TICKET_API from .warning_letter import API as WARNING_LETTER_API +from .report import API as REPORT_API __all__ = ("API_BLUEPRINT", "OPS_BLUEPRINT") @@ -124,3 +125,4 @@ API.add_namespace(INSPECTION_REQUIREMENTS_API, path="inspection-requirements") API.add_namespace(SENTENCE_TYPE_OPTION_API, path="sentence-type-options") API.add_namespace(FILE_JOB_API, path="document-jobs") +API.add_namespace(REPORT_API, path="reports") diff --git a/compliance-api/src/compliance_api/resources/complaint.py b/compliance-api/src/compliance_api/resources/complaint.py index d930485ad..eab4ad75b 100644 --- a/compliance-api/src/compliance_api/resources/complaint.py +++ b/compliance-api/src/compliance_api/resources/complaint.py @@ -170,7 +170,7 @@ def get(): @API.response(code=201, model=complaint_list_model, description="ComplaintCreated") @API.response(400, "Bad Request") def post(): - """Create an complaint.""" + """Create a complaint.""" current_app.logger.info(f"Creating Complaint with payload: {API.payload}") complaint_data = ComplaintCreateSchema().load(API.payload) created_complaint = ComplaintService.create(complaint_data) diff --git a/compliance-api/src/compliance_api/resources/report.py b/compliance-api/src/compliance_api/resources/report.py new file mode 100644 index 000000000..259209ef7 --- /dev/null +++ b/compliance-api/src/compliance_api/resources/report.py @@ -0,0 +1,62 @@ +# Copyright © 2024 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. +"""API endpoints for managing report resources.""" + +from datetime import datetime +from io import BytesIO + +from flask import request, send_file +from flask_restx import Namespace, Resource + +from compliance_api.services.report.report import ReportService +from compliance_api.auth import auth +from compliance_api.utils.util import cors_preflight +from compliance_api.schemas.report import ReportGenerationSchema + + +from .apihelper import Api as ApiHelper + + +API = Namespace( + "reports", + description="Endpoints for Report Management", +) + +report_generation_schema = ApiHelper.convert_ma_schema_to_restx_model( + API, ReportGenerationSchema(), "ReportGenerationSchema" +) + + +@cors_preflight("POST, OPTIONS") +@API.route("/export", methods=["POST", "OPTIONS"]) +class Reports(Resource): + """Resource for managing reports.""" + + @staticmethod + @ApiHelper.swagger_decorators(API, endpoint_description="Fetch report") + @API.expect(report_generation_schema) + @API.doc() + @API.response(code=200, description="Success - Excel file generated") + @auth.require + def post(): + """Fetch all reports.""" + schema = ReportGenerationSchema() + + report_data = schema.load(request.json or {}) + report_type = report_data.get("report_type") + data = ReportService.generate_report(report_data, report_type) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + file_name = f"{report_type}_{timestamp}.xlsx" + + return send_file(BytesIO(data), as_attachment=True, download_name=file_name) diff --git a/compliance-api/src/compliance_api/schemas/report.py b/compliance-api/src/compliance_api/schemas/report.py new file mode 100644 index 000000000..d4270ee37 --- /dev/null +++ b/compliance-api/src/compliance_api/schemas/report.py @@ -0,0 +1,46 @@ +"""Schema for report generation parameters.""" +from marshmallow import Schema, fields +from marshmallow_enum import EnumField +from compliance_api.models.report_enum import ReportTypeEnum + + +class ReportGenerationSchema(Schema): + """Schema to filter the case files for export and pagination.""" + + report_type = EnumField( + ReportTypeEnum, + metadata={"description": "Type of report to generate"}, + by_value=True, + required=True, + ) + + project_id = fields.Int( + required=False, + allow_none=True, + metadata={"description": "Project ID for report"}, + ) + + officer_ids = fields.List( + fields.Int(), + required=False, + allow_none=True, + metadata={"description": "List of officer IDs for report"}, + ) + + first_nation_id = fields.Int( + required=False, + allow_none=True, + metadata={"description": "First Nation Alliance ID for report"}, + ) + + start_date = fields.DateTime( + required=False, + allow_none=True, + metadata={"description": "Start date for report"}, + ) + + end_date = fields.DateTime( + required=False, + allow_none=True, + metadata={"description": "End date for report"}, + ) diff --git a/compliance-api/src/compliance_api/services/epic_track_service/track_service.py b/compliance-api/src/compliance_api/services/epic_track_service/track_service.py index d6bf5d6a7..4629135a4 100644 --- a/compliance-api/src/compliance_api/services/epic_track_service/track_service.py +++ b/compliance-api/src/compliance_api/services/epic_track_service/track_service.py @@ -132,6 +132,14 @@ def get_first_nation_by_id(first_nation_id: int): f"Unable to parse First Nation information for ID {first_nation_id}" ) + @staticmethod + def get_first_nations(): + """Return firstnations.""" + first_nation_response = _request_track_service("indigenous-nations") + if first_nation_response.status_code != 200: + raise BusinessError("Error finding first nations") + return first_nation_response.json() + @retry( retry=retry_if_exception_type(requests.exceptions.RequestException), diff --git a/compliance-api/src/compliance_api/services/inspection_requirement.py b/compliance-api/src/compliance_api/services/inspection_requirement.py index 13caef856..24c186506 100644 --- a/compliance-api/src/compliance_api/services/inspection_requirement.py +++ b/compliance-api/src/compliance_api/services/inspection_requirement.py @@ -1351,51 +1351,6 @@ def _apply_requirement_source_number_sort(query, subq, sort_order): ) -def _get_enforcement_number_by_type(result): - """Get the correct enforcement number based on the enforcement action type.""" - enforcement_action_id = result.enforcement_action_id - - # Map enforcement action ID to the corresponding number field attribute - enforcement_number_map = { - EnforcementActionOptionEnum.ORDER.value: "order_number", - EnforcementActionOptionEnum.WARNING_LETTER.value: "warning_letter_number", - EnforcementActionOptionEnum.VIOLATION_TICKET.value: "violation_ticket_number", - EnforcementActionOptionEnum.ADMINISTRATIVE_PENALTY_RECOMMENDATION.value: "admin_penalty_number", - EnforcementActionOptionEnum.CHARGE_RECOMMENDATION.value: "charge_rec_number", - EnforcementActionOptionEnum.RESTORATIVE_JUSTICE.value: "restorative_justice_number", - } - - field_name = enforcement_number_map.get(enforcement_action_id) - if field_name: - return getattr(result, field_name, "") or "" - return "" - - -def _get_enforcement_status_by_type(result): # pylint: disable=too-many-return-statements - """Get the correct enforcement status based on the enforcement action type.""" - enforcement_action_id = result.enforcement_action_id - - # Map enforcement action ID to the corresponding status field - if enforcement_action_id == EnforcementActionOptionEnum.ORDER.value: - return result.order_status - if enforcement_action_id == EnforcementActionOptionEnum.WARNING_LETTER.value: - return result.warning_letter_status - if enforcement_action_id == EnforcementActionOptionEnum.VIOLATION_TICKET.value: - return result.violation_ticket_status - if ( - enforcement_action_id - == EnforcementActionOptionEnum.ADMINISTRATIVE_PENALTY_RECOMMENDATION.value - ): - return result.admin_penalty_status - if ( - enforcement_action_id == EnforcementActionOptionEnum.CHARGE_RECOMMENDATION.value - ): - return result.charge_rec_status - if enforcement_action_id == EnforcementActionOptionEnum.RESTORATIVE_JUSTICE.value: - return result.restorative_justice_status - return None - - def _get_enforcement_progress_by_type(result): """Get the correct enforcement progress based on the enforcement action type.""" enforcement_action_id = result.enforcement_action_id @@ -1503,15 +1458,15 @@ def _process_inspection_requirement_query_results(query_results): item["inspection_status"] = result.inspection_status # Convert enforcement status to proper object format - raw_status = _get_enforcement_status_by_type(result) - item["status"] = _convert_enum_to_object(raw_status) if raw_status else None + raw_status = ServiceUtils.get_enforcement_status_by_type(result) + item["status"] = ServiceUtils.convert_enum_to_object(raw_status) if raw_status else None # Convert enforcement progress to proper object format raw_progress = _get_enforcement_progress_by_type(result) item["progress"] = ( - _convert_enum_to_object(raw_progress) if raw_progress else None + ServiceUtils.convert_enum_to_object(raw_progress) if raw_progress else None ) - item["enforcement_number"] = _get_enforcement_number_by_type(result) + item["enforcement_number"] = ServiceUtils.get_enforcement_number_by_type(result) # Add all requirement source names item["requirement_sources"] = getattr(result, "requirement_sources", None) @@ -1520,25 +1475,6 @@ def _process_inspection_requirement_query_results(query_results): return processed_requirements -def _convert_enum_to_object(enum_value): - """Convert enum value to proper object format for API response.""" - if not enum_value: - return None - - # If it's already an enum object, convert it to the expected format - if hasattr(enum_value, "name") and hasattr(enum_value, "value"): - return { - "id": enum_value.name, - "name": enum_value.value, - } - - # If it's just a string, return it as is (shouldn't happen with our current logic) - return { - "id": str(enum_value), - "name": str(enum_value), - } - - def _make_requirement_detail_object(requirements: list): """Make requirement detail object.""" diff --git a/compliance-api/src/compliance_api/services/report/base.py b/compliance-api/src/compliance_api/services/report/base.py new file mode 100644 index 000000000..bb41dfa4b --- /dev/null +++ b/compliance-api/src/compliance_api/services/report/base.py @@ -0,0 +1,16 @@ +"""Report Generator Base Class.""" +from abc import ABC, abstractmethod + + +class BaseReportGenerator(ABC): + """Base class for report generators.""" + + @abstractmethod + def __init__(self, report_data): + """Initialize the report generator with the provided report data.""" + self.report_data = report_data + + @abstractmethod + def generate(self): + """Generate the report.""" + pass diff --git a/compliance-api/src/compliance_api/services/report/case_file_management.py b/compliance-api/src/compliance_api/services/report/case_file_management.py new file mode 100644 index 000000000..573d89a73 --- /dev/null +++ b/compliance-api/src/compliance_api/services/report/case_file_management.py @@ -0,0 +1,629 @@ +"""Case File Management Report Generator Service.""" +from io import BytesIO +from zoneinfo import ZoneInfo + +import pandas as pd +from flask import current_app +from sqlalchemy import and_ +from sqlalchemy.orm import selectinload + +from compliance_api.models import db +from compliance_api.models.administrative_penalty import AdministrativePenalty +from compliance_api.models.agency import Agency +from compliance_api.models.case_file import CaseFile, CaseFileInitiationOption +from compliance_api.models.charge_recommendation import ChargeRecommendation +from compliance_api.models.complaint.complaint import Complaint +from compliance_api.models.complaint.complaint_option import ComplaintSource, ComplaintSourceEnum +from compliance_api.models.complaint.complaint_resolution import ComplaintResolution +from compliance_api.models.complaint.complaint_source_contact import ComplaintSourceContact +from compliance_api.models.compliance_finding import ComplianceFindingOption +from compliance_api.models.enforcement_action import EnforcementActionOption +from compliance_api.models.inspection.inspection import Inspection +from compliance_api.models.inspection.inspection_req_enforcement_map import InspectionReqEnforcementMap +from compliance_api.models.inspection.inspection_requirement import InspectionRequirement +from compliance_api.models.inspection_record import InspectionRecord +from compliance_api.models.order import Order +from compliance_api.models.project import Project +from compliance_api.models.restorative_justice import RestorativeJustice +from compliance_api.models.staff_user import StaffUser +from compliance_api.models.topic import Topic +from compliance_api.models.unapproved_project import UnapprovedProject +from compliance_api.models.violation_ticket import ViolationTicket +from compliance_api.models.warning_letter import WarningLetter +from compliance_api.services.epic_track_service.track_service import TrackService +from compliance_api.services.report.shared_queries import ( + get_requirement_admin_penalty_sub_query, get_requirement_charge_rec_sub_query, get_requirement_order_sub_query, + get_requirement_restorative_justice_sub_query, get_requirement_violation_ticket_sub_query, + get_requirement_warning_letter_sub_query) +from compliance_api.services.service_utils import ServiceUtils + +from .base import BaseReportGenerator + + +class CaseFileManagementReportGenerator(BaseReportGenerator): + """Case File Management Report Generator Service.""" + + def __init__(self, report_data): + """Initialize the Case File Management Report Generator with the provided report data.""" + super().__init__(report_data) + + self.officer_ids = report_data.get("officer_ids") + current_app.logger.info( + f"Case File Management Report Generator initialized with officers: {self.officer_ids}" + ) + self.project_map = {} + + def generate(self): + """CEB Summary Report Generation Logic.""" + first_nations = TrackService.get_first_nations() + + # Inspections Tab + data = self._build_case_file_tab_query().all() + data = self._format_case_file_tab_data(data) + case_file_data_frame = pd.json_normalize(data) + case_file_headers, case_file_columns = self._get_case_file_tab_columns_and_headers() + + data = self._build_inspections_tab_query().all() + data = self._format_inspections_tab_data(data) + inspections_data_frame = pd.json_normalize(data) + inspections_headers, inspections_columns = self._get_inspections_tab_columns_and_headers() + + data = self._build_complaints_tab_query().all() + data = self._format_complaints_tab_data(data, first_nations) + complaints_data_frame = pd.json_normalize(data) + complaints_headers, complaints_columns = self._get_complaints_tab_columns_and_headers() + + output = self._to_excel( + case_file_data_frame, + case_file_columns, + case_file_headers, + inspections_data_frame, + inspections_columns, + inspections_headers, + complaints_data_frame, + complaints_columns, + complaints_headers, + ) + return output + + def _build_case_file_tab_query(self): + query = ( + db.session.query( + CaseFile.case_file_number.label("case_file_number"), + StaffUser, + Project.id.label("project_id"), + UnapprovedProject.id.label("unapproved_project_id"), + UnapprovedProject.name.label("unapproved_project_name"), + UnapprovedProject.type.label("unapproved_project_type"), + CaseFileInitiationOption.name.label("case_file_initiation_option"), + CaseFile.date_created.label("date_created"), + CaseFile.case_file_status.label("status"), + CaseFile.date_created.label("case_file_date_created") + ) + .outerjoin(StaffUser, CaseFile.primary_officer_id == StaffUser.id) + .outerjoin(Project, CaseFile.project_id == Project.id) + .outerjoin(UnapprovedProject, CaseFile.id == UnapprovedProject.case_file_id) + .outerjoin(CaseFileInitiationOption, CaseFile.initiation_id == CaseFileInitiationOption.id) + .filter( + CaseFile.is_active.is_(True), + CaseFile.is_deleted.is_(False), + CaseFile.primary_officer_id.in_(self.officer_ids) if self.officer_ids else True, + ) + ) + return query + + def _build_inspections_tab_query(self): + requirement_order_subquery = get_requirement_order_sub_query() + requirement_warning_letter_subquery = get_requirement_warning_letter_sub_query() + requirement_violation_ticket_subquery = get_requirement_violation_ticket_sub_query() + requirement_admin_penalty_subquery = get_requirement_admin_penalty_sub_query() + requirement_charge_rec_subquery = get_requirement_charge_rec_sub_query() + requirement_restorative_justice_subquery = get_requirement_restorative_justice_sub_query() + + query = ( + db.session.query( + InspectionRequirement, + InspectionRequirement.summary.label("summary"), + Inspection.ir_number.label("ir_number"), + Topic.name.label("topic_name"), + InspectionRecord.ir_progress.label("ir_progress"), + Project.id.label("project_id"), + UnapprovedProject.id.label("unapproved_project_id"), + UnapprovedProject.name.label("unapproved_project_name"), + UnapprovedProject.type.label("unapproved_project_type"), + ComplianceFindingOption.name.label("compliance_finding"), + InspectionReqEnforcementMap.enforcement_action_id.label("enforcement_action_id"), + EnforcementActionOption.name.label("enforcement_action"), + # Enforcement statuses + Order.order_status.label("order_status"), + WarningLetter.status.label("warning_letter_status"), + ViolationTicket.status.label("violation_ticket_status"), + AdministrativePenalty.referral_status.label("admin_penalty_status"), + ChargeRecommendation.status.label("charge_rec_status"), + RestorativeJustice.status.label("restorative_justice_status"), + # Enforcement numbers + Order.order_number.label("order_number"), + WarningLetter.warning_letter_number.label("warning_letter_number"), + ViolationTicket.ticket_number.label("violation_ticket_number"), + AdministrativePenalty.administrative_penalty_number.label("admin_penalty_number"), + ChargeRecommendation.charge_recommendation_number.label("charge_rec_number"), + RestorativeJustice.restorative_justice_number.label("restorative_justice_number"), + InspectionRecord.date_issued.label("ir_date_issued"), + StaffUser, + Inspection.inspection_status.label("inspection_status"), + CaseFile.case_file_number.label("case_file_number"), + CaseFile.date_created.label("case_file_date_created"), + ) + .join(Inspection, InspectionRequirement.inspection_id == Inspection.id) + .join(Topic, InspectionRequirement.topic_id == Topic.id) + .join(CaseFile, Inspection.case_file_id == CaseFile.id) + .outerjoin(Project, Inspection.project_id == Project.id) + .outerjoin(UnapprovedProject, Inspection.case_file_id == UnapprovedProject.case_file_id) + .join(ComplianceFindingOption, InspectionRequirement.compliance_finding_id == ComplianceFindingOption.id) + .join(InspectionReqEnforcementMap, and_( + InspectionReqEnforcementMap.requirement_id == InspectionRequirement.id, + InspectionReqEnforcementMap.is_active.is_(True), + InspectionReqEnforcementMap.is_deleted.is_(False) + )) + .join(InspectionRecord, InspectionRecord.inspection_id == Inspection.id) + .join( + EnforcementActionOption, + InspectionReqEnforcementMap.enforcement_action_id == EnforcementActionOption.id + ) + .join(StaffUser, Inspection.primary_officer_id == StaffUser.id) + .outerjoin( + requirement_order_subquery, + requirement_order_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + Order, + Order.id == requirement_order_subquery.c.order_id + ) + .outerjoin( + requirement_warning_letter_subquery, + requirement_warning_letter_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + WarningLetter, + WarningLetter.id == requirement_warning_letter_subquery.c.warning_letter_id + ) + .outerjoin( + requirement_violation_ticket_subquery, + requirement_violation_ticket_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + ViolationTicket, + ViolationTicket.id == requirement_violation_ticket_subquery.c.violation_ticket_id + ) + .outerjoin( + requirement_admin_penalty_subquery, + requirement_admin_penalty_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + AdministrativePenalty, + AdministrativePenalty.id == requirement_admin_penalty_subquery.c.administrative_penalty_id + ) + .outerjoin( + requirement_charge_rec_subquery, + requirement_charge_rec_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + ChargeRecommendation, + ChargeRecommendation.id == requirement_charge_rec_subquery.c.charge_recommendation_id + ) + .outerjoin( + requirement_restorative_justice_subquery, + requirement_restorative_justice_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + RestorativeJustice, + RestorativeJustice.id == requirement_restorative_justice_subquery.c.restorative_justice_id + ) + .filter( + InspectionRequirement.is_active.is_(True), + InspectionRequirement.is_deleted.is_(False), + Inspection.is_active.is_(True), + Inspection.is_deleted.is_(False), + InspectionRecord.is_active.is_(True), + InspectionRecord.is_deleted.is_(False), + CaseFile.is_active.is_(True), + CaseFile.is_deleted.is_(False), + Inspection.primary_officer_id.in_(self.officer_ids) if self.officer_ids else True, + ) + .order_by(InspectionRequirement.id, EnforcementActionOption.id) + .options( + selectinload(InspectionRequirement.requirement_source_details) + ) + ) + return query + + def _build_complaints_tab_query(self): + query = ( + db.session.query( + Complaint.complaint_number.label("complaint_number"), + Project.id.label("project_id"), + UnapprovedProject.id.label("unapproved_project_id"), + UnapprovedProject.name.label("unapproved_project_name"), + UnapprovedProject.type.label("unapproved_project_type"), + Topic.name.label("topic"), + Complaint.date_received.label("date_received"), + ComplaintSource.id.label("complaint_source_id"), + ComplaintSource.name.label("complaint_source"), + ComplaintSourceContact, + Agency.name.label("source_agency"), + Complaint.source_first_nation_id.label("source_first_nation_id"), + StaffUser, + Complaint.status.label("complaint_status"), + ComplaintResolution.name.label("complaint_resolution"), + CaseFile.case_file_number.label("case_file_number"), + CaseFile.date_created.label("case_file_date_created"), + ) + .join(CaseFile, Complaint.case_file_id == CaseFile.id) + .outerjoin(Topic, Complaint.topic_id == Topic.id) + .outerjoin(Project, CaseFile.project_id == Project.id) + .outerjoin(UnapprovedProject, CaseFile.id == UnapprovedProject.case_file_id) + .outerjoin(StaffUser, Complaint.primary_officer_id == StaffUser.id) + .outerjoin(ComplaintResolution, Complaint.resolution_id == ComplaintResolution.id) + .outerjoin(ComplaintSourceContact, ComplaintSourceContact.complaint_id == Complaint.id) + .outerjoin(Agency, Complaint.source_agency_id == Agency.id) + .join(ComplaintSource, Complaint.source_type_id == ComplaintSource.id) + .filter( + Complaint.is_active.is_(True), + Complaint.is_deleted.is_(False), + CaseFile.is_active.is_(True), + CaseFile.is_deleted.is_(False), + Complaint.primary_officer_id.in_(self.officer_ids) if self.officer_ids else True, + ) + .distinct(Complaint.id) + ) + return query + + @staticmethod + def _to_excel( + case_file_data_frame, + case_file_columns, + case_file_headers, + inspections_data_frame, + inspections_columns, + inspections_headers, + complaints_data_frame, + complaints_columns, + complaints_headers, + ): + output = BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: + + # If there is no data, create an empty dataframe with columns so that + # the excel file will still have the correct headers and structure + if case_file_data_frame.empty: + case_file_data_frame = pd.DataFrame(columns=case_file_columns) + if inspections_data_frame.empty: + inspections_data_frame = pd.DataFrame(columns=inspections_columns) + if complaints_data_frame.empty: + complaints_data_frame = pd.DataFrame(columns=complaints_columns) + + # Case Files Tab + case_file_data_frame.to_excel( + writer, + sheet_name="Case Files", + columns=case_file_columns, + header=case_file_headers, + index=False, + ) + worksheet = writer.sheets["Case Files"] + # Making the columns wider + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + adjusted_width = min(max_length, 30) + worksheet.column_dimensions[column_letter].width = adjusted_width + + # Inspections Tab + inspections_data_frame.to_excel( + writer, + sheet_name="Inspections", + columns=inspections_columns, + header=inspections_headers, + index=False, + ) + worksheet = writer.sheets["Inspections"] + # Making the columns wider + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + adjusted_width = min(max_length, 30) + worksheet.column_dimensions[column_letter].width = adjusted_width + + # Complaints Tab + complaints_data_frame.to_excel( + writer, + sheet_name="Complaints", + columns=complaints_columns, + header=complaints_headers, + index=False, + ) + worksheet = writer.sheets["Complaints"] + # Making the columns wider + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + adjusted_width = min(max_length, 30) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + return output.getvalue() + + def _format_case_file_tab_data(self, data): + result = [] + + if not data: + return result + + for row in data: + primary_officer = row.StaffUser + + # Project Logic: + # If case_file.project_id is null it is unapproved + # and you should be able to find it in the unapproved_projects table + # If case_file.project_id has a valid number it should be from EPIC.Track + + if not row.project_id: + project_name = row.unapproved_project_name + project_type = row.unapproved_project_type + else: + if row.project_id in self.project_map: + project = self.project_map[row.project_id] + else: + project = TrackService.get_project_by_id(row.project_id, as_of_date=row.case_file_date_created) + self.project_map[row.project_id] = project + project_name = project.get("name") if project else None + project_type = project.get("type", None).get("name", None) if project else None + + item = { + "case_file_number": row.case_file_number, + "primary_officer": f"{primary_officer.first_name} {primary_officer.last_name}", + "project_name": project_name, + "project_type": project_type, + "case_file_initiation_option": row.case_file_initiation_option, + "date_created": row.date_created.astimezone(ZoneInfo("America/Los_Angeles")) + .strftime("%Y-%m-%d") if row.date_created else None, + "status": row.status.value if row.status else None, + } + result.append(item) + return result + + @staticmethod + def _get_case_file_tab_columns_and_headers(): + headers = [ + "Case File Number", + "Primary Officer", + "Project Name", + "Project Type", + "Case File Initiation Option", + "Date Created", + "Status", + ] + + columns = [ + "case_file_number", + "primary_officer", + "project_name", + "project_type", + "case_file_initiation_option", + "date_created", + "status", + ] + + return headers, columns + + def _format_inspections_tab_data(self, data): + """Format data for excel export.""" + result = [] + + if not data: + return result + + for row in data: + inspection_requirement = row.InspectionRequirement + raw_enforcement_status = ServiceUtils.get_enforcement_status_by_type(row) + primary_officer = row.StaffUser + req_source_details = inspection_requirement.requirement_source_details + + # Project Logic: + # If case_file.project_id is null it is unapproved + # and you should be able to find it in the unapproved_projects table + # If case_file.project_id has a valid number it should be from EPIC.Track + + if not row.project_id: + project_name = row.unapproved_project_name + project_type = row.unapproved_project_type + else: + if row.project_id in self.project_map: + project = self.project_map[row.project_id] + else: + project = TrackService.get_project_by_id(row.project_id, as_of_date=row.case_file_date_created) + self.project_map[row.project_id] = project + project_name = project.get("name") if project else None + project_type = project.get("type", None).get("name", None) if project else None + + condition_num_string = "" + source_string = "" + for req_source in req_source_details: + number_field = ServiceUtils.get_requirement_grid_source_number_field(req_source) + name_field = ServiceUtils.get_requirement_grid_source_name_field(req_source) + condition_num_string += f", {number_field}" if condition_num_string else number_field or "" + source_string += f", {name_field}" if source_string else name_field + + item = { + "ir_number": row.ir_number, + "topic_name": row.topic_name, + "summary": row.summary, + "ir_progress": row.ir_progress.value if row.ir_progress else None, + "project_name": project_name, + "project_type": project_type, + "compliance_finding": row.compliance_finding, + "enforcement_action": row.enforcement_action, + "enforcement_status": raw_enforcement_status.value if raw_enforcement_status else None, + "enforcement_document_number": ServiceUtils.get_enforcement_number_by_type(row), + "condition_number": condition_num_string, + "requirement_source": source_string, + "ir_issuance_date": row.ir_date_issued.astimezone(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d") + if row.ir_date_issued else None, + "primary_officer": f"{primary_officer.first_name} {primary_officer.last_name}" + if primary_officer else None, + "inspection_status": row.inspection_status.value if row.inspection_status else None, + "case_file_number": row.case_file_number, + } + result.append(item) + return result + + @staticmethod + def _get_inspections_tab_columns_and_headers(): + """Get existing columns and their headers for Excel export.""" + headers = [ + "IR Number", + "Topic", + "Summary", + "IR Progress", + "Project Name", + "Project Type", + "Compliance Finding", + "Enforcement Action", + "Enforcement Status", + "Enforcement Document Number", + "Condition Number", + "Requirement Source", + "IR Issuance Date", + "Primary Officer", + "Inspection Status", + "Case File Number", + ] + + columns = [ + "ir_number", + "topic_name", + "summary", + "ir_progress", + "project_name", + "project_type", + "compliance_finding", + "enforcement_action", + "enforcement_status", + "enforcement_document_number", + "condition_number", + "requirement_source", + "ir_issuance_date", + "primary_officer", + "inspection_status", + "case_file_number", + ] + + return headers, columns + + def _format_complaints_tab_data(self, data, first_nations): + """Format complaints data for excel export.""" + result = [] + + if not data: + return result + + for row in data: + primary_officer = row.StaffUser + + # Project Logic: + # If case_file.project_id is null it is unapproved + # and you should be able to find it in the unapproved_projects table + # If case_file.project_id has a valid number it should be from EPIC.Track + + if not row.project_id: + project_name = row.unapproved_project_name + project_type = row.unapproved_project_type + else: + if row.project_id in self.project_map: + project = self.project_map[row.project_id] + else: + project = TrackService.get_project_by_id(row.project_id, as_of_date=row.case_file_date_created) + self.project_map[row.project_id] = project + project_name = project.get("name") if project else None + project_type = project.get("type", None).get("name", None) if project else None + + # Complaint Source Details Logic + # If Complaint Source = Public, pull field “Full Name” + # If Complaint Source = First Nation, pull field “First Nation” + # If Complaint Source = First Nations Alliance, pull field “Alliance Name” + # If Complaint Source = Agency, pull field “Agency” + # If Complaint Source = Other, pull field “Description” + + complaint_source_contact = row.ComplaintSourceContact + complaint_source_details = "" + + if row.complaint_source == ComplaintSourceEnum.PUBLIC.value: + complaint_source_details = complaint_source_contact.full_name if complaint_source_contact else "" + elif row.complaint_source == ComplaintSourceEnum.FIRST_NATION.value: + first_nation = next((fn for fn in first_nations if fn.get('id') == row.source_first_nation_id), None) + complaint_source_details = first_nation.get("name") if first_nation else "" + elif row.complaint_source == ComplaintSourceEnum.FIRST_NATIONS_ALLIANCE.value: + complaint_source_details = complaint_source_contact.alliance_name if complaint_source_contact else "" + elif row.complaint_source == ComplaintSourceEnum.AGENCY.value: + complaint_source_details = row.source_agency if row.source_agency else "" + elif row.complaint_source == ComplaintSourceEnum.OTHER.value: + complaint_source_details = complaint_source_contact.description if complaint_source_contact else "" + + item = { + "complaint_number": row.complaint_number, + "project_name": project_name, + "project_type": project_type, + "topic": row.topic, + "date_received": row.date_received.astimezone(ZoneInfo("America/Los_Angeles")) + .strftime("%Y-%m-%d") if row.date_received else None, + "complaint_source": row.complaint_source, + "complaint_source_details": complaint_source_details, + "primary_officer": f"{primary_officer.first_name} {primary_officer.last_name}" + if primary_officer else None, + "complaint_status": row.complaint_status.value if row.complaint_status else None, + "complaint_resolution": row.complaint_resolution, + "case_file_number": row.case_file_number, + } + result.append(item) + return result + + @staticmethod + def _get_complaints_tab_columns_and_headers(): + """Get existing columns and their headers for Excel export.""" + headers = [ + "Complaint Number", + "Project Name", + "Project Type", + "Topic", + "Date Received", + "Complaint Source", + "Complaint Source Details", + "Primary Officer", + "Complaint Status", + "Complaint Resolution", + "Case File Number", + ] + columns = [ + "complaint_number", + "project_name", + "project_type", + "topic", + "date_received", + "complaint_source", + "complaint_source_details", + "primary_officer", + "complaint_status", + "complaint_resolution", + "case_file_number", + ] + + return headers, columns diff --git a/compliance-api/src/compliance_api/services/report/ceb_summary.py b/compliance-api/src/compliance_api/services/report/ceb_summary.py new file mode 100644 index 000000000..3e2fdbbd1 --- /dev/null +++ b/compliance-api/src/compliance_api/services/report/ceb_summary.py @@ -0,0 +1,515 @@ +"""CEB Summary Report Generator Service.""" +from datetime import datetime, time +from io import BytesIO +from zoneinfo import ZoneInfo + +import pandas as pd +from flask import current_app +from sqlalchemy import and_ +from sqlalchemy.orm import selectinload + +from compliance_api.models import db +from compliance_api.models.administrative_penalty import AdministrativePenalty +from compliance_api.models.agency import Agency +from compliance_api.models.case_file import CaseFile +from compliance_api.models.charge_recommendation import ChargeRecommendation +from compliance_api.models.complaint.complaint import Complaint +from compliance_api.models.complaint.complaint_option import ComplaintSource, ComplaintSourceEnum +from compliance_api.models.complaint.complaint_resolution import ComplaintResolution +from compliance_api.models.complaint.complaint_source_contact import ComplaintSourceContact +from compliance_api.models.compliance_finding import ComplianceFindingOption +from compliance_api.models.enforcement_action import EnforcementActionOption +from compliance_api.models.inspection.inspection import Inspection +from compliance_api.models.inspection.inspection_req_enforcement_map import InspectionReqEnforcementMap +from compliance_api.models.inspection.inspection_requirement import InspectionRequirement +from compliance_api.models.inspection_record import InspectionRecord +from compliance_api.models.order import Order +from compliance_api.models.project import Project +from compliance_api.models.restorative_justice import RestorativeJustice +from compliance_api.models.staff_user import StaffUser +from compliance_api.models.topic import Topic +from compliance_api.models.unapproved_project import UnapprovedProject +from compliance_api.models.violation_ticket import ViolationTicket +from compliance_api.models.warning_letter import WarningLetter +from compliance_api.services.epic_track_service.track_service import TrackService +from compliance_api.services.report.shared_queries import ( + get_requirement_admin_penalty_sub_query, get_requirement_charge_rec_sub_query, get_requirement_order_sub_query, + get_requirement_restorative_justice_sub_query, get_requirement_violation_ticket_sub_query, + get_requirement_warning_letter_sub_query) +from compliance_api.services.service_utils import ServiceUtils + +from .base import BaseReportGenerator + + +class CEBSummaryReportGenerator(BaseReportGenerator): + """CEB Summary Report Generator Service.""" + + def __init__(self, report_data): + """Initialize the CEB Summary Report Generator with the provided report data.""" + super().__init__(report_data) + + start_date_raw = report_data.get("start_date") + end_date_raw = report_data.get("end_date") + + self.start_date = ( + datetime.combine( + start_date_raw, time.min + ) if start_date_raw else None + ) + self.end_date = ( + datetime.combine( + end_date_raw, time.max + ) if end_date_raw else None + ) + current_app.logger.info( + f"CEB Summary Report Generator initialized with start_date: {self.start_date}, end_date: {self.end_date}" + ) + self.project_map = {} + + def generate(self): + """CEB Summary Report Generation Logic.""" + first_nations = TrackService.get_first_nations() + + # Inspections Tab + data = self._build_inspections_tab_query().all() + data = self._format_inspections_tab_data(data) + inspections_data_frame = pd.json_normalize(data) + inspections_headers, inspections_columns = self._get_inspections_tab_columns_and_headers() + + # Complaints Tab + data = self._build_complaints_tab_query().all() + data = self._format_complaints_tab_data(data, first_nations) + complaints_data_frame = pd.json_normalize(data) + complaints_headers, complaints_columns = self._get_complaints_tab_columns_and_headers() + + output = self._to_excel( + inspections_data_frame, + inspections_columns, + inspections_headers, + complaints_data_frame, + complaints_columns, + complaints_headers) + return output + + def _to_excel( + self, + inspections_data_frame, + inspections_columns, + inspections_headers, + complaints_data_frame, + complaints_columns, + complaints_headers + ): + output = BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: + + # If there is no data, create an empty dataframe with columns so that + # the excel file will still have the correct headers and structure + if inspections_data_frame.empty: + inspections_data_frame = pd.DataFrame(columns=inspections_columns) + if complaints_data_frame.empty: + complaints_data_frame = pd.DataFrame(columns=complaints_columns) + + # Inspections Tab + inspections_data_frame.to_excel( + writer, + sheet_name="Inspections", + columns=inspections_columns, + header=inspections_headers, + index=False, + ) + worksheet = writer.sheets["Inspections"] + # Making the columns wider + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + + for cell in column: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + + adjusted_width = min(max_length, 30) + worksheet.column_dimensions[column_letter].width = adjusted_width + + # Complaints Tab + complaints_data_frame.to_excel( + writer, + sheet_name="Complaints", + columns=complaints_columns, + header=complaints_headers, + index=False, + ) + complaints_worksheet = writer.sheets["Complaints"] + # Making the columns wider + for column in complaints_worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + + for cell in column: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + + adjusted_width = min(max_length, 30) + complaints_worksheet.column_dimensions[column_letter].width = adjusted_width + output.seek(0) + return output.getvalue() + + def _build_inspections_tab_query(self): + """Build base query for CEB Summary Report.""" + + requirement_order_subquery = get_requirement_order_sub_query() + requirement_warning_letter_subquery = get_requirement_warning_letter_sub_query() + requirement_violation_ticket_subquery = get_requirement_violation_ticket_sub_query() + requirement_admin_penalty_subquery = get_requirement_admin_penalty_sub_query() + requirement_charge_rec_subquery = get_requirement_charge_rec_sub_query() + requirement_restorative_justice_subquery = get_requirement_restorative_justice_sub_query() + + query = ( + db.session.query( + InspectionRequirement, + InspectionRequirement.summary.label("summary"), + Inspection.ir_number.label("ir_number"), + Topic.name.label("topic_name"), + InspectionRecord.ir_progress.label("ir_progress"), + Project.id.label("project_id"), + UnapprovedProject.id.label("unapproved_project_id"), + UnapprovedProject.name.label("unapproved_project_name"), + UnapprovedProject.type.label("unapproved_project_type"), + ComplianceFindingOption.name.label("compliance_finding"), + InspectionReqEnforcementMap.enforcement_action_id.label("enforcement_action_id"), + EnforcementActionOption.name.label("enforcement_action"), + # Enforcement statuses + Order.order_status.label("order_status"), + WarningLetter.status.label("warning_letter_status"), + ViolationTicket.status.label("violation_ticket_status"), + AdministrativePenalty.referral_status.label("admin_penalty_status"), + ChargeRecommendation.status.label("charge_rec_status"), + RestorativeJustice.status.label("restorative_justice_status"), + # Enforcement numbers + Order.order_number.label("order_number"), + WarningLetter.warning_letter_number.label("warning_letter_number"), + ViolationTicket.ticket_number.label("violation_ticket_number"), + AdministrativePenalty.administrative_penalty_number.label("admin_penalty_number"), + ChargeRecommendation.charge_recommendation_number.label("charge_rec_number"), + RestorativeJustice.restorative_justice_number.label("restorative_justice_number"), + InspectionRecord.date_issued.label("ir_date_issued"), + StaffUser, + Inspection.inspection_status.label("inspection_status"), + CaseFile.date_created.label("case_file_date_created"), + ) + .join(Inspection, InspectionRequirement.inspection_id == Inspection.id) + .join(CaseFile, Inspection.case_file_id == CaseFile.id) + .join(Topic, InspectionRequirement.topic_id == Topic.id) + .outerjoin(Project, Inspection.project_id == Project.id) + .outerjoin(UnapprovedProject, Inspection.case_file_id == UnapprovedProject.case_file_id) + .join(ComplianceFindingOption, InspectionRequirement.compliance_finding_id == ComplianceFindingOption.id) + .join(InspectionReqEnforcementMap, and_( + InspectionReqEnforcementMap.requirement_id == InspectionRequirement.id, + InspectionReqEnforcementMap.is_active.is_(True), + InspectionReqEnforcementMap.is_deleted.is_(False) + )) + .join(InspectionRecord, InspectionRecord.inspection_id == Inspection.id) + .join( + EnforcementActionOption, + InspectionReqEnforcementMap.enforcement_action_id == EnforcementActionOption.id + ) + .join(StaffUser, Inspection.primary_officer_id == StaffUser.id) + .outerjoin( + requirement_order_subquery, + requirement_order_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + Order, + Order.id == requirement_order_subquery.c.order_id + ) + .outerjoin( + requirement_warning_letter_subquery, + requirement_warning_letter_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + WarningLetter, + WarningLetter.id == requirement_warning_letter_subquery.c.warning_letter_id + ) + .outerjoin( + requirement_violation_ticket_subquery, + requirement_violation_ticket_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + ViolationTicket, + ViolationTicket.id == requirement_violation_ticket_subquery.c.violation_ticket_id + ) + .outerjoin( + requirement_admin_penalty_subquery, + requirement_admin_penalty_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + AdministrativePenalty, + AdministrativePenalty.id == requirement_admin_penalty_subquery.c.administrative_penalty_id + ) + .outerjoin( + requirement_charge_rec_subquery, + requirement_charge_rec_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + ChargeRecommendation, + ChargeRecommendation.id == requirement_charge_rec_subquery.c.charge_recommendation_id + ) + .outerjoin( + requirement_restorative_justice_subquery, + requirement_restorative_justice_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + RestorativeJustice, + RestorativeJustice.id == requirement_restorative_justice_subquery.c.restorative_justice_id + ) + .filter( + InspectionRequirement.is_active.is_(True), + InspectionRequirement.is_deleted.is_(False), + Inspection.is_active.is_(True), + Inspection.is_deleted.is_(False), + InspectionRecord.is_active.is_(True), + InspectionRecord.is_deleted.is_(False), + Inspection.start_date >= self.start_date if self.start_date else True, + Inspection.start_date <= self.end_date if self.end_date else True, + ) + .order_by(InspectionRequirement.id, EnforcementActionOption.id) + .options( + selectinload(InspectionRequirement.requirement_source_details) + ) + ) + return query + + def _build_complaints_tab_query(self): + query = ( + db.session.query( + Complaint.complaint_number.label("complaint_number"), + Project.id.label("project_id"), + UnapprovedProject.id.label("unapproved_project_id"), + UnapprovedProject.name.label("unapproved_project_name"), + UnapprovedProject.type.label("unapproved_project_type"), + Topic.name.label("topic"), + Complaint.date_received.label("date_received"), + ComplaintSource.id.label("complaint_source_id"), + ComplaintSource.name.label("complaint_source"), + ComplaintSourceContact, + Agency.name.label("source_agency"), + Complaint.source_first_nation_id.label("source_first_nation_id"), + StaffUser, + Complaint.status.label("complaint_status"), + ComplaintResolution.name.label("complaint_resolution"), + CaseFile.case_file_number.label("case_file_number"), + CaseFile.date_created.label("case_file_date_created"), + ) + .join(CaseFile, Complaint.case_file_id == CaseFile.id) + .outerjoin(Topic, Complaint.topic_id == Topic.id) + .outerjoin(Project, CaseFile.project_id == Project.id) + .outerjoin(UnapprovedProject, CaseFile.id == UnapprovedProject.case_file_id) + .outerjoin(StaffUser, Complaint.primary_officer_id == StaffUser.id) + .outerjoin(ComplaintResolution, Complaint.resolution_id == ComplaintResolution.id) + .outerjoin(ComplaintSourceContact, ComplaintSourceContact.complaint_id == Complaint.id) + .outerjoin(Agency, Complaint.source_agency_id == Agency.id) + .join(ComplaintSource, Complaint.source_type_id == ComplaintSource.id) + .filter( + Complaint.is_active.is_(True), + Complaint.is_deleted.is_(False), + CaseFile.is_active.is_(True), + CaseFile.is_deleted.is_(False), + Complaint.date_received >= self.start_date if self.start_date else True, + Complaint.date_received <= self.end_date if self.end_date else True, + ) + .distinct(Complaint.id) + ) + return query + + def _format_inspections_tab_data(self, data): + """Format data for excel export.""" + result = [] + for row in data: + inspection_requirement = row.InspectionRequirement + raw_enforcement_status = ServiceUtils.get_enforcement_status_by_type(row) + primary_officer = row.StaffUser + req_source_details = inspection_requirement.requirement_source_details + + # Project Logic: + # If case_file.project_id is null it is unapproved + # and you should be able to find it in the unapproved_projects table + # If case_file.project_id has a valid number it should be from EPIC.Track + + if not row.project_id: + project_name = row.unapproved_project_name + project_type = row.unapproved_project_type + else: + if row.project_id in self.project_map: + project = self.project_map[row.project_id] + else: + project = TrackService.get_project_by_id(row.project_id, as_of_date=row.case_file_date_created) + self.project_map[row.project_id] = project + project_name = project.get("name") if project else None + project_type = project.get("type", None).get("name", None) if project else None + + condition_num_string = "" + source_string = "" + for req_source in req_source_details: + number_field = ServiceUtils.get_requirement_grid_source_number_field(req_source) + name_field = ServiceUtils.get_requirement_grid_source_name_field(req_source) + condition_num_string += f", {number_field}" if condition_num_string else number_field or "" + source_string += f", {name_field}" if source_string else name_field + + item = { + "ir_number": row.ir_number, + "topic_name": row.topic_name, + "summary": row.summary, + "ir_progress": row.ir_progress.value if row.ir_progress else None, + "project_name": project_name, + "project_type": project_type, + "compliance_finding": row.compliance_finding, + "enforcement_action": row.enforcement_action, + "enforcement_status": raw_enforcement_status.value if raw_enforcement_status else None, + "enforcement_document_number": ServiceUtils.get_enforcement_number_by_type(row), + "condition_number": condition_num_string, + "requirement_source": source_string, + "ir_issuance_date": row.ir_date_issued.astimezone(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d") + if row.ir_date_issued else None, + "primary_officer": f"{primary_officer.first_name} {primary_officer.last_name}" + if primary_officer else None, + "inspection_status": row.inspection_status.value if row.inspection_status else None, + } + result.append(item) + return result + + def _format_complaints_tab_data(self, data, first_nations): + """Format complaints data for excel export.""" + result = [] + for row in data: + primary_officer = row.StaffUser + + # Project Logic: + # If case_file.project_id is null it is unapproved + # and you should be able to find it in the unapproved_projects table + # If case_file.project_id has a valid number it should be from EPIC.Track + + if not row.project_id: + project_name = row.unapproved_project_name + project_type = row.unapproved_project_type + else: + if row.project_id in self.project_map: + project = self.project_map[row.project_id] + else: + project = TrackService.get_project_by_id(row.project_id, as_of_date=row.case_file_date_created) + self.project_map[row.project_id] = project + project_name = project.get("name") if project else None + project_type = project.get("type", None).get("name", None) if project else None + + # Complaint Source Details Logic + # If Complaint Source = Public, pull field “Full Name” + # If Complaint Source = First Nation, pull field “First Nation” + # If Complaint Source = First Nations Alliance, pull field “Alliance Name” + # If Complaint Source = Agency, pull field “Agency” + # If Complaint Source = Other, pull field “Description” + + complaint_source_contact = row.ComplaintSourceContact + complaint_source_details = "" + + if row.complaint_source == ComplaintSourceEnum.PUBLIC.value: + complaint_source_details = complaint_source_contact.full_name if complaint_source_contact else "" + elif row.complaint_source == ComplaintSourceEnum.FIRST_NATION.value: + first_nation = next((fn for fn in first_nations if fn.get('id') == row.source_first_nation_id), None) + complaint_source_details = first_nation.get("name") if first_nation else "" + elif row.complaint_source == ComplaintSourceEnum.FIRST_NATIONS_ALLIANCE.value: + complaint_source_details = complaint_source_contact.alliance_name if complaint_source_contact else "" + elif row.complaint_source == ComplaintSourceEnum.AGENCY.value: + complaint_source_details = row.source_agency if row.source_agency else "" + elif row.complaint_source == ComplaintSourceEnum.OTHER.value: + complaint_source_details = complaint_source_contact.description if complaint_source_contact else "" + + item = { + "complaint_number": row.complaint_number, + "project_name": project_name, + "project_type": project_type, + "topic": row.topic, + "date_received": row.date_received.astimezone(ZoneInfo("America/Los_Angeles")) + .strftime("%Y-%m-%d") if row.date_received else None, + "complaint_source": row.complaint_source, + "complaint_source_details": complaint_source_details, + "primary_officer": f"{primary_officer.first_name} {primary_officer.last_name}" + if primary_officer else None, + "complaint_status": row.complaint_status.value if row.complaint_status else None, + "complaint_resolution": row.complaint_resolution, + "case_file_number": row.case_file_number, + } + result.append(item) + return result + + @staticmethod + def _get_inspections_tab_columns_and_headers(): + """Get existing columns and their headers for Excel export.""" + headers = [ + "IR Number", + "Topic", + "Summary", + "IR Progress", + "Project Name", + "Project Type", + "Compliance Finding", + "Enforcement Action", + "Enforcement Status", + "Enforcement Document Number", + "Condition Number", + "Requirement Source", + "IR Issuance Date", + "Primary Officer", + "Inspection Status", + ] + + columns = [ + "ir_number", + "topic_name", + "summary", + "ir_progress", + "project_name", + "project_type", + "compliance_finding", + "enforcement_action", + "enforcement_status", + "enforcement_document_number", + "condition_number", + "requirement_source", + "ir_issuance_date", + "primary_officer", + "inspection_status", + ] + + return headers, columns + + @staticmethod + def _get_complaints_tab_columns_and_headers(): + """Get existing columns and their headers for Excel export.""" + headers = [ + "Complaint Number", + "Project Name", + "Project Type", + "Topic", + "Date Received", + "Complaint Source", + "Complaint Source Details", + "Primary Officer", + "Complaint Status", + "Complaint Resolution", + "Case File Number", + ] + + columns = [ + "complaint_number", + "project_name", + "project_type", + "topic", + "date_received", + "complaint_source", + "complaint_source_details", + "primary_officer", + "complaint_status", + "complaint_resolution", + "case_file_number", + ] + + return headers, columns diff --git a/compliance-api/src/compliance_api/services/report/report.py b/compliance-api/src/compliance_api/services/report/report.py new file mode 100644 index 000000000..7b2807cfe --- /dev/null +++ b/compliance-api/src/compliance_api/services/report/report.py @@ -0,0 +1,25 @@ +"""Service for report.""" + +from compliance_api.models.report_enum import ReportTypeEnum +from compliance_api.services.report.case_file_management import CaseFileManagementReportGenerator + +from .ceb_summary import CEBSummaryReportGenerator + + +class ReportService: + """Report service.""" + + _generator_map = { + ReportTypeEnum.CEB_SUMMARY: CEBSummaryReportGenerator, + ReportTypeEnum.CASE_FILE_MANAGEMENT: CaseFileManagementReportGenerator, + } + + @classmethod + def generate_report(cls, report_data, report_type): + """Generate report.""" + generator_class = ReportService._generator_map.get(report_type) + + if not generator_class: + raise ValueError(f"Unknown report type: {report_type}") + generator = generator_class(report_data) + return generator.generate() diff --git a/compliance-api/src/compliance_api/services/report/shared_queries.py b/compliance-api/src/compliance_api/services/report/shared_queries.py new file mode 100644 index 000000000..5413bedc4 --- /dev/null +++ b/compliance-api/src/compliance_api/services/report/shared_queries.py @@ -0,0 +1,142 @@ +"""Shared sub queries for reports.""" + +from compliance_api.models import db +from compliance_api.models.administrative_penalty import ( + AdministrativePenalty, AdministrativePenaltyInspectionRequirementMap) +from compliance_api.models.charge_recommendation import ( + ChargeRecommendation, ChargeRecommendationInspectionRequirementMap) +from compliance_api.models.order import Order, OrderInspectionRequirementMap, OrderReplaceStatusEnum +from compliance_api.models.restorative_justice import RestorativeJustice, RestorativeJusticeInspectionRequirementMap +from compliance_api.models.violation_ticket import ViolationTicket, ViolationTicketInspectionRequirementMap +from compliance_api.models.warning_letter import WarningLetter, WarningLetterInspectionRequirementMap + + +def get_requirement_order_sub_query(): + """Get requirement order sub query.""" + return ( + db.session.query( + OrderInspectionRequirementMap.inspection_requirement_id, + OrderInspectionRequirementMap.order_id, + ) + .join( + Order, + Order.id == OrderInspectionRequirementMap.order_id, + ) + .filter( + OrderInspectionRequirementMap.is_active.is_(True), + OrderInspectionRequirementMap.is_deleted.is_(False), + Order.is_active.is_(True), + Order.is_deleted.is_(False), + Order.order_replace_status == OrderReplaceStatusEnum.ORIGINAL, + ) + .subquery("requirement_order") + ) + + +def get_requirement_warning_letter_sub_query(): + """Get requirement warning letter sub query.""" + return ( + db.session.query( + WarningLetterInspectionRequirementMap.inspection_requirement_id, + WarningLetterInspectionRequirementMap.warning_letter_id, + ) + .join( + WarningLetter, + WarningLetter.id == WarningLetterInspectionRequirementMap.warning_letter_id, + ) + .filter( + WarningLetterInspectionRequirementMap.is_active.is_(True), + WarningLetterInspectionRequirementMap.is_deleted.is_(False), + WarningLetter.is_active.is_(True), + WarningLetter.is_deleted.is_(False), + ) + .subquery("requirement_warning_letter") + ) + + +def get_requirement_violation_ticket_sub_query(): + """Get requirement violation ticket sub query.""" + return ( + db.session.query( + ViolationTicketInspectionRequirementMap.inspection_requirement_id, + ViolationTicketInspectionRequirementMap.violation_ticket_id, + ) + .join( + ViolationTicket, + ViolationTicket.id + == ViolationTicketInspectionRequirementMap.violation_ticket_id, + ) + .filter( + ViolationTicketInspectionRequirementMap.is_active.is_(True), + ViolationTicketInspectionRequirementMap.is_deleted.is_(False), + ViolationTicket.is_active.is_(True), + ViolationTicket.is_deleted.is_(False), + ) + .subquery("requirement_violation_ticket") + ) + + +def get_requirement_admin_penalty_sub_query(): + """Get requirement administrative penalty sub query.""" + return ( + db.session.query( + AdministrativePenaltyInspectionRequirementMap.inspection_requirement_id, + AdministrativePenaltyInspectionRequirementMap.administrative_penalty_id, + ) + .join( + AdministrativePenalty, + AdministrativePenalty.id + == AdministrativePenaltyInspectionRequirementMap.administrative_penalty_id, + ) + .filter( + AdministrativePenaltyInspectionRequirementMap.is_active.is_(True), + AdministrativePenaltyInspectionRequirementMap.is_deleted.is_(False), + AdministrativePenalty.is_active.is_(True), + AdministrativePenalty.is_deleted.is_(False), + ) + .subquery("requirement_admin_penalty") + ) + + +def get_requirement_charge_rec_sub_query(): + """Get requirement charge recommendation sub query.""" + return ( + db.session.query( + ChargeRecommendationInspectionRequirementMap.inspection_requirement_id, + ChargeRecommendationInspectionRequirementMap.charge_recommendation_id, + ) + .join( + ChargeRecommendation, + ChargeRecommendation.id + == ChargeRecommendationInspectionRequirementMap.charge_recommendation_id, + ) + .filter( + ChargeRecommendationInspectionRequirementMap.is_active.is_(True), + ChargeRecommendationInspectionRequirementMap.is_deleted.is_(False), + ChargeRecommendation.is_active.is_(True), + ChargeRecommendation.is_deleted.is_(False), + ) + .subquery("requirement_charge_rec") + ) + + +def get_requirement_restorative_justice_sub_query(): + """Get requirement restorative justice sub query.""" + return ( + db.session.query( + RestorativeJusticeInspectionRequirementMap.inspection_requirement_id, + RestorativeJusticeInspectionRequirementMap.restorative_justice_id, + ) + .join( + RestorativeJustice, + RestorativeJustice.id + == RestorativeJusticeInspectionRequirementMap.restorative_justice_id, + ) + .filter( + RestorativeJusticeInspectionRequirementMap.is_active.is_(True), + RestorativeJusticeInspectionRequirementMap.is_deleted.is_(False), + RestorativeJustice.is_active.is_(True), + RestorativeJustice.is_deleted.is_(False), + ) + .subquery("requirement_restorative_justice") + ) diff --git a/compliance-api/src/compliance_api/services/service_utils.py b/compliance-api/src/compliance_api/services/service_utils.py index dee5c90ad..760adfc7a 100644 --- a/compliance-api/src/compliance_api/services/service_utils.py +++ b/compliance-api/src/compliance_api/services/service_utils.py @@ -675,3 +675,67 @@ def get_project_abbreviation(project_id: int) -> str: project = TrackService.get_project_by_id(project_id) return project.get("abbreviation") return UNAPPROVED_PROJECT_CODE + + @staticmethod + def get_enforcement_status_by_type(result): # pylint: disable=too-many-return-statements + """Get the correct enforcement status based on the enforcement action type.""" + enforcement_action_id = result.enforcement_action_id + + # Map enforcement action ID to the corresponding status field + if enforcement_action_id == EnforcementActionOptionEnum.ORDER.value: + return result.order_status + if enforcement_action_id == EnforcementActionOptionEnum.WARNING_LETTER.value: + return result.warning_letter_status + if enforcement_action_id == EnforcementActionOptionEnum.VIOLATION_TICKET.value: + return result.violation_ticket_status + if ( + enforcement_action_id + == EnforcementActionOptionEnum.ADMINISTRATIVE_PENALTY_RECOMMENDATION.value + ): + return result.admin_penalty_status + if ( + enforcement_action_id == EnforcementActionOptionEnum.CHARGE_RECOMMENDATION.value + ): + return result.charge_rec_status + if enforcement_action_id == EnforcementActionOptionEnum.RESTORATIVE_JUSTICE.value: + return result.restorative_justice_status + return None + + @staticmethod + def convert_enum_to_object(enum_value): + """Convert enum value to proper object format for API response.""" + if not enum_value: + return None + + # If it's already an enum object, convert it to the expected format + if hasattr(enum_value, "name") and hasattr(enum_value, "value"): + return { + "id": enum_value.name, + "name": enum_value.value, + } + + # If it's just a string, return it as is (shouldn't happen with our current logic) + return { + "id": str(enum_value), + "name": str(enum_value), + } + + @staticmethod + def get_enforcement_number_by_type(result): + """Get the correct enforcement number based on the enforcement action type.""" + enforcement_action_id = result.enforcement_action_id + + # Map enforcement action ID to the corresponding number field attribute + enforcement_number_map = { + EnforcementActionOptionEnum.ORDER.value: "order_number", + EnforcementActionOptionEnum.WARNING_LETTER.value: "warning_letter_number", + EnforcementActionOptionEnum.VIOLATION_TICKET.value: "violation_ticket_number", + EnforcementActionOptionEnum.ADMINISTRATIVE_PENALTY_RECOMMENDATION.value: "admin_penalty_number", + EnforcementActionOptionEnum.CHARGE_RECOMMENDATION.value: "charge_rec_number", + EnforcementActionOptionEnum.RESTORATIVE_JUSTICE.value: "restorative_justice_number", + } + + field_name = enforcement_number_map.get(enforcement_action_id) + if field_name: + return getattr(result, field_name, "") or "" + return "" diff --git a/compliance-api/tests/unit/services/test_ceb_summary.py b/compliance-api/tests/unit/services/test_ceb_summary.py new file mode 100644 index 000000000..d7197cf7d --- /dev/null +++ b/compliance-api/tests/unit/services/test_ceb_summary.py @@ -0,0 +1,208 @@ +"""Test ceb_summary report service.""" +from datetime import datetime, timedelta +from faker import Faker +import pytest + +from compliance_api.models.complaint.complaint import Complaint, ComplaintStatusEnum +from compliance_api.models.complaint.complaint_enum import ComplaintSourceEnum +from compliance_api.models.complaint.complaint_option import ComplaintSource +from compliance_api.models import db +from compliance_api.models.case_file import CaseFile +from compliance_api.models.inspection.inspection_req_enforcement_map import InspectionReqEnforcementMap +from compliance_api.models.inspection_record import InspectionRecord +from compliance_api.services.report import ceb_summary +from compliance_api.models.inspection.inspection import Inspection +from compliance_api.models.inspection.inspection_requirement import InspectionRequirement +from compliance_api.models.topic import Topic +from compliance_api.models.compliance_finding import ComplianceFindingOption +from compliance_api.models.staff_user import StaffUser + +fake = Faker() + + +class TestCEBSummaryReportGenerator: + """Test CEB Summary Report Generator.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Fixture to execute before and after each test.""" + self.insp_req = self._create_test_inspection_requirement() + self.complaint = self._create_test_complaint() + yield + self._clean_up_database() + + def test_build_inspections_tab_query(self): + """Test building inspections tab query with no date range.""" + generator = ceb_summary.CEBSummaryReportGenerator({ + "start_date": datetime.now() + timedelta(days=100), + }) + results = generator._build_inspections_tab_query().all() + assert len(results) == 1 + assert results[0].InspectionRequirement == self.insp_req + + def test_build_inspections_tab_query_results_expected_within_date_range(self): + """Test building inspections tab query with date range that includes data.""" + generator = ceb_summary.CEBSummaryReportGenerator({ + "start_date": datetime.now() + timedelta(days=150), + "end_date": datetime.now() + timedelta(days=153) + }) + results = generator._build_inspections_tab_query().all() + assert results[0].InspectionRequirement == self.insp_req + + def test_build_inspections_tab_query_no_results_expected_with_start_date(self): + """Test building inspections tab query with start date that excludes data.""" + generator = ceb_summary.CEBSummaryReportGenerator({ + "start_date": datetime.now() + timedelta(days=154) + }) + results = generator._build_inspections_tab_query().all() + assert len(results) == 0 + + def test_build_inspections_tab_query_no_results_expected_with_end_date(self): + """Test building inspections tab query with end date that excludes data.""" + generator = ceb_summary.CEBSummaryReportGenerator({"end_date": datetime.now() - timedelta(days=10)}) + results = generator._build_inspections_tab_query().all() + assert len(results) == 0 + + def test_build_complaints_tab_query(self): + """Test building complaints tab query with no date range.""" + generator = ceb_summary.CEBSummaryReportGenerator({"start_date": datetime.now() + timedelta(days=150)}) + results = generator._build_complaints_tab_query().all() + assert results[0].complaint_number == self.complaint.complaint_number + + def test_build_complaints_tab_query_results_expected_within_date_range(self): + """Test building complaints tab query with date range that includes data.""" + generator = ceb_summary.CEBSummaryReportGenerator({ + "start_date": datetime.now() + timedelta(days=150), + "end_date": datetime.now() + timedelta(days=153) + }) + results = generator._build_complaints_tab_query().all() + assert results[0].complaint_number == self.complaint.complaint_number + + def test_build_complaints_tab_query_no_results_expected_with_start_date(self): + """Test building complaints tab query with start date that excludes data.""" + generator = ceb_summary.CEBSummaryReportGenerator({ + "start_date": datetime.now() + timedelta(days=1000) + }) + results = generator._build_complaints_tab_query().all() + assert len(results) == 0 + + def test_build_complaints_tab_query_no_results_expected_with_end_date(self): + """Test building complaints tab query with end date that excludes data.""" + generator = ceb_summary.CEBSummaryReportGenerator({"end_date": datetime.now() - timedelta(days=1000)}) + results = generator._build_complaints_tab_query().all() + assert len(results) == 0 + + def _create_test_inspection_requirement(self): + """Create an inspection requirement for testing.""" + case_file = CaseFile( + date_created=datetime.now(), + case_file_number=fake.pystr(min_chars=5, max_chars=10), + initiation_id=1 + ) + + db.session.add(case_file) + db.session.flush() + + topic = Topic( + name="Test Topic" + ) + finding = ComplianceFindingOption( + name=fake.pystr(min_chars=5, max_chars=10) + ) + officer = StaffUser( + first_name=fake.pystr(min_chars=5, max_chars=10), + last_name=fake.last_name(), + position_id=1 + ) + inspection = Inspection( + ir_number=fake.pystr(min_chars=5, max_chars=10), + primary_officer=officer, + start_date=datetime.now() + timedelta(days=152), + end_date=datetime.now() + timedelta(days=152), + initiation_id=1, + case_file_id=case_file.id + ) + + db.session.add_all([topic, finding, officer, inspection, case_file]) + db.session.flush() + + inspection_record = InspectionRecord( + inspection_id=inspection.id, + date_issued=datetime.now(), + ir_status_id=1 + ) + + db.session.add(inspection_record) + db.session.flush() + + insp_req = InspectionRequirement( + inspection_id=inspection.id, + topic_id=topic.id, + compliance_finding_id=finding.id, + summary="Requirement 1", + sort_order=1, + ) + + db.session.add(insp_req) + db.session.flush() + + insp_req_enf_map = InspectionReqEnforcementMap( + requirement_id=insp_req.id, + enforcement_action_id=1 + ) + + db.session.add(insp_req_enf_map) + db.session.commit() + + return insp_req + + def _create_test_complaint(self): + case_file = CaseFile( + date_created=datetime.now(), + case_file_number=fake.pystr(min_chars=5, max_chars=10), + initiation_id=1 + ) + + db.session.add(case_file) + db.session.flush() + + topic = Topic( + name="Test Topic" + ) + + public_complaint_source = db.session.query(ComplaintSource).where( + ComplaintSource.name == "Public" + ).first() + + if not public_complaint_source: + complaint_source = ComplaintSource( + name=ComplaintSourceEnum.PUBLIC.value + ) + else: + complaint_source = public_complaint_source + + db.session.add_all([topic, complaint_source]) + db.session.flush() + + complaint = Complaint( + case_file_id=case_file.id, + date_received=datetime.now() + timedelta(days=152), + source_type_id=complaint_source.id, + concern_description=fake.text(max_nb_chars=200), + status=ComplaintStatusEnum.OPEN, + complaint_number=fake.pystr(min_chars=5, max_chars=10) + ) + + db.session.add(complaint) + db.session.commit() + + return complaint + + def _clean_up_database(self): + """Clean up the database after tests.""" + db.session.query(InspectionReqEnforcementMap).where( + InspectionReqEnforcementMap.requirement_id == self.insp_req.id + ).delete() + db.session.query(InspectionRequirement).where(InspectionRequirement.id == self.insp_req.id).delete() + db.session.query(Complaint).where(Complaint.id == self.complaint.id).delete() + db.session.commit() diff --git a/compliance-web/cypress/components/_components/_App/_Inspections/InspectionFormRight.cy.tsx b/compliance-web/cypress/components/_components/_App/_Inspections/InspectionFormRight.cy.tsx index 1d690f4c1..1277434fc 100644 --- a/compliance-web/cypress/components/_components/_App/_Inspections/InspectionFormRight.cy.tsx +++ b/compliance-web/cypress/components/_components/_App/_Inspections/InspectionFormRight.cy.tsx @@ -168,7 +168,7 @@ describe("InspectionFormRight Component", () => { cy.get('.MuiAutocomplete-root[name="inAttendance"]').within(() => { cy.get(".MuiAutocomplete-tag") .should("contain.text", "Agency") - .find('svg[data-testid="CloseIcon"]') + .find('svg[data-testid="CancelIcon"]') .click(); }); diff --git a/compliance-web/cypress/components/_components/_Shared/_Controlled/ControlledAutoComplete.cy.tsx b/compliance-web/cypress/components/_components/_Shared/_Controlled/ControlledAutoComplete.cy.tsx index e1c4950e3..3ee2de490 100644 --- a/compliance-web/cypress/components/_components/_Shared/_Controlled/ControlledAutoComplete.cy.tsx +++ b/compliance-web/cypress/components/_components/_Shared/_Controlled/ControlledAutoComplete.cy.tsx @@ -40,6 +40,7 @@ const TestComponent = ({ options = optionsList, multiple = false, onDeleteOption multiple={multiple} onDeleteOption={onDeleteOption} onChange={onChange} + showAllSelectedText={false} /> @@ -97,6 +98,7 @@ describe("ControlledAutoComplete", () => { isOptionEqualToValue={(option, value) => option.value === value.value } + showAllSelectedText={false} /> @@ -124,8 +126,17 @@ describe("ControlledAutoComplete", () => { cy.get("input[name='testAutocomplete']").click(); cy.get("li[data-option-index='0']").click(); cy.get("li[data-option-index='1']").click(); - cy.get("div.MuiChip-root").first().find("svg").click(); // Click the delete icon on the chip - cy.wrap(onDeleteOption).should("have.been.calledOnceWith", optionsList[0]); + + // Verify chips exist + cy.get("div.MuiChip-root").should("have.length", 2); + + // Verify delete icon exists + cy.get("div.MuiChip-root").first().find("svg").should("exist").and("be.visible"); + + // Try clicking more specifically + cy.get("div.MuiChip-root").first().find("svg[data-testid='CancelIcon']").click(); + + cy.wrap(onDeleteOption).should("have.been.calledOnce"); }); it("updates the selected value when multiple is enabled and chip is deleted", () => { diff --git a/compliance-web/src/components/Shared/Controlled/ControlledAutoComplete.tsx b/compliance-web/src/components/Shared/Controlled/ControlledAutoComplete.tsx index 7930870d1..74eae4264 100644 --- a/compliance-web/src/components/Shared/Controlled/ControlledAutoComplete.tsx +++ b/compliance-web/src/components/Shared/Controlled/ControlledAutoComplete.tsx @@ -16,8 +16,9 @@ import { useMemo } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { VARIANT_COLORS } from "@/utils/constants"; -interface FormAutocompleteProps - extends Partial> { +interface FormAutocompleteProps extends Partial< + AutocompleteProps +> { name: string; label: string; options: T[]; @@ -29,8 +30,9 @@ interface FormAutocompleteProps isSortOptions?: boolean; isRequired?: boolean; renderOptionBadge?: ( - option: T + option: T, ) => { label: string; color: VARIANT_COLORS } | null; + showAllSelectedText?: boolean; } const ControlledAutoComplete = ({ @@ -45,6 +47,7 @@ const ControlledAutoComplete = ({ isSortOptions = false, isRequired = false, renderOptionBadge, + showAllSelectedText = false, ...props }: FormAutocompleteProps) => { const { @@ -55,7 +58,7 @@ const ControlledAutoComplete = ({ const sortedOptions = useMemo(() => { if (isSortOptions) { return options.sort((a, b) => - getOptionLabel(a).localeCompare(getOptionLabel(b)) + getOptionLabel(a).localeCompare(getOptionLabel(b)), ); } return options; @@ -85,6 +88,31 @@ const ControlledAutoComplete = ({ disableCloseOnSelect={multiple} limitTags={multiple ? 1 : undefined} popupIcon={} + renderTags={(value, getTagProps) => { + if ( + showAllSelectedText && + multiple && + Array.isArray(value) && + value.length === sortedOptions.length && + sortedOptions.length > 0 + ) { + return [All Selected]; + } + return value.map((option, index) => { + const { onDelete, ...chipProps } = getTagProps({ index }); + + return ( + { + onDelete(e); + onDeleteOption?.(option); + }} + /> + ); + }); + }} renderOption={(props, option, { selected }) => { const { key, ...otherProps } = props; const badge = renderOptionBadge ? renderOptionBadge(option) : null; @@ -146,7 +174,7 @@ const ControlledAutoComplete = ({ const optionToDelete = field.value.find( (item: T) => getOptionLabel(item) === - chipProps.currentTarget.parentElement?.textContent + chipProps.currentTarget.parentElement?.textContent, ); if (optionToDelete) { onDeleteOption(optionToDelete); @@ -157,8 +185,8 @@ const ControlledAutoComplete = ({ chipProps.currentTarget.parentElement?.textContent; field.onChange( field.value.filter( - (item: T) => getOptionLabel(item) !== chipToDelete - ) + (item: T) => getOptionLabel(item) !== chipToDelete, + ), ); } }, diff --git a/compliance-web/src/components/Shared/SideNav/RouteItemsList.tsx b/compliance-web/src/components/Shared/SideNav/RouteItemsList.tsx index f88b4f66b..0978c3c30 100644 --- a/compliance-web/src/components/Shared/SideNav/RouteItemsList.tsx +++ b/compliance-web/src/components/Shared/SideNav/RouteItemsList.tsx @@ -33,10 +33,10 @@ export default function RouteItemsList() { routeName: "Requirements", path: "/ce-database/requirements", }, - // { - // routeName: "Reports", - // path: "/ce-database/reports", - // }, + { + routeName: "Reports", + path: "/ce-database/reports", + }, ], }, { diff --git a/compliance-web/src/hooks/useSystemReports.tsx b/compliance-web/src/hooks/useSystemReports.tsx new file mode 100644 index 000000000..d57d50348 --- /dev/null +++ b/compliance-web/src/hooks/useSystemReports.tsx @@ -0,0 +1,22 @@ +import { ReportFormValues } from "@/models/Report"; +import { OnSuccessType, request } from "@/utils/axiosUtils"; +import { useMutation } from "@tanstack/react-query"; + +const systemReportExport = (data: ReportFormValues = {}) => { + const officer_ids = data.officers?.flatMap((o) => (o.id)) || []; + const project_id = data.project?.id || null; + const first_nation_id = data.first_nation?.id || null; + delete data.officers; + delete data.project; + delete data.first_nation; + return request({ + method: "POST", + url: `/reports/export`, + data: { ...data, officer_ids, project_id, first_nation_id }, + responseType: "blob", + }); +}; + +export const useSystemReportsExport = (onSuccess: OnSuccessType) => { + return useMutation({ mutationFn: systemReportExport, onSuccess }); +}; diff --git a/compliance-web/src/models/Report.ts b/compliance-web/src/models/Report.ts new file mode 100644 index 000000000..986cf5389 --- /dev/null +++ b/compliance-web/src/models/Report.ts @@ -0,0 +1,13 @@ +import { FirstNation } from "./FirstNation"; +import { Project } from "./Project"; +import { ReportType } from "./ReportType"; +import { StaffUser } from "./Staff"; + +export interface ReportFormValues { + report_type?: ReportType; + officers?: StaffUser[]; + project?: Project | null; + first_nation?: FirstNation | null; + start_date?: string | null; + end_date?: string | null; +} diff --git a/compliance-web/src/routeTree.gen.ts b/compliance-web/src/routeTree.gen.ts index e069e92dc..e6cd7a23f 100644 --- a/compliance-web/src/routeTree.gen.ts +++ b/compliance-web/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as AuthenticatedAdminStaffRouteImport } from './routes/_authentic import { Route as AuthenticatedAdminProponentsRouteImport } from './routes/_authenticated/admin/proponents' import { Route as AuthenticatedAdminAgenciesRouteImport } from './routes/_authenticated/admin/agencies' import { Route as AuthenticatedCeDatabaseRequirementsIndexRouteImport } from './routes/_authenticated/ce-database/requirements/index' +import { Route as AuthenticatedCeDatabaseReportsIndexRouteImport } from './routes/_authenticated/ce-database/reports/index' import { Route as AuthenticatedCeDatabaseInspectionsIndexRouteImport } from './routes/_authenticated/ce-database/inspections/index' import { Route as AuthenticatedCeDatabaseComplaintsIndexRouteImport } from './routes/_authenticated/ce-database/complaints/index' import { Route as AuthenticatedCeDatabaseCaseFilesIndexRouteImport } from './routes/_authenticated/ce-database/case-files/index' @@ -74,6 +75,12 @@ const AuthenticatedCeDatabaseRequirementsIndexRoute = path: '/ce-database/requirements/', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedCeDatabaseReportsIndexRoute = + AuthenticatedCeDatabaseReportsIndexRouteImport.update({ + id: '/ce-database/reports/', + path: '/ce-database/reports/', + getParentRoute: () => AuthenticatedRoute, + } as any) const AuthenticatedCeDatabaseInspectionsIndexRoute = AuthenticatedCeDatabaseInspectionsIndexRouteImport.update({ id: '/ce-database/inspections/', @@ -141,6 +148,7 @@ export interface FileRoutesByTo { '/ce-database/case-files': typeof AuthenticatedCeDatabaseCaseFilesIndexRoute '/ce-database/complaints': typeof AuthenticatedCeDatabaseComplaintsIndexRoute '/ce-database/inspections': typeof AuthenticatedCeDatabaseInspectionsIndexRoute + '/ce-database/reports': typeof AuthenticatedCeDatabaseReportsIndexRoute '/ce-database/requirements': typeof AuthenticatedCeDatabaseRequirementsIndexRoute } export interface FileRoutesById { @@ -159,6 +167,7 @@ export interface FileRoutesById { '/_authenticated/ce-database/case-files/': typeof AuthenticatedCeDatabaseCaseFilesIndexRoute '/_authenticated/ce-database/complaints/': typeof AuthenticatedCeDatabaseComplaintsIndexRoute '/_authenticated/ce-database/inspections/': typeof AuthenticatedCeDatabaseInspectionsIndexRoute + '/_authenticated/ce-database/reports/': typeof AuthenticatedCeDatabaseReportsIndexRoute '/_authenticated/ce-database/requirements/': typeof AuthenticatedCeDatabaseRequirementsIndexRoute } export interface FileRouteTypes { @@ -193,6 +202,7 @@ export interface FileRouteTypes { | '/ce-database/case-files' | '/ce-database/complaints' | '/ce-database/inspections' + | '/ce-database/reports' | '/ce-database/requirements' id: | '__root__' @@ -210,6 +220,7 @@ export interface FileRouteTypes { | '/_authenticated/ce-database/case-files/' | '/_authenticated/ce-database/complaints/' | '/_authenticated/ce-database/inspections/' + | '/_authenticated/ce-database/reports/' | '/_authenticated/ce-database/requirements/' fileRoutesById: FileRoutesById } @@ -284,6 +295,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedCeDatabaseRequirementsIndexRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/ce-database/reports/': { + id: '/_authenticated/ce-database/reports/' + path: '/ce-database/reports' + fullPath: '/ce-database/reports' + preLoaderRoute: typeof AuthenticatedCeDatabaseReportsIndexRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/ce-database/inspections/': { id: '/_authenticated/ce-database/inspections/' path: '/ce-database/inspections' @@ -341,6 +359,7 @@ interface AuthenticatedRouteChildren { AuthenticatedCeDatabaseCaseFilesIndexRoute: typeof AuthenticatedCeDatabaseCaseFilesIndexRoute AuthenticatedCeDatabaseComplaintsIndexRoute: typeof AuthenticatedCeDatabaseComplaintsIndexRoute AuthenticatedCeDatabaseInspectionsIndexRoute: typeof AuthenticatedCeDatabaseInspectionsIndexRoute + AuthenticatedCeDatabaseReportsIndexRoute: typeof AuthenticatedCeDatabaseReportsIndexRoute AuthenticatedCeDatabaseRequirementsIndexRoute: typeof AuthenticatedCeDatabaseRequirementsIndexRoute } @@ -362,6 +381,8 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedCeDatabaseComplaintsIndexRoute, AuthenticatedCeDatabaseInspectionsIndexRoute: AuthenticatedCeDatabaseInspectionsIndexRoute, + AuthenticatedCeDatabaseReportsIndexRoute: + AuthenticatedCeDatabaseReportsIndexRoute, AuthenticatedCeDatabaseRequirementsIndexRoute: AuthenticatedCeDatabaseRequirementsIndexRoute, } diff --git a/compliance-web/src/routes/_authenticated/ce-database/reports/index.tsx b/compliance-web/src/routes/_authenticated/ce-database/reports/index.tsx index ef73c2389..6a5f8a3a0 100644 --- a/compliance-web/src/routes/_authenticated/ce-database/reports/index.tsx +++ b/compliance-web/src/routes/_authenticated/ce-database/reports/index.tsx @@ -1,6 +1,6 @@ -// import { createFileRoute } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; -import { useForm, FormProvider, Controller } from "react-hook-form"; +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { useForm, FormProvider } from "react-hook-form"; import { Box, Button, @@ -19,8 +19,11 @@ import { useFirstNationsData } from "@/hooks/useFirstNations"; import ControlledDateField from "@/components/Shared/Controlled/ControlledDateField"; import { useProjectsData } from "@/hooks/useProjects"; import { BCDesignTokens } from "epic.theme"; -import { StaffUser } from "@/models/Staff"; import { ReportType } from "@/models/ReportType"; +import { downloadFile } from "@/utils/appUtils"; +import { ReportFormValues } from "@/models/Report"; +import { useSystemReportsExport } from "@/hooks/useSystemReports"; +import dateUtils from "@/utils/dateUtils"; const REPORT_TYPES = [ { @@ -35,55 +38,50 @@ const REPORT_TYPES = [ { label: "First Nation Report", value: ReportType.FirstNation }, ]; -// Prep for COMP-744 - System Reports -// export const Route = createFileRoute("/_authenticated/ce-database/reports/")({ -// component: ReportsTab, -// }); - -interface ReportFormValues { - reportType: ReportType; - project: { id: number; name: string } | null; - dateRangeType: "none" | "range"; - startDate: string | null; - endDate: string | null; - officers: StaffUser[]; - firstNation: { id: number; name: string } | null; -} +export const Route = createFileRoute("/_authenticated/ce-database/reports/")({ + component: ReportsTab, +}); export function ReportsTab() { + const [dateRangeType, setDateRangeType] = useState<"none" | "range">("none"); + const methods = useForm({ defaultValues: { - reportType: ReportType.ProjectCompliance, + report_type: ReportType.ProjectCompliance, project: null, - dateRangeType: "none", - startDate: null, - endDate: null, + start_date: null, + end_date: null, officers: [], - firstNation: null, + first_nation: null, }, }); - const { handleSubmit, watch, control, setValue } = methods; + const { handleSubmit, watch, setValue } = methods; const { data: projects = [] } = useProjectsData(); const { data: staffUsers = [] } = useStaffUsersData(); const { data: firstNations = [] } = useFirstNationsData(); - const reportType = watch("reportType"); - const dateRangeType = watch("dateRangeType"); + const report_type = watch("report_type"); const officers = watch("officers"); const hasManuallyChangedOfficers = useRef(false); useEffect(() => { - if (reportType !== ReportType.CaseFileManagement) { + if (report_type !== ReportType.CaseFileManagement) { hasManuallyChangedOfficers.current = false; } - }, [reportType]); + setValue("start_date", null); + setValue("end_date", null); + setValue("project", null); + setValue("officers", []); + setValue("first_nation", null); + setDateRangeType("none"); + }, [report_type, setValue]); useEffect(() => { if ( - reportType === ReportType.CaseFileManagement && + report_type === ReportType.CaseFileManagement && staffUsers.length > 0 && - officers.length === 0 && + officers?.length === 0 && !hasManuallyChangedOfficers.current ) { setValue("officers", staffUsers, { @@ -91,17 +89,25 @@ export function ReportsTab() { shouldDirty: true, }); } - }, [reportType, staffUsers, officers, setValue]); + }, [report_type, staffUsers, officers, setValue]); useEffect(() => { - if (reportType === ReportType.CaseFileManagement && officers.length === 0) { + if ( + report_type === ReportType.CaseFileManagement && + officers?.length === 0 + ) { hasManuallyChangedOfficers.current = true; } - }, [reportType, officers]); + }, [report_type, officers]); - const onSubmit = (data: ReportFormValues) => { - alert(`Generating report for ${data.reportType}`); - }; + const { mutate: downloadSystemReport, isPending } = useSystemReportsExport( + (data) => { + downloadFile( + data, + `${report_type}-${dateUtils.formatDate(new Date().toISOString(), "YYYY-MM-DD-HH-mm-ss")}.xlsx`, + ); + }, + ); return ( @@ -125,21 +131,23 @@ export function ReportsTab() { { + downloadSystemReport(data); + })} display="flex" flexDirection="column" gap={1} > Report Type} select fullWidth @@ -151,7 +159,7 @@ export function ReportsTab() { ))} - {reportType === ReportType.ProjectCompliance && ( + {report_type === ReportType.ProjectCompliance && ( <> View all inspection, enforcement and compliant data for a @@ -172,34 +180,35 @@ export function ReportsTab() { /> - ( - - } - label="No Date Range" - /> - } - label="Select Date Range" - /> - - )} - /> + { + setDateRangeType(e.target.value as "none" | "range"); + setValue("start_date", null); + setValue("end_date", null); + }} + > + } + label="No Date Range" + /> + } + label="Select Date Range" + /> + {dateRangeType === "range" && ( @@ -207,41 +216,42 @@ export function ReportsTab() { )} )} - {reportType === ReportType.CebSummary && ( + {report_type === ReportType.CebSummary && ( <> View CEB activities, including inspection, enforcement and complaint summary data across projects. - ( - - } - label="No Date Range" - /> - } - label="Select Date Range" - /> - - )} - /> + { + setDateRangeType(e.target.value as "none" | "range"); + setValue("start_date", null); + setValue("end_date", null); + }} + > + } + label="No Date Range" + /> + } + label="Select Date Range" + /> + {dateRangeType === "range" && ( @@ -249,7 +259,7 @@ export function ReportsTab() { )} )} - {reportType === ReportType.CaseFileManagement && ( + {report_type === ReportType.CaseFileManagement && ( <> View CEB case file management data and statistics. @@ -267,11 +277,12 @@ export function ReportsTab() { fullWidth multiple={true} isRequired={true} + showAllSelectedText={true} /> )} - {reportType === ReportType.FirstNation && ( + {report_type === ReportType.FirstNation && ( <> View attended inspections and received complaints for a @@ -280,7 +291,7 @@ export function ReportsTab() { option.name} @@ -296,8 +307,16 @@ export function ReportsTab() { )} - From 041535cb4232848831ef3dc69fd1ff9874a3909b Mon Sep 17 00:00:00 2001 From: Shaelyn Tolkamp <46355612+tolkamps1@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:27:47 -0800 Subject: [PATCH 2/4] [Comp-750] Project Compliance report (#738) --- .../src/compliance_api/resources/report.py | 9 +- .../services/report/project_compliance.py | 372 ++++++++++++++++++ .../compliance_api/services/report/report.py | 3 +- .../test_project_compliance_history_report.py | 257 ++++++++++++ .../ce-database/reports/index.tsx | 27 +- 5 files changed, 662 insertions(+), 6 deletions(-) create mode 100644 compliance-api/src/compliance_api/services/report/project_compliance.py create mode 100644 compliance-api/tests/unit/services/test_project_compliance_history_report.py diff --git a/compliance-api/src/compliance_api/resources/report.py b/compliance-api/src/compliance_api/resources/report.py index 259209ef7..31116fd20 100644 --- a/compliance-api/src/compliance_api/resources/report.py +++ b/compliance-api/src/compliance_api/resources/report.py @@ -16,7 +16,7 @@ from datetime import datetime from io import BytesIO -from flask import request, send_file +from flask import current_app, request, send_file from flask_restx import Namespace, Resource from compliance_api.services.report.report import ReportService @@ -55,7 +55,12 @@ def post(): report_data = schema.load(request.json or {}) report_type = report_data.get("report_type") - data = ReportService.generate_report(report_data, report_type) + + try: + data = ReportService.generate_report(report_data, report_type) + except ValueError as value_error: + current_app.logger.error(f"Error generating report: {value_error}") + return {"message": str(value_error)}, 400 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") file_name = f"{report_type}_{timestamp}.xlsx" diff --git a/compliance-api/src/compliance_api/services/report/project_compliance.py b/compliance-api/src/compliance_api/services/report/project_compliance.py new file mode 100644 index 000000000..692d46f81 --- /dev/null +++ b/compliance-api/src/compliance_api/services/report/project_compliance.py @@ -0,0 +1,372 @@ +"""Project Compliance History Report Generator Service.""" +from datetime import datetime, time +from io import BytesIO +from zoneinfo import ZoneInfo + +import pandas as pd + +from flask import current_app +from sqlalchemy import and_, func +from sqlalchemy.orm import selectinload + +from compliance_api.models import db +from compliance_api.models.administrative_penalty import AdministrativePenalty, DecisionEnum +from compliance_api.models.charge_recommendation import ChargeRecommendation +from compliance_api.models.compliance_finding import ComplianceFindingOption +from compliance_api.models.enforcement_action import EnforcementActionOption +from compliance_api.models.inspection.inspection import Inspection +from compliance_api.models.inspection.inspection_option import InspectionInitiationOption, InspectionTypeOption +from compliance_api.models.inspection.inspection_req_enforcement_map import InspectionReqEnforcementMap +from compliance_api.models.inspection.inspection_requirement import InspectionRequirement +from compliance_api.models.inspection.inspection_type import InspectionType +from compliance_api.models.inspection_record import InspectionRecord +from compliance_api.models.order import Order +from compliance_api.models.project import Project +from compliance_api.models.restorative_justice import RestorativeJustice +from compliance_api.models.staff_user import StaffUser +from compliance_api.models.topic import Topic +from compliance_api.models.violation_ticket import ViolationTicket +from compliance_api.models.warning_letter import WarningLetter +from compliance_api.services.report.shared_queries import ( + get_requirement_admin_penalty_sub_query, get_requirement_charge_rec_sub_query, get_requirement_order_sub_query, + get_requirement_restorative_justice_sub_query, get_requirement_violation_ticket_sub_query, + get_requirement_warning_letter_sub_query) +from compliance_api.services.service_utils import ServiceUtils + +from .base import BaseReportGenerator + + +class ProjectComplianceReportGenerator(BaseReportGenerator): + """Project Compliance Report Generator Service.""" + + def __init__(self, report_data): + """Initialize the Project Compliance Report Generator with the provided report data.""" + super().__init__(report_data) + + start_date_raw = report_data.get("start_date") + end_date_raw = report_data.get("end_date") + + self.start_date = ( + datetime.combine( + start_date_raw, time.min + ) if start_date_raw else None + ) + self.end_date = ( + datetime.combine( + end_date_raw, time.max + ) if end_date_raw else None + ) + self.project_id = report_data.get("project_id") + + current_app.logger.info( + f"Project Compliance History Report Generator initialized with start_date: {self.start_date}, \ + end_date: {self.end_date}, project_id: {self.project_id}." + ) + + if self.project_id is None: + raise ValueError("Project ID must be provided for Project Compliance History Report.") + + def generate(self): + """Project Compliance Report Generation Logic.""" + # Inspections Requirements Tab + data = self._build_inspection_requirements_query(self.project_id).all() + data = self._format_inspection_requirements_data(data) + inspections_data_frame = pd.json_normalize(data) + inspections_headers, inspections_columns = self._get_inspection_requirements_tab_columns_and_headers() + output = self._to_excel( + inspections_data_frame, + inspections_columns, + inspections_headers + ) + return output + + def _to_excel( + self, + inspections_data_frame, + inspections_columns, + inspections_headers + ): + output = BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: + + # If there is no data, create an empty dataframe with columns so that + # the excel file will still have the correct headers and structure + if inspections_data_frame.empty: + inspections_data_frame = pd.DataFrame(columns=inspections_columns) + + # Inspection Requirements + inspections_data_frame.to_excel( + writer, + sheet_name="Inspection Requirements", + columns=inspections_columns, + header=inspections_headers, + index=False, + ) + worksheet = writer.sheets["Inspection Requirements"] + # Making the columns wider + for col_idx, column in enumerate(worksheet.columns, start=1): + column_letter = column[0].column_letter + + # Header length (from inspections_headers) + header_text = inspections_headers[col_idx - 1] + max_length = len(str(header_text)) + + # Data cell lengths + for cell in column[1:]: + if cell.value is not None: + max_length = max(max_length, len(str(cell.value))) + + worksheet.column_dimensions[column_letter].width = min(max_length, 50) + + output.seek(0) + return output.getvalue() + + def _build_inspection_requirements_query(self, project_id: int): + """Build base query for Project Compliance History Report.""" + + requirement_order_subquery = get_requirement_order_sub_query() + requirement_warning_letter_subquery = get_requirement_warning_letter_sub_query() + requirement_violation_ticket_subquery = get_requirement_violation_ticket_sub_query() + requirement_admin_penalty_subquery = get_requirement_admin_penalty_sub_query() + requirement_charge_rec_subquery = get_requirement_charge_rec_sub_query() + requirement_restorative_justice_subquery = get_requirement_restorative_justice_sub_query() + inspection_type_subquery = _get_inspection_type_subquery() + + query = ( + db.session.query( + InspectionRequirement, + InspectionRequirement.summary.label("summary"), + Inspection.ir_number.label("ir_number"), + Topic.name.label("topic_name"), + Inspection.start_date.label("start_date"), + Inspection.end_date.label("end_date"), + inspection_type_subquery.c.inspection_types.label("inspection_type"), + InspectionInitiationOption.name.label("initiation_name"), + InspectionRecord.ir_progress.label("ir_progress"), + Project.id.label("project_id"), + ComplianceFindingOption.name.label("compliance_finding"), + InspectionReqEnforcementMap.enforcement_action_id.label("enforcement_action_id"), + EnforcementActionOption.name.label("enforcement_action"), + # Enforcement statuses + Order.order_status.label("order_status"), + WarningLetter.status.label("warning_letter_status"), + ViolationTicket.status.label("violation_ticket_status"), + AdministrativePenalty.referral_status.label("admin_penalty_status"), + ChargeRecommendation.status.label("charge_rec_status"), + RestorativeJustice.status.label("restorative_justice_status"), + # Enforcement document numbers + Order.order_number.label("order_number"), + WarningLetter.warning_letter_number.label("warning_letter_number"), + ViolationTicket.ticket_number.label("violation_ticket_number"), + AdministrativePenalty.administrative_penalty_number.label("admin_penalty_number"), + ChargeRecommendation.charge_recommendation_number.label("charge_rec_number"), + RestorativeJustice.restorative_justice_number.label("restorative_justice_number"), + AdministrativePenalty.decision.label("ap_dm_decision"), + AdministrativePenalty.penalty_amount.label("ap_penalty_amount"), + InspectionRecord.date_issued.label("ir_date_issued"), + StaffUser, + Inspection.inspection_status.label("inspection_status"), + ) + .join(Inspection, InspectionRequirement.inspection_id == Inspection.id) + .outerjoin( + inspection_type_subquery, + inspection_type_subquery.c.inspection_id == Inspection.id + ) + .outerjoin(InspectionInitiationOption, Inspection.initiation_id == InspectionInitiationOption.id) + .outerjoin(Topic, InspectionRequirement.topic_id == Topic.id) + .outerjoin(Project, Inspection.project_id == Project.id) + .outerjoin( + ComplianceFindingOption, + InspectionRequirement.compliance_finding_id == ComplianceFindingOption.id + ) + .outerjoin(InspectionReqEnforcementMap, and_( + InspectionReqEnforcementMap.requirement_id == InspectionRequirement.id, + InspectionReqEnforcementMap.is_active.is_(True), + InspectionReqEnforcementMap.is_deleted.is_(False) + )) + .join(InspectionRecord, InspectionRecord.inspection_id == Inspection.id) + .outerjoin( + EnforcementActionOption, + InspectionReqEnforcementMap.enforcement_action_id == EnforcementActionOption.id + ) + .outerjoin(StaffUser, Inspection.primary_officer_id == StaffUser.id) + .outerjoin( + requirement_order_subquery, + requirement_order_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + Order, + Order.id == requirement_order_subquery.c.order_id + ) + .outerjoin( + requirement_warning_letter_subquery, + requirement_warning_letter_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + WarningLetter, + WarningLetter.id == requirement_warning_letter_subquery.c.warning_letter_id + ) + .outerjoin( + requirement_violation_ticket_subquery, + requirement_violation_ticket_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + ViolationTicket, + ViolationTicket.id == requirement_violation_ticket_subquery.c.violation_ticket_id + ) + .outerjoin( + requirement_admin_penalty_subquery, + requirement_admin_penalty_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + AdministrativePenalty, + AdministrativePenalty.id == requirement_admin_penalty_subquery.c.administrative_penalty_id + ) + .outerjoin( + requirement_charge_rec_subquery, + requirement_charge_rec_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + ChargeRecommendation, + ChargeRecommendation.id == requirement_charge_rec_subquery.c.charge_recommendation_id + ) + .outerjoin( + requirement_restorative_justice_subquery, + requirement_restorative_justice_subquery.c.inspection_requirement_id == InspectionRequirement.id + ) + .outerjoin( + RestorativeJustice, + RestorativeJustice.id == requirement_restorative_justice_subquery.c.restorative_justice_id + ) + .filter( + InspectionRequirement.is_active.is_(True), + InspectionRequirement.is_deleted.is_(False), + InspectionRecord.is_active.is_(True), + InspectionRecord.is_deleted.is_(False), + Inspection.is_active.is_(True), + Inspection.is_deleted.is_(False), + Inspection.project_id == project_id, + Inspection.start_date >= self.start_date if self.start_date else True, + Inspection.start_date <= self.end_date if self.end_date else True, + ) + .order_by(Inspection.ir_number, EnforcementActionOption.id) + .options( + selectinload(InspectionRequirement.requirement_source_details) + ) + ) + return query + + @staticmethod + def _format_inspection_requirements_data(data): + """Format data for excel export.""" + result = [] + for row in data: + inspection_requirement = row.InspectionRequirement + raw_enforcement_status = ServiceUtils.get_enforcement_status_by_type(row) + primary_officer = row.StaffUser + req_source_details = inspection_requirement.requirement_source_details + + condition_num_string = "" + source_string = "" + for req_source in req_source_details: + number_field = ServiceUtils.get_requirement_grid_source_number_field(req_source) + name_field = ServiceUtils.get_requirement_grid_source_name_field(req_source) + condition_num_string += f", {number_field}" if condition_num_string else number_field or "" + source_string += f", {name_field}" if source_string else name_field + + item = { + "ir_number": row.ir_number, + "topic_name": row.topic_name, + "summary": row.summary, + "start_date": row.start_date.astimezone(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d") + if row.start_date else None, + "end_date": row.end_date.astimezone(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d") + if row.end_date and row.start_date != row.end_date else None, + "initiation": row.initiation_name if row.initiation_name else row.initiation, + "ir_progress": row.ir_progress.value if row.ir_progress else None, + "inspection_type": row.inspection_type, + "compliance_finding": row.compliance_finding, + "enforcement_action": row.enforcement_action, + "enforcement_status": raw_enforcement_status.value if raw_enforcement_status else None, + "ap_dm_decision": row.ap_dm_decision.value if row.ap_dm_decision else None, + "ap_penalty_amount": row.ap_penalty_amount + if row.ap_penalty_amount and row.ap_dm_decision == DecisionEnum.AP_ISSUED else None, + "enforcement_document_number": ServiceUtils.get_enforcement_number_by_type(row), + "condition_number": condition_num_string, + "requirement_source": source_string, + "ir_issuance_date": row.ir_date_issued.astimezone(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d") + if row.ir_date_issued else None, + "primary_officer": f"{primary_officer.first_name} {primary_officer.last_name}" + if primary_officer else None, + "inspection_status": row.inspection_status.value if row.inspection_status else None, + } + result.append(item) + return result + + @staticmethod + def _get_inspection_requirements_tab_columns_and_headers(): + """Get existing columns and their headers for Excel export.""" + headers = [ + "IR Number", + "Topic", + "Summary", + "Inspection Start Date", + "Inspection End Date", + "Initiation", + "IR Progress", + "Inspection Type", + "Compliance Finding", + "Enforcement Action", + "Enforcement Status", + "DM Decision", + "AP Value", + "Enforcement Document", + "Condition Number", + "Requirement Source", + "IR Issuance Date", + "Primary Officer", + "Inspection Status", + ] + + columns = [ + "ir_number", + "topic_name", + "summary", + "start_date", + "end_date", + "initiation", + "ir_progress", + "inspection_type", + "compliance_finding", + "enforcement_action", + "enforcement_status", + "ap_dm_decision", + "ap_penalty_amount", + "enforcement_document_number", + "condition_number", + "requirement_source", + "ir_issuance_date", + "primary_officer", + "inspection_status", + ] + + return headers, columns + + +def _get_inspection_type_subquery(): + """Get subquery for inspection types as comma-separated list.""" + return ( + db.session.query( + InspectionType.inspection_id, + func.string_agg( + InspectionTypeOption.name, + ", " + ).label("inspection_types") + ) + .join( + InspectionTypeOption, + InspectionType.type_id == InspectionTypeOption.id + ) + .group_by(InspectionType.inspection_id) + .subquery() + ) diff --git a/compliance-api/src/compliance_api/services/report/report.py b/compliance-api/src/compliance_api/services/report/report.py index 7b2807cfe..82997b453 100644 --- a/compliance-api/src/compliance_api/services/report/report.py +++ b/compliance-api/src/compliance_api/services/report/report.py @@ -2,7 +2,7 @@ from compliance_api.models.report_enum import ReportTypeEnum from compliance_api.services.report.case_file_management import CaseFileManagementReportGenerator - +from compliance_api.services.report.project_compliance import ProjectComplianceReportGenerator from .ceb_summary import CEBSummaryReportGenerator @@ -12,6 +12,7 @@ class ReportService: _generator_map = { ReportTypeEnum.CEB_SUMMARY: CEBSummaryReportGenerator, ReportTypeEnum.CASE_FILE_MANAGEMENT: CaseFileManagementReportGenerator, + ReportTypeEnum.PROJECT_COMPLIANCE: ProjectComplianceReportGenerator, } @classmethod diff --git a/compliance-api/tests/unit/services/test_project_compliance_history_report.py b/compliance-api/tests/unit/services/test_project_compliance_history_report.py new file mode 100644 index 000000000..7b3bd1239 --- /dev/null +++ b/compliance-api/tests/unit/services/test_project_compliance_history_report.py @@ -0,0 +1,257 @@ +"""Test project_compliance report service.""" +from datetime import datetime, timedelta +from faker import Faker +import pytest +from sqlalchemy import text + +from compliance_api.models import db +from compliance_api.models.case_file import CaseFile +from compliance_api.models.compliance_finding import ComplianceFindingOption +from compliance_api.models.inspection.inspection import Inspection +from compliance_api.models.inspection.inspection_req_enforcement_map import InspectionReqEnforcementMap +from compliance_api.models.inspection.inspection_requirement import InspectionRequirement +from compliance_api.models.inspection_record import InspectionRecord +from compliance_api.models.project import Project +from compliance_api.models.staff_user import StaffUser +from compliance_api.models.topic import Topic +from compliance_api.services.report import project_compliance + +fake = Faker() + + +class TestProjectComplianceReportGenerator: + """Test Project Compliance Report Generator.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Fixture to execute before and after each test.""" + # Clean tables before test + db.session.execute(text('TRUNCATE projects RESTART IDENTITY CASCADE')) + db.session.commit() + + self.project = self._create_test_project() + self.insp_req = self._create_test_inspection_requirement(self.project.id) + + yield + + # Clean tables after test + db.session.execute(text('TRUNCATE projects RESTART IDENTITY CASCADE')) + db.session.commit() + + def test_initialization_with_project_id(self): + """Test that the generator initializes correctly with a project ID.""" + generator = project_compliance.ProjectComplianceReportGenerator({ + "project_id": self.project.id + }) + assert generator.project_id == self.project.id + assert generator.start_date is None + assert generator.end_date is None + + def test_initialization_without_project_id_raises_error(self): + """Test that initialization without project ID raises ValueError.""" + with pytest.raises(ValueError, match="Project ID must be provided"): + project_compliance.ProjectComplianceReportGenerator({}) + + def test_initialization_with_date_range(self): + """Test that the generator initializes correctly with date range.""" + start_date = datetime.now().date() + end_date = (datetime.now() + timedelta(days=30)).date() + + generator = project_compliance.ProjectComplianceReportGenerator({ + "project_id": self.project.id, + "start_date": start_date, + "end_date": end_date + }) + + assert generator.start_date is not None + assert generator.end_date is not None + + def test_build_inspection_requirements_query_no_date_range(self): + """Test building inspection requirements query with no date range.""" + generator = project_compliance.ProjectComplianceReportGenerator({ + "project_id": self.project.id + }) + results = generator._build_inspection_requirements_query(self.project.id).all() + assert len(results) == 1 + assert results[0].InspectionRequirement.id == self.insp_req.id + + def test_build_inspection_requirements_query_results_expected_within_date_range(self): + """Test building inspection requirements query with date range that includes data.""" + generator = project_compliance.ProjectComplianceReportGenerator({ + "project_id": self.project.id, + "start_date": (datetime.now() + timedelta(days=150)).date(), + "end_date": (datetime.now() + timedelta(days=153)).date() + }) + results = generator._build_inspection_requirements_query(self.project.id).all() + assert len(results) == 1 + assert results[0].InspectionRequirement.id == self.insp_req.id + + def test_build_inspection_requirements_query_no_results_expected_with_start_date(self): + """Test building inspection requirements query with start date that excludes data.""" + generator = project_compliance.ProjectComplianceReportGenerator({ + "project_id": self.project.id, + "start_date": (datetime.now() + timedelta(days=200)).date() + }) + results = generator._build_inspection_requirements_query(self.project.id).all() + assert len(results) == 0 + + def test_build_inspection_requirements_query_no_results_expected_with_end_date(self): + """Test building inspection requirements query with end date that excludes data.""" + generator = project_compliance.ProjectComplianceReportGenerator({ + "project_id": self.project.id, + "end_date": (datetime.now() - timedelta(days=10)).date() + }) + results = generator._build_inspection_requirements_query(self.project.id).all() + assert len(results) == 0 + + def test_build_inspection_requirements_query_filters_by_project(self): + """Test that query only returns requirements for the specified project.""" + # Create another project with inspection requirement + other_project = self._create_test_project() + other_insp_req = self._create_test_inspection_requirement(other_project.id) + + generator = project_compliance.ProjectComplianceReportGenerator({ + "project_id": self.project.id + }) + results = generator._build_inspection_requirements_query(self.project.id).all() + + # Should only return requirements for the specified project + assert len(results) == 1 + assert results[0].InspectionRequirement.id == self.insp_req.id + assert results[0].InspectionRequirement.id != other_insp_req.id + + # Clean up + self._clean_up_inspection_requirement(other_insp_req) + db.session.query(Project).where(Project.id == other_project.id).delete() + db.session.flush() + + def test_format_inspection_requirements_data(self): + """Test formatting of inspection requirements data.""" + generator = project_compliance.ProjectComplianceReportGenerator({ + "project_id": self.project.id + }) + results = generator._build_inspection_requirements_query(self.project.id).all() + formatted_data = generator._format_inspection_requirements_data(results) + + assert len(formatted_data) == 1 + item = formatted_data[0] + + # Check that all expected fields are present + assert "ir_number" in item + assert "topic_name" in item + assert "summary" in item + assert "start_date" in item + assert "end_date" in item + assert "initiation" in item + assert "ir_progress" in item + assert "inspection_type" in item + assert "compliance_finding" in item + assert "enforcement_action" in item + assert "enforcement_status" in item + assert "primary_officer" in item + assert "inspection_status" in item + + def test_generate_returns_excel_file(self): + """Test that generate method returns Excel file bytes.""" + generator = project_compliance.ProjectComplianceReportGenerator({ + "project_id": self.project.id + }) + result = generator.generate() + + # Check that result is bytes (Excel file) + assert isinstance(result, bytes) + assert len(result) > 0 + + def _create_test_project(self): + """Create a project for testing.""" + project = Project( + name=fake.company(), + ) + db.session.add(project) + db.session.flush() + return project + + def _create_test_inspection_requirement(self, project_id): + """Create an inspection requirement for testing.""" + case_file = CaseFile( + date_created=datetime.now(), + case_file_number=fake.pystr(min_chars=5, max_chars=10), + initiation_id=1 + ) + + db.session.add(case_file) + db.session.flush() + + topic = Topic( + name="Test Topic" + ) + finding = ComplianceFindingOption( + name=fake.pystr(min_chars=5, max_chars=10) + ) + officer = StaffUser( + first_name=fake.pystr(min_chars=5, max_chars=10), + last_name=fake.last_name(), + position_id=1 + ) + inspection = Inspection( + ir_number=fake.pystr(min_chars=5, max_chars=10), + primary_officer=officer, + start_date=datetime.now() + timedelta(days=152), + end_date=datetime.now() + timedelta(days=152), + initiation_id=1, + case_file_id=case_file.id, + project_id=project_id # Link to project + ) + + db.session.add_all([topic, finding, officer, inspection, case_file]) + db.session.flush() + + inspection_record = InspectionRecord( + inspection_id=inspection.id, + date_issued=datetime.now(), + ir_status_id=1 + ) + + db.session.add(inspection_record) + db.session.flush() + + insp_req = InspectionRequirement( + inspection_id=inspection.id, + topic_id=topic.id, + compliance_finding_id=finding.id, + summary="Requirement 1", + sort_order=1, + ) + + db.session.add(insp_req) + db.session.flush() + + insp_req_enf_map = InspectionReqEnforcementMap( + requirement_id=insp_req.id, + enforcement_action_id=1 + ) + + db.session.add(insp_req_enf_map) + db.session.flush() + + return insp_req + + def _clean_up_inspection_requirement(self, insp_req): + """Help clean up a specific inspection requirement.""" + db.session.query(InspectionReqEnforcementMap).where( + InspectionReqEnforcementMap.requirement_id == insp_req.id + ).delete() + db.session.query(InspectionRequirement).where( + InspectionRequirement.id == insp_req.id + ).delete() + db.session.query(InspectionRecord).where( + InspectionRecord.inspection_id == insp_req.inspection_id + ).delete() + db.session.query(Inspection).where( + Inspection.id == insp_req.inspection_id + ).delete() + db.session.flush() + + def _clean_up_database(self): + """Clean up the database after tests.""" + db.session.rollback() diff --git a/compliance-web/src/routes/_authenticated/ce-database/reports/index.tsx b/compliance-web/src/routes/_authenticated/ce-database/reports/index.tsx index 6a5f8a3a0..254dad200 100644 --- a/compliance-web/src/routes/_authenticated/ce-database/reports/index.tsx +++ b/compliance-web/src/routes/_authenticated/ce-database/reports/index.tsx @@ -46,6 +46,7 @@ export function ReportsTab() { const [dateRangeType, setDateRangeType] = useState<"none" | "range">("none"); const methods = useForm({ + mode: "onChange", defaultValues: { report_type: ReportType.ProjectCompliance, project: null, @@ -62,6 +63,8 @@ export function ReportsTab() { const report_type = watch("report_type"); const officers = watch("officers"); + const project = watch("project"); + const first_nation = watch("first_nation"); const hasManuallyChangedOfficers = useRef(false); @@ -109,6 +112,21 @@ export function ReportsTab() { }, ); + const isFormComplete = () => { + switch (report_type) { + case ReportType.ProjectCompliance: + return project !== null; + case ReportType.CaseFileManagement: + return officers && officers.length > 0; + case ReportType.FirstNation: + return first_nation !== null; + case ReportType.CebSummary: + return true; + default: + return false; + } + }; + return ( @@ -309,13 +327,16 @@ export function ReportsTab() {