Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions backend/lcfs/db/migrations/versions/2026-01-25-12-00_f3b1b9f03c9a.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Create report_opening configuration table

Revision ID: f3b1b9f03c9a
Revises: a1b2c3d4e5f1
Create Date: 2026-01-25 12:00:00.000000

"""

from datetime import datetime

import sqlalchemy as sa
from enum import Enum
from alembic import op

# revision identifiers, used by Alembic.
revision = "f3b1b9f03c9a"
down_revision = "a1b2c3d4e5f1"
branch_labels = None
depends_on = None


class SupplementalReportAccessRoleEnum(str, Enum):
BCeID = "BCeID"
IDIR = "IDIR"


enum_for_column = sa.Enum(
SupplementalReportAccessRoleEnum,
name="supplemental_report_access_role_enum",
create_type=False,
)


def upgrade() -> None:
op.execute("DROP TYPE IF EXISTS supplemental_report_access_role_enum")

op.create_table(
"report_opening",
sa.Column(
"report_opening_id",
sa.Integer(),
autoincrement=True,
nullable=False,
comment="Unique identifier for the report opening row",
),
sa.Column(
"compliance_year",
sa.Integer(),
nullable=False,
comment="Compliance year that this configuration applies to",
),
sa.Column(
"compliance_reporting_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
comment="If TRUE, suppliers can create compliance reports for this year",
),
sa.Column(
"early_issuance_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
comment="Indicates whether early issuance is enabled for this year",
),
sa.Column(
"supplemental_report_role",
enum_for_column,
nullable=False,
server_default=sa.text("'BCeID'"),
comment="Which role may create supplemental reports for the year",
),
sa.Column(
"create_date",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=True,
comment="Date/time when the record was created",
),
sa.Column(
"update_date",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=True,
comment="Date/time when the record was last updated",
),
sa.Column(
"create_user",
sa.String(),
nullable=True,
comment="User who created the record",
),
sa.Column(
"update_user",
sa.String(),
nullable=True,
comment="User who last updated the record",
),
sa.PrimaryKeyConstraint(
"report_opening_id",
name=op.f("pk_report_opening"),
),
sa.UniqueConstraint(
"compliance_year",
name="uq_report_opening_compliance_year",
),
comment="Stores per-year configuration for compliance reporting availability and permissions.",
)
op.execute('commit;')
report_opening_table = sa.table(
"report_opening",
sa.column("compliance_year", sa.Integer()),
sa.column("compliance_reporting_enabled", sa.Boolean()),
sa.column("early_issuance_enabled", sa.Boolean()),
sa.column("supplemental_report_role", enum_for_column),
)

current_year = datetime.utcnow().year
op.bulk_insert(
report_opening_table,
[
{
"compliance_year": year,
"compliance_reporting_enabled": year == 2025,
"early_issuance_enabled": False,
"supplemental_report_role": SupplementalReportAccessRoleEnum.BCeID if year >= current_year else SupplementalReportAccessRoleEnum.IDIR,
}
for year in range(2019, 2031)
],
)


def downgrade() -> None:
op.drop_table("report_opening")
op.execute("DROP TYPE IF EXISTS supplemental_report_access_role_enum")
66 changes: 66 additions & 0 deletions backend/lcfs/db/models/compliance/ReportOpening.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from enum import Enum as PyEnum

from sqlalchemy import Boolean, Column, Enum, Integer, UniqueConstraint, text

from lcfs.db.base import Auditable, BaseModel


class SupplementalReportAccessRole(str, PyEnum):
"""Enumerates which user role may create supplemental reports for a year."""

BCeID = "BCeID"
IDIR = "IDIR"


class ReportOpening(BaseModel, Auditable):
__tablename__ = "report_opening"
__table_args__ = (
UniqueConstraint(
"compliance_year", name="uq_report_opening_compliance_year"
),
{
"comment": "Stores per-year configuration for compliance reporting availability and permissions.",
},
)

report_opening_id = Column(
Integer,
primary_key=True,
autoincrement=True,
comment="Unique identifier for the report opening row",
)
compliance_year = Column(
Integer,
nullable=False,
comment="Compliance year that this configuration applies to",
)
compliance_reporting_enabled = Column(
Boolean,
nullable=False,
server_default=text("true"),
default=True,
comment="If True, suppliers can create compliance reports for this year",
)
early_issuance_enabled = Column(
Boolean,
nullable=False,
server_default=text("false"),
default=False,
comment="Indicates whether early issuance is enabled for this year",
)
supplemental_report_role = Column(
Enum(
SupplementalReportAccessRole,
name="supplemental_report_access_role_enum",
),
nullable=False,
default=SupplementalReportAccessRole.BCeID,
server_default=text("'BCeID'"),
comment="Which role (BCeID or IDIR) may create supplemental reports for the year",
)

def __repr__(self) -> str: # pragma: no cover - simple repr
return (
f"<ReportOpening(year={self.compliance_year}, "
f"enabled={self.compliance_reporting_enabled})>"
)
3 changes: 3 additions & 0 deletions backend/lcfs/db/models/compliance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .ChargingPowerOutput import ChargingPowerOutput
from .NotionalTransfer import NotionalTransfer
from .OtherUses import OtherUses
from .ReportOpening import ReportOpening, SupplementalReportAccessRole

__all__ = [
"AllocationAgreement",
Expand All @@ -46,4 +47,6 @@
"NotionalTransfer",
"OtherUses",
"EndUserType",
"ReportOpening",
"SupplementalReportAccessRole",
]
1 change: 1 addition & 0 deletions backend/lcfs/services/jobs/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ async def submit_supplemental_report(report_id: int, app: FastAPI):
internal_comment_service = InternalCommentService(session)

compliance_report_services = ComplianceReportServices(
request=None,
repo=compliance_report_repo,
org_repo=org_repo,
snapshot_services=snapshot_service,
Expand Down
1 change: 1 addition & 0 deletions backend/lcfs/services/rabbitmq/report_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ async def handle_message(
db=session, fuel_supply_repo=fuel_supply_repo
)
compliance_report_service = ComplianceReportServices(
request=None,
repo=compliance_report_repo
)
user = await UserRepository(db=session).get_user_by_id(user_id)
Expand Down
12 changes: 0 additions & 12 deletions backend/lcfs/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,6 @@ class Settings(BaseSettings):
# Feature flags
feature_credit_market_notifications: bool = True
feature_fuel_code_expiry_email: bool = True
# TEMPORARY SOLUTION - Issue #3730
# This is a temporary approach to gate compliance year access.
# A more robust long-term solution should be implemented to support future years
# dynamically (e.g., database-driven configuration per compliance period).
#
# Current behavior:
# - 2025: Enabled by default (backend allows it), frontend flag controls UI visibility
# - 2026: ALWAYS blocked unless org has early issuance enabled for 2026
#
# Set LCFS_FEATURE_REPORTING_2025_ENABLED=false to disable 2025 reporting on backend.
# Frontend has corresponding flag: reporting2025Enabled in config.ts
feature_reporting_2025_enabled: bool = True

def __init__(self, **kwargs):
# Map APP_ENVIRONMENT to environment if present
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def service(mock_repo_full, mock_fuel_repo_full):
@pytest.fixture
def compliance_service(mock_compliance_repo, mock_snapshot_services):
return ComplianceReportServices(
request=None,
repo=mock_compliance_repo,
snapshot_services=mock_snapshot_services,
)
Expand Down
13 changes: 13 additions & 0 deletions backend/lcfs/tests/compliance_report/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from lcfs.web.api.final_supply_equipment.services import FinalSupplyEquipmentServices
from lcfs.web.api.organizations.repo import OrganizationsRepository
from lcfs.web.api.transaction.repo import TransactionRepository
from lcfs.web.api.report_opening.repo import ReportOpeningRepository
from lcfs.services.s3.client import DocumentService
from lcfs.db.models.user.Role import RoleEnum
from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatusEnum
Expand Down Expand Up @@ -133,6 +134,15 @@ def mock_org_repo():
return repo


@pytest.fixture
def mock_report_opening_repo():
repo = AsyncMock(spec=ReportOpeningRepository)
repo.sync_configured_years = AsyncMock(return_value=[])
repo.ensure_year = AsyncMock()
repo.upsert_year = AsyncMock()
return repo


@pytest.fixture
def mock_snapshot_service():
return AsyncMock(spec=OrganizationSnapshotService)
Expand Down Expand Up @@ -332,15 +342,18 @@ def compliance_report_service(
mock_document_service,
mock_internal_comment_service,
mock_trxn_repo,
mock_report_opening_repo,
):
service = ComplianceReportServices(
request=None,
repo=mock_repo,
org_repo=mock_org_repo,
snapshot_services=mock_snapshot_service,
fse_service=mock_fse_services,
document_service=mock_document_service,
transaction_repo=mock_trxn_repo,
internal_comment_service=mock_internal_comment_service,
report_opening_repo=mock_report_opening_repo,
)
service.request = MagicMock()
service.request.user = mock_user_profile
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import datetime
import copy
from types import SimpleNamespace
from lcfs.db.base import ActionTypeEnum
from lcfs.db.models.compliance.AllocationAgreement import AllocationAgreement
from lcfs.db.models.compliance.FuelSupply import FuelSupply
Expand Down Expand Up @@ -39,19 +40,70 @@

# get_all_compliance_periods
@pytest.mark.anyio
async def test_get_all_compliance_periods_success(compliance_report_service, mock_repo):
async def test_get_all_compliance_periods_success(
compliance_report_service, mock_repo, mock_report_opening_repo
):
mock_periods = [
{"compliance_period_id": 1, "description": "2024 Compliance Period"},
{"compliance_period_id": 2, "description": "2025 Compliance Period"},
]
mock_repo.get_all_compliance_periods.return_value = mock_periods
mock_report_opening_repo.sync_configured_years.return_value = [
SimpleNamespace(
compliance_year=2024,
compliance_reporting_enabled=True,
early_issuance_enabled=False,
supplemental_report_role="BCeID",
report_opening_id=1,
),
SimpleNamespace(
compliance_year=2025,
compliance_reporting_enabled=True,
early_issuance_enabled=False,
supplemental_report_role="BCeID",
report_opening_id=2,
),
]

result = await compliance_report_service.get_all_compliance_periods()

assert len(result) == 2
assert result[0].compliance_period_id == 1
assert result[0].description == "2024 Compliance Period"
mock_repo.get_all_compliance_periods.assert_called_once()
mock_report_opening_repo.sync_configured_years.assert_called()


@pytest.mark.anyio
async def test_get_all_compliance_periods_filters_disabled_years(
compliance_report_service, mock_repo, mock_report_opening_repo
):
mock_periods = [
{"compliance_period_id": 1, "description": "2024"},
{"compliance_period_id": 2, "description": "2025"},
]
mock_repo.get_all_compliance_periods.return_value = mock_periods
mock_report_opening_repo.sync_configured_years.return_value = [
SimpleNamespace(
compliance_year=2024,
compliance_reporting_enabled=True,
early_issuance_enabled=False,
supplemental_report_role="BCeID",
report_opening_id=1,
),
SimpleNamespace(
compliance_year=2025,
compliance_reporting_enabled=False,
early_issuance_enabled=False,
supplemental_report_role="BCeID",
report_opening_id=2,
),
]

result = await compliance_report_service.get_all_compliance_periods()

assert len(result) == 1
assert result[0].description == "2024"


@pytest.mark.anyio
Expand Down Expand Up @@ -1945,6 +1997,7 @@ def service(self) -> ComplianceReportServices:
# The _mask_report_status_for_history method is pure Python logic on its inputs,
# so it doesn't need real dependencies for these tests.
return ComplianceReportServices(
request=None,
repo=MagicMock(),
org_repo=MagicMock(),
snapshot_services=MagicMock(),
Expand Down Expand Up @@ -2382,6 +2435,7 @@ class TestIsSupplementalRequestedByGovUser:
def service(self) -> ComplianceReportServices:
"""Provides a simple instance of ComplianceReportServices for testing this method."""
return ComplianceReportServices(
request=None,
repo=MagicMock(),
org_repo=MagicMock(),
snapshot_services=MagicMock(),
Expand Down
Loading
Loading