diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 4fd598e69..c913fcfa0 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,9 @@ +## March 06, 2024 +- **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. + - Added Survey Translation tests. + ## March 04, 2024 - **Task**Engagement "save" enhancements [DESENG-507](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-507) - The existing "Save" button in the floating bar has been split into two distinct actions: "Save and Continue" and "Save and Exit". diff --git a/met-api/migrations/versions/274a2774607b_survey_translation_migration.py b/met-api/migrations/versions/274a2774607b_survey_translation_migration.py new file mode 100644 index 000000000..0624c5058 --- /dev/null +++ b/met-api/migrations/versions/274a2774607b_survey_translation_migration.py @@ -0,0 +1,44 @@ +"""survey translation migration + +Revision ID: 274a2774607b +Revises: e6c320c178fc +Create Date: 2024-03-05 13:41:19.539004 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '274a2774607b' +down_revision = 'e6c320c178fc' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('survey_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('survey_id', sa.Integer(), nullable=False), + sa.Column('language_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=True), + sa.Column('form_json', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', 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'], ), + sa.ForeignKeyConstraint(['survey_id'], ['survey.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('survey_id', 'language_id', name='_survey_language_uc') + ) + op.create_index(op.f('ix_survey_translation_name'), 'survey_translation', ['name'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_survey_translation_name'), table_name='survey_translation') + op.drop_table('survey_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 850657c65..36adcef66 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -59,3 +59,4 @@ from .poll_answers import PollAnswer from .poll_responses import PollResponse from .language import Language +from .survey_translation import SurveyTranslation diff --git a/met-api/src/met_api/models/survey_translation.py b/met-api/src/met_api/models/survey_translation.py new file mode 100644 index 000000000..7d313a5db --- /dev/null +++ b/met-api/src/met_api/models/survey_translation.py @@ -0,0 +1,95 @@ +"""SurveyTranslation model class. + +Manages the Survey Translations. +""" + +from __future__ import annotations + +from sqlalchemy import UniqueConstraint +from sqlalchemy.dialects import postgresql + +from .base_model import BaseModel +from .db import db + + +class SurveyTranslation(BaseModel): + """Definition of the SurveyTranslation entity.""" + + __tablename__ = 'survey_translation' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + survey_id = db.Column( + db.Integer, + db.ForeignKey('survey.id', ondelete='CASCADE'), + nullable=False, + ) + language_id = db.Column( + db.Integer, db.ForeignKey('language.id'), nullable=False + ) + name = db.Column( + db.String(50), index=True, nullable=True + ) # pre-populate it with the base Survey content is optional so can be nullable + form_json = db.Column( + postgresql.JSONB(astext_type=db.Text()), + nullable=True, + server_default='{}', + ) # pre-populate it with the base Survey content is optional so can be nullable + + # Add a unique constraint on survey_id and language_id + # A survey has only one version in a particular language + __table_args__ = ( + UniqueConstraint( + 'survey_id', 'language_id', name='_survey_language_uc' + ), + ) + + @staticmethod + def get_survey_translation_by_survey_and_language( + survey_id=None, language_id=None + ): + """Get survey translation by survey_id and language_id, or by either one.""" + query = SurveyTranslation.query + if survey_id is not None: + query = query.filter_by(survey_id=survey_id) + if language_id is not None: + query = query.filter_by(language_id=language_id) + + survey_translation_records = query.all() + return survey_translation_records + + @staticmethod + def create_survey_translation(data): + """Create a new survey translation.""" + survey_translation = SurveyTranslation( + survey_id=data['survey_id'], + language_id=data['language_id'], + name=data.get( + 'name' + ), # Returns `None` if 'name' is not in `data` as its optional + form_json=data.get( + 'form_json' + ), # Returns `None` if 'form_json' is not in `data` as its optional + ) + survey_translation.save() + return survey_translation + + @staticmethod + def update_survey_translation(survey_translation_id, data): + """Update an existing survey translation.""" + survey_translation = SurveyTranslation.query.get(survey_translation_id) + if survey_translation: + for key, value in data.items(): + setattr(survey_translation, key, value) + db.session.commit() + return survey_translation + return None + + @staticmethod + def delete_survey_translation(survey_translation_id): + """Delete a survey translation.""" + survey_translation = SurveyTranslation.query.get(survey_translation_id) + if survey_translation: + db.session.delete(survey_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 e25de8d01..ce1867976 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 .survey_translation import API as SURVEY_TRANSLATION_API __all__ = ('API_BLUEPRINT',) @@ -103,3 +104,4 @@ 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(SURVEY_TRANSLATION_API, path='/surveys//translations') diff --git a/met-api/src/met_api/resources/survey_translation.py b/met-api/src/met_api/resources/survey_translation.py new file mode 100644 index 000000000..28f2d0af8 --- /dev/null +++ b/met-api/src/met_api/resources/survey_translation.py @@ -0,0 +1,149 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +"""API endpoints for managing a SurveyTranslation resource.""" + +from http import HTTPStatus + +from flask import 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.survey_translation_schema import SurveyTranslationSchema +from met_api.services.survey_translation_service import SurveyTranslationService +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace( + 'survey_translations', + description='Endpoints for SurveyTranslation Management', +) + + +@cors_preflight('GET, POST, PATCH, DELETE, OPTIONS') +@API.route('/') +class SurveyTranslationResource(Resource): + """Resource for managing survey translations.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + # pylint: disable=unused-argument + def get(survey_id, survey_translation_id): + """Fetch a survey translation by id.""" + try: + survey_translation = ( + SurveyTranslationService.get_survey_translation_by_id( + survey_translation_id + ) + ) + return ( + SurveyTranslationSchema().dump(survey_translation), + HTTPStatus.OK, + ) + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @_jwt.requires_auth + @cross_origin(origins=allowedorigins()) + def patch(survey_id, survey_translation_id): + """Update saved survey translation partially.""" + try: + request_json = request.get_json() + survey_translation = ( + SurveyTranslationService.update_survey_translation( + survey_id, survey_translation_id, request_json + ) + ) + return ( + SurveyTranslationSchema().dump(survey_translation), + HTTPStatus.OK, + ) + except ValueError as err: + return str(err), HTTPStatus.NOT_FOUND + except ValidationError as err: + return str(err.messages), HTTPStatus.BAD_REQUEST + + @staticmethod + @_jwt.requires_auth + @cross_origin(origins=allowedorigins()) + def delete(survey_id, survey_translation_id): + """Delete a survey translation.""" + try: + success = SurveyTranslationService.delete_survey_translation( + survey_id, survey_translation_id + ) + if success: + return ( + 'Successfully deleted survey translation', + HTTPStatus.NO_CONTENT, + ) + raise ValueError('Survey translation not found') + except KeyError as err: + return str(err), HTTPStatus.BAD_REQUEST + except ValueError as err: + return str(err), HTTPStatus.NOT_FOUND + + +@cors_preflight('GET, POST, PATCH, DELETE, OPTIONS') +@API.route('/language/') +class SurveyTranslationResourceByLanguage(Resource): + """Resource for managing survey using language_id.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(survey_id, language_id): + """Fetch a survey translation by language_id.""" + try: + survey_translation = SurveyTranslationService.get_translation_by_survey_and_language( + survey_id, language_id + ) + return ( + SurveyTranslationSchema().dump(survey_translation, many=True), + HTTPStatus.OK, + ) + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + +@cors_preflight('POST, OPTIONS') +@API.route('/') +class SurveyTranslations(Resource): + """Resource for managing multiple survey translations.""" + + @staticmethod + @_jwt.requires_auth + @cross_origin(origins=allowedorigins()) + def post(survey_id): + """Create a new survey translation.""" + try: + request_json = request.get_json() + request_json['survey_id'] = survey_id + valid_format, errors = schema_utils.validate( + request_json, 'survey_translation' + ) + if not valid_format: + return { + 'message': schema_utils.serialize(errors) + }, HTTPStatus.BAD_REQUEST + pre_populate = request_json.get('pre_populate', True) + + survey_translation = ( + SurveyTranslationService.create_survey_translation( + request_json, pre_populate + ) + ) + return ( + SurveyTranslationSchema().dump(survey_translation), + HTTPStatus.CREATED, + ) + 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, err.status_code diff --git a/met-api/src/met_api/schemas/schemas/survey_translation.json b/met-api/src/met_api/schemas/schemas/survey_translation.json new file mode 100644 index 000000000..001569861 --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/survey_translation.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/survey_translation", + "type": "object", + "title": "The SurveyTranslation Schema", + "description": "Schema for SurveyTranslation POST request validation.", + "default": {}, + "examples": [ + { + "survey_id": 1, + "language_id": 2, + "name": "Survey Name in Spanish", + "form_json": {}, + "pre_populate" : false + } + ], + "required": ["survey_id", "language_id"], + "properties": { + "survey_id": { + "$id": "#/properties/survey_id", + "type": "integer", + "title": "Survey ID", + "description": "The ID of the survey." + }, + "language_id": { + "$id": "#/properties/language_id", + "type": "integer", + "title": "Language ID", + "description": "The ID of the language in which the survey is translated." + }, + "name": { + "$id": "#/properties/name", + "type": "string", + "title": "Survey Name", + "description": "The name of the survey in the translated language.", + "maxLength": 50, + "examples": ["Survey Name in Spanish"] + }, + "form_json": { + "$id": "#/properties/form_json", + "type": "object", + "title": "Form JSON", + "description": "The JSON representation of the survey form.", + "default": {} + }, + "pre_populate": { + "$id": "#/properties/pre_populate", + "type": "boolean", + "title": "Prepopulate", + "description": "Indicates whether the survey translation should be prepopulated with survey data. Default true.", + "examples": [false] + } + } +} diff --git a/met-api/src/met_api/schemas/survey_translation_schema.py b/met-api/src/met_api/schemas/survey_translation_schema.py new file mode 100644 index 000000000..00b580d3e --- /dev/null +++ b/met-api/src/met_api/schemas/survey_translation_schema.py @@ -0,0 +1,22 @@ +"""SurveyTranslation schema.""" + +from marshmallow import fields +from marshmallow_sqlalchemy import SQLAlchemyAutoSchema + +from met_api.models.survey_translation import SurveyTranslation + + +class SurveyTranslationSchema(SQLAlchemyAutoSchema): + """Schema for SurveyTranslation serialization and deserialization.""" + + class Meta: + """SurveyTranslationSchema metadata.""" + + model = SurveyTranslation + load_instance = True # Optional: deserialize to model instances + + id = fields.Int(dump_only=True) + survey_id = fields.Int(required=True) + language_id = fields.Int(required=True) + name = fields.Str(required=False, allow_none=True) + form_json = fields.Raw(required=False, allow_none=True) diff --git a/met-api/src/met_api/services/survey_translation_service.py b/met-api/src/met_api/services/survey_translation_service.py new file mode 100644 index 000000000..fff3bdabf --- /dev/null +++ b/met-api/src/met_api/services/survey_translation_service.py @@ -0,0 +1,118 @@ +"""Service for SurveyTranslation management.""" + +from http import HTTPStatus + +from sqlalchemy.exc import IntegrityError +from met_api.constants.membership_type import MembershipType +from met_api.exceptions.business_exception import BusinessException +from met_api.models.survey_translation import SurveyTranslation +from met_api.services import authorization +from met_api.services.language_service import LanguageService +from met_api.services.survey_service import SurveyService +from met_api.utils.roles import Role + + +class SurveyTranslationService: + """SurveyTranslation management service.""" + + @staticmethod + def get_survey_translation_by_id(survey_translation_id): + """Get survey translation by id.""" + survey_translation_record = SurveyTranslation.find_by_id( + survey_translation_id + ) + return survey_translation_record + + @staticmethod + def get_translation_by_survey_and_language( + survey_id=None, language_id=None + ): + """Get survey translation by survey_id and/or language_id.""" + survey_translations = ( + SurveyTranslation.get_survey_translation_by_survey_and_language( + survey_id=survey_id, language_id=language_id + ) + ) + return survey_translations + + @staticmethod + def create_survey_translation(translation_data, pre_populate=True): + """Create survey translation.""" + try: + survey = SurveyService.get(translation_data['survey_id']) + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.CREATE_SURVEY.value, + ) + authorization.check_auth( + one_of_roles=one_of_roles, + engagement_id=survey.get('engagement_id'), + ) + if not survey: + raise ValueError('Survey to translate was not found') + language_record = LanguageService.get_language_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 + translation_data['name'] = survey.get('name') + translation_data['form_json'] = survey.get('form_json') + + return SurveyTranslation.create_survey_translation( + translation_data + ) + 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_survey_translation( + survey_id, survey_translation_id, data: dict + ): + """Update survey translation partially.""" + survey = SurveyService.get(survey_id) + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_SURVEY.value, + ) + authorization.check_auth( + one_of_roles=one_of_roles, + engagement_id=survey.get('engagement_id'), + ) + + updated_survey_translation = ( + SurveyTranslation.update_survey_translation( + survey_translation_id, data + ) + ) + if not updated_survey_translation: + raise ValueError('SurveyTranslation to update was not found') + return updated_survey_translation + + @staticmethod + def delete_survey_translation(survey_id, survey_translation_id): + """Delete survey translation.""" + survey = SurveyService.get(survey_id) + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_SURVEY.value, + ) + authorization.check_auth( + one_of_roles=one_of_roles, + engagement_id=survey.get('engagement_id'), + ) + + return SurveyTranslation.delete_survey_translation( + survey_translation_id + ) diff --git a/met-api/tests/unit/api/test_survey_translation.py b/met-api/tests/unit/api/test_survey_translation.py new file mode 100644 index 000000000..7fb065436 --- /dev/null +++ b/met-api/tests/unit/api/test_survey_translation.py @@ -0,0 +1,102 @@ +"""Tests to verify the SurveyTranslation API endpoints.""" + +import json +from http import HTTPStatus + +from met_api.utils.enums import ContentType +from tests.utilities.factory_utils import ( + factory_auth_header, factory_language_model, factory_survey_and_eng_model, + factory_survey_translation_and_engagement_model) + + +def test_get_survey_translation(client, jwt, session): + """Assert that a survey translation can be fetched by its ID.""" + headers = factory_auth_header(jwt=jwt, claims={}) + survey_translation, survey, _ = ( + factory_survey_translation_and_engagement_model() + ) + session.add(survey_translation) + session.commit() + + rv = client.get( + f'/api/surveys/{survey.id}/translations/{survey_translation.id}', + headers=headers, + content_type=ContentType.JSON.value, + ) + + assert rv.status_code == HTTPStatus.OK + json_data = rv.json + assert json_data['id'] == survey_translation.id + + +def test_create_survey_translation(client, jwt, session, setup_admin_user_and_claims): + """Assert that a new survey translation can be created using the POST API endpoint.""" + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + survey, eng = factory_survey_and_eng_model() + language = factory_language_model({'name': 'French', 'code': 'fr', 'right_to_left': False}) + session.add(eng) + session.add(survey) + session.add(language) + session.commit() + + data = { + 'survey_id': survey.id, + 'language_id': language.id, + 'name': 'New Translation', + 'form_json': {'question': 'Your name?'}, + 'pre_populate': False, + } + print(data) + rv = client.post( + f'/api/surveys/{survey.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['name'] == 'New Translation' + + +def test_update_survey_translation(client, jwt, session, setup_admin_user_and_claims): + """Assert that a survey translation can be updated using the PATCH API endpoint.""" + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + survey_translation, survey, _ = ( + factory_survey_translation_and_engagement_model() + ) + session.add(survey_translation) + session.commit() + + data = {'name': 'Updated Translation'} + rv = client.patch( + f'/api/surveys/{survey.id}/translations/{survey_translation.id}', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value, + ) + + assert rv.status_code == HTTPStatus.OK + json_data = rv.json + assert json_data['name'] == 'Updated Translation' + + +def test_delete_survey_translation(client, jwt, session, setup_admin_user_and_claims): + """Assert that a survey translation can be deleted using the DELETE API endpoint.""" + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + survey_translation, survey, _ = ( + factory_survey_translation_and_engagement_model() + ) + session.add(survey_translation) + session.commit() + + rv = client.delete( + f'/api/surveys/{survey.id}/translations/{survey_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_survey_translation.py b/met-api/tests/unit/models/test_survey_translation.py new file mode 100644 index 000000000..4b0503acc --- /dev/null +++ b/met-api/tests/unit/models/test_survey_translation.py @@ -0,0 +1,58 @@ +"""Tests for the SurveyTranslation model. + +Test suite to ensure that the SurveyTranslation model routines are working as expected. +""" + +from met_api.models.survey_translation import SurveyTranslation +from tests.utilities.factory_utils import ( + factory_language_model, factory_survey_and_eng_model, factory_survey_translation_and_engagement_model, + factory_survey_translation_model) + + +def test_create_survey_translation(session): + """Assert that a survey translation can be created.""" + language_data = {'name': 'Spanish', 'code': 'es', 'right_to_left': False} + language = factory_language_model(language_data) + survey, _ = factory_survey_and_eng_model() + translation_data = { + 'survey_id': survey.id, + 'language_id': language.id, + 'name': 'Survey Name', + 'form_json': '{"question": "What is your name?"}', + } + translation = factory_survey_translation_model(translation_data) + assert translation.id is not None + assert translation.name == 'Survey Name' + + +def test_get_survey_translation_by_survey_and_language(session): + """Assert that a survey translation can be fetched by survey_id and language_id.""" + translation, survey, lang = ( + factory_survey_translation_and_engagement_model() + ) + fetched_translation = ( + SurveyTranslation.get_survey_translation_by_survey_and_language( + survey.id, lang.id + ) + ) + assert len(fetched_translation) == 1 + assert fetched_translation[0].name == translation.name + + +def test_update_survey_translation(session): + """Assert that a survey translation can be updated.""" + translation, _, _ = factory_survey_translation_and_engagement_model() + updated_data = {'name': 'Updated Survey 3'} + SurveyTranslation.update_survey_translation(translation.id, updated_data) + updated_translation = SurveyTranslation.query.get(translation.id) + + assert updated_translation.name == 'Updated Survey 3' + + +def test_delete_survey_translation(session): + """Assert that a survey translation can be deleted.""" + translation, _, _ = factory_survey_translation_and_engagement_model() + SurveyTranslation.delete_survey_translation(translation.id) + deleted_translation = SurveyTranslation.query.get(translation.id) + + assert deleted_translation is None diff --git a/met-api/tests/unit/services/test_survey_translation_service.py b/met-api/tests/unit/services/test_survey_translation_service.py new file mode 100644 index 000000000..d106153b4 --- /dev/null +++ b/met-api/tests/unit/services/test_survey_translation_service.py @@ -0,0 +1,94 @@ +"""Tests for the SurveyTranslationService. + +Test suite to ensure that the SurveyTranslationService routines are working as expected. +""" + +from met_api.services.survey_translation_service import SurveyTranslationService +from tests.utilities.factory_scenarios import TestJwtClaims +from tests.utilities.factory_utils import ( + factory_language_model, factory_staff_user_model, factory_survey_and_eng_model, + factory_survey_translation_and_engagement_model, patch_token_info) + + +def test_get_survey_translation_by_id(session): + """Assert that a survey translation can be fetched by ID.""" + translation, _, _ = factory_survey_translation_and_engagement_model() + session.add(translation) + session.commit() + + fetched_translation = ( + SurveyTranslationService.get_survey_translation_by_id(translation.id) + ) + assert fetched_translation.id == translation.id + assert fetched_translation.name == translation.name + + +def test_create_survey_translation(session, monkeypatch): + """Assert that a survey translation can be created.""" + # Setup language and survey + patch_token_info(TestJwtClaims.staff_admin_role, monkeypatch) + factory_staff_user_model(external_id=TestJwtClaims.staff_admin_role['sub']) + language = factory_language_model() + session.add(language) + session.commit() + survey, _ = factory_survey_and_eng_model() + + # Create translation with Prepoulate true + translation_data = { + 'survey_id': survey.id, + 'language_id': language.id, + 'name': 'Survey in French', + 'form_json': '{"question": "Votre nom?"}', + } + created_translation = SurveyTranslationService.create_survey_translation( + translation_data, pre_populate=False + ) + assert created_translation.id is not None + assert created_translation.name == 'Survey in French' + + language_data = {'name': 'Spanish', 'code': 'es', 'right_to_left': False} + language1 = factory_language_model(language_data) + + # Create translation with Prepoulate True + translation_data2 = { + 'survey_id': survey.id, + 'language_id': language1.id, + } + + created_translation = SurveyTranslationService.create_survey_translation( + translation_data2, pre_populate=True + ) + assert created_translation.id is not None + assert created_translation.name == survey.name + + +def test_update_survey_translation(session, monkeypatch): + """Assert that a survey translation can be updated.""" + patch_token_info(TestJwtClaims.staff_admin_role, monkeypatch) + factory_staff_user_model(external_id=TestJwtClaims.staff_admin_role['sub']) + + translation, _, _ = factory_survey_translation_and_engagement_model() + + updated_data = {'name': 'Updated Survey Translation'} + updated_translation = SurveyTranslationService.update_survey_translation( + translation.survey_id, translation.id, updated_data + ) + assert updated_translation.id == translation.id + assert updated_translation.name == 'Updated Survey Translation' + + +def test_delete_survey_translation(session, monkeypatch): + """Assert that a survey translation can be deleted.""" + patch_token_info(TestJwtClaims.staff_admin_role, monkeypatch) + factory_staff_user_model(external_id=TestJwtClaims.staff_admin_role['sub']) + translation, _, _ = factory_survey_translation_and_engagement_model() + session.add(translation) + session.commit() + + deleted = SurveyTranslationService.delete_survey_translation( + translation.survey_id, translation.id + ) + assert deleted + assert not SurveyTranslationService.get_survey_translation_by_id( + translation.id + ) diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 6faef5681..19b700005 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -915,3 +915,14 @@ class TestLanguageInfo(dict, Enum): 'code': 'en', 'right_to_left': False, } + + +class TestSurveyTranslationInfo(dict, Enum): + """Test scenarios of Survey Translation.""" + + survey_translation1 = { + 'survey_id': 1, + 'language_id': 2, + 'name': 'Survey Name', + 'form_json': '{"question": "What is your name?"}' + } diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index 634e16e4c..f1145c3d1 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -43,6 +43,7 @@ from met_api.models.submission import Submission as SubmissionModel from met_api.models.subscription import Subscription as SubscriptionModel from met_api.models.survey import Survey as SurveyModel +from met_api.models.survey_translation import SurveyTranslation as SurveyTranslationModel from met_api.models.timeline_event import TimelineEvent as TimelineEventModel from met_api.models.widget import Widget as WidgetModal from met_api.models.widget_documents import WidgetDocuments as WidgetDocumentModel @@ -56,9 +57,9 @@ from tests.utilities.factory_scenarios import ( TestCommentInfo, TestEngagementInfo, TestEngagementMetadataInfo, TestEngagementMetadataTaxonInfo, TestEngagementSlugInfo, TestFeedbackInfo, TestJwtClaims, TestLanguageInfo, TestParticipantInfo, TestPollAnswerInfo, - TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, TestTimelineInfo, - TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, TestWidgetMap, TestWidgetPollInfo, - TestWidgetVideo) + TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestSurveyTranslationInfo, + TestTenantInfo, TestTimelineInfo, TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, + TestWidgetMap, TestWidgetPollInfo, TestWidgetVideo) fake = Faker() @@ -68,7 +69,7 @@ JWT_HEADER = { 'alg': CONFIG.JWT_OIDC_TEST_ALGORITHMS, 'typ': 'JWT', - 'kid': CONFIG.JWT_OIDC_TEST_AUDIENCE + 'kid': CONFIG.JWT_OIDC_TEST_AUDIENCE, } @@ -87,7 +88,7 @@ def factory_survey_model(survey_info: dict = TestSurveyInfo.survey1): created_date=survey_info.get('created_date'), updated_date=survey_info.get('updated_date'), is_hidden=survey_info.get('is_hidden'), - is_template=survey_info.get('is_template') + is_template=survey_info.get('is_template'), ) survey.save() return survey @@ -105,7 +106,7 @@ def factory_survey_and_eng_model(survey_info: dict = TestSurveyInfo.survey1): updated_date=survey_info.get('updated_date'), is_hidden=survey_info.get('is_hidden'), is_template=survey_info.get('is_template'), - engagement_id=eng.id + engagement_id=eng.id, ) survey.save() return survey, eng @@ -142,7 +143,9 @@ def factory_email_verification(survey_id, type=None): return email_verification -def factory_engagement_model(eng_info: dict = TestEngagementInfo.engagement1, name=None, status=None): +def factory_engagement_model( + eng_info: dict = TestEngagementInfo.engagement1, name=None, status=None +): """Produce a engagement model.""" engagement = EngagementModel( name=name if name else fake.name(), @@ -155,7 +158,7 @@ def factory_engagement_model(eng_info: dict = TestEngagementInfo.engagement1, na status_id=status if status else eng_info.get('status'), start_date=eng_info.get('start_date'), end_date=eng_info.get('end_date'), - is_internal=eng_info.get('is_internal') + is_internal=eng_info.get('is_internal'), ) if tenant_id := eng_info.get('tenant_id'): engagement.tenant_id = tenant_id @@ -177,7 +180,8 @@ def factory_tenant_model(tenant_info: dict = TestTenantInfo.tenant1): def factory_engagement_metadata_model( - metadata_info: dict = TestEngagementMetadataInfo.metadata0): + metadata_info: dict = TestEngagementMetadataInfo.metadata0, +): """Produce a test-ready engagement metadata model.""" metadata = EngagementMetadata( engagement_id=metadata_info.get('engagement_id'), @@ -192,13 +196,19 @@ 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) taxon = factory_metadata_taxon_model(tenant.id) if auth: - headers = factory_auth_header(auth, claims=TestJwtClaims.staff_admin_role, tenant_id=tenant.short_name) + headers = factory_auth_header( + auth, + claims=TestJwtClaims.staff_admin_role, + tenant_id=tenant.short_name, + ) return taxon, engagement, tenant, headers return taxon, engagement, tenant, None @@ -208,15 +218,23 @@ 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, claims=TestJwtClaims.staff_admin_role, tenant_id=tenant.short_name) + headers = factory_auth_header( + auth, + claims=TestJwtClaims.staff_admin_role, + tenant_id=tenant.short_name, + ) return tenant, headers return tenant, None -def factory_metadata_taxon_model(tenant_id: int = 1, - taxon_info: dict = TestEngagementMetadataTaxonInfo.taxon1): +def factory_metadata_taxon_model( + tenant_id: int = 1, + taxon_info: dict = TestEngagementMetadataTaxonInfo.taxon1, +): """Produce a test-ready metadata taxon model.""" taxon = MetadataTaxon( tenant_id=tenant_id, @@ -232,7 +250,9 @@ def factory_metadata_taxon_model(tenant_id: int = 1, return taxon -def factory_staff_user_model(external_id=None, user_info: dict = TestUserInfo.user_staff_1): +def factory_staff_user_model( + external_id=None, user_info: dict = TestUserInfo.user_staff_1 +): """Produce a staff user model.""" # Generate a external id if not passed external_id = external_id or fake.uuid4() @@ -249,31 +269,43 @@ def factory_staff_user_model(external_id=None, user_info: dict = TestUserInfo.us return user -def factory_participant_model(participant: dict = TestParticipantInfo.participant1): +def factory_participant_model( + participant: dict = TestParticipantInfo.participant1, +): """Produce a participant model.""" participant = ParticipantModel( email_address=ParticipantModel.encode_email( - participant['email_address']), + participant['email_address'] + ), ) participant.save() return participant -def factory_membership_model(user_id, engagement_id, member_type='TEAM_MEMBER', status=MembershipStatus.ACTIVE.value): +def factory_membership_model( + user_id, + engagement_id, + member_type='TEAM_MEMBER', + status=MembershipStatus.ACTIVE.value, +): """Produce a Membership model.""" - membership = MembershipModel(user_id=user_id, - engagement_id=engagement_id, - type=member_type, - is_latest=True, - version=1, - status=status) + membership = MembershipModel( + user_id=user_id, + engagement_id=engagement_id, + type=member_type, + is_latest=True, + version=1, + status=status, + ) membership.created_by_id = user_id membership.save() return membership -def factory_feedback_model(feedback_info: dict = TestFeedbackInfo.feedback1, status=None): +def factory_feedback_model( + feedback_info: dict = TestFeedbackInfo.feedback1, status=None +): """Produce a feedback model.""" feedback = FeedbackModel( status=feedback_info.get('status'), @@ -290,8 +322,9 @@ def factory_auth_header(jwt, claims, tenant_id=None): """Produce JWT tokens for use in tests.""" return { 'Authorization': 'Bearer ' + jwt.create_jwt(claims=claims, header=JWT_HEADER), - TENANT_ID_HEADER: (tenant_id or - current_app.config.get('DEFAULT_TENANT_SHORT_NAME')), + TENANT_ID_HEADER: ( + tenant_id or current_app.config.get('DEFAULT_TENANT_SHORT_NAME') + ), } @@ -309,7 +342,9 @@ def factory_widget_model(widget_info: dict = TestWidgetInfo.widget1): return widget -def factory_widget_item_model(widget_info: dict = TestWidgetItemInfo.widget_item1): +def factory_widget_item_model( + widget_info: dict = TestWidgetItemInfo.widget_item1, +): """Produce a widget model.""" widget = WidgetItemModal( widget_id=widget_info.get('widget_id'), @@ -323,8 +358,12 @@ def factory_widget_item_model(widget_info: dict = TestWidgetItemInfo.widget_item return widget -def factory_submission_model(survey_id, engagement_id, participant_id, - submission_info: dict = TestSubmissionInfo.submission1): +def factory_submission_model( + survey_id, + engagement_id, + participant_id, + submission_info: dict = TestSubmissionInfo.submission1, +): """Produce a submission model.""" submission = SubmissionModel( survey_id=survey_id, @@ -343,7 +382,9 @@ def factory_submission_model(survey_id, engagement_id, participant_id, return submission -def factory_comment_model(survey_id, submission_id, comment_info: dict = TestCommentInfo.comment1): +def factory_comment_model( + survey_id, submission_id, comment_info: dict = TestCommentInfo.comment1 +): """Produce a comment model.""" comment = CommentModel( survey_id=survey_id, @@ -356,7 +397,9 @@ def factory_comment_model(survey_id, submission_id, comment_info: dict = TestCom return comment -def factory_document_model(document_info: dict = TestWidgetDocumentInfo.document1): +def factory_document_model( + document_info: dict = TestWidgetDocumentInfo.document1, +): """Produce a comment model.""" document = WidgetDocumentModel( title=document_info.get('title'), @@ -378,13 +421,16 @@ def token_info(): return claims monkeypatch.setattr( - 'met_api.utils.user_context._get_token_info', token_info) + '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')) -def factory_engagement_slug_model(eng_slug_info: dict = TestEngagementSlugInfo.slug1): +def factory_engagement_slug_model( + eng_slug_info: dict = TestEngagementSlugInfo.slug1, +): """Produce a engagement model.""" slug = EngagementSlugModel( slug=eng_slug_info.get('slug'), @@ -395,7 +441,9 @@ def factory_engagement_slug_model(eng_slug_info: dict = TestEngagementSlugInfo.s return slug -def factory_survey_report_setting_model(report_setting_info: dict = TestReportSettingInfo.report_setting_1): +def factory_survey_report_setting_model( + report_setting_info: dict = TestReportSettingInfo.report_setting_1, +): """Produce a engagement model.""" setting = ReportSettingModel( survey_id=report_setting_info.get('survey_id'), @@ -426,29 +474,32 @@ def factory_poll_model(widget, poll_info: dict = TestWidgetPollInfo.poll1): description=poll_info.get('description'), status=poll_info.get('status'), engagement_id=widget.engagement_id, - widget_id=widget.id + widget_id=widget.id, ) poll.save() return poll -def factory_poll_answer_model(poll, answer_info: dict = TestPollAnswerInfo.answer1): +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_text=answer_info.get('answer_text'), poll_id=poll.id ) answer.save() return answer -def factory_poll_response_model(poll, answer, response_info: dict = TestPollResponseInfo.response1): +def factory_poll_response_model( + poll, answer, response_info: dict = TestPollResponseInfo.response1 +): """Produce a Poll model.""" response = PollResponseModel( participant_id=response_info.get('participant_id'), selected_answer_id=answer.id, poll_id=poll.id, - widget_id=poll.widget_id + widget_id=poll.widget_id, ) response.save() return response @@ -466,7 +517,9 @@ def factory_video_model(video_info: dict = TestWidgetVideo.video1): return video -def factory_widget_timeline_model(widget_timeline: dict = TestTimelineInfo.widget_timeline): +def factory_widget_timeline_model( + widget_timeline: dict = TestTimelineInfo.widget_timeline, +): """Produce a widget timeline model.""" widget_timeline = WidgetTimelineModel( widget_id=widget_timeline.get('widget_id'), @@ -478,7 +531,9 @@ def factory_widget_timeline_model(widget_timeline: dict = TestTimelineInfo.widge return widget_timeline -def factory_timeline_event_model(timeline_event: dict = TestTimelineInfo.timeline_event): +def factory_timeline_event_model( + timeline_event: dict = TestTimelineInfo.timeline_event, +): """Produce a widget timeline model.""" timeline_event = TimelineEventModel( widget_id=timeline_event.get('widget_id'), @@ -507,11 +562,40 @@ def factory_widget_map_model(widget_map: dict = TestWidgetMap.map1): def factory_language_model(lang_info: dict = TestLanguageInfo.language1): - """Produce a Language model.""" - language = LanguageModel( + """Produce a Language model.""" + language_model = LanguageModel( name=lang_info.get('name'), code=lang_info.get('code'), right_to_left=lang_info.get('right_to_left'), ) - language.save() - return language + language_model.save() + return language_model + + +def factory_survey_translation_model( + translate_info: dict = TestSurveyTranslationInfo.survey_translation1, +): + """Produce a translation model.""" + translation = SurveyTranslationModel( + survey_id=translate_info.get('survey_id'), + language_id=translate_info.get('language_id'), + name=translate_info.get('name'), + form_json=translate_info.get('form_json'), + ) + translation.save() + return translation + + +def factory_survey_translation_and_engagement_model(): + """Produce a translation model along with survey and language.""" + survey, eng = factory_survey_and_eng_model() + lang = factory_language_model() + + translation = SurveyTranslationModel( + survey_id=survey.id, + language_id=lang.id, + name=TestSurveyTranslationInfo.survey_translation1.get('name'), + form_json=TestSurveyTranslationInfo.survey_translation1.get('form_json'), + ) + translation.save() + return translation, survey, lang