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')}
+
+
+