From 1260fd0efbbf248dad3b96b737ece67aefa82482 Mon Sep 17 00:00:00 2001 From: Kevin Hashimoto Date: Wed, 11 Dec 2024 11:09:29 -0800 Subject: [PATCH 01/55] fix: remove allocation agreement from line 2 summary --- .../api/compliance_report/summary_service.py | 66 ++++++++++++------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/backend/lcfs/web/api/compliance_report/summary_service.py b/backend/lcfs/web/api/compliance_report/summary_service.py index 6d781a3d6..8c50af531 100644 --- a/backend/lcfs/web/api/compliance_report/summary_service.py +++ b/backend/lcfs/web/api/compliance_report/summary_service.py @@ -118,9 +118,11 @@ def convert_summary_to_dict( "description" ] ), - field=RENEWABLE_FUEL_TARGET_DESCRIPTIONS[str(line)]["field"], + field=RENEWABLE_FUEL_TARGET_DESCRIPTIONS[str( + line)]["field"], ) - summary.renewable_fuel_target_summary.append(existing_element) + summary.renewable_fuel_target_summary.append( + existing_element) value = int(getattr(summary_obj, column.key) or 0) if column.key.endswith("_gasoline"): existing_element.gasoline = value @@ -150,7 +152,8 @@ def convert_summary_to_dict( "description" ] ), - field=LOW_CARBON_FUEL_TARGET_DESCRIPTIONS[str(line)]["field"], + field=LOW_CARBON_FUEL_TARGET_DESCRIPTIONS[str( + line)]["field"], value=int(getattr(summary_obj, column.key) or 0), ) ) @@ -188,7 +191,8 @@ def convert_summary_to_dict( "field" ], ) - summary.non_compliance_penalty_summary.append(existing_element) + summary.non_compliance_penalty_summary.append( + existing_element) value = int(getattr(summary_obj, column.key) or 0) if column.key.endswith("_gasoline"): existing_element.gasoline = value @@ -307,7 +311,8 @@ async def calculate_compliance_report_summary( for transfer in notional_transfers.notional_transfers: # Normalize the fuel category key - normalized_category = transfer.fuel_category.replace(" ", "_").lower() + normalized_category = transfer.fuel_category.replace( + " ", "_").lower() # Update the corresponding category sum if transfer.received_or_transferred.lower() == "received": @@ -324,12 +329,12 @@ async def calculate_compliance_report_summary( fossil_quantities = await self.calculate_fuel_quantities( compliance_report.compliance_report_id, effective_fuel_supplies, - fossil_derived=True, + fossil_derived=True ) renewable_quantities = await self.calculate_fuel_quantities( compliance_report.compliance_report_id, effective_fuel_supplies, - fossil_derived=False, + fossil_derived=False ) renewable_fuel_target_summary = self.calculate_renewable_fuel_target_summary( @@ -450,18 +455,21 @@ def calculate_renewable_fuel_target_summary( deferred_renewables = {"gasoline": 0.0, "diesel": 0.0, "jet_fuel": 0.0} for category in ["gasoline", "diesel", "jet_fuel"]: - required_renewable_quantity = eligible_renewable_fuel_required.get(category) + required_renewable_quantity = eligible_renewable_fuel_required.get( + category) previous_required_renewable_quantity = getattr( - prev_summary, f"line_4_eligible_renewable_fuel_required_{category}" + prev_summary, f"""line_4_eligible_renewable_fuel_required_{ + category}""" ) # only carry over line 6,8 if required quantities have not changed if previous_required_renewable_quantity == required_renewable_quantity: retained_renewables[category] = getattr( - prev_summary, f"line_6_renewable_fuel_retained_{category}" + prev_summary, f"""line_6_renewable_fuel_retained_{ + category}""" ) deferred_renewables[category] = getattr( - prev_summary, f"line_8_obligation_deferred_{category}" + prev_summary, f"""line_8_obligation_deferred_{category}""" ) # line 10 @@ -557,9 +565,12 @@ def calculate_renewable_fuel_target_summary( line=line, description=( RENEWABLE_FUEL_TARGET_DESCRIPTIONS[line]["description"].format( - "{:,}".format(int(summary_lines["4"]["gasoline"] * 0.05)), - "{:,}".format(int(summary_lines["4"]["diesel"] * 0.05)), - "{:,}".format(int(summary_lines["4"]["jet_fuel"] * 0.05)), + "{:,}".format( + int(summary_lines["4"]["gasoline"] * 0.05)), + "{:,}".format( + int(summary_lines["4"]["diesel"] * 0.05)), + "{:,}".format( + int(summary_lines["4"]["jet_fuel"] * 0.05)), ) if (line in ["6", "8"]) else RENEWABLE_FUEL_TARGET_DESCRIPTIONS[line]["description"] @@ -571,7 +582,8 @@ def calculate_renewable_fuel_target_summary( total_value=values.get("gasoline", 0) + values.get("diesel", 0) + values.get("jet_fuel", 0), - format=(FORMATS.CURRENCY if (str(line) == "11") else FORMATS.NUMBER), + format=(FORMATS.CURRENCY if ( + str(line) == "11") else FORMATS.NUMBER), ) for line, values in summary_lines.items() ] @@ -660,7 +672,8 @@ async def calculate_low_carbon_fuel_target_summary( ), field=LOW_CARBON_FUEL_TARGET_DESCRIPTIONS[line]["field"], value=values.get("value", 0), - format=(FORMATS.CURRENCY if (str(line) == "21") else FORMATS.NUMBER), + format=(FORMATS.CURRENCY if ( + str(line) == "21") else FORMATS.NUMBER), ) for line, values in low_carbon_summary_lines.items() ] @@ -675,7 +688,8 @@ def calculate_non_compliance_penalty_summary( non_compliance_penalty_payable = int( (non_compliance_penalty_payable_units * Decimal(-600.0)).max(0) ) - line_11 = next(row for row in renewable_fuel_target_summary if row.line == "11") + line_11 = next( + row for row in renewable_fuel_target_summary if row.line == "11") non_compliance_summary_lines = { "11": {"total_value": line_11.total_value}, @@ -720,11 +734,10 @@ async def calculate_fuel_quantities( await self.repo.aggregate_other_uses(compliance_report_id, fossil_derived) ) - if not fossil_derived: - fuel_quantities.update( - await self.repo.aggregate_allocation_agreements(compliance_report_id) - ) - + # if not fossil_derived: + # fuel_quantities.update( + # await self.repo.aggregate_allocation_agreements(compliance_report_id) + # ) return dict(fuel_quantities) @service_handler @@ -752,7 +765,8 @@ async def calculate_fuel_supply_compliance_units( ED = fuel_supply.energy_density or 0 # Energy Density # Apply the compliance units formula - compliance_units = calculate_compliance_units(TCI, EER, RCI, UCI, Q, ED) + compliance_units = calculate_compliance_units( + TCI, EER, RCI, UCI, Q, ED) compliance_units_sum += compliance_units return int(compliance_units_sum) @@ -781,9 +795,11 @@ async def calculate_fuel_export_compliance_units( ED = fuel_export.energy_density or 0 # Energy Density # Apply the compliance units formula - compliance_units = calculate_compliance_units(TCI, EER, RCI, UCI, Q, ED) + compliance_units = calculate_compliance_units( + TCI, EER, RCI, UCI, Q, ED) compliance_units = -compliance_units - compliance_units = round(compliance_units) if compliance_units < 0 else 0 + compliance_units = round( + compliance_units) if compliance_units < 0 else 0 compliance_units_sum += compliance_units From f9c76d3bd309ad96bbcb96ab20ffe6aeb89c0632 Mon Sep 17 00:00:00 2001 From: Kevin Hashimoto Date: Wed, 11 Dec 2024 15:38:50 -0800 Subject: [PATCH 02/55] chore: clean --- backend/lcfs/web/api/compliance_report/summary_service.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/lcfs/web/api/compliance_report/summary_service.py b/backend/lcfs/web/api/compliance_report/summary_service.py index 8c50af531..f241da143 100644 --- a/backend/lcfs/web/api/compliance_report/summary_service.py +++ b/backend/lcfs/web/api/compliance_report/summary_service.py @@ -734,10 +734,6 @@ async def calculate_fuel_quantities( await self.repo.aggregate_other_uses(compliance_report_id, fossil_derived) ) - # if not fossil_derived: - # fuel_quantities.update( - # await self.repo.aggregate_allocation_agreements(compliance_report_id) - # ) return dict(fuel_quantities) @service_handler From 56ab63db01e3e26405286f2aa8842f274e389d48 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 11:22:31 -0800 Subject: [PATCH 03/55] Fix caching issues in github workflow --- .github/workflows/docker-auto-test.yaml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docker-auto-test.yaml b/.github/workflows/docker-auto-test.yaml index 1c7976d15..30c21c1dc 100644 --- a/.github/workflows/docker-auto-test.yaml +++ b/.github/workflows/docker-auto-test.yaml @@ -32,18 +32,10 @@ jobs: uses: actions/cache@v3 with: path: ~/.cache/pypoetry - key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }} + key: ${{ runner.os }}-poetry-${{ hashFiles('backend/poetry.lock') }} restore-keys: | ${{ runner.os }}-poetry- - - name: Cache Docker images - uses: actions/cache@v3 - with: - path: /var/lib/docker - key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile') }} - restore-keys: | - ${{ runner.os }}-docker- - - name: Install Poetry run: pip install poetry==1.6.1 @@ -110,7 +102,6 @@ jobs: report_individual_runs: "true" deduplicate_classes_by_file_name: "true" - frontend-tests: runs-on: ubuntu-latest steps: @@ -127,7 +118,7 @@ jobs: uses: actions/cache@v3 with: path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- @@ -162,4 +153,4 @@ jobs: check_name: "Frontend Test Results" fail_on: "errors" report_individual_runs: "true" - deduplicate_classes_by_file_name: "true" + deduplicate_classes_by_file_name: "true" \ No newline at end of file From 71c2d5b628911eb3c4cb3c9a0a5c932b805d3320 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 11:33:57 -0800 Subject: [PATCH 04/55] . --- backend/test.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 backend/test.txt diff --git a/backend/test.txt b/backend/test.txt new file mode 100644 index 000000000..6a1013938 --- /dev/null +++ b/backend/test.txt @@ -0,0 +1 @@ +testing workflow, remove after testing. \ No newline at end of file From 971335bd93911d90e1364518c2af00a12d91c18d Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 11:48:12 -0800 Subject: [PATCH 05/55] optimize --- .github/workflows/docker-auto-test.yaml | 9 +++++ backend/Dockerfile | 44 +++++++++++-------------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/.github/workflows/docker-auto-test.yaml b/.github/workflows/docker-auto-test.yaml index 30c21c1dc..5b11e92af 100644 --- a/.github/workflows/docker-auto-test.yaml +++ b/.github/workflows/docker-auto-test.yaml @@ -51,6 +51,15 @@ jobs: run: | sed -i 's/: true/: "true"/g; s/: false/: "false"/g' docker-compose.yml + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-docker-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-docker-${{ github.ref_name }} + ${{ runner.os }}-docker- + - name: Build and start services run: | docker-compose build diff --git a/backend/Dockerfile b/backend/Dockerfile index 2f863e90b..749325463 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,38 +1,31 @@ # Base stage for common setup FROM python:3.11-slim-bullseye as base -RUN apt-get update && apt-get install -y \ - gcc \ - && rm -rf /var/lib/apt/lists/* - -RUN pip install poetry==1.6.1 - -# Configuring poetry -RUN poetry config virtualenvs.create false - -# Copying requirements of a project -COPY pyproject.toml poetry.lock /app/ +# Install build dependencies and Poetry in one step to minimize layers +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && pip install --no-cache-dir poetry==1.6.1 \ + && poetry config virtualenvs.create false \ + && apt-get purge -y gcc \ + && rm -rf /var/lib/apt/lists/* + +# Set work directory WORKDIR /app -# Installing requirements -RUN poetry install --only main +# Copy only dependency files first (to leverage caching) +COPY pyproject.toml poetry.lock /app/ -# Removing gcc -RUN apt-get purge -y \ - gcc \ - && rm -rf /var/lib/apt/lists/* +# Install only main dependencies +RUN poetry install --only main --no-root -# Copying the actual application, wait-for-it script, and prestart script +# Copy the rest of the application files COPY . /app/ -# Note: We mount the local directory using docker-compose so ensure these scripts also have execute permissions -# by running the following command on your host machine from the root of this project: -# chmod +x ./backend/wait-for-it.sh ./backend/lcfs/prestart.sh ./backend/lcfs/start.sh ./backend/lcfs/start-reload.sh - -# Make all startup scripts executable +# Make necessary scripts executable RUN chmod +x /app/wait-for-it.sh /app/lcfs/prestart.sh /app/lcfs/start.sh -CMD /bin/bash /app/lcfs/start.sh +# Default startup command +CMD ["/bin/bash", "/app/lcfs/start.sh"] # Production stage FROM base as prod @@ -43,5 +36,6 @@ ENV APP_ENVIRONMENT=prod FROM base as dev # Set the APP_ENVIRONMENT variable to 'development' ENV APP_ENVIRONMENT=dev + # Install additional dependencies for development -RUN poetry install +RUN poetry install --no-root From 92773a30a68fffe0cb2fd592191cefcc330b41a2 Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Thu, 12 Dec 2024 11:50:22 -0800 Subject: [PATCH 06/55] feat: add $ sign to fair market value per credit field in transfer input page --- .../Transfers/components/TransferDetails.jsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/frontend/src/views/Transfers/components/TransferDetails.jsx b/frontend/src/views/Transfers/components/TransferDetails.jsx index c9e80f29a..22377b629 100644 --- a/frontend/src/views/Transfers/components/TransferDetails.jsx +++ b/frontend/src/views/Transfers/components/TransferDetails.jsx @@ -157,20 +157,21 @@ export const TransferDetails = () => { size="small" error={!!errors.pricePerUnit} helperText={errors.pricePerUnit?.message} - inputProps={{ - maxLength: 13, - step: '10', - style: { textAlign: 'right' }, - 'data-test': 'price-per-unit', - startadornment: ( - $ - ) - }} sx={{ - minWidth: '24rem', + minWidth: '25rem', marginInline: '0.2rem', bottom: '0.2rem' }} + InputProps={{ + startAdornment: ( + $ + ), + style: { textAlign: 'right' } + }} + inputProps={{ + maxLength: 13, + 'data-test': 'price-per-unit' + }} /> {t('transfer:totalValueText')} Date: Thu, 12 Dec 2024 11:54:50 -0800 Subject: [PATCH 07/55] update --- backend/test.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/test.txt b/backend/test.txt index 6a1013938..742cf3868 100644 --- a/backend/test.txt +++ b/backend/test.txt @@ -1 +1,2 @@ -testing workflow, remove after testing. \ No newline at end of file +testing workflow, remove after testing. +update to app files. \ No newline at end of file From 31acf1f10f04b84f517e02b43f0a9286a8568dd9 Mon Sep 17 00:00:00 2001 From: Kevin Hashimoto Date: Thu, 12 Dec 2024 11:54:13 -0800 Subject: [PATCH 08/55] fix: phantom pill --- .../Editors/AutocompleteCellEditor.jsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/BCDataGrid/components/Editors/AutocompleteCellEditor.jsx b/frontend/src/components/BCDataGrid/components/Editors/AutocompleteCellEditor.jsx index de12c61ea..af3cc524d 100644 --- a/frontend/src/components/BCDataGrid/components/Editors/AutocompleteCellEditor.jsx +++ b/frontend/src/components/BCDataGrid/components/Editors/AutocompleteCellEditor.jsx @@ -37,9 +37,16 @@ export const AutocompleteCellEditor = forwardRef((props, ref) => { onPaste } = props - const [selectedValues, setSelectedValues] = useState( - (Array.isArray(value) ? value : value.split(',').map((v) => v.trim())) || [] - ) + const [selectedValues, setSelectedValues] = useState(() => { + if (!value) { + return [] + } else if (Array.isArray(value)) { + return value + } else { + return value.split(',').map((v) => v.trim) + } + }) + const inputRef = useRef() useImperativeHandle(ref, () => ({ @@ -77,7 +84,7 @@ export const AutocompleteCellEditor = forwardRef((props, ref) => { if (focusedCell) { api.startEditingCell({ rowIndex: focusedCell.rowIndex, - colKey: focusedCell.column.getId(), + colKey: focusedCell.column.getId() }) } } @@ -94,7 +101,6 @@ export const AutocompleteCellEditor = forwardRef((props, ref) => { } } - const handleBlur = (event) => { if (onBlur) { onBlur(event) From a971634639b862571fd3c70f45ebda6710f5226f Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Thu, 12 Dec 2024 12:57:10 -0700 Subject: [PATCH 09/55] Autopopulating units field depending on the Fuel Type selected --- .../src/views/OtherUses/AddEditOtherUses.jsx | 12 +++++++ frontend/src/views/OtherUses/_schema.jsx | 31 ++++++++++--------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/frontend/src/views/OtherUses/AddEditOtherUses.jsx b/frontend/src/views/OtherUses/AddEditOtherUses.jsx index 58586cb9e..bbd553ca3 100644 --- a/frontend/src/views/OtherUses/AddEditOtherUses.jsx +++ b/frontend/src/views/OtherUses/AddEditOtherUses.jsx @@ -150,6 +150,18 @@ export const AddEditOtherUses = () => { ) { const ciOfFuel = findCiOfFuel(params.data, optionsData) params.node.setDataValue('ciOfFuel', ciOfFuel) + + // Auto-populate the "Unit" field based on the selected fuel type + if (params.colDef.field === 'fuelType') { + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ); + if (fuelType && fuelType.units) { + params.node.setDataValue('units', fuelType.units); + } else { + params.node.setDataValue('units', ''); + } + } } }, [optionsData] diff --git a/frontend/src/views/OtherUses/_schema.jsx b/frontend/src/views/OtherUses/_schema.jsx index a392bfc4a..82e96bd37 100644 --- a/frontend/src/views/OtherUses/_schema.jsx +++ b/frontend/src/views/OtherUses/_schema.jsx @@ -180,21 +180,24 @@ export const otherUsesColDefs = (optionsData, errors) => [ }, { field: 'units', - headerName: i18n.t('otherUses:otherUsesColLabels.units'), - headerComponent: RequiredHeader, - cellEditor: AutocompleteCellEditor, - minWidth: '155', - cellEditorParams: { - options: optionsData.unitsOfMeasure.map((obj) => obj), - multiple: false, - disableCloseOnSelect: false, - freeSolo: false, - openOnFocus: true + headerName: i18n.t('otherUses:units'), + cellEditor: 'agSelectCellEditor', + cellEditorParams: (params) => { + console.log('cellEditorParams: ', params); + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ); + const values = fuelType ? [fuelType.units] : []; + return { + values: values + }; }, - suppressKeyboardEvent, - cellRenderer: (params) => - params.value || Select, - cellStyle: (params) => StandardCellErrors(params, errors) + cellRenderer: (params) => { + return params.value ? params.value : Select; + }, + cellStyle: (params) => StandardCellErrors(params, errors), + editable: true, + minWidth: 100 }, { field: 'ciOfFuel', From 929e2fc33c30cd92ea26e65209e3eeddfe8024aa Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 11:59:59 -0800 Subject: [PATCH 10/55] Revert "optimize" This reverts commit 971335bd93911d90e1364518c2af00a12d91c18d. --- .github/workflows/docker-auto-test.yaml | 9 ----- backend/Dockerfile | 44 ++++++++++++++----------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/.github/workflows/docker-auto-test.yaml b/.github/workflows/docker-auto-test.yaml index 5b11e92af..30c21c1dc 100644 --- a/.github/workflows/docker-auto-test.yaml +++ b/.github/workflows/docker-auto-test.yaml @@ -51,15 +51,6 @@ jobs: run: | sed -i 's/: true/: "true"/g; s/: false/: "false"/g' docker-compose.yml - - name: Cache Docker layers - uses: actions/cache@v3 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-docker-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-docker-${{ github.ref_name }} - ${{ runner.os }}-docker- - - name: Build and start services run: | docker-compose build diff --git a/backend/Dockerfile b/backend/Dockerfile index 749325463..2f863e90b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,31 +1,38 @@ # Base stage for common setup FROM python:3.11-slim-bullseye as base -# Install build dependencies and Poetry in one step to minimize layers -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - && pip install --no-cache-dir poetry==1.6.1 \ - && poetry config virtualenvs.create false \ - && apt-get purge -y gcc \ - && rm -rf /var/lib/apt/lists/* - -# Set work directory -WORKDIR /app +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install poetry==1.6.1 -# Copy only dependency files first (to leverage caching) +# Configuring poetry +RUN poetry config virtualenvs.create false + +# Copying requirements of a project COPY pyproject.toml poetry.lock /app/ +WORKDIR /app -# Install only main dependencies -RUN poetry install --only main --no-root +# Installing requirements +RUN poetry install --only main -# Copy the rest of the application files +# Removing gcc +RUN apt-get purge -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copying the actual application, wait-for-it script, and prestart script COPY . /app/ -# Make necessary scripts executable +# Note: We mount the local directory using docker-compose so ensure these scripts also have execute permissions +# by running the following command on your host machine from the root of this project: +# chmod +x ./backend/wait-for-it.sh ./backend/lcfs/prestart.sh ./backend/lcfs/start.sh ./backend/lcfs/start-reload.sh + +# Make all startup scripts executable RUN chmod +x /app/wait-for-it.sh /app/lcfs/prestart.sh /app/lcfs/start.sh -# Default startup command -CMD ["/bin/bash", "/app/lcfs/start.sh"] +CMD /bin/bash /app/lcfs/start.sh # Production stage FROM base as prod @@ -36,6 +43,5 @@ ENV APP_ENVIRONMENT=prod FROM base as dev # Set the APP_ENVIRONMENT variable to 'development' ENV APP_ENVIRONMENT=dev - # Install additional dependencies for development -RUN poetry install --no-root +RUN poetry install From 8d17b43df67e2289cbb7d932ecddb2a2182dec9b Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 12:12:34 -0800 Subject: [PATCH 11/55] parallelize tests --- .github/workflows/docker-auto-test.yaml | 2 +- backend/poetry.lock | 46 +++++++++++++++++++++---- backend/pyproject.toml | 1 + 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-auto-test.yaml b/.github/workflows/docker-auto-test.yaml index 30c21c1dc..0c47293b0 100644 --- a/.github/workflows/docker-auto-test.yaml +++ b/.github/workflows/docker-auto-test.yaml @@ -61,7 +61,7 @@ jobs: continue-on-error: true run: | cd backend - poetry run pytest --junitxml=pytest-results.xml + poetry run pytest -n auto --junitxml=pytest-results.xml env: LCFS_DB_HOST: localhost LCFS_DB_PORT: 5432 diff --git a/backend/poetry.lock b/backend/poetry.lock index 30bf2f4bf..146fc519d 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "aio-pika" @@ -306,8 +306,8 @@ files = [ jmespath = ">=0.7.1,<2.0.0" python-dateutil = ">=2.1,<3.0.0" urllib3 = [ - {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, + {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, ] [package.extras] @@ -835,6 +835,20 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "execnet" +version = "2.1.1" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +files = [ + {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, + {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + [[package]] name = "fakeredis" version = "2.26.1" @@ -2113,9 +2127,9 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.22.4", markers = "python_version < \"3.11\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -2504,8 +2518,8 @@ annotated-types = ">=0.6.0" email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} pydantic-core = "2.23.4" typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, ] [package.extras] @@ -2749,6 +2763,26 @@ pytest = ">=7.3.1" [package.extras] test = ["coverage (>=7.2.7)", "pytest-mock (>=3.10)"] +[[package]] +name = "pytest-xdist" +version = "3.6.1" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, + {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3109,7 +3143,7 @@ files = [ ] [package.dependencies] -greenlet = {version = "!=0.4.17", optional = true, markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") or extra == \"asyncio\""} +greenlet = {version = "!=0.4.17", optional = true, markers = "python_version < \"3.13\" and (platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\") or extra == \"asyncio\""} typing-extensions = ">=4.6.0" [package.extras] @@ -3784,4 +3818,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "1898dc5b68facf49cb53d1fe98daecb39c1dc39b184d17c31d8d23a48cfce2c0" +content-hash = "55d9cb15f5685f6da727dfa156a16ac4d8085c6c6f98bec321f9e8b311bb162f" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e40217ccb..8d64b46a2 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -46,6 +46,7 @@ python-multipart = "^0.0.18" aio-pika = "^9.4.3" jinja2 = "^3.1.4" requests = "^2.32.3" +pytest-xdist = "^3.6.1" [tool.poetry.dev-dependencies] pytest = "^8.3.3" From 8195a590972603c8b3055b27c47df66bc5997997 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 12:18:37 -0800 Subject: [PATCH 12/55] Revert "parallelize tests" This reverts commit 8d17b43df67e2289cbb7d932ecddb2a2182dec9b. --- .github/workflows/docker-auto-test.yaml | 2 +- backend/poetry.lock | 46 ++++--------------------- backend/pyproject.toml | 1 - 3 files changed, 7 insertions(+), 42 deletions(-) diff --git a/.github/workflows/docker-auto-test.yaml b/.github/workflows/docker-auto-test.yaml index 0c47293b0..30c21c1dc 100644 --- a/.github/workflows/docker-auto-test.yaml +++ b/.github/workflows/docker-auto-test.yaml @@ -61,7 +61,7 @@ jobs: continue-on-error: true run: | cd backend - poetry run pytest -n auto --junitxml=pytest-results.xml + poetry run pytest --junitxml=pytest-results.xml env: LCFS_DB_HOST: localhost LCFS_DB_PORT: 5432 diff --git a/backend/poetry.lock b/backend/poetry.lock index 146fc519d..30bf2f4bf 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aio-pika" @@ -306,8 +306,8 @@ files = [ jmespath = ">=0.7.1,<2.0.0" python-dateutil = ">=2.1,<3.0.0" urllib3 = [ - {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, + {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, ] [package.extras] @@ -835,20 +835,6 @@ files = [ [package.extras] test = ["pytest (>=6)"] -[[package]] -name = "execnet" -version = "2.1.1" -description = "execnet: rapid multi-Python deployment" -optional = false -python-versions = ">=3.8" -files = [ - {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, - {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, -] - -[package.extras] -testing = ["hatch", "pre-commit", "pytest", "tox"] - [[package]] name = "fakeredis" version = "2.26.1" @@ -2127,9 +2113,9 @@ files = [ [package.dependencies] numpy = [ + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.22.4", markers = "python_version < \"3.11\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -2518,8 +2504,8 @@ annotated-types = ">=0.6.0" email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} pydantic-core = "2.23.4" typing-extensions = [ - {version = ">=4.6.1", markers = "python_version < \"3.13\""}, {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, ] [package.extras] @@ -2763,26 +2749,6 @@ pytest = ">=7.3.1" [package.extras] test = ["coverage (>=7.2.7)", "pytest-mock (>=3.10)"] -[[package]] -name = "pytest-xdist" -version = "3.6.1" -description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, - {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, -] - -[package.dependencies] -execnet = ">=2.1" -pytest = ">=7.0.0" - -[package.extras] -psutil = ["psutil (>=3.0)"] -setproctitle = ["setproctitle"] -testing = ["filelock"] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3143,7 +3109,7 @@ files = [ ] [package.dependencies] -greenlet = {version = "!=0.4.17", optional = true, markers = "python_version < \"3.13\" and (platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\") or extra == \"asyncio\""} +greenlet = {version = "!=0.4.17", optional = true, markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") or extra == \"asyncio\""} typing-extensions = ">=4.6.0" [package.extras] @@ -3818,4 +3784,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "55d9cb15f5685f6da727dfa156a16ac4d8085c6c6f98bec321f9e8b311bb162f" +content-hash = "1898dc5b68facf49cb53d1fe98daecb39c1dc39b184d17c31d8d23a48cfce2c0" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8d64b46a2..e40217ccb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -46,7 +46,6 @@ python-multipart = "^0.0.18" aio-pika = "^9.4.3" jinja2 = "^3.1.4" requests = "^2.32.3" -pytest-xdist = "^3.6.1" [tool.poetry.dev-dependencies] pytest = "^8.3.3" From 8bb9a90cdeaa59c422c0509ee4dc88a78f7e1d55 Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Thu, 12 Dec 2024 13:12:10 -0700 Subject: [PATCH 13/55] Provide default value to organization_name field in FSE --- .../versions/2024-12-06-09-59_9206124a098b.py | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/backend/lcfs/db/migrations/versions/2024-12-06-09-59_9206124a098b.py b/backend/lcfs/db/migrations/versions/2024-12-06-09-59_9206124a098b.py index fc805ff14..d12cf71d4 100644 --- a/backend/lcfs/db/migrations/versions/2024-12-06-09-59_9206124a098b.py +++ b/backend/lcfs/db/migrations/versions/2024-12-06-09-59_9206124a098b.py @@ -5,21 +5,33 @@ Create Date: 2024-12-04 09:59:22.876386 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '9206124a098b' -down_revision = '26ab15f8ab18' +revision = "9206124a098b" +down_revision = "26ab15f8ab18" branch_labels = None depends_on = None def upgrade(): - # Add the column 'organization_name' to 'final_supply_equipment' table - op.add_column("final_supply_equipment", sa.Column("organization_name", sa.String(), nullable=True)) + # Add the column 'organization_name' to 'final_supply_equipment' table with a default value + op.add_column( + "final_supply_equipment", + sa.Column("organization_name", sa.String(), nullable=False, server_default=""), + ) + + # Update existing rows to have the default value + op.execute( + "UPDATE final_supply_equipment SET organization_name = '' WHERE organization_name IS NULL" + ) + + # Remove the server default to prevent future rows from automatically getting the default value + op.alter_column("final_supply_equipment", "organization_name", server_default=None) def downgrade(): # Remove the column 'organization_name' from 'final_supply_equipment' table - op.drop_column("final_supply_equipment", "organization_name") \ No newline at end of file + op.drop_column("final_supply_equipment", "organization_name") From 799afa6368868e0a0dcd3b175bafd84aea8e4f04 Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Thu, 12 Dec 2024 13:53:08 -0800 Subject: [PATCH 14/55] fix: correct default_carbon_intensity for 'Other diesel' fuel type --- .../versions/2024-12-12-21-43_5d729face5ab.py | 35 +++++++++++++++++++ .../db/seeders/common/seed_fuel_data.json | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 backend/lcfs/db/migrations/versions/2024-12-12-21-43_5d729face5ab.py diff --git a/backend/lcfs/db/migrations/versions/2024-12-12-21-43_5d729face5ab.py b/backend/lcfs/db/migrations/versions/2024-12-12-21-43_5d729face5ab.py new file mode 100644 index 000000000..558720d38 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-12-12-21-43_5d729face5ab.py @@ -0,0 +1,35 @@ +"""Update default_carbon_intensity for 'Other diesel' fuel type + +Revision ID: 5d729face5ab +Revises: 7ae38a8413ab +Create Date: 2024-12-12 21:43:01.414475 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5d729face5ab" +down_revision = "7ae38a8413ab" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + UPDATE fuel_type + SET default_carbon_intensity = 100.21 + WHERE fuel_type_id = 20 + """ + ) + + +def downgrade() -> None: + op.execute( + """ + UPDATE fuel_type + SET default_carbon_intensity = 94.38 + WHERE fuel_type_id = 20 + """ + ) diff --git a/backend/lcfs/db/seeders/common/seed_fuel_data.json b/backend/lcfs/db/seeders/common/seed_fuel_data.json index bc4bf1d87..ac68f21d7 100644 --- a/backend/lcfs/db/seeders/common/seed_fuel_data.json +++ b/backend/lcfs/db/seeders/common/seed_fuel_data.json @@ -188,7 +188,7 @@ "fossil_derived": true, "other_uses_fossil_derived": true, "provision_1_id": 1, - "default_carbon_intensity": 94.38, + "default_carbon_intensity": 100.21, "units": "L", "unrecognized": false } From ad157c144b8146068989caeb3c624114770749cc Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 13:56:26 -0800 Subject: [PATCH 15/55] finalize --- backend/test.txt | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 backend/test.txt diff --git a/backend/test.txt b/backend/test.txt deleted file mode 100644 index 742cf3868..000000000 --- a/backend/test.txt +++ /dev/null @@ -1,2 +0,0 @@ -testing workflow, remove after testing. -update to app files. \ No newline at end of file From 1f21cc6589f4184eb954d958a75afbe0eab48124 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 14:01:59 -0800 Subject: [PATCH 16/55] update --- .github/workflows/docker-auto-test.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-auto-test.yaml b/.github/workflows/docker-auto-test.yaml index 30c21c1dc..e690e70ff 100644 --- a/.github/workflows/docker-auto-test.yaml +++ b/.github/workflows/docker-auto-test.yaml @@ -47,14 +47,18 @@ jobs: poetry install pip install pytest-github-actions-annotate-failures typing_extensions - - name: Fix docker-compose.yml + - name: Modify docker-compose.yml run: | - sed -i 's/: true/: "true"/g; s/: false/: "false"/g' docker-compose.yml + sed -i '/minio:/,/depends_on:/d' docker-compose.yml + sed -i '/create_bucket:/,/depends_on:/d' docker-compose.yml + sed -i '/frontend:/,/networks:/d' docker-compose.yml + sed -i 's/create_bucket,//g' + sed -i 's/frontend,//g' - name: Build and start services run: | docker-compose build - docker-compose up -d + docker-compose up -d db redis rabbitmq backend - name: Run backend tests id: backend_tests From 33a3c0d724bcda86f243ed689aeaaede84091db8 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 14:05:42 -0800 Subject: [PATCH 17/55] . --- .github/workflows/docker-auto-test.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-auto-test.yaml b/.github/workflows/docker-auto-test.yaml index e690e70ff..a0b650155 100644 --- a/.github/workflows/docker-auto-test.yaml +++ b/.github/workflows/docker-auto-test.yaml @@ -49,6 +49,7 @@ jobs: - name: Modify docker-compose.yml run: | + sed -i 's/: true/: "true"/g; s/: false/: "false"/g' docker-compose.yml sed -i '/minio:/,/depends_on:/d' docker-compose.yml sed -i '/create_bucket:/,/depends_on:/d' docker-compose.yml sed -i '/frontend:/,/networks:/d' docker-compose.yml @@ -58,7 +59,7 @@ jobs: - name: Build and start services run: | docker-compose build - docker-compose up -d db redis rabbitmq backend + docker-compose up -d - name: Run backend tests id: backend_tests From 882a3819c461890c8b9c48b0f9d9f9e5d438e300 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 14:07:31 -0800 Subject: [PATCH 18/55] . --- .github/workflows/docker-auto-test.yaml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/docker-auto-test.yaml b/.github/workflows/docker-auto-test.yaml index a0b650155..837d2d171 100644 --- a/.github/workflows/docker-auto-test.yaml +++ b/.github/workflows/docker-auto-test.yaml @@ -18,11 +18,6 @@ jobs: with: python-version: "3.10.13" - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: "20" - - name: Install Docker Compose run: | sudo apt-get update @@ -47,14 +42,9 @@ jobs: poetry install pip install pytest-github-actions-annotate-failures typing_extensions - - name: Modify docker-compose.yml + - name: Fix docker-compose.yml run: | sed -i 's/: true/: "true"/g; s/: false/: "false"/g' docker-compose.yml - sed -i '/minio:/,/depends_on:/d' docker-compose.yml - sed -i '/create_bucket:/,/depends_on:/d' docker-compose.yml - sed -i '/frontend:/,/networks:/d' docker-compose.yml - sed -i 's/create_bucket,//g' - sed -i 's/frontend,//g' - name: Build and start services run: | From 780c475bec33e5c2f8b24089e80f44afc2732f36 Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Thu, 12 Dec 2024 15:24:57 -0700 Subject: [PATCH 19/55] Code review changes. --- frontend/src/views/OtherUses/_schema.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/views/OtherUses/_schema.jsx b/frontend/src/views/OtherUses/_schema.jsx index 82e96bd37..9af82de0b 100644 --- a/frontend/src/views/OtherUses/_schema.jsx +++ b/frontend/src/views/OtherUses/_schema.jsx @@ -180,10 +180,9 @@ export const otherUsesColDefs = (optionsData, errors) => [ }, { field: 'units', - headerName: i18n.t('otherUses:units'), + headerName: i18n.t('otherUses:otherUsesColLabels.units'), cellEditor: 'agSelectCellEditor', cellEditorParams: (params) => { - console.log('cellEditorParams: ', params); const fuelType = optionsData?.fuelTypes?.find( (obj) => params.data.fuelType === obj.fuelType ); From b488ee95772c47fb84ecaa06c648681b07c9960d Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 14:28:11 -0800 Subject: [PATCH 20/55] . --- .github/workflows/docker-auto-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-auto-test.yaml b/.github/workflows/docker-auto-test.yaml index 837d2d171..fe886f335 100644 --- a/.github/workflows/docker-auto-test.yaml +++ b/.github/workflows/docker-auto-test.yaml @@ -49,7 +49,7 @@ jobs: - name: Build and start services run: | docker-compose build - docker-compose up -d + docker-compose up -d db redis rabbitmq backend - name: Run backend tests id: backend_tests From c2864ecea19290d7a2afab618aeb5ff9fff69f24 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Thu, 12 Dec 2024 14:32:39 -0800 Subject: [PATCH 21/55] . --- .github/workflows/docker-auto-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-auto-test.yaml b/.github/workflows/docker-auto-test.yaml index fe886f335..837d2d171 100644 --- a/.github/workflows/docker-auto-test.yaml +++ b/.github/workflows/docker-auto-test.yaml @@ -49,7 +49,7 @@ jobs: - name: Build and start services run: | docker-compose build - docker-compose up -d db redis rabbitmq backend + docker-compose up -d - name: Run backend tests id: backend_tests From ec5620a7ed892d50b49f499ea315a815cea115cf Mon Sep 17 00:00:00 2001 From: Kevin Hashimoto Date: Thu, 12 Dec 2024 14:59:08 -0800 Subject: [PATCH 22/55] feat: clickable summaries --- .../AllocationAgreementSummary.jsx | 17 +++++++++--- .../FinalSupplyEquipmentSummary.jsx | 26 ++++++++++++++----- .../views/FuelExports/FuelExportSummary.jsx | 16 ++++++++++-- .../views/FuelSupplies/FuelSupplySummary.jsx | 17 +++++++++--- .../NotionalTransferSummary.jsx | 18 +++++++++++-- .../src/views/OtherUses/OtherUsesSummary.jsx | 23 +++++++++++++--- 6 files changed, 97 insertions(+), 20 deletions(-) diff --git a/frontend/src/views/AllocationAgreements/AllocationAgreementSummary.jsx b/frontend/src/views/AllocationAgreements/AllocationAgreementSummary.jsx index 7e76706cf..30b1a9f02 100644 --- a/frontend/src/views/AllocationAgreements/AllocationAgreementSummary.jsx +++ b/frontend/src/views/AllocationAgreements/AllocationAgreementSummary.jsx @@ -1,23 +1,24 @@ import BCAlert from '@/components/BCAlert' import BCBox from '@/components/BCBox' import BCDataGridServer from '@/components/BCDataGrid/BCDataGridServer' -import { apiRoutes } from '@/constants/routes' +import { apiRoutes, ROUTES } from '@/constants/routes' import Grid2 from '@mui/material/Unstable_Grid2/Grid2' import { formatNumberWithCommas as valueFormatter } from '@/utils/formatters' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useLocation, useParams } from 'react-router-dom' +import { useLocation, useParams, useNavigate } from 'react-router-dom' import { v4 as uuid } from 'uuid' export const AllocationAgreementSummary = ({ data }) => { const [alertMessage, setAlertMessage] = useState('') const [alertSeverity, setAlertSeverity] = useState('info') const [gridKey, setGridKey] = useState(`allocation-agreements-grid`) - const { complianceReportId } = useParams() + const { complianceReportId, compliancePeriod } = useParams() const gridRef = useRef() const { t } = useTranslation(['common', 'allocationAgreement']) const location = useLocation() + const navigate = useNavigate() useEffect(() => { if (location.state?.message) { @@ -135,6 +136,15 @@ export const AllocationAgreementSummary = ({ data }) => { setGridKey(`allocation-agreements-grid-${uuid()}`) } + const handleRowClicked = (params) => { + navigate( + ROUTES.REPORTS_ADD_ALLOCATION_AGREEMENTS.replace( + ':compliancePeriod', + compliancePeriod + ).replace(':complianceReportId', complianceReportId) + ) + } + return (
@@ -159,6 +169,7 @@ export const AllocationAgreementSummary = ({ data }) => { enableCopyButton={false} defaultColDef={defaultColDef} suppressPagination={data.allocationAgreements.length <= 10} + handleRowClicked={handleRowClicked} /> diff --git a/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx b/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx index 1a2f7851e..4753dc1fc 100644 --- a/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx +++ b/frontend/src/views/FinalSupplyEquipments/FinalSupplyEquipmentSummary.jsx @@ -1,23 +1,24 @@ import BCAlert from '@/components/BCAlert' import BCBox from '@/components/BCBox' import BCDataGridServer from '@/components/BCDataGrid/BCDataGridServer' -import { apiRoutes } from '@/constants/routes' +import { apiRoutes, ROUTES } from '@/constants/routes' import { CommonArrayRenderer } from '@/utils/grid/cellRenderers' import Grid2 from '@mui/material/Unstable_Grid2/Grid2' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useLocation, useParams } from 'react-router-dom' +import { useLocation, useParams, useNavigate } from 'react-router-dom' import { v4 as uuid } from 'uuid' export const FinalSupplyEquipmentSummary = ({ data }) => { const [alertMessage, setAlertMessage] = useState('') const [alertSeverity, setAlertSeverity] = useState('info') const [gridKey, setGridKey] = useState(`final-supply-equipments-grid`) - const { complianceReportId } = useParams() + const { complianceReportId, compliancePeriod } = useParams() const gridRef = useRef() const { t } = useTranslation(['common', 'finalSupplyEquipments']) const location = useLocation() + const navigate = useNavigate() useEffect(() => { if (location.state?.message) { @@ -67,9 +68,12 @@ export const FinalSupplyEquipmentSummary = ({ data }) => { field: 'supplyToDate' }, { - headerName: t('finalSupplyEquipment:finalSupplyEquipmentColLabels.kwhUsage'), + headerName: t( + 'finalSupplyEquipment:finalSupplyEquipmentColLabels.kwhUsage' + ), field: 'kwhUsage', - valueFormatter: (params) => params.value ? params.value.toFixed(2) : '0.00' + valueFormatter: (params) => + params.value ? params.value.toFixed(2) : '0.00' }, { headerName: t( @@ -106,7 +110,7 @@ export const FinalSupplyEquipmentSummary = ({ data }) => { headerName: t( 'finalSupplyEquipment:finalSupplyEquipmentColLabels.ports' ), - field: 'ports', + field: 'ports' }, { headerName: t( @@ -183,6 +187,15 @@ export const FinalSupplyEquipmentSummary = ({ data }) => { setGridKey(`final-supply-equipments-grid-${uuid()}`) } + const handleRowClicked = (params) => { + navigate( + ROUTES.REPORTS_ADD_FINAL_SUPPLY_EQUIPMENTS.replace( + ':compliancePeriod', + compliancePeriod + ).replace(':complianceReportId', complianceReportId) + ) + } + return (
@@ -207,6 +220,7 @@ export const FinalSupplyEquipmentSummary = ({ data }) => { enableCopyButton={false} defaultColDef={defaultColDef} suppressPagination={data.finalSupplyEquipments.length <= 10} + handleRowClicked={handleRowClicked} /> diff --git a/frontend/src/views/FuelExports/FuelExportSummary.jsx b/frontend/src/views/FuelExports/FuelExportSummary.jsx index 03f2f06d5..4cab9c377 100644 --- a/frontend/src/views/FuelExports/FuelExportSummary.jsx +++ b/frontend/src/views/FuelExports/FuelExportSummary.jsx @@ -6,17 +6,19 @@ import { formatNumberWithCommas as valueFormatter } from '@/utils/formatters' import Grid2 from '@mui/material/Unstable_Grid2/Grid2' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useLocation, useParams } from 'react-router-dom' +import { useLocation, useParams, useNavigate } from 'react-router-dom' import i18n from '@/i18n' +import { ROUTES } from '@/constants/routes' export const FuelExportSummary = ({ data }) => { const [alertMessage, setAlertMessage] = useState('') const [alertSeverity, setAlertSeverity] = useState('info') - const { complianceReportId } = useParams() + const { complianceReportId, compliancePeriod } = useParams() const gridRef = useRef() const { t } = useTranslation(['common', 'fuelExport']) const location = useLocation() + const navigate = useNavigate() useEffect(() => { if (location.state?.message) { @@ -122,6 +124,15 @@ export const FuelExportSummary = ({ data }) => { return params.data.fuelExportId.toString() } + const handleRowClicked = (params) => { + navigate( + ROUTES.REPORTS_ADD_FUEL_EXPORTS.replace( + ':compliancePeriod', + compliancePeriod + ).replace(':complianceReportId', complianceReportId) + ) + } + return (
@@ -144,6 +155,7 @@ export const FuelExportSummary = ({ data }) => { enableCopyButton={false} defaultColDef={defaultColDef} suppressPagination={data.fuelExports.length <= 10} + onRowClicked={handleRowClicked} /> diff --git a/frontend/src/views/FuelSupplies/FuelSupplySummary.jsx b/frontend/src/views/FuelSupplies/FuelSupplySummary.jsx index a8ea2298c..2e913d8dd 100644 --- a/frontend/src/views/FuelSupplies/FuelSupplySummary.jsx +++ b/frontend/src/views/FuelSupplies/FuelSupplySummary.jsx @@ -1,12 +1,12 @@ import BCAlert from '@/components/BCAlert' import BCBox from '@/components/BCBox' import BCDataGridServer from '@/components/BCDataGrid/BCDataGridServer' -import { apiRoutes } from '@/constants/routes' +import { apiRoutes, ROUTES } from '@/constants/routes' import { formatNumberWithCommas as valueFormatter } from '@/utils/formatters' import Grid2 from '@mui/material/Unstable_Grid2/Grid2' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useLocation, useParams } from 'react-router-dom' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import { v4 as uuid } from 'uuid' import i18n from '@/i18n' import { StandardCellWarningAndErrors } from '@/utils/grid/errorRenderers' @@ -15,11 +15,12 @@ export const FuelSupplySummary = ({ data }) => { const [alertMessage, setAlertMessage] = useState('') const [alertSeverity, setAlertSeverity] = useState('info') const [gridKey, setGridKey] = useState(`fuel-supplies-grid`) - const { complianceReportId } = useParams() + const { complianceReportId, compliancePeriod } = useParams() const gridRef = useRef() const { t } = useTranslation(['common', 'fuelSupply']) const location = useLocation() + const navigate = useNavigate() useEffect(() => { if (location.state?.message) { @@ -125,6 +126,15 @@ export const FuelSupplySummary = ({ data }) => { setGridKey(`fuel-supplies-grid-${uuid()}`) } + const handleRowClicked = (params) => { + navigate( + ROUTES.REPORTS_ADD_SUPPLY_OF_FUEL.replace( + ':compliancePeriod', + compliancePeriod + ).replace(':complianceReportId', complianceReportId) + ) + } + return (
@@ -149,6 +159,7 @@ export const FuelSupplySummary = ({ data }) => { enableCopyButton={false} defaultColDef={defaultColDef} suppressPagination={data.fuelSupplies.length <= 10} + handleRowClicked={handleRowClicked} /> diff --git a/frontend/src/views/NotionalTransfers/NotionalTransferSummary.jsx b/frontend/src/views/NotionalTransfers/NotionalTransferSummary.jsx index 1cc1c5017..e93d2502f 100644 --- a/frontend/src/views/NotionalTransfers/NotionalTransferSummary.jsx +++ b/frontend/src/views/NotionalTransfers/NotionalTransferSummary.jsx @@ -5,16 +5,18 @@ import { useGetNotionalTransfers } from '@/hooks/useNotionalTransfer' import Grid2 from '@mui/material/Unstable_Grid2/Grid2' import { useEffect, useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useLocation, useParams } from 'react-router-dom' +import { useLocation, useParams, useNavigate } from 'react-router-dom' import { formatNumberWithCommas as valueFormatter } from '@/utils/formatters' +import { ROUTES } from '@/constants/routes' export const NotionalTransferSummary = ({ data }) => { const [alertMessage, setAlertMessage] = useState('') const [alertSeverity, setAlertSeverity] = useState('info') - const { complianceReportId } = useParams() + const { complianceReportId, compliancePeriod } = useParams() const { t } = useTranslation(['common', 'notionalTransfers']) const location = useLocation() + const navigate = useNavigate() useEffect(() => { if (location.state?.message) { @@ -32,6 +34,16 @@ export const NotionalTransferSummary = ({ data }) => { [] ) + const handleRowClicked = (params) => { + console.log('Row clicked', params) + navigate( + ROUTES.REPORTS_ADD_NOTIONAL_TRANSFERS.replace( + ':compliancePeriod', + compliancePeriod + ).replace(':complianceReportId', complianceReportId) + ) + } + const columns = [ { headerName: t('notionalTransfer:notionalTransferColLabels.legalName'), @@ -90,6 +102,8 @@ export const NotionalTransferSummary = ({ data }) => { }} enableCellTextSelection ensureDomOrder + handleRo + onRowClicked={handleRowClicked} /> diff --git a/frontend/src/views/OtherUses/OtherUsesSummary.jsx b/frontend/src/views/OtherUses/OtherUsesSummary.jsx index bd5f2b499..7c8020739 100644 --- a/frontend/src/views/OtherUses/OtherUsesSummary.jsx +++ b/frontend/src/views/OtherUses/OtherUsesSummary.jsx @@ -4,18 +4,23 @@ import { BCGridViewer } from '@/components/BCDataGrid/BCGridViewer' import { useGetOtherUses } from '@/hooks/useOtherUses' import Grid2 from '@mui/material/Unstable_Grid2/Grid2' import { useEffect, useState } from 'react' -import { useLocation, useParams } from 'react-router-dom' -import { formatNumberWithCommas as valueFormatter, decimalFormatter } from '@/utils/formatters' +import { useLocation, useParams, useNavigate } from 'react-router-dom' +import { + formatNumberWithCommas as valueFormatter, + decimalFormatter +} from '@/utils/formatters' import { useTranslation } from 'react-i18next' +import { ROUTES } from '@/constants/routes' export const OtherUsesSummary = ({ data }) => { const [alertMessage, setAlertMessage] = useState('') const [alertSeverity, setAlertSeverity] = useState('info') const { t } = useTranslation(['common', 'otherUses']) - const { complianceReportId } = useParams() + const { complianceReportId, compliancePeriod } = useParams() const location = useLocation() + const navigate = useNavigate() useEffect(() => { if (location.state?.message) { @@ -81,6 +86,15 @@ export const OtherUsesSummary = ({ data }) => { const getRowId = (params) => params.data.otherUsesId + const handleRowClicked = (params) => { + navigate( + ROUTES.REPORTS_ADD_OTHER_USE_FUELS.replace( + ':compliancePeriod', + compliancePeriod + ).replace(':complianceReportId', complianceReportId) + ) + } + return (
@@ -106,10 +120,11 @@ export const OtherUsesSummary = ({ data }) => { }} enableCellTextSelection ensureDomOrder + onRowClicked={handleRowClicked} /> ) } -OtherUsesSummary.displayName = 'OtherUsesSummary' \ No newline at end of file +OtherUsesSummary.displayName = 'OtherUsesSummary' From 39d0e622d37799b1d8ad036f1cfa541d90a9c6e0 Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Fri, 13 Dec 2024 10:07:48 -0700 Subject: [PATCH 23/55] Autopopulate fuelCategory and fuelCode when selecting fuelType --- backend/lcfs/web/api/other_uses/schema.py | 7 ++++ frontend/src/components/BCDataGrid/columns.js | 1 + .../src/views/OtherUses/AddEditOtherUses.jsx | 41 ++++++++++++++----- frontend/src/views/OtherUses/_schema.jsx | 17 +++++--- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/backend/lcfs/web/api/other_uses/schema.py b/backend/lcfs/web/api/other_uses/schema.py index 51327f772..db3e591be 100644 --- a/backend/lcfs/web/api/other_uses/schema.py +++ b/backend/lcfs/web/api/other_uses/schema.py @@ -40,12 +40,19 @@ class ExpectedUseTypeSchema(BaseSchema): description: Optional[str] = None +class FuelCategorySchema(BaseSchema): + fuel_category_id: int + category: str + description: Optional[str] = None + + class FuelTypeSchema(BaseSchema): fuel_type_id: int fuel_type: str fossil_derived: Optional[bool] = None provision_1_id: Optional[int] = None provision_2_id: Optional[int] = None + fuel_categories: List[FuelCategorySchema] default_carbon_intensity: Optional[float] = None fuel_codes: Optional[List[FuelCodeSchema]] = [] provision_of_the_act: Optional[List[ProvisionOfTheActSchema]] = [] diff --git a/frontend/src/components/BCDataGrid/columns.js b/frontend/src/components/BCDataGrid/columns.js index 0e491f34e..4e26c12cd 100644 --- a/frontend/src/components/BCDataGrid/columns.js +++ b/frontend/src/components/BCDataGrid/columns.js @@ -24,6 +24,7 @@ export const actions = (props) => ({ cellRendererParams: props, pinned: 'left', maxWidth: 110, + minWidth: 90, editable: false, suppressKeyboardEvent, filter: false, diff --git a/frontend/src/views/OtherUses/AddEditOtherUses.jsx b/frontend/src/views/OtherUses/AddEditOtherUses.jsx index bbd553ca3..af806ce00 100644 --- a/frontend/src/views/OtherUses/AddEditOtherUses.jsx +++ b/frontend/src/views/OtherUses/AddEditOtherUses.jsx @@ -151,20 +151,39 @@ export const AddEditOtherUses = () => { const ciOfFuel = findCiOfFuel(params.data, optionsData) params.node.setDataValue('ciOfFuel', ciOfFuel) - // Auto-populate the "Unit" field based on the selected fuel type - if (params.colDef.field === 'fuelType') { - const fuelType = optionsData?.fuelTypes?.find( - (obj) => params.data.fuelType === obj.fuelType - ); - if (fuelType && fuelType.units) { - params.node.setDataValue('units', fuelType.units); - } else { - params.node.setDataValue('units', ''); + // Auto-populate fields based on the selected fuel type + if (params.colDef.field === 'fuelType') { + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ); + if (fuelType) { + // Auto-populate the "units" field + if (fuelType.units) { + params.node.setDataValue('units', fuelType.units); + } else { + params.node.setDataValue('units', ''); + } + + // Auto-populate the "fuelCategory" field + const fuelCategoryOptions = fuelType.fuelCategories.map( + (item) => item.category + ); + params.node.setDataValue('fuelCategory', fuelCategoryOptions[0] ?? null); + + // Auto-populate the "fuelCode" field + const fuelCodeOptions = fuelType.fuelCodes.map( + (code) => code.fuelCode + ); + params.node.setDataValue('fuelCode', fuelCodeOptions[0] ?? null); + params.node.setDataValue( + 'fuelCodeId', + fuelType.fuelCodes[0]?.fuelCodeId ?? null + ); + } } } - } }, - [optionsData] + [optionsData, findCiOfFuel] ) const onCellEditingStopped = useCallback( diff --git a/frontend/src/views/OtherUses/_schema.jsx b/frontend/src/views/OtherUses/_schema.jsx index 9af82de0b..555e86f25 100644 --- a/frontend/src/views/OtherUses/_schema.jsx +++ b/frontend/src/views/OtherUses/_schema.jsx @@ -49,12 +49,17 @@ export const otherUsesColDefs = (optionsData, errors) => [ headerName: i18n.t('otherUses:otherUsesColLabels.fuelCategory'), headerComponent: RequiredHeader, cellEditor: AutocompleteCellEditor, - cellEditorParams: { - options: optionsData.fuelCategories.map((obj) => obj.category), - multiple: false, - disableCloseOnSelect: false, - freeSolo: false, - openOnFocus: true + cellEditorParams: (params) => { + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ); + return { + options: fuelType ? fuelType.fuelCategories.map((item) => item.category) : [], + multiple: false, + disableCloseOnSelect: false, + freeSolo: false, + openOnFocus: true + }; }, suppressKeyboardEvent, cellRenderer: (params) => From ff27e832b63b57d41e638a2bbc262028fa92ff14 Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Fri, 13 Dec 2024 12:48:02 -0800 Subject: [PATCH 24/55] feat: enforce mandatory fuel code selection for schedules validation --- backend/lcfs/web/api/fuel_export/repo.py | 1 + backend/lcfs/web/application.py | 2 + .../locales/en/allocationAgreement.json | 1 + .../src/assets/locales/en/fuelExport.json | 3 +- .../src/assets/locales/en/fuelSupply.json | 1 + frontend/src/assets/locales/en/otherUses.json | 1 + .../AddEditAllocationAgreements.jsx | 24 ++- .../views/AllocationAgreements/_schema.jsx | 78 +++++++++- .../views/FuelExports/AddEditFuelExports.jsx | 34 ++++- frontend/src/views/FuelExports/_schema.jsx | 99 ++++++++++-- .../FuelSupplies/AddEditFuelSupplies.jsx | 37 +++-- frontend/src/views/FuelSupplies/_schema.jsx | 81 +++++++--- .../src/views/OtherUses/AddEditOtherUses.jsx | 61 +++++--- frontend/src/views/OtherUses/_schema.jsx | 142 +++++++++++------- 14 files changed, 437 insertions(+), 128 deletions(-) diff --git a/backend/lcfs/web/api/fuel_export/repo.py b/backend/lcfs/web/api/fuel_export/repo.py index 36aeb4ce1..d09a546dc 100644 --- a/backend/lcfs/web/api/fuel_export/repo.py +++ b/backend/lcfs/web/api/fuel_export/repo.py @@ -260,6 +260,7 @@ async def create_fuel_export(self, fuel_export: FuelExport) -> FuelExport: "fuel_type", "provision_of_the_act", "end_use_type", + "fuel_code", ], ) return fuel_export diff --git a/backend/lcfs/web/application.py b/backend/lcfs/web/application.py index e7117a105..8aef6126c 100644 --- a/backend/lcfs/web/application.py +++ b/backend/lcfs/web/application.py @@ -1,4 +1,6 @@ import logging +import os +import debugpy import uuid import structlog diff --git a/frontend/src/assets/locales/en/allocationAgreement.json b/frontend/src/assets/locales/en/allocationAgreement.json index 1da1af078..f120b7ebe 100644 --- a/frontend/src/assets/locales/en/allocationAgreement.json +++ b/frontend/src/assets/locales/en/allocationAgreement.json @@ -3,6 +3,7 @@ "noAllocationAgreementsFound": "No allocation agreements found", "addAllocationAgreementRowsTitle": "Allocation agreements (e.g., allocating responsibility for fuel)", "allocationAgreementSubtitle": "Enter allocation agreement details below", + "fuelCodeFieldRequiredError": "Error updating row: Fuel code field required", "allocationAgreementColLabels": { "transaction": "Responsibility", "transactionPartner": "Legal name of transaction partner", diff --git a/frontend/src/assets/locales/en/fuelExport.json b/frontend/src/assets/locales/en/fuelExport.json index 002fba7c1..d5cf55dc6 100644 --- a/frontend/src/assets/locales/en/fuelExport.json +++ b/frontend/src/assets/locales/en/fuelExport.json @@ -32,5 +32,6 @@ }, "validateMsg": { "isRequired": "{{field}} is required" - } + }, + "fuelCodeFieldRequiredError": "Error updating row: Fuel code field required" } diff --git a/frontend/src/assets/locales/en/fuelSupply.json b/frontend/src/assets/locales/en/fuelSupply.json index 3e6036080..93c75760b 100644 --- a/frontend/src/assets/locales/en/fuelSupply.json +++ b/frontend/src/assets/locales/en/fuelSupply.json @@ -9,6 +9,7 @@ "LoadFailMsg": "Failed to load supply of fuel rows", "addRow": "Add row", "rows": "rows", + "fuelCodeFieldRequiredError": "Error updating row: Fuel code field required", "fuelSupplyColLabels": { "complianceReportId": "Compliance Report ID", "fuelSupplyId": "Fuel supply ID", diff --git a/frontend/src/assets/locales/en/otherUses.json b/frontend/src/assets/locales/en/otherUses.json index c67e328ab..70b32650d 100644 --- a/frontend/src/assets/locales/en/otherUses.json +++ b/frontend/src/assets/locales/en/otherUses.json @@ -21,6 +21,7 @@ "approveConfirmText": "Are you sure you want to approve this other use entry?", "addRow": "Add row", "rows": "rows", + "fuelCodeFieldRequiredError": "Error updating row: Fuel code field required", "otherUsesColLabels": { "complianceReportId": "Compliance report ID", "fuelType": "Fuel type", diff --git a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx index f09d3ee0e..e5a2e34c9 100644 --- a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx +++ b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx @@ -3,7 +3,6 @@ import BCTypography from '@/components/BCTypography' import Grid2 from '@mui/material/Unstable_Grid2/Grid2' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate, useParams } from 'react-router-dom' -import { BCAlert2 } from '@/components/BCAlert' import BCBox from '@/components/BCBox' import { BCGridEditor } from '@/components/BCDataGrid/BCGridEditor' import { @@ -169,6 +168,29 @@ export const AddEditAllocationAgreements = () => { updatedData.ciOfFuel = DEFAULT_CI_FUEL[updatedData.fuelCategory] } + const isFuelCodeScenario = + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + if (isFuelCodeScenario && !updatedData.fuelCode) { + // Fuel code is required but not provided + setErrors((prevErrors) => ({ + ...prevErrors, + [params.node.data.id]: ['fuelCode'] + })) + + alertRef.current?.triggerAlert({ + message: t('allocationAgreement:fuelCodeFieldRequiredError'), + severity: 'error' + }) + + updatedData = { + ...updatedData, + validationStatus: 'error' + } + + params.node.updateData(updatedData) + return // Stop execution, do not proceed to save + } + try { setErrors({}) await saveRow(updatedData) diff --git a/frontend/src/views/AllocationAgreements/_schema.jsx b/frontend/src/views/AllocationAgreements/_schema.jsx index 6e503e3dd..d90a4e212 100644 --- a/frontend/src/views/AllocationAgreements/_schema.jsx +++ b/frontend/src/views/AllocationAgreements/_schema.jsx @@ -196,6 +196,7 @@ export const allocationAgreementColDefs = (optionsData, errors) => [ params.data.units = fuelType?.units params.data.unrecognized = fuelType?.unrecognized params.data.provisionOfTheAct = null + params.data.fuelCode = undefined } return true }, @@ -302,16 +303,85 @@ export const allocationAgreementColDefs = (optionsData, errors) => [ }), cellStyle: (params) => { const style = StandardCellErrors(params, errors) - const conditionalStyle = + const isFuelCodeScenario = params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE - ? { backgroundColor: '#fff', borderColor: 'unset' } - : { backgroundColor: '#f2f2f2' } + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + const fuelCodes = fuelType?.fuelCodes || [] + const fuelCodeRequiredAndMissing = + isFuelCodeScenario && !params.data.fuelCode + + let conditionalStyle = {} + + // If required and missing, show red border and white background + if (fuelCodeRequiredAndMissing) { + style.borderColor = 'red' + style.backgroundColor = '#fff' + } else { + // Apply conditional styling if not missing + conditionalStyle = + isFuelCodeScenario && fuelCodes.length > 0 + ? { + backgroundColor: '#fff', + borderColor: style.borderColor || 'unset' + } + : { backgroundColor: '#f2f2f2' } + } + return { ...style, ...conditionalStyle } }, suppressKeyboardEvent, minWidth: 150, editable: (params) => - params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE, + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE && + optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + )?.fuelCodes?.length > 0, + valueGetter: (params) => { + const fuelTypeObj = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + if (!fuelTypeObj) return params.data.fuelCode + + const isFuelCodeScenario = + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + const fuelCodes = + fuelTypeObj.fuelCodes?.map((item) => item.fuelCode) || [] + + if (isFuelCodeScenario && !params.data.fuelCode) { + // Autopopulate if only one fuel code is available + if (fuelCodes.length === 1) { + const singleFuelCode = fuelTypeObj.fuelCodes[0] + params.data.fuelCode = singleFuelCode.fuelCode + params.data.fuelCodeId = singleFuelCode.fuelCodeId + } + } + + return params.data.fuelCode + }, + valueSetter: (params) => { + if (params.newValue) { + params.data.fuelCode = params.newValue + + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + if (params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE) { + const matchingFuelCode = fuelType?.fuelCodes?.find( + (fuelCode) => params.data.fuelCode === fuelCode.fuelCode + ) + if (matchingFuelCode) { + params.data.fuelCodeId = matchingFuelCode.fuelCodeId + } + } + } else { + // If user clears the value + params.data.fuelCode = undefined + params.data.fuelCodeId = undefined + } + return true + }, tooltipValueGetter: (p) => 'Select the approved fuel code' }, { diff --git a/frontend/src/views/FuelExports/AddEditFuelExports.jsx b/frontend/src/views/FuelExports/AddEditFuelExports.jsx index ebaa03498..b9a40d86d 100644 --- a/frontend/src/views/FuelExports/AddEditFuelExports.jsx +++ b/frontend/src/views/FuelExports/AddEditFuelExports.jsx @@ -13,7 +13,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { v4 as uuid } from 'uuid' -import { defaultColDef, fuelExportColDefs } from './_schema' +import { + defaultColDef, + fuelExportColDefs, + PROVISION_APPROVED_FUEL_CODE +} from './_schema' export const AddEditFuelExports = () => { const [rowData, setRowData] = useState([]) @@ -143,7 +147,33 @@ export const AddEditFuelExports = () => { acc[key] = value return acc }, {}) + updatedData.compliancePeriod = compliancePeriod + + // Local validation before saving + const isFuelCodeScenario = + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + + if (isFuelCodeScenario && !updatedData.fuelCode) { + // Fuel code is required but not provided + setErrors((prevErrors) => ({ + ...prevErrors, + [params.node.data.id]: ['fuelCode'] + })) + + alertRef.current?.triggerAlert({ + message: t('fuelExport:fuelCodeFieldRequiredError'), + severity: 'error' + }) + + updatedData = { + ...updatedData, + validationStatus: 'error' + } + params.node.updateData(updatedData) + return // Don't proceed with save + } + try { setErrors({}) await saveRow(updatedData) @@ -189,7 +219,7 @@ export const AddEditFuelExports = () => { params.node.updateData(updatedData) }, - [saveRow, t] + [saveRow, t, compliancePeriod] ) const onAction = async (action, params) => { diff --git a/frontend/src/views/FuelExports/_schema.jsx b/frontend/src/views/FuelExports/_schema.jsx index f113671d3..0caae593b 100644 --- a/frontend/src/views/FuelExports/_schema.jsx +++ b/frontend/src/views/FuelExports/_schema.jsx @@ -17,6 +17,8 @@ import { fuelTypeOtherConditionalStyle } from '@/utils/fuelTypeOther' +export const PROVISION_APPROVED_FUEL_CODE = 'Fuel code - section 19 (b) (i)' + const cellErrorStyle = (params, errors) => { let style = {} if ( @@ -318,29 +320,94 @@ export const fuelExportColDefs = (optionsData, errors) => [ field: 'fuelCode', headerName: i18n.t('fuelExport:fuelExportColLabels.fuelCode'), cellEditor: 'agSelectCellEditor', - cellEditorParams: (params) => ({ - values: optionsData?.fuelTypes - ?.find((obj) => params.data.fuelType === obj.fuelType) - ?.fuelCodes.map((item) => item.fuelCode) - }), + suppressKeyboardEvent, + minWidth: 135, + cellEditorParams: (params) => { + const fuelTypeObj = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + return { + values: fuelTypeObj?.fuelCodes?.map((item) => item.fuelCode) || [] + } + }, cellStyle: (params) => { const style = cellErrorStyle(params, errors) + const fuelTypeObj = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + const fuelCodes = + fuelTypeObj?.fuelCodes.map((item) => item.fuelCode) || [] + const isFuelCodeScenario = + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + + // Check if fuel code is required (scenario) but missing + const fuelCodeRequiredAndMissing = + isFuelCodeScenario && !params.data.fuelCode + + if (fuelCodeRequiredAndMissing) { + // If required and missing, force a red border + style.borderColor = 'red' + } + const conditionalStyle = - optionsData?.fuelTypes - ?.find((obj) => params.data.fuelType === obj.fuelType) - ?.fuelCodes.map((item) => item.fuelCode).length > 0 && - /Fuel code/i.test(params.data.provisionOfTheAct) + fuelCodes.length > 0 && + isFuelCodeScenario && + !fuelCodeRequiredAndMissing ? { backgroundColor: '#fff', borderColor: 'unset' } : { backgroundColor: '#f2f2f2' } return { ...style, ...conditionalStyle } }, - suppressKeyboardEvent, - minWidth: 135, - editable: (params) => - optionsData?.fuelTypes - ?.find((obj) => params.data.fuelType === obj.fuelType) - ?.fuelCodes.map((item) => item.fuelCode).length > 0 && - /Fuel code/i.test(params.data.provisionOfTheAct) + editable: (params) => { + const fuelTypeObj = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + const fuelCodes = fuelTypeObj?.fuelCodes || [] + return ( + fuelCodes.length > 0 && + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + ) + }, + valueGetter: (params) => { + const fuelTypeObj = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + if (!fuelTypeObj) return params.data.fuelCode + + const isFuelCodeScenario = + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + const fuelCodes = + fuelTypeObj.fuelCodes?.map((item) => item.fuelCode) || [] + + if (isFuelCodeScenario && !params.data.fuelCode) { + // Autopopulate if only one fuel code is available + if (fuelCodes.length === 1) { + const singleFuelCode = fuelTypeObj.fuelCodes[0] + params.data.fuelCode = singleFuelCode.fuelCode + params.data.fuelCodeId = singleFuelCode.fuelCodeId + } + } + + return params.data.fuelCode + }, + valueSetter: (params) => { + const newCode = params.newValue + const fuelTypeObj = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + const selectedFuelCodeObj = fuelTypeObj?.fuelCodes.find( + (item) => item.fuelCode === newCode + ) + + if (selectedFuelCodeObj) { + params.data.fuelCode = selectedFuelCodeObj.fuelCode + params.data.fuelCodeId = selectedFuelCodeObj.fuelCodeId + } else { + params.data.fuelCode = undefined + params.data.fuelCodeId = undefined + } + + return true + } }, { field: 'quantity', diff --git a/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx b/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx index 7d7c4bd29..c78718694 100644 --- a/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx +++ b/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx @@ -14,7 +14,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { v4 as uuid } from 'uuid' -import { defaultColDef, fuelSupplyColDefs } from './_schema' +import { + defaultColDef, + fuelSupplyColDefs, + PROVISION_APPROVED_FUEL_CODE +} from './_schema' export const AddEditFuelSupplies = () => { const [rowData, setRowData] = useState([]) @@ -131,15 +135,6 @@ export const AddEditFuelSupplies = () => { 'fuelCategory', fuelCategoryOptions[0] ?? null ) - - const fuelCodeOptions = selectedFuelType.fuelCodes.map( - (code) => code.fuelCode - ) - params.node.setDataValue('fuelCode', fuelCodeOptions[0] ?? null) - params.node.setDataValue( - 'fuelCodeId', - selectedFuelType.fuelCodes[0]?.fuelCodeId ?? null - ) } } }, @@ -164,6 +159,28 @@ export const AddEditFuelSupplies = () => { if (updatedData.fuelType === 'Other') { updatedData.ciOfFuel = DEFAULT_CI_FUEL[updatedData.fuelCategory] } + + const isFuelCodeScenario = + params.node.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + if (isFuelCodeScenario && !params.node.data.fuelCode) { + // Set error on the row + setErrors({ + [params.node.data.id]: ['fuelCode'] + }) + + alertRef.current?.triggerAlert({ + message: t('fuelSupply:fuelCodeFieldRequiredError'), + severity: 'error' + }) + + // Update node data to reflect error state + params.node.updateData({ + ...params.node.data, + validationStatus: 'error' + }) + return // Stop saving further + } + try { setErrors({}) await saveRow(updatedData) diff --git a/frontend/src/views/FuelSupplies/_schema.jsx b/frontend/src/views/FuelSupplies/_schema.jsx index 912dc994d..0dc792d73 100644 --- a/frontend/src/views/FuelSupplies/_schema.jsx +++ b/frontend/src/views/FuelSupplies/_schema.jsx @@ -19,6 +19,8 @@ import { } from '@/utils/grid/errorRenderers' import { apiRoutes } from '@/constants/routes' +export const PROVISION_APPROVED_FUEL_CODE = 'Fuel code - section 19 (b) (i)' + export const fuelSupplyColDefs = (optionsData, errors, warnings) => [ validation, actions({ @@ -292,21 +294,35 @@ export const fuelSupplyColDefs = (optionsData, errors, warnings) => [ field: 'fuelCode', headerName: i18n.t('fuelSupply:fuelSupplyColLabels.fuelCode'), cellEditor: 'agSelectCellEditor', - cellEditorParams: (params) => ({ - values: optionsData?.fuelTypes - ?.find((obj) => params.data.fuelType === obj.fuelType) - ?.fuelCodes.map((item) => item.fuelCode) - }), + cellEditorParams: (params) => { + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + return { + values: fuelType?.fuelCodes.map((item) => item.fuelCode) || [] + } + }, cellStyle: (params) => { const style = StandardCellWarningAndErrors(params, errors, warnings) - const conditionalStyle = - optionsData?.fuelTypes - ?.find((obj) => params.data.fuelType === obj.fuelType) - ?.fuelCodes.map((item) => item.fuelCode).length > 0 && - /Fuel code/i.test(params.data.provisionOfTheAct) - ? { backgroundColor: '#fff' } - : { backgroundColor: '#f2f2f2', borderColor: 'unset' } - return { ...style, ...conditionalStyle } + const isFuelCodeScenario = + params.data?.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + const fuelCodes = fuelType?.fuelCodes || [] + const fuelCodeRequiredAndMissing = + isFuelCodeScenario && !params.data.fuelCode + + if (fuelCodeRequiredAndMissing) { + // Highlight the cell if fuel code is required but not selected + return { ...style, backgroundColor: '#fff', borderColor: 'red' } + } else if (isFuelCodeScenario && fuelCodes.length > 0) { + // Allow selection when scenario matches and codes are present + return { ...style, backgroundColor: '#fff', borderColor: 'unset' } + } else { + // Otherwise disabled styling + return { ...style, backgroundColor: '#f2f2f2', borderColor: 'unset' } + } }, suppressKeyboardEvent, minWidth: 135, @@ -314,29 +330,50 @@ export const fuelSupplyColDefs = (optionsData, errors, warnings) => [ const fuelType = optionsData?.fuelTypes?.find( (obj) => params.data.fuelType === obj.fuelType ) - if (fuelType) { - return ( - fuelType.fuelCodes.map((item) => item.fuelCode).length > 0 && - /Fuel code/i.test(params.data.provisionOfTheAct) - ) + return ( + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE && + fuelType?.fuelCodes?.length > 0 + ) + }, + valueGetter: (params) => { + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + if (!fuelType) return params.data.fuelCode + + const isFuelCodeScenario = + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + const fuelCodes = fuelType?.fuelCodes?.map((item) => item.fuelCode) || [] + + if (isFuelCodeScenario && !params.data.fuelCode) { + // If only one code is available, auto-populate + if (fuelCodes.length === 1) { + const singleFuelCode = fuelType.fuelCodes[0] + params.data.fuelCode = singleFuelCode.fuelCode + params.data.fuelCodeId = singleFuelCode.fuelCodeId + } } - return false + + return params.data.fuelCode }, valueSetter: (params) => { if (params.newValue) { params.data.fuelCode = params.newValue - const fuelType = optionsData?.fuelTypes?.find( (obj) => params.data.fuelType === obj.fuelType ) - if (/Fuel code/i.test(params.data.provisionOfTheAct)) { - const matchingFuelCode = fuelType.fuelCodes?.find( + if (params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE) { + const matchingFuelCode = fuelType?.fuelCodes?.find( (fuelCode) => params.data.fuelCode === fuelCode.fuelCode ) if (matchingFuelCode) { params.data.fuelCodeId = matchingFuelCode.fuelCodeId } } + } else { + // If user clears the value + params.data.fuelCode = undefined + params.data.fuelCodeId = undefined } return true } diff --git a/frontend/src/views/OtherUses/AddEditOtherUses.jsx b/frontend/src/views/OtherUses/AddEditOtherUses.jsx index 58586cb9e..e77edef9a 100644 --- a/frontend/src/views/OtherUses/AddEditOtherUses.jsx +++ b/frontend/src/views/OtherUses/AddEditOtherUses.jsx @@ -1,5 +1,4 @@ -import { BCAlert2 } from '@/components/BCAlert' -import BCButton from '@/components/BCButton' + import { BCGridEditor } from '@/components/BCDataGrid/BCGridEditor' import Loading from '@/components/Loading' import { @@ -8,16 +7,17 @@ import { useSaveOtherUses } from '@/hooks/useOtherUses' import { cleanEmptyStringValues } from '@/utils/formatters' -import { faFloppyDisk } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { Stack } from '@mui/material' import BCTypography from '@/components/BCTypography' import Grid2 from '@mui/material/Unstable_Grid2/Grid2' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { v4 as uuid } from 'uuid' -import { defaultColDef, otherUsesColDefs, PROVISION_APPROVED_FUEL_CODE} from './_schema' +import { + defaultColDef, + otherUsesColDefs, + PROVISION_APPROVED_FUEL_CODE +} from './_schema' import * as ROUTES from '@/constants/routes/routes.js' export const AddEditOtherUses = () => { @@ -55,31 +55,31 @@ export const AddEditOtherUses = () => { rows.map((row) => ({ ...row, id: row.id || uuid(), - isValid: true, - })); + isValid: true + })) - setRowData(ensureRowIds(otherUses)); + setRowData(ensureRowIds(otherUses)) } - }, [otherUses]); + }, [otherUses]) const findCiOfFuel = useCallback((data, optionsData) => { - let ciOfFuel = 0; + let ciOfFuel = 0 if (data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE) { const fuelType = optionsData?.fuelTypes?.find( (obj) => data.fuelType === obj.fuelType - ); + ) const fuelCode = fuelType?.fuelCodes?.find( (item) => item.fuelCode === data.fuelCode - ); - ciOfFuel = fuelCode?.carbonIntensity || 0; + ) + ciOfFuel = fuelCode?.carbonIntensity || 0 } else { const fuelType = optionsData?.fuelTypes?.find( (obj) => data.fuelType === obj.fuelType - ); - ciOfFuel = fuelType?.defaultCarbonIntensity || 0; + ) + ciOfFuel = fuelType?.defaultCarbonIntensity || 0 } - return ciOfFuel; - }, []); + return ciOfFuel + }, []) const onGridReady = (params) => { const ensureRowIds = (rows) => { @@ -169,6 +169,29 @@ export const AddEditOtherUses = () => { // clean up any null or empty string values let updatedData = cleanEmptyStringValues(params.data) + const isFuelCodeScenario = + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + if (isFuelCodeScenario && !updatedData.fuelCode) { + // Fuel code is required but not provided + setErrors((prevErrors) => ({ + ...prevErrors, + [params.node.data.id]: ['fuelCode'] + })) + + alertRef.current?.triggerAlert({ + message: t('otherUses:fuelCodeFieldRequiredError'), + severity: 'error' + }) + + updatedData = { + ...updatedData, + validationStatus: 'error' + } + + params.node.updateData(updatedData) + return // Stop execution, do not proceed to save + } + try { setErrors({}) await saveRow(updatedData) diff --git a/frontend/src/views/OtherUses/_schema.jsx b/frontend/src/views/OtherUses/_schema.jsx index a392bfc4a..a9088f6a6 100644 --- a/frontend/src/views/OtherUses/_schema.jsx +++ b/frontend/src/views/OtherUses/_schema.jsx @@ -23,7 +23,7 @@ export const otherUsesColDefs = (optionsData, errors) => [ hide: true }, { - field:'otherUsesId', + field: 'otherUsesId', hide: true }, { @@ -42,7 +42,15 @@ export const otherUsesColDefs = (optionsData, errors) => [ suppressKeyboardEvent, cellRenderer: (params) => params.value || Select, - cellStyle: (params) => StandardCellErrors(params, errors) + cellStyle: (params) => StandardCellErrors(params, errors), + valueSetter: (params) => { + if (params.newValue) { + // TODO: Evaluate if additional fields need to be reset when fuel type changes + params.data.fuelType = params.newValue + params.data.fuelCode = undefined + } + return true + } }, { field: 'fuelCategory', @@ -65,9 +73,7 @@ export const otherUsesColDefs = (optionsData, errors) => [ { field: 'provisionOfTheAct', headerComponent: RequiredHeader, - headerName: i18n.t( - 'otherUses:otherUsesColLabels.provisionOfTheAct' - ), + headerName: i18n.t('otherUses:otherUsesColLabels.provisionOfTheAct'), cellEditor: 'agSelectCellEditor', cellEditorParams: (params) => { const fuelType = optionsData?.fuelTypes?.find( @@ -89,11 +95,11 @@ export const otherUsesColDefs = (optionsData, errors) => [ suppressKeyboardEvent, valueSetter: (params) => { if (params.newValue !== params.oldValue) { - params.data.provisionOfTheAct = params.newValue; - params.data.fuelCode = ''; // Reset fuelCode when provisionOfTheAct changes - return true; + params.data.provisionOfTheAct = params.newValue + params.data.fuelCode = '' // Reset fuelCode when provisionOfTheAct changes + return true } - return false; + return false }, minWidth: 300, editable: true, @@ -105,61 +111,91 @@ export const otherUsesColDefs = (optionsData, errors) => [ headerName: i18n.t('otherUses:otherUsesColLabels.fuelCode'), cellEditor: AutocompleteCellEditor, cellEditorParams: (params) => { - const fuelType = optionsData?.fuelTypes?.find((obj) => params.data.fuelType === obj.fuelType); + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) - return { - options: fuelType?.fuelCodes?.map((item) => item.fuelCode) || [], // Safely access fuelCodes - multiple: false, - disableCloseOnSelect: false, - freeSolo: false, - openOnFocus: true - }; + return { + options: fuelType?.fuelCodes?.map((item) => item.fuelCode) || [], // Safely access fuelCodes + multiple: false, + disableCloseOnSelect: false, + freeSolo: false, + openOnFocus: true + } }, cellRenderer: (params) => { - const fuelType = optionsData?.fuelTypes?.find((obj) => params.data.fuelType === obj.fuelType); + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) if ( params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE && fuelType?.fuelCodes?.length > 0 ) { - return params.value || Select; + return ( + params.value || Select + ) } - return null; + return null }, cellStyle: (params) => { - const style = StandardCellErrors(params, errors); - const conditionalStyle = - params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE && - optionsData?.fuelTypes - ?.find((obj) => params.data.fuelType === obj.fuelType) - ?.fuelCodes?.length > 0 - ? { backgroundColor: '#fff', borderColor: 'unset' } - : { backgroundColor: '#f2f2f2' }; - return { ...style, ...conditionalStyle }; + const style = StandardCellErrors(params, errors) + const isFuelCodeScenario = + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + const fuelCodes = fuelType?.fuelCodes || [] + const fuelCodeRequiredAndMissing = + isFuelCodeScenario && !params.data.fuelCode + + // If required and missing, show red border + if (fuelCodeRequiredAndMissing) { + style.borderColor = 'red' + } + + const conditionalStyle = + isFuelCodeScenario && + fuelCodes.length > 0 && + !fuelCodeRequiredAndMissing + ? { + backgroundColor: '#fff', + borderColor: style.borderColor || 'unset' + } + : { backgroundColor: '#f2f2f2' } + + return { ...style, ...conditionalStyle } }, suppressKeyboardEvent, minWidth: 150, editable: (params) => { - const fuelType = optionsData?.fuelTypes?.find((obj) => params.data.fuelType === obj.fuelType); + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) return ( params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE && fuelType?.fuelCodes?.length > 0 - ); + ) }, - valueSetter: (params) => { - if (params.newValue) { - params.data.fuelCode = params.newValue; + valueGetter: (params) => { + const isFuelCodeScenario = + params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE + const fuelType = optionsData?.fuelTypes?.find( + (obj) => params.data.fuelType === obj.fuelType + ) + const fuelCodes = fuelType?.fuelCodes || [] - const fuelType = optionsData?.fuelTypes?.find((obj) => params.data.fuelType === obj.fuelType); - if (params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE) { - const matchingFuelCode = fuelType?.fuelCodes?.find( - (fuelCode) => params.data.fuelCode === fuelCode.fuelCode - ); - if (matchingFuelCode) { - params.data.fuelCodeId = matchingFuelCode.fuelCodeId; - } - } - } - return true; + if ( + isFuelCodeScenario && + !params.data.fuelCode && + fuelCodes.length === 1 + ) { + // Autopopulate if only one fuel code is available + const singleFuelCode = fuelCodes[0] + params.data.fuelCode = singleFuelCode.fuelCode + params.data.fuelCodeId = singleFuelCode.fuelCodeId + } + + return params.data.fuelCode }, tooltipValueGetter: (p) => 'Select the approved fuel code' }, @@ -205,31 +241,31 @@ export const otherUsesColDefs = (optionsData, errors) => [ valueGetter: (params) => { const fuelType = optionsData?.fuelTypes?.find( (obj) => params.data.fuelType === obj.fuelType - ); + ) if (params.data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE) { return ( fuelType?.fuelCodes?.find( (item) => item.fuelCode === params.data.fuelCode )?.carbonIntensity || 0 - ); + ) } if (fuelType) { if (params.data.fuelType === 'Other' && params.data.fuelCategory) { - const categories = fuelType.fuelCategories; + const categories = fuelType.fuelCategories const defaultCI = categories?.find( (cat) => cat.category === params.data.fuelCategory - )?.defaultAndPrescribedCi; + )?.defaultAndPrescribedCi - return defaultCI || 0; + return defaultCI || 0 } - return fuelType.defaultCarbonIntensity || 0; + return fuelType.defaultCarbonIntensity || 0 } - return 0; + return 0 }, - minWidth: 150, + minWidth: 150 }, { field: 'expectedUse', From 1ffb4f0a79e492e145c283c1e214c556f34e7bb7 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Sun, 15 Dec 2024 05:47:47 -0800 Subject: [PATCH 25/55] initial draft --- .../versions/2024-12-13-12-44_62bc9695a764.py | 44 ++++ .../notification/NotificationMessage.py | 4 + .../web/api/initiative_agreement/services.py | 11 +- backend/lcfs/web/api/notification/repo.py | 143 ++++++++++++- backend/lcfs/web/api/notification/schema.py | 35 ++- backend/lcfs/web/api/notification/services.py | 54 ++++- backend/lcfs/web/api/notification/views.py | 54 ++++- .../src/assets/locales/en/notifications.json | 21 +- .../components/BCDataGrid/BCGridViewer.jsx | 4 +- .../components/Renderers/ActionsRenderer2.jsx | 2 +- frontend/src/constants/routes/apiRoutes.js | 2 + frontend/src/hooks/useNotifications.js | 48 +++++ frontend/src/themes/base/globals.js | 9 +- .../AdminMenu/components/UserLoginHistory.jsx | 1 - .../components/Notifications.jsx | 200 +++++++++++++++++- .../NotificationMenu/components/_schema.jsx | 45 ++++ 16 files changed, 648 insertions(+), 29 deletions(-) create mode 100644 backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx diff --git a/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py b/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py new file mode 100644 index 000000000..160d3b11d --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py @@ -0,0 +1,44 @@ +"""Add type and transaction details to notification messages + +Revision ID: 62bc9695a764 +Revises: 7ae38a8413ab +Create Date: 2024-12-13 12:44:44.348419 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "62bc9695a764" +down_revision = "7ae38a8413ab" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("notification_message", sa.Column("type", sa.Text(), nullable=False)) + op.add_column( + "notification_message", sa.Column("transaction_id", sa.Integer(), nullable=True) + ) + op.create_foreign_key( + op.f("fk_notification_message_transaction_id_transaction"), + "notification_message", + "transaction", + ["transaction_id"], + ["transaction_id"], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("fk_notification_message_transaction_id_transaction"), + "notification_message", + type_="foreignkey", + ) + op.drop_column("notification_message", "transaction_id") + op.drop_column("notification_message", "type") + # ### end Alembic commands ### diff --git a/backend/lcfs/db/models/notification/NotificationMessage.py b/backend/lcfs/db/models/notification/NotificationMessage.py index b28919330..a339da56e 100644 --- a/backend/lcfs/db/models/notification/NotificationMessage.py +++ b/backend/lcfs/db/models/notification/NotificationMessage.py @@ -20,6 +20,7 @@ class NotificationMessage(BaseModel, Auditable): is_warning = Column(Boolean, default=False) is_error = Column(Boolean, default=False) is_archived = Column(Boolean, default=False) + type = Column(Text, nullable=False) message = Column(Text, nullable=False) related_organization_id = Column( @@ -32,12 +33,15 @@ class NotificationMessage(BaseModel, Auditable): notification_type_id = Column( Integer, ForeignKey("notification_type.notification_type_id") ) + transaction_id = Column(Integer, ForeignKey("transaction.transaction_id"), nullable=True) # Models not created yet # related_transaction_id = Column(Integer,ForeignKey('')) # related_document_id = Column(Integer, ForeignKey('document.id')) # related_report_id = Column(Integer, ForeignKey('compliance_report.id')) + # Relationships + related_transaction = relationship("Transaction") related_organization = relationship( "Organization", back_populates="notification_messages" ) diff --git a/backend/lcfs/web/api/initiative_agreement/services.py b/backend/lcfs/web/api/initiative_agreement/services.py index b7697f2a4..93fe6df58 100644 --- a/backend/lcfs/web/api/initiative_agreement/services.py +++ b/backend/lcfs/web/api/initiative_agreement/services.py @@ -212,12 +212,13 @@ async def director_approve_initiative_agreement( async def _perform_notificaiton_call(self, ia, re_recommended=False): """Send notifications based on the current status of the transfer.""" - notifications = INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER.get( - ia.current_status.status if not re_recommended else "Return to analyst", - None, - ) + status = ia.current_status.status if not re_recommended else "Return to analyst" + status_val = (status.value if isinstance(status, InitiativeAgreementStatusEnum) else status).lower() + notifications = INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER.get(status, None) notification_data = NotificationMessageSchema( - message=f"Initiative Agreement {ia.initiative_agreement_id} has been {ia.current_status.status}", + type=f"Initiative agreement {status_val}", + transaction_id=ia.transaction_id, + message=f"Initiative Agreement {ia.initiative_agreement_id} has been {status_val}", related_organization_id=ia.to_organization_id, origin_user_profile_id=self.request.user.user_profile_id, ) diff --git a/backend/lcfs/web/api/notification/repo.py b/backend/lcfs/web/api/notification/repo.py index ec32f9716..75c9cf05b 100644 --- a/backend/lcfs/web/api/notification/repo.py +++ b/backend/lcfs/web/api/notification/repo.py @@ -6,19 +6,25 @@ ChannelEnum, ) from lcfs.db.models.user import UserProfile -from lcfs.web.api.base import NotificationTypeEnum +from lcfs.db.models.user.UserRole import UserRole +from lcfs.web.api.base import ( + NotificationTypeEnum, + PaginationRequestSchema, + validate_pagination, +) import structlog -from typing import List, Optional +from typing import List, Optional, Sequence from fastapi import Depends from lcfs.db.dependencies import get_async_db_session from lcfs.web.exception.exceptions import DataNotFoundException -from sqlalchemy import delete, or_, select, func +from sqlalchemy import delete, or_, select, func, update from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import selectinload, joinedload from lcfs.web.core.decorators import repo_handler +from sqlalchemy import and_ logger = structlog.get_logger(__name__) @@ -66,8 +72,15 @@ async def get_notification_messages_by_user( Retrieve all notification messages for a user """ # Start building the query - query = select(NotificationMessage).where( - NotificationMessage.related_user_profile_id == user_profile_id + query = ( + select(NotificationMessage) + .options( + joinedload(NotificationMessage.related_organization), + joinedload(NotificationMessage.origin_user_profile) + .joinedload(UserProfile.user_roles) + .joinedload(UserRole.role), + ) + .where(NotificationMessage.related_user_profile_id == user_profile_id) ) # Apply additional filter for `is_read` if provided @@ -76,7 +89,82 @@ async def get_notification_messages_by_user( # Execute the query and retrieve the results result = await self.db.execute(query) - return result.scalars().all() + return result.unique().scalars().all() + + def _apply_notification_filters( + self, pagination: PaginationRequestSchema, conditions: List + ): + for filter in pagination.filters: + filter_value = filter.filter + filter_option = filter.type + filter_type = filter.filter_type + + # Handle date filters + if filter.filter_type == "date": + filter_value = [] + if filter.date_from: + filter_value.append(filter.date_from) + if filter.date_to: + filter_value.append(filter.date_to) + if not filter_value: + continue # Skip if no valid date is provided + + return conditions + + @repo_handler + async def get_paginated_notification_messages( + self, user_id, pagination: PaginationRequestSchema + ) -> tuple[Sequence[NotificationMessage], int]: + """ + Queries notification messages from the database with optional filters. Supports pagination and sorting. + + Args: + pagination (dict): Pagination and sorting parameters. + + Returns: + List[NotificationSchema]: A list of notification messages matching the query. + """ + conditions = [NotificationMessage.related_user_profile_id == user_id] + pagination = validate_pagination(pagination) + + if pagination.filters: + self._apply_notification_filters(pagination, conditions) + + offset = 0 if (pagination.page < 1) else (pagination.page - 1) * pagination.size + limit = pagination.size + # Start building the query + query = ( + select(NotificationMessage) + .options( + joinedload(NotificationMessage.related_organization), + joinedload(NotificationMessage.origin_user_profile) + .joinedload(UserProfile.user_roles) + .joinedload(UserRole.role), + ) + .where(and_(*conditions)) + ) + + # Apply sorting + if not pagination.sort_orders: + query = query.order_by(NotificationMessage.create_date.desc()) + # for order in pagination.sort_orders: + # direction = asc if order.direction == "asc" else desc + # if order.field == "status": + # field = getattr(FuelCodeStatus, "status") + # elif order.field == "prefix": + # field = getattr(FuelCodePrefix, "prefix") + # else: + # field = getattr(FuelCode, order.field) + # query = query.order_by(direction(field)) + + # Execute the count query to get the total count + count_query = query.with_only_columns(func.count()).order_by(None) + total_count = (await self.db.execute(count_query)).scalar() + + # Execute the main query to retrieve all notification_messages + result = await self.db.execute(query.offset(offset).limit(limit)) + notification_messages = result.unique().scalars().all() + return notification_messages, total_count @repo_handler async def get_notification_message_by_id( @@ -136,6 +224,20 @@ async def delete_notification_message(self, notification_id: int): await self.db.execute(query) await self.db.flush() + @repo_handler + async def delete_notification_messages(self, user_id, notification_ids: List[int]): + """ + Delete a notification_message by id + """ + query = delete(NotificationMessage).where( + and_( + NotificationMessage.notification_message_id.in_(notification_ids), + NotificationMessage.related_user_profile_id == user_id, + ) + ) + await self.db.execute(query) + await self.db.flush() + @repo_handler async def mark_notification_as_read( self, notification_id @@ -156,6 +258,31 @@ async def mark_notification_as_read( return notification + @repo_handler + async def mark_notifications_as_read( + self, user_id: int, notification_ids: List[int] + ): + """ + Mark notification messages as read for a user + """ + if not notification_ids: + return [] + + stmt = ( + update(NotificationMessage) + .where( + and_( + NotificationMessage.notification_message_id.in_(notification_ids), + NotificationMessage.related_user_profile_id == user_id, + ) + ) + .values(is_read=True) + ) + await self.db.execute(stmt) + await self.db.flush() + + return notification_ids + @repo_handler async def create_notification_channel_subscription( self, notification_channel_subscription: NotificationChannelSubscription @@ -291,7 +418,7 @@ async def get_subscribed_users_by_channel( NotificationChannel.channel_name == channel.value, or_( UserProfile.organization_id == organization_id, - UserProfile.organization_id.is_(None), + UserProfile.organization_id.is_(None), ), ) ) diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index 0176b9bdd..afe859f04 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any, Dict, List, Optional from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatusEnum @@ -5,7 +6,31 @@ InitiativeAgreementStatusEnum, ) from lcfs.db.models.transfer.TransferStatus import TransferStatusEnum -from lcfs.web.api.base import BaseSchema, NotificationTypeEnum +from lcfs.web.api.base import BaseSchema, NotificationTypeEnum, PaginationResponseSchema +from pydantic import computed_field, field_validator + + +class OrganizationSchema(BaseSchema): + organization_id: int + name: str + + +class UserProfileSchema(BaseSchema): + first_name: str + last_name: str + organization_id: Optional[int] = None + is_government: bool = False + + @field_validator("is_government", mode="after") + def update_gov_profile(cls, value, info): + if info.data.get("is_government", True): + info.data.update({"first_name": "Government of B.C.", "last_name": ""}) + return value + + @computed_field + @property + def full_name(self) -> str: + return f"{self.first_name} {self.last_name}" class NotificationMessageSchema(BaseSchema): @@ -14,9 +39,14 @@ class NotificationMessageSchema(BaseSchema): is_archived: Optional[bool] = False is_warning: Optional[bool] = False is_error: Optional[bool] = False + type: Optional[str] = None message: Optional[str] = None related_organization_id: Optional[int] = None + related_organization: Optional[OrganizationSchema] = None + transaction_id: Optional[int] = None + create_date: Optional[datetime] = None origin_user_profile_id: Optional[int] = None + origin_user_profile: Optional[UserProfileSchema] = None related_user_profile_id: Optional[int] = None notification_type_id: Optional[int] = None deleted: Optional[bool] = None @@ -52,6 +82,9 @@ class DeleteSubscriptionSchema(BaseSchema): class DeleteNotificationChannelSubscriptionResponseSchema(BaseSchema): message: str +class NotificationsSchema(BaseSchema): + notifications: List[NotificationMessageSchema] = [] + pagination: PaginationResponseSchema = None class NotificationRequestSchema(BaseSchema): notification_types: List[NotificationTypeEnum] diff --git a/backend/lcfs/web/api/notification/services.py b/backend/lcfs/web/api/notification/services.py index e64848823..a36796af7 100644 --- a/backend/lcfs/web/api/notification/services.py +++ b/backend/lcfs/web/api/notification/services.py @@ -1,13 +1,18 @@ -from typing import List, Optional, Union +import math +from typing import List, Optional from lcfs.db.models.notification import ( NotificationChannelSubscription, NotificationMessage, ChannelEnum, ) -from lcfs.web.api.base import NotificationTypeEnum +from lcfs.web.api.base import ( + PaginationRequestSchema, + PaginationResponseSchema, +) from lcfs.web.api.email.services import CHESEmailService from lcfs.web.api.notification.schema import ( NotificationRequestSchema, + NotificationsSchema, SubscriptionSchema, NotificationMessageSchema, ) @@ -47,6 +52,51 @@ async def get_notification_messages_by_user_id( for message in notification_models ] + @service_handler + async def get_paginated_notification_messages( + self, user_id: int, pagination: PaginationRequestSchema + ) -> NotificationsSchema: + """ + Retrieve all notifications for a given user with pagination, filtering and sorting. + """ + notifications, total_count = ( + await self.repo.get_paginated_notification_messages(user_id, pagination) + ) + return NotificationsSchema( + pagination=PaginationResponseSchema( + total=total_count, + page=pagination.page, + size=pagination.size, + total_pages=math.ceil(total_count / pagination.size), + ), + notifications=[ + NotificationMessageSchema.model_validate(notification) + for notification in notifications + ], + ) + + @service_handler + async def update_notification_messages( + self, user_id: int, notification_ids: List[int] + ): + """ + Update multiple notifications (mark as read). + """ + await self.repo.mark_notifications_as_read(user_id, notification_ids) + + return notification_ids + + @service_handler + async def delete_notification_messages( + self, user_id: int, notification_ids: List[int] + ): + """ + Delete multiple notifications. + """ + await self.repo.delete_notification_messages(user_id, notification_ids) + + return notification_ids + @service_handler async def get_notification_message_by_id(self, notification_id: int): """ diff --git a/backend/lcfs/web/api/notification/views.py b/backend/lcfs/web/api/notification/views.py index f4f98d0e9..f5caa8695 100644 --- a/backend/lcfs/web/api/notification/views.py +++ b/backend/lcfs/web/api/notification/views.py @@ -3,15 +3,17 @@ """ from typing import Union, List +from lcfs.web.api.base import PaginationRequestSchema from lcfs.web.exception.exceptions import DataNotFoundException import structlog -from fastapi import APIRouter, Body, Depends, HTTPException, Request +from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response from lcfs.db.models.user.Role import RoleEnum from lcfs.web.api.notification.schema import ( DeleteNotificationChannelSubscriptionResponseSchema, DeleteNotificationMessageResponseSchema, DeleteSubscriptionSchema, DeleteNotificationMessageSchema, + NotificationsSchema, SubscriptionSchema, NotificationMessageSchema, NotificationCountSchema, @@ -43,6 +45,56 @@ async def get_notification_messages_by_user_id( ) +@router.post( + "/list", response_model=NotificationsSchema, status_code=status.HTTP_200_OK +) +@view_handler(["*"]) +async def get_notification_messages_by_user_id( + request: Request, + pagination: PaginationRequestSchema = Body(..., embed=False), + response: Response = None, + service: NotificationService = Depends(), +): + """ + Retrieve all notifications of a user with pagination + """ + return await service.get_paginated_notification_messages( + user_id=request.user.user_profile_id, pagination=pagination + ) + + +@router.put("/", response_model=List[int], status_code=status.HTTP_200_OK) +@view_handler(["*"]) +async def update_notification_messages_to_read( + request: Request, + notification_ids: List[int] = Body(..., embed=False), + response: Response = None, + service: NotificationService = Depends(), +): + """ + Update notifications (mark the messages as read) + """ + return await service.update_notification_messages( + request.user.user_profile_id, notification_ids + ) + + +@router.delete("/", response_model=List[int], status_code=status.HTTP_200_OK) +@view_handler(["*"]) +async def delete_notification_messages( + request: Request, + notification_ids: List[int] = Body(..., embed=False), + response: Response = None, + service: NotificationService = Depends(), +): + """ + Delete notification messages + """ + return await service.delete_notification_messages( + request.user.user_profile_id, notification_ids + ) + + @router.get( "/count", response_model=NotificationCountSchema, diff --git a/frontend/src/assets/locales/en/notifications.json b/frontend/src/assets/locales/en/notifications.json index 5f985416c..5d3ac2313 100644 --- a/frontend/src/assets/locales/en/notifications.json +++ b/frontend/src/assets/locales/en/notifications.json @@ -90,5 +90,24 @@ "managerRecommendation": "Compliance manager recommendation" } } - } + }, + "buttonStack": { + "selectAll": "Select all", + "unselectAll": "Unselect all", + "markAsRead": "Mark as read", + "deleteSelected": "Delete selected" + }, + "notificationColLabels": { + "type": "Type", + "date": "Date", + "user": "User", + "transactionId": "Transaction ID", + "organization": "Organization" + }, + "noNotificationsFound": "No notification messages found.", + "noNotificationsSelectedText": "No messages selected.", + "deleteSuccessText": "Successfully deleted selected message(s).", + "deleteErrorText": "An error occurred while deleting the selected message(s).", + "markAsReadSuccessText": "Successfully updated message(s) as read.", + "markAsReadErrorText": "An error occurred while updating the message(s) as read." } diff --git a/frontend/src/components/BCDataGrid/BCGridViewer.jsx b/frontend/src/components/BCDataGrid/BCGridViewer.jsx index 429c3c153..8a5638431 100644 --- a/frontend/src/components/BCDataGrid/BCGridViewer.jsx +++ b/frontend/src/components/BCDataGrid/BCGridViewer.jsx @@ -1,4 +1,4 @@ -import BCAlert from '@/components/BCAlert' +import BCAlert, { FloatingAlert } from '@/components/BCAlert' import BCBox from '@/components/BCBox' import { BCGridBase } from '@/components/BCDataGrid/BCGridBase' import { BCPagination } from '@/components/BCDataGrid/components' @@ -9,6 +9,7 @@ import BCButton from '../BCButton' export const BCGridViewer = ({ gridRef, + alertRef, loading, defaultColDef, columnDefs, @@ -202,6 +203,7 @@ export const BCGridViewer = ({ className="bc-grid-container" data-test="bc-grid-container" > + { .some((cell) => cell.rowIndex === props.node.rowIndex) return ( - + {props.enableDuplicate && ( diff --git a/frontend/src/constants/routes/apiRoutes.js b/frontend/src/constants/routes/apiRoutes.js index c96a65db8..d79d25464 100644 --- a/frontend/src/constants/routes/apiRoutes.js +++ b/frontend/src/constants/routes/apiRoutes.js @@ -73,6 +73,8 @@ export const apiRoutes = { getUserLoginHistories: '/users/login-history', getAuditLogs: '/audit-log/list', getAuditLog: '/audit-log/:auditLogId', + notifications: '/notifications/', + getNotifications: '/notifications/list', getNotificationsCount: '/notifications/count', getNotificationSubscriptions: '/notifications/subscriptions', saveNotificationSubscriptions: '/notifications/subscriptions/save', diff --git a/frontend/src/hooks/useNotifications.js b/frontend/src/hooks/useNotifications.js index 87b286a87..df52f3fa1 100644 --- a/frontend/src/hooks/useNotifications.js +++ b/frontend/src/hooks/useNotifications.js @@ -17,6 +17,54 @@ export const useNotificationsCount = (options) => { }) } +export const useGetNotificationMessages = ( + { page = 1, size = 10, sortOrders = [], filters = [] } = {}, + options +) => { + const client = useApiService() + return useQuery({ + queryKey: ['notification-messages', page, size, sortOrders, filters], + queryFn: async () => { + const response = await client.post(apiRoutes.getNotifications, { + page, + size, + sortOrders, + filters + }) + return response.data + }, + ...options + }) +} + +export const useMarkNotificationAsRead = (options) => { + const client = useApiService() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (_ids) => + client.put(apiRoutes.notifications, _ids), + onSettled: () => { + queryClient.invalidateQueries(['notifications-count']) + queryClient.invalidateQueries(['notifications-messages']) + }, + ...options + }) +} + +export const useDeleteNotificationMessages = (options) => { + const client = useApiService() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (_ids) => + client.delete(apiRoutes.notifications, { data: _ids }), + onSettled: () => { + queryClient.invalidateQueries(['notifications-count']) + queryClient.invalidateQueries(['notifications-messages']) + }, + ...options + }) +} + export const useNotificationSubscriptions = (options) => { const client = useApiService() return useQuery({ diff --git a/frontend/src/themes/base/globals.js b/frontend/src/themes/base/globals.js index 4d39236fb..b247a8d51 100644 --- a/frontend/src/themes/base/globals.js +++ b/frontend/src/themes/base/globals.js @@ -88,6 +88,7 @@ const globals = { '--ag-header-column-resize-handle-height': '30%', '--ag-header-column-resize-handle-width': '2px', '--ag-header-column-resize-handle-color': '#dde2eb', + '--ag-material-accent-color': '#003366', '--ag-borders': `1px solid ${grey[700]}`, '--ag-border-color': grey[700], '--ag-odd-row-background-color': rgba(light.main, 0.6), @@ -109,6 +110,10 @@ const globals = { border: 'none', borderBottom: '2px solid #495057' }, + '.row-not-read': { + fontWeight: 700, + color: grey[700] + }, // editor theme for ag-grid quertz theme '.ag-theme-quartz': { '--ag-borders': `0.5px solid ${grey[400]} !important`, @@ -197,10 +202,10 @@ const globals = { color: grey[600], textTransform: 'none', fontSize: pxToRem(14), - padding: '6px 12px', + padding: '6px 12px' }, '.ag-filter-apply-panel-button[data-ref="clearFilterButton"]:hover': { - color: grey[900], + color: grey[900] }, '.MuiPaper-elevation': { diff --git a/frontend/src/views/Admin/AdminMenu/components/UserLoginHistory.jsx b/frontend/src/views/Admin/AdminMenu/components/UserLoginHistory.jsx index 1dbdd618c..3f3fbc452 100644 --- a/frontend/src/views/Admin/AdminMenu/components/UserLoginHistory.jsx +++ b/frontend/src/views/Admin/AdminMenu/components/UserLoginHistory.jsx @@ -31,7 +31,6 @@ export const UserLoginHistory = () => { defaultMinWidth: 50, defaultMaxWidth: 600 }} - rowSelection={{ isRowSelectable: false }} /> diff --git a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx index 13d413031..b3b740c2e 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx @@ -1,14 +1,202 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import BCTypography from '@/components/BCTypography' +import { Stack, Grid } from '@mui/material' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faSquareCheck } from '@fortawesome/free-solid-svg-icons' + +import BCButton from '@/components/BCButton' +import { BCGridViewer } from '@/components/BCDataGrid/BCGridViewer' +import { columnDefs } from './_schema' +import { + useGetNotificationMessages, + useMarkNotificationAsRead, + useDeleteNotificationMessages +} from '@/hooks/useNotifications' export const Notifications = () => { const { t } = useTranslation(['notifications']) + const gridRef = useRef(null) + const alertRef = useRef(null) + const [isAllSelected, setIsAllSelected] = useState(false) + + // react query hooks + const { refetch } = useGetNotificationMessages() + const markAsReadMutation = useMarkNotificationAsRead() + const deleteMutation = useDeleteNotificationMessages() + + // row class rules for unread messages + const rowClassRules = useMemo( + () => ({ + 'row-not-read': (params) => !params.data.isRead + }), + [] + ) + + // Consolidated mutation handler + const handleMutation = useCallback( + (mutation, selectedNotifications, successMessage, errorMessage) => { + if (selectedNotifications.length === 0) { + alertRef.current?.triggerAlert({ + message: t('notifications:noNotificationsSelectedText'), + severity: 'warning' + }) + return + } + + mutation.mutate(selectedNotifications, { + onSuccess: () => { + alertRef.current?.triggerAlert({ + message: t(successMessage), + severity: 'success' + }) + refetch() + }, + onError: (error) => { + alertRef.current?.triggerAlert({ + message: t(errorMessage, { error: error.message }), + severity: 'error' + }) + } + }) + }, + [t, refetch] + ) + + // Toggle selection for visible rows + const toggleSelectVisibleRows = useCallback(() => { + const gridApi = gridRef.current?.api + if (gridApi) { + gridApi.forEachNodeAfterFilterAndSort((node) => { + node.setSelected(!isAllSelected) + }) + setIsAllSelected(!isAllSelected) + } + }, [isAllSelected]) + + // event handlers for delete, markAsRead, and row-level deletes + const handleMarkAsRead = useCallback(() => { + const gridApi = gridRef.current?.api + if (gridApi) { + const selectedNotifications = gridApi + .getSelectedNodes() + .map((node) => node.data.notificationMessageId) + handleMutation( + markAsReadMutation, + selectedNotifications, + 'notifications:markAsReadSuccessText', + 'notifications:markAsReadErrorText' + ) + } + }, [handleMutation, markAsReadMutation]) + + const handleDelete = useCallback(() => { + const gridApi = gridRef.current?.api + if (gridApi) { + const selectedNotifications = gridApi + .getSelectedNodes() + .map((node) => node.data.notificationMessageId) + handleMutation( + deleteMutation, + selectedNotifications, + 'notifications:deleteSuccessText', + 'notifications:deleteErrorText' + ) + } + }, [handleMutation, deleteMutation]) + + const onCellClicked = useCallback( + (params) => { + if ( + params.column.colId === 'action' && + params.event.target.dataset.action + ) { + handleMutation( + deleteMutation, + [params.data.notificationMessageId], + 'notifications:deleteSuccessText', + 'notifications:deleteErrorText' + ) + } + }, + [handleMutation, deleteMutation] + ) + + // toggling selections effect + useEffect(() => { + const gridApi = gridRef.current?.api + if (gridApi) { + const selectionChangedHandler = () => { + const visibleRows = [] + gridApi.forEachNodeAfterFilterAndSort((node) => { + visibleRows.push(node) + }) + const selectedRows = visibleRows.filter((node) => node.isSelected()) + setIsAllSelected( + visibleRows.length > 0 && visibleRows.length === selectedRows.length + ) + } + + gridApi.addEventListener('selectionChanged', selectionChangedHandler) + + return () => { + gridApi.removeEventListener('selectionChanged', selectionChangedHandler) + } + } + }, []) return ( - <> - - {t('title.Notifications')} - - + + + + } + onClick={toggleSelectVisibleRows} + > + {isAllSelected + ? t('notifications:buttonStack.unselectAll') + : t('notifications:buttonStack.selectAll')} + + + {t('notifications:buttonStack.markAsRead')} + + + {t('notifications:buttonStack.deleteSelected')} + + + + ) } diff --git a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx new file mode 100644 index 000000000..dd1a5c022 --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx @@ -0,0 +1,45 @@ +import { dateFormatter } from '@/utils/formatters' +import { actions } from '@/components/BCDataGrid/columns' + +export const columnDefs = (t) => [ + { + ...actions({ enableDelete: true }), + headerName: 'Delete', + pinned: '' + }, + { + colId: 'type', + field: 'type', + headerName: t('notifications:notificationColLabels.type') + }, + { + colId: 'date', + field: 'date', + headerName: t('notifications:notificationColLabels.date'), + valueGetter: (params) => params.data.createDate, + valueFormatter: dateFormatter + }, + { + colId: 'user', + field: 'user', + headerName: t('notifications:notificationColLabels.user'), + valueGetter: (params) => params.data.originUserProfile.fullName.trim() + }, + { + colId: 'transactionId', + field: 'transactionId', + headerName: t('notifications:notificationColLabels.transactionId') + }, + { + colId: 'organization', + field: 'organization', + headerName: t('notifications:notificationColLabels.organization'), + valueGetter: (params) => params.data.relatedOrganization.name + } +] + +export const defaultColDef = { + editable: false, + resizable: true, + sortable: true +} From c4a6cc0bac919c69d4d6004737de3de4d23d78ca Mon Sep 17 00:00:00 2001 From: prv-proton Date: Mon, 16 Dec 2024 10:20:40 -0800 Subject: [PATCH 26/55] updates --- frontend/src/themes/base/globals.js | 2 +- .../components/Notifications.jsx | 45 ++++++++++--------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/frontend/src/themes/base/globals.js b/frontend/src/themes/base/globals.js index b247a8d51..26b4fe3cd 100644 --- a/frontend/src/themes/base/globals.js +++ b/frontend/src/themes/base/globals.js @@ -88,7 +88,7 @@ const globals = { '--ag-header-column-resize-handle-height': '30%', '--ag-header-column-resize-handle-width': '2px', '--ag-header-column-resize-handle-color': '#dde2eb', - '--ag-material-accent-color': '#003366', + '--ag-material-accent-color': grey[700], '--ag-borders': `1px solid ${grey[700]}`, '--ag-border-color': grey[700], '--ag-odd-row-background-color': rgba(light.main, 0.6), diff --git a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx index b3b740c2e..1bb406706 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx @@ -18,6 +18,7 @@ export const Notifications = () => { const gridRef = useRef(null) const alertRef = useRef(null) const [isAllSelected, setIsAllSelected] = useState(false) + const [selectedRowCount, setSelectedRowCount] = useState(0) // react query hooks const { refetch } = useGetNotificationMessages() @@ -31,6 +32,14 @@ export const Notifications = () => { }), [] ) + const selectionColumnDef = useMemo(() => { + return { + sortable: false, + resizable: false, + suppressHeaderMenuButton: true, + headerTooltip: 'Checkboxes indicate selection' + } + }, []) // Consolidated mutation handler const handleMutation = useCallback( @@ -121,28 +130,18 @@ export const Notifications = () => { [handleMutation, deleteMutation] ) - // toggling selections effect - useEffect(() => { + const onSelectionChanged = useCallback(() => { const gridApi = gridRef.current?.api - if (gridApi) { - const selectionChangedHandler = () => { - const visibleRows = [] - gridApi.forEachNodeAfterFilterAndSort((node) => { - visibleRows.push(node) - }) - const selectedRows = visibleRows.filter((node) => node.isSelected()) - setIsAllSelected( - visibleRows.length > 0 && visibleRows.length === selectedRows.length - ) - } - - gridApi.addEventListener('selectionChanged', selectionChangedHandler) - - return () => { - gridApi.removeEventListener('selectionChanged', selectionChangedHandler) - } - } - }, []) + const visibleRows = [] + gridApi.forEachNodeAfterFilterAndSort((node) => { + visibleRows.push(node) + }) + const selectedRows = visibleRows.filter((node) => node.isSelected()) + setSelectedRowCount(selectedRows.length) + setIsAllSelected( + visibleRows.length > 0 && visibleRows.length === selectedRows.length + ) + },[]) return ( @@ -168,6 +167,7 @@ export const Notifications = () => { variant="contained" color="primary" onClick={handleMarkAsRead} + disabled={selectedRowCount === 0} > {t('notifications:buttonStack.markAsRead')} @@ -176,6 +176,7 @@ export const Notifications = () => { variant="outlined" color="error" onClick={handleDelete} + disabled={selectedRowCount === 0} > {t('notifications:buttonStack.deleteSelected')} @@ -196,6 +197,8 @@ export const Notifications = () => { rowSelection={{ mode: 'multiRow' }} rowClassRules={rowClassRules} onCellClicked={onCellClicked} + selectionColumnDef={selectionColumnDef} + onSelectionChanged={onSelectionChanged} /> ) From 2552cdf74f02153d577cfbdf603ddbe4175d8849 Mon Sep 17 00:00:00 2001 From: Kuan Fan Date: Mon, 16 Dec 2024 15:42:03 -0800 Subject: [PATCH 27/55] cache oc command --- .github/workflows/dev-ci.yml | 33 +++++++++++++++++++++ .github/workflows/pr-build.yaml | 46 ++++++++++++++++++++++++++++++ .github/workflows/pr-teardown.yaml | 32 +++++++++++++++++++++ .github/workflows/prod-ci.yaml | 32 +++++++++++++++++++++ .github/workflows/test-ci.yaml | 33 +++++++++++++++++++++ 5 files changed, 176 insertions(+) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index ac8071561..6de1db940 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -23,9 +23,36 @@ concurrency: jobs: + install-oc: + runs-on: ubuntu-latest + outputs: + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - name: Check out repository + uses: actions/checkout@v4.1.1 + + - name: Set up cache for OpenShift CLI + id: cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc # Path where the `oc` binary will be installed + key: oc-cli-${{ runner.os }} + + - name: Install OpenShift CLI (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz + tar -xvf openshift-client-linux.tar.gz + sudo mv oc /usr/local/bin/ + oc version --client + + - name: Confirm OpenShift CLI is Available + run: oc version --client + set-pre-release: name: Calculate pre-release number runs-on: ubuntu-latest + needs: [install-oc] outputs: output1: ${{ steps.set-pre-release.outputs.PRE_RELEASE }} @@ -49,6 +76,12 @@ jobs: - name: Check out repository uses: actions/checkout@v4.1.1 + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml index f351a91c5..7086a5834 100644 --- a/.github/workflows/pr-build.yaml +++ b/.github/workflows/pr-build.yaml @@ -19,17 +19,51 @@ concurrency: cancel-in-progress: true jobs: + install-oc: + runs-on: ubuntu-latest + outputs: + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - name: Check out repository + uses: actions/checkout@v4.1.1 + + - name: Set up cache for OpenShift CLI + id: cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc # Path where the `oc` binary will be installed + key: oc-cli-${{ runner.os }} + + - name: Install OpenShift CLI (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz + tar -xvf openshift-client-linux.tar.gz + sudo mv oc /usr/local/bin/ + oc version --client + + - name: Confirm OpenShift CLI is Available + run: oc version --client + get-version: if: > (github.event.action == 'labeled' && github.event.label.name == 'build' && github.event.pull_request.base.ref == github.event.repository.default_branch) || (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'build') && github.event.pull_request.base.ref == github.event.repository.default_branch) name: Retrieve version runs-on: ubuntu-latest + needs: [install-oc] outputs: output1: ${{ steps.get-version.outputs.VERSION }} steps: + + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: @@ -69,6 +103,12 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: @@ -123,6 +163,12 @@ jobs: ref: main ssh-key: ${{ secrets.MANIFEST_REPO_DEPLOY_KEY }} + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: diff --git a/.github/workflows/pr-teardown.yaml b/.github/workflows/pr-teardown.yaml index 783c221a8..201c08e04 100644 --- a/.github/workflows/pr-teardown.yaml +++ b/.github/workflows/pr-teardown.yaml @@ -13,6 +13,31 @@ concurrency: cancel-in-progress: true jobs: + install-oc: + runs-on: ubuntu-latest + outputs: + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - name: Check out repository + uses: actions/checkout@v4.1.1 + + - name: Set up cache for OpenShift CLI + id: cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc # Path where the `oc` binary will be installed + key: oc-cli-${{ runner.os }} + + - name: Install OpenShift CLI (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz + tar -xvf openshift-client-linux.tar.gz + sudo mv oc /usr/local/bin/ + oc version --client + + - name: Confirm OpenShift CLI is Available + run: oc version --client teardown: if: > @@ -20,9 +45,16 @@ jobs: (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'build') ) name: PR Teardown runs-on: ubuntu-latest + needs: [install-oc] timeout-minutes: 60 steps: + + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 diff --git a/.github/workflows/prod-ci.yaml b/.github/workflows/prod-ci.yaml index 3478be8ff..b3a1eab61 100644 --- a/.github/workflows/prod-ci.yaml +++ b/.github/workflows/prod-ci.yaml @@ -14,12 +14,38 @@ concurrency: cancel-in-progress: true jobs: + install-oc: + runs-on: ubuntu-latest + outputs: + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - name: Check out repository + uses: actions/checkout@v4.1.1 + + - name: Set up cache for OpenShift CLI + id: cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc # Path where the `oc` binary will be installed + key: oc-cli-${{ runner.os }} + + - name: Install OpenShift CLI (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz + tar -xvf openshift-client-linux.tar.gz + sudo mv oc /usr/local/bin/ + oc version --client + + - name: Confirm OpenShift CLI is Available + run: oc version --client # Read the image tag from test environment get-image-tag: name: Get the image-tag from values-test.yaml runs-on: ubuntu-latest + needs: [install-oc] outputs: IMAGE_TAG: ${{ steps.get-image-tag.outputs.IMAGE_TAG }} @@ -84,6 +110,12 @@ jobs: approvers: AlexZorkin,kuanfandevops,hamed-valiollahi,airinggov,areyeslo,dhaselhan,Grulin minimum-approvals: 2 issue-title: "LCFS ${{env.IMAGE_TAG }} Prod Deployment at ${{ env.CURRENT_TIME }}." + + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 diff --git a/.github/workflows/test-ci.yaml b/.github/workflows/test-ci.yaml index 1119b9432..80d6690f2 100644 --- a/.github/workflows/test-ci.yaml +++ b/.github/workflows/test-ci.yaml @@ -14,9 +14,36 @@ concurrency: cancel-in-progress: true jobs: + install-oc: + runs-on: ubuntu-latest + outputs: + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - name: Check out repository + uses: actions/checkout@v4.1.1 + + - name: Set up cache for OpenShift CLI + id: cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc # Path where the `oc` binary will be installed + key: oc-cli-${{ runner.os }} + + - name: Install OpenShift CLI (if not cached) + if: steps.cache.outputs.cache-hit != 'true' + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz + tar -xvf openshift-client-linux.tar.gz + sudo mv oc /usr/local/bin/ + oc version --client + + - name: Confirm OpenShift CLI is Available + run: oc version --client + run-tests: name: Run Tests runs-on: ubuntu-latest + needs: [install-oc] steps: - uses: actions/checkout@v3 @@ -229,6 +256,12 @@ jobs: minimum-approvals: 1 issue-title: "LCFS ${{ env.VERSION }}-${{ env.PRE_RELEASE }} Test Deployment" + - name: Restore oc command from Cache + uses: actions/cache@v4.2.0 + with: + path: /usr/local/bin/oc + key: oc-cli-${{ runner.os }} + - name: Log in to Openshift uses: redhat-actions/oc-login@v1.3 with: From d70cc19b92c8f94df4a7c2fb4edf2faa98dbb19d Mon Sep 17 00:00:00 2001 From: Kevin Hashimoto Date: Mon, 16 Dec 2024 16:44:28 -0800 Subject: [PATCH 28/55] feat: reports to have blank row on load + open first cell --- .../components/Editors/DateEditor.jsx | 16 ++++++- .../AddEditAllocationAgreements.jsx | 11 ++++- .../views/AllocationAgreements/_schema.jsx | 9 ++-- .../AddEditFinalSupplyEquipments.jsx | 22 ++++++++-- .../views/FuelExports/AddEditFuelExports.jsx | 22 ++++++++-- frontend/src/views/FuelExports/_schema.jsx | 7 ++- .../FuelSupplies/AddEditFuelSupplies.jsx | 9 +++- .../AddEditNotionalTransfers.jsx | 16 ++++++- .../src/views/OtherUses/AddEditOtherUses.jsx | 44 +++++++++++++------ 9 files changed, 124 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/BCDataGrid/components/Editors/DateEditor.jsx b/frontend/src/components/BCDataGrid/components/Editors/DateEditor.jsx index 01321d990..d2c5f51e4 100644 --- a/frontend/src/components/BCDataGrid/components/Editors/DateEditor.jsx +++ b/frontend/src/components/BCDataGrid/components/Editors/DateEditor.jsx @@ -2,11 +2,23 @@ import { DatePicker } from '@mui/x-date-pickers' import { format, parseISO } from 'date-fns' import { useEffect, useRef, useState } from 'react' -export const DateEditor = ({ value, onValueChange, minDate, maxDate }) => { +export const DateEditor = ({ + value, + onValueChange, + minDate, + maxDate, + rowIndex, + api, + autoOpenLastRow +}) => { const [selectedDate, setSelectedDate] = useState( value ? parseISO(value) : null ) - const [isOpen, setIsOpen] = useState(false) + const [isOpen, setIsOpen] = useState(() => { + if (!autoOpenLastRow) return false + const lastRowIndex = api.getLastDisplayedRowIndex() + return rowIndex === lastRowIndex + }) const containerRef = useRef(null) useEffect(() => { diff --git a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx index f09d3ee0e..4e4a3c722 100644 --- a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx +++ b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx @@ -80,13 +80,22 @@ export const AddEditAllocationAgreements = () => { ...item, id: item.id || uuid() // Ensure every item has a unique ID })) - setRowData(updatedRowData) + setRowData([...updatedRowData, { id: uuid() }]) } else { // If allocationAgreements is not available or empty, initialize with a single row setRowData([{ id: uuid() }]) } params.api.sizeColumnsToFit() + + setTimeout(() => { + const lastRowIndex = params.api.getLastDisplayedRowIndex() + params.api.setFocusedCell(lastRowIndex, 'allocationTransactionType') + params.api.startEditingCell({ + rowIndex: lastRowIndex, + colKey: 'allocationTransactionType' + }) + }, 100) }, [data] ) diff --git a/frontend/src/views/AllocationAgreements/_schema.jsx b/frontend/src/views/AllocationAgreements/_schema.jsx index 6e503e3dd..5f1e71c81 100644 --- a/frontend/src/views/AllocationAgreements/_schema.jsx +++ b/frontend/src/views/AllocationAgreements/_schema.jsx @@ -57,9 +57,13 @@ export const allocationAgreementColDefs = (optionsData, errors) => [ headerName: i18n.t( 'allocationAgreement:allocationAgreementColLabels.transaction' ), - cellEditor: 'agSelectCellEditor', + cellEditor: AutocompleteCellEditor, cellEditorParams: { - values: ['Allocated from', 'Allocated to'] + options: ['Allocated from', 'Allocated to'], + multiple: false, + disableCloseOnSelect: false, + freeSolo: false, + openOnFocus: true }, cellRenderer: (params) => params.value || @@ -361,7 +365,6 @@ export const allocationAgreementColDefs = (optionsData, errors) => [ headerName: i18n.t( 'allocationAgreement:allocationAgreementColLabels.quantity' ), - editor: NumberEditor, valueFormatter, cellEditor: NumberEditor, cellEditorParams: { diff --git a/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx b/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx index fb9fcafaa..2244efea4 100644 --- a/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx +++ b/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx @@ -75,18 +75,32 @@ export const AddEditFinalSupplyEquipments = () => { } ]) } else { - setRowData( - data.finalSupplyEquipments.map((item) => ({ + setRowData([ + ...data.finalSupplyEquipments.map((item) => ({ ...item, levelOfEquipment: item.levelOfEquipment.name, fuelMeasurementType: item.fuelMeasurementType.type, intendedUses: item.intendedUseTypes.map((i) => i.type), intendedUsers: item.intendedUserTypes.map((i) => i.typeName), id: uuid() - })) - ) + })), + { + id: uuid(), + complianceReportId, + supplyFromDate: `${compliancePeriod}-01-01`, + supplyToDate: `${compliancePeriod}-12-31` + } + ]) } params.api.sizeColumnsToFit() + + setTimeout(() => { + const lastRowIndex = params.api.getLastDisplayedRowIndex() + params.api.startEditingCell({ + rowIndex: lastRowIndex, + colKey: 'organizationName' + }) + }, 100) }, [compliancePeriod, complianceReportId, data] ) diff --git a/frontend/src/views/FuelExports/AddEditFuelExports.jsx b/frontend/src/views/FuelExports/AddEditFuelExports.jsx index ebaa03498..9850d04b5 100644 --- a/frontend/src/views/FuelExports/AddEditFuelExports.jsx +++ b/frontend/src/views/FuelExports/AddEditFuelExports.jsx @@ -18,9 +18,10 @@ import { defaultColDef, fuelExportColDefs } from './_schema' export const AddEditFuelExports = () => { const [rowData, setRowData] = useState([]) const gridRef = useRef(null) - const [gridApi, setGridApi] = useState() + const [, setGridApi] = useState() const [errors, setErrors] = useState({}) const [columnDefs, setColumnDefs] = useState([]) + const [gridReady, setGridReady] = useState(false) const alertRef = useRef() const location = useLocation() const { t } = useTranslation(['common', 'fuelExport']) @@ -74,21 +75,34 @@ export const AddEditFuelExports = () => { endUse: item.endUse?.type || 'Any', id: uuid() })) - setRowData(updatedRowData) + setRowData([...updatedRowData, { id: uuid(), compliancePeriod }]) } else { setRowData([{ id: uuid(), compliancePeriod }]) } params.api.sizeColumnsToFit() + + setTimeout(() => { + const lastRowIndex = params.api.getLastDisplayedRowIndex() + params.api.startEditingCell({ + rowIndex: lastRowIndex, + colKey: 'exportDate' + }) + setGridReady(true) + }, 500) }, [compliancePeriod, data] ) useEffect(() => { if (optionsData?.fuelTypes?.length > 0) { - const updatedColumnDefs = fuelExportColDefs(optionsData, errors) + const updatedColumnDefs = fuelExportColDefs( + optionsData, + errors, + gridReady + ) setColumnDefs(updatedColumnDefs) } - }, [errors, optionsData]) + }, [errors, gridReady, optionsData]) useEffect(() => { if (!fuelExportsLoading && !isArrayEmpty(data)) { diff --git a/frontend/src/views/FuelExports/_schema.jsx b/frontend/src/views/FuelExports/_schema.jsx index f113671d3..58346a607 100644 --- a/frontend/src/views/FuelExports/_schema.jsx +++ b/frontend/src/views/FuelExports/_schema.jsx @@ -44,7 +44,7 @@ const cellErrorStyle = (params, errors) => { return style } -export const fuelExportColDefs = (optionsData, errors) => [ +export const fuelExportColDefs = (optionsData, errors, gridReady) => [ validation, actions({ enableDuplicate: false, @@ -103,7 +103,10 @@ export const fuelExportColDefs = (optionsData, errors) => [ ), suppressKeyboardEvent, cellEditor: DateEditor, - cellEditorPopup: true + cellEditorPopup: true, + cellEditorParams: { + autoOpenLastRow: !gridReady + } }, { field: 'fuelType', diff --git a/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx b/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx index 7d7c4bd29..1c0a852f4 100644 --- a/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx +++ b/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx @@ -79,10 +79,17 @@ export const AddEditFuelSupplies = () => { endUse: item.endUse?.type || 'Any', id: uuid() })) - setRowData(updatedRowData) + setRowData([...updatedRowData, { id: uuid() }]) } else { setRowData([{ id: uuid() }]) } + setTimeout(() => { + const lastRowIndex = params.api.getLastDisplayedRowIndex() + params.api.startEditingCell({ + rowIndex: lastRowIndex, + colKey: 'fuelType' + }) + }, 100) }, [data, complianceReportId, compliancePeriod] ) diff --git a/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx b/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx index 2360dfb19..2df027624 100644 --- a/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx +++ b/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx @@ -64,7 +64,13 @@ export const AddEditNotionalTransfers = () => { if (notionalTransfers && notionalTransfers.length > 0) { try { - setRowData(ensureRowIds(notionalTransfers)) + setRowData([ + ...ensureRowIds(notionalTransfers), + { + id: uuid(), + complianceReportId + } + ]) } catch (error) { alertRef.triggerAlert({ message: t('notionalTransfer:LoadFailMsg'), @@ -78,6 +84,14 @@ export const AddEditNotionalTransfers = () => { } params.api.sizeColumnsToFit() + + setTimeout(() => { + const lastRowIndex = params.api.getLastDisplayedRowIndex() + params.api.startEditingCell({ + rowIndex: lastRowIndex, + colKey: 'legalName' + }) + }, 100) } const onCellEditingStopped = useCallback( diff --git a/frontend/src/views/OtherUses/AddEditOtherUses.jsx b/frontend/src/views/OtherUses/AddEditOtherUses.jsx index bbd553ca3..d78737564 100644 --- a/frontend/src/views/OtherUses/AddEditOtherUses.jsx +++ b/frontend/src/views/OtherUses/AddEditOtherUses.jsx @@ -17,7 +17,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { v4 as uuid } from 'uuid' -import { defaultColDef, otherUsesColDefs, PROVISION_APPROVED_FUEL_CODE} from './_schema' +import { + defaultColDef, + otherUsesColDefs, + PROVISION_APPROVED_FUEL_CODE +} from './_schema' import * as ROUTES from '@/constants/routes/routes.js' export const AddEditOtherUses = () => { @@ -55,31 +59,31 @@ export const AddEditOtherUses = () => { rows.map((row) => ({ ...row, id: row.id || uuid(), - isValid: true, - })); + isValid: true + })) - setRowData(ensureRowIds(otherUses)); + setRowData(ensureRowIds(otherUses)) } - }, [otherUses]); + }, [otherUses]) const findCiOfFuel = useCallback((data, optionsData) => { - let ciOfFuel = 0; + let ciOfFuel = 0 if (data.provisionOfTheAct === PROVISION_APPROVED_FUEL_CODE) { const fuelType = optionsData?.fuelTypes?.find( (obj) => data.fuelType === obj.fuelType - ); + ) const fuelCode = fuelType?.fuelCodes?.find( (item) => item.fuelCode === data.fuelCode - ); - ciOfFuel = fuelCode?.carbonIntensity || 0; + ) + ciOfFuel = fuelCode?.carbonIntensity || 0 } else { const fuelType = optionsData?.fuelTypes?.find( (obj) => data.fuelType === obj.fuelType - ); - ciOfFuel = fuelType?.defaultCarbonIntensity || 0; + ) + ciOfFuel = fuelType?.defaultCarbonIntensity || 0 } - return ciOfFuel; - }, []); + return ciOfFuel + }, []) const onGridReady = (params) => { const ensureRowIds = (rows) => { @@ -98,7 +102,10 @@ export const AddEditOtherUses = () => { if (otherUses && otherUses.length > 0) { try { - setRowData(ensureRowIds(otherUses)) + setRowData([ + ...ensureRowIds(otherUses), + { id: uuid(), complianceReportId } + ]) } catch (error) { alertRef.triggerAlert({ message: t('otherUses:otherUsesLoadFailMsg'), @@ -112,6 +119,15 @@ export const AddEditOtherUses = () => { } params.api.sizeColumnsToFit() + + setTimeout(() => { + const lastRowIndex = params.api.getLastDisplayedRowIndex() + + params.api.startEditingCell({ + rowIndex: lastRowIndex, + colKey: 'fuelType' + }) + }, 100) } const onAction = async (action, params) => { From 1e40528d2697c5cea45f2005bd56078759e9c4c3 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 02:40:07 -0800 Subject: [PATCH 29/55] updates --- .../versions/2024-12-13-12-44_62bc9695a764.py | 2 +- .../api/compliance_report/update_service.py | 53 ++++++----- backend/lcfs/web/api/email/services.py | 51 +++++++---- .../web/api/initiative_agreement/services.py | 25 ++++-- backend/lcfs/web/api/notification/schema.py | 7 ++ backend/lcfs/web/api/transfer/services.py | 88 +++++++++++++------ .../src/components/BCDataGrid/BCGridBase.jsx | 2 +- frontend/src/themes/base/globals.js | 2 +- .../IDIRAnalystNotificationSettings.jsx | 6 +- .../components/NotificationSettingsForm.jsx | 9 +- .../components/Notifications.jsx | 48 +++++++--- .../NotificationMenu/components/_schema.jsx | 43 ++++++++- .../src/views/Notifications/Notifications.jsx | 14 --- 13 files changed, 233 insertions(+), 117 deletions(-) delete mode 100644 frontend/src/views/Notifications/Notifications.jsx diff --git a/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py b/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py index 160d3b11d..44019fc73 100644 --- a/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py +++ b/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "62bc9695a764" -down_revision = "7ae38a8413ab" +down_revision = "5d729face5ab" branch_labels = None depends_on = None diff --git a/backend/lcfs/web/api/compliance_report/update_service.py b/backend/lcfs/web/api/compliance_report/update_service.py index 7e76ea76b..87df1bb50 100644 --- a/backend/lcfs/web/api/compliance_report/update_service.py +++ b/backend/lcfs/web/api/compliance_report/update_service.py @@ -1,3 +1,4 @@ +import json from fastapi import Depends, HTTPException, Request from lcfs.web.api.notification.schema import ( COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER, @@ -48,13 +49,7 @@ async def update_compliance_report( raise DataNotFoundException( f"Compliance report with ID {report_id} not found" ) - - notifications = None - notification_data: NotificationMessageSchema = NotificationMessageSchema( - message=f"Compliance report {report.compliance_report_id} has been updated", - related_organization_id=report.organization_id, - origin_user_profile_id=self.request.user.user_profile_id, - ) + current_status = report_data.status # if we're just returning the compliance report back to either compliance manager or analyst, # then neither history nor any updates to summary is required. if report_data.status in RETURN_STATUSES: @@ -64,19 +59,10 @@ async def update_compliance_report( ) if report_data.status == "Return to analyst": report_data.status = ComplianceReportStatusEnum.Submitted.value - notification_data.message = f"Compliance report {report.compliance_report_id} has been returned to analyst" else: report_data.status = ( ComplianceReportStatusEnum.Recommended_by_analyst.value ) - - notification_data.message = f"Compliance report {report.compliance_report_id} has been returned by director" - notification_data.related_user_profile_id = [ - h.user_profile.user_profile_id - for h in report.history - if h.status.status - == ComplianceReportStatusEnum.Recommended_by_analyst - ][0] else: status_has_changed = report.current_status.status != getattr( ComplianceReportStatusEnum, report_data.status.replace(" ", "_") @@ -91,14 +77,36 @@ async def update_compliance_report( updated_report = await self.repo.update_compliance_report(report) if status_has_changed: await self.handle_status_change(report, new_status.status) - notification_data.message = ( - f"Compliance report {report.compliance_report_id} has been updated" - ) - notifications = COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER.get( - new_status.status - ) # Add history record await self.repo.add_compliance_report_history(report, self.request.user) + + await self._perform_notificaiton_call(report, current_status) + return updated_report + + async def _perform_notificaiton_call(self, cr, status): + """Send notifications based on the current status of the transfer.""" + status_mapper = status.replace(" ", "_") + notifications = COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER.get( + ( + ComplianceReportStatusEnum[status_mapper] + if status_mapper in ComplianceReportStatusEnum.__members__ + else status + ), + None, + ) + message_data = { + "service": "ComplianceReport", + "id": cr.compliance_report_id, + "compliancePeriod": cr.compliance_period.description, + "status": status.lower(), + } + notification_data = NotificationMessageSchema( + type=f"Compliance report {status.lower()}", + transaction_id=cr.transaction_id, + message=json.dumps(message_data), + related_organization_id=cr.organization_id, + origin_user_profile_id=self.request.user.user_profile_id, + ) if notifications and isinstance(notifications, list): await self.notfn_service.send_notification( NotificationRequestSchema( @@ -106,7 +114,6 @@ async def update_compliance_report( notification_data=notification_data, ) ) - return updated_report async def handle_status_change( self, report: ComplianceReport, new_status: ComplianceReportStatusEnum diff --git a/backend/lcfs/web/api/email/services.py b/backend/lcfs/web/api/email/services.py index 8c7dc4cd8..066a7a664 100644 --- a/backend/lcfs/web/api/email/services.py +++ b/backend/lcfs/web/api/email/services.py @@ -23,19 +23,8 @@ class CHESEmailService: def __init__(self, repo: CHESEmailRepository = Depends()): self.repo = repo - - # CHES configuration - self.config = { - "AUTH_URL": settings.ches_auth_url, - "EMAIL_URL": settings.ches_email_url, - "CLIENT_ID": settings.ches_client_id, - "CLIENT_SECRET": settings.ches_client_secret, - "SENDER_EMAIL": settings.ches_sender_email, - "SENDER_NAME": settings.ches_sender_name, - } self._access_token = None self._token_expiry = None - self._validate_configuration() # Update template directory path to the root templates directory template_dir = os.path.join(os.path.dirname(__file__), "templates") @@ -48,9 +37,24 @@ def _validate_configuration(self): """ Validate the CHES configuration to ensure all necessary environment variables are set. """ - missing = [key for key, value in self.config.items() if not value] - if missing: - raise ValueError(f"Missing configuration: {', '.join(missing)}") + missing_configs = [] + + # Check each required CHES configuration setting + if not settings.ches_auth_url: + missing_configs.append("ches_auth_url") + if not settings.ches_email_url: + missing_configs.append("ches_email_url") + if not settings.ches_client_id: + missing_configs.append("ches_client_id") + if not settings.ches_client_secret: + missing_configs.append("ches_client_secret") + if not settings.ches_sender_email: + missing_configs.append("ches_sender_email") + if not settings.ches_sender_name: + missing_configs.append("ches_sender_name") + + if missing_configs: + raise ValueError(f"Missing CHES configuration: {', '.join(missing_configs)}") @service_handler async def send_notification_email( @@ -62,6 +66,9 @@ async def send_notification_email( """ Send an email notification to users subscribed to the specified notification type. """ + # Validate configuration before performing any operations + self._validate_configuration() + # Retrieve subscribed user emails recipient_emails = await self.repo.get_subscribed_user_emails( notification_type.value, organization_id @@ -109,7 +116,7 @@ def _build_email_payload( return { "bcc": recipients, "to": ["Undisclosed recipients"], - "from": f"{self.config['SENDER_NAME']} <{self.config['SENDER_EMAIL']}>", + "from": f"{settings.ches_sender_name} <{settings.ches_sender_email}>", "delayTS": 0, "encoding": "utf-8", "priority": "normal", @@ -124,9 +131,12 @@ async def send_email(self, payload: Dict[str, Any]) -> bool: """ Send an email using CHES. """ + # Validate configuration before performing any operations + self._validate_configuration() + token = await self.get_ches_token() response = requests.post( - self.config["EMAIL_URL"], + settings.ches_email_url, json=payload, headers={ "Authorization": f"Bearer {token}", @@ -142,12 +152,15 @@ async def get_ches_token(self) -> str: """ Retrieve and cache the CHES access token. """ + # Validate configuration before performing any operations + self._validate_configuration() + if self._access_token and datetime.now().timestamp() < self._token_expiry: return self._access_token response = requests.post( - self.config["AUTH_URL"], + settings.ches_auth_url, data={"grant_type": "client_credentials"}, - auth=(self.config["CLIENT_ID"], self.config["CLIENT_SECRET"]), + auth=(settings.ches_client_id, settings.ches_client_secret), timeout=10, ) response.raise_for_status() @@ -158,4 +171,4 @@ async def get_ches_token(self) -> str: "expires_in", 3600 ) logger.info("Retrieved new CHES token.") - return self._access_token + return self._access_token \ No newline at end of file diff --git a/backend/lcfs/web/api/initiative_agreement/services.py b/backend/lcfs/web/api/initiative_agreement/services.py index 93fe6df58..6494d57d0 100644 --- a/backend/lcfs/web/api/initiative_agreement/services.py +++ b/backend/lcfs/web/api/initiative_agreement/services.py @@ -1,3 +1,4 @@ +import json from lcfs.web.api.notification.schema import ( INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER, NotificationMessageSchema, @@ -129,7 +130,7 @@ async def update_initiative_agreement( # Return the updated initiative agreement schema with the returned status flag ia_schema = InitiativeAgreementSchema.from_orm(updated_initiative_agreement) ia_schema.returned = returned - await self._perform_notificaiton_call(ia_schema, re_recommended) + await self._perform_notificaiton_call(updated_initiative_agreement, returned) return ia_schema @service_handler @@ -208,17 +209,27 @@ async def director_approve_initiative_agreement( initiative_agreement.transaction_effective_date = datetime.now().date() await self.repo.refresh_initiative_agreement(initiative_agreement) - await self._perform_notificaiton_call(initiative_agreement) - async def _perform_notificaiton_call(self, ia, re_recommended=False): + async def _perform_notificaiton_call(self, ia, returned=False): """Send notifications based on the current status of the transfer.""" - status = ia.current_status.status if not re_recommended else "Return to analyst" - status_val = (status.value if isinstance(status, InitiativeAgreementStatusEnum) else status).lower() - notifications = INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER.get(status, None) + status = ia.current_status.status if not returned else "Return to analyst" + status_val = ( + status.value + if isinstance(status, InitiativeAgreementStatusEnum) + else status + ).lower() + notifications = INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER.get( + status, None + ) + message_data = { + "service": "InitiativeAgreement", + "id": ia.initiative_agreement_id, + "status": status_val, + } notification_data = NotificationMessageSchema( type=f"Initiative agreement {status_val}", transaction_id=ia.transaction_id, - message=f"Initiative Agreement {ia.initiative_agreement_id} has been {status_val}", + message=json.dumps(message_data), related_organization_id=ia.to_organization_id, origin_user_profile_id=self.request.user.user_profile_id, ) diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index afe859f04..5cf97cb56 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -126,10 +126,14 @@ class NotificationRequestSchema(BaseSchema): TransferStatusEnum.Sent: [ NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, ], + TransferStatusEnum.Rescinded: [ + NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, + ], TransferStatusEnum.Declined: [ NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, ], TransferStatusEnum.Submitted: [ + NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, NotificationTypeEnum.IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW ], TransferStatusEnum.Recommended: [ @@ -143,6 +147,9 @@ class NotificationRequestSchema(BaseSchema): NotificationTypeEnum.BCEID__TRANSFER__DIRECTOR_DECISION, NotificationTypeEnum.IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED, ], + "Return to analyst": [ + NotificationTypeEnum.IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW + ] } INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER = { diff --git a/backend/lcfs/web/api/transfer/services.py b/backend/lcfs/web/api/transfer/services.py index f498d927e..ea1dcd2a2 100644 --- a/backend/lcfs/web/api/transfer/services.py +++ b/backend/lcfs/web/api/transfer/services.py @@ -1,3 +1,4 @@ +import json from lcfs.web.api.notification.schema import ( TRANSFER_STATUS_NOTIFICATION_MAPPER, NotificationMessageSchema, @@ -155,7 +156,6 @@ async def create_transfer( # transfer.transfer_category_id = 1 transfer.current_status = current_status - notifications = TRANSFER_STATUS_NOTIFICATION_MAPPER.get(current_status.status) if current_status.status == TransferStatusEnum.Sent: await self.sign_and_send_from_supplier(transfer) @@ -166,7 +166,7 @@ async def create_transfer( current_status.transfer_status_id, self.request.user.user_profile_id, ) - await self._perform_notificaiton_call(notifications, transfer) + await self._perform_notificaiton_call(transfer, current_status.status) return transfer @service_handler @@ -264,37 +264,67 @@ async def update_transfer(self, transfer_data: TransferCreateSchema) -> Transfer # Finally, update the transfer's status and save the changes transfer.current_status = new_status transfer_result = await self.repo.update_transfer(transfer) - await self._perform_notificaiton_call(transfer_result) + await self._perform_notificaiton_call( + transfer, + status=( + new_status.status + if status_has_changed or re_recommended + else "Return to analyst" + ), + ) return transfer_result - async def _perform_notificaiton_call(self, transfer): + async def _perform_notificaiton_call( + self, transfer: TransferSchema, status: TransferStatusEnum + ): """Send notifications based on the current status of the transfer.""" - notifications = TRANSFER_STATUS_NOTIFICATION_MAPPER.get( - transfer.current_status.status - ) - notification_data = NotificationMessageSchema( - message=f"Transfer {transfer.transfer_id} has been updated", - origin_user_profile_id=self.request.user.user_profile_id, - ) - if notifications and isinstance(notifications, list): - notification_data.related_organization_id = ( - transfer.from_organization_id - if transfer.current_status.status == TransferStatusEnum.Declined - else transfer.to_organization_id - ) - await self.notfn_service.send_notification( - NotificationRequestSchema( - notification_types=notifications, - notification_data=notification_data, - ) + notifications = TRANSFER_STATUS_NOTIFICATION_MAPPER.get(status) + status_val = ( + status.value if isinstance(status, TransferStatusEnum) else status + ).lower() + organization_ids = [] + if status in [ + TransferStatusEnum.Submitted, + TransferStatusEnum.Recommended, + TransferStatusEnum.Declined, + ]: + organization_ids = [transfer.from_organization.organization_id] + elif status in [ + TransferStatusEnum.Sent, + TransferStatusEnum.Rescinded, + ]: + organization_ids = [transfer.to_organization.organization_id] + elif status in [ + TransferStatusEnum.Recorded, + TransferStatusEnum.Refused, + ]: + organization_ids = [ + transfer.to_organization.organization_id, + transfer.from_organization.organization_id, + ] + message_data = { + "service": "Transfer", + "id": transfer.transfer_id, + "status": status_val, + "fromOrganizationId": transfer.from_organization.organization_id, + "fromOrganization": transfer.from_organization.name, + "toOrganizationId": transfer.to_organization.organization_id, + "toOrganization": transfer.to_organization.name, + } + type = f"Transfer {status_val}" + if status_val == "sent": + type = "Transfer received" + elif status_val == "return to analyst": + type = "Transfer returned" + for org_id in organization_ids: + notification_data = NotificationMessageSchema( + type=type, + transaction_id=transfer.from_transaction.transaction_id if getattr(transfer, 'from_transaction', None) else None, + message=json.dumps(message_data), + related_organization_id=org_id, + origin_user_profile_id=self.request.user.user_profile_id, ) - if transfer.current_status.status in [ - TransferStatusEnum.Refused, - TransferStatusEnum.Recorded, - ]: - notification_data.related_organization_id = ( - transfer.from_organization_id - ) + if notifications and isinstance(notifications, list): await self.notfn_service.send_notification( NotificationRequestSchema( notification_types=notifications, diff --git a/frontend/src/components/BCDataGrid/BCGridBase.jsx b/frontend/src/components/BCDataGrid/BCGridBase.jsx index d26ef13b7..dd7d6c9fa 100644 --- a/frontend/src/components/BCDataGrid/BCGridBase.jsx +++ b/frontend/src/components/BCDataGrid/BCGridBase.jsx @@ -34,7 +34,7 @@ export const BCGridBase = forwardRef(({ autoSizeStrategy, ...props }, ref) => { suppressMovableColumns suppressColumnMoveAnimation={false} reactiveCustomComponents - rowSelection="multiple" + rowSelection='multiple' suppressCsvExport={false} suppressPaginationPanel suppressScrollOnNewData diff --git a/frontend/src/themes/base/globals.js b/frontend/src/themes/base/globals.js index 26b4fe3cd..f8a092509 100644 --- a/frontend/src/themes/base/globals.js +++ b/frontend/src/themes/base/globals.js @@ -110,7 +110,7 @@ const globals = { border: 'none', borderBottom: '2px solid #495057' }, - '.row-not-read': { + '.unread-row': { fontWeight: 700, color: grey[700] }, diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx index 731dedd68..8294115b1 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx @@ -9,8 +9,10 @@ const IDIRAnalystNotificationSettings = () => { 'idirAnalyst.categories.transfers.submittedForReview', IDIR_ANALYST__TRANSFER__RESCINDED_ACTION: 'idirAnalyst.categories.transfers.rescindedAction', - IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDEDIDIR_A__TR__DIRECTOR_RECORDED: - 'idirAnalyst.categories.transfers.directorRecorded' + IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED: + 'idirAnalyst.categories.transfers.directorRecorded', + IDIR_ANALYST__TRANSFER__RETURNED_TO_ANALYST: + 'idirAnalyst.categories.initiativeAgreements.returnedToAnalyst' }, 'idirAnalyst.categories.initiativeAgreements': { title: 'idirAnalyst.categories.initiativeAgreements.title', diff --git a/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx index 5a7e0bd93..bb2058482 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx @@ -34,8 +34,8 @@ import BCTypography from '@/components/BCTypography' const NotificationSettingsForm = ({ categories, - showEmailField, - initialEmail + showEmailField = false, + initialEmail = '' }) => { const { t } = useTranslation(['notifications']) const [isFormLoading, setIsFormLoading] = useState(false) @@ -468,9 +468,4 @@ NotificationSettingsForm.propTypes = { initialEmail: PropTypes.string } -NotificationSettingsForm.defaultProps = { - showEmailField: false, - initialEmail: '' -} - export default NotificationSettingsForm diff --git a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx index 1bb406706..9d4a5b290 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { Stack, Grid } from '@mui/material' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -6,20 +7,23 @@ import { faSquareCheck } from '@fortawesome/free-solid-svg-icons' import BCButton from '@/components/BCButton' import { BCGridViewer } from '@/components/BCDataGrid/BCGridViewer' -import { columnDefs } from './_schema' +import { columnDefs, routesMapping } from './_schema' import { useGetNotificationMessages, useMarkNotificationAsRead, useDeleteNotificationMessages } from '@/hooks/useNotifications' +import { useCurrentUser } from '@/hooks/useCurrentUser' export const Notifications = () => { - const { t } = useTranslation(['notifications']) const gridRef = useRef(null) const alertRef = useRef(null) const [isAllSelected, setIsAllSelected] = useState(false) const [selectedRowCount, setSelectedRowCount] = useState(0) + const { t } = useTranslation(['notifications']) + const navigate = useNavigate() + const { data: currentUser } = useCurrentUser() // react query hooks const { refetch } = useGetNotificationMessages() const markAsReadMutation = useMarkNotificationAsRead() @@ -28,7 +32,7 @@ export const Notifications = () => { // row class rules for unread messages const rowClassRules = useMemo( () => ({ - 'row-not-read': (params) => !params.data.isRead + 'unread-row': (params) => !params.data.isRead }), [] ) @@ -54,10 +58,12 @@ export const Notifications = () => { mutation.mutate(selectedNotifications, { onSuccess: () => { - alertRef.current?.triggerAlert({ - message: t(successMessage), - severity: 'success' - }) + // eslint-disable-next-line chai-friendly/no-unused-expressions + successMessage && + alertRef.current?.triggerAlert({ + message: t(successMessage), + severity: 'success' + }) refetch() }, onError: (error) => { @@ -113,6 +119,27 @@ export const Notifications = () => { } }, [handleMutation, deleteMutation]) + const handleRowClicked = useCallback( + (params) => { + const { id, service, compliancePeriod } = JSON.parse(params.data.message) + // Select the appropriate route based on the notification type + const routeTemplate = routesMapping(currentUser)[service] + + if (routeTemplate && params.event.target.dataset.action !== 'delete') { + navigate( + // replace any matching query params by chaining these replace methods + routeTemplate + .replace(':transactionId', id) + .replace(':transferId', id) + .replace(':compliancePeriod', compliancePeriod) + .replace(':complianceReportId', id) + ) + handleMutation(markAsReadMutation, [params.data.notificationMessageId]) + } + }, + [currentUser, navigate] + ) + const onCellClicked = useCallback( (params) => { if ( @@ -141,7 +168,7 @@ export const Notifications = () => { setIsAllSelected( visibleRows.length > 0 && visibleRows.length === selectedRows.length ) - },[]) + }, []) return ( @@ -185,7 +212,7 @@ export const Notifications = () => { gridKey="notifications-grid" gridRef={gridRef} alertRef={alertRef} - columnDefs={columnDefs(t)} + columnDefs={columnDefs(t, currentUser)} query={useGetNotificationMessages} dataKey="notifications" overlayNoRowsTemplate={t('notifications:noNotificationsFound')} @@ -199,6 +226,7 @@ export const Notifications = () => { onCellClicked={onCellClicked} selectionColumnDef={selectionColumnDef} onSelectionChanged={onSelectionChanged} + onRowClicked={handleRowClicked} /> ) diff --git a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx index dd1a5c022..71061b79c 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx @@ -1,7 +1,8 @@ import { dateFormatter } from '@/utils/formatters' import { actions } from '@/components/BCDataGrid/columns' +import { ROUTES } from '@/constants/routes' -export const columnDefs = (t) => [ +export const columnDefs = (t, currentUser) => [ { ...actions({ enableDelete: true }), headerName: 'Delete', @@ -28,13 +29,36 @@ export const columnDefs = (t) => [ { colId: 'transactionId', field: 'transactionId', - headerName: t('notifications:notificationColLabels.transactionId') + headerName: t('notifications:notificationColLabels.transactionId'), + valueGetter: (params) => { + const { service, id } = JSON.parse(params.data.message) + if (service === 'Transfer') { + return `CT${id}` + } else if (service === 'InitiativeAgreement') { + return `IA${id}` + } else if (service === 'ComplianceReport') { + return `CR${id}` + } else { + return id + } + } }, { colId: 'organization', field: 'organization', headerName: t('notifications:notificationColLabels.organization'), - valueGetter: (params) => params.data.relatedOrganization.name + valueGetter: (params) => { + const { service, toOrganizationId, fromOrganization } = JSON.parse( + params.data.message + ) + if ( + service === 'Transfer' && + toOrganizationId === currentUser?.organization?.organizationId + ) { + return fromOrganization + } + return params.data.relatedOrganization.name + } } ] @@ -43,3 +67,16 @@ export const defaultColDef = { resizable: true, sortable: true } + +export const routesMapping = (currentUser) => ({ + Transfer: ROUTES.TRANSFERS_VIEW, + AdminAdjustment: currentUser.isGovernmentUser + ? ROUTES.ADMIN_ADJUSTMENT_VIEW + : ROUTES.ORG_ADMIN_ADJUSTMENT_VIEW, + InitiativeAgreement: currentUser.isGovernmentUser + ? ROUTES.INITIATIVE_AGREEMENT_VIEW + : ROUTES.ORG_INITIATIVE_AGREEMENT_VIEW, + ComplianceReport: currentUser.isGovernmentUser + ? ROUTES.REPORTS_VIEW + : ROUTES.ORG_COMPLIANCE_REPORT_VIEW +}) diff --git a/frontend/src/views/Notifications/Notifications.jsx b/frontend/src/views/Notifications/Notifications.jsx deleted file mode 100644 index da555141f..000000000 --- a/frontend/src/views/Notifications/Notifications.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import * as ROUTES from '@/constants/routes/routes.js' -import withFeatureFlag from '@/utils/withFeatureFlag.jsx' -import { FEATURE_FLAGS } from '@/constants/config.js' - -export const NotificationsBase = () => { - return
Notifications
-} - -export const Notifications = withFeatureFlag( - NotificationsBase, - FEATURE_FLAGS.NOTIFICATIONS, - ROUTES.DASHBOARD -) -Notifications.displayName = 'Notifications' From 66b2615967ef67e91f820c4665873b59ecc2d455 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 03:08:28 -0800 Subject: [PATCH 30/55] route and user name fixes. --- backend/lcfs/web/api/notification/schema.py | 23 +++++++++++-------- .../NotificationMenu/components/_schema.jsx | 4 +--- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index 5cf97cb56..04dc9d339 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -7,7 +7,7 @@ ) from lcfs.db.models.transfer.TransferStatus import TransferStatusEnum from lcfs.web.api.base import BaseSchema, NotificationTypeEnum, PaginationResponseSchema -from pydantic import computed_field, field_validator +from pydantic import computed_field, model_validator class OrganizationSchema(BaseSchema): @@ -21,15 +21,18 @@ class UserProfileSchema(BaseSchema): organization_id: Optional[int] = None is_government: bool = False - @field_validator("is_government", mode="after") - def update_gov_profile(cls, value, info): - if info.data.get("is_government", True): - info.data.update({"first_name": "Government of B.C.", "last_name": ""}) - return value - + @model_validator(mode="before") + def update_government_profile(cls, data): + if data.is_government: + data.first_name = "Government of B.C." + data.last_name = "" + return data + @computed_field @property def full_name(self) -> str: + if self.is_government: + return "Government of B.C." return f"{self.first_name} {self.last_name}" @@ -82,10 +85,12 @@ class DeleteSubscriptionSchema(BaseSchema): class DeleteNotificationChannelSubscriptionResponseSchema(BaseSchema): message: str + class NotificationsSchema(BaseSchema): notifications: List[NotificationMessageSchema] = [] pagination: PaginationResponseSchema = None + class NotificationRequestSchema(BaseSchema): notification_types: List[NotificationTypeEnum] notification_context: Optional[Dict[str, Any]] = {} @@ -134,7 +139,7 @@ class NotificationRequestSchema(BaseSchema): ], TransferStatusEnum.Submitted: [ NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, - NotificationTypeEnum.IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW + NotificationTypeEnum.IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW, ], TransferStatusEnum.Recommended: [ NotificationTypeEnum.IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION @@ -149,7 +154,7 @@ class NotificationRequestSchema(BaseSchema): ], "Return to analyst": [ NotificationTypeEnum.IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW - ] + ], } INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER = { diff --git a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx index 71061b79c..131ab0d81 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx @@ -76,7 +76,5 @@ export const routesMapping = (currentUser) => ({ InitiativeAgreement: currentUser.isGovernmentUser ? ROUTES.INITIATIVE_AGREEMENT_VIEW : ROUTES.ORG_INITIATIVE_AGREEMENT_VIEW, - ComplianceReport: currentUser.isGovernmentUser - ? ROUTES.REPORTS_VIEW - : ROUTES.ORG_COMPLIANCE_REPORT_VIEW + ComplianceReport: ROUTES.REPORTS_VIEW }) From dd6e869cf853b8c7cc1420d6d788cbdfc54d2384 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 04:08:37 -0800 Subject: [PATCH 31/55] fix tests --- .../compliance_report/test_update_service.py | 27 +++-- .../test_initiative_agreement_services.py | 25 +++- .../notification/test_notification_repo.py | 107 ++++++++++++++---- .../tests/transfer/test_transfer_services.py | 18 +++ 4 files changed, 144 insertions(+), 33 deletions(-) diff --git a/backend/lcfs/tests/compliance_report/test_update_service.py b/backend/lcfs/tests/compliance_report/test_update_service.py index ec4b7e130..753ec8b76 100644 --- a/backend/lcfs/tests/compliance_report/test_update_service.py +++ b/backend/lcfs/tests/compliance_report/test_update_service.py @@ -30,8 +30,8 @@ def mock_user_has_roles(): def mock_notification_service(): mock_service = AsyncMock(spec=NotificationService) with patch( - "lcfs.web.api.compliance_report.update_service.Depends", - return_value=mock_service + "lcfs.web.api.compliance_report.update_service.Depends", + return_value=mock_service, ): yield mock_service @@ -47,6 +47,7 @@ def mock_environment_vars(): mock_settings.ches_sender_name = "Mock Notification System" yield mock_settings + # Mock for adjust_balance method within the OrganizationsService @pytest.fixture def mock_org_service(): @@ -66,6 +67,8 @@ async def test_update_compliance_report_status_change( mock_report.compliance_report_id = report_id mock_report.current_status = MagicMock(spec=ComplianceReportStatus) mock_report.current_status.status = ComplianceReportStatusEnum.Draft + mock_report.compliance_period = MagicMock() + mock_report.compliance_period.description = "2024" new_status = MagicMock(spec=ComplianceReportStatus) new_status.status = ComplianceReportStatusEnum.Submitted @@ -78,8 +81,8 @@ async def test_update_compliance_report_status_change( mock_repo.get_compliance_report_by_id.return_value = mock_report mock_repo.get_compliance_report_status_by_desc.return_value = new_status compliance_report_update_service.handle_status_change = AsyncMock() - compliance_report_update_service.notfn_service = mock_notification_service mock_repo.update_compliance_report.return_value = mock_report + compliance_report_update_service._perform_notificaiton_call = AsyncMock() # Call the method updated_report = await compliance_report_update_service.update_compliance_report( @@ -101,10 +104,9 @@ async def test_update_compliance_report_status_change( mock_report, compliance_report_update_service.request.user ) mock_repo.update_compliance_report.assert_called_once_with(mock_report) - - assert mock_report.current_status == new_status - assert mock_report.supplemental_note == report_data.supplemental_note - mock_notification_service.send_notification.assert_called_once() + compliance_report_update_service._perform_notificaiton_call.assert_called_once_with( + mock_report, "Submitted" + ) @pytest.mark.anyio @@ -118,6 +120,10 @@ async def test_update_compliance_report_no_status_change( mock_report.current_status = MagicMock(spec=ComplianceReportStatus) mock_report.current_status.status = ComplianceReportStatusEnum.Draft + # Fix for JSON serialization + mock_report.compliance_period = MagicMock() + mock_report.compliance_period.description = "2024" + report_data = ComplianceReportUpdateSchema( status="Draft", supplemental_note="Test note" ) @@ -131,6 +137,7 @@ async def test_update_compliance_report_no_status_change( # Mock the handle_status_change method compliance_report_update_service.handle_status_change = AsyncMock() + compliance_report_update_service._perform_notificaiton_call = AsyncMock() # Call the method updated_report = await compliance_report_update_service.update_compliance_report( @@ -148,9 +155,9 @@ async def test_update_compliance_report_no_status_change( compliance_report_update_service.handle_status_change.assert_not_called() mock_repo.add_compliance_report_history.assert_not_called() mock_repo.update_compliance_report.assert_called_once_with(mock_report) - - assert mock_report.current_status == mock_report.current_status - assert mock_report.supplemental_note == report_data.supplemental_note + compliance_report_update_service._perform_notificaiton_call.assert_called_once_with( + mock_report, "Draft" + ) @pytest.mark.anyio diff --git a/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py b/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py index 85d0299a9..2eb16223d 100644 --- a/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py +++ b/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py @@ -87,14 +87,23 @@ async def test_get_initiative_agreement(service, mock_repo): mock_repo.get_initiative_agreement_by_id.assert_called_once_with(1) +@pytest.mark.anyio @pytest.mark.anyio async def test_create_initiative_agreement(service, mock_repo, mock_request): + # Mock status for the initiative agreement mock_status = MagicMock(status=InitiativeAgreementStatusEnum.Recommended) mock_repo.get_initiative_agreement_status_by_name.return_value = mock_status - mock_repo.create_initiative_agreement.return_value = MagicMock( - spec=InitiativeAgreement - ) + # Create a mock initiative agreement with serializable fields + mock_initiative_agreement = MagicMock(spec=InitiativeAgreement) + mock_initiative_agreement.initiative_agreement_id = 1 + mock_initiative_agreement.current_status.status = "Recommended" + mock_initiative_agreement.to_organization_id = 3 + + # Mock return value of create_initiative_agreement + mock_repo.create_initiative_agreement.return_value = mock_initiative_agreement + + # Create input data create_data = InitiativeAgreementCreateSchema( compliance_units=150, current_status="Recommended", @@ -104,10 +113,18 @@ async def test_create_initiative_agreement(service, mock_repo, mock_request): internal_comment=None, ) + # Mock _perform_notificaiton_call to isolate it + service._perform_notificaiton_call = AsyncMock() + + # Call the service method result = await service.create_initiative_agreement(create_data) - assert isinstance(result, InitiativeAgreement) + # Assertions + assert result == mock_initiative_agreement mock_repo.create_initiative_agreement.assert_called_once() + service._perform_notificaiton_call.assert_called_once_with( + mock_initiative_agreement + ) @pytest.mark.anyio diff --git a/backend/lcfs/tests/notification/test_notification_repo.py b/backend/lcfs/tests/notification/test_notification_repo.py index 20eb31169..bbc4ee80f 100644 --- a/backend/lcfs/tests/notification/test_notification_repo.py +++ b/backend/lcfs/tests/notification/test_notification_repo.py @@ -1,3 +1,5 @@ +from lcfs.db.models.notification.NotificationChannel import ChannelEnum +from lcfs.web.api.base import NotificationTypeEnum, PaginationRequestSchema import pytest from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import delete @@ -92,34 +94,21 @@ async def mock_execute(*args, **kwargs): @pytest.mark.anyio async def test_get_notification_messages_by_user(notification_repo, mock_db_session): mock_notification1 = MagicMock(spec=NotificationMessage) - mock_notification1.related_user_id = 1 - mock_notification1.origin_user_id = 2 - mock_notification1.notification_message_id = 1 - mock_notification1.message = "Test message 1" - mock_notification2 = MagicMock(spec=NotificationMessage) - mock_notification2.related_user_id = 1 - mock_notification2.origin_user_id = 2 - mock_notification2.notification_message_id = 2 - mock_notification2.message = "Test message 2" - mock_result_chain = MagicMock() - mock_result_chain.scalars.return_value.all.return_value = [ + mock_result = MagicMock() + mock_result.unique.return_value.scalars.return_value.all.return_value = [ mock_notification1, mock_notification2, ] - async def mock_execute(*args, **kwargs): - return mock_result_chain - - # Inject the mocked execute method into the session - mock_db_session.execute = mock_execute + mock_db_session.execute = AsyncMock(return_value=mock_result) result = await notification_repo.get_notification_messages_by_user(1) assert len(result) == 2 - assert result[0].notification_message_id == 1 - assert result[1].notification_message_id == 2 + assert result == [mock_notification1, mock_notification2] + mock_db_session.execute.assert_called_once() @pytest.mark.anyio @@ -158,7 +147,7 @@ async def test_delete_notification_message(notification_repo, mock_db_session): NotificationMessage.notification_message_id == notification_id ) assert str(executed_query) == str(expected_query) - + mock_db_session.execute.assert_called_once() mock_db_session.flush.assert_called_once() @@ -277,3 +266,83 @@ async def mock_execute(*args, **kwargs): assert result is not None assert result.notification_channel_subscription_id == subscription_id + + +@pytest.mark.anyio +async def test_create_notification_messages(notification_repo, mock_db_session): + messages = [ + MagicMock(spec=NotificationMessage), + MagicMock(spec=NotificationMessage), + ] + + await notification_repo.create_notification_messages(messages) + + mock_db_session.add_all.assert_called_once_with(messages) + mock_db_session.flush.assert_called_once() + + +@pytest.mark.anyio +async def test_mark_notifications_as_read(notification_repo, mock_db_session): + user_id = 1 + notification_ids = [1, 2, 3] + + mock_db_session.execute = AsyncMock() + mock_db_session.flush = AsyncMock() + + result = await notification_repo.mark_notifications_as_read( + user_id, notification_ids + ) + + assert result == notification_ids + mock_db_session.execute.assert_called_once() + mock_db_session.flush.assert_called_once() + + +@pytest.mark.anyio +async def test_get_notification_type_by_name(notification_repo, mock_db_session): + # Create a mock result that properly simulates the SQLAlchemy result + mock_result = MagicMock() + mock_scalars = MagicMock() + mock_scalars.first.return_value = 123 + mock_result.scalars.return_value = mock_scalars + + mock_db_session.execute = AsyncMock(return_value=mock_result) + + result = await notification_repo.get_notification_type_by_name("TestNotification") + + assert result == 123 + mock_db_session.execute.assert_called_once() + + +@pytest.mark.anyio +async def test_get_notification_channel_by_name(notification_repo, mock_db_session): + # Similar setup to the previous test + mock_result = MagicMock() + mock_scalars = MagicMock() + mock_scalars.first.return_value = 456 + mock_result.scalars.return_value = mock_scalars + + mock_db_session.execute = AsyncMock(return_value=mock_result) + + result = await notification_repo.get_notification_channel_by_name(ChannelEnum.EMAIL) + + assert result == 456 + mock_db_session.execute.assert_called_once() + + +@pytest.mark.anyio +async def test_get_subscribed_users_by_channel(notification_repo, mock_db_session): + # Similar setup, but using .all() instead of .first() + mock_result = MagicMock() + mock_scalars = MagicMock() + mock_scalars.all.return_value = [1, 2, 3] + mock_result.scalars.return_value = mock_scalars + + mock_db_session.execute = AsyncMock(return_value=mock_result) + + result = await notification_repo.get_subscribed_users_by_channel( + NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, ChannelEnum.EMAIL + ) + + assert result == [1, 2, 3] + mock_db_session.execute.assert_called_once() diff --git a/backend/lcfs/tests/transfer/test_transfer_services.py b/backend/lcfs/tests/transfer/test_transfer_services.py index d9e30abfb..91c8e7f21 100644 --- a/backend/lcfs/tests/transfer/test_transfer_services.py +++ b/backend/lcfs/tests/transfer/test_transfer_services.py @@ -91,8 +91,15 @@ async def test_update_transfer_success( ): transfer_status = TransferStatus(transfer_status_id=1, status="status") transfer_id = 1 + # Create valid nested organization objects + from_org = Organization(organization_id=1, name="org1") + to_org = Organization(organization_id=2, name="org2") + + # Create a Transfer object with the necessary attributes transfer = Transfer( transfer_id=transfer_id, + from_organization=from_org, + to_organization=to_org, from_organization_id=1, to_organization_id=2, from_transaction_id=1, @@ -114,11 +121,22 @@ async def test_update_transfer_success( mock_transfer_repo.get_transfer_by_id.return_value = transfer mock_transfer_repo.update_transfer.return_value = transfer + # Replace _perform_notificaiton_call with an AsyncMock + transfer_service._perform_notificaiton_call = AsyncMock() + result = await transfer_service.update_transfer(transfer) + # Assertions assert result.transfer_id == transfer_id assert isinstance(result, Transfer) + # Verify mocks + mock_transfer_repo.get_transfer_by_id.assert_called_once_with(transfer_id) + mock_transfer_repo.update_transfer.assert_called_once_with(transfer) + transfer_service._perform_notificaiton_call.assert_awaited_once_with( + transfer, status="Return to analyst" + ) + @pytest.mark.anyio async def test_update_category_success(transfer_service, mock_transfer_repo): From 71736f84add778bf4ea211f3010b67ee5fd877e9 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 09:46:50 -0800 Subject: [PATCH 32/55] filter fixes --- backend/lcfs/web/api/notification/repo.py | 22 ++++++++++++------- .../NotificationMenu/components/_schema.jsx | 1 + 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/backend/lcfs/web/api/notification/repo.py b/backend/lcfs/web/api/notification/repo.py index 75c9cf05b..ebb5647a3 100644 --- a/backend/lcfs/web/api/notification/repo.py +++ b/backend/lcfs/web/api/notification/repo.py @@ -10,6 +10,8 @@ from lcfs.web.api.base import ( NotificationTypeEnum, PaginationRequestSchema, + apply_filter_conditions, + get_field_for_filter, validate_pagination, ) import structlog @@ -100,14 +102,18 @@ def _apply_notification_filters( filter_type = filter.filter_type # Handle date filters - if filter.filter_type == "date": - filter_value = [] - if filter.date_from: - filter_value.append(filter.date_from) - if filter.date_to: - filter_value.append(filter.date_to) - if not filter_value: - continue # Skip if no valid date is provided + if filter.field == "date": + filter_value = filter.date_from + field = get_field_for_filter(NotificationMessage, 'create_date') + elif filter.field == 'user': + field = get_field_for_filter(NotificationMessage, 'related_user_profile.first_name') + else: + field = get_field_for_filter(NotificationMessage, filter.field) + conditions.append( + apply_filter_conditions( + field, filter_value, filter_option, filter_type + ) + ) return conditions diff --git a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx index 131ab0d81..5ea74c314 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx @@ -16,6 +16,7 @@ export const columnDefs = (t, currentUser) => [ { colId: 'date', field: 'date', + cellDataType: 'date', headerName: t('notifications:notificationColLabels.date'), valueGetter: (params) => params.data.createDate, valueFormatter: dateFormatter From 86b114669bec756af408f81339fb847a7f8ed365 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 10:34:00 -0800 Subject: [PATCH 33/55] update for PR review comments --- .../db/models/notification/NotificationMessage.py | 5 ----- .../lcfs/web/api/compliance_report/update_service.py | 12 ++++++------ .../lcfs/web/api/initiative_agreement/services.py | 6 +++--- backend/lcfs/web/api/notification/schema.py | 8 ++++---- backend/lcfs/web/api/transfer/services.py | 6 +++--- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/backend/lcfs/db/models/notification/NotificationMessage.py b/backend/lcfs/db/models/notification/NotificationMessage.py index a339da56e..2c3a5fd6d 100644 --- a/backend/lcfs/db/models/notification/NotificationMessage.py +++ b/backend/lcfs/db/models/notification/NotificationMessage.py @@ -35,11 +35,6 @@ class NotificationMessage(BaseModel, Auditable): ) transaction_id = Column(Integer, ForeignKey("transaction.transaction_id"), nullable=True) - # Models not created yet - # related_transaction_id = Column(Integer,ForeignKey('')) - # related_document_id = Column(Integer, ForeignKey('document.id')) - # related_report_id = Column(Integer, ForeignKey('compliance_report.id')) - # Relationships related_transaction = relationship("Transaction") related_organization = relationship( diff --git a/backend/lcfs/web/api/compliance_report/update_service.py b/backend/lcfs/web/api/compliance_report/update_service.py index 87df1bb50..05b82b994 100644 --- a/backend/lcfs/web/api/compliance_report/update_service.py +++ b/backend/lcfs/web/api/compliance_report/update_service.py @@ -80,10 +80,10 @@ async def update_compliance_report( # Add history record await self.repo.add_compliance_report_history(report, self.request.user) - await self._perform_notificaiton_call(report, current_status) + await self._perform_notification_call(report, current_status) return updated_report - async def _perform_notificaiton_call(self, cr, status): + async def _perform_notification_call(self, report, status): """Send notifications based on the current status of the transfer.""" status_mapper = status.replace(" ", "_") notifications = COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER.get( @@ -96,15 +96,15 @@ async def _perform_notificaiton_call(self, cr, status): ) message_data = { "service": "ComplianceReport", - "id": cr.compliance_report_id, - "compliancePeriod": cr.compliance_period.description, + "id": report.compliance_report_id, + "compliancePeriod": report.compliance_period.description, "status": status.lower(), } notification_data = NotificationMessageSchema( type=f"Compliance report {status.lower()}", - transaction_id=cr.transaction_id, + transaction_id=report.transaction_id, message=json.dumps(message_data), - related_organization_id=cr.organization_id, + related_organization_id=report.organization_id, origin_user_profile_id=self.request.user.user_profile_id, ) if notifications and isinstance(notifications, list): diff --git a/backend/lcfs/web/api/initiative_agreement/services.py b/backend/lcfs/web/api/initiative_agreement/services.py index 6494d57d0..387eceaad 100644 --- a/backend/lcfs/web/api/initiative_agreement/services.py +++ b/backend/lcfs/web/api/initiative_agreement/services.py @@ -130,7 +130,7 @@ async def update_initiative_agreement( # Return the updated initiative agreement schema with the returned status flag ia_schema = InitiativeAgreementSchema.from_orm(updated_initiative_agreement) ia_schema.returned = returned - await self._perform_notificaiton_call(updated_initiative_agreement, returned) + await self._perform_notification_call(updated_initiative_agreement, returned) return ia_schema @service_handler @@ -175,7 +175,7 @@ async def create_initiative_agreement( await self.internal_comment_service.create_internal_comment( internal_comment_data ) - await self._perform_notificaiton_call(initiative_agreement) + await self._perform_notification_call(initiative_agreement) return initiative_agreement async def director_approve_initiative_agreement( @@ -210,7 +210,7 @@ async def director_approve_initiative_agreement( await self.repo.refresh_initiative_agreement(initiative_agreement) - async def _perform_notificaiton_call(self, ia, returned=False): + async def _perform_notification_call(self, ia, returned=False): """Send notifications based on the current status of the transfer.""" status = ia.current_status.status if not returned else "Return to analyst" status_val = ( diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index 04dc9d339..f64d8ba36 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -10,12 +10,12 @@ from pydantic import computed_field, model_validator -class OrganizationSchema(BaseSchema): +class NotificationOrganizationSchema(BaseSchema): organization_id: int name: str -class UserProfileSchema(BaseSchema): +class NotificationUserProfileSchema(BaseSchema): first_name: str last_name: str organization_id: Optional[int] = None @@ -45,11 +45,11 @@ class NotificationMessageSchema(BaseSchema): type: Optional[str] = None message: Optional[str] = None related_organization_id: Optional[int] = None - related_organization: Optional[OrganizationSchema] = None + related_organization: Optional[NotificationOrganizationSchema] = None transaction_id: Optional[int] = None create_date: Optional[datetime] = None origin_user_profile_id: Optional[int] = None - origin_user_profile: Optional[UserProfileSchema] = None + origin_user_profile: Optional[NotificationUserProfileSchema] = None related_user_profile_id: Optional[int] = None notification_type_id: Optional[int] = None deleted: Optional[bool] = None diff --git a/backend/lcfs/web/api/transfer/services.py b/backend/lcfs/web/api/transfer/services.py index ea1dcd2a2..e0427618a 100644 --- a/backend/lcfs/web/api/transfer/services.py +++ b/backend/lcfs/web/api/transfer/services.py @@ -166,7 +166,7 @@ async def create_transfer( current_status.transfer_status_id, self.request.user.user_profile_id, ) - await self._perform_notificaiton_call(transfer, current_status.status) + await self._perform_notification_call(transfer, current_status.status) return transfer @service_handler @@ -264,7 +264,7 @@ async def update_transfer(self, transfer_data: TransferCreateSchema) -> Transfer # Finally, update the transfer's status and save the changes transfer.current_status = new_status transfer_result = await self.repo.update_transfer(transfer) - await self._perform_notificaiton_call( + await self._perform_notification_call( transfer, status=( new_status.status @@ -274,7 +274,7 @@ async def update_transfer(self, transfer_data: TransferCreateSchema) -> Transfer ) return transfer_result - async def _perform_notificaiton_call( + async def _perform_notification_call( self, transfer: TransferSchema, status: TransferStatusEnum ): """Send notifications based on the current status of the transfer.""" From eeae8a77ca431e67ba7cb4fd4d5e7129a6ae7a32 Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Mon, 2 Dec 2024 13:12:10 -0700 Subject: [PATCH 34/55] Validation for Quantity Supplied in Fuel Supply. --- backend/lcfs/web/api/fuel_supply/schema.py | 4 ++- .../FuelSupplies/AddEditFuelSupplies.jsx | 26 +++++++++++++++++++ frontend/src/views/FuelSupplies/_schema.jsx | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/backend/lcfs/web/api/fuel_supply/schema.py b/backend/lcfs/web/api/fuel_supply/schema.py index 60592dffe..68300b1e0 100644 --- a/backend/lcfs/web/api/fuel_supply/schema.py +++ b/backend/lcfs/web/api/fuel_supply/schema.py @@ -119,7 +119,9 @@ class FuelSupplyCreateUpdateSchema(BaseSchema): fuel_category_id: int end_use_id: Optional[int] = None provision_of_the_act_id: int - quantity: int + quantity: int = Field( + ..., gt=0, description="Quantity supplied must be greater than 0" + ) units: str fuel_type_other: Optional[str] = None fuel_code_id: Optional[int] = None diff --git a/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx b/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx index ec29c0e2f..385c70f68 100644 --- a/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx +++ b/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx @@ -66,6 +66,22 @@ export const AddEditFuelSupplies = () => { } }, [location.state]) + const validateField = (params, field, validationFn, errorMessage, alertRef) => { + const newValue = params.newValue; + + if (params.colDef.field === field) { + if (!validationFn(newValue)) { + alertRef.current?.triggerAlert({ + message: errorMessage, + severity: 'error', + }); + return false; + } + } + + return true; // Proceed with the update + }; + const onGridReady = useCallback( async (params) => { setGridApi(params.api) @@ -150,6 +166,16 @@ export const AddEditFuelSupplies = () => { const onCellEditingStopped = useCallback( async (params) => { + const isValid = validateField( + params, + 'quantity', + (value) => value !== null && !isNaN(value) && value > 0, + 'Quantity supplied must be greater than 0.', + alertRef + ); + + if (!isValid) return; + if (params.oldValue === params.newValue) return params.node.updateData({ diff --git a/frontend/src/views/FuelSupplies/_schema.jsx b/frontend/src/views/FuelSupplies/_schema.jsx index 0dc792d73..83848a97c 100644 --- a/frontend/src/views/FuelSupplies/_schema.jsx +++ b/frontend/src/views/FuelSupplies/_schema.jsx @@ -382,7 +382,7 @@ export const fuelSupplyColDefs = (optionsData, errors, warnings) => [ field: 'quantity', headerComponent: RequiredHeader, headerName: i18n.t('fuelSupply:fuelSupplyColLabels.quantity'), - valueFormatter, + valueFormatter: (params) => valueFormatter({ value: params.value }), cellEditor: NumberEditor, cellEditorParams: { precision: 0, From b4a8d8a4be57c039528337d1bf8916202e95088b Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Mon, 2 Dec 2024 15:59:48 -0700 Subject: [PATCH 35/55] Validation of quantity in LCFS. --- .../lcfs/web/api/notional_transfer/schema.py | 4 ++- backend/lcfs/web/api/transaction/schema.py | 6 +++-- backend/lcfs/web/api/transfer/schema.py | 6 +++-- .../AddEditAllocationAgreements.jsx | 26 +++++++++++++++++++ .../views/AllocationAgreements/_schema.jsx | 3 ++- .../AddEditNotionalTransfers.jsx | 26 +++++++++++++++++++ .../src/views/NotionalTransfers/_schema.jsx | 2 +- 7 files changed, 66 insertions(+), 7 deletions(-) diff --git a/backend/lcfs/web/api/notional_transfer/schema.py b/backend/lcfs/web/api/notional_transfer/schema.py index 5f6571e57..6ca7085ea 100644 --- a/backend/lcfs/web/api/notional_transfer/schema.py +++ b/backend/lcfs/web/api/notional_transfer/schema.py @@ -20,7 +20,9 @@ class NotionalTransferCreateSchema(BaseSchema): address_for_service: str fuel_category: str received_or_transferred: ReceivedOrTransferredEnumSchema - quantity: int + quantity: int = Field( + ..., gt=0, description="Quantity supplied must be greater than 0" + ) notional_transfer_id: Optional[int] = None compliance_report_id: int deleted: Optional[bool] = None diff --git a/backend/lcfs/web/api/transaction/schema.py b/backend/lcfs/web/api/transaction/schema.py index 34a44b441..8bd05856d 100644 --- a/backend/lcfs/web/api/transaction/schema.py +++ b/backend/lcfs/web/api/transaction/schema.py @@ -1,6 +1,6 @@ from typing import Optional, List -from pydantic import ConfigDict +from pydantic import ConfigDict, Field from lcfs.web.api.base import BaseSchema from datetime import datetime from enum import Enum @@ -71,7 +71,9 @@ class TransactionViewSchema(BaseSchema): transaction_type: str from_organization: Optional[str] = None to_organization: str - quantity: int + quantity: int = Field( + ..., gt=0, description="Quantity supplied must be greater than 0" + ) price_per_unit: Optional[float] = None status: str create_date: datetime diff --git a/backend/lcfs/web/api/transfer/schema.py b/backend/lcfs/web/api/transfer/schema.py index 858accf73..4d8826c10 100644 --- a/backend/lcfs/web/api/transfer/schema.py +++ b/backend/lcfs/web/api/transfer/schema.py @@ -3,7 +3,7 @@ from typing import Optional, List from datetime import date, datetime from enum import Enum -from pydantic import ConfigDict +from pydantic import ConfigDict, Field class TransferRecommendationEnumSchema(str, Enum): @@ -48,7 +48,9 @@ class TransferSchema(BaseSchema): from_organization: TransferOrganizationSchema to_organization: TransferOrganizationSchema agreement_date: date - quantity: int + quantity: int = Field( + ..., gt=0, description="Quantity supplied must be greater than 0" + ) price_per_unit: float comments: Optional[List[TransferCommentSchema]] = None from_org_comment: Optional[str] = None diff --git a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx index ebf346e27..88ff97d19 100644 --- a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx +++ b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx @@ -67,6 +67,22 @@ export const AddEditAllocationAgreements = () => { } }, [location.state]) + const validateField = (params, field, validationFn, errorMessage, alertRef) => { + const newValue = params.newValue; + + if (params.colDef.field === field) { + if (!validationFn(newValue)) { + alertRef.current?.triggerAlert({ + message: errorMessage, + severity: 'error', + }); + return false; + } + } + + return true; // Proceed with the update + }; + const onGridReady = useCallback( async (params) => { setGridApi(params.api) @@ -154,6 +170,16 @@ export const AddEditAllocationAgreements = () => { const onCellEditingStopped = useCallback( async (params) => { + const isValid = validateField( + params, + 'quantity', + (value) => value !== null && !isNaN(value) && value > 0, + 'Quantity must be greater than 0.', + alertRef + ); + + if (!isValid) return; + if (params.oldValue === params.newValue) return params.node.updateData({ diff --git a/frontend/src/views/AllocationAgreements/_schema.jsx b/frontend/src/views/AllocationAgreements/_schema.jsx index c1a878878..e9d6e29d6 100644 --- a/frontend/src/views/AllocationAgreements/_schema.jsx +++ b/frontend/src/views/AllocationAgreements/_schema.jsx @@ -435,7 +435,8 @@ export const allocationAgreementColDefs = (optionsData, errors) => [ headerName: i18n.t( 'allocationAgreement:allocationAgreementColLabels.quantity' ), - valueFormatter, + editor: NumberEditor, + valueFormatter: (params) => valueFormatter({ value: params.value }), cellEditor: NumberEditor, cellEditorParams: { precision: 0, diff --git a/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx b/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx index 2df027624..541865995 100644 --- a/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx +++ b/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx @@ -47,6 +47,22 @@ export const AddEditNotionalTransfers = () => { } }, [location.state]) + const validateField = (params, field, validationFn, errorMessage, alertRef) => { + const newValue = params.newValue; + + if (params.colDef.field === field) { + if (!validationFn(newValue)) { + alertRef.current?.triggerAlert({ + message: errorMessage, + severity: 'error', + }); + return false; + } + } + + return true; // Proceed with the update + }; + const onGridReady = (params) => { const ensureRowIds = (rows) => { return rows.map((row) => { @@ -96,6 +112,16 @@ export const AddEditNotionalTransfers = () => { const onCellEditingStopped = useCallback( async (params) => { + const isValid = validateField( + params, + 'quantity', + (value) => value !== null && !isNaN(value) && value > 0, + 'Quantity must be greater than 0.', + alertRef + ); + + if (!isValid) return; + if (params.oldValue === params.newValue) return // Initialize updated data with 'pending' status diff --git a/frontend/src/views/NotionalTransfers/_schema.jsx b/frontend/src/views/NotionalTransfers/_schema.jsx index 38673f676..6064debe4 100644 --- a/frontend/src/views/NotionalTransfers/_schema.jsx +++ b/frontend/src/views/NotionalTransfers/_schema.jsx @@ -140,7 +140,7 @@ export const notionalTransferColDefs = (optionsData, errors) => [ min: 0, showStepperButtons: false }, - valueFormatter, + valueFormatter: (params) => valueFormatter({ value: params.value }), cellStyle: (params) => StandardCellErrors(params, errors) } ] From 9879041254e3b8c8875e5453d4d39cb6ca78360f Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Thu, 28 Nov 2024 16:41:27 -0700 Subject: [PATCH 36/55] Zero is not a valid number for quantity supplied --- backend/lcfs/web/api/other_uses/schema.py | 4 ++- .../src/views/OtherUses/AddEditOtherUses.jsx | 26 +++++++++++++++++++ frontend/src/views/OtherUses/_schema.jsx | 3 +-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/lcfs/web/api/other_uses/schema.py b/backend/lcfs/web/api/other_uses/schema.py index db3e591be..03612276a 100644 --- a/backend/lcfs/web/api/other_uses/schema.py +++ b/backend/lcfs/web/api/other_uses/schema.py @@ -90,7 +90,9 @@ class OtherUsesCreateSchema(BaseSchema): fuel_type: str fuel_category: str provision_of_the_act: str - quantity_supplied: int + quantity_supplied: int = Field( + ..., gt=0, description="Quantity supplied must be greater than 0" + ) units: str expected_use: str fuel_code: Optional[str] = None diff --git a/frontend/src/views/OtherUses/AddEditOtherUses.jsx b/frontend/src/views/OtherUses/AddEditOtherUses.jsx index 08d9f250a..f983bd120 100644 --- a/frontend/src/views/OtherUses/AddEditOtherUses.jsx +++ b/frontend/src/views/OtherUses/AddEditOtherUses.jsx @@ -81,6 +81,22 @@ export const AddEditOtherUses = () => { return ciOfFuel }, []) + const validateField = (params, field, validationFn, errorMessage, alertRef) => { + const newValue = params.newValue; + + if (params.colDef.field === field) { + if (!validationFn(newValue)) { + alertRef.current?.triggerAlert({ + message: errorMessage, + severity: 'error', + }); + return false; + } + } + + return true; // Proceed with the update + }; + const onGridReady = (params) => { const ensureRowIds = (rows) => { return rows.map((row) => { @@ -200,6 +216,16 @@ export const AddEditOtherUses = () => { const onCellEditingStopped = useCallback( async (params) => { + const isValid = validateField( + params, + 'quantitySupplied', + (value) => value !== null && !isNaN(value) && value > 0, + 'Quantity supplied must be greater than 0.', + alertRef + ); + + if (!isValid) return; + if (params.oldValue === params.newValue) return params.data.complianceReportId = complianceReportId params.data.validationStatus = 'pending' diff --git a/frontend/src/views/OtherUses/_schema.jsx b/frontend/src/views/OtherUses/_schema.jsx index 03ce751fb..401b6db61 100644 --- a/frontend/src/views/OtherUses/_schema.jsx +++ b/frontend/src/views/OtherUses/_schema.jsx @@ -209,11 +209,10 @@ export const otherUsesColDefs = (optionsData, errors) => [ headerName: i18n.t('otherUses:otherUsesColLabels.quantitySupplied'), headerComponent: RequiredHeader, cellEditor: NumberEditor, - valueFormatter, + valueFormatter: (params) => valueFormatter({ value: params.value }), type: 'numericColumn', cellEditorParams: { precision: 0, - min: 0, showStepperButtons: false }, cellStyle: (params) => StandardCellErrors(params, errors), From 747e5e4aa147ef5d6b8152db75e4f06851f6491a Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Tue, 3 Dec 2024 18:02:57 -0700 Subject: [PATCH 37/55] Added vitests. --- .../AddEditAllocationAgreements.jsx | 26 +- .../__tests__/AllocationAgreements.test.jsx | 189 ++++++++++++++ .../FuelSupplies/AddEditFuelSupplies.jsx | 4 +- .../__tests__/FuelSupplies.test.jsx | 232 ++++++++++++++++++ .../AddEditNotionalTransfers.jsx | 4 +- .../AddEditNotionalTransfer.test.jsx | 136 ++++++++++ 6 files changed, 577 insertions(+), 14 deletions(-) create mode 100644 frontend/src/views/AllocationAgreements/__tests__/AllocationAgreements.test.jsx create mode 100644 frontend/src/views/FuelSupplies/__tests__/FuelSupplies.test.jsx create mode 100644 frontend/src/views/NotionalTransfers/__tests__/AddEditNotionalTransfer.test.jsx diff --git a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx index 88ff97d19..843d41a7c 100644 --- a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx +++ b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx @@ -65,23 +65,29 @@ export const AddEditAllocationAgreements = () => { severity: location.state.severity || 'info' }) } - }, [location.state]) + }, [location.state?.message, location.state?.severity]) - const validateField = (params, field, validationFn, errorMessage, alertRef) => { - const newValue = params.newValue; + const validateField = ( + params, + field, + validationFn, + errorMessage, + alertRef + ) => { + const newValue = params.newValue if (params.colDef.field === field) { if (!validationFn(newValue)) { alertRef.current?.triggerAlert({ message: errorMessage, - severity: 'error', - }); - return false; + severity: 'error' + }) + return false } } - return true; // Proceed with the update - }; + return true // Proceed with the update + } const onGridReady = useCallback( async (params) => { @@ -176,9 +182,9 @@ export const AddEditAllocationAgreements = () => { (value) => value !== null && !isNaN(value) && value > 0, 'Quantity must be greater than 0.', alertRef - ); + ) - if (!isValid) return; + if (!isValid) return if (params.oldValue === params.newValue) return diff --git a/frontend/src/views/AllocationAgreements/__tests__/AllocationAgreements.test.jsx b/frontend/src/views/AllocationAgreements/__tests__/AllocationAgreements.test.jsx new file mode 100644 index 000000000..eb53f33cb --- /dev/null +++ b/frontend/src/views/AllocationAgreements/__tests__/AllocationAgreements.test.jsx @@ -0,0 +1,189 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { vi } from 'vitest' +import { AddEditAllocationAgreements } from '../AddAllocationAgreements' +import * as useGetAllocationAgreements from '@/hooks/useAllocationAgreement' +import * as useAllocationAgreementOptions from '@/hooks/useAllocationAgreement' +import * as useSaveAllocationAgreement from '@/hooks/useAllocationAgreement' +import { wrapper } from '@/tests/utils/wrapper' + +vi.mock('@react-keycloak/web', () => ({ + ReactKeycloakProvider: ({ children }) => children, + useKeycloak: () => ({ + keycloak: { + authenticated: true, + login: vi.fn(), + logout: vi.fn(), + register: vi.fn() + }, + initialized: true + }) +})) + +// Mock useApiService +vi.mock('@/services/useApiService', () => ({ + default: vi.fn(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn() + })), + useApiService: vi.fn(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn() + })) +})) + +// Mock react-router-dom +const mockUseParams = vi.fn() +const mockUseLocation = vi.fn(() => ({ + state: { message: 'Test message', severity: 'info' } +})) +const mockUseNavigate = vi.fn() +const mockHasRoles = vi.fn() + +vi.mock('react-router-dom', () => ({ + ...vi.importActual('react-router-dom'), + useParams: () => ({ + complianceReportId: '123', + compliancePeriod: '2023' + }), + useLocation: () => mockUseLocation, + useNavigate: () => mockUseNavigate +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => key + }) +})) + + +describe('AddEditAllocationAgreement', () => { + const setupMocks = (overrides = {}) => { + const defaultMocks = { + useParams: { compliancePeriod: '2023', complianceReportId: '123' }, + useLocation: { state: {} } + } + + const mocks = { ...defaultMocks, ...overrides } + mockUseParams.mockReturnValue(mocks.useParams) + mockUseLocation.mockReturnValue(mocks.useLocation) + } + + beforeEach(() => { + vi.resetAllMocks() + setupMocks() + + // Reapply mocks to ensure they are correctly initialized + vi.mock('@/hooks/useAllocationAgreement', () => ({ + useAllocationAgreementOptions: vi.fn(() => ({ + data: { + allocationTransactionTypes: [ + { + allocationTransactionTypeId: 1, + type: "Purchased" + }, + { + allocationTransactionTypeId: 2, + type: "Sold" + } + ], + fuelTypes: [ + { + fuelTypeId: 1, + fuelType: "Biodiesel", + defaultCarbonIntensity: 100.21, + units: "L", + unrecognized: false, + fuelCategories: [ + { + fuelCategoryId: 2, + category: "Diesel", + defaultAndPrescribedCi: 100.21 + } + ], + fuelCodes: [ + { + fuelCodeId: 2, + fuelCode: "BCLCF124.4", + carbonIntensity: 3.62 + } + ], + provisionOfTheAct: [ + { + provisionOfTheActId: 2, + name: "Fuel code - section 19 (b) (i)" + }, + { + provisionOfTheActId: 3, + name: "Default carbon intensity - section 19 (b) (ii)" + } + ] + } + ], + provisionsOfTheAct: [ + { + provisionOfTheActId: 3, + name: "Default carbon intensity - section 19 (b) (ii)" + } + ], + fuelCodes: [ + { + fuelCodeId: 1, + fuelCode: "BCLCF102.5", + carbonIntensity: 37.21 + } + ], + unitsOfMeasure: [ + "L" + ] + }, + isLoading: false, + isFetched: true + })), + useGetAllocationAgreements: vi.fn(() => ({ + data: { allocationAgreements: [], pagination: {} }, + isLoading: false + })), + useSaveAllocationAgreement: vi.fn(() => ({ + mutateAsync: vi.fn() + })) + })) + }) + + it('renders the component', async () => { + render(, { wrapper }) + await waitFor(() => { + expect( + screen.getByText(/Enter allocation agreement details below/i) + ).toBeInTheDocument() + }) + }) + + it('should show error for 0 quantity', () => { + render(); + const quantityInput = screen.getByLabelText('Quantity'); + fireEvent.change(quantityInput, { target: { value: '0' } }); + fireEvent.blur(quantityInput); + expect(screen.getByText('Quantity must be greater than 0.')).toBeInTheDocument(); + }); + + it('should show error for empty quantity', () => { + render(); + const quantityInput = screen.getByLabelText('Quantity'); + fireEvent.change(quantityInput, { target: { value: '' } }); + fireEvent.blur(quantityInput); + expect(screen.getByText('Quantity must be greater than 0.')).toBeInTheDocument(); + }); + + it('should not show error for valid quantity', () => { + render(); + const quantityInput = screen.getByLabelText('Quantity'); + fireEvent.change(quantityInput, { target: { value: '10' } }); + fireEvent.blur(quantityInput); + expect(screen.queryByText('Quantity must be greater than 0.')).not.toBeInTheDocument(); + }); +}) diff --git a/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx b/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx index 385c70f68..f78eb2ab3 100644 --- a/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx +++ b/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx @@ -58,13 +58,13 @@ export const AddEditFuelSupplies = () => { ) useEffect(() => { - if (location.state?.message) { + if (location?.state?.message) { alertRef.current?.triggerAlert({ message: location.state.message, severity: location.state.severity || 'info' }) } - }, [location.state]) + }, [location?.state?.message, location?.state?.severity]); const validateField = (params, field, validationFn, errorMessage, alertRef) => { const newValue = params.newValue; diff --git a/frontend/src/views/FuelSupplies/__tests__/FuelSupplies.test.jsx b/frontend/src/views/FuelSupplies/__tests__/FuelSupplies.test.jsx new file mode 100644 index 000000000..cc051c66f --- /dev/null +++ b/frontend/src/views/FuelSupplies/__tests__/FuelSupplies.test.jsx @@ -0,0 +1,232 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { vi } from 'vitest' +import { AddEditFuelSupplies } from '../AddEditFuelSupplies' +import * as useFuelSupplyHooks from '@/hooks/useFuelSupply' +import { wrapper } from '@/tests/utils/wrapper' + +vi.mock('@react-keycloak/web', () => ({ + ReactKeycloakProvider: ({ children }) => children, + useKeycloak: () => ({ + keycloak: { + authenticated: true, + login: vi.fn(), + logout: vi.fn(), + register: vi.fn() + }, + initialized: true + }) +})) + +// Mock react-router-dom +const mockUseParams = vi.fn() +const mockUseLocation = vi.fn(() => ({ + state: { message: 'Test message', severity: 'info' } +})) +const mockUseNavigate = vi.fn() + +vi.mock('react-router-dom', () => ({ + ...vi.importActual('react-router-dom'), + useParams: () => ({ + complianceReportId: '123', + compliancePeriod: '2023' + }), + useLocation: () => mockUseLocation(), + useNavigate: () => mockUseNavigate +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => key + }) +})) + +// Mock hooks +vi.mock('@/hooks/useFuelSupply', () => ({ + useFuelSupplyOptions: vi.fn(() => ({ + data: { fuelTypes: [ + { + fuelTypeId: 2, + fuelType: 'CNG', + fossilDerived: false, + defaultCarbonIntensity: 63.91, + units: 'm³', + unrecognized: false, + }, + { + fuelTypeId: 3, + fuelType: 'Electric', + defaultCarbonIntensity: 12.14, + units: 'kWh', + unrecognized: false, + }, + ] }, + isLoading: false, + isFetched: true, + })), + useGetFuelSupplies: { + data: { fuelSupplies: [ + { + fuelSupplyId: 1, + complianceReportId: 2, + groupUuid: "fc44368c-ca60-4654-8f3d-32b55aa16245", + version: 0, + userType: "SUPPLIER", + actionType: "CREATE", + fuelTypeId: 3, + fuelType: { + fuelTypeId: 3, + fuelType: "Electricity", + fossilDerived: false, + defaultCarbonIntensity: 12.14, + units: "kWh" + }, + fuelCategoryId: 1, + fuelCategory: { + fuelCategoryId: 1, + category: "Gasoline" + }, + endUseId: 1, + endUseType: { + endUseTypeId: 1, + type: "Light duty motor vehicles" + }, + provisionOfTheActId: 3, + provisionOfTheAct: { + provisionOfTheActId: 3, + name: "Default carbon intensity - section 19 (b) (ii)" + }, + quantity: 1000000, + units: "kWh" + }, + { + fuelSupplyId: 2, + complianceReportId: 2, + groupUuid: "0f571126-43ae-43e7-b04b-705a22a2cbaf", + version: 0, + userType: "SUPPLIER", + actionType: "CREATE", + fuelTypeId: 3, + fuelType: { + fuelTypeId: 3, + fuelType: "Electricity", + fossilDerived: false, + defaultCarbonIntensity: 12.14, + units: "kWh" + }, + fuelCategoryId: 1, + fuelCategory: { + fuelCategoryId: 1, + category: "Gasoline" + }, + endUseId: 2, + endUseType: { + endUseTypeId: 2, + type: "Other or unknown" + }, + provisionOfTheActId: 3, + provisionOfTheAct: { + provisionOfTheActId: 3, + name: "Default carbon intensity - section 19 (b) (ii)" + }, + quantity: 100000, + units: "kWh" + } + ] + , + pagination: { + page: 1, + size: 10, + total: 2, + totalPages: 1, + }, }, + isLoading: false + }, + useSaveFuelSupply: vi.fn(() => ({ + mutateAsync: vi.fn(), + })), +})); + +describe('AddEditFuelSupplies', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('renders the component with no initial data', async () => { + render(, { wrapper }) + + await waitFor(() => { + expect( + screen.getByText(/Add new supply of fuel/i) + ).toBeInTheDocument() + }) + }) + + it('should show error for 0 quantity', async () => { + render(, { wrapper }) + + const quantityInput = screen.getByLabelText(/quantity/i) + fireEvent.change(quantityInput, { target: { value: '0' } }) + fireEvent.blur(quantityInput) + + await waitFor(() => { + expect( + screen.getByText(/quantity supplied must be greater than 0./i) + ).toBeInTheDocument() + }) + }) + + it('should show error for empty quantity', async () => { + render(, { wrapper }) + + const quantityInput = screen.getByLabelText(/quantity/i) + fireEvent.change(quantityInput, { target: { value: '' } }) + fireEvent.blur(quantityInput) + + await waitFor(() => { + expect( + screen.getByText(/quantity supplied must be greater than 0./i) + ).toBeInTheDocument() + }) + }) + + it('should not show error for valid quantity', async () => { + render(, { wrapper }) + + const quantityInput = screen.getByLabelText(/quantity/i) + fireEvent.change(quantityInput, { target: { value: '10' } }) + fireEvent.blur(quantityInput) + + await waitFor(() => { + expect( + screen.queryByText(/quantity supplied must be greater than 0./i) + ).not.toBeInTheDocument() + }) + }) + + it('displays an error message when row update fails', async () => { + const mockMutateAsync = vi.fn().mockRejectedValueOnce({ + response: { + data: { + errors: [{ fields: ['quantity'], message: 'Invalid quantity' }] + } + } + }) + + vi.mocked(useFuelSupplyHooks.useSaveFuelSupply).mockReturnValueOnce({ + mutateAsync: mockMutateAsync + }) + + render(, { wrapper }) + + const quantityInput = screen.getByLabelText(/quantity/i) + fireEvent.change(quantityInput, { target: { value: '-5' } }) + fireEvent.blur(quantityInput) + + await waitFor(() => { + expect( + screen.getByText(/error updating row: invalid quantity/i) + ).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx b/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx index 541865995..ad5aaee2b 100644 --- a/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx +++ b/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx @@ -39,13 +39,13 @@ export const AddEditNotionalTransfers = () => { const navigate = useNavigate() useEffect(() => { - if (location.state?.message) { + if (location?.state?.message) { alertRef.triggerAlert({ message: location.state.message, severity: location.state.severity || 'info' }) } - }, [location.state]) + }, [location?.state?.message, location?.state?.severity]); const validateField = (params, field, validationFn, errorMessage, alertRef) => { const newValue = params.newValue; diff --git a/frontend/src/views/NotionalTransfers/__tests__/AddEditNotionalTransfer.test.jsx b/frontend/src/views/NotionalTransfers/__tests__/AddEditNotionalTransfer.test.jsx new file mode 100644 index 000000000..18145bca7 --- /dev/null +++ b/frontend/src/views/NotionalTransfers/__tests__/AddEditNotionalTransfer.test.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; +import { AddEditNotionalTransfers } from '../AddEditNotionalTransfers'; +import * as useNotionalTransfer from '@/hooks/useNotionalTransfer'; +import { wrapper } from '@/tests/utils/wrapper'; + +vi.mock('@react-keycloak/web', () => ({ + ReactKeycloakProvider: ({ children }) => children, + useKeycloak: () => ({ + keycloak: { + authenticated: true, + login: vi.fn(), + logout: vi.fn(), + register: vi.fn(), + }, + initialized: true, + }), +})); + +// Mock react-router-dom +const mockUseParams = vi.fn(); +const mockUseLocation = vi.fn(() => ({ + state: { message: 'Test message', severity: 'info' }, +})); +const mockUseNavigate = vi.fn(); + +vi.mock('react-router-dom', () => ({ + ...vi.importActual('react-router-dom'), + useParams: () => ({ + complianceReportId: '123', + compliancePeriod: '2023', + }), + useLocation: () => mockUseLocation(), + useNavigate: () => mockUseNavigate, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => key, + }), +})); + +vi.mock('@/hooks/useNotionalTransfer', () => ({ + useNotionalTransferOptions: vi.fn(() => ({ + data: { + fuelCategories: [ + { + fuelCategoryId: 1, + category: "Gasoline", + description: "Gasoline" + }, + { + fuelCategoryId: 2, + category: "Diesel", + description: "Diesel" + }, + { + fuelCategoryId: 3, + category: "Jet fuel", + description: "Jet fuel" + } + ], + receivedOrTransferred: [ + "Received", + "Transferred" + ] + }, + isLoading: false, + isFetched: true, + })), + useGetAllNotionalTransfers: vi.fn(() => ({ + data: { + notionalTransfers: [] + }, + isLoading: false, + })), + useSaveNotionalTransfer: vi.fn(() => ({ + mutateAsync: vi.fn(), // Properly mock mutateAsync + })), + })); + +describe('AddEditNotionalTransfers', () => { + beforeEach(() => { + vi.resetAllMocks(); + + vi.spyOn(useNotionalTransfer, 'useSaveNotionalTransfer').mockReturnValue({ + mutateAsync: vi.fn(), // Ensure mutateAsync is mocked + }); +}); + it('renders the component successfully', async () => { + render(, { wrapper }); + + await waitFor(() => { + expect( + screen.getByText(/Add new notional transfer(s)/i) + ).toBeInTheDocument(); + }); + }); + + it('shows an error for 0 quantity', async () => { + render(, { wrapper }); + + const quantityInput = screen.getByLabelText(/quantity/i); + fireEvent.change(quantityInput, { target: { value: '0' } }); + fireEvent.blur(quantityInput); + + await waitFor(() => { + expect(screen.getByText(/quantity must be greater than 0./i)).toBeInTheDocument(); + }); + }); + + it('shows an error for empty quantity', async () => { + render(, { wrapper }); + + const quantityInput = screen.getByLabelText(/quantity/i); + fireEvent.change(quantityInput, { target: { value: '' } }); + fireEvent.blur(quantityInput); + + await waitFor(() => { + expect(screen.getByText(/quantity must be greater than 0./i)).toBeInTheDocument(); + }); + }); + + it('does not show an error for a valid quantity', async () => { + render(, { wrapper }); + + const quantityInput = screen.getByLabelText(/quantity/i); + fireEvent.change(quantityInput, { target: { value: '10' } }); + fireEvent.blur(quantityInput); + + await waitFor(() => { + expect(screen.queryByText(/quantity must be greater than 0./i)).not.toBeInTheDocument(); + }); + }); +}); From 5ff19a3832219ba3f81adf5e466371352ce6c6ef Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Wed, 11 Dec 2024 11:33:41 -0700 Subject: [PATCH 38/55] removing vitests --- .../__tests__/AllocationAgreements.test.jsx | 189 -------------- .../__tests__/FuelSupplies.test.jsx | 232 ------------------ .../AddEditNotionalTransfer.test.jsx | 136 ---------- 3 files changed, 557 deletions(-) delete mode 100644 frontend/src/views/AllocationAgreements/__tests__/AllocationAgreements.test.jsx delete mode 100644 frontend/src/views/FuelSupplies/__tests__/FuelSupplies.test.jsx delete mode 100644 frontend/src/views/NotionalTransfers/__tests__/AddEditNotionalTransfer.test.jsx diff --git a/frontend/src/views/AllocationAgreements/__tests__/AllocationAgreements.test.jsx b/frontend/src/views/AllocationAgreements/__tests__/AllocationAgreements.test.jsx deleted file mode 100644 index eb53f33cb..000000000 --- a/frontend/src/views/AllocationAgreements/__tests__/AllocationAgreements.test.jsx +++ /dev/null @@ -1,189 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { vi } from 'vitest' -import { AddEditAllocationAgreements } from '../AddAllocationAgreements' -import * as useGetAllocationAgreements from '@/hooks/useAllocationAgreement' -import * as useAllocationAgreementOptions from '@/hooks/useAllocationAgreement' -import * as useSaveAllocationAgreement from '@/hooks/useAllocationAgreement' -import { wrapper } from '@/tests/utils/wrapper' - -vi.mock('@react-keycloak/web', () => ({ - ReactKeycloakProvider: ({ children }) => children, - useKeycloak: () => ({ - keycloak: { - authenticated: true, - login: vi.fn(), - logout: vi.fn(), - register: vi.fn() - }, - initialized: true - }) -})) - -// Mock useApiService -vi.mock('@/services/useApiService', () => ({ - default: vi.fn(() => ({ - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - delete: vi.fn() - })), - useApiService: vi.fn(() => ({ - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - delete: vi.fn() - })) -})) - -// Mock react-router-dom -const mockUseParams = vi.fn() -const mockUseLocation = vi.fn(() => ({ - state: { message: 'Test message', severity: 'info' } -})) -const mockUseNavigate = vi.fn() -const mockHasRoles = vi.fn() - -vi.mock('react-router-dom', () => ({ - ...vi.importActual('react-router-dom'), - useParams: () => ({ - complianceReportId: '123', - compliancePeriod: '2023' - }), - useLocation: () => mockUseLocation, - useNavigate: () => mockUseNavigate -})) - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key) => key - }) -})) - - -describe('AddEditAllocationAgreement', () => { - const setupMocks = (overrides = {}) => { - const defaultMocks = { - useParams: { compliancePeriod: '2023', complianceReportId: '123' }, - useLocation: { state: {} } - } - - const mocks = { ...defaultMocks, ...overrides } - mockUseParams.mockReturnValue(mocks.useParams) - mockUseLocation.mockReturnValue(mocks.useLocation) - } - - beforeEach(() => { - vi.resetAllMocks() - setupMocks() - - // Reapply mocks to ensure they are correctly initialized - vi.mock('@/hooks/useAllocationAgreement', () => ({ - useAllocationAgreementOptions: vi.fn(() => ({ - data: { - allocationTransactionTypes: [ - { - allocationTransactionTypeId: 1, - type: "Purchased" - }, - { - allocationTransactionTypeId: 2, - type: "Sold" - } - ], - fuelTypes: [ - { - fuelTypeId: 1, - fuelType: "Biodiesel", - defaultCarbonIntensity: 100.21, - units: "L", - unrecognized: false, - fuelCategories: [ - { - fuelCategoryId: 2, - category: "Diesel", - defaultAndPrescribedCi: 100.21 - } - ], - fuelCodes: [ - { - fuelCodeId: 2, - fuelCode: "BCLCF124.4", - carbonIntensity: 3.62 - } - ], - provisionOfTheAct: [ - { - provisionOfTheActId: 2, - name: "Fuel code - section 19 (b) (i)" - }, - { - provisionOfTheActId: 3, - name: "Default carbon intensity - section 19 (b) (ii)" - } - ] - } - ], - provisionsOfTheAct: [ - { - provisionOfTheActId: 3, - name: "Default carbon intensity - section 19 (b) (ii)" - } - ], - fuelCodes: [ - { - fuelCodeId: 1, - fuelCode: "BCLCF102.5", - carbonIntensity: 37.21 - } - ], - unitsOfMeasure: [ - "L" - ] - }, - isLoading: false, - isFetched: true - })), - useGetAllocationAgreements: vi.fn(() => ({ - data: { allocationAgreements: [], pagination: {} }, - isLoading: false - })), - useSaveAllocationAgreement: vi.fn(() => ({ - mutateAsync: vi.fn() - })) - })) - }) - - it('renders the component', async () => { - render(, { wrapper }) - await waitFor(() => { - expect( - screen.getByText(/Enter allocation agreement details below/i) - ).toBeInTheDocument() - }) - }) - - it('should show error for 0 quantity', () => { - render(); - const quantityInput = screen.getByLabelText('Quantity'); - fireEvent.change(quantityInput, { target: { value: '0' } }); - fireEvent.blur(quantityInput); - expect(screen.getByText('Quantity must be greater than 0.')).toBeInTheDocument(); - }); - - it('should show error for empty quantity', () => { - render(); - const quantityInput = screen.getByLabelText('Quantity'); - fireEvent.change(quantityInput, { target: { value: '' } }); - fireEvent.blur(quantityInput); - expect(screen.getByText('Quantity must be greater than 0.')).toBeInTheDocument(); - }); - - it('should not show error for valid quantity', () => { - render(); - const quantityInput = screen.getByLabelText('Quantity'); - fireEvent.change(quantityInput, { target: { value: '10' } }); - fireEvent.blur(quantityInput); - expect(screen.queryByText('Quantity must be greater than 0.')).not.toBeInTheDocument(); - }); -}) diff --git a/frontend/src/views/FuelSupplies/__tests__/FuelSupplies.test.jsx b/frontend/src/views/FuelSupplies/__tests__/FuelSupplies.test.jsx deleted file mode 100644 index cc051c66f..000000000 --- a/frontend/src/views/FuelSupplies/__tests__/FuelSupplies.test.jsx +++ /dev/null @@ -1,232 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { vi } from 'vitest' -import { AddEditFuelSupplies } from '../AddEditFuelSupplies' -import * as useFuelSupplyHooks from '@/hooks/useFuelSupply' -import { wrapper } from '@/tests/utils/wrapper' - -vi.mock('@react-keycloak/web', () => ({ - ReactKeycloakProvider: ({ children }) => children, - useKeycloak: () => ({ - keycloak: { - authenticated: true, - login: vi.fn(), - logout: vi.fn(), - register: vi.fn() - }, - initialized: true - }) -})) - -// Mock react-router-dom -const mockUseParams = vi.fn() -const mockUseLocation = vi.fn(() => ({ - state: { message: 'Test message', severity: 'info' } -})) -const mockUseNavigate = vi.fn() - -vi.mock('react-router-dom', () => ({ - ...vi.importActual('react-router-dom'), - useParams: () => ({ - complianceReportId: '123', - compliancePeriod: '2023' - }), - useLocation: () => mockUseLocation(), - useNavigate: () => mockUseNavigate -})) - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key) => key - }) -})) - -// Mock hooks -vi.mock('@/hooks/useFuelSupply', () => ({ - useFuelSupplyOptions: vi.fn(() => ({ - data: { fuelTypes: [ - { - fuelTypeId: 2, - fuelType: 'CNG', - fossilDerived: false, - defaultCarbonIntensity: 63.91, - units: 'm³', - unrecognized: false, - }, - { - fuelTypeId: 3, - fuelType: 'Electric', - defaultCarbonIntensity: 12.14, - units: 'kWh', - unrecognized: false, - }, - ] }, - isLoading: false, - isFetched: true, - })), - useGetFuelSupplies: { - data: { fuelSupplies: [ - { - fuelSupplyId: 1, - complianceReportId: 2, - groupUuid: "fc44368c-ca60-4654-8f3d-32b55aa16245", - version: 0, - userType: "SUPPLIER", - actionType: "CREATE", - fuelTypeId: 3, - fuelType: { - fuelTypeId: 3, - fuelType: "Electricity", - fossilDerived: false, - defaultCarbonIntensity: 12.14, - units: "kWh" - }, - fuelCategoryId: 1, - fuelCategory: { - fuelCategoryId: 1, - category: "Gasoline" - }, - endUseId: 1, - endUseType: { - endUseTypeId: 1, - type: "Light duty motor vehicles" - }, - provisionOfTheActId: 3, - provisionOfTheAct: { - provisionOfTheActId: 3, - name: "Default carbon intensity - section 19 (b) (ii)" - }, - quantity: 1000000, - units: "kWh" - }, - { - fuelSupplyId: 2, - complianceReportId: 2, - groupUuid: "0f571126-43ae-43e7-b04b-705a22a2cbaf", - version: 0, - userType: "SUPPLIER", - actionType: "CREATE", - fuelTypeId: 3, - fuelType: { - fuelTypeId: 3, - fuelType: "Electricity", - fossilDerived: false, - defaultCarbonIntensity: 12.14, - units: "kWh" - }, - fuelCategoryId: 1, - fuelCategory: { - fuelCategoryId: 1, - category: "Gasoline" - }, - endUseId: 2, - endUseType: { - endUseTypeId: 2, - type: "Other or unknown" - }, - provisionOfTheActId: 3, - provisionOfTheAct: { - provisionOfTheActId: 3, - name: "Default carbon intensity - section 19 (b) (ii)" - }, - quantity: 100000, - units: "kWh" - } - ] - , - pagination: { - page: 1, - size: 10, - total: 2, - totalPages: 1, - }, }, - isLoading: false - }, - useSaveFuelSupply: vi.fn(() => ({ - mutateAsync: vi.fn(), - })), -})); - -describe('AddEditFuelSupplies', () => { - beforeEach(() => { - vi.resetAllMocks() - }) - - it('renders the component with no initial data', async () => { - render(, { wrapper }) - - await waitFor(() => { - expect( - screen.getByText(/Add new supply of fuel/i) - ).toBeInTheDocument() - }) - }) - - it('should show error for 0 quantity', async () => { - render(, { wrapper }) - - const quantityInput = screen.getByLabelText(/quantity/i) - fireEvent.change(quantityInput, { target: { value: '0' } }) - fireEvent.blur(quantityInput) - - await waitFor(() => { - expect( - screen.getByText(/quantity supplied must be greater than 0./i) - ).toBeInTheDocument() - }) - }) - - it('should show error for empty quantity', async () => { - render(, { wrapper }) - - const quantityInput = screen.getByLabelText(/quantity/i) - fireEvent.change(quantityInput, { target: { value: '' } }) - fireEvent.blur(quantityInput) - - await waitFor(() => { - expect( - screen.getByText(/quantity supplied must be greater than 0./i) - ).toBeInTheDocument() - }) - }) - - it('should not show error for valid quantity', async () => { - render(, { wrapper }) - - const quantityInput = screen.getByLabelText(/quantity/i) - fireEvent.change(quantityInput, { target: { value: '10' } }) - fireEvent.blur(quantityInput) - - await waitFor(() => { - expect( - screen.queryByText(/quantity supplied must be greater than 0./i) - ).not.toBeInTheDocument() - }) - }) - - it('displays an error message when row update fails', async () => { - const mockMutateAsync = vi.fn().mockRejectedValueOnce({ - response: { - data: { - errors: [{ fields: ['quantity'], message: 'Invalid quantity' }] - } - } - }) - - vi.mocked(useFuelSupplyHooks.useSaveFuelSupply).mockReturnValueOnce({ - mutateAsync: mockMutateAsync - }) - - render(, { wrapper }) - - const quantityInput = screen.getByLabelText(/quantity/i) - fireEvent.change(quantityInput, { target: { value: '-5' } }) - fireEvent.blur(quantityInput) - - await waitFor(() => { - expect( - screen.getByText(/error updating row: invalid quantity/i) - ).toBeInTheDocument() - }) - }) -}) diff --git a/frontend/src/views/NotionalTransfers/__tests__/AddEditNotionalTransfer.test.jsx b/frontend/src/views/NotionalTransfers/__tests__/AddEditNotionalTransfer.test.jsx deleted file mode 100644 index 18145bca7..000000000 --- a/frontend/src/views/NotionalTransfers/__tests__/AddEditNotionalTransfer.test.jsx +++ /dev/null @@ -1,136 +0,0 @@ -import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { vi } from 'vitest'; -import { AddEditNotionalTransfers } from '../AddEditNotionalTransfers'; -import * as useNotionalTransfer from '@/hooks/useNotionalTransfer'; -import { wrapper } from '@/tests/utils/wrapper'; - -vi.mock('@react-keycloak/web', () => ({ - ReactKeycloakProvider: ({ children }) => children, - useKeycloak: () => ({ - keycloak: { - authenticated: true, - login: vi.fn(), - logout: vi.fn(), - register: vi.fn(), - }, - initialized: true, - }), -})); - -// Mock react-router-dom -const mockUseParams = vi.fn(); -const mockUseLocation = vi.fn(() => ({ - state: { message: 'Test message', severity: 'info' }, -})); -const mockUseNavigate = vi.fn(); - -vi.mock('react-router-dom', () => ({ - ...vi.importActual('react-router-dom'), - useParams: () => ({ - complianceReportId: '123', - compliancePeriod: '2023', - }), - useLocation: () => mockUseLocation(), - useNavigate: () => mockUseNavigate, -})); - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key) => key, - }), -})); - -vi.mock('@/hooks/useNotionalTransfer', () => ({ - useNotionalTransferOptions: vi.fn(() => ({ - data: { - fuelCategories: [ - { - fuelCategoryId: 1, - category: "Gasoline", - description: "Gasoline" - }, - { - fuelCategoryId: 2, - category: "Diesel", - description: "Diesel" - }, - { - fuelCategoryId: 3, - category: "Jet fuel", - description: "Jet fuel" - } - ], - receivedOrTransferred: [ - "Received", - "Transferred" - ] - }, - isLoading: false, - isFetched: true, - })), - useGetAllNotionalTransfers: vi.fn(() => ({ - data: { - notionalTransfers: [] - }, - isLoading: false, - })), - useSaveNotionalTransfer: vi.fn(() => ({ - mutateAsync: vi.fn(), // Properly mock mutateAsync - })), - })); - -describe('AddEditNotionalTransfers', () => { - beforeEach(() => { - vi.resetAllMocks(); - - vi.spyOn(useNotionalTransfer, 'useSaveNotionalTransfer').mockReturnValue({ - mutateAsync: vi.fn(), // Ensure mutateAsync is mocked - }); -}); - it('renders the component successfully', async () => { - render(, { wrapper }); - - await waitFor(() => { - expect( - screen.getByText(/Add new notional transfer(s)/i) - ).toBeInTheDocument(); - }); - }); - - it('shows an error for 0 quantity', async () => { - render(, { wrapper }); - - const quantityInput = screen.getByLabelText(/quantity/i); - fireEvent.change(quantityInput, { target: { value: '0' } }); - fireEvent.blur(quantityInput); - - await waitFor(() => { - expect(screen.getByText(/quantity must be greater than 0./i)).toBeInTheDocument(); - }); - }); - - it('shows an error for empty quantity', async () => { - render(, { wrapper }); - - const quantityInput = screen.getByLabelText(/quantity/i); - fireEvent.change(quantityInput, { target: { value: '' } }); - fireEvent.blur(quantityInput); - - await waitFor(() => { - expect(screen.getByText(/quantity must be greater than 0./i)).toBeInTheDocument(); - }); - }); - - it('does not show an error for a valid quantity', async () => { - render(, { wrapper }); - - const quantityInput = screen.getByLabelText(/quantity/i); - fireEvent.change(quantityInput, { target: { value: '10' } }); - fireEvent.blur(quantityInput); - - await waitFor(() => { - expect(screen.queryByText(/quantity must be greater than 0./i)).not.toBeInTheDocument(); - }); - }); -}); From a14d1ec8da25bc5292a40ee2abdcdc8cf33d3e43 Mon Sep 17 00:00:00 2001 From: Arturo Reyes Lopez Date: Fri, 13 Dec 2024 14:18:37 -0700 Subject: [PATCH 39/55] Add validation in quantity for different schedules. --- backend/lcfs/web/api/fuel_supply/schema.py | 4 +- .../lcfs/web/api/notional_transfer/schema.py | 4 +- backend/lcfs/web/api/other_uses/schema.py | 4 +- backend/lcfs/web/api/transaction/schema.py | 4 +- backend/lcfs/web/api/transfer/schema.py | 4 +- .../AddEditAllocationAgreements.jsx | 53 ++++++++++--------- .../FuelSupplies/AddEditFuelSupplies.jsx | 39 ++++++++------ frontend/src/views/FuelSupplies/_schema.jsx | 2 - .../AddEditNotionalTransfers.jsx | 39 ++++++++------ .../src/views/OtherUses/AddEditOtherUses.jsx | 38 +++++++------ 10 files changed, 98 insertions(+), 93 deletions(-) diff --git a/backend/lcfs/web/api/fuel_supply/schema.py b/backend/lcfs/web/api/fuel_supply/schema.py index 68300b1e0..60592dffe 100644 --- a/backend/lcfs/web/api/fuel_supply/schema.py +++ b/backend/lcfs/web/api/fuel_supply/schema.py @@ -119,9 +119,7 @@ class FuelSupplyCreateUpdateSchema(BaseSchema): fuel_category_id: int end_use_id: Optional[int] = None provision_of_the_act_id: int - quantity: int = Field( - ..., gt=0, description="Quantity supplied must be greater than 0" - ) + quantity: int units: str fuel_type_other: Optional[str] = None fuel_code_id: Optional[int] = None diff --git a/backend/lcfs/web/api/notional_transfer/schema.py b/backend/lcfs/web/api/notional_transfer/schema.py index 6ca7085ea..5f6571e57 100644 --- a/backend/lcfs/web/api/notional_transfer/schema.py +++ b/backend/lcfs/web/api/notional_transfer/schema.py @@ -20,9 +20,7 @@ class NotionalTransferCreateSchema(BaseSchema): address_for_service: str fuel_category: str received_or_transferred: ReceivedOrTransferredEnumSchema - quantity: int = Field( - ..., gt=0, description="Quantity supplied must be greater than 0" - ) + quantity: int notional_transfer_id: Optional[int] = None compliance_report_id: int deleted: Optional[bool] = None diff --git a/backend/lcfs/web/api/other_uses/schema.py b/backend/lcfs/web/api/other_uses/schema.py index 03612276a..db3e591be 100644 --- a/backend/lcfs/web/api/other_uses/schema.py +++ b/backend/lcfs/web/api/other_uses/schema.py @@ -90,9 +90,7 @@ class OtherUsesCreateSchema(BaseSchema): fuel_type: str fuel_category: str provision_of_the_act: str - quantity_supplied: int = Field( - ..., gt=0, description="Quantity supplied must be greater than 0" - ) + quantity_supplied: int units: str expected_use: str fuel_code: Optional[str] = None diff --git a/backend/lcfs/web/api/transaction/schema.py b/backend/lcfs/web/api/transaction/schema.py index 8bd05856d..ad0d8411e 100644 --- a/backend/lcfs/web/api/transaction/schema.py +++ b/backend/lcfs/web/api/transaction/schema.py @@ -71,9 +71,7 @@ class TransactionViewSchema(BaseSchema): transaction_type: str from_organization: Optional[str] = None to_organization: str - quantity: int = Field( - ..., gt=0, description="Quantity supplied must be greater than 0" - ) + quantity: int price_per_unit: Optional[float] = None status: str create_date: datetime diff --git a/backend/lcfs/web/api/transfer/schema.py b/backend/lcfs/web/api/transfer/schema.py index 4d8826c10..889437c8a 100644 --- a/backend/lcfs/web/api/transfer/schema.py +++ b/backend/lcfs/web/api/transfer/schema.py @@ -48,9 +48,7 @@ class TransferSchema(BaseSchema): from_organization: TransferOrganizationSchema to_organization: TransferOrganizationSchema agreement_date: date - quantity: int = Field( - ..., gt=0, description="Quantity supplied must be greater than 0" - ) + quantity: int price_per_unit: float comments: Optional[List[TransferCommentSchema]] = None from_org_comment: Optional[str] = None diff --git a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx index 843d41a7c..6a79fd45e 100644 --- a/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx +++ b/frontend/src/views/AllocationAgreements/AddEditAllocationAgreements.jsx @@ -67,27 +67,22 @@ export const AddEditAllocationAgreements = () => { } }, [location.state?.message, location.state?.severity]) - const validateField = ( - params, - field, - validationFn, - errorMessage, - alertRef - ) => { - const newValue = params.newValue - - if (params.colDef.field === field) { - if (!validationFn(newValue)) { - alertRef.current?.triggerAlert({ - message: errorMessage, - severity: 'error' - }) - return false - } + const validate = (params, validationFn, errorMessage, alertRef, field = null) => { + const value = field ? params.node?.data[field] : params; + + if (field && params.colDef.field !== field) { + return true; } - return true // Proceed with the update - } + if (!validationFn(value)) { + alertRef.current?.triggerAlert({ + message: errorMessage, + severity: 'error', + }); + return false; + } + return true; // Proceed with the update + }; const onGridReady = useCallback( async (params) => { @@ -176,17 +171,23 @@ export const AddEditAllocationAgreements = () => { const onCellEditingStopped = useCallback( async (params) => { - const isValid = validateField( + if (params.oldValue === params.newValue) return + + const isValid = validate( params, + (value) => { + return value !== null && !isNaN(value) && value > 0; + }, + 'Quantity supplied must be greater than 0.', + alertRef, 'quantity', - (value) => value !== null && !isNaN(value) && value > 0, - 'Quantity must be greater than 0.', - alertRef - ) + ); - if (!isValid) return + if (!isValid) { + return + } - if (params.oldValue === params.newValue) return + if (!isValid) return params.node.updateData({ ...params.node.data, diff --git a/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx b/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx index f78eb2ab3..4badf3167 100644 --- a/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx +++ b/frontend/src/views/FuelSupplies/AddEditFuelSupplies.jsx @@ -66,19 +66,20 @@ export const AddEditFuelSupplies = () => { } }, [location?.state?.message, location?.state?.severity]); - const validateField = (params, field, validationFn, errorMessage, alertRef) => { - const newValue = params.newValue; + const validate = (params, validationFn, errorMessage, alertRef, field = null) => { + const value = field ? params.node?.data[field] : params; - if (params.colDef.field === field) { - if (!validationFn(newValue)) { - alertRef.current?.triggerAlert({ - message: errorMessage, - severity: 'error', - }); - return false; - } + if (field && params.colDef.field !== field) { + return true; } + if (!validationFn(value)) { + alertRef.current?.triggerAlert({ + message: errorMessage, + severity: 'error', + }); + return false; + } return true; // Proceed with the update }; @@ -166,17 +167,21 @@ export const AddEditFuelSupplies = () => { const onCellEditingStopped = useCallback( async (params) => { - const isValid = validateField( + if (params.oldValue === params.newValue) return + + const isValid = validate( params, - 'quantity', - (value) => value !== null && !isNaN(value) && value > 0, + (value) => { + return value !== null && !isNaN(value) && value > 0; + }, 'Quantity supplied must be greater than 0.', - alertRef + alertRef, + 'quantity', ); - if (!isValid) return; - - if (params.oldValue === params.newValue) return + if (!isValid) { + return + } params.node.updateData({ ...params.node.data, diff --git a/frontend/src/views/FuelSupplies/_schema.jsx b/frontend/src/views/FuelSupplies/_schema.jsx index 83848a97c..5939d074b 100644 --- a/frontend/src/views/FuelSupplies/_schema.jsx +++ b/frontend/src/views/FuelSupplies/_schema.jsx @@ -104,7 +104,6 @@ export const fuelSupplyColDefs = (optionsData, errors, warnings) => [ params.data.provisionOfTheAct = null params.data.fuelCode = null params.data.fuelCodeId = null - params.data.quantity = 0 params.data.units = fuelType?.unit params.data.unrecognized = fuelType?.unrecognized } @@ -178,7 +177,6 @@ export const fuelSupplyColDefs = (optionsData, errors, warnings) => [ params.data.eer = null params.data.provisionOfTheAct = null params.data.fuelCode = null - params.data.quantity = 0 } return true }, diff --git a/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx b/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx index ad5aaee2b..5d8073495 100644 --- a/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx +++ b/frontend/src/views/NotionalTransfers/AddEditNotionalTransfers.jsx @@ -47,19 +47,20 @@ export const AddEditNotionalTransfers = () => { } }, [location?.state?.message, location?.state?.severity]); - const validateField = (params, field, validationFn, errorMessage, alertRef) => { - const newValue = params.newValue; + const validate = (params, validationFn, errorMessage, alertRef, field = null) => { + const value = field ? params.node?.data[field] : params; - if (params.colDef.field === field) { - if (!validationFn(newValue)) { - alertRef.current?.triggerAlert({ - message: errorMessage, - severity: 'error', - }); - return false; - } + if (field && params.colDef.field !== field) { + return true; } + if (!validationFn(value)) { + alertRef.current?.triggerAlert({ + message: errorMessage, + severity: 'error', + }); + return false; + } return true; // Proceed with the update }; @@ -112,17 +113,21 @@ export const AddEditNotionalTransfers = () => { const onCellEditingStopped = useCallback( async (params) => { - const isValid = validateField( + if (params.oldValue === params.newValue) return + + const isValid = validate( params, + (value) => { + return value !== null && !isNaN(value) && value > 0; + }, + 'Quantity supplied must be greater than 0.', + alertRef, 'quantity', - (value) => value !== null && !isNaN(value) && value > 0, - 'Quantity must be greater than 0.', - alertRef ); - if (!isValid) return; - - if (params.oldValue === params.newValue) return + if (!isValid) { + return + } // Initialize updated data with 'pending' status params.node.updateData({ diff --git a/frontend/src/views/OtherUses/AddEditOtherUses.jsx b/frontend/src/views/OtherUses/AddEditOtherUses.jsx index f983bd120..d3fc18ec8 100644 --- a/frontend/src/views/OtherUses/AddEditOtherUses.jsx +++ b/frontend/src/views/OtherUses/AddEditOtherUses.jsx @@ -81,19 +81,20 @@ export const AddEditOtherUses = () => { return ciOfFuel }, []) - const validateField = (params, field, validationFn, errorMessage, alertRef) => { - const newValue = params.newValue; + const validate = (params, validationFn, errorMessage, alertRef, field = null) => { + const value = field ? params.node?.data[field] : params; - if (params.colDef.field === field) { - if (!validationFn(newValue)) { - alertRef.current?.triggerAlert({ - message: errorMessage, - severity: 'error', - }); - return false; - } + if (field && params.colDef.field !== field) { + return true; } + if (!validationFn(value)) { + alertRef.current?.triggerAlert({ + message: errorMessage, + severity: 'error', + }); + return false; + } return true; // Proceed with the update }; @@ -216,17 +217,22 @@ export const AddEditOtherUses = () => { const onCellEditingStopped = useCallback( async (params) => { - const isValid = validateField( + if (params.oldValue === params.newValue) return + + const isValid = validate( params, - 'quantitySupplied', - (value) => value !== null && !isNaN(value) && value > 0, + (value) => { + return value !== null && !isNaN(value) && value > 0; + }, 'Quantity supplied must be greater than 0.', - alertRef + alertRef, + 'quantitySupplied', ); - if (!isValid) return; + if (!isValid) { + return + } - if (params.oldValue === params.newValue) return params.data.complianceReportId = complianceReportId params.data.validationStatus = 'pending' From f403a4205a25478c865eb4182f6f7de589f1f6a8 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 11:54:29 -0800 Subject: [PATCH 40/55] updates --- .../versions/2024-12-13-12-44_62bc9695a764.py | 44 ------------ .../versions/2024-12-17-11-23_f93546eaec61.py | 33 +++++++++ .../notification/NotificationMessage.py | 3 +- .../api/compliance_report/update_service.py | 3 +- .../web/api/initiative_agreement/services.py | 3 +- backend/lcfs/web/api/notification/repo.py | 67 ++++++++++++++----- backend/lcfs/web/api/notification/schema.py | 3 +- backend/lcfs/web/api/transfer/services.py | 3 +- .../NotificationMenu/components/_schema.jsx | 13 +--- 9 files changed, 92 insertions(+), 80 deletions(-) delete mode 100644 backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py create mode 100644 backend/lcfs/db/migrations/versions/2024-12-17-11-23_f93546eaec61.py diff --git a/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py b/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py deleted file mode 100644 index 44019fc73..000000000 --- a/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Add type and transaction details to notification messages - -Revision ID: 62bc9695a764 -Revises: 7ae38a8413ab -Create Date: 2024-12-13 12:44:44.348419 - -""" - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "62bc9695a764" -down_revision = "5d729face5ab" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column("notification_message", sa.Column("type", sa.Text(), nullable=False)) - op.add_column( - "notification_message", sa.Column("transaction_id", sa.Integer(), nullable=True) - ) - op.create_foreign_key( - op.f("fk_notification_message_transaction_id_transaction"), - "notification_message", - "transaction", - ["transaction_id"], - ["transaction_id"], - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint( - op.f("fk_notification_message_transaction_id_transaction"), - "notification_message", - type_="foreignkey", - ) - op.drop_column("notification_message", "transaction_id") - op.drop_column("notification_message", "type") - # ### end Alembic commands ### diff --git a/backend/lcfs/db/migrations/versions/2024-12-17-11-23_f93546eaec61.py b/backend/lcfs/db/migrations/versions/2024-12-17-11-23_f93546eaec61.py new file mode 100644 index 000000000..4fbabc280 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-12-17-11-23_f93546eaec61.py @@ -0,0 +1,33 @@ +"""update notification message model + +Revision ID: f93546eaec61 +Revises: 5d729face5ab +Create Date: 2024-12-17 11:23:19.563138 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f93546eaec61" +down_revision = "5d729face5ab" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("notification_message", sa.Column("type", sa.Text(), nullable=False)) + op.add_column( + "notification_message", + sa.Column("related_transaction_id", sa.Text(), nullable=False), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("notification_message", "related_transaction_id") + op.drop_column("notification_message", "type") + # ### end Alembic commands ### diff --git a/backend/lcfs/db/models/notification/NotificationMessage.py b/backend/lcfs/db/models/notification/NotificationMessage.py index 2c3a5fd6d..fddc1a961 100644 --- a/backend/lcfs/db/models/notification/NotificationMessage.py +++ b/backend/lcfs/db/models/notification/NotificationMessage.py @@ -33,10 +33,9 @@ class NotificationMessage(BaseModel, Auditable): notification_type_id = Column( Integer, ForeignKey("notification_type.notification_type_id") ) - transaction_id = Column(Integer, ForeignKey("transaction.transaction_id"), nullable=True) + related_transaction_id = Column(Text, nullable=False) # Relationships - related_transaction = relationship("Transaction") related_organization = relationship( "Organization", back_populates="notification_messages" ) diff --git a/backend/lcfs/web/api/compliance_report/update_service.py b/backend/lcfs/web/api/compliance_report/update_service.py index 05b82b994..1a1d7d9c7 100644 --- a/backend/lcfs/web/api/compliance_report/update_service.py +++ b/backend/lcfs/web/api/compliance_report/update_service.py @@ -97,12 +97,13 @@ async def _perform_notification_call(self, report, status): message_data = { "service": "ComplianceReport", "id": report.compliance_report_id, + "transactionId": report.transaction_id, "compliancePeriod": report.compliance_period.description, "status": status.lower(), } notification_data = NotificationMessageSchema( type=f"Compliance report {status.lower()}", - transaction_id=report.transaction_id, + related_transaction_id=f"CR{report.compliance_report_id}", message=json.dumps(message_data), related_organization_id=report.organization_id, origin_user_profile_id=self.request.user.user_profile_id, diff --git a/backend/lcfs/web/api/initiative_agreement/services.py b/backend/lcfs/web/api/initiative_agreement/services.py index 387eceaad..c9cb3c4de 100644 --- a/backend/lcfs/web/api/initiative_agreement/services.py +++ b/backend/lcfs/web/api/initiative_agreement/services.py @@ -224,11 +224,12 @@ async def _perform_notification_call(self, ia, returned=False): message_data = { "service": "InitiativeAgreement", "id": ia.initiative_agreement_id, + "transactionId": ia.transaction_id, "status": status_val, } notification_data = NotificationMessageSchema( type=f"Initiative agreement {status_val}", - transaction_id=ia.transaction_id, + related_transaction_id=f"IA{ia.initiative_agreement_id}", message=json.dumps(message_data), related_organization_id=ia.to_organization_id, origin_user_profile_id=self.request.user.user_profile_id, diff --git a/backend/lcfs/web/api/notification/repo.py b/backend/lcfs/web/api/notification/repo.py index ebb5647a3..bd9d874fa 100644 --- a/backend/lcfs/web/api/notification/repo.py +++ b/backend/lcfs/web/api/notification/repo.py @@ -5,6 +5,7 @@ NotificationType, ChannelEnum, ) +from lcfs.db.models.organization import Organization from lcfs.db.models.user import UserProfile from lcfs.db.models.user.UserRole import UserRole from lcfs.web.api.base import ( @@ -21,7 +22,7 @@ from lcfs.db.dependencies import get_async_db_session from lcfs.web.exception.exceptions import DataNotFoundException -from sqlalchemy import delete, or_, select, func, update +from sqlalchemy import asc, delete, desc, or_, select, func, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload @@ -104,16 +105,38 @@ def _apply_notification_filters( # Handle date filters if filter.field == "date": filter_value = filter.date_from - field = get_field_for_filter(NotificationMessage, 'create_date') - elif filter.field == 'user': - field = get_field_for_filter(NotificationMessage, 'related_user_profile.first_name') + field = get_field_for_filter(NotificationMessage, "create_date") + conditions.append( + apply_filter_conditions( + field, filter_value, filter_option, filter_type + ) + ) + elif filter.field == "user": + conditions.append( + NotificationMessage.origin_user_profile.has( + UserProfile.first_name.like(f"%{filter_value}%") + ) + ) + elif filter.field == "organization": + conditions.append( + NotificationMessage.related_organization.has( + Organization.name.like(f"%{filter_value}%") + ) + ) + elif filter.field == "transaction_id": + field = get_field_for_filter(NotificationMessage, 'related_transaction_id') + conditions.append( + apply_filter_conditions( + field, filter_value, filter_option, filter_type + ) + ) else: field = get_field_for_filter(NotificationMessage, filter.field) - conditions.append( - apply_filter_conditions( - field, filter_value, filter_option, filter_type + conditions.append( + apply_filter_conditions( + field, filter_value, filter_option, filter_type + ) ) - ) return conditions @@ -151,17 +174,25 @@ async def get_paginated_notification_messages( ) # Apply sorting + order_clauses = [] if not pagination.sort_orders: - query = query.order_by(NotificationMessage.create_date.desc()) - # for order in pagination.sort_orders: - # direction = asc if order.direction == "asc" else desc - # if order.field == "status": - # field = getattr(FuelCodeStatus, "status") - # elif order.field == "prefix": - # field = getattr(FuelCodePrefix, "prefix") - # else: - # field = getattr(FuelCode, order.field) - # query = query.order_by(direction(field)) + order_clauses.append(desc(NotificationMessage.create_date)) + else: + for order in pagination.sort_orders: + direction = asc if order.direction == "asc" else desc + if order.field == "date": + field = NotificationMessage.create_date + elif order.field == "user": + field = UserProfile.first_name + elif order.field == "organization": + field = Organization.name + elif order.field == "transaction_id": + field = NotificationMessage.related_transaction_id + else: + field = getattr(NotificationMessage, order.field) + if field is not None: + order_clauses.append(direction(field)) + query = query.order_by(*order_clauses) # Execute the count query to get the total count count_query = query.with_only_columns(func.count()).order_by(None) diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index f64d8ba36..30ff2d5f2 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -46,7 +46,7 @@ class NotificationMessageSchema(BaseSchema): message: Optional[str] = None related_organization_id: Optional[int] = None related_organization: Optional[NotificationOrganizationSchema] = None - transaction_id: Optional[int] = None + related_transaction_id: Optional[str] = None create_date: Optional[datetime] = None origin_user_profile_id: Optional[int] = None origin_user_profile: Optional[NotificationUserProfileSchema] = None @@ -163,6 +163,7 @@ class NotificationRequestSchema(BaseSchema): ], InitiativeAgreementStatusEnum.Approved: [ NotificationTypeEnum.BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL, + NotificationTypeEnum.IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST ], "Return to analyst": [ NotificationTypeEnum.IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST diff --git a/backend/lcfs/web/api/transfer/services.py b/backend/lcfs/web/api/transfer/services.py index e0427618a..4b2aba0c9 100644 --- a/backend/lcfs/web/api/transfer/services.py +++ b/backend/lcfs/web/api/transfer/services.py @@ -305,6 +305,7 @@ async def _perform_notification_call( message_data = { "service": "Transfer", "id": transfer.transfer_id, + "transactionId": transfer.from_transaction.transaction_id if getattr(transfer, 'from_transaction', None) else None, "status": status_val, "fromOrganizationId": transfer.from_organization.organization_id, "fromOrganization": transfer.from_organization.name, @@ -319,7 +320,7 @@ async def _perform_notification_call( for org_id in organization_ids: notification_data = NotificationMessageSchema( type=type, - transaction_id=transfer.from_transaction.transaction_id if getattr(transfer, 'from_transaction', None) else None, + related_transaction_id=f"CT{transfer.transfer_id}", message=json.dumps(message_data), related_organization_id=org_id, origin_user_profile_id=self.request.user.user_profile_id, diff --git a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx index 5ea74c314..601fb29c6 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx @@ -31,18 +31,7 @@ export const columnDefs = (t, currentUser) => [ colId: 'transactionId', field: 'transactionId', headerName: t('notifications:notificationColLabels.transactionId'), - valueGetter: (params) => { - const { service, id } = JSON.parse(params.data.message) - if (service === 'Transfer') { - return `CT${id}` - } else if (service === 'InitiativeAgreement') { - return `IA${id}` - } else if (service === 'ComplianceReport') { - return `CR${id}` - } else { - return id - } - } + valueGetter: (params) => params.data.relatedTransactionId }, { colId: 'organization', From a12ebacefa6da1df25590ce1f6c95b44221e73f3 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 12:36:31 -0800 Subject: [PATCH 41/55] bug fix for ag-grid checkbox --- frontend/src/components/BCDataGrid/BCGridBase.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/components/BCDataGrid/BCGridBase.jsx b/frontend/src/components/BCDataGrid/BCGridBase.jsx index dd7d6c9fa..4a30c198e 100644 --- a/frontend/src/components/BCDataGrid/BCGridBase.jsx +++ b/frontend/src/components/BCDataGrid/BCGridBase.jsx @@ -34,7 +34,6 @@ export const BCGridBase = forwardRef(({ autoSizeStrategy, ...props }, ref) => { suppressMovableColumns suppressColumnMoveAnimation={false} reactiveCustomComponents - rowSelection='multiple' suppressCsvExport={false} suppressPaginationPanel suppressScrollOnNewData From b03c127f669222d3e9b2e6d6fe1152f5b76a215b Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 12:45:42 -0800 Subject: [PATCH 42/55] . --- .../NotificationMenu/components/Notifications.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx index 9d4a5b290..afb8bd493 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx @@ -44,6 +44,11 @@ export const Notifications = () => { headerTooltip: 'Checkboxes indicate selection' } }, []) + const rowSelection = useMemo(() => { + return { + mode: 'multiRow' + } + }, []) // Consolidated mutation handler const handleMutation = useCallback( @@ -221,7 +226,7 @@ export const Notifications = () => { defaultMinWidth: 50, defaultMaxWidth: 600 }} - rowSelection={{ mode: 'multiRow' }} + rowSelection={rowSelection} rowClassRules={rowClassRules} onCellClicked={onCellClicked} selectionColumnDef={selectionColumnDef} From d8fb658dc567ca2e036f148b56dc8784c8392b4c Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 12:58:45 -0800 Subject: [PATCH 43/55] correct test cases --- .../compliance_report/test_update_service.py | 26 ++++++------------- .../test_initiative_agreement_services.py | 6 ++--- .../tests/transfer/test_transfer_services.py | 12 ++++----- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/backend/lcfs/tests/compliance_report/test_update_service.py b/backend/lcfs/tests/compliance_report/test_update_service.py index 753ec8b76..12532c4e0 100644 --- a/backend/lcfs/tests/compliance_report/test_update_service.py +++ b/backend/lcfs/tests/compliance_report/test_update_service.py @@ -69,6 +69,7 @@ async def test_update_compliance_report_status_change( mock_report.current_status.status = ComplianceReportStatusEnum.Draft mock_report.compliance_period = MagicMock() mock_report.compliance_period.description = "2024" + mock_report.transaction_id = 123 new_status = MagicMock(spec=ComplianceReportStatus) new_status.status = ComplianceReportStatusEnum.Submitted @@ -82,7 +83,7 @@ async def test_update_compliance_report_status_change( mock_repo.get_compliance_report_status_by_desc.return_value = new_status compliance_report_update_service.handle_status_change = AsyncMock() mock_repo.update_compliance_report.return_value = mock_report - compliance_report_update_service._perform_notificaiton_call = AsyncMock() + compliance_report_update_service._perform_notification_call = AsyncMock() # Call the method updated_report = await compliance_report_update_service.update_compliance_report( @@ -104,7 +105,7 @@ async def test_update_compliance_report_status_change( mock_report, compliance_report_update_service.request.user ) mock_repo.update_compliance_report.assert_called_once_with(mock_report) - compliance_report_update_service._perform_notificaiton_call.assert_called_once_with( + compliance_report_update_service._perform_notification_call.assert_called_once_with( mock_report, "Submitted" ) @@ -119,11 +120,11 @@ async def test_update_compliance_report_no_status_change( mock_report.compliance_report_id = report_id mock_report.current_status = MagicMock(spec=ComplianceReportStatus) mock_report.current_status.status = ComplianceReportStatusEnum.Draft - - # Fix for JSON serialization mock_report.compliance_period = MagicMock() mock_report.compliance_period.description = "2024" + mock_report.transaction_id = 123 + # Status does not change report_data = ComplianceReportUpdateSchema( status="Draft", supplemental_note="Test note" ) @@ -134,10 +135,7 @@ async def test_update_compliance_report_no_status_change( mock_report.current_status ) mock_repo.update_compliance_report.return_value = mock_report - - # Mock the handle_status_change method - compliance_report_update_service.handle_status_change = AsyncMock() - compliance_report_update_service._perform_notificaiton_call = AsyncMock() + compliance_report_update_service._perform_notification_call = AsyncMock() # Call the method updated_report = await compliance_report_update_service.update_compliance_report( @@ -146,18 +144,10 @@ async def test_update_compliance_report_no_status_change( # Assertions assert updated_report == mock_report - mock_repo.get_compliance_report_by_id.assert_called_once_with( - report_id, is_model=True - ) - mock_repo.get_compliance_report_status_by_desc.assert_called_once_with( - report_data.status - ) - compliance_report_update_service.handle_status_change.assert_not_called() - mock_repo.add_compliance_report_history.assert_not_called() - mock_repo.update_compliance_report.assert_called_once_with(mock_report) - compliance_report_update_service._perform_notificaiton_call.assert_called_once_with( + compliance_report_update_service._perform_notification_call.assert_called_once_with( mock_report, "Draft" ) + mock_repo.update_compliance_report.assert_called_once_with(mock_report) @pytest.mark.anyio diff --git a/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py b/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py index 2eb16223d..cb0ee6994 100644 --- a/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py +++ b/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py @@ -113,8 +113,8 @@ async def test_create_initiative_agreement(service, mock_repo, mock_request): internal_comment=None, ) - # Mock _perform_notificaiton_call to isolate it - service._perform_notificaiton_call = AsyncMock() + # Mock _perform_notification_call to isolate it + service._perform_notification_call = AsyncMock() # Call the service method result = await service.create_initiative_agreement(create_data) @@ -122,7 +122,7 @@ async def test_create_initiative_agreement(service, mock_repo, mock_request): # Assertions assert result == mock_initiative_agreement mock_repo.create_initiative_agreement.assert_called_once() - service._perform_notificaiton_call.assert_called_once_with( + service._perform_notification_call.assert_called_once_with( mock_initiative_agreement ) diff --git a/backend/lcfs/tests/transfer/test_transfer_services.py b/backend/lcfs/tests/transfer/test_transfer_services.py index 91c8e7f21..f82ef70c7 100644 --- a/backend/lcfs/tests/transfer/test_transfer_services.py +++ b/backend/lcfs/tests/transfer/test_transfer_services.py @@ -76,13 +76,13 @@ async def test_create_transfer_success(transfer_service, mock_transfer_repo): ) mock_transfer_repo.create_transfer.return_value = transfer_data - # Patch the _perform_notificaiton_call method - with patch.object(transfer_service, "_perform_notificaiton_call", AsyncMock()): + # Patch the _perform_notification_call method + with patch.object(transfer_service, "_perform_notification_call", AsyncMock()): result = await transfer_service.create_transfer(transfer_data) assert result.transfer_id == transfer_id assert isinstance(result, TransferCreateSchema) - transfer_service._perform_notificaiton_call.assert_called_once() + transfer_service._perform_notification_call.assert_called_once() @pytest.mark.anyio @@ -121,8 +121,8 @@ async def test_update_transfer_success( mock_transfer_repo.get_transfer_by_id.return_value = transfer mock_transfer_repo.update_transfer.return_value = transfer - # Replace _perform_notificaiton_call with an AsyncMock - transfer_service._perform_notificaiton_call = AsyncMock() + # Replace _perform_notification_call with an AsyncMock + transfer_service._perform_notification_call = AsyncMock() result = await transfer_service.update_transfer(transfer) @@ -133,7 +133,7 @@ async def test_update_transfer_success( # Verify mocks mock_transfer_repo.get_transfer_by_id.assert_called_once_with(transfer_id) mock_transfer_repo.update_transfer.assert_called_once_with(transfer) - transfer_service._perform_notificaiton_call.assert_awaited_once_with( + transfer_service._perform_notification_call.assert_awaited_once_with( transfer, status="Return to analyst" ) From ec65eb2c52fe40da4cb54621dd5a49c5f82dde8b Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Tue, 17 Dec 2024 13:17:56 -0800 Subject: [PATCH 44/55] fix: ensure fuel code assignment and prevent field clearing on prefix change --- .../components/BCDataGrid/BCGridEditor.jsx | 155 ++++++++++++------ .../FuelCodes/AddFuelCode/AddEditFuelCode.jsx | 128 ++++++++++----- .../views/FuelCodes/AddFuelCode/_schema.jsx | 19 +-- 3 files changed, 193 insertions(+), 109 deletions(-) diff --git a/frontend/src/components/BCDataGrid/BCGridEditor.jsx b/frontend/src/components/BCDataGrid/BCGridEditor.jsx index cdc02be64..03c9b4649 100644 --- a/frontend/src/components/BCDataGrid/BCGridEditor.jsx +++ b/frontend/src/components/BCDataGrid/BCGridEditor.jsx @@ -27,6 +27,7 @@ import { BCAlert2 } from '@/components/BCAlert' * @property {React.Ref} gridRef * @property {Function} handlePaste * @property {Function} onAction + * @property {Function} onAddRows * * @param {BCGridEditorProps & GridOptions} props * @returns {JSX.Element} @@ -44,6 +45,7 @@ export const BCGridEditor = ({ saveButtonProps = { enabled: false }, + onAddRows, ...props }) => { const localRef = useRef(null) @@ -59,32 +61,36 @@ export const BCGridEditor = ({ if (!firstEditableColumnRef.current) { const columns = ref.current.api.getAllDisplayedColumns() - firstEditableColumnRef.current = columns.find(col => - col.colDef.editable !== false && - !['action', 'checkbox'].includes(col.colDef.field) + firstEditableColumnRef.current = columns.find( + (col) => + col.colDef.editable !== false && + !['action', 'checkbox'].includes(col.colDef.field) ) } return firstEditableColumnRef.current }, []) // Helper function to start editing first editable cell in a row - const startEditingFirstEditableCell = useCallback((rowIndex) => { - if (!ref.current?.api) return + const startEditingFirstEditableCell = useCallback( + (rowIndex) => { + if (!ref.current?.api) return - // Ensure we have the first editable column - const firstEditableColumn = findFirstEditableColumn() - if (!firstEditableColumn) return + // Ensure we have the first editable column + const firstEditableColumn = findFirstEditableColumn() + if (!firstEditableColumn) return - // Use setTimeout to ensure the grid is ready - setTimeout(() => { - ref.current.api.ensureIndexVisible(rowIndex) - ref.current.api.setFocusedCell(rowIndex, firstEditableColumn.getColId()) - ref.current.api.startEditingCell({ - rowIndex, - colKey: firstEditableColumn.getColId() - }) - }, 100) - }, [findFirstEditableColumn]) + // Use setTimeout to ensure the grid is ready + setTimeout(() => { + ref.current.api.ensureIndexVisible(rowIndex) + ref.current.api.setFocusedCell(rowIndex, firstEditableColumn.getColId()) + ref.current.api.startEditingCell({ + rowIndex, + colKey: firstEditableColumn.getColId() + }) + }, 100) + }, + [findFirstEditableColumn] + ) const handleExcelPaste = useCallback( (params) => { @@ -175,11 +181,18 @@ export const BCGridEditor = ({ params.event.target.dataset.action && onAction ) { - const transaction = await onAction(params.event.target.dataset.action, params) - // Focus and edit the first editable column of the duplicated row - if (transaction?.add.length > 0) { - const duplicatedRowNode = transaction.add[0] - startEditingFirstEditableCell(duplicatedRowNode.rowIndex) + const action = params.event.target.dataset.action + const transaction = await onAction(action, params) + + // Apply the transaction if it exists + if (transaction?.add?.length > 0) { + const res = ref.current.api.applyTransaction(transaction) + + // Focus and edit the first editable column of the added rows + if (res.add && res.add.length > 0) { + const firstNewRow = res.add[0] + startEditingFirstEditableCell(firstNewRow.rowIndex) + } } } } @@ -192,28 +205,45 @@ export const BCGridEditor = ({ setAnchorEl(null) } - const handleAddRows = useCallback((numRows) => { - let newRows = [] - if (props.onAddRows) { - newRows = props.onAddRows(numRows) - } else { - newRows = Array(numRows) - .fill() - .map(() => ({ id: uuid() })) - } + const handleAddRowsInternal = useCallback( + async (numRows) => { + let newRows = [] - // Add the new rows - ref.current.api.applyTransaction({ - add: newRows, - addIndex: ref.current.api.getDisplayedRowCount() - }) + if (onAction) { + try { + for (let i = 0; i < numRows; i++) { + const transaction = await onAction('add') + if (transaction?.add?.length > 0) { + newRows = [...newRows, ...transaction.add] + } + } + } catch (error) { + console.error('Error during onAction add:', error) + } + } - // Focus and start editing the first new row - const firstNewRowIndex = ref.current.api.getDisplayedRowCount() - numRows - startEditingFirstEditableCell(firstNewRowIndex) + // Default logic if onAction doesn't return rows + if (newRows.length === 0) { + newRows = Array(numRows) + .fill() + .map(() => ({ id: uuid() })) + } - setAnchorEl(null) - }, [props.onAddRows, startEditingFirstEditableCell]) + // Apply the new rows to the grid + const result = ref.current.api.applyTransaction({ + add: newRows, + addIndex: ref.current.api.getDisplayedRowCount() + }) + + // Focus the first editable cell in the first new row + if (result.add && result.add.length > 0) { + startEditingFirstEditableCell(result.add[0].rowIndex) + } + + setAnchorEl(null) + }, + [onAction, startEditingFirstEditableCell] + ) const isGridValid = () => { let isValid = true @@ -238,24 +268,25 @@ export const BCGridEditor = ({ setShowCloseModal(true) } const hasRequiredHeaderComponent = useCallback(() => { - const columnDefs = ref.current?.api?.getColumnDefs() || []; + const columnDefs = ref.current?.api?.getColumnDefs() || [] // Check if any column has `headerComponent` matching "RequiredHeader" - return columnDefs.some( - colDef => colDef.headerComponent?.name === 'RequiredHeader' - ) || columnDefs.some(colDef => !!colDef.headerComponent) + return ( + columnDefs.some( + (colDef) => colDef.headerComponent?.name === 'RequiredHeader' + ) || columnDefs.some((colDef) => !!colDef.headerComponent) + ) }, [ref]) - return ( - {hasRequiredHeaderComponent() && + {hasRequiredHeaderComponent() && ( - } + )} ) } - onClick={addMultiRow ? handleAddRowsClick : () => handleAddRows(1)} + onClick={ + addMultiRow ? handleAddRowsClick : () => handleAddRowsInternal(1) + } > Add row @@ -301,9 +334,15 @@ export const BCGridEditor = ({ } }} > - handleAddRows(1)}>1 row - handleAddRows(5)}>5 rows - handleAddRows(10)}>10 rows + handleAddRowsInternal(1)}> + 1 row + + handleAddRowsInternal(5)}> + 5 rows + + handleAddRowsInternal(10)}> + 10 rows + )} @@ -345,8 +384,16 @@ BCGridEditor.propTypes = { alertRef: PropTypes.shape({ current: PropTypes.any }), handlePaste: PropTypes.func, onAction: PropTypes.func, + onAddRows: PropTypes.func, onRowEditingStopped: PropTypes.func, onCellValueChanged: PropTypes.func, showAddRowsButton: PropTypes.bool, - onAddRows: PropTypes.func + addMultiRow: PropTypes.bool, + saveButtonProps: PropTypes.shape({ + enabled: PropTypes.bool, + text: PropTypes.string, + onSave: PropTypes.func, + confirmText: PropTypes.string, + confirmLabel: PropTypes.string + }) } diff --git a/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx b/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx index c06a73a7b..ece671487 100644 --- a/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx +++ b/frontend/src/views/FuelCodes/AddFuelCode/AddEditFuelCode.jsx @@ -38,9 +38,15 @@ const AddEditFuelCodeBase = () => { const [columnDefs, setColumnDefs] = useState([]) const [isGridReady, setGridReady] = useState(false) const [modalData, setModalData] = useState(null) + const [initialized, setInitialized] = useState(false) const { hasRoles } = useCurrentUser() - const { data: optionsData, isLoading, isFetched } = useFuelCodeOptions() + const { + data: optionsData, + isLoading, + isFetched, + refetch: refetchOptions + } = useFuelCodeOptions() const { mutateAsync: updateFuelCode } = useUpdateFuelCode(fuelCodeID) const { mutateAsync: createFuelCode } = useCreateFuelCode() const { mutateAsync: deleteFuelCode } = useDeleteFuelCode() @@ -51,6 +57,35 @@ const AddEditFuelCodeBase = () => { refetch } = useGetFuelCode(fuelCodeID) + useEffect(() => { + // Only initialize rowData once when all data is available and the grid is ready + if (!initialized && isFetched && !isLoadingExistingCode && isGridReady) { + if (existingFuelCode) { + setRowData([existingFuelCode]) + } else { + const defaultPrefix = optionsData?.fuelCodePrefixes?.find( + (item) => item.prefix === 'BCLCF' + ) + setRowData([ + { + id: uuid(), + prefixId: defaultPrefix?.fuelCodePrefixId || 1, + prefix: defaultPrefix?.prefix || 'BCLCF', + fuelSuffix: defaultPrefix?.nextFuelCode + } + ]) + } + setInitialized(true) + } + }, [ + initialized, + isFetched, + isLoadingExistingCode, + isGridReady, + existingFuelCode, + optionsData + ]) + useEffect(() => { if (optionsData) { const updatedColumnDefs = fuelCodeColDefs( @@ -62,23 +97,7 @@ const AddEditFuelCodeBase = () => { ) setColumnDefs(updatedColumnDefs) } - }, [errors, optionsData, existingFuelCode]) - - useEffect(() => { - if (existingFuelCode) { - setRowData([existingFuelCode]) - } else { - setRowData([ - { - id: uuid(), - prefixId: 1, - fuelSuffix: optionsData?.fuelCodePrefixes?.find( - (item) => item.prefix === 'BCLCF' - ).nextFuelCode - } - ]) - } - }, [optionsData, existingFuelCode, isGridReady]) + }, [errors, optionsData, existingFuelCode, hasRoles]) const onGridReady = (params) => { setGridReady(true) @@ -100,7 +119,7 @@ const AddEditFuelCodeBase = () => { if (params.colDef.field === 'prefix') { updatedData.fuelSuffix = optionsData?.fuelCodePrefixes?.find( (item) => item.prefix === params.newValue - ).nextFuelCode + )?.nextFuelCode } params.api.applyTransaction({ update: [updatedData] }) @@ -213,8 +232,8 @@ const AddEditFuelCodeBase = () => { } catch (error) { setErrors({ [params.node.data.id]: - error.response.data?.errors && - error.response.data?.errors[0]?.fields + error.response?.data?.errors && + error.response.data.errors[0]?.fields }) updatedData = { @@ -244,7 +263,7 @@ const AddEditFuelCodeBase = () => { params.node.updateData(updatedData) }, - [updateFuelCode, t] + [updateFuelCode, t, createFuelCode] ) const handlePaste = useCallback( @@ -311,8 +330,16 @@ const AddEditFuelCodeBase = () => { ) const duplicateFuelCode = async (params) => { - const rowData = { - ...params.data, + const originalData = params.data + const originalPrefix = originalData.prefix || 'BCLCF' + + const updatedOptions = await refetchOptions() + const selectedPrefix = updatedOptions.data.fuelCodePrefixes?.find( + (p) => p.prefix === originalPrefix + ) + + const newRow = { + ...originalData, id: uuid(), fuelCodeId: null, modified: true, @@ -320,10 +347,18 @@ const AddEditFuelCodeBase = () => { validationStatus: 'error', validationMsg: 'Fill in the missing fields' } + + if (selectedPrefix) { + newRow.prefixId = selectedPrefix.fuelCodePrefixId + newRow.prefix = selectedPrefix.prefix + newRow.fuelSuffix = selectedPrefix.nextFuelCode + } + if (params.api) { - if (params.data.fuelCodeId) { + if (originalData.fuelCodeId) { try { - const response = await updateFuelCode(rowData) + // If the original was a saved row, create a new code in the backend + const response = await createFuelCode(newRow) const updatedData = { ...response.data, id: uuid(), @@ -331,23 +366,13 @@ const AddEditFuelCodeBase = () => { isValid: false, validationStatus: 'error' } - params.api.applyTransaction({ - add: [updatedData], - addIndex: params.node?.rowIndex + 1 - }) - params.api.refreshCells() - alertRef.current?.triggerAlert({ - message: 'Row duplicated successfully.', - severity: 'success' - }) + return { add: [updatedData] } } catch (error) { handleError(error, `Error duplicating row: ${error.message}`) } } else { - params.api.applyTransaction({ - add: [rowData], - addIndex: params.node?.rowIndex + 1 - }) + // If the original row wasn’t saved, just return the transaction + return { add: [newRow] } } } } @@ -359,12 +384,31 @@ const AddEditFuelCodeBase = () => { const onAction = useCallback( async (action, params) => { if (action === 'duplicate') { - await duplicateFuelCode(params) + return await duplicateFuelCode(params) } else if (action === 'delete') { await openDeleteModal(params.data.fuelCodeId, params) + } else if (action === 'add') { + // Refetch options to get updated nextFuelCode + const updatedOptions = await refetchOptions() + const defaultPrefix = updatedOptions.data.fuelCodePrefixes.find( + (item) => item.prefix === 'BCLCF' + ) + + const newRow = { + id: uuid(), + prefixId: defaultPrefix.fuelCodePrefixId, + prefix: defaultPrefix.prefix, + fuelSuffix: defaultPrefix.nextFuelCode, + modified: true, + validationStatus: 'error', + validationMsg: 'Fill in missing fields' + } + + // Return a transaction (no resetting rowData) + return { add: [newRow] } } }, - [updateFuelCode, deleteFuelCode] + [duplicateFuelCode, refetchOptions] ) if (isLoading || isLoadingExistingCode) { @@ -390,7 +434,7 @@ const AddEditFuelCodeBase = () => { columnDefs={columnDefs} defaultColDef={defaultColDef} onGridReady={onGridReady} - rowData={rowData} + rowData={rowData} // Only set once, do not update again onCellValueChanged={onCellValueChanged} onCellEditingStopped={onCellEditingStopped} onAction={onAction} diff --git a/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx b/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx index 66d033af6..8e335ba2e 100644 --- a/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx +++ b/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx @@ -97,20 +97,13 @@ export const fuelCodeColDefs = (optionsData, errors, isCreate, canEdit) => [ const selectedPrefix = optionsData?.fuelCodePrefixes?.find( (obj) => obj.prefix === params.newValue ) - params.data.fuelCodePrefixId = selectedPrefix.fuelCodePrefixId + if (selectedPrefix) { + params.data.prefixId = selectedPrefix.fuelCodePrefixId + params.data.fuelCodePrefixId = selectedPrefix.fuelCodePrefixId + params.data.fuelCodePrefix = selectedPrefix.fuelCodePrefix - params.data.fuelSuffix = optionsData?.fuelCodePrefixes?.find( - (obj) => obj.prefix === params.newValue - )?.nextFuelCode - params.data.company = undefined - params.data.fuel = undefined - params.data.feedstock = undefined - params.data.feedstockLocation = undefined - params.data.feedstockFuelTransportMode = [] - params.data.finishedFuelTransportMode = [] - params.data.formerCompany = undefined - params.data.contactName = undefined - params.data.contactEmail = undefined + params.data.fuelSuffix = selectedPrefix.nextFuelCode + } } return true }, From ab89b0b3a51abba1a936052b48654005efcbb35b Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Tue, 17 Dec 2024 13:24:30 -0800 Subject: [PATCH 45/55] feat: Add validation rule for other_fuel_type * Validate that if the fuel type is unrecognized we require other to be populated * Add new tests * Fix broken tests in develop --- .../compliance_report/test_summary_service.py | 6 +- .../test_fuel_supplies_validation.py | 90 +++++++++++++++++-- .../internal_comment/test_internal_comment.py | 8 ++ backend/lcfs/tests/user/test_user_views.py | 2 +- .../lcfs/web/api/fuel_supply/validation.py | 23 ++++- backend/lcfs/web/api/fuel_supply/views.py | 1 + .../lcfs/web/api/internal_comment/services.py | 5 +- backend/lcfs/web/core/decorators.py | 3 + 8 files changed, 122 insertions(+), 16 deletions(-) diff --git a/backend/lcfs/tests/compliance_report/test_summary_service.py b/backend/lcfs/tests/compliance_report/test_summary_service.py index efc5f51ce..30cf918ef 100644 --- a/backend/lcfs/tests/compliance_report/test_summary_service.py +++ b/backend/lcfs/tests/compliance_report/test_summary_service.py @@ -810,8 +810,7 @@ async def test_calculate_fuel_quantities_renewable( ): # Create a mock repository mock_repo.aggregate_fuel_supplies.return_value = {"gasoline": 200.0} - mock_repo.aggregate_other_uses.return_value = {"diesel": 75.0} - mock_repo.aggregate_allocation_agreements.return_value = {"jet-fuel": 25.0} + mock_repo.aggregate_other_uses.return_value = {"diesel": 75.0, "jet-fuel": 25.0} # Define test inputs compliance_report_id = 2 @@ -830,7 +829,4 @@ async def test_calculate_fuel_quantities_renewable( mock_repo.aggregate_other_uses.assert_awaited_once_with( compliance_report_id, fossil_derived ) - mock_repo.aggregate_allocation_agreements.assert_awaited_once_with( - compliance_report_id - ) assert result == {"gasoline": 200.0, "diesel": 75.0, "jet-fuel": 25.0} diff --git a/backend/lcfs/tests/fuel_supply/test_fuel_supplies_validation.py b/backend/lcfs/tests/fuel_supply/test_fuel_supplies_validation.py index 747416563..eefa25c91 100644 --- a/backend/lcfs/tests/fuel_supply/test_fuel_supplies_validation.py +++ b/backend/lcfs/tests/fuel_supply/test_fuel_supplies_validation.py @@ -1,26 +1,30 @@ from unittest.mock import MagicMock, AsyncMock - import pytest -from fastapi import Request +from fastapi.exceptions import RequestValidationError from lcfs.web.api.fuel_supply.repo import FuelSupplyRepository +from lcfs.web.api.fuel_code.repo import FuelCodeRepository from lcfs.web.api.fuel_supply.schema import FuelSupplyCreateUpdateSchema from lcfs.web.api.fuel_supply.validation import FuelSupplyValidation @pytest.fixture def fuel_supply_validation(): + # Mock repositories mock_fs_repo = MagicMock(spec=FuelSupplyRepository) - request = MagicMock(spec=Request) + mock_fc_repo = MagicMock(spec=FuelCodeRepository) + + # Create the validation instance with mocked repositories validation = FuelSupplyValidation( - request=request, fs_repo=mock_fs_repo + fs_repo=mock_fs_repo, + fc_repo=mock_fc_repo, ) - return validation, mock_fs_repo + return validation, mock_fs_repo, mock_fc_repo @pytest.mark.anyio async def test_check_duplicate(fuel_supply_validation): - validation, mock_fs_repo = fuel_supply_validation + validation, mock_fs_repo, _ = fuel_supply_validation fuel_supply_data = FuelSupplyCreateUpdateSchema( compliance_report_id=1, fuel_type_id=1, @@ -29,9 +33,83 @@ async def test_check_duplicate(fuel_supply_validation): quantity=2000, units="L", ) + mock_fs_repo.check_duplicate = AsyncMock(return_value=True) result = await validation.check_duplicate(fuel_supply_data) assert result is True mock_fs_repo.check_duplicate.assert_awaited_once_with(fuel_supply_data) + + +@pytest.mark.anyio +async def test_validate_other_recognized_type(fuel_supply_validation): + validation, _, mock_fc_repo = fuel_supply_validation + # Mock a recognized fuel type (unrecognized = False) + mock_fc_repo.get_fuel_type_by_id = AsyncMock( + return_value=MagicMock(unrecognized=False) + ) + + fuel_supply_data = FuelSupplyCreateUpdateSchema( + compliance_report_id=1, + fuel_type_id=1, # Some recognized type ID + fuel_category_id=1, + provision_of_the_act_id=1, + quantity=2000, + units="L", + ) + + # Should not raise any error as fuel_type_other is not needed for recognized type + await validation.validate_other(fuel_supply_data) + + +@pytest.mark.anyio +async def test_validate_other_unrecognized_type_with_other(fuel_supply_validation): + validation, _, mock_fc_repo = fuel_supply_validation + # Mock an unrecognized fuel type + mock_fc_repo.get_fuel_type_by_id = AsyncMock( + return_value=MagicMock(unrecognized=True) + ) + + # Provide fuel_type_other since it's unrecognized + fuel_supply_data = FuelSupplyCreateUpdateSchema( + compliance_report_id=1, + fuel_type_id=99, # Assume 99 is unrecognized "Other" type + fuel_category_id=1, + provision_of_the_act_id=1, + quantity=2000, + units="L", + fuel_type_other="Some other fuel", + ) + + # Should not raise an error since fuel_type_other is provided + await validation.validate_other(fuel_supply_data) + + +@pytest.mark.anyio +async def test_validate_other_unrecognized_type_missing_other(fuel_supply_validation): + validation, _, mock_fc_repo = fuel_supply_validation + # Mock an unrecognized fuel type + mock_fc_repo.get_fuel_type_by_id = AsyncMock( + return_value=MagicMock(unrecognized=True) + ) + + # Missing fuel_type_other + fuel_supply_data = FuelSupplyCreateUpdateSchema( + compliance_report_id=1, + fuel_type_id=99, # Assume 99 is unrecognized "Other" type + fuel_category_id=1, + provision_of_the_act_id=1, + quantity=2000, + units="L", + ) + + # Should raise RequestValidationError since fuel_type_other is required + with pytest.raises(RequestValidationError) as exc: + await validation.validate_other(fuel_supply_data) + + # Assert that the error message is as expected + errors = exc.value.errors() + assert len(errors) == 1 + assert errors[0]["loc"] == ("fuelTypeOther",) + assert "required when using Other" in errors[0]["msg"] diff --git a/backend/lcfs/tests/internal_comment/test_internal_comment.py b/backend/lcfs/tests/internal_comment/test_internal_comment.py index dfae4ca5a..046327621 100644 --- a/backend/lcfs/tests/internal_comment/test_internal_comment.py +++ b/backend/lcfs/tests/internal_comment/test_internal_comment.py @@ -4,6 +4,7 @@ from httpx import AsyncClient from datetime import datetime +from lcfs.db.models import UserProfile from lcfs.db.models.transfer.Transfer import Transfer, TransferRecommendationEnum from lcfs.db.models.initiative_agreement.InitiativeAgreement import InitiativeAgreement from lcfs.db.models.admin_adjustment.AdminAdjustment import AdminAdjustment @@ -334,6 +335,13 @@ async def test_get_internal_comments_multiple_comments( ) await add_models([transfer]) + user = UserProfile( + keycloak_username="IDIRUSER", + first_name="Test", + last_name="User", + ) + await add_models([user]) + comments = [] for i in range(3): internal_comment = InternalComment( diff --git a/backend/lcfs/tests/user/test_user_views.py b/backend/lcfs/tests/user/test_user_views.py index 1c68b120d..24c9e303f 100644 --- a/backend/lcfs/tests/user/test_user_views.py +++ b/backend/lcfs/tests/user/test_user_views.py @@ -125,7 +125,7 @@ async def test_get_user_activities_as_manage_users_same_org( add_models, ): # Mock the current user as a user with MANAGE_USERS - set_mock_user(fastapi_app, [RoleEnum.MANAGE_USERS]) + set_mock_user(fastapi_app, [RoleEnum.ADMINISTRATOR, RoleEnum.MANAGE_USERS]) # Assuming target user with user_profile_id=3 exists and is in organization_id=1 target_user_id = 1 diff --git a/backend/lcfs/web/api/fuel_supply/validation.py b/backend/lcfs/web/api/fuel_supply/validation.py index bdc2ba2b4..dc065e06a 100644 --- a/backend/lcfs/web/api/fuel_supply/validation.py +++ b/backend/lcfs/web/api/fuel_supply/validation.py @@ -1,5 +1,7 @@ -from fastapi import Depends, Request +from fastapi import Depends +from fastapi.exceptions import RequestValidationError +from lcfs.web.api.fuel_code.repo import FuelCodeRepository from lcfs.web.api.fuel_supply.repo import FuelSupplyRepository from lcfs.web.api.fuel_supply.schema import FuelSupplyCreateUpdateSchema @@ -7,11 +9,26 @@ class FuelSupplyValidation: def __init__( self, - request: Request = None, fs_repo: FuelSupplyRepository = Depends(FuelSupplyRepository), + fc_repo: FuelCodeRepository = Depends(FuelCodeRepository), ): self.fs_repo = fs_repo - self.request = request + self.fc_repo = fc_repo async def check_duplicate(self, fuel_supply: FuelSupplyCreateUpdateSchema): return await self.fs_repo.check_duplicate(fuel_supply) + + async def validate_other(self, fuel_supply: FuelSupplyCreateUpdateSchema): + fuel_type = await self.fc_repo.get_fuel_type_by_id(fuel_supply.fuel_type_id) + + if fuel_type.unrecognized: + if not fuel_supply.fuel_type_other: + raise RequestValidationError( + [ + { + "loc": ("fuelTypeOther",), + "msg": "required when using Other", + "type": "value_error", + } + ] + ) diff --git a/backend/lcfs/web/api/fuel_supply/views.py b/backend/lcfs/web/api/fuel_supply/views.py index 52c1b8b82..3f5674408 100644 --- a/backend/lcfs/web/api/fuel_supply/views.py +++ b/backend/lcfs/web/api/fuel_supply/views.py @@ -95,6 +95,7 @@ async def save_fuel_supply_row( return await action_service.delete_fuel_supply(request_data, current_user_type) else: duplicate_id = await fs_validate.check_duplicate(request_data) + await fs_validate.validate_other(request_data) if duplicate_id is not None: duplicate_response = format_duplicate_error(duplicate_id) return duplicate_response diff --git a/backend/lcfs/web/api/internal_comment/services.py b/backend/lcfs/web/api/internal_comment/services.py index 965d03e8e..c051f03cb 100644 --- a/backend/lcfs/web/api/internal_comment/services.py +++ b/backend/lcfs/web/api/internal_comment/services.py @@ -73,7 +73,10 @@ async def get_internal_comments( List[InternalCommentResponseSchema]: A list of internal comments as data transfer objects. """ comments = await self.repo.get_internal_comments(entity_type, entity_id) - return [InternalCommentResponseSchema.from_orm(comment) for comment in comments] + return [ + InternalCommentResponseSchema.model_validate(comment) + for comment in comments + ] @service_handler async def get_internal_comment_by_id( diff --git a/backend/lcfs/web/core/decorators.py b/backend/lcfs/web/core/decorators.py index e67d9afca..0ccfc9a3b 100644 --- a/backend/lcfs/web/core/decorators.py +++ b/backend/lcfs/web/core/decorators.py @@ -9,6 +9,7 @@ import os from fastapi import HTTPException, Request +from fastapi.exceptions import RequestValidationError from lcfs.services.clamav.client import VirusScanException from lcfs.web.exception.exceptions import ( @@ -191,6 +192,8 @@ async def wrapper(request: Request, *args, **kwargs): status_code=422, detail="Viruses detected in file, please upload another", ) + except RequestValidationError as e: + raise e except Exception as e: context = extract_context() log_unhandled_exception(logger, e, context, "view", func=func) From 7b266bf29de77cd033cac1dc9d809e368f7db443 Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Mon, 16 Dec 2024 11:07:37 -0800 Subject: [PATCH 46/55] feat: Sync reports from TFRS -> LCFS * Add logic to handle create, submit, and approve messages * Add a function / test to handle logic for each one * Add legacy_id to track the id from TFRS * Change consumer to use app state directly since it has no request --- .../versions/2024-12-13-19-25_5b374dd97469.py | 36 +++ .../db/models/compliance/ComplianceReport.py | 5 + .../lcfs/services/rabbitmq/base_consumer.py | 5 +- backend/lcfs/services/rabbitmq/consumers.py | 12 +- .../lcfs/services/rabbitmq/report_consumer.py | 292 ++++++++++++++++++ .../services/rabbitmq/transaction_consumer.py | 71 ----- .../test_compliance_report_repo.py | 4 +- .../test_compliance_report_services.py | 13 +- .../services/rabbitmq/test_report_consumer.py | 218 +++++++++++++ .../rabbitmq/test_transaction_consumer.py | 111 ------- .../lcfs/web/api/compliance_report/repo.py | 36 ++- .../lcfs/web/api/compliance_report/schema.py | 3 +- .../web/api/compliance_report/services.py | 26 +- backend/lcfs/web/api/organization/views.py | 13 +- .../lcfs/web/api/organizations/services.py | 5 +- backend/lcfs/web/api/transaction/repo.py | 2 +- backend/lcfs/web/lifetime.py | 2 +- 17 files changed, 631 insertions(+), 223 deletions(-) create mode 100644 backend/lcfs/db/migrations/versions/2024-12-13-19-25_5b374dd97469.py create mode 100644 backend/lcfs/services/rabbitmq/report_consumer.py delete mode 100644 backend/lcfs/services/rabbitmq/transaction_consumer.py create mode 100644 backend/lcfs/tests/services/rabbitmq/test_report_consumer.py delete mode 100644 backend/lcfs/tests/services/rabbitmq/test_transaction_consumer.py diff --git a/backend/lcfs/db/migrations/versions/2024-12-13-19-25_5b374dd97469.py b/backend/lcfs/db/migrations/versions/2024-12-13-19-25_5b374dd97469.py new file mode 100644 index 000000000..304f2a83e --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-12-13-19-25_5b374dd97469.py @@ -0,0 +1,36 @@ +"""Add legacy id to compliance reports + +Revision ID: 5b374dd97469 +Revises: 5d729face5ab +Create Date: 2024-12-13 19:25:32.076684 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5b374dd97469" +down_revision = "5d729face5ab" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "compliance_report", + sa.Column( + "legacy_id", + sa.Integer(), + nullable=True, + comment="ID from TFRS if this is a transferred application, NULL otherwise", + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("compliance_report", "legacy_id") + # ### end Alembic commands ### diff --git a/backend/lcfs/db/models/compliance/ComplianceReport.py b/backend/lcfs/db/models/compliance/ComplianceReport.py index d88e023d2..6656cc6ec 100644 --- a/backend/lcfs/db/models/compliance/ComplianceReport.py +++ b/backend/lcfs/db/models/compliance/ComplianceReport.py @@ -100,6 +100,11 @@ class ComplianceReport(BaseModel, Auditable): default=lambda: str(uuid.uuid4()), comment="UUID that groups all versions of a compliance report", ) + legacy_id = Column( + Integer, + nullable=True, + comment="ID from TFRS if this is a transferred application, NULL otherwise", + ) version = Column( Integer, nullable=False, diff --git a/backend/lcfs/services/rabbitmq/base_consumer.py b/backend/lcfs/services/rabbitmq/base_consumer.py index 26bc3ebdd..9a80bf56a 100644 --- a/backend/lcfs/services/rabbitmq/base_consumer.py +++ b/backend/lcfs/services/rabbitmq/base_consumer.py @@ -3,6 +3,7 @@ import aio_pika from aio_pika.abc import AbstractChannel, AbstractQueue +from fastapi import FastAPI from lcfs.settings import settings @@ -12,11 +13,12 @@ class BaseConsumer: - def __init__(self, queue_name=None): + def __init__(self, app: FastAPI, queue_name: str): self.connection = None self.channel = None self.queue = None self.queue_name = queue_name + self.app = app async def connect(self): """Connect to RabbitMQ and set up the consumer.""" @@ -42,7 +44,6 @@ async def start_consuming(self): async with message.process(): logger.debug(f"Received message: {message.body.decode()}") await self.process_message(message.body) - logger.debug("Message Processed") async def process_message(self, body: bytes): """Process the incoming message. Override this method in subclasses.""" diff --git a/backend/lcfs/services/rabbitmq/consumers.py b/backend/lcfs/services/rabbitmq/consumers.py index de934c00f..17cfdf193 100644 --- a/backend/lcfs/services/rabbitmq/consumers.py +++ b/backend/lcfs/services/rabbitmq/consumers.py @@ -1,14 +1,14 @@ import asyncio -from lcfs.services.rabbitmq.transaction_consumer import ( - setup_transaction_consumer, - close_transaction_consumer, +from lcfs.services.rabbitmq.report_consumer import ( + setup_report_consumer, + close_report_consumer, ) -async def start_consumers(): - await setup_transaction_consumer() +async def start_consumers(app): + await setup_report_consumer(app) async def stop_consumers(): - await close_transaction_consumer() + await close_report_consumer() diff --git a/backend/lcfs/services/rabbitmq/report_consumer.py b/backend/lcfs/services/rabbitmq/report_consumer.py new file mode 100644 index 000000000..f03df28ee --- /dev/null +++ b/backend/lcfs/services/rabbitmq/report_consumer.py @@ -0,0 +1,292 @@ +import asyncio +import json +import logging +from typing import Optional + +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import AsyncSession + +from lcfs.db.dependencies import async_engine +from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatusEnum +from lcfs.db.models.transaction.Transaction import TransactionActionEnum +from lcfs.db.models.user import UserProfile +from lcfs.services.rabbitmq.base_consumer import BaseConsumer +from lcfs.services.tfrs.redis_balance import RedisBalanceService +from lcfs.settings import settings +from lcfs.web.api.compliance_report.repo import ComplianceReportRepository +from lcfs.web.api.compliance_report.schema import ComplianceReportCreateSchema +from lcfs.web.api.compliance_report.services import ComplianceReportServices +from lcfs.web.api.organizations.repo import OrganizationsRepository +from lcfs.web.api.organizations.services import OrganizationsService +from lcfs.web.api.transaction.repo import TransactionRepository +from lcfs.web.api.user.repo import UserRepository +from lcfs.web.exception.exceptions import ServiceException + +logger = logging.getLogger(__name__) + +consumer = None +consumer_task = None + +VALID_ACTIONS = {"Created", "Submitted", "Approved"} + + +async def setup_report_consumer(app: FastAPI): + """ + Set up the report consumer and start consuming messages. + """ + global consumer, consumer_task + consumer = ReportConsumer(app) + await consumer.connect() + consumer_task = asyncio.create_task(consumer.start_consuming()) + + +async def close_report_consumer(): + """ + Cancel the consumer task if it exists and close the consumer connection. + """ + global consumer, consumer_task + + if consumer_task: + consumer_task.cancel() + + if consumer: + await consumer.close_connection() + + +class ReportConsumer(BaseConsumer): + """ + A consumer for handling TFRS compliance report messages from a RabbitMQ queue. + """ + + def __init__( + self, app: FastAPI, queue_name: str = settings.rabbitmq_transaction_queue + ): + super().__init__(app, queue_name) + + async def process_message(self, body: bytes): + """ + Process an incoming message from the queue. + + Expected message structure: + { + "tfrs_id": int, + "organization_id": int, + "compliance_period": str, + "nickname": str, + "action": "Created"|"Submitted"|"Approved", + "credits": int (optional), + "user_id": int + } + """ + message = self._parse_message(body) + if not message: + return # Invalid message already logged + + action = message["action"] + org_id = message["organization_id"] + + if action not in VALID_ACTIONS: + logger.error(f"Invalid action '{action}' in message.") + return + + logger.info(f"Received '{action}' action from TFRS for Org {org_id}") + + try: + await self.handle_message( + action=action, + compliance_period=message.get("compliance_period"), + compliance_units=message.get("credits"), + legacy_id=message["tfrs_id"], + nickname=message.get("nickname"), + org_id=org_id, + user_id=message["user_id"], + ) + except Exception: + logger.exception("Failed to handle message") + + def _parse_message(self, body: bytes) -> Optional[dict]: + """ + Parse the message body into a dictionary. + Log and return None if parsing fails or required fields are missing. + """ + try: + message_content = json.loads(body.decode()) + except json.JSONDecodeError: + logger.error("Failed to decode message body as JSON.") + return None + + required_fields = ["tfrs_id", "organization_id", "action", "user_id"] + if any(field not in message_content for field in required_fields): + logger.error("Message missing required fields.") + return None + + return message_content + + async def handle_message( + self, + action: str, + compliance_period: str, + compliance_units: Optional[int], + legacy_id: int, + nickname: Optional[str], + org_id: int, + user_id: int, + ): + """ + Handle a given message action by loading dependencies and calling the respective handler. + """ + redis_client = self.app.state.redis_client + + async with AsyncSession(async_engine) as session: + async with session.begin(): + # Initialize repositories and services + org_repo = OrganizationsRepository(db=session) + transaction_repo = TransactionRepository(db=session) + redis_balance_service = RedisBalanceService( + transaction_repo=transaction_repo, redis_client=redis_client + ) + org_service = OrganizationsService( + repo=org_repo, + transaction_repo=transaction_repo, + redis_balance_service=redis_balance_service, + ) + compliance_report_repo = ComplianceReportRepository(db=session) + compliance_report_service = ComplianceReportServices( + repo=compliance_report_repo + ) + user = await UserRepository(db=session).get_user_by_id(user_id) + + if action == "Created": + await self._handle_created( + org_id, + legacy_id, + compliance_period, + nickname, + user, + compliance_report_service, + ) + elif action == "Submitted": + await self._handle_submitted( + compliance_report_repo, + compliance_units, + legacy_id, + org_id, + org_service, + session, + user, + ) + elif action == "Approved": + await self._handle_approved( + legacy_id, + compliance_report_repo, + transaction_repo, + user, + session, + ) + + async def _handle_created( + self, + org_id: int, + legacy_id: int, + compliance_period: str, + nickname: str, + user: UserProfile, + compliance_report_service: ComplianceReportServices, + ): + """ + Handle the 'Created' action by creating a new compliance report draft. + """ + lcfs_report = ComplianceReportCreateSchema( + legacy_id=legacy_id, + compliance_period=compliance_period, + organization_id=org_id, + nickname=nickname, + status=ComplianceReportStatusEnum.Draft.value, + ) + await compliance_report_service.create_compliance_report( + org_id, lcfs_report, user + ) + + async def _handle_approved( + self, + legacy_id: int, + compliance_report_repo: ComplianceReportRepository, + transaction_repo: TransactionRepository, + user: UserProfile, + session: AsyncSession, + ): + """ + Handle the 'Approved' action by updating the report status to 'Assessed' + and confirming the associated transaction. + """ + existing_report = ( + await compliance_report_repo.get_compliance_report_by_legacy_id(legacy_id) + ) + if not existing_report: + raise ServiceException( + f"No compliance report found for legacy ID {legacy_id}" + ) + + new_status = await compliance_report_repo.get_compliance_report_status_by_desc( + ComplianceReportStatusEnum.Assessed.value + ) + existing_report.current_status_id = new_status.compliance_report_status_id + session.add(existing_report) + await session.flush() + + await compliance_report_repo.add_compliance_report_history( + existing_report, user + ) + + existing_transaction = await transaction_repo.get_transaction_by_id( + existing_report.transaction_id + ) + if not existing_transaction: + raise ServiceException( + "Compliance Report does not have an associated transaction" + ) + + if existing_transaction.transaction_action != TransactionActionEnum.Reserved: + raise ServiceException( + f"Transaction {existing_transaction.transaction_id} is not in 'Reserved' status" + ) + + await transaction_repo.confirm_transaction(existing_transaction.transaction_id) + + async def _handle_submitted( + self, + compliance_report_repo: ComplianceReportRepository, + compliance_units: int, + legacy_id: int, + org_id: int, + org_service: OrganizationsService, + session: AsyncSession, + user: UserProfile, + ): + """ + Handle the 'Submitted' action by linking a reserved transaction + to the compliance report and updating its status. + """ + existing_report = ( + await compliance_report_repo.get_compliance_report_by_legacy_id(legacy_id) + ) + if not existing_report: + raise ServiceException( + f"No compliance report found for legacy ID {legacy_id}" + ) + + transaction = await org_service.adjust_balance( + TransactionActionEnum.Reserved, compliance_units, org_id + ) + existing_report.transaction_id = transaction.transaction_id + + new_status = await compliance_report_repo.get_compliance_report_status_by_desc( + ComplianceReportStatusEnum.Submitted.value + ) + existing_report.current_status_id = new_status.compliance_report_status_id + session.add(existing_report) + await session.flush() + + await compliance_report_repo.add_compliance_report_history( + existing_report, user + ) diff --git a/backend/lcfs/services/rabbitmq/transaction_consumer.py b/backend/lcfs/services/rabbitmq/transaction_consumer.py deleted file mode 100644 index 381142d6c..000000000 --- a/backend/lcfs/services/rabbitmq/transaction_consumer.py +++ /dev/null @@ -1,71 +0,0 @@ -import asyncio -import json -import logging - -from redis.asyncio import Redis -from sqlalchemy.ext.asyncio import AsyncSession -from lcfs.services.redis.dependency import get_redis_client -from fastapi import Request - -from lcfs.db.dependencies import async_engine -from lcfs.db.models.transaction.Transaction import TransactionActionEnum -from lcfs.services.rabbitmq.base_consumer import BaseConsumer -from lcfs.services.tfrs.redis_balance import RedisBalanceService -from lcfs.settings import settings -from lcfs.web.api.organizations.repo import OrganizationsRepository -from lcfs.web.api.organizations.services import OrganizationsService -from lcfs.web.api.transaction.repo import TransactionRepository - -logger = logging.getLogger(__name__) -consumer = None -consumer_task = None - - -async def setup_transaction_consumer(): - global consumer, consumer_task - consumer = TransactionConsumer() - await consumer.connect() - consumer_task = asyncio.create_task(consumer.start_consuming()) - - -async def close_transaction_consumer(): - global consumer, consumer_task - - if consumer_task: - consumer_task.cancel() - - if consumer: - await consumer.close_connection() - - -class TransactionConsumer(BaseConsumer): - def __init__( - self, - queue_name=settings.rabbitmq_transaction_queue, - ): - super().__init__(queue_name) - - async def process_message(self, body: bytes, request: Request): - message_content = json.loads(body.decode()) - compliance_units = message_content.get("compliance_units_amount") - org_id = message_content.get("organization_id") - - redis_client = await get_redis_client(request) - - async with AsyncSession(async_engine) as session: - async with session.begin(): - repo = OrganizationsRepository(db=session) - transaction_repo = TransactionRepository(db=session) - redis_balance_service = RedisBalanceService( - transaction_repo=transaction_repo, redis_client=redis_client - ) - org_service = OrganizationsService( - repo=repo, - transaction_repo=transaction_repo, - redis_balance_service=redis_balance_service, - ) - - await org_service.adjust_balance( - TransactionActionEnum.Adjustment, compliance_units, org_id - ) - logger.debug(f"Processed Transaction from TFRS for Org {org_id}") diff --git a/backend/lcfs/tests/compliance_report/test_compliance_report_repo.py b/backend/lcfs/tests/compliance_report/test_compliance_report_repo.py index c26603dd8..84ed2520b 100644 --- a/backend/lcfs/tests/compliance_report/test_compliance_report_repo.py +++ b/backend/lcfs/tests/compliance_report/test_compliance_report_repo.py @@ -558,7 +558,7 @@ async def test_add_compliance_report_success( version=1, ) - report = await compliance_report_repo.add_compliance_report(report=new_report) + report = await compliance_report_repo.create_compliance_report(report=new_report) assert isinstance(report, ComplianceReportBaseSchema) assert report.compliance_period_id == compliance_periods[0].compliance_period_id @@ -577,7 +577,7 @@ async def test_add_compliance_report_exception( new_report = ComplianceReport() with pytest.raises(DatabaseException): - await compliance_report_repo.add_compliance_report(report=new_report) + await compliance_report_repo.create_compliance_report(report=new_report) @pytest.mark.anyio diff --git a/backend/lcfs/tests/compliance_report/test_compliance_report_services.py b/backend/lcfs/tests/compliance_report/test_compliance_report_services.py index 3237762be..9300d2918 100644 --- a/backend/lcfs/tests/compliance_report/test_compliance_report_services.py +++ b/backend/lcfs/tests/compliance_report/test_compliance_report_services.py @@ -4,6 +4,7 @@ from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatus from lcfs.web.exception.exceptions import ServiceException, DataNotFoundException + # get_all_compliance_periods @pytest.mark.anyio async def test_get_all_compliance_periods_success(compliance_report_service, mock_repo): @@ -41,6 +42,8 @@ async def test_create_compliance_report_success( compliance_report_base_schema, compliance_report_create_schema, ): + mock_user = MagicMock() + # Mock the compliance period mock_compliance_period = CompliancePeriod( compliance_period_id=1, @@ -57,10 +60,10 @@ async def test_create_compliance_report_success( # Mock the added compliance report mock_compliance_report = compliance_report_base_schema() - mock_repo.add_compliance_report.return_value = mock_compliance_report + mock_repo.create_compliance_report.return_value = mock_compliance_report result = await compliance_report_service.create_compliance_report( - 1, compliance_report_create_schema + 1, compliance_report_create_schema, mock_user ) assert result == mock_compliance_report @@ -70,14 +73,16 @@ async def test_create_compliance_report_success( mock_repo.get_compliance_report_status_by_desc.assert_called_once_with( compliance_report_create_schema.status ) - mock_repo.add_compliance_report.assert_called_once() + mock_repo.create_compliance_report.assert_called_once() @pytest.mark.anyio async def test_create_compliance_report_unexpected_error( compliance_report_service, mock_repo ): - mock_repo.add_compliance_report.side_effect = Exception("Unexpected error occurred") + mock_repo.create_compliance_report.side_effect = Exception( + "Unexpected error occurred" + ) with pytest.raises(ServiceException): await compliance_report_service.create_compliance_report( diff --git a/backend/lcfs/tests/services/rabbitmq/test_report_consumer.py b/backend/lcfs/tests/services/rabbitmq/test_report_consumer.py new file mode 100644 index 000000000..838c9fe0c --- /dev/null +++ b/backend/lcfs/tests/services/rabbitmq/test_report_consumer.py @@ -0,0 +1,218 @@ +import json +from contextlib import ExitStack +from unittest.mock import AsyncMock, patch, MagicMock + +import pytest +from pandas.io.formats.format import return_docstring + +from lcfs.db.models.transaction.Transaction import TransactionActionEnum, Transaction +from lcfs.services.rabbitmq.report_consumer import ( + ReportConsumer, +) +from lcfs.tests.fuel_export.conftest import mock_compliance_report_repo +from lcfs.web.api.compliance_report.schema import ComplianceReportCreateSchema + + +@pytest.fixture +def mock_app(): + """Fixture to provide a mocked FastAPI app.""" + return MagicMock() + + +@pytest.fixture +def mock_redis(): + """Fixture to mock Redis client.""" + return AsyncMock() + + +@pytest.fixture +def mock_session(): + # Create a mock session that behaves like an async context manager. + # Specifying `spec=AsyncSession` helps ensure it behaves like the real class. + from sqlalchemy.ext.asyncio import AsyncSession + + mock_session = AsyncMock(spec=AsyncSession) + + # `async with mock_session:` should work, so we define what happens on enter/exit + mock_session.__aenter__.return_value = mock_session + mock_session.__aexit__.return_value = None + + # Now mock the transaction context manager returned by `session.begin()` + mock_transaction = AsyncMock() + mock_transaction.__aenter__.return_value = mock_transaction + mock_transaction.__aexit__.return_value = None + mock_session.begin.return_value = mock_transaction + + return mock_session + + +@pytest.fixture +def mock_repositories(): + """Fixture to mock all repositories and services.""" + + mock_compliance_report_repo = MagicMock() + mock_compliance_report_repo.get_compliance_report_by_legacy_id = AsyncMock( + return_value=MagicMock() + ) + mock_compliance_report_repo.get_compliance_report_status_by_desc = AsyncMock( + return_value=MagicMock() + ) + mock_compliance_report_repo.add_compliance_report_history = AsyncMock() + + org_service = MagicMock() + org_service.adjust_balance = AsyncMock() + + mock_transaction_repo = MagicMock() + mock_transaction_repo.get_transaction_by_id = AsyncMock( + return_value=MagicMock( + spec=Transaction, transaction_action=TransactionActionEnum.Reserved + ) + ) + + return { + "compliance_report_repo": mock_compliance_report_repo, + "transaction_repo": mock_transaction_repo, + "user_repo": AsyncMock(), + "org_service": org_service, + "compliance_service": AsyncMock(), + } + + +@pytest.fixture +def setup_patches(mock_redis, mock_session, mock_repositories): + """Fixture to apply patches for dependencies.""" + with ExitStack() as stack: + stack.enter_context( + patch("redis.asyncio.Redis.from_url", return_value=mock_redis) + ) + + stack.enter_context( + patch( + "lcfs.services.rabbitmq.report_consumer.AsyncSession", + return_value=mock_session, + ) + ) + stack.enter_context( + patch("lcfs.services.rabbitmq.report_consumer.async_engine", MagicMock()) + ) + + stack.enter_context( + patch( + "lcfs.services.rabbitmq.report_consumer.ComplianceReportRepository", + return_value=mock_repositories["compliance_report_repo"], + ) + ) + stack.enter_context( + patch( + "lcfs.services.rabbitmq.report_consumer.TransactionRepository", + return_value=mock_repositories["transaction_repo"], + ) + ) + stack.enter_context( + patch( + "lcfs.services.rabbitmq.report_consumer.UserRepository", + return_value=mock_repositories["user_repo"], + ) + ) + stack.enter_context( + patch( + "lcfs.services.rabbitmq.report_consumer.OrganizationsService", + return_value=mock_repositories["org_service"], + ) + ) + stack.enter_context( + patch( + "lcfs.services.rabbitmq.report_consumer.ComplianceReportServices", + return_value=mock_repositories["compliance_service"], + ) + ) + yield stack + + +@pytest.mark.anyio +async def test_process_message_created(mock_app, setup_patches, mock_repositories): + consumer = ReportConsumer(mock_app) + + # Prepare a sample message for "Created" action + message = { + "tfrs_id": 123, + "organization_id": 1, + "compliance_period": "2023", + "nickname": "Test Report", + "action": "Created", + "user_id": 42, + } + body = json.dumps(message).encode() + + # Ensure correct mock setup + mock_user = MagicMock() + mock_repositories["user_repo"].get_user_by_id.return_value = mock_user + + await consumer.process_message(body) + + # Assertions for "Created" action + mock_repositories[ + "compliance_service" + ].create_compliance_report.assert_called_once_with( + 1, # org_id + ComplianceReportCreateSchema( + legacy_id=123, + compliance_period="2023", + organization_id=1, + nickname="Test Report", + status="Draft", + ), + mock_user, + ) + + +@pytest.mark.anyio +async def test_process_message_submitted(mock_app, setup_patches, mock_repositories): + consumer = ReportConsumer(mock_app) + + # Prepare a sample message for "Submitted" action + message = { + "tfrs_id": 123, + "organization_id": 1, + "compliance_period": "2023", + "nickname": "Test Report", + "action": "Submitted", + "credits": 50, + "user_id": 42, + } + body = json.dumps(message).encode() + + await consumer.process_message(body) + + # Assertions for "Submitted" action + mock_repositories[ + "compliance_report_repo" + ].get_compliance_report_by_legacy_id.assert_called_once_with(123) + mock_repositories["org_service"].adjust_balance.assert_called_once_with( + TransactionActionEnum.Reserved, 50, 1 + ) + mock_repositories[ + "compliance_report_repo" + ].add_compliance_report_history.assert_called_once() + + +@pytest.mark.anyio +async def test_process_message_approved(mock_app, setup_patches, mock_repositories): + consumer = ReportConsumer(mock_app) + + # Prepare a sample message for "Approved" action + message = { + "tfrs_id": 123, + "organization_id": 1, + "action": "Approved", + "user_id": 42, + } + body = json.dumps(message).encode() + + await consumer.process_message(body) + + # Assertions for "Approved" action + mock_repositories[ + "compliance_report_repo" + ].get_compliance_report_by_legacy_id.assert_called_once_with(123) + mock_repositories["transaction_repo"].confirm_transaction.assert_called_once() diff --git a/backend/lcfs/tests/services/rabbitmq/test_transaction_consumer.py b/backend/lcfs/tests/services/rabbitmq/test_transaction_consumer.py deleted file mode 100644 index 3bd8d539a..000000000 --- a/backend/lcfs/tests/services/rabbitmq/test_transaction_consumer.py +++ /dev/null @@ -1,111 +0,0 @@ -from contextlib import ExitStack - -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -import json - - -from lcfs.db.models.transaction.Transaction import TransactionActionEnum -from lcfs.services.rabbitmq.transaction_consumer import ( - setup_transaction_consumer, - close_transaction_consumer, - TransactionConsumer, - consumer, - consumer_task, -) - - -@pytest.mark.anyio -async def test_setup_transaction_consumer(): - with patch( - "lcfs.services.rabbitmq.transaction_consumer.TransactionConsumer" - ) as MockConsumer: - mock_consumer = MockConsumer.return_value - mock_consumer.connect = AsyncMock() - mock_consumer.start_consuming = AsyncMock() - - await setup_transaction_consumer() - - mock_consumer.connect.assert_called_once() - mock_consumer.start_consuming.assert_called_once() - - -@pytest.mark.anyio -async def test_close_transaction_consumer(): - with patch( - "lcfs.services.rabbitmq.transaction_consumer.TransactionConsumer" - ) as MockConsumer: - mock_consumer = MockConsumer.return_value - mock_consumer.connect = AsyncMock() - mock_consumer.start_consuming = AsyncMock() - mock_consumer.close_connection = AsyncMock() - - await setup_transaction_consumer() - - await close_transaction_consumer() - - mock_consumer.close_connection.assert_called_once() - - -@pytest.mark.anyio -async def test_process_message(): - mock_redis = AsyncMock() - mock_session = AsyncMock() - mock_repo = AsyncMock() - mock_redis_balance_service = AsyncMock() - adjust_balance = AsyncMock() - - with ExitStack() as stack: - stack.enter_context( - patch("redis.asyncio.Redis.from_url", return_value=mock_redis) - ) - stack.enter_context( - patch("sqlalchemy.ext.asyncio.AsyncSession", return_value=mock_session) - ) - stack.enter_context( - patch( - "lcfs.web.api.organizations.repo.OrganizationsRepository", - return_value=mock_repo, - ) - ) - stack.enter_context( - patch( - "lcfs.web.api.transaction.repo.TransactionRepository.calculate_available_balance", - side_effect=[100, 200, 150, 250, 300, 350], - ) - ) - stack.enter_context( - patch( - "lcfs.web.api.transaction.repo.TransactionRepository.calculate_reserved_balance", - side_effect=[100, 200, 150, 250, 300, 350], - ) - ) - stack.enter_context( - patch( - "lcfs.services.tfrs.redis_balance.RedisBalanceService", - return_value=mock_redis_balance_service, - ) - ) - stack.enter_context( - patch( - "lcfs.web.api.organizations.services.OrganizationsService.adjust_balance", - adjust_balance, - ) - ) - - # Create an instance of the consumer - consumer = TransactionConsumer() - - # Prepare a sample message - message = { - "compliance_units_amount": 100, - "organization_id": 1, - } - body = json.dumps(message).encode() - - mock_request = AsyncMock() - - await consumer.process_message(body, mock_request) - - # Assert that the organization service's adjust_balance method was called correctly - adjust_balance.assert_called_once_with(TransactionActionEnum.Adjustment, 100, 1) diff --git a/backend/lcfs/web/api/compliance_report/repo.py b/backend/lcfs/web/api/compliance_report/repo.py index 194afb8d0..1bac62843 100644 --- a/backend/lcfs/web/api/compliance_report/repo.py +++ b/backend/lcfs/web/api/compliance_report/repo.py @@ -2,6 +2,8 @@ from typing import List, Optional, Dict from collections import defaultdict from datetime import datetime + +from lcfs.db.models import UserProfile from lcfs.db.models.organization.Organization import Organization from lcfs.db.models.fuel.FuelType import FuelType from lcfs.db.models.fuel.FuelCategory import FuelCategory @@ -15,7 +17,6 @@ PaginationRequestSchema, apply_filter_conditions, get_field_for_filter, - get_enum_value, ) from lcfs.db.models.compliance import CompliancePeriod from lcfs.db.models.compliance.ComplianceReport import ( @@ -181,7 +182,9 @@ async def check_compliance_report( ) @repo_handler - async def get_compliance_report_status_by_desc(self, status: str) -> int: + async def get_compliance_report_status_by_desc( + self, status: str + ) -> ComplianceReportStatus: """ Retrieve the compliance report status ID from the database based on the description. Replaces spaces with underscores in the status description. @@ -266,7 +269,7 @@ async def get_assessed_compliance_report_by_period( return result @repo_handler - async def add_compliance_report(self, report: ComplianceReport): + async def create_compliance_report(self, report: ComplianceReport): """ Add a new compliance report to the database """ @@ -304,7 +307,9 @@ async def get_compliance_report_history(self, report: ComplianceReport): return history.scalar_one_or_none() @repo_handler - async def add_compliance_report_history(self, report: ComplianceReport, user): + async def add_compliance_report_history( + self, report: ComplianceReport, user: UserProfile + ): """ Add a new compliance report history record to the database """ @@ -823,3 +828,26 @@ async def get_latest_report_by_group_uuid( .limit(1) ) return result.scalars().first() + + async def get_compliance_report_by_legacy_id(self, legacy_id): + """ + Retrieve a compliance report from the database by ID + """ + result = await self.db.execute( + select(ComplianceReport) + .options( + joinedload(ComplianceReport.organization), + joinedload(ComplianceReport.compliance_period), + joinedload(ComplianceReport.current_status), + joinedload(ComplianceReport.summary), + joinedload(ComplianceReport.history).joinedload( + ComplianceReportHistory.status + ), + joinedload(ComplianceReport.history).joinedload( + ComplianceReportHistory.user_profile + ), + joinedload(ComplianceReport.transaction), + ) + .where(ComplianceReport.legacy_id == legacy_id) + ) + return result.scalars().unique().first() diff --git a/backend/lcfs/web/api/compliance_report/schema.py b/backend/lcfs/web/api/compliance_report/schema.py index 9eb215c53..34696dee2 100644 --- a/backend/lcfs/web/api/compliance_report/schema.py +++ b/backend/lcfs/web/api/compliance_report/schema.py @@ -148,7 +148,6 @@ class ComplianceReportBaseSchema(BaseSchema): current_status_id: int current_status: ComplianceReportStatusSchema transaction_id: Optional[int] = None - # transaction: Optional[TransactionBaseSchema] = None nickname: Optional[str] = None supplemental_note: Optional[str] = None reporting_frequency: Optional[ReportingFrequency] = None @@ -166,6 +165,8 @@ class ComplianceReportCreateSchema(BaseSchema): compliance_period: str organization_id: int status: str + legacy_id: Optional[int] = None + nickname: Optional[str] = None class ComplianceReportListSchema(BaseSchema): diff --git a/backend/lcfs/web/api/compliance_report/services.py b/backend/lcfs/web/api/compliance_report/services.py index 31993bc75..dac78edd9 100644 --- a/backend/lcfs/web/api/compliance_report/services.py +++ b/backend/lcfs/web/api/compliance_report/services.py @@ -27,10 +27,7 @@ class ComplianceReportServices: - def __init__( - self, request: Request = None, repo: ComplianceReportRepository = Depends() - ) -> None: - self.request = request + def __init__(self, repo: ComplianceReportRepository = Depends()) -> None: self.repo = repo @service_handler @@ -41,7 +38,10 @@ async def get_all_compliance_periods(self) -> List[CompliancePeriodSchema]: @service_handler async def create_compliance_report( - self, organization_id: int, report_data: ComplianceReportCreateSchema + self, + organization_id: int, + report_data: ComplianceReportCreateSchema, + user: UserProfile, ) -> ComplianceReportBaseSchema: """Creates a new compliance report.""" period = await self.repo.get_compliance_period(report_data.compliance_period) @@ -52,8 +52,7 @@ async def create_compliance_report( report_data.status ) if not draft_status: - raise DataNotFoundException( - f"Status '{report_data.status}' not found.") + raise DataNotFoundException(f"Status '{report_data.status}' not found.") # Generate a new group_uuid for the new report series group_uuid = str(uuid.uuid4()) @@ -65,15 +64,17 @@ async def create_compliance_report( reporting_frequency=ReportingFrequency.ANNUAL, compliance_report_group_uuid=group_uuid, # New group_uuid for the series version=0, # Start with version 0 - nickname="Original Report", + nickname=report_data.nickname or "Original Report", summary=ComplianceReportSummary(), # Create an empty summary object + legacy_id=report_data.legacy_id, + create_user=user.keycloak_username, ) # Add the new compliance report - report = await self.repo.add_compliance_report(report) + report = await self.repo.create_compliance_report(report) # Create the history record - await self.repo.add_compliance_report_history(report, self.request.user) + await self.repo.add_compliance_report_history(report, user) return ComplianceReportBaseSchema.model_validate(report) @@ -137,7 +138,7 @@ async def create_supplemental_report( ) # Add the new supplemental report - new_report = await self.repo.add_compliance_report(new_report) + new_report = await self.repo.create_compliance_report(new_report) # Create the history record for the new supplemental report await self.repo.add_compliance_report_history(new_report, user) @@ -228,8 +229,7 @@ async def get_compliance_report_by_id( if apply_masking: # Apply masking to each report in the chain - masked_chain = self._mask_report_status( - compliance_report_chain) + masked_chain = self._mask_report_status(compliance_report_chain) # Apply history masking to each report in the chain masked_chain = [ self._mask_report_status_for_history(report, apply_masking) diff --git a/backend/lcfs/web/api/organization/views.py b/backend/lcfs/web/api/organization/views.py index e175bf756..a33cdd984 100644 --- a/backend/lcfs/web/api/organization/views.py +++ b/backend/lcfs/web/api/organization/views.py @@ -33,7 +33,7 @@ ComplianceReportCreateSchema, ComplianceReportListSchema, CompliancePeriodSchema, - ChainedComplianceReportSchema + ChainedComplianceReportSchema, ) from lcfs.web.api.compliance_report.services import ComplianceReportServices from .services import OrganizationService @@ -56,8 +56,7 @@ async def get_org_users( request: Request, organization_id: int, - status: str = Query( - default="Active", description="Active or Inactive users list"), + status: str = Query(default="Active", description="Active or Inactive users list"), pagination: PaginationRequestSchema = Body(..., embed=False), response: Response = None, org_service: OrganizationService = Depends(), @@ -249,7 +248,9 @@ async def create_compliance_report( validate: OrganizationValidation = Depends(), ): await validate.create_compliance_report(organization_id, report_data) - return await report_service.create_compliance_report(organization_id, report_data) + return await report_service.create_compliance_report( + organization_id, report_data, request.user + ) @router.post( @@ -307,4 +308,6 @@ async def get_compliance_report_by_id( This endpoint returns the information of a user by ID, including their roles and organization. """ await report_validate.validate_organization_access(report_id) - return await report_service.get_compliance_report_by_id(report_id, apply_masking=True, get_chain=True) + return await report_service.get_compliance_report_by_id( + report_id, apply_masking=True, get_chain=True + ) diff --git a/backend/lcfs/web/api/organizations/services.py b/backend/lcfs/web/api/organizations/services.py index e8ef43620..35c2155a3 100644 --- a/backend/lcfs/web/api/organizations/services.py +++ b/backend/lcfs/web/api/organizations/services.py @@ -16,6 +16,7 @@ OrganizationStatus, OrgStatusEnum, ) +from lcfs.db.models.transaction import Transaction from lcfs.db.models.transaction.Transaction import TransactionActionEnum from lcfs.services.tfrs.redis_balance import ( RedisBalanceService, @@ -44,6 +45,7 @@ logger = structlog.get_logger(__name__) + class OrganizationsService: def __init__( self, @@ -198,7 +200,6 @@ async def update_organization( updated_organization = await self.repo.update_organization(organization) return updated_organization - @service_handler async def get_organization(self, organization_id: int): """handles fetching an organization""" @@ -400,7 +401,7 @@ async def adjust_balance( transaction_action: TransactionActionEnum, compliance_units: int, organization_id: int, - ): + ) -> Transaction: """ Adjusts an organization's balance based on the transaction action. diff --git a/backend/lcfs/web/api/transaction/repo.py b/backend/lcfs/web/api/transaction/repo.py index 7134e7332..861b1e32b 100644 --- a/backend/lcfs/web/api/transaction/repo.py +++ b/backend/lcfs/web/api/transaction/repo.py @@ -318,7 +318,7 @@ async def create_transaction( transaction_action: TransactionActionEnum, compliance_units: int, organization_id: int, - ): + ) -> Transaction: """ Creates and saves a new transaction to the database. diff --git a/backend/lcfs/web/lifetime.py b/backend/lcfs/web/lifetime.py index 5de67c16c..fbe7b0b6e 100644 --- a/backend/lcfs/web/lifetime.py +++ b/backend/lcfs/web/lifetime.py @@ -62,7 +62,7 @@ async def _startup() -> None: # noqa: WPS430 await init_org_balance_cache(app) # Setup RabbitMQ Listeners - await start_consumers() + await start_consumers(app) return _startup From a6635a17c02acf0b465275fc45b5d2660760b0db Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 13:49:41 -0800 Subject: [PATCH 47/55] ag-grid upgrade --- frontend/package-lock.json | 58 +++++++++++++++++++------------------- frontend/package.json | 10 +++---- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 129680549..0aa961047 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,11 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@ag-grid-community/client-side-row-model": "^32.0.2", - "@ag-grid-community/core": "^32.0.2", - "@ag-grid-community/csv-export": "^32.0.2", - "@ag-grid-community/react": "^32.0.2", - "@ag-grid-community/styles": "^32.0.2", + "@ag-grid-community/client-side-row-model": "^32.3.0", + "@ag-grid-community/core": "^32.3.0", + "@ag-grid-community/csv-export": "^32.3.0", + "@ag-grid-community/react": "^32.3.0", + "@ag-grid-community/styles": "^32.3.0", "@bcgov/bc-sans": "^2.1.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", @@ -119,49 +119,49 @@ "license": "MIT" }, "node_modules/@ag-grid-community/client-side-row-model": { - "version": "32.1.0", - "resolved": "https://registry.npmjs.org/@ag-grid-community/client-side-row-model/-/client-side-row-model-32.1.0.tgz", - "integrity": "sha512-R/IA3chA/w9fy6/EeZhi42PTwVnb6bNjGMah1GWGvuNDTvfbPO4X9r4nhOMj6YH483bO+C7pPb4EoLECx0dfRQ==", + "version": "32.3.3", + "resolved": "https://registry.npmjs.org/@ag-grid-community/client-side-row-model/-/client-side-row-model-32.3.3.tgz", + "integrity": "sha512-/6OFltj9qax/xfOcYMOKGFQRFTrPX8hrELfS2jChWwpo/+rpnnFqN2iUlIiAB1tDJZsi2ryl8S4UoFSTcEv/VA==", "dependencies": { - "@ag-grid-community/core": "32.1.0", + "@ag-grid-community/core": "32.3.3", "tslib": "^2.3.0" } }, "node_modules/@ag-grid-community/core": { - "version": "32.1.0", - "resolved": "https://registry.npmjs.org/@ag-grid-community/core/-/core-32.1.0.tgz", - "integrity": "sha512-fHpgSZa/aBjg2DdOzooDxILFZqxmxP8vsjRfeZVtqby19mTKwNAclE7Z6rWzOA0GYjgN9s8JwLFcNA5pvfswMg==", + "version": "32.3.3", + "resolved": "https://registry.npmjs.org/@ag-grid-community/core/-/core-32.3.3.tgz", + "integrity": "sha512-JMr5ahDjjl+pvQbBM1/VrfVFlioCVnMl1PKWc6MC1ENhpXT1+CPQdfhUEUw2VytOulQeQ4eeP0pFKPuBZ5Jn2g==", "dependencies": { - "ag-charts-types": "10.1.0", + "ag-charts-types": "10.3.3", "tslib": "^2.3.0" } }, "node_modules/@ag-grid-community/csv-export": { - "version": "32.1.0", - "resolved": "https://registry.npmjs.org/@ag-grid-community/csv-export/-/csv-export-32.1.0.tgz", - "integrity": "sha512-rtHY+MvfmzlRq3dH8prvoNPOmNrvSxZNDmxSYEGC/y12d6ucoAH+Q1cTksMx5d/LKrUXGCrd/jKoPEi9FSdkNA==", + "version": "32.3.3", + "resolved": "https://registry.npmjs.org/@ag-grid-community/csv-export/-/csv-export-32.3.3.tgz", + "integrity": "sha512-uu5BdegnQCpoySFbhd7n0/yK9mMoepZMN6o36DblPydLXCOLEqOuroIPqQv008slDOK676Pe/O6bMszY3/MUlQ==", "dependencies": { - "@ag-grid-community/core": "32.1.0", + "@ag-grid-community/core": "32.3.3", "tslib": "^2.3.0" } }, "node_modules/@ag-grid-community/react": { - "version": "32.1.0", - "resolved": "https://registry.npmjs.org/@ag-grid-community/react/-/react-32.1.0.tgz", - "integrity": "sha512-ObaMk+g5IpfuiHSNar56IhJ0dLKkHaeMQYI9H1JlJyf5+3IafY1DiuGZ5mZTU7GyfNBgmMuRWrUxwOyt0tp7Lw==", + "version": "32.3.3", + "resolved": "https://registry.npmjs.org/@ag-grid-community/react/-/react-32.3.3.tgz", + "integrity": "sha512-YU8nOMZjvJsrbbW41PT1jFZQw67p1RGvTk3W7w1dFmtzXFOoXzpB2pWf2jMxREyLYGvz2P9TwmfeHEM50osSPQ==", "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { - "@ag-grid-community/core": "32.1.0", - "react": "^16.3.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0" + "@ag-grid-community/core": "32.3.3", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@ag-grid-community/styles": { - "version": "32.1.0", - "resolved": "https://registry.npmjs.org/@ag-grid-community/styles/-/styles-32.1.0.tgz", - "integrity": "sha512-OjakLetS/zr0g5mJWpnjldk/RjGnl7Rv3I/5cGuvtgdmSgS+4FNZMr8ZmyR8Bl34s0RM63OSIphpVaFGlnJM4w==" + "version": "32.3.3", + "resolved": "https://registry.npmjs.org/@ag-grid-community/styles/-/styles-32.3.3.tgz", + "integrity": "sha512-QAJc1CPbmFsAAq5M/8r0IOm8HL4Fb3eVK6tZXKzV9zibIereBjUwvvJRaSJa8iwtTlgxCtaULAQyE2gJcctphA==" }, "node_modules/@ampproject/remapping": { "version": "2.3.0", @@ -8912,9 +8912,9 @@ } }, "node_modules/ag-charts-types": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-10.1.0.tgz", - "integrity": "sha512-pk9ft8hbgTXJ/thI/SEUR1BoauNplYExpcHh7tMOqVikoDsta1O15TB1ZL4XWnl4TPIzROBmONKsz7d8a2HBuQ==" + "version": "10.3.3", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-10.3.3.tgz", + "integrity": "sha512-8rmyquaTkwfP4Lzei/W/cbkq9wwEl8+grIo3z97mtxrMIXh9sHJK1oJipd/u08MmBZrca5Jjtn5F1+UNPu/4fQ==" }, "node_modules/agent-base": { "version": "7.1.1", diff --git a/frontend/package.json b/frontend/package.json index 136507a04..9d34e71a4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,11 +30,11 @@ ] }, "dependencies": { - "@ag-grid-community/client-side-row-model": "^32.0.2", - "@ag-grid-community/core": "^32.0.2", - "@ag-grid-community/csv-export": "^32.0.2", - "@ag-grid-community/react": "^32.0.2", - "@ag-grid-community/styles": "^32.0.2", + "@ag-grid-community/client-side-row-model": "^32.3.0", + "@ag-grid-community/core": "^32.3.0", + "@ag-grid-community/csv-export": "^32.3.0", + "@ag-grid-community/react": "^32.3.0", + "@ag-grid-community/styles": "^32.3.0", "@bcgov/bc-sans": "^2.1.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", From efa1fb36a987b242f47295d771d1f58d30ab497a Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Tue, 17 Dec 2024 13:35:50 -0800 Subject: [PATCH 48/55] Rebase Migration --- ...-25_5b374dd97469.py => 2024-12-17-12-25_5b374dd97469.py} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename backend/lcfs/db/migrations/versions/{2024-12-13-19-25_5b374dd97469.py => 2024-12-17-12-25_5b374dd97469.py} (89%) diff --git a/backend/lcfs/db/migrations/versions/2024-12-13-19-25_5b374dd97469.py b/backend/lcfs/db/migrations/versions/2024-12-17-12-25_5b374dd97469.py similarity index 89% rename from backend/lcfs/db/migrations/versions/2024-12-13-19-25_5b374dd97469.py rename to backend/lcfs/db/migrations/versions/2024-12-17-12-25_5b374dd97469.py index 304f2a83e..3c7475040 100644 --- a/backend/lcfs/db/migrations/versions/2024-12-13-19-25_5b374dd97469.py +++ b/backend/lcfs/db/migrations/versions/2024-12-17-12-25_5b374dd97469.py @@ -1,8 +1,8 @@ """Add legacy id to compliance reports Revision ID: 5b374dd97469 -Revises: 5d729face5ab -Create Date: 2024-12-13 19:25:32.076684 +Revises: f93546eaec61 +Create Date: 2024-17-13 12:25:32.076684 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "5b374dd97469" -down_revision = "5d729face5ab" +down_revision = "f93546eaec61" branch_labels = None depends_on = None From 0fc47d71877430be3aa86d85b32d39fffe106607 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 13:58:51 -0800 Subject: [PATCH 49/55] ag-grid upgrade --- frontend/package-lock.json | 10 +++++----- frontend/package.json | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0aa961047..fdce412f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,11 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@ag-grid-community/client-side-row-model": "^32.3.0", - "@ag-grid-community/core": "^32.3.0", - "@ag-grid-community/csv-export": "^32.3.0", - "@ag-grid-community/react": "^32.3.0", - "@ag-grid-community/styles": "^32.3.0", + "@ag-grid-community/client-side-row-model": "^32.3.3", + "@ag-grid-community/core": "^32.3.3", + "@ag-grid-community/csv-export": "^32.3.3", + "@ag-grid-community/react": "^32.3.3", + "@ag-grid-community/styles": "^32.3.3", "@bcgov/bc-sans": "^2.1.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", diff --git a/frontend/package.json b/frontend/package.json index 9d34e71a4..6e8783097 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,11 +30,11 @@ ] }, "dependencies": { - "@ag-grid-community/client-side-row-model": "^32.3.0", - "@ag-grid-community/core": "^32.3.0", - "@ag-grid-community/csv-export": "^32.3.0", - "@ag-grid-community/react": "^32.3.0", - "@ag-grid-community/styles": "^32.3.0", + "@ag-grid-community/client-side-row-model": "^32.3.3", + "@ag-grid-community/core": "^32.3.3", + "@ag-grid-community/csv-export": "^32.3.3", + "@ag-grid-community/react": "^32.3.3", + "@ag-grid-community/styles": "^32.3.3", "@bcgov/bc-sans": "^2.1.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", From 36458ea93e6229c301724d5bff235929b5cb42ee Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Tue, 17 Dec 2024 14:10:57 -0800 Subject: [PATCH 50/55] fix: correct column headings in Export Fuel table --- frontend/src/assets/locales/en/fuelExport.json | 4 ++-- frontend/src/views/FuelExports/_schema.jsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/assets/locales/en/fuelExport.json b/frontend/src/assets/locales/en/fuelExport.json index d5cf55dc6..3050cb2da 100644 --- a/frontend/src/assets/locales/en/fuelExport.json +++ b/frontend/src/assets/locales/en/fuelExport.json @@ -12,10 +12,10 @@ "fuelExportColLabels": { "complianceReportId": "Compliance Report ID", "fuelExportId": "Fuel export ID", - "fuelTypeId": "Fuel type", + "fuelType": "Fuel type", "exportDate": "Export date", "fuelTypeOther": "Fuel type other", - "fuelCategoryId": "Fuel catgory", + "fuelCategory": "Fuel catgory", "endUse": "End use", "provisionOfTheActId": "Determining carbon intensity", "fuelCode": "Fuel code", diff --git a/frontend/src/views/FuelExports/_schema.jsx b/frontend/src/views/FuelExports/_schema.jsx index d826fa567..60656d905 100644 --- a/frontend/src/views/FuelExports/_schema.jsx +++ b/frontend/src/views/FuelExports/_schema.jsx @@ -113,7 +113,7 @@ export const fuelExportColDefs = (optionsData, errors, gridReady) => [ { field: 'fuelType', headerComponent: RequiredHeader, - headerName: i18n.t('fuelExport:fuelExportColLabels.fuelTypeId'), + headerName: i18n.t('fuelExport:fuelExportColLabels.fuelType'), cellEditor: AutocompleteCellEditor, cellRenderer: (params) => params.value || @@ -182,7 +182,7 @@ export const fuelExportColDefs = (optionsData, errors, gridReady) => [ { field: 'fuelCategory', headerComponent: RequiredHeader, - headerName: i18n.t('fuelExport:fuelExportColLabels.fuelCategoryId'), + headerName: i18n.t('fuelExport:fuelExportColLabels.fuelCategory'), cellEditor: AutocompleteCellEditor, cellRenderer: (params) => params.value || From cef77fd16dd050a12647a5abbd27b1e8ef96f6d9 Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Tue, 17 Dec 2024 14:37:17 -0800 Subject: [PATCH 51/55] fix: relocate 'Comments to the Director' widget to correct position --- .../views/Transfers/AddEditViewTransfer.jsx | 19 ++++++++++++++++++- .../Transfers/components/TransferView.jsx | 12 ++---------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/frontend/src/views/Transfers/AddEditViewTransfer.jsx b/frontend/src/views/Transfers/AddEditViewTransfer.jsx index 2968b16a8..4dec7f977 100644 --- a/frontend/src/views/Transfers/AddEditViewTransfer.jsx +++ b/frontend/src/views/Transfers/AddEditViewTransfer.jsx @@ -7,7 +7,7 @@ import { useNavigate, useParams } from 'react-router-dom' -import { roles } from '@/constants/roles' +import { roles, govRoles } from '@/constants/roles' import { ROUTES } from '@/constants/routes' import { TRANSACTIONS } from '@/constants/routes/routes' import { TRANSFER_STATUSES } from '@/constants/statuses' @@ -47,6 +47,7 @@ import { buttonClusterConfigFn } from './buttonConfigs' import { CategoryCheckbox } from './components/CategoryCheckbox' import { Recommendation } from './components/Recommendation' import SigningAuthority from './components/SigningAuthority' +import InternalComments from '@/components/InternalComments' export const AddEditViewTransfer = () => { const queryClient = useQueryClient() @@ -444,6 +445,22 @@ export const AddEditViewTransfer = () => { )} + {/* Internal Comments */} + {!editorMode && ( + <> + + {transferId && ( + + + + )} + + + )} + {/* Signing Authority Confirmation show it to FromOrg user when in draft and ToOrg when in Sent status */} {(!currentStatus || (currentStatus === TRANSFER_STATUSES.DRAFT && diff --git a/frontend/src/views/Transfers/components/TransferView.jsx b/frontend/src/views/Transfers/components/TransferView.jsx index cd837d0e8..b44013d8f 100644 --- a/frontend/src/views/Transfers/components/TransferView.jsx +++ b/frontend/src/views/Transfers/components/TransferView.jsx @@ -1,7 +1,6 @@ import BCBox from '@/components/BCBox' -import InternalComments from '@/components/InternalComments' -import { Role } from '@/components/Role' -import { roles, govRoles } from '@/constants/roles' + +import { roles } from '@/constants/roles' import { TRANSFER_STATUSES, getAllTerminalTransferStatuses @@ -89,13 +88,6 @@ export const TransferView = ({ transferId, editorMode, transferData }) => { /> )} - {/* Internal Comments */} - - {transferId && ( - - )} - - {/* List of attachments */} {/* */} From 33d3ddf8fcc14f9d12195d760cd11e3215ac5913 Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Tue, 17 Dec 2024 14:59:39 -0800 Subject: [PATCH 52/55] feat: update FSE identification form explanation wording --- .../src/assets/locales/en/finalSupplyEquipment.json | 1 + .../AddEditFinalSupplyEquipments.jsx | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/assets/locales/en/finalSupplyEquipment.json b/frontend/src/assets/locales/en/finalSupplyEquipment.json index d48862f59..d8ed2de94 100644 --- a/frontend/src/assets/locales/en/finalSupplyEquipment.json +++ b/frontend/src/assets/locales/en/finalSupplyEquipment.json @@ -2,6 +2,7 @@ "fseTitle": "Final supply equipment (FSE)", "addFSErowsTitle": "Add new final supply equipment(s) (FSE)", "fseSubtitle": "Report dates of supply for your FSE. If your billing location is different from your equipment location provided below use the Notes field. Use the Notes field if you use any Other options.", + "reportingResponsibilityInfo": "If you are reporting on behalf of an FSE for which you hold allocated reporting responsibility, please list the utility account holder's organization name associated with the specific station, rather than your own organization's name.", "newFinalSupplyEquipmentBtn": "New final supply equipment", "noFinalSupplyEquipmentsFound": "No final supply equipments found", "finalSupplyEquipmentDownloadBtn": "Download as Excel", diff --git a/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx b/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx index 2244efea4..2e9285580 100644 --- a/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx +++ b/frontend/src/views/FinalSupplyEquipments/AddEditFinalSupplyEquipments.jsx @@ -282,11 +282,19 @@ export const AddEditFinalSupplyEquipments = () => { {t('finalSupplyEquipment:fseSubtitle')} + + {t('finalSupplyEquipment:reportingResponsibilityInfo')} +
Date: Tue, 17 Dec 2024 15:04:18 -0800 Subject: [PATCH 53/55] refactor: remove unnecessary React fragment wrapper --- .../views/Transfers/AddEditViewTransfer.jsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/src/views/Transfers/AddEditViewTransfer.jsx b/frontend/src/views/Transfers/AddEditViewTransfer.jsx index 4dec7f977..4dfb34c09 100644 --- a/frontend/src/views/Transfers/AddEditViewTransfer.jsx +++ b/frontend/src/views/Transfers/AddEditViewTransfer.jsx @@ -447,18 +447,16 @@ export const AddEditViewTransfer = () => { {/* Internal Comments */} {!editorMode && ( - <> - - {transferId && ( - - - - )} - - + + {transferId && ( + + + + )} + )} {/* Signing Authority Confirmation show it to FromOrg user when in draft and ToOrg when in Sent status */} From a458e4c2525814ff4d3587bc51cf801d80b86999 Mon Sep 17 00:00:00 2001 From: Kevin Hashimoto Date: Tue, 17 Dec 2024 13:44:45 -0800 Subject: [PATCH 54/55] feat: toast on 400+ errors --- frontend/package-lock.json | 39 ++++++++++++++++++++++++++ frontend/package.json | 1 + frontend/src/main.jsx | 7 +++-- frontend/src/services/useApiService.js | 20 +++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fdce412f9..3fd41e4ec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,6 +40,7 @@ "material-ui-popup-state": "^5.0.10", "moment": "^2.30.1", "mui-daterange-picker-plus": "^1.0.4", + "notistack": "^3.0.1", "papaparse": "^5.4.1", "pretty-bytes": "^6.1.1", "quill": "^2.0.2", @@ -13052,6 +13053,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -16449,6 +16458,36 @@ "node": ">=0.10.0" } }, + "node_modules/notistack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.1.tgz", + "integrity": "sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.0", + "goober": "^2.0.33" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/notistack" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/notistack/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6e8783097..d6bec5c8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -62,6 +62,7 @@ "material-ui-popup-state": "^5.0.10", "moment": "^2.30.1", "mui-daterange-picker-plus": "^1.0.4", + "notistack": "^3.0.1", "papaparse": "^5.4.1", "pretty-bytes": "^6.1.1", "quill": "^2.0.2", diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index acb541131..509ac3e8e 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -8,6 +8,7 @@ import { KeycloakProvider } from '@/components/KeycloakProvider' import { getKeycloak } from '@/utils/keycloak' import { LocalizationProvider } from '@mui/x-date-pickers' import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3' +import { SnackbarProvider } from 'notistack' const queryClient = new QueryClient() const keycloak = getKeycloak() @@ -20,8 +21,10 @@ if (root) { - - + + + + diff --git a/frontend/src/services/useApiService.js b/frontend/src/services/useApiService.js index a8525e2ae..31a34bb40 100644 --- a/frontend/src/services/useApiService.js +++ b/frontend/src/services/useApiService.js @@ -2,9 +2,11 @@ import { useMemo } from 'react' import axios from 'axios' import { useKeycloak } from '@react-keycloak/web' import { CONFIG } from '@/constants/config' +import { useSnackbar } from 'notistack' export const useApiService = (opts = {}) => { const { keycloak } = useKeycloak() + const { enqueueSnackbar, closeSnackbar } = useSnackbar() // useMemo to memoize the apiService instance const apiService = useMemo(() => { @@ -25,6 +27,24 @@ export const useApiService = (opts = {}) => { } ) + // Add response interceptor + instance.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status >= 400) { + console.error( + 'API Error:', + error.response.status, + error.response.data + ) + enqueueSnackbar(`${error.response.status} error`, { + autoHideDuration: 5000, + variant: 'error' + }) + } + } + ) + // Download method instance.download = async (url, params = {}) => { try { From 1ee64051a53d7f7eefe8db7a98bd469ecf7a2d7b Mon Sep 17 00:00:00 2001 From: Kevin Hashimoto Date: Tue, 17 Dec 2024 13:48:07 -0800 Subject: [PATCH 55/55] chore: remove unused methods --- frontend/src/services/useApiService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/services/useApiService.js b/frontend/src/services/useApiService.js index 31a34bb40..7b4fdca4b 100644 --- a/frontend/src/services/useApiService.js +++ b/frontend/src/services/useApiService.js @@ -6,7 +6,7 @@ import { useSnackbar } from 'notistack' export const useApiService = (opts = {}) => { const { keycloak } = useKeycloak() - const { enqueueSnackbar, closeSnackbar } = useSnackbar() + const { enqueueSnackbar } = useSnackbar() // useMemo to memoize the apiService instance const apiService = useMemo(() => {