From 6ce4accc12acb4ffbc56785ea7b0ebba414f5836 Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Thu, 14 Nov 2024 14:51:55 -0800 Subject: [PATCH] feat: Add versioning to other uses --- backend/lcfs/db/dependencies.py | 2 - .../versions/2024-11-13-00-39_654858661ae7.py | 100 +++++++++ .../lcfs/db/models/compliance/OtherUses.py | 4 +- backend/lcfs/tests/other_uses/__init__.py | 0 backend/lcfs/tests/other_uses/conftest.py | 49 +++++ .../tests/other_uses/test_other_uses_repo.py | 149 +++++++------- .../other_uses/test_other_uses_services.py | 189 ++++++++++-------- .../tests/other_uses/test_other_uses_view.py | 118 ++++------- backend/lcfs/web/api/other_uses/repo.py | 171 +++++++++++++--- backend/lcfs/web/api/other_uses/schema.py | 4 + backend/lcfs/web/api/other_uses/services.py | 187 +++++++++++------ backend/lcfs/web/api/other_uses/views.py | 27 ++- .../src/views/OtherUses/AddEditOtherUses.jsx | 1 + frontend/src/views/OtherUses/_schema.jsx | 52 +++++ 14 files changed, 699 insertions(+), 354 deletions(-) create mode 100644 backend/lcfs/db/migrations/versions/2024-11-13-00-39_654858661ae7.py create mode 100644 backend/lcfs/tests/other_uses/__init__.py create mode 100644 backend/lcfs/tests/other_uses/conftest.py diff --git a/backend/lcfs/db/dependencies.py b/backend/lcfs/db/dependencies.py index 8a7cd3d31..dfcd2f393 100644 --- a/backend/lcfs/db/dependencies.py +++ b/backend/lcfs/db/dependencies.py @@ -24,8 +24,6 @@ async def set_user_context(session: AsyncSession, username: str): try: await session.execute(text(f"SET SESSION app.username = '{username}'")) - logging.info(f"SET SESSION app.username = '{username}' executed successfully") - except Exception as e: logging.error(f"Failed to execute SET LOCAL app.user_id = '{username}': {e}") raise e diff --git a/backend/lcfs/db/migrations/versions/2024-11-13-00-39_654858661ae7.py b/backend/lcfs/db/migrations/versions/2024-11-13-00-39_654858661ae7.py new file mode 100644 index 000000000..ae5b454fc --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-11-13-00-39_654858661ae7.py @@ -0,0 +1,100 @@ +"""Add Versioning to Other Uses + +Revision ID: 654858661ae7 +Revises: b659816d0a86 +Create Date: 2024-11-13 00:39:22.594912 + +""" + +import uuid + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "654858661ae7" +down_revision = "b659816d0a86" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "other_uses", + sa.Column( + "group_uuid", + sa.String(length=36), + nullable=True, + comment="UUID that groups all versions of a record series", + ), + ) + op.add_column( + "other_uses", + sa.Column( + "version", + sa.Integer(), + nullable=False, + server_default="0", + comment="Version number of the record", + ), + ) + op.add_column( + "other_uses", + sa.Column( + "user_type", + postgresql.ENUM( + "SUPPLIER", "GOVERNMENT", name="usertypeenum", create_type=False + ), + nullable=False, + server_default=sa.text("'SUPPLIER'"), + comment="Indicates whether the record was created/modified by a supplier or government user", + ), + ) + op.add_column( + "other_uses", + sa.Column( + "action_type", + postgresql.ENUM( + "CREATE", "UPDATE", "DELETE", name="actiontypeenum", create_type=False + ), + server_default=sa.text("'CREATE'"), + nullable=True, + comment="Action type for this record", + ), + ) + + #Update existing records with generated UUIDs + connection = op.get_bind() + + # Update other_uses table + fuel_exports = connection.execute( + sa.text("SELECT other_uses_id FROM other_uses WHERE group_uuid IS NULL") + ).fetchall() + for export in fuel_exports: + export_id = export[0] + connection.execute( + sa.text( + "UPDATE other_uses SET group_uuid = :uuid WHERE other_uses_id = :export_id" + ), + {"uuid": str(uuid.uuid4()), "export_id": export_id}, + ) + + #Alter the column to be non-nullable + op.alter_column( + "other_uses", + "group_uuid", + existing_type=sa.String(length=36), + nullable=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("other_uses", "action_type") + op.drop_column("other_uses", "user_type") + op.drop_column("other_uses", "version") + op.drop_column("other_uses", "group_uuid") + # ### end Alembic commands ### diff --git a/backend/lcfs/db/models/compliance/OtherUses.py b/backend/lcfs/db/models/compliance/OtherUses.py index 006ca5ea4..973b84c46 100644 --- a/backend/lcfs/db/models/compliance/OtherUses.py +++ b/backend/lcfs/db/models/compliance/OtherUses.py @@ -1,9 +1,9 @@ from sqlalchemy import Column, Integer, String, ForeignKey from sqlalchemy.orm import relationship -from lcfs.db.base import BaseModel, Auditable +from lcfs.db.base import BaseModel, Auditable, Versioning -class OtherUses(BaseModel, Auditable): +class OtherUses(BaseModel, Auditable, Versioning): __tablename__ = "other_uses" __table_args__ = { "comment": "Records other uses of fuels that are subject to renewable requirements but do not earn credits." diff --git a/backend/lcfs/tests/other_uses/__init__.py b/backend/lcfs/tests/other_uses/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/lcfs/tests/other_uses/conftest.py b/backend/lcfs/tests/other_uses/conftest.py new file mode 100644 index 000000000..706cbb695 --- /dev/null +++ b/backend/lcfs/tests/other_uses/conftest.py @@ -0,0 +1,49 @@ +from unittest.mock import MagicMock + +from lcfs.db.base import UserTypeEnum, ActionTypeEnum +from lcfs.db.models import OtherUses +from lcfs.web.api.other_uses.schema import OtherUsesCreateSchema + + +def create_mock_entity(overrides: dict): + mock_entity = MagicMock(spec=OtherUses) + mock_entity.other_uses_id = 1 + mock_entity.compliance_report_id = 1 + mock_entity.fuel_type.fuel_type = "Gasoline" + mock_entity.fuel_category.category = "Petroleum-based" + mock_entity.expected_use.name = "Transportation" + mock_entity.units = "L" + mock_entity.rationale = "Test rationale" + mock_entity.group_uuid = "test-group-uuid" + mock_entity.version = 1 + mock_entity.quantity_supplied = 1000 + mock_entity.action_type = ActionTypeEnum.CREATE + mock_entity.user_type = UserTypeEnum.SUPPLIER + + # Apply overrides + if overrides: + for key, value in overrides.items(): + setattr(mock_entity, key, value) + + return mock_entity + + +def create_mock_schema(overrides: dict): + mock_schema = OtherUsesCreateSchema( + compliance_report_id=1, + quantity_supplied=1000, + fuel_type="Gasoline", + fuel_category="Petroleum-based", + expected_use="Transportation", + units="L", + rationale="Test rationale", + group_uuid="test-group-uuid", + version=1, + ) + + # Apply overrides + if overrides: + for key, value in overrides.items(): + setattr(mock_schema, key, value) + + return mock_schema diff --git a/backend/lcfs/tests/other_uses/test_other_uses_repo.py b/backend/lcfs/tests/other_uses/test_other_uses_repo.py index 58f148719..3690a73ed 100644 --- a/backend/lcfs/tests/other_uses/test_other_uses_repo.py +++ b/backend/lcfs/tests/other_uses/test_other_uses_repo.py @@ -2,10 +2,12 @@ from unittest.mock import MagicMock, AsyncMock from sqlalchemy.ext.asyncio import AsyncSession +from lcfs.db.base import UserTypeEnum from lcfs.db.models.compliance import OtherUses from lcfs.web.api.other_uses.repo import OtherUsesRepository from lcfs.web.api.other_uses.schema import OtherUsesSchema -from lcfs.web.api.base import PaginationRequestSchema +from lcfs.tests.other_uses.conftest import create_mock_entity + @pytest.fixture def mock_db_session(): @@ -18,6 +20,7 @@ def mock_db_session(): session.execute.return_value = execute_result return session + @pytest.fixture def other_uses_repo(mock_db_session): repo = OtherUsesRepository(db=mock_db_session) @@ -27,6 +30,7 @@ def other_uses_repo(mock_db_session): repo.fuel_code_repo.get_expected_use_types = AsyncMock(return_value=[]) return repo + @pytest.mark.anyio async def test_get_table_options(other_uses_repo): result = await other_uses_repo.get_table_options() @@ -37,20 +41,28 @@ async def test_get_table_options(other_uses_repo): assert "units_of_measure" in result assert "expected_uses" in result + @pytest.mark.anyio async def test_get_other_uses(other_uses_repo, mock_db_session): compliance_report_id = 1 - mock_other_use = MagicMock(spec=OtherUses) - mock_other_use.other_uses_id = 1 - mock_other_use.compliance_report_id = compliance_report_id - mock_other_use.quantity_supplied = 1000 - mock_other_use.fuel_type.fuel_type = "Gasoline" - mock_other_use.fuel_category.category = "Petroleum-based" - mock_other_use.expected_use.name = "Transportation" - mock_other_use.units = "L" - mock_other_use.rationale = "Test rationale" - mock_result = [mock_other_use] - mock_db_session.execute.return_value.unique.return_value.scalars.return_value.all.return_value = mock_result + mock_other_use = create_mock_entity({}) + mock_result_other_uses = [mock_other_use] + mock_compliance_report_uuid = "mock_group_uuid" + + # Mock the first db.execute call for fetching compliance report group UUID + mock_first_execute = MagicMock() + mock_first_execute.scalar.return_value = mock_compliance_report_uuid + + # Mock the second db.execute call for fetching other uses + mock_second_execute = MagicMock() + mock_second_execute.unique.return_value.scalars.return_value.all.return_value = ( + mock_result_other_uses + ) + + # Assign side effects to return these mocked execute calls in sequence + mock_db_session.execute = AsyncMock( + side_effect=[mock_first_execute, mock_second_execute] + ) result = await other_uses_repo.get_other_uses(compliance_report_id) @@ -61,93 +73,68 @@ async def test_get_other_uses(other_uses_repo, mock_db_session): assert result[0].fuel_category == "Petroleum-based" assert result[0].expected_use == "Transportation" + @pytest.mark.anyio -async def test_get_other_uses_paginated(other_uses_repo, mock_db_session): - pagination = PaginationRequestSchema(page=1, size=10) - compliance_report_id = 1 +async def test_get_latest_other_uses_by_group_uuid(other_uses_repo, mock_db_session): + group_uuid = "test-group-uuid" + mock_other_use_gov = MagicMock(spec=OtherUses) + mock_other_use_gov.user_type = UserTypeEnum.GOVERNMENT + mock_other_use_gov.version = 2 - # Create a mock OtherUsesSchema instance - mock_other_use = MagicMock(spec=OtherUsesSchema) - mock_other_use.other_uses_id = 1 - mock_other_use.compliance_report_id = compliance_report_id - mock_other_use.quantity_supplied = 1000 - mock_other_use.fuel_type = "Gasoline" - mock_other_use.fuel_category = "Petroleum-based" - mock_other_use.expected_use = "Transportation" - mock_other_use.units = "L" - mock_other_use.rationale = "Test rationale" - mock_result = [mock_other_use] + mock_other_use_supplier = MagicMock(spec=OtherUses) + mock_other_use_supplier.user_type = UserTypeEnum.SUPPLIER + mock_other_use_supplier.version = 3 - # Mock the result of the count query - mock_count_result = MagicMock() - mock_count_result.scalar.return_value = 1 + # Mock response with both government and supplier versions + mock_db_session.execute.return_value.scalars.return_value.first.side_effect = [ + mock_other_use_gov, + mock_other_use_supplier, + ] - # Mock the result of the main query - mock_main_result = MagicMock() - mock_main_result.unique.return_value.scalars.return_value.all.return_value = mock_result + result = await other_uses_repo.get_latest_other_uses_by_group_uuid(group_uuid) - # Configure the execute method to return different results based on the call sequence - mock_db_session.execute = AsyncMock(side_effect=[mock_count_result, mock_main_result]) + assert result.user_type == UserTypeEnum.GOVERNMENT + assert result.version == 2 - # Call the repository method - result, total_count = await other_uses_repo.get_other_uses_paginated(pagination, compliance_report_id) - # Assertions - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], OtherUsesSchema) - assert result[0].fuel_type == "Gasoline" - assert result[0].fuel_category == "Petroleum-based" - assert result[0].expected_use == "Transportation" - assert isinstance(total_count, int) - assert total_count == 1 +@pytest.mark.anyio +async def test_get_other_use_version_by_user(other_uses_repo, mock_db_session): + group_uuid = "test-group-uuid" + version = 2 + user_type = UserTypeEnum.SUPPLIER + + mock_other_use = MagicMock(spec=OtherUses) + mock_other_use.group_uuid = group_uuid + mock_other_use.version = version + mock_other_use.user_type = user_type + + mock_db_session.execute.return_value.scalars.return_value.first.return_value = ( + mock_other_use + ) + + result = await other_uses_repo.get_other_use_version_by_user( + group_uuid, version, user_type + ) + + assert result.group_uuid == group_uuid + assert result.version == version + assert result.user_type == user_type + @pytest.mark.anyio async def test_update_other_use(other_uses_repo, mock_db_session): - updated_other_use = MagicMock(spec=OtherUses) - updated_other_use.other_uses_id = 1 - updated_other_use.compliance_report_id = 1 + updated_other_use = create_mock_entity({}) updated_other_use.quantity_supplied = 2000 updated_other_use.fuel_type.fuel_type = "Diesel" - updated_other_use.fuel_category.category = "Petroleum-based" - updated_other_use.expected_use.name = "Transportation" - updated_other_use.units = "L" updated_other_use.rationale = "Updated rationale" + mock_db_session.flush = AsyncMock() mock_db_session.refresh = AsyncMock() mock_db_session.merge.return_value = updated_other_use result = await other_uses_repo.update_other_use(updated_other_use) - assert isinstance(result, OtherUses) - assert result.fuel_type.fuel_type == "Diesel" - assert result.fuel_category.category == "Petroleum-based" - assert result.expected_use.name == "Transportation" - -@pytest.mark.anyio -async def test_get_other_use(other_uses_repo, mock_db_session): - other_uses_id = 1 - - # Create a mock OtherUses instance - mock_result = MagicMock(spec=OtherUses) - mock_result.other_uses_id = other_uses_id - mock_result.compliance_report_id = 1 - mock_result.quantity_supplied = 1000 - mock_result.fuel_type.fuel_type = "Gasoline" - mock_result.fuel_category.category = "Petroleum-based" - mock_result.expected_use.name = "Transportation" - mock_result.units = "L" - mock_result.rationale = "Test rationale" - - # Configure the scalar method to return the mock_result - mock_db_session.scalar = AsyncMock(return_value=mock_result) - - # Call the repository method - result = await other_uses_repo.get_other_use(other_uses_id) - # Assertions assert isinstance(result, OtherUses) - assert result.other_uses_id == other_uses_id - assert result.fuel_type.fuel_type == "Gasoline" - assert result.fuel_category.category == "Petroleum-based" - assert result.expected_use.name == "Transportation" \ No newline at end of file + assert mock_db_session.flush.call_count == 1 + assert mock_db_session.flush.call_count == 1 diff --git a/backend/lcfs/tests/other_uses/test_other_uses_services.py b/backend/lcfs/tests/other_uses/test_other_uses_services.py index 0f6192744..4d701e4a5 100644 --- a/backend/lcfs/tests/other_uses/test_other_uses_services.py +++ b/backend/lcfs/tests/other_uses/test_other_uses_services.py @@ -1,18 +1,18 @@ -import pytest from unittest.mock import MagicMock, AsyncMock -from fastapi import HTTPException +import pytest + +from lcfs.db.base import ActionTypeEnum +from lcfs.db.base import UserTypeEnum from lcfs.web.api.other_uses.repo import OtherUsesRepository +from lcfs.web.api.other_uses.schema import OtherUsesSchema from lcfs.web.api.other_uses.schema import ( - OtherUsesCreateSchema, OtherUsesTableOptionsSchema, - OtherUsesSchema, - OtherUsesListSchema, - OtherUsesAllSchema, ) from lcfs.web.api.other_uses.services import OtherUsesServices -from lcfs.web.api.base import PaginationRequestSchema from lcfs.web.exception.exceptions import ServiceException +from lcfs.tests.other_uses.conftest import create_mock_schema, create_mock_entity + @pytest.fixture def other_uses_service(): @@ -21,6 +21,7 @@ def other_uses_service(): service = OtherUsesServices(repo=mock_repo, fuel_repo=mock_fuel_repo) return service, mock_repo, mock_fuel_repo + @pytest.mark.anyio async def test_get_table_options(other_uses_service): service, mock_repo, _ = other_uses_service @@ -38,39 +39,26 @@ async def test_get_table_options(other_uses_service): assert isinstance(response, OtherUsesTableOptionsSchema) mock_repo.get_table_options.assert_awaited_once() + @pytest.mark.anyio async def test_create_other_use(other_uses_service): service, mock_repo, mock_fuel_repo = other_uses_service - other_use_data = OtherUsesCreateSchema( - compliance_report_id=1, - quantity_supplied=1000, - fuel_type="Gasoline", - fuel_category="Petroleum-based", - expected_use="Transportation", - units="L", - rationale="Test rationale", + other_use_data = create_mock_schema({}) + mock_fuel_repo.get_fuel_category_by_name = AsyncMock( + return_value=MagicMock(fuel_category_id=1) + ) + mock_fuel_repo.get_fuel_type_by_name = AsyncMock( + return_value=MagicMock(fuel_type_id=1) + ) + mock_fuel_repo.get_expected_use_type_by_name = AsyncMock( + return_value=MagicMock(expected_use_type_id=1) ) - mock_fuel_repo.get_fuel_category_by_name = AsyncMock(return_value=MagicMock(fuel_category_id=1)) - mock_fuel_repo.get_fuel_type_by_name = AsyncMock(return_value=MagicMock(fuel_type_id=1)) - mock_fuel_repo.get_expected_use_type_by_name = AsyncMock(return_value=MagicMock(expected_use_type_id=1)) - - mock_created_use = MagicMock() - mock_created_use.other_uses_id = 1 - mock_created_use.compliance_report_id = 1 - mock_created_use.quantity_supplied = 1000 - mock_created_use.fuel_type.fuel_type = "Gasoline" - mock_created_use.fuel_category.category = "Petroleum-based" - mock_created_use.expected_use.name = "Transportation" - mock_created_use.units = "L" - mock_created_use.rationale = "Test rationale" + mock_created_use = create_mock_entity({}) mock_repo.create_other_use = AsyncMock(return_value=mock_created_use) - try: - response = await service.create_other_use(other_use_data) - except ServiceException: - pytest.fail("ServiceException was raised unexpectedly") - + response = await service.create_other_use(other_use_data, UserTypeEnum.SUPPLIER) + assert isinstance(response, OtherUsesSchema) assert response.fuel_type == "Gasoline" assert response.fuel_category == "Petroleum-based" @@ -78,74 +66,103 @@ async def test_create_other_use(other_uses_service): mock_repo.create_other_use.assert_awaited_once() + @pytest.mark.anyio async def test_update_other_use(other_uses_service): service, mock_repo, mock_fuel_repo = other_uses_service - other_use_data = OtherUsesCreateSchema( - other_uses_id=1, - compliance_report_id=1, - quantity_supplied=2000, - fuel_type="Diesel", - fuel_category="Petroleum-based", - expected_use="Transportation", - units="L", - rationale="Updated rationale", + + # Create test data with OtherUsesCreateSchema + other_use_data = create_mock_schema( + {"quantity_supplied": 4444, "rationale": "Updated rationale"} ) + mock_existing_use = create_mock_entity({}) - mock_existing_use = MagicMock() - mock_existing_use.other_uses_id = 1 - mock_existing_use.compliance_report_id = 1 - mock_existing_use.quantity_supplied = 1000 - mock_existing_use.fuel_type.fuel_type = "Gasoline" - mock_existing_use.fuel_category.category = "Petroleum-based" - mock_existing_use.expected_use.name = "Transportation" - mock_existing_use.units = "L" - mock_existing_use.rationale = "Test rationale" - - mock_repo.get_other_use = AsyncMock(return_value=mock_existing_use) - - mock_fuel_repo.get_fuel_type_by_name = AsyncMock(return_value=MagicMock(fuel_type_id=2)) - mock_fuel_repo.get_fuel_category_by_name = AsyncMock(return_value=MagicMock(fuel_category_id=1)) - mock_fuel_repo.get_expected_use_type_by_name = AsyncMock(return_value=MagicMock(expected_use_type_id=1)) - - mock_updated_use = MagicMock() - mock_updated_use.other_uses_id = 1 - mock_updated_use.compliance_report_id = 1 - mock_updated_use.quantity_supplied = 2000 - mock_updated_use.fuel_type.fuel_type = "Diesel" - mock_updated_use.fuel_category.category = "Petroleum-based" - mock_updated_use.expected_use.name = "Transportation" - mock_updated_use.units = "L" - mock_updated_use.rationale = "Updated rationale" + # Configure repository methods to return these mocked objects + mock_repo.get_other_use_version_by_user = AsyncMock(return_value=mock_existing_use) + mock_fuel_repo.get_fuel_type_by_name = AsyncMock( + return_value=MagicMock(fuel_type="Diesel") + ) + mock_fuel_repo.get_fuel_category_by_name = AsyncMock( + return_value=MagicMock(category="Petroleum-based") + ) + mock_fuel_repo.get_expected_use_type_by_name = AsyncMock( + return_value=MagicMock(name="Transportation") + ) + # Mock the updated use that will be returned after the update + mock_updated_use = create_mock_entity( + { + "rationale": "Updated rationale", + "action_type": ActionTypeEnum.UPDATE, + "quantity_supplied": 2222, + } + ) + # Set the return value for update_other_use mock_repo.update_other_use = AsyncMock(return_value=mock_updated_use) - try: - response = await service.update_other_use(other_use_data) - except ServiceException: - pytest.fail("ServiceException was raised unexpectedly") + # Execute the update function and capture the response + response = await service.update_other_use(other_use_data, UserTypeEnum.SUPPLIER) + # Assertions assert isinstance(response, OtherUsesSchema) - assert response.fuel_type == "Diesel" - assert response.fuel_category == "Petroleum-based" - assert response.expected_use == "Transportation" + assert response.action_type == ActionTypeEnum.UPDATE.value + assert response.quantity_supplied == 2222 + assert response.rationale == "Updated rationale" + # Check that the update method was called mock_repo.update_other_use.assert_awaited_once() + mock_repo.get_other_use_version_by_user.assert_awaited_once() + @pytest.mark.anyio async def test_update_other_use_not_found(other_uses_service): service, mock_repo, _ = other_uses_service - other_use_data = OtherUsesCreateSchema( - other_uses_id=1, - compliance_report_id=1, - quantity_supplied=2000, - fuel_type="Diesel", - fuel_category="Petroleum-based", - expected_use="Transportation", - units="L", - rationale="Updated rationale", - ) - mock_repo.get_other_use = AsyncMock(return_value=None) + other_use_data = create_mock_schema({}) + + mock_repo.get_other_use_version_by_user = AsyncMock(return_value=None) with pytest.raises(ServiceException): - await service.update_other_use(other_use_data) \ No newline at end of file + await service.update_other_use(other_use_data, UserTypeEnum.SUPPLIER) + + +@pytest.mark.anyio +async def test_delete_other_use(other_uses_service): + service, mock_repo, _ = other_uses_service + other_use_data = create_mock_schema({}) + + # Mock the existing other use with a "CREATE" action type + mock_existing_use = MagicMock() + mock_existing_use.group_uuid = "test-group-uuid" + mock_existing_use.version = 1 + mock_existing_use.action_type = ActionTypeEnum.CREATE + + # Set up the mock __table__.columns.keys() to return field names as a list + mock_existing_use.__table__ = MagicMock() + mock_existing_use.__table__.columns.keys.return_value = [ + "other_uses_id", + "compliance_report_id", + "quantity_supplied", + "fuel_type", + "fuel_category", + "expected_use", + "units", + "rationale", + "deleted", + "group_uuid", + "user_type", + "version", + "action_type", + ] + + # Mock repository methods + mock_repo.get_latest_other_uses_by_group_uuid = AsyncMock( + return_value=mock_existing_use + ) + mock_repo.create_other_use = AsyncMock(return_value=mock_existing_use) + + # Call the delete service + response = await service.delete_other_use(other_use_data, UserTypeEnum.SUPPLIER) + + # Assertions + assert response.message == "Marked as deleted." + mock_repo.create_other_use.assert_awaited_once() diff --git a/backend/lcfs/tests/other_uses/test_other_uses_view.py b/backend/lcfs/tests/other_uses/test_other_uses_view.py index c48dff416..4cac07231 100644 --- a/backend/lcfs/tests/other_uses/test_other_uses_view.py +++ b/backend/lcfs/tests/other_uses/test_other_uses_view.py @@ -3,9 +3,13 @@ from httpx import AsyncClient from unittest.mock import MagicMock, AsyncMock, patch +from lcfs.db.base import UserTypeEnum, ActionTypeEnum from lcfs.db.models.user.Role import RoleEnum +from lcfs.web.api.base import ComplianceReportRequestSchema +from lcfs.web.api.other_uses.schema import PaginatedOtherUsesRequestSchema from lcfs.web.api.other_uses.services import OtherUsesServices from lcfs.web.api.other_uses.validation import OtherUsesValidation +from lcfs.tests.other_uses.conftest import create_mock_schema, create_mock_entity @pytest.fixture @@ -65,7 +69,7 @@ async def test_get_other_uses( ) as mock_validate_organization_access: set_mock_user(fastapi_app, [RoleEnum.SUPPLIER]) url = fastapi_app.url_path_for("get_other_uses") - payload = {"compliance_report_id": 1} + payload = ComplianceReportRequestSchema(compliance_report_id=1).model_dump() mock_validate_organization_access.return_value = True mock_other_uses_service.get_other_uses.return_value = {"otherUses": []} @@ -93,13 +97,13 @@ async def test_get_other_uses_paginated( ) as mock_validate_organization_access: set_mock_user(fastapi_app, [RoleEnum.SUPPLIER]) url = fastapi_app.url_path_for("get_other_uses_paginated") - payload = { - "complianceReportId": 1, - "page": 1, - "size": 10, - "sort_orders": [], - "filters": [], - } + payload = PaginatedOtherUsesRequestSchema( + compliance_report_id=1, + page=1, + size=10, + sort_orders=[], + filters=[], + ).model_dump() mock_validate_organization_access.return_value = True mock_other_uses_service.get_other_uses_paginated.return_value = { @@ -132,26 +136,9 @@ async def test_save_other_uses_row_create( ) as mock_validate_organization_access: set_mock_user(fastapi_app, [RoleEnum.SUPPLIER]) url = fastapi_app.url_path_for("save_other_uses_row") - payload = { - "compliance_report_id": 1, - "quantity_supplied": 1000, - "fuel_type": "Gasoline", - "fuel_category": "Petroleum-based", - "expected_use": "Transportation", - "units": "L", - "rationale": "Test rationale", - } + payload = create_mock_schema({}).model_dump() - mock_other_uses_service.create_other_use.return_value = { - "otherUsesId": 1, - "complianceReportId": 1, - "quantitySupplied": 1000, - "fuelType": "Gasoline", - "fuelCategory": "Petroleum-based", - "expectedUse": "Transportation", - "units": "L", - "rationale": "Test rationale", - } + mock_other_uses_service.create_other_use.return_value = payload mock_validate_organization_access.return_value = True fastapi_app.dependency_overrides[OtherUsesServices] = ( @@ -183,27 +170,15 @@ async def test_save_other_uses_row_update( ) as mock_validate_organization_access: set_mock_user(fastapi_app, [RoleEnum.SUPPLIER]) url = fastapi_app.url_path_for("save_other_uses_row") - payload = { - "other_uses_id": 1, - "compliance_report_id": 1, - "quantity_supplied": 2000, - "fuel_type": "Diesel", - "fuel_category": "Petroleum-based", - "expected_use": "Transportation", - "units": "L", - "rationale": "Updated rationale", - } - - mock_other_uses_service.update_other_use.return_value = { - "otherUsesId": 1, - "complianceReportId": 1, - "quantitySupplied": 2000, - "fuelType": "Diesel", - "fuelCategory": "Petroleum-based", - "expectedUse": "Transportation", - "units": "L", - "rationale": "Updated rationale", - } + payload = create_mock_schema( + { + "other_uses_id": 1, + "quantity_supplied": 2000, + "rationale": "Updated rationale", + } + ).model_dump() + + mock_other_uses_service.update_other_use.return_value = payload mock_validate_organization_access.return_value = True fastapi_app.dependency_overrides[OtherUsesServices] = ( @@ -219,10 +194,9 @@ async def test_save_other_uses_row_update( data = response.json() assert data["otherUsesId"] == 1 assert data["quantitySupplied"] == 2000 - assert data["fuelType"] == "Diesel" + assert data["fuelType"] == "Gasoline" -@pytest.mark.anyio @pytest.mark.anyio async def test_save_other_uses_row_delete( client: AsyncClient, @@ -231,36 +205,22 @@ async def test_save_other_uses_row_delete( mock_other_uses_service, mock_other_uses_validation, ): - # Patch the validate_organization_access method in the correct module with patch( "lcfs.web.api.other_uses.views.ComplianceReportValidation.validate_organization_access" ) as mock_validate_organization_access: - # Set up a mock user with the correct role set_mock_user(fastapi_app, [RoleEnum.SUPPLIER]) - - # Define the URL and payload for the test request url = fastapi_app.url_path_for("save_other_uses_row") - payload = { - "other_uses_id": 1, - "compliance_report_id": 1, - "quantity_supplied": 0, - "fuel_type": "", - "fuel_category": "", - "expected_use": "", - "units": "", - "rationale": "", - "deleted": True, - } + mock_schema = create_mock_schema( + { + "other_uses_id": 1, + "deleted": True, + } + ) - # Mock the delete_other_use method to return None mock_other_uses_service.delete_other_use.return_value = None - # Mock validate_organization_access to return True (success) mock_validate_organization_access.return_value = True - - # Mock the validation methods to avoid any validation errors mock_other_uses_validation.validate_compliance_report_id.return_value = None - # Override the service dependencies with the mocked versions fastapi_app.dependency_overrides[OtherUsesServices] = ( lambda: mock_other_uses_service ) @@ -268,22 +228,14 @@ async def test_save_other_uses_row_delete( lambda: mock_other_uses_validation ) - # Send the POST request to the API - response = await client.post(url, json=payload) + response = await client.post(url, json=mock_schema.model_dump()) - # Assert that the response status code is 200 (OK) - assert ( - response.status_code == 200 - ), f"Unexpected status code: {response.status_code}. Response: {response.text}" - # Assert the response data matches the expected message + assert response.status_code == 200 data = response.json() assert data == {"message": "Other use deleted successfully"} - # Verify that the delete_other_use method was called with the correct parameter - mock_other_uses_service.delete_other_use.assert_called_once_with(1) - - # **Directly** verify that validate_organization_access was called + mock_other_uses_service.delete_other_use.assert_called_once_with( + mock_schema, UserTypeEnum.SUPPLIER + ) mock_validate_organization_access.assert_called_once_with(1) - - # Verify that the validate_compliance_report_id method was called once mock_other_uses_validation.validate_compliance_report_id.assert_called_once() diff --git a/backend/lcfs/web/api/other_uses/repo.py b/backend/lcfs/web/api/other_uses/repo.py index d463bc43a..201a16c7e 100644 --- a/backend/lcfs/web/api/other_uses/repo.py +++ b/backend/lcfs/web/api/other_uses/repo.py @@ -1,15 +1,17 @@ import structlog -from typing import List +from typing import List, Optional, Tuple, Any from fastapi import Depends + +from lcfs.db.base import ActionTypeEnum, UserTypeEnum from lcfs.db.dependencies import get_async_db_session -from sqlalchemy import select, delete, func +from sqlalchemy import select, delete, func, case, and_ from sqlalchemy.orm import joinedload from sqlalchemy.ext.asyncio import AsyncSession +from lcfs.db.models.compliance import ComplianceReport from lcfs.db.models.compliance.OtherUses import OtherUses -from lcfs.db.models.fuel.ExpectedUseType import ExpectedUseType from lcfs.db.models.fuel.FuelType import QuantityUnitsEnum from lcfs.web.api.fuel_code.repo import FuelCodeRepository from lcfs.web.api.other_uses.schema import OtherUsesSchema @@ -43,23 +45,114 @@ async def get_table_options(self) -> dict: "units_of_measure": units_of_measure, } + @repo_handler + async def get_latest_other_uses_by_group_uuid( + self, group_uuid: str + ) -> Optional[OtherUses]: + """ + Retrieve the latest OtherUses record for a given group UUID. + Government records are prioritized over supplier records by ordering first by `user_type` + (with GOVERNMENT records coming first) and then by `version` in descending order. + """ + query = ( + select(OtherUses) + .where(OtherUses.group_uuid == group_uuid) + .order_by( + # OtherUses.user_type == UserTypeEnum.SUPPLIER evaluates to False for GOVERNMENT, + # thus bringing GOVERNMENT records to the top in the ordered results. + OtherUses.user_type == UserTypeEnum.SUPPLIER, + OtherUses.version.desc(), + ) + ) + + result = await self.db.execute(query) + return result.scalars().first() + @repo_handler async def get_other_uses(self, compliance_report_id: int) -> List[OtherUsesSchema]: """ Queries other uses from the database for a specific compliance report. """ - query = ( + + # Retrieve the compliance report's group UUID + report_group_query = await self.db.execute( + select(ComplianceReport.compliance_report_group_uuid).where( + ComplianceReport.compliance_report_id == compliance_report_id + ) + ) + group_uuid = report_group_query.scalar() + if not group_uuid: + return [] + + result = await self.get_effective_other_uses(group_uuid) + return result + + async def get_effective_other_uses( + self, compliance_report_group_uuid: str + ) -> List[OtherUsesSchema]: + """ + Queries other uses from the database for a specific compliance report. + """ + + # Step 1: Subquery to get all compliance_report_ids in the specified group + compliance_reports_select = select(ComplianceReport.compliance_report_id).where( + ComplianceReport.compliance_report_group_uuid + == compliance_report_group_uuid + ) + + # Step 2: Subquery to identify record group_uuids that have any DELETE action + delete_group_select = ( + select(OtherUses.group_uuid) + .where( + OtherUses.compliance_report_id.in_(compliance_reports_select), + OtherUses.action_type == ActionTypeEnum.DELETE, + ) + .distinct() + ) + + # Step 3: Subquery to find the maximum version and priority per group_uuid, + # excluding groups with any DELETE action + user_type_priority = case( + (OtherUses.user_type == UserTypeEnum.GOVERNMENT, 1), + (OtherUses.user_type == UserTypeEnum.SUPPLIER, 0), + else_=0, + ) + + valid_other_uses_select = ( + select( + OtherUses.group_uuid, + func.max(OtherUses.version).label("max_version"), + func.max(user_type_priority).label("max_role_priority"), + ) + .where( + OtherUses.compliance_report_id.in_(compliance_reports_select), + OtherUses.action_type != ActionTypeEnum.DELETE, + ~OtherUses.group_uuid.in_(delete_group_select), + ) + .group_by(OtherUses.group_uuid) + ) + # Now create a subquery for use in the JOIN + valid_fuel_supplies_subq = valid_other_uses_select.subquery() + + other_uses_select = ( select(OtherUses) .options( joinedload(OtherUses.fuel_category), joinedload(OtherUses.fuel_type), joinedload(OtherUses.expected_use), ) - .where(OtherUses.compliance_report_id == compliance_report_id) + .join( + valid_fuel_supplies_subq, + and_( + OtherUses.group_uuid == valid_fuel_supplies_subq.c.group_uuid, + OtherUses.version == valid_fuel_supplies_subq.c.max_version, + user_type_priority == valid_fuel_supplies_subq.c.max_role_priority, + ), + ) .order_by(OtherUses.other_uses_id) ) - result = await self.db.execute(query) + result = await self.db.execute(other_uses_select) other_uses = result.unique().scalars().all() return [ @@ -72,36 +165,39 @@ async def get_other_uses(self, compliance_report_id: int) -> List[OtherUsesSchem expected_use=ou.expected_use.name, units=ou.units, rationale=ou.rationale, + group_uuid=ou.group_uuid, + version=ou.version, + user_type=ou.user_type, + action_type=ou.action_type, ) for ou in other_uses ] async def get_other_uses_paginated( self, pagination: PaginationRequestSchema, compliance_report_id: int - ) -> List[OtherUsesSchema]: - conditions = [OtherUses.compliance_report_id == compliance_report_id] - offset = 0 if pagination.page < 1 else (pagination.page - 1) * pagination.size - limit = pagination.size - - query = ( - select(OtherUses) - .options( - joinedload(OtherUses.fuel_category), - joinedload(OtherUses.fuel_type), - joinedload(OtherUses.expected_use), + ) -> tuple[list[Any], int] | tuple[list[OtherUsesSchema], int]: + # Retrieve the compliance report's group UUID + report_group_query = await self.db.execute( + select(ComplianceReport.compliance_report_group_uuid).where( + ComplianceReport.compliance_report_id == compliance_report_id ) - .where(*conditions) ) + group_uuid = report_group_query.scalar() + if not group_uuid: + return [], 0 - count_query = query.with_only_columns(func.count()).order_by(None) - total_count = (await self.db.execute(count_query)).scalar() - - result = await self.db.execute( - query.offset(offset).limit(limit).order_by(OtherUses.create_date.desc()) + # Retrieve effective fuel supplies using the group UUID + other_uses = await self.get_effective_other_uses( + compliance_report_group_uuid=group_uuid ) - other_uses = result.unique().scalars().all() - return other_uses, total_count + # Manually apply pagination + total_count = len(other_uses) + offset = 0 if pagination.page < 1 else (pagination.page - 1) * pagination.size + limit = pagination.size + paginated_other_uses = other_uses[offset : offset + limit] + + return paginated_other_uses, total_count @repo_handler async def get_other_use(self, other_uses_id: int) -> OtherUses: @@ -145,3 +241,28 @@ async def delete_other_use(self, other_uses_id: int): delete(OtherUses).where(OtherUses.other_uses_id == other_uses_id) ) await self.db.flush() + + @repo_handler + async def get_other_use_version_by_user( + self, group_uuid: str, version: int, user_type: UserTypeEnum + ) -> Optional[OtherUses]: + """ + Retrieve a specific OtherUses record by group UUID, version, and user_type. + This method explicitly requires user_type to avoid ambiguity. + """ + query = ( + select(OtherUses) + .where( + OtherUses.group_uuid == group_uuid, + OtherUses.version == version, + OtherUses.user_type == user_type, + ) + .options( + joinedload(OtherUses.fuel_category), + joinedload(OtherUses.fuel_type), + joinedload(OtherUses.expected_use), + ) + ) + + result = await self.db.execute(query) + return result.scalars().first() diff --git a/backend/lcfs/web/api/other_uses/schema.py b/backend/lcfs/web/api/other_uses/schema.py index 4e2bd624f..6c385f317 100644 --- a/backend/lcfs/web/api/other_uses/schema.py +++ b/backend/lcfs/web/api/other_uses/schema.py @@ -85,6 +85,10 @@ class OtherUsesCreateSchema(BaseSchema): units: str rationale: Optional[str] = None deleted: Optional[bool] = None + group_uuid: Optional[str] = None + version: Optional[int] = None + user_type: Optional[str] = None + action_type: Optional[str] = None class OtherUsesSchema(OtherUsesCreateSchema): diff --git a/backend/lcfs/web/api/other_uses/services.py b/backend/lcfs/web/api/other_uses/services.py index f547b870c..d8e703cb7 100644 --- a/backend/lcfs/web/api/other_uses/services.py +++ b/backend/lcfs/web/api/other_uses/services.py @@ -1,9 +1,11 @@ import math +import uuid +from typing import Optional + import structlog -from typing import List from fastapi import Depends -from datetime import datetime +from lcfs.db.base import UserTypeEnum, ActionTypeEnum from lcfs.web.api.other_uses.repo import OtherUsesRepository from lcfs.web.core.decorators import service_handler from lcfs.db.models.compliance.OtherUses import OtherUses @@ -16,13 +18,24 @@ OtherUsesFuelCategorySchema, OtherUsesAllSchema, FuelTypeSchema, - UnitOfMeasureSchema, ExpectedUseTypeSchema, + DeleteOtherUsesResponseSchema, ) from lcfs.web.api.fuel_code.repo import FuelCodeRepository logger = structlog.get_logger(__name__) +# Constants defining which fields to exclude during model operations +OTHER_USE_EXCLUDE_FIELDS = { + "id", + "other_uses_id", + "deleted", + "group_uuid", + "user_type", + "version", + "action_type", +} + class OtherUsesServices: def __init__( @@ -33,7 +46,7 @@ def __init__( self.repo = repo self.fuel_repo = fuel_repo - async def convert_to_model(self, other_use: OtherUsesCreateSchema) -> OtherUses: + async def schema_to_model(self, other_use: OtherUsesCreateSchema) -> OtherUses: """ Converts data from OtherUsesCreateSchema to OtherUses data model to store into the database. """ @@ -47,13 +60,39 @@ async def convert_to_model(self, other_use: OtherUsesCreateSchema) -> OtherUses: return OtherUses( **other_use.model_dump( - exclude={"id", "fuel_category", "fuel_type", "expected_use", "deleted"} + exclude={ + "other_uses_id", + "fuel_category", + "fuel_type", + "expected_use", + "deleted", + } ), fuel_category_id=fuel_category.fuel_category_id, fuel_type_id=fuel_type.fuel_type_id, expected_use_id=expected_use.expected_use_type_id ) + def model_to_schema(self, model: OtherUses): + """ + Converts data from OtherUses to OtherUsesCreateSchema data model to store into the database. + """ + updated_schema = OtherUsesSchema( + other_uses_id=model.other_uses_id, + compliance_report_id=model.compliance_report_id, + quantity_supplied=model.quantity_supplied, + rationale=model.rationale, + units=model.units, + fuel_type=model.fuel_type.fuel_type, + fuel_category=model.fuel_category.category, + expected_use=model.expected_use.name, + user_type=model.user_type, + group_uuid=model.group_uuid, + version=model.version, + action_type=model.action_type, + ) + return updated_schema + @service_handler async def get_table_options(self) -> OtherUsesTableOptionsSchema: """ @@ -100,87 +139,105 @@ async def get_other_uses_paginated( size=pagination.size, total_pages=math.ceil(total_count / pagination.size), ), - other_uses=[ - OtherUsesSchema( - other_uses_id=ou.other_uses_id, - compliance_report_id=ou.compliance_report_id, - quantity_supplied=ou.quantity_supplied, - fuel_type=ou.fuel_type.fuel_type, - fuel_category=ou.fuel_category.category, - expected_use=ou.expected_use.name, - units=ou.units, - rationale=ou.rationale, - ) - for ou in other_uses - ], + other_uses=other_uses, ) @service_handler async def update_other_use( - self, other_use_data: OtherUsesCreateSchema + self, other_use_data: OtherUsesCreateSchema, user_type: UserTypeEnum ) -> OtherUsesSchema: """Update an existing other use""" - existing_use = await self.repo.get_other_use(other_use_data.other_uses_id) - if not existing_use: + other_use = await self.repo.get_other_use_version_by_user( + other_use_data.group_uuid, other_use_data.version, user_type + ) + + if not other_use: raise ValueError("Other use not found") - if existing_use.fuel_type.fuel_type != other_use_data.fuel_type: - existing_use.fuel_type = await self.fuel_repo.get_fuel_type_by_name( - other_use_data.fuel_type - ) + if other_use.compliance_report_id == other_use_data.compliance_report_id: + # Update existing record if compliance report ID matches + for field, value in other_use_data.model_dump( + exclude={"id", "deleted", "fuel_type", "fuel_category", "expected_use"} + ).items(): + setattr(other_use, field, value) - if existing_use.fuel_category.category != other_use_data.fuel_category: - existing_use.fuel_category = await self.fuel_repo.get_fuel_category_by_name( - other_use_data.fuel_category - ) + if other_use.fuel_type.fuel_type != other_use_data.fuel_type: + other_use.fuel_type = await self.fuel_repo.get_fuel_type_by_name( + other_use_data.fuel_type + ) - if existing_use.expected_use.name != other_use_data.expected_use: - existing_use.expected_use = ( - await self.fuel_repo.get_expected_use_type_by_name( - other_use_data.expected_use + if other_use.fuel_category.category != other_use_data.fuel_category: + other_use.fuel_category = ( + await self.fuel_repo.get_fuel_category_by_name( + other_use_data.fuel_category + ) ) - ) - existing_use.quantity_supplied = other_use_data.quantity_supplied - existing_use.rationale = other_use_data.rationale + if other_use.expected_use.name != other_use_data.expected_use: + other_use.expected_use = ( + await self.fuel_repo.get_expected_use_type_by_name( + other_use_data.expected_use + ) + ) - updated_use = await self.repo.update_other_use(existing_use) + updated_use = await self.repo.update_other_use(other_use) + updated_schema = self.model_to_schema(updated_use) + return OtherUsesSchema.model_validate(updated_schema) - return OtherUsesSchema( - other_uses_id=updated_use.other_uses_id, - compliance_report_id=updated_use.compliance_report_id, - quantity_supplied=updated_use.quantity_supplied, - fuel_type=updated_use.fuel_type.fuel_type, - fuel_category=updated_use.fuel_category.category, - expected_use=updated_use.expected_use.name, - units=updated_use.units, - rationale=updated_use.rationale, - ) + else: + updated_use = await self.create_other_use( + other_use_data, user_type, existing_record=other_use + ) + return OtherUsesSchema.model_validate(updated_use) @service_handler async def create_other_use( - self, other_use_data: OtherUsesCreateSchema + self, + other_use_data: OtherUsesCreateSchema, + user_type: UserTypeEnum, + existing_record: Optional[OtherUses] = None, ) -> OtherUsesSchema: """Create a new other use""" - other_use = await self.convert_to_model(other_use_data) + other_use = await self.schema_to_model(other_use_data) + new_group_uuid = str(uuid.uuid4()) + other_use.group_uuid = ( + new_group_uuid if not existing_record else existing_record.group_uuid + ) + other_use.action_type = ( + ActionTypeEnum.CREATE if not existing_record else ActionTypeEnum.UPDATE + ) + other_use.version = 0 if not existing_record else existing_record.version + 1 + other_use.user_type = user_type created_use = await self.repo.create_other_use(other_use) - fuel_category_value = created_use.fuel_category.category - fuel_type_value = created_use.fuel_type.fuel_type - expected_use_value = created_use.expected_use.name - - return OtherUsesSchema( - other_uses_id=created_use.other_uses_id, - compliance_report_id=created_use.compliance_report_id, - quantity_supplied=created_use.quantity_supplied, - rationale=created_use.rationale, - units=created_use.units, - fuel_type=fuel_type_value, - fuel_category=fuel_category_value, - expected_use=expected_use_value, - ) + return self.model_to_schema(created_use) @service_handler - async def delete_other_use(self, other_uses_id: int) -> str: + async def delete_other_use( + self, other_use_data: OtherUsesCreateSchema, user_type: UserTypeEnum + ) -> DeleteOtherUsesResponseSchema: """Delete an other use""" - return await self.repo.delete_other_use(other_uses_id) + existing_fuel_supply = await self.repo.get_latest_other_uses_by_group_uuid( + other_use_data.group_uuid + ) + + if existing_fuel_supply.action_type == ActionTypeEnum.DELETE: + return DeleteOtherUsesResponseSchema( + success=True, message="Already deleted." + ) + + deleted_entity = OtherUses( + compliance_report_id=other_use_data.compliance_report_id, + group_uuid=other_use_data.group_uuid, + version=existing_fuel_supply.version + 1, + action_type=ActionTypeEnum.DELETE, + user_type=user_type, + ) + + # Copy fields from the latest version for the deletion record + for field in existing_fuel_supply.__table__.columns.keys(): + if field not in OTHER_USE_EXCLUDE_FIELDS: + setattr(deleted_entity, field, getattr(existing_fuel_supply, field)) + + await self.repo.create_other_use(deleted_entity) + return DeleteOtherUsesResponseSchema(success=True, message="Marked as deleted.") diff --git a/backend/lcfs/web/api/other_uses/views.py b/backend/lcfs/web/api/other_uses/views.py index fe3f4098f..78704e62e 100644 --- a/backend/lcfs/web/api/other_uses/views.py +++ b/backend/lcfs/web/api/other_uses/views.py @@ -1,7 +1,3 @@ -""" -Other Uses endpoints -""" - import structlog from typing import Optional, Union @@ -12,6 +8,7 @@ Request, Response, Depends, + HTTPException, ) from lcfs.db import dependencies @@ -21,7 +18,6 @@ from lcfs.web.api.other_uses.schema import ( OtherUsesCreateSchema, OtherUsesSchema, - OtherUsesListSchema, OtherUsesTableOptionsSchema, DeleteOtherUsesResponseSchema, PaginatedOtherUsesRequestSchema, @@ -64,7 +60,9 @@ async def get_other_uses( report_validate: ComplianceReportValidation = Depends(), ): """Endpoint to get list of other uses for a compliance report""" - await report_validate.validate_organization_access(request_data.compliance_report_id) + await report_validate.validate_organization_access( + request_data.compliance_report_id + ) return await service.get_other_uses(request_data.compliance_report_id) @@ -86,7 +84,9 @@ async def get_other_uses_paginated( sort_orders=request_data.sort_orders, filters=request_data.filters, ) - await report_validate.validate_organization_access(request_data.compliance_report_id) + await report_validate.validate_organization_access( + request_data.compliance_report_id + ) compliance_report_id = request_data.compliance_report_id return await service.get_other_uses_paginated(pagination, compliance_report_id) @@ -110,22 +110,29 @@ async def save_other_uses_row( await report_validate.validate_organization_access(compliance_report_id) + # Determine user type for record creation + current_user_type = request.user.user_type + if not current_user_type: + raise HTTPException( + status_code=403, detail="User does not have the required role." + ) + if request_data.deleted: # Delete existing other use await validate.validate_compliance_report_id( compliance_report_id, [request_data] ) - await service.delete_other_use(other_uses_id) + await service.delete_other_use(request_data, current_user_type) return DeleteOtherUsesResponseSchema(message="Other use deleted successfully") elif other_uses_id: # Update existing other use await validate.validate_compliance_report_id( compliance_report_id, [request_data] ) - return await service.update_other_use(request_data) + return await service.update_other_use(request_data, current_user_type) else: # Create new other use await validate.validate_compliance_report_id( compliance_report_id, [request_data] ) - return await service.create_other_use(request_data) + return await service.create_other_use(request_data, current_user_type) diff --git a/frontend/src/views/OtherUses/AddEditOtherUses.jsx b/frontend/src/views/OtherUses/AddEditOtherUses.jsx index a2cb965c5..6e4b9f2c8 100644 --- a/frontend/src/views/OtherUses/AddEditOtherUses.jsx +++ b/frontend/src/views/OtherUses/AddEditOtherUses.jsx @@ -53,6 +53,7 @@ export const AddEditOtherUses = () => { if (!row.id) { return { ...row, + complianceReportId, // This takes current reportId, important for versioning id: uuid(), isValid: true } diff --git a/frontend/src/views/OtherUses/_schema.jsx b/frontend/src/views/OtherUses/_schema.jsx index fca594576..557330a07 100644 --- a/frontend/src/views/OtherUses/_schema.jsx +++ b/frontend/src/views/OtherUses/_schema.jsx @@ -19,6 +19,58 @@ export const otherUsesColDefs = (optionsData, errors) => [ field: 'id', hide: true }, + { + // TODO Temporary column to show version types, change this logic in later ticket + field: 'actionType', + headerName: i18n.t('fuelSupply:fuelSupplyColLabels.actionType'), + minWidth: 125, + maxWidth: 150, + editable: false, + cellStyle: (params) => { + switch (params.data.actionType) { + case 'CREATE': + return { + backgroundColor: '#e0f7df', + color: '#388e3c', + fontWeight: 'bold' + } + case 'UPDATE': + return { + backgroundColor: '#fff8e1', + color: '#f57c00', + fontWeight: 'bold' + } + case 'DELETE': + return { + backgroundColor: '#ffebee', + color: '#d32f2f', + fontWeight: 'bold' + } + default: + return {} + } + }, + cellRenderer: (params) => { + switch (params.data.actionType) { + case 'CREATE': + return 'Create' + case 'UPDATE': + return 'Edit' + case 'DELETE': + return 'Deleted' + default: + return '' + } + }, + tooltipValueGetter: (params) => { + const actionMap = { + CREATE: 'This record was created.', + UPDATE: 'This record has been edited.', + DELETE: 'This record was deleted.' + } + return actionMap[params.data.actionType] || '' + } + }, { field: 'fuelType', headerName: i18n.t('otherUses:otherUsesColLabels.fuelType'),