Hello,
+ +Your access request for {{application_name}} has been denied.
+If you have any questions or believe you should have been approved, please email EAO.EPICsystem@gov.bc.ca.
+ +Thank you,
+ +EPIC.centre System
+diff --git a/.vscode/launch.json b/.vscode/launch.json index ff3302e75..0b1a25390 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,6 +12,16 @@ "program": "${workspaceFolder}/submit-api/wsgi.py", "console": "integratedTerminal", "justMyCode": true + }, + { + "name": "Submit CRON - CENTRE", + "type": "python", + "cwd": "${workspaceFolder}/submit-cron/", + "request": "launch", + "program": "${workspaceFolder}/submit-cron/invoke_jobs.py", + "args": ["EMAIL", "CENTRE"], + "console": "integratedTerminal", + "justMyCode": true } ] } \ No newline at end of file diff --git a/submit-cron/config.py b/submit-cron/config.py index 870ff886f..e38977cba 100644 --- a/submit-cron/config.py +++ b/submit-cron/config.py @@ -108,6 +108,8 @@ class _Config(): # pylint: disable=too-few-public-methods CONDITION_API_BASE_URL = os.getenv('CONDITION_API_BASE_URL') + ENVIRONMENT = os.getenv('ENVIRONMENT', os.getenv('ENV_NAME', '')) + class DevConfig(_Config): # pylint: disable=too-few-public-methods """Dev Config.""" diff --git a/submit-cron/invoke_jobs.py b/submit-cron/invoke_jobs.py index 1dade7206..bb6ac1de4 100644 --- a/submit-cron/invoke_jobs.py +++ b/submit-cron/invoke_jobs.py @@ -24,8 +24,6 @@ from utils.logger import setup_logging from datetime import datetime -from submit_api.models.project import Project - import config setup_logging(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'logging.conf')) # important to do this first diff --git a/submit-cron/requirements.txt b/submit-cron/requirements.txt index 6292ccfaa..d5de4e071 100644 --- a/submit-cron/requirements.txt +++ b/submit-cron/requirements.txt @@ -21,7 +21,7 @@ certifi==2025.4.26 cffi==1.17.1 charset-normalizer==3.4.2 click==8.1.8 -cryptography==44.0.3 +cryptography==41.0.7 flake8-import-order==0.18.2 flask-cors==5.0.1 flask-jwt-oidc==0.8.0 @@ -55,4 +55,4 @@ typing_extensions==4.13.2 urllib3==2.4.0 zimports==0.6.1 zipp==3.21.0 --e git+https://github.com/bcgov/EPIC.submit.git@develop#egg=submit-api&subdirectory=submit-api +-e git+https://github.com/bcgov/EPIC.submit.git@release/v1.0.8_cron_changes#egg=submit-api&subdirectory=submit-api diff --git a/submit-cron/requirements/prod.txt b/submit-cron/requirements/prod.txt index 836e71198..6001beb69 100644 --- a/submit-cron/requirements/prod.txt +++ b/submit-cron/requirements/prod.txt @@ -25,4 +25,5 @@ python-dotenv requests flask_cors pyhumps -importlib-resources \ No newline at end of file +importlib-resources +pytz \ No newline at end of file diff --git a/submit-cron/src/submit_cron/models/db.py b/submit-cron/src/submit_cron/models/db.py index bf2b76ec7..a6e568548 100644 --- a/submit-cron/src/submit_cron/models/db.py +++ b/submit-cron/src/submit_cron/models/db.py @@ -1,6 +1,7 @@ """Initilizations for db, migration and marshmallow.""" from contextlib import contextmanager + from flask import current_app from flask_marshmallow import Marshmallow from flask_migrate import Migrate @@ -8,6 +9,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker + # DB initialize in __init__ file # db variable use for create models from here db = SQLAlchemy() diff --git a/submit-cron/src/submit_cron/processors/centre/__init__.py b/submit-cron/src/submit_cron/processors/centre/__init__.py index b1b9aaf66..05a9e86e9 100644 --- a/submit-cron/src/submit_cron/processors/centre/__init__.py +++ b/submit-cron/src/submit_cron/processors/centre/__init__.py @@ -1,21 +1,28 @@ # processors/centre/__init__.py -from typing import Dict, Callable +from typing import Callable, Dict from submit_api.data_classes.email_details import EmailDetails +from ...models.email_job import EmailJob +from .access_denied import process_access_denied +from .access_granted import process_access_granted from .access_request_received_dst import process_access_request_received_dst from .access_request_submitted import process_access_request_submitted -from ...models.email_job import EmailJob + # Template names (export as constants, so they’re used consistently) TEMPLATE_ACCESS_REQUEST_SUBMITTED = "access_request_submitted_confirmation.html" ACCESS_REQUEST_RECEIVED_NOTIFICATION = 'access_request_received_notification.html' +ACCESS_GRANTED_NOTIFICATION = "access_granted_notification.html" +ACCESS_DENIED_NOTIFICATION = "access_denied_notification.html" # Map: template_name -> processor function # Each processor takes (job: EmailJob) and returns EmailDetails PROCESSORS: Dict[str, Callable[[EmailJob], EmailDetails]] = { TEMPLATE_ACCESS_REQUEST_SUBMITTED: process_access_request_submitted, ACCESS_REQUEST_RECEIVED_NOTIFICATION: process_access_request_received_dst, + ACCESS_GRANTED_NOTIFICATION: process_access_granted, + ACCESS_DENIED_NOTIFICATION: process_access_denied } __all__ = [ diff --git a/submit-cron/src/submit_cron/processors/centre/access_denied.py b/submit-cron/src/submit_cron/processors/centre/access_denied.py new file mode 100644 index 000000000..54c234c63 --- /dev/null +++ b/submit-cron/src/submit_cron/processors/centre/access_denied.py @@ -0,0 +1,46 @@ +from typing import Any, Dict, List + +from submit_api.data_classes.email_details import EmailDetails +from submit_api.exceptions import BadRequestError + +from submit_cron.models.email_job import EmailJob + + +def _require(payload: Dict[str, Any], fields: List[str]) -> None: + missing = [f for f in fields if not payload.get(f)] + if missing: + raise BadRequestError(f"Missing required payload fields: {', '.join(missing)}") + + +def process_access_denied(job: EmailJob) -> EmailDetails: + """ + Processor for the 'access request submitted' template. + + Expected job.payload: + { + "recipients": ["user@example.com"], # required + "user_name": "Jane Doe", # required + "application_name": "EPIC.centre", # required + "sender": "staff@email.com", # required (email address) + } + """ + payload = job.payload or {} + _require(payload, ["recipients", "user_name", "application_name", "sender"]) + + recipients = payload["recipients"] + if not isinstance(recipients, list) or not recipients: + raise BadRequestError("payload.recipients must be a non-empty list of email addresses") + + subject = f"Your EPIC Access Request for {payload['application_name']} Has Been Denied" + + email_details = EmailDetails( + template_name=job.template_name, + body_args={ + 'user_name': payload['user_name'], + 'application_name': payload['application_name'] + }, + subject=subject, + sender=payload['sender'], + recipients=recipients, + ) + return email_details diff --git a/submit-cron/src/submit_cron/processors/centre/access_granted.py b/submit-cron/src/submit_cron/processors/centre/access_granted.py new file mode 100644 index 000000000..9b2c0e797 --- /dev/null +++ b/submit-cron/src/submit_cron/processors/centre/access_granted.py @@ -0,0 +1,50 @@ +from typing import Any, Dict, List + +from submit_api.data_classes.email_details import EmailDetails +from submit_api.exceptions import BadRequestError + +from submit_cron.models.email_job import EmailJob + + +def _require(payload: Dict[str, Any], fields: List[str]) -> None: + missing = [f for f in fields if not payload.get(f)] + if missing: + raise BadRequestError(f"Missing required payload fields: {', '.join(missing)}") + + +def process_access_granted(job: EmailJob) -> EmailDetails: + """ + Processor for the 'access request submitted' template. + + Expected job.payload: + { + "recipients": ["user@example.com"], # required + "user_name": "Jane Doe", # required + "application_name": "EPIC.centre", # required + "sender": "staff@email.com", # required (email address), + "access_level": "VIEWER", + "auth_link: "https://centre.example.com/request-access" # required + } + """ + payload = job.payload or {} + _require(payload, ["recipients", "user_name", "application_name", "sender", "auth_link", "access_level"]) + + recipients = payload["recipients"] + if not isinstance(recipients, list) or not recipients: + raise BadRequestError("payload.recipients must be a non-empty list of email addresses") + + subject = f"Your EPIC Access Request for {payload['application_name']} Has Been Granted" + + email_details = EmailDetails( + template_name=job.template_name, + body_args={ + 'user_name': payload['user_name'], + 'application_name': payload['application_name'], + 'auth_link': payload['auth_link'], + 'access_level': payload['access_level'] + }, + subject=subject, + sender=payload['sender'], + recipients=recipients, + ) + return email_details diff --git a/submit-cron/src/submit_cron/processors/centre/access_request_received_dst.py b/submit-cron/src/submit_cron/processors/centre/access_request_received_dst.py index 8626b5652..165f388c0 100644 --- a/submit-cron/src/submit_cron/processors/centre/access_request_received_dst.py +++ b/submit-cron/src/submit_cron/processors/centre/access_request_received_dst.py @@ -1,4 +1,4 @@ -from typing import Dict, Any, List +from typing import Any, Dict, List from submit_api.data_classes.email_details import EmailDetails from submit_api.exceptions import BadRequestError diff --git a/submit-cron/src/submit_cron/processors/centre/access_request_submitted.py b/submit-cron/src/submit_cron/processors/centre/access_request_submitted.py index 97ca19afe..1139459dd 100644 --- a/submit-cron/src/submit_cron/processors/centre/access_request_submitted.py +++ b/submit-cron/src/submit_cron/processors/centre/access_request_submitted.py @@ -1,4 +1,4 @@ -from typing import Dict, Any, List +from typing import Any, Dict, List from submit_api.data_classes.email_details import EmailDetails from submit_api.exceptions import BadRequestError diff --git a/submit-cron/src/submit_cron/repositories/email_repository.py b/submit-cron/src/submit_cron/repositories/email_repository.py index c4d6041d2..23f0ac652 100644 --- a/submit-cron/src/submit_cron/repositories/email_repository.py +++ b/submit-cron/src/submit_cron/repositories/email_repository.py @@ -1,10 +1,12 @@ from typing import List -from sqlalchemy import Table, Column, Integer, String, Text, MetaData, select, DateTime, func + +from sqlalchemy import Column, DateTime, Integer, MetaData, String, Table, Text, func, select from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Session from submit_cron.models.email_job import EmailJob + metadata = MetaData() # Local definition of the table (decoupled from submit_api.models) diff --git a/submit-cron/src/submit_cron/services/approved_condition_sync_service.py b/submit-cron/src/submit_cron/services/approved_condition_sync_service.py index 28ae1c66b..49ef44e6e 100644 --- a/submit-cron/src/submit_cron/services/approved_condition_sync_service.py +++ b/submit-cron/src/submit_cron/services/approved_condition_sync_service.py @@ -1,8 +1,8 @@ import requests from flask import current_app +from submit_api.models.project import Project from submit_cron.models import db -from submit_api.models.project import Project class ApprovedConditionService: diff --git a/submit-cron/src/submit_cron/services/centre_email_service.py b/submit-cron/src/submit_cron/services/centre_email_service.py index d9c3b62c5..97da66682 100644 --- a/submit-cron/src/submit_cron/services/centre_email_service.py +++ b/submit-cron/src/submit_cron/services/centre_email_service.py @@ -1,12 +1,14 @@ import logging from typing import Callable, Dict -from submit_cron.services.ches_service import ChesApiService -from submit_cron.repositories.email_repository import EmailRepository -from submit_cron.models.email_job import EmailJob from submit_api.data_classes.email_details import EmailDetails from submit_api.exceptions import BadRequestError +from submit_cron.models.email_job import EmailJob +from submit_cron.repositories.email_repository import EmailRepository +from submit_cron.services.ches_service import ChesApiService + + logger = logging.getLogger(__name__) diff --git a/submit-cron/src/submit_cron/services/ches_service.py b/submit-cron/src/submit_cron/services/ches_service.py index 8ade070b3..5f38ea233 100644 --- a/submit-cron/src/submit_cron/services/ches_service.py +++ b/submit-cron/src/submit_cron/services/ches_service.py @@ -5,8 +5,8 @@ import requests from flask import current_app - from submit_api.data_classes.email_details import EmailDetails + from utils.template import Template @@ -63,7 +63,7 @@ def _ensure_valid_token(self): @staticmethod def _get_email_body_from_template(template_name: str, body_args: dict, template_sub_directory: str = None): - """Get email body from a template.""" + """Get email body from a template with optional environment message for centre templates.""" if not template_name: raise ValueError('Template name is required') @@ -77,8 +77,33 @@ def _get_email_body_from_template(template_name: str, body_args: dict, template_ body_args['logo_url'] = f'{current_app.config.get("WEB_URL")}/assets/EAO_Logo-BZOR9oRj.png' rendered_body = template.render(body_args) + # Add environment notification for centre templates in non-production + if template_sub_directory == 'centre': + env_name = current_app.config.get('ENVIRONMENT', '') + if env_name and env_name.lower() != 'production': + env_message = ChesApiService._create_environment_banner(env_name) + rendered_body = ChesApiService._inject_environment_banner(rendered_body, env_message) + return rendered_body + @staticmethod + def _create_environment_banner(env_name: str) -> str: + """Create HTML banner showing the current environment.""" + return f''' +
Hello,
+ +Your access request for {{application_name}} has been denied.
+If you have any questions or believe you should have been approved, please email EAO.EPICsystem@gov.bc.ca.
+ +Thank you,
+ +EPIC.centre System
+' in rendered_body: + return rendered_body.replace('', f'{env_message}') + else: + return rendered_body + env_message + def _get_email_body(self, email_details: EmailDetails, template_sub_directory: str = None): """Get email body based on details or template.""" if email_details.body: diff --git a/submit-cron/src/submit_cron/services/invitation_email_service.py b/submit-cron/src/submit_cron/services/invitation_email_service.py index a3c4137d2..343fb5d64 100644 --- a/submit-cron/src/submit_cron/services/invitation_email_service.py +++ b/submit-cron/src/submit_cron/services/invitation_email_service.py @@ -1,15 +1,16 @@ +from urllib.parse import urljoin + from flask import current_app from submit_api.data_classes.email_details import EmailDetails +from submit_api.enums.role import RoleEnum from submit_api.exceptions import BadRequestError +from submit_api.models.account_project import AccountProject as AccountProjectModel from submit_api.models.invitations import Invitations as InvitationsModel from submit_api.models.package import Package as PackageModel from submit_api.models.project import Project as ProjectModel -from submit_api.enums.role import RoleEnum -from submit_api.models.account_project import AccountProject as AccountProjectModel +from submit_api.utils.constants import NEW_USER_INVITATION_EMAIL_TEMPLATE from submit_cron.models import db -from submit_api.utils.constants import NEW_USER_INVITATION_EMAIL_TEMPLATE -from urllib.parse import urljoin class InvitationEmailService: # pylint: disable=too-few-public-methods diff --git a/submit-cron/src/submit_cron/services/mail_service.py b/submit-cron/src/submit_cron/services/mail_service.py index 9bbdea248..6a1175287 100644 --- a/submit-cron/src/submit_cron/services/mail_service.py +++ b/submit-cron/src/submit_cron/services/mail_service.py @@ -1,23 +1,22 @@ from datetime import datetime +from functools import partial from typing import List from submit_api.data_classes.email_details import EmailDetails from submit_api.exceptions import BadRequestError +from submit_api.models.email_queue import EmailQueue, EmailStatus from submit_api.models.invitations import Invitations as InvitationsModel from submit_api.models.package import Package as PackageModel -from submit_api.models.email_queue import EmailQueue, EmailStatus -from functools import partial +from submit_api.utils.constants import ( + MANAGEMENT_PLAN_RESUBMISSION_REQUEST_EMAIL_TEMPLATE, MANAGEMENT_PLAN_SUBMISSION_CONFIRMATION_EMAIL_TEMPLATE, + MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE, MANAGEMENT_PLAN_UPDATE_REQUEST_CREATED_EMAIL_TEMPLATE, + NEW_USER_INVITATION_EMAIL_TEMPLATE) -from submit_cron.services.package_submission_email_service import PackageSubmissionEmailService -from submit_cron.services.ches_service import ChesApiService from submit_cron.models import db +from submit_cron.services.ches_service import ChesApiService from submit_cron.services.invitation_email_service import InvitationEmailService +from submit_cron.services.package_submission_email_service import PackageSubmissionEmailService from submit_cron.services.request_update_email_service import RequestUpdateEmailService -from submit_api.utils.constants import MANAGEMENT_PLAN_SUBMISSION_CONFIRMATION_EMAIL_TEMPLATE, \ - MANAGEMENT_PLAN_UPDATE_REQUEST_CREATED_EMAIL_TEMPLATE, \ - NEW_USER_INVITATION_EMAIL_TEMPLATE, \ - MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE, \ - MANAGEMENT_PLAN_RESUBMISSION_REQUEST_EMAIL_TEMPLATE from submit_cron.services.resubmission_email_service import ResubmissionEmailService diff --git a/submit-cron/src/submit_cron/services/package_submission_email_service.py b/submit-cron/src/submit_cron/services/package_submission_email_service.py index 747ac80ae..12481049a 100644 --- a/submit-cron/src/submit_cron/services/package_submission_email_service.py +++ b/submit-cron/src/submit_cron/services/package_submission_email_service.py @@ -3,15 +3,16 @@ from submit_api.data_classes.email_details import EmailDetails from submit_api.exceptions import BadRequestError from submit_api.models import AccountProject -from submit_api.models.project import Project as ProjectModel -from submit_api.models.package import Package as PackageModel -from submit_api.models.user import User as UserModel from submit_api.models.account_user import AccountUser as AccountUserModel +from submit_api.models.package import Package as PackageModel +from submit_api.models.project import Project as ProjectModel from submit_api.models.submission import SubmissionType +from submit_api.models.user import User as UserModel +from submit_api.utils.constants import ( + MANAGEMENT_PLAN_SUBMISSION_CONFIRMATION_EMAIL_TEMPLATE, MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE) -from submit_cron.utils import constants from submit_cron.models import db -from submit_api.utils.constants import MANAGEMENT_PLAN_SUBMISSION_CONFIRMATION_EMAIL_TEMPLATE, MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE +from submit_cron.utils import constants from submit_cron.utils.datetime import convert_utc_to_local_str diff --git a/submit-cron/src/submit_cron/services/request_update_email_service.py b/submit-cron/src/submit_cron/services/request_update_email_service.py index 71747abab..88ccbe497 100644 --- a/submit-cron/src/submit_cron/services/request_update_email_service.py +++ b/submit-cron/src/submit_cron/services/request_update_email_service.py @@ -2,9 +2,9 @@ from submit_api.data_classes.email_details import EmailDetails from submit_api.exceptions import BadRequestError from submit_api.models.package import Package as PackageModel +from submit_api.utils.constants import MANAGEMENT_PLAN_UPDATE_REQUEST_CREATED_EMAIL_TEMPLATE from submit_cron.utils import constants -from submit_api.utils.constants import MANAGEMENT_PLAN_UPDATE_REQUEST_CREATED_EMAIL_TEMPLATE class RequestUpdateEmailService: # pylint: disable=too-few-public-methods diff --git a/submit-cron/src/submit_cron/services/resubmission_email_service.py b/submit-cron/src/submit_cron/services/resubmission_email_service.py index 469d429ee..f228c6371 100644 --- a/submit-cron/src/submit_cron/services/resubmission_email_service.py +++ b/submit-cron/src/submit_cron/services/resubmission_email_service.py @@ -1,12 +1,13 @@ from flask import current_app from submit_api.data_classes.email_details import EmailDetails +from submit_api.enums.role import RoleEnum from submit_api.exceptions import BadRequestError -from submit_api.models.package import Package as PackageModel from submit_api.models.account_user import AccountUser as AccountUserModel -from submit_api.models.user_role import UserRole as UserRoleModel +from submit_api.models.package import Package as PackageModel from submit_api.models.role import Role as RoleModel +from submit_api.models.user_role import UserRole as UserRoleModel from submit_api.utils.constants import MANAGEMENT_PLAN_RESUBMISSION_REQUEST_EMAIL_TEMPLATE -from submit_api.enums.role import RoleEnum + from submit_cron.models import db diff --git a/submit-cron/src/submit_cron/utils/datetime.py b/submit-cron/src/submit_cron/utils/datetime.py index d676422c5..75db00256 100644 --- a/submit-cron/src/submit_cron/utils/datetime.py +++ b/submit-cron/src/submit_cron/utils/datetime.py @@ -1,9 +1,11 @@ """Datetime object helper.""" from datetime import datetime + import pytz from flask import current_app + # Constants PACIFIC_TZ = pytz.timezone('US/Pacific') UTC_TZ = pytz.utc diff --git a/submit-cron/tasks/centre_mail.py b/submit-cron/tasks/centre_mail.py index fef23da59..2a4f1f1a7 100644 --- a/submit-cron/tasks/centre_mail.py +++ b/submit-cron/tasks/centre_mail.py @@ -16,10 +16,10 @@ from flask import current_app -from submit_cron.repositories.email_repository import EmailRepository from submit_cron.models.db import init_centre_db, ma -from submit_cron.services.centre_email_service import CentreEmailService from submit_cron.processors.centre import PROCESSORS # noqa: F401 pylint:disable=unused-import +from submit_cron.repositories.email_repository import EmailRepository +from submit_cron.services.centre_email_service import CentreEmailService class CentreMailer: # pylint:disable=too-few-public-methods @@ -27,13 +27,14 @@ class CentreMailer: # pylint:disable=too-few-public-methods @classmethod def send_mail(cls): - print("Starting Centre Email At---", datetime.now()) - _Session = init_centre_db(current_app) - session = _Session() + """Send queued Centre emails using registered processors.""" + print('Starting Centre Email At---', datetime.now()) + _session = init_centre_db(current_app) + session = _session() ma.init_app(current_app) for template_name, processor in PROCESSORS.items(): - current_app.logger.debug(f"Registering processor for template: {template_name}") + current_app.logger.debug(f'Registering processor for template: {template_name}') CentreEmailService.register_processor(template_name, processor) repo = EmailRepository(session) diff --git a/submit-cron/templates/centre/access_denied_notification.html b/submit-cron/templates/centre/access_denied_notification.html new file mode 100644 index 000000000..68065cf47 --- /dev/null +++ b/submit-cron/templates/centre/access_denied_notification.html @@ -0,0 +1,87 @@ + + +
+ +
+ + +
+