diff --git a/docs/MET_database_ERD.md b/docs/MET_database_ERD.md index e1ea22876..ddd1b41a7 100644 --- a/docs/MET_database_ERD.md +++ b/docs/MET_database_ERD.md @@ -18,7 +18,7 @@ erDiagram string banner_filename timestamp scheduled_date integer tenant_id FK "The id from tenant" - boolean is_internal + integer visibility FK "The id from engagement visibility" } survey { integer id PK @@ -45,6 +45,14 @@ erDiagram string updated_by } engagement only one to one engagement_status : has + engagement_visibility { + integer id PK + string visibility_name + string description + timestamp created_date + timestamp updated_date + } + engagement only one to one engagement_visibility : has tenant { integer id PK string short_name diff --git a/met-api/migrations/versions/a3e6dae331ab_add_visibility_column_to_engagment.py b/met-api/migrations/versions/a3e6dae331ab_add_visibility_column_to_engagment.py new file mode 100644 index 000000000..f469e28f8 --- /dev/null +++ b/met-api/migrations/versions/a3e6dae331ab_add_visibility_column_to_engagment.py @@ -0,0 +1,63 @@ +"""Add visibility column to engagment + +Revision ID: a3e6dae331ab +Revises: 9a93fda677eb +Create Date: 2024-05-28 08:26:11.155679 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a3e6dae331ab' +down_revision = '9a93fda677eb' +branch_labels = None +depends_on = None + +def upgrade(): + # Create the engagement_visibility table + engagement_visiblity_table = op.create_table( + 'engagement_visibility', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('visibility_name', sa.String(length=50), nullable=False), + sa.Column('description', sa.String(length=100), nullable=False), + sa.Column('created_date', sa.TIMESTAMP(timezone=False), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_date', sa.TIMESTAMP(timezone=False), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # Insert the initial data into the engagement_visibility table + op.bulk_insert( + engagement_visiblity_table, + [ + {'id': 1, 'visibility_name': 'Public', 'description': 'Visible to all users'}, + {'id': 2, 'visibility_name': 'Slug', 'description': 'Accessible to users with the direct link'}, + {'id': 3, 'visibility_name': 'AuthToken', 'description': 'Visible to authenticated users'} + ] + ) + # Add the visibility column to the engagement table + op.add_column('engagement', sa.Column('visibility', sa.Integer(), nullable=False, server_default='1')) + op.create_foreign_key("engagement_visibility_fkey", 'engagement', 'engagement_visibility', ['visibility'], ['id']) + # Update the visibility column based on the is_internal column + op.execute(""" + UPDATE engagement e + SET visibility = ev.id + FROM engagement_visibility ev + WHERE (e.is_internal AND ev.visibility_name = 'AuthToken') + OR (NOT e.is_internal AND ev.visibility_name = 'Public') + """) + op.drop_column('engagement', 'is_internal') + + +def downgrade(): + # Add the is_internal column to the engagement table + op.add_column('engagement', sa.Column('is_internal', sa.BOOLEAN(), nullable=False, server_default='0')) + # Populate the is_internal column based on the visibility column + op.execute(""" + UPDATE engagement e + SET is_internal = (e.visibility = (SELECT id FROM engagement_visibility WHERE visibility_name = 'AuthToken')) + """) + # Drop the foreign key constraint on the visibility column + op.drop_constraint("engagement_visibility_fkey", 'engagement', type_='foreignkey') + op.drop_column('engagement', 'visibility') + op.drop_table('engagement_visibility') \ No newline at end of file diff --git a/met-api/src/met_api/constants/engagement_visibility.py b/met-api/src/met_api/constants/engagement_visibility.py new file mode 100644 index 000000000..ac7cfb892 --- /dev/null +++ b/met-api/src/met_api/constants/engagement_visibility.py @@ -0,0 +1,23 @@ +# Copyright © 2024 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 visibility.""" +from enum import IntEnum + + +class Visibility(IntEnum): + """Enum of engagement visibility.""" + + Public = 1 + Slug = 2 + AuthToken = 3 diff --git a/met-api/src/met_api/models/engagement.py b/met-api/src/met_api/models/engagement.py index cf54e1dd6..20d51f83a 100644 --- a/met-api/src/met_api/models/engagement.py +++ b/met-api/src/met_api/models/engagement.py @@ -14,6 +14,7 @@ from sqlalchemy.sql.schema import ForeignKey from met_api.constants.engagement_status import EngagementDisplayStatus, Status +from met_api.constants.engagement_visibility import Visibility from met_api.constants.user import SYSTEM_USER from met_api.models.engagement_metadata import EngagementMetadataModel from met_api.models.membership import Membership as MembershipModel @@ -26,6 +27,7 @@ from .base_model import BaseModel from .db import db from .engagement_status import EngagementStatus +from .engagement_visibility import EngagementVisibility class Engagement(BaseModel): @@ -47,7 +49,7 @@ class Engagement(BaseModel): surveys = db.relationship('Survey', backref='engagement', cascade='all, delete') status_block = db.relationship('EngagementStatusBlock', backref='engagement') tenant_id = db.Column(db.Integer, db.ForeignKey('tenant.id'), nullable=True) - is_internal = db.Column(db.Boolean, nullable=False) + visibility = db.Column(db.Integer, ForeignKey('engagement_visibility.id'), nullable=False) @classmethod def get_engagements_paginated( @@ -58,7 +60,7 @@ def get_engagements_paginated( search_options=None, ): """Get engagements paginated.""" - query = db.session.query(Engagement).join(EngagementStatus) + query = db.session.query(Engagement).join(EngagementStatus).join(EngagementVisibility) query = cls._add_tenant_filter(query) @@ -73,7 +75,7 @@ def get_engagements_paginated( query = cls._filter_by_project_metadata(query, search_options) - query = cls._filter_by_internal(query, search_options) + query = cls._filter_by_visibility(query, search_options) if scope_options.restricted: if scope_options.include_assigned: @@ -122,7 +124,7 @@ def update_engagement(cls, engagement: EngagementSchema) -> Engagement: banner_filename=engagement.get('banner_filename', None), content=engagement.get('content', None), rich_content=engagement.get('rich_content', None), - is_internal=engagement.get('is_internal', record.is_internal), + visibility=engagement.get('visibility', record.visibility) ) query.update(update_fields) db.session.commit() @@ -184,10 +186,9 @@ def publish_scheduled_engagements_due(cls) -> List[Engagement]: .filter(Engagement.status_id == Status.Scheduled.value) \ .filter(Engagement.scheduled_date <= datetime_due) records = query.all() - if not records: - return None - query.update(update_fields) - db.session.commit() + if records: + query.update(update_fields) + db.session.commit() return records @staticmethod @@ -253,10 +254,12 @@ def _filter_by_search_text(query, search_options): return query @staticmethod - def _filter_by_internal(query, search_options): + def _filter_by_visibility(query, search_options): if exclude_internal := search_options.get('exclude_internal'): if exclude_internal: - query = query.filter(Engagement.is_internal.is_(False)) + query = query.filter(Engagement.visibility == Visibility.Public) + else: + query = query.filter(Engagement.visibility == Visibility.AuthToken) return query @staticmethod diff --git a/met-api/src/met_api/models/engagement_visibility.py b/met-api/src/met_api/models/engagement_visibility.py new file mode 100644 index 000000000..828a5c364 --- /dev/null +++ b/met-api/src/met_api/models/engagement_visibility.py @@ -0,0 +1,27 @@ +"""Engagement Visibility model class. + +Manages the engagement visibility +""" +from .base_model import BaseModel +from .db import db, ma + + +class EngagementVisibility(BaseModel): # pylint: disable=too-few-public-methods + """Definition of the Engagement Visibility entity.""" + + __tablename__ = 'engagement_visibility' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + visibility_name = db.Column(db.String(50), nullable=False) + description = db.Column(db.String(50)) + created_date = db.Column(db.DateTime, nullable=False, default=db.func.current_timestamp()) + updated_date = db.Column(db.DateTime, nullable=True) + + +class EngagementVisibilitySchema(ma.Schema): + """Engagement visibility schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Meta class.""" + + fields = ('id', 'visibility_name', 'description', 'created_date', 'updated_date') diff --git a/met-api/src/met_api/schemas/engagement.py b/met-api/src/met_api/schemas/engagement.py index b1961b29e..1748860be 100644 --- a/met-api/src/met_api/schemas/engagement.py +++ b/met-api/src/met_api/schemas/engagement.py @@ -15,6 +15,7 @@ from met_api.utils.datetime import local_datetime from .engagement_status import EngagementStatusSchema +from .engagement_visibility import EngagementVisibilitySchema class EngagementSchema(Schema): @@ -47,7 +48,8 @@ class Meta: # pylint: disable=too-few-public-methods submissions_meta_data = fields.Method('get_submissions_meta_data') status_block = fields.List(fields.Nested(EngagementStatusBlockSchema)) tenant_id = fields.Str(data_key='tenant_id') - is_internal = fields.Bool(data_key='is_internal') + visibility = fields.Int(data_key='visibility') + engagement_visibility = fields.Nested(EngagementVisibilitySchema) def get_submissions_meta_data(self, obj): """Get the meta data of the submissions made in the survey.""" diff --git a/met-api/src/met_api/schemas/engagement_visibility.py b/met-api/src/met_api/schemas/engagement_visibility.py new file mode 100644 index 000000000..09dd13b38 --- /dev/null +++ b/met-api/src/met_api/schemas/engagement_visibility.py @@ -0,0 +1,17 @@ +"""Engagement visibility schema class.""" +from marshmallow import EXCLUDE, Schema, fields + + +class EngagementVisibilitySchema(Schema): + """Schema for engagement visibility.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int(data_key='id') + visibility_name = fields.Str(data_key='visibility_name') + description = fields.Str(data_key='description') + created_date = fields.Str(data_key='created_date') + updated_date = fields.Str(data_key='updated_date') diff --git a/met-api/src/met_api/services/email_verification_service.py b/met-api/src/met_api/services/email_verification_service.py index 9db1e2640..1a2247d96 100644 --- a/met-api/src/met_api/services/email_verification_service.py +++ b/met-api/src/met_api/services/email_verification_service.py @@ -5,6 +5,7 @@ from flask import current_app from met_api.constants.email_verification import INTERNAL_EMAIL_DOMAIN, EmailVerificationType +from met_api.constants.engagement_visibility import Visibility from met_api.constants.subscription_type import SubscriptionTypes from met_api.exceptions.business_exception import BusinessException @@ -52,7 +53,7 @@ def create(cls, email_verification: EmailVerificationSchema, survey = SurveyModel.find_by_id(email_verification.get('survey_id')) engagement: EngagementModel = EngagementModel.find_by_id( survey.engagement_id) - if engagement.is_internal and not email_address.endswith(INTERNAL_EMAIL_DOMAIN): + if engagement.visibility == Visibility.AuthToken and not email_address.endswith(INTERNAL_EMAIL_DOMAIN): raise BusinessException( error='Not an internal email address.', status_code=HTTPStatus.INTERNAL_SERVER_ERROR) diff --git a/met-api/src/met_api/services/engagement_service.py b/met-api/src/met_api/services/engagement_service.py index 6692749cc..9e8a69145 100644 --- a/met-api/src/met_api/services/engagement_service.py +++ b/met-api/src/met_api/services/engagement_service.py @@ -5,6 +5,7 @@ from flask import current_app from met_api.constants.engagement_status import Status +from met_api.constants.engagement_visibility import Visibility from met_api.constants.membership_type import MembershipType from met_api.exceptions.business_exception import BusinessException from met_api.models.engagement import Engagement as EngagementModel @@ -139,9 +140,11 @@ def publish_scheduled_engagements(): engagements = EngagementModel.publish_scheduled_engagements_due() print('Engagements published: ', engagements) for engagement in engagements: - email_util.publish_to_email_queue(SourceType.ENGAGEMENT.value, engagement.id, - SourceAction.PUBLISHED.value, True) - print('Engagements published added to email queue: ', engagement.id) + # Only add to email queue if engagement is public. + if engagement.visibility == Visibility.Public.value: + email_util.publish_to_email_queue(SourceType.ENGAGEMENT.value, engagement.id, + SourceAction.PUBLISHED.value, True) + print('Engagements published added to email queue: ', engagement.id) return engagements @staticmethod @@ -178,7 +181,7 @@ def _create_engagement_model(engagement_data: dict) -> EngagementModel: banner_filename=engagement_data.get('banner_filename', None), content=engagement_data.get('content', None), rich_content=engagement_data.get('rich_content', None), - is_internal=engagement_data.get('is_internal', False) + visibility=engagement_data.get('visibility', None) ) new_engagement.save() return new_engagement @@ -220,7 +223,7 @@ def _save_or_update_eng_block(engagement_id, status_block): @staticmethod def _validate_engagement_edit_data(engagement_id: int, data: dict): engagement = EngagementModel.find_by_id(engagement_id) - draft_status_restricted_changes = (EngagementModel.is_internal.key,) + draft_status_restricted_changes = (EngagementModel.visibility.key,) engagement_has_been_opened = engagement.status_id != Status.Draft.value if engagement_has_been_opened and any(field in data for field in draft_status_restricted_changes): raise ValueError('Some fields cannot be updated after the engagement has been published') diff --git a/met-api/src/met_api/services/engagement_slug_service.py b/met-api/src/met_api/services/engagement_slug_service.py index 98fcc7967..4ecd49362 100644 --- a/met-api/src/met_api/services/engagement_slug_service.py +++ b/met-api/src/met_api/services/engagement_slug_service.py @@ -100,7 +100,7 @@ def update_engagement_slug(cls, slug: str, engagement_id: int) -> EngagementSlug # publish changes to EPIC ProjectService.update_project_info(engagement_id) - + return { 'slug': engagement_slug.slug, 'engagement_id': engagement_slug.engagement_id, diff --git a/met-api/tests/unit/api/test_engagement.py b/met-api/tests/unit/api/test_engagement.py index d5a9d0f59..f8ef20f1f 100644 --- a/met-api/tests/unit/api/test_engagement.py +++ b/met-api/tests/unit/api/test_engagement.py @@ -25,6 +25,7 @@ from flask import current_app from met_api.constants.engagement_status import EngagementDisplayStatus, SubmissionStatus +from met_api.constants.engagement_visibility import Visibility from met_api.models.tenant import Tenant as TenantModel from met_api.utils.constants import TENANT_ID_HEADER from met_api.utils.enums import ContentType @@ -277,6 +278,20 @@ def test_search_engagements_not_logged_in(client, session): # pylint:disable=un assert rv.status_code == 200 +def test_search_hidden_engagements_not_logged_in(client, session): # pylint:disable=unused-argument + """Assert that an engagement cannot be searched if it is hidden.""" + factory_engagement_model(visibility=Visibility.Slug) + + rv = client.get('/api/engagements/', content_type=ContentType.JSON.value) + assert rv.json.get('total') == 0, 'it is not visible for public user' + assert rv.status_code == 200 + + factory_engagement_model(TestEngagementInfo.engagement3) + rv = client.get('/api/engagements/', content_type=ContentType.JSON.value) + assert rv.json.get('total') == 1, 'Only the public engagaments should be visible for public user' + assert rv.status_code == 200 + + @pytest.mark.parametrize('engagement_info', [TestEngagementInfo.engagement1]) def test_patch_engagement(client, jwt, session, engagement_info): # pylint:disable=unused-argument """Assert that an engagement can be updated.""" diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index fd78141f9..d4adbba02 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -25,6 +25,7 @@ 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_status import SubmissionStatus +from met_api.constants.engagement_visibility import Visibility 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 @@ -163,7 +164,7 @@ class TestEngagementInfo(dict, Enum): 'created_by': '123', 'updated_by': '123', 'status': EngagementStatus.Published.value, - 'is_internal': False, + 'visibility': Visibility.Public.value, 'description': 'My Test Engagement Description', 'rich_description': '"{\"blocks\":[{\"key\":\"2ku94\",\"text\":\"Rich Description Sample\",\"type\":\"unstyled\",\ \"depth\":0,\"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"', @@ -180,7 +181,7 @@ class TestEngagementInfo(dict, Enum): 'created_by': '123', 'updated_by': '123', 'status': EngagementStatus.Draft.value, - 'is_internal': False, + 'visibility': Visibility.Public.value, 'description': 'My Test Engagement Description', 'rich_description': '"{\"blocks\":[{\"key\":\"2ku94\",\"text\":\"Rich Description Sample\",\"type\":\"unstyled\",\ \"depth\":0,\"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"', @@ -197,7 +198,7 @@ class TestEngagementInfo(dict, Enum): 'created_by': '123', 'updated_by': '123', 'status': SubmissionStatus.Open.value, - 'is_internal': False, + 'visibility': Visibility.Public.value, 'description': 'My Test Engagement Description', 'rich_description': '"{\"blocks\":[{\"key\":\"2ku94\",\"text\":\"Rich Description Sample\",\"type\":\"unstyled\",\ \"depth\":0,\"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"', @@ -221,7 +222,7 @@ class TestEngagementInfo(dict, Enum): 'created_by': '123', 'updated_by': '123', 'status': SubmissionStatus.Open.value, - 'is_internal': False, + 'visibility': Visibility.Public.value, 'description': 'My Test Engagement Description', 'rich_description': '"{\"blocks\":[{\"key\":\"2ku94\",\"text\":\"Rich Description Sample\",\"type\":\"unstyled\",\ \"depth\":0,\"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"', diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index c5750e596..f2d71e0c1 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -126,7 +126,7 @@ def factory_email_verification(survey_id, verification_type=None, submission_id= return email_verification -def factory_engagement_model(eng_info: dict = TestEngagementInfo.engagement1, name=None, status=None): +def factory_engagement_model(eng_info: dict = TestEngagementInfo.engagement1, name=None, status=None, visibility=None): """Produce a engagement model.""" engagement = EngagementModel( name=name if name else fake.name(), @@ -139,7 +139,7 @@ def factory_engagement_model(eng_info: dict = TestEngagementInfo.engagement1, na status_id=status if status else eng_info.get('status'), start_date=eng_info.get('start_date'), end_date=eng_info.get('end_date'), - is_internal=eng_info.get('is_internal') + visibility=visibility if visibility else eng_info.get('visibility') ) engagement.save() return engagement diff --git a/met-cron/src/met_cron/services/closing_soon_mail_service.py b/met-cron/src/met_cron/services/closing_soon_mail_service.py index db6cbc409..d7c693368 100644 --- a/met-cron/src/met_cron/services/closing_soon_mail_service.py +++ b/met-cron/src/met_cron/services/closing_soon_mail_service.py @@ -4,6 +4,7 @@ from sqlalchemy import and_, func from typing import List from met_api.constants.engagement_status import Status +from met_api.constants.engagement_visibility import Visibility from met_api.models.engagement import Engagement as EngagementModel from met_api.utils.datetime import local_datetime from met_api.utils.template import Template @@ -12,36 +13,31 @@ class ClosingSoonEmailService: # pylint: disable=too-few-public-methods - """Mail for newly published engagements""" + """Mail for closing soon published engagements""" @staticmethod def do_mail(): - """Send mail by listening to the email_queue. - - 1. Get N number of unprocessed recoreds from the email_queue table - 2. Process each mail and send it to subscribed users - - """ + """Send Closing Soon mail when public engagements are closing.""" offset_days: int = int(current_app.config.get('OFFSET_DAYS')) - engagements_closing_soon = ClosingSoonEmailService.get_engagements_closing_soon(offset_days) + engagements_closing_soon = ClosingSoonEmailService.get_public_engagements_closing_soon(offset_days) template_id = current_app.config.get('ENGAGEMENT_CLOSING_SOON_EMAIL_TEMPLATE_ID', None) subject = current_app.config.get('ENGAGEMENT_CLOSING_SOON_EMAIL_SUBJECT') template = Template.get_template('engagement_closing_soon.html') - for engagement in engagements_closing_soon: - # Process each mails.First set status as PROCESSING - - EmailService._send_email_notification_for_subscription(engagement.id, template_id, - subject, template) + if engagements_closing_soon is not None: + for engagement in engagements_closing_soon: + EmailService._send_email_notification_for_subscription(engagement.id, template_id, + subject, template) @staticmethod - def get_engagements_closing_soon(offset_days: int) -> List[EngagementModel]: - """Get engagements that are closing within two days.""" + def get_public_engagements_closing_soon(offset_days: int) -> List[EngagementModel]: + """Get public engagements that are closing within two days. We only want to notify users of public engagements.""" now = local_datetime() days_from_now = now + timedelta(days=offset_days) engagements = db.session.query(EngagementModel) \ .filter( and_( EngagementModel.status_id == Status.Published.value, + EngagementModel.visibility == Visiblity.Public.value, func.date(EngagementModel.end_date) == func.date(days_from_now) )) \ .all() diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx index 0a2ea9ce4..41628689c 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx @@ -21,7 +21,7 @@ interface EngagementFormData { end_date: string; description: string; content: string; - is_internal: boolean; + visibility: number; project_id: string; project_metadata: ProjectMetadata; } @@ -36,7 +36,7 @@ const initialEngagementFormData = { end_date: '', description: '', content: '', - is_internal: false, + visibility: 1, project_id: '', project_metadata: { project_name: '', @@ -147,7 +147,7 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re end_date: savedEngagement.end_date, description: savedEngagement.description || '', content: savedEngagement.content || '', - is_internal: savedEngagement.is_internal || false, + visibility: savedEngagement.visibility || 1, project_id: engagementMetadata.project_id, project_metadata: { project_name: engagementMetadata?.project_metadata?.project_name || '', diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementAccessAndVisibility.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementAccessAndVisibility.tsx new file mode 100644 index 000000000..fe1e7051f --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementAccessAndVisibility.tsx @@ -0,0 +1,91 @@ +import React, { useContext } from 'react'; +import { Grid, FormControlLabel, RadioGroup, Radio, Box } from '@mui/material'; +import { MetHeader4, MetDescription, MetLabel } from '../../../../common'; +import { INTERNAL_EMAIL_DOMAIN } from 'constants/emailVerification'; +import { EngagementSettingsContext } from './EngagementSettingsContext'; +import { EngagementVisibility } from 'constants/engagementVisibility'; + +const EngagementAccessAndVisibility = () => { + const { visibility, setVisibility } = useContext(EngagementSettingsContext); + const { hasBeenOpened } = useContext(EngagementSettingsContext); + + const handleChangeVisibility = (e: React.ChangeEvent) => { + console.log('event: ', e); + setVisibility(parseInt(e.target.value)); + }; + + return ( + + + Engagement Access & Visibility + + + + } + disabled={hasBeenOpened} + label={ + + Public Engagement + + This is the default mode. This engagement will be displayed on the homepage, be + searchable and accessible to everyone + + + } + sx={{ + alignItems: 'start', + }} + /> + + } + disabled={hasBeenOpened} + label={ + + Hidden Engagement + + This engagement will not show up on the homepage or search within EPIC.engage. The + engagement will only be accessible to external users with a link to the engagement. + NOTE: The engagement may still be searchable within Google and other search engines, + and should not contain confidential information. + + + } + sx={{ + alignItems: 'start', + }} + /> + } + disabled={hasBeenOpened} + label={ + + Internal Engagement + + This engagement is only available to people requesting access from a{' '} + {INTERNAL_EMAIL_DOMAIN} email address. This engagement will not show up on the + homepage or search within EPIC.engage. + + + } + sx={{ + alignItems: 'start', + }} + /> + + + + ); +}; + +export default EngagementAccessAndVisibility; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsContext.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsContext.tsx index 9412c5823..a5d8bbd94 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsContext.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsContext.tsx @@ -6,8 +6,8 @@ import { openNotification } from 'services/notificationService/notificationSlice import { SubmissionStatus } from 'constants/engagementStatus'; export interface EngagementSettingsContextState { - isInternal: boolean; - setIsInternal: (isInternal: boolean) => void; + visibility: number; + setVisibility: (visibility: number) => void; sendReport: boolean; setSendReport: (sendReport: boolean) => void; handleSaveSettings: () => void; @@ -16,8 +16,8 @@ export interface EngagementSettingsContextState { } export const EngagementSettingsContext = createContext({ - isInternal: false, - setIsInternal: () => { + visibility: 1, + setVisibility: () => { return; }, sendReport: false, @@ -37,8 +37,8 @@ export const EngagementSettingsContextProvider = ({ children }: { children: Reac const { engagementFormData, updateEngagementSettings, settings } = useContext(EngagementTabsContext); const dispatch = useAppDispatch(); - const { is_internal: savedIsInternal } = engagementFormData; - const [isInternal, setIsInternal] = useState(savedIsInternal); + const { visibility: savedVisibility } = engagementFormData; + const [visibility, setVisibility] = useState(savedVisibility); const [sendReport, setSendReport] = useState(Boolean(settings.send_report)); const [updatingSettings, setUpdatingSettings] = useState(false); @@ -52,7 +52,7 @@ export const EngagementSettingsContextProvider = ({ children }: { children: Reac const handleUpdateEngagementSettings = () => { return handleUpdateEngagementRequest({ ...engagementFormData, - is_internal: isInternal, + visibility: visibility, }); }; @@ -87,13 +87,13 @@ export const EngagementSettingsContextProvider = ({ children }: { children: Reac return ( {children} diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsForm.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsForm.tsx index 19b15506d..55f209f77 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsForm.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsForm.tsx @@ -2,7 +2,7 @@ import React, { useContext } from 'react'; import { Divider, Grid } from '@mui/material'; import { MetPaper, PrimaryButton } from 'components/common'; import EngagementInformation from './EngagementInformation'; -import InternalEngagement from './InternalEngagement'; +import EngagementAccessAndVisibility from './EngagementAccessAndVisibility'; import SendReport from './SendReport'; import { EngagementSettingsContext } from './EngagementSettingsContext'; @@ -26,7 +26,7 @@ const EngagementSettingsForm = () => { - + diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/InternalEngagement.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/InternalEngagement.tsx deleted file mode 100644 index 4a353df32..000000000 --- a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/InternalEngagement.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useContext } from 'react'; -import { Grid, FormControlLabel, Switch } from '@mui/material'; -import { MetLabel, MetHeader4, MetDescription } from '../../../../common'; -import { INTERNAL_EMAIL_DOMAIN } from 'constants/emailVerification'; -import { EngagementSettingsContext } from './EngagementSettingsContext'; - -const InternalEngagement = () => { - const { isInternal, setIsInternal } = useContext(EngagementSettingsContext); - const { hasBeenOpened } = useContext(EngagementSettingsContext); - - const handleChangeIsInternal = (e: React.ChangeEvent) => { - setIsInternal(e.target.checked); - }; - - return ( - - - Internal Engagement - - - - This will make the engagement only available to people requesting access from a{' '} - {INTERNAL_EMAIL_DOMAIN} email address and will not show on the engagement home page. - - - - } - label={Set-up as Internal Engagement} - disabled={hasBeenOpened} - /> - - - ); -}; - -export default InternalEngagement; diff --git a/met-web/src/components/engagement/form/types.ts b/met-web/src/components/engagement/form/types.ts index 28ac86bce..3042c28cb 100644 --- a/met-web/src/components/engagement/form/types.ts +++ b/met-web/src/components/engagement/form/types.ts @@ -45,7 +45,7 @@ export interface EngagementFormUpdate { end_date?: string; content?: string; rich_content?: string; - is_internal?: boolean; + visibility?: number; status_block?: EngagementStatusBlock[]; project_id: string; project_metadata: ProjectMetadata; diff --git a/met-web/src/components/engagement/view/EmailModal.tsx b/met-web/src/components/engagement/view/EmailModal.tsx index 5f12d07c9..fa20027fe 100644 --- a/met-web/src/components/engagement/view/EmailModal.tsx +++ b/met-web/src/components/engagement/view/EmailModal.tsx @@ -14,6 +14,7 @@ import { ActionContext } from './ActionContext'; import ThankYouPanel from './ThankYouPanel'; import { EmailVerificationType } from 'models/emailVerification'; import { INTERNAL_EMAIL_DOMAIN } from 'constants/emailVerification'; +import { EngagementVisibility } from 'constants/engagementVisibility'; const EmailModal = ({ defaultPanel, open, handleClose }: EmailModalProps) => { const dispatch = useAppDispatch(); @@ -30,7 +31,10 @@ const EmailModal = ({ defaultPanel, open, handleClose }: EmailModalProps) => { const updateTabValue = () => { if (!checkEmail(email)) { setFormIndex('error'); - } else if (savedEngagement.is_internal && !email.endsWith(INTERNAL_EMAIL_DOMAIN)) { + } else if ( + savedEngagement.visibility == EngagementVisibility.Internal && + !email.endsWith(INTERNAL_EMAIL_DOMAIN) + ) { setFormIndex('error'); } else { handleSubmit(); @@ -89,7 +93,7 @@ const EmailModal = ({ defaultPanel, open, handleClose }: EmailModalProps) => { handleClose={() => close()} updateEmail={setEmail} isSaving={isSaving} - isInternal={savedEngagement.is_internal} + visibility={savedEngagement.visibility} /> @@ -103,7 +107,7 @@ const EmailModal = ({ defaultPanel, open, handleClose }: EmailModalProps) => { tryAgain={() => setFormIndex('email')} handleClose={() => close()} email={email} - isInternal={savedEngagement.is_internal} + visibility={savedEngagement.visibility} /> diff --git a/met-web/src/components/engagement/view/EmailPanel.tsx b/met-web/src/components/engagement/view/EmailPanel.tsx index 042ecce34..9446b4bec 100644 --- a/met-web/src/components/engagement/view/EmailPanel.tsx +++ b/met-web/src/components/engagement/view/EmailPanel.tsx @@ -13,8 +13,9 @@ import { } from 'components/common'; import { When } from 'react-if'; import { INTERNAL_EMAIL_DOMAIN } from 'constants/emailVerification'; +import { EngagementVisibility } from 'constants/engagementVisibility'; -const EmailPanel = ({ email, checkEmail, handleClose, updateEmail, isSaving, isInternal }: EmailPanelProps) => { +const EmailPanel = ({ email, checkEmail, handleClose, updateEmail, isSaving, visibility }: EmailPanelProps) => { const [checked, setChecked] = useState(false); const [emailFormError, setEmailFormError] = useState({ terms: false, @@ -142,7 +143,7 @@ const EmailPanel = ({ email, checkEmail, handleClose, updateEmail, isSaving, isI /> - + This is an Internal Engagement! You can only use a {INTERNAL_EMAIL_DOMAIN}{' '} diff --git a/met-web/src/components/engagement/view/FailurePanel.tsx b/met-web/src/components/engagement/view/FailurePanel.tsx index f09fabb20..272dd29f5 100644 --- a/met-web/src/components/engagement/view/FailurePanel.tsx +++ b/met-web/src/components/engagement/view/FailurePanel.tsx @@ -3,8 +3,9 @@ import { Grid, Stack } from '@mui/material'; import { FailurePanelProps } from './types'; import { modalStyle, PrimaryButton, SecondaryButton, MetHeader1, MetBody } from 'components/common'; import { When } from 'react-if'; +import { EngagementVisibility } from 'constants/engagementVisibility'; -const FailurePanel = ({ email, handleClose, tryAgain, isInternal }: FailurePanelProps) => { +const FailurePanel = ({ email, handleClose, tryAgain, visibility }: FailurePanelProps) => { return ( {email} - + This is an internal engagement. Make sure you are using a government email. diff --git a/met-web/src/components/engagement/view/types.ts b/met-web/src/components/engagement/view/types.ts index 0c1fb55c2..32ce35057 100644 --- a/met-web/src/components/engagement/view/types.ts +++ b/met-web/src/components/engagement/view/types.ts @@ -25,7 +25,7 @@ export interface EmailPanelProps { handleClose: () => void; updateEmail: (string: string) => void; isSaving: boolean; - isInternal: boolean; + visibility: number; } export interface SuccessPanelProps { @@ -41,7 +41,7 @@ export interface FailurePanelProps { tryAgain: () => void; handleClose: () => void; email: string; - isInternal: boolean; + visibility: number; } export interface SurveyBlockProps { diff --git a/met-web/src/constants/engagementVisibility.ts b/met-web/src/constants/engagementVisibility.ts new file mode 100644 index 000000000..91bf98d55 --- /dev/null +++ b/met-web/src/constants/engagementVisibility.ts @@ -0,0 +1,5 @@ +export enum EngagementVisibility { + Public = 1, + Hidden = 2, + Internal = 3, +} diff --git a/met-web/src/models/engagement.ts b/met-web/src/models/engagement.ts index 39657b688..0b862c0de 100644 --- a/met-web/src/models/engagement.ts +++ b/met-web/src/models/engagement.ts @@ -24,7 +24,8 @@ export interface Engagement { submission_status: SubmissionStatus; submissions_meta_data: SurveySubmissionData; status_block: EngagementStatusBlock[]; - is_internal: boolean; + visibility: number; + engagement_visibility: Visibility; } export interface Status { @@ -32,6 +33,11 @@ export interface Status { status_name: string; } +export interface Visibility { + id: number; + visibility_name: string; +} + export interface EngagementMetadata { engagement_id: number; project_id: string; @@ -79,7 +85,8 @@ export const createDefaultEngagement = (): Engagement => { approved: 0, }, status_block: [], - is_internal: false, + visibility: 0, + engagement_visibility: { id: 0, visibility_name: '' }, }; }; diff --git a/met-web/tests/unit/components/engagement/EngagementFormSettingsTab.test.tsx b/met-web/tests/unit/components/engagement/EngagementFormSettingsTab.test.tsx new file mode 100644 index 000000000..669b3cc21 --- /dev/null +++ b/met-web/tests/unit/components/engagement/EngagementFormSettingsTab.test.tsx @@ -0,0 +1,147 @@ +import React, { ReactNode } from 'react'; +import { render, waitFor, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import EngagementForm from '../../../../src/components/engagement/form'; +import { setupEnv } from '../setEnvVars'; +import * as reactRedux from 'react-redux'; +import * as reactRouter from 'react-router'; +import * as engagementService from 'services/engagementService'; +import * as engagementMetadataService from 'services/engagementMetadataService'; +import * as engagementSettingService from 'services/engagementSettingService'; +import * as teamMemberService from 'services/membershipService'; +import * as widgetService from 'services/widgetService'; +import { draftEngagement, engagementMetadata, engagementSetting, openEngagement} from '../factory'; +import { createDefaultUser, USER_GROUP } from 'models/user'; +import { EngagementTeamMember, initialDefaultTeamMember } from 'models/engagementTeamMember'; +import { USER_ROLES } from 'services/userService/constants'; +import { EngagementVisibility } from 'constants/engagementVisibility'; + +const mockTeamMember1: EngagementTeamMember = { + ...initialDefaultTeamMember, + user_id: 1, + user: { + ...createDefaultUser, + id: 1, + first_name: 'Jane', + last_name: 'Doe', + groups: [USER_GROUP.VIEWER.label], + }, +}; + +jest.mock('axios'); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => { + return { + roles: [USER_ROLES.VIEW_PRIVATE_ENGAGEMENTS, USER_ROLES.EDIT_ENGAGEMENT, USER_ROLES.CREATE_ENGAGEMENT], + assignedEngagements: [draftEngagement.id], + }; + }), +})); + +jest.mock('@reduxjs/toolkit/query/react', () => ({ + ...jest.requireActual('@reduxjs/toolkit/query/react'), + fetchBaseQuery: jest.fn(), +})); + +jest.mock('@mui/material', () => ({ + ...jest.requireActual('@mui/material'), + Link: ({ children }: { children: ReactNode }) => { + return {children}; + }, +})); + +jest.mock('components/map', () => () => { + return
; +}); + +jest.mock('apiManager/apiSlices/widgets', () => ({ + ...jest.requireActual('apiManager/apiSlices/widgets'), + useDeleteWidgetMutation: () => [jest.fn(() => Promise.resolve())], + useSortWidgetsMutation: () => [jest.fn(() => Promise.resolve())], +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(() => ({ search: '' })), + useParams: jest.fn(() => { + return { projectId: '' }; + }), + useNavigate: () => jest.fn(), +})); + +describe('Engagement form Settings tab tests', () => { + jest.spyOn(reactRedux, 'useDispatch').mockImplementation(() => jest.fn()); + jest.spyOn(teamMemberService, 'getTeamMembers').mockReturnValue(Promise.resolve([mockTeamMember1])); + const useParamsMock = jest.spyOn(reactRouter, 'useParams'); + const getEngagements = jest.spyOn(engagementService, 'getEngagement') + jest.spyOn(widgetService, 'getWidgets').mockReturnValue(Promise.resolve([])); + jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); + jest.spyOn(engagementSettingService, 'getEngagementSettings').mockReturnValue(Promise.resolve(engagementSetting)); + + beforeEach(() => { + setupEnv(); + }); + + test('Settings tab renders', async () => { + useParamsMock.mockReturnValue({ engagementId: '1' }); + getEngagements.mockReturnValue(Promise.resolve(draftEngagement)); + render(); + + await waitFor(() => { + expect(screen.getByDisplayValue(draftEngagement.name)).toBeInTheDocument(); + }); + + const settingsTabButton = screen.getByRole("tab", { "name": "Settings" }); + + fireEvent.click(settingsTabButton); + + expect(screen.getByRole("heading", { level: 4, name: "Engagement Information" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 4, name: "Engagement Access & Visibility" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 4, name: "Send Report" })).toBeInTheDocument(); + + const radioButtonPublic = screen.getByRole("radio", { name: /Public Engagement/i }) + expect(radioButtonPublic).toBeInTheDocument(); + expect(radioButtonPublic).toBeChecked(); + + const radioButtonHidden = screen.getByRole("radio", { name: /Hidden Engagement/i }) + expect(radioButtonHidden).toBeInTheDocument(); + + const radioButtonInternal = screen.getByRole("radio", { name: /Internal Engagement/i }) + expect(radioButtonInternal).toBeInTheDocument(); + }); + + test('Settings tab renders with disabled Engagement Visibility buttons when engagement is already Open', async () => { + useParamsMock.mockReturnValue({ engagementId: '2' }); + getEngagements.mockReturnValue(Promise.resolve({ + ...openEngagement, + visibility: EngagementVisibility.Hidden + })); + render(); + + await waitFor(() => { + expect(screen.getByDisplayValue(openEngagement.name)).toBeInTheDocument(); + }); + + const settingsTabButton = screen.getByRole("tab", { "name": "Settings" }); + + fireEvent.click(settingsTabButton); + + expect(screen.getByRole("heading", { level: 4, name: "Engagement Access & Visibility" })).toBeInTheDocument(); + + const radioButtonPublic = screen.getByRole("radio", { name: /Public Engagement/i }) + expect(radioButtonPublic).toBeInTheDocument(); + expect(radioButtonPublic).toBeDisabled(); + + const radioButtonHidden = screen.getByRole("radio", { name: /Hidden Engagement/i }) + expect(radioButtonHidden).toBeInTheDocument(); + expect(radioButtonHidden).toBeChecked(); + expect(radioButtonHidden).toBeDisabled(); + + const radioButtonInternal = screen.getByRole("radio", { name: /Internal Engagement/i }) + expect(radioButtonInternal).toBeInTheDocument(); + expect(radioButtonInternal).toBeDisabled(); + }); + +});