diff --git a/met-api/migrations/versions/b1196306955f_tracking_id_for_metadata.py b/met-api/migrations/versions/b1196306955f_tracking_id_for_metadata.py new file mode 100644 index 000000000..6bf2285f1 --- /dev/null +++ b/met-api/migrations/versions/b1196306955f_tracking_id_for_metadata.py @@ -0,0 +1,28 @@ +"""tracking id for metadata + +Revision ID: b1196306955f +Revises: 7ebd9ecfccdd +Create Date: 2023-09-17 22:11:54.456358 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'b1196306955f' +down_revision = '7ebd9ecfccdd' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('engagement_metadata', sa.Column('project_tracking_id', sa.String(length=100), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('engagement_metadata', 'project_tracking_id') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/models/engagement_metadata.py b/met-api/src/met_api/models/engagement_metadata.py index 4a591cd17..53650426d 100644 --- a/met-api/src/met_api/models/engagement_metadata.py +++ b/met-api/src/met_api/models/engagement_metadata.py @@ -21,6 +21,12 @@ class EngagementMetadataModel(BaseModel): engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), primary_key=True) project_id = db.Column(db.String(50), unique=False, nullable=True) project_metadata = db.Column(postgresql.JSONB(astext_type=db.Text()), unique=False, nullable=True) + project_tracking_id = db.Column(db.String(100), unique=False, nullable=True) + + @classmethod + def find_by_engagement_id(cls, engagement_id): + """Return engagement slug by engagement id.""" + return cls.query.filter_by(engagement_id=engagement_id).first() @classmethod def update(cls, engagement_metadata_data: dict) -> Optional[EngagementMetadataModel]: diff --git a/met-api/src/met_api/services/engagement_metadata_service.py b/met-api/src/met_api/services/engagement_metadata_service.py index d90cc05a1..a356f4722 100644 --- a/met-api/src/met_api/services/engagement_metadata_service.py +++ b/met-api/src/met_api/services/engagement_metadata_service.py @@ -7,7 +7,7 @@ from met_api.models.engagement_metadata import EngagementMetadataModel from met_api.schemas.engagement_metadata import EngagementMetadataSchema from met_api.services import authorization -from met_api.utils import eao_util +from met_api.services.project_service import ProjectService from met_api.utils.roles import Role @@ -43,7 +43,7 @@ def create_metadata(request_json: dict): metadata_model.commit() updated_metadata: EngagementMetadataModel = metadata_model.find_by_id(metadata_model.engagement_id) # publish changes to EPIC - eao_util.publish_to_epic(updated_metadata.project_id, updated_metadata.engagement_id) + ProjectService.update_project_info(updated_metadata.project_id, updated_metadata.engagement_id) return updated_metadata @staticmethod @@ -81,7 +81,7 @@ def update_metadata(data: dict): updated_metadata = EngagementMetadataService._create_metadata_model(data) # publish changes to EPIC - eao_util.publish_to_epic(updated_metadata.project_id, updated_metadata.engagement_id) + ProjectService.update_project_info(updated_metadata.project_id, updated_metadata.engagement_id) return updated_metadata diff --git a/met-api/src/met_api/services/project_service.py b/met-api/src/met_api/services/project_service.py new file mode 100644 index 000000000..9be218615 --- /dev/null +++ b/met-api/src/met_api/services/project_service.py @@ -0,0 +1,78 @@ +"""Service for project management.""" +import logging +from http import HTTPStatus +from flask import current_app + +from met_api.models.engagement import Engagement as EngagementModel +from met_api.models.engagement_metadata import EngagementMetadataModel +from met_api.services.email_verification_service import EmailVerificationService +from met_api.services.rest_service import RestService +from met_api.utils import notification + + +class ProjectService: + """Project management service.""" + + @staticmethod + def update_project_info(project_id: str, eng_id: str) -> EngagementModel: + """Publish new comment period to EPIC/EAO system.""" + logger = logging.getLogger(__name__) + + try: + is_eao_environment = current_app.config.get('IS_EAO_ENVIRONMENT') + if not is_eao_environment: + return + + engagement, engagement_metadata = ProjectService._get_engagement_and_metadata(eng_id) + + epic_comment_period_payload = ProjectService._construct_epic_payload(engagement, project_id) + + eao_service_account_token = ProjectService._get_eao_service_account_token() + + if engagement_metadata and engagement_metadata.project_tracking_id: + update_url = f'{current_app.config.get("EPIC_URL")}/{engagement_metadata.project_tracking_id}' + RestService.put(endpoint=update_url, token=eao_service_account_token, data=epic_comment_period_payload, + raise_for_status=False) + else: + create_url = f'{current_app.config.get("EPIC_URL")}' + api_response = RestService.post(endpoint=create_url, token=eao_service_account_token, + data=epic_comment_period_payload, raise_for_status=False) + response_data = api_response.json() + + if api_response.status_code == HTTPStatus.OK: + tracking_number = response_data.get('accountNumber') + engagement_metadata.project_tracking_id = tracking_number + engagement_metadata.commit() + + except Exception as e: # NOQA # pylint:disable=broad-except + logger.error('Error in update_project_info: %s', str(e)) + + @staticmethod + def _get_engagement_and_metadata(eng_id: str): + engagement = EngagementModel.find_by_id(eng_id) + engagement_metadata = EngagementMetadataModel.find_by_engagement_id(eng_id) + return engagement, engagement_metadata + + @staticmethod + def _construct_epic_payload(engagement, project_id): + site_url = notification.get_tenant_site_url(engagement.tenant_id) + epic_comment_period_payload = { + 'isMet': True, + 'metURL': f'{site_url}{EmailVerificationService.get_engagement_path(engagement)}', + 'dateCompleted': engagement.end_date, + 'dateStarted': engagement.start_date, + 'instructions': '', + 'commentTip': '', + 'milestone': current_app.config.get('EPIC_MILESTONE'), + 'openHouse': '', + 'relatedDocuments': '', + 'project': project_id + } + return epic_comment_period_payload + + @staticmethod + def _get_eao_service_account_token(): + kc_service_id = current_app.config.get('EPIC_KEYCLOAK_SERVICE_ACCOUNT_ID') + kc_secret = current_app.config.get('EPIC_KEYCLOAK_SERVICE_ACCOUNT_SECRET') + issuer_url = current_app.config.get('EPIC_JWT_OIDC_ISSUER') + return RestService.get_service_account_token(kc_service_id, kc_secret, issuer_url) diff --git a/met-api/src/met_api/services/rest_service.py b/met-api/src/met_api/services/rest_service.py index 6b6311019..4896d85ed 100644 --- a/met-api/src/met_api/services/rest_service.py +++ b/met-api/src/met_api/services/rest_service.py @@ -13,12 +13,13 @@ # limitations under the License. """Service to invoke Rest services.""" import json +from typing import Iterable import requests -from flask import current_app +from flask import current_app, request +from requests.exceptions import ConnectTimeout, HTTPError # pylint:disable=ungrouped-imports from requests.exceptions import ConnectionError as ReqConnectionError -from requests.exceptions import ConnectTimeout, HTTPError from met_api.utils.enums import AuthHeaderType, ContentType @@ -27,16 +28,27 @@ class RestService: """Service to invoke Rest services which uses OAuth 2.0 implementation.""" @staticmethod - def post(endpoint, token=None, # pylint: disable=too-many-arguments - auth_header_type: AuthHeaderType = AuthHeaderType.BEARER, - content_type: ContentType = ContentType.JSON, data=None, raise_for_status: bool = True): - """POST service.""" - current_app.logger.debug('= 500: raise Exception(exc) from exc raise exc finally: - current_app.logger.info(f'response : {response.text if response else ""}') + RestService.__log_response(response) current_app.logger.debug('>post') return response + @staticmethod + def __log_response(response): + if response is not None: + current_app.logger.info(f'Response Headers {response.headers}') + if response.headers and isinstance(response.headers, Iterable) and \ + 'Content-Type' in response.headers and \ + response.headers['Content-Type'] == ContentType.JSON.value: + current_app.logger.info(f"response : {response.text if response else ''}") + + @staticmethod + def post(endpoint, token=None, # pylint: disable=too-many-arguments + auth_header_type: AuthHeaderType = AuthHeaderType.BEARER, + content_type: ContentType = ContentType.JSON, data=None, raise_for_status: bool = True, + additional_headers: dict = None, generate_token: bool = True): + """POST service.""" + current_app.logger.debug(' str: """Generate a service account token.""" @@ -78,3 +118,8 @@ def get_service_account_token(kc_service_id: str = None, kc_secret: str = None, 'Content-Type': ContentType.FORM_URL_ENCODED.value}, data='grant_type=client_credentials') auth_response.raise_for_status() return auth_response.json().get('access_token') + + +def _get_token() -> str: + token: str = request.headers['Authorization'] if request and 'Authorization' in request.headers else None + return token.replace('Bearer ', '') if token else None diff --git a/met-api/src/met_api/utils/eao_util.py b/met-api/src/met_api/utils/eao_util.py deleted file mode 100644 index ba9a2c53b..000000000 --- a/met-api/src/met_api/utils/eao_util.py +++ /dev/null @@ -1,67 +0,0 @@ -"""EAO publishing utility. - -Manages data transfer to eao -""" - -from flask import current_app - -from met_api.models.engagement import Engagement as EngagementModel -from met_api.services.email_verification_service import EmailVerificationService -from met_api.services.rest_service import RestService -from met_api.utils import notification - - -def publish_to_epic(project_id: str, eng_id: str): - """Publish new comment period to EPIC/EAO system. - - EAO accepts data in the below format. - { - "isMet":"true", - "metURL":"https://eagle-dev.apps.silver.devops.gov.bc.ca", - "dateCompleted":"2023-03-31 23:11:49.582", - "dateStarted":"2023-01-31 23:11:49.582", - "instructions":"", - "commentTip":"", - "milestone":"5cf00c03a266b7e1877504e9", - "openHouse":"", - "relatedDocuments":"", - "project":"5d40cc5b4cb2c7001b1336b8" - } - """ - try: - # publish to epic should happen only in EAO environments - is_eao_environment = current_app.config.get('IS_EAO_ENVIRONMENT') - if not is_eao_environment: - return - - kc_service_id = current_app.config.get('EPIC_KEYCLOAK_SERVICE_ACCOUNT_ID') - kc_secret = current_app.config.get('EPIC_KEYCLOAK_SERVICE_ACCOUNT_SECRET') - issuer_url = current_app.config.get('EPIC_JWT_OIDC_ISSUER') - eao_service_account_token = RestService.get_service_account_token(kc_service_id, kc_secret, issuer_url) - eao_endpoint = current_app.config.get('EPIC_URL') - engagement: EngagementModel = EngagementModel.find_by_id(eng_id) - - engagement_path = EmailVerificationService.get_engagement_path( - engagement) - site_url = notification.get_tenant_site_url(engagement.tenant_id) - - epic_comment_period_payload = dict( - isMet=True, - metURL=f'{site_url}{engagement_path}', - dateCompleted=engagement.end_date, - dateStarted=engagement.start_date, - instructions='', - commentTip='', - milestone=current_app.config.get('EPIC_MILESTONE'), - openHouse='', - relatedDocuments='', - project=project_id - ) - - RestService.post(endpoint=eao_endpoint, - token=eao_service_account_token, - data=epic_comment_period_payload, - raise_for_status=False) - except Exception as e: # NOQA # pylint:disable=broad-except - # Log the error and continue execution without raising the exception - current_app.logger.error(f'Error in publish_to_epic: {str(e)}')