diff --git a/backend/lcfs/conftest.py b/backend/lcfs/conftest.py index cea7bae8a..e8f23547e 100644 --- a/backend/lcfs/conftest.py +++ b/backend/lcfs/conftest.py @@ -1,6 +1,7 @@ -import structlog import warnings +import structlog + # Suppress the PendingDeprecationWarning for multipart warnings.filterwarnings( "ignore", @@ -13,13 +14,11 @@ import pytest from fakeredis import FakeServer, aioredis -from fakeredis.aioredis import FakeConnection from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi_cache import FastAPICache from fastapi_cache.backends.redis import RedisBackend from httpx import AsyncClient -from redis.asyncio import ConnectionPool from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, diff --git a/backend/lcfs/tests/final_supply_equipment/test_final_supply_equipment_export.py b/backend/lcfs/tests/final_supply_equipment/test_final_supply_equipment_export.py new file mode 100644 index 000000000..b27f40a04 --- /dev/null +++ b/backend/lcfs/tests/final_supply_equipment/test_final_supply_equipment_export.py @@ -0,0 +1,112 @@ +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest +from docutils.nodes import description +from starlette.responses import StreamingResponse + +from lcfs.db.models import Organization +from lcfs.db.models.user.Role import RoleEnum +from lcfs.utils.constants import FILE_MEDIA_TYPE +from lcfs.web.api.final_supply_equipment.export import FinalSupplyEquipmentExporter +from lcfs.web.exception.exceptions import DataNotFoundException + + +@pytest.fixture +def exporter(repo_mock, compliance_report_services_mock): + return FinalSupplyEquipmentExporter( + repo=repo_mock, compliance_report_services=compliance_report_services_mock + ) + + +@pytest.fixture +def repo_mock(): + with patch( + "lcfs.web.api.final_supply_equipment.export.FinalSupplyEquipmentRepository" + ) as mock: + mock_instance = mock.return_value + mock_instance.get_fse_options = AsyncMock( + return_value=[ + [], + [], + [], + ["Port1", "Port2"], + ["Org1", "Org2"], + ] + ) + mock_instance.get_fse_paginated = AsyncMock(return_value=([],)) + return mock_instance + + +@pytest.fixture +def compliance_report_services_mock(): + with patch( + "lcfs.web.api.final_supply_equipment.export.ComplianceReportServices" + ) as mock: + mock_instance = mock.return_value + mock_instance.get_compliance_report_by_id = AsyncMock( + return_value=AsyncMock( + compliance_period=AsyncMock( + description="2023", + effective_date=datetime(2023, 1, 1), + expiration_date=datetime(2023, 12, 31), + ) + ) + ) + return mock_instance + + +@pytest.mark.anyio +async def test_export_success( + exporter, fastapi_app, client, set_mock_user, compliance_report_services_mock +): + set_mock_user(fastapi_app, [RoleEnum.SUPPLIER]) + response = await exporter.export( + compliance_report_id=1, + organization=Organization(name="TestOrg"), + include_data=True, + ) + assert isinstance(response, StreamingResponse) + assert response.status_code == 200 + assert ( + response.headers["Content-Disposition"] + == 'attachment; filename="FSE_TestOrg-2023.xlsx"' + ) + assert response.media_type == FILE_MEDIA_TYPE["XLSX"].value + + +@pytest.mark.anyio +async def test_export_no_data( + exporter, fastapi_app, client, set_mock_user, compliance_report_services_mock +): + exporter.repo.get_fse_paginated = AsyncMock(return_value=([],)) + set_mock_user(fastapi_app, [RoleEnum.SUPPLIER]) + response = await exporter.export( + compliance_report_id=1, + organization=Organization(name="TestOrg"), + include_data=False, + ) + assert isinstance(response, StreamingResponse) + assert response.status_code == 200 + assert ( + response.headers["Content-Disposition"] + == 'attachment; filename="FSE_TestOrg-2023.xlsx"' + ) + assert response.media_type == FILE_MEDIA_TYPE["XLSX"].value + + +@pytest.mark.anyio +async def test_export_invalid_compliance_report_id( + exporter, fastapi_app, client, set_mock_user +): + set_mock_user(fastapi_app, [RoleEnum.SUPPLIER]) + exporter.compliance_report_services.get_compliance_report_by_id = AsyncMock( + side_effect=DataNotFoundException("Report not found") + ) + with pytest.raises(DataNotFoundException) as exc_info: + await exporter.export( + compliance_report_id=999, + organization=Organization(name="TestOrg"), + include_data=True, + ) + assert str(exc_info.value) == "Report not found" diff --git a/backend/lcfs/tests/final_supply_equipment/test_final_supply_equipment_views.py b/backend/lcfs/tests/final_supply_equipment/test_final_supply_equipment_views.py new file mode 100644 index 000000000..a6eafdbcf --- /dev/null +++ b/backend/lcfs/tests/final_supply_equipment/test_final_supply_equipment_views.py @@ -0,0 +1,274 @@ +import io +from datetime import date +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient +from starlette.responses import StreamingResponse + +from lcfs.db.models import ComplianceReport +from lcfs.db.models.user.Role import RoleEnum +from lcfs.utils.constants import FILE_MEDIA_TYPE +from lcfs.web.api.compliance_report.schema import FinalSupplyEquipmentSchema +from lcfs.web.api.final_supply_equipment.schema import ( + FinalSupplyEquipmentsSchema, + FSEOptionsSchema, + PortsEnum, + LevelOfEquipmentSchema, +) + + +@pytest.mark.anyio +async def test_get_fse_options_success( + client: AsyncClient, fastapi_app: FastAPI, set_mock_user +): + with patch( + "lcfs.web.api.final_supply_equipment.services.FinalSupplyEquipmentServices.get_fse_options" + ) as mock_get_fse_options: + mock_get_fse_options.return_value = FSEOptionsSchema( + intended_user_types=[], + ports=[], + organization_names=[], + intended_use_types=[], + levels_of_equipment=[], + ) + + set_mock_user(fastapi_app, [RoleEnum.COMPLIANCE_REPORTING]) + url = fastapi_app.url_path_for("get_fse_options") + response = await client.get(url) + assert response.status_code == 200 + assert isinstance(response.json(), dict) + + +@pytest.mark.anyio +async def test_get_fse_options_unauthorized( + client: AsyncClient, fastapi_app: FastAPI, set_mock_user +): + set_mock_user(fastapi_app, [RoleEnum.SUPPLIER]) + url = fastapi_app.url_path_for("get_fse_options") + response = await client.get(url) + assert response.status_code == 403 + + +@pytest.mark.anyio +async def test_get_final_supply_equipments_paginated_success( + client: AsyncClient, fastapi_app: FastAPI, set_mock_user +): + with patch( + "lcfs.web.api.final_supply_equipment.services.FinalSupplyEquipmentServices.get_compliance_report_by_id" + ) as mock_get_compliance_report_by_id: + mock_get_compliance_report_by_id.return_value = ComplianceReport() + + with patch( + "lcfs.web.api.compliance_report.validation.ComplianceReportValidation.validate_compliance_report_access" + ) as mock_validate_report: + mock_validate_report.return_value = None + + with patch( + "lcfs.web.api.compliance_report.validation.ComplianceReportValidation.validate_organization_access" + ) as mock_validate_report_2: + mock_validate_report_2.return_value = None + + with patch( + "lcfs.web.api.final_supply_equipment.services.FinalSupplyEquipmentServices.get_fse_options" + ) as mock_get_fse_options: + mock_get_fse_options.return_value = FinalSupplyEquipmentsSchema() + + set_mock_user(fastapi_app, [RoleEnum.COMPLIANCE_REPORTING]) + url = fastapi_app.url_path_for("get_final_supply_equipments") + payload = { + "complianceReportId": 1, + "page": 1, + "size": 10, + "sortOrders": [], + "filters": [], + } + response = await client.post(url, json=payload) + assert response.status_code == 200 + assert "finalSupplyEquipments" in response.json() + + +@pytest.mark.anyio +async def test_get_final_supply_equipments_not_found( + client: AsyncClient, fastapi_app: FastAPI, set_mock_user +): + set_mock_user(fastapi_app, [RoleEnum.COMPLIANCE_REPORTING]) + url = fastapi_app.url_path_for("get_final_supply_equipments") + payload = { + "compliance_report_id": 999, + "page": 1, + "size": 10, + } + response = await client.post(url, json=payload) + assert response.status_code == 404 + assert response.json()["detail"] == "Compliance report not found for this period" + + +@pytest.mark.anyio +async def test_save_final_supply_equipment_create_success( + client: AsyncClient, fastapi_app: FastAPI, set_mock_user +): + with patch( + "lcfs.web.api.compliance_report.validation.ComplianceReportValidation.validate_organization_access" + ) as mock_validate_report: + mock_validate_report.return_value = None + + with patch( + "lcfs.web.api.final_supply_equipment.validation.FinalSupplyEquipmentValidation.check_equipment_uniqueness_and_overlap" + ) as mock_validate_fse: + mock_validate_fse.return_value = None + + with patch( + "lcfs.web.api.final_supply_equipment.services.FinalSupplyEquipmentServices.create_final_supply_equipment" + ) as mock_create_fse: + mock_create_fse.return_value = FinalSupplyEquipmentSchema( + final_supply_equipment_id=1, + compliance_report_id=42, + organization_name="ACME Charging Inc.", + supply_from_date=date(2025, 1, 1), + supply_to_date=date(2025, 12, 31), + registration_nbr="REG-98765", + kwh_usage=123.45, + serial_nbr="SN-ABC-12345", + manufacturer="ACME", + model="Model X", + level_of_equipment=LevelOfEquipmentSchema( + level_of_equipment_id=1, name="", display_order=1 + ), + ports=PortsEnum.SINGLE, + intended_use_types=[], + intended_user_types=[], + street_address="123 Main St", + city="Metropolis", + postal_code="A1A 1A1", + latitude=49.2827, + longitude=-123.1207, + notes="Some additional info here", + ) + + set_mock_user(fastapi_app, [RoleEnum.COMPLIANCE_REPORTING]) + url = fastapi_app.url_path_for("save_final_supply_equipment_row") + payload = { + "complianceReportId": 456, + "organizationName": "Example Organization", + "supplyFromDate": "2025-01-01", + "supplyToDate": "2025-12-31", + "kwhUsage": 250.5, + "serialNbr": "ABC123XYZ", + "manufacturer": "Generic Manufacturer", + "model": "Model X", + "levelOfEquipment": "Level 2", + "ports": "Single port", + "intendedUses": ["public charging", "fleet management"], + "intendedUsers": ["general public", "employees"], + "streetAddress": "123 Main St", + "city": "Anytown", + "postalCode": "A1A 1A1", + "latitude": 49.2827, + "longitude": -123.1207, + "notes": "Additional notes about the equipment", + } + response = await client.post(url, json=payload) + print(response.json()) + assert response.status_code == 201 + assert "finalSupplyEquipmentId" in response.json() + + +@pytest.mark.anyio +async def test_save_final_supply_equipment_delete_success( + client: AsyncClient, fastapi_app: FastAPI, set_mock_user +): + with patch( + "lcfs.web.api.compliance_report.validation.ComplianceReportValidation.validate_organization_access" + ) as mock_validate_report: + mock_validate_report.return_value = None + set_mock_user(fastapi_app, [RoleEnum.COMPLIANCE_REPORTING]) + url = fastapi_app.url_path_for("save_final_supply_equipment_row") + payload = { + "final_supply_equipment_id": 123, + "compliance_report_id": 456, + "organization_name": "Example Organization", + "supply_from_date": "2025-01-01", + "supply_to_date": "2025-12-31", + "kwh_usage": 250.5, + "serial_nbr": "ABC123XYZ", + "manufacturer": "Generic Manufacturer", + "model": "Model X", + "level_of_equipment": "Level 2", + "ports": PortsEnum.SINGLE, + "intended_uses": ["public charging", "fleet management"], + "intended_users": ["general public", "employees"], + "street_address": "123 Main St", + "city": "Anytown", + "postal_code": "A1A 1A1", + "latitude": 49.2827, + "longitude": -123.1207, + "notes": "Additional notes about the equipment", + "deleted": True, + } + response = await client.post(url, json=payload) + assert response.status_code == 201 + assert ( + response.json()["message"] + == "Final supply equipment row deleted successfully" + ) + + +@pytest.mark.anyio +async def test_search_table_options_with_manufacturer( + client: AsyncClient, fastapi_app: FastAPI, set_mock_user +): + set_mock_user(fastapi_app, [RoleEnum.COMPLIANCE_REPORTING]) + url = fastapi_app.url_path_for("search_table_options") + params = {"manufacturer": "TestManufacturer"} + response = await client.get(url, params=params) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +@pytest.mark.anyio +async def test_search_table_options_without_manufacturer( + client: AsyncClient, fastapi_app: FastAPI, set_mock_user +): + set_mock_user(fastapi_app, [RoleEnum.COMPLIANCE_REPORTING]) + url = fastapi_app.url_path_for("search_table_options") + response = await client.get(url) + assert response.status_code == 200 + assert response.json() == [] + + +@pytest.mark.anyio +async def test_export_success(client: AsyncClient, fastapi_app: FastAPI, set_mock_user): + with patch( + "lcfs.web.api.compliance_report.validation.ComplianceReportValidation.validate_organization_access" + ) as mock_validate_report: + mock_validate_report.return_value = None + with patch( + "lcfs.web.api.final_supply_equipment.export.FinalSupplyEquipmentExporter.export" + ) as mock_export: + headers = {"Content-Disposition": f'attachment; filename="Cats"'} + mock_export.return_value = StreamingResponse( + io.BytesIO(), + media_type=FILE_MEDIA_TYPE.XLSX.value, + headers=headers, + ) + set_mock_user(fastapi_app, [RoleEnum.COMPLIANCE_REPORTING]) + url = fastapi_app.url_path_for("export", report_id="1") + response = await client.get(url) + assert response.status_code == 200 + assert ( + response.headers["content-type"] + == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + + +@pytest.mark.anyio +async def test_export_invalid_report_id( + client: AsyncClient, fastapi_app: FastAPI, set_mock_user +): + set_mock_user(fastapi_app, [RoleEnum.COMPLIANCE_REPORTING]) + url = fastapi_app.url_path_for("export", report_id="invalid") + response = await client.get(url) + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid report id. Must be an integer." diff --git a/backend/lcfs/tests/fuel_code/test_fuel_code_exporter.py b/backend/lcfs/tests/fuel_code/test_fuel_code_exporter.py index d5a8f8e54..ea4319a2a 100644 --- a/backend/lcfs/tests/fuel_code/test_fuel_code_exporter.py +++ b/backend/lcfs/tests/fuel_code/test_fuel_code_exporter.py @@ -59,7 +59,9 @@ async def test_export_success(): assert response.media_type == "text/csv" assert "attachment; filename=" in response.headers["Content-Disposition"] repo_mock.get_fuel_codes_paginated.assert_called_once_with( - pagination=PaginationRequestSchema(page=1, size=1000, filters=[], sortOrders=[]) + pagination=PaginationRequestSchema( + page=1, size=1000, filters=[], sort_orders=[] + ) ) # Verify file content diff --git a/backend/lcfs/tests/fuel_code/test_fuel_code_views.py b/backend/lcfs/tests/fuel_code/test_fuel_code_views.py index 28f75bb4d..3425dae70 100644 --- a/backend/lcfs/tests/fuel_code/test_fuel_code_views.py +++ b/backend/lcfs/tests/fuel_code/test_fuel_code_views.py @@ -2,7 +2,6 @@ from unittest.mock import patch import pytest -from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from httpx import AsyncClient from starlette import status diff --git a/backend/lcfs/utils/constants.py b/backend/lcfs/utils/constants.py index 87917c740..3305bcf35 100644 --- a/backend/lcfs/utils/constants.py +++ b/backend/lcfs/utils/constants.py @@ -1,5 +1,6 @@ from enum import Enum from lcfs.db.models.transfer.TransferStatus import TransferStatusEnum +from lcfs.utils.spreadsheet_builder import SpreadsheetColumn class LCFS_Constants: @@ -7,16 +8,16 @@ class LCFS_Constants: USERS_EXPORT_FILENAME = "BC-LCFS-BCeID-Users" USERS_EXPORT_SHEETNAME = "BCeID Users" USERS_EXPORT_COLUMNS = [ - "Last name", - "First name", - "Email", - "BCeID User ID", - "Title", - "Phone", - "Mobile", - "Status", - "Role(s)", - "Organization name", + SpreadsheetColumn("Last name", "text"), + SpreadsheetColumn("First name", "text"), + SpreadsheetColumn("Email", "text"), + SpreadsheetColumn("BCeID User ID", "text"), + SpreadsheetColumn("Title", "text"), + SpreadsheetColumn("Phone", "text"), + SpreadsheetColumn("Mobile", "text"), + SpreadsheetColumn("Status", "text"), + SpreadsheetColumn("Role(s)", "text"), + SpreadsheetColumn("Organization name", "text"), ] FROM_ORG_TRANSFER_STATUSES = [ TransferStatusEnum.Draft, @@ -39,23 +40,25 @@ class LCFS_Constants: # Export transactions TRANSACTIONS_EXPORT_MEDIA_TYPE = "application/vnd.ms-excel" TRANSACTIONS_EXPORT_COLUMNS = [ - "ID", - "Compliance period", - "Type", - "Compliance units from", - "Compliance units to", - "Number of units", - "Value per unit", - "Category", - "Status", - "Effective Date", - "Recorded", - "Approved", - "Comments (external)", + SpreadsheetColumn("ID", "int"), + SpreadsheetColumn("Compliance period", "text"), + SpreadsheetColumn("Type", "text"), + SpreadsheetColumn("Compliance units from", "int"), + SpreadsheetColumn("Compliance units to", "int"), + SpreadsheetColumn("Number of units", "int"), + SpreadsheetColumn("Value per unit", "float"), + SpreadsheetColumn("Category", "text"), + SpreadsheetColumn("Status", "text"), + SpreadsheetColumn("Effective Date", "date"), + SpreadsheetColumn("Recorded", "date"), + SpreadsheetColumn("Approved", "date"), + SpreadsheetColumn("Comments (external)", "text"), ] TRANSACTIONS_EXPORT_SHEETNAME = "Transactions" TRANSACTIONS_EXPORT_FILENAME = "BC-LCFS-transactions" - LEGISLATION_TRANSITION_YEAR = "2024" # First year that the new LCFS Legislation takes effect + LEGISLATION_TRANSITION_YEAR = ( + "2024" # First year that the new LCFS Legislation takes effect + ) class FILE_MEDIA_TYPE(Enum): diff --git a/backend/lcfs/utils/spreadsheet_builder.py b/backend/lcfs/utils/spreadsheet_builder.py index b0087b401..2b6aaccef 100644 --- a/backend/lcfs/utils/spreadsheet_builder.py +++ b/backend/lcfs/utils/spreadsheet_builder.py @@ -1,15 +1,34 @@ from io import BytesIO +from typing import Literal, TypedDict, List, Dict, Any + import structlog import pandas as pd from openpyxl import styles from openpyxl.utils import get_column_letter import xlwt - +from openpyxl.worksheet.datavalidation import DataValidation +from openpyxl.worksheet.worksheet import Worksheet logger = structlog.get_logger(__name__) MAX_XLS_WIDTH = 65535 +class SpreadsheetColumn: + def __init__( + self, label: str, column_type: Literal["float", "int", "text", "date"] + ): + self.label = label + self.column_type = column_type + + +class SheetData(TypedDict): + sheet_name: str + columns: List[SpreadsheetColumn] + rows: List[Any] + styles: Dict[str, Any] + validators: List[DataValidation] + + class SpreadsheetBuilder: """ A class to build spreadsheets in xlsx, xls, or csv format. @@ -41,10 +60,15 @@ def __init__(self, file_format: str = "xls"): raise ValueError(f"Unsupported file format: {file_format}") self.file_format = file_format - self.sheets_data = [] + self.sheets_data: List[SheetData] = [] def add_sheet( - self, sheet_name: str, columns: list, rows: list, styles: dict = None + self, + sheet_name: str, + columns: list[SpreadsheetColumn], + rows: list, + styles: dict = None, + validators: list[DataValidation] = [], ): self.sheets_data.append( { @@ -52,6 +76,7 @@ def add_sheet( "columns": columns, "rows": rows, "styles": styles or {}, + "validators": validators, } ) @@ -80,24 +105,35 @@ def _write_xlsx(self, output): # Auto-adjusting column widths self._auto_adjust_column_width_xlsx(writer, sheet) - def _write_sheet_to_excel(self, writer, sheet): - df = pd.DataFrame(sheet["rows"], columns=sheet["columns"]) - df.to_excel(writer, index=False, sheet_name=sheet["sheet_name"]) - - worksheet = writer.sheets[sheet["sheet_name"]] + def _write_sheet_to_excel(self, writer, sheet: SheetData): + columns = [column.label for column in sheet["columns"]] - # Apply number formatting for numeric columns - for col_idx, column in enumerate(df.columns): - if df[column].dtype in ["float", "int"]: - for row in worksheet.iter_rows( - min_row=2, - max_row=worksheet.max_row, - min_col=col_idx + 1, - max_col=col_idx + 1, - ): - for cell in row: + df = pd.DataFrame(sheet["rows"], columns=columns) + df.to_excel(writer, index=False, sheet_name=sheet["sheet_name"]) + worksheet: Worksheet = writer.sheets[sheet["sheet_name"]] + + for data_validator in sheet["validators"]: + worksheet.add_data_validation(data_validator) + + # Apply number formatting based on column type + for col_idx, column in enumerate(sheet["columns"]): + # Iterate over each cell in the current column, starting from row 2 + for row in worksheet.iter_rows( + min_row=2, + max_row=10000, # Apply formatting to the first 10,000 rows even if they don't have data + min_col=col_idx + 1, + max_col=col_idx + 1, + ): + for cell in row: + cell.alignment = styles.Alignment(horizontal="left") + if column.column_type == "int": cell.number_format = "#,##0" - cell.alignment = styles.Alignment(horizontal="left") + cell.alignment = styles.Alignment(horizontal="right") + elif column.column_type == "float": + cell.number_format = "#,##0.00" + cell.alignment = styles.Alignment(horizontal="right") + elif column.column_type == "date": + cell.number_format = "yyyy-mm-dd" self._apply_excel_styling(writer, sheet) @@ -113,7 +149,6 @@ def _apply_excel_styling(self, writer, sheet): bottom=styles.Side(style=None), ) cell.font = styles.Font(bold=False) - cell.alignment = styles.Alignment(horizontal="left") if sheet["styles"].get("bold_headers"): for cell in worksheet[1]: @@ -126,8 +161,8 @@ def _write_xls(self, output): self._write_sheet_to_xls(book, sheet_data) book.save(output) - def _write_sheet_to_xls(self, book, sheet_data): - sheet = book.add_sheet(sheet_data["sheet_name"]) + def _write_sheet_to_xls(self, book: xlwt.Workbook, sheet_data: SheetData): + sheet: xlwt.Worksheet = book.add_sheet(sheet_data["sheet_name"]) # Styles for headers bold_style = xlwt.XFStyle() @@ -151,7 +186,7 @@ def _write_sheet_to_xls(self, book, sheet_data): ) for col_index, column in enumerate(sheet_data["columns"]): - sheet.write(0, col_index, column, header_style) + sheet.write(0, col_index, column.label, header_style) # Auto-adjusting column widths self._auto_adjust_column_width_xls(sheet, sheet_data) @@ -184,16 +219,18 @@ def _auto_adjust_column_width_xlsx(self, writer, sheet_data): worksheet.column_dimensions[column_letter].width = adjusted_width def _auto_adjust_column_width_xls(self, sheet, sheet_data): + column_labels = [column.label for column in sheet_data["columns"]] + column_widths = [ max(len(str(cell)) for cell in column) + 2 - for column in zip(*sheet_data["rows"], sheet_data["columns"]) + for column in zip(*sheet_data["rows"], column_labels) ] for i, width in enumerate(column_widths): sheet.col(i).width = min(256 * width, MAX_XLS_WIDTH) def _write_csv(self, output): if self.sheets_data: - df = pd.DataFrame( - self.sheets_data[0]["rows"], columns=self.sheets_data[0]["columns"] - ) + column_labels = [column.label for column in self.sheets_data[0]["columns"]] + + df = pd.DataFrame(self.sheets_data[0]["rows"], columns=column_labels) df.to_csv(output, index=False) diff --git a/backend/lcfs/web/api/final_supply_equipment/export.py b/backend/lcfs/web/api/final_supply_equipment/export.py new file mode 100644 index 000000000..322bc0d97 --- /dev/null +++ b/backend/lcfs/web/api/final_supply_equipment/export.py @@ -0,0 +1,221 @@ +import io +from typing import List + +from fastapi import Depends +from openpyxl.worksheet.datavalidation import DataValidation +from starlette.responses import StreamingResponse + +from lcfs.db.models import Organization +from lcfs.utils.constants import FILE_MEDIA_TYPE +from lcfs.utils.spreadsheet_builder import SpreadsheetBuilder, SpreadsheetColumn +from lcfs.web.api.base import PaginationRequestSchema +from lcfs.web.api.compliance_report.services import ComplianceReportServices +from lcfs.web.api.final_supply_equipment.repo import FinalSupplyEquipmentRepository +from lcfs.web.core.decorators import service_handler + +FSE_EXPORT_FILENAME = "FSE" +FSE_EXPORT_SHEETNAME = "FSE" +FSE_EXPORT_COLUMNS = [ + SpreadsheetColumn("Organization", "text"), + SpreadsheetColumn("Supply from date", "date"), + SpreadsheetColumn("Supply to date", "date"), + SpreadsheetColumn("kWh usage", "int"), + SpreadsheetColumn("Serial #", "text"), + SpreadsheetColumn("Manufacturer", "text"), + SpreadsheetColumn("Model", "text"), + SpreadsheetColumn("Level of equipment", "text"), + SpreadsheetColumn("Ports", "text"), + SpreadsheetColumn("Intended use", "text"), + SpreadsheetColumn("Intended users", "text"), + SpreadsheetColumn("Street address", "text"), + SpreadsheetColumn("City", "text"), + SpreadsheetColumn("Postal code", "text"), + SpreadsheetColumn("Latitude", "float"), + SpreadsheetColumn("Longitude", "float"), + SpreadsheetColumn("Notes", "text"), +] + + +class FinalSupplyEquipmentExporter: + def __init__( + self, + repo: FinalSupplyEquipmentRepository = Depends(FinalSupplyEquipmentRepository), + compliance_report_services: ComplianceReportServices = Depends( + ComplianceReportServices + ), + ) -> None: + self.compliance_report_services = compliance_report_services + self.repo = repo + + @service_handler + async def export( + self, + compliance_report_id: int, + organization: Organization, + include_data=True, + ) -> StreamingResponse: + """ + Prepares a list of users in a file that is downloadable + """ + export_format = ( + "xlsx" # Uses Advanced Excel features, so only use modern format + ) + compliance_report = ( + await self.compliance_report_services.get_compliance_report_by_id( + report_id=compliance_report_id + ) + ) + + validators = await self._create_validators(compliance_report, organization) + + data = [] + if include_data: + data = await self.load_fse_data(compliance_report_id) + + # Create a spreadsheet + builder = SpreadsheetBuilder(file_format=export_format) + builder.add_sheet( + sheet_name=FSE_EXPORT_SHEETNAME, + columns=FSE_EXPORT_COLUMNS, + rows=data, + styles={"bold_headers": True}, + validators=validators, + ) + file_content = builder.build_spreadsheet() + + compliance_period = compliance_report.compliance_period.description + filename = f"{FSE_EXPORT_FILENAME}_{organization.name}-{compliance_period}.{export_format}" + headers = {"Content-Disposition": f'attachment; filename="{filename}"'} + + return StreamingResponse( + io.BytesIO(file_content), + media_type=FILE_MEDIA_TYPE[export_format.upper()].value, + headers=headers, + ) + + async def _create_validators(self, compliance_report, organization): + validators: List[DataValidation] = [] + table_options = await self.repo.get_fse_options(organization) + + # Select Validators + org_options = [obj for obj in table_options[4]] + org_validator = DataValidation( + type="list", + formula1=f'"{",".join(org_options)}"', + ) + org_validator.add("A2:A100000") + validators.append(org_validator) + + level_of_equipment_options = [obj.name for obj in table_options[1]] + level_of_equipment_validator = DataValidation( + type="list", + formula1=f'"{",".join(level_of_equipment_options)}"', + error="Please select a valid option from the list", + showDropDown=False, + showErrorMessage=True, + ) + level_of_equipment_validator.add("H2:H100000") + validators.append(level_of_equipment_validator) + + port_options = [obj for obj in table_options[3]] + port_validator = DataValidation( + type="list", + formula1=f'"{",".join(port_options)}"', + error="Please select a valid option from the list", + showDropDown=False, + showErrorMessage=True, + ) + port_validator.add("I2:I100000") + validators.append(port_validator) + + intended_use_options = [obj.type for obj in table_options[0]] + intended_use_validator = DataValidation( + type="list", + formula1=f'"{",".join(intended_use_options)}"', + showDropDown=False, + ) + intended_use_validator.add("J2:J100000") + validators.append(intended_use_validator) + + intended_user_options = [obj.type_name for obj in table_options[2]] + intended_user_validator = DataValidation( + type="list", + formula1=f'"{",".join(intended_user_options)}"', + showDropDown=False, + ) + intended_user_validator.add("K2:K100000") + validators.append(intended_user_validator) + + # Date Validators + effective_date = compliance_report.compliance_period.effective_date + expiration_date = compliance_report.compliance_period.expiration_date + date_validator = DataValidation( + type="date", + operator="between", + # Lower bound of the date range + formula1=f"DATE({effective_date.year}, {effective_date.month}, {effective_date.day})", + # Upper bound of the date range + formula2=f"DATE({expiration_date.year}, {expiration_date.month}, {expiration_date.day})", + allow_blank=False, + showErrorMessage=True, + errorTitle="Invalid Date", + error=f"Please enter a date that falls within the {compliance_report.compliance_period.description} calendar year.", + ) + date_validator.add("B2:B100000") + date_validator.add("C2:C100000") + validators.append(date_validator) + + # Number Validators + decimal_validator = DataValidation( + type="decimal", + showErrorMessage=True, + error="Please enter a valid decimal.", + ) + decimal_validator.add("O2:O100000") + decimal_validator.add("P2:P100000") + validators.append(decimal_validator) + integer_validator = DataValidation( + type="whole", + showErrorMessage=True, + error="Please enter a valid integer.", + ) + integer_validator.add("D2:D100000") + validators.append(integer_validator) + return validators + + async def load_fse_data(self, compliance_report_id): + results = await self.repo.get_fse_paginated( + compliance_report_id=compliance_report_id, + pagination=PaginationRequestSchema( + page=1, size=1000, filters=[], sort_orders=[] + ), + ) + data = [] + for final_supply_equipment in results[0]: + data.append( + [ + final_supply_equipment.organization_name, + final_supply_equipment.supply_from_date, + final_supply_equipment.supply_to_date, + final_supply_equipment.kwh_usage, + final_supply_equipment.serial_nbr, + final_supply_equipment.manufacturer, + final_supply_equipment.model, + final_supply_equipment.level_of_equipment.name, + final_supply_equipment.ports, + ", ".join( + type.type for type in final_supply_equipment.intended_use_types + ), + ", ".join( + type.type_name + for type in final_supply_equipment.intended_user_types + ), + final_supply_equipment.street_address, + final_supply_equipment.city, + final_supply_equipment.postal_code, + final_supply_equipment.latitude, + final_supply_equipment.longitude, + final_supply_equipment.notes, + ] + ) + return data diff --git a/backend/lcfs/web/api/final_supply_equipment/repo.py b/backend/lcfs/web/api/final_supply_equipment/repo.py index b70bcd3d7..81647abe7 100644 --- a/backend/lcfs/web/api/final_supply_equipment/repo.py +++ b/backend/lcfs/web/api/final_supply_equipment/repo.py @@ -1,13 +1,27 @@ import structlog -from typing import List, Tuple -from lcfs.db.models.compliance import EndUserType, FinalSupplyEquipment, ComplianceReport +from typing import List, Tuple, Any, Coroutine, Sequence + +from lcfs.db.models import ( + FinalSupplyEquipment, + EndUseType, + LevelOfEquipment, + EndUserType, +) +from lcfs.db.models.compliance import ( + EndUserType, + FinalSupplyEquipment, + ComplianceReport, +) from lcfs.db.models.compliance.FinalSupplyEquipmentRegNumber import ( FinalSupplyEquipmentRegNumber, ) from lcfs.db.models.compliance.LevelOfEquipment import LevelOfEquipment from lcfs.db.models.fuel.EndUseType import EndUseType from lcfs.web.api.base import PaginationRequestSchema -from lcfs.web.api.final_supply_equipment.schema import FinalSupplyEquipmentCreateSchema, PortsEnum +from lcfs.web.api.final_supply_equipment.schema import ( + FinalSupplyEquipmentCreateSchema, + PortsEnum, +) from sqlalchemy import and_, delete, distinct, exists, select, update from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.ext.asyncio import AsyncSession @@ -25,13 +39,12 @@ def __init__(self, db: AsyncSession = Depends(get_async_db_session)): self.db = db @repo_handler - async def get_fse_options( - self, organization - ) -> Tuple[ - List[EndUseType], - List[LevelOfEquipment], - List[PortsEnum], - List[str], + async def get_fse_options(self, organization) -> tuple[ + list[EndUseType], + list[LevelOfEquipment], + list[EndUserType], + list[str], + list[str], ]: """ Retrieve all FSE options in a single database transaction @@ -116,8 +129,14 @@ async def get_organization_names(self, organization) -> List[str]: organization_names = ( await self.db.execute( select(distinct(FinalSupplyEquipment.organization_name)) - .join(ComplianceReport, FinalSupplyEquipment.compliance_report_id == ComplianceReport.compliance_report_id) - .filter(ComplianceReport.organization_id == organization.organization_id) + .join( + ComplianceReport, + FinalSupplyEquipment.compliance_report_id + == ComplianceReport.compliance_report_id, + ) + .filter( + ComplianceReport.organization_id == organization.organization_id + ) .filter(FinalSupplyEquipment.organization_name.isnot(None)) ) ).all() @@ -161,8 +180,7 @@ async def get_level_of_equipment_by_name(self, name: str) -> LevelOfEquipment: return ( ( await self.db.execute( - select(LevelOfEquipment).where( - LevelOfEquipment.name == name) + select(LevelOfEquipment).where(LevelOfEquipment.name == name) ) ) .unique() @@ -189,14 +207,12 @@ async def get_fse_list(self, report_id: int) -> List[FinalSupplyEquipment]: @repo_handler async def get_fse_paginated( self, pagination: PaginationRequestSchema, compliance_report_id: int - ) -> List[FinalSupplyEquipment]: + ) -> tuple[Sequence[FinalSupplyEquipment], Any]: """ Retrieve a list of final supply equipment from the database with pagination """ - conditions = [ - FinalSupplyEquipment.compliance_report_id == compliance_report_id] - offset = 0 if pagination.page < 1 else ( - pagination.page - 1) * pagination.size + conditions = [FinalSupplyEquipment.compliance_report_id == compliance_report_id] + offset = 0 if pagination.page < 1 else (pagination.page - 1) * pagination.size limit = pagination.size query = ( select(FinalSupplyEquipment) @@ -251,8 +267,7 @@ async def update_final_supply_equipment( await self.db.flush() await self.db.refresh( final_supply_equipment, - ["level_of_equipment", - "intended_use_types", "intended_user_types"], + ["level_of_equipment", "intended_use_types", "intended_user_types"], ) return updated_final_supply_equipment @@ -340,7 +355,9 @@ async def increment_seq_by_org_and_postal_code( return sequence_number @repo_handler - async def check_uniques_of_fse_row(self, row: FinalSupplyEquipmentCreateSchema) -> bool: + async def check_uniques_of_fse_row( + self, row: FinalSupplyEquipmentCreateSchema + ) -> bool: """ Check if a duplicate final supply equipment row exists in the database based on the provided data. Returns True if a duplicate is found, False otherwise. diff --git a/backend/lcfs/web/api/final_supply_equipment/views.py b/backend/lcfs/web/api/final_supply_equipment/views.py index abe0d9eaa..6b4a8d434 100644 --- a/backend/lcfs/web/api/final_supply_equipment/views.py +++ b/backend/lcfs/web/api/final_supply_equipment/views.py @@ -1,6 +1,6 @@ -import structlog from typing import List, Optional, Union +import structlog from fastapi import ( APIRouter, Body, @@ -11,15 +11,17 @@ Response, Depends, ) +from starlette.responses import StreamingResponse from lcfs.db import dependencies +from lcfs.db.models.user.Role import RoleEnum from lcfs.web.api.base import PaginationRequestSchema -from lcfs.web.api.compliance_report.validation import ComplianceReportValidation from lcfs.web.api.compliance_report.schema import ( CommonPaginatedReportRequestSchema, FinalSupplyEquipmentSchema, ) from lcfs.web.api.compliance_report.validation import ComplianceReportValidation +from lcfs.web.api.final_supply_equipment.export import FinalSupplyEquipmentExporter from lcfs.web.api.final_supply_equipment.schema import ( DeleteFinalSupplyEquipmentResponseSchema, FSEOptionsSchema, @@ -31,7 +33,6 @@ FinalSupplyEquipmentValidation, ) from lcfs.web.core.decorators import view_handler -from lcfs.db.models.user.Role import RoleEnum router = APIRouter() logger = structlog.get_logger(__name__) @@ -74,11 +75,6 @@ async def get_final_supply_equipments( compliance_report = await service.get_compliance_report_by_id( compliance_report_id ) - if not compliance_report: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Compliance report not found", - ) await report_validate.validate_compliance_report_access(compliance_report) await report_validate.validate_organization_access(compliance_report_id) @@ -158,3 +154,59 @@ async def search_table_options( if manufacturer: return await service.search_manufacturers(manufacturer) return [] + + +@router.get( + "/export/{report_id}", + response_class=StreamingResponse, + status_code=status.HTTP_200_OK, +) +@view_handler([RoleEnum.COMPLIANCE_REPORTING, RoleEnum.SIGNING_AUTHORITY]) +async def export( + request: Request, + report_id: str, + report_validate: ComplianceReportValidation = Depends(), + exporter: FinalSupplyEquipmentExporter = Depends(), +): + """ + Endpoint to export information of all FSE + """ + try: + compliance_report_id = int(report_id) + except ValueError: + raise HTTPException( + status_code=400, detail="Invalid report id. Must be an integer." + ) + + await report_validate.validate_organization_access(compliance_report_id) + + organization = request.user.organization + return await exporter.export(compliance_report_id, organization, True) + + +@router.get( + "/template/{report_id}", + response_class=StreamingResponse, + status_code=status.HTTP_200_OK, +) +@view_handler([RoleEnum.COMPLIANCE_REPORTING, RoleEnum.SIGNING_AUTHORITY]) +async def get_template( + request: Request, + report_id: str, + report_validate: ComplianceReportValidation = Depends(), + exporter: FinalSupplyEquipmentExporter = Depends(), +): + """ + Endpoint to export a template for FSE + """ + try: + compliance_report_id = int(report_id) + except ValueError: + raise HTTPException( + status_code=400, detail="Invalid report id. Must be an integer." + ) + + await report_validate.validate_organization_access(compliance_report_id) + + organization = request.user.organization + return await exporter.export(compliance_report_id, organization, False) diff --git a/backend/lcfs/web/api/fuel_code/export.py b/backend/lcfs/web/api/fuel_code/export.py index e1f7c70ec..bfaa68dfd 100644 --- a/backend/lcfs/web/api/fuel_code/export.py +++ b/backend/lcfs/web/api/fuel_code/export.py @@ -5,7 +5,7 @@ from starlette.responses import StreamingResponse from lcfs.utils.constants import FILE_MEDIA_TYPE -from lcfs.utils.spreadsheet_builder import SpreadsheetBuilder +from lcfs.utils.spreadsheet_builder import SpreadsheetBuilder, SpreadsheetColumn from lcfs.web.api.base import PaginationRequestSchema from lcfs.web.api.fuel_code.repo import FuelCodeRepository from lcfs.web.core.decorators import service_handler @@ -15,31 +15,31 @@ FUEL_CODE_EXPORT_FILENAME = "BC-LCFS-Fuel-Codes" FUEL_CODE_EXPORT_SHEETNAME = "Fuel Codes" FUEL_CODE_EXPORT_COLUMNS = [ - "Status", - "Prefix", - "Fuel code", - "Carbon intensity", - "EDRMS#", - "Company", - "Contact name", - "Contact email", - "Application date", - "Approval date", - "Effective date", - "Expiry date", - "Fuel", - "Feedstock", - "Feedstock location", - "Misc", - "Fuel production facility city", - "Fuel production facility province/state", - "Fuel production facility country", - "Facility nameplate capacity", - "Unit", - "Feedstock transport mode", - "Finished fuel transport mode", - "Former company", - "Notes", + SpreadsheetColumn("Status", "text"), + SpreadsheetColumn("Prefix", "text"), + SpreadsheetColumn("Fuel code", "text"), + SpreadsheetColumn("Carbon intensity", "text"), + SpreadsheetColumn("EDRMS#", "text"), + SpreadsheetColumn("Company", "text"), + SpreadsheetColumn("Contact name", "text"), + SpreadsheetColumn("Contact email", "text"), + SpreadsheetColumn("Application date", "date"), + SpreadsheetColumn("Approval date", "date"), + SpreadsheetColumn("Effective date", "date"), + SpreadsheetColumn("Expiry date", "date"), + SpreadsheetColumn("Fuel", "text"), + SpreadsheetColumn("Feedstock", "text"), + SpreadsheetColumn("Feedstock location", "text"), + SpreadsheetColumn("Misc", "text"), + SpreadsheetColumn("Fuel production facility city", "text"), + SpreadsheetColumn("Fuel production facility province/state", "text"), + SpreadsheetColumn("Fuel production facility country", "text"), + SpreadsheetColumn("Facility nameplate capacity", "text"), + SpreadsheetColumn("Unit", "text"), + SpreadsheetColumn("Feedstock transport mode", "text"), + SpreadsheetColumn("Finished fuel transport mode", "text"), + SpreadsheetColumn("Former company", "text"), + SpreadsheetColumn("Notes", "text"), ] @@ -61,7 +61,7 @@ async def export(self, export_format) -> StreamingResponse: page=1, size=1000, filters=[], - sortOrders=[], + sort_orders=[], ) ) diff --git a/backend/lcfs/web/api/organizations/services.py b/backend/lcfs/web/api/organizations/services.py index 3e122634b..3cab2a580 100644 --- a/backend/lcfs/web/api/organizations/services.py +++ b/backend/lcfs/web/api/organizations/services.py @@ -21,7 +21,7 @@ from lcfs.services.tfrs.redis_balance import ( RedisBalanceService, ) -from lcfs.utils.spreadsheet_builder import SpreadsheetBuilder +from lcfs.utils.spreadsheet_builder import SpreadsheetBuilder, SpreadsheetColumn from lcfs.web.api.base import ( PaginationRequestSchema, PaginationResponseSchema, @@ -98,11 +98,11 @@ async def export_organizations(self) -> StreamingResponse: builder.add_sheet( sheet_name="Organizations", columns=[ - "ID", - "Organization Name", - "Compliance Units", - "In Reserve", - "Registered", + SpreadsheetColumn("ID", "int"), + SpreadsheetColumn("Organization Name", "text"), + SpreadsheetColumn("Compliance Units", "int"), + SpreadsheetColumn("In Reserve", "text"), + SpreadsheetColumn("Registered", "date"), ], rows=data, styles={"bold_headers": True}, diff --git a/frontend/src/assets/locales/en/finalSupplyEquipment.json b/frontend/src/assets/locales/en/finalSupplyEquipment.json index 6d774cc7c..b9c1dce6e 100644 --- a/frontend/src/assets/locales/en/finalSupplyEquipment.json +++ b/frontend/src/assets/locales/en/finalSupplyEquipment.json @@ -1,6 +1,5 @@ { "fseTitle": "Final supply equipment (FSE)", - "addFSErowsTitle": "Final supply equipment", "reportingResponsibilityInfo": [ "Report dates of supply for your final supply equipment (FSE).", "Organization: Enter your organization name unless you are reporting FSE that you have received allocated responsibility. In this case, enter the utility account holder's organization name.", @@ -8,25 +7,9 @@ ], "newFinalSupplyEquipmentBtn": "New final supply equipment", "noFinalSupplyEquipmentsFound": "No final supply equipments found", - "finalSupplyEquipmentDownloadBtn": "Download as Excel", - "finalSupplyEquipmentDownloadingMsg": "Downloading final supply equipments information", - "finalSupplyEquipmentDownloadFailMsg": "Failed to download final supply equipment information.", - "finalSupplyEquipmentLoadFailMsg": "Failed to load final supply equipment information.", - "LoadFailMsg": "Failed to load final supply equipment rows", - "newFinalSupplyEquipmentTitle": "Add new final supply equipment(s)", - "editFinalSupplyEquipmentTitle": "Edit draft final supply equipment", - "approvedFinalSupplyEquipmentTitle": "Approved draft final supply equipment", - "deletedFinalSupplyEquipmentTitle": "Deleted draft final supply equipment", - "finalSupplyEquipmentAddSuccessMsg": "Final supply equipment(s) added successfully.", - "finalSupplyEquipmentDeleteSuccessMsg": "Final supply equipment deleted successfully.", - "finalSupplyEquipmentAddFailMsg": "Failed to add final supply equipment(s).", - "saveFinalSupplyEquipmentBtn": "Save final supply equipments", - "deleteFinalSupplyEquipmentBtn": "Delete final supply equipment", - "deleteFinalSupplyEquipment": "Delete final supply equipment", - "deleteConfirmText": "Are you sure you want to delete this final supply equipment?", - "approveFinalSupplyEquipmentBtn": "Approve final supply equipment", - "approveFinalSupplyEquipment": "Approve final supply equipment", - "approveConfirmText": "Are you sure you want to approve this final supply equipment?", + "downloadBtn": "Download Excel template", + "downloadWithDataBtn": "Include data", + "downloadWithoutDataBtn": "Do not include data", "addRow": "Add row", "rows": "rows", "finalSupplyEquipmentColLabels": { diff --git a/frontend/src/components/BCButton/index.jsx b/frontend/src/components/BCButton/index.jsx index 5b82934cf..1b6c37e23 100644 --- a/frontend/src/components/BCButton/index.jsx +++ b/frontend/src/components/BCButton/index.jsx @@ -26,7 +26,14 @@ const BCButton = forwardRef( size={size} ownerState={{ color, variant, size, circular, iconOnly }} > - {isLoading ? : children} + {isLoading ? ( + + ) : ( + children + )} ) } diff --git a/frontend/src/components/BCDataGrid/BCGridEditor.jsx b/frontend/src/components/BCDataGrid/BCGridEditor.jsx index 6b1493e1f..514baaf9c 100644 --- a/frontend/src/components/BCDataGrid/BCGridEditor.jsx +++ b/frontend/src/components/BCDataGrid/BCGridEditor.jsx @@ -125,7 +125,7 @@ export const BCGridEditor = ({ onCellEditingStopped({ node, oldValue: '', - newvalue: node.data[findFirstEditableColumn()], + newValue: node.data[findFirstEditableColumn()], ...props }) }) diff --git a/frontend/src/constants/routes/apiRoutes.js b/frontend/src/constants/routes/apiRoutes.js index 0cdb86114..a0f6f967f 100644 --- a/frontend/src/constants/routes/apiRoutes.js +++ b/frontend/src/constants/routes/apiRoutes.js @@ -50,6 +50,9 @@ export const apiRoutes = { finalSupplyEquipmentOptions: '/final-supply-equipments/table-options', getAllFinalSupplyEquipments: '/final-supply-equipments/list-all', saveFinalSupplyEquipments: '/final-supply-equipments/save', + exportFinalSupplyEquipments: '/final-supply-equipments/export/:reportID', + downloadFinalSupplyEquipmentsTemplate: + '/final-supply-equipments/template/:reportID', searchFinalSupplyEquipments: '/final-supply-equipments/search?', fuelSupplyOptions: '/fuel-supply/table-options?', getAllFuelSupplies: '/fuel-supply/list-all', diff --git a/frontend/src/views/ComplianceReports/buttonConfigs.jsx b/frontend/src/views/ComplianceReports/buttonConfigs.jsx index fe903ede3..7fdff822a 100644 --- a/frontend/src/views/ComplianceReports/buttonConfigs.jsx +++ b/frontend/src/views/ComplianceReports/buttonConfigs.jsx @@ -1,6 +1,6 @@ // complianceReportButtonConfigs.js -import { faPencil, faTrash } from '@fortawesome/free-solid-svg-icons' +import { faPencil } from '@fortawesome/free-solid-svg-icons' import { COMPLIANCE_REPORT_STATUSES } from '@/constants/statuses' import { roles } from '@/constants/roles' diff --git a/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx b/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx index 8e2e23f79..e5dd262f3 100644 --- a/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx +++ b/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx @@ -15,6 +15,12 @@ import { v4 as uuid } from 'uuid' import * as ROUTES from '@/constants/routes/routes.js' import { handleScheduleDelete, handleScheduleSave } from '@/utils/schedules.js' import { isArrayEmpty } from '@/utils/array.js' +import { useApiService } from '@/services/useApiService.js' +import { apiRoutes } from '@/constants/routes/index.js' +import BCButton from '@/components/BCButton/index.jsx' +import { Menu, MenuItem } from '@mui/material' +import { faCaretDown } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' export const AddEditFinalSupplyEquipments = () => { const [rowData, setRowData] = useState([]) @@ -22,6 +28,8 @@ export const AddEditFinalSupplyEquipments = () => { const [errors, setErrors] = useState({}) const [warnings, setWarnings] = useState({}) const [columnDefs, setColumnDefs] = useState([]) + const [isDownloading, setIsDownloading] = useState(false) + const apiService = useApiService() const alertRef = useRef() const location = useLocation() @@ -204,6 +212,31 @@ export const AddEditFinalSupplyEquipments = () => { } } + const handleDownload = async (includeData) => { + try { + handleClose() + setIsDownloading(true) + await apiService.download( + includeData + ? apiRoutes.exportFinalSupplyEquipments.replace( + ':reportID', + complianceReportId + ) + : apiRoutes.downloadFinalSupplyEquipmentsTemplate.replace( + ':reportID', + complianceReportId + ) + ) + } catch (error) { + console.error( + 'Error downloading final supply equipment information:', + error + ) + } finally { + setIsDownloading(false) + } + } + const handleNavigateBack = useCallback(() => { navigate( ROUTES.REPORTS_VIEW.replace( @@ -229,6 +262,15 @@ export const AddEditFinalSupplyEquipments = () => { [compliancePeriod, complianceReportId] ) + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + const handleClick = (event) => { + setAnchorEl(event.currentTarget) + } + const handleClose = () => { + setAnchorEl(null) + } + return ( isFetched && !equipmentsLoading && ( @@ -250,6 +292,54 @@ export const AddEditFinalSupplyEquipments = () => { ))} + + } + isLoading={isDownloading} + > + {t('finalSupplyEquipment:downloadBtn')} + + + { + handleDownload(true) + }} + > + {t('finalSupplyEquipment:downloadWithDataBtn')} + + { + handleDownload(false) + }} + > + {t('finalSupplyEquipment:downloadWithoutDataBtn')} + + +