Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TO MAIN] DESENG-511 - Survey Translation model and API #2410

Merged
merged 8 commits into from
Mar 8, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -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**Multi-language - Create language table & API [DESENG-509](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-509)
- Added Language model.
Original file line number Diff line number Diff line change
@@ -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 ###
1 change: 1 addition & 0 deletions met-api/src/met_api/models/__init__.py
Original file line number Diff line number Diff line change
@@ -59,3 +59,4 @@
from .poll_answers import PollAnswer
from .poll_responses import PollResponse
from .language import Language
from .survey_translation import SurveyTranslation
95 changes: 95 additions & 0 deletions met-api/src/met_api/models/survey_translation.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions met-api/src/met_api/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -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/<int:widget_id>/timelines')
API.add_namespace(WIDGET_POLL_API, path='/widgets/<int:widget_id>/polls')
API.add_namespace(LANGUAGE_API, path='/languages')
API.add_namespace(SURVEY_TRANSLATION_API, path='/surveys/<int:survey_id>/translations')
149 changes: 149 additions & 0 deletions met-api/src/met_api/resources/survey_translation.py
Original file line number Diff line number Diff line change
@@ -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('/<int:survey_translation_id>')
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/<int:language_id>')
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
54 changes: 54 additions & 0 deletions met-api/src/met_api/schemas/schemas/survey_translation.json
Original file line number Diff line number Diff line change
@@ -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]
}
}
}
22 changes: 22 additions & 0 deletions met-api/src/met_api/schemas/survey_translation_schema.py
Original file line number Diff line number Diff line change
@@ -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)
118 changes: 118 additions & 0 deletions met-api/src/met_api/services/survey_translation_service.py
Original file line number Diff line number Diff line change
@@ -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
)
102 changes: 102 additions & 0 deletions met-api/tests/unit/api/test_survey_translation.py
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions met-api/tests/unit/models/test_survey_translation.py
Original file line number Diff line number Diff line change
@@ -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
94 changes: 94 additions & 0 deletions met-api/tests/unit/services/test_survey_translation_service.py
Original file line number Diff line number Diff line change
@@ -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
)
11 changes: 11 additions & 0 deletions met-api/tests/utilities/factory_scenarios.py
Original file line number Diff line number Diff line change
@@ -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?"}'
}
180 changes: 132 additions & 48 deletions met-api/tests/utilities/factory_utils.py
Original file line number Diff line number Diff line change
@@ -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