From 1ffb4f0a79e492e145c283c1e214c556f34e7bb7 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Sun, 15 Dec 2024 05:47:47 -0800 Subject: [PATCH 1/8] initial draft --- .../versions/2024-12-13-12-44_62bc9695a764.py | 44 ++++ .../notification/NotificationMessage.py | 4 + .../web/api/initiative_agreement/services.py | 11 +- backend/lcfs/web/api/notification/repo.py | 143 ++++++++++++- backend/lcfs/web/api/notification/schema.py | 35 ++- backend/lcfs/web/api/notification/services.py | 54 ++++- backend/lcfs/web/api/notification/views.py | 54 ++++- .../src/assets/locales/en/notifications.json | 21 +- .../components/BCDataGrid/BCGridViewer.jsx | 4 +- .../components/Renderers/ActionsRenderer2.jsx | 2 +- frontend/src/constants/routes/apiRoutes.js | 2 + frontend/src/hooks/useNotifications.js | 48 +++++ frontend/src/themes/base/globals.js | 9 +- .../AdminMenu/components/UserLoginHistory.jsx | 1 - .../components/Notifications.jsx | 200 +++++++++++++++++- .../NotificationMenu/components/_schema.jsx | 45 ++++ 16 files changed, 648 insertions(+), 29 deletions(-) create mode 100644 backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx diff --git a/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py b/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py new file mode 100644 index 000000000..160d3b11d --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py @@ -0,0 +1,44 @@ +"""Add type and transaction details to notification messages + +Revision ID: 62bc9695a764 +Revises: 7ae38a8413ab +Create Date: 2024-12-13 12:44:44.348419 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "62bc9695a764" +down_revision = "7ae38a8413ab" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("notification_message", sa.Column("type", sa.Text(), nullable=False)) + op.add_column( + "notification_message", sa.Column("transaction_id", sa.Integer(), nullable=True) + ) + op.create_foreign_key( + op.f("fk_notification_message_transaction_id_transaction"), + "notification_message", + "transaction", + ["transaction_id"], + ["transaction_id"], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("fk_notification_message_transaction_id_transaction"), + "notification_message", + type_="foreignkey", + ) + op.drop_column("notification_message", "transaction_id") + op.drop_column("notification_message", "type") + # ### end Alembic commands ### diff --git a/backend/lcfs/db/models/notification/NotificationMessage.py b/backend/lcfs/db/models/notification/NotificationMessage.py index b28919330..a339da56e 100644 --- a/backend/lcfs/db/models/notification/NotificationMessage.py +++ b/backend/lcfs/db/models/notification/NotificationMessage.py @@ -20,6 +20,7 @@ class NotificationMessage(BaseModel, Auditable): is_warning = Column(Boolean, default=False) is_error = Column(Boolean, default=False) is_archived = Column(Boolean, default=False) + type = Column(Text, nullable=False) message = Column(Text, nullable=False) related_organization_id = Column( @@ -32,12 +33,15 @@ class NotificationMessage(BaseModel, Auditable): notification_type_id = Column( Integer, ForeignKey("notification_type.notification_type_id") ) + transaction_id = Column(Integer, ForeignKey("transaction.transaction_id"), nullable=True) # Models not created yet # related_transaction_id = Column(Integer,ForeignKey('')) # related_document_id = Column(Integer, ForeignKey('document.id')) # related_report_id = Column(Integer, ForeignKey('compliance_report.id')) + # Relationships + related_transaction = relationship("Transaction") related_organization = relationship( "Organization", back_populates="notification_messages" ) diff --git a/backend/lcfs/web/api/initiative_agreement/services.py b/backend/lcfs/web/api/initiative_agreement/services.py index b7697f2a4..93fe6df58 100644 --- a/backend/lcfs/web/api/initiative_agreement/services.py +++ b/backend/lcfs/web/api/initiative_agreement/services.py @@ -212,12 +212,13 @@ async def director_approve_initiative_agreement( async def _perform_notificaiton_call(self, ia, re_recommended=False): """Send notifications based on the current status of the transfer.""" - notifications = INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER.get( - ia.current_status.status if not re_recommended else "Return to analyst", - None, - ) + status = ia.current_status.status if not re_recommended else "Return to analyst" + status_val = (status.value if isinstance(status, InitiativeAgreementStatusEnum) else status).lower() + notifications = INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER.get(status, None) notification_data = NotificationMessageSchema( - message=f"Initiative Agreement {ia.initiative_agreement_id} has been {ia.current_status.status}", + type=f"Initiative agreement {status_val}", + transaction_id=ia.transaction_id, + message=f"Initiative Agreement {ia.initiative_agreement_id} has been {status_val}", related_organization_id=ia.to_organization_id, origin_user_profile_id=self.request.user.user_profile_id, ) diff --git a/backend/lcfs/web/api/notification/repo.py b/backend/lcfs/web/api/notification/repo.py index ec32f9716..75c9cf05b 100644 --- a/backend/lcfs/web/api/notification/repo.py +++ b/backend/lcfs/web/api/notification/repo.py @@ -6,19 +6,25 @@ ChannelEnum, ) from lcfs.db.models.user import UserProfile -from lcfs.web.api.base import NotificationTypeEnum +from lcfs.db.models.user.UserRole import UserRole +from lcfs.web.api.base import ( + NotificationTypeEnum, + PaginationRequestSchema, + validate_pagination, +) import structlog -from typing import List, Optional +from typing import List, Optional, Sequence from fastapi import Depends from lcfs.db.dependencies import get_async_db_session from lcfs.web.exception.exceptions import DataNotFoundException -from sqlalchemy import delete, or_, select, func +from sqlalchemy import delete, or_, select, func, update from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import selectinload, joinedload from lcfs.web.core.decorators import repo_handler +from sqlalchemy import and_ logger = structlog.get_logger(__name__) @@ -66,8 +72,15 @@ async def get_notification_messages_by_user( Retrieve all notification messages for a user """ # Start building the query - query = select(NotificationMessage).where( - NotificationMessage.related_user_profile_id == user_profile_id + query = ( + select(NotificationMessage) + .options( + joinedload(NotificationMessage.related_organization), + joinedload(NotificationMessage.origin_user_profile) + .joinedload(UserProfile.user_roles) + .joinedload(UserRole.role), + ) + .where(NotificationMessage.related_user_profile_id == user_profile_id) ) # Apply additional filter for `is_read` if provided @@ -76,7 +89,82 @@ async def get_notification_messages_by_user( # Execute the query and retrieve the results result = await self.db.execute(query) - return result.scalars().all() + return result.unique().scalars().all() + + def _apply_notification_filters( + self, pagination: PaginationRequestSchema, conditions: List + ): + for filter in pagination.filters: + filter_value = filter.filter + filter_option = filter.type + filter_type = filter.filter_type + + # Handle date filters + if filter.filter_type == "date": + filter_value = [] + if filter.date_from: + filter_value.append(filter.date_from) + if filter.date_to: + filter_value.append(filter.date_to) + if not filter_value: + continue # Skip if no valid date is provided + + return conditions + + @repo_handler + async def get_paginated_notification_messages( + self, user_id, pagination: PaginationRequestSchema + ) -> tuple[Sequence[NotificationMessage], int]: + """ + Queries notification messages from the database with optional filters. Supports pagination and sorting. + + Args: + pagination (dict): Pagination and sorting parameters. + + Returns: + List[NotificationSchema]: A list of notification messages matching the query. + """ + conditions = [NotificationMessage.related_user_profile_id == user_id] + pagination = validate_pagination(pagination) + + if pagination.filters: + self._apply_notification_filters(pagination, conditions) + + offset = 0 if (pagination.page < 1) else (pagination.page - 1) * pagination.size + limit = pagination.size + # Start building the query + query = ( + select(NotificationMessage) + .options( + joinedload(NotificationMessage.related_organization), + joinedload(NotificationMessage.origin_user_profile) + .joinedload(UserProfile.user_roles) + .joinedload(UserRole.role), + ) + .where(and_(*conditions)) + ) + + # Apply sorting + if not pagination.sort_orders: + query = query.order_by(NotificationMessage.create_date.desc()) + # for order in pagination.sort_orders: + # direction = asc if order.direction == "asc" else desc + # if order.field == "status": + # field = getattr(FuelCodeStatus, "status") + # elif order.field == "prefix": + # field = getattr(FuelCodePrefix, "prefix") + # else: + # field = getattr(FuelCode, order.field) + # query = query.order_by(direction(field)) + + # Execute the count query to get the total count + count_query = query.with_only_columns(func.count()).order_by(None) + total_count = (await self.db.execute(count_query)).scalar() + + # Execute the main query to retrieve all notification_messages + result = await self.db.execute(query.offset(offset).limit(limit)) + notification_messages = result.unique().scalars().all() + return notification_messages, total_count @repo_handler async def get_notification_message_by_id( @@ -136,6 +224,20 @@ async def delete_notification_message(self, notification_id: int): await self.db.execute(query) await self.db.flush() + @repo_handler + async def delete_notification_messages(self, user_id, notification_ids: List[int]): + """ + Delete a notification_message by id + """ + query = delete(NotificationMessage).where( + and_( + NotificationMessage.notification_message_id.in_(notification_ids), + NotificationMessage.related_user_profile_id == user_id, + ) + ) + await self.db.execute(query) + await self.db.flush() + @repo_handler async def mark_notification_as_read( self, notification_id @@ -156,6 +258,31 @@ async def mark_notification_as_read( return notification + @repo_handler + async def mark_notifications_as_read( + self, user_id: int, notification_ids: List[int] + ): + """ + Mark notification messages as read for a user + """ + if not notification_ids: + return [] + + stmt = ( + update(NotificationMessage) + .where( + and_( + NotificationMessage.notification_message_id.in_(notification_ids), + NotificationMessage.related_user_profile_id == user_id, + ) + ) + .values(is_read=True) + ) + await self.db.execute(stmt) + await self.db.flush() + + return notification_ids + @repo_handler async def create_notification_channel_subscription( self, notification_channel_subscription: NotificationChannelSubscription @@ -291,7 +418,7 @@ async def get_subscribed_users_by_channel( NotificationChannel.channel_name == channel.value, or_( UserProfile.organization_id == organization_id, - UserProfile.organization_id.is_(None), + UserProfile.organization_id.is_(None), ), ) ) diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index 0176b9bdd..afe859f04 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any, Dict, List, Optional from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatusEnum @@ -5,7 +6,31 @@ InitiativeAgreementStatusEnum, ) from lcfs.db.models.transfer.TransferStatus import TransferStatusEnum -from lcfs.web.api.base import BaseSchema, NotificationTypeEnum +from lcfs.web.api.base import BaseSchema, NotificationTypeEnum, PaginationResponseSchema +from pydantic import computed_field, field_validator + + +class OrganizationSchema(BaseSchema): + organization_id: int + name: str + + +class UserProfileSchema(BaseSchema): + first_name: str + last_name: str + organization_id: Optional[int] = None + is_government: bool = False + + @field_validator("is_government", mode="after") + def update_gov_profile(cls, value, info): + if info.data.get("is_government", True): + info.data.update({"first_name": "Government of B.C.", "last_name": ""}) + return value + + @computed_field + @property + def full_name(self) -> str: + return f"{self.first_name} {self.last_name}" class NotificationMessageSchema(BaseSchema): @@ -14,9 +39,14 @@ class NotificationMessageSchema(BaseSchema): is_archived: Optional[bool] = False is_warning: Optional[bool] = False is_error: Optional[bool] = False + type: Optional[str] = None message: Optional[str] = None related_organization_id: Optional[int] = None + related_organization: Optional[OrganizationSchema] = None + transaction_id: Optional[int] = None + create_date: Optional[datetime] = None origin_user_profile_id: Optional[int] = None + origin_user_profile: Optional[UserProfileSchema] = None related_user_profile_id: Optional[int] = None notification_type_id: Optional[int] = None deleted: Optional[bool] = None @@ -52,6 +82,9 @@ class DeleteSubscriptionSchema(BaseSchema): class DeleteNotificationChannelSubscriptionResponseSchema(BaseSchema): message: str +class NotificationsSchema(BaseSchema): + notifications: List[NotificationMessageSchema] = [] + pagination: PaginationResponseSchema = None class NotificationRequestSchema(BaseSchema): notification_types: List[NotificationTypeEnum] diff --git a/backend/lcfs/web/api/notification/services.py b/backend/lcfs/web/api/notification/services.py index e64848823..a36796af7 100644 --- a/backend/lcfs/web/api/notification/services.py +++ b/backend/lcfs/web/api/notification/services.py @@ -1,13 +1,18 @@ -from typing import List, Optional, Union +import math +from typing import List, Optional from lcfs.db.models.notification import ( NotificationChannelSubscription, NotificationMessage, ChannelEnum, ) -from lcfs.web.api.base import NotificationTypeEnum +from lcfs.web.api.base import ( + PaginationRequestSchema, + PaginationResponseSchema, +) from lcfs.web.api.email.services import CHESEmailService from lcfs.web.api.notification.schema import ( NotificationRequestSchema, + NotificationsSchema, SubscriptionSchema, NotificationMessageSchema, ) @@ -47,6 +52,51 @@ async def get_notification_messages_by_user_id( for message in notification_models ] + @service_handler + async def get_paginated_notification_messages( + self, user_id: int, pagination: PaginationRequestSchema + ) -> NotificationsSchema: + """ + Retrieve all notifications for a given user with pagination, filtering and sorting. + """ + notifications, total_count = ( + await self.repo.get_paginated_notification_messages(user_id, pagination) + ) + return NotificationsSchema( + pagination=PaginationResponseSchema( + total=total_count, + page=pagination.page, + size=pagination.size, + total_pages=math.ceil(total_count / pagination.size), + ), + notifications=[ + NotificationMessageSchema.model_validate(notification) + for notification in notifications + ], + ) + + @service_handler + async def update_notification_messages( + self, user_id: int, notification_ids: List[int] + ): + """ + Update multiple notifications (mark as read). + """ + await self.repo.mark_notifications_as_read(user_id, notification_ids) + + return notification_ids + + @service_handler + async def delete_notification_messages( + self, user_id: int, notification_ids: List[int] + ): + """ + Delete multiple notifications. + """ + await self.repo.delete_notification_messages(user_id, notification_ids) + + return notification_ids + @service_handler async def get_notification_message_by_id(self, notification_id: int): """ diff --git a/backend/lcfs/web/api/notification/views.py b/backend/lcfs/web/api/notification/views.py index f4f98d0e9..f5caa8695 100644 --- a/backend/lcfs/web/api/notification/views.py +++ b/backend/lcfs/web/api/notification/views.py @@ -3,15 +3,17 @@ """ from typing import Union, List +from lcfs.web.api.base import PaginationRequestSchema from lcfs.web.exception.exceptions import DataNotFoundException import structlog -from fastapi import APIRouter, Body, Depends, HTTPException, Request +from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response from lcfs.db.models.user.Role import RoleEnum from lcfs.web.api.notification.schema import ( DeleteNotificationChannelSubscriptionResponseSchema, DeleteNotificationMessageResponseSchema, DeleteSubscriptionSchema, DeleteNotificationMessageSchema, + NotificationsSchema, SubscriptionSchema, NotificationMessageSchema, NotificationCountSchema, @@ -43,6 +45,56 @@ async def get_notification_messages_by_user_id( ) +@router.post( + "/list", response_model=NotificationsSchema, status_code=status.HTTP_200_OK +) +@view_handler(["*"]) +async def get_notification_messages_by_user_id( + request: Request, + pagination: PaginationRequestSchema = Body(..., embed=False), + response: Response = None, + service: NotificationService = Depends(), +): + """ + Retrieve all notifications of a user with pagination + """ + return await service.get_paginated_notification_messages( + user_id=request.user.user_profile_id, pagination=pagination + ) + + +@router.put("/", response_model=List[int], status_code=status.HTTP_200_OK) +@view_handler(["*"]) +async def update_notification_messages_to_read( + request: Request, + notification_ids: List[int] = Body(..., embed=False), + response: Response = None, + service: NotificationService = Depends(), +): + """ + Update notifications (mark the messages as read) + """ + return await service.update_notification_messages( + request.user.user_profile_id, notification_ids + ) + + +@router.delete("/", response_model=List[int], status_code=status.HTTP_200_OK) +@view_handler(["*"]) +async def delete_notification_messages( + request: Request, + notification_ids: List[int] = Body(..., embed=False), + response: Response = None, + service: NotificationService = Depends(), +): + """ + Delete notification messages + """ + return await service.delete_notification_messages( + request.user.user_profile_id, notification_ids + ) + + @router.get( "/count", response_model=NotificationCountSchema, diff --git a/frontend/src/assets/locales/en/notifications.json b/frontend/src/assets/locales/en/notifications.json index 5f985416c..5d3ac2313 100644 --- a/frontend/src/assets/locales/en/notifications.json +++ b/frontend/src/assets/locales/en/notifications.json @@ -90,5 +90,24 @@ "managerRecommendation": "Compliance manager recommendation" } } - } + }, + "buttonStack": { + "selectAll": "Select all", + "unselectAll": "Unselect all", + "markAsRead": "Mark as read", + "deleteSelected": "Delete selected" + }, + "notificationColLabels": { + "type": "Type", + "date": "Date", + "user": "User", + "transactionId": "Transaction ID", + "organization": "Organization" + }, + "noNotificationsFound": "No notification messages found.", + "noNotificationsSelectedText": "No messages selected.", + "deleteSuccessText": "Successfully deleted selected message(s).", + "deleteErrorText": "An error occurred while deleting the selected message(s).", + "markAsReadSuccessText": "Successfully updated message(s) as read.", + "markAsReadErrorText": "An error occurred while updating the message(s) as read." } diff --git a/frontend/src/components/BCDataGrid/BCGridViewer.jsx b/frontend/src/components/BCDataGrid/BCGridViewer.jsx index 429c3c153..8a5638431 100644 --- a/frontend/src/components/BCDataGrid/BCGridViewer.jsx +++ b/frontend/src/components/BCDataGrid/BCGridViewer.jsx @@ -1,4 +1,4 @@ -import BCAlert from '@/components/BCAlert' +import BCAlert, { FloatingAlert } from '@/components/BCAlert' import BCBox from '@/components/BCBox' import { BCGridBase } from '@/components/BCDataGrid/BCGridBase' import { BCPagination } from '@/components/BCDataGrid/components' @@ -9,6 +9,7 @@ import BCButton from '../BCButton' export const BCGridViewer = ({ gridRef, + alertRef, loading, defaultColDef, columnDefs, @@ -202,6 +203,7 @@ export const BCGridViewer = ({ className="bc-grid-container" data-test="bc-grid-container" > + { .some((cell) => cell.rowIndex === props.node.rowIndex) return ( - + {props.enableDuplicate && ( diff --git a/frontend/src/constants/routes/apiRoutes.js b/frontend/src/constants/routes/apiRoutes.js index c96a65db8..d79d25464 100644 --- a/frontend/src/constants/routes/apiRoutes.js +++ b/frontend/src/constants/routes/apiRoutes.js @@ -73,6 +73,8 @@ export const apiRoutes = { getUserLoginHistories: '/users/login-history', getAuditLogs: '/audit-log/list', getAuditLog: '/audit-log/:auditLogId', + notifications: '/notifications/', + getNotifications: '/notifications/list', getNotificationsCount: '/notifications/count', getNotificationSubscriptions: '/notifications/subscriptions', saveNotificationSubscriptions: '/notifications/subscriptions/save', diff --git a/frontend/src/hooks/useNotifications.js b/frontend/src/hooks/useNotifications.js index 87b286a87..df52f3fa1 100644 --- a/frontend/src/hooks/useNotifications.js +++ b/frontend/src/hooks/useNotifications.js @@ -17,6 +17,54 @@ export const useNotificationsCount = (options) => { }) } +export const useGetNotificationMessages = ( + { page = 1, size = 10, sortOrders = [], filters = [] } = {}, + options +) => { + const client = useApiService() + return useQuery({ + queryKey: ['notification-messages', page, size, sortOrders, filters], + queryFn: async () => { + const response = await client.post(apiRoutes.getNotifications, { + page, + size, + sortOrders, + filters + }) + return response.data + }, + ...options + }) +} + +export const useMarkNotificationAsRead = (options) => { + const client = useApiService() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (_ids) => + client.put(apiRoutes.notifications, _ids), + onSettled: () => { + queryClient.invalidateQueries(['notifications-count']) + queryClient.invalidateQueries(['notifications-messages']) + }, + ...options + }) +} + +export const useDeleteNotificationMessages = (options) => { + const client = useApiService() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (_ids) => + client.delete(apiRoutes.notifications, { data: _ids }), + onSettled: () => { + queryClient.invalidateQueries(['notifications-count']) + queryClient.invalidateQueries(['notifications-messages']) + }, + ...options + }) +} + export const useNotificationSubscriptions = (options) => { const client = useApiService() return useQuery({ diff --git a/frontend/src/themes/base/globals.js b/frontend/src/themes/base/globals.js index 4d39236fb..b247a8d51 100644 --- a/frontend/src/themes/base/globals.js +++ b/frontend/src/themes/base/globals.js @@ -88,6 +88,7 @@ const globals = { '--ag-header-column-resize-handle-height': '30%', '--ag-header-column-resize-handle-width': '2px', '--ag-header-column-resize-handle-color': '#dde2eb', + '--ag-material-accent-color': '#003366', '--ag-borders': `1px solid ${grey[700]}`, '--ag-border-color': grey[700], '--ag-odd-row-background-color': rgba(light.main, 0.6), @@ -109,6 +110,10 @@ const globals = { border: 'none', borderBottom: '2px solid #495057' }, + '.row-not-read': { + fontWeight: 700, + color: grey[700] + }, // editor theme for ag-grid quertz theme '.ag-theme-quartz': { '--ag-borders': `0.5px solid ${grey[400]} !important`, @@ -197,10 +202,10 @@ const globals = { color: grey[600], textTransform: 'none', fontSize: pxToRem(14), - padding: '6px 12px', + padding: '6px 12px' }, '.ag-filter-apply-panel-button[data-ref="clearFilterButton"]:hover': { - color: grey[900], + color: grey[900] }, '.MuiPaper-elevation': { diff --git a/frontend/src/views/Admin/AdminMenu/components/UserLoginHistory.jsx b/frontend/src/views/Admin/AdminMenu/components/UserLoginHistory.jsx index 1dbdd618c..3f3fbc452 100644 --- a/frontend/src/views/Admin/AdminMenu/components/UserLoginHistory.jsx +++ b/frontend/src/views/Admin/AdminMenu/components/UserLoginHistory.jsx @@ -31,7 +31,6 @@ export const UserLoginHistory = () => { defaultMinWidth: 50, defaultMaxWidth: 600 }} - rowSelection={{ isRowSelectable: false }} /> diff --git a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx index 13d413031..b3b740c2e 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx @@ -1,14 +1,202 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import BCTypography from '@/components/BCTypography' +import { Stack, Grid } from '@mui/material' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faSquareCheck } from '@fortawesome/free-solid-svg-icons' + +import BCButton from '@/components/BCButton' +import { BCGridViewer } from '@/components/BCDataGrid/BCGridViewer' +import { columnDefs } from './_schema' +import { + useGetNotificationMessages, + useMarkNotificationAsRead, + useDeleteNotificationMessages +} from '@/hooks/useNotifications' export const Notifications = () => { const { t } = useTranslation(['notifications']) + const gridRef = useRef(null) + const alertRef = useRef(null) + const [isAllSelected, setIsAllSelected] = useState(false) + + // react query hooks + const { refetch } = useGetNotificationMessages() + const markAsReadMutation = useMarkNotificationAsRead() + const deleteMutation = useDeleteNotificationMessages() + + // row class rules for unread messages + const rowClassRules = useMemo( + () => ({ + 'row-not-read': (params) => !params.data.isRead + }), + [] + ) + + // Consolidated mutation handler + const handleMutation = useCallback( + (mutation, selectedNotifications, successMessage, errorMessage) => { + if (selectedNotifications.length === 0) { + alertRef.current?.triggerAlert({ + message: t('notifications:noNotificationsSelectedText'), + severity: 'warning' + }) + return + } + + mutation.mutate(selectedNotifications, { + onSuccess: () => { + alertRef.current?.triggerAlert({ + message: t(successMessage), + severity: 'success' + }) + refetch() + }, + onError: (error) => { + alertRef.current?.triggerAlert({ + message: t(errorMessage, { error: error.message }), + severity: 'error' + }) + } + }) + }, + [t, refetch] + ) + + // Toggle selection for visible rows + const toggleSelectVisibleRows = useCallback(() => { + const gridApi = gridRef.current?.api + if (gridApi) { + gridApi.forEachNodeAfterFilterAndSort((node) => { + node.setSelected(!isAllSelected) + }) + setIsAllSelected(!isAllSelected) + } + }, [isAllSelected]) + + // event handlers for delete, markAsRead, and row-level deletes + const handleMarkAsRead = useCallback(() => { + const gridApi = gridRef.current?.api + if (gridApi) { + const selectedNotifications = gridApi + .getSelectedNodes() + .map((node) => node.data.notificationMessageId) + handleMutation( + markAsReadMutation, + selectedNotifications, + 'notifications:markAsReadSuccessText', + 'notifications:markAsReadErrorText' + ) + } + }, [handleMutation, markAsReadMutation]) + + const handleDelete = useCallback(() => { + const gridApi = gridRef.current?.api + if (gridApi) { + const selectedNotifications = gridApi + .getSelectedNodes() + .map((node) => node.data.notificationMessageId) + handleMutation( + deleteMutation, + selectedNotifications, + 'notifications:deleteSuccessText', + 'notifications:deleteErrorText' + ) + } + }, [handleMutation, deleteMutation]) + + const onCellClicked = useCallback( + (params) => { + if ( + params.column.colId === 'action' && + params.event.target.dataset.action + ) { + handleMutation( + deleteMutation, + [params.data.notificationMessageId], + 'notifications:deleteSuccessText', + 'notifications:deleteErrorText' + ) + } + }, + [handleMutation, deleteMutation] + ) + + // toggling selections effect + useEffect(() => { + const gridApi = gridRef.current?.api + if (gridApi) { + const selectionChangedHandler = () => { + const visibleRows = [] + gridApi.forEachNodeAfterFilterAndSort((node) => { + visibleRows.push(node) + }) + const selectedRows = visibleRows.filter((node) => node.isSelected()) + setIsAllSelected( + visibleRows.length > 0 && visibleRows.length === selectedRows.length + ) + } + + gridApi.addEventListener('selectionChanged', selectionChangedHandler) + + return () => { + gridApi.removeEventListener('selectionChanged', selectionChangedHandler) + } + } + }, []) return ( - <> - - {t('title.Notifications')} - - + + + + } + onClick={toggleSelectVisibleRows} + > + {isAllSelected + ? t('notifications:buttonStack.unselectAll') + : t('notifications:buttonStack.selectAll')} + + + {t('notifications:buttonStack.markAsRead')} + + + {t('notifications:buttonStack.deleteSelected')} + + + + ) } diff --git a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx new file mode 100644 index 000000000..dd1a5c022 --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx @@ -0,0 +1,45 @@ +import { dateFormatter } from '@/utils/formatters' +import { actions } from '@/components/BCDataGrid/columns' + +export const columnDefs = (t) => [ + { + ...actions({ enableDelete: true }), + headerName: 'Delete', + pinned: '' + }, + { + colId: 'type', + field: 'type', + headerName: t('notifications:notificationColLabels.type') + }, + { + colId: 'date', + field: 'date', + headerName: t('notifications:notificationColLabels.date'), + valueGetter: (params) => params.data.createDate, + valueFormatter: dateFormatter + }, + { + colId: 'user', + field: 'user', + headerName: t('notifications:notificationColLabels.user'), + valueGetter: (params) => params.data.originUserProfile.fullName.trim() + }, + { + colId: 'transactionId', + field: 'transactionId', + headerName: t('notifications:notificationColLabels.transactionId') + }, + { + colId: 'organization', + field: 'organization', + headerName: t('notifications:notificationColLabels.organization'), + valueGetter: (params) => params.data.relatedOrganization.name + } +] + +export const defaultColDef = { + editable: false, + resizable: true, + sortable: true +} From c4a6cc0bac919c69d4d6004737de3de4d23d78ca Mon Sep 17 00:00:00 2001 From: prv-proton Date: Mon, 16 Dec 2024 10:20:40 -0800 Subject: [PATCH 2/8] updates --- frontend/src/themes/base/globals.js | 2 +- .../components/Notifications.jsx | 45 ++++++++++--------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/frontend/src/themes/base/globals.js b/frontend/src/themes/base/globals.js index b247a8d51..26b4fe3cd 100644 --- a/frontend/src/themes/base/globals.js +++ b/frontend/src/themes/base/globals.js @@ -88,7 +88,7 @@ const globals = { '--ag-header-column-resize-handle-height': '30%', '--ag-header-column-resize-handle-width': '2px', '--ag-header-column-resize-handle-color': '#dde2eb', - '--ag-material-accent-color': '#003366', + '--ag-material-accent-color': grey[700], '--ag-borders': `1px solid ${grey[700]}`, '--ag-border-color': grey[700], '--ag-odd-row-background-color': rgba(light.main, 0.6), diff --git a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx index b3b740c2e..1bb406706 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx @@ -18,6 +18,7 @@ export const Notifications = () => { const gridRef = useRef(null) const alertRef = useRef(null) const [isAllSelected, setIsAllSelected] = useState(false) + const [selectedRowCount, setSelectedRowCount] = useState(0) // react query hooks const { refetch } = useGetNotificationMessages() @@ -31,6 +32,14 @@ export const Notifications = () => { }), [] ) + const selectionColumnDef = useMemo(() => { + return { + sortable: false, + resizable: false, + suppressHeaderMenuButton: true, + headerTooltip: 'Checkboxes indicate selection' + } + }, []) // Consolidated mutation handler const handleMutation = useCallback( @@ -121,28 +130,18 @@ export const Notifications = () => { [handleMutation, deleteMutation] ) - // toggling selections effect - useEffect(() => { + const onSelectionChanged = useCallback(() => { const gridApi = gridRef.current?.api - if (gridApi) { - const selectionChangedHandler = () => { - const visibleRows = [] - gridApi.forEachNodeAfterFilterAndSort((node) => { - visibleRows.push(node) - }) - const selectedRows = visibleRows.filter((node) => node.isSelected()) - setIsAllSelected( - visibleRows.length > 0 && visibleRows.length === selectedRows.length - ) - } - - gridApi.addEventListener('selectionChanged', selectionChangedHandler) - - return () => { - gridApi.removeEventListener('selectionChanged', selectionChangedHandler) - } - } - }, []) + const visibleRows = [] + gridApi.forEachNodeAfterFilterAndSort((node) => { + visibleRows.push(node) + }) + const selectedRows = visibleRows.filter((node) => node.isSelected()) + setSelectedRowCount(selectedRows.length) + setIsAllSelected( + visibleRows.length > 0 && visibleRows.length === selectedRows.length + ) + },[]) return ( @@ -168,6 +167,7 @@ export const Notifications = () => { variant="contained" color="primary" onClick={handleMarkAsRead} + disabled={selectedRowCount === 0} > {t('notifications:buttonStack.markAsRead')} @@ -176,6 +176,7 @@ export const Notifications = () => { variant="outlined" color="error" onClick={handleDelete} + disabled={selectedRowCount === 0} > {t('notifications:buttonStack.deleteSelected')} @@ -196,6 +197,8 @@ export const Notifications = () => { rowSelection={{ mode: 'multiRow' }} rowClassRules={rowClassRules} onCellClicked={onCellClicked} + selectionColumnDef={selectionColumnDef} + onSelectionChanged={onSelectionChanged} /> ) From 1e40528d2697c5cea45f2005bd56078759e9c4c3 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 02:40:07 -0800 Subject: [PATCH 3/8] updates --- .../versions/2024-12-13-12-44_62bc9695a764.py | 2 +- .../api/compliance_report/update_service.py | 53 ++++++----- backend/lcfs/web/api/email/services.py | 51 +++++++---- .../web/api/initiative_agreement/services.py | 25 ++++-- backend/lcfs/web/api/notification/schema.py | 7 ++ backend/lcfs/web/api/transfer/services.py | 88 +++++++++++++------ .../src/components/BCDataGrid/BCGridBase.jsx | 2 +- frontend/src/themes/base/globals.js | 2 +- .../IDIRAnalystNotificationSettings.jsx | 6 +- .../components/NotificationSettingsForm.jsx | 9 +- .../components/Notifications.jsx | 48 +++++++--- .../NotificationMenu/components/_schema.jsx | 43 ++++++++- .../src/views/Notifications/Notifications.jsx | 14 --- 13 files changed, 233 insertions(+), 117 deletions(-) delete mode 100644 frontend/src/views/Notifications/Notifications.jsx diff --git a/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py b/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py index 160d3b11d..44019fc73 100644 --- a/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py +++ b/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "62bc9695a764" -down_revision = "7ae38a8413ab" +down_revision = "5d729face5ab" branch_labels = None depends_on = None diff --git a/backend/lcfs/web/api/compliance_report/update_service.py b/backend/lcfs/web/api/compliance_report/update_service.py index 7e76ea76b..87df1bb50 100644 --- a/backend/lcfs/web/api/compliance_report/update_service.py +++ b/backend/lcfs/web/api/compliance_report/update_service.py @@ -1,3 +1,4 @@ +import json from fastapi import Depends, HTTPException, Request from lcfs.web.api.notification.schema import ( COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER, @@ -48,13 +49,7 @@ async def update_compliance_report( raise DataNotFoundException( f"Compliance report with ID {report_id} not found" ) - - notifications = None - notification_data: NotificationMessageSchema = NotificationMessageSchema( - message=f"Compliance report {report.compliance_report_id} has been updated", - related_organization_id=report.organization_id, - origin_user_profile_id=self.request.user.user_profile_id, - ) + current_status = report_data.status # if we're just returning the compliance report back to either compliance manager or analyst, # then neither history nor any updates to summary is required. if report_data.status in RETURN_STATUSES: @@ -64,19 +59,10 @@ async def update_compliance_report( ) if report_data.status == "Return to analyst": report_data.status = ComplianceReportStatusEnum.Submitted.value - notification_data.message = f"Compliance report {report.compliance_report_id} has been returned to analyst" else: report_data.status = ( ComplianceReportStatusEnum.Recommended_by_analyst.value ) - - notification_data.message = f"Compliance report {report.compliance_report_id} has been returned by director" - notification_data.related_user_profile_id = [ - h.user_profile.user_profile_id - for h in report.history - if h.status.status - == ComplianceReportStatusEnum.Recommended_by_analyst - ][0] else: status_has_changed = report.current_status.status != getattr( ComplianceReportStatusEnum, report_data.status.replace(" ", "_") @@ -91,14 +77,36 @@ async def update_compliance_report( updated_report = await self.repo.update_compliance_report(report) if status_has_changed: await self.handle_status_change(report, new_status.status) - notification_data.message = ( - f"Compliance report {report.compliance_report_id} has been updated" - ) - notifications = COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER.get( - new_status.status - ) # Add history record await self.repo.add_compliance_report_history(report, self.request.user) + + await self._perform_notificaiton_call(report, current_status) + return updated_report + + async def _perform_notificaiton_call(self, cr, status): + """Send notifications based on the current status of the transfer.""" + status_mapper = status.replace(" ", "_") + notifications = COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER.get( + ( + ComplianceReportStatusEnum[status_mapper] + if status_mapper in ComplianceReportStatusEnum.__members__ + else status + ), + None, + ) + message_data = { + "service": "ComplianceReport", + "id": cr.compliance_report_id, + "compliancePeriod": cr.compliance_period.description, + "status": status.lower(), + } + notification_data = NotificationMessageSchema( + type=f"Compliance report {status.lower()}", + transaction_id=cr.transaction_id, + message=json.dumps(message_data), + related_organization_id=cr.organization_id, + origin_user_profile_id=self.request.user.user_profile_id, + ) if notifications and isinstance(notifications, list): await self.notfn_service.send_notification( NotificationRequestSchema( @@ -106,7 +114,6 @@ async def update_compliance_report( notification_data=notification_data, ) ) - return updated_report async def handle_status_change( self, report: ComplianceReport, new_status: ComplianceReportStatusEnum diff --git a/backend/lcfs/web/api/email/services.py b/backend/lcfs/web/api/email/services.py index 8c7dc4cd8..066a7a664 100644 --- a/backend/lcfs/web/api/email/services.py +++ b/backend/lcfs/web/api/email/services.py @@ -23,19 +23,8 @@ class CHESEmailService: def __init__(self, repo: CHESEmailRepository = Depends()): self.repo = repo - - # CHES configuration - self.config = { - "AUTH_URL": settings.ches_auth_url, - "EMAIL_URL": settings.ches_email_url, - "CLIENT_ID": settings.ches_client_id, - "CLIENT_SECRET": settings.ches_client_secret, - "SENDER_EMAIL": settings.ches_sender_email, - "SENDER_NAME": settings.ches_sender_name, - } self._access_token = None self._token_expiry = None - self._validate_configuration() # Update template directory path to the root templates directory template_dir = os.path.join(os.path.dirname(__file__), "templates") @@ -48,9 +37,24 @@ def _validate_configuration(self): """ Validate the CHES configuration to ensure all necessary environment variables are set. """ - missing = [key for key, value in self.config.items() if not value] - if missing: - raise ValueError(f"Missing configuration: {', '.join(missing)}") + missing_configs = [] + + # Check each required CHES configuration setting + if not settings.ches_auth_url: + missing_configs.append("ches_auth_url") + if not settings.ches_email_url: + missing_configs.append("ches_email_url") + if not settings.ches_client_id: + missing_configs.append("ches_client_id") + if not settings.ches_client_secret: + missing_configs.append("ches_client_secret") + if not settings.ches_sender_email: + missing_configs.append("ches_sender_email") + if not settings.ches_sender_name: + missing_configs.append("ches_sender_name") + + if missing_configs: + raise ValueError(f"Missing CHES configuration: {', '.join(missing_configs)}") @service_handler async def send_notification_email( @@ -62,6 +66,9 @@ async def send_notification_email( """ Send an email notification to users subscribed to the specified notification type. """ + # Validate configuration before performing any operations + self._validate_configuration() + # Retrieve subscribed user emails recipient_emails = await self.repo.get_subscribed_user_emails( notification_type.value, organization_id @@ -109,7 +116,7 @@ def _build_email_payload( return { "bcc": recipients, "to": ["Undisclosed recipients"], - "from": f"{self.config['SENDER_NAME']} <{self.config['SENDER_EMAIL']}>", + "from": f"{settings.ches_sender_name} <{settings.ches_sender_email}>", "delayTS": 0, "encoding": "utf-8", "priority": "normal", @@ -124,9 +131,12 @@ async def send_email(self, payload: Dict[str, Any]) -> bool: """ Send an email using CHES. """ + # Validate configuration before performing any operations + self._validate_configuration() + token = await self.get_ches_token() response = requests.post( - self.config["EMAIL_URL"], + settings.ches_email_url, json=payload, headers={ "Authorization": f"Bearer {token}", @@ -142,12 +152,15 @@ async def get_ches_token(self) -> str: """ Retrieve and cache the CHES access token. """ + # Validate configuration before performing any operations + self._validate_configuration() + if self._access_token and datetime.now().timestamp() < self._token_expiry: return self._access_token response = requests.post( - self.config["AUTH_URL"], + settings.ches_auth_url, data={"grant_type": "client_credentials"}, - auth=(self.config["CLIENT_ID"], self.config["CLIENT_SECRET"]), + auth=(settings.ches_client_id, settings.ches_client_secret), timeout=10, ) response.raise_for_status() @@ -158,4 +171,4 @@ async def get_ches_token(self) -> str: "expires_in", 3600 ) logger.info("Retrieved new CHES token.") - return self._access_token + return self._access_token \ No newline at end of file diff --git a/backend/lcfs/web/api/initiative_agreement/services.py b/backend/lcfs/web/api/initiative_agreement/services.py index 93fe6df58..6494d57d0 100644 --- a/backend/lcfs/web/api/initiative_agreement/services.py +++ b/backend/lcfs/web/api/initiative_agreement/services.py @@ -1,3 +1,4 @@ +import json from lcfs.web.api.notification.schema import ( INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER, NotificationMessageSchema, @@ -129,7 +130,7 @@ async def update_initiative_agreement( # Return the updated initiative agreement schema with the returned status flag ia_schema = InitiativeAgreementSchema.from_orm(updated_initiative_agreement) ia_schema.returned = returned - await self._perform_notificaiton_call(ia_schema, re_recommended) + await self._perform_notificaiton_call(updated_initiative_agreement, returned) return ia_schema @service_handler @@ -208,17 +209,27 @@ async def director_approve_initiative_agreement( initiative_agreement.transaction_effective_date = datetime.now().date() await self.repo.refresh_initiative_agreement(initiative_agreement) - await self._perform_notificaiton_call(initiative_agreement) - async def _perform_notificaiton_call(self, ia, re_recommended=False): + async def _perform_notificaiton_call(self, ia, returned=False): """Send notifications based on the current status of the transfer.""" - status = ia.current_status.status if not re_recommended else "Return to analyst" - status_val = (status.value if isinstance(status, InitiativeAgreementStatusEnum) else status).lower() - notifications = INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER.get(status, None) + status = ia.current_status.status if not returned else "Return to analyst" + status_val = ( + status.value + if isinstance(status, InitiativeAgreementStatusEnum) + else status + ).lower() + notifications = INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER.get( + status, None + ) + message_data = { + "service": "InitiativeAgreement", + "id": ia.initiative_agreement_id, + "status": status_val, + } notification_data = NotificationMessageSchema( type=f"Initiative agreement {status_val}", transaction_id=ia.transaction_id, - message=f"Initiative Agreement {ia.initiative_agreement_id} has been {status_val}", + message=json.dumps(message_data), related_organization_id=ia.to_organization_id, origin_user_profile_id=self.request.user.user_profile_id, ) diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index afe859f04..5cf97cb56 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -126,10 +126,14 @@ class NotificationRequestSchema(BaseSchema): TransferStatusEnum.Sent: [ NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, ], + TransferStatusEnum.Rescinded: [ + NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, + ], TransferStatusEnum.Declined: [ NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, ], TransferStatusEnum.Submitted: [ + NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, NotificationTypeEnum.IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW ], TransferStatusEnum.Recommended: [ @@ -143,6 +147,9 @@ class NotificationRequestSchema(BaseSchema): NotificationTypeEnum.BCEID__TRANSFER__DIRECTOR_DECISION, NotificationTypeEnum.IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED, ], + "Return to analyst": [ + NotificationTypeEnum.IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW + ] } INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER = { diff --git a/backend/lcfs/web/api/transfer/services.py b/backend/lcfs/web/api/transfer/services.py index f498d927e..ea1dcd2a2 100644 --- a/backend/lcfs/web/api/transfer/services.py +++ b/backend/lcfs/web/api/transfer/services.py @@ -1,3 +1,4 @@ +import json from lcfs.web.api.notification.schema import ( TRANSFER_STATUS_NOTIFICATION_MAPPER, NotificationMessageSchema, @@ -155,7 +156,6 @@ async def create_transfer( # transfer.transfer_category_id = 1 transfer.current_status = current_status - notifications = TRANSFER_STATUS_NOTIFICATION_MAPPER.get(current_status.status) if current_status.status == TransferStatusEnum.Sent: await self.sign_and_send_from_supplier(transfer) @@ -166,7 +166,7 @@ async def create_transfer( current_status.transfer_status_id, self.request.user.user_profile_id, ) - await self._perform_notificaiton_call(notifications, transfer) + await self._perform_notificaiton_call(transfer, current_status.status) return transfer @service_handler @@ -264,37 +264,67 @@ async def update_transfer(self, transfer_data: TransferCreateSchema) -> Transfer # Finally, update the transfer's status and save the changes transfer.current_status = new_status transfer_result = await self.repo.update_transfer(transfer) - await self._perform_notificaiton_call(transfer_result) + await self._perform_notificaiton_call( + transfer, + status=( + new_status.status + if status_has_changed or re_recommended + else "Return to analyst" + ), + ) return transfer_result - async def _perform_notificaiton_call(self, transfer): + async def _perform_notificaiton_call( + self, transfer: TransferSchema, status: TransferStatusEnum + ): """Send notifications based on the current status of the transfer.""" - notifications = TRANSFER_STATUS_NOTIFICATION_MAPPER.get( - transfer.current_status.status - ) - notification_data = NotificationMessageSchema( - message=f"Transfer {transfer.transfer_id} has been updated", - origin_user_profile_id=self.request.user.user_profile_id, - ) - if notifications and isinstance(notifications, list): - notification_data.related_organization_id = ( - transfer.from_organization_id - if transfer.current_status.status == TransferStatusEnum.Declined - else transfer.to_organization_id - ) - await self.notfn_service.send_notification( - NotificationRequestSchema( - notification_types=notifications, - notification_data=notification_data, - ) + notifications = TRANSFER_STATUS_NOTIFICATION_MAPPER.get(status) + status_val = ( + status.value if isinstance(status, TransferStatusEnum) else status + ).lower() + organization_ids = [] + if status in [ + TransferStatusEnum.Submitted, + TransferStatusEnum.Recommended, + TransferStatusEnum.Declined, + ]: + organization_ids = [transfer.from_organization.organization_id] + elif status in [ + TransferStatusEnum.Sent, + TransferStatusEnum.Rescinded, + ]: + organization_ids = [transfer.to_organization.organization_id] + elif status in [ + TransferStatusEnum.Recorded, + TransferStatusEnum.Refused, + ]: + organization_ids = [ + transfer.to_organization.organization_id, + transfer.from_organization.organization_id, + ] + message_data = { + "service": "Transfer", + "id": transfer.transfer_id, + "status": status_val, + "fromOrganizationId": transfer.from_organization.organization_id, + "fromOrganization": transfer.from_organization.name, + "toOrganizationId": transfer.to_organization.organization_id, + "toOrganization": transfer.to_organization.name, + } + type = f"Transfer {status_val}" + if status_val == "sent": + type = "Transfer received" + elif status_val == "return to analyst": + type = "Transfer returned" + for org_id in organization_ids: + notification_data = NotificationMessageSchema( + type=type, + transaction_id=transfer.from_transaction.transaction_id if getattr(transfer, 'from_transaction', None) else None, + message=json.dumps(message_data), + related_organization_id=org_id, + origin_user_profile_id=self.request.user.user_profile_id, ) - if transfer.current_status.status in [ - TransferStatusEnum.Refused, - TransferStatusEnum.Recorded, - ]: - notification_data.related_organization_id = ( - transfer.from_organization_id - ) + if notifications and isinstance(notifications, list): await self.notfn_service.send_notification( NotificationRequestSchema( notification_types=notifications, diff --git a/frontend/src/components/BCDataGrid/BCGridBase.jsx b/frontend/src/components/BCDataGrid/BCGridBase.jsx index d26ef13b7..dd7d6c9fa 100644 --- a/frontend/src/components/BCDataGrid/BCGridBase.jsx +++ b/frontend/src/components/BCDataGrid/BCGridBase.jsx @@ -34,7 +34,7 @@ export const BCGridBase = forwardRef(({ autoSizeStrategy, ...props }, ref) => { suppressMovableColumns suppressColumnMoveAnimation={false} reactiveCustomComponents - rowSelection="multiple" + rowSelection='multiple' suppressCsvExport={false} suppressPaginationPanel suppressScrollOnNewData diff --git a/frontend/src/themes/base/globals.js b/frontend/src/themes/base/globals.js index 26b4fe3cd..f8a092509 100644 --- a/frontend/src/themes/base/globals.js +++ b/frontend/src/themes/base/globals.js @@ -110,7 +110,7 @@ const globals = { border: 'none', borderBottom: '2px solid #495057' }, - '.row-not-read': { + '.unread-row': { fontWeight: 700, color: grey[700] }, diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx index 731dedd68..8294115b1 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx @@ -9,8 +9,10 @@ const IDIRAnalystNotificationSettings = () => { 'idirAnalyst.categories.transfers.submittedForReview', IDIR_ANALYST__TRANSFER__RESCINDED_ACTION: 'idirAnalyst.categories.transfers.rescindedAction', - IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDEDIDIR_A__TR__DIRECTOR_RECORDED: - 'idirAnalyst.categories.transfers.directorRecorded' + IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED: + 'idirAnalyst.categories.transfers.directorRecorded', + IDIR_ANALYST__TRANSFER__RETURNED_TO_ANALYST: + 'idirAnalyst.categories.initiativeAgreements.returnedToAnalyst' }, 'idirAnalyst.categories.initiativeAgreements': { title: 'idirAnalyst.categories.initiativeAgreements.title', diff --git a/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx index 5a7e0bd93..bb2058482 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx @@ -34,8 +34,8 @@ import BCTypography from '@/components/BCTypography' const NotificationSettingsForm = ({ categories, - showEmailField, - initialEmail + showEmailField = false, + initialEmail = '' }) => { const { t } = useTranslation(['notifications']) const [isFormLoading, setIsFormLoading] = useState(false) @@ -468,9 +468,4 @@ NotificationSettingsForm.propTypes = { initialEmail: PropTypes.string } -NotificationSettingsForm.defaultProps = { - showEmailField: false, - initialEmail: '' -} - export default NotificationSettingsForm diff --git a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx index 1bb406706..9d4a5b290 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { Stack, Grid } from '@mui/material' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -6,20 +7,23 @@ import { faSquareCheck } from '@fortawesome/free-solid-svg-icons' import BCButton from '@/components/BCButton' import { BCGridViewer } from '@/components/BCDataGrid/BCGridViewer' -import { columnDefs } from './_schema' +import { columnDefs, routesMapping } from './_schema' import { useGetNotificationMessages, useMarkNotificationAsRead, useDeleteNotificationMessages } from '@/hooks/useNotifications' +import { useCurrentUser } from '@/hooks/useCurrentUser' export const Notifications = () => { - const { t } = useTranslation(['notifications']) const gridRef = useRef(null) const alertRef = useRef(null) const [isAllSelected, setIsAllSelected] = useState(false) const [selectedRowCount, setSelectedRowCount] = useState(0) + const { t } = useTranslation(['notifications']) + const navigate = useNavigate() + const { data: currentUser } = useCurrentUser() // react query hooks const { refetch } = useGetNotificationMessages() const markAsReadMutation = useMarkNotificationAsRead() @@ -28,7 +32,7 @@ export const Notifications = () => { // row class rules for unread messages const rowClassRules = useMemo( () => ({ - 'row-not-read': (params) => !params.data.isRead + 'unread-row': (params) => !params.data.isRead }), [] ) @@ -54,10 +58,12 @@ export const Notifications = () => { mutation.mutate(selectedNotifications, { onSuccess: () => { - alertRef.current?.triggerAlert({ - message: t(successMessage), - severity: 'success' - }) + // eslint-disable-next-line chai-friendly/no-unused-expressions + successMessage && + alertRef.current?.triggerAlert({ + message: t(successMessage), + severity: 'success' + }) refetch() }, onError: (error) => { @@ -113,6 +119,27 @@ export const Notifications = () => { } }, [handleMutation, deleteMutation]) + const handleRowClicked = useCallback( + (params) => { + const { id, service, compliancePeriod } = JSON.parse(params.data.message) + // Select the appropriate route based on the notification type + const routeTemplate = routesMapping(currentUser)[service] + + if (routeTemplate && params.event.target.dataset.action !== 'delete') { + navigate( + // replace any matching query params by chaining these replace methods + routeTemplate + .replace(':transactionId', id) + .replace(':transferId', id) + .replace(':compliancePeriod', compliancePeriod) + .replace(':complianceReportId', id) + ) + handleMutation(markAsReadMutation, [params.data.notificationMessageId]) + } + }, + [currentUser, navigate] + ) + const onCellClicked = useCallback( (params) => { if ( @@ -141,7 +168,7 @@ export const Notifications = () => { setIsAllSelected( visibleRows.length > 0 && visibleRows.length === selectedRows.length ) - },[]) + }, []) return ( @@ -185,7 +212,7 @@ export const Notifications = () => { gridKey="notifications-grid" gridRef={gridRef} alertRef={alertRef} - columnDefs={columnDefs(t)} + columnDefs={columnDefs(t, currentUser)} query={useGetNotificationMessages} dataKey="notifications" overlayNoRowsTemplate={t('notifications:noNotificationsFound')} @@ -199,6 +226,7 @@ export const Notifications = () => { onCellClicked={onCellClicked} selectionColumnDef={selectionColumnDef} onSelectionChanged={onSelectionChanged} + onRowClicked={handleRowClicked} /> ) diff --git a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx index dd1a5c022..71061b79c 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx @@ -1,7 +1,8 @@ import { dateFormatter } from '@/utils/formatters' import { actions } from '@/components/BCDataGrid/columns' +import { ROUTES } from '@/constants/routes' -export const columnDefs = (t) => [ +export const columnDefs = (t, currentUser) => [ { ...actions({ enableDelete: true }), headerName: 'Delete', @@ -28,13 +29,36 @@ export const columnDefs = (t) => [ { colId: 'transactionId', field: 'transactionId', - headerName: t('notifications:notificationColLabels.transactionId') + headerName: t('notifications:notificationColLabels.transactionId'), + valueGetter: (params) => { + const { service, id } = JSON.parse(params.data.message) + if (service === 'Transfer') { + return `CT${id}` + } else if (service === 'InitiativeAgreement') { + return `IA${id}` + } else if (service === 'ComplianceReport') { + return `CR${id}` + } else { + return id + } + } }, { colId: 'organization', field: 'organization', headerName: t('notifications:notificationColLabels.organization'), - valueGetter: (params) => params.data.relatedOrganization.name + valueGetter: (params) => { + const { service, toOrganizationId, fromOrganization } = JSON.parse( + params.data.message + ) + if ( + service === 'Transfer' && + toOrganizationId === currentUser?.organization?.organizationId + ) { + return fromOrganization + } + return params.data.relatedOrganization.name + } } ] @@ -43,3 +67,16 @@ export const defaultColDef = { resizable: true, sortable: true } + +export const routesMapping = (currentUser) => ({ + Transfer: ROUTES.TRANSFERS_VIEW, + AdminAdjustment: currentUser.isGovernmentUser + ? ROUTES.ADMIN_ADJUSTMENT_VIEW + : ROUTES.ORG_ADMIN_ADJUSTMENT_VIEW, + InitiativeAgreement: currentUser.isGovernmentUser + ? ROUTES.INITIATIVE_AGREEMENT_VIEW + : ROUTES.ORG_INITIATIVE_AGREEMENT_VIEW, + ComplianceReport: currentUser.isGovernmentUser + ? ROUTES.REPORTS_VIEW + : ROUTES.ORG_COMPLIANCE_REPORT_VIEW +}) diff --git a/frontend/src/views/Notifications/Notifications.jsx b/frontend/src/views/Notifications/Notifications.jsx deleted file mode 100644 index da555141f..000000000 --- a/frontend/src/views/Notifications/Notifications.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import * as ROUTES from '@/constants/routes/routes.js' -import withFeatureFlag from '@/utils/withFeatureFlag.jsx' -import { FEATURE_FLAGS } from '@/constants/config.js' - -export const NotificationsBase = () => { - return
Notifications
-} - -export const Notifications = withFeatureFlag( - NotificationsBase, - FEATURE_FLAGS.NOTIFICATIONS, - ROUTES.DASHBOARD -) -Notifications.displayName = 'Notifications' From 66b2615967ef67e91f820c4665873b59ecc2d455 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 03:08:28 -0800 Subject: [PATCH 4/8] route and user name fixes. --- backend/lcfs/web/api/notification/schema.py | 23 +++++++++++-------- .../NotificationMenu/components/_schema.jsx | 4 +--- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index 5cf97cb56..04dc9d339 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -7,7 +7,7 @@ ) from lcfs.db.models.transfer.TransferStatus import TransferStatusEnum from lcfs.web.api.base import BaseSchema, NotificationTypeEnum, PaginationResponseSchema -from pydantic import computed_field, field_validator +from pydantic import computed_field, model_validator class OrganizationSchema(BaseSchema): @@ -21,15 +21,18 @@ class UserProfileSchema(BaseSchema): organization_id: Optional[int] = None is_government: bool = False - @field_validator("is_government", mode="after") - def update_gov_profile(cls, value, info): - if info.data.get("is_government", True): - info.data.update({"first_name": "Government of B.C.", "last_name": ""}) - return value - + @model_validator(mode="before") + def update_government_profile(cls, data): + if data.is_government: + data.first_name = "Government of B.C." + data.last_name = "" + return data + @computed_field @property def full_name(self) -> str: + if self.is_government: + return "Government of B.C." return f"{self.first_name} {self.last_name}" @@ -82,10 +85,12 @@ class DeleteSubscriptionSchema(BaseSchema): class DeleteNotificationChannelSubscriptionResponseSchema(BaseSchema): message: str + class NotificationsSchema(BaseSchema): notifications: List[NotificationMessageSchema] = [] pagination: PaginationResponseSchema = None + class NotificationRequestSchema(BaseSchema): notification_types: List[NotificationTypeEnum] notification_context: Optional[Dict[str, Any]] = {} @@ -134,7 +139,7 @@ class NotificationRequestSchema(BaseSchema): ], TransferStatusEnum.Submitted: [ NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, - NotificationTypeEnum.IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW + NotificationTypeEnum.IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW, ], TransferStatusEnum.Recommended: [ NotificationTypeEnum.IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION @@ -149,7 +154,7 @@ class NotificationRequestSchema(BaseSchema): ], "Return to analyst": [ NotificationTypeEnum.IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW - ] + ], } INITIATIVE_AGREEMENT_STATUS_NOTIFICATION_MAPPER = { diff --git a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx index 71061b79c..131ab0d81 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx @@ -76,7 +76,5 @@ export const routesMapping = (currentUser) => ({ InitiativeAgreement: currentUser.isGovernmentUser ? ROUTES.INITIATIVE_AGREEMENT_VIEW : ROUTES.ORG_INITIATIVE_AGREEMENT_VIEW, - ComplianceReport: currentUser.isGovernmentUser - ? ROUTES.REPORTS_VIEW - : ROUTES.ORG_COMPLIANCE_REPORT_VIEW + ComplianceReport: ROUTES.REPORTS_VIEW }) From dd6e869cf853b8c7cc1420d6d788cbdfc54d2384 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 04:08:37 -0800 Subject: [PATCH 5/8] fix tests --- .../compliance_report/test_update_service.py | 27 +++-- .../test_initiative_agreement_services.py | 25 +++- .../notification/test_notification_repo.py | 107 ++++++++++++++---- .../tests/transfer/test_transfer_services.py | 18 +++ 4 files changed, 144 insertions(+), 33 deletions(-) diff --git a/backend/lcfs/tests/compliance_report/test_update_service.py b/backend/lcfs/tests/compliance_report/test_update_service.py index ec4b7e130..753ec8b76 100644 --- a/backend/lcfs/tests/compliance_report/test_update_service.py +++ b/backend/lcfs/tests/compliance_report/test_update_service.py @@ -30,8 +30,8 @@ def mock_user_has_roles(): def mock_notification_service(): mock_service = AsyncMock(spec=NotificationService) with patch( - "lcfs.web.api.compliance_report.update_service.Depends", - return_value=mock_service + "lcfs.web.api.compliance_report.update_service.Depends", + return_value=mock_service, ): yield mock_service @@ -47,6 +47,7 @@ def mock_environment_vars(): mock_settings.ches_sender_name = "Mock Notification System" yield mock_settings + # Mock for adjust_balance method within the OrganizationsService @pytest.fixture def mock_org_service(): @@ -66,6 +67,8 @@ async def test_update_compliance_report_status_change( mock_report.compliance_report_id = report_id mock_report.current_status = MagicMock(spec=ComplianceReportStatus) mock_report.current_status.status = ComplianceReportStatusEnum.Draft + mock_report.compliance_period = MagicMock() + mock_report.compliance_period.description = "2024" new_status = MagicMock(spec=ComplianceReportStatus) new_status.status = ComplianceReportStatusEnum.Submitted @@ -78,8 +81,8 @@ async def test_update_compliance_report_status_change( mock_repo.get_compliance_report_by_id.return_value = mock_report mock_repo.get_compliance_report_status_by_desc.return_value = new_status compliance_report_update_service.handle_status_change = AsyncMock() - compliance_report_update_service.notfn_service = mock_notification_service mock_repo.update_compliance_report.return_value = mock_report + compliance_report_update_service._perform_notificaiton_call = AsyncMock() # Call the method updated_report = await compliance_report_update_service.update_compliance_report( @@ -101,10 +104,9 @@ async def test_update_compliance_report_status_change( mock_report, compliance_report_update_service.request.user ) mock_repo.update_compliance_report.assert_called_once_with(mock_report) - - assert mock_report.current_status == new_status - assert mock_report.supplemental_note == report_data.supplemental_note - mock_notification_service.send_notification.assert_called_once() + compliance_report_update_service._perform_notificaiton_call.assert_called_once_with( + mock_report, "Submitted" + ) @pytest.mark.anyio @@ -118,6 +120,10 @@ async def test_update_compliance_report_no_status_change( mock_report.current_status = MagicMock(spec=ComplianceReportStatus) mock_report.current_status.status = ComplianceReportStatusEnum.Draft + # Fix for JSON serialization + mock_report.compliance_period = MagicMock() + mock_report.compliance_period.description = "2024" + report_data = ComplianceReportUpdateSchema( status="Draft", supplemental_note="Test note" ) @@ -131,6 +137,7 @@ async def test_update_compliance_report_no_status_change( # Mock the handle_status_change method compliance_report_update_service.handle_status_change = AsyncMock() + compliance_report_update_service._perform_notificaiton_call = AsyncMock() # Call the method updated_report = await compliance_report_update_service.update_compliance_report( @@ -148,9 +155,9 @@ async def test_update_compliance_report_no_status_change( compliance_report_update_service.handle_status_change.assert_not_called() mock_repo.add_compliance_report_history.assert_not_called() mock_repo.update_compliance_report.assert_called_once_with(mock_report) - - assert mock_report.current_status == mock_report.current_status - assert mock_report.supplemental_note == report_data.supplemental_note + compliance_report_update_service._perform_notificaiton_call.assert_called_once_with( + mock_report, "Draft" + ) @pytest.mark.anyio diff --git a/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py b/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py index 85d0299a9..2eb16223d 100644 --- a/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py +++ b/backend/lcfs/tests/initiative_agreement/test_initiative_agreement_services.py @@ -87,14 +87,23 @@ async def test_get_initiative_agreement(service, mock_repo): mock_repo.get_initiative_agreement_by_id.assert_called_once_with(1) +@pytest.mark.anyio @pytest.mark.anyio async def test_create_initiative_agreement(service, mock_repo, mock_request): + # Mock status for the initiative agreement mock_status = MagicMock(status=InitiativeAgreementStatusEnum.Recommended) mock_repo.get_initiative_agreement_status_by_name.return_value = mock_status - mock_repo.create_initiative_agreement.return_value = MagicMock( - spec=InitiativeAgreement - ) + # Create a mock initiative agreement with serializable fields + mock_initiative_agreement = MagicMock(spec=InitiativeAgreement) + mock_initiative_agreement.initiative_agreement_id = 1 + mock_initiative_agreement.current_status.status = "Recommended" + mock_initiative_agreement.to_organization_id = 3 + + # Mock return value of create_initiative_agreement + mock_repo.create_initiative_agreement.return_value = mock_initiative_agreement + + # Create input data create_data = InitiativeAgreementCreateSchema( compliance_units=150, current_status="Recommended", @@ -104,10 +113,18 @@ async def test_create_initiative_agreement(service, mock_repo, mock_request): internal_comment=None, ) + # Mock _perform_notificaiton_call to isolate it + service._perform_notificaiton_call = AsyncMock() + + # Call the service method result = await service.create_initiative_agreement(create_data) - assert isinstance(result, InitiativeAgreement) + # Assertions + assert result == mock_initiative_agreement mock_repo.create_initiative_agreement.assert_called_once() + service._perform_notificaiton_call.assert_called_once_with( + mock_initiative_agreement + ) @pytest.mark.anyio diff --git a/backend/lcfs/tests/notification/test_notification_repo.py b/backend/lcfs/tests/notification/test_notification_repo.py index 20eb31169..bbc4ee80f 100644 --- a/backend/lcfs/tests/notification/test_notification_repo.py +++ b/backend/lcfs/tests/notification/test_notification_repo.py @@ -1,3 +1,5 @@ +from lcfs.db.models.notification.NotificationChannel import ChannelEnum +from lcfs.web.api.base import NotificationTypeEnum, PaginationRequestSchema import pytest from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import delete @@ -92,34 +94,21 @@ async def mock_execute(*args, **kwargs): @pytest.mark.anyio async def test_get_notification_messages_by_user(notification_repo, mock_db_session): mock_notification1 = MagicMock(spec=NotificationMessage) - mock_notification1.related_user_id = 1 - mock_notification1.origin_user_id = 2 - mock_notification1.notification_message_id = 1 - mock_notification1.message = "Test message 1" - mock_notification2 = MagicMock(spec=NotificationMessage) - mock_notification2.related_user_id = 1 - mock_notification2.origin_user_id = 2 - mock_notification2.notification_message_id = 2 - mock_notification2.message = "Test message 2" - mock_result_chain = MagicMock() - mock_result_chain.scalars.return_value.all.return_value = [ + mock_result = MagicMock() + mock_result.unique.return_value.scalars.return_value.all.return_value = [ mock_notification1, mock_notification2, ] - async def mock_execute(*args, **kwargs): - return mock_result_chain - - # Inject the mocked execute method into the session - mock_db_session.execute = mock_execute + mock_db_session.execute = AsyncMock(return_value=mock_result) result = await notification_repo.get_notification_messages_by_user(1) assert len(result) == 2 - assert result[0].notification_message_id == 1 - assert result[1].notification_message_id == 2 + assert result == [mock_notification1, mock_notification2] + mock_db_session.execute.assert_called_once() @pytest.mark.anyio @@ -158,7 +147,7 @@ async def test_delete_notification_message(notification_repo, mock_db_session): NotificationMessage.notification_message_id == notification_id ) assert str(executed_query) == str(expected_query) - + mock_db_session.execute.assert_called_once() mock_db_session.flush.assert_called_once() @@ -277,3 +266,83 @@ async def mock_execute(*args, **kwargs): assert result is not None assert result.notification_channel_subscription_id == subscription_id + + +@pytest.mark.anyio +async def test_create_notification_messages(notification_repo, mock_db_session): + messages = [ + MagicMock(spec=NotificationMessage), + MagicMock(spec=NotificationMessage), + ] + + await notification_repo.create_notification_messages(messages) + + mock_db_session.add_all.assert_called_once_with(messages) + mock_db_session.flush.assert_called_once() + + +@pytest.mark.anyio +async def test_mark_notifications_as_read(notification_repo, mock_db_session): + user_id = 1 + notification_ids = [1, 2, 3] + + mock_db_session.execute = AsyncMock() + mock_db_session.flush = AsyncMock() + + result = await notification_repo.mark_notifications_as_read( + user_id, notification_ids + ) + + assert result == notification_ids + mock_db_session.execute.assert_called_once() + mock_db_session.flush.assert_called_once() + + +@pytest.mark.anyio +async def test_get_notification_type_by_name(notification_repo, mock_db_session): + # Create a mock result that properly simulates the SQLAlchemy result + mock_result = MagicMock() + mock_scalars = MagicMock() + mock_scalars.first.return_value = 123 + mock_result.scalars.return_value = mock_scalars + + mock_db_session.execute = AsyncMock(return_value=mock_result) + + result = await notification_repo.get_notification_type_by_name("TestNotification") + + assert result == 123 + mock_db_session.execute.assert_called_once() + + +@pytest.mark.anyio +async def test_get_notification_channel_by_name(notification_repo, mock_db_session): + # Similar setup to the previous test + mock_result = MagicMock() + mock_scalars = MagicMock() + mock_scalars.first.return_value = 456 + mock_result.scalars.return_value = mock_scalars + + mock_db_session.execute = AsyncMock(return_value=mock_result) + + result = await notification_repo.get_notification_channel_by_name(ChannelEnum.EMAIL) + + assert result == 456 + mock_db_session.execute.assert_called_once() + + +@pytest.mark.anyio +async def test_get_subscribed_users_by_channel(notification_repo, mock_db_session): + # Similar setup, but using .all() instead of .first() + mock_result = MagicMock() + mock_scalars = MagicMock() + mock_scalars.all.return_value = [1, 2, 3] + mock_result.scalars.return_value = mock_scalars + + mock_db_session.execute = AsyncMock(return_value=mock_result) + + result = await notification_repo.get_subscribed_users_by_channel( + NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS, ChannelEnum.EMAIL + ) + + assert result == [1, 2, 3] + mock_db_session.execute.assert_called_once() diff --git a/backend/lcfs/tests/transfer/test_transfer_services.py b/backend/lcfs/tests/transfer/test_transfer_services.py index d9e30abfb..91c8e7f21 100644 --- a/backend/lcfs/tests/transfer/test_transfer_services.py +++ b/backend/lcfs/tests/transfer/test_transfer_services.py @@ -91,8 +91,15 @@ async def test_update_transfer_success( ): transfer_status = TransferStatus(transfer_status_id=1, status="status") transfer_id = 1 + # Create valid nested organization objects + from_org = Organization(organization_id=1, name="org1") + to_org = Organization(organization_id=2, name="org2") + + # Create a Transfer object with the necessary attributes transfer = Transfer( transfer_id=transfer_id, + from_organization=from_org, + to_organization=to_org, from_organization_id=1, to_organization_id=2, from_transaction_id=1, @@ -114,11 +121,22 @@ async def test_update_transfer_success( mock_transfer_repo.get_transfer_by_id.return_value = transfer mock_transfer_repo.update_transfer.return_value = transfer + # Replace _perform_notificaiton_call with an AsyncMock + transfer_service._perform_notificaiton_call = AsyncMock() + result = await transfer_service.update_transfer(transfer) + # Assertions assert result.transfer_id == transfer_id assert isinstance(result, Transfer) + # Verify mocks + mock_transfer_repo.get_transfer_by_id.assert_called_once_with(transfer_id) + mock_transfer_repo.update_transfer.assert_called_once_with(transfer) + transfer_service._perform_notificaiton_call.assert_awaited_once_with( + transfer, status="Return to analyst" + ) + @pytest.mark.anyio async def test_update_category_success(transfer_service, mock_transfer_repo): From 71736f84add778bf4ea211f3010b67ee5fd877e9 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 09:46:50 -0800 Subject: [PATCH 6/8] filter fixes --- backend/lcfs/web/api/notification/repo.py | 22 ++++++++++++------- .../NotificationMenu/components/_schema.jsx | 1 + 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/backend/lcfs/web/api/notification/repo.py b/backend/lcfs/web/api/notification/repo.py index 75c9cf05b..ebb5647a3 100644 --- a/backend/lcfs/web/api/notification/repo.py +++ b/backend/lcfs/web/api/notification/repo.py @@ -10,6 +10,8 @@ from lcfs.web.api.base import ( NotificationTypeEnum, PaginationRequestSchema, + apply_filter_conditions, + get_field_for_filter, validate_pagination, ) import structlog @@ -100,14 +102,18 @@ def _apply_notification_filters( filter_type = filter.filter_type # Handle date filters - if filter.filter_type == "date": - filter_value = [] - if filter.date_from: - filter_value.append(filter.date_from) - if filter.date_to: - filter_value.append(filter.date_to) - if not filter_value: - continue # Skip if no valid date is provided + if filter.field == "date": + filter_value = filter.date_from + field = get_field_for_filter(NotificationMessage, 'create_date') + elif filter.field == 'user': + field = get_field_for_filter(NotificationMessage, 'related_user_profile.first_name') + else: + field = get_field_for_filter(NotificationMessage, filter.field) + conditions.append( + apply_filter_conditions( + field, filter_value, filter_option, filter_type + ) + ) return conditions diff --git a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx index 131ab0d81..5ea74c314 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx @@ -16,6 +16,7 @@ export const columnDefs = (t, currentUser) => [ { colId: 'date', field: 'date', + cellDataType: 'date', headerName: t('notifications:notificationColLabels.date'), valueGetter: (params) => params.data.createDate, valueFormatter: dateFormatter From 86b114669bec756af408f81339fb847a7f8ed365 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 10:34:00 -0800 Subject: [PATCH 7/8] update for PR review comments --- .../db/models/notification/NotificationMessage.py | 5 ----- .../lcfs/web/api/compliance_report/update_service.py | 12 ++++++------ .../lcfs/web/api/initiative_agreement/services.py | 6 +++--- backend/lcfs/web/api/notification/schema.py | 8 ++++---- backend/lcfs/web/api/transfer/services.py | 6 +++--- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/backend/lcfs/db/models/notification/NotificationMessage.py b/backend/lcfs/db/models/notification/NotificationMessage.py index a339da56e..2c3a5fd6d 100644 --- a/backend/lcfs/db/models/notification/NotificationMessage.py +++ b/backend/lcfs/db/models/notification/NotificationMessage.py @@ -35,11 +35,6 @@ class NotificationMessage(BaseModel, Auditable): ) transaction_id = Column(Integer, ForeignKey("transaction.transaction_id"), nullable=True) - # Models not created yet - # related_transaction_id = Column(Integer,ForeignKey('')) - # related_document_id = Column(Integer, ForeignKey('document.id')) - # related_report_id = Column(Integer, ForeignKey('compliance_report.id')) - # Relationships related_transaction = relationship("Transaction") related_organization = relationship( diff --git a/backend/lcfs/web/api/compliance_report/update_service.py b/backend/lcfs/web/api/compliance_report/update_service.py index 87df1bb50..05b82b994 100644 --- a/backend/lcfs/web/api/compliance_report/update_service.py +++ b/backend/lcfs/web/api/compliance_report/update_service.py @@ -80,10 +80,10 @@ async def update_compliance_report( # Add history record await self.repo.add_compliance_report_history(report, self.request.user) - await self._perform_notificaiton_call(report, current_status) + await self._perform_notification_call(report, current_status) return updated_report - async def _perform_notificaiton_call(self, cr, status): + async def _perform_notification_call(self, report, status): """Send notifications based on the current status of the transfer.""" status_mapper = status.replace(" ", "_") notifications = COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER.get( @@ -96,15 +96,15 @@ async def _perform_notificaiton_call(self, cr, status): ) message_data = { "service": "ComplianceReport", - "id": cr.compliance_report_id, - "compliancePeriod": cr.compliance_period.description, + "id": report.compliance_report_id, + "compliancePeriod": report.compliance_period.description, "status": status.lower(), } notification_data = NotificationMessageSchema( type=f"Compliance report {status.lower()}", - transaction_id=cr.transaction_id, + transaction_id=report.transaction_id, message=json.dumps(message_data), - related_organization_id=cr.organization_id, + related_organization_id=report.organization_id, origin_user_profile_id=self.request.user.user_profile_id, ) if notifications and isinstance(notifications, list): diff --git a/backend/lcfs/web/api/initiative_agreement/services.py b/backend/lcfs/web/api/initiative_agreement/services.py index 6494d57d0..387eceaad 100644 --- a/backend/lcfs/web/api/initiative_agreement/services.py +++ b/backend/lcfs/web/api/initiative_agreement/services.py @@ -130,7 +130,7 @@ async def update_initiative_agreement( # Return the updated initiative agreement schema with the returned status flag ia_schema = InitiativeAgreementSchema.from_orm(updated_initiative_agreement) ia_schema.returned = returned - await self._perform_notificaiton_call(updated_initiative_agreement, returned) + await self._perform_notification_call(updated_initiative_agreement, returned) return ia_schema @service_handler @@ -175,7 +175,7 @@ async def create_initiative_agreement( await self.internal_comment_service.create_internal_comment( internal_comment_data ) - await self._perform_notificaiton_call(initiative_agreement) + await self._perform_notification_call(initiative_agreement) return initiative_agreement async def director_approve_initiative_agreement( @@ -210,7 +210,7 @@ async def director_approve_initiative_agreement( await self.repo.refresh_initiative_agreement(initiative_agreement) - async def _perform_notificaiton_call(self, ia, returned=False): + async def _perform_notification_call(self, ia, returned=False): """Send notifications based on the current status of the transfer.""" status = ia.current_status.status if not returned else "Return to analyst" status_val = ( diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index 04dc9d339..f64d8ba36 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -10,12 +10,12 @@ from pydantic import computed_field, model_validator -class OrganizationSchema(BaseSchema): +class NotificationOrganizationSchema(BaseSchema): organization_id: int name: str -class UserProfileSchema(BaseSchema): +class NotificationUserProfileSchema(BaseSchema): first_name: str last_name: str organization_id: Optional[int] = None @@ -45,11 +45,11 @@ class NotificationMessageSchema(BaseSchema): type: Optional[str] = None message: Optional[str] = None related_organization_id: Optional[int] = None - related_organization: Optional[OrganizationSchema] = None + related_organization: Optional[NotificationOrganizationSchema] = None transaction_id: Optional[int] = None create_date: Optional[datetime] = None origin_user_profile_id: Optional[int] = None - origin_user_profile: Optional[UserProfileSchema] = None + origin_user_profile: Optional[NotificationUserProfileSchema] = None related_user_profile_id: Optional[int] = None notification_type_id: Optional[int] = None deleted: Optional[bool] = None diff --git a/backend/lcfs/web/api/transfer/services.py b/backend/lcfs/web/api/transfer/services.py index ea1dcd2a2..e0427618a 100644 --- a/backend/lcfs/web/api/transfer/services.py +++ b/backend/lcfs/web/api/transfer/services.py @@ -166,7 +166,7 @@ async def create_transfer( current_status.transfer_status_id, self.request.user.user_profile_id, ) - await self._perform_notificaiton_call(transfer, current_status.status) + await self._perform_notification_call(transfer, current_status.status) return transfer @service_handler @@ -264,7 +264,7 @@ async def update_transfer(self, transfer_data: TransferCreateSchema) -> Transfer # Finally, update the transfer's status and save the changes transfer.current_status = new_status transfer_result = await self.repo.update_transfer(transfer) - await self._perform_notificaiton_call( + await self._perform_notification_call( transfer, status=( new_status.status @@ -274,7 +274,7 @@ async def update_transfer(self, transfer_data: TransferCreateSchema) -> Transfer ) return transfer_result - async def _perform_notificaiton_call( + async def _perform_notification_call( self, transfer: TransferSchema, status: TransferStatusEnum ): """Send notifications based on the current status of the transfer.""" From f403a4205a25478c865eb4182f6f7de589f1f6a8 Mon Sep 17 00:00:00 2001 From: prv-proton Date: Tue, 17 Dec 2024 11:54:29 -0800 Subject: [PATCH 8/8] updates --- .../versions/2024-12-13-12-44_62bc9695a764.py | 44 ------------ .../versions/2024-12-17-11-23_f93546eaec61.py | 33 +++++++++ .../notification/NotificationMessage.py | 3 +- .../api/compliance_report/update_service.py | 3 +- .../web/api/initiative_agreement/services.py | 3 +- backend/lcfs/web/api/notification/repo.py | 67 ++++++++++++++----- backend/lcfs/web/api/notification/schema.py | 3 +- backend/lcfs/web/api/transfer/services.py | 3 +- .../NotificationMenu/components/_schema.jsx | 13 +--- 9 files changed, 92 insertions(+), 80 deletions(-) delete mode 100644 backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py create mode 100644 backend/lcfs/db/migrations/versions/2024-12-17-11-23_f93546eaec61.py diff --git a/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py b/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py deleted file mode 100644 index 44019fc73..000000000 --- a/backend/lcfs/db/migrations/versions/2024-12-13-12-44_62bc9695a764.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Add type and transaction details to notification messages - -Revision ID: 62bc9695a764 -Revises: 7ae38a8413ab -Create Date: 2024-12-13 12:44:44.348419 - -""" - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "62bc9695a764" -down_revision = "5d729face5ab" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column("notification_message", sa.Column("type", sa.Text(), nullable=False)) - op.add_column( - "notification_message", sa.Column("transaction_id", sa.Integer(), nullable=True) - ) - op.create_foreign_key( - op.f("fk_notification_message_transaction_id_transaction"), - "notification_message", - "transaction", - ["transaction_id"], - ["transaction_id"], - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint( - op.f("fk_notification_message_transaction_id_transaction"), - "notification_message", - type_="foreignkey", - ) - op.drop_column("notification_message", "transaction_id") - op.drop_column("notification_message", "type") - # ### end Alembic commands ### diff --git a/backend/lcfs/db/migrations/versions/2024-12-17-11-23_f93546eaec61.py b/backend/lcfs/db/migrations/versions/2024-12-17-11-23_f93546eaec61.py new file mode 100644 index 000000000..4fbabc280 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-12-17-11-23_f93546eaec61.py @@ -0,0 +1,33 @@ +"""update notification message model + +Revision ID: f93546eaec61 +Revises: 5d729face5ab +Create Date: 2024-12-17 11:23:19.563138 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f93546eaec61" +down_revision = "5d729face5ab" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("notification_message", sa.Column("type", sa.Text(), nullable=False)) + op.add_column( + "notification_message", + sa.Column("related_transaction_id", sa.Text(), nullable=False), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("notification_message", "related_transaction_id") + op.drop_column("notification_message", "type") + # ### end Alembic commands ### diff --git a/backend/lcfs/db/models/notification/NotificationMessage.py b/backend/lcfs/db/models/notification/NotificationMessage.py index 2c3a5fd6d..fddc1a961 100644 --- a/backend/lcfs/db/models/notification/NotificationMessage.py +++ b/backend/lcfs/db/models/notification/NotificationMessage.py @@ -33,10 +33,9 @@ class NotificationMessage(BaseModel, Auditable): notification_type_id = Column( Integer, ForeignKey("notification_type.notification_type_id") ) - transaction_id = Column(Integer, ForeignKey("transaction.transaction_id"), nullable=True) + related_transaction_id = Column(Text, nullable=False) # Relationships - related_transaction = relationship("Transaction") related_organization = relationship( "Organization", back_populates="notification_messages" ) diff --git a/backend/lcfs/web/api/compliance_report/update_service.py b/backend/lcfs/web/api/compliance_report/update_service.py index 05b82b994..1a1d7d9c7 100644 --- a/backend/lcfs/web/api/compliance_report/update_service.py +++ b/backend/lcfs/web/api/compliance_report/update_service.py @@ -97,12 +97,13 @@ async def _perform_notification_call(self, report, status): message_data = { "service": "ComplianceReport", "id": report.compliance_report_id, + "transactionId": report.transaction_id, "compliancePeriod": report.compliance_period.description, "status": status.lower(), } notification_data = NotificationMessageSchema( type=f"Compliance report {status.lower()}", - transaction_id=report.transaction_id, + related_transaction_id=f"CR{report.compliance_report_id}", message=json.dumps(message_data), related_organization_id=report.organization_id, origin_user_profile_id=self.request.user.user_profile_id, diff --git a/backend/lcfs/web/api/initiative_agreement/services.py b/backend/lcfs/web/api/initiative_agreement/services.py index 387eceaad..c9cb3c4de 100644 --- a/backend/lcfs/web/api/initiative_agreement/services.py +++ b/backend/lcfs/web/api/initiative_agreement/services.py @@ -224,11 +224,12 @@ async def _perform_notification_call(self, ia, returned=False): message_data = { "service": "InitiativeAgreement", "id": ia.initiative_agreement_id, + "transactionId": ia.transaction_id, "status": status_val, } notification_data = NotificationMessageSchema( type=f"Initiative agreement {status_val}", - transaction_id=ia.transaction_id, + related_transaction_id=f"IA{ia.initiative_agreement_id}", message=json.dumps(message_data), related_organization_id=ia.to_organization_id, origin_user_profile_id=self.request.user.user_profile_id, diff --git a/backend/lcfs/web/api/notification/repo.py b/backend/lcfs/web/api/notification/repo.py index ebb5647a3..bd9d874fa 100644 --- a/backend/lcfs/web/api/notification/repo.py +++ b/backend/lcfs/web/api/notification/repo.py @@ -5,6 +5,7 @@ NotificationType, ChannelEnum, ) +from lcfs.db.models.organization import Organization from lcfs.db.models.user import UserProfile from lcfs.db.models.user.UserRole import UserRole from lcfs.web.api.base import ( @@ -21,7 +22,7 @@ from lcfs.db.dependencies import get_async_db_session from lcfs.web.exception.exceptions import DataNotFoundException -from sqlalchemy import delete, or_, select, func, update +from sqlalchemy import asc, delete, desc, or_, select, func, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload @@ -104,16 +105,38 @@ def _apply_notification_filters( # Handle date filters if filter.field == "date": filter_value = filter.date_from - field = get_field_for_filter(NotificationMessage, 'create_date') - elif filter.field == 'user': - field = get_field_for_filter(NotificationMessage, 'related_user_profile.first_name') + field = get_field_for_filter(NotificationMessage, "create_date") + conditions.append( + apply_filter_conditions( + field, filter_value, filter_option, filter_type + ) + ) + elif filter.field == "user": + conditions.append( + NotificationMessage.origin_user_profile.has( + UserProfile.first_name.like(f"%{filter_value}%") + ) + ) + elif filter.field == "organization": + conditions.append( + NotificationMessage.related_organization.has( + Organization.name.like(f"%{filter_value}%") + ) + ) + elif filter.field == "transaction_id": + field = get_field_for_filter(NotificationMessage, 'related_transaction_id') + conditions.append( + apply_filter_conditions( + field, filter_value, filter_option, filter_type + ) + ) else: field = get_field_for_filter(NotificationMessage, filter.field) - conditions.append( - apply_filter_conditions( - field, filter_value, filter_option, filter_type + conditions.append( + apply_filter_conditions( + field, filter_value, filter_option, filter_type + ) ) - ) return conditions @@ -151,17 +174,25 @@ async def get_paginated_notification_messages( ) # Apply sorting + order_clauses = [] if not pagination.sort_orders: - query = query.order_by(NotificationMessage.create_date.desc()) - # for order in pagination.sort_orders: - # direction = asc if order.direction == "asc" else desc - # if order.field == "status": - # field = getattr(FuelCodeStatus, "status") - # elif order.field == "prefix": - # field = getattr(FuelCodePrefix, "prefix") - # else: - # field = getattr(FuelCode, order.field) - # query = query.order_by(direction(field)) + order_clauses.append(desc(NotificationMessage.create_date)) + else: + for order in pagination.sort_orders: + direction = asc if order.direction == "asc" else desc + if order.field == "date": + field = NotificationMessage.create_date + elif order.field == "user": + field = UserProfile.first_name + elif order.field == "organization": + field = Organization.name + elif order.field == "transaction_id": + field = NotificationMessage.related_transaction_id + else: + field = getattr(NotificationMessage, order.field) + if field is not None: + order_clauses.append(direction(field)) + query = query.order_by(*order_clauses) # Execute the count query to get the total count count_query = query.with_only_columns(func.count()).order_by(None) diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index f64d8ba36..30ff2d5f2 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -46,7 +46,7 @@ class NotificationMessageSchema(BaseSchema): message: Optional[str] = None related_organization_id: Optional[int] = None related_organization: Optional[NotificationOrganizationSchema] = None - transaction_id: Optional[int] = None + related_transaction_id: Optional[str] = None create_date: Optional[datetime] = None origin_user_profile_id: Optional[int] = None origin_user_profile: Optional[NotificationUserProfileSchema] = None @@ -163,6 +163,7 @@ class NotificationRequestSchema(BaseSchema): ], InitiativeAgreementStatusEnum.Approved: [ NotificationTypeEnum.BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL, + NotificationTypeEnum.IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST ], "Return to analyst": [ NotificationTypeEnum.IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST diff --git a/backend/lcfs/web/api/transfer/services.py b/backend/lcfs/web/api/transfer/services.py index e0427618a..4b2aba0c9 100644 --- a/backend/lcfs/web/api/transfer/services.py +++ b/backend/lcfs/web/api/transfer/services.py @@ -305,6 +305,7 @@ async def _perform_notification_call( message_data = { "service": "Transfer", "id": transfer.transfer_id, + "transactionId": transfer.from_transaction.transaction_id if getattr(transfer, 'from_transaction', None) else None, "status": status_val, "fromOrganizationId": transfer.from_organization.organization_id, "fromOrganization": transfer.from_organization.name, @@ -319,7 +320,7 @@ async def _perform_notification_call( for org_id in organization_ids: notification_data = NotificationMessageSchema( type=type, - transaction_id=transfer.from_transaction.transaction_id if getattr(transfer, 'from_transaction', None) else None, + related_transaction_id=f"CT{transfer.transfer_id}", message=json.dumps(message_data), related_organization_id=org_id, origin_user_profile_id=self.request.user.user_profile_id, diff --git a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx index 5ea74c314..601fb29c6 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/_schema.jsx @@ -31,18 +31,7 @@ export const columnDefs = (t, currentUser) => [ colId: 'transactionId', field: 'transactionId', headerName: t('notifications:notificationColLabels.transactionId'), - valueGetter: (params) => { - const { service, id } = JSON.parse(params.data.message) - if (service === 'Transfer') { - return `CT${id}` - } else if (service === 'InitiativeAgreement') { - return `IA${id}` - } else if (service === 'ComplianceReport') { - return `CR${id}` - } else { - return id - } - } + valueGetter: (params) => params.data.relatedTransactionId }, { colId: 'organization',