diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 380b39045..46ed71d64 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,9 @@ +## March 04, 2024 +- **Task**Multi-language - Create language table & API [DESENG-509](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-509) + - Added Language model. + - Added Language API. + - Added Unit tests. + ## February 27, 2024 - **Bug Fix**Comments cannot be approved while reviewing [DESENG-496](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-496) - Fixed by adding a missing decorator for transactional methods. diff --git a/met-api/migrations/versions/e6c320c178fc_multi_langauge_table_migration.py b/met-api/migrations/versions/e6c320c178fc_multi_langauge_table_migration.py new file mode 100644 index 000000000..8fde558ae --- /dev/null +++ b/met-api/migrations/versions/e6c320c178fc_multi_langauge_table_migration.py @@ -0,0 +1,39 @@ +"""multi language table migration + +Revision ID: e6c320c178fc +Revises: cec8d0371f42 +Create Date: 2024-02-29 09:18:13.949848 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e6c320c178fc' +down_revision = 'cec8d0371f42' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('language', + 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('name', sa.String(length=50), nullable=False), + sa.Column('code', sa.String(length=2), nullable=False), + sa.Column('right_to_left', sa.Boolean(), nullable=False), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('language') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index d0bd60cdc..850657c65 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -58,3 +58,4 @@ from .widget_poll import Poll from .poll_answers import PollAnswer from .poll_responses import PollResponse +from .language import Language diff --git a/met-api/src/met_api/models/language.py b/met-api/src/met_api/models/language.py new file mode 100644 index 000000000..26d8b7894 --- /dev/null +++ b/met-api/src/met_api/models/language.py @@ -0,0 +1,53 @@ +"""Language model class. + +Manages the Language +""" +from __future__ import annotations +from .base_model import BaseModel +from .db import db + + +class Language(BaseModel): + """Definition of the Language entity.""" + + __tablename__ = 'language' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String(50), nullable=False) # eg. English, French etc + code = db.Column(db.String(2), nullable=False, unique=True) # eg. en, fr etc + right_to_left = db.Column(db.Boolean, nullable=False, default=False) + + @staticmethod + def get_languages(): + """Retrieve all languages.""" + return Language.query.all() + + @staticmethod + def create_language(data): + """Create a new language.""" + language = Language(name=data['name'], code=data['code'], + right_to_left=data.get('right_to_left', False)) + db.session.add(language) + db.session.commit() + return language + + @staticmethod + def update_language(language_id, data): + """Update an existing language.""" + language = Language.query.get(language_id) + if language: + for key, value in data.items(): + setattr(language, key, value) + db.session.commit() + return language + return None + + @staticmethod + def delete_language(language_id): + """Delete a language.""" + language = Language.query.get(language_id) + if language: + db.session.delete(language) + 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 d26448d4f..e25de8d01 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -55,6 +55,7 @@ from .cac_form import API as CAC_FORM_API 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 __all__ = ('API_BLUEPRINT',) @@ -101,3 +102,4 @@ API.add_namespace(CAC_FORM_API, path='/engagements//cacform') 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') diff --git a/met-api/src/met_api/resources/language.py b/met-api/src/met_api/resources/language.py new file mode 100644 index 000000000..47fe9ec74 --- /dev/null +++ b/met-api/src/met_api/resources/language.py @@ -0,0 +1,117 @@ +# Copyright © 2021 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +"""API endpoints for managing a Language 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.language import LanguageSchema +from met_api.services.language_service import LanguageService +from met_api.utils.util import allowedorigins, cors_preflight +from met_api.exceptions.business_exception import BusinessException + +API = Namespace('languages', description='Endpoints for Language Management') + + +@cors_preflight('GET, OPTIONS') +@API.route('/') +class LanguageResource(Resource): + """Resource for managing languages.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(language_id): + """Fetch a language by id.""" + try: + language = LanguageService.get_language_by_id(language_id) + return LanguageSchema().dump(language), HTTPStatus.OK + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @_jwt.requires_auth + @cross_origin(origins=allowedorigins()) + def patch(language_id): + """Update saved language partially.""" + try: + request_json = request.get_json() + valid_format, errors = schema_utils.validate( + request_json, 'language_update' + ) + if not valid_format: + raise BusinessException( + error=schema_utils.serialize(errors), + status_code=HTTPStatus.BAD_REQUEST, + ) + language = LanguageService.update_language( + language_id, request_json + ) + return LanguageSchema().dump(language), 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(language_id): + """Delete a language.""" + try: + success = LanguageService.delete_language(language_id) + if success: + return 'Successfully deleted language', HTTPStatus.NO_CONTENT + raise ValueError('Language 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, OPTIONS, PATCH, DELETE') +@API.route('/') +class Languages(Resource): + """Resource for managing multiple languages.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(): + """Fetch list of languages.""" + try: + languages = LanguageService.get_languages() + return ( + jsonify(LanguageSchema(many=True).dump(languages)), + HTTPStatus.OK, + ) + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @_jwt.requires_auth + @cross_origin(origins=allowedorigins()) + def post(): + """Create a new language.""" + try: + request_json = request.get_json() + valid_format, errors = schema_utils.validate( + request_json, 'language' + ) + if not valid_format: + return { + 'message': schema_utils.serialize(errors) + }, HTTPStatus.BAD_REQUEST + result = LanguageService.create_language(request_json) + return LanguageSchema().dump(result), HTTPStatus.CREATED + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValidationError as err: + return str(err.messages), HTTPStatus.INTERNAL_SERVER_ERROR + except BusinessException as err: + return err.error, err.status_code diff --git a/met-api/src/met_api/schemas/language.py b/met-api/src/met_api/schemas/language.py new file mode 100644 index 000000000..74151df11 --- /dev/null +++ b/met-api/src/met_api/schemas/language.py @@ -0,0 +1,17 @@ +"""Language schema.""" + +from marshmallow import EXCLUDE, Schema, fields + + +class LanguageSchema(Schema): + """Language schema.""" + + class Meta: + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int(data_key='id') + name = fields.Str(data_key='name', required=True) + code = fields.Str(data_key='code', required=True) + right_to_left = fields.Bool(data_key='right_to_left') diff --git a/met-api/src/met_api/schemas/schemas/language.json b/met-api/src/met_api/schemas/schemas/language.json new file mode 100644 index 000000000..8a81c35cb --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/language.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/language", + "type": "object", + "title": "The Language Schema", + "description": "Schema for Language POST request validation.", + "default": {}, + "examples": [ + { + "name": "Spanish", + "code": "es", + "right_to_left": false + } + ], + "required": ["name", "code", "right_to_left"], + "properties": { + "name": { + "$id": "#/properties/name", + "type": "string", + "title": "Language Name", + "description": "The name of the language.", + "examples": ["Spanish"] + }, + "code": { + "$id": "#/properties/code", + "type": "string", + "title": "Language Code", + "description": "The two-letter code of the language.", + "examples": ["es"] + }, + "right_to_left": { + "$id": "#/properties/right_to_left", + "type": "boolean", + "title": "Right to Left", + "description": "Indicates if the language is written from right to left.", + "examples": [false] + } + } + } + \ No newline at end of file diff --git a/met-api/src/met_api/schemas/schemas/language_update.json b/met-api/src/met_api/schemas/schemas/language_update.json new file mode 100644 index 000000000..5db9991a1 --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/language_update.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/language_update", + "type": "object", + "title": "The Language Schema", + "description": "Schema for Language PATCH request validation.", + "default": {}, + "examples": [ + { + "name": "Spanish", + "code": "es", + "right_to_left": false + } + ], + "required": [], + "properties": { + "name": { + "$id": "#/properties/name", + "type": "string", + "title": "Language Name", + "description": "The name of the language.", + "examples": ["Spanish"] + }, + "code": { + "$id": "#/properties/code", + "type": "string", + "title": "Language Code", + "description": "The two-letter code of the language.", + "examples": ["es"] + }, + "right_to_left": { + "$id": "#/properties/right_to_left", + "type": "boolean", + "title": "Right to Left", + "description": "Indicates if the language is written from right to left.", + "examples": [false] + } + } + } + \ No newline at end of file diff --git a/met-api/src/met_api/services/language_service.py b/met-api/src/met_api/services/language_service.py new file mode 100644 index 000000000..8aefa035f --- /dev/null +++ b/met-api/src/met_api/services/language_service.py @@ -0,0 +1,54 @@ +"""Service for Language management.""" + +from http import HTTPStatus + +from sqlalchemy.exc import IntegrityError + +from met_api.exceptions.business_exception import BusinessException +from met_api.models.language import Language +from met_api.schemas.language import LanguageSchema + + +class LanguageService: + """Language management service.""" + + @staticmethod + def get_language_by_id(language_id): + """Get language by id.""" + language_record = Language.find_by_id(language_id) + return LanguageSchema().dump(language_record) + + @staticmethod + def get_languages(): + """Get languages.""" + languages_records = Language.get_languages() + return LanguageSchema(many=True).dump(languages_records) + + @staticmethod + def create_language(language_data): + """Create language.""" + try: + return Language.create_language(language_data) + except IntegrityError as e: + # Catching language code already exists error + 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_language(language_id, data: dict): + """Update language partially.""" + updated_language = Language.update_language(language_id, data) + if not updated_language: + raise ValueError('Language to update was not found') + return updated_language + + @staticmethod + def delete_language(language_id): + """Delete language.""" + return Language.delete_language(language_id) diff --git a/met-api/tests/unit/api/test_language.py b/met-api/tests/unit/api/test_language.py new file mode 100644 index 000000000..d498b5176 --- /dev/null +++ b/met-api/tests/unit/api/test_language.py @@ -0,0 +1,103 @@ +"""Tests to verify the Language API endpoints. + +Test-Suite to ensure that the Language API endpoints are working as expected. +""" +import json +from http import HTTPStatus + +from faker import Faker +from met_api.utils.enums import ContentType +from tests.utilities.factory_utils import factory_auth_header, factory_language_model + +fake = Faker() + + +def test_get_language(client, jwt, session): + """Assert that the get language by ID API endpoint is working as expected.""" + headers = factory_auth_header(jwt=jwt, claims={}) + language = factory_language_model({'name': 'French', 'code': 'fr', 'right_to_left': False}) + session.add(language) + session.commit() + + rv = client.get( + f'/api/languages/{language.id}', + headers=headers, + content_type=ContentType.JSON.value, + ) + + assert rv.status_code == HTTPStatus.OK + json_data = rv.json + assert json_data['name'] == 'French' + + +def test_get_languages(client, jwt, session): + """Assert that the get all languages API endpoint is working as expected.""" + headers = factory_auth_header(jwt=jwt, claims={}) + factory_language_model({'name': 'German', 'code': 'de', 'right_to_left': False}) + session.commit() + + rv = client.get( + '/api/languages/', + headers=headers, + content_type=ContentType.JSON.value, + ) + + assert rv.status_code == HTTPStatus.OK + json_data = rv.json + assert len(json_data) > 0 + + +def test_create_language(client, jwt, session, setup_admin_user_and_claims): + """Assert that a new language can be created using the POST API endpoint.""" + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + data = {'name': 'Italian', 'code': 'it', 'right_to_left': False} + + rv = client.post( + '/api/languages/', + 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'] == 'Italian' + + +def test_update_language(client, jwt, session, setup_admin_user_and_claims): + """Assert that a language can be updated using the PATCH API endpoint.""" + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + language = factory_language_model({'name': 'Japanese', 'code': 'jp', 'right_to_left': True}) + session.add(language) + session.commit() + + data = {'name': 'Nihongo', 'right_to_left': False} + rv = client.patch( + f'/api/languages/{language.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'] == 'Nihongo' + + +def test_delete_language(client, jwt, session, setup_admin_user_and_claims): + """Assert that a language can be deleted using the DELETE API endpoint.""" + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + language = factory_language_model({'name': 'Russian', 'code': 'ru', 'right_to_left': False}) + session.add(language) + session.commit() + + rv = client.delete( + f'/api/languages/{language.id}', + headers=headers, + content_type=ContentType.JSON.value, + ) + + assert rv.status_code == HTTPStatus.NO_CONTENT diff --git a/met-api/tests/unit/models/test_language.py b/met-api/tests/unit/models/test_language.py new file mode 100644 index 000000000..54b3e9502 --- /dev/null +++ b/met-api/tests/unit/models/test_language.py @@ -0,0 +1,60 @@ +"""Tests for the Language model. + +Test suite to ensure that the Language model routines are working as expected. +""" + +from met_api.models.language import Language +from tests.utilities.factory_utils import factory_language_model + + +def test_create_language(session): + """Assert that a language can be created.""" + language_data = {'name': 'Spanish', 'code': 'es', 'right_to_left': False} + language = factory_language_model(language_data) + session.add(language) + session.commit() + + assert language.id is not None + assert language.name == 'Spanish' + + +def test_get_language_by_id(session): + """Assert that a language can be fetched by its ID.""" + language = factory_language_model( + {'name': 'French', 'code': 'fr', 'right_to_left': False} + ) + session.add(language) + session.commit() + + fetched_language = Language.find_by_id(language.id) + assert fetched_language.id == language.id + assert fetched_language.name == 'French' + + +def test_update_language(session): + """Assert that a language can be updated.""" + language = factory_language_model( + {'name': 'German', 'code': 'de', 'right_to_left': False} + ) + session.add(language) + session.commit() + + updated_data = {'name': 'Deutsch', 'right_to_left': False} + Language.update_language(language.id, updated_data) + updated_language = Language.query.get(language.id) + + assert updated_language.name == 'Deutsch' + + +def test_delete_language(session): + """Assert that a language can be deleted.""" + language = factory_language_model( + {'name': 'Italian', 'code': 'it', 'right_to_left': False} + ) + session.add(language) + session.commit() + + Language.delete_language(language.id) + deleted_language = Language.query.get(language.id) + + assert deleted_language is None diff --git a/met-api/tests/unit/services/test_language_service.py b/met-api/tests/unit/services/test_language_service.py new file mode 100644 index 000000000..b84004b5d --- /dev/null +++ b/met-api/tests/unit/services/test_language_service.py @@ -0,0 +1,76 @@ +"""Tests for the LanguageService. + +Test suite to ensure that the LanguageService routines are working as expected. +""" + +from met_api.models.language import Language +from met_api.services.language_service import LanguageService +from tests.utilities.factory_utils import factory_language_model + + +def test_get_language_by_id(session): + """Assert that a language can be fetched by its ID.""" + language = factory_language_model( + {'name': 'French', 'code': 'fr', 'right_to_left': False} + ) + session.add(language) + session.commit() + + fetched_language = LanguageService.get_language_by_id(language.id) + assert fetched_language['name'] == 'French' + + +def test_get_languages(session): + """Assert that all languages can be fetched.""" + factory_language_model( + {'name': 'German', 'code': 'de', 'right_to_left': False} + ) + factory_language_model( + {'name': 'Spanish', 'code': 'es', 'right_to_left': False} + ) + session.commit() + + languages = LanguageService.get_languages() + assert len(languages) >= 2 + + +def test_create_language(session): + """Assert that a language can be created.""" + language_data = {'name': 'Italian', 'code': 'it', 'right_to_left': False} + created_language = LanguageService.create_language(language_data) + + assert created_language.name == 'Italian' + + +def test_update_language(session): + """Assert that a language can be updated.""" + language = factory_language_model( + {'name': 'Japanese', 'code': 'jp', 'right_to_left': True} + ) + session.add(language) + session.commit() + + updated_data = {'name': 'Nihongo', 'right_to_left': True} + updated_language = LanguageService.update_language( + language.id, updated_data + ) + + assert updated_language.name == 'Nihongo' + + +def test_delete_language(session): + """Assert that a language can be deleted.""" + language = factory_language_model( + {'name': 'Russian', 'code': 'ru', 'right_to_left': False} + ) + session.add(language) + session.commit() + + LanguageService.delete_language(language.id) + deleted_language = Language.query.get(language.id) + + assert deleted_language is None + + # Testing for invalid id + is_deleted = LanguageService.delete_language(99999) + assert is_deleted is False diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 13e24fadc..6faef5681 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -905,3 +905,13 @@ class TestEngagementContentInfo(dict, Enum): 'content_type': EngagementContentType.Custom.name, 'is_internal': False, } + + +class TestLanguageInfo(dict, Enum): + """Test scenarios of language.""" + + language1 = { + 'name': 'Spanish', + 'code': 'en', + 'right_to_left': False, + } diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index a04528468..634e16e4c 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -16,11 +16,13 @@ Test Utility for creating model factory. """ from typing import Optional + from faker import Faker from flask import current_app, g -from met_api.auth import Auth +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 @@ -31,6 +33,7 @@ from met_api.models.engagement_settings import EngagementSettingsModel from met_api.models.engagement_slug import EngagementSlug as EngagementSlugModel from met_api.models.feedback import Feedback as FeedbackModel +from met_api.models.language import Language as LanguageModel from met_api.models.membership import Membership as MembershipModel from met_api.models.participant import Participant as ParticipantModel from met_api.models.poll_answers import PollAnswer as PollAnswerModel @@ -50,10 +53,9 @@ from met_api.models.widget_video import WidgetVideo as WidgetVideoModel 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, TestFeedbackInfo, TestJwtClaims, TestParticipantInfo, TestPollAnswerInfo, + TestEngagementSlugInfo, TestFeedbackInfo, TestJwtClaims, TestLanguageInfo, TestParticipantInfo, TestPollAnswerInfo, TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, TestTimelineInfo, TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, TestWidgetMap, TestWidgetPollInfo, TestWidgetVideo) @@ -502,3 +504,14 @@ def factory_widget_map_model(widget_map: dict = TestWidgetMap.map1): ) widget_map.save() return widget_map + + +def factory_language_model(lang_info: dict = TestLanguageInfo.language1): + """Produce a Language model.""" + language = LanguageModel( + name=lang_info.get('name'), + code=lang_info.get('code'), + right_to_left=lang_info.get('right_to_left'), + ) + language.save() + return language