From c27ddcd36147bd6c99a092dbb738debab0e5c51f Mon Sep 17 00:00:00 2001 From: VineetBala-AOT <90332175+VineetBala-AOT@users.noreply.github.com> Date: Wed, 13 Mar 2024 09:48:08 -0700 Subject: [PATCH] Added Engagement translation model (#2412) (#2415) --- CHANGELOG.MD | 6 + ...4ed_create_engagement_translation_table.py | 50 ++++ met-api/src/met_api/models/__init__.py | 1 + .../met_api/models/engagement_translation.py | 102 ++++++++ met-api/src/met_api/resources/__init__.py | 2 + .../resources/engagement_translation.py | 132 +++++++++++ .../met_api/schemas/engagement_translation.py | 26 +++ .../schemas/engagement_translation.json | 31 +++ .../engagement_translation_service.py | 149 ++++++++++++ .../unit/api/test_engagement_translation.py | 217 ++++++++++++++++++ met-api/tests/utilities/factory_scenarios.py | 14 ++ met-api/tests/utilities/factory_utils.py | 25 +- 12 files changed, 751 insertions(+), 4 deletions(-) create mode 100644 met-api/migrations/versions/c4f7189494ed_create_engagement_translation_table.py create mode 100644 met-api/src/met_api/models/engagement_translation.py create mode 100644 met-api/src/met_api/resources/engagement_translation.py create mode 100644 met-api/src/met_api/schemas/engagement_translation.py create mode 100644 met-api/src/met_api/schemas/schemas/engagement_translation.json create mode 100644 met-api/src/met_api/services/engagement_translation_service.py create mode 100644 met-api/tests/unit/api/test_engagement_translation.py diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 548e80297..50a958408 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,9 @@ +## March 08, 2024 +- **Task**Multi-language - Create engagement translation table & API routes [DESENG-510](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-510) + - Added Engagement translation model. + - Added Engagement translation API. + - Added Unit tests. + ## March 06, 2024 - **Task**Multi-language - Create simple widget translation tables & API routes [DESENG-514](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-514) - Added Widget translation model. diff --git a/met-api/migrations/versions/c4f7189494ed_create_engagement_translation_table.py b/met-api/migrations/versions/c4f7189494ed_create_engagement_translation_table.py new file mode 100644 index 000000000..fedd36d7c --- /dev/null +++ b/met-api/migrations/versions/c4f7189494ed_create_engagement_translation_table.py @@ -0,0 +1,50 @@ +"""create_engagement_translation_table + +Revision ID: c4f7189494ed +Revises: 35124d2e41cb +Create Date: 2024-03-07 16:38:26.958748 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c4f7189494ed' +down_revision = '35124d2e41cb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('engagement_translation', + sa.Column('created_date', sa.DateTime(), nullable=False), + sa.Column('updated_date', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('engagement_id', sa.Integer(), nullable=False), + sa.Column('language_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('rich_description', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('rich_content', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('consent_message', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('slug', sa.String(length=200), nullable=True), + sa.Column('upcoming_status_block_text', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('open_status_block_text', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('closed_status_block_text', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['language_id'], ['language.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('engagement_id', 'language_id', name='_engagement_language_uc') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('engagement_translation') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index c1c889f80..ce0468da6 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -61,3 +61,4 @@ from .language import Language from .widget_translation import WidgetTranslation from .survey_translation import SurveyTranslation +from .engagement_translation import EngagementTranslation diff --git a/met-api/src/met_api/models/engagement_translation.py b/met-api/src/met_api/models/engagement_translation.py new file mode 100644 index 000000000..37b3967fb --- /dev/null +++ b/met-api/src/met_api/models/engagement_translation.py @@ -0,0 +1,102 @@ +"""Engagement translation model class. + +Manages the Engagement Translations. +""" + +from __future__ import annotations +from typing import Optional + +from sqlalchemy import UniqueConstraint +from sqlalchemy.dialects.postgresql import JSON + +from .base_model import BaseModel +from .db import db + + +class EngagementTranslation(BaseModel): + """Definition of the Engagement Translation entity.""" + + __tablename__ = 'engagement_translation' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + engagement_id = db.Column(db.Integer, db.ForeignKey('engagement.id', ondelete='CASCADE'), nullable=False) + language_id = db.Column(db.Integer, db.ForeignKey('language.id', ondelete='CASCADE'), nullable=False) + name = db.Column(db.String(50)) + description = db.Column(db.Text()) + rich_description = db.Column(JSON, unique=False, nullable=True) + content = db.Column(db.Text()) + rich_content = db.Column(JSON, unique=False, nullable=True) + consent_message = db.Column(JSON, unique=False, nullable=True) + slug = db.Column(db.String(200)) + upcoming_status_block_text = db.Column(JSON, unique=False, nullable=True) + open_status_block_text = db.Column(JSON, unique=False, nullable=True) + closed_status_block_text = db.Column(JSON, unique=False, nullable=True) + + # Add a unique constraint on engagement_id and language_id + # A engagement has only one version in a particular language + __table_args__ = ( + UniqueConstraint( + 'engagement_id', 'language_id', name='_engagement_language_uc' + ), + ) + + @staticmethod + def get_engagement_translation_by_engagement_and_language( + engagement_id=None, language_id=None + ): + """Get engagement translation by engagement_id and language_id, or by either one.""" + query = EngagementTranslation.query + if engagement_id is not None: + query = query.filter_by(engagement_id=engagement_id) + if language_id is not None: + query = query.filter_by(language_id=language_id) + + engagement_translation_records = query.all() + return engagement_translation_records + + @classmethod + def create_engagement_translation(cls, data): + """Create a new engagement translation.""" + engagement_translation = cls.__create_new_engagement_translation_entity(data) + db.session.add(engagement_translation) + db.session.commit() + return engagement_translation + + @staticmethod + def __create_new_engagement_translation_entity(data): + """Create new engagement translation entity.""" + return EngagementTranslation( + engagement_id=data.get('engagement_id'), + language_id=data.get('language_id'), + name=data.get('name', None), + description=data.get('description', None), + rich_description=data.get('rich_description', None), + content=data.get('content', None), + rich_content=data.get('rich_content', None), + consent_message=data.get('consent_message', None), + slug=data.get('slug', None), + upcoming_status_block_text=data.get('upcoming_status_block_text', None), + open_status_block_text=data.get('open_status_block_text', None), + closed_status_block_text=data.get('closed_status_block_text', None), + ) + + @staticmethod + def update_engagement_translation(engagement_translation_id, data: dict) -> Optional[EngagementTranslation]: + """Update an existing engagement translation.""" + query = EngagementTranslation.query.filter_by(id=engagement_translation_id) + engagement_translation: EngagementTranslation = query.first() + if not engagement_translation: + return None + query.update(data) + db.session.commit() + return engagement_translation + + @staticmethod + def delete_engagement_translation(engagement_translation_id): + """Delete a engagement translation.""" + engagement_translation = EngagementTranslation.query.get(engagement_translation_id) + if engagement_translation: + db.session.delete(engagement_translation) + db.session.commit() + return True + return False diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index 62878e7d4..5e362f44e 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -58,6 +58,7 @@ from .language import API as LANGUAGE_API from .widget_translation import API as WIDGET_TRANSLATION_API from .survey_translation import API as SURVEY_TRANSLATION_API +from .engagement_translation import API as ENGAGEMENT_TRANSLATION_API __all__ = ('API_BLUEPRINT',) @@ -107,3 +108,4 @@ API.add_namespace(LANGUAGE_API, path='/languages') API.add_namespace(WIDGET_TRANSLATION_API, path='/widget//translations') API.add_namespace(SURVEY_TRANSLATION_API, path='/surveys//translations') +API.add_namespace(ENGAGEMENT_TRANSLATION_API, path='/engagement//translations') diff --git a/met-api/src/met_api/resources/engagement_translation.py b/met-api/src/met_api/resources/engagement_translation.py new file mode 100644 index 000000000..1dcbf7863 --- /dev/null +++ b/met-api/src/met_api/resources/engagement_translation.py @@ -0,0 +1,132 @@ +# Copyright © 2021 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for managing an engagement translation resource.""" + +from http import HTTPStatus + +from flask import jsonify, request +from flask_cors import cross_origin +from flask_restx import Namespace, Resource +from marshmallow import ValidationError + +from met_api.auth import jwt as _jwt +from met_api.exceptions.business_exception import BusinessException +from met_api.schemas import utils as schema_utils +from met_api.schemas.engagement_translation import EngagementTranslationSchema +from met_api.services.engagement_translation_service import EngagementTranslationService +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace('engagement_translation', description='Endpoints for Engagement translation Management') + + +@cors_preflight('GET, OPTIONS') +@API.route('/language/') +class EngagementTranslationResourceByLanguage(Resource): + """Resource for managing a engagement translation.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(engagement_id, language_id): + """Fetch a engagement by widget_id and language_id.""" + try: + engagement = EngagementTranslationService().get_translation_by_engagement_and_language( + engagement_id, language_id) + return jsonify(engagement), HTTPStatus.OK + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + +@cors_preflight('POST, OPTIONS') +@API.route('/') +class EngagementTranslations(Resource): + """Resource for creating a engagement translation.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(engagement_id): + """Add new engagement translation.""" + try: + request_json = request.get_json() + request_json['engagement_id'] = engagement_id + valid_format, errors = schema_utils.validate(request_json, 'engagement_translation') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + + pre_populate = request_json.get('pre_populate', True) + + engagement_translation = EngagementTranslationSchema().load(request_json) + created_engagement_translation = EngagementTranslationService().create_engagement_translation( + engagement_translation, pre_populate) + return created_engagement_translation, HTTPStatus.OK + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValidationError as err: + return str(err.messages), HTTPStatus.BAD_REQUEST + except BusinessException as err: + return err.error, HTTPStatus.CONFLICT + + +@cors_preflight('GET, DELETE, PATCH, OPTIONS') +@API.route('/') +class EngagementTranslation(Resource): + """Resource for managing engagement translations.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + # pylint: disable=unused-argument + def get(engagement_id, engagement_translation_id): + """Fetch a engagement translation by id.""" + try: + engagement_translation = ( + EngagementTranslationService.get_engagement_translation_by_id( + engagement_translation_id + ) + ) + return ( + EngagementTranslationSchema().dump(engagement_translation), + HTTPStatus.OK, + ) + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def delete(engagement_id, engagement_translation_id): + """Remove engagement translation.""" + try: + EngagementTranslationService().delete_engagement_translation(engagement_id, + engagement_translation_id) + return 'Engagement translation successfully removed', HTTPStatus.OK + except KeyError as err: + return str(err), HTTPStatus.BAD_REQUEST + except ValueError as err: + return str(err), HTTPStatus.NOT_FOUND + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(engagement_id, engagement_translation_id): + """Update engagement translation.""" + try: + translation_data = request.get_json() + updated_engagement = EngagementTranslationService().update_engagement_translation( + engagement_id, engagement_translation_id, translation_data) + return updated_engagement, HTTPStatus.OK + except ValueError as err: + return str(err), HTTPStatus.NOT_FOUND + except ValidationError as err: + return str(err.messages), HTTPStatus.BAD_REQUEST diff --git a/met-api/src/met_api/schemas/engagement_translation.py b/met-api/src/met_api/schemas/engagement_translation.py new file mode 100644 index 000000000..3005ea0e4 --- /dev/null +++ b/met-api/src/met_api/schemas/engagement_translation.py @@ -0,0 +1,26 @@ +"""Engagement translation schema class.""" + +from marshmallow import EXCLUDE, Schema, fields + + +class EngagementTranslationSchema(Schema): + """Engagement translation schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int(data_key='id') + engagement_id = fields.Int(data_key='engagement_id', required=True) + language_id = fields.Int(data_key='language_id', required=True) + name = fields.Str(data_key='name') + description = fields.Str(data_key='description') + rich_description = fields.Str(data_key='rich_description') + content = fields.Str(data_key='content') + rich_content = fields.Str(data_key='rich_content') + consent_message = fields.Str(data_key='consent_message') + slug = fields.Str(data_key='slug') + upcoming_status_block_text = fields.Str(data_key='upcoming_status_block_text') + open_status_block_text = fields.Str(data_key='open_status_block_text') + closed_status_block_text = fields.Str(data_key='closed_status_block_text') diff --git a/met-api/src/met_api/schemas/schemas/engagement_translation.json b/met-api/src/met_api/schemas/schemas/engagement_translation.json new file mode 100644 index 000000000..04b4aa86a --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/engagement_translation.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/engagement_translation", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document.", + "default": {}, + "examples": [ + { + "engagement_id": 1, + "language_id": 1 + } + ], + "required": ["engagement_id", "language_id"], + "properties": { + "engagement_id": { + "$id": "#/properties/engagement_id", + "type": "number", + "title": "engagement id", + "description": "The engagement to which this translation belongs.", + "examples": [1] + }, + "language_id": { + "$id": "#/properties/language_id", + "type": "number", + "title": "Language id", + "description": "The language to which this translation belongs.", + "examples": [1] + } + } + } diff --git a/met-api/src/met_api/services/engagement_translation_service.py b/met-api/src/met_api/services/engagement_translation_service.py new file mode 100644 index 000000000..a7f6f79d6 --- /dev/null +++ b/met-api/src/met_api/services/engagement_translation_service.py @@ -0,0 +1,149 @@ +"""Service for engagement translation management.""" +from http import HTTPStatus + +from sqlalchemy.exc import IntegrityError +from met_api.constants.engagement_status import SubmissionStatus +from met_api.constants.membership_type import MembershipType +from met_api.exceptions.business_exception import BusinessException +from met_api.models.engagement import Engagement as EngagementModel +from met_api.models.engagement_slug import EngagementSlug as EngagementSlugModel +from met_api.models.engagement_status_block import EngagementStatusBlock as EngagementStatusBlockModel +from met_api.models.engagement_translation import EngagementTranslation as EngagementTranslationModel +from met_api.models.language import Language as LanguageModel +from met_api.schemas.engagement_translation import EngagementTranslationSchema +from met_api.services import authorization +from met_api.utils.roles import Role + + +class EngagementTranslationService: + """Engagement translation management service.""" + + @staticmethod + def get_engagement_translation_by_id(engagement_translation_id): + """Get engagement translation by id.""" + engagement_translation_record = EngagementTranslationModel.find_by_id( + engagement_translation_id + ) + return engagement_translation_record + + @staticmethod + def get_translation_by_engagement_and_language(engagement_id=None, language_id=None): + """Get engagement translation by engagement id and/or language id.""" + engagement_translation_schema = EngagementTranslationSchema(many=True) + engagement_translation_records =\ + EngagementTranslationModel.get_engagement_translation_by_engagement_and_language(engagement_id, + language_id) + engagement_translations = engagement_translation_schema.dump(engagement_translation_records) + return engagement_translations + + @staticmethod + def create_engagement_translation(translation_data, pre_populate=True): + """Create engagement translation.""" + try: + engagement = EngagementModel.find_by_id(translation_data['engagement_id']) + if not engagement: + raise ValueError('Engagement to translate was not found') + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value + ) + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement.id) + + language_record = LanguageModel.find_by_id(translation_data['language_id']) + if not language_record: + raise ValueError('Language to translate was not found') + + if pre_populate: + # prepopulate translation with base language data + EngagementTranslationService._get_default_language_values(engagement, translation_data) + + created_engagement_translation = EngagementTranslationModel.create_engagement_translation( + translation_data) + return EngagementTranslationSchema().dump(created_engagement_translation) + except IntegrityError as e: + detail = ( + str(e.orig).split('DETAIL: ')[1] + if 'DETAIL: ' in str(e.orig) + else 'Duplicate entry.' + ) + raise BusinessException( + str(detail), HTTPStatus.INTERNAL_SERVER_ERROR + ) from e + + @staticmethod + def update_engagement_translation(engagement_id, engagement_translation_id: int, translation_data: dict): + """Update engagement translation.""" + engagement = EngagementModel.find_by_id(engagement_id) + if not engagement: + raise ValueError('Engagement to translate was not found') + + EngagementTranslationService._verify_engagement_translation(engagement_translation_id) + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value + ) + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement.id) + + updated_engagement_translation = EngagementTranslationModel.update_engagement_translation( + engagement_translation_id, translation_data) + return EngagementTranslationSchema().dump(updated_engagement_translation) + + @staticmethod + def delete_engagement_translation(engagement_id, engagement_translation_id): + """Remove engagement translation.""" + engagement = EngagementModel.find_by_id(engagement_id) + if not engagement: + raise ValueError('Engagement to translate was not found') + + EngagementTranslationService._verify_engagement_translation(engagement_translation_id) + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value + ) + + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement.id) + + return EngagementTranslationModel.delete_engagement_translation(engagement_translation_id) + + @staticmethod + def _verify_engagement_translation(engagement_translation_id): + """Verify if engagement translation exists.""" + engagement_translation = EngagementTranslationModel.find_by_id(engagement_translation_id) + if not engagement_translation: + raise KeyError('Engagement translation' + engagement_translation_id + ' does not exist') + return engagement_translation + + @staticmethod + def _get_default_language_values(engagement, translation_data): + """Populate the default values.""" + engagement_id = engagement.id + translation_data['name'] = engagement.name + translation_data['description'] = engagement.description + translation_data['rich_description'] = engagement.rich_description + translation_data['content'] = engagement.content + translation_data['rich_content'] = engagement.rich_content + translation_data['consent_message'] = engagement.consent_message + + engagement_slug = EngagementSlugModel.find_by_engagement_id(engagement_id) + if engagement_slug: + translation_data['slug'] = engagement_slug.slug + + upcoming_status_block = EngagementStatusBlockModel.get_by_status(engagement_id, + SubmissionStatus.Upcoming.name) + if upcoming_status_block: + translation_data['upcoming_status_block_text'] = upcoming_status_block.block_text + + open_status_block = EngagementStatusBlockModel.get_by_status(engagement_id, + SubmissionStatus.Open.name) + if open_status_block: + translation_data['open_status_block_text'] = open_status_block.block_text + + closed_status_block = EngagementStatusBlockModel.get_by_status(engagement_id, + SubmissionStatus.Closed.name) + if closed_status_block: + translation_data['closed_status_block_text'] = closed_status_block.block_text + + return translation_data diff --git a/met-api/tests/unit/api/test_engagement_translation.py b/met-api/tests/unit/api/test_engagement_translation.py new file mode 100644 index 000000000..ff1cf2c8a --- /dev/null +++ b/met-api/tests/unit/api/test_engagement_translation.py @@ -0,0 +1,217 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to verify the Engagement Translation API end-point. + +Test-Suite to ensure that the Engagement Translation endpoint is working as expected. +""" +import json +from http import HTTPStatus +from marshmallow import ValidationError +from unittest.mock import patch + +import pytest +from faker import Faker + +from met_api.exceptions.business_exception import BusinessException +from met_api.services.engagement_translation_service import EngagementTranslationService +from met_api.utils.enums import ContentType +from tests.utilities.factory_scenarios import TestEngagementTranslationInfo +from tests.utilities.factory_utils import ( + factory_auth_header, factory_engagement_model, factory_engagement_translation_model, factory_language_model) + + +fake = Faker() + + +@pytest.mark.parametrize('engagement_translation_info', [TestEngagementTranslationInfo.engagementtranslation1]) +def test_create_engagement_translation(client, jwt, session, engagement_translation_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement translation can be POSTed.""" + engagement = factory_engagement_model() + language = factory_language_model({'name': 'French', 'code': 'FR', 'right_to_left': False}) + engagement_translation_info['engagement_id'] = engagement.id + engagement_translation_info['language_id'] = language.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + + rv = client.post(f'/api/engagement/{engagement.id}/translations/', + data=json.dumps(engagement_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK + + rv = client.get(f'/api/engagement/{engagement.id}/translations/language/{language.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK + assert rv.json[0].get('engagement_id') == engagement.id + + with patch.object(EngagementTranslationService, 'create_engagement_translation', + side_effect=ValueError('Test error')): + rv = client.post(f'/api/engagement/{engagement.id}/translations/', + data=json.dumps(engagement_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch.object(EngagementTranslationService, 'create_engagement_translation', + side_effect=KeyError('Test error')): + rv = client.post(f'/api/engagement/{engagement.id}/translations/', + data=json.dumps(engagement_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch.object(EngagementTranslationService, 'create_engagement_translation', + side_effect=ValidationError('Test error')): + rv = client.post(f'/api/engagement/{engagement.id}/translations/', + data=json.dumps(engagement_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + with patch.object(EngagementTranslationService, 'create_engagement_translation', + side_effect=BusinessException('Test error', status_code=HTTPStatus.CONFLICT)): + rv = client.post(f'/api/engagement/{engagement.id}/translations/', + data=json.dumps(engagement_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.CONFLICT + + +@pytest.mark.parametrize('engagement_translation_info', [TestEngagementTranslationInfo.engagementtranslation1]) +def test_get_engagement_translation(client, jwt, session, engagement_translation_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement translation can be fetched.""" + engagement = factory_engagement_model() + language = factory_language_model({'name': 'French', 'code': 'FR', 'right_to_left': False}) + engagement_translation_info['engagement_id'] = engagement.id + engagement_translation_info['language_id'] = language.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post(f'/api/engagement/{engagement.id}/translations/', + data=json.dumps(engagement_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK + + rv = client.get(f'/api/engagement/{engagement.id}/translations/language/{language.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK + assert rv.json[0].get('engagement_id') == engagement.id + + with patch.object(EngagementTranslationService, 'get_translation_by_engagement_and_language', + side_effect=ValueError('Test error')): + rv = client.get(f'/api/engagement/{engagement.id}/translations/language/{language.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch.object(EngagementTranslationService, 'get_translation_by_engagement_and_language', + side_effect=KeyError('Test error')): + rv = client.get(f'/api/engagement/{engagement.id}/translations/language/{language.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.parametrize('engagement_translation_info', [TestEngagementTranslationInfo.engagementtranslation1]) +def test_delete_engagement_translation(client, jwt, session, engagement_translation_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement translation can be deleted.""" + engagement = factory_engagement_model() + language = factory_language_model({'name': 'French', 'code': 'FR', 'right_to_left': False}) + engagement_translation_info['engagement_id'] = engagement.id + engagement_translation_info['language_id'] = language.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + engagement_translation = factory_engagement_translation_model(engagement_translation_info) + + rv = client.delete(f'/api/engagement/{engagement.id}/translations/{engagement_translation.id}', + headers=headers, content_type=ContentType.JSON.value) + + assert rv.status_code == HTTPStatus.OK + + engagement_translation = factory_engagement_translation_model(engagement_translation_info) + with patch.object(EngagementTranslationService, 'delete_engagement_translation', + side_effect=ValueError('Test error')): + rv = client.delete(f'/api/engagement/{engagement.id}/translations/{engagement_translation.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.NOT_FOUND + + with patch.object(EngagementTranslationService, 'delete_engagement_translation', + side_effect=KeyError('Test error')): + rv = client.delete(f'/api/engagement/{engagement.id}/translations/{engagement_translation.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.parametrize('engagement_translation_info', [TestEngagementTranslationInfo.engagementtranslation1]) +def test_patch_engagement_translation(client, jwt, session, engagement_translation_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement translation can be PATCHed.""" + engagement = factory_engagement_model() + language = factory_language_model({'name': 'French', 'code': 'FR', 'right_to_left': False}) + engagement_translation_info['engagement_id'] = engagement.id + engagement_translation_info['language_id'] = language.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + engagement_translation = factory_engagement_translation_model(engagement_translation_info) + + data = { + 'name': fake.text(max_nb_chars=10), + } + rv = client.patch(f'/api/engagement/{engagement.id}/translations/{engagement_translation.id}', + data=json.dumps(data), + headers=headers, content_type=ContentType.JSON.value) + + assert rv.status_code == HTTPStatus.OK + assert rv.json.get('name') == data.get('name') + + with patch.object(EngagementTranslationService, 'update_engagement_translation', + side_effect=ValueError('Test error')): + rv = client.patch(f'/api/engagement/{engagement.id}/translations/{engagement_translation.id}', + data=json.dumps(data), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.NOT_FOUND + + with patch.object(EngagementTranslationService, 'update_engagement_translation', + side_effect=ValidationError('Test error')): + rv = client.patch(f'/api/engagement/{engagement.id}/translations/{engagement_translation.id}', + data=json.dumps(data), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.parametrize('engagement_translation_info', [TestEngagementTranslationInfo.engagementtranslation1]) +def test_get_engagement_translation_by_id(client, jwt, session, engagement_translation_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement translation can be fetched by id.""" + engagement = factory_engagement_model() + language = factory_language_model({'name': 'French', 'code': 'FR', 'right_to_left': False}) + engagement_translation_info['engagement_id'] = engagement.id + engagement_translation_info['language_id'] = language.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + engagement_translation = factory_engagement_translation_model(engagement_translation_info) + + rv = client.get(f'/api/engagement/{engagement.id}/translations/{engagement_translation.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK + json_data = rv.json + assert json_data['engagement_id'] == engagement.id + + with patch.object(EngagementTranslationService, 'get_engagement_translation_by_id', + side_effect=ValueError('Test error')): + rv = client.get(f'/api/engagement/{engagement.id}/translations/{engagement_translation.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch.object(EngagementTranslationService, 'get_engagement_translation_by_id', + side_effect=KeyError('Test error')): + rv = client.get(f'/api/engagement/{engagement.id}/translations/{engagement_translation.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 24e680b02..1b26c8f8f 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -935,3 +935,17 @@ class TestSurveyTranslationInfo(dict, Enum): 'name': 'Survey Name', 'form_json': '{"question": "What is your name?"}' } + + +class TestEngagementTranslationInfo(dict, Enum): + """Test scenarios of engagement translation content.""" + + engagementtranslation1 = { + 'name': fake.text(max_nb_chars=20), + 'description': fake.text(max_nb_chars=20), + 'rich_description': '"{\"blocks\":[{\"key\":\"2ku94\",\"text\":\"Rich Description Sample\",\"type\":\"unstyled\",\ + \"depth\":0,\"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"', + 'content': 'Content Sample', + 'rich_content': '"{\"blocks\":[{\"key\":\"fclgj\",\"text\":\"Rich Content Sample\",\"type\":\"unstyled\",\"depth\":0,\ + \"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"', + } diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index f421d9476..bd0cc1e1d 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -32,6 +32,7 @@ from met_api.models.engagement_metadata import EngagementMetadata, MetadataTaxon from met_api.models.engagement_settings import EngagementSettingsModel from met_api.models.engagement_slug import EngagementSlug as EngagementSlugModel +from met_api.models.engagement_translation import EngagementTranslation as EngagementTranslationModel from met_api.models.feedback import Feedback as FeedbackModel from met_api.models.language import Language as LanguageModel from met_api.models.membership import Membership as MembershipModel @@ -57,10 +58,10 @@ from met_api.utils.enums import MembershipStatus from tests.utilities.factory_scenarios import ( TestCommentInfo, TestEngagementInfo, TestEngagementMetadataInfo, TestEngagementMetadataTaxonInfo, - TestEngagementSlugInfo, TestFeedbackInfo, TestJwtClaims, TestLanguageInfo, TestParticipantInfo, TestPollAnswerInfo, - TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestSurveyTranslationInfo, - TestTenantInfo, TestTimelineInfo, TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, - TestWidgetMap, TestWidgetPollInfo, TestWidgetTranslationInfo, TestWidgetVideo) + TestEngagementSlugInfo, TestEngagementTranslationInfo, TestFeedbackInfo, TestJwtClaims, TestLanguageInfo, + TestParticipantInfo, TestPollAnswerInfo, TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, + TestSurveyInfo, TestSurveyTranslationInfo, TestTenantInfo, TestTimelineInfo, TestUserInfo, TestWidgetDocumentInfo, + TestWidgetInfo, TestWidgetItemInfo, TestWidgetMap, TestWidgetPollInfo, TestWidgetTranslationInfo, TestWidgetVideo) fake = Faker() @@ -612,3 +613,19 @@ def factory_survey_translation_and_engagement_model(): ) translation.save() return translation, survey, lang + + +def factory_engagement_translation_model( + engagement_translation: dict = TestEngagementTranslationInfo.engagementtranslation1, +): + """Produce a engagement translation model.""" + engagement_translation = EngagementTranslationModel( + engagement_id=engagement_translation.get('engagement_id'), + language_id=engagement_translation.get('language_id'), + name=engagement_translation.get('name'), + description=engagement_translation.get('description'), + content=engagement_translation.get('content'), + rich_content=engagement_translation.get('rich_content'), + ) + engagement_translation.save() + return engagement_translation