"""Contract logic.""" from datetime import date import logging from typing import Type from abacus_common_logic.connectors.database import db from abacus_common_logic.constants.constants import DATE_FORMAT import httpx from marshmallow import ValidationError from owsresponse import response import sqlalchemy from abacus_contract import models from abacus_contract.config import Config from abacus_contract.connectors import ows_abacus_account from abacus_contract.connectors.kafka import emit_contract_event from abacus_contract.constants import constants from abacus_contract.constants import error from abacus_contract.logic import contract_exclusion from abacus_contract.logic import legacy_contract from abacus_contract.logic.account_contract import create_account_contract from abacus_contract.logic.contract_lifecycle import \ _create_contract_lifecycle from abacus_contract.logic.contract_lifecycle import \ reactivate_contract_lifecycle from abacus_contract.logic.contract_lifecycle import \ terminate_contract_lifecycle from abacus_contract.logic.contract_lifecycle_schedule import \ _create_contract_lifecycle_schedules from abacus_contract.logic.contract_lifecycle_schedule import \ _validate_request_payload from abacus_contract.schemas.contract import ( ContractDetail, ContractDetailSchema, ContractSapFormattedSchema, ContractVatInfoSchema, ) from abacus_contract.utils.format_error import validation_error from abacus_contract.utils.format_response import \ prepare_dataload_response, prepare_dataload_with_data_as_list_response contract_detail_schema = ContractDetailSchema() CONTRACTS_SNAPSHOT_HEADER = ( 'contract_id', 'account_id', 'term_start', 'term_end', 'payee_currency_code' ) logger = logging.getLogger(Config.LOGGER_NAME) def create_contract( contract_name: str, contract_type: str, contract_id: int | None = None, execution_date: date | None = None, reference_signing_entity_id: int | None = None, term_start: date | None = None, # TODO: remove (deprecated) term_end: date | None = None, # TODO: remove (deprecated) oa_contract_id: int | None = None, account_id: int | None = None, contract_exclusions: dict | None = None, summary_note: str | None = None, general_note: str | None = None, is_excluded_from_accounting_run: bool | None = None, ) -> response.Response: """Create a contract. Args: contract_name (str): name of the contract contract_type (str): type of contract (i.e. 'distribution') contract_id (int): ID of the contract; optional execution_date (date): date on which the contract was countersigned reference_signing_entity_id (int): id of the reference_signing_entity; optional term_start (date): date the contract starts; DEPRECATED term_end (date): date the contract ends; DEPRECATED oa_contract_id (int): ID of legacy contract in OA; optional (legacy only) account_id (int): ID of the account to which the new contract belongs; optional contract_exclusions (dict): countries/stores excluded from delivery; optional summary_note (str): summary notes for the contract general_note (str): general notes for the contract Returns: a response.Response """ try: if contract_id and models.Contract.get_by_id(contract_id): raise ValidationError( error.ERROR_CONTRACT_ID_ALREADY_EXISTS.format(contract_id=contract_id) ) if contract_type == constants.CONTRACT_TYPES.LEGACY_DISTRIBUTION: raise ValidationError( error.ERROR_INVALID_CONTRACT_TYPE.format(contract_type=contract_type) ) try: if not _account_exists(account_id): raise ValidationError( error.ERROR_ACCOUNT_NOT_FOUND.format( account_id=account_id ) ) except httpx.HTTPError as e: logger.error(f'Failed to get account {account_id}: {e}') return response.create_error_response('error', str(e), 500) new_contract = models.Contract.create( contract_id=contract_id, contract_name=contract_name, contract_type=contract_type, execution_date=execution_date, reference_signing_entity_id=reference_signing_entity_id, term_start=term_start, term_end=term_end, summary_note=summary_note, general_note=general_note, is_excluded_from_accounting_run=is_excluded_from_accounting_run ) if oa_contract_id: legacy_contract.create_legacy_contract( new_contract.contract_id, oa_contract_id ) else: if not contract_exclusions: contract_exclusions = constants.DEFAULT_CONTRACT_EXCLUSIONS new_contract_exclusion = contract_exclusion.create_contract_exclusions( new_contract.contract_id, contract_exclusions ) if new_contract_exclusion.status != 201: return new_contract_exclusion if account_id: account_contract = create_account_contract( account_id=account_id, contract_id=new_contract.contract_id ) if account_contract.status != 201: message = account_contract.errors and \ account_contract.errors.get('message') if not message: message = error.ERROR_FAILURE_TO_CREATE.format('account_contract') raise ValidationError(message) emit_contract_event( new_contract.contract_id, constants.CONTRACT_KAFKA_EVENT_NAMES.CONTRACT_CREATED ) return response.Response( message=contract_detail_schema.dump(new_contract), status=201 ) except ValidationError as e: return validation_error(str(e)) except sqlalchemy.exc.SQLAlchemyError as e: db.session.rollback() raise e def _account_exists(account_id: int | None) -> bool: """Check if account exists.""" return account_id is None or ows_abacus_account.get_account( account_id ).status_code == 200 def update_contract( contract: models.Contract, **params: dict ) -> Type[response.Response]: """Update contract's fields. contract(models.Contract): Contract model object params(dict): PUT request body - Optional fields: - contract_name: name of the contract - sap_created_at: date when contract data is sent to SAP - reference_signing_entity_id: id of the reference_signing_entity - term_start: term start date of the contract; DEPRECATED - term_end: term end date of the contract; DEPRECATED - summary_note: summary notes for the contract - general_note: general notes for the contract - is_excluded_from_accounting_run: whether a contract should be used during the accounting run calculation - execution_date: date on which the contract was countersigned - initial_start_date: date on which the contract was first activated Returns: updated contract data """ try: contract.update_attributes(**params) models.Contract.commit_changes() except ValidationError as e: return validation_error(str(e)) except sqlalchemy.exc.SQLAlchemyError as e: db.session.rollback() raise e emit_contract_event( contract.contract_id, constants.CONTRACT_KAFKA_EVENT_NAMES.CONTRACT_UPDATED ) return response.Response( message=contract_detail_schema.dump(contract), status=200 ) def get_contracts_by_ids(contract_ids: list[int]) -> list[ContractDetail]: """Get contracts by their identifiers.""" contracts = models.Contract.get_by_ids(contract_ids) return ContractDetailSchema(many=True).dump(contracts) def format_contracts_for_dataloader( contract_ids: list[int], contracts: list[ContractDetail]) -> response.Response: """Get contracts by their identifiers with dataload format.""" result = prepare_dataload_response( contract_ids, contracts, 'contract_id' ) return response.Response(message=result, status=200) def get_contracts_by_account(account_ids: list, dataload: bool = False): """Get contracts associated to the specified accounts.""" contracts = models.Contract.get_by_accounts(account_ids) contracts_items = ContractDetailSchema(many=True).dump(contracts) if dataload: message = prepare_dataload_with_data_as_list_response( account_ids, contracts_items, 'account_id', ) else: message = { 'items': contracts_items, 'total_count': len(contracts) } return response.Response(message=message, status=200) def get_contracts_by_oa_contract_ids(oa_contract_ids): """Get contracts related to specified oa_contract_ids.""" contracts = models.Contract.get_by_legacy_contract_ids(oa_contract_ids) message = ContractDetailSchema(many=True).dump(contracts) return response.Response(message=message, status=200) def get_vat_info_by_contract_ids(contract_ids): """GET contracts vat info by list of contract ids.""" vat_info = models.Contract.get_contract_vat_info_by_contract_ids(contract_ids) message = ContractVatInfoSchema(many=True).dump(vat_info) return response.Response(message=message, status=200) def contract_export(contract_ids=None): """Get contract terms as csv data.""" yield '\t'.join(CONTRACTS_SNAPSHOT_HEADER) + '\n' for item in models.Contract.stream_all(contract_ids=contract_ids): yield build_contract_row(item) def build_contract_row(item): """Format a contract row.""" return '\t'.join( [ str(item.contract_id), str(item.account_id), item.term_start.strftime(DATE_FORMAT) if item.term_start else '', item.term_end.strftime(DATE_FORMAT) if item.term_end else '', item.currency_code ] ) + '\n' def _create_contract(post_request_payload: dict, initial_start_date: str) -> object: """Create contract, account_contract and legacy_contract. Args: post_payload (dict): POST request payload having below fields - contract_id (int): id of the contract - contract_name (str): name of the contract - contract_type (str): type of the contract either distribution or neighbouring_rights - execution_date (date): date on which the contract was countersigned - reference_signing_entity_id (int): id of the reference_signing_entity - oa_contract_id (int): id of the legacy contract in OA; optional (legacy only) - account_id (int): id of the account to which the new contract belongs - summary_note (str): summary notes for the contract - general_note (str): general notes for the contract - contract_exclusions (dict): countries/stores excluded from delivery initial_start_date: start date of the contract Returns: a contract record """ reference_signing_entity_id = \ post_request_payload.get('reference_signing_entity_id') account_id = post_request_payload.get('account_id') contract_id = post_request_payload.get('contract_id', None) contract_name = post_request_payload.get('contract_name') contract_type = post_request_payload.get('contract_type') execution_date = post_request_payload.get('execution_date', None) summary_note = post_request_payload.get('summary_note', None) general_note = post_request_payload.get('general_note', None) oa_contract_id = post_request_payload.get('oa_contract_id', None) contract_exclusions = post_request_payload.get('contract_exclusions', None) if contract_id and models.Contract.get_by_id(contract_id): raise ValidationError( error.ERROR_CONTRACT_ID_ALREADY_EXISTS.format( contract_id=contract_id ) ) new_contract = models.Contract.build( contract_id=contract_id, contract_name=contract_name, contract_type=contract_type, execution_date=execution_date, reference_signing_entity_id=reference_signing_entity_id, summary_note=summary_note, general_note=general_note, initial_start_date=initial_start_date ) db.session.flush() contract_id = new_contract.contract_id models.AccountContract.build( account_id=account_id, contract_id=contract_id ) db.session.flush() if oa_contract_id: models.LegacyContract.build( contract_id=contract_id, oa_contract_id=oa_contract_id ) else: if not contract_exclusions: contract_exclusions = constants.DEFAULT_CONTRACT_EXCLUSIONS models.ContractExclusion.build( contract_id=contract_id, exclusions=contract_exclusions ) return new_contract def create_contract_with_lifecycle_and_schedules( contract: dict, contract_lifecycle: dict, contract_lifecycle_schedules: list ) -> response.Response: """Create a contract with contract lifecycle and schedules. Args: contract (dict): contract payload contract_lifecycle (dict): contract_lifecycle payload contract_lifecycle_schedules (list): contract_lifecycle_schedule payload Returns: a contract with contract_lifecycle and contract_lifecycle_schedules """ lifecycle_term_start = contract_lifecycle.get('lifecycle_term_start') try: contract_type = contract.get('contract_type') if contract_type == constants.CONTRACT_TYPES.LEGACY_DISTRIBUTION: raise ValidationError( error.ERROR_INVALID_CONTRACT_TYPE.format( contract_type=contract_type ) ) new_contract = _create_contract(contract, lifecycle_term_start) contract_id = new_contract.contract_id contract_type = new_contract.contract_type _validate_request_payload( contract_id, contract_type, [], contract_lifecycle_schedules ) new_contract_lifecycle_schedules = _create_contract_lifecycle_schedules( contract_id, contract_lifecycle_schedules ) db.session.flush() _create_contract_lifecycle( contract_id, lifecycle_term_start, new_contract_lifecycle_schedules[0] ) db.session.flush() except ValidationError as e: db.session.rollback() return validation_error(str(e)) except sqlalchemy.exc.SQLAlchemyError as e: db.session.rollback() raise e except Exception as e: db.session.rollback() raise e db.session.commit() emit_contract_event( new_contract.contract_id, constants.CONTRACT_KAFKA_EVENT_NAMES.CONTRACT_CREATED ) return response.Response( message=ContractDetailSchema().dump(new_contract), status=201 ) def terminate_contract( contract_id: int, termination_effective: date, termination_notice_received: date ): """Terminate a contract. Args: contract_id (int): ID of the contract termination_effective (date): date of termination termination_notice_received (date): date of notice received Returns: a contract record """ contract = models.Contract.get_by_id_or_error(contract_id) try: terminate_contract_lifecycle( contract_id, termination_effective, termination_notice_received, ) except ValidationError as e: return validation_error(str(e)) except sqlalchemy.exc.SQLAlchemyError as e: db.session.rollback() raise e return response.Response( message=contract_detail_schema.dump(contract), status=200 ) def reactivate_contract(contract_id: int) -> response.Response: """Reactivate a contract by id. Args: contract_id (int): id of the contract Returns: a contract record """ try: contract = models.Contract.get_by_id_or_error(contract_id) reactivate_contract_lifecycle(contract_id) except ValidationError as e: return validation_error(str(e)) return response.Response( message=contract_detail_schema.dump(contract), status=200 ) def get_account_ids_by_contract_ids(contract_ids: list[int]) -> list[int | None]: """Get account ids by contract ids. Args: contract_ids (list[int]): list of contract ids Returns: list[int | None]: list of account ids or None if no contract is found """ contracts = ( db.session .query(models.Contract) .options( sqlalchemy.orm.joinedload(models.Contract.account_contract) ) .filter(models.Contract.contract_id.in_(contract_ids)) .all() ) return [ contract.account_contract.account_id if contract.account_contract else None for contract in contracts ] def get_account_id_by_contract_id(contract_id: int) -> int | None: """Get account id by contract id. Args: contract_id (int): id of the contract Returns: int: id of the account None: if no contract is found """ contract = ( db.session .query(models.Contract) .options( sqlalchemy.orm.joinedload(models.Contract.account_contract) ) .filter(models.Contract.contract_id == contract_id) .one_or_none() ) if contract and contract.account_contract: return contract.account_contract.account_id return None def can_contract_be_deleted(contract_id: int) -> response.Response: """Check if a contract can be deleted.""" return response.Response( message={'can_be_deleted': models.Contract.can_be_deleted(contract_id)}, status=200 ) def delete_contract(contract_id: int) -> response.Response: """Delete a contract.""" can_be_deleted = models.Contract.can_be_deleted(contract_id) if not can_be_deleted: raise ValidationError( error.ERROR_CONTRACT_CANNOT_BE_DELETED.format(contract_id=contract_id) ) models.Contract.delete(contract_id) return response.Response( message={'deleted': True}, status=200 ) def sap_details(contract_id) -> response.Response: """Return formatted contract data for SAP using schema.""" sap_details = models.Contract.get_sap_profit_center_by_contract_id( contract_id=contract_id ) # Serialize with schema message = ContractSapFormattedSchema().dump(sap_details) return response.Response(message=message, status=200)