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..31116fd20
--- /dev/null
+++ b/compliance-api/src/compliance_api/resources/report.py
@@ -0,0 +1,67 @@
+# 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 current_app, 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")
+
+ 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"
+
+ 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..183581739 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,39 @@ 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."""
+ try:
+ first_nation_response = _request_track_service("indigenous-nations")
+ if first_nation_response.status_code != 200:
+ current_app.logger.error(
+ f"EPIC.track returned status {first_nation_response.status_code} for GET first nations."
+ )
+ raise BadRequestError(
+ "Unable to retrieve First Nation information at this time"
+ )
+
+ return first_nation_response.json()
+ except (RetryError, requests.exceptions.RequestException) as e:
+ current_app.logger.error(
+ f"EPIC.track service unavailable for GET first nations: {str(e)}",
+ exc_info=True
+ )
+ raise BadRequestError(
+ "The First Nation information service is temporarily unavailable. Please try again later."
+ )
+ except (ResourceNotFoundError, BadRequestError):
+ raise
+ except (KeyError, ValueError, TypeError) as e:
+ current_app.logger.error(
+ f"Error parsing first nation data for GET first nations: {str(e)}",
+ exc_info=True
+ )
+ raise BadRequestError(
+ "Unable to parse First Nation information for GET first nations"
+ )
+
@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..8491f9ced
--- /dev/null
+++ b/compliance-api/src/compliance_api/services/report/ceb_summary.py
@@ -0,0 +1,519 @@
+"""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.case_file_number.label("case_file_number"),
+ 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,
+ "case_file_number": row.case_file_number,
+ }
+ 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",
+ "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
+
+ @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/first_nation.py b/compliance-api/src/compliance_api/services/report/first_nation.py
new file mode 100644
index 000000000..376d99fdb
--- /dev/null
+++ b/compliance-api/src/compliance_api/services/report/first_nation.py
@@ -0,0 +1,538 @@
+"""First Nation Report Generator Service."""
+from io import BytesIO
+from zoneinfo import ZoneInfo
+
+import pandas as pd
+from compliance_api.models.inspection.inspection_attendance import InspectionAttendance
+from compliance_api.models.inspection.inspection_firstnation import InspectionFirstnation
+from compliance_api.models.inspection.inspection_option import InspectionAttendanceOption
+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
+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
+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 FirstNationReportGenerator(BaseReportGenerator):
+ """First Nation Report Generator Service."""
+
+ def __init__(self, report_data):
+ """Initialize the First Nation Report Generator with the provided report data."""
+ super().__init__(report_data)
+
+ self.first_nation_id = report_data.get("first_nation_id")
+ self._project_cache = {} # key: (project_id, date_str), value: project dict
+
+ current_app.logger.info(
+ f"First Nation Report Generator initialized with first_nation_id: {self.first_nation_id}."
+ )
+
+ def generate(self):
+ """First Nation Report Generation Logic."""
+ first_nations = TrackService.get_first_nations()
+ first_nations_name = next(
+ (fn.get("name") for fn in first_nations if fn.get("id") == self.first_nation_id),
+ "Unknown First Nation"
+ )
+
+ # Inspections Tab
+ data = self._build_inspections_tab_query().all()
+ data = self._format_inspections_tab_data(data, first_nations)
+ 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_name)
+ 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 First Nation 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()
+ attendance_subquery = _get_inspection_attendance_subquery()
+
+ query = (
+ db.session.query(
+ InspectionRequirement,
+ InspectionRequirement.summary.label("summary"),
+ Inspection.ir_number.label("ir_number"),
+ attendance_subquery.c.attendance_types.label("inspection_attendance"),
+ InspectionFirstnation.firstnation_id.label("first_nation_id"),
+ Topic.name.label("topic_name"),
+ InspectionRecord.ir_progress.label("ir_progress"),
+ Inspection.start_date.label("start_date"),
+ Inspection.end_date.label("end_date"),
+ 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 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"),
+ 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(attendance_subquery, attendance_subquery.c.inspection_id == Inspection.id)
+ .join(InspectionFirstnation, and_(
+ InspectionFirstnation.is_deleted.is_(False),
+ InspectionFirstnation.inspection_id == Inspection.id,
+ InspectionFirstnation.firstnation_id == self.first_nation_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),
+ )
+ .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,
+ Complaint.source_first_nation_id.label("source_first_nation_id"),
+ Complaint.concern_description.label("concern_description"),
+ 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, and_(
+ ComplaintSourceContact.complaint_id == Complaint.id,
+ ComplaintSourceContact.is_active.is_(True),
+ ComplaintSourceContact.is_deleted.is_(False)
+ ))
+ .join(ComplaintSource, Complaint.source_type_id == ComplaintSource.id)
+ .filter(
+ Complaint.is_active.is_(True),
+ Complaint.is_deleted.is_(False),
+ Complaint.source_first_nation_id == self.first_nation_id,
+ CaseFile.is_active.is_(True),
+ CaseFile.is_deleted.is_(False),
+ )
+ .order_by(Complaint.id)
+ .distinct(Complaint.id)
+ )
+ return query
+
+ def _format_inspections_tab_data(self, data, first_nations):
+ """Format data for excel export."""
+ result = []
+ first_nation = next((fn for fn in first_nations if fn.get('id') == self.first_nation_id), None)
+ first_nation_name = first_nation.get("name") if first_nation else ""
+
+ 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 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:
+ project = self._get_project_cached(row.project_id, as_of_date=row.case_file_date_created)
+ 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,
+ "first_nation": first_nation_name,
+ "ir_progress": row.ir_progress.value if row.ir_progress else None,
+ "project_name": project_name,
+ "project_type": project_type,
+ "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,
+ "topic_name": row.topic_name,
+ "summary": row.summary,
+ "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
+
+ def _format_complaints_tab_data(self, data, first_nations_name):
+ """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 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:
+ project = self._get_project_cached(row.project_id, as_of_date=row.case_file_date_created)
+ project_name = project.get("name") if project else None
+ project_type = project.get("type", None).get("name", None) if project else None
+
+ 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,
+ "concern_description": row.concern_description,
+ "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 inspection existing columns and their headers for Excel export."""
+ headers = [
+ "IR Number",
+ "First Nation/First Nation Alliance",
+ "IR Progress",
+ "Project",
+ "Project Type",
+ "Inspection Start Date",
+ "Inspection End Date",
+ "Topic",
+ "Summary",
+ "Compliance Finding",
+ "Enforcement Action",
+ "Enforcement Status",
+ "Enforcement Document",
+ "Condition Number",
+ "Requirement Source",
+ "IR Issuance Date",
+ "Primary",
+ "Inspection Status",
+ "Case File Number"
+ ]
+
+ columns = [
+ "ir_number",
+ "first_nation",
+ "ir_progress",
+ "project_name",
+ "project_type",
+ "start_date",
+ "end_date",
+ "topic_name",
+ "summary",
+ "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
+
+ @staticmethod
+ def _get_complaints_tab_columns_and_headers():
+ """Get complaints existing columns and their headers for Excel export."""
+ headers = [
+ "Complaint Number",
+ "Project",
+ "Project Type",
+ "Topic",
+ "Concern Description",
+ "Date Received",
+ "Primary",
+ "Status",
+ "Complaint Resolution",
+ "Case File Number",
+ ]
+
+ columns = [
+ "complaint_number",
+ "project_name",
+ "project_type",
+ "topic",
+ "concern_description",
+ "date_received",
+ "primary_officer",
+ "complaint_status",
+ "complaint_resolution",
+ "case_file_number",
+ ]
+
+ return headers, columns
+
+ def _get_project_cached(self, project_id, as_of_date):
+ if not project_id:
+ return None
+ date_str = as_of_date.strftime("%Y-%m-%d") if as_of_date else None
+ cache_key = (project_id, date_str)
+ if cache_key not in self._project_cache:
+ self._project_cache[cache_key] = TrackService.get_project_by_id(project_id, as_of_date=as_of_date)
+ return self._project_cache[cache_key]
+
+
+def _get_inspection_attendance_subquery():
+ """Get subquery for inspection attendance types as comma-separated list."""
+ return (
+ db.session.query(
+ InspectionAttendance.inspection_id,
+ func.string_agg(
+ InspectionAttendanceOption.name,
+ ", "
+ ).label("attendance_types")
+ )
+ .join(
+ InspectionAttendanceOption,
+ InspectionAttendance.attendance_option_id == InspectionAttendanceOption.id
+ )
+ .filter(
+ InspectionAttendance.is_active.is_(True),
+ InspectionAttendance.is_deleted.is_(False)
+ )
+ .group_by(InspectionAttendance.inspection_id)
+ .subquery()
+ )
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
new file mode 100644
index 000000000..c8eb82e72
--- /dev/null
+++ b/compliance-api/src/compliance_api/services/report/report.py
@@ -0,0 +1,28 @@
+"""Service for report."""
+
+from compliance_api.models.report_enum import ReportTypeEnum
+from compliance_api.services.report.case_file_management import CaseFileManagementReportGenerator
+from compliance_api.services.report.first_nation import FirstNationReportGenerator
+from compliance_api.services.report.project_compliance import ProjectComplianceReportGenerator
+from .ceb_summary import CEBSummaryReportGenerator
+
+
+class ReportService:
+ """Report service."""
+
+ _generator_map = {
+ ReportTypeEnum.CEB_SUMMARY: CEBSummaryReportGenerator,
+ ReportTypeEnum.CASE_FILE_MANAGEMENT: CaseFileManagementReportGenerator,
+ ReportTypeEnum.FIRST_NATION: FirstNationReportGenerator,
+ ReportTypeEnum.PROJECT_COMPLIANCE: ProjectComplianceReportGenerator,
+ }
+
+ @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-api/tests/unit/services/test_first_nation_report.py b/compliance-api/tests/unit/services/test_first_nation_report.py
new file mode 100644
index 000000000..0e12b6f74
--- /dev/null
+++ b/compliance-api/tests/unit/services/test_first_nation_report.py
@@ -0,0 +1,559 @@
+"""Test first_nation report service."""
+from datetime import datetime, timedelta
+
+import pytest
+from faker import Faker
+from sqlalchemy import text
+
+from compliance_api.models import db
+from compliance_api.models.case_file import CaseFile
+from compliance_api.models.complaint.complaint import Complaint, ComplaintStatusEnum
+from compliance_api.models.complaint.complaint_option import ComplaintSource, ComplaintSourceEnum
+from compliance_api.models.complaint.complaint_source_contact import ComplaintSourceContact
+from compliance_api.models.compliance_finding import ComplianceFindingOption
+from compliance_api.models.inspection.inspection import Inspection
+from compliance_api.models.inspection.inspection_attendance import InspectionAttendance
+from compliance_api.models.inspection.inspection_enum import InspectionAttendanceOptionEnum
+from compliance_api.models.inspection.inspection_firstnation import InspectionFirstnation
+from compliance_api.models.inspection.inspection_option import InspectionAttendanceOption
+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.staff_user import StaffUser
+from compliance_api.models.topic import Topic
+from compliance_api.models.unapproved_project import UnapprovedProject
+from compliance_api.services.report import first_nation
+
+fake = Faker()
+
+# Constants for test first nation IDs
+TEST_FIRST_NATION_ID = 999
+OTHER_FIRST_NATION_ID = 888
+
+
+class TestFirstNationReportGenerator:
+ """Test First Nation Report Generator."""
+
+ @pytest.fixture(autouse=True)
+ def setup(self, mocker):
+ """Fixture to execute before and after each test."""
+ # Mock TrackService methods
+ self.mock_get_first_nations = mocker.patch(
+ "compliance_api.services.report.first_nation.TrackService.get_first_nations"
+ )
+ self.mock_get_first_nations.return_value = [
+ {"id": TEST_FIRST_NATION_ID, "name": "Test First Nation"},
+ {"id": OTHER_FIRST_NATION_ID, "name": "Other First Nation"},
+ ]
+
+ self.mock_get_project_by_id = mocker.patch(
+ "compliance_api.services.report.first_nation.TrackService.get_project_by_id"
+ )
+ self.mock_get_project_by_id.return_value = {
+ "name": "Test Project",
+ "type": {"name": "Mine"},
+ }
+
+ # Clean tables before test
+ db.session.execute(text('TRUNCATE inspection_firstnations RESTART IDENTITY CASCADE'))
+ db.session.execute(text('TRUNCATE inspection_attendance_mappings RESTART IDENTITY CASCADE'))
+ db.session.execute(text('TRUNCATE complaint_source_contacts RESTART IDENTITY CASCADE'))
+ db.session.commit()
+
+ self.inspection = None
+ self.insp_req = None
+ self.complaint = None
+
+ yield
+
+ # Clean up after test
+ self._clean_up_database()
+
+ def test_initialization_with_first_nation_id(self):
+ """Test that the generator initializes correctly with a first_nation_id."""
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ assert generator.first_nation_id == TEST_FIRST_NATION_ID
+ assert generator._project_cache == {}
+
+ def test_initialization_without_first_nation_id(self):
+ """Test that the generator handles missing first_nation_id."""
+ generator = first_nation.FirstNationReportGenerator({})
+ assert generator.first_nation_id is None
+
+ def test_build_inspections_query_returns_results_for_first_nation(self):
+ """Test that inspections query returns results for the specified first nation."""
+ self.insp_req = self._create_test_inspection_with_first_nation(TEST_FIRST_NATION_ID)
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_inspections_tab_query().all()
+
+ assert len(results) >= 1
+ assert any(r.first_nation_id == TEST_FIRST_NATION_ID for r in results)
+
+ def test_build_inspections_query_excludes_other_first_nations(self):
+ """Test that inspections query excludes inspections for other first nations."""
+ # Create inspection for a different first nation
+ other_insp_req = self._create_test_inspection_with_first_nation(OTHER_FIRST_NATION_ID)
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_inspections_tab_query().all()
+
+ # Should not contain results for OTHER_FIRST_NATION_ID
+ assert not any(r.first_nation_id == OTHER_FIRST_NATION_ID for r in results)
+
+ # Clean up the other inspection
+ self._clean_up_inspection_requirement(other_insp_req)
+
+ def test_build_inspections_query_excludes_soft_deleted_first_nation_link(self):
+ """Test that soft-deleted InspectionFirstnation records are excluded."""
+ self.insp_req = self._create_test_inspection_with_first_nation(
+ TEST_FIRST_NATION_ID,
+ first_nation_deleted=True
+ )
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_inspections_tab_query().all()
+
+ # Should not return results for soft-deleted first nation link
+ assert len(results) == 0
+
+ def test_build_inspections_query_excludes_soft_deleted_attendance(self):
+ """Test that soft-deleted InspectionAttendance records are excluded."""
+ self.insp_req = self._create_test_inspection_with_first_nation(
+ TEST_FIRST_NATION_ID,
+ attendance_deleted=True
+ )
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_inspections_tab_query().all()
+
+ # Should not return results since attendance is deleted
+ assert len(results) == 0
+
+ def test_build_inspections_query_aggregates_multiple_attendance_types(self):
+ """Test that multiple attendance types are aggregated into single row."""
+ self.insp_req = self._create_test_inspection_with_first_nation(
+ TEST_FIRST_NATION_ID,
+ multiple_attendance=True
+ )
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_inspections_tab_query().all()
+
+ # Count rows for this inspection requirement
+ req_rows = [r for r in results if r.InspectionRequirement.id == self.insp_req.id]
+
+ # Should have one row per requirement (not per attendance type)
+ # Multiple attendance types should be combined in inspection_attendance field
+ assert len(req_rows) == 1
+ # The attendance field should contain comma-separated values
+ assert ", " in req_rows[0].inspection_attendance or req_rows[0].inspection_attendance is not None
+
+ def test_build_complaints_query_returns_results_for_first_nation(self):
+ """Test that complaints query returns results for the specified first nation."""
+ self.complaint = self._create_test_complaint_for_first_nation(TEST_FIRST_NATION_ID)
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_complaints_tab_query().all()
+
+ assert len(results) >= 1
+ assert any(r.source_first_nation_id == TEST_FIRST_NATION_ID for r in results)
+
+ def test_build_complaints_query_excludes_other_first_nations(self):
+ """Test that complaints query excludes complaints for other first nations."""
+ # Create complaint for a different first nation
+ other_complaint = self._create_test_complaint_for_first_nation(OTHER_FIRST_NATION_ID)
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_complaints_tab_query().all()
+
+ # Should not contain results for OTHER_FIRST_NATION_ID
+ assert not any(r.source_first_nation_id == OTHER_FIRST_NATION_ID for r in results)
+
+ # Clean up
+ self._clean_up_complaint(other_complaint)
+
+ def test_build_complaints_query_excludes_soft_deleted_source_contact(self):
+ """Test that soft-deleted ComplaintSourceContact records are excluded."""
+ self.complaint = self._create_test_complaint_for_first_nation(
+ TEST_FIRST_NATION_ID,
+ source_contact_deleted=True
+ )
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_complaints_tab_query().all()
+
+ # Complaint should still be returned, but ComplaintSourceContact should be None
+ assert len(results) >= 1
+ # The source contact should be None due to soft delete
+ contact_row = next((r for r in results if r.source_first_nation_id == TEST_FIRST_NATION_ID), None)
+ assert contact_row is not None
+ assert contact_row.ComplaintSourceContact is None
+
+ def test_format_inspections_data_returns_expected_fields(self):
+ """Test that formatting returns all expected fields."""
+ self.insp_req = self._create_test_inspection_with_first_nation(TEST_FIRST_NATION_ID)
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_inspections_tab_query().all()
+ first_nations = [{"id": TEST_FIRST_NATION_ID, "name": "Test First Nation"}]
+ formatted_data = generator._format_inspections_tab_data(results, first_nations)
+
+ assert len(formatted_data) >= 1
+ item = formatted_data[0]
+
+ # Check all expected fields are present
+ expected_fields = [
+ "ir_number", "first_nation", "ir_progress", "project_name", "project_type",
+ "start_date", "end_date", "topic_name", "summary", "compliance_finding",
+ "enforcement_action", "enforcement_status", "enforcement_document_number",
+ "condition_number", "requirement_source", "ir_issuance_date",
+ "primary_officer", "inspection_status", "case_file_number"
+ ]
+ for field in expected_fields:
+ assert field in item, f"Missing field: {field}"
+
+ def test_format_complaints_data_returns_expected_fields(self):
+ """Test that complaints formatting returns all expected fields."""
+ self.complaint = self._create_test_complaint_for_first_nation(TEST_FIRST_NATION_ID)
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_complaints_tab_query().all()
+ formatted_data = generator._format_complaints_tab_data(results, "Test First Nation")
+
+ assert len(formatted_data) >= 1
+ item = formatted_data[0]
+
+ # Check all expected fields are present
+ expected_fields = [
+ "complaint_number", "project_name", "project_type", "topic",
+ "date_received", "concern_description", "primary_officer",
+ "complaint_status", "complaint_resolution", "case_file_number"
+ ]
+ for field in expected_fields:
+ assert field in item, f"Missing field: {field}"
+
+ def test_generate_returns_excel_bytes(self):
+ """Test that generate method returns Excel file bytes."""
+ self.insp_req = self._create_test_inspection_with_first_nation(TEST_FIRST_NATION_ID)
+ self.complaint = self._create_test_complaint_for_first_nation(TEST_FIRST_NATION_ID)
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ result = generator.generate()
+
+ # Check that result is bytes (Excel file)
+ assert isinstance(result, bytes)
+ assert len(result) > 0
+
+ def test_empty_data_generates_valid_excel(self):
+ """Test that empty data still generates valid Excel with headers."""
+ # Don't create any data - first nation ID that has no data
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": 12345 # Non-existent first nation
+ })
+ result = generator.generate()
+
+ # Should still return valid Excel bytes
+ assert isinstance(result, bytes)
+ assert len(result) > 0
+
+ def test_project_cache_avoids_duplicate_calls(self):
+ """Test that project cache prevents duplicate TrackService calls."""
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+
+ test_date = datetime.now()
+
+ # Call twice with same project_id and date
+ generator._get_project_cached(100, test_date)
+ generator._get_project_cached(100, test_date)
+
+ # TrackService should only be called once
+ assert self.mock_get_project_by_id.call_count == 1
+
+ def test_project_cache_different_dates_make_separate_calls(self):
+ """Test that different dates result in separate TrackService calls."""
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+
+ date1 = datetime.now()
+ date2 = datetime.now() + timedelta(days=1)
+
+ generator._get_project_cached(100, date1)
+ generator._get_project_cached(100, date2)
+
+ # Should be called twice for different dates
+ assert self.mock_get_project_by_id.call_count == 2
+
+ def test_unapproved_project_uses_local_fields(self):
+ """Test that unapproved projects use UnapprovedProject fields instead of TrackService."""
+ self.insp_req = self._create_test_inspection_with_first_nation(
+ TEST_FIRST_NATION_ID,
+ unapproved_project=True
+ )
+
+ generator = first_nation.FirstNationReportGenerator({
+ "first_nation_id": TEST_FIRST_NATION_ID
+ })
+ results = generator._build_inspections_tab_query().all()
+
+ # Should have unapproved project data
+ assert len(results) >= 1
+ result = results[0]
+ assert result.project_id is None
+ assert result.unapproved_project_name is not None
+
+ def _create_test_inspection_with_first_nation(
+ self,
+ first_nation_id,
+ first_nation_deleted=False,
+ attendance_deleted=False,
+ multiple_attendance=False,
+ unapproved_project=False
+ ):
+ """Create an inspection linked to a first nation 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()
+
+ # Create unapproved project if needed
+ if unapproved_project:
+ unapproved = UnapprovedProject(
+ case_file_id=case_file.id,
+ name=fake.company(),
+ type="Mine"
+ )
+ db.session.add(unapproved)
+ db.session.flush()
+
+ topic = Topic(name=f"Test Topic {fake.pystr(min_chars=3, max_chars=5)}")
+ finding = ComplianceFindingOption(name=fake.pystr(min_chars=5, max_chars=10))
+ officer = StaffUser(
+ first_name=fake.first_name(),
+ last_name=fake.last_name(),
+ position_id=1
+ )
+ db.session.add_all([topic, finding, officer])
+ db.session.flush()
+
+ inspection = Inspection(
+ ir_number=fake.pystr(min_chars=5, max_chars=10),
+ primary_officer_id=officer.id,
+ 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=None if unapproved_project else None # No project for simplicity
+ )
+ db.session.add(inspection)
+ db.session.flush()
+
+ # Create InspectionFirstnation link
+ first_nation_link = InspectionFirstnation(
+ inspection_id=inspection.id,
+ firstnation_id=first_nation_id,
+ is_deleted=first_nation_deleted
+ )
+ db.session.add(first_nation_link)
+ db.session.flush()
+
+ # Get or create attendance option
+ attendance_option = db.session.query(InspectionAttendanceOption).filter(
+ InspectionAttendanceOption.id == InspectionAttendanceOptionEnum.ATTENDING_OFFICERS.value
+ ).first()
+ if not attendance_option:
+ attendance_option = InspectionAttendanceOption(
+ id=InspectionAttendanceOptionEnum.ATTENDING_OFFICERS.value,
+ name="Attending Officers"
+ )
+ db.session.add(attendance_option)
+ db.session.flush()
+
+ # Create InspectionAttendance
+ attendance = InspectionAttendance(
+ inspection_id=inspection.id,
+ attendance_option_id=attendance_option.id,
+ is_deleted=attendance_deleted
+ )
+ db.session.add(attendance)
+ db.session.flush()
+
+ # Add second attendance type if needed
+ if multiple_attendance:
+ attendance_option2 = db.session.query(InspectionAttendanceOption).filter(
+ InspectionAttendanceOption.id == InspectionAttendanceOptionEnum.FIRSTNATIONS.value
+ ).first()
+ if not attendance_option2:
+ attendance_option2 = InspectionAttendanceOption(
+ id=InspectionAttendanceOptionEnum.FIRSTNATIONS.value,
+ name="First Nations"
+ )
+ db.session.add(attendance_option2)
+ db.session.flush()
+
+ attendance2 = InspectionAttendance(
+ inspection_id=inspection.id,
+ attendance_option_id=attendance_option2.id,
+ is_deleted=False
+ )
+ db.session.add(attendance2)
+ 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="Test Requirement",
+ 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()
+
+ self.inspection = inspection
+ return insp_req
+
+ def _create_test_complaint_for_first_nation(
+ self,
+ first_nation_id,
+ source_contact_deleted=False
+ ):
+ """Create a complaint for a first nation 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=f"Complaint Topic {fake.pystr(min_chars=3, max_chars=5)}")
+ db.session.add(topic)
+ db.session.flush()
+
+ # Get or create First Nation complaint source
+ complaint_source = db.session.query(ComplaintSource).filter(
+ ComplaintSource.name == ComplaintSourceEnum.FIRST_NATION.value
+ ).first()
+ if not complaint_source:
+ complaint_source = ComplaintSource(
+ name=ComplaintSourceEnum.FIRST_NATION.value
+ )
+ db.session.add(complaint_source)
+ db.session.flush()
+
+ complaint = Complaint(
+ case_file_id=case_file.id,
+ date_received=datetime.now() + timedelta(days=10),
+ source_type_id=complaint_source.id,
+ source_first_nation_id=first_nation_id,
+ concern_description=fake.text(max_nb_chars=200),
+ status=ComplaintStatusEnum.OPEN,
+ complaint_number=fake.pystr(min_chars=5, max_chars=10),
+ topic_id=topic.id
+ )
+ db.session.add(complaint)
+ db.session.flush()
+
+ # Create source contact if needed
+ source_contact = ComplaintSourceContact(
+ complaint_id=complaint.id,
+ full_name=fake.name(),
+ is_deleted=source_contact_deleted
+ )
+ db.session.add(source_contact)
+ db.session.commit()
+
+ return complaint
+
+ def _clean_up_inspection_requirement(self, insp_req):
+ """Clean up a specific inspection requirement and related records."""
+ if not insp_req:
+ return
+
+ inspection_id = insp_req.inspection_id
+
+ 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 == inspection_id
+ ).delete()
+ db.session.query(InspectionAttendance).where(
+ InspectionAttendance.inspection_id == inspection_id
+ ).delete()
+ db.session.query(InspectionFirstnation).where(
+ InspectionFirstnation.inspection_id == inspection_id
+ ).delete()
+ db.session.query(Inspection).where(
+ Inspection.id == inspection_id
+ ).delete()
+ db.session.commit()
+
+ def _clean_up_complaint(self, complaint):
+ """Clean up a specific complaint and related records."""
+ if not complaint:
+ return
+
+ db.session.query(ComplaintSourceContact).where(
+ ComplaintSourceContact.complaint_id == complaint.id
+ ).delete()
+ db.session.query(Complaint).where(
+ Complaint.id == complaint.id
+ ).delete()
+ db.session.commit()
+
+ def _clean_up_database(self):
+ """Clean up the database after tests."""
+ if self.insp_req:
+ self._clean_up_inspection_requirement(self.insp_req)
+ if self.complaint:
+ self._clean_up_complaint(self.complaint)
+ db.session.rollback()
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/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/hooks/useProjects.tsx b/compliance-web/src/hooks/useProjects.tsx
index d8918d135..68e7bf62f 100644
--- a/compliance-web/src/hooks/useProjects.tsx
+++ b/compliance-web/src/hooks/useProjects.tsx
@@ -1,5 +1,5 @@
import { Project } from "@/models/Project";
-import { request, requestTrackAPI } from "@/utils/axiosUtils";
+import { request } from "@/utils/axiosUtils";
import { UNAPPROVED_PROJECT_ID } from "@/utils/constants";
import { useQuery } from "@tanstack/react-query";
@@ -7,9 +7,9 @@ const fetchProjects = (): Promise => {
return request({ url: "/projects" });
};
-/** FETCH project details from TRACK API */
+/** FETCH project details */
const fetchProjectById = (id: number): Promise => {
- return requestTrackAPI({ url: `/projects/${id}` });
+ return request({ url: `/projects/${id}` });
};
export const useProjectsData = (args?: { includeUnapproved?: boolean }) => {
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/routes/_authenticated/ce-database/reports/index.tsx b/compliance-web/src/routes/_authenticated/ce-database/reports/index.tsx
index ef73c2389..832424378 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 { 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,53 @@ 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 function ReportsTab() {
+ const [dateRangeType, setDateRangeType] = useState<"none" | "range">("none");
+
const methods = useForm({
+ mode: "onChange",
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 project = watch("project");
+ const first_nation = watch("first_nation");
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,16 +92,39 @@ 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 { mutate: downloadSystemReport, isPending } = useSystemReportsExport(
+ (data) => {
+ downloadFile(
+ data,
+ `${report_type}-${dateUtils.formatDate(new Date().toISOString(), "YYYY-MM-DD-HH-mm-ss")}.xlsx`,
+ );
+ },
+ );
- const onSubmit = (data: ReportFormValues) => {
- alert(`Generating report for ${data.reportType}`);
+ 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 (
@@ -125,21 +149,23 @@ export function ReportsTab() {
{
+ downloadSystemReport(data);
+ })}
display="flex"
flexDirection="column"
gap={1}
>
Report Type}
select
fullWidth
@@ -151,7 +177,7 @@ export function ReportsTab() {
))}
- {reportType === ReportType.ProjectCompliance && (
+ {report_type === ReportType.ProjectCompliance && (
<>
View all inspection, enforcement and compliant data for a
@@ -172,34 +198,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 +234,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 +277,7 @@ export function ReportsTab() {
)}
>
)}
- {reportType === ReportType.CaseFileManagement && (
+ {report_type === ReportType.CaseFileManagement && (
<>
View CEB case file management data and statistics.
@@ -267,11 +295,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 +309,7 @@ export function ReportsTab() {
option.name}
@@ -296,8 +325,19 @@ export function ReportsTab() {
)}
-