diff --git a/CHANGELOG.MD b/CHANGELOG.MD index c913fcfa0..548e80297 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,4 +1,8 @@ ## 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. + - Added Widget translation API. + - Added Unit tests. - **Task**Multi-language - Create survey translation table & API routes [DESENG-511](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-511) - Added Survey Translation model. - Added Survey Translation API. @@ -9,8 +13,6 @@ - The existing "Save" button in the floating bar has been split into two distinct actions: "Save and Continue" and "Save and Exit". - Tabs are greyed out, and widgets are disabled until the engagement is successfully saved. A helpful tool-tip has been added to inform users that the engagement needs to be saved before accessing certain features. - Independent save buttons previously present in tabs, such as "Additional Details", "Settings" have been removed. Now, the floating save bar is universally employed when editing an engagement. - -## March 04, 2024 - **Task**Multi-language - Create language table & API [DESENG-509](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-509) - Added Language model. - Added Language API. diff --git a/met-api/migrations/versions/35124d2e41cb_create_widget_translation_table.py b/met-api/migrations/versions/35124d2e41cb_create_widget_translation_table.py new file mode 100644 index 000000000..171a08f81 --- /dev/null +++ b/met-api/migrations/versions/35124d2e41cb_create_widget_translation_table.py @@ -0,0 +1,47 @@ +"""create_widget_translation_table + +Revision ID: 35124d2e41cb +Revises: 274a2774607b +Create Date: 2024-03-05 16:43:50.911576 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '35124d2e41cb' +down_revision = '274a2774607b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('widget_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('widget_id', sa.Integer(), nullable=False), + sa.Column('language_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=100), nullable=True, comment='Custom title for the widget.'), + sa.Column('map_marker_label', sa.String(length=30), nullable=True), + sa.Column('map_file_name', sa.Text(), nullable=True), + sa.Column('poll_title', sa.String(length=255), nullable=True), + sa.Column('poll_description', sa.String(length=2048), nullable=True), + sa.Column('video_url', sa.String(length=255), nullable=True), + sa.Column('video_description', 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(['language_id'], ['language.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['widget_id'], ['widget.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('widget_id', 'language_id', name='unique_widget_language') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('widget_translation') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/constants/widget.py b/met-api/src/met_api/constants/widget.py index 5f05073c0..3da7c94d0 100644 --- a/met-api/src/met_api/constants/widget.py +++ b/met-api/src/met_api/constants/widget.py @@ -26,3 +26,4 @@ class WidgetType(IntEnum): Map = 6 Video = 7 Timeline = 9 + Poll = 10 diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index 36adcef66..c1c889f80 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -59,4 +59,5 @@ from .poll_answers import PollAnswer from .poll_responses import PollResponse from .language import Language +from .widget_translation import WidgetTranslation from .survey_translation import SurveyTranslation diff --git a/met-api/src/met_api/models/widget_translation.py b/met-api/src/met_api/models/widget_translation.py new file mode 100644 index 000000000..88d6b7d68 --- /dev/null +++ b/met-api/src/met_api/models/widget_translation.py @@ -0,0 +1,84 @@ +"""Widget translation model class. + +Manages the widget language translation +""" +from __future__ import annotations +from typing import Optional + +from sqlalchemy.sql.schema import ForeignKey + +from .base_model import BaseModel +from .db import db + + +class WidgetTranslation(BaseModel): # pylint: disable=too-few-public-methods + """Definition of the Widget translation entity.""" + + __tablename__ = 'widget_translation' + __table_args__ = ( + db.UniqueConstraint('widget_id', 'language_id', name='unique_widget_language'), + ) + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=False) + language_id = db.Column(db.Integer, ForeignKey('language.id', ondelete='CASCADE'), nullable=False) + title = db.Column(db.String(100), comment='Custom title for the widget.') + map_marker_label = db.Column(db.String(30)) + map_file_name = db.Column(db.Text()) + poll_title = db.Column(db.String(255)) + poll_description = db.Column(db.String(2048)) + video_url = db.Column(db.String(255)) + video_description = db.Column(db.Text()) + + @classmethod + def get_translation_by_widget_id_and_language_id(cls, widget_id=None, language_id=None): + """Get translation by widget_id and language_id, or by either one.""" + query = WidgetTranslation.query + if widget_id is not None: + query = query.filter_by(widget_id=widget_id) + if language_id is not None: + query = query.filter_by(language_id=language_id) + + widget_translation_records = query.all() + return widget_translation_records + + @classmethod + def create_widget_translation(cls, translation) -> WidgetTranslation: + """Create widget translation.""" + new_widget_translation = cls.__create_new_widget_translation_entity(translation) + db.session.add(new_widget_translation) + db.session.commit() + return new_widget_translation + + @staticmethod + def __create_new_widget_translation_entity(translation): + """Create new widget translation entity.""" + return WidgetTranslation( + widget_id=translation.get('widget_id'), + language_id=translation.get('language_id'), + title=translation.get('title', None), + map_marker_label=translation.get('map_marker_label', None), + map_file_name=translation.get('map_file_name', None), + poll_title=translation.get('poll_title', None), + poll_description=translation.get('poll_description', None), + video_url=translation.get('video_url', None), + video_description=translation.get('video_description', None), + ) + + @classmethod + def remove_widget_translation(cls, widget_translation_id) -> WidgetTranslation: + """Remove widget translation from widget.""" + widget_translation = WidgetTranslation.query.filter_by(id=widget_translation_id).delete() + db.session.commit() + return widget_translation + + @classmethod + def update_widget_translation(cls, widget_translation_id, translation: dict) -> Optional[WidgetTranslation]: + """Update widget translation.""" + query = WidgetTranslation.query.filter_by(id=widget_translation_id) + widget_translation: WidgetTranslation = query.first() + if not widget_translation: + return None + query.update(translation) + db.session.commit() + return widget_translation diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index ce1867976..62878e7d4 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -56,6 +56,7 @@ from .widget_timeline import API as WIDGET_TIMELINE_API from .widget_poll import API as WIDGET_POLL_API 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 __all__ = ('API_BLUEPRINT',) @@ -104,4 +105,5 @@ API.add_namespace(WIDGET_TIMELINE_API, path='/widgets//timelines') API.add_namespace(WIDGET_POLL_API, path='/widgets//polls') 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') diff --git a/met-api/src/met_api/resources/widget_translation.py b/met-api/src/met_api/resources/widget_translation.py new file mode 100644 index 000000000..20a658c04 --- /dev/null +++ b/met-api/src/met_api/resources/widget_translation.py @@ -0,0 +1,111 @@ +# 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 widget 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.schemas import utils as schema_utils +from met_api.schemas.widget_translation import WidgetTranslationSchema +from met_api.services.widget_translation_service import WidgetTranslationService +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace('widget_translation', description='Endpoints for Widget translation Management') + + +@cors_preflight('GET, OPTIONS') +@API.route('/language/') +class WidgetTranslationResourceByLanguage(Resource): + """Resource for managing a widget translation.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(widget_id, language_id): + """Fetch a list of widgets by widget_id and language_id.""" + try: + widgets = WidgetTranslationService().get_translation_by_widget_id_and_language_id( + widget_id, language_id) + return jsonify(widgets), HTTPStatus.OK + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + +@cors_preflight('POST, OPTIONS') +@API.route('/') +class WidgetTranslations(Resource): + """Resource for creating a widget translation.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(widget_id): + """Add new widget translation.""" + try: + request_json = request.get_json() + request_json['widget_id'] = widget_id + valid_format, errors = schema_utils.validate(request_json, 'widget_translation') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + + pre_populate = request_json.get('pre_populate', True) + + translation = WidgetTranslationSchema().load(request_json) + created_widget_translation = WidgetTranslationService().create_widget_translation(translation, + pre_populate) + return created_widget_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 + + +@cors_preflight('GET, DELETE, PATCH') +@API.route('/') +class EditWidgetTranslation(Resource): + """Resource for updating or deleting a widget translation.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def delete(widget_id, widget_translation_id): + """Remove widget translation for a widget.""" + try: + WidgetTranslationService().delete_widget_translation(widget_id, widget_translation_id) + return 'Widget 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(widget_id, widget_translation_id): + """Update widget translation.""" + try: + translation_data = request.get_json() + updated_widget = WidgetTranslationService().update_widget_translation(widget_id, + widget_translation_id, + translation_data) + return updated_widget, 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/schemas/widget_translation.json b/met-api/src/met_api/schemas/schemas/widget_translation.json new file mode 100644 index 000000000..3936a0e1c --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/widget_translation.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/widget_translation", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document.", + "default": {}, + "examples": [ + { + "widget_id": 1, + "language_id": 1 + } + ], + "required": ["widget_id", "language_id"], + "properties": { + "widget_id": { + "$id": "#/properties/widget_id", + "type": "number", + "title": "Widget id", + "description": "The widget 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/schemas/widget_translation.py b/met-api/src/met_api/schemas/widget_translation.py new file mode 100644 index 000000000..f7588d29b --- /dev/null +++ b/met-api/src/met_api/schemas/widget_translation.py @@ -0,0 +1,23 @@ +"""Widget translation schema class.""" + +from marshmallow import EXCLUDE, Schema, fields + + +class WidgetTranslationSchema(Schema): + """Widget 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') + widget_id = fields.Int(data_key='widget_id', required=True) + language_id = fields.Int(data_key='language_id', required=True) + title = fields.Str(data_key='title') + map_marker_label = fields.Str(data_key='map_marker_label') + map_file_name = fields.Str(data_key='map_file_name') + poll_title = fields.Str(data_key='poll_title') + poll_description = fields.Str(data_key='poll_description') + video_url = fields.Str(data_key='video_url') + video_description = fields.Str(data_key='video_description') diff --git a/met-api/src/met_api/services/widget_translation_service.py b/met-api/src/met_api/services/widget_translation_service.py new file mode 100644 index 000000000..58d4bdc39 --- /dev/null +++ b/met-api/src/met_api/services/widget_translation_service.py @@ -0,0 +1,135 @@ +"""Service for widget translation management.""" +from http import HTTPStatus + +from sqlalchemy.exc import IntegrityError +from met_api.constants.membership_type import MembershipType +from met_api.constants.widget import WidgetType +from met_api.exceptions.business_exception import BusinessException +from met_api.models.language import Language as LanguageModel +from met_api.models.widget import Widget as WidgetModel +from met_api.models.widget_map import WidgetMap as WidgetMapModel +from met_api.models.widget_poll import Poll as PollModel +from met_api.models.widget_translation import WidgetTranslation as WidgetTranslationModel +from met_api.models.widget_video import WidgetVideo as WidgetVideoModel +from met_api.schemas.widget_translation import WidgetTranslationSchema +from met_api.services import authorization +from met_api.utils.roles import Role + + +class WidgetTranslationService: + """Widget translation management service.""" + + @staticmethod + def get_translation_by_widget_id_and_language_id(widget_id=None, language_id=None): + """Get translation by widget id and language id.""" + widget_translation_schema = WidgetTranslationSchema(many=True) + widgets_translation_records =\ + WidgetTranslationModel.get_translation_by_widget_id_and_language_id(widget_id, language_id) + widget_translations = widget_translation_schema.dump(widgets_translation_records) + return widget_translations + + @staticmethod + def create_widget_translation(translation_data, pre_populate=True): + """Create widget translation item.""" + try: + widget = WidgetModel.find_by_id(translation_data['widget_id']) + if not widget: + raise ValueError('Widget 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=widget.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 + WidgetTranslationService._get_default_language_values(widget, translation_data) + + created_widget_translation = WidgetTranslationModel.create_widget_translation(translation_data) + return WidgetTranslationSchema().dump(created_widget_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_widget_translation(widget_id, widget_translation_id: int, translation_data: dict): + """Update widget translation.""" + widget = WidgetModel.find_by_id(widget_id) + if not widget: + raise ValueError('Widget to translate was not found') + + WidgetTranslationService._verify_widget_translation(widget_translation_id) + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value + ) + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=widget.engagement_id) + + updated_widget_translation = WidgetTranslationModel.update_widget_translation(widget_translation_id, + translation_data) + return WidgetTranslationSchema().dump(updated_widget_translation) + + @staticmethod + def delete_widget_translation(widget_id, widget_translation_id): + """Remove widget translation.""" + widget = WidgetModel.find_by_id(widget_id) + if not widget: + raise ValueError('Widget to translate was not found') + + WidgetTranslationService._verify_widget_translation(widget_translation_id) + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value + ) + + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=widget.engagement_id) + + return WidgetTranslationModel.remove_widget_translation(widget_translation_id) + + @staticmethod + def _verify_widget_translation(widget_translation_id): + """Verify if widget translation exists.""" + widget_translation = WidgetTranslationModel.find_by_id(widget_translation_id) + if not widget_translation: + raise KeyError('Widget translation' + widget_translation_id + ' does not exist') + return widget_translation + + @staticmethod + def _get_default_language_values(widget, translation_data): + """Populate the default values.""" + widget_type = widget.widget_type_id + widget_id = widget.id + translation_data['title'] = widget.title + + if widget_type == WidgetType.Map.value: + widget_map = WidgetMapModel.get_map(widget_id) + if widget_map: + translation_data['map_marker_label'] = widget_map[0].marker_label + translation_data['map_file_name'] = widget_map[0].file_name + + if widget_type == WidgetType.Poll.value: + widget_poll = PollModel.get_polls(widget_id) + if widget_poll: + translation_data['poll_title'] = widget_poll[0].title + translation_data['poll_description'] = widget_poll[0].description + + if widget_type == WidgetType.Video.value: + widget_video = WidgetVideoModel.get_video(widget_id) + if widget_video: + translation_data['video_url'] = widget_video[0].video_url + translation_data['video_description'] = widget_video[0].description + + return translation_data diff --git a/met-api/tests/unit/api/test_widget_translation.py b/met-api/tests/unit/api/test_widget_translation.py new file mode 100644 index 000000000..9c4e8339d --- /dev/null +++ b/met-api/tests/unit/api/test_widget_translation.py @@ -0,0 +1,178 @@ +# 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 Widget Translation API end-point. + +Test-Suite to ensure that the Widget 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.services.widget_translation_service import WidgetTranslationService +from met_api.utils.enums import ContentType +from tests.utilities.factory_scenarios import TestWidgetInfo, TestWidgetTranslationInfo +from tests.utilities.factory_utils import ( + factory_auth_header, factory_engagement_model, factory_language_model, factory_widget_model, + factory_widget_translation_model) + + +fake = Faker() + + +@pytest.mark.parametrize('widget_translation_info', [TestWidgetTranslationInfo.widgettranslation1]) +def test_create_widget_translation(client, jwt, session, widget_translation_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a widget translation can be POSTed.""" + engagement = factory_engagement_model() + TestWidgetInfo.widget1['engagement_id'] = engagement.id + widget = factory_widget_model(TestWidgetInfo.widget1) + language = factory_language_model({'name': 'French', 'code': 'FR', 'right_to_left': False}) + widget_translation_info['widget_id'] = widget.id + widget_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/widget/{widget.id}/translations/', + data=json.dumps(widget_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + + rv = client.get(f'/api/widget/{widget.id}/translations/language/{language.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + assert rv.json[0].get('widget_id') == widget.id + + with patch.object(WidgetTranslationService, 'create_widget_translation', side_effect=ValueError('Test error')): + rv = client.post(f'/api/widget/{widget.id}/translations/', + data=json.dumps(widget_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch.object(WidgetTranslationService, 'create_widget_translation', side_effect=KeyError('Test error')): + rv = client.post(f'/api/widget/{widget.id}/translations/', + data=json.dumps(widget_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch.object(WidgetTranslationService, 'create_widget_translation', side_effect=ValidationError('Test error')): + rv = client.post(f'/api/widget/{widget.id}/translations/', + data=json.dumps(widget_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.parametrize('widget_translation_info', [TestWidgetTranslationInfo.widgettranslation1]) +def test_get_widget_translation(client, jwt, session, widget_translation_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a widget translation can be fetched.""" + engagement = factory_engagement_model() + TestWidgetInfo.widget1['engagement_id'] = engagement.id + widget = factory_widget_model(TestWidgetInfo.widget1) + language = factory_language_model({'name': 'French', 'code': 'FR', 'right_to_left': False}) + widget_translation_info['widget_id'] = widget.id + widget_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/widget/{widget.id}/translations/', + data=json.dumps(widget_translation_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + + rv = client.get(f'/api/widget/{widget.id}/translations/language/{language.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + assert rv.json[0].get('widget_id') == widget.id + + with patch.object(WidgetTranslationService, 'get_translation_by_widget_id_and_language_id', + side_effect=ValueError('Test error')): + rv = client.get(f'/api/widget/{widget.id}/translations/language/{language.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.parametrize('widget_translation_info', [TestWidgetTranslationInfo.widgettranslation1]) +def test_delete_widget_translation(client, jwt, session, widget_translation_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a widget translation can be deleted.""" + engagement = factory_engagement_model() + TestWidgetInfo.widget1['engagement_id'] = engagement.id + widget = factory_widget_model(TestWidgetInfo.widget1) + language = factory_language_model({'name': 'French', 'code': 'FR', 'right_to_left': False}) + widget_translation_info['widget_id'] = widget.id + widget_translation_info['language_id'] = language.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + widget_translation = factory_widget_translation_model(widget_translation_info) + + rv = client.delete(f'/api/widget/{widget.id}/translations/{widget_translation.id}', + headers=headers, content_type=ContentType.JSON.value) + + assert rv.status_code == HTTPStatus.OK + + widget_translation = factory_widget_translation_model(widget_translation_info) + with patch.object(WidgetTranslationService, 'delete_widget_translation', + side_effect=ValueError('Test error')): + rv = client.delete(f'/api/widget/{widget.id}/translations/{widget_translation.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.NOT_FOUND + + with patch.object(WidgetTranslationService, 'delete_widget_translation', + side_effect=KeyError('Test error')): + rv = client.delete(f'/api/widget/{widget.id}/translations/{widget_translation.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.parametrize('widget_translation_info', [TestWidgetTranslationInfo.widgettranslation1]) +def test_patch_widget_translation(client, jwt, session, widget_translation_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a widget translation can be PATCHed.""" + engagement = factory_engagement_model() + TestWidgetInfo.widget1['engagement_id'] = engagement.id + widget = factory_widget_model(TestWidgetInfo.widget1) + language = factory_language_model({'name': 'French', 'code': 'FR', 'right_to_left': False}) + widget_translation_info['widget_id'] = widget.id + widget_translation_info['language_id'] = language.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + widget_translation = factory_widget_translation_model(widget_translation_info) + + data = { + 'title': fake.text(max_nb_chars=10), + } + rv = client.patch(f'/api/widget/{widget.id}/translations/{widget_translation.id}', + data=json.dumps(data), + headers=headers, content_type=ContentType.JSON.value) + + assert rv.status_code == HTTPStatus.OK + assert rv.json.get('title') == data.get('title') + + with patch.object(WidgetTranslationService, 'update_widget_translation', + side_effect=ValueError('Test error')): + rv = client.patch(f'/api/widget/{widget.id}/translations/{widget_translation.id}', + data=json.dumps(data), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.NOT_FOUND + + with patch.object(WidgetTranslationService, 'update_widget_translation', + side_effect=ValidationError('Test error')): + rv = client.patch(f'/api/widget/{widget.id}/translations/{widget_translation.id}', + data=json.dumps(data), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.BAD_REQUEST diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 19b700005..24e680b02 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -917,6 +917,15 @@ class TestLanguageInfo(dict, Enum): } +class TestWidgetTranslationInfo(dict, Enum): + """Test scenarios of widget translation content.""" + + widgettranslation1 = { + 'title': fake.text(max_nb_chars=20), + 'map_marker_label': fake.text(max_nb_chars=20), + } + + class TestSurveyTranslationInfo(dict, Enum): """Test scenarios of Survey Translation.""" diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index f1145c3d1..f421d9476 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -49,6 +49,7 @@ from met_api.models.widget_documents import WidgetDocuments as WidgetDocumentModel from met_api.models.widget_item import WidgetItem as WidgetItemModal from met_api.models.widget_map import WidgetMap as WidgetMapModel +from met_api.models.widget_translation import WidgetTranslation as WidgetTranslationModel from met_api.models.widget_poll import Poll as WidgetPollModel from met_api.models.widget_timeline import WidgetTimeline as WidgetTimelineModel from met_api.models.widget_video import WidgetVideo as WidgetVideoModel @@ -59,7 +60,7 @@ TestEngagementSlugInfo, TestFeedbackInfo, TestJwtClaims, TestLanguageInfo, TestParticipantInfo, TestPollAnswerInfo, TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestSurveyTranslationInfo, TestTenantInfo, TestTimelineInfo, TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, - TestWidgetMap, TestWidgetPollInfo, TestWidgetVideo) + TestWidgetMap, TestWidgetPollInfo, TestWidgetTranslationInfo, TestWidgetVideo) fake = Faker() @@ -572,6 +573,18 @@ def factory_language_model(lang_info: dict = TestLanguageInfo.language1): return language_model +def factory_widget_translation_model(widget_translation: dict = TestWidgetTranslationInfo.widgettranslation1): + """Produce a widget translation model.""" + widget_translation = WidgetTranslationModel( + widget_id=widget_translation.get('widget_id'), + language_id=widget_translation.get('language_id'), + title=widget_translation.get('title'), + map_marker_label=widget_translation.get('map_marker_label'), + ) + widget_translation.save() + return widget_translation + + def factory_survey_translation_model( translate_info: dict = TestSurveyTranslationInfo.survey_translation1, ):