diff --git a/CHANGELOG.MD b/CHANGELOG.MD index cebe2ef53..2b61b10ed 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,9 @@ +## April 11, 2024 +- **Task** Multi-language - Create engagement content translation tables & API routes [DESENG-544](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-544) + - Created a new table for engagement content translations + - Created API routes and services for engagement content translations + - Created Unit tests for engagement content translations + ## April 10, 2024 - **Task** Remove default taxa from GDX tenant [DESENG-578](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-578) diff --git a/met-api/migrations/versions/495d2dbe19b8_enagement_content_translation.py b/met-api/migrations/versions/495d2dbe19b8_enagement_content_translation.py new file mode 100644 index 000000000..d9f0014a2 --- /dev/null +++ b/met-api/migrations/versions/495d2dbe19b8_enagement_content_translation.py @@ -0,0 +1,43 @@ +"""Enagement Content Translation + +Revision ID: 495d2dbe19b8 +Revises: e4d15a1af865 +Create Date: 2024-04-10 14:20:23.777834 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '495d2dbe19b8' +down_revision = 'e4d15a1af865' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('engagement_content_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('language_id', sa.Integer(), nullable=False), + sa.Column('engagement_content_id', sa.Integer(), nullable=False), + sa.Column('content_title', sa.String(length=50), nullable=False), + sa.Column('custom_text_content', sa.Text(), nullable=True), + sa.Column('custom_json_content', 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_content_id'], ['engagement_content.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['language_id'], ['language.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('engagement_content_id', 'language_id', name='_engagement_content_language_uc') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('engagement_content_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 405affb61..2adb01b7c 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -66,3 +66,4 @@ from .timeline_event_translation import TimelineEventTranslation from .subscribe_item_translation import SubscribeItemTranslation from .engagement_translation import EngagementTranslation +from .engagement_content_translation import EngagementContentTranslation diff --git a/met-api/src/met_api/models/engagement_content_translation.py b/met-api/src/met_api/models/engagement_content_translation.py new file mode 100644 index 000000000..69d9680d0 --- /dev/null +++ b/met-api/src/met_api/models/engagement_content_translation.py @@ -0,0 +1,101 @@ +"""Engagement Content translation model class. + +Manages the Engagement Content Translations. +""" + +from __future__ import annotations +from sqlalchemy import UniqueConstraint +from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.exc import IntegrityError +from .base_model import BaseModel +from .db import db + + +class EngagementContentTranslation(BaseModel): + """Definition of the Engagement Content Translation entity.""" + + __tablename__ = 'engagement_content_translation' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + language_id = db.Column(db.Integer, db.ForeignKey('language.id', ondelete='CASCADE'), nullable=False) + engagement_content_id = db.Column( + db.Integer, db.ForeignKey('engagement_content.id', ondelete='CASCADE'), nullable=False + ) + content_title = db.Column(db.String(50), unique=False, nullable=False) + custom_text_content = db.Column(db.Text, unique=False, nullable=True) + custom_json_content = db.Column(JSON, unique=False, nullable=True) + + # Add a unique constraint on engagement_content_id and language_id + # A engagement content has only one version in a particular language + __table_args__ = (UniqueConstraint('engagement_content_id', 'language_id', name='_engagement_content_language_uc'),) + + @classmethod + def get_translations_by_content_and_language(cls, engagement_content_id=None, language_id=None): + """ + Retrieve engagement content translations by content ID and language ID. + + :param engagement_content_id: ID of the engagement content. + :param language_id: ID of the language. + :return: List of engagement content translations matching the criteria. + """ + query = cls.query + if engagement_content_id is not None: + query = query.filter(cls.engagement_content_id == engagement_content_id) + if language_id is not None: + query = query.filter(cls.language_id == language_id) + + return query.all() + + @classmethod + def create_engagement_content_translation(cls, data): + """ + Create a new EngagementContentTranslation record. + + :param data: Dictionary containing the fields for EngagementContentTranslation. + :return: EngagementContentTranslation instance. + """ + try: + new_translation = cls( + language_id=data['language_id'], + engagement_content_id=data['engagement_content_id'], + content_title=data['content_title'], + custom_text_content=data.get('custom_text_content'), + custom_json_content=data.get('custom_json_content') + ) + db.session.add(new_translation) + db.session.commit() + return new_translation + except IntegrityError as e: + db.session.rollback() + raise e + + @classmethod + def update_engagement_content_translation(cls, translation_id, data): + """ + Update an existing EngagementContentTranslation record. + + :param translation_id: ID of the EngagementContentTranslation to update. + :param data: Dictionary of fields to update. + :return: Updated EngagementContentTranslation instance. + """ + translation = cls.find_by_id(translation_id) + if translation: + for key, value in data.items(): + setattr(translation, key, value) + db.session.commit() + return translation + + @classmethod + def delete_engagement_content_translation(cls, translation_id): + """ + Delete an EngagementContentTranslation record. + + :param translation_id: ID of the EngagementContentTranslation to delete. + :return: Boolean indicating successful deletion. + """ + translation = cls.find_by_id(translation_id) + if translation: + db.session.delete(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 7cab76bee..d1c864e2f 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -64,18 +64,14 @@ from .subscribe_item_translation import API as SUBSCRIBE_ITEM_TRANSLATION_API from .timeline_event_translation import API as TIMELINE_EVENT_TRANSLATION_API from .engagement_translation import API as ENGAGEMENT_TRANSLATION_API +from .engagement_content_translation import API as ENGAGEMENT_CONTENT_TRANSLATION_API __all__ = ('API_BLUEPRINT',) URL_PREFIX = '/api/' API_BLUEPRINT = Blueprint('API', __name__, url_prefix=URL_PREFIX) -API = Api( - API_BLUEPRINT, - title='MET API', - version='1.0', - description='The Core API for MET' -) +API = Api(API_BLUEPRINT, title='MET API', version='1.0', description='The Core API for MET') # HANDLER = ExceptionHandler(API) @@ -118,3 +114,4 @@ API.add_namespace(SUBSCRIBE_ITEM_TRANSLATION_API, path='/subscribe//translations') API.add_namespace(TIMELINE_EVENT_TRANSLATION_API, path='/timelines//translations') API.add_namespace(ENGAGEMENT_TRANSLATION_API, path='/engagement//translations') +API.add_namespace(ENGAGEMENT_CONTENT_TRANSLATION_API, path='/engagement_content//translations') diff --git a/met-api/src/met_api/resources/engagement_content_translation.py b/met-api/src/met_api/resources/engagement_content_translation.py new file mode 100644 index 000000000..0a7d1e8c2 --- /dev/null +++ b/met-api/src/met_api/resources/engagement_content_translation.py @@ -0,0 +1,120 @@ +"""API endpoints for managing an engagement content 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.engagement_content_translation import EngagementContentTranslationSchema +from met_api.services.engagement_content_translation_service import EngagementContentTranslationService +from met_api.exceptions.business_exception import BusinessException +from met_api.utils.util import allowedorigins, cors_preflight + +API = Namespace('engagement_content_translation', description='Endpoints for Engagement Content Translation Management') + + +@cors_preflight('GET, POST, OPTIONS') +@API.route('/language/') +class EngagementContentTranslationsByLanguage(Resource): + """Resource for managing engagement content translations.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(content_id, language_id): + """Fetch content translations based on content_id AND language_id.""" + translations = EngagementContentTranslationService().get_translations_by_content_and_language( + content_id, language_id + ) + return jsonify(translations), HTTPStatus.OK + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(): + """Add new engagement content translation.""" + request_json = request.get_json() + valid_format, errors = schema_utils.validate(request_json, 'engagement_content_translation') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + + pre_populate = request_json.get('pre_populate', True) + + try: + translation = EngagementContentTranslationSchema().load(request_json) + created_translation = EngagementContentTranslationService().create_engagement_content_translation( + translation, pre_populate + ) + return jsonify(created_translation), HTTPStatus.CREATED + except (ValidationError, BusinessException) as err: + return {'message': str(err)}, HTTPStatus.BAD_REQUEST + + +@cors_preflight('GET, PATCH, DELETE, OPTIONS') +@API.route('/') +class EditEngagementContentTranslation(Resource): + """Resource for updating or deleting an engagement content translation.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(translation_id, **_): + """Get engagement content translation by id.""" + translation = EngagementContentTranslationService().get_engagement_content_translation_by_id(translation_id) + if translation: + return jsonify(translation), HTTPStatus.OK + return {'message': 'Translation not found'}, HTTPStatus.NOT_FOUND + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(translation_id, **_): + """Update engagement content translation.""" + translation_data = request.get_json() + try: + updated_translation = EngagementContentTranslationService().update_engagement_content_translation( + translation_id, translation_data + ) + return jsonify(updated_translation), HTTPStatus.OK + except (ValidationError, BusinessException) as err: + return {'message': str(err)}, HTTPStatus.BAD_REQUEST + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def delete(translation_id, **_): + """Remove engagement content translation.""" + try: + EngagementContentTranslationService().delete_engagement_content_translation(translation_id) + return {'message': 'Translation successfully removed'}, HTTPStatus.NO_CONTENT + except BusinessException as err: + return {'message': str(err)}, HTTPStatus.BAD_REQUEST + + +@cors_preflight('GET, POST, OPTIONS') +@API.route('/') +class EngagementContentTranslations(Resource): + """Resource for managing engagement content translations.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(**_): + """Add new engagement content translation.""" + request_json = request.get_json() + valid_format, errors = schema_utils.validate(request_json, 'engagement_content_translation') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + + pre_populate = request_json.get('pre_populate', True) + + try: + translation = EngagementContentTranslationSchema().load(request_json) + created_translation = EngagementContentTranslationService().create_engagement_content_translation( + translation, pre_populate + ) + return jsonify(created_translation), HTTPStatus.CREATED + except (ValidationError, BusinessException) as err: + return {'message': str(err)}, HTTPStatus.BAD_REQUEST diff --git a/met-api/src/met_api/schemas/engagement_content_translation.py b/met-api/src/met_api/schemas/engagement_content_translation.py new file mode 100644 index 000000000..cb5764a08 --- /dev/null +++ b/met-api/src/met_api/schemas/engagement_content_translation.py @@ -0,0 +1,19 @@ +"""Schema for engagement content translation.""" + +from marshmallow import EXCLUDE, Schema, fields + + +class EngagementContentTranslationSchema(Schema): + """Engagement content translation schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Meta class to exclude unknown fields.""" + + unknown = EXCLUDE + + id = fields.Int(data_key='id') + language_id = fields.Int(data_key='language_id', required=True) + engagement_content_id = fields.Int(data_key='engagement_content_id', required=True) + content_title = fields.Str(data_key='content_title', required=True) + custom_text_content = fields.Str(data_key='custom_text_content') + custom_json_content = fields.Str(data_key='custom_json_content') diff --git a/met-api/src/met_api/schemas/schemas/engagement_content_translation.json b/met-api/src/met_api/schemas/schemas/engagement_content_translation.json new file mode 100644 index 000000000..67bb3b02b --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/engagement_content_translation.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/engagement_content_translation", + "type": "object", + "title": "Engagement Content Translation Schema", + "description": "Schema for Engagement Content Translation data structure.", + "default": {}, + "examples": [ + { + "language_id": 1, + "engagement_content_id": 1, + "content_title": "Sample Title", + "custom_text_content": "Sample text content", + "custom_json_content": {}, + "pre_populate": true + } + ], + "required": ["language_id", "engagement_content_id", "content_title"], + "properties": { + "language_id": { + "$id": "#/properties/language_id", + "type": "number", + "title": "Language ID", + "description": "The ID of the language for the translation." + }, + "engagement_content_id": { + "$id": "#/properties/engagement_content_id", + "type": "number", + "title": "Engagement Content ID", + "description": "The ID of the engagement content being translated." + }, + "content_title": { + "$id": "#/properties/content_title", + "type": "string", + "title": "Content Title", + "description": "The title of the engagement content." + }, + "custom_text_content": { + "$id": "#/properties/custom_text_content", + "type": "string", + "title": "Custom Text Content", + "description": "Custom textual content of the translation.", + "nullable": true + }, + "custom_json_content": { + "$id": "#/properties/custom_json_content", + "type": "object", + "title": "Custom JSON Content", + "description": "Custom JSON structured content of the translation.", + "nullable": true + } + } +} diff --git a/met-api/src/met_api/services/engagement_content_service.py b/met-api/src/met_api/services/engagement_content_service.py index 51e21ab44..13371626b 100644 --- a/met-api/src/met_api/services/engagement_content_service.py +++ b/met-api/src/met_api/services/engagement_content_service.py @@ -15,6 +15,27 @@ class EngagementContentService: """Engagement content management service.""" + @staticmethod + def get_content_by_content_id(engagement_content_id): + """Get content by content id.""" + content_data = {} + engagement_content_record = EngagementContentModel.find_by_id(engagement_content_id) + if not engagement_content_record: + raise BusinessException( + error='Engagement Content not found', + status_code=HTTPStatus.NOT_FOUND + ) + content_data['id'] = engagement_content_record.id + content_data['title'] = engagement_content_record.title + if engagement_content_record.content_type == EngagementContentType.Custom.name: + custom_content_records = EngagementCustomContentService.get_custom_content(engagement_content_id) + if custom_content_records: + custom_record = custom_content_records[0] + content_data['title'] = custom_record.get('title') + content_data['custom_text_content'] = custom_record.get('custom_text_content') + content_data['custom_json_content'] = custom_record.get('custom_json_content') + return content_data + @staticmethod def get_contents_by_engagement_id(engagement_id): """Get contents by engagement id.""" diff --git a/met-api/src/met_api/services/engagement_content_translation_service.py b/met-api/src/met_api/services/engagement_content_translation_service.py new file mode 100644 index 000000000..79c1f7738 --- /dev/null +++ b/met-api/src/met_api/services/engagement_content_translation_service.py @@ -0,0 +1,87 @@ +"""Service for engagement content translation management.""" + +from http import HTTPStatus +from sqlalchemy.exc import IntegrityError +from met_api.constants.membership_type import MembershipType +from met_api.models.engagement_content_translation import EngagementContentTranslation as ECTranslationModel +from met_api.models.language import Language as LanguageModel +from met_api.schemas.engagement_content_translation import EngagementContentTranslationSchema as ECTranslationSchema +from met_api.services import authorization +from met_api.utils.roles import Role +from met_api.exceptions.business_exception import BusinessException +from met_api.services.engagement_content_service import EngagementContentService + + +class EngagementContentTranslationService: + """Engagement content translation management service.""" + + @staticmethod + def get_engagement_content_translation_by_id(translation_id): + """Get engagement content translation by id.""" + translation_schema = ECTranslationSchema(many=False) + translation_record = ECTranslationModel.find_by_id(translation_id) + return translation_schema.dump(translation_record) + + @staticmethod + def get_translations_by_content_and_language(engagement_content_id=None, language_id=None): + """Get engagement content translations by content id and/or language id.""" + translation_schema = ECTranslationSchema(many=True) + translation_records = ECTranslationModel.get_translations_by_content_and_language( + engagement_content_id, language_id + ) + return translation_schema.dump(translation_records) + + @staticmethod + def create_engagement_content_translation(translation_data, pre_populate=True): + """Create engagement content translation.""" + try: + language_record = LanguageModel.find_by_id(translation_data['language_id']) + if not language_record: + raise ValueError('Language not found') + + one_of_roles = (MembershipType.TEAM_MEMBER.name, Role.CREATE_ENGAGEMENT.value) + authorization.check_auth(one_of_roles=one_of_roles) + + if pre_populate: + default_content = EngagementContentService.get_content_by_content_id( + translation_data['engagement_content_id'] + ) + if default_content.get('id') is not None: + translation_data['content_title'] = default_content.get('title', None) + translation_data['custom_text_content'] = default_content.get('custom_text_content', None) + translation_data['custom_json_content'] = default_content.get('custom_json_content', None) + + created_translation = ECTranslationModel.create_engagement_content_translation(translation_data) + return ECTranslationSchema().dump(created_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_content_translation(translation_id, translation_data): + """Update engagement content translation.""" + EngagementContentTranslationService._verify_translation_exists(translation_id) + + one_of_roles = (MembershipType.TEAM_MEMBER.name, Role.EDIT_ENGAGEMENT.value) + authorization.check_auth(one_of_roles=one_of_roles) + + updated_translation = ECTranslationModel.update_engagement_content_translation(translation_id, translation_data) + return ECTranslationSchema().dump(updated_translation) + + @staticmethod + def delete_engagement_content_translation(translation_id): + """Delete engagement content translation.""" + EngagementContentTranslationService._verify_translation_exists(translation_id) + + one_of_roles = (Role.EDIT_ENGAGEMENT.value,) + authorization.check_auth(one_of_roles=one_of_roles) + + return ECTranslationModel.delete_engagement_content_translation(translation_id) + + @staticmethod + def _verify_translation_exists(translation_id): + """Verify if the engagement content translation exists.""" + translation = ECTranslationModel.find_by_id(translation_id) + if not translation: + raise KeyError(f'Engagement content translation {translation_id} does not exist') + return translation diff --git a/met-api/tests/unit/api/test_engagement_content_translation.py b/met-api/tests/unit/api/test_engagement_content_translation.py new file mode 100644 index 000000000..acc5373b4 --- /dev/null +++ b/met-api/tests/unit/api/test_engagement_content_translation.py @@ -0,0 +1,136 @@ +"""Tests to verify the EngagementContentTranslation API endpoints.""" + +import json +from http import HTTPStatus + +from met_api.utils.enums import ContentType +from tests.utilities.factory_scenarios import TestEngagementContentTranslationInfo +from tests.utilities.factory_utils import ( + engagement_content_model_with_language, factory_auth_header, factory_engagement_content_translation_model) + + +def test_get_engagement_content_translation(client, jwt, session): + """Assert that an engagement content translation can be fetched by its ID.""" + headers = factory_auth_header(jwt=jwt, claims={}) + content, language = engagement_content_model_with_language() + engagement_content_translation = factory_engagement_content_translation_model( + { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': content.id, + 'language_id': language.id, + } + ) + session.add(engagement_content_translation) + session.commit() + + rv = client.get( + f'/api/engagement_content/{content.id}/translations/{engagement_content_translation.id}', + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.OK + json_data = rv.json + assert json_data['id'] == engagement_content_translation.id + + +def test_get_engagement_content_translation_by_language(client, jwt, session): + """Assert that engagement content translations can be fetched by content ID and Language ID.""" + headers = factory_auth_header(jwt=jwt, claims={}) + content, language = engagement_content_model_with_language() + engagement_content_translation = factory_engagement_content_translation_model( + { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': content.id, + 'language_id': language.id, + } + ) + session.add(engagement_content_translation) + session.commit() + + rv = client.get( + f'/api/engagement_content/{content.id}/translations/language/{language.id}', + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.OK + json_data = rv.json + assert json_data[0]['id'] == engagement_content_translation.id + + +def test_create_engagement_content_translation(client, jwt, session, setup_admin_user_and_claims): + """Assert that a new engagement content translation can be created using the POST API endpoint.""" + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + content, language = engagement_content_model_with_language() + + session.commit() + + data = { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': content.id, + 'language_id': language.id, + 'pre_populate': False + } + + rv = client.post( + f'/api/engagement_content/{content.id}/translations/', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.CREATED + json_data = rv.json + assert json_data['content_title'] == TestEngagementContentTranslationInfo.translation_info1.value['content_title'] + + +def test_update_engagement_content_translation(client, jwt, session, setup_admin_user_and_claims): + """Assert that an engagement content translation can be updated using the PATCH API endpoint.""" + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + content, language = engagement_content_model_with_language() + engagement_content_translation = factory_engagement_content_translation_model( + { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': content.id, + 'language_id': language.id, + } + ) + session.commit() + + updated_data = {'content_title': 'Updated Translation'} + rv = client.patch( + f'/api/engagement_content/{content.id}/translations/{engagement_content_translation.id}', + data=json.dumps(updated_data), + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.OK + json_data = rv.json + assert json_data['content_title'] == 'Updated Translation' + + +def test_delete_engagement_content_translation(client, jwt, session, setup_admin_user_and_claims): + """Assert that an engagement content translation can be deleted using the DELETE API endpoint.""" + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + content, language = engagement_content_model_with_language() + engagement_content_translation = factory_engagement_content_translation_model( + { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': content.id, + 'language_id': language.id, + } + ) + session.commit() + + rv = client.delete( + f'/api/engagement_content/{content.id}/translations/{engagement_content_translation.id}', + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.NO_CONTENT diff --git a/met-api/tests/unit/models/test_engagement_content_translation.py b/met-api/tests/unit/models/test_engagement_content_translation.py new file mode 100644 index 000000000..f57439618 --- /dev/null +++ b/met-api/tests/unit/models/test_engagement_content_translation.py @@ -0,0 +1,86 @@ +"""Tests for the EngagementContentTranslation model. + +Test suite to ensure that the EngagementContentTranslation model +routines are working as expected. +""" + +from met_api.models.engagement_content_translation import EngagementContentTranslation +from tests.utilities.factory_scenarios import TestEngagementContentTranslationInfo +from tests.utilities.factory_utils import ( + engagement_content_model_with_language, factory_engagement_content_translation_model) + + +def test_get_translations_by_content_and_language(session): + """Translations for engagement content can be fetched by content and language.""" + engagement_content, language = engagement_content_model_with_language() + translation_data = { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': engagement_content.id, + 'language_id': language.id, + } + + factory_engagement_content_translation_model(translation_data) + session.commit() + + translations = EngagementContentTranslation.get_translations_by_content_and_language( + engagement_content.id, language.id + ) + assert len(translations) == 1 + assert ( + translations[0].content_title == TestEngagementContentTranslationInfo.translation_info1.value['content_title'] + ) + + +def test_create_engagement_content_translation(session): + """Assert that an engagement content translation can be created.""" + engagement_content, language = engagement_content_model_with_language() + translation_data = { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': engagement_content.id, + 'language_id': language.id, + } + + translation = EngagementContentTranslation.create_engagement_content_translation(translation_data) + session.commit() + + assert translation.id is not None + assert translation.content_title == TestEngagementContentTranslationInfo.translation_info1.value['content_title'] + + +def test_update_engagement_content_translation(session): + """Assert that an engagement content translation can be updated.""" + engagement_content, language = engagement_content_model_with_language() + translation_data = { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': engagement_content.id, + 'language_id': language.id, + } + + translation = factory_engagement_content_translation_model(translation_data) + session.commit() + + updated_data = {'content_title': 'Updated Title'} + EngagementContentTranslation.update_engagement_content_translation(translation.id, updated_data) + session.commit() + + updated_translation = EngagementContentTranslation.query.get(translation.id) + assert updated_translation.content_title == 'Updated Title' + + +def test_delete_engagement_content_translation(session): + """Assert that an engagement content translation can be deleted.""" + engagement_content, language = engagement_content_model_with_language() + translation_data = { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': engagement_content.id, + 'language_id': language.id, + } + + translation = factory_engagement_content_translation_model(translation_data) + session.commit() + + EngagementContentTranslation.delete_engagement_content_translation(translation.id) + session.commit() + + deleted_translation = EngagementContentTranslation.query.get(translation.id) + assert deleted_translation is None diff --git a/met-api/tests/unit/services/test_engagement_content_translation_service.py b/met-api/tests/unit/services/test_engagement_content_translation_service.py new file mode 100644 index 000000000..4f71b058b --- /dev/null +++ b/met-api/tests/unit/services/test_engagement_content_translation_service.py @@ -0,0 +1,119 @@ +"""Tests for the EngagementContentTranslationService.""" + +from met_api.services.engagement_content_translation_service import EngagementContentTranslationService +from tests.utilities.factory_scenarios import TestEngagementContentTranslationInfo, TestJwtClaims +from tests.utilities.factory_utils import ( + engagement_content_model_with_language, factory_engagement_content_translation_model, factory_staff_user_model, + patch_token_info) + + +def test_get_engagement_content_translation_by_id(session): + """Assert that engagement content translation can be fetched by its ID.""" + engagement_content, language = engagement_content_model_with_language() + translation = factory_engagement_content_translation_model( + { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': engagement_content.id, + 'language_id': language.id, + } + ) + session.commit() + + fetched_translation = EngagementContentTranslationService.get_engagement_content_translation_by_id(translation.id) + assert fetched_translation is not None + assert fetched_translation['id'] == translation.id + + +def test_get_translations_by_content_and_language(session): + """Assert that engagement content translations can be fetched by content and language.""" + engagement_content, language = engagement_content_model_with_language() + factory_engagement_content_translation_model( + { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': engagement_content.id, + 'language_id': language.id, + } + ) + session.commit() + + translations = EngagementContentTranslationService.get_translations_by_content_and_language( + engagement_content.id, language.id + ) + assert len(translations) == 1 + title = translations[0]['content_title'] + assert title == TestEngagementContentTranslationInfo.translation_info1.value['content_title'] + + +def test_create_engagement_content_translation_without_prepopulate(session, monkeypatch): + """Assert that an engagement content translation can be created with proper authorization.""" + # Mock authorization + patch_token_info(TestJwtClaims.staff_admin_role, monkeypatch) + factory_staff_user_model(external_id=TestJwtClaims.staff_admin_role['sub']) + engagement_content, language = engagement_content_model_with_language() + data = { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': engagement_content.id, + 'language_id': language.id, + } + + created_translation = EngagementContentTranslationService.create_engagement_content_translation(data, False) + assert created_translation is not None + assert created_translation['content_title'] == data['content_title'] + + +def test_create_engagement_content_translation_with_prepopulate(session, monkeypatch): + """Assert that an engagement content translation can be created with proper authorization.""" + # Mock authorization + patch_token_info(TestJwtClaims.staff_admin_role, monkeypatch) + factory_staff_user_model(external_id=TestJwtClaims.staff_admin_role['sub']) + engagement_content, language = engagement_content_model_with_language() + data = { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': engagement_content.id, + 'language_id': language.id, + } + + created_translation = EngagementContentTranslationService.create_engagement_content_translation(data, True) + assert created_translation is not None + assert created_translation['content_title'] == data['content_title'] + + +def test_update_engagement_content_translation_with_authorization(session, monkeypatch): + """Assert that an engagement content translation can be updated with proper authorization.""" + # Mock authorization + patch_token_info(TestJwtClaims.staff_admin_role, monkeypatch) + factory_staff_user_model(external_id=TestJwtClaims.staff_admin_role['sub']) + engagement_content, language = engagement_content_model_with_language() + translation = factory_engagement_content_translation_model( + { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': engagement_content.id, + 'language_id': language.id, + } + ) + session.commit() + + updated_data = {'content_title': 'Updated Title'} + updated_translation = EngagementContentTranslationService.update_engagement_content_translation( + translation.id, updated_data + ) + assert updated_translation['content_title'] == updated_data['content_title'] + + +def test_delete_engagement_content_translation_with_authorization(session, monkeypatch): + """Assert that an engagement content translation can be deleted with proper authorization.""" + # Mock authorization + patch_token_info(TestJwtClaims.staff_admin_role, monkeypatch) + factory_staff_user_model(external_id=TestJwtClaims.staff_admin_role['sub']) + engagement_content, language = engagement_content_model_with_language() + translation = factory_engagement_content_translation_model( + { + **TestEngagementContentTranslationInfo.translation_info1.value, + 'engagement_content_id': engagement_content.id, + 'language_id': language.id, + } + ) + session.commit() + + deleted_translation = EngagementContentTranslationService.delete_engagement_content_translation(translation.id) + assert deleted_translation is True diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 27e73cb08..18c5c7b76 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -1012,3 +1012,13 @@ class TestEngagementTranslationInfo(dict, Enum): 'rich_content': '"{\"blocks\":[{\"key\":\"fclgj\",\"text\":\"Rich Content Sample\",\"type\":\"unstyled\",\"depth\":0,\ \"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"', } + + +class TestEngagementContentTranslationInfo(dict, Enum): + """Test scenarios of engagement content translation content.""" + + translation_info1 = { + 'engagement_content_id': 1, + 'language_id': 2, + 'content_title': fake.text(max_nb_chars=20), + } diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index 245f1f0e7..cdb3158a8 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -23,12 +23,16 @@ from met_api.auth import Auth from met_api.config import get_named_config +from met_api.constants.email_verification import EmailVerificationType from met_api.constants.engagement_status import Status from met_api.constants.widget import WidgetType from met_api.models import Tenant from met_api.models.comment import Comment as CommentModel from met_api.models.email_verification import EmailVerification as EmailVerificationModel from met_api.models.engagement import Engagement as EngagementModel +from met_api.models.engagement_content import EngagementContent as EngagementContentModel +from met_api.models.engagement_content_translation import \ + EngagementContentTranslation as EngagementContentTranslationModel 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 @@ -64,13 +68,12 @@ from met_api.models.widgets_subscribe import WidgetSubscribe as WidgetSubscribeModel from met_api.utils.constants import TENANT_ID_HEADER from met_api.utils.enums import MembershipStatus -from met_api.constants.email_verification import EmailVerificationType from tests.utilities.factory_scenarios import ( - TestCommentInfo, TestEngagementInfo, TestEngagementMetadataInfo, TestEngagementMetadataTaxonInfo, - TestEngagementSlugInfo, TestEngagementTranslationInfo, TestEventItemTranslationInfo, TestEventnfo, TestFeedbackInfo, - TestJwtClaims, TestLanguageInfo, TestParticipantInfo, TestPollAnswerInfo, TestPollAnswerTranslationInfo, - TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, TestSubscribeInfo, - TestSubscribeItemTranslationInfo, TestSurveyInfo, TestSurveyTranslationInfo, TestTenantInfo, + TestCommentInfo, TestEngagementContentInfo, TestEngagementContentTranslationInfo, TestEngagementInfo, + TestEngagementMetadataInfo, TestEngagementMetadataTaxonInfo, TestEngagementSlugInfo, TestEngagementTranslationInfo, + TestEventItemTranslationInfo, TestEventnfo, TestFeedbackInfo, TestJwtClaims, TestLanguageInfo, TestParticipantInfo, + TestPollAnswerInfo, TestPollAnswerTranslationInfo, TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, + TestSubscribeInfo, TestSubscribeItemTranslationInfo, TestSurveyInfo, TestSurveyTranslationInfo, TestTenantInfo, TestTimelineEventTranslationInfo, TestTimelineInfo, TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, TestWidgetMap, TestWidgetPollInfo, TestWidgetTranslationInfo, TestWidgetVideo) @@ -208,8 +211,7 @@ def factory_metadata_requirements(auth: Optional[Auth] = None): """Create a tenant, an associated staff user, and engagement, for tests.""" tenant = factory_tenant_model() tenant.short_name = fake.lexify(text='????').upper() - (engagement_info := TestEngagementInfo.engagement1.copy())[ - 'tenant_id'] = tenant.id + (engagement_info := TestEngagementInfo.engagement1.copy())['tenant_id'] = tenant.id engagement = factory_engagement_model(engagement_info) (staff_info := TestUserInfo.user_staff_1.copy())['tenant_id'] = tenant.id factory_staff_user_model(TestJwtClaims.staff_admin_role['sub'], staff_info) @@ -229,8 +231,7 @@ def factory_taxon_requirements(auth: Optional[Auth] = None): tenant = factory_tenant_model() tenant.short_name = fake.lexify(text='????').upper() (staff_info := TestUserInfo.user_staff_1.copy())['tenant_id'] = tenant.id - factory_staff_user_model( - TestJwtClaims.staff_admin_role.get('sub'), staff_info) + factory_staff_user_model(TestJwtClaims.staff_admin_role.get('sub'), staff_info) if auth: headers = factory_auth_header( auth, @@ -281,8 +282,7 @@ def factory_participant_model( ): """Produce a participant model.""" participant = ParticipantModel( - email_address=ParticipantModel.encode_email( - participant['email_address']), + email_address=ParticipantModel.encode_email(participant['email_address']), ) participant.save() return participant @@ -420,8 +420,7 @@ def token_info(): """Return token info.""" return claims - monkeypatch.setattr( - 'met_api.utils.user_context._get_token_info', token_info) + monkeypatch.setattr('met_api.utils.user_context._get_token_info', token_info) # Add a database user that matches the token # factory_staff_user_model(external_id=claims.get('sub')) @@ -481,8 +480,7 @@ def factory_poll_model(widget, poll_info: dict = TestWidgetPollInfo.poll1): def factory_poll_answer_model(poll, answer_info: dict = TestPollAnswerInfo.answer1): """Produce a Poll model.""" - answer = PollAnswerModel(answer_text=answer_info.get( - 'answer_text'), poll_id=poll.id) + answer = PollAnswerModel(answer_text=answer_info.get('answer_text'), poll_id=poll.id) answer.save() return answer @@ -603,8 +601,7 @@ def factory_survey_translation_and_engagement_model(): survey_id=survey.id, language_id=lang.id, name=TestSurveyTranslationInfo.survey_translation1.get('name'), - form_json=TestSurveyTranslationInfo.survey_translation1.get( - 'form_json'), + form_json=TestSurveyTranslationInfo.survey_translation1.get('form_json'), ) translation.save() return translation, survey, lang @@ -832,3 +829,41 @@ def factory_engagement_translation_model( ) engagement_translation.save() return engagement_translation + + +def factory_enagement_content_model( + engagement_id: int = None, engagement_content: dict = TestEngagementContentInfo.content1 +): + """Produce a engagement content model instance.""" + if engagement_id is None: + engagement_id = factory_engagement_model().id + + engagement_content = EngagementContentModel( + title=engagement_content.get('title'), + icon_name=engagement_content.get('icon_name'), + content_type=engagement_content.get('content_type'), + engagement_id=engagement_id, + is_internal=engagement_content.get('is_internal', False), + ) + engagement_content.save() + return engagement_content + + +def engagement_content_model_with_language(): + """Produce a engagement content model instance with language.""" + content_model = factory_enagement_content_model() + language_model = factory_language_model({'code': 'en', 'name': 'English'}) + return content_model, language_model + + +def factory_engagement_content_translation_model( + engagement_content_translation: dict = TestEngagementContentTranslationInfo.translation_info1, +): + """Produce a engagement content translation model.""" + engagement_content_translation = EngagementContentTranslationModel( + engagement_content_id=engagement_content_translation.get('engagement_content_id'), + language_id=engagement_content_translation.get('language_id'), + content_title=engagement_content_translation.get('content_title'), + ) + engagement_content_translation.save() + return engagement_content_translation