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 ( -
-
-
- - Government of B.C. - -
-
- -
-
-
-

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')} + + + + + +
+ + + + {Object.entries(categories).map( + ([categoryKey, category], index) => ( + + {/* Category Header */} + + + + + + + + + + {t(`${categoryKey}.title`)} + + + + {/* Notifications */} + {Object.entries(category) + .filter(([key]) => key !== 'title') + .map(([notificationTypeName, notificationLabelKey]) => { + const notificationTypeKey = notificationTypeName + const notificationTypeId = + notificationTypes[notificationTypeName] + if (!notificationTypeId) return null + + return ( + + {/* Email Checkbox */} + + ( + { + field.onChange(e) + handleCheckboxChange( + notificationTypeKey, + notificationChannels.EMAIL, + e.target.checked + ) + }} + color="primary" + disabled={isFormLoading} // Disable during loading + /> + )} + /> + + {/* In-App Checkbox */} + + ( + { + field.onChange(e) + handleCheckboxChange( + notificationTypeKey, + notificationChannels.IN_APP, + e.target.checked + ) + }} + color="primary" + disabled={isFormLoading} // Disable during loading + /> + )} + /> + + {/* Label */} + + {t(notificationLabelKey)} + + + ) + })} + + ) + )} + {/* Submit Button and Email Field */} + + + {showEmailField && ( + + {t('saveButton')} + + )} + + + {showEmailField && ( + + + {t('notificationsEmail')}: + + ( + + )} + /> + + )} + + + +
+
+
+
+ + {/* 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 ( + + ) +} + +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