From 2615fc5d8d0084f051df2feda8d7fb3a0fa305c0 Mon Sep 17 00:00:00 2001 From: VineetBala-AOT <90332175+VineetBala-AOT@users.noreply.github.com> Date: Mon, 26 Feb 2024 19:37:16 -0800 Subject: [PATCH 1/2] API changes for dynamic engagement pages (#2392) * API changes for dynamic engagement pages --- CHANGELOG.MD | 6 + ...0d07ab_add_tables_for_dynamic_eng_pages.py | 130 +++++++++++++++++ .../constants/engagement_content_type.py | 25 ++++ met-api/src/met_api/models/__init__.py | 3 + .../src/met_api/models/engagement_content.py | 69 +++++++++ .../models/engagement_custom_content.py | 47 ++++++ .../models/engagement_summary_content.py | 47 ++++++ met-api/src/met_api/resources/__init__.py | 6 + .../met_api/resources/engagement_content.py | 128 +++++++++++++++++ .../resources/engagement_custom_content.py | 72 ++++++++++ .../resources/engagement_summary_content.py | 72 ++++++++++ .../src/met_api/schemas/engagement_content.py | 24 ++++ .../schemas/engagement_custom_content.py | 20 +++ .../schemas/engagement_summary_content.py | 20 +++ .../schemas/schemas/engagement_content.json | 43 ++++++ .../schemas/engagement_content_update.json | 24 ++++ .../services/engagement_content_service.py | 136 ++++++++++++++++++ .../engagement_custom_content_service.py | 53 +++++++ .../engagement_summary_content_service.py | 53 +++++++ met-api/src/met_api/utils/enums.py | 7 + 20 files changed, 985 insertions(+) create mode 100644 met-api/migrations/versions/e2625b0d07ab_add_tables_for_dynamic_eng_pages.py create mode 100644 met-api/src/met_api/constants/engagement_content_type.py create mode 100644 met-api/src/met_api/models/engagement_content.py create mode 100644 met-api/src/met_api/models/engagement_custom_content.py create mode 100644 met-api/src/met_api/models/engagement_summary_content.py create mode 100644 met-api/src/met_api/resources/engagement_content.py create mode 100644 met-api/src/met_api/resources/engagement_custom_content.py create mode 100644 met-api/src/met_api/resources/engagement_summary_content.py create mode 100644 met-api/src/met_api/schemas/engagement_content.py create mode 100644 met-api/src/met_api/schemas/engagement_custom_content.py create mode 100644 met-api/src/met_api/schemas/engagement_summary_content.py create mode 100644 met-api/src/met_api/schemas/schemas/engagement_content.json create mode 100644 met-api/src/met_api/schemas/schemas/engagement_content_update.json create mode 100644 met-api/src/met_api/services/engagement_content_service.py create mode 100644 met-api/src/met_api/services/engagement_custom_content_service.py create mode 100644 met-api/src/met_api/services/engagement_summary_content_service.py diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 75d567aee..4c2aefe32 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,9 @@ +## February 26, 2024 +- **Task**Models for dynamic engagement pages [DESENG-500](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-500) + - Implemented endpoints for dynamic engagement pages, including summary and custom sections. + - Default behavior ensures that each engagement has a dynamic summary page. + - Introduced logic to migrate existing content and rich content for engagements to the summary table. + ## February 20, 2024 - **Task**Upgrade the version of flask [DESENG-502](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-502) - Performed a Flask version upgrade to version 2.2.5. diff --git a/met-api/migrations/versions/e2625b0d07ab_add_tables_for_dynamic_eng_pages.py b/met-api/migrations/versions/e2625b0d07ab_add_tables_for_dynamic_eng_pages.py new file mode 100644 index 000000000..14f8ff695 --- /dev/null +++ b/met-api/migrations/versions/e2625b0d07ab_add_tables_for_dynamic_eng_pages.py @@ -0,0 +1,130 @@ +"""add_tables_for_dynamic_eng_pages + +Revision ID: e2625b0d07ab +Revises: 37176ea4708d +Create Date: 2024-02-25 21:49:58.191570 + +""" +import json +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text +from sqlalchemy.dialects import postgresql + +from met_api.constants.engagement_content_type import EngagementContentType +from met_api.utils.enums import ContentTitle + +# revision identifiers, used by Alembic. +revision = 'e2625b0d07ab' +down_revision = '37176ea4708d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('engagement_content', + 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('title', sa.String(length=50), nullable=False), + sa.Column('icon_name', sa.Text(), nullable=True), + sa.Column('content_type', sa.Enum('Summary', 'Custom', name='engagementcontenttype'), nullable=False), + sa.Column('engagement_id', sa.Integer(), nullable=True), + sa.Column('sort_index', sa.Integer(), nullable=False), + sa.Column('is_internal', 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.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('engagement_custom_content', + 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('custom_text_content', sa.Text(), nullable=True), + sa.Column('custom_json_content', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('engagement_content_id', sa.Integer(), nullable=True), + sa.Column('engagement_id', sa.Integer(), nullable=True), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['engagement_content_id'], ['engagement_content.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('engagement_summary_content', + 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('content', sa.Text(), nullable=False), + sa.Column('rich_content', postgresql.JSON(astext_type=sa.Text()), nullable=False), + sa.Column('engagement_content_id', sa.Integer(), nullable=True), + sa.Column('engagement_id', sa.Integer(), nullable=True), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['engagement_content_id'], ['engagement_content.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + # content and rich content data needs to be moved from engagement table to engagement summary table + connection = op.get_bind() + result = connection.execute(""" + SELECT id, content, rich_content + FROM engagement + """) + engagements = result.fetchall() + + # Iterate through each engagement record and insert into new tables + for index, engagement in enumerate(engagements): + eng_id = engagement['id'] + + # Insert into 'engagement_content' + connection.execute( + text( + """ + INSERT INTO engagement_content + (created_date, title, icon_name, content_type, engagement_id, sort_index, is_internal) + VALUES + (NOW(), :title, :icon_name, :content_type, :engagement_id, 1, false) + """ + ), + { + 'title': ContentTitle.DEFAULT.value, + 'icon_name': ContentTitle.DEFAULT_ICON.value, + 'content_type': EngagementContentType(1).name, + 'engagement_id': eng_id, + }, + ) + + engagement_content_id = index + 1 # Adjust the index to start from 1 + rich_content = engagement['rich_content'] + + # Insert into 'engagement_summary' + connection.execute( + text( + """ + INSERT INTO engagement_summary_content + (created_date, content, rich_content, engagement_content_id, engagement_id) + VALUES + (NOW(), :content, :rich_content, :engagement_content_id, :engagement_id) + """ + ), + { + 'content': engagement['content'], + 'rich_content': json.dumps(rich_content), + 'engagement_content_id': engagement_content_id, + 'engagement_id': eng_id, + }, + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('engagement_summary_content') + op.drop_table('engagement_custom_content') + op.drop_table('engagement_content') + op.execute('DROP TYPE IF EXISTS engagementcontenttype;') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/constants/engagement_content_type.py b/met-api/src/met_api/constants/engagement_content_type.py new file mode 100644 index 000000000..d3515103a --- /dev/null +++ b/met-api/src/met_api/constants/engagement_content_type.py @@ -0,0 +1,25 @@ +# Copyright © 2021 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Constants of engagement content type. + +Each value in this corresponds to a specific section or content element. +""" +from enum import IntEnum + + +class EngagementContentType(IntEnum): + """Enum of engagement content type.""" + + Summary = 1 + Custom = 2 diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index 17ce01c95..d0bd60cdc 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -20,9 +20,12 @@ from .db import db, ma, migrate from .email_verification import EmailVerification from .engagement import Engagement +from .engagement_content import EngagementContent from .engagement_status import EngagementStatus from .engagement_status_block import EngagementStatusBlock from .engagement_settings import EngagementSettingsModel +from .engagement_custom_content import EngagementCustom +from .engagement_summary_content import EngagementSummary from .event_item import EventItem from .subscribe_item import SubscribeItem from .feedback import Feedback diff --git a/met-api/src/met_api/models/engagement_content.py b/met-api/src/met_api/models/engagement_content.py new file mode 100644 index 000000000..870fe8652 --- /dev/null +++ b/met-api/src/met_api/models/engagement_content.py @@ -0,0 +1,69 @@ +"""Engagement content model class. + +Manages the engagement content. Each record in this table stores the configurations +associated with different sections or content elements within an engagement. +""" + +from __future__ import annotations +from datetime import datetime +from typing import Optional + +from sqlalchemy.sql.schema import ForeignKey +from met_api.constants.engagement_content_type import EngagementContentType + +from .base_model import BaseModel +from .db import db + + +class EngagementContent(BaseModel): + """Definition of the Engagement content entity.""" + + __tablename__ = 'engagement_content' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + title = db.Column(db.String(50), unique=False, nullable=False) + icon_name = db.Column(db.Text, unique=False, nullable=True) + content_type = db.Column(db.Enum(EngagementContentType), nullable=False, + default=EngagementContentType.Summary) + engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE')) + sort_index = db.Column(db.Integer, nullable=False, default=1) + is_internal = db.Column(db.Boolean, nullable=False) + + @classmethod + def get_contents_by_engagement_id(cls, engagement_id): + """Get contents by engagement id.""" + return db.session.query(EngagementContent)\ + .filter(EngagementContent.engagement_id == engagement_id)\ + .order_by(EngagementContent.sort_index.asc())\ + .all() + + @classmethod + def update_engagement_contents(cls, update_mappings: list) -> None: + """Update contents.""" + db.session.bulk_update_mappings(EngagementContent, update_mappings) + db.session.commit() + + @classmethod + def save_engagement_content(cls, content: list) -> None: + """Update custom content.""" + db.session.bulk_save_objects(content) + + @classmethod + def remove_engagement_content(cls, engagement_id, engagement_content_id,) -> EngagementContent: + """Remove engagement content from engagement.""" + engagement_content = EngagementContent.query.filter_by(id=engagement_content_id, + engagement_id=engagement_id).delete() + db.session.commit() + return engagement_content + + @classmethod + def update_engagement_content(cls, engagement_id, engagement_content_id, + engagement_content_data: dict) -> Optional[EngagementContent]: + """Update engagement content.""" + query = EngagementContent.query.filter_by(id=engagement_content_id, engagement_id=engagement_id) + engagement_content: EngagementContent = query.first() + if not engagement_content: + return None + engagement_content_data['updated_date'] = datetime.utcnow() + query.update(engagement_content_data) + db.session.commit() + return engagement_content diff --git a/met-api/src/met_api/models/engagement_custom_content.py b/met-api/src/met_api/models/engagement_custom_content.py new file mode 100644 index 000000000..9f32409f5 --- /dev/null +++ b/met-api/src/met_api/models/engagement_custom_content.py @@ -0,0 +1,47 @@ +"""Engagement custom model class. + +Manages the engagement custom content +""" + +from __future__ import annotations + +from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.sql.schema import ForeignKey + +from .base_model import BaseModel +from .db import db + + +class EngagementCustom(BaseModel): + """Definition of the Engagement custom content entity.""" + + __tablename__ = 'engagement_custom_content' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + custom_text_content = db.Column(db.Text, unique=False, nullable=True) + custom_json_content = db.Column(JSON, unique=False, nullable=True) + engagement_content_id = db.Column(db.Integer, ForeignKey('engagement_content.id', ondelete='CASCADE')) + engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE')) + + @classmethod + def get_custom_content(cls, content_id) -> list[EngagementCustom]: + """Get engagement custom content.""" + custom_content = db.session.query(EngagementCustom) \ + .filter(EngagementCustom.engagement_content_id == content_id) \ + .all() + return custom_content + + @classmethod + def update_custom_content(cls, content_id, custom_content_data: dict) -> EngagementCustom: + """Update engagement custom content.""" + query = EngagementCustom.query.filter_by(engagement_content_id=content_id) + custom_content: EngagementCustom = query.first() + if not custom_content: + return custom_content_data + query.update(custom_content_data) + db.session.commit() + return custom_content + + @classmethod + def save_engagement_custom_content(cls, custom_content: list) -> None: + """Save custom content.""" + db.session.bulk_save_objects(custom_content) diff --git a/met-api/src/met_api/models/engagement_summary_content.py b/met-api/src/met_api/models/engagement_summary_content.py new file mode 100644 index 000000000..b7dd2367c --- /dev/null +++ b/met-api/src/met_api/models/engagement_summary_content.py @@ -0,0 +1,47 @@ +"""Engagement summary model class. + +Manages the engagement summary content +""" + +from __future__ import annotations + +from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.sql.schema import ForeignKey + +from .base_model import BaseModel +from .db import db + + +class EngagementSummary(BaseModel): + """Definition of the Engagement summary content entity.""" + + __tablename__ = 'engagement_summary_content' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + content = db.Column(db.Text, unique=False, nullable=False) + rich_content = db.Column(JSON, unique=False, nullable=False) + engagement_content_id = db.Column(db.Integer, ForeignKey('engagement_content.id', ondelete='CASCADE')) + engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE')) + + @classmethod + def get_summary_content(cls, content_id) -> list[EngagementSummary]: + """Get engagement summary content.""" + summary_content = db.session.query(EngagementSummary) \ + .filter(EngagementSummary.engagement_content_id == content_id) \ + .all() + return summary_content + + @classmethod + def update_summary_content(cls, content_id, summary_content_data: dict) -> EngagementSummary: + """Update engagement summary content.""" + query = EngagementSummary.query.filter_by(engagement_content_id=content_id) + summary_content: EngagementSummary = query.first() + if not summary_content: + return summary_content_data + query.update(summary_content_data) + db.session.commit() + return summary_content + + @classmethod + def save_engagement_summary_content(cls, summary_content: list) -> None: + """Save summary content.""" + db.session.bulk_save_objects(summary_content) diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index 1f31982bd..d26448d4f 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -28,6 +28,9 @@ from .contact import API as CONTACT_API from .document import API as DOCUMENT_API from .email_verification import API as EMAIL_VERIFICATION_API +from .engagement_content import API as ENGAGEMENT_CONTENT_API +from .engagement_custom_content import API as ENGAGEMENT_CUSTOM_CONTENT_API +from .engagement_summary_content import API as ENGAGEMENT_SUMMARY_CONTENT_API from .engagement import API as ENGAGEMENT_API from .engagement_metadata import API as ENGAGEMENT_METADATA_API from .metadata_taxon import API as METADATA_TAXON_API @@ -75,6 +78,9 @@ API.add_namespace(SUBSCRIPTION_API) API.add_namespace(COMMENT_API) API.add_namespace(EMAIL_VERIFICATION_API) +API.add_namespace(ENGAGEMENT_CONTENT_API) +API.add_namespace(ENGAGEMENT_CUSTOM_CONTENT_API, path='/engagement_content//custom') +API.add_namespace(ENGAGEMENT_SUMMARY_CONTENT_API, path='/engagement_content//summary') API.add_namespace(FEEDBACK_API) API.add_namespace(WIDGET_API) API.add_namespace(CONTACT_API) diff --git a/met-api/src/met_api/resources/engagement_content.py b/met-api/src/met_api/resources/engagement_content.py new file mode 100644 index 000000000..9734a97ab --- /dev/null +++ b/met-api/src/met_api/resources/engagement_content.py @@ -0,0 +1,128 @@ +# Copyright © 2021 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for managing an engagement content 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.exceptions.business_exception import BusinessException +from met_api.schemas import utils as schema_utils +from met_api.services.engagement_content_service import EngagementContentService +from met_api.utils.token_info import TokenInfo +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace('engagement_content', description='Endpoints for Engagement Content Management') +"""Custom exception messages +""" + + +@cors_preflight('GET, POST, OPTIONS') +@API.route('/engagement/') +class EngagementContent(Resource): + """Resource for managing a engagement content.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(engagement_id): + """Fetch a list of engagement contents by engagement id.""" + try: + engagement_contents = EngagementContentService().get_contents_by_engagement_id(engagement_id) + return jsonify(engagement_contents), HTTPStatus.OK + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(engagement_id): + """Add new engagement content for an engagement.""" + try: + request_json = request.get_json() + valid_format, errors = schema_utils.validate(request_json, 'engagement_content') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + + created_content = EngagementContentService().create_engagement_content(request_json, + engagement_id) + return jsonify(created_content), HTTPStatus.OK + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValidationError as err: + return str(err.messages), HTTPStatus.INTERNAL_SERVER_ERROR + + +@cors_preflight('PATCH') +@API.route('/engagement//sort_index') +class EngagementContentSort(Resource): + """Resource for managing engagement contents sort order with engagements.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(engagement_id): + """Sort contents for an engagement.""" + try: + request_json = request.get_json() + EngagementContentService().sort_engagement_content(engagement_id, request_json, + user_id=TokenInfo.get_id()) + return {}, HTTPStatus.NO_CONTENT + except BusinessException as err: + return {'message': err.error}, err.status_code + + +@cors_preflight('GET, DELETE, PATCH') +@API.route('//engagements/') +class EngagementContentEdit(Resource): + """Resource for managing engagement contents with engagements.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def delete(engagement_id, engagement_content_id): + """Remove engagement content for an engagement.""" + try: + EngagementContentService().delete_engagement_content(engagement_id, engagement_content_id) + return 'Engagement content successfully removed', HTTPStatus.OK + except KeyError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValueError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(engagement_id, engagement_content_id): + """Update engagement content.""" + try: + user_id = TokenInfo.get_id() + engagement_content_data = request.get_json() + valid_format, errors = schema_utils.validate(engagement_content_data, 'engagement_content_update') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + + updated_engagement_content = EngagementContentService().update_engagement_content(engagement_id, + engagement_content_id, + engagement_content_data, + user_id) + return updated_engagement_content, HTTPStatus.OK + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValidationError as err: + return str(err.messages), HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/met-api/src/met_api/resources/engagement_custom_content.py b/met-api/src/met_api/resources/engagement_custom_content.py new file mode 100644 index 000000000..600bcbff6 --- /dev/null +++ b/met-api/src/met_api/resources/engagement_custom_content.py @@ -0,0 +1,72 @@ +# Copyright © 2021 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for managing an engagement custom content resource.""" + +from http import HTTPStatus + +from flask import jsonify, request +from flask_cors import cross_origin +from flask_restx import Namespace, Resource + +from met_api.auth import jwt as _jwt +from met_api.exceptions.business_exception import BusinessException +from met_api.schemas.engagement_custom_content import EngagementCustomSchema +from met_api.services.engagement_custom_content_service import EngagementCustomContentService +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace('custom_content', description='Endpoints for Engagement Custom Content Management') +"""Custom exception messages +""" + + +@cors_preflight('GET, POST, PATCH, OPTIONS') +@API.route('') +class Map(Resource): + """Resource for managing engagement custom content.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(content_id): + """Get engagement custom content.""" + try: + custom_content = EngagementCustomContentService().get_custom_content(content_id) + return jsonify(EngagementCustomSchema().dump(custom_content, many=True)), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(content_id): + """Create engagement custom content.""" + try: + request_json = request.get_json() + custom_content = EngagementCustomContentService().create_custom_content(content_id, + request_json) + return EngagementCustomSchema().dump(custom_content), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(content_id): + """Update engagement custom content.""" + request_json = request.get_json() + try: + custom_content = EngagementCustomContentService().update_custom_content(content_id, request_json) + return EngagementCustomSchema().dump(custom_content), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code diff --git a/met-api/src/met_api/resources/engagement_summary_content.py b/met-api/src/met_api/resources/engagement_summary_content.py new file mode 100644 index 000000000..cda1a7724 --- /dev/null +++ b/met-api/src/met_api/resources/engagement_summary_content.py @@ -0,0 +1,72 @@ +# Copyright © 2021 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for managing an engagement summary content resource.""" + +from http import HTTPStatus + +from flask import jsonify, request +from flask_cors import cross_origin +from flask_restx import Namespace, Resource + +from met_api.auth import jwt as _jwt +from met_api.exceptions.business_exception import BusinessException +from met_api.schemas.engagement_summary_content import EngagementSummarySchema +from met_api.services.engagement_summary_content_service import EngagementSummaryContentService +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace('summary_content', description='Endpoints for Engagement Summary Content Management') +"""Custom exception messages +""" + + +@cors_preflight('GET, POST, PATCH, OPTIONS') +@API.route('') +class Map(Resource): + """Resource for managing engagement summary content.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(content_id): + """Get engagement summary content.""" + try: + summary_content = EngagementSummaryContentService().get_summary_content(content_id) + return jsonify(EngagementSummarySchema().dump(summary_content, many=True)), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(content_id): + """Create engagement summary content.""" + try: + request_json = request.get_json() + summary_content = EngagementSummaryContentService().create_summary_content(content_id, + request_json) + return EngagementSummarySchema().dump(summary_content), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(content_id): + """Update engagement summary content.""" + request_json = request.get_json() + try: + summary_content = EngagementSummaryContentService().update_summary_content(content_id, request_json) + return EngagementSummarySchema().dump(summary_content), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code diff --git a/met-api/src/met_api/schemas/engagement_content.py b/met-api/src/met_api/schemas/engagement_content.py new file mode 100644 index 000000000..ef92a0f72 --- /dev/null +++ b/met-api/src/met_api/schemas/engagement_content.py @@ -0,0 +1,24 @@ +"""Engagement content model class. + +Manages the engagement content. +""" +from marshmallow import EXCLUDE, Schema, fields +from marshmallow_enum import EnumField +from met_api.constants.engagement_content_type import EngagementContentType + + +class EngagementContentSchema(Schema): + """Schema for engagement content.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int(data_key='id') + title = fields.Str(data_key='title') + icon_name = fields.Str(data_key='icon_name') + content_type = EnumField(EngagementContentType) + engagement_id = fields.Int(data_key='engagement_id') + sort_index = fields.Int(data_key='sort_index') + is_internal = fields.Bool(data_key='is_internal') diff --git a/met-api/src/met_api/schemas/engagement_custom_content.py b/met-api/src/met_api/schemas/engagement_custom_content.py new file mode 100644 index 000000000..96d70d954 --- /dev/null +++ b/met-api/src/met_api/schemas/engagement_custom_content.py @@ -0,0 +1,20 @@ +"""Engagement custom model class. + +Manages the engagement custom +""" +from marshmallow import EXCLUDE, Schema, fields + + +class EngagementCustomSchema(Schema): + """Schema for engagement custom.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int(data_key='id') + custom_text_content = fields.Str(data_key='custom_text_content') + custom_json_content = fields.Str(data_key='custom_json_content') + engagement_content_id = fields.Int(data_key='engagement_content_id') + engagement_id = fields.Int(data_key='engagement_id') diff --git a/met-api/src/met_api/schemas/engagement_summary_content.py b/met-api/src/met_api/schemas/engagement_summary_content.py new file mode 100644 index 000000000..f76e53b32 --- /dev/null +++ b/met-api/src/met_api/schemas/engagement_summary_content.py @@ -0,0 +1,20 @@ +"""Engagement summary model class. + +Manages the engagement summary +""" +from marshmallow import EXCLUDE, Schema, fields + + +class EngagementSummarySchema(Schema): + """Schema for engagement summary.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int(data_key='id') + content = fields.Str(data_key='content') + rich_content = fields.Str(data_key='rich_content') + engagement_content_id = fields.Int(data_key='engagement_content_id') + engagement_id = fields.Int(data_key='engagement_id') diff --git a/met-api/src/met_api/schemas/schemas/engagement_content.json b/met-api/src/met_api/schemas/schemas/engagement_content.json new file mode 100644 index 000000000..814620abb --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/engagement_content.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/engagement_content", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document.", + "default": {}, + "examples": [ + { + "title": "Summary", + "icon_name": "SummarizeIcon", + "content_type": "Summary", + "sort_index": 1, + "is_internal": "False", + "engagement_id": 1 + } + ], + "required": ["title", "content_type", "engagement_id"], + "properties": { + "title": { + "$id": "#/properties/title", + "type": "string", + "title": "Summary", + "description": "The title of this content.", + "examples": [1] + }, + "content_type": { + "$id": "#/properties/content_type", + "type": "string", + "title": "Summary", + "description": "The content type enum.", + "examples": [1] + }, + "engagement_id": { + "$id": "#/properties/engagement_id", + "type": "number", + "title": "Engagement id", + "description": "The engagement to which this content belongs.", + "examples": [1] + } + } + } + \ No newline at end of file diff --git a/met-api/src/met_api/schemas/schemas/engagement_content_update.json b/met-api/src/met_api/schemas/schemas/engagement_content_update.json new file mode 100644 index 000000000..e6e3325c3 --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/engagement_content_update.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/engagement_content_update", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document.", + "default": {}, + "examples": [ + { + "title": "Summary" + } + ], + "required": ["title"], + "properties": { + "title": { + "$id": "#/properties/title", + "type": "string", + "title": "Summary", + "description": "The title of this content.", + "examples": [1] + } + } + } + \ No newline at end of file diff --git a/met-api/src/met_api/services/engagement_content_service.py b/met-api/src/met_api/services/engagement_content_service.py new file mode 100644 index 000000000..fe89b4cc2 --- /dev/null +++ b/met-api/src/met_api/services/engagement_content_service.py @@ -0,0 +1,136 @@ +"""Service for engagement content management.""" +from http import HTTPStatus + +from met_api.constants.engagement_content_type import EngagementContentType +from met_api.constants.membership_type import MembershipType +from met_api.exceptions.business_exception import BusinessException +from met_api.models.engagement_content import EngagementContent as EngagementContentModel +from met_api.schemas.engagement_content import EngagementContentSchema +from met_api.services import authorization +from met_api.utils.roles import Role + + +class EngagementContentService: + """Engagement content management service.""" + + @staticmethod + def get_contents_by_engagement_id(engagement_id): + """Get contents by engagement id.""" + engagement_content_schema = EngagementContentSchema(many=True) + engagement_content_records = EngagementContentModel.get_contents_by_engagement_id(engagement_id) + engagement_contents = engagement_content_schema.dump(engagement_content_records) + return engagement_contents + + @staticmethod + def create_engagement_content(engagement_content_data, engagement_id): + """Create engagement content item.""" + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value + ) + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement_id) + + if engagement_content_data.get('engagement_id', None) != int(engagement_id): + raise ValueError('engagement content data has engagement id for a different engagement') + + sort_index = EngagementContentService._find_higest_sort_index(engagement_id) + + engagement_content_data['sort_index'] = sort_index + 1 + created_content = EngagementContentService._create_content(engagement_id, engagement_content_data) + created_content.commit() + return EngagementContentSchema().dump(created_content) + + @staticmethod + def _find_higest_sort_index(engagement_id): + # find the highest sort order of the engagement content + sort_index = 0 + contents = EngagementContentModel.get_contents_by_engagement_id(engagement_id) + if contents: + # Find the largest in the existing engagement contents + sort_index = max(content.sort_index for content in contents) + return sort_index + + @staticmethod + def _create_content(engagement_id, engagement_content_data: dict): + engagement_content_model: EngagementContentModel = EngagementContentModel() + engagement_content_model.engagement_id = engagement_id + engagement_content_model.title = engagement_content_data.get('title') + engagement_content_model.icon_name = engagement_content_data.get('icon_name') + engagement_content_model.content_type = EngagementContentType[engagement_content_data.get('content_type')] + engagement_content_model.sort_index = engagement_content_data.get('sort_index') + engagement_content_model.is_internal = engagement_content_data.get('is_internal') + engagement_content_model.flush() + return engagement_content_model + + @staticmethod + def sort_engagement_content(engagement_id, engagement_contents: list, user_id=None): + """Sort engagement contents.""" + EngagementContentService._validate_engagement_content_ids(engagement_id, engagement_contents) + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value + ) + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement_id) + + engagement_content_sort_mappings = [{ + 'id': engagement_content.get('id'), + 'sort_index': index + 1, + 'updated_by': user_id + } for index, engagement_content in enumerate(engagement_contents) + ] + + EngagementContentModel.update_engagement_contents(engagement_content_sort_mappings) + + @staticmethod + def _validate_engagement_content_ids(engagement_id, engagement_contents): + """Validate if engagement content ids belong to the engagement.""" + eng_contents = EngagementContentModel.get_contents_by_engagement_id(engagement_id) + content_ids = [content.id for content in eng_contents] + input_content_ids = [engagement_content.get('id') for engagement_content in engagement_contents] + if len(set(content_ids) - set(input_content_ids)) > 0: + raise BusinessException( + error='Invalid engagement contents.', + status_code=HTTPStatus.BAD_REQUEST) + + @staticmethod + def delete_engagement_content(engagement_id, engagement_content_id): + """Remove engagement content from engagement.""" + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value + ) + + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement_id) + + engagement_contents = EngagementContentModel.remove_engagement_content(engagement_id, engagement_content_id) + if not engagement_contents: + raise ValueError('Engagement content to remove was not found') + return engagement_contents + + @staticmethod + def update_engagement_content(engagement_id, engagement_content_id: list, + engagement_content_data: dict, user_id=None): + """Sort engagement contents.""" + EngagementContentService._verify_engagement_content(engagement_content_id) + + engagement_content_data['updated_by'] = user_id + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value + ) + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement_id) + + updated_engagement_content = EngagementContentModel.update_engagement_content(engagement_id, + engagement_content_id, + engagement_content_data) + return EngagementContentSchema().dump(updated_engagement_content) + + @staticmethod + def _verify_engagement_content(engagement_content_id): + """Verify if engagement content exists.""" + engagement_content = EngagementContentModel.find_by_id(engagement_content_id) + if not engagement_content: + raise KeyError('Engagement content ' + engagement_content_id + ' does not exist') + return engagement_content diff --git a/met-api/src/met_api/services/engagement_custom_content_service.py b/met-api/src/met_api/services/engagement_custom_content_service.py new file mode 100644 index 000000000..4f0b3c057 --- /dev/null +++ b/met-api/src/met_api/services/engagement_custom_content_service.py @@ -0,0 +1,53 @@ +"""Service for engagement custom content management.""" +from met_api.constants.membership_type import MembershipType +from met_api.models.engagement_custom_content import EngagementCustom as EngagementCustomModel +from met_api.services import authorization +from met_api.utils.roles import Role + + +class EngagementCustomContentService: + """Engagement custom content management service.""" + + @staticmethod + def get_custom_content(content_id): + """Get content by engagement custom content id.""" + custom_content = EngagementCustomModel.get_custom_content(content_id) + return custom_content + + @staticmethod + def create_custom_content(content_id, custom_content_details: dict): + """Create engagement custom content.""" + custom_content = dict(custom_content_details) + eng_id = custom_content.get('engagement_id') + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value), engagement_id=eng_id) + + engagement_custom_content = EngagementCustomContentService._create_custom_content_model(content_id, + custom_content) + engagement_custom_content.commit() + return engagement_custom_content + + @staticmethod + def _create_custom_content_model(content_id, custom_content: dict): + custom_content_model: EngagementCustomModel = EngagementCustomModel() + custom_content_model.engagement_content_id = content_id + custom_content_model.engagement_id = custom_content.get('engagement_id') + custom_content_model.custom_text_content = custom_content.get('custom_text_content') + custom_content_model.custom_json_content = custom_content.get('custom_json_content') + custom_content_model.flush() + return custom_content_model + + @staticmethod + def update_custom_content(content_id, request_json): + """Update engagement custom content.""" + custom_content_list: EngagementCustomModel = EngagementCustomModel.get_custom_content(content_id) + if not custom_content_list: + raise KeyError('Engagement custom content ' + content_id + ' does not exist') + + custom_content = custom_content_list[0] + eng_id = custom_content.engagement_id + + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value), engagement_id=eng_id) + + return EngagementCustomModel.update_custom_content(content_id, request_json) diff --git a/met-api/src/met_api/services/engagement_summary_content_service.py b/met-api/src/met_api/services/engagement_summary_content_service.py new file mode 100644 index 000000000..b050c625a --- /dev/null +++ b/met-api/src/met_api/services/engagement_summary_content_service.py @@ -0,0 +1,53 @@ +"""Service for engagement summary content management.""" +from met_api.constants.membership_type import MembershipType +from met_api.models.engagement_summary_content import EngagementSummary as EngagementSummaryModel +from met_api.services import authorization +from met_api.utils.roles import Role + + +class EngagementSummaryContentService: + """Engagement summary content management service.""" + + @staticmethod + def get_summary_content(content_id): + """Get content by engagement summary content id.""" + summary_content = EngagementSummaryModel.get_summary_content(content_id) + return summary_content + + @staticmethod + def create_summary_content(content_id, summary_content_details: dict): + """Create engagement summary content.""" + summary_content = dict(summary_content_details) + eng_id = summary_content.get('engagement_id') + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value), engagement_id=eng_id) + + engagement_summary_content = EngagementSummaryContentService._create_summary_content_model(content_id, + summary_content) + engagement_summary_content.commit() + return engagement_summary_content + + @staticmethod + def _create_summary_content_model(content_id, summary_content: dict): + summary_content_model: EngagementSummaryModel = EngagementSummaryModel() + summary_content_model.engagement_content_id = content_id + summary_content_model.engagement_id = summary_content.get('engagement_id') + summary_content_model.content = summary_content.get('content') + summary_content_model.rich_content = summary_content.get('rich_content') + summary_content_model.flush() + return summary_content_model + + @staticmethod + def update_summary_content(content_id, request_json): + """Update engagement summary content.""" + summary_content_list: EngagementSummaryModel = EngagementSummaryModel.get_summary_content(content_id) + if not summary_content_list: + raise KeyError('Engagement summary content ' + content_id + ' does not exist') + + summary_content = summary_content_list[0] + eng_id = summary_content.engagement_id + + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value), engagement_id=eng_id) + + return EngagementSummaryModel.update_summary_content(content_id, request_json) diff --git a/met-api/src/met_api/utils/enums.py b/met-api/src/met_api/utils/enums.py index 64de3e805..63208a9d2 100644 --- a/met-api/src/met_api/utils/enums.py +++ b/met-api/src/met_api/utils/enums.py @@ -102,3 +102,10 @@ class UserStatus(IntEnum): ACTIVE = 1 INACTIVE = 2 + + +class ContentTitle(Enum): + """User status.""" + + DEFAULT = 'Summary' + DEFAULT_ICON = 'SummarizeIcon' From 83c4312b72e889c4225fd763573fe85486e81f11 Mon Sep 17 00:00:00 2001 From: VineetBala-AOT <90332175+VineetBala-AOT@users.noreply.github.com> Date: Mon, 26 Feb 2024 19:38:01 -0800 Subject: [PATCH 2/2] Unit Tests for dynamic engagement pages (#2393) --- .../tests/unit/api/test_engagement_content.py | 256 ++++++++++++++++++ .../api/test_engagement_custom_content.py | 121 +++++++++ .../api/test_engagement_summary_content.py | 121 +++++++++ met-api/tests/utilities/factory_scenarios.py | 20 +- 4 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 met-api/tests/unit/api/test_engagement_content.py create mode 100644 met-api/tests/unit/api/test_engagement_custom_content.py create mode 100644 met-api/tests/unit/api/test_engagement_summary_content.py diff --git a/met-api/tests/unit/api/test_engagement_content.py b/met-api/tests/unit/api/test_engagement_content.py new file mode 100644 index 000000000..9a7cf33b0 --- /dev/null +++ b/met-api/tests/unit/api/test_engagement_content.py @@ -0,0 +1,256 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to verify the engagement content API end-point. + +Test-Suite to ensure that the engagement content endpoint is working as expected. +""" +import json +from http import HTTPStatus +from marshmallow import ValidationError +from unittest.mock import patch + +import pytest +from faker import Faker + +from met_api.constants.engagement_content_type import EngagementContentType +from met_api.services.engagement_content_service import EngagementContentService +from met_api.utils.enums import ContentType +from tests.utilities.factory_scenarios import TestEngagementContentInfo +from tests.utilities.factory_utils import factory_auth_header, factory_engagement_model + + +fake = Faker() + + +@pytest.mark.parametrize('engagement_content_info', [TestEngagementContentInfo.content1]) +def test_create_engagement_content(client, jwt, session, engagement_content_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement content can be POSTed.""" + engagement = factory_engagement_model() + engagement_content_info['engagement_id'] = engagement.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + + rv = client.get('/api/engagement_content/engagement/' + str(engagement.id), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + assert rv.json[0].get('sort_index') == 1 + + with patch.object(EngagementContentService, 'create_engagement_content', + side_effect=ValueError('Test error')): + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch.object(EngagementContentService, 'create_engagement_content', + side_effect=ValidationError('Test error')): + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.parametrize('engagement_content_info', [TestEngagementContentInfo.content1]) +def test_get_engagement_content(client, jwt, session, engagement_content_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement content can be fetched.""" + engagement = factory_engagement_model() + engagement_content_info['engagement_id'] = engagement.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + + rv = client.get('/api/engagement_content/engagement/' + str(engagement.id), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + assert rv.json[0].get('sort_index') == 1 + + with patch.object(EngagementContentService, 'get_contents_by_engagement_id', side_effect=ValueError('Test error')): + rv = client.get('/api/engagement_content/engagement/' + str(engagement.id), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +def test_create_engagement_content_sort(client, jwt, session, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement content can be sorted.""" + engagement = factory_engagement_model() + engagement_content_info_1 = TestEngagementContentInfo.content1 + engagement_content_info_1['engagement_id'] = engagement.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info_1), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + + engagement_content_info_2 = TestEngagementContentInfo.content2 + engagement_content_info_2['engagement_id'] = engagement.id + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info_2), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + + rv = client.get('/api/engagement_content/engagement/' + str(engagement.id), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + assert len(rv.json) == 2, 'Two Contents Should exist.' + engagement_contents = rv.json + summary_content = _find_engagement_content(engagement_contents, EngagementContentType.Summary.name) + assert summary_content.get('sort_index') == 1 + + custom_content = _find_engagement_content(engagement_contents, EngagementContentType.Custom.name) + assert custom_content.get('sort_index') == 2 + + # Do reorder + + reorder_dict = [ + { + 'id': custom_content.get('id'), + }, + { + 'id': summary_content.get('id'), + } + ] + + rv = client.patch(f'/api/engagement_content/engagement/{engagement.id}/sort_index', + data=json.dumps(reorder_dict), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 204 + + rv = client.get('/api/engagement_content/engagement/' + str(engagement.id), + headers=headers, content_type=ContentType.JSON.value) + engagement_contents = rv.json + summary_content = _find_engagement_content(engagement_contents, EngagementContentType.Summary.name) + assert summary_content.get('sort_index') == 2 + + custom_content = _find_engagement_content(engagement_contents, EngagementContentType.Custom.name) + assert custom_content.get('sort_index') == 1 + + +def _find_engagement_content(engagement_contents, content_type): + search_result = next(x for x in engagement_contents if x.get('content_type') == content_type) + return search_result + + +def test_create_engagement_content_sort_invalid(client, jwt, session, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement content sort error handling is done.""" + engagement = factory_engagement_model() + engagement_content_info_1 = TestEngagementContentInfo.content1 + engagement_content_info_1['engagement_id'] = engagement.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info_1), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + + # invalid reorder + reorder_dict = [{ + 'id': 123, + 'sort_index': 2 + }, { + 'id': 1234, + 'sort_index': 1 + } + ] + rv = client.patch(f'/api/engagement_content/engagement/{engagement.id}/sort_index', + data=json.dumps(reorder_dict), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +def test_delete_engagement_content(client, jwt, session, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement content can be deleted.""" + engagement = factory_engagement_model() + engagement_content_info_1 = TestEngagementContentInfo.content1 + engagement_content_info_1['engagement_id'] = engagement.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info_1), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + response_json = rv.json + created_content_id = response_json.get('id') + + rv = client.delete(f'/api/engagement_content/{created_content_id}/engagements/' + str(engagement.id), + headers=headers, content_type=ContentType.JSON.value) + + assert rv.status_code == HTTPStatus.OK + + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info_1), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + response_json = rv.json + created_content_id = response_json.get('id') + + with patch.object(EngagementContentService, 'delete_engagement_content', + side_effect=ValueError('Test error')): + rv = client.delete(f'/api/engagement_content/{created_content_id}/engagements/' + str(engagement.id), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +def test_patch_engagement_content(client, jwt, session, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement content can be PATCHed.""" + engagement = factory_engagement_model() + engagement_content_info_1 = TestEngagementContentInfo.content1 + engagement_content_info_1['engagement_id'] = engagement.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info_1), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + response_json = rv.json + created_content_id = response_json.get('id') + + data = { + 'title': fake.text(max_nb_chars=10), + } + rv = client.patch(f'/api/engagement_content/{created_content_id}/engagements/' + str(engagement.id), + data=json.dumps(data), + headers=headers, content_type=ContentType.JSON.value) + + assert rv.status_code == HTTPStatus.OK + assert rv.json.get('title') == data.get('title') + + with patch.object(EngagementContentService, 'update_engagement_content', + side_effect=ValueError('Test error')): + rv = client.patch(f'/api/engagement_content/{created_content_id}/engagements/' + str(engagement.id), + data=json.dumps(data), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch.object(EngagementContentService, 'update_engagement_content', + side_effect=ValidationError('Test error')): + rv = client.patch(f'/api/engagement_content/{created_content_id}/engagements/' + str(engagement.id), + data=json.dumps(data), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/met-api/tests/unit/api/test_engagement_custom_content.py b/met-api/tests/unit/api/test_engagement_custom_content.py new file mode 100644 index 000000000..d274cf97b --- /dev/null +++ b/met-api/tests/unit/api/test_engagement_custom_content.py @@ -0,0 +1,121 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to verify the engagement custom content API end-point. + +Test-Suite to ensure that the engagement custom content endpoint is working as expected. +""" +import json +from http import HTTPStatus +from unittest.mock import patch + +import pytest +from faker import Faker + +from met_api.exceptions.business_exception import BusinessException +from met_api.services.engagement_custom_content_service import EngagementCustomContentService +from met_api.utils.enums import ContentType +from tests.utilities.factory_scenarios import TestEngagementContentInfo +from tests.utilities.factory_utils import factory_auth_header, factory_engagement_model + + +fake = Faker() + + +@pytest.mark.parametrize('engagement_content_info', [TestEngagementContentInfo.content2]) +def test_engagement_custom_content(client, jwt, session, engagement_content_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement custom content can be POSTed.""" + engagement = factory_engagement_model() + engagement_content_info['engagement_id'] = engagement.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + response_json = rv.json + created_content_id = response_json.get('id') + + data = { + 'custom_text_content': 'Content Sample', + 'custom_json_content': '"{\"blocks\":[{\"key\":\"fclgj\",\"text\":\"Rich Content Sample\",\"type\":\"unstyled\",\"depth\":0,\ + \"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"', + 'engagement_id': engagement.id + } + + rv = client.post( + f'/api/engagement_content/{created_content_id}/custom', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK.value + + with patch.object(EngagementCustomContentService, 'create_custom_content', + side_effect=BusinessException('Test error', status_code=HTTPStatus.BAD_REQUEST)): + rv = client.post( + f'/api/engagement_content/{created_content_id}/custom', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + rv = client.get( + f'/api/engagement_content/{created_content_id}/custom', + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK.value + assert rv.json[0].get('custom_text_content') == data.get('custom_text_content') + + with patch.object(EngagementCustomContentService, 'get_custom_content', + side_effect=BusinessException('Test error', status_code=HTTPStatus.BAD_REQUEST)): + rv = client.get( + f'/api/engagement_content/{created_content_id}/custom', + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + data_edits = { + 'custom_text_content': fake.text(max_nb_chars=10) + } + + rv = client.patch( + f'/api/engagement_content/{created_content_id}/custom', + data=json.dumps(data_edits), + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK + + rv = client.get( + f'/api/engagement_content/{created_content_id}/custom', + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK.value + assert rv.json[0].get('custom_text_content') == data_edits.get('custom_text_content') + + with patch.object(EngagementCustomContentService, 'update_custom_content', + side_effect=BusinessException('Test error', status_code=HTTPStatus.BAD_REQUEST)): + rv = client.patch( + f'/api/engagement_content/{created_content_id}/custom', + data=json.dumps(data_edits), + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.BAD_REQUEST diff --git a/met-api/tests/unit/api/test_engagement_summary_content.py b/met-api/tests/unit/api/test_engagement_summary_content.py new file mode 100644 index 000000000..506982064 --- /dev/null +++ b/met-api/tests/unit/api/test_engagement_summary_content.py @@ -0,0 +1,121 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to verify the engagement summary content API end-point. + +Test-Suite to ensure that the engagement summary content endpoint is working as expected. +""" +import json +from http import HTTPStatus +from unittest.mock import patch + +import pytest +from faker import Faker + +from met_api.exceptions.business_exception import BusinessException +from met_api.services.engagement_summary_content_service import EngagementSummaryContentService +from met_api.utils.enums import ContentType +from tests.utilities.factory_scenarios import TestEngagementContentInfo +from tests.utilities.factory_utils import factory_auth_header, factory_engagement_model + + +fake = Faker() + + +@pytest.mark.parametrize('engagement_content_info', [TestEngagementContentInfo.content1]) +def test_engagement_summary_content(client, jwt, session, engagement_content_info, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a engagement summary content can be POSTed.""" + engagement = factory_engagement_model() + engagement_content_info['engagement_id'] = engagement.id + user, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/engagement_content/engagement/' + str(engagement.id), + data=json.dumps(engagement_content_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + response_json = rv.json + created_content_id = response_json.get('id') + + data = { + 'content': 'Content Sample', + 'rich_content': '"{\"blocks\":[{\"key\":\"fclgj\",\"text\":\"Rich Content Sample\",\"type\":\"unstyled\",\"depth\":0,\ + \"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"', + 'engagement_id': engagement.id + } + + rv = client.post( + f'/api/engagement_content/{created_content_id}/summary', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK.value + + with patch.object(EngagementSummaryContentService, 'create_summary_content', + side_effect=BusinessException('Test error', status_code=HTTPStatus.BAD_REQUEST)): + rv = client.post( + f'/api/engagement_content/{created_content_id}/summary', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + rv = client.get( + f'/api/engagement_content/{created_content_id}/summary', + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK.value + assert rv.json[0].get('content') == data.get('content') + + with patch.object(EngagementSummaryContentService, 'get_summary_content', + side_effect=BusinessException('Test error', status_code=HTTPStatus.BAD_REQUEST)): + rv = client.get( + f'/api/engagement_content/{created_content_id}/summary', + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + data_edits = { + 'content': fake.text(max_nb_chars=10) + } + + rv = client.patch( + f'/api/engagement_content/{created_content_id}/summary', + data=json.dumps(data_edits), + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK + + rv = client.get( + f'/api/engagement_content/{created_content_id}/summary', + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK.value + assert rv.json[0].get('content') == data_edits.get('content') + + with patch.object(EngagementSummaryContentService, 'update_summary_content', + side_effect=BusinessException('Test error', status_code=HTTPStatus.BAD_REQUEST)): + rv = client.patch( + f'/api/engagement_content/{created_content_id}/summary', + data=json.dumps(data_edits), + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.BAD_REQUEST diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 477363623..13e24fadc 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -24,11 +24,12 @@ from met_api.config import get_named_config from met_api.constants.comment_status import Status as CommentStatus from met_api.constants.engagement_status import Status as EngagementStatus +from met_api.constants.engagement_content_type import EngagementContentType from met_api.constants.engagement_status import SubmissionStatus from met_api.constants.timeline_event_status import TimelineEventStatus from met_api.constants.feedback import CommentType, FeedbackSourceType, FeedbackStatusType, RatingType from met_api.constants.widget import WidgetType -from met_api.utils.enums import LoginSource, UserStatus +from met_api.utils.enums import ContentTitle, LoginSource, UserStatus fake = Faker() @@ -887,3 +888,20 @@ class TestPollResponseInfo(dict, Enum): 'poll_id': 1, # should be replaced with an actual poll ID in tests 'widget_id': 1, # should be replaced with an actual widget ID in tests } + + +class TestEngagementContentInfo(dict, Enum): + """Test scenarios of engagement content.""" + + content1 = { + 'title': ContentTitle.DEFAULT.value, + 'icon_name': ContentTitle.DEFAULT_ICON.value, + 'content_type': EngagementContentType.Summary.name, + 'is_internal': False, + } + content2 = { + 'title': 'Custom', + 'icon_name': ContentTitle.DEFAULT_ICON.value, + 'content_type': EngagementContentType.Custom.name, + 'is_internal': False, + }