diff --git a/backend/Dockerfile.openshift b/backend/Dockerfile.openshift
index 2b4b4a1f4..61f02b187 100644
--- a/backend/Dockerfile.openshift
+++ b/backend/Dockerfile.openshift
@@ -23,9 +23,9 @@ ENV POETRY_CACHE_DIR=/.cache/pypoetry
RUN poetry install --only main
# Removing gcc
-# RUN apt-get purge -y \
-# gcc \
-# && rm -rf /var/lib/apt/lists/*
+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/
@@ -45,4 +45,4 @@ RUN chmod +x /app/wait-for-it.sh /app/lcfs/prestart.sh /app/lcfs/start.sh
# Set the APP_ENVIRONMENT variable to 'production'
ENV APP_ENVIRONMENT=prod
-CMD ["/app/lcfs/start.sh"]
+CMD ["/app/lcfs/start.sh"]
\ No newline at end of file
diff --git a/backend/lcfs/db/migrations/versions/2024-12-05-02-19_b69a33bbd135.py b/backend/lcfs/db/migrations/versions/2024-12-05-02-19_b69a33bbd135.py
new file mode 100644
index 000000000..3f1477892
--- /dev/null
+++ b/backend/lcfs/db/migrations/versions/2024-12-05-02-19_b69a33bbd135.py
@@ -0,0 +1,34 @@
+"""Add notifications email to user_profile
+
+Revision ID: b69a33bbd135
+Revises: 8491890dd688
+Create Date: 2024-12-05 20:48:24.724112
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "b69a33bbd135"
+down_revision = "8491890dd688"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Add notifications_email column to user_profile table
+ op.add_column(
+ "user_profile",
+ sa.Column(
+ "notifications_email",
+ sa.String(length=255),
+ nullable=True,
+ comment="Email address used for notifications",
+ ),
+ )
+
+
+def downgrade() -> None:
+ # Remove notifications_email column from user_profile table
+ op.drop_column("user_profile", "notifications_email")
diff --git a/backend/lcfs/db/migrations/versions/2024-12-05-02-20_d4104af84f2b.py b/backend/lcfs/db/migrations/versions/2024-12-05-02-20_d4104af84f2b.py
new file mode 100644
index 000000000..2ccc2ba9e
--- /dev/null
+++ b/backend/lcfs/db/migrations/versions/2024-12-05-02-20_d4104af84f2b.py
@@ -0,0 +1,90 @@
+"""Update Notification Types and remove data
+
+Revision ID: d4104af84f2b
+Revises: b69a33bbd135
+Create Date: 2024-12-05 02:20:33.898150
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects.postgresql import ENUM
+
+# Revision identifiers, used by Alembic.
+revision = "d4104af84f2b"
+down_revision = "b69a33bbd135"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Remove the notification types added in the previous migration
+ op.execute("DELETE FROM notification_type;")
+
+ # Alter the `name` column in `notification_type` to be a VARCHAR
+ with op.batch_alter_table("notification_type") as batch_op:
+ batch_op.alter_column(
+ "name",
+ existing_type=ENUM(
+ "TRANSFER_PARTNER_UPDATE",
+ "TRANSFER_DIRECTOR_REVIEW",
+ "INITIATIVE_APPROVED",
+ "INITIATIVE_DA_REQUEST",
+ "SUPPLEMENTAL_REQUESTED",
+ "DIRECTOR_ASSESSMENT",
+ name="notification_type_enum_v2",
+ ),
+ type_=sa.String(length=255),
+ existing_nullable=False,
+ )
+
+ # Drop the old enum types
+ op.execute("DROP TYPE IF EXISTS notification_type_enum_v2;")
+
+
+def downgrade():
+ # First, remove all existing data that might not match the enum
+ op.execute("DELETE FROM notification_type;")
+
+ # Re-create the old enum type
+ notification_type_enum_v2 = ENUM(
+ "TRANSFER_PARTNER_UPDATE",
+ "TRANSFER_DIRECTOR_REVIEW",
+ "INITIATIVE_APPROVED",
+ "INITIATIVE_DA_REQUEST",
+ "SUPPLEMENTAL_REQUESTED",
+ "DIRECTOR_ASSESSMENT",
+ name="notification_type_enum_v2",
+ )
+ notification_type_enum_v2.create(op.get_bind(), checkfirst=False)
+
+ # Alter the `name` column back to the old enum
+ with op.batch_alter_table("notification_type") as batch_op:
+ batch_op.alter_column(
+ "name",
+ type_=notification_type_enum_v2,
+ existing_type=sa.String(length=255),
+ postgresql_using="name::notification_type_enum_v2",
+ existing_nullable=False,
+ )
+
+ # Re-insert the previous notification types
+ op.execute(
+ """
+ INSERT INTO notification_type (notification_type_id, name, description, email_content, create_user, update_user)
+ VALUES
+ (1, 'TRANSFER_PARTNER_UPDATE', 'Transfer partner update notification', 'Email content for transfer partner update', 'system', 'system'),
+ (2, 'TRANSFER_DIRECTOR_REVIEW', 'Director review notification', 'Email content for director review', 'system', 'system'),
+ (3, 'INITIATIVE_APPROVED', 'Initiative approved notification', 'Email content for initiative approval', 'system', 'system'),
+ (4, 'INITIATIVE_DA_REQUEST', 'DA request notification', 'Email content for DA request', 'system', 'system'),
+ (5, 'SUPPLEMENTAL_REQUESTED', 'Supplemental requested notification', 'Email content for supplemental request', 'system', 'system'),
+ (6, 'DIRECTOR_ASSESSMENT', 'Director assessment notification', 'Email content for director assessment', 'system', 'system');
+ """
+ )
+
+ # Reset the sequence for the id column with correct sequence name
+ op.execute(
+ """
+ SELECT setval('notification_type_notification_type_id_seq', (SELECT MAX(notification_type_id) FROM notification_type));
+ """
+ )
diff --git a/backend/lcfs/db/migrations/versions/2024-12-06-01-23_26ab15f8ab18.py b/backend/lcfs/db/migrations/versions/2024-12-06-01-23_26ab15f8ab18.py
new file mode 100644
index 000000000..5a7c88e10
--- /dev/null
+++ b/backend/lcfs/db/migrations/versions/2024-12-06-01-23_26ab15f8ab18.py
@@ -0,0 +1,282 @@
+"""update end use table with new values
+
+Revision ID: 26ab15f8ab18
+Revises: d4104af84f2b
+Create Date: 2024-12-06 01:23:21.598991
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from datetime import datetime
+
+# revision identifiers, used by Alembic.
+revision = "26ab15f8ab18"
+down_revision = "d4104af84f2b"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ current_time = datetime.now()
+
+ # Update existing end use types 14-21 to new values
+ updates = [
+ (14, 'Aircraft'),
+ (15, 'Compression-ignition engine- Marine, general'),
+ (16, 'Compression-ignition engine- Marine, operated within 51 to 75% of load range'),
+ (17, 'Compression-ignition engine- Marine, operated within 76 to 100% of load range'),
+ (18, 'Compression-ignition engine- Marine, with methane slip reduction kit- General'),
+ (19, 'Compression-ignition engine- Marine, with methane slip reduction kit- Operated within 51 to 75% of load range'),
+ (20, 'Compression-ignition engine- Marine, with methane slip reduction kit- Operated within 76 to 100% of load range'),
+ (21, 'Compression-ignition engine- Marine, unknown whether kit is installed or average operating load range')
+ ]
+
+ for end_use_id, type_name in updates:
+ op.execute(
+ """
+ UPDATE end_use_type
+ SET type = '{}',
+ sub_type = NULL,
+ intended_use = true,
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE end_use_type_id = {}
+ """.format(type_name, current_time, end_use_id)
+ )
+
+ # Update existing UCI values for IDs 1-9
+ uci_updates = [
+ (1, 7, 5, None, 0), # Remove end_use_type_id 14
+ (2, None, 5, None, 0), # Remove fuel_type_id and end_use_type_id
+ (3, 7, 5, 15, 27.3), # Update with new end_use_type_id and intensity
+ (4, 7, 5, 16, 17.8),
+ (5, 7, 5, 17, 12.2),
+ (6, 7, 5, 18, 10.6),
+ (7, 7, 5, 19, 8.4),
+ (8, 7, 5, 20, 8.0),
+ (9, 7, 5, 21, 27.3)
+ ]
+
+ for uci_id, fuel_type_id, uom_id, end_use_type_id, intensity in uci_updates:
+ if fuel_type_id and end_use_type_id:
+ op.execute(
+ """
+ UPDATE additional_carbon_intensity
+ SET fuel_type_id = {},
+ uom_id = {},
+ end_use_type_id = {},
+ intensity = {},
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE additional_uci_id = {}
+ """.format(fuel_type_id, uom_id, end_use_type_id, intensity, current_time, uci_id)
+ )
+ elif fuel_type_id:
+ op.execute(
+ """
+ UPDATE additional_carbon_intensity
+ SET fuel_type_id = {},
+ uom_id = {},
+ end_use_type_id = NULL,
+ intensity = {},
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE additional_uci_id = {}
+ """.format(fuel_type_id, uom_id, intensity, current_time, uci_id)
+ )
+ else:
+ op.execute(
+ """
+ UPDATE additional_carbon_intensity
+ SET fuel_type_id = NULL,
+ uom_id = {},
+ end_use_type_id = NULL,
+ intensity = {},
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE additional_uci_id = {}
+ """.format(uom_id, intensity, current_time, uci_id)
+ )
+
+ # Update existing EER values for IDs 14-24
+ eer_updates = [
+ (14, 2, 3, 10, 2.8), # Changed to Shore power
+ (15, 2, 3, 11, 2.4), # Changed to Trolley bus
+ (16, 2, 3, 2, 1.0), # Changed to Other or unknown
+ (17, 2, 6, 3, 1.8), # Changed to Fuel cell vehicle
+ (18, 2, 6, 2, 0.9), # Changed to Other or unknown
+ (19, 2, 13, None, 0.9), # Changed to default ratio
+ (20, 3, 3, None, 2.5), # Changed to default ratio
+ (21, 3, 11, None, 1.0), # Changed to default ratio
+ (22, 2, 7, 15, 1.0), # Changed to new marine type
+ (23, 2, 7, 16, 1.0), # Changed to new marine type
+ (24, 2, 7, 17, 1.0) # Changed to new marine type
+ ]
+
+ for eer_id, fuel_category_id, fuel_type_id, end_use_type_id, ratio in eer_updates:
+ if end_use_type_id:
+ op.execute(
+ """
+ UPDATE energy_effectiveness_ratio
+ SET fuel_category_id = {},
+ fuel_type_id = {},
+ end_use_type_id = {},
+ ratio = {},
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE eer_id = {}
+ """.format(fuel_category_id, fuel_type_id, end_use_type_id, ratio, current_time, eer_id)
+ )
+ else:
+ op.execute(
+ """
+ UPDATE energy_effectiveness_ratio
+ SET fuel_category_id = {},
+ fuel_type_id = {},
+ end_use_type_id = NULL,
+ ratio = {},
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE eer_id = {}
+ """.format(fuel_category_id, fuel_type_id, ratio, current_time, eer_id)
+ )
+
+
+def downgrade() -> None:
+ current_time = datetime.now()
+
+ # Restore original end use types 14-21
+ original_values = [
+ (14, 'Marine', 'General'),
+ (15, 'Marine', 'Operated within 51 to 75% of load range'),
+ (16, 'Marine', 'Operated within 76 to 100% of load range'),
+ (17, 'Marine, w/ methane slip reduction kit', 'General'),
+ (18, 'Marine, w/ methane slip reduction kit',
+ 'Operated within 51 to 75% of load range'),
+ (19, 'Marine, w/ methane slip reduction kit',
+ 'Operated within 76 to 100% of load range'),
+ (20, 'Unknown', None),
+ (21, 'Aircraft', None)
+ ]
+
+ for end_use_id, type_name, sub_type in original_values:
+ if sub_type:
+ op.execute(
+ """
+ UPDATE end_use_type
+ SET type = '{}',
+ sub_type = '{}',
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE end_use_type_id = {}
+ """.format(type_name, sub_type, current_time, end_use_id)
+ )
+ else:
+ op.execute(
+ """
+ UPDATE end_use_type
+ SET type = '{}',
+ sub_type = NULL,
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE end_use_type_id = {}
+ """.format(type_name, current_time, end_use_id)
+ )
+
+ # Restore original UCI values
+ uci_originals = [
+ (1, 7, 5, 14, 27.3),
+ (2, 7, 5, 15, 17.8),
+ (3, 7, 5, 16, 12.2),
+ (4, 7, 5, 17, 10.6),
+ (5, 7, 5, 18, 8.4),
+ (6, 7, 5, 19, 8.0),
+ (7, 7, 5, 20, 27.3),
+ (8, 7, 5, None, 0),
+ (9, None, 5, None, 0)
+ ]
+
+ for uci_id, fuel_type_id, uom_id, end_use_type_id, intensity in uci_originals:
+ if fuel_type_id and end_use_type_id:
+ op.execute(
+ """
+ UPDATE additional_carbon_intensity
+ SET fuel_type_id = {},
+ uom_id = {},
+ end_use_type_id = {},
+ intensity = {},
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE additional_uci_id = {}
+ """.format(fuel_type_id, uom_id, end_use_type_id, intensity, current_time, uci_id)
+ )
+ elif fuel_type_id:
+ op.execute(
+ """
+ UPDATE additional_carbon_intensity
+ SET fuel_type_id = {},
+ uom_id = {},
+ end_use_type_id = NULL,
+ intensity = {},
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE additional_uci_id = {}
+ """.format(fuel_type_id, uom_id, intensity, current_time, uci_id)
+ )
+ else:
+ op.execute(
+ """
+ UPDATE additional_carbon_intensity
+ SET fuel_type_id = NULL,
+ uom_id = {},
+ end_use_type_id = NULL,
+ intensity = {},
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE additional_uci_id = {}
+ """.format(uom_id, intensity, current_time, uci_id)
+ )
+
+ # Restore original EER values
+ eer_originals = [
+ (14, 2, 3, 14, 2.5), # Restore to Marine
+ (15, 2, 3, 10, 2.8), # Restore to Shore power
+ (16, 2, 3, 11, 2.4), # Restore to Trolley bus
+ (17, 2, 3, 2, 1.0), # Restore to Other or unknown
+ (18, 2, 6, 3, 1.8), # Restore to Fuel cell vehicle
+ (19, 2, 6, 2, 0.9), # Restore to Other or unknown
+ (20, 2, 7, 12, 1.0), # Restore to Compression-ignition engine
+ (21, 2, 7, 2, 0.9), # Restore to Other or unknown
+ (22, 2, 13, None, 0.9), # Restore to default ratio
+ (23, 3, 3, None, 2.5), # Restore to default ratio
+ (24, 3, 11, None, 1.0) # Restore to default ratio
+ ]
+
+ for eer_id, fuel_category_id, fuel_type_id, end_use_type_id, ratio in eer_originals:
+ if end_use_type_id:
+ op.execute(
+ """
+ UPDATE energy_effectiveness_ratio
+ SET fuel_category_id = {},
+ fuel_type_id = {},
+ end_use_type_id = {},
+ ratio = {},
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE eer_id = {}
+ """.format(fuel_category_id, fuel_type_id, end_use_type_id, ratio, current_time, eer_id)
+ )
+ else:
+ op.execute(
+ """
+ UPDATE energy_effectiveness_ratio
+ SET fuel_category_id = {},
+ fuel_type_id = {},
+ end_use_type_id = NULL,
+ ratio = {},
+ update_date = '{}',
+ update_user = 'no_user'
+ WHERE eer_id = {}
+ """.format(fuel_category_id, fuel_type_id, ratio, current_time, eer_id)
+ )
diff --git a/backend/lcfs/db/models/fuel/FuelCode.py b/backend/lcfs/db/models/fuel/FuelCode.py
index 441969b37..aa3a28ed0 100644
--- a/backend/lcfs/db/models/fuel/FuelCode.py
+++ b/backend/lcfs/db/models/fuel/FuelCode.py
@@ -94,7 +94,7 @@ class FuelCode(BaseModel, Auditable, EffectiveDates):
fuel_code_prefix = relationship(
"FuelCodePrefix", back_populates="fuel_codes", lazy="joined"
)
- fuel_code_type = relationship(
+ fuel_type = relationship(
"FuelType", back_populates="fuel_codes", lazy="joined"
)
diff --git a/backend/lcfs/db/models/fuel/FuelType.py b/backend/lcfs/db/models/fuel/FuelType.py
index cf603c4ca..5f652b738 100644
--- a/backend/lcfs/db/models/fuel/FuelType.py
+++ b/backend/lcfs/db/models/fuel/FuelType.py
@@ -54,7 +54,7 @@ class FuelType(BaseModel, Auditable, DisplayOrder):
)
# Relationships
- fuel_codes = relationship("FuelCode", back_populates="fuel_code_type")
+ fuel_codes = relationship("FuelCode", back_populates="fuel_type")
energy_density = relationship(
"EnergyDensity", back_populates="fuel_type", uselist=False
) # One energy density per fuel type
diff --git a/backend/lcfs/db/models/notification/NotificationChannel.py b/backend/lcfs/db/models/notification/NotificationChannel.py
index f27a61eb2..2d1103b51 100644
--- a/backend/lcfs/db/models/notification/NotificationChannel.py
+++ b/backend/lcfs/db/models/notification/NotificationChannel.py
@@ -5,8 +5,8 @@
class ChannelEnum(enum.Enum):
- EMAIL = "Email"
- IN_APP = "In-Application"
+ EMAIL = "EMAIL"
+ IN_APP = "IN_APP"
class NotificationChannel(BaseModel, Auditable):
diff --git a/backend/lcfs/db/models/notification/NotificationType.py b/backend/lcfs/db/models/notification/NotificationType.py
index 2311d834d..9c8f31914 100644
--- a/backend/lcfs/db/models/notification/NotificationType.py
+++ b/backend/lcfs/db/models/notification/NotificationType.py
@@ -1,28 +1,17 @@
-import enum
from lcfs.db.base import BaseModel, Auditable
-from sqlalchemy import Column, Integer, Enum, Text
+from sqlalchemy import Column, Integer, Text, String
from sqlalchemy.orm import relationship
-class NotificationTypeEnum(enum.Enum):
- TRANSFER_PARTNER_UPDATE = "Transfer partner proposed, declined, rescinded, or signed"
- TRANSFER_DIRECTOR_REVIEW = "Director recorded/refused"
- INITIATIVE_APPROVED = "Director approved"
- INITIATIVE_DA_REQUEST = "DA request"
- SUPPLEMENTAL_REQUESTED = "Supplemental requested"
- DIRECTOR_ASSESSMENT = "Director assessment"
-
-
class NotificationType(BaseModel, Auditable):
__tablename__ = "notification_type"
__table_args__ = {"comment": "Represents a Notification type"}
notification_type_id = Column(Integer, primary_key=True, autoincrement=True)
- name = Column(
- Enum(NotificationTypeEnum, name="notification_type_enum", create_type=True),
- nullable=False,
- )
+ name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
email_content = Column(Text, nullable=True)
- subscriptions = relationship("NotificationChannelSubscription", back_populates="notification_type")
+ subscriptions = relationship(
+ "NotificationChannelSubscription", back_populates="notification_type"
+ )
diff --git a/backend/lcfs/db/models/notification/__init__.py b/backend/lcfs/db/models/notification/__init__.py
index ebc71c209..e16003e7e 100644
--- a/backend/lcfs/db/models/notification/__init__.py
+++ b/backend/lcfs/db/models/notification/__init__.py
@@ -1,10 +1,12 @@
from .NotificationChannel import NotificationChannel
+from .NotificationChannel import ChannelEnum
from .NotificationChannelSubscription import NotificationChannelSubscription
from .NotificationMessage import NotificationMessage
from .NotificationType import NotificationType
__all__ = [
"NotificationChannel",
+ "ChannelEnum",
"NotificationChannelSubscription",
"NotificationMessage",
"NotificationType",
diff --git a/backend/lcfs/db/models/user/UserProfile.py b/backend/lcfs/db/models/user/UserProfile.py
index 7fecca2f8..681d19aa0 100644
--- a/backend/lcfs/db/models/user/UserProfile.py
+++ b/backend/lcfs/db/models/user/UserProfile.py
@@ -26,6 +26,9 @@ class UserProfile(BaseModel, Auditable):
String(150), unique=True, nullable=False, comment="keycloak Username"
)
email = Column(String(255), nullable=True, comment="Primary email address")
+ notifications_email = Column(
+ String(255), nullable=True, comment="Email address used for notifications"
+ )
title = Column(String(100), nullable=True, comment="Professional Title")
phone = Column(String(50), nullable=True, comment="Primary phone number")
mobile_phone = Column(String(50), nullable=True, comment="Mobile phone number")
diff --git a/backend/lcfs/db/seeders/common/notifications_seeder.py b/backend/lcfs/db/seeders/common/notifications_seeder.py
index e1bfe6fc8..363ae6c98 100644
--- a/backend/lcfs/db/seeders/common/notifications_seeder.py
+++ b/backend/lcfs/db/seeders/common/notifications_seeder.py
@@ -58,44 +58,121 @@ async def seed_notification_types(session):
"""
types_to_seed = [
{
- "name": "TRANSFER_PARTNER_UPDATE",
- "description": "Transfer partner update notification",
- "email_content": "Email content for transfer partner update",
+ "name": "BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT",
+ "description": "Director assessed a compliance report or supplemental report.",
+ "email_content": "Email content",
"create_user": "system",
"update_user": "system",
},
{
- "name": "TRANSFER_DIRECTOR_REVIEW",
- "description": "Director review notification",
- "email_content": "Email content for director review",
+ "name": "BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL",
+ "description": "Director approved the initiative agreement or transaction",
+ "email_content": "Email content",
"create_user": "system",
"update_user": "system",
},
{
- "name": "INITIATIVE_APPROVED",
- "description": "Initiative approved notification",
- "email_content": "Email content for initiative approval",
+ "name": "BCEID__TRANSFER__DIRECTOR_DECISION",
+ "description": "Director recorded or refused a transfer request",
+ "email_content": "Email content",
"create_user": "system",
"update_user": "system",
},
{
- "name": "INITIATIVE_DA_REQUEST",
- "description": "DA request notification",
- "email_content": "Email content for DA request",
+ "name": "BCEID__TRANSFER__PARTNER_ACTIONS",
+ "description": "A transfer partner took action (proposed, declined, rescinded, or signed & submitted) on a transfer request",
+ "email_content": "Email content",
"create_user": "system",
"update_user": "system",
},
{
- "name": "SUPPLEMENTAL_REQUESTED",
- "description": "Supplemental requested notification",
- "email_content": "Email content for supplemental request",
+ "name": "IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION",
+ "description": "Director assessed compliance report",
+ "email_content": "Email content",
"create_user": "system",
"update_user": "system",
},
{
- "name": "DIRECTOR_ASSESSMENT",
- "description": "Director assessment notification",
- "email_content": "Email content for director assessment",
+ "name": "IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION",
+ "description": "Compliance manager recommended action on the compliance report.",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW",
+ "description": "Compliance report submitted for government analyst review or returned by compliance manager",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST",
+ "description": "Director approved/returned the initiative agreement to the analyst",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED",
+ "description": "Director recorded or refused a transfer request",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_ANALYST__TRANSFER__RESCINDED_ACTION",
+ "description": "A transfer request was rescinded by a transfer partner",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW",
+ "description": "Transfer request submitted for government analyst review",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION",
+ "description": "Analyst recommendation on the compliance report or returned by the director",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT",
+ "description": "Director assessed a compliance report",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW",
+ "description": "Compliance report submitted for government analyst review",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION",
+ "description": "Compliance manager recommended action on the compliance report",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION",
+ "description": "Analyst recommendation provided for the initiative agreement",
+ "email_content": "Email content",
+ "create_user": "system",
+ "update_user": "system",
+ },
+ {
+ "name": "IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION",
+ "description": "Analyst recommendation provided for the transfer request",
+ "email_content": "Email content",
"create_user": "system",
"update_user": "system",
},
diff --git a/backend/lcfs/prestart.sh b/backend/lcfs/prestart.sh
index 952f220c6..3fe3f9cda 100755
--- a/backend/lcfs/prestart.sh
+++ b/backend/lcfs/prestart.sh
@@ -1,6 +1,13 @@
#!/usr/bin/env bash
-echo "running prestart.sh from $(pwd)"
+echo "Running prestart.sh from $(pwd)"
+
+# Check for Alembic head conflicts
+HEAD_COUNT=$(poetry run alembic heads | wc -l)
+if [ "$HEAD_COUNT" -gt 1 ]; then
+ echo "Alembic head conflict detected: Multiple migration heads present."
+ exit 1
+fi
# Apply base database migrations
echo "Applying base migrations."
@@ -23,5 +30,4 @@ if [ $? -ne 0 ]; then
fi
echo "Migrations and seeding completed successfully."
-
-echo "done running prestart.sh from $(pwd)"
+echo "Done running prestart.sh from $(pwd)"
diff --git a/backend/lcfs/start.sh b/backend/lcfs/start.sh
index b84880611..7e19eb1ef 100755
--- a/backend/lcfs/start.sh
+++ b/backend/lcfs/start.sh
@@ -1,5 +1,8 @@
#!/usr/bin/env bash
+# Enable strict error handling
+set -e
+
# Wait for the database to be ready
./wait-for-it.sh $LCFS_DB_HOST:5432 --timeout=30
diff --git a/backend/lcfs/tests/fuel_code/test_fuel_code_exporter.py b/backend/lcfs/tests/fuel_code/test_fuel_code_exporter.py
index 412615f61..7b7840be5 100644
--- a/backend/lcfs/tests/fuel_code/test_fuel_code_exporter.py
+++ b/backend/lcfs/tests/fuel_code/test_fuel_code_exporter.py
@@ -28,7 +28,7 @@ async def test_export_success():
approval_date="2023-11-01",
effective_date="2023-12-01",
expiration_date="2024-01-01",
- fuel_code_type=MagicMock(fuel_type="Diesel"),
+ fuel_type=MagicMock(fuel_type="Diesel"),
feedstock="Corn oil",
feedstock_location="Canada",
feedstock_misc=None,
diff --git a/backend/lcfs/tests/fuel_code/test_fuel_code_repo.py b/backend/lcfs/tests/fuel_code/test_fuel_code_repo.py
index 1e72c4abd..a8c4945d1 100644
--- a/backend/lcfs/tests/fuel_code/test_fuel_code_repo.py
+++ b/backend/lcfs/tests/fuel_code/test_fuel_code_repo.py
@@ -1,5 +1,5 @@
import pytest
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, MagicMock
from lcfs.web.api.fuel_code.repo import FuelCodeRepository
from lcfs.db.models.fuel.TransportMode import TransportMode
@@ -23,10 +23,10 @@ async def test_get_transport_mode_by_name(fuel_code_repo, mock_db):
# Define the test transport mode
transport_mode_name = "Truck"
mock_transport_mode = TransportMode(transport_mode_id=1, transport_mode="Truck")
-
+
# Mock the database query result
- mock_db.execute.return_value.scalar_one_or_none = AsyncMock()
- mock_db.execute.return_value.scalar_one_or_none.return_value = mock_transport_mode
+ mock_db.execute.return_value.scalar_one = MagicMock()
+ mock_db.execute.return_value.scalar_one.return_value = mock_transport_mode
# Call the repository method
result = await fuel_code_repo.get_transport_mode_by_name(transport_mode_name)
@@ -35,4 +35,4 @@ async def test_get_transport_mode_by_name(fuel_code_repo, mock_db):
assert result == mock_transport_mode
# Ensure the database query was called
- mock_db.execute.assert_called_once()
\ No newline at end of file
+ mock_db.execute.assert_called_once()
diff --git a/backend/lcfs/tests/fuel_code/test_fuel_code_service.py b/backend/lcfs/tests/fuel_code/test_fuel_code_service.py
index 8560a4401..c71608031 100644
--- a/backend/lcfs/tests/fuel_code/test_fuel_code_service.py
+++ b/backend/lcfs/tests/fuel_code/test_fuel_code_service.py
@@ -43,7 +43,7 @@ async def test_get_fuel_codes_success():
pagination = PaginationRequestSchema(page=1, size=10)
# Act
- result = await service.get_fuel_codes(pagination)
+ result = await service.search_fuel_codes(pagination)
# Assert
assert isinstance(result.pagination, PaginationResponseSchema)
diff --git a/backend/lcfs/tests/fuel_code/test_fuel_code_views.py b/backend/lcfs/tests/fuel_code/test_fuel_code_views.py
index 12d3ccce4..4bfa75bfc 100644
--- a/backend/lcfs/tests/fuel_code/test_fuel_code_views.py
+++ b/backend/lcfs/tests/fuel_code/test_fuel_code_views.py
@@ -7,8 +7,15 @@
from httpx import AsyncClient
from starlette import status
+from fastapi import FastAPI
+
+from lcfs.db.models.fuel.FuelCodeStatus import FuelCodeStatusEnum
from lcfs.db.models.user.Role import RoleEnum
-from lcfs.web.api.fuel_code.schema import FuelCodeCreateUpdateSchema
+from lcfs.web.api.fuel_code.schema import (
+ FuelCodeCreateUpdateSchema,
+ TransportModeSchema,
+ FuelCodeStatusSchema,
+)
from lcfs.web.exception.exceptions import DataNotFoundException
@@ -232,7 +239,7 @@ async def test_get_fuel_codes_success(
pagination_request_schema,
):
with patch(
- "lcfs.web.api.fuel_code.services.FuelCodeServices.get_fuel_codes"
+ "lcfs.web.api.fuel_code.services.FuelCodeServices.search_fuel_codes"
) as mock_get_fuel_codes:
set_user_role(RoleEnum.GOVERNMENT)
@@ -570,3 +577,62 @@ def test_valid_capacity_unit(valid_fuel_code_data):
valid_data["facility_nameplate_capacity_unit"] = "Gallons"
model = FuelCodeCreateUpdateSchema(**valid_data)
assert model.facility_nameplate_capacity == 100
+
+
+@pytest.mark.anyio
+async def test_get_fuel_code_statuses_success(
+ client: AsyncClient, fastapi_app: FastAPI
+):
+ with patch(
+ "lcfs.web.api.fuel_code.services.FuelCodeServices.get_fuel_code_statuses"
+ ) as mock_get_statuses:
+ # Mock the return value of the service
+ mock_get_statuses.return_value = [
+ FuelCodeStatusSchema(
+ fuel_code_status_id=1, status=FuelCodeStatusEnum.Draft
+ ),
+ FuelCodeStatusSchema(
+ fuel_code_status_id=2, status=FuelCodeStatusEnum.Approved
+ ),
+ ]
+
+ # Send GET request to the endpoint
+ url = "/api/fuel-codes/statuses"
+ response = await client.get(url)
+
+ # Assertions
+ assert response.status_code == status.HTTP_200_OK
+ result = response.json()
+ assert isinstance(result, list)
+ assert len(result) == 2
+ print(result[0])
+ assert result[0]["fuelCodeStatusId"] == 1
+ assert result[0]["status"] == "Draft"
+ assert result[1]["fuelCodeStatusId"] == 2
+ assert result[1]["status"] == "Approved"
+
+
+@pytest.mark.anyio
+async def test_get_transport_modes_success(client: AsyncClient, fastapi_app: FastAPI):
+ with patch(
+ "lcfs.web.api.fuel_code.services.FuelCodeServices.get_transport_modes"
+ ) as mock_get_modes:
+ # Mock the return value of the service
+ mock_get_modes.return_value = [
+ TransportModeSchema(transport_mode_id=1, transport_mode="Truck"),
+ TransportModeSchema(transport_mode_id=2, transport_mode="Ship"),
+ ]
+
+ # Send GET request to the endpoint
+ url = "/api/fuel-codes/transport-modes"
+ response = await client.get(url)
+
+ # Assertions
+ assert response.status_code == status.HTTP_200_OK
+ result = response.json()
+ assert isinstance(result, list)
+ assert len(result) == 2
+ assert result[0]["transportModeId"] == 1
+ assert result[0]["transportMode"] == "Truck"
+ assert result[1]["transportModeId"] == 2
+ assert result[1]["transportMode"] == "Ship"
diff --git a/backend/lcfs/tests/notification/test_notification_repo.py b/backend/lcfs/tests/notification/test_notification_repo.py
index f3dbdcd12..20eb31169 100644
--- a/backend/lcfs/tests/notification/test_notification_repo.py
+++ b/backend/lcfs/tests/notification/test_notification_repo.py
@@ -1,11 +1,15 @@
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import delete
+from sqlalchemy import delete
from lcfs.web.api.notification.repo import NotificationRepository
-from lcfs.db.models.notification import NotificationMessage, NotificationChannelSubscription
+from lcfs.db.models.notification import (
+ NotificationMessage,
+ NotificationChannelSubscription,
+)
from lcfs.web.exception.exceptions import DataNotFoundException
from unittest.mock import AsyncMock, MagicMock
+
@pytest.fixture
def mock_db_session():
session = AsyncMock(spec=AsyncSession)
@@ -37,7 +41,9 @@ def notification_repo(mock_db_session):
async def test_create_notification_message(notification_repo, mock_db_session):
new_notification_message = MagicMock(spec=NotificationMessage)
- result = await notification_repo.create_notification_message(new_notification_message)
+ result = await notification_repo.create_notification_message(
+ new_notification_message
+ )
assert result == new_notification_message
mock_db_session.add.assert_called_once_with(new_notification_message)
@@ -66,9 +72,10 @@ async def mock_execute(*args, **kwargs):
mock_result_chain.scalar_one_or_none.assert_called_once()
-
@pytest.mark.anyio
-async def test_get_notification_message_by_id_not_found(notification_repo, mock_db_session):
+async def test_get_notification_message_by_id_not_found(
+ notification_repo, mock_db_session
+):
mock_result_chain = MagicMock()
mock_result_chain.scalars = MagicMock(return_value=mock_result_chain)
mock_result_chain.scalar_one_or_none.side_effect = DataNotFoundException
@@ -97,7 +104,10 @@ async def test_get_notification_messages_by_user(notification_repo, mock_db_sess
mock_notification2.message = "Test message 2"
mock_result_chain = MagicMock()
- mock_result_chain.scalars.return_value.all.return_value = [mock_notification1, mock_notification2]
+ mock_result_chain.scalars.return_value.all.return_value = [
+ mock_notification1,
+ mock_notification2,
+ ]
async def mock_execute(*args, **kwargs):
return mock_result_chain
@@ -113,7 +123,9 @@ async def mock_execute(*args, **kwargs):
@pytest.mark.anyio
-async def test_get_unread_notification_message_count_by_user_id(notification_repo, mock_db_session):
+async def test_get_unread_notification_message_count_by_user_id(
+ notification_repo, mock_db_session
+):
user_id = 1
expected_unread_count = 5
@@ -122,7 +134,9 @@ async def test_get_unread_notification_message_count_by_user_id(notification_rep
mock_db_session.execute = AsyncMock(return_value=mock_result)
- result = await notification_repo.get_unread_notification_message_count_by_user_id(user_id)
+ result = await notification_repo.get_unread_notification_message_count_by_user_id(
+ user_id
+ )
assert result == expected_unread_count
mock_db_session.execute.assert_called_once()
@@ -148,7 +162,6 @@ async def test_delete_notification_message(notification_repo, mock_db_session):
mock_db_session.flush.assert_called_once()
-
@pytest.mark.anyio
async def test_update_notification_message(notification_repo, mock_db_session):
mock_notification = MagicMock(spec=NotificationMessage)
@@ -197,14 +210,20 @@ async def mock_execute(*args, **kwargs):
assert result.is_read is True # Verify that is_read was set to True
mock_db_session.commit.assert_called_once() # Ensure commit was called
- mock_db_session.refresh.assert_called_once_with(mock_notification) # Check refresh was called
+ mock_db_session.refresh.assert_called_once_with(
+ mock_notification
+ ) # Check refresh was called
@pytest.mark.anyio
-async def test_create_notification_channel_subscription(notification_repo, mock_db_session):
+async def test_create_notification_channel_subscription(
+ notification_repo, mock_db_session
+):
new_subscription = MagicMock(spec=NotificationChannelSubscription)
- result = await notification_repo.create_notification_channel_subscription(new_subscription)
+ result = await notification_repo.create_notification_channel_subscription(
+ new_subscription
+ )
assert result == new_subscription
mock_db_session.add.assert_called_once_with(new_subscription)
@@ -213,16 +232,18 @@ async def test_create_notification_channel_subscription(notification_repo, mock_
@pytest.mark.anyio
-async def test_get_notification_channel_subscriptions_by_user(notification_repo, mock_db_session):
+async def test_get_notification_channel_subscriptions_by_user(
+ notification_repo, mock_db_session
+):
mock_subscription = MagicMock(spec=NotificationChannelSubscription)
mock_subscription.user_profile_id = 1
mock_subscription.notification_channel_subscription_id = 1
mock_subscription.notification_type_id = 1
mock_subscription.notification_channel_id = 1
-
+
mock_result_chain = MagicMock()
mock_result_chain.scalars.return_value.all.return_value = [mock_subscription]
-
+
async def mock_execute(*args, **kwargs):
return mock_result_chain
@@ -235,54 +256,24 @@ async def mock_execute(*args, **kwargs):
@pytest.mark.anyio
-async def test_get_notification_channel_subscriptions_by_id(notification_repo, mock_db_session):
+async def test_get_notification_channel_subscriptions_by_id(
+ notification_repo, mock_db_session
+):
subscription_id = 1
mock_subscription = MagicMock(spec=NotificationChannelSubscription)
mock_subscription.notification_channel_subscription_id = subscription_id
-
mock_result_chain = MagicMock()
- mock_result_chain.scalar_one.return_value = mock_subscription
-
+ mock_result_chain.scalar_one.return_value = mock_subscription
+
async def mock_execute(*args, **kwargs):
return mock_result_chain
mock_db_session.execute = mock_execute
- result = await notification_repo.get_notification_channel_subscription_by_id(subscription_id)
+ result = await notification_repo.get_notification_channel_subscription_by_id(
+ subscription_id
+ )
assert result is not None
assert result.notification_channel_subscription_id == subscription_id
-
-
-@pytest.mark.anyio
-async def test_update_notification_channel_subscription(notification_repo, mock_db_session):
- mock_subscription = MagicMock(spec=NotificationChannelSubscription)
- mock_subscription.notification_channel_subscription_id = 1
- mock_subscription.is_enabled = True
- mock_subscription.user_profile_id = 1
- mock_subscription.notification_type_id= 1
- mock_subscription.notification_channel_id = 1
-
- updated_subscription = NotificationChannelSubscription(
- notification_channel_subscription_id=1,
- is_enabled=False,
- user_profile_id=1,
- notification_type_id=2,
- notification_channel_id=1
- )
-
- mock_db_session.merge.return_value = updated_subscription
- mock_db_session.flush = AsyncMock()
-
- notification_repo.db = mock_db_session
-
- result = await notification_repo.update_notification_channel_subscription(mock_subscription)
-
- mock_db_session.merge.assert_called_once_with(mock_subscription)
- mock_db_session.flush.assert_called_once()
- assert result == updated_subscription
- assert result.is_enabled is False
- assert result.notification_type_id == 2
-
-
diff --git a/backend/lcfs/tests/notification/test_notification_services.py b/backend/lcfs/tests/notification/test_notification_services.py
index 2d2cc6ef7..a23b49388 100644
--- a/backend/lcfs/tests/notification/test_notification_services.py
+++ b/backend/lcfs/tests/notification/test_notification_services.py
@@ -272,49 +272,41 @@ async def test_create_notification_channel_subscription(notification_service):
subscription_data = SubscriptionSchema(
is_enabled=True,
- user_profile_id=1,
- notification_type_id=2,
- notification_channel_id=3,
+ notification_channel_name="EMAIL",
+ notification_type_name="BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT",
)
+ user_profile_id = 1
+
+ # Mock the methods that fetch IDs from keys
+ service.get_notification_channel_id_by_key = AsyncMock(return_value=3)
+ service.get_notification_type_id_by_key = AsyncMock(return_value=2)
+ # Mock the repo method
created_subscription = NotificationChannelSubscription(
notification_channel_subscription_id=123,
is_enabled=True,
- user_profile_id=1,
+ user_profile_id=user_profile_id,
notification_type_id=2,
notification_channel_id=3,
)
-
mock_repo.create_notification_channel_subscription = AsyncMock(
return_value=created_subscription
)
- result = await service.create_notification_channel_subscription(subscription_data)
+ result = await service.create_notification_channel_subscription(
+ subscription_data, user_profile_id
+ )
+
+ # Assertions
+ assert isinstance(result, SubscriptionSchema)
+ assert result.notification_channel_subscription_id == 123
+ assert result.is_enabled == True
+ # Verify that the repo method was called with the correct subscription
called_args, _ = mock_repo.create_notification_channel_subscription.await_args
passed_subscription = called_args[0]
-
- assert (
- result.notification_channel_subscription_id
- == created_subscription.notification_channel_subscription_id
- )
- assert result.is_enabled == created_subscription.is_enabled
- assert result.user_profile_id == created_subscription.user_profile_id
- assert result.notification_type_id == created_subscription.notification_type_id
- assert (
- result.notification_channel_id == created_subscription.notification_channel_id
- )
-
- assert passed_subscription.is_enabled == subscription_data.is_enabled
- assert passed_subscription.user_profile_id == subscription_data.user_profile_id
- assert (
- passed_subscription.notification_type_id
- == subscription_data.notification_type_id
- )
- assert (
- passed_subscription.notification_channel_id
- == subscription_data.notification_channel_id
- )
+ assert passed_subscription.is_enabled == True
+ assert passed_subscription.user_profile_id == user_profile_id
@pytest.mark.anyio
@@ -322,23 +314,33 @@ async def test_get_notification_channel_subscriptions_by_user_id(notification_se
service, mock_repo = notification_service
user_id = 1
- expected_subscriptions = [
- NotificationChannelSubscription(
- notification_channel_subscription_id=123,
- is_enabled=True,
- user_profile_id=user_id,
- notification_type_id=2,
- notification_channel_id=3,
- )
- ]
+ # Mock subscription data
+ mock_subscription = MagicMock(spec=NotificationChannelSubscription)
+ mock_subscription.notification_channel_subscription_id = 123
+ mock_subscription.user_profile_id = user_id
+ mock_subscription.is_enabled = True
+
+ # Mock associated channel and type
+ mock_channel = MagicMock()
+ mock_channel.channel_name.name = "email"
+ mock_subscription.notification_channel = mock_channel
+
+ mock_type = MagicMock()
+ mock_type.name = "new_message"
+ mock_subscription.notification_type = mock_type
mock_repo.get_notification_channel_subscriptions_by_user = AsyncMock(
- return_value=expected_subscriptions
+ return_value=[mock_subscription]
)
result = await service.get_notification_channel_subscriptions_by_user_id(user_id)
- assert result == expected_subscriptions
+ assert len(result) == 1
+ subscription = result[0]
+ assert subscription["notification_channel_subscription_id"] == 123
+ assert subscription["notification_channel_name"] == "email"
+ assert subscription["notification_type_name"] == "new_message"
+
mock_repo.get_notification_channel_subscriptions_by_user.assert_awaited_once_with(
user_id
)
@@ -369,63 +371,16 @@ async def test_get_notification_channel_subscription_by_id(notification_service)
)
-@pytest.mark.anyio
-async def test_update_notification_channel_subscription(notification_service):
- service, mock_repo = notification_service
-
- subscription_data = SubscriptionSchema(
- notification_channel_subscription_id=123,
- is_enabled=False,
- user_profile_id=1,
- notification_type_id=2,
- notification_channel_id=3,
- )
-
- updated_subscription = NotificationChannelSubscription(
- notification_channel_subscription_id=123,
- is_enabled=False,
- user_profile_id=1,
- notification_type_id=2,
- notification_channel_id=3,
- )
-
- mock_repo.update_notification_channel_subscription = AsyncMock(
- return_value=updated_subscription
- )
-
- result = await service.update_notification_channel_subscription(subscription_data)
-
- called_args, _ = mock_repo.update_notification_channel_subscription.await_args
- passed_subscription = called_args[0]
- assert (
- passed_subscription.notification_channel_subscription_id
- == updated_subscription.notification_channel_subscription_id
- )
- assert passed_subscription.is_enabled == subscription_data.is_enabled
- assert passed_subscription.user_profile_id == subscription_data.user_profile_id
- assert (
- passed_subscription.notification_type_id
- == subscription_data.notification_type_id
- )
- assert (
- passed_subscription.notification_channel_id
- == subscription_data.notification_channel_id
- )
-
-
@pytest.mark.anyio
async def test_delete_notification_channel_subscription(notification_service):
service, mock_repo = notification_service
- user_id = 1
+ user_profile_id = 1
subscription_id = 456
mock_subscription_data = NotificationChannelSubscription(
notification_channel_subscription_id=subscription_id,
- is_enabled=True,
- user_profile_id=user_id,
- notification_type_id=2,
- notification_channel_id=3,
+ user_profile_id=user_profile_id,
)
mock_repo.get_notification_channel_subscription_by_id = AsyncMock(
@@ -433,7 +388,9 @@ async def test_delete_notification_channel_subscription(notification_service):
)
mock_repo.delete_notification_channel_subscription = AsyncMock()
- await service.delete_notification_channel_subscription(subscription_id)
+ await service.delete_notification_channel_subscription(
+ subscription_id, user_profile_id
+ )
mock_repo.get_notification_channel_subscription_by_id.assert_awaited_once_with(
subscription_id
diff --git a/backend/lcfs/tests/notification/test_notification_views.py b/backend/lcfs/tests/notification/test_notification_views.py
index 9ce72fa86..7a7e2fa93 100644
--- a/backend/lcfs/tests/notification/test_notification_views.py
+++ b/backend/lcfs/tests/notification/test_notification_views.py
@@ -172,11 +172,9 @@ async def test_get_notification_channel_subscription_by_id(
mock_subscription = SubscriptionSchema(
notification_channel_subscription_id=1,
user_profile_id=1, # Match mock user
- channel_name="Test Channel",
- is_active=True,
- notification_type_id=1,
- created_at="2023-01-01T00:00:00",
- updated_at="2023-01-01T00:00:00",
+ notification_channel_name="EMAIL",
+ notification_type_name="BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT",
+ is_enabled=True,
)
mock_get_subscription.return_value = mock_subscription
@@ -200,7 +198,6 @@ async def test_get_notification_channel_subscription_by_id(
)
-
@pytest.mark.anyio
async def test_create_subscription(client, fastapi_app, set_mock_user):
with patch(
@@ -233,16 +230,19 @@ async def test_delete_subscription(client, fastapi_app, set_mock_user):
"lcfs.web.api.notification.views.NotificationService.delete_notification_channel_subscription",
return_value=None,
) as mock_delete_subscription:
- set_mock_user(fastapi_app, [RoleEnum.GOVERNMENT])
-
- mock_delete_subscription.return_value = {
- "message": "Notification Subscription deleted successfully"
- }
-
- subscription_data = {"notification_channel_subscription_id": 1, "deleted": True}
+ set_mock_user(
+ fastapi_app,
+ [RoleEnum.GOVERNMENT],
+ user_details={"user_profile_id": 1},
+ )
url = fastapi_app.url_path_for("save_subscription")
+ subscription_data = {
+ "notification_channel_subscription_id": 1,
+ "deleted": True,
+ }
+
response = await client.post(url, json=subscription_data)
assert response.status_code == 200
@@ -250,32 +250,6 @@ async def test_delete_subscription(client, fastapi_app, set_mock_user):
response.json()["message"]
== "Notification Subscription deleted successfully"
)
- mock_delete_subscription.assert_called_once_with(1)
-
-
-@pytest.mark.anyio
-async def test_update_subscription(client, fastapi_app, set_mock_user):
- with patch(
- "lcfs.web.api.notification.views.NotificationService.update_notification_channel_subscription"
- ) as mock_update_subscription:
- updated_subscription_data = {
- "notification_channel_subscription_id": 1,
- "is_enabled": False,
- "notification_channel_id": 1,
- "user_profile_id": 1,
- "notification_type_id": 1,
- }
-
- mock_update_subscription.return_value = updated_subscription_data
-
- set_mock_user(fastapi_app, [RoleEnum.GOVERNMENT])
-
- url = fastapi_app.url_path_for("save_subscription")
-
- response = await client.post(url, json=updated_subscription_data)
-
- assert response.status_code == 200
- assert response.json()["isEnabled"] is False
- mock_update_subscription.assert_called_once_with(
- SubscriptionSchema(**updated_subscription_data)
- )
+ mock_delete_subscription.assert_called_once_with(
+ 1, 1
+ ) # subscription_id, user_profile_id
diff --git a/backend/lcfs/tests/test_users.py b/backend/lcfs/tests/test_users.py
index 587c6b60d..0b286e642 100644
--- a/backend/lcfs/tests/test_users.py
+++ b/backend/lcfs/tests/test_users.py
@@ -110,7 +110,7 @@ async def test_get_users_with_filter(
{
"filterType": "number",
"type": "equals",
- "filter": "1",
+ "filter": 1,
"field": "user_profile_id",
}
],
@@ -120,6 +120,7 @@ async def test_get_users_with_filter(
# Check the status code
assert response.status_code == status.HTTP_200_OK
# check if pagination is working as expected.
+
content = UsersSchema(**response.json())
ids = [user.user_profile_id for user in content.users]
# check if only one user element exists with user_profile_id 1.
diff --git a/backend/lcfs/tests/user/test_user_repo.py b/backend/lcfs/tests/user/test_user_repo.py
index 19468ebc3..56cb75403 100644
--- a/backend/lcfs/tests/user/test_user_repo.py
+++ b/backend/lcfs/tests/user/test_user_repo.py
@@ -5,6 +5,7 @@
from lcfs.db.models import UserProfile, UserLoginHistory
from lcfs.web.api.user.repo import UserRepository
from lcfs.tests.user.user_payloads import user_orm_model
+from lcfs.web.exception.exceptions import DataNotFoundException
@pytest.fixture
@@ -46,3 +47,33 @@ async def test_create_login_history(dbsession, user_repo):
assert added_object.external_username == user.keycloak_username
assert added_object.keycloak_user_id == user.keycloak_user_id
assert added_object.is_login_successful is True
+
+
+@pytest.mark.anyio
+async def test_update_notifications_email_success(dbsession, user_repo):
+ # Arrange: Create a user in the database
+ user = UserProfile(
+ keycloak_user_id="user_id_1",
+ keycloak_email="user1@domain.com",
+ keycloak_username="username1",
+ email="user1@domain.com",
+ notifications_email=None,
+ title="Developer",
+ phone="1234567890",
+ mobile_phone="0987654321",
+ first_name="John",
+ last_name="Doe",
+ is_active=True,
+ organization_id=1,
+ )
+ dbsession.add(user)
+ await dbsession.commit()
+ await dbsession.refresh(user)
+
+ # Act: Update the notifications email
+ updated_user = await user_repo.update_notifications_email(
+ user_profile_id=1, email="new_email@domain.com"
+ )
+
+ # Assert: Check if the notifications email was updated
+ assert updated_user.notifications_email == "new_email@domain.com"
diff --git a/backend/lcfs/tests/user/test_user_views.py b/backend/lcfs/tests/user/test_user_views.py
index 2d4f1bafa..75a228c0c 100644
--- a/backend/lcfs/tests/user/test_user_views.py
+++ b/backend/lcfs/tests/user/test_user_views.py
@@ -22,6 +22,7 @@
UserActivitiesResponseSchema,
UserLoginHistoryResponseSchema,
)
+from lcfs.web.exception.exceptions import DataNotFoundException
@pytest.mark.anyio
@@ -464,3 +465,23 @@ async def test_track_logged_in_success(client: AsyncClient, fastapi_app, set_moc
# Extract the first argument of the first call
user_profile = mock_track_user_login.call_args[0][0]
assert isinstance(user_profile, UserProfile)
+
+
+@pytest.mark.anyio
+async def test_update_notifications_email_success(
+ client: AsyncClient,
+ fastapi_app,
+ set_mock_user,
+ add_models,
+):
+ set_mock_user(fastapi_app, [RoleEnum.GOVERNMENT])
+
+ # Prepare request data
+ request_data = {"notifications_email": "new_email@domain.com"}
+
+ # Act: Send POST request to the endpoint
+ url = fastapi_app.url_path_for("update_notifications_email")
+ response = await client.post(url, json=request_data)
+
+ # Assert: Check response status and content
+ assert response.status_code == status.HTTP_200_OK
diff --git a/backend/lcfs/tests/user/user_payloads.py b/backend/lcfs/tests/user/user_payloads.py
index 6361299a0..3491cfd5e 100644
--- a/backend/lcfs/tests/user/user_payloads.py
+++ b/backend/lcfs/tests/user/user_payloads.py
@@ -5,9 +5,11 @@
keycloak_user_id="user_id",
keycloak_email="email@domain.com",
keycloak_username="username",
+ email="email@domain.com",
+ notifications_email=None,
title="Developer",
phone="1234567890",
- mobile_phone="1234567890",
+ mobile_phone="0987654321",
first_name="John",
last_name="Smith",
is_active=True,
diff --git a/backend/lcfs/web/api/base.py b/backend/lcfs/web/api/base.py
index c8d09bde9..d072e8531 100644
--- a/backend/lcfs/web/api/base.py
+++ b/backend/lcfs/web/api/base.py
@@ -21,9 +21,11 @@ class BaseSchema(BaseModel):
from_attributes=True,
)
+
class ComplianceReportRequestSchema(BaseSchema):
compliance_report_id: int
+
def row_to_dict(row, schema):
d = {}
for field in schema.__fields__.values():
@@ -216,12 +218,12 @@ def apply_number_filter_conditions(field, filter_value, filter_option):
return and_(field >= filter_value[0], field <= filter_value[1])
else:
number_filter_mapping = {
- "equals": field == int(filter_value),
- "notEqual": field != int(filter_value),
- "greaterThan": field > int(filter_value),
- "greaterThanOrEqual": field >= int(filter_value),
- "lessThan": field < int(filter_value),
- "lessThanOrEqual": field <= int(filter_value),
+ "equals": field == filter_value,
+ "notEqual": field != filter_value,
+ "greaterThan": field > filter_value,
+ "greaterThanOrEqual": field >= filter_value,
+ "lessThan": field < filter_value,
+ "lessThanOrEqual": field <= filter_value,
}
return number_filter_mapping.get(filter_option)
diff --git a/backend/lcfs/web/api/email/repo.py b/backend/lcfs/web/api/email/repo.py
index d05e6863d..7d8c6cd6e 100644
--- a/backend/lcfs/web/api/email/repo.py
+++ b/backend/lcfs/web/api/email/repo.py
@@ -4,7 +4,6 @@
ChannelEnum,
NotificationChannel,
)
-from lcfs.db.models.notification.NotificationType import NotificationTypeEnum
from lcfs.web.core.decorators import repo_handler
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
@@ -32,7 +31,7 @@ async def get_subscribed_user_emails(
.join(NotificationChannelSubscription.notification_channel)
.filter(
NotificationChannelSubscription.notification_type.has(
- name=NotificationTypeEnum[notification_type.upper()]
+ name=notification_type
),
NotificationChannelSubscription.is_enabled == True,
NotificationChannel.channel_name
@@ -59,9 +58,7 @@ async def get_notification_template(self, notification_type: str) -> str:
NotificationChannelSubscription.notification_type_id
== NotificationType.notification_type_id,
)
- .filter(
- NotificationType.name == NotificationTypeEnum[notification_type.upper()]
- )
+ .filter(NotificationType.name == notification_type)
.limit(1) # Fetch only one record
)
diff --git a/backend/lcfs/web/api/email/services.py b/backend/lcfs/web/api/email/services.py
index f2ab678e1..602283e03 100644
--- a/backend/lcfs/web/api/email/services.py
+++ b/backend/lcfs/web/api/email/services.py
@@ -1,5 +1,4 @@
import os
-from lcfs.db.models.notification.NotificationType import NotificationTypeEnum
import requests
import structlog
from fastapi import Depends
@@ -37,9 +36,12 @@ def __init__(self, repo: CHESEmailRepository = Depends()):
self._token_expiry = None
self._validate_configuration()
- # Initialize template environment
+ # Update template directory path to the root templates directory
template_dir = os.path.join(os.path.dirname(__file__), "templates")
- self.template_env = Environment(loader=FileSystemLoader(template_dir))
+ self.template_env = Environment(
+ loader=FileSystemLoader(template_dir),
+ autoescape=True # Enable autoescaping for security
+ )
def _validate_configuration(self):
"""
@@ -64,7 +66,8 @@ async def send_notification_email(
notification_type, organization_id
)
if not recipient_emails:
- logger.info(f"No subscribers for notification type: {notification_type}")
+ logger.info(f"""No subscribers for notification type: {
+ notification_type}""")
return False
# Render the email content
@@ -85,15 +88,16 @@ def _render_email_template(
) -> str:
"""
Render an email template using a predefined mapping of template names to file paths.
+ Raises an exception if template is not found.
"""
- # Fetch template file path from the imported mapping
- template_file = TEMPLATE_MAPPING.get(
- NotificationTypeEnum[template_name], TEMPLATE_MAPPING["default"]
- )
-
- # Render the template
- template = self.template_env.get_template(template_file)
- return template.render(context)
+ try:
+ template_file = TEMPLATE_MAPPING[template_name]
+ template = self.template_env.get_template(template_file)
+ return template.render(**context)
+ except Exception as e:
+ logger.error(f"Template rendering error: {str(e)}")
+ raise ValueError(
+ f"Failed to render email template for {template_name}")
def _build_email_payload(
self, recipients: List[str], context: Dict[str, Any], body: str
diff --git a/backend/lcfs/web/api/email/template_mapping.py b/backend/lcfs/web/api/email/template_mapping.py
index 41f041d18..4aa78e163 100644
--- a/backend/lcfs/web/api/email/template_mapping.py
+++ b/backend/lcfs/web/api/email/template_mapping.py
@@ -1,12 +1,19 @@
-from lcfs.db.models.notification.NotificationType import NotificationTypeEnum
-
-
TEMPLATE_MAPPING = {
- NotificationTypeEnum.TRANSFER_DIRECTOR_REVIEW: "transfer_director_review.html",
- NotificationTypeEnum.INITIATIVE_APPROVED: "initiative_approved.html",
- NotificationTypeEnum.INITIATIVE_DA_REQUEST: "initiative_da_request.html",
- NotificationTypeEnum.SUPPLEMENTAL_REQUESTED: "supplemental_requested.html",
- NotificationTypeEnum.DIRECTOR_ASSESSMENT: "director_assessment.html",
- NotificationTypeEnum.TRANSFER_PARTNER_UPDATE: "transfer_partner_update.html",
- "default": "default.html"
+ "BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT": "bceid__compliance_report__director_assessment.html",
+ "BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL": "bceid__initiative_agreement__director_approval.html",
+ "BCEID__TRANSFER__DIRECTOR_DECISION": "bceid__transfer__director_decision.html",
+ "BCEID__TRANSFER__PARTNER_ACTIONS": "bceid__transfer__partner_actions.html",
+ "IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION": "idir_analyst__compliance_report__director_decision.html",
+ "IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION": "idir_analyst__compliance_report__manager_recommendation.html",
+ "IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW": "idir_analyst__compliance_report__submitted_for_review.html",
+ "IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST": "idir_analyst__initiative_agreement__returned_to_analyst.html",
+ "IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED": "idir_analyst__transfer__director_recorded.html",
+ "IDIR_ANALYST__TRANSFER__RESCINDED_ACTION": "idir_analyst__transfer__rescinded_action.html",
+ "IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW": "idir_analyst__transfer__submitted_for_review.html",
+ "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION": "idir_compliance_manager__compliance_report__analyst_recommendation.html",
+ "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT": "idir_compliance_manager__compliance_report__director_assessment.html",
+ "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW": "idir_compliance_manager__compliance_report__submitted_for_review.html",
+ "IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION": "idir_director__compliance_report__manager_recommendation.html",
+ "IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION": "idir_director__initiative_agreement__analyst_recommendation.html",
+ "IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION": "idir_director__transfer__analyst_recommendation.html",
}
diff --git a/backend/lcfs/web/api/email/templates/bceid__compliance_report__director_assessment.html b/backend/lcfs/web/api/email/templates/bceid__compliance_report__director_assessment.html
new file mode 100644
index 000000000..56009ef1b
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/bceid__compliance_report__director_assessment.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Credit Record' %}
+{% set url_slug = 'credit-records' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/bceid__initiative_agreement__director_approval.html b/backend/lcfs/web/api/email/templates/bceid__initiative_agreement__director_approval.html
new file mode 100644
index 000000000..35e2912d7
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/bceid__initiative_agreement__director_approval.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Initiative Agreement' %}
+{% set url_slug = 'initiative-agreement' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/bceid__transfer__director_decision.html b/backend/lcfs/web/api/email/templates/bceid__transfer__director_decision.html
new file mode 100644
index 000000000..18063fc72
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/bceid__transfer__director_decision.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Transfer' %}
+{% set url_slug = 'transfers' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/bceid__transfer__partner_actions.html b/backend/lcfs/web/api/email/templates/bceid__transfer__partner_actions.html
new file mode 100644
index 000000000..18063fc72
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/bceid__transfer__partner_actions.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Transfer' %}
+{% set url_slug = 'transfers' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/default.html b/backend/lcfs/web/api/email/templates/default.html
deleted file mode 100644
index 18e1cd851..000000000
--- a/backend/lcfs/web/api/email/templates/default.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- System Notification
- A {{ email_type }} has occurred in the Low Carbon Fuel Reporting System.
-
- For more details, please visit: {{ link }}
-
- You received this email because you subscribed to notifications.
-
-
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_analyst__compliance_report__director_decision.html b/backend/lcfs/web/api/email/templates/idir_analyst__compliance_report__director_decision.html
new file mode 100644
index 000000000..56009ef1b
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_analyst__compliance_report__director_decision.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Credit Record' %}
+{% set url_slug = 'credit-records' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_analyst__compliance_report__manager_recommendation.html b/backend/lcfs/web/api/email/templates/idir_analyst__compliance_report__manager_recommendation.html
new file mode 100644
index 000000000..56009ef1b
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_analyst__compliance_report__manager_recommendation.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Credit Record' %}
+{% set url_slug = 'credit-records' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_analyst__compliance_report__submitted_for_review.html b/backend/lcfs/web/api/email/templates/idir_analyst__compliance_report__submitted_for_review.html
new file mode 100644
index 000000000..56009ef1b
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_analyst__compliance_report__submitted_for_review.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Credit Record' %}
+{% set url_slug = 'credit-records' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_analyst__initiative_agreement__returned_to_analyst.html b/backend/lcfs/web/api/email/templates/idir_analyst__initiative_agreement__returned_to_analyst.html
new file mode 100644
index 000000000..43a49a9d1
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_analyst__initiative_agreement__returned_to_analyst.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Initiative Agreement' %}
+{% set url_slug = 'initiative-agreements' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_analyst__transfer__director_recorded.html b/backend/lcfs/web/api/email/templates/idir_analyst__transfer__director_recorded.html
new file mode 100644
index 000000000..9ddb47ee8
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_analyst__transfer__director_recorded.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Transfer' %}
+{% set url_slug = 'transfer-requests' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_analyst__transfer__rescinded_action.html b/backend/lcfs/web/api/email/templates/idir_analyst__transfer__rescinded_action.html
new file mode 100644
index 000000000..9ddb47ee8
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_analyst__transfer__rescinded_action.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Transfer' %}
+{% set url_slug = 'transfer-requests' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_analyst__transfer__submitted_for_review.html b/backend/lcfs/web/api/email/templates/idir_analyst__transfer__submitted_for_review.html
new file mode 100644
index 000000000..9ddb47ee8
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_analyst__transfer__submitted_for_review.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Transfer' %}
+{% set url_slug = 'transfer-requests' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_compliance_manager__compliance_report__analyst_recommendation.html b/backend/lcfs/web/api/email/templates/idir_compliance_manager__compliance_report__analyst_recommendation.html
new file mode 100644
index 000000000..56009ef1b
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_compliance_manager__compliance_report__analyst_recommendation.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Credit Record' %}
+{% set url_slug = 'credit-records' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_compliance_manager__compliance_report__director_assessment.html b/backend/lcfs/web/api/email/templates/idir_compliance_manager__compliance_report__director_assessment.html
new file mode 100644
index 000000000..56009ef1b
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_compliance_manager__compliance_report__director_assessment.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Credit Record' %}
+{% set url_slug = 'credit-records' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_compliance_manager__compliance_report__submitted_for_review.html b/backend/lcfs/web/api/email/templates/idir_compliance_manager__compliance_report__submitted_for_review.html
new file mode 100644
index 000000000..56009ef1b
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_compliance_manager__compliance_report__submitted_for_review.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Credit Record' %}
+{% set url_slug = 'credit-records' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_director__compliance_report__manager_recommendation.html b/backend/lcfs/web/api/email/templates/idir_director__compliance_report__manager_recommendation.html
new file mode 100644
index 000000000..56009ef1b
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_director__compliance_report__manager_recommendation.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Credit Record' %}
+{% set url_slug = 'credit-records' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_director__initiative_agreement__analyst_recommendation.html b/backend/lcfs/web/api/email/templates/idir_director__initiative_agreement__analyst_recommendation.html
new file mode 100644
index 000000000..43a49a9d1
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_director__initiative_agreement__analyst_recommendation.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Initiative Agreement' %}
+{% set url_slug = 'initiative-agreements' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/idir_director__transfer__analyst_recommendation.html b/backend/lcfs/web/api/email/templates/idir_director__transfer__analyst_recommendation.html
new file mode 100644
index 000000000..9ddb47ee8
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/idir_director__transfer__analyst_recommendation.html
@@ -0,0 +1,3 @@
+{% extends 'notification_base.html' %}
+{% set notification_type = 'Transfer' %}
+{% set url_slug = 'transfer-requests' %}
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/initiative_approved.html b/backend/lcfs/web/api/email/templates/initiative_approved.html
deleted file mode 100644
index f2ae933b6..000000000
--- a/backend/lcfs/web/api/email/templates/initiative_approved.html
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
- Initiative agreement approved notification
- IA ID: {{ initiative_agreement_id }}
- Compliance units: {{ compliance_units }}
- Effective date: {{ transaction_effective_date }}
-
-
\ No newline at end of file
diff --git a/backend/lcfs/web/api/email/templates/notification_base.html b/backend/lcfs/web/api/email/templates/notification_base.html
new file mode 100644
index 000000000..560b93243
--- /dev/null
+++ b/backend/lcfs/web/api/email/templates/notification_base.html
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ Low Carbon Fuels Standard Reporting System
+ |
+
+
+
+
+ This email was generated by the Government of British Columbia, Low Carbon Fuel Standard
+ portal.
+ A {{ notification_type }} update has occurred within the Low Carbon Fuel Standard portal.
+ For more details, please go to: https://lowcarbonfuels.gov.bc.ca/{{
+ url_slug }}
+
+ You received this email because you subscribed at the site above, to stop receiving these
+ email, log in to your account here: https://lowcarbonfuels.gov.bc.ca/notifications
+
+
+ |
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/lcfs/web/api/fuel_code/export.py b/backend/lcfs/web/api/fuel_code/export.py
index 5be1727f7..e1f7c70ec 100644
--- a/backend/lcfs/web/api/fuel_code/export.py
+++ b/backend/lcfs/web/api/fuel_code/export.py
@@ -82,7 +82,7 @@ async def export(self, export_format) -> StreamingResponse:
fuel_code.approval_date,
fuel_code.effective_date,
fuel_code.expiration_date,
- fuel_code.fuel_code_type.fuel_type,
+ fuel_code.fuel_type.fuel_type,
fuel_code.feedstock,
fuel_code.feedstock_location,
fuel_code.feedstock_misc,
diff --git a/backend/lcfs/web/api/fuel_code/repo.py b/backend/lcfs/web/api/fuel_code/repo.py
index 8eb686fea..aa0577466 100644
--- a/backend/lcfs/web/api/fuel_code/repo.py
+++ b/backend/lcfs/web/api/fuel_code/repo.py
@@ -1,9 +1,10 @@
from datetime import date
-from typing import List, Dict, Any, Union, Optional, Sequence, TypedDict
+from typing import List, Dict, Any, Union, Optional, Sequence
import structlog
from fastapi import Depends
-from sqlalchemy import and_, or_, select, func, text, update, distinct
+from sqlalchemy import and_, or_, select, func, text, update, distinct, desc, asc
+from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, contains_eager
@@ -25,7 +26,11 @@
from lcfs.db.models.fuel.TargetCarbonIntensity import TargetCarbonIntensity
from lcfs.db.models.fuel.TransportMode import TransportMode
from lcfs.db.models.fuel.UnitOfMeasure import UnitOfMeasure
-from lcfs.web.api.base import PaginationRequestSchema
+from lcfs.web.api.base import (
+ PaginationRequestSchema,
+ get_field_for_filter,
+ apply_filter_conditions,
+)
from lcfs.web.api.fuel_code.schema import FuelCodeCloneSchema, FuelCodeSchema
from lcfs.web.core.decorators import repo_handler
from dataclasses import dataclass
@@ -192,7 +197,7 @@ async def get_transport_mode(self, transport_mode_id: int) -> TransportMode:
async def get_transport_mode_by_name(self, mode_name: str) -> TransportMode:
query = select(TransportMode).where(TransportMode.transport_mode == mode_name)
result = await self.db.execute(query)
- transport_mode = await result.scalar_one_or_none()
+ transport_mode = result.scalar_one()
return transport_mode
@@ -287,9 +292,51 @@ async def get_fuel_codes_paginated(
Returns:
List[FuelCodeSchema]: A list of fuel codes matching the query.
"""
- conditions = []
- # TODO: Filtering and Sorting logic needs to be added.
delete_status = await self.get_fuel_status_by_status("Deleted")
+ conditions = [FuelCode.fuel_status_id != delete_status.fuel_code_status_id]
+
+ for filter in pagination.filters:
+
+ filter_value = filter.filter
+ if filter.filter_type == "date":
+ if filter.type == "inRange":
+ filter_value = [filter.date_from, filter.date_to]
+ else:
+ filter_value = filter.date_from
+
+ filter_option = filter.type
+ filter_type = filter.filter_type
+ if filter.field == "status":
+ field = get_field_for_filter(FuelCodeStatus, filter.field)
+ elif filter.field == "prefix":
+ field = get_field_for_filter(FuelCodePrefix, filter.field)
+ elif filter.field == "fuel_type":
+ field = get_field_for_filter(FuelType, filter.field)
+ elif (
+ filter.field == "feedstock_fuel_transport_mode"
+ or filter.field == "finished_fuel_transport_mode"
+ ):
+ transport_mode = await self.get_transport_mode_by_name(filter_value)
+
+ if filter.field == "feedstock_fuel_transport_mode":
+ conditions.append(
+ FeedstockFuelTransportMode.transport_mode_id
+ == transport_mode.transport_mode_id
+ )
+
+ if filter.field == "finished_fuel_transport_mode":
+ conditions.append(
+ FinishedFuelTransportMode.transport_mode_id
+ == transport_mode.transport_mode_id
+ )
+ continue
+ else:
+ field = get_field_for_filter(FuelCode, filter.field)
+
+ conditions.append(
+ apply_filter_conditions(field, filter_value, filter_option, filter_type)
+ )
+
# setup pagination
offset = 0 if (pagination.page < 1) else (pagination.page - 1) * pagination.size
limit = pagination.size
@@ -299,8 +346,8 @@ async def get_fuel_codes_paginated(
.options(
joinedload(FuelCode.fuel_code_status),
joinedload(FuelCode.fuel_code_prefix),
- joinedload(FuelCode.fuel_code_type).joinedload(FuelType.provision_1),
- joinedload(FuelCode.fuel_code_type).joinedload(FuelType.provision_2),
+ joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_1),
+ joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_2),
joinedload(FuelCode.feedstock_fuel_transport_modes).joinedload(
FeedstockFuelTransportMode.feedstock_fuel_transport_mode
),
@@ -308,8 +355,20 @@ async def get_fuel_codes_paginated(
FinishedFuelTransportMode.finished_fuel_transport_mode
),
)
- .where(FuelCode.fuel_status_id != delete_status.fuel_code_status_id)
+ .where(and_(*conditions))
)
+
+ # Apply sorting
+ 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()
@@ -321,6 +380,12 @@ async def get_fuel_codes_paginated(
fuel_codes = result.unique().scalars().all()
return fuel_codes, total_count
+ @repo_handler
+ async def get_fuel_code_statuses(self):
+ query = select(FuelCodeStatus).order_by(asc(FuelCodeStatus.status))
+ status_results = await self.db.execute(query)
+ return status_results.scalars().all()
+
@repo_handler
async def create_fuel_code(self, fuel_code: FuelCode) -> FuelCode:
"""
@@ -336,13 +401,13 @@ async def create_fuel_code(self, fuel_code: FuelCode) -> FuelCode:
[
"fuel_code_status",
"fuel_code_prefix",
- "fuel_code_type",
+ "fuel_type",
"feedstock_fuel_transport_modes",
"finished_fuel_transport_modes",
],
)
# Manually load nested relationships
- await self.db.refresh(fuel_code.fuel_code_type, ["provision_1", "provision_2"])
+ await self.db.refresh(fuel_code.fuel_type, ["provision_1", "provision_2"])
return fuel_code
@repo_handler
@@ -356,8 +421,8 @@ async def get_fuel_code(self, fuel_code_id: int) -> FuelCode:
joinedload(FuelCode.finished_fuel_transport_modes).joinedload(
FinishedFuelTransportMode.finished_fuel_transport_mode
),
- joinedload(FuelCode.fuel_code_type).joinedload(FuelType.provision_1),
- joinedload(FuelCode.fuel_code_type).joinedload(FuelType.provision_2),
+ joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_1),
+ joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_2),
)
.where(FuelCode.fuel_code_id == fuel_code_id)
)
@@ -467,8 +532,8 @@ async def get_fuel_code_by_code_prefix(
.options(
joinedload(FuelCode.fuel_code_status),
joinedload(FuelCode.fuel_code_prefix),
- joinedload(FuelCode.fuel_code_type).joinedload(FuelType.provision_1),
- joinedload(FuelCode.fuel_code_type).joinedload(FuelType.provision_2),
+ joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_1),
+ joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_2),
joinedload(FuelCode.feedstock_fuel_transport_modes).joinedload(
FeedstockFuelTransportMode.feedstock_fuel_transport_mode
),
@@ -626,8 +691,8 @@ async def get_latest_fuel_codes(self) -> List[FuelCodeSchema]:
joinedload(FuelCode.finished_fuel_transport_modes).joinedload(
FinishedFuelTransportMode.finished_fuel_transport_mode
),
- joinedload(FuelCode.fuel_code_type).joinedload(FuelType.provision_1),
- joinedload(FuelCode.fuel_code_type).joinedload(FuelType.provision_2),
+ joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_1),
+ joinedload(FuelCode.fuel_type).joinedload(FuelType.provision_2),
)
.filter(FuelCodeStatus.status != FuelCodeStatusEnum.Deleted)
)
@@ -684,7 +749,10 @@ async def get_fuel_code_by_name(self, fuel_code: str) -> FuelCode:
result = await self.db.execute(
select(FuelCode)
.join(FuelCode.fuel_code_prefix)
- .join(FuelCodeStatus, FuelCode.fuel_status_id == FuelCodeStatus.fuel_code_status_id)
+ .join(
+ FuelCodeStatus,
+ FuelCode.fuel_status_id == FuelCodeStatus.fuel_code_status_id,
+ )
.outerjoin(FuelType, FuelCode.fuel_type_id == FuelType.fuel_type_id)
.options(
contains_eager(FuelCode.fuel_code_prefix),
@@ -693,7 +761,8 @@ async def get_fuel_code_by_name(self, fuel_code: str) -> FuelCode:
)
.where(
and_(
- func.concat(FuelCodePrefix.prefix, FuelCode.fuel_suffix) == fuel_code,
+ func.concat(FuelCodePrefix.prefix, FuelCode.fuel_suffix)
+ == fuel_code,
FuelCodeStatus.status != FuelCodeStatusEnum.Deleted,
)
)
diff --git a/backend/lcfs/web/api/fuel_code/schema.py b/backend/lcfs/web/api/fuel_code/schema.py
index 1a356d227..061ed20a1 100644
--- a/backend/lcfs/web/api/fuel_code/schema.py
+++ b/backend/lcfs/web/api/fuel_code/schema.py
@@ -12,6 +12,8 @@
)
from enum import Enum
+from lcfs.web.api.fuel_type.schema import FuelTypeQuantityUnitsEnumSchema
+
class FuelCodeStatusEnumSchema(str, Enum):
Draft = "Draft"
@@ -19,13 +21,6 @@ class FuelCodeStatusEnumSchema(str, Enum):
Deleted = "Deleted"
-class FuelTypeQuantityUnitsEnumSchema(str, Enum):
- Litres = "L"
- Kilograms = "kg"
- Kilowatt_hour = "kWh"
- Cubic_metres = "m³"
-
-
class ProvisionOfTheActSchema(BaseSchema):
provision_of_the_act_id: int
name: str
@@ -52,6 +47,15 @@ class FuelCodeStatusSchema(BaseSchema):
status: FuelCodeStatusEnumSchema
+class FuelCodeResponseSchema(BaseSchema):
+ fuel_code_id: Optional[int] = None
+ fuel_status_id: Optional[int] = None
+ fuel_status: Optional[FuelCodeStatusSchema] = None
+ prefix_id: Optional[int] = None
+ fuel_code: str
+ carbon_intensity: float
+
+
class TransportModeSchema(BaseSchema):
transport_mode_id: int
transport_mode: str
@@ -172,7 +176,7 @@ class FuelCodeSchema(BaseSchema):
notes: Optional[str] = None
fuel_code_status: Optional[FuelCodeStatusSchema] = None
fuel_code_prefix: Optional[FuelCodePrefixSchema] = None
- fuel_code_type: Optional[FuelTypeSchema] = None
+ fuel_type: Optional[FuelTypeSchema] = None
feedstock_fuel_transport_modes: Optional[List[FeedstockFuelTransportModeSchema]] = (
None
)
@@ -209,7 +213,7 @@ class FuelCodeCloneSchema(BaseSchema):
notes: Optional[str] = None
fuel_code_status: Optional[FuelCodeStatusSchema] = None
fuel_code_prefix: Optional[FuelCodePrefixSchema] = None
- fuel_code_type: Optional[FuelTypeSchema] = None
+ fuel_type: Optional[FuelTypeSchema] = None
feedstock_fuel_transport_modes: Optional[List[FeedstockFuelTransportModeSchema]] = (
None
)
diff --git a/backend/lcfs/web/api/fuel_code/services.py b/backend/lcfs/web/api/fuel_code/services.py
index 2a09c5e6d..039634e6a 100644
--- a/backend/lcfs/web/api/fuel_code/services.py
+++ b/backend/lcfs/web/api/fuel_code/services.py
@@ -1,5 +1,4 @@
import math
-from datetime import datetime
import structlog
from fastapi import Depends
@@ -7,7 +6,7 @@
from lcfs.db.models.fuel.FeedstockFuelTransportMode import FeedstockFuelTransportMode
from lcfs.db.models.fuel.FinishedFuelTransportMode import FinishedFuelTransportMode
from lcfs.db.models.fuel.FuelCode import FuelCode
-from lcfs.db.models.fuel.FuelCodeStatus import FuelCodeStatus, FuelCodeStatusEnum
+from lcfs.db.models.fuel.FuelCodeStatus import FuelCodeStatusEnum
from lcfs.db.models.fuel.FuelType import QuantityUnitsEnum
from lcfs.web.api.base import (
PaginationRequestSchema,
@@ -23,9 +22,9 @@
TransportModeSchema,
FuelCodePrefixSchema,
TableOptionsSchema,
+ FuelCodeStatusSchema,
)
from lcfs.web.core.decorators import service_handler
-from lcfs.web.exception.exceptions import DataNotFoundException
logger = structlog.get_logger(__name__)
@@ -110,16 +109,13 @@ async def get_table_options(self) -> TableOptionsSchema:
)
@service_handler
- async def get_fuel_codes(
+ async def search_fuel_codes(
self, pagination: PaginationRequestSchema
) -> FuelCodesSchema:
"""
Gets the list of fuel codes.
"""
fuel_codes, total_count = await self.repo.get_fuel_codes_paginated(pagination)
-
- if len(fuel_codes) == 0:
- raise DataNotFoundException("No fuel codes found")
return FuelCodesSchema(
pagination=PaginationResponseSchema(
total=total_count,
@@ -132,6 +128,32 @@ async def get_fuel_codes(
],
)
+ async def get_fuel_code_statuses(self):
+ """
+ Get all available statuses for fuel codes from the database.
+
+ Returns:
+ List[TransactionStatusView]: A list of TransactionStatusView objects containing the basic transaction status details.
+ """
+ fuel_code_statuses = await self.repo.get_fuel_code_statuses()
+ return [
+ FuelCodeStatusSchema.model_validate(fuel_code_status)
+ for fuel_code_status in fuel_code_statuses
+ ]
+
+ async def get_transport_modes(self):
+ """
+ Get all available transport modes for fuel codes from the database.
+
+ Returns:
+ List[TransportModeSchema]: A list of TransportModeSchema.
+ """
+ transport_modes = await self.repo.get_transport_modes()
+ return [
+ TransportModeSchema.model_validate(fuel_code_status)
+ for fuel_code_status in transport_modes
+ ]
+
async def convert_to_model(
self, fuel_code_schema: FuelCodeCreateUpdateSchema, status: FuelCodeStatusEnum
) -> FuelCode:
diff --git a/backend/lcfs/web/api/fuel_code/views.py b/backend/lcfs/web/api/fuel_code/views.py
index 4e49068c1..08d7c320f 100644
--- a/backend/lcfs/web/api/fuel_code/views.py
+++ b/backend/lcfs/web/api/fuel_code/views.py
@@ -14,6 +14,7 @@
Depends,
Query,
)
+from fastapi_cache.decorator import cache
from starlette.responses import StreamingResponse
from lcfs.db import dependencies
@@ -26,7 +27,8 @@
SearchFuelCodeList,
TableOptionsSchema,
FuelCodeSchema,
- DeleteFuelCodeResponseSchema,
+ FuelCodeStatusSchema,
+ TransportModeSchema,
)
from lcfs.web.api.fuel_code.services import FuelCodeServices
from lcfs.web.core.decorators import view_handler
@@ -115,7 +117,7 @@ async def get_fuel_codes(
service: FuelCodeServices = Depends(),
):
"""Endpoint to get list of fuel codes with pagination options"""
- return await service.get_fuel_codes(pagination)
+ return await service.search_fuel_codes(pagination)
@router.get("/export", response_class=StreamingResponse, status_code=status.HTTP_200_OK)
@@ -146,6 +148,34 @@ async def export_users(
return await exporter.export(format)
+@router.get(
+ "/statuses",
+ response_model=List[FuelCodeStatusSchema],
+ status_code=status.HTTP_200_OK,
+)
+@cache(expire=60 * 60 * 24) # cache for 24 hours
+@view_handler(["*"])
+async def get_fuel_code_statuses(
+ request: Request, service: FuelCodeServices = Depends()
+) -> List[FuelCodeStatusSchema]:
+ """Fetch all fuel code statuses"""
+ return await service.get_fuel_code_statuses()
+
+
+@router.get(
+ "/transport-modes",
+ response_model=List[TransportModeSchema],
+ status_code=status.HTTP_200_OK,
+)
+@cache(expire=60 * 60 * 24) # cache for 24 hours
+@view_handler(["*"])
+async def get_transport_modes(
+ request: Request, service: FuelCodeServices = Depends()
+) -> List[TransportModeSchema]:
+ """Fetch all fuel code transport modes"""
+ return await service.get_transport_modes()
+
+
@router.get("/{fuel_code_id}", status_code=status.HTTP_200_OK)
@view_handler(["*"])
async def get_fuel_code(
diff --git a/backend/lcfs/web/api/fuel_export/schema.py b/backend/lcfs/web/api/fuel_export/schema.py
index 80f66af7b..f3a5d7584 100644
--- a/backend/lcfs/web/api/fuel_export/schema.py
+++ b/backend/lcfs/web/api/fuel_export/schema.py
@@ -9,12 +9,8 @@
)
from pydantic import Field, field_validator, validator
-
-class FuelTypeQuantityUnitsEnumSchema(str, Enum):
- Litres = "L"
- Kilograms = "kg"
- Kilowatt_hour = "kWh"
- Cubic_metres = "m³"
+from lcfs.web.api.fuel_code.schema import FuelCodeResponseSchema
+from lcfs.web.api.fuel_type.schema import FuelTypeQuantityUnitsEnumSchema
class CommonPaginatedReportRequestSchema(BaseSchema):
@@ -112,20 +108,6 @@ class FuelCategoryResponseSchema(BaseSchema):
category: str
-class FuelCodeStatusSchema(BaseSchema):
- fuel_code_status_id: Optional[int] = None
- status: str
-
-
-class FuelCodeResponseSchema(BaseSchema):
- fuel_code_id: Optional[int] = None
- fuel_status_id: Optional[int] = None
- fuel_status: Optional[FuelCodeStatusSchema] = None
- prefix_id: Optional[int] = None
- fuel_code: str
- carbon_intensity: float
-
-
class FuelExportSchema(BaseSchema):
fuel_export_id: Optional[int] = None
compliance_report_id: int
diff --git a/backend/lcfs/web/api/fuel_supply/schema.py b/backend/lcfs/web/api/fuel_supply/schema.py
index c83288a54..60592dffe 100644
--- a/backend/lcfs/web/api/fuel_supply/schema.py
+++ b/backend/lcfs/web/api/fuel_supply/schema.py
@@ -1,20 +1,16 @@
from enum import Enum
from typing import List, Optional
+
+from pydantic import Field, field_validator
+
from lcfs.web.api.base import (
BaseSchema,
FilterModel,
PaginationResponseSchema,
SortOrder,
)
-from pydantic import Field, field_validator
-from lcfs.db.models.fuel.FuelType import QuantityUnitsEnum
-
-
-class FuelTypeQuantityUnitsEnumSchema(str, Enum):
- Litres = "L"
- Kilograms = "kg"
- Kilowatt_hour = "kWh"
- Cubic_metres = "m³"
+from lcfs.web.api.fuel_code.schema import FuelCodeResponseSchema
+from lcfs.web.api.fuel_type.schema import FuelTypeQuantityUnitsEnumSchema
class CommonPaginatedReportRequestSchema(BaseSchema):
@@ -113,20 +109,6 @@ class FuelCategoryResponseSchema(BaseSchema):
category: str
-class FuelCodeStatusSchema(BaseSchema):
- fuel_code_status_id: Optional[int] = None
- status: str
-
-
-class FuelCodeResponseSchema(BaseSchema):
- fuel_code_id: Optional[int] = None
- fuel_status_id: Optional[int] = None
- fuel_status: Optional[FuelCodeStatusSchema] = None
- prefix_id: Optional[int] = None
- fuel_code: Optional[str]
- carbon_intensity: float
-
-
class FuelSupplyCreateUpdateSchema(BaseSchema):
compliance_report_id: int
fuel_supply_id: Optional[int] = None
diff --git a/backend/lcfs/web/api/fuel_type/schema.py b/backend/lcfs/web/api/fuel_type/schema.py
new file mode 100644
index 000000000..1689804c1
--- /dev/null
+++ b/backend/lcfs/web/api/fuel_type/schema.py
@@ -0,0 +1,8 @@
+from enum import Enum
+
+
+class FuelTypeQuantityUnitsEnumSchema(str, Enum):
+ Litres = "L"
+ Kilograms = "kg"
+ Kilowatt_hour = "kWh"
+ Cubic_metres = "m³"
diff --git a/backend/lcfs/web/api/notification/repo.py b/backend/lcfs/web/api/notification/repo.py
index 12788666e..c12689ee4 100644
--- a/backend/lcfs/web/api/notification/repo.py
+++ b/backend/lcfs/web/api/notification/repo.py
@@ -1,20 +1,20 @@
from lcfs.db.models.notification import (
NotificationChannelSubscription,
NotificationMessage,
+ NotificationChannel,
+ NotificationType,
+ ChannelEnum,
)
-from lcfs.web.api.notification.schema import NotificationMessageSchema
import structlog
-from datetime import date
-from typing import List, Dict, Any, Optional, Union
+
+from typing import List, Optional
from fastapi import Depends
from lcfs.db.dependencies import get_async_db_session
from lcfs.web.exception.exceptions import DataNotFoundException
-from sqlalchemy import and_, delete, or_, select, func, text, update, distinct
+from sqlalchemy import delete, select, func
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import joinedload, contains_eager, selectinload
-from sqlalchemy.exc import NoResultFound
-from fastapi import HTTPException
+from sqlalchemy.orm import selectinload
from lcfs.web.core.decorators import repo_handler
@@ -162,12 +162,17 @@ async def create_notification_channel_subscription(
@repo_handler
async def get_notification_channel_subscriptions_by_user(
self, user_profile_id: int
- ) -> Optional[NotificationChannelSubscription]:
+ ) -> List[NotificationChannelSubscription]:
"""
- Retrieve channel subscriptions for a user
+ Retrieve channel subscriptions for a user, including channel name and notification type name.
"""
- query = select(NotificationChannelSubscription).where(
- NotificationChannelSubscription.user_profile_id == user_profile_id
+ query = (
+ select(NotificationChannelSubscription)
+ .options(
+ selectinload(NotificationChannelSubscription.notification_channel),
+ selectinload(NotificationChannelSubscription.notification_type),
+ )
+ .where(NotificationChannelSubscription.user_profile_id == user_profile_id)
)
result = await self.db.execute(query)
subscriptions = result.scalars().all()
@@ -202,18 +207,6 @@ async def get_notification_channel_subscription_by_id(
return subscription
- @repo_handler
- async def update_notification_channel_subscription(
- self, notification_channel_subscription: NotificationChannelSubscription
- ) -> NotificationChannelSubscription:
- """
- Update a notification chanel subscription
- """
- merged_subscription = await self.db.merge(notification_channel_subscription)
- await self.db.flush()
-
- return merged_subscription
-
@repo_handler
async def delete_notification_channel_subscription(
self, notification_channel_subscription_id: int
@@ -227,3 +220,28 @@ async def delete_notification_channel_subscription(
)
await self.db.execute(query)
await self.db.flush()
+
+ @repo_handler
+ async def get_notification_type_by_name(self, name: str) -> Optional[int]:
+ """
+ Retrieve a NotificationType by its name
+ """
+ query = select(NotificationType.notification_type_id).where(
+ NotificationType.name == name
+ )
+ result = await self.db.execute(query)
+ x = result.scalars().first()
+ return x
+
+ @repo_handler
+ async def get_notification_channel_by_name(
+ self, name: ChannelEnum
+ ) -> Optional[int]:
+ """
+ Retrieve a NotificationChannel by its name
+ """
+ query = select(NotificationChannel.notification_channel_id).where(
+ NotificationChannel.channel_name == name.value
+ )
+ result = await self.db.execute(query)
+ return result.scalars().first()
diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py
index e6a17504c..419f212c5 100644
--- a/backend/lcfs/web/api/notification/schema.py
+++ b/backend/lcfs/web/api/notification/schema.py
@@ -16,9 +16,11 @@ class NotificationMessageSchema(BaseSchema):
notification_type_id: Optional[int] = None
deleted: Optional[bool] = None
+
class NotificationCountSchema(BaseSchema):
count: int
+
class DeleteNotificationMessageSchema(BaseSchema):
notification_message_id: int
deleted: bool
@@ -31,9 +33,9 @@ class DeleteNotificationMessageResponseSchema(BaseSchema):
class SubscriptionSchema(BaseSchema):
notification_channel_subscription_id: Optional[int] = None
is_enabled: Optional[bool] = True
- notification_channel_id: Optional[int] = None
- user_profile_id: Optional[int]= None
- notification_type_id: Optional[int] = None
+ notification_channel_name: Optional[str] = None
+ user_profile_id: Optional[int] = None
+ notification_type_name: Optional[str] = None
deleted: Optional[bool] = None
diff --git a/backend/lcfs/web/api/notification/services.py b/backend/lcfs/web/api/notification/services.py
index 5340df504..f9bc27951 100644
--- a/backend/lcfs/web/api/notification/services.py
+++ b/backend/lcfs/web/api/notification/services.py
@@ -2,6 +2,7 @@
from lcfs.db.models.notification import (
NotificationChannelSubscription,
NotificationMessage,
+ ChannelEnum,
)
from lcfs.web.api.notification.schema import (
SubscriptionSchema,
@@ -9,7 +10,6 @@
)
from lcfs.web.exception.exceptions import DataNotFoundException
import structlog
-import math
from fastapi import Depends
from lcfs.web.api.notification.repo import NotificationRepository
from lcfs.web.core.decorators import service_handler
@@ -106,21 +106,42 @@ async def delete_notification_message(self, notification_id: int):
else:
raise DataNotFoundException(f"Notification with ID {notification_id}.")
+ @service_handler
+ async def get_notification_type_id_by_name(self, name: str) -> int:
+ notification_type = await self.repo.get_notification_type_by_name(name)
+ if not notification_type:
+ raise ValueError(f"Invalid notification type name: {name}")
+ return notification_type
+
+ @service_handler
+ async def get_notification_channel_id_by_name(self, name: ChannelEnum) -> int:
+ notification_channel = await self.repo.get_notification_channel_by_name(name)
+ if not notification_channel:
+ raise ValueError(f"Invalid notification channel name: {name}")
+ return notification_channel
+
@service_handler
async def create_notification_channel_subscription(
- self, subscription_data: SubscriptionSchema
+ self, subscription_data: SubscriptionSchema, user_profile_id: int
):
- """
- Create a new notification channel subscription.
- """
+ channel_enum_name = ChannelEnum(subscription_data.notification_channel_name)
+ notification_channel_id = await self.get_notification_channel_id_by_name(
+ channel_enum_name
+ )
+ notification_type_id = await self.get_notification_type_id_by_name(
+ subscription_data.notification_type_name
+ )
+
subscription = NotificationChannelSubscription(
- **subscription_data.model_dump(exclude={"deleted"})
+ user_profile_id=user_profile_id,
+ notification_channel_id=notification_channel_id,
+ notification_type_id=notification_type_id,
+ is_enabled=subscription_data.is_enabled,
)
created_subscription = await self.repo.create_notification_channel_subscription(
subscription
)
-
- # Convert the SQLAlchemy model instance to the Pydantic model
+ x = 1
return SubscriptionSchema.model_validate(created_subscription)
@service_handler
@@ -128,7 +149,20 @@ async def get_notification_channel_subscriptions_by_user_id(self, user_id: int):
"""
Retrieve all notification channel subscriptions for a user.
"""
- return await self.repo.get_notification_channel_subscriptions_by_user(user_id)
+ subscriptions = await self.repo.get_notification_channel_subscriptions_by_user(
+ user_id
+ )
+
+ subscriptions_with_names = [
+ {
+ "notification_channel_subscription_id": subscription.notification_channel_subscription_id,
+ "notification_channel_name": subscription.notification_channel.channel_name.name,
+ "notification_type_name": subscription.notification_type.name,
+ }
+ for subscription in subscriptions
+ ]
+
+ return subscriptions_with_names
@service_handler
async def get_notification_channel_subscription_by_id(
@@ -141,36 +175,17 @@ async def get_notification_channel_subscription_by_id(
notification_channel_subscription_id
)
- @service_handler
- async def update_notification_channel_subscription(
- self, subscription_data: SubscriptionSchema
- ):
- """
- Update an existing notification channel subscription.
- """
- subscription = NotificationChannelSubscription(
- **subscription_data.model_dump(exclude={"deleted"})
- )
- return await self.repo.update_notification_channel_subscription(subscription)
-
@service_handler
async def delete_notification_channel_subscription(
- self, subscription_id: int
+ self, subscription_id: int, user_profile_id: int
):
- """
- Delete a notification channel subscription.
- """
- # Ensure the subscription exists
subscription = await self.repo.get_notification_channel_subscription_by_id(
subscription_id
)
- if not subscription:
+ if not subscription or subscription.user_profile_id != user_profile_id:
raise DataNotFoundException(
- f"Subscription with ID {subscription_id} not found."
+ "Subscription not found or you are not authorized to delete it."
)
- # Proceed to delete if it exists and belongs to the user
await self.repo.delete_notification_channel_subscription(subscription_id)
- logger.info(
- f"Deleted notification channel subscription {subscription_id}."
- )
+ logger.info(f"Deleted notification channel subscription {subscription_id}.")
diff --git a/backend/lcfs/web/api/notification/views.py b/backend/lcfs/web/api/notification/views.py
index 6f4828d32..f4f98d0e9 100644
--- a/backend/lcfs/web/api/notification/views.py
+++ b/backend/lcfs/web/api/notification/views.py
@@ -5,8 +5,7 @@
from typing import Union, List
from lcfs.web.exception.exceptions import DataNotFoundException
import structlog
-from fastapi import APIRouter, Body, Depends, HTTPException, Request, Query
-from lcfs.db import dependencies
+from fastapi import APIRouter, Body, Depends, HTTPException, Request
from lcfs.db.models.user.Role import RoleEnum
from lcfs.web.api.notification.schema import (
DeleteNotificationChannelSubscriptionResponseSchema,
@@ -49,7 +48,7 @@ async def get_notification_messages_by_user_id(
response_model=NotificationCountSchema,
status_code=status.HTTP_200_OK,
)
-@view_handler([RoleEnum.GOVERNMENT])
+@view_handler(["*"])
async def get_unread_notifications(
request: Request, service: NotificationService = Depends()
):
@@ -68,11 +67,10 @@ async def get_unread_notifications(
response_model=List[SubscriptionSchema],
status_code=status.HTTP_200_OK,
)
-@view_handler([RoleEnum.GOVERNMENT])
+@view_handler(["*"])
async def get_notifications_channel_subscriptions_by_user_id(
request: Request, service: NotificationService = Depends()
):
-
return await service.get_notification_channel_subscriptions_by_user_id(
user_id=request.user.user_profile_id
)
@@ -118,25 +116,29 @@ async def save_notification(
],
status_code=status.HTTP_200_OK,
)
-@view_handler([RoleEnum.GOVERNMENT])
+@view_handler(["*"])
async def save_subscription(
request: Request,
request_data: Union[SubscriptionSchema, DeleteSubscriptionSchema] = Body(...),
service: NotificationService = Depends(),
):
+ user_profile_id = request.user.user_profile_id
subscription_id = request_data.notification_channel_subscription_id
+
if request_data.deleted:
try:
- await service.delete_notification_channel_subscription(subscription_id)
+ await service.delete_notification_channel_subscription(
+ subscription_id, user_profile_id
+ )
return DeleteNotificationChannelSubscriptionResponseSchema(
message="Notification Subscription deleted successfully"
)
except DataNotFoundException as e:
raise HTTPException(status_code=404, detail=str(e))
- elif subscription_id:
- return await service.update_notification_channel_subscription(request_data)
else:
- return await service.create_notification_channel_subscription(request_data)
+ return await service.create_notification_channel_subscription(
+ request_data, user_profile_id
+ )
@router.get(
diff --git a/backend/lcfs/web/api/other_uses/schema.py b/backend/lcfs/web/api/other_uses/schema.py
index 7b21dfa1b..51327f772 100644
--- a/backend/lcfs/web/api/other_uses/schema.py
+++ b/backend/lcfs/web/api/other_uses/schema.py
@@ -9,19 +9,14 @@
)
from enum import Enum
+from lcfs.web.api.fuel_type.schema import FuelTypeQuantityUnitsEnumSchema
+
class FuelCodeStatusEnumSchema(str, Enum):
Draft = "Draft"
Approved = "Approved"
Deleted = "Deleted"
-
-class FuelTypeQuantityUnitsEnumSchema(str, Enum):
- Litres = "L"
- Kilograms = "kg"
- Kilowatt_hour = "kWh"
- Cubic_metres = "m³"
-
class FuelCodeSchema(BaseSchema):
fuel_code_id: int
fuel_code: str
diff --git a/backend/lcfs/web/api/user/repo.py b/backend/lcfs/web/api/user/repo.py
index ab3b76485..c1906c34c 100644
--- a/backend/lcfs/web/api/user/repo.py
+++ b/backend/lcfs/web/api/user/repo.py
@@ -14,9 +14,11 @@
func,
cast,
String,
+ update,
)
from sqlalchemy.orm import joinedload
from sqlalchemy.ext.asyncio import AsyncSession
+from lcfs.web.exception.exceptions import DataNotFoundException
from lcfs.services.keycloak.dependencies import parse_external_username
from lcfs.web.core.decorators import repo_handler
@@ -665,3 +667,23 @@ async def create_login_history(self, user: UserProfile):
)
self.db.add(login_history)
+
+ @repo_handler
+ async def update_notifications_email(
+ self, user_profile_id: int, email: str
+ ) -> UserProfile:
+ # Fetch the user profile
+ query = select(UserProfile).where(
+ UserProfile.user_profile_id == user_profile_id
+ )
+ result = await self.db.execute(query)
+ user_profile = result.scalar_one_or_none()
+
+ # Update the notifications_email field
+ user_profile.notifications_email = email
+
+ # Flush and refresh without committing
+ await self.db.flush()
+ await self.db.refresh(user_profile)
+
+ return user_profile
diff --git a/backend/lcfs/web/api/user/schema.py b/backend/lcfs/web/api/user/schema.py
index 8ad458383..6cf34fe46 100644
--- a/backend/lcfs/web/api/user/schema.py
+++ b/backend/lcfs/web/api/user/schema.py
@@ -40,6 +40,7 @@ class UserBaseSchema(BaseSchema):
keycloak_username: str
keycloak_email: EmailStr
email: Optional[EmailStr] = None
+ notifications_email: Optional[EmailStr] = None
title: Optional[str] = None
phone: Optional[str] = None
first_name: Optional[str] = None
@@ -80,6 +81,7 @@ class UserActivitiesResponseSchema(BaseSchema):
activities: List[UserActivitySchema]
pagination: PaginationResponseSchema
+
class UserLoginHistorySchema(BaseSchema):
user_login_history_id: int
keycloak_email: str
@@ -93,3 +95,7 @@ class UserLoginHistorySchema(BaseSchema):
class UserLoginHistoryResponseSchema(BaseSchema):
histories: List[UserLoginHistorySchema]
pagination: PaginationResponseSchema
+
+
+class UpdateNotificationsEmailSchema(BaseSchema):
+ notifications_email: EmailStr
diff --git a/backend/lcfs/web/api/user/services.py b/backend/lcfs/web/api/user/services.py
index aad7ed006..48bad17de 100644
--- a/backend/lcfs/web/api/user/services.py
+++ b/backend/lcfs/web/api/user/services.py
@@ -332,3 +332,17 @@ async def _has_access_to_user_activities(
async def track_user_login(self, user: UserProfile):
await self.repo.create_login_history(user)
+
+ @service_handler
+ async def update_notifications_email(self, user_id: int, email: str):
+ try:
+ # Update the notifications_email field of the user
+ return await self.repo.update_notifications_email(user_id, email)
+ # Return the updated user
+ return UserBaseSchema.model_validate(user)
+ except DataNotFoundException as e:
+ logger.error(f"User not found: {e}")
+ raise HTTPException(status_code=404, detail=str(e))
+ except Exception as e:
+ logger.error(f"Error updating notifications email: {e}")
+ raise HTTPException(status_code=500, detail="Internal Server Error")
diff --git a/backend/lcfs/web/api/user/views.py b/backend/lcfs/web/api/user/views.py
index 54ae147e0..ff1a69aca 100644
--- a/backend/lcfs/web/api/user/views.py
+++ b/backend/lcfs/web/api/user/views.py
@@ -9,6 +9,7 @@
Response,
Depends,
Query,
+ HTTPException,
)
from fastapi.responses import StreamingResponse
@@ -22,6 +23,7 @@
UserLoginHistoryResponseSchema,
UsersSchema,
UserActivitiesResponseSchema,
+ UpdateNotificationsEmailSchema,
)
from lcfs.web.api.user.services import UserServices
from lcfs.web.core.decorators import view_handler
@@ -250,3 +252,21 @@ async def get_all_user_login_history(
"""
current_user = request.user
return await service.get_all_user_login_history(current_user, pagination)
+
+
+@router.post(
+ "/update-notifications-email",
+ response_model=UpdateNotificationsEmailSchema,
+ status_code=status.HTTP_200_OK,
+)
+@view_handler(["*"])
+async def update_notifications_email(
+ request: Request,
+ email_data: UpdateNotificationsEmailSchema = Body(...),
+ service: UserServices = Depends(),
+):
+ user_id = request.user.user_profile_id
+ email = email_data.notifications_email
+
+ user = await service.update_notifications_email(user_id, email)
+ return user
diff --git a/backend/poetry.lock b/backend/poetry.lock
index dba1d2518..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]
@@ -2113,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"
@@ -2504,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]
@@ -2779,13 +2779,13 @@ cli = ["click (>=5.0)"]
[[package]]
name = "python-multipart"
-version = "0.0.16"
+version = "0.0.18"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "python_multipart-0.0.16-py3-none-any.whl", hash = "sha256:c2759b7b976ef3937214dfb592446b59dfaa5f04682a076f78b117c94776d87a"},
- {file = "python_multipart-0.0.16.tar.gz", hash = "sha256:8dee37b88dab9b59922ca173c35acb627cc12ec74019f5cd4578369c6df36554"},
+ {file = "python_multipart-0.0.18-py3-none-any.whl", hash = "sha256:efe91480f485f6a361427a541db4796f9e1591afc0fb8e7a4ba06bfbc6708996"},
+ {file = "python_multipart-0.0.18.tar.gz", hash = "sha256:7a68db60c8bfb82e460637fa4750727b45af1d5e2ed215593f917f64694d34fe"},
]
[[package]]
@@ -3109,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]
@@ -3784,4 +3784,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
-content-hash = "6ac5bbf72db0f71b3519fd274af81ebfa064cad93bdf34c801568939d6e4214f"
+content-hash = "1898dc5b68facf49cb53d1fe98daecb39c1dc39b184d17c31d8d23a48cfce2c0"
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 120fe80d5..e40217ccb 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -42,7 +42,7 @@ black = "^24.4.2"
boto3 = "^1.35.26"
typing-extensions = "^4.12.2"
structlog = "^24.4.0"
-python-multipart = "^0.0.16"
+python-multipart = "^0.0.18"
aio-pika = "^9.4.3"
jinja2 = "^3.1.4"
requests = "^2.32.3"
diff --git a/etl/data-migration.sh b/etl/data-migration.sh
index c1823d6fc..b3326dd85 100755
--- a/etl/data-migration.sh
+++ b/etl/data-migration.sh
@@ -1,8 +1,10 @@
-#!/bin/bash
+#!/usr/local/bin/bash
+# Update bash dir to your local version
# Optimized NiFi Processor Management Script with Advanced Logging
# This script manages NiFi processors, updates database connections, and establishes port-forwarding.
# Usage:
# ./nifi_processor_manager.sh [dev|test|prod] [--debug|--verbose]
+# ./data-migration.sh test --debug
#
# Arguments:
# [dev|test|prod] - The environment for which the script will run.
@@ -39,6 +41,7 @@ DEBUG_LEVEL=${DEBUG_LEVEL:-$DEBUG_INFO}
readonly ORGANIZATION_PROCESSOR="328e2539-0192-1000-0000-00007a4304c1"
readonly USER_PROCESSOR="e6c63130-3eac-1b13-a947-ee0103275138"
readonly TRANSFER_PROCESSOR="b9d73248-1438-1418-a736-cc94c8c21e70"
+readonly TRANSACTIONS_PROCESSOR="7a010ef5-0193-1000-ffff-ffff8c22e67e"
# Global Port Configuration (Update these based on your setup)
declare -A LOCAL_PORTS=(
@@ -116,8 +119,10 @@ curl_with_retry() {
response=$(curl -sS -w "%{http_code}" -X "$method" -H "Content-Type: application/json" -d "$data" "$url")
fi
- http_code=$(echo "$response" | tail -c 4)
- response_body=$(echo "$response" | head -c -4)
+ # Extract the HTTP status code (last 3 characters)
+ http_code="${response: -3}"
+ # Extract the response body (everything except the last 3 characters)
+ response_body="${response:0:${#response}-3}"
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
verbose "Curl successful. HTTP Code: $http_code"
@@ -179,8 +184,9 @@ execute_processor() {
-d "$run_once_payload" \
"$NIFI_API_URL/processors/$processor_id/run-status")
- local http_code=$(echo "$response" | tail -c 4)
- local response_body=$(echo "$response" | head -c -4)
+ # Extract HTTP code and response body using parameter expansion
+ local http_code="${response: -3}"
+ local response_body="${response:0:${#response}-3}"
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
info "Processor $processor_id triggered for single run successfully"
@@ -257,7 +263,7 @@ forward_database_ports() {
local port_mappings=(
"tfrs:0ab226-$env:tfrs-spilo:tfrs"
- # "lcfs:d2bd59-$env:lcfs-crunchy-$env-lcfs:lcfs" # Uncomment if you want to load directly to LCFS openshift environment
+ "lcfs:d2bd59-$env:lcfs-crunchy-$env-lcfs:lcfs" # Uncomment if you want to load directly to LCFS openshift environment
)
for mapping in "${port_mappings[@]}"; do
@@ -312,10 +318,10 @@ update_nifi_connection() {
database_name=$(echo "$db_env_vars" | grep 'DATABASE_NAME' | cut -d'=' -f2)
;;
lcfs)
- db_env_vars=$(oc exec -n "$namespace" "$pod_name" -- bash -c "env | grep 'LCFS_DB_")
+ db_env_vars=$(oc exec -n "$namespace" "$pod_name" -- env | grep 'LCFS_DB_')
database_user=$(echo "$db_env_vars" | grep 'LCFS_DB_USER' | cut -d'=' -f2)
database_pass=$(echo "$db_env_vars" | grep 'LCFS_DB_PASS' | cut -d'=' -f2)
- database_name=$(echo "$db_env_vars" | grep 'LCFS_DB_NAME' | cut -d'=' -f2)
+ database_name=$(echo "$db_env_vars" | grep 'LCFS_DB_BASE' | cut -d'=' -f2)
;;
*)
error_exit "Invalid application: $app"
@@ -412,25 +418,29 @@ main() {
oc whoami
# Controller service configuration
- local LCFS_CONTROLLER_SERVICE_ID="3244bf63-0192-1000-ffff-ffffc8ec6d93"
+ local LCFS_CONTROLLER_SERVICE_ID="32417e8c-0192-1000-ffff-ffff8ccb5dfa"
local TFRS_CONTROLLER_SERVICE_ID="3245b078-0192-1000-ffff-ffffba20c1eb"
# Update NiFi connections
- # update_nifi_connection "$LCFS_CONTROLLER_SERVICE_ID" "d2bd59-$env" "lcfs-backend-$env" "lcfs" # Uncomment if we're loading the data to openshift database.
+ update_nifi_connection "$LCFS_CONTROLLER_SERVICE_ID" "d2bd59-$env" "lcfs-backend-$env" "lcfs" # Uncomment if we're loading the data to openshift database.
update_nifi_connection "$TFRS_CONTROLLER_SERVICE_ID" "0ab226-$env" "tfrs-backend-$env" "tfrs"
+
+ # Uncomment if you want to use port forwarding from the script
## Duplicating as the connections gets disconnected when the port forwarding is enabled for first time.
- forward_database_ports "$env"
- sleep 5 # Allow time for port forwarding
- info "Executing processors in sequence..."
- execute_processor "$ORGANIZATION_PROCESSOR" "$env"
- ##
- # Actual processing starts here
- forward_database_ports "$env"
- sleep 5 # Allow time for port forwarding
+ # forward_database_ports "$env"
+ # sleep 5 # Allow time for port forwarding
+ # info "Executing processors in sequence..."
+ # execute_processor "$ORGANIZATION_PROCESSOR" "$env"
+ # ##
+ # # Actual processing starts here
+ # forward_database_ports "$env"
+ # sleep 5 # Allow time for port forwarding
+
# Expand these processors as needed
execute_processor "$ORGANIZATION_PROCESSOR" "$env"
execute_processor "$USER_PROCESSOR" "$env"
execute_processor "$TRANSFER_PROCESSOR" "$env"
+ execute_processor "$TRANSACTIONS_PROCESSOR" "$env"
info "All processors executed successfully."
info "Validate all the loaded data manually in the database."
diff --git a/etl/database/nifi-registry-primary.mv.db b/etl/database/nifi-registry-primary.mv.db
index 098352e9f..56acc8498 100644
Binary files a/etl/database/nifi-registry-primary.mv.db and b/etl/database/nifi-registry-primary.mv.db differ
diff --git a/etl/docker-compose.yml b/etl/docker-compose.yml
index 8f0a3eec6..982c29ef0 100755
--- a/etl/docker-compose.yml
+++ b/etl/docker-compose.yml
@@ -62,23 +62,23 @@ services:
networks:
- shared_network
# TFRS database loaded with TFRS data
- tfrs:
- image: postgres:14.2
- container_name: tfrs
- environment:
- POSTGRES_USER: tfrs
- POSTGRES_PASSWORD: development_only
- POSTGRES_DB: tfrs
- ports:
- - "5435:5432"
- volumes:
- - tfrs_data:/var/lib/postgresql/data
- restart: unless-stopped
- networks:
- - shared_network
+ # tfrs:
+ # image: postgres:14.2
+ # container_name: tfrs
+ # environment:
+ # POSTGRES_USER: tfrs
+ # POSTGRES_PASSWORD: development_only
+ # POSTGRES_DB: tfrs
+ # ports:
+ # - "5435:5432"
+ # volumes:
+ # - tfrs_data:/var/lib/postgresql/data
+ # restart: unless-stopped
+ # networks:
+ # - shared_network
volumes:
- tfrs_data:
+ # tfrs_data:
nifi_output:
nifi_scripts:
diff --git a/etl/nifi/conf/flow.json.gz b/etl/nifi/conf/flow.json.gz
index 61422be2b..3d193752f 100644
Binary files a/etl/nifi/conf/flow.json.gz and b/etl/nifi/conf/flow.json.gz differ
diff --git a/etl/nifi/conf/flow.xml.gz b/etl/nifi/conf/flow.xml.gz
index 3c0735fe7..be40cbe0a 100644
Binary files a/etl/nifi/conf/flow.xml.gz and b/etl/nifi/conf/flow.xml.gz differ
diff --git a/etl/nifi/conf/nifi.properties b/etl/nifi/conf/nifi.properties
old mode 100755
new mode 100644
diff --git a/etl/nifi_scripts/organization.groovy b/etl/nifi_scripts/organization.groovy
index 2bd67a715..dfbd435f0 100644
--- a/etl/nifi_scripts/organization.groovy
+++ b/etl/nifi_scripts/organization.groovy
@@ -123,7 +123,7 @@ try {
// If no duplicate exists, proceed with the insert logic
def name = resultSet.getString("name")
- def operatingName = resultSet.getString("operating_name")
+ def operatingName = resultSet.getString("operating_name") ?: "" // not nullable string field
def email = resultSet.getString("email")
def phone = resultSet.getString("phone")
def edrmsRecord = resultSet.getString("edrms_record")
diff --git a/etl/nifi_scripts/transactions.groovy b/etl/nifi_scripts/transactions.groovy
new file mode 100644
index 000000000..9cb4c40f4
--- /dev/null
+++ b/etl/nifi_scripts/transactions.groovy
@@ -0,0 +1,99 @@
+import groovy.json.JsonSlurper
+import java.sql.Connection
+import java.sql.PreparedStatement
+import java.sql.ResultSet
+
+def SOURCE_QUERY = """
+ SELECT
+ ct.id AS transaction_id,
+ ct.initiator_id AS initiator_id,
+ ct.respondent_id AS respondent_id,
+ ct.date_of_written_agreement AS agreement_date,
+ ct.trade_effective_date AS transaction_effective_date,
+ ct.number_of_credits AS quantity,
+ ct.create_user_id AS create_user,
+ ct.create_timestamp AS create_date,
+ ct.update_user_id AS update_user,
+ ct.update_timestamp AS update_date,
+ ctt.the_type AS transaction_type,
+ ct.fair_market_value_per_credit AS price_per_unit
+ FROM
+ credit_trade ct
+ JOIN credit_trade_type ctt ON ct.type_id = ctt.id
+ JOIN credit_trade_status cts ON ct.status_id = cts.id
+ WHERE
+ ctt.the_type IN ('Credit Validation', 'Part 3 Award', 'Credit Reduction', 'Administrative Adjustment')
+ AND cts.status = 'Approved';
+"""
+
+// Fetch connections to both the source and destination databases
+def sourceDbcpService = context.controllerServiceLookup.getControllerService('3245b078-0192-1000-ffff-ffffba20c1eb')
+def destinationDbcpService = context.controllerServiceLookup.getControllerService('3244bf63-0192-1000-ffff-ffffc8ec6d93')
+
+Connection sourceConn = null
+Connection destinationConn = null
+
+try {
+ sourceConn = sourceDbcpService.getConnection()
+ destinationConn = destinationDbcpService.getConnection()
+ destinationConn.setAutoCommit(false)
+
+ def transactionStmt = destinationConn.prepareStatement('''
+ INSERT INTO transaction (
+ compliance_units, organization_id, transaction_action, effective_status
+ ) VALUES (?, ?, ?::transaction_action_enum, TRUE)
+ RETURNING transaction_id
+ ''')
+
+ PreparedStatement sourceStmt = sourceConn.prepareStatement(SOURCE_QUERY)
+ ResultSet resultSet = sourceStmt.executeQuery()
+
+ int recordCount = 0
+
+ while (resultSet.next()) {
+ recordCount++
+ def transactionType = resultSet.getString('transaction_type')
+ def organizationId = resultSet.getInt('respondent_id')
+ def quantity = resultSet.getInt('quantity')
+ def action = 'Adjustment'
+
+ // Adjust quantity for 'Credit Reduction' transactions
+ if (transactionType == 'Credit Reduction') {
+ quantity = -quantity
+ }
+
+ if (organizationId > 0 && quantity != null) {
+ insertTransaction(transactionStmt, organizationId, quantity, action)
+ } else {
+ log.warn("Skipping transaction_id ${resultSet.getInt('transaction_id')}: Missing required data.")
+ }
+ }
+
+ resultSet.close()
+ destinationConn.commit()
+ log.debug("Processed ${recordCount} records successfully.")
+} catch (Exception e) {
+ log.error('Error occurred while processing data', e)
+ destinationConn?.rollback()
+ throw e // Rethrow the exception to allow NiFi to handle retries or failure routing
+} finally {
+ // Close resources in reverse order of their creation
+ if (transactionStmt != null) transactionStmt.close()
+ if (resultSet != null) resultSet.close()
+ if (sourceStmt != null) sourceStmt.close()
+ if (sourceConn != null) sourceConn.close()
+ if (destinationConn != null) destinationConn.close()
+}
+
+def insertTransaction(PreparedStatement stmt, int orgId, int quantity, String action) {
+ stmt.setInt(1, quantity)
+ stmt.setInt(2, orgId)
+ stmt.setString(3, action)
+
+ def result = stmt.executeQuery()
+ if (result.next()) {
+ def transactionId = result.getInt('transaction_id')
+ log.debug("Inserted transaction_id ${transactionId} for organization_id ${orgId}")
+ }
+ result.close()
+}
diff --git a/etl/nifi_scripts/transfer.groovy b/etl/nifi_scripts/transfer.groovy
index 6840e4787..69211d890 100644
--- a/etl/nifi_scripts/transfer.groovy
+++ b/etl/nifi_scripts/transfer.groovy
@@ -198,7 +198,7 @@ try {
statements.transactionStmt)
def transferId = insertTransfer(resultSet, statements.transferStmt,
- fromTransactionId, toTransactionId, preparedData)
+ fromTransactionId, toTransactionId, preparedData, destinationConn)
if (transferId) {
processHistory(transferId, creditTradeHistoryJson, statements.historyStmt, preparedData)
@@ -450,7 +450,23 @@ def getAudienceScope(String roleNames) {
}
def insertTransfer(ResultSet rs, PreparedStatement transferStmt, Long fromTransactionId,
- Long toTransactionId, Map preparedData) {
+ Long toTransactionId, Map preparedData, Connection conn) {
+ // Check for duplicates in the `transfer` table
+ def transferId = rs.getInt('transfer_id')
+ def duplicateCheckStmt = conn.prepareStatement('SELECT COUNT(*) FROM transfer WHERE transfer_id = ?')
+ duplicateCheckStmt.setInt(1, transferId)
+ def duplicateResult = duplicateCheckStmt.executeQuery()
+ duplicateResult.next()
+ def count = duplicateResult.getInt(1)
+ duplicateResult.close()
+ duplicateCheckStmt.close()
+
+ if (count > 0) {
+ log.warn("Duplicate transfer detected with transfer_id: ${transferId}, skipping insertion.")
+ return null
+ }
+
+ // Proceed with insertion if no duplicate exists
def categoryId = getTransferCategoryId(rs.getString('transfer_category'), preparedData)
def statusId = getStatusId(rs.getString('current_status'), preparedData)
transferStmt.setInt(1, rs.getInt('from_organization_id'))
@@ -474,4 +490,5 @@ def insertTransfer(ResultSet rs, PreparedStatement transferStmt, Long fromTransa
transferStmt.setInt(19, rs.getInt('transfer_id'))
def result = transferStmt.executeQuery()
return result.next() ? result.getInt('transfer_id') : null
- }
+}
+
diff --git a/frontend/cypress/e2e/Pages/ComplianceReport/ComplianceReport.test.js b/frontend/cypress/e2e/Pages/ComplianceReport/ComplianceReport.test.js
new file mode 100644
index 000000000..7190e79a9
--- /dev/null
+++ b/frontend/cypress/e2e/Pages/ComplianceReport/ComplianceReport.test.js
@@ -0,0 +1,59 @@
+import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor'
+
+const currentYear = new Date().getFullYear().toString()
+
+Given('the supplier is on the login page', () => {
+ cy.clearAllCookies()
+ cy.clearAllLocalStorage()
+ cy.clearAllSessionStorage()
+ cy.visit('/')
+ cy.getByDataTest('login-container').should('exist')
+})
+
+When('the supplier logs in with valid credentials', () => {
+ cy.loginWith(
+ 'becid',
+ Cypress.env('BCEID_TEST_USER'),
+ Cypress.env('BCEID_TEST_PASS')
+ )
+ cy.visit('/')
+ cy.getByDataTest('dashboard-container').should('exist')
+})
+
+When('the supplier navigates to the compliance reports page', () => {
+ cy.get('a[href="/compliance-reporting"]').click()
+})
+
+When('the supplier creates a new compliance report', () => {
+ cy.get('.MuiStack-root > :nth-child(1) > .MuiButtonBase-root').click()
+ // Select and click the current year button
+ cy.contains('.MuiList-root li', currentYear).click()
+})
+
+Then('the compliance report introduction is shown', () => {
+ // Assert the header
+ cy.get('[data-test="compliance-report-header"]')
+ .should('be.visible')
+ .and('have.text', `${currentYear} Compliance report - Original Report`)
+
+ // Assert the status
+ cy.get('[data-test="compliance-report-status"]')
+ .should('be.visible')
+ .and('have.text', 'Status: Draft')
+
+ // Assert the Introduction Header
+ cy.contains('div.MuiTypography-h5', 'Introduction')
+ .should('be.visible')
+ .and('have.text', 'Introduction')
+
+ // Assert the Welcome Message
+ cy.contains(
+ 'h6',
+ 'Welcome to the British Columbia Low Carbon Fuel Standard Portal'
+ )
+ .should('be.visible')
+ .and(
+ 'have.text',
+ 'Welcome to the British Columbia Low Carbon Fuel Standard Portal'
+ )
+})
diff --git a/frontend/cypress/e2e/Pages/ComplianceReport/ComplianceReportManagement.feature b/frontend/cypress/e2e/Pages/ComplianceReport/ComplianceReportManagement.feature
new file mode 100644
index 000000000..01361dcff
--- /dev/null
+++ b/frontend/cypress/e2e/Pages/ComplianceReport/ComplianceReportManagement.feature
@@ -0,0 +1,8 @@
+Feature: Compliance Report Management
+
+ Scenario: Supplier saves a draft compliance report
+ Given the supplier is on the login page
+ When the supplier logs in with valid credentials
+ And the supplier navigates to the compliance reports page
+ And the supplier creates a new compliance report
+ Then the compliance report introduction is shown
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index f9ec1948b..127024c7d 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -7,8 +7,8 @@ import { ViewUser } from '@/views/Admin/AdminMenu/components/ViewUser'
import { ComplianceReports } from './views/ComplianceReports'
import { Dashboard } from './views/Dashboard'
import { FileSubmissions } from './views/FileSubmissions'
+import { NotificationMenu } from '@/views/Notifications/NotificationMenu'
import { FuelCodes, AddEditFuelCode } from './views/FuelCodes'
-import { Notifications, NotificationSettings } from './views/Notifications'
import {
Organizations,
AddEditOrg,
@@ -201,13 +201,13 @@ const router = createBrowserRouter([
},
{
path: ROUTES.NOTIFICATIONS,
- element: ,
+ element: ,
handle: { title: 'Notifications' }
},
{
path: ROUTES.NOTIFICATIONS_SETTINGS,
- element: ,
- handle: { title: 'Notification Settings' }
+ element: ,
+ handle: { title: 'Configure notifications' }
},
{
path: ROUTES.ADMIN,
diff --git a/frontend/src/assets/locales/en/common.json b/frontend/src/assets/locales/en/common.json
index 7277d6d16..fa6f2b947 100644
--- a/frontend/src/assets/locales/en/common.json
+++ b/frontend/src/assets/locales/en/common.json
@@ -42,5 +42,6 @@
"FuelCodes": "Fuel codes",
"lcfsEmail": "lcfs@gov.bc.ca",
"scrollToTop": "Scroll to top",
- "scrollToBottom": "Scroll to bottom"
+ "scrollToBottom": "Scroll to bottom",
+ "Notifications": "Notifications"
}
diff --git a/frontend/src/assets/locales/en/fuelCode.json b/frontend/src/assets/locales/en/fuelCode.json
index 5ff43d9ff..84f150236 100644
--- a/frontend/src/assets/locales/en/fuelCode.json
+++ b/frontend/src/assets/locales/en/fuelCode.json
@@ -36,7 +36,8 @@
"approvalDate": "Approval date",
"effectiveDate": "Effective date",
"expirationDate": "Expiry date",
- "fuel": "Fuel",
+ "fuelTypeId": "Fuel type id",
+ "fuelType": "Fuel",
"feedstock": "Feedstock",
"feedstockLocation": "Feedstock location",
"misc": "Misc (e.g. CCS, EOR, waste steam, non rendered, etc.,)",
diff --git a/frontend/src/assets/locales/en/notifications.json b/frontend/src/assets/locales/en/notifications.json
new file mode 100644
index 000000000..5f985416c
--- /dev/null
+++ b/frontend/src/assets/locales/en/notifications.json
@@ -0,0 +1,94 @@
+{
+ "title": {
+ "ConfigureNotifications": "Configure notifications",
+ "Notifications": "Notifications"
+ },
+ "tabs": {
+ "configuration": "Configuration",
+ "notifications": "Notifications"
+ },
+ "emailNotification": "Email notification",
+ "inAppNotification": "In-app notification",
+ "saveButton": "Save",
+ "notificationsEmail": "Notifications email",
+ "messages": {
+ "subscriptionUpdated": "Subscription updated successfully.",
+ "emailSaved": "Email address saved successfully."
+ },
+ "errors": {
+ "invalidEmail": "Please enter a valid email address.",
+ "emailRequired": "Email address is required.",
+ "loadingNotificationSettings": "Error loading notification settings.",
+ "operationFailed": "An error occurred while processing your request. Please try again."
+ },
+ "loading": {
+ "savingPreferences": "Updating your subscription preferences...",
+ "notificationSettings": "Loading notification settings..."
+ },
+ "aria": {
+ "notificationsTabs": "Notifications Tabs"
+ },
+ "bceid": {
+ "categories": {
+ "transfers": {
+ "title": "Transfers",
+ "partnerActions": "Transfer partner proposed, declined, rescinded or signed & submitted",
+ "directorDecision": "Director recorded/refused"
+ },
+ "initiativeAgreements": {
+ "title": "Initiative agreements and other transactions",
+ "directorApproval": "Director approved"
+ },
+ "complianceReports": {
+ "title": "Compliance & supplemental reports",
+ "directorAssessment": "Director assessment"
+ }
+ }
+ },
+ "idirAnalyst": {
+ "categories": {
+ "transfers": {
+ "title": "Transfers",
+ "submittedForReview": "Submitted to government for analyst review",
+ "rescindedAction": "Rescinded by either transfer partner",
+ "directorRecorded": "Director recorded/refused"
+ },
+ "initiativeAgreements": {
+ "title": "Initiative agreements and other transactions",
+ "returnedToAnalyst": "Director approved/returned to analyst"
+ },
+ "complianceReports": {
+ "title": "Compliance & supplemental reports",
+ "submittedForReview": "Submitted to government for analyst review (or returned by compliance manager)",
+ "managerRecommendation": "Recommended by compliance manager",
+ "directorDecision": "Director assessment"
+ }
+ }
+ },
+ "idirComplianceManager": {
+ "categories": {
+ "complianceReports": {
+ "title": "Compliance & supplemental reports",
+ "submittedForReview": "Submitted to government for analyst review",
+ "analystRecommendation": "Analyst recommendation (or returned by the director)",
+ "directorAssessment": "Director assessment"
+ }
+ }
+ },
+ "idirDirector": {
+ "categories": {
+ "transfers": {
+ "title": "Transfers",
+ "analystRecommendation": "Analyst recommendation"
+ },
+ "initiativeAgreements": {
+ "title": "Initiative agreements and other transactions",
+ "analystRecommendation": "Analyst recommendation"
+ },
+ "complianceReports": {
+ "title": "Compliance & supplemental reports",
+ "managerRecommendation": "Compliance manager recommendation"
+ }
+ }
+ }
+}
diff --git a/frontend/src/components/BCDataGrid/BCGridEditor.jsx b/frontend/src/components/BCDataGrid/BCGridEditor.jsx
index 6771a3454..cdc02be64 100644
--- a/frontend/src/components/BCDataGrid/BCGridEditor.jsx
+++ b/frontend/src/components/BCDataGrid/BCGridEditor.jsx
@@ -1,3 +1,4 @@
+/* eslint-disable react-hooks/exhaustive-deps */
import BCBox from '@/components/BCBox'
import { BCGridBase } from '@/components/BCDataGrid/BCGridBase'
import { isEqual } from '@/utils/grid/eventHandlers'
@@ -33,6 +34,7 @@ import { BCAlert2 } from '@/components/BCAlert'
export const BCGridEditor = ({
gridRef,
alertRef,
+ enablePaste = true,
handlePaste,
onCellEditingStopped,
onCellValueChanged,
@@ -97,9 +99,13 @@ export const BCGridEditor = ({
const parsedData = Papa.parse(headerRow + '\n' + pastedData, {
delimiter: '\t',
header: true,
+ transform: (value) => {
+ const num = Number(value) // Attempt to convert to a number if possible
+ return isNaN(num) ? value : num // Return the number if valid, otherwise keep as string
+ },
skipEmptyLines: true
})
- if (parsedData.data.length < 1 || parsedData.data[1].length < 2) {
+ if (parsedData.data.length < 0 || parsedData.data[1].length < 2) {
return
}
parsedData.data.forEach((row) => {
@@ -107,17 +113,38 @@ export const BCGridEditor = ({
newRow.id = uuid()
newData.push(newRow)
})
- ref.current.api.applyTransaction({ add: newData })
+ const transactions = ref.current.api.applyTransaction({ add: newData })
+ // Trigger onCellEditingStopped event to update the row in backend.
+ transactions.add.forEach((node) => {
+ onCellEditingStopped({
+ node,
+ oldValue: '',
+ newvalue: node.data[findFirstEditableColumn()],
+ ...props
+ })
+ })
},
- [ref]
+ [findFirstEditableColumn, onCellEditingStopped, props, ref]
)
useEffect(() => {
- window.addEventListener('paste', handlePaste || handleExcelPaste)
- return () => {
- window.removeEventListener('paste', handlePaste || handleExcelPaste)
+ const pasteHandler = (event) => {
+ const gridApi = ref.current?.api
+ const columnApi = ref.current?.columnApi
+
+ if (handlePaste) {
+ handlePaste(event, { api: gridApi, columnApi })
+ } else {
+ handleExcelPaste(event) // Fallback to the default paste function
+ }
+ }
+ if (enablePaste) {
+ window.addEventListener('paste', pasteHandler)
+ return () => {
+ window.removeEventListener('paste', pasteHandler)
+ }
}
- }, [handleExcelPaste, handlePaste])
+ }, [handleExcelPaste, handlePaste, ref, enablePaste])
const handleOnCellEditingStopped = useCallback(
async (params) => {
diff --git a/frontend/src/components/BCDataGrid/BCGridViewer.jsx b/frontend/src/components/BCDataGrid/BCGridViewer.jsx
index f2e0c9d8f..429c3c153 100644
--- a/frontend/src/components/BCDataGrid/BCGridViewer.jsx
+++ b/frontend/src/components/BCDataGrid/BCGridViewer.jsx
@@ -82,12 +82,12 @@ export const BCGridViewer = ({
[defaultSortModel, gridKey]
)
const resetGrid = useCallback(() => {
- localStorage.removeItem(`${gridKey}-filter`);
- localStorage.removeItem(`${gridKey}-column`);
- setPage(1);
- setSize(paginationPageSize);
- setSortModel(defaultSortModel);
- setFilterModel(defaultFilterModel);
+ localStorage.removeItem(`${gridKey}-filter`)
+ localStorage.removeItem(`${gridKey}-column`)
+ setPage(1)
+ setSize(paginationPageSize)
+ setSortModel(defaultSortModel)
+ setFilterModel(defaultFilterModel)
// Re-fetch the data by calling the query function
query(
@@ -99,8 +99,15 @@ export const BCGridViewer = ({
...queryParams
},
{ retry: false }
- );
- }, [gridKey, paginationPageSize, defaultSortModel, defaultFilterModel, query, queryParams]);
+ )
+ }, [
+ gridKey,
+ paginationPageSize,
+ defaultSortModel,
+ defaultFilterModel,
+ query,
+ queryParams
+ ])
const onFirstDataRendered = useCallback((params) => {
params.api.hideOverlay()
@@ -115,21 +122,21 @@ export const BCGridViewer = ({
setPage(1)
}, [])
- const onFilterChanged = useCallback(() => {
- setPage(1)
- const filterModel = ref.current?.api.getFilterModel()
- const filterArr = [
- ...Object.entries(filterModel).map(([field, value]) => {
- return { field, ...value }
- }),
- ...defaultFilterModel
- ]
- setFilterModel(filterArr)
- localStorage.setItem(
- `${gridKey}-filter`,
- JSON.stringify(ref.current?.api.getFilterModel())
- )
- }, [defaultFilterModel, gridKey, ref])
+ const onFilterChanged = useCallback(
+ (grid) => {
+ setPage(1)
+ const gridFilters = grid.api.getFilterModel()
+ const filterArr = [
+ ...Object.entries(gridFilters).map(([field, value]) => {
+ return { field, ...value }
+ }),
+ ...defaultFilterModel
+ ]
+ setFilterModel(filterArr)
+ localStorage.setItem(`${gridKey}-filter`, JSON.stringify(filterArr))
+ },
+ [defaultFilterModel, gridKey, ref]
+ )
const onSortChanged = useCallback(() => {
setPage(1)
@@ -173,10 +180,13 @@ export const BCGridViewer = ({
{error.message}. Please contact your administrator.
-
+
Clear filter & sort
diff --git a/frontend/src/components/BCDataGrid/components/Editors/AutocompleteCellEditor.jsx b/frontend/src/components/BCDataGrid/components/Editors/AutocompleteCellEditor.jsx
index f68b086b9..de12c61ea 100644
--- a/frontend/src/components/BCDataGrid/components/Editors/AutocompleteCellEditor.jsx
+++ b/frontend/src/components/BCDataGrid/components/Editors/AutocompleteCellEditor.jsx
@@ -37,7 +37,9 @@ export const AutocompleteCellEditor = forwardRef((props, ref) => {
onPaste
} = props
- const [selectedValues, setSelectedValues] = useState(value || [])
+ const [selectedValues, setSelectedValues] = useState(
+ (Array.isArray(value) ? value : value.split(',').map((v) => v.trim())) || []
+ )
const inputRef = useRef()
useImperativeHandle(ref, () => ({
diff --git a/frontend/src/components/BCDataGrid/components/Editors/DateEditor.jsx b/frontend/src/components/BCDataGrid/components/Editors/DateEditor.jsx
index fa4ca4ca6..01321d990 100644
--- a/frontend/src/components/BCDataGrid/components/Editors/DateEditor.jsx
+++ b/frontend/src/components/BCDataGrid/components/Editors/DateEditor.jsx
@@ -1,7 +1,6 @@
-import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'
-import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3'
+import { DatePicker } from '@mui/x-date-pickers'
import { format, parseISO } from 'date-fns'
-import { useState, useEffect, useRef } from 'react'
+import { useEffect, useRef, useState } from 'react'
export const DateEditor = ({ value, onValueChange, minDate, maxDate }) => {
const [selectedDate, setSelectedDate] = useState(
@@ -52,28 +51,26 @@ export const DateEditor = ({ value, onValueChange, minDate, maxDate }) => {
onMouseDown={stopPropagation}
onClick={stopPropagation}
>
-
-
-
+
)
}
diff --git a/frontend/src/components/BCDataGrid/components/Filters/BCColumnSetFilter.jsx b/frontend/src/components/BCDataGrid/components/Filters/BCColumnSetFilter.jsx
index 785cbb8de..2bc840392 100644
--- a/frontend/src/components/BCDataGrid/components/Filters/BCColumnSetFilter.jsx
+++ b/frontend/src/components/BCDataGrid/components/Filters/BCColumnSetFilter.jsx
@@ -40,11 +40,11 @@ export const BCColumnSetFilter = forwardRef((props, ref) => {
if (!props.multiple) {
const val = input ? input.name : ''
setCurrentValue(val)
- instance.onFloatingFilterChanged('equals', val)
+ instance.onFloatingFilterChanged('custom', val)
} else {
const filterArr = input.map((item) => item.name).join(', ')
setCurrentValue(filterArr)
- instance.onFloatingFilterChanged('equals', filterArr)
+ instance.onFloatingFilterChanged('custom', filterArr)
}
})
}
@@ -66,6 +66,7 @@ export const BCColumnSetFilter = forwardRef((props, ref) => {
multiple={props.multiple}
disableCloseOnSelect={props.disableCloseOnSelect}
onChange={onInputBoxChanged}
+ openOnFocus
isOptionEqualToValue={(option, value) => option.name === value.name}
limitTags={1}
className="bc-column-set-filter ag-list ag-select-list ag-ltr ag-popup-child ag-popup-positioned-under"
@@ -79,7 +80,6 @@ export const BCColumnSetFilter = forwardRef((props, ref) => {
renderOption={(propsIn, option, { selected }) => (
{
role="option"
sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
{...propsIn}
+ key={option.name}
>
{props.multiple && (
({
@@ -54,8 +54,7 @@ function DefaultNavbarLink({
backgroundColor: isMobileView
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(0, 0, 0, 0.3)',
- paddingBottom: isMobileView ? '11px' : '12px',
- paddingLeft: isMobileView ? '13px' : 2
+ paddingBottom: isMobileView ? '11px' : '12px'
},
transform: 'translateX(0)',
transition: transitions.create('transform', {
@@ -87,7 +86,9 @@ function DefaultNavbarLink({
lineHeight: 0,
'&:hover': {
textDecoration: 'none'
- }
+ },
+ whiteSpace: 'nowrap',
+ flexShrink: 0
}}
>
{name}
diff --git a/frontend/src/components/BCNavbar/components/MenuBar.jsx b/frontend/src/components/BCNavbar/components/MenuBar.jsx
index 0e9f738fd..5844d82b4 100644
--- a/frontend/src/components/BCNavbar/components/MenuBar.jsx
+++ b/frontend/src/components/BCNavbar/components/MenuBar.jsx
@@ -17,18 +17,19 @@ const MenuBar = (props) => {
backdropFilter: `saturate(200%) blur(30px)`,
color: white.main,
maxHeight: '50px',
- display: { xs: 'none', sm: 'flex' }
+ display: { xs: 'none', sm: 'flex' },
+ justifyContent: 'space-between'
})}
disableGutters
variant="dense"
>
{routes.map(
diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx
deleted file mode 100644
index 49c90cb5e..000000000
--- a/frontend/src/components/Header.jsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import logo from '@/assets/images/gov3_bc_logo.png'
-import { Logout } from './Logout'
-
-const Header = () => {
- return (
-
-
-
-
Low Carbon Fuel Standard
-
-
- )
-}
-export default Header
diff --git a/frontend/src/constants/notificationTypes.js b/frontend/src/constants/notificationTypes.js
new file mode 100644
index 000000000..c694e5fc9
--- /dev/null
+++ b/frontend/src/constants/notificationTypes.js
@@ -0,0 +1,46 @@
+export const notificationTypes = {
+ // BCEID
+ BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT:
+ 'BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT',
+ BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL:
+ 'BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL',
+ BCEID__TRANSFER__DIRECTOR_DECISION: 'BCEID__TRANSFER__DIRECTOR_DECISION',
+ BCEID__TRANSFER__PARTNER_ACTIONS: 'BCEID__TRANSFER__PARTNER_ACTIONS',
+
+ // IDIR ANALYST
+ IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION:
+ 'IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION',
+ IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION:
+ 'IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION',
+ IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW:
+ 'IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW',
+ IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST:
+ 'IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST',
+ IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED:
+ 'IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED',
+ IDIR_ANALYST__TRANSFER__RESCINDED_ACTION:
+ 'IDIR_ANALYST__TRANSFER__RESCINDED_ACTION',
+ IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW:
+ 'IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW',
+
+ // IDIR COMPLIANCE_MANAGER
+ IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION:
+ 'IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION',
+ IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT:
+ 'IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT',
+ IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW:
+ 'IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW',
+
+ // IDIR DIRECTOR
+ IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION:
+ 'IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION',
+ IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION:
+ 'IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION',
+ IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION:
+ 'IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION'
+}
+
+export const notificationChannels = {
+ EMAIL: 'EMAIL',
+ IN_APP: 'IN_APP'
+}
diff --git a/frontend/src/constants/routes/apiRoutes.js b/frontend/src/constants/routes/apiRoutes.js
index 4dbef0d6d..f9c089581 100644
--- a/frontend/src/constants/routes/apiRoutes.js
+++ b/frontend/src/constants/routes/apiRoutes.js
@@ -71,5 +71,9 @@ export const apiRoutes = {
trackUserLogin: '/users/logged-in',
getUserLoginHistories: '/users/login-history',
getAuditLogs: '/audit-log/list',
- getAuditLog: '/audit-log/:auditLogId'
+ getAuditLog: '/audit-log/:auditLogId',
+ getNotificationsCount: '/notifications/count',
+ getNotificationSubscriptions: '/notifications/subscriptions',
+ saveNotificationSubscriptions: '/notifications/subscriptions/save',
+ updateNotificationsEmail: '/users/update-notifications-email'
}
diff --git a/frontend/src/constants/routes/routes.js b/frontend/src/constants/routes/routes.js
index 0a6c6d3d2..78c3978a8 100644
--- a/frontend/src/constants/routes/routes.js
+++ b/frontend/src/constants/routes/routes.js
@@ -45,7 +45,7 @@ export const REPORTS_ADD_OTHER_USE_FUELS = `${REPORTS_VIEW}/fuels-other-use`
export const REPORTS_ADD_FUEL_EXPORTS = `${REPORTS_VIEW}/fuel-exports`
export const NOTIFICATIONS = '/notifications'
-export const NOTIFICATIONS_SETTINGS = `${NOTIFICATIONS}/settings`
+export const NOTIFICATIONS_SETTINGS = `${NOTIFICATIONS}/configure`
export const ADMIN = '/admin'
export const ADMIN_USERS = `${ADMIN}/users`
diff --git a/frontend/src/hooks/useFuelCode.js b/frontend/src/hooks/useFuelCode.js
index d4acd0b0d..855330e6f 100644
--- a/frontend/src/hooks/useFuelCode.js
+++ b/frontend/src/hooks/useFuelCode.js
@@ -1,6 +1,9 @@
import { apiRoutes } from '@/constants/routes'
import { useApiService } from '@/services/useApiService'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useCurrentUser } from '@/hooks/useCurrentUser'
+import { roles } from '@/constants/roles'
+import { TRANSFER_STATUSES } from '@/constants/statuses'
export const useFuelCodeOptions = (params, options) => {
const client = useApiService()
@@ -75,6 +78,32 @@ export const useDeleteFuelCode = (options) => {
})
}
+export const useFuelCodeStatuses = (options) => {
+ const client = useApiService()
+
+ return useQuery({
+ queryKey: ['fuel-code-statuses'],
+ queryFn: async () => {
+ const optionsData = await client.get('/fuel-codes/statuses')
+ return optionsData.data
+ },
+ ...options
+ })
+}
+
+export const useTransportModes = (options) => {
+ const client = useApiService()
+
+ return useQuery({
+ queryKey: ['transport-modes'],
+ queryFn: async () => {
+ const optionsData = await client.get('/fuel-codes/transport-modes')
+ return optionsData.data
+ },
+ ...options
+ })
+}
+
export const useGetFuelCodes = (
{ page = 1, size = 10, sortOrders = [], filters = [] } = {},
options
diff --git a/frontend/src/hooks/useNotifications.js b/frontend/src/hooks/useNotifications.js
new file mode 100644
index 000000000..87b286a87
--- /dev/null
+++ b/frontend/src/hooks/useNotifications.js
@@ -0,0 +1,77 @@
+import { apiRoutes } from '@/constants/routes'
+import { useApiService } from '@/services/useApiService'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+
+export const useNotificationsCount = (options) => {
+ const client = useApiService()
+ return useQuery({
+ queryKey: ['notifications-count'],
+ queryFn: async () => {
+ const response = await client.get(apiRoutes.getNotificationsCount)
+ return response.data
+ },
+ staleTime: 1 * 60 * 1000, // 1 minute
+ cacheTime: 5 * 60 * 1000, // 5 minutes
+ refetchInterval: 1 * 60 * 1000, // 1 minute
+ ...options
+ })
+}
+
+export const useNotificationSubscriptions = (options) => {
+ const client = useApiService()
+ return useQuery({
+ queryKey: ['notification-subscriptions'],
+ queryFn: async () => {
+ try {
+ const response = await client.get(
+ apiRoutes.getNotificationSubscriptions
+ )
+ return response.data
+ } catch (error) {
+ if (error.response && error.response.status === 404) {
+ // Return an empty array if 404 is returned
+ return []
+ }
+ throw error
+ }
+ },
+ ...options
+ })
+}
+
+export const useCreateSubscription = (options) => {
+ const client = useApiService()
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: (data) =>
+ client.post(apiRoutes.saveNotificationSubscriptions, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(['notification-subscriptions'])
+ },
+ ...options
+ })
+}
+
+export const useDeleteSubscription = (options) => {
+ const client = useApiService()
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: (subscriptionId) =>
+ client.post(apiRoutes.saveNotificationSubscriptions, {
+ notificationChannelSubscriptionId: subscriptionId,
+ deleted: true
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries(['notification-subscriptions'])
+ },
+ ...options
+ })
+}
+
+export const useUpdateNotificationsEmail = (options) => {
+ const client = useApiService()
+ return useMutation({
+ mutationFn: (data) => client.post(apiRoutes.updateNotificationsEmail, data),
+ ...options
+ })
+}
diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js
index 296fe8fd9..5eed4a07a 100644
--- a/frontend/src/i18n.js
+++ b/frontend/src/i18n.js
@@ -17,6 +17,7 @@ import fuelSupplyEn from '@/assets/locales/en/fuelSupply.json'
import fuelExportEn from '@/assets/locales/en/fuelExport.json'
import dashboardEn from '@/assets/locales/en/dashboard.json'
import allocationAgreementEn from '@/assets/locales/en/allocationAgreement.json'
+import notificationsEn from '@/assets/locales/en/notifications.json'
// manage translations separated from your code: https://react.i18next.com/guides/multiple-translation-files)
const resources = {
@@ -38,7 +39,8 @@ const resources = {
fuelSupply: fuelSupplyEn,
fuelExport: fuelExportEn,
dashboard: dashboardEn,
- allocationAgreement: allocationAgreementEn
+ allocationAgreement: allocationAgreementEn,
+ notifications: notificationsEn
}
}
diff --git a/frontend/src/layouts/MainLayout/components/Logout.jsx b/frontend/src/layouts/MainLayout/components/Logout.jsx
deleted file mode 100644
index cc5fd5c22..000000000
--- a/frontend/src/layouts/MainLayout/components/Logout.jsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { logout } from '@/utils/keycloak'
-import { useKeycloak } from '@react-keycloak/web'
-import { useTranslation } from 'react-i18next'
-// @mui components
-import BCBox from '@/components/BCBox'
-import BCButton from '@/components/BCButton'
-import BCTypography from '@/components/BCTypography'
-
-import { useCurrentUser } from '@/hooks/useCurrentUser'
-
-export const Logout = () => {
- const { t } = useTranslation()
- const { data: currentUser } = useCurrentUser()
- const { keycloak } = useKeycloak()
-
- return (
- keycloak.authenticated && (
-
- {currentUser?.firstName && (
-
- {currentUser?.firstName + ' ' + currentUser?.lastName}
-
- )}
- {
- logout()
- }}
- color="light"
- size="small"
- variant="outlined"
- data-test="logout-button"
- >
- {t('logout')}
-
-
- )
- )
-}
diff --git a/frontend/src/layouts/MainLayout/components/Navbar.jsx b/frontend/src/layouts/MainLayout/components/Navbar.jsx
index a4ee6122b..705466fe4 100644
--- a/frontend/src/layouts/MainLayout/components/Navbar.jsx
+++ b/frontend/src/layouts/MainLayout/components/Navbar.jsx
@@ -5,7 +5,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { HeaderComponent } from './HeaderComponent'
-import { Logout } from './Logout'
+import { UserProfileActions } from './UserProfileActions'
import { useMediaQuery, useTheme } from '@mui/material'
export const Navbar = () => {
@@ -34,7 +34,10 @@ export const Navbar = () => {
{ name: t('ComplianceReporting'), route: ROUTES.REPORTS },
{ name: t('Organization'), route: ROUTES.ORGANIZATION }
]
- const mobileRoutes = [{ name: t('logout'), route: ROUTES.LOG_OUT }]
+ const mobileRoutes = [
+ { name: t('Notifications'), route: ROUTES.NOTIFICATIONS },
+ { name: t('logout'), route: ROUTES.LOG_OUT }
+ ]
const activeRoutes = currentUser?.isGovernmentUser
? idirRoutes
@@ -53,7 +56,7 @@ export const Navbar = () => {
beta={true}
data-test="main-layout-navbar"
headerRightPart={}
- menuRightPart={}
+ menuRightPart={}
/>
)
}
diff --git a/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx b/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx
new file mode 100644
index 000000000..f5f7dbc68
--- /dev/null
+++ b/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx
@@ -0,0 +1,134 @@
+import { useEffect } from 'react'
+import { logout } from '@/utils/keycloak'
+import { useKeycloak } from '@react-keycloak/web'
+import { useTranslation } from 'react-i18next'
+import BCBox from '@/components/BCBox'
+import BCButton from '@/components/BCButton'
+import BCTypography from '@/components/BCTypography'
+import { useCurrentUser } from '@/hooks/useCurrentUser'
+import { useNotificationsCount } from '@/hooks/useNotifications'
+import {
+ Badge,
+ IconButton,
+ Divider,
+ CircularProgress,
+ Tooltip
+} from '@mui/material'
+import NotificationsIcon from '@mui/icons-material/Notifications'
+import { useNavigate, useLocation } from 'react-router-dom'
+import { ROUTES } from '@/constants/routes'
+
+export const UserProfileActions = () => {
+ const { t } = useTranslation()
+ const { data: currentUser } = useCurrentUser()
+ const { keycloak } = useKeycloak()
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ // TODO:
+ // Automatically refetch every 1 minute (60000ms) for real-time updates.
+ // Alternatively, for better efficiency and scalability, consider implementing
+ // server-side push mechanisms (e.g., WebSockets, Server-Sent Events) to notify
+ // the client of updates as they occur, reducing unnecessary polling.
+ const {
+ data: notificationsData,
+ isLoading,
+ refetch
+ } = useNotificationsCount({
+ refetchInterval: 60000 // Automatically refetch every 1 minute (60000ms)
+ })
+ const notificationsCount = notificationsData?.count || 0
+ console.log(notificationsData)
+
+ // Call refetch whenever the route changes
+ useEffect(() => {
+ refetch()
+ }, [location, refetch])
+
+ return (
+ keycloak.authenticated && (
+
+ {currentUser?.firstName && (
+ <>
+
+ {`${currentUser.firstName} ${currentUser.lastName}`}
+
+ ({
+ backgroundColor: secondary.main,
+ height: '60%',
+ alignSelf: 'center',
+ marginLeft: 1,
+ marginRight: 1
+ })}
+ />
+ >
+ )}
+ <>
+ {isLoading ? (
+
+ ) : (
+
+ navigate(ROUTES.NOTIFICATIONS)}
+ aria-label={t('Notifications')}
+ >
+ 0 ? notificationsCount : null
+ }
+ color="error"
+ >
+
+
+
+
+ )}
+ ({
+ backgroundColor: secondary.main,
+ height: '60%',
+ alignSelf: 'center',
+ marginLeft: 1,
+ marginRight: 3
+ })}
+ />
+ >
+
+ {t('logout')}
+
+
+ )
+ )
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 941a3e9b9..c6116f53f 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -6,6 +6,8 @@ import theme from '@/themes'
import './i18n'
import { KeycloakProvider } from '@/components/KeycloakProvider'
import { getKeycloak } from '@/utils/keycloak'
+import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
+import { LocalizationProvider } from '@mui/x-date-pickers'
const queryClient = new QueryClient()
const keycloak = getKeycloak()
@@ -17,8 +19,10 @@ if (root) {
-
-
+
+
+
+
diff --git a/frontend/src/views/Admin/AdminMenu/components/_schema.js b/frontend/src/views/Admin/AdminMenu/components/_schema.js
index 7e9f3a303..85691e6c8 100644
--- a/frontend/src/views/Admin/AdminMenu/components/_schema.js
+++ b/frontend/src/views/Admin/AdminMenu/components/_schema.js
@@ -45,7 +45,6 @@ export const usersColumnDefs = (t) => [
suppressFilterButton: true
},
floatingFilterComponent: BCColumnSetFilter,
- suppressFloatingFilterButton: true,
floatingFilterComponentParams: {
apiQuery: useRoleList, // all data returned should be an array which includes an object of key 'name'
// Eg: [{id: 1, name: 'EntryListItem' }] except name all others are optional
@@ -86,7 +85,6 @@ export const usersColumnDefs = (t) => [
cellRenderer: StatusRenderer,
cellClass: 'vertical-middle',
floatingFilterComponent: BCColumnSetFilter,
- suppressFloatingFilterButton: true,
floatingFilterComponentParams: {
apiQuery: () => ({
data: [
@@ -250,7 +248,6 @@ export const auditLogColDefs = (t) => [
flex: 1,
valueGetter: ({ data }) => data.createDate || '',
valueFormatter: timezoneFormatter,
- suppressFloatingFilterButton: true,
filter: 'agDateColumnFilter',
filterParams: {
filterOptions: ['equals', 'lessThan', 'greaterThan', 'inRange'],
diff --git a/frontend/src/views/AllocationAgreements/_schema.jsx b/frontend/src/views/AllocationAgreements/_schema.jsx
index 98e91b0b3..6cdc43401 100644
--- a/frontend/src/views/AllocationAgreements/_schema.jsx
+++ b/frontend/src/views/AllocationAgreements/_schema.jsx
@@ -59,7 +59,7 @@ export const allocationAgreementColDefs = (optionsData, errors) => [
),
cellEditor: 'agSelectCellEditor',
cellEditorParams: {
- values: ['Purchased', 'Sold']
+ values: ['Allocated from', 'Allocated to']
},
cellRenderer: (params) =>
params.value ||
diff --git a/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx b/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx
index e05a6c55a..4ba45bf38 100644
--- a/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx
+++ b/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx
@@ -180,7 +180,11 @@ export const EditViewComplianceReport = () => {
data={modalData}
/>
-
+
{compliancePeriod + ' ' + t('report:complianceReport')} -{' '}
{reportData?.report.nickname}
@@ -188,6 +192,7 @@ export const EditViewComplianceReport = () => {
variant="h6"
color="primary"
style={{ marginLeft: '0.25rem' }}
+ data-test="compliance-report-status"
>
Status: {currentStatus}
diff --git a/frontend/src/views/ComplianceReports/components/_schema.jsx b/frontend/src/views/ComplianceReports/components/_schema.jsx
index eeba24abc..e83fcdc19 100644
--- a/frontend/src/views/ComplianceReports/components/_schema.jsx
+++ b/frontend/src/views/ComplianceReports/components/_schema.jsx
@@ -1,4 +1,3 @@
-/* eslint-disable chai-friendly/no-unused-expressions */
import { BCColumnSetFilter } from '@/components/BCDataGrid/components'
import { SUMMARY } from '@/constants/common'
import { ReportsStatusRenderer, LinkRenderer } from '@/utils/grid/cellRenderers'
@@ -14,7 +13,6 @@ export const reportsColDefs = (t, bceidRole) => [
url: ({ data }) =>
`${data.compliancePeriod?.description}/${data.complianceReportId}`
},
- suppressFloatingFilterButton: true,
valueGetter: ({ data }) => data.compliancePeriod?.description || '',
filterParams: {
buttons: ['clear']
@@ -42,7 +40,6 @@ export const reportsColDefs = (t, bceidRole) => [
return `${typeLabel}${nickname}`
},
filter: 'agTextColumnFilter', // Enable text filtering
- suppressFloatingFilterButton: true,
filterParams: {
textFormatter: (value) => value.replace(/\s+/g, '').toLowerCase(),
textCustomComparator: (filter, value, filterText) => {
@@ -96,12 +93,12 @@ export const reportsColDefs = (t, bceidRole) => [
flex: 1,
valueGetter: ({ data }) => data.updateDate || '',
valueFormatter: timezoneFormatter,
- suppressFloatingFilterButton: true,
filter: 'agDateColumnFilter',
filterParams: {
filterOptions: ['equals', 'lessThan', 'greaterThan', 'inRange'],
suppressAndOrCondition: true,
- buttons: ['clear']
+ buttons: ['clear'],
+ maxValidYear: 2400
}
}
]
diff --git a/frontend/src/views/FinalSupplyEquipments/_schema.jsx b/frontend/src/views/FinalSupplyEquipments/_schema.jsx
index 2596196a9..b00d5f437 100644
--- a/frontend/src/views/FinalSupplyEquipments/_schema.jsx
+++ b/frontend/src/views/FinalSupplyEquipments/_schema.jsx
@@ -5,7 +5,8 @@ import {
RequiredHeader,
DateRangeCellEditor,
TextCellEditor,
- AsyncSuggestionEditor
+ AsyncSuggestionEditor,
+ NumberEditor
} from '@/components/BCDataGrid/components'
import i18n from '@/i18n'
import { actions, validation } from '@/components/BCDataGrid/columns'
@@ -13,8 +14,13 @@ import moment from 'moment'
import { CommonArrayRenderer } from '@/utils/grid/cellRenderers'
import { StandardCellWarningAndErrors, StandardCellErrors } from '@/utils/grid/errorRenderers'
import { apiRoutes } from '@/constants/routes'
+import { numberFormatter } from '@/utils/formatters.js'
-export const finalSupplyEquipmentColDefs = (optionsData, compliancePeriod, errors) => [
+export const finalSupplyEquipmentColDefs = (
+ optionsData,
+ compliancePeriod,
+ errors
+) => [
validation,
actions({
enableDuplicate: true,
@@ -112,13 +118,15 @@ export const finalSupplyEquipmentColDefs = (optionsData, compliancePeriod, error
'finalSupplyEquipment:finalSupplyEquipmentColLabels.kwhUsage'
),
minWidth: 220,
- cellEditor: 'agTextCellEditor',
- cellDataType: 'text',
- cellStyle: (params) => StandardCellErrors(params, errors),
- valueFormatter: (params) => {
- const value = parseFloat(params.value)
- return !isNaN(value) ? value.toFixed(2) : ''
- }
+ valueFormatter: numberFormatter,
+ cellEditor: NumberEditor,
+ type: 'numericColumn',
+ cellEditorParams: {
+ precision: 0,
+ min: 0,
+ showStepperButtons: false
+ },
+ cellStyle: (params) => StandardCellErrors(params, errors)
},
{
field: 'serialNbr',
diff --git a/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx b/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx
index 0e90b55d3..3c39a6f4c 100644
--- a/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx
+++ b/frontend/src/views/FuelCodes/AddFuelCode/_schema.jsx
@@ -84,6 +84,13 @@ export const fuelCodeColDefs = (optionsData, errors, isCreate, canEdit) => [
)
return selectedOption.prefix
}
+ const selectedOption = optionsData?.fuelCodePrefixes?.find(
+ (obj) => obj.prefix === params.data.prefix
+ )
+ if (selectedOption) {
+ params.data.prefixId = selectedOption.fuelCodePrefixId
+ }
+ return params.data.prefix
},
valueSetter: (params) => {
if (params.newValue !== params.oldValue) {
@@ -304,12 +311,12 @@ export const fuelCodeColDefs = (optionsData, errors, isCreate, canEdit) => [
cellEditor: DateEditor
},
{
- field: 'fuel',
+ field: 'fuelType',
editable: canEdit,
headerComponent: canEdit ? RequiredHeader : undefined,
- headerName: i18n.t('fuelCode:fuelCodeColLabels.fuel'),
+ headerName: i18n.t('fuelCode:fuelCodeColLabels.fuelType'),
cellEditor: AutocompleteCellEditor,
- cellRenderer: createCellRenderer('fuel'),
+ cellRenderer: createCellRenderer('fuelType'),
valueGetter: (params) => {
if (params.data?.fuelCodeType?.fuelType) {
return params.data.fuelCodeType.fuelType
@@ -319,6 +326,13 @@ export const fuelCodeColDefs = (optionsData, errors, isCreate, canEdit) => [
)
return selectedOption.fuelType
}
+ const selectedOption = optionsData?.fuelTypes?.find(
+ (obj) => obj.fuelType === params.data.fuel
+ )
+ if (selectedOption) {
+ params.data.fuelTypeId = selectedOption.fuelTypeId
+ }
+ return params.data.fuel
},
valueSetter: (params) => {
if (params.newValue) {
diff --git a/frontend/src/views/FuelCodes/_schema.jsx b/frontend/src/views/FuelCodes/_schema.jsx
index dc4e430ed..cd805dcb0 100644
--- a/frontend/src/views/FuelCodes/_schema.jsx
+++ b/frontend/src/views/FuelCodes/_schema.jsx
@@ -1,4 +1,3 @@
-import { KEY_ENTER, KEY_TAB } from '@/constants/common'
import {
CommonArrayRenderer,
FuelCodeStatusTextRenderer,
@@ -6,19 +5,20 @@ import {
} from '@/utils/grid/cellRenderers'
import { numberFormatter, timezoneFormatter } from '@/utils/formatters'
import { Typography } from '@mui/material'
-import { v4 as uuid } from 'uuid'
-import * as yup from 'yup'
-import {
- AutocompleteCellEditor,
- RequiredHeader,
- NumberEditor
-} from '@/components/BCDataGrid/components'
-
+import { BCColumnSetFilter } from '@/components/BCDataGrid/components'
+import { useFuelCodeStatuses, useTransportModes } from '@/hooks/useFuelCode'
export const fuelCodeColDefs = (t) => [
{
field: 'status',
headerName: t('fuelCode:fuelCodeColLabels.status'),
+ floatingFilterComponent: BCColumnSetFilter,
+ floatingFilterComponentParams: {
+ apiOptionField: 'status',
+ apiQuery: useFuelCodeStatuses,
+ disableCloseOnSelect: false,
+ multiple: false
+ },
valueGetter: (params) => params.data.fuelCodeStatus.status,
cellRenderer: FuelCodeStatusTextRenderer
},
@@ -65,28 +65,52 @@ export const fuelCodeColDefs = (t) => [
{
field: 'applicationDate',
headerName: t('fuelCode:fuelCodeColLabels.applicationDate'),
+ filter: 'agDateColumnFilter',
+ filterParams: {
+ filterOptions: ['equals', 'lessThan', 'greaterThan', 'inRange'],
+ suppressAndOrCondition: true,
+ maxValidYear: 2400
+ },
cellRenderer: TextRenderer
},
{
field: 'approvalDate',
headerName: t('fuelCode:fuelCodeColLabels.approvalDate'),
+ filter: 'agDateColumnFilter',
+ filterParams: {
+ filterOptions: ['equals', 'lessThan', 'greaterThan', 'inRange'],
+ suppressAndOrCondition: true,
+ maxValidYear: 2400
+ },
cellRenderer: TextRenderer
},
{
field: 'effectiveDate',
headerName: t('fuelCode:fuelCodeColLabels.effectiveDate'),
+ filter: 'agDateColumnFilter',
+ filterParams: {
+ filterOptions: ['equals', 'lessThan', 'greaterThan', 'inRange'],
+ suppressAndOrCondition: true,
+ maxValidYear: 2400
+ },
cellRenderer: TextRenderer
},
{
field: 'expirationDate',
- headerName: t('fuelCode:fuelCodeColLabels.expiryDate'),
+ headerName: t('fuelCode:fuelCodeColLabels.expirationDate'),
+ filter: 'agDateColumnFilter',
+ filterParams: {
+ filterOptions: ['equals', 'lessThan', 'greaterThan', 'inRange'],
+ suppressAndOrCondition: true,
+ maxValidYear: 2400
+ },
cellRenderer: TextRenderer
},
{
- field: 'fuel',
- headerName: t('fuelCode:fuelCodeColLabels.fuel'),
+ field: 'fuelType',
+ headerName: t('fuelCode:fuelCodeColLabels.fuelType'),
cellRenderer: TextRenderer,
- valueGetter: (params) => params.data.fuelCodeType.fuelType
+ valueGetter: (params) => params.data.fuelType.fuelType
},
{
field: 'feedstock',
@@ -142,6 +166,14 @@ export const fuelCodeColDefs = (t) => [
{
field: 'feedstockFuelTransportMode',
headerName: t('fuelCode:fuelCodeColLabels.feedstockFuelTransportMode'),
+ sortable: false,
+ floatingFilterComponent: BCColumnSetFilter,
+ floatingFilterComponentParams: {
+ apiOptionField: 'transportMode',
+ apiQuery: useTransportModes,
+ disableCloseOnSelect: false,
+ multiple: false
+ },
minWidth: 335,
valueGetter: (params) =>
params.data.feedstockFuelTransportModes.map(
@@ -152,6 +184,14 @@ export const fuelCodeColDefs = (t) => [
{
field: 'finishedFuelTransportMode',
headerName: t('fuelCode:fuelCodeColLabels.finishedFuelTransportMode'),
+ sortable: false,
+ floatingFilterComponent: BCColumnSetFilter,
+ floatingFilterComponentParams: {
+ apiOptionField: 'transportMode',
+ apiQuery: useTransportModes,
+ disableCloseOnSelect: false,
+ multiple: false
+ },
minWidth: 335,
valueGetter: (params) =>
params.data.finishedFuelTransportModes.map(
@@ -167,6 +207,7 @@ export const fuelCodeColDefs = (t) => [
},
{
field: 'lastUpdated',
+ filter: false,
headerName: t('fuelCode:fuelCodeColLabels.lastUpdated'),
cellRenderer: (params) => (
@@ -184,560 +225,3 @@ export const fuelCodeColDefs = (t) => [
minWidth: 600
}
]
-
-export const addEditSchema = {
- duplicateRow: (props) => {
- const newRow = {
- ...props.data,
- id: uuid(),
- modified: true,
- fuelSuffix: '100' + '.' + `${props.node?.rowIndex + 1}`
- }
- props.api.applyTransaction({
- add: [newRow],
- addIndex: props.node?.rowIndex + 1
- })
- props.api.stopEditing()
- },
-
- fuelCodeSchema: (t, optionsData) =>
- yup.object().shape({
- prefix: yup
- .string()
- .oneOf(
- optionsData.fuelCodePrefixes.map((obj) => obj.prefix),
- t('fuelCode:validateMsg.prefix')
- )
- .required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.prefix')
- })
- ),
- fuelSuffix: yup.number().required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.fuelSuffix')
- })
- ),
- company: yup.string().required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.company')
- })
- ),
- carbonIntensity: yup.number().required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.carbonIntensity')
- })
- ),
- edrms: yup.string().required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.edrms')
- })
- ),
- applicationDate: yup.date().required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.applicationDate')
- })
- ),
- fuel: yup
- .string()
- .oneOf(
- optionsData.fuelTypes
- .filter((fuel) => !fuel.fossilDerived)
- .map((obj) => obj.fuelType),
- t('fuelCode:validateMsg.fuel')
- )
- .required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.fuel')
- })
- ),
- feedstock: yup.string().required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.feedstock')
- })
- ),
- feedstockLocation: yup.string().required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.feedstockLocation')
- })
- ),
- fuelProductionFacilityCity: yup.string().required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.fuelProductionFacilityCity')
- })
- ),
- fuelProductionFacilityProvinceState: yup.string().required(
- t('fuelCode:validateMsg.isRequired', {
- field: t(
- 'fuelCode:fuelCodeColLabels.fuelProductionFacilityProvinceState'
- )
- })
- ),
- fuelProductionFacilityCountry: yup.string().required(
- t('fuelCode:validateMsg.isRequired', {
- field: t('fuelCode:fuelCodeColLabels.fuelProductionFacilityCountry')
- })
- )
- }),
- fuelCodeColDefs: (t, optionsData, isDraftOrNew = true) => [
- {
- colId: 'action',
- cellRenderer: 'actionsRenderer',
- cellRendererParams: {
- enableDuplicate: true,
- enableEdit: false,
- enableDelete: true,
- onDuplicate: addEditSchema.duplicateRow
- },
- pinned: 'left',
- maxWidth: 100,
- editable: false,
- suppressKeyboardEvent: (params) =>
- params.event.key === KEY_ENTER || params.event.key === KEY_TAB,
- filter: false,
- hide: !isDraftOrNew
- },
- {
- field: 'id',
- cellEditor: 'agTextCellEditor',
- cellDataType: 'text',
- hide: true
- },
- {
- field: 'prefix',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.prefix'),
- cellEditor: AutocompleteCellEditor,
- cellRenderer: (params) =>
- params.value ||
- (!params.value && Select),
- cellEditorParams: {
- options: optionsData.fuelCodePrefixes.map((obj) => obj.prefix),
- multiple: false, // ability to select multiple values from dropdown
- disableCloseOnSelect: false, // if multiple is true, this will prevent closing dropdown on selecting an option
- freeSolo: false, // this will allow user to type in the input box or choose from the dropdown
- openOnFocus: true // this will open the dropdown on input focus
- },
- suppressKeyboardEvent: (params) => {
- // return true (to suppress) if editing and user hit Enter key
- return params.editing && params.event.key === KEY_ENTER
- },
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- cellDataType: 'text',
- minWidth: 135,
- editable: isDraftOrNew
- },
- {
- field: 'fuelSuffix',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.fuelSuffix'),
- cellDataType: 'text',
- editable: false
- },
- {
- field: 'carbonIntensity',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.carbonIntensity'),
- cellEditor: 'agNumberCellEditor',
- cellEditorParams: {
- precision: 2,
- showStepperButtons: false
- },
- cellStyle: (params) => {
- if (params.data.modified && !params.value) return { borderColor: 'red' }
- },
- type: 'numericColumn',
- editable: isDraftOrNew
- },
- {
- field: 'edrms',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.edrms'),
- cellEditor: 'agTextCellEditor',
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- cellDataType: 'text',
- editable: isDraftOrNew
- },
- {
- field: 'company',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.company'),
- cellEditor: 'agTextCellEditor',
- cellDataType: 'text',
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- minWidth: 300,
- editable: isDraftOrNew
- },
- {
- field: 'contactName',
- headerName: t('fuelCode:fuelCodeColLabels.contactName'),
- cellEditor: 'agTextCellEditor',
- cellDataType: 'text',
- minWidth: 300,
- editable: isDraftOrNew
- },
- {
- field: 'contactEmail',
- headerName: t('fuelCode:fuelCodeColLabels.contactEmail'),
- cellEditor: 'agTextCellEditor',
- cellDataType: 'text',
- minWidth: 300,
- editable: isDraftOrNew
- },
-
- {
- field: 'lastUpdated',
- headerName: t('fuelCode:fuelCodeColLabels.lastUpdated'),
- maxWidth: 180,
- minWidth: 180,
- cellRenderer: (params) => (
-
- {params.value ? `${params.value} PDT` : 'YYYY-MM-DD'}
-
- ),
- editable: false, // TODO: change as per #516
- cellDataType: 'text',
- // valueGetter: (params) => {
- // return new Date().toLocaleDateString()
- // },
- hide: isDraftOrNew
- },
- {
- field: 'applicationDate',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.applicationDate'),
- maxWidth: 180,
- minWidth: 180,
- cellRenderer: (params) => (
-
- {params.value ? params.value : 'YYYY-MM-DD'}
-
- ),
- suppressKeyboardEvent: (params) =>
- params.editing &&
- (params.event.key === KEY_ENTER || params.event.key === KEY_TAB),
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- cellEditor: 'dateEditor',
- editable: isDraftOrNew
- },
- {
- field: 'approvalDate',
- headerName: t('fuelCode:fuelCodeColLabels.approvalDate'),
- maxWidth: 180,
- minWidth: 180,
- cellRenderer: (params) => (
-
- {params.value ? params.value : 'YYYY-MM-DD'}
-
- ),
- suppressKeyboardEvent: (params) => params.editing,
- cellEditor: 'dateEditor'
- },
- {
- field: 'effectiveDate',
- headerName: t('fuelCode:fuelCodeColLabels.effectiveDate'),
- maxWidth: 180,
- minWidth: 180,
- cellRenderer: (params) => (
-
- {params.value ? params.value : 'YYYY-MM-DD'}
-
- ),
- suppressKeyboardEvent: (params) => params.editing,
- cellEditor: 'dateEditor',
- editable: isDraftOrNew
- },
- {
- field: 'expirationDate',
- headerName: t('fuelCode:fuelCodeColLabels.expiryDate'),
- maxWidth: 180,
- minWidth: 180,
- cellRenderer: (params) => (
-
- {params.value ? params.value : 'YYYY-MM-DD'}
-
- ),
- suppressKeyboardEvent: (params) => params.editing,
- cellEditor: 'dateEditor',
- editable: isDraftOrNew
- },
- {
- field: 'fuel',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.fuel'),
- cellEditor: AutocompleteCellEditor,
- cellRenderer: (params) =>
- params.value ||
- (!params.value && Select),
- cellEditorParams: {
- options: optionsData.fuelTypes
- .filter((fuel) => !fuel.fossilDerived)
- .map((obj) => obj.fuelType),
- multiple: false, // ability to select multiple values from dropdown
- disableCloseOnSelect: false, // if multiple is true, this will prevent closing dropdown on selecting an option
- freeSolo: false, // this will allow user to type in the input box or choose from the dropdown
- openOnFocus: true // this will open the dropdown on input focus
- },
- suppressKeyboardEvent: (params) => {
- // return true (to suppress) if editing and user hit Enter key
- return params.editing && params.event.key === KEY_ENTER
- },
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- minWidth: 300,
- editable: isDraftOrNew
- },
- {
- field: 'feedstock',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.feedstock'),
- cellEditor: 'agTextCellEditor',
- cellDataType: 'text',
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- minWidth: 300,
- editable: isDraftOrNew
- },
- {
- field: 'feedstockLocation',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.feedstockLocation'),
- cellEditor: 'agTextCellEditor',
- cellDataType: 'text',
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- minWidth: 300,
- editable: isDraftOrNew
- },
- {
- field: 'feedstockMisc',
- headerName: t('fuelCode:fuelCodeColLabels.misc'),
- cellEditor: 'agTextCellEditor',
- cellDataType: 'text',
- minWidth: 495,
- editable: isDraftOrNew
- },
- {
- field: 'fuelProductionFacilityCity',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.fuelProductionFacilityCity'),
- cellEditor: AutocompleteCellEditor,
- cellDataType: 'text',
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- cellRenderer: (params) =>
- params.value ||
- (!params.value && Select),
- cellEditorParams: {
- onDynamicUpdate: (val, params) => params.api.stopEditing(),
- noLabel: true,
- options: [
- ...new Map(
- optionsData.fpLocations.map((location) => [
- location.fuelProductionFacilityCity,
- location.fuelProductionFacilityCity
- ])
- ).values()
- ],
- multiple: false, // ability to select multiple values from dropdown
- disableCloseOnSelect: false, // if multiple is true, this will prevent closing dropdown on selecting an option
- freeSolo: true, // this will allow user to type in the input box or choose from the dropdown
- openOnFocus: true // this will open the dropdown on input focus
- },
- minWidth: 325 // TODO: handle in #486
- },
- {
- field: 'fuelProductionFacilityProvinceState',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t(
- 'fuelCode:fuelCodeColLabels.fuelProductionFacilityProvinceState'
- ),
- cellEditor: AutocompleteCellEditor,
- cellDataType: 'text',
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- cellRenderer: (params) =>
- params.value ||
- (!params.value && Select),
- cellEditorParams: {
- onDynamicUpdate: (val, params) => params.api.stopEditing(),
- noLabel: true,
- options: [
- ...new Map(
- optionsData.fpLocations.map((location) => [
- location.fuelProductionFacilityProvinceState,
- location.fuelProductionFacilityProvinceState
- ])
- ).values()
- ],
- multiple: false, // ability to select multiple values from dropdown
- disableCloseOnSelect: false, // if multiple is true, this will prevent closing dropdown on selecting an option
- freeSolo: true, // this will allow user to type in the input box or choose from the dropdown
- openOnFocus: true // this will open the dropdown on input focus
- },
- minWidth: 325 // TODO: handle in #486
- },
- {
- field: 'fuelProductionFacilityCountry',
- headerComponent: isDraftOrNew ? RequiredHeader : undefined,
- headerName: t('fuelCode:fuelCodeColLabels.fuelProductionFacilityCountry'),
- cellEditor: AutocompleteCellEditor,
- cellDataType: 'text',
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- cellRenderer: (params) =>
- params.value ||
- (!params.value && Select),
- cellEditorParams: {
- noLabel: true,
- options: [
- ...new Map(
- optionsData.fpLocations.map((location) => [
- location.fuelProductionFacilityCountry,
- location.fuelProductionFacilityCountry
- ])
- ).values()
- ],
- multiple: false, // ability to select multiple values from dropdown
- disableCloseOnSelect: false, // if multiple is true, this will prevent closing dropdown on selecting an option
- freeSolo: true, // this will allow user to type in the input box or choose from the dropdown
- openOnFocus: true // this will open the dropdown on input focus
- },
- minWidth: 325 // TODO: handle in #486
- },
- {
- field: 'facilityNameplateCapacity',
- headerName: t('fuelCode:fuelCodeColLabels.facilityNameplateCapacity'),
- cellEditor: NumberEditor,
- type: 'numericColumn',
- valueFormatter: numberFormatter,
- cellEditorParams: {
- precision: 0,
- min: 0,
- showStepperButtons: false
- },
- minWidth: 290,
- editable: isDraftOrNew
- },
- {
- field: 'facilityNameplateCapacityUnit',
- headerName: t('fuelCode:fuelCodeColLabels.facilityNameplateCapacityUnit'),
- cellEditor: AutocompleteCellEditor,
- cellRenderer: (params) =>
- params.value ||
- (!params.value && Select),
- cellEditorParams: {
- options: optionsData.facilityNameplateCapacityUnits,
- multiple: false, // ability to select multiple values from dropdown
- disableCloseOnSelect: false, // if multiple is true, this will prevent closing dropdown on selecting an option
- freeSolo: false, // this will allow user to type in the input box or choose from the dropdown
- openOnFocus: true // this will open the dropdown on input focus
- },
- suppressKeyboardEvent: (params) => {
- // return true (to suppress) if editing and user hit Enter key
- return params.editing && params.event.key === KEY_ENTER
- },
- cellStyle: (params) => {
- if (params.data.modified && (!params.value || params.value === ''))
- return { borderColor: 'red' }
- },
- minWidth: 300,
- editable: isDraftOrNew
- },
- {
- field: 'feedstockFuelTransportMode',
- headerName: t('fuelCode:fuelCodeColLabels.feedstockFuelTransportMode'),
- cellEditor: AutocompleteCellEditor,
- cellRenderer: (params) =>
- params.value ? (
-
- ) : (
- Select
- ),
- cellRendererParams: {
- disableLink: true
- },
- cellEditorParams: {
- options: optionsData.transportModes.map((obj) => obj.transportMode),
- multiple: true,
- openOnFocus: true,
- disableCloseOnSelect: true
- },
- suppressKeyboardEvent: (params) => params.editing,
- minWidth: 325,
- editable: isDraftOrNew
- },
- {
- field: 'finishedFuelTransportMode',
- headerName: t('fuelCode:fuelCodeColLabels.finishedFuelTransportMode'),
- cellEditor: AutocompleteCellEditor,
- cellRenderer: (params) =>
- params.value ? (
-
- ) : (
- Select
- ),
- cellRendererParams: {
- disableLink: true
- },
- cellEditorParams: {
- options: optionsData.transportModes.map((obj) => obj.transportMode),
- multiple: true,
- openOnFocus: true,
- disableCloseOnSelect: true
- },
- suppressKeyboardEvent: (params) => params.editing,
- minWidth: 325,
- editable: isDraftOrNew
- },
- {
- field: 'formerCompany',
- headerName: t('fuelCode:fuelCodeColLabels.formerCompany'),
- cellEditor: 'agTextCellEditor',
- cellDataType: 'text',
- minWidth: 300,
- editable: isDraftOrNew
- },
- {
- field: 'notes',
- headerName: t('fuelCode:fuelCodeColLabels.notes'),
- cellEditor: 'agTextCellEditor',
- cellDataType: 'text',
- minWidth: 600,
- editable: isDraftOrNew
- }
- ],
-
- defaultColDef: {
- editable: true,
- resizable: true,
- filter: true,
- floatingFilter: false,
- sortable: false,
- singleClickEdit: true
- }
-}
diff --git a/frontend/src/views/Notifications/NotificationMenu/NotificationMenu.jsx b/frontend/src/views/Notifications/NotificationMenu/NotificationMenu.jsx
new file mode 100644
index 000000000..a6c2974de
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/NotificationMenu.jsx
@@ -0,0 +1,76 @@
+import BCBox from '@/components/BCBox'
+import {
+ NOTIFICATIONS,
+ NOTIFICATIONS_SETTINGS
+} from '@/constants/routes/routes'
+import breakpoints from '@/themes/base/breakpoints'
+import { NotificationTabPanel } from './components/NotificationTabPanel'
+import { AppBar, Tab, Tabs } from '@mui/material'
+import { PropTypes } from 'prop-types'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useNavigate } from 'react-router-dom'
+import { Notifications, NotificationSettings } from '.'
+
+function a11yProps(index) {
+ return {
+ id: `full-width-tab-${index}`,
+ 'aria-controls': `full-width-notifications-tabs-${index}`
+ }
+}
+
+export function NotificationMenu({ tabIndex }) {
+ const { t } = useTranslation(['notifications'])
+ const [tabsOrientation, setTabsOrientation] = useState('horizontal')
+ const navigate = useNavigate()
+ const paths = useMemo(() => [NOTIFICATIONS, NOTIFICATIONS_SETTINGS], [])
+
+ useEffect(() => {
+ // A function that sets the orientation state of the tabs.
+ function handleTabsOrientation() {
+ return window.innerWidth < breakpoints.values.lg
+ ? setTabsOrientation('vertical')
+ : setTabsOrientation('horizontal')
+ }
+
+ // The event listener that's calling the handleTabsOrientation function when resizing the window.
+ window.addEventListener('resize', handleTabsOrientation)
+
+ // Call the handleTabsOrientation function to set the state with the initial value.
+ handleTabsOrientation()
+
+ // Remove event listener on cleanup
+ return () => window.removeEventListener('resize', handleTabsOrientation)
+ }, [tabsOrientation])
+
+ const handleSetTabValue = (event, newValue) => {
+ navigate(paths[newValue])
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+NotificationMenu.propTypes = {
+ tabIndex: PropTypes.number.isRequired
+}
diff --git a/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx
new file mode 100644
index 000000000..74af3935c
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx
@@ -0,0 +1,37 @@
+import { useCurrentUser } from '@/hooks/useCurrentUser'
+import NotificationSettingsForm from './NotificationSettingsForm'
+
+const BCeIDNotificationSettings = () => {
+ const { data: currentUser } = useCurrentUser()
+
+ // Categories for BCeID users
+ const categories = {
+ 'bceid.categories.transfers': {
+ title: 'bceid.categories.transfers.title',
+ BCEID__TRANSFER__PARTNER_ACTIONS:
+ 'bceid.categories.transfers.partnerActions',
+ BCEID__TRANSFER__DIRECTOR_DECISION:
+ 'bceid.categories.transfers.directorDecision'
+ },
+ 'bceid.categories.initiativeAgreements': {
+ title: 'bceid.categories.initiativeAgreements.title',
+ BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL:
+ 'bceid.categories.initiativeAgreements.directorApproval'
+ },
+ 'bceid.categories.complianceReports': {
+ title: 'bceid.categories.complianceReports.title',
+ BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT:
+ 'bceid.categories.complianceReports.directorAssessment'
+ }
+ }
+
+ return (
+
+ )
+}
+
+export default BCeIDNotificationSettings
diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx
new file mode 100644
index 000000000..731dedd68
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx
@@ -0,0 +1,34 @@
+import NotificationSettingsForm from './NotificationSettingsForm'
+
+const IDIRAnalystNotificationSettings = () => {
+ // Categories for IDIR Analyst
+ const categories = {
+ 'idirAnalyst.categories.transfers': {
+ title: 'idirAnalyst.categories.transfers.title',
+ IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW:
+ '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'
+ },
+ 'idirAnalyst.categories.initiativeAgreements': {
+ title: 'idirAnalyst.categories.initiativeAgreements.title',
+ IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST:
+ 'idirAnalyst.categories.initiativeAgreements.returnedToAnalyst'
+ },
+ 'idirAnalyst.categories.complianceReports': {
+ title: 'idirAnalyst.categories.complianceReports.title',
+ IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW:
+ 'idirAnalyst.categories.complianceReports.submittedForReview',
+ IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION:
+ 'idirAnalyst.categories.complianceReports.managerRecommendation',
+ IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION:
+ 'idirAnalyst.categories.complianceReports.directorDecision'
+ }
+ }
+
+ return
+}
+
+export default IDIRAnalystNotificationSettings
diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx
new file mode 100644
index 000000000..ee76d9958
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx
@@ -0,0 +1,20 @@
+import NotificationSettingsForm from './NotificationSettingsForm'
+
+const IDIRComplianceManagerNotificationSettings = () => {
+ // Categories for IDIR Compliance Manager
+ const categories = {
+ 'idirComplianceManager.categories.complianceReports': {
+ title: 'idirComplianceManager.categories.complianceReports.title',
+ IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW:
+ 'idirComplianceManager.categories.complianceReports.submittedForReview',
+ IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION:
+ 'idirComplianceManager.categories.complianceReports.analystRecommendation',
+ IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT:
+ 'idirComplianceManager.categories.complianceReports.directorAssessment'
+ }
+ }
+
+ return
+}
+
+export default IDIRComplianceManagerNotificationSettings
diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx
new file mode 100644
index 000000000..2c5e366d9
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx
@@ -0,0 +1,26 @@
+import NotificationSettingsForm from './NotificationSettingsForm'
+
+const IDIRDirectorNotificationSettings = () => {
+ // Categories for IDIR Director
+ const categories = {
+ 'idirDirector.categories.transfers': {
+ title: 'idirDirector.categories.transfers.title',
+ IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION:
+ 'idirDirector.categories.transfers.analystRecommendation'
+ },
+ 'idirDirector.categories.initiativeAgreements': {
+ title: 'idirDirector.categories.initiativeAgreements.title',
+ IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION:
+ 'idirDirector.categories.initiativeAgreements.analystRecommendation'
+ },
+ 'idirDirector.categories.complianceReports': {
+ title: 'idirDirector.categories.complianceReports.title',
+ IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION:
+ 'idirDirector.categories.complianceReports.managerRecommendation'
+ }
+ }
+
+ return
+}
+
+export default IDIRDirectorNotificationSettings
diff --git a/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettings.jsx
new file mode 100644
index 000000000..17e541aca
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettings.jsx
@@ -0,0 +1,34 @@
+import { roles } from '@/constants/roles'
+import { Role } from '@/components/Role'
+
+// Import user-type-specific components
+import BCeIDNotificationSettings from './BCeIDNotificationSettings'
+import IDIRAnalystNotificationSettings from './IDIRAnalystNotificationSettings'
+import IDIRComplianceManagerNotificationSettings from './IDIRComplianceManagerNotificationSettings'
+import IDIRDirectorNotificationSettings from './IDIRDirectorNotificationSettings'
+
+export const NotificationSettings = () => {
+ return (
+ <>
+ {/* BCeID User */}
+
+
+
+
+ {/* IDIR Director */}
+
+
+
+
+ {/* IDIR Compliance Manager */}
+
+
+
+
+ {/* IDIR Analyst */}
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx
new file mode 100644
index 000000000..6fd482744
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx
@@ -0,0 +1,479 @@
+import React, { useEffect, useState } from 'react'
+import PropTypes from 'prop-types'
+import { useForm, FormProvider, Controller } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+import {
+ Checkbox,
+ TextField,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableRow,
+ InputLabel
+} from '@mui/material'
+import { yupResolver } from '@hookform/resolvers/yup'
+import * as Yup from 'yup'
+import Loading from '@/components/Loading'
+import {
+ useNotificationSubscriptions,
+ useCreateSubscription,
+ useDeleteSubscription,
+ useUpdateNotificationsEmail
+} from '@/hooks/useNotifications'
+import {
+ notificationTypes,
+ notificationChannels
+} from '@/constants/notificationTypes'
+import { useNavigate } from 'react-router-dom'
+import { ROUTES } from '@/constants/routes'
+import MailIcon from '@mui/icons-material/Mail'
+import NotificationsIcon from '@mui/icons-material/Notifications'
+import BCButton from '@/components/BCButton'
+import BCBox from '@/components/BCBox'
+import BCAlert from '@/components/BCAlert'
+import BCTypography from '@/components/BCTypography'
+
+const NotificationSettingsForm = ({
+ categories,
+ showEmailField,
+ initialEmail
+}) => {
+ const { t } = useTranslation(['notifications'])
+ const navigate = useNavigate()
+ const [isFormLoading, setIsFormLoading] = useState(false)
+ const [message, setMessage] = useState('')
+ const { data: subscriptionsData, isLoading: isSubscriptionsLoading } =
+ useNotificationSubscriptions()
+ const createSubscription = useCreateSubscription()
+ const deleteSubscription = useDeleteSubscription()
+ const updateNotificationsEmail = useUpdateNotificationsEmail()
+
+ // Validation schema
+ const validationSchema = Yup.object().shape({
+ ...(showEmailField && {
+ notificationEmail: Yup.string()
+ .email(t('errors.invalidEmail'))
+ .required(t('errors.emailRequired'))
+ })
+ })
+
+ const formMethods = useForm({
+ resolver: yupResolver(validationSchema),
+ mode: 'onChange'
+ })
+
+ const { handleSubmit, control, setValue } = formMethods
+
+ useEffect(() => {
+ if (subscriptionsData) {
+ subscriptionsData.forEach((subscription) => {
+ const { notificationTypeName, notificationChannelName, isEnabled } =
+ subscription
+ const fieldName = `${notificationTypeName}_${notificationChannelName}`
+ setValue(fieldName, isEnabled)
+ })
+ }
+ if (showEmailField && initialEmail) {
+ setValue('notificationEmail', initialEmail)
+ }
+ }, [subscriptionsData, showEmailField, initialEmail, setValue])
+
+ const handleCheckboxChange = async (
+ notificationTypeName,
+ notificationChannelName,
+ isChecked
+ ) => {
+ setIsFormLoading(true)
+ setMessage('') // Clear any existing messages
+ try {
+ if (isChecked) {
+ // Create the subscription
+ await createSubscription.mutateAsync({
+ notificationTypeName,
+ notificationChannelName,
+ isEnabled: true
+ })
+ } else {
+ // Find the subscription ID
+ const subscription = subscriptionsData.find(
+ (sub) =>
+ sub.notificationTypeName === notificationTypeName &&
+ sub.notificationChannelName === notificationChannelName
+ )
+ if (subscription) {
+ // Delete the subscription
+ await deleteSubscription.mutateAsync(
+ subscription.notificationChannelSubscriptionId
+ )
+ }
+ }
+ setMessage(t('messages.subscriptionUpdated'))
+ } catch (error) {
+ setMessage(t('errors.operationFailed'))
+ } finally {
+ setIsFormLoading(false)
+ }
+ }
+
+ const onSubmit = async (data) => {
+ setIsFormLoading(true)
+ setMessage('') // Clear any existing messages
+ try {
+ if (showEmailField) {
+ // BCeID user, save the email address
+ await updateNotificationsEmail.mutateAsync({
+ notifications_email: data.notificationEmail
+ })
+ setMessage(t('messages.emailSaved'))
+ }
+ } catch (err) {
+ setMessage(t('errors.operationFailed'))
+ } finally {
+ setIsFormLoading(false)
+ }
+ }
+
+ if (isSubscriptionsLoading) {
+ return
+ }
+
+ return (
+
+ {isFormLoading && (
+
+ )}
+
+
+ {t('title.ConfigureNotifications')}
+
+
+
+
+
+ {/* Email Notification */}
+
+
+
+ {t('emailNotification')}
+
+
+
+ {/* In-App Notification */}
+
+
+
+ {t('inAppNotification')}
+
+
+
+
+
+
+
+
+ {/* Success/Error Message */}
+ {message && (
+
+
+ {message}
+
+
+ )}
+
+ )
+}
+
+NotificationSettingsForm.propTypes = {
+ categories: PropTypes.object.isRequired,
+ showEmailField: PropTypes.bool,
+ initialEmail: PropTypes.string
+}
+
+NotificationSettingsForm.defaultProps = {
+ showEmailField: false,
+ initialEmail: ''
+}
+
+export default NotificationSettingsForm
diff --git a/frontend/src/views/Notifications/NotificationMenu/components/NotificationTabPanel.jsx b/frontend/src/views/Notifications/NotificationMenu/components/NotificationTabPanel.jsx
new file mode 100644
index 000000000..7c1669dd2
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/components/NotificationTabPanel.jsx
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types'
+import BCBox from '@/components/BCBox'
+
+export function NotificationTabPanel(props) {
+ const { children, value, index, ...other } = props
+
+ return (
+
+ {value === index && {children}}
+
+ )
+}
+
+NotificationTabPanel.propTypes = {
+ children: PropTypes.node,
+ index: PropTypes.number.isRequired,
+ value: PropTypes.number.isRequired
+}
diff --git a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx
new file mode 100644
index 000000000..13d413031
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx
@@ -0,0 +1,14 @@
+import { useTranslation } from 'react-i18next'
+import BCTypography from '@/components/BCTypography'
+
+export const Notifications = () => {
+ const { t } = useTranslation(['notifications'])
+
+ return (
+ <>
+
+ {t('title.Notifications')}
+
+ >
+ )
+}
diff --git a/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationSettingsForm.test.jsx b/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationSettingsForm.test.jsx
new file mode 100644
index 000000000..b2190e1a1
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationSettingsForm.test.jsx
@@ -0,0 +1,153 @@
+import { vi, describe, it, expect, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import NotificationSettingsForm from '../NotificationSettingsForm'
+import { ThemeProvider } from '@mui/material/styles'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import theme from '@/themes'
+import * as useNotificationsHook from '@/hooks/useNotifications'
+import { MemoryRouter } from 'react-router-dom'
+
+// Updated mock translation function with meaningful translations
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key) => {
+ const translations = {
+ notificationsEmail: 'Notifications Email',
+ 'title.ConfigureNotifications': 'Configure Notifications',
+ 'general.title': 'General Notifications',
+ 'notifications:notificationType1': 'Notification Type 1',
+ 'notifications:notificationType2': 'Notification Type 2',
+ saveButton: 'Save',
+ emailNotification: 'Email Notification',
+ inAppNotification: 'In-App Notification'
+ // Add other translations as needed
+ }
+ return translations[key] || key
+ },
+ i18n: { language: 'en' }
+ })
+}))
+
+vi.mock('@/constants/notificationTypes', () => ({
+ notificationTypes: {
+ notificationType1: 'NOTIFICATION_TYPE_1',
+ notificationType2: 'NOTIFICATION_TYPE_2'
+ },
+ notificationChannels: {
+ EMAIL: 'EMAIL',
+ IN_APP: 'IN_APP'
+ }
+}))
+
+const customRender = (ui, options = {}) => {
+ const queryClient = new QueryClient()
+ const AllTheProviders = ({ children }) => (
+
+
+ {children}
+
+
+ )
+
+ return render(ui, { wrapper: AllTheProviders, ...options })
+}
+
+describe('NotificationSettingsForm Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders correctly', () => {
+ // Mock data
+ const categories = {
+ general: {
+ title: 'general.title',
+ notificationType1: 'notifications:notificationType1',
+ notificationType2: 'notifications:notificationType2'
+ }
+ }
+
+ const subscriptionsData = [
+ {
+ notificationTypeName: 'notificationType1',
+ notificationChannelName: 'EMAIL',
+ isEnabled: true,
+ notificationChannelSubscriptionId: '1'
+ },
+ {
+ notificationTypeName: 'notificationType2',
+ notificationChannelName: 'IN_APP',
+ isEnabled: true,
+ notificationChannelSubscriptionId: '2'
+ }
+ ]
+
+ vi.spyOn(
+ useNotificationsHook,
+ 'useNotificationSubscriptions'
+ ).mockReturnValue({
+ data: subscriptionsData,
+ isLoading: false
+ })
+
+ vi.spyOn(useNotificationsHook, 'useCreateSubscription').mockReturnValue({
+ mutateAsync: vi.fn()
+ })
+
+ vi.spyOn(useNotificationsHook, 'useDeleteSubscription').mockReturnValue({
+ mutateAsync: vi.fn()
+ })
+
+ vi.spyOn(
+ useNotificationsHook,
+ 'useUpdateNotificationsEmail'
+ ).mockReturnValue({
+ mutateAsync: vi.fn()
+ })
+
+ customRender()
+
+ expect(screen.getByText('Configure Notifications')).toBeInTheDocument()
+ expect(screen.getByText('General Notifications')).toBeInTheDocument()
+ expect(screen.getByText('Notification Type 1')).toBeInTheDocument()
+ expect(screen.getByText('Notification Type 2')).toBeInTheDocument()
+ })
+
+ it('handles checkbox changes correctly', async () => {
+ const categories = {
+ general: {
+ title: 'general.title',
+ notificationType1: 'notifications:notificationType1'
+ }
+ }
+
+ const subscriptionsData = []
+
+ const mockMutateAsync = vi.fn()
+
+ vi.spyOn(
+ useNotificationsHook,
+ 'useNotificationSubscriptions'
+ ).mockReturnValue({
+ data: subscriptionsData,
+ isLoading: false
+ })
+
+ vi.spyOn(useNotificationsHook, 'useCreateSubscription').mockReturnValue({
+ mutateAsync: mockMutateAsync
+ })
+
+ vi.spyOn(useNotificationsHook, 'useDeleteSubscription').mockReturnValue({
+ mutateAsync: mockMutateAsync
+ })
+
+ customRender()
+
+ const checkbox = screen.getAllByRole('checkbox')[0]
+ fireEvent.click(checkbox)
+
+ await waitFor(() => {
+ expect(mockMutateAsync).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationTabPanel.test.jsx b/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationTabPanel.test.jsx
new file mode 100644
index 000000000..7fc65a438
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationTabPanel.test.jsx
@@ -0,0 +1,49 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { NotificationTabPanel } from '../NotificationTabPanel'
+import { ThemeProvider } from '@mui/material/styles'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import theme from '@/themes'
+
+// Custom render function with all necessary providers
+const customRender = (ui, options = {}) => {
+ const queryClient = new QueryClient()
+ const AllTheProviders = ({ children }) => (
+
+ {children}
+
+ )
+
+ return render(ui, { wrapper: AllTheProviders, ...options })
+}
+
+describe('NotificationTabPanel Component', () => {
+ it('renders children when selected', () => {
+ customRender(
+
+ Test Content
+
+ )
+ expect(screen.getByText('Test Content')).toBeInTheDocument()
+ })
+
+ it('does not render children when not selected', () => {
+ customRender(
+
+ Test Content
+
+ )
+ expect(screen.queryByText('Test Content')).not.toBeInTheDocument()
+ })
+
+ it('has correct ARIA attributes', () => {
+ customRender(
+
+ Test Content
+
+ )
+ const panel = screen.getByRole('NotificationTabPanel')
+ expect(panel).toHaveAttribute('aria-labelledby', 'full-width-tab-0')
+ expect(panel).toHaveAttribute('id', 'full-width-NotificationTabPanel-0')
+ })
+})
diff --git a/frontend/src/views/Notifications/NotificationMenu/index.js b/frontend/src/views/Notifications/NotificationMenu/index.js
new file mode 100644
index 000000000..2b919a36b
--- /dev/null
+++ b/frontend/src/views/Notifications/NotificationMenu/index.js
@@ -0,0 +1,3 @@
+export { NotificationMenu } from './NotificationMenu'
+export { Notifications } from './components/Notifications'
+export { NotificationSettings } from './components/NotificationSettings'
diff --git a/frontend/src/views/Notifications/NotificationSettings/NotificationSettings.jsx b/frontend/src/views/Notifications/NotificationSettings/NotificationSettings.jsx
deleted file mode 100644
index 150188e4a..000000000
--- a/frontend/src/views/Notifications/NotificationSettings/NotificationSettings.jsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export const NotificationSettings = () => {
- return NotificationSettings
-}
diff --git a/frontend/src/views/Notifications/__tests__/NotificationSettings.test.jsx b/frontend/src/views/Notifications/__tests__/NotificationSettings.test.jsx
deleted file mode 100644
index 1d74ce903..000000000
--- a/frontend/src/views/Notifications/__tests__/NotificationSettings.test.jsx
+++ /dev/null
@@ -1 +0,0 @@
-describe.todo()
diff --git a/frontend/src/views/Notifications/__tests__/Notifications.test.jsx b/frontend/src/views/Notifications/__tests__/Notifications.test.jsx
deleted file mode 100644
index 1d74ce903..000000000
--- a/frontend/src/views/Notifications/__tests__/Notifications.test.jsx
+++ /dev/null
@@ -1 +0,0 @@
-describe.todo()
diff --git a/frontend/src/views/Notifications/index.js b/frontend/src/views/Notifications/index.js
deleted file mode 100644
index fca16690f..000000000
--- a/frontend/src/views/Notifications/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export { Notifications } from './Notifications'
-export { NotificationSettings } from './NotificationSettings/NotificationSettings'
diff --git a/frontend/src/views/Organizations/ViewOrganization/_schema.js b/frontend/src/views/Organizations/ViewOrganization/_schema.js
index 4c053a038..bc1a33fb8 100644
--- a/frontend/src/views/Organizations/ViewOrganization/_schema.js
+++ b/frontend/src/views/Organizations/ViewOrganization/_schema.js
@@ -49,7 +49,6 @@ export const organizationsColDefs = (t) => [
cellRenderer: OrgStatusRenderer,
cellClass: 'vertical-middle',
floatingFilterComponent: BCColumnSetFilter,
- suppressFloatingFilterButton: true,
floatingFilterComponentParams: {
apiOptionField: 'status',
apiQuery: useOrganizationStatuses,
diff --git a/openshift/templates/backend-bc.yaml b/openshift/templates/backend-bc.yaml
index 1bf2ea52c..9ef2f1781 100644
--- a/openshift/templates/backend-bc.yaml
+++ b/openshift/templates/backend-bc.yaml
@@ -59,16 +59,9 @@ objects:
strategy:
dockerStrategy:
dockerfilePath: ./Dockerfile.openshift
- env:
- - name: ARTIFACTORY_USER
- valueFrom:
- secretKeyRef:
- name: artifacts-default-gyszor
- key: username
- - name: ARTIFACTORY_PASSWORD
- valueFrom:
- secretKeyRef:
- name: artifacts-default-gyszor
- key: password
+ pullSecret:
+ name: artifacts-pull-default-gyszor
+ forcePull: true
+ noCache: true
type: Docker
triggers: []
\ No newline at end of file
diff --git a/openshift/templates/frontend-bc.yaml b/openshift/templates/frontend-bc.yaml
index 24daec0ae..f9e7571d7 100644
--- a/openshift/templates/frontend-bc.yaml
+++ b/openshift/templates/frontend-bc.yaml
@@ -59,16 +59,9 @@ objects:
strategy:
dockerStrategy:
dockerfilePath: ./Dockerfile.openshift
- env:
- - name: ARTIFACTORY_USER
- valueFrom:
- secretKeyRef:
- name: artifacts-default-gyszor
- key: username
- - name: ARTIFACTORY_PASSWORD
- valueFrom:
- secretKeyRef:
- name: artifacts-default-gyszor
- key: password
+ pullSecret:
+ name: artifacts-pull-default-gyszor
+ forcePull: true
+ noCache: true
type: Docker
triggers: []
\ No newline at end of file