From f29e46bf80bb704938c2c000c70ac97d59dbfdeb Mon Sep 17 00:00:00 2001 From: nk-hystax <128669932+nk-hystax@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:53:21 +0300 Subject: [PATCH] Feature/notification settings Co-authored-by: ek-hystax <33006768+ek-hystax@users.noreply.github.com> --- docker_images/herald_executor/worker.py | 127 +++++--- docker_images/webhook_executor/worker.py | 2 +- ...d604_checking_email_settings_task_state.py | 76 +++++ katara/katara_service/migrate.py | 2 +- katara/katara_service/models/models.py | 1 + katara/katara_worker/consts.py | 1 + .../katara_worker/reports_generators/base.py | 15 + .../organization_expenses.py | 4 +- .../reports_generators/pool_limit_exceed.py | 2 +- .../pool_limit_exceed_resources.py | 6 +- .../violated_constraints.py | 2 +- .../violated_constraints_diff.py | 2 +- katara/katara_worker/tasks.py | 44 ++- katara/katara_worker/transitions.py | 2 + ngui/server/api/restapi/client.ts | 46 +++ .../graphql/resolvers/restapi.generated.ts | 64 ++++ ngui/server/graphql/resolvers/restapi.ts | 13 + ngui/server/graphql/schemas/restapi.graphql | 32 ++ .../components/Accordion/Accordion.styles.ts | 54 +++- .../ui/src/components/Accordion/Accordion.tsx | 15 +- .../UserEmailNotificationSettings.tsx | 298 ++++++++++++++++++ .../UserEmailNotificationSettings/index.ts | 3 + .../UserEmailNotificationSettings/types.ts | 56 ++++ ...UserEmailNotificationSettingsContainer.tsx | 21 ++ .../index.ts | 3 + .../api/restapi/queries/restapi.queries.ts | 38 ++- ngui/ui/src/pages/Settings/Settings.tsx | 11 +- ngui/ui/src/theme.ts | 10 +- ngui/ui/src/translations/en-US/app.json | 39 +++ optscale_client/rest_api_client/client_v2.py | 15 + .../versions/a1d0494e9815_employee_emails.py | 105 ++++++ rest_api/rest_api_server/constants.py | 4 + .../rest_api_server/controllers/context.py | 12 +- .../rest_api_server/controllers/employee.py | 31 +- .../controllers/employee_email.py | 142 +++++++++ .../rest_api_server/controllers/invite.py | 35 +- .../rest_api_server/handlers/v2/__init__.py | 1 + .../handlers/v2/employee_emails.py | 179 +++++++++++ rest_api/rest_api_server/models/models.py | 27 ++ rest_api/rest_api_server/server.py | 6 + .../tests/unittests/test_employee_email.py | 173 ++++++++++ .../tests/unittests/test_pools.py | 23 +- 42 files changed, 1633 insertions(+), 109 deletions(-) create mode 100644 katara/katara_service/alembic/versions/f15bef09d604_checking_email_settings_task_state.py create mode 100644 ngui/ui/src/components/UserEmailNotificationSettings/UserEmailNotificationSettings.tsx create mode 100644 ngui/ui/src/components/UserEmailNotificationSettings/index.ts create mode 100644 ngui/ui/src/components/UserEmailNotificationSettings/types.ts create mode 100644 ngui/ui/src/containers/UserEmailNotificationSettingsContainer/UserEmailNotificationSettingsContainer.tsx create mode 100644 ngui/ui/src/containers/UserEmailNotificationSettingsContainer/index.ts create mode 100644 rest_api/rest_api_server/alembic/versions/a1d0494e9815_employee_emails.py create mode 100644 rest_api/rest_api_server/controllers/employee_email.py create mode 100644 rest_api/rest_api_server/handlers/v2/employee_emails.py create mode 100644 rest_api/rest_api_server/tests/unittests/test_employee_email.py diff --git a/docker_images/herald_executor/worker.py b/docker_images/herald_executor/worker.py index 4dbc49bfd..b94451b3d 100644 --- a/docker_images/herald_executor/worker.py +++ b/docker_images/herald_executor/worker.py @@ -13,12 +13,11 @@ from kombu.utils.debug import setup_logging from kombu import Exchange, Queue, binding import urllib3 - +from currency_symbols.currency_symbols import CURRENCY_SYMBOLS_MAP from optscale_client.config_client.client import Client as ConfigClient from optscale_client.rest_api_client.client_v2 import Client as RestClient from optscale_client.herald_client.client_v2 import Client as HeraldClient from optscale_client.auth_client.client_v2 import Client as AuthClient -from currency_symbols.currency_symbols import CURRENCY_SYMBOLS_MAP from tools.optscale_time import utcnow_timestamp, utcfromtimestamp LOG = get_logger(__name__) @@ -76,6 +75,16 @@ class HeraldTemplates(Enum): FIRST_RUN_STARTED = 'first_run_started' +CONSTRAINT_TYPE_TEMPLATE_MAP = { + 'expense_anomaly': HeraldTemplates.ANOMALY_DETECTION.value, + 'resource_count_anomaly': HeraldTemplates.ANOMALY_DETECTION.value, + 'expiring_budget': HeraldTemplates.EXPIRING_BUDGET.value, + 'recurring_budget': HeraldTemplates.RECURRING_BUDGET.value, + 'resource_quota': HeraldTemplates.RESOURCE_QUOTA.value, + 'tagging_policy': HeraldTemplates.TAGGING_POLICY.value +} + + class HeraldExecutorWorker(ConsumerMixin): def __init__(self, connection, config_cl): self.connection = connection @@ -118,32 +127,39 @@ def get_consumers(self, consumer, channel): return [consumer(queues=[TASK_QUEUE], accept=['json'], callbacks=[self.process_task], prefetch_count=10)] - def get_auth_users(self, user_ids): - _, response = self.auth_cl.user_list(user_ids) - return response - - def get_owner_manager_infos(self, organization_id, - tenant_auth_user_ids=None): - auth_users = [] - if tenant_auth_user_ids: - auth_users = self.get_auth_users(tenant_auth_user_ids) - all_user_info = {auth_user['id']: { - 'display_name': auth_user.get('display_name'), - 'email': auth_user.get('email') - } for auth_user in auth_users} - - _, org_managers = self.auth_cl.user_roles_get( - scope_ids=[organization_id], - role_purposes=[MANAGER_ROLE]) - for manager in org_managers: - user_id = manager['user_id'] - if not tenant_auth_user_ids or user_id not in tenant_auth_user_ids: + def get_owner_manager_infos( + self, organization_id, tenant_auth_user_ids=None, + email_template=None): + _, employees = self.rest_cl.employee_list(organization_id) + _, user_roles = self.auth_cl.user_roles_get( + scope_ids=[organization_id], + user_ids=[x['auth_user_id'] for x in employees['employees']] + ) + all_user_info = {} + for user_role in user_roles: + user_id = user_role['user_id'] + if (user_role['role_purpose'] == MANAGER_ROLE or + tenant_auth_user_ids and user_id in tenant_auth_user_ids): all_user_info[user_id] = { - 'display_name': manager.get('user_display_name'), - 'email': manager.get('user_email') + 'display_name': user_role.get('user_display_name'), + 'email': user_role.get('user_email') } + if email_template: + for employee in employees['employees']: + auth_user_id = employee['auth_user_id'] + if (auth_user_id in all_user_info and + not self.is_email_enabled(employee['id'], + email_template)): + all_user_info.pop(auth_user_id, None) return all_user_info + def is_email_enabled(self, employee_id, email_template): + _, employee_emails = self.rest_cl.employee_emails_get( + employee_id, email_template) + employee_email = employee_emails.get('employee_emails') + if employee_email: + return employee_email[0]['enabled'] + def _get_service_emails(self): return self.config_cl.optscale_email_recipient() @@ -217,7 +233,7 @@ def format_remained_time(start_date, end_date): shareable_booking_data = self._filter_bookings( shareable_bookings.get('data', []), resource_id, now_ts) for booking in shareable_booking_data: - acquired_by_id = booking.get('acquired_by_id') + acquired_by_id = booking.get('acquired_by', {}).get('id') if acquired_by_id: resource_tenant_ids.append(acquired_by_id) _, employees = self.rest_cl.employee_list(org_id=organization_id) @@ -227,11 +243,10 @@ def format_remained_time(start_date, end_date): tenant_auth_user_ids = [ emp['auth_user_id'] for emp in list(employee_id_map.values()) ] - for booking in shareable_booking_data: acquired_since = booking['acquired_since'] released_at = booking['released_at'] - acquired_by_id = booking.get('acquired_by_id') + acquired_by_id = booking.get('acquired_by', {}).get('id') utc_acquired_since = int( utcfromtimestamp(acquired_since).timestamp()) utc_released_at = int( @@ -272,7 +287,8 @@ def format_remained_time(start_date, end_date): }}) all_user_info = self.get_owner_manager_infos( - cloud_account['organization_id'], tenant_auth_user_ids) + cloud_account['organization_id'], tenant_auth_user_ids, + HeraldTemplates.ENVIRONMENT_CHANGES.value) env_properties_list = [ {'env_key': env_prop_key, 'env_value': env_prop_value} for env_prop_key, env_prop_value in env_properties.items() @@ -409,9 +425,11 @@ def execute_expense_alert(self, pool_id, organization_id, meta): employee_id = contact.get('employee_id') if employee_id: _, employee = self.rest_cl.employee_get(employee_id) - _, user = self.auth_cl.user_get(employee['auth_user_id']) - self.send_expenses_alert( - user['email'], alert, pool['name'], organization) + if self.is_email_enabled(employee['id'], + HeraldTemplates.POOL_ALERT.value): + _, user = self.auth_cl.user_get(employee['auth_user_id']) + self.send_expenses_alert( + user['email'], alert, pool['name'], organization) def execute_constraint_violated(self, object_id, organization_id, meta, object_type): @@ -423,6 +441,14 @@ def execute_constraint_violated(self, object_id, organization_id, meta, if user.get('slack_connected'): return + _, employees = self.rest_cl.employee_list(organization_id) + employee = next((x for x in employees['employees'] + if x['auth_user_id'] == object_id), None) + if employee and not self.is_email_enabled( + employee['id'], + HeraldTemplates.RESOURCE_OWNER_VIOLATION_ALERT.value): + return + hit_list = meta.get('violations') resource_type_map = { 'ttl': 'TTL', @@ -536,14 +562,6 @@ def _get_org_constraint_link(self, constraint, created_at, filters): def _get_org_constraint_template_params(self, organization, constraint, constraint_data, hit_date, latest_hit, link, user_info): - constraint_template_map = { - 'expense_anomaly': HeraldTemplates.ANOMALY_DETECTION.value, - 'resource_count_anomaly': HeraldTemplates.ANOMALY_DETECTION.value, - 'expiring_budget': HeraldTemplates.EXPIRING_BUDGET.value, - 'recurring_budget': HeraldTemplates.RECURRING_BUDGET.value, - 'resource_quota': HeraldTemplates.RESOURCE_QUOTA.value, - 'tagging_policy': HeraldTemplates.TAGGING_POLICY.value - } if 'anomaly' in constraint['type']: title = 'Anomaly detection alert' else: @@ -578,7 +596,7 @@ def _get_org_constraint_template_params(self, organization, constraint, if without_tag: conditions.append(f'without tag "{without_tag}"') params['texts']['conditions'] = ', '.join(conditions) - return params, title, constraint_template_map[constraint['type']] + return params, title def execute_organization_constraint_violated(self, constraint_id, organization_id): @@ -587,7 +605,8 @@ def execute_organization_constraint_violated(self, constraint_id, LOG.warning('Organization %s was not found, error code: %s' % ( organization_id, code)) return - code, constraint = self.rest_cl.organization_constraint_get(constraint_id) + code, constraint = self.rest_cl.organization_constraint_get( + constraint_id) if not constraint: LOG.warning( 'Organization constraint %s was not found, error code: %s' % ( @@ -616,9 +635,11 @@ def execute_organization_constraint_violated(self, constraint_id, constraint_data['definition']['start_date'] = utcfromtimestamp( int(constraint_data['definition']['start_date'])).strftime( '%m/%d/%Y %I:%M %p UTC') - managers = self.get_owner_manager_infos(organization_id) + template = CONSTRAINT_TYPE_TEMPLATE_MAP[constraint['type']] + managers = self.get_owner_manager_infos( + organization_id, email_template=template) for user_id, user_info in managers.items(): - params, subject, template = self._get_org_constraint_template_params( + params, subject = self._get_org_constraint_template_params( organization, constraint, constraint_data, hit_date, latest_hit, link, user_info) self.herald_cl.email_send( @@ -634,7 +655,9 @@ def execute_new_security_recommendation(self, organization_id, return for i, data_dict in enumerate(module_count_list): module_count_list[i] = data_dict - managers = self.get_owner_manager_infos(organization_id) + managers = self.get_owner_manager_infos( + organization_id, + email_template=HeraldTemplates.NEW_SECURITY_RECOMMENDATION.value) for user_id, user_info in managers.items(): template_params = { 'texts': { @@ -662,7 +685,8 @@ def execute_saving_spike(self, organization_id, meta): opt['saving'] = round(opt['saving'], 2) top3[i] = opt - managers = self.get_owner_manager_infos(organization_id) + managers = self.get_owner_manager_infos( + organization_id, email_template=HeraldTemplates.SAVING_SPIKE.value) for user_id, user_info in managers.items(): template_params = { 'texts': { @@ -683,7 +707,9 @@ def execute_saving_spike(self, organization_id, meta): def execute_report_imports_passed_for_org(self, organization_id): _, organization = self.rest_cl.organization_get(organization_id) - managers = self.get_owner_manager_infos(organization_id) + managers = self.get_owner_manager_infos( + organization_id, + email_template=HeraldTemplates.REPORT_IMPORT_PASSED.value) emails = [x['email'] for x in managers.values()] subject = 'Expenses initial processing completed' template_params = { @@ -691,10 +717,11 @@ def execute_report_imports_passed_for_org(self, organization_id): 'organization': self._get_organization_params(organization), } } - self.herald_cl.email_send( - emails, subject, - template_type=HeraldTemplates.REPORT_IMPORT_PASSED.value, - template_params=template_params) + if emails: + self.herald_cl.email_send( + emails, subject, + template_type=HeraldTemplates.REPORT_IMPORT_PASSED.value, + template_params=template_params) def execute_insider_prices(self): self._send_service_email('Insider faced Azure SSLError', diff --git a/docker_images/webhook_executor/worker.py b/docker_images/webhook_executor/worker.py index b1634bdff..ebe4ef7e7 100644 --- a/docker_images/webhook_executor/worker.py +++ b/docker_images/webhook_executor/worker.py @@ -101,7 +101,7 @@ def get_environment_meta(self, webhook, meta_info): ssh_key_map = json.loads(ssh_key_map_json) ssh_key = ssh_key_map.get('key') booking['ssh_key'] = ssh_key - owner_id = booking.get('acquired_by_id') + owner_id = booking.get('acquired_by', {}).get('id') owner = {} if owner_id: _, owner = self.rest_cl.employee_get(owner_id) diff --git a/katara/katara_service/alembic/versions/f15bef09d604_checking_email_settings_task_state.py b/katara/katara_service/alembic/versions/f15bef09d604_checking_email_settings_task_state.py new file mode 100644 index 000000000..4c7959eae --- /dev/null +++ b/katara/katara_service/alembic/versions/f15bef09d604_checking_email_settings_task_state.py @@ -0,0 +1,76 @@ +""""checking_email_settings_task_state" + +Revision ID: f15bef09d604 +Revises: 66dbed1e88e6 +Create Date: 2024-08-30 12:02:40.374500 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.orm import Session +from sqlalchemy.sql import table, column +from sqlalchemy import update, String + +# revision identifiers, used by Alembic. +revision = "f15bef09d604" +down_revision = "66dbed1e88e6" +branch_labels = None +depends_on = None + + +old_states = sa.Enum( + "created", + "started", + "getting_scopes", + "got_scopes", + "getting_recipients", + "got_recipients", + "generating_data", + "generated_data", + "putting_to_object_storage", + "put_to_object_storage", + "putting_to_herald", + "completed", + "error", +) +new_states = sa.Enum( + "created", + "started", + "getting_scopes", + "got_scopes", + "getting_recipients", + "got_recipients", + "checking_email_settings", + "generating_data", + "generated_data", + "putting_to_object_storage", + "put_to_object_storage", + "putting_to_herald", + "completed", + "error", +) + + +def upgrade(): + op.alter_column("task", "state", existing_type=new_states, nullable=False) + + +def downgrade(): + task_table = table( + "task", + column("state", String(128)), + ) + bind = op.get_bind() + session = Session(bind=bind) + try: + update_task_stmt = ( + update(task_table) + .values(state="started") + .where(task_table.c.state == "checking_email_settings") + ) + session.execute(update_task_stmt) + session.commit() + finally: + session.close() + + op.alter_column("task", "state", existing_type=old_states, nullable=False) diff --git a/katara/katara_service/migrate.py b/katara/katara_service/migrate.py index c094e134f..77256f1ef 100644 --- a/katara/katara_service/migrate.py +++ b/katara/katara_service/migrate.py @@ -35,7 +35,7 @@ def save(self, host, username, password, db, file_name='alembic.ini'): config.write(fh) -def execute(cmd, path='..'): +def execute(cmd, path='../..'): LOG.debug('Executing command %s', ''.join(cmd)) myenv = os.environ.copy() myenv['PYTHONPATH'] = path diff --git a/katara/katara_service/models/models.py b/katara/katara_service/models/models.py index 206c266df..b42b728c0 100644 --- a/katara/katara_service/models/models.py +++ b/katara/katara_service/models/models.py @@ -29,6 +29,7 @@ class TaskState(enum.Enum): got_scopes = 'got_scopes' getting_recipients = 'getting_recipients' got_recipients = 'got_recipients' + checking_email_settings = 'checking_email_settings' generating_data = 'generating_data' generated_data = 'generated_data' putting_to_herald = 'putting_to_herald' diff --git a/katara/katara_worker/consts.py b/katara/katara_worker/consts.py index 46749202b..7fceabc5e 100644 --- a/katara/katara_worker/consts.py +++ b/katara/katara_worker/consts.py @@ -5,6 +5,7 @@ class TaskState(object): GOT_SCOPES = 'got_scopes' GETTING_RECIPIENTS = 'getting_recipients' GOT_RECIPIENTS = 'got_recipients' + CHECKING_EMAIL_SETTINGS = 'checking_email_settings' GENERATING_DATA = 'generating_data' GENERATED_DATA = 'generated_data' PUTTING_TO_HERALD = 'putting_to_herald' diff --git a/katara/katara_worker/reports_generators/base.py b/katara/katara_worker/reports_generators/base.py index 889109102..329d228ea 100644 --- a/katara/katara_worker/reports_generators/base.py +++ b/katara/katara_worker/reports_generators/base.py @@ -1,8 +1,18 @@ +import os from currency_symbols.currency_symbols import CURRENCY_SYMBOLS_MAP from optscale_client.auth_client.client_v2 import Client as AuthClient from optscale_client.rest_api_client.client_v2 import Client as RestClient +MODULE_NAME_EMAIL_TEMPLATE = { + 'organization_expenses': 'weekly_expense_report', + 'pool_limit_exceed': 'pool_exceed_report', + 'pool_limit_exceed_resources': 'pool_exceed_resources_report', + 'violated_constraints': 'resource_owner_violation_report', + 'violated_constraints_diff': 'pool_owner_violation_report' +} + + class Base(object): def __init__(self, organization_id, report_data, config_client): self.organization_id = organization_id @@ -30,3 +40,8 @@ def auth_cl(self): @staticmethod def get_currency_code(currency): return CURRENCY_SYMBOLS_MAP.get(currency, '') + + @staticmethod + def get_template_type(path): + return MODULE_NAME_EMAIL_TEMPLATE[(os.path.splitext( + os.path.basename(path)))[0]] diff --git a/katara/katara_worker/reports_generators/organization_expenses.py b/katara/katara_worker/reports_generators/organization_expenses.py index 3763d168c..e8d4026b5 100644 --- a/katara/katara_worker/reports_generators/organization_expenses.py +++ b/katara/katara_worker/reports_generators/organization_expenses.py @@ -1,8 +1,8 @@ import uuid from calendar import monthrange -from katara.katara_worker.reports_generators.base import Base from tools.optscale_time import utcnow +from katara.katara_worker.reports_generators.base import Base class OrganizationExpenses(Base): @@ -54,7 +54,7 @@ def generate(self): return { 'email': [self.report_data['user_email']], - 'template_type': 'weekly_expense_report', + 'template_type': self.get_template_type(__file__), 'subject': 'OptScale weekly expense report', 'template_params': { 'texts': { diff --git a/katara/katara_worker/reports_generators/pool_limit_exceed.py b/katara/katara_worker/reports_generators/pool_limit_exceed.py index a8071a1d5..1f6f6550c 100644 --- a/katara/katara_worker/reports_generators/pool_limit_exceed.py +++ b/katara/katara_worker/reports_generators/pool_limit_exceed.py @@ -34,7 +34,7 @@ def generate(self): return return { 'email': [self.report_data['user_email']], - 'template_type': 'pool_exceed_report', + 'template_type': self.get_template_type(__file__), 'subject': 'Action Required: Hystax OptScale Pool Limit ' 'Exceed Alert', 'template_params': { diff --git a/katara/katara_worker/reports_generators/pool_limit_exceed_resources.py b/katara/katara_worker/reports_generators/pool_limit_exceed_resources.py index fe824abb5..8648e0c2d 100644 --- a/katara/katara_worker/reports_generators/pool_limit_exceed_resources.py +++ b/katara/katara_worker/reports_generators/pool_limit_exceed_resources.py @@ -1,4 +1,6 @@ -from katara.katara_worker.reports_generators.base import Base +from katara.katara_worker.reports_generators.base import ( + Base, MODULE_NAME_EMAIL_TEMPLATE +) class PoolExceedResources(Base): @@ -51,7 +53,7 @@ def generate(self): return return { 'email': [self.report_data['user_email']], - 'template_type': 'pool_exceed_resources_report', + 'template_type': self.get_template_type(__file__), 'subject': 'Action Required: Hystax OptScale Pool Limit ' 'Exceed Alert', 'template_params': { diff --git a/katara/katara_worker/reports_generators/violated_constraints.py b/katara/katara_worker/reports_generators/violated_constraints.py index 51f57ddde..100b2697f 100644 --- a/katara/katara_worker/reports_generators/violated_constraints.py +++ b/katara/katara_worker/reports_generators/violated_constraints.py @@ -34,7 +34,7 @@ def generate(self): res_constaint['type'] = type_value_for_replace return { 'email': [self.report_data['user_email']], - 'template_type': 'resource_owner_violation_report', + 'template_type': self.get_template_type(__file__), 'subject': 'Action required: Hystax OptScale Resource Constraints Report', 'template_params': { diff --git a/katara/katara_worker/reports_generators/violated_constraints_diff.py b/katara/katara_worker/reports_generators/violated_constraints_diff.py index 06fafe6ab..86f001eaf 100644 --- a/katara/katara_worker/reports_generators/violated_constraints_diff.py +++ b/katara/katara_worker/reports_generators/violated_constraints_diff.py @@ -6,7 +6,7 @@ class ViolatedConstraintsDiff(ViolatedConstraints): def generate(self): report = super().generate() if report: - report['template_type'] = 'pool_owner_violation_report' + report['template_type'] = self.get_template_type(__file__) return report diff --git a/katara/katara_worker/tasks.py b/katara/katara_worker/tasks.py index 030e15b05..efe5b685a 100644 --- a/katara/katara_worker/tasks.py +++ b/katara/katara_worker/tasks.py @@ -7,6 +7,9 @@ from katara.katara_worker.consts import TaskState +from katara.katara_worker.reports_generators.base import ( + MODULE_NAME_EMAIL_TEMPLATE +) from katara.katara_worker.reports_generators.report import create_report @@ -272,13 +275,52 @@ def execute(self): result['user_role'] = user_role new_tasks.append({ 'schedule_id': task['schedule_id'], - 'state': TaskState.GENERATING_DATA, + 'state': TaskState.CHECKING_EMAIL_SETTINGS, 'result': json.dumps(result), 'parent_id': task['id']}) self.katara_cl.tasks_create(tasks=new_tasks) super().execute() +class CheckingEmployeeEmailSettings(CheckTimeoutThreshold): + def execute(self): + _, task = self.katara_cl.task_get( + self.body['task_id'], expanded=True) + schedule = task.get('schedule') or {} + organization_id = schedule.get('recipient', {}).get('scope_id') + result = self._load_result(task['result']) + auth_user_id = result['user_role']['user_id'] + _, employees = self.rest_cl.employee_list(organization_id) + employee = next((x for x in employees['employees'] + if x['auth_user_id'] == auth_user_id), None) + if not employee: + LOG.info('Employee not found, completing task %s', + self.body['task_id']) + SetCompleted(body=self.body, message=self.message, + config_cl=self.config_cl, + on_continue_cb=self.on_continue_cb, + on_complete_cb=self.on_complete_cb).execute() + return + module_name = schedule.get('report', {}).get('module_name') + email_template = MODULE_NAME_EMAIL_TEMPLATE[module_name] + _, email_templates = self.rest_cl.employee_emails_get( + employee['id'], email_template=email_template) + if (not email_templates.get('employee_emails') or + not email_templates['employee_emails'][0]['enabled']): + LOG.info('Employee email %s for employee %s is disabled, ' + 'completing task %s', module_name, auth_user_id, + self.body['task_id']) + SetCompleted(body=self.body, message=self.message, + config_cl=self.config_cl, + on_continue_cb=self.on_continue_cb, + on_complete_cb=self.on_complete_cb).execute() + return + self.katara_cl.task_update( + self.body['task_id'], result=json.dumps(result), + state=TaskState.GENERATING_DATA) + super().execute() + + class GenerateReportData(CheckTimeoutThreshold): def execute(self): _, task = self.katara_cl.task_get( diff --git a/katara/katara_worker/transitions.py b/katara/katara_worker/transitions.py index e6a0f46d1..c5529465f 100644 --- a/katara/katara_worker/transitions.py +++ b/katara/katara_worker/transitions.py @@ -6,6 +6,7 @@ SetGettingRecipients, GetRecipients, SetGeneratingReportData, + CheckingEmployeeEmailSettings, GenerateReportData, SetPuttingToHerald, PutToHerald @@ -19,6 +20,7 @@ TaskState.GOT_SCOPES: SetGettingRecipients, TaskState.GETTING_RECIPIENTS: GetRecipients, TaskState.GOT_RECIPIENTS: SetGeneratingReportData, + TaskState.CHECKING_EMAIL_SETTINGS: CheckingEmployeeEmailSettings, TaskState.GENERATING_DATA: GenerateReportData, TaskState.GENERATED_DATA: SetPuttingToHerald, TaskState.PUTTING_TO_HERALD: PutToHerald diff --git a/ngui/server/api/restapi/client.ts b/ngui/server/api/restapi/client.ts index 7ba68b386..d6e98ca05 100644 --- a/ngui/server/api/restapi/client.ts +++ b/ngui/server/api/restapi/client.ts @@ -1,7 +1,9 @@ import BaseClient from "../baseClient.js"; import { DataSourceRequestParams, + MutationUpdateEmployeeEmailsArgs, UpdateDataSourceInput, + MutationUpdateEmployeeEmailArgs, } from "../../graphql/resolvers/restapi.generated.js"; class RestClient extends BaseClient { @@ -44,6 +46,50 @@ class RestClient extends BaseClient { return dataSource; } + + async getEmployeeEmails(employeeId: string) { + const path = `employees/${employeeId}/emails`; + + const emails = await this.get(path); + + return emails.employee_emails; + } + + async updateEmployeeEmails( + employeeId: MutationUpdateEmployeeEmailsArgs["employeeId"], + params: MutationUpdateEmployeeEmailsArgs["params"] + ) { + const path = `employees/${employeeId}/emails/bulk`; + + const emails = await this.post(path, { + body: params, + }); + + const emailIds = [...(params?.enable ?? []), ...(params.disable ?? [])]; + + return emails.employee_emails.filter((email) => + emailIds.includes(email.id) + ); + } + + async updateEmployeeEmail( + employeeId: MutationUpdateEmployeeEmailArgs["employeeId"], + params: MutationUpdateEmployeeEmailArgs["params"] + ) { + const { emailId, action } = params; + + const path = `employees/${employeeId}/emails/bulk`; + + const emails = await this.post(path, { + body: { + [action === "enable" ? "enable" : "disable"]: [emailId], + }, + }); + + const email = emails.employee_emails.find((email) => email.id === emailId); + + return email; + } } export default RestClient; diff --git a/ngui/server/graphql/resolvers/restapi.generated.ts b/ngui/server/graphql/resolvers/restapi.generated.ts index 652934c23..501a92915 100644 --- a/ngui/server/graphql/resolvers/restapi.generated.ts +++ b/ngui/server/graphql/resolvers/restapi.generated.ts @@ -235,6 +235,15 @@ export type DatabricksDataSource = DataSourceInterface & { type: DataSourceType; }; +export type EmployeeEmail = { + __typename?: 'EmployeeEmail'; + available_by_role: Scalars['Boolean']['output']; + email_template: Scalars['String']['output']; + employee_id: Scalars['ID']['output']; + enabled: Scalars['Boolean']['output']; + id: Scalars['ID']['output']; +}; + export type EnvironmentDataSource = DataSourceInterface & { __typename?: 'EnvironmentDataSource'; account_id: Scalars['String']['output']; @@ -327,6 +336,8 @@ export type K8sDataSource = DataSourceInterface & { export type Mutation = { __typename?: 'Mutation'; updateDataSource?: Maybe; + updateEmployeeEmail?: Maybe; + updateEmployeeEmails?: Maybe>>; }; @@ -335,6 +346,18 @@ export type MutationUpdateDataSourceArgs = { params: UpdateDataSourceInput; }; + +export type MutationUpdateEmployeeEmailArgs = { + employeeId: Scalars['ID']['input']; + params: UpdateEmployeeEmailInput; +}; + + +export type MutationUpdateEmployeeEmailsArgs = { + employeeId: Scalars['ID']['input']; + params: UpdateEmployeeEmailsInput; +}; + export type NebiusConfig = { __typename?: 'NebiusConfig'; access_key_id?: Maybe; @@ -376,6 +399,7 @@ export type NebiusDataSource = DataSourceInterface & { export type Query = { __typename?: 'Query'; dataSource?: Maybe; + employeeEmails?: Maybe>>; }; @@ -384,6 +408,11 @@ export type QueryDataSourceArgs = { requestParams?: InputMaybe; }; + +export type QueryEmployeeEmailsArgs = { + employeeId: Scalars['ID']['input']; +}; + export type UpdateDataSourceInput = { alibabaConfig?: InputMaybe; awsLinkedConfig?: InputMaybe; @@ -399,6 +428,21 @@ export type UpdateDataSourceInput = { nebiusConfig?: InputMaybe; }; +export type UpdateEmployeeEmailInput = { + action: UpdateEmployeeEmailsAction; + emailId: Scalars['ID']['input']; +}; + +export enum UpdateEmployeeEmailsAction { + Disable = 'disable', + Enable = 'enable' +} + +export type UpdateEmployeeEmailsInput = { + disable?: InputMaybe>; + enable?: InputMaybe>; +}; + export type ResolverTypeWrapper = Promise | T; @@ -496,6 +540,7 @@ export type ResolversTypes = { DatabricksConfig: ResolverTypeWrapper; DatabricksConfigInput: DatabricksConfigInput; DatabricksDataSource: ResolverTypeWrapper; + EmployeeEmail: ResolverTypeWrapper; EnvironmentDataSource: ResolverTypeWrapper; Float: ResolverTypeWrapper; GcpBillingDataConfig: ResolverTypeWrapper; @@ -517,6 +562,9 @@ export type ResolversTypes = { Query: ResolverTypeWrapper<{}>; String: ResolverTypeWrapper; UpdateDataSourceInput: UpdateDataSourceInput; + UpdateEmployeeEmailInput: UpdateEmployeeEmailInput; + UpdateEmployeeEmailsAction: UpdateEmployeeEmailsAction; + UpdateEmployeeEmailsInput: UpdateEmployeeEmailsInput; }; /** Mapping between all available schema types and the resolvers parents */ @@ -542,6 +590,7 @@ export type ResolversParentTypes = { DatabricksConfig: DatabricksConfig; DatabricksConfigInput: DatabricksConfigInput; DatabricksDataSource: DatabricksDataSource; + EmployeeEmail: EmployeeEmail; EnvironmentDataSource: EnvironmentDataSource; Float: Scalars['Float']['output']; GcpBillingDataConfig: GcpBillingDataConfig; @@ -563,6 +612,8 @@ export type ResolversParentTypes = { Query: {}; String: Scalars['String']['output']; UpdateDataSourceInput: UpdateDataSourceInput; + UpdateEmployeeEmailInput: UpdateEmployeeEmailInput; + UpdateEmployeeEmailsInput: UpdateEmployeeEmailsInput; }; export type AlibabaConfigResolvers = { @@ -727,6 +778,15 @@ export type DatabricksDataSourceResolvers; }; +export type EmployeeEmailResolvers = { + available_by_role?: Resolver; + email_template?: Resolver; + employee_id?: Resolver; + enabled?: Resolver; + id?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type EnvironmentDataSourceResolvers = { account_id?: Resolver; details?: Resolver, ParentType, ContextType>; @@ -807,6 +867,8 @@ export type K8sDataSourceResolvers = { updateDataSource?: Resolver, ParentType, ContextType, RequireFields>; + updateEmployeeEmail?: Resolver, ParentType, ContextType, RequireFields>; + updateEmployeeEmails?: Resolver>>, ParentType, ContextType, RequireFields>; }; export type NebiusConfigResolvers = { @@ -838,6 +900,7 @@ export type NebiusDataSourceResolvers = { dataSource?: Resolver, ParentType, ContextType, RequireFields>; + employeeEmails?: Resolver>>, ParentType, ContextType, RequireFields>; }; export type Resolvers = { @@ -854,6 +917,7 @@ export type Resolvers = { DataSourceInterface?: DataSourceInterfaceResolvers; DatabricksConfig?: DatabricksConfigResolvers; DatabricksDataSource?: DatabricksDataSourceResolvers; + EmployeeEmail?: EmployeeEmailResolvers; EnvironmentDataSource?: EnvironmentDataSourceResolvers; GcpBillingDataConfig?: GcpBillingDataConfigResolvers; GcpConfig?: GcpConfigResolvers; diff --git a/ngui/server/graphql/resolvers/restapi.ts b/ngui/server/graphql/resolvers/restapi.ts index 3cdcd89d4..599e3534b 100644 --- a/ngui/server/graphql/resolvers/restapi.ts +++ b/ngui/server/graphql/resolvers/restapi.ts @@ -44,11 +44,24 @@ const resolvers: Resolvers = { dataSource: async (_, { dataSourceId, requestParams }, { dataSources }) => { return dataSources.restapi.getDataSource(dataSourceId, requestParams); }, + employeeEmails: async (_, { employeeId }, { dataSources }) => { + return dataSources.restapi.getEmployeeEmails(employeeId); + }, }, Mutation: { updateDataSource: async (_, { dataSourceId, params }, { dataSources }) => { return dataSources.restapi.updateDataSource(dataSourceId, params); }, + updateEmployeeEmails: async ( + _, + { employeeId, params }, + { dataSources } + ) => { + return dataSources.restapi.updateEmployeeEmails(employeeId, params); + }, + updateEmployeeEmail: async (_, { employeeId, params }, { dataSources }) => { + return dataSources.restapi.updateEmployeeEmail(employeeId, params); + }, }, }; diff --git a/ngui/server/graphql/schemas/restapi.graphql b/ngui/server/graphql/schemas/restapi.graphql index 9b11d9898..2ce9a481d 100644 --- a/ngui/server/graphql/schemas/restapi.graphql +++ b/ngui/server/graphql/schemas/restapi.graphql @@ -351,11 +351,35 @@ input UpdateDataSourceInput { k8sConfig: K8sConfigInput } +type EmployeeEmail { + id: ID! + employee_id: ID! + email_template: String! + enabled: Boolean! + available_by_role: Boolean! +} + +input UpdateEmployeeEmailsInput { + enable: [ID!] + disable: [ID!] +} + +enum UpdateEmployeeEmailsAction { + enable + disable +} + +input UpdateEmployeeEmailInput { + emailId: ID! + action: UpdateEmployeeEmailsAction! +} + type Query { dataSource( dataSourceId: ID! requestParams: DataSourceRequestParams ): DataSourceInterface + employeeEmails(employeeId: ID!): [EmployeeEmail] } type Mutation { @@ -363,4 +387,12 @@ type Mutation { dataSourceId: ID! params: UpdateDataSourceInput! ): DataSourceInterface + updateEmployeeEmails( + employeeId: ID! + params: UpdateEmployeeEmailsInput! + ): [EmployeeEmail] + updateEmployeeEmail( + employeeId: ID! + params: UpdateEmployeeEmailInput! + ): EmployeeEmail } diff --git a/ngui/ui/src/components/Accordion/Accordion.styles.ts b/ngui/ui/src/components/Accordion/Accordion.styles.ts index 477258f79..163c2f322 100644 --- a/ngui/ui/src/components/Accordion/Accordion.styles.ts +++ b/ngui/ui/src/components/Accordion/Accordion.styles.ts @@ -1,24 +1,45 @@ import { makeStyles } from "tss-react/mui"; -const useStyles = makeStyles()((theme) => ({ +const getExpandColorStyles = ({ theme, expandTitleColor, alwaysHighlightTitle = false }) => { + const style = { + background: { + backgroundColor: theme.palette.background.default + } + }[expandTitleColor] ?? { + color: theme.palette.secondary.contrastText, + backgroundColor: theme.palette.action.selected, + "& svg": { + color: theme.palette.secondary.contrastText + }, + "& p": { + color: theme.palette.secondary.contrastText + }, + "& input": { + color: theme.palette.secondary.contrastText + } + }; + + return { + "&.MuiAccordionSummary-root": alwaysHighlightTitle + ? style + : { + "&.Mui-expanded": style + } + }; +}; + +const useStyles = makeStyles()((theme, { expandTitleColor, alwaysHighlightTitle }) => ({ details: { display: "block" }, summary: { - flexDirection: "row-reverse", - "&.MuiAccordionSummary-root": { - "&.Mui-expanded": { - "& svg": { - color: theme.palette.secondary.contrastText - }, - "& p": { - color: theme.palette.secondary.contrastText - }, - "& input": { - color: theme.palette.secondary.contrastText - } - } - } + flexDirection: "row-reverse" + }, + enableBorder: { + borderBottom: `1px solid ${theme.palette.divider}` + }, + disableShadows: { + boxShadow: "none" }, inheritFlexDirection: { flexDirection: "inherit" @@ -33,7 +54,8 @@ const useStyles = makeStyles()((theme) => ({ }, zeroSummaryMinHeight: { minHeight: "0" - } + }, + expandTitleColor: getExpandColorStyles({ theme, expandTitleColor, alwaysHighlightTitle }) })); export default useStyles; diff --git a/ngui/ui/src/components/Accordion/Accordion.tsx b/ngui/ui/src/components/Accordion/Accordion.tsx index d7b274979..9d6143147 100644 --- a/ngui/ui/src/components/Accordion/Accordion.tsx +++ b/ngui/ui/src/components/Accordion/Accordion.tsx @@ -13,15 +13,22 @@ const Accordion = ({ inheritFlexDirection = false, actions = null, headerDataTestId, + disableShadows = false, + enabledBorder = false, + expandTitleColor, + alwaysHighlightTitle = false, ...rest }) => { - const { classes, cx } = useStyles(); + const { classes, cx } = useStyles({ + expandTitleColor, + alwaysHighlightTitle + }); return ( {summary} diff --git a/ngui/ui/src/components/UserEmailNotificationSettings/UserEmailNotificationSettings.tsx b/ngui/ui/src/components/UserEmailNotificationSettings/UserEmailNotificationSettings.tsx new file mode 100644 index 000000000..4c70611ca --- /dev/null +++ b/ngui/ui/src/components/UserEmailNotificationSettings/UserEmailNotificationSettings.tsx @@ -0,0 +1,298 @@ +import { useMutation } from "@apollo/client"; +import { Box, CircularProgress, Stack, Switch, Typography } from "@mui/material"; +import { FormattedMessage } from "react-intl"; +import Accordion from "components/Accordion"; +import Chip from "components/Chip"; +import KeyValueLabel from "components/KeyValueLabel"; +import PanelLoader from "components/PanelLoader"; +import SubTitle from "components/SubTitle"; +import { UPDATE_EMPLOYEE_EMAIL, UPDATE_EMPLOYEE_EMAILS } from "graphql/api/restapi/queries/restapi.queries"; +import { isEmpty as isEmptyArray } from "utils/arrays"; +import { SPACING_2 } from "utils/layouts"; +import { ObjectKeys } from "utils/types"; +import { + ApiEmployeeEmail, + EmailSettingProps, + EmployeeEmail, + LoadingSwitchProps, + UserEmailNotificationSettingsProps, + UserEmailSettingsProps +} from "./types"; + +const EMAIL_TEMPLATES = { + finOps: { + weekly_expense_report: { + title: "emailTemplates.finOps.weekly_expense_report.title", + description: "emailTemplates.finOps.weekly_expense_report.description" + }, + pool_exceed_resources_report: { + title: "emailTemplates.finOps.pool_exceed_resources_report.title", + description: "emailTemplates.finOps.pool_exceed_resources_report.description" + }, + pool_exceed_report: { + title: "emailTemplates.finOps.pool_exceed_report.title", + description: "emailTemplates.finOps.pool_exceed_report.description" + }, + alert: { + title: "emailTemplates.finOps.alert.title", + description: "emailTemplates.finOps.alert.description" + }, + saving_spike: { + title: "emailTemplates.finOps.saving_spike.title", + description: "emailTemplates.finOps.saving_spike.description" + } + }, + policy: { + resource_owner_violation_report: { + title: "emailTemplates.policy.resource_owner_violation_report.title", + description: "emailTemplates.policy.resource_owner_violation_report.description" + }, + pool_owner_violation_report: { + title: "emailTemplates.policy.pool_owner_violation_report.title", + description: "emailTemplates.policy.pool_owner_violation_report.description" + }, + resource_owner_violation_alert: { + title: "emailTemplates.policy.resource_owner_violation_alert.title", + description: "emailTemplates.policy.resource_owner_violation_alert.description" + }, + anomaly_detection_alert: { + title: "emailTemplates.policy.anomaly_detection_alert.title", + description: "emailTemplates.policy.anomaly_detection_alert.description" + }, + organization_policy_expiring_budget: { + title: "emailTemplates.policy.organization_policy_expiring_budget.title", + description: "emailTemplates.policy.organization_policy_expiring_budget.description" + }, + organization_policy_quota: { + title: "emailTemplates.policy.organization_policy_quota.title", + description: "emailTemplates.policy.organization_policy_quota.description" + }, + organization_policy_recurring_budget: { + title: "emailTemplates.policy.organization_policy_recurring_budget.title", + description: "emailTemplates.policy.organization_policy_recurring_budget.description" + }, + organization_policy_tagging: { + title: "emailTemplates.policy.organization_policy_tagging.title", + description: "emailTemplates.policy.organization_policy_tagging.description" + } + }, + recommendations: { + new_security_recommendation: { + title: "emailTemplates.recommendations.new_security_recommendation.title", + description: "emailTemplates.recommendations.new_security_recommendation.description" + } + }, + systemNotifications: { + environment_changes: { + title: "emailTemplates.systemNotifications.environment_changes.title", + description: "emailTemplates.systemNotifications.environment_changes.description" + }, + report_imports_passed_for_org: { + title: "emailTemplates.systemNotifications.report_imports_passed_for_org.title", + description: "emailTemplates.systemNotifications.report_imports_passed_for_org.description" + } + }, + accountManagement: { + invite: { + title: "emailTemplates.accountManagement.invite.title", + description: "emailTemplates.accountManagement.invite.description" + } + } +} as const; + +const LoadingSwitch = ({ checked, onChange, isLoading = false }: LoadingSwitchProps) => { + const icon = ( + (checked ? theme.palette.secondary.main : theme.palette.background.default), + boxShadow: (theme) => theme.shadows[1] + }} + > + {isLoading && } + + ); + + return ; +}; + +const EmailSetting = ({ emailId, employeeId, enabled, emailTitle, description }: EmailSettingProps) => { + const [updateEmployeeEmail, { loading: updateEmployeeEmailLoading }] = useMutation(UPDATE_EMPLOYEE_EMAIL); + + return ( + + + + + + { + const { checked } = event.target; + + updateEmployeeEmail({ + variables: { + employeeId, + params: { + emailId, + action: checked ? "enable" : "disable" + } + } + }); + }} + isLoading={updateEmployeeEmailLoading} + /> + + {} + + ); +}; + +const UserEmailSettings = ({ title, employeeEmails }: UserEmailSettingsProps) => { + const { employee_id: employeeId } = employeeEmails[0]; + + const [updateEmployeeEmails, { loading: updateEmployeeEmailsLoading }] = useMutation(UPDATE_EMPLOYEE_EMAILS); + + const areAllEmailsEnabled = employeeEmails.every((email) => email.enabled); + + const enabledEmailsCount = employeeEmails.filter((email) => email.enabled).length; + const totalEmailsCount = employeeEmails.length; + + return ( + theme.spacing(2) + } + }} + > + + + {title} + + } + /> + } + /> + + { + // prevent opening the accordion when clicking on the switch + e.stopPropagation(); + }} + > + { + const { checked } = event.target; + + updateEmployeeEmails({ + variables: { + employeeId, + params: { + [checked ? "enable" : "disable"]: employeeEmails.map((email) => email.id) + } + } + }); + }} + isLoading={updateEmployeeEmailsLoading} + /> + + + + {employeeEmails.map((email) => { + const { id: emailId, enabled, title: emailTitle, description } = email; + + return ( + + ); + })} + + + ); +}; + +const getGroupedEmailTemplates = (employeeEmails: ApiEmployeeEmail[]) => { + const employeeEmailsMap = Object.fromEntries(employeeEmails.map((email) => [email.email_template, email])); + + return Object.fromEntries( + Object.entries(EMAIL_TEMPLATES).map(([groupName, templates]) => [ + groupName, + Object.entries(templates) + .filter(([templateName]) => templateName in employeeEmailsMap) + .map(([templateName, { title, description }]) => { + const email = employeeEmailsMap[templateName]; + + return { ...email, title, description } as EmployeeEmail; + }) + .filter(({ available_by_role: availableByRole }) => availableByRole) + ]) + ) as { + [K in ObjectKeys]: EmployeeEmail[]; + }; +}; + +const UserEmailNotificationSettings = ({ employeeEmails, isLoading = false }: UserEmailNotificationSettingsProps) => { + if (isLoading) { + return ; + } + + if (isEmptyArray(employeeEmails)) { + return ; + } + + const { finOps, policy, recommendations, systemNotifications, accountManagement } = getGroupedEmailTemplates(employeeEmails); + return ( + <> + {isEmptyArray(finOps) ? null : } employeeEmails={finOps} />} + {isEmptyArray(policy) ? null : ( + } employeeEmails={policy} /> + )} + {isEmptyArray(recommendations) ? null : ( + } employeeEmails={recommendations} /> + )} + {isEmptyArray(systemNotifications) ? null : ( + } employeeEmails={systemNotifications} /> + )} + {isEmptyArray(accountManagement) ? null : ( + } employeeEmails={accountManagement} /> + )} + + ); +}; + +export default UserEmailNotificationSettings; diff --git a/ngui/ui/src/components/UserEmailNotificationSettings/index.ts b/ngui/ui/src/components/UserEmailNotificationSettings/index.ts new file mode 100644 index 000000000..de4b998b4 --- /dev/null +++ b/ngui/ui/src/components/UserEmailNotificationSettings/index.ts @@ -0,0 +1,3 @@ +import UserEmailNotificationSettings from "./UserEmailNotificationSettings"; + +export default UserEmailNotificationSettings; diff --git a/ngui/ui/src/components/UserEmailNotificationSettings/types.ts b/ngui/ui/src/components/UserEmailNotificationSettings/types.ts new file mode 100644 index 000000000..d75343bd8 --- /dev/null +++ b/ngui/ui/src/components/UserEmailNotificationSettings/types.ts @@ -0,0 +1,56 @@ +import { ChangeEvent, ReactNode } from "react"; + +// TODO TS: Replace with apollo types +export type ApiEmployeeEmail = { + id: string; + available_by_role: boolean; + email_template: + | "weekly_expense_report" + | "pool_exceed_resources_report" + | "pool_exceed_report" + | "alert" + | "saving_spike" + | "resource_owner_violation_report" + | "pool_owner_violation_report" + | "resource_owner_violation_alert" + | "anomaly_detection_alert" + | "organization_policy_expiring_budget" + | "organization_policy_quota" + | "organization_policy_recurring_budget" + | "organization_policy_tagging" + | "new_security_recommendation" + | "environment_changes" + | "report_imports_passed_for_org" + | "invite"; + enabled: boolean; + employee_id: string; +}; + +export type EmployeeEmail = { + title: string; + description: string; +} & ApiEmployeeEmail; + +export type EmailSettingProps = { + emailId: string; + employeeId: string; + enabled: boolean; + emailTitle: string; + description: string; +}; + +export type LoadingSwitchProps = { + checked: boolean; + onChange: (event: ChangeEvent) => void; + isLoading?: boolean; +}; + +export type UserEmailSettingsProps = { + title: ReactNode; + employeeEmails: EmployeeEmail[]; +}; + +export type UserEmailNotificationSettingsProps = { + employeeEmails: ApiEmployeeEmail[]; + isLoading: boolean; +}; diff --git a/ngui/ui/src/containers/UserEmailNotificationSettingsContainer/UserEmailNotificationSettingsContainer.tsx b/ngui/ui/src/containers/UserEmailNotificationSettingsContainer/UserEmailNotificationSettingsContainer.tsx new file mode 100644 index 000000000..a129f2343 --- /dev/null +++ b/ngui/ui/src/containers/UserEmailNotificationSettingsContainer/UserEmailNotificationSettingsContainer.tsx @@ -0,0 +1,21 @@ +import { useQuery } from "@apollo/client"; +import { GET_CURRENT_EMPLOYEE } from "api/restapi/actionTypes"; +import UserEmailNotificationSettings from "components/UserEmailNotificationSettings"; +import { GET_EMPLOYEE_EMAILS } from "graphql/api/restapi/queries/restapi.queries"; +import { useApiData } from "hooks/useApiData"; + +const UserEmailNotificationSettingsContainer = () => { + const { + apiData: { currentEmployee = {} } + } = useApiData(GET_CURRENT_EMPLOYEE); + + const { loading, data } = useQuery(GET_EMPLOYEE_EMAILS, { + variables: { + employeeId: currentEmployee.id + } + }); + + return ; +}; + +export default UserEmailNotificationSettingsContainer; diff --git a/ngui/ui/src/containers/UserEmailNotificationSettingsContainer/index.ts b/ngui/ui/src/containers/UserEmailNotificationSettingsContainer/index.ts new file mode 100644 index 000000000..7006f6455 --- /dev/null +++ b/ngui/ui/src/containers/UserEmailNotificationSettingsContainer/index.ts @@ -0,0 +1,3 @@ +import UserEmailNotificationSettingsContainer from "./UserEmailNotificationSettingsContainer"; + +export default UserEmailNotificationSettingsContainer; diff --git a/ngui/ui/src/graphql/api/restapi/queries/restapi.queries.ts b/ngui/ui/src/graphql/api/restapi/queries/restapi.queries.ts index 8fb4afadd..8c27e4c4f 100644 --- a/ngui/ui/src/graphql/api/restapi/queries/restapi.queries.ts +++ b/ngui/ui/src/graphql/api/restapi/queries/restapi.queries.ts @@ -110,4 +110,40 @@ const UPDATE_DATA_SOURCE = gql` } `; -export { GET_DATA_SOURCE, UPDATE_DATA_SOURCE }; +const GET_EMPLOYEE_EMAILS = gql` + query EmployeeEmails($employeeId: ID!) { + employeeEmails(employeeId: $employeeId) { + id + employee_id + email_template + enabled + available_by_role + } + } +`; + +const UPDATE_EMPLOYEE_EMAILS = gql` + mutation UpdateEmployeeEmails($employeeId: ID!, $params: UpdateEmployeeEmailsInput!) { + updateEmployeeEmails(employeeId: $employeeId, params: $params) { + id + employee_id + email_template + enabled + available_by_role + } + } +`; + +const UPDATE_EMPLOYEE_EMAIL = gql` + mutation UpdateEmployeeEmail($employeeId: ID!, $params: UpdateEmployeeEmailInput!) { + updateEmployeeEmail(employeeId: $employeeId, params: $params) { + id + employee_id + email_template + enabled + available_by_role + } + } +`; + +export { GET_DATA_SOURCE, UPDATE_DATA_SOURCE, GET_EMPLOYEE_EMAILS, UPDATE_EMPLOYEE_EMAILS, UPDATE_EMPLOYEE_EMAIL }; diff --git a/ngui/ui/src/pages/Settings/Settings.tsx b/ngui/ui/src/pages/Settings/Settings.tsx index 24597b476..3c253d4e9 100644 --- a/ngui/ui/src/pages/Settings/Settings.tsx +++ b/ngui/ui/src/pages/Settings/Settings.tsx @@ -5,6 +5,7 @@ import TabsWrapper from "components/TabsWrapper"; import InvitationsContainer from "containers/InvitationsContainer"; import ModeContainer from "containers/ModeContainer"; import SshSettingsContainer from "containers/SshSettingsContainer"; +import UserEmailNotificationSettingsContainer from "containers/UserEmailNotificationSettingsContainer"; import { useIsOptScaleModeEnabled } from "hooks/useIsOptScaleModeEnabled"; import { OPTSCALE_MODE } from "utils/constants"; @@ -18,7 +19,8 @@ export const SETTINGS_TABS = Object.freeze({ ORGANIZATION: "organization", INVITATIONS: "invitations", MODE: "mode", - SSH: "sshKeys" + SSH: "sshKeys", + EMAIL_NOTIFICATIONS: "emailNotifications" }); const Settings = () => { @@ -48,7 +50,12 @@ const Settings = () => { node: } ] - : []) + : []), + { + title: SETTINGS_TABS.EMAIL_NOTIFICATIONS, + dataTestId: `tab_${SETTINGS_TABS.EMAIL_NOTIFICATIONS}`, + node: + } ]; return ( diff --git a/ngui/ui/src/theme.ts b/ngui/ui/src/theme.ts index 8504f2768..5d8e8b2d3 100644 --- a/ngui/ui/src/theme.ts +++ b/ngui/ui/src/theme.ts @@ -261,6 +261,7 @@ const getThemeConfig = (settings = {}) => { styleOverrides: { root: { "&:before": { + // disable border between accordions display: "none" } } @@ -277,14 +278,7 @@ const getThemeConfig = (settings = {}) => { }, root: { "&.Mui-expanded": { - minHeight: "48px", - color: secondary.contrastText, - backgroundColor: ACTION_SELECTED - } - }, - expandIconWrapper: { - "&.Mui-expanded": { - color: secondary.contrastText + minHeight: "48px" } } } diff --git a/ngui/ui/src/translations/en-US/app.json b/ngui/ui/src/translations/en-US/app.json index fef2176da..94406434e 100644 --- a/ngui/ui/src/translations/en-US/app.json +++ b/ngui/ui/src/translations/en-US/app.json @@ -46,6 +46,7 @@ "accessKey": "Access key", "accessKeyId": "Access key ID", "accountId": "Account ID", + "accountManagementTitle": "Account Management", "acquireWebhook": "Acquire webhook", "action": "Action", "actions": "Actions", @@ -630,6 +631,41 @@ "email": "Email", "emailVerificationDescription": "To verify your email, please enter the verification code sent to:", "emailVerifiedSuccessfully": "Email has been verified successfully!", + "emailNotifications": "Email notifications", + "emailTemplates.accountManagement.invite.description": "Notification of an invitation to join OptScale", + "emailTemplates.accountManagement.invite.title": "Invitation notification", + "emailTemplates.finOps.alert.description": "Notification of a pool limit being reached or exceeded", + "emailTemplates.finOps.alert.title": "Pool limit alert", + "emailTemplates.finOps.pool_exceed_report.description": "Alert for exceeding the limits of a specific resource pool", + "emailTemplates.finOps.pool_exceed_report.title": "Pool limit exceed alert", + "emailTemplates.finOps.pool_exceed_resources_report.description": "Notification that resource pools have exceeded or are forecasted to exceed their defined limits", + "emailTemplates.finOps.pool_exceed_resources_report.title": "Pool limit exceed alert", + "emailTemplates.finOps.saving_spike.description": "Alert about new saving opportunities", + "emailTemplates.finOps.saving_spike.title": "Saving spike", + "emailTemplates.finOps.weekly_expense_report.description": "A summary of weekly expenses", + "emailTemplates.finOps.weekly_expense_report.title": "Weekly expense report", + "emailTemplates.policy.anomaly_detection_alert.description": "Alert for detecting unusual activity or anomalies", + "emailTemplates.policy.anomaly_detection_alert.title": "Anomaly detection", + "emailTemplates.policy.organization_policy_expiring_budget.description": "Notification of a violation related to an expiring budget policy", + "emailTemplates.policy.organization_policy_expiring_budget.title": "Expiring budget policy violation", + "emailTemplates.policy.organization_policy_quota.description": "Notification of a violation related to a quota policy", + "emailTemplates.policy.organization_policy_quota.title": "Quota policy violation", + "emailTemplates.policy.organization_policy_recurring_budget.description": "Notification of a violation related to a recurring budget policy", + "emailTemplates.policy.organization_policy_recurring_budget.title": "Recurring budget policy violation", + "emailTemplates.policy.organization_policy_tagging.description": "Notification of a violation related to a tagging policy", + "emailTemplates.policy.organization_policy_tagging.title": "Tagging policy violation", + "emailTemplates.policy.pool_owner_violation_report.description": "Report regarding resource constraint violations within your managed pool", + "emailTemplates.policy.pool_owner_violation_report.title": "Resource constraints report", + "emailTemplates.policy.resource_owner_violation_alert.description": "Alert for detecting new constraint violations", + "emailTemplates.policy.resource_owner_violation_alert.title": "Resource constraint violation alert", + "emailTemplates.policy.resource_owner_violation_report.description": "Notification of resource constraint violations within your managed pool", + "emailTemplates.policy.resource_owner_violation_report.title": "Resource constraints report", + "emailTemplates.recommendations.new_security_recommendation.description": "Alert regarding a newly detected security recommendation", + "emailTemplates.recommendations.new_security_recommendation.title": "New security recommendation detection", + "emailTemplates.systemNotifications.environment_changes.description": "Notification of changes made in a Shared Environment", + "emailTemplates.systemNotifications.environment_changes.title": "Environment changed", + "emailTemplates.systemNotifications.report_imports_passed_for_org.description": "Confirmation that initial expense processing for your organization is complete", + "emailTemplates.systemNotifications.report_imports_passed_for_org.title": "Expenses initial processing completed", "employee": "Employee", "enabled": "Enabled", "endDate": "End date", @@ -1303,6 +1339,7 @@ "noArchivedRecommendationsAvailable": "No archived recommendations available.", "noArtifacts": "No artifacts", "noAutomaticResourceAssignmentRules": "No automatic resource assignment rules", + "noAvailableEmailNotifications": "No available email notifications", "noBIExports": "No Business Intelligence exports", "noBuckets": "No buckets", "noChildDataSourcesDiscovered": "No child data sources have been discovered", @@ -1554,6 +1591,7 @@ "pleaseUseTheFollowingEnvCollectorUrl": "Please use the following ENV_COLLECTOR_URL in your automation to set properties of this Shared Environment:", "pluralHoursValue": "{value, plural,\n =0 {hours}\n =1 { hour}\n other { hours}\n}", "policies": "Policies", + "policyAlertsTitle": "Policy Alerts", "policyName": "Policy name", "policyType": "Policy type", "policyViolations": "Policy violations", @@ -2098,6 +2136,7 @@ "survey": "Survey", "syncTooltips": "Sync tooltips", "system": "System", + "systemNotificationsTitle": "System Notifications", "tSystems": "T-Systems", "table": "Table", "tag": "Tag", diff --git a/optscale_client/rest_api_client/client_v2.py b/optscale_client/rest_api_client/client_v2.py index 0f401237f..dd99910b0 100644 --- a/optscale_client/rest_api_client/client_v2.py +++ b/optscale_client/rest_api_client/client_v2.py @@ -2225,3 +2225,18 @@ def verify_email_url(): def verify_email(self, email): url = self.verify_email_url() return self.post(url, {'email': email}) + + @staticmethod + def employee_emails_url(employee_id): + return '%s/emails' % Client.employee_url(employee_id) + + def employee_emails_get(self, employee_id, email_template=None): + return self.get(self.employee_emails_url(employee_id) + self.query_url( + email_template=email_template)) + + @staticmethod + def employee_emails_bulk_url(employee_id): + return '%s/bulk' % Client.employee_emails_url(employee_id) + + def employee_emails_bulk(self, employee_id, params): + return self.post(self.employee_emails_bulk_url(employee_id), params) diff --git a/rest_api/rest_api_server/alembic/versions/a1d0494e9815_employee_emails.py b/rest_api/rest_api_server/alembic/versions/a1d0494e9815_employee_emails.py new file mode 100644 index 000000000..6ed6e5993 --- /dev/null +++ b/rest_api/rest_api_server/alembic/versions/a1d0494e9815_employee_emails.py @@ -0,0 +1,105 @@ +""""employee_emails" + +Revision ID: a1d0494e9815 +Revises: 7d91396a219d +Create Date: 2024-08-30 04:17:27.866034 + +""" +import uuid +from alembic import op +import sqlalchemy as sa +from sqlalchemy import and_, Boolean, insert, Integer, select, String +from sqlalchemy.orm import Session +from sqlalchemy.sql import table, column +from rest_api.rest_api_server.models.types import ( + MediumLargeNullableString, NullableBool, NullableUuid +) + +# revision identifiers, used by Alembic. +revision = 'a1d0494e9815' +down_revision = '7d91396a219d' +branch_labels = None +depends_on = None + +EMAIL_TEMPLATES = [ + 'alert', + 'anomaly_detection_alert', + 'employee_greetings', + 'environment_changes', + 'invite', + 'new_security_recommendation', + 'organization_policy_expiring_budget', + 'organization_policy_quota', + 'organization_policy_recurring_budget', + 'organization_policy_tagging', + 'pool_exceed_report', + 'pool_exceed_resources_report', + 'pool_owner_violation_report', + 'resource_owner_violation_alert', + 'resource_owner_violation_report', + 'report_imports_passed_for_org', + 'saving_spike', + 'weekly_expense_report' +] + + +def _fill_table(): + bind = op.get_bind() + session = Session(bind=bind) + try: + org_t = table('organization', + column('id', String(36)), + column('deleted_at', Integer()), + column('is_demo', Integer())) + emp_t = table('employee', + column('id', String(36)), + column('organization_id', String(36)), + column('deleted_at', Integer())) + emp_email_t = table('employee_email', + column('id', String(36)), + column('employee_id', String(36)), + column('email_template', String(256)), + column('enabled', Boolean()), + column('deleted_at', Integer())) + cmd = select([emp_t.c.id]).where( + and_(emp_t.c.deleted_at == 0, + emp_t.c.organization_id.in_( + select([org_t.c.id]).where( + and_(org_t.c.deleted_at == 0, + org_t.c.is_demo.is_(False))))) + ) + employee_ids = session.execute(cmd) + for employee in employee_ids: + for email_template in EMAIL_TEMPLATES: + insert_cmd = insert(emp_email_t).values( + id=str(uuid.uuid4()), + employee_id=employee['id'], + email_template=email_template, + enabled=True, + deleted_at=0 + ) + session.execute(insert_cmd) + session.commit() + finally: + session.close() + + +def upgrade(): + op.create_table( + 'employee_email', + sa.Column('id', NullableUuid(length=36), nullable=False), + sa.Column('employee_id', NullableUuid(length=36), nullable=False), + sa.Column('email_template', MediumLargeNullableString(length=128), + nullable=False), + sa.Column('enabled', NullableBool(), nullable=False), + sa.Column('deleted_at', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['employee_id'], ['employee.id'], ), + sa.UniqueConstraint('employee_id', 'email_template', 'deleted_at', + name='uc_employee_email_template') + ) + _fill_table() + + +def downgrade(): + op.drop_table('employee_email') diff --git a/rest_api/rest_api_server/constants.py b/rest_api/rest_api_server/constants.py index 5fa614cbf..231c799b0 100644 --- a/rest_api/rest_api_server/constants.py +++ b/rest_api/rest_api_server/constants.py @@ -229,6 +229,10 @@ class UrlsV2(Urls): 'restore_password': r"%s/restore_password", 'profiling_token_info': r"%s/profiling_tokens/(?P[^/]+)", 'verify_email': r"%s/verify_email", + 'employee_emails_collection': + r"%s/employees/(?P[^/]+)/emails", + 'employee_emails_bulk': + r"%s/employees/(?P[^/]+)/emails/bulk", }) diff --git a/rest_api/rest_api_server/controllers/context.py b/rest_api/rest_api_server/controllers/context.py index cbf618c74..054d703ab 100644 --- a/rest_api/rest_api_server/controllers/context.py +++ b/rest_api/rest_api_server/controllers/context.py @@ -7,7 +7,8 @@ from rest_api.rest_api_server.models.models import ( Organization, CloudAccount, Employee, Pool, ReportImport, PoolAlert, PoolPolicy, ResourceConstraint, Rule, ShareableBooking, Webhook, - OrganizationConstraint, OrganizationBI, OrganizationGemini, PowerSchedule) + OrganizationConstraint, OrganizationBI, OrganizationGemini, PowerSchedule, + EmployeeEmail) from tools.optscale_exceptions.common_exc import (WrongArgumentsException, NotFoundException) from rest_api.rest_api_server.utils import tp_executor_context @@ -45,7 +46,8 @@ def _get_input(self, **kwargs): 'resource_constraint', 'rule', 'shareable_booking', 'webhook', 'organization_constraint', 'organization_bi', - 'organization_gemini', 'power_schedule']: + 'organization_gemini', 'power_schedule', + 'employee_email']: raise WrongArgumentsException(Err.OE0174, [type_name]) return type_name, uuid @@ -64,6 +66,7 @@ def _get_item(self, type_name, uuid): 'organization_bi': OrganizationBI.__name__, 'organization_gemini': OrganizationGemini.__name__, 'power_schedule': PowerSchedule.__name__, + 'employee_email': EmployeeEmail.__name__ } def call_query(base): @@ -91,6 +94,7 @@ def call_pipeline(base): 'organization_gemini': (self.session.query(OrganizationGemini), call_query), 'power_schedule': (self.session.query(PowerSchedule), call_query), + 'employee_email': (self.session.query(EmployeeEmail), call_query), } query_base, func = query_map.get(type_name) @@ -156,6 +160,10 @@ def context(self, **kwargs): 'power_schedule': lambda x: ( 'organization', self._get_item('organization', x.organization_id) ), + 'employee_email': lambda x: ( + 'employee', + self._get_item('employee', x.employee_id) + ), } item = self._get_item(type_name, uuid) source_type = type_name diff --git a/rest_api/rest_api_server/controllers/employee.py b/rest_api/rest_api_server/controllers/employee.py index cf014febb..aee34d267 100644 --- a/rest_api/rest_api_server/controllers/employee.py +++ b/rest_api/rest_api_server/controllers/employee.py @@ -1,6 +1,7 @@ import logging import re import requests +from datetime import datetime, timezone from optscale_client.config_client.client import etcd from sqlalchemy import exists, and_, or_, func from sqlalchemy.exc import IntegrityError @@ -8,8 +9,10 @@ NotFoundException, ConflictException, ForbiddenException, UnauthorizedException, WrongArgumentsException) -from rest_api.rest_api_server.controllers.base import BaseController, MongoMixin -from rest_api.rest_api_server.controllers.base_async import BaseAsyncControllerWrapper +from rest_api.rest_api_server.controllers.base import ( + BaseController, MongoMixin) +from rest_api.rest_api_server.controllers.base_async import ( + BaseAsyncControllerWrapper) from rest_api.rest_api_server.controllers.expense import ( CloudFilteredEmployeeFormattedExpenseController, PoolFilteredEmployeeFormattedExpenseController) @@ -17,12 +20,14 @@ OrganizationConstraintController) from rest_api.rest_api_server.controllers.profiling.base import ( BaseProfilingController) +from rest_api.rest_api_server.controllers.employee_email import ( + EmployeeEmailController) from rest_api.rest_api_server.exceptions import Err from rest_api.rest_api_server.models.enums import ( AuthenticationType, PoolPurposes, RolePurposes) from rest_api.rest_api_server.models.models import ( - AssignmentRequest, Employee, Layout, Organization, Pool, Rule, - ShareableBooking) + AssignmentRequest, Employee, EmployeeEmail, Layout, Organization, Pool, + Rule, ShareableBooking) from rest_api.rest_api_server.utils import Config from optscale_client.auth_client.client_v2 import Client as AuthClient @@ -221,6 +226,15 @@ def list(self, organization_id, last_login=False, **kwargs): result.append(item) return result + def _create_employee_emails(self, employee_id): + emp_email_ctr = EmployeeEmailController(self.session, self._config) + emp_email_ctr.create_all_email_templates(employee_id) + + def create(self, **kwargs): + employee = super().create(**kwargs) + self._create_employee_emails(employee.id) + return employee + def get_expenses(self, employee, start_date, end_date, filter_by): controller_map = { 'cloud': CloudFilteredEmployeeFormattedExpenseController, @@ -331,6 +345,13 @@ def _reassign_resources_to_new_owner(self, new_owner_id, employee, scopes): self.session.rollback() raise WrongArgumentsException(Err.OE0003, [str(ex)]) + def _delete_employee_emails(self, employee_id): + self.session.query(EmployeeEmail).filter( + EmployeeEmail.employee_id == employee_id, + EmployeeEmail.deleted_at == 0 + ).update({EmployeeEmail.deleted_at: int(datetime.now( + tz=timezone.utc).timestamp())}) + def delete(self, item_id, reassign_resources=True, **kwargs): employee = self.get(item_id) scopes = self.get_org_and_pool_summary_map(employee.organization_id) @@ -344,6 +365,8 @@ def delete(self, item_id, reassign_resources=True, **kwargs): raise UnauthorizedException(Err.OE0235, []) raise + self._delete_employee_emails(item_id) + if reassign_resources: new_owner_id = kwargs.get('new_owner_id') user_id = kwargs.get('user_id') diff --git a/rest_api/rest_api_server/controllers/employee_email.py b/rest_api/rest_api_server/controllers/employee_email.py new file mode 100644 index 000000000..aa84f5c30 --- /dev/null +++ b/rest_api/rest_api_server/controllers/employee_email.py @@ -0,0 +1,142 @@ +import logging +from sqlalchemy import and_ +from sqlalchemy.exc import IntegrityError +from tools.optscale_exceptions.common_exc import ( + NotFoundException, WrongArgumentsException +) + +from rest_api.rest_api_server.controllers.base import BaseController +from rest_api.rest_api_server.controllers.base_async import ( + BaseAsyncControllerWrapper +) +from rest_api.rest_api_server.controllers.pool import PoolController +from rest_api.rest_api_server.exceptions import Err +from rest_api.rest_api_server.models.models import Employee, EmployeeEmail + +LOG = logging.getLogger(__name__) + +ROLE_TEMPLATES = { + 'optscale_manager': [ + 'anomaly_detection_alert', + 'new_security_recommendation', + 'saving_spike', + 'organization_policy_expiring_budget', + 'organization_policy_quota', + 'organization_policy_recurring_budget', + 'organization_policy_tagging', + 'pool_exceed_report', + 'pool_owner_violation_report', + 'report_imports_passed_for_org', + 'weekly_expense_report', + 'environment_changes', + 'resource_owner_violation_alert', + ], + 'optscale_engineer': [ + 'pool_exceed_resources_report', + 'resource_owner_violation_report', + 'environment_changes', + 'resource_owner_violation_alert', + ], + 'optscale_member': [ + 'alert', + 'invite', + 'employee_greetings', + ] +} + + +class EmployeeEmailController(BaseController): + + def _get_model_type(self): + return EmployeeEmail + + def get_employee(self, employee_id): + employee = self.session.query(Employee).filter( + Employee.id == employee_id, + Employee.deleted_at == 0).scalar() + if not employee: + raise NotFoundException(Err.OE0002, + [Employee.__name__, employee_id]) + return employee + + def create_all_email_templates(self, employee_id): + email_templates = set(t for t_list in ROLE_TEMPLATES.values() + for t in t_list) + model = self._get_model_type() + for template in email_templates: + employee_email = model(employee_id=employee_id, + email_template=template, + enabled=True) + self.session.add(employee_email) + try: + self.session.commit() + except IntegrityError as exc: + self.session.rollback() + raise WrongArgumentsException(Err.OE0003, [str(exc)]) + + def _get_scopes(self, organization_id): + pool_ctrl = PoolController(self.session, self._config) + pools = pool_ctrl.get_organization_pools(organization_id) + scopes = [x['id'] for x in pools] + scopes.append(organization_id) + return scopes + + def list(self, employee_id, **kwargs): + employee = self.get_employee(employee_id) + model = self._get_model_type() + email_template = kwargs.get('email_template') + employee_emails = self.session.query(model).filter(and_( + model.employee_id == employee_id, + model.deleted_at == 0 + )).all() + if email_template: + employee_emails = self.session.query(model).filter(and_( + model.employee_id == employee_id, + model.deleted_at == 0, + model.email_template == email_template + )).all() + result = {'employee_emails': [ + x.to_dict() for x in employee_emails + ]} + scopes = self._get_scopes(employee.organization_id) + _, roles = self.auth_client.user_roles_get( + [employee.auth_user_id], + scope_ids=scopes + ) + role_purposes = [x['role_purpose'] for x in roles] + available_emails = set( + template for purpose, templates in ROLE_TEMPLATES.items() + for template in templates if purpose in role_purposes + ) + available_emails.update(ROLE_TEMPLATES['optscale_member']) + for employee_email in result['employee_emails']: + employee_email['available_by_role'] = True + if employee_email['email_template'] not in available_emails: + employee_email['available_by_role'] = False + return result + + def bulk_update(self, employee_id, **kwargs): + self.get_employee(employee_id) + enable = kwargs.get('enable', []) + disable = kwargs.get('disable', []) + model = self._get_model_type() + employee_emails = self.session.query(model).filter( + model.employee_id == employee_id, + model.deleted_at == 0, + model.id.in_(enable + disable)).all() + for employee_email in employee_emails: + if employee_email.id in enable: + employee_email.enabled = True + elif employee_email.id in disable: + employee_email.enabled = False + self.session.add(employee_email) + try: + self.session.commit() + except IntegrityError as exc: + raise WrongArgumentsException(Err.OE0003, [str(exc)]) + return self.list(employee_id) + + +class EmployeeEmailAsyncController(BaseAsyncControllerWrapper): + def _get_controller_class(self): + return EmployeeEmailController diff --git a/rest_api/rest_api_server/controllers/invite.py b/rest_api/rest_api_server/controllers/invite.py index 29d6d3b5f..2f52286bc 100644 --- a/rest_api/rest_api_server/controllers/invite.py +++ b/rest_api/rest_api_server/controllers/invite.py @@ -8,8 +8,11 @@ from etcd import EtcdKeyNotFound from rest_api.rest_api_server.controllers.base import BaseController -from rest_api.rest_api_server.controllers.base_async import BaseAsyncControllerWrapper +from rest_api.rest_api_server.controllers.base_async import ( + BaseAsyncControllerWrapper) from rest_api.rest_api_server.controllers.employee import EmployeeController +from rest_api.rest_api_server.controllers.employee_email import ( + EmployeeEmailController) from rest_api.rest_api_server.exceptions import Err from rest_api.rest_api_server.models.enums import InviteAssignmentScopeTypes from rest_api.rest_api_server.models.models import ( @@ -57,11 +60,29 @@ def get_scopes(self, ids): def get_invite_expiration_days(self): try: - invite_expiration_days = int(self._config.read('/restapi/invite_expiration_days').value) + invite_expiration_days = int( + self._config.read('/restapi/invite_expiration_days').value) except EtcdKeyNotFound: invite_expiration_days = 30 return invite_expiration_days + def _is_invite_email_enabled(self, organization_id, email): + exists, info = self.check_user_exists(email) + if not exists: + return True + employee_ctrl = EmployeeController(self.session, self._config) + employee = employee_ctrl.list(organization_id, + auth_user_id=info['id']) + if employee: + empl_email_ctrl = EmployeeEmailController( + self.session, self._config) + emails = empl_email_ctrl.list( + employee[0]['id'], email_template='invite') + if emails['employee_emails'] and not emails['employee_emails'][0][ + 'enabled']: + return False + return True + def create(self, email, user_id, user_info, invite_assignments: 'list', show_link=False): def get_highest_role(current, new): @@ -136,9 +157,10 @@ def get_highest_role(current, new): invite_url = self.generate_link(email) if show_link: invite_dict['url'] = invite_url - self.send_notification( - email, invite_url, organization.name, organization.id, - organization.currency) + if self._is_invite_email_enabled(organization.id, email): + self.send_notification( + email, invite_url, organization.name, organization.id, + organization.currency) meta = { 'object_name': organization.name, 'email': email, @@ -305,7 +327,8 @@ def generate_link(self, email): base_url=base_url, action='invited', params=params) return url - def send_notification(self, email, url, organization_name, organization_id, currency): + def send_notification(self, email, url, organization_name, organization_id, + currency): subject = 'OptScale invitation notification' template_params = { 'texts': { diff --git a/rest_api/rest_api_server/handlers/v2/__init__.py b/rest_api/rest_api_server/handlers/v2/__init__.py index 6e028378e..151e5539e 100644 --- a/rest_api/rest_api_server/handlers/v2/__init__.py +++ b/rest_api/rest_api_server/handlers/v2/__init__.py @@ -81,3 +81,4 @@ import rest_api.rest_api_server.handlers.v2.ri_group_breakdowns import rest_api.rest_api_server.handlers.v2.restore_passwords import rest_api.rest_api_server.handlers.v2.verify_emails +import rest_api.rest_api_server.handlers.v2.employee_emails diff --git a/rest_api/rest_api_server/handlers/v2/employee_emails.py b/rest_api/rest_api_server/handlers/v2/employee_emails.py new file mode 100644 index 000000000..ed31349bb --- /dev/null +++ b/rest_api/rest_api_server/handlers/v2/employee_emails.py @@ -0,0 +1,179 @@ +import json +from rest_api.rest_api_server.controllers.employee_email import ( + EmployeeEmailAsyncController +) +from rest_api.rest_api_server.handlers.v1.base_async import ( + BaseAsyncCollectionHandler +) +from rest_api.rest_api_server.handlers.v1.base import BaseAuthHandler +from rest_api.rest_api_server.handlers.v2.base import BaseHandler +from rest_api.rest_api_server.utils import ( + check_list_attribute, ModelEncoder, raise_unexpected_exception, run_task +) +from tools.optscale_exceptions.common_exc import WrongArgumentsException +from tools.optscale_exceptions.http_exc import OptHTTPError + + +class EmployeeEmailsAsyncCollectionHandler(BaseAsyncCollectionHandler, + BaseAuthHandler, BaseHandler): + def _get_controller_class(self): + return EmployeeEmailAsyncController + + async def get(self, employee_id): + """ + --- + description: | + Gets a list of employee emails. + Required permission: INFO_ORGANIZATION or CLUSTER_SECRET + tags: [employee_emails] + summary: List of employee emails + parameters: + - name: employee_id + in: path + description: Id of employee + required: true + type: string + - name: email_template + in: query + description: Name of email template + required: false + type: string + responses: + 200: + description: Employee emails list + schema: + type: object + properties: + employee_emails: + type: array + items: + type: object + properties: + id: + type: string + description: Employee email id + deleted_at: + type: string + description: | + Deleted timestamp (service field) + employee_id: + type: string + description: Employee id + enabled: + type: boolean + description: Is email sending enabled + email_template: + type: string + description: Email template name + 401: + description: | + Unauthorized: + - OE0235: Unauthorized + - OE0237: This resource requires authorization + 403: + description: | + Forbidden: + - OE0236: Bad secret + 404: + description: | + Not found: + - OE0002: Employee not found + security: + - token: [] + - secret: [] + """ + if not self.check_cluster_secret(raises=False): + await self.check_permissions( + 'INFO_ORGANIZATION', 'employee', employee_id) + email_template = self.get_arg('email_template', str, None) + res = await run_task(self.controller.list, employee_id, + email_template=email_template) + self.write(json.dumps(res, cls=ModelEncoder)) + + def post(self, *args, **kwargs): + self.raise405() + + +class EmployeeEmailsBulkAsyncCollectionHandler(BaseAsyncCollectionHandler, + BaseAuthHandler, BaseHandler): + def _get_controller_class(self): + return EmployeeEmailAsyncController + + def get(self, *args, **kwargs): + self.raise405() + + def _validate_params(self, **kwargs): + allowed_params = ['enable', 'disable'] + try: + args_unexpected = list(filter( + lambda x: x not in allowed_params, + kwargs.keys())) + if args_unexpected: + raise_unexpected_exception(args_unexpected) + for param in allowed_params: + if param in kwargs and kwargs[param]: + check_list_attribute(param, kwargs[param]) + except WrongArgumentsException as ex: + raise OptHTTPError.from_opt_exception(400, ex) + + async def post(self, employee_id): + """ + --- + description: | + Bulk update employee emails + Required permission: INFO_ORGANIZATION + tags: [employee_emails] + summary: Bulk update employee emails + parameters: + - name: employee_id + in: path + description: Employee id + required: true + type: string + - in: body + name: body + description: Ids of employee emails to enable/disable + required: true + schema: + type: object + required: true + properties: + enable: + type: array + description: list of employee emails ids to enable + items: + type: string + description: employee email id + disable: + type: array + description: list of employee emails ids to disable + items: + type: string + description: employee email id + responses: + 200: + description: Employee emails data + 400: + description: | + Wrong arguments: + - OE0385: Argument should be a list + - OE0212: Unexpected parameters + 401: + description: | + Unauthorized: + - OE0237: This resource requires authorization + 404: + description: | + Not found: + - OE0002: Employee not found + security: + - token: [] + """ + await self.check_permissions( + 'INFO_ORGANIZATION', 'employee', employee_id) + data = self._request_body() + self._validate_params(**data) + res = await run_task( + self.controller.bulk_update, employee_id, **data + ) + self.write(json.dumps(res, cls=ModelEncoder)) diff --git a/rest_api/rest_api_server/models/models.py b/rest_api/rest_api_server/models/models.py index 113968505..06b789d10 100644 --- a/rest_api/rest_api_server/models/models.py +++ b/rest_api/rest_api_server/models/models.py @@ -1700,3 +1700,30 @@ def _validate(self, key, value): @hybrid_property def deleted(self): return false() + + +class EmployeeEmail(Base, ValidatorMixin, MutableMixin): + __tablename__ = 'employee_email' + id = Column(NullableUuid('id'), primary_key=True, default=gen_id, + info=ColumnPermissions.create_only) + employee_id = Column(Uuid('employee_id'), + ForeignKey('employee.id'), + info=ColumnPermissions.create_only, + nullable=False) + employee = relationship("Employee", foreign_keys=[employee_id]) + email_template = Column(MediumLargeNullableString("email_template"), + nullable=False, info=ColumnPermissions.create_only) + enabled = Column(NullableBool('enabled'), nullable=False, default=True, + info=ColumnPermissions.full) + + __table_args__ = ( + UniqueConstraint("employee_id", "email_template", "deleted_at", + name="uc_employee_email_template"),) + + @hybrid_property + def unique_fields(self): + return ["employee_id", "email_template"] + + @validates("employee_id", "enabled", "email_template") + def _validate(self, key, value): + return self.get_validator(key, value) diff --git a/rest_api/rest_api_server/server.py b/rest_api/rest_api_server/server.py index 36a179ad2..b60174c48 100644 --- a/rest_api/rest_api_server/server.py +++ b/rest_api/rest_api_server/server.py @@ -540,6 +540,12 @@ def get_handlers(handler_kwargs, version=None): (urls_v2.verify_email, h_v2.verify_emails.VerifyEmailAsyncCollectionHandler, handler_kwargs), + (urls_v2.employee_emails_collection, + h_v2.employee_emails.EmployeeEmailsAsyncCollectionHandler, + handler_kwargs), + (urls_v2.employee_emails_bulk, + h_v2.employee_emails.EmployeeEmailsBulkAsyncCollectionHandler, + handler_kwargs), *profiling_urls, ]) return result diff --git a/rest_api/rest_api_server/tests/unittests/test_employee_email.py b/rest_api/rest_api_server/tests/unittests/test_employee_email.py new file mode 100644 index 000000000..46243e2d6 --- /dev/null +++ b/rest_api/rest_api_server/tests/unittests/test_employee_email.py @@ -0,0 +1,173 @@ +import uuid +from unittest.mock import patch +from rest_api.rest_api_server.controllers.employee_email import ROLE_TEMPLATES +from rest_api.rest_api_server.tests.unittests.test_api_base import TestApiBase + + +class TestOrganizationApi(TestApiBase): + + def setUp(self, version='v2'): + super().setUp(version) + _, self.org = self.client.organization_create( + {'name': "organization"}) + self.org_id = self.org['id'] + self.user_id = self.gen_id() + self._mock_auth_user(self.user_id) + _, self.employee = self.client.employee_create( + self.org_id, {'name': 'name1', 'auth_user_id': self.user_id}) + patch('rest_api.rest_api_server.controllers.employee_email.' + 'EmployeeEmailController.auth_client').start() + patch('rest_api.rest_api_server.controllers.employee_email.' + 'EmployeeEmailController.auth_client.user_roles_get', + return_value=( + 200, [{'user_id': self.employee['auth_user_id'], + 'role_purpose': 'optscale_engineer'}])).start() + self.valid_params = { + 'employee_id': self.employee['id'], + 'email_template': 'saving_spike', + 'enabled': True + } + + def test_get_employee_emails(self): + code, employee = self.client.employee_create( + self.org_id, {'name': 'name1', 'auth_user_id': str(uuid.uuid4())}) + self.assertEqual(code, 201) + patch('rest_api.rest_api_server.controllers.employee_email.' + 'EmployeeEmailController.auth_client.user_roles_get', + return_value=( + 200, [{'user_id': employee['auth_user_id'], + 'role_purpose': 'optscale_member'}])).start() + emails_num = len(set(t for t_list in ROLE_TEMPLATES.values() + for t in t_list)) + code, employee_emails = self.client.employee_emails_get( + employee['id']) + self.assertEqual(code, 200) + self.assertEqual(len(employee_emails['employee_emails']), emails_num) + self.assertEqual(len([x for x in employee_emails['employee_emails'] + if x['available_by_role']]), + len(ROLE_TEMPLATES['optscale_member'])) + + # employee is manager + patch('rest_api.rest_api_server.controllers.employee_email.' + 'EmployeeEmailController.auth_client.user_roles_get', + return_value=( + 200, [{'user_id': employee['auth_user_id'], + 'role_purpose': 'optscale_manager'}])).start() + code, employee_emails = self.client.employee_emails_get( + employee['id']) + self.assertEqual(code, 200) + self.assertEqual(len(employee_emails['employee_emails']), emails_num) + self.assertEqual( + len([x for x in employee_emails['employee_emails'] + if x['available_by_role']]), + len(ROLE_TEMPLATES['optscale_manager'] + ROLE_TEMPLATES[ + 'optscale_member'])) + + # employee is engineer + patch('rest_api.rest_api_server.controllers.employee_email.' + 'EmployeeEmailController.auth_client.user_roles_get', + return_value=( + 200, [{'user_id': employee['auth_user_id'], + 'role_purpose': 'optscale_engineer'}])).start() + code, employee_emails = self.client.employee_emails_get( + employee['id']) + self.assertEqual(code, 200) + self.assertEqual(len(employee_emails['employee_emails']), emails_num) + self.assertEqual( + len([x for x in employee_emails['employee_emails'] + if x['available_by_role']]), + len(ROLE_TEMPLATES['optscale_engineer'] + ROLE_TEMPLATES[ + 'optscale_member'])) + + def test_employee_email_get_by_email_template(self): + code, employee_emails = self.client.employee_emails_get( + self.employee['id'], email_template='saving_spike') + self.assertEqual(code, 200) + self.assertEqual(len(employee_emails['employee_emails']), 1) + self.assertEqual( + employee_emails['employee_emails'][0]['email_template'], + 'saving_spike' + ) + + def test_employee_email_get_invalid_employee(self): + code, resp = self.client.employee_emails_get('employee_id') + self.assertEqual(code, 404) + self.assertEqual(resp['error']['error_code'], 'OE0002') + + def test_employee_email_bulk(self): + _, resp = self.client.employee_emails_get(self.employee['id']) + employee_email1 = resp['employee_emails'][0]['id'] + employee_email2 = resp['employee_emails'][1]['id'] + params = { + 'enable': [employee_email1], + 'disable': [employee_email2] + } + code, resp = self.client.employee_emails_bulk( + self.employee['id'], params) + self.assertEqual(code, 200) + emp_email1 = list(filter(lambda x: x['id'] == employee_email1, + resp['employee_emails'])) + self.assertEqual(emp_email1[0]['enabled'], True) + emp_email2 = list(filter(lambda x: x['id'] == employee_email2, + resp['employee_emails'])) + self.assertEqual(emp_email2[0]['enabled'], False) + + def test_employee_email_bulk_invalid_id(self): + params = { + 'enable': ['test'] + } + code, resp = self.client.employee_emails_bulk( + self.employee['id'], params) + self.assertEqual(code, 200) + + def test_employee_email_bulk_empty(self): + _, resp = self.client.employee_emails_get(self.employee['id']) + code, resp = self.client.employee_emails_bulk( + self.employee['id'], {}) + self.assertEqual(code, 200) + emails_num = len(set(t for t_list in ROLE_TEMPLATES.values() + for t in t_list)) + self.assertEqual(len(resp['employee_emails']), emails_num) + + def test_employee_email_bulk_unexpected(self): + _, resp = self.client.employee_emails_get(self.employee['id']) + code, resp = self.client.employee_emails_bulk( + self.employee['id'], {'unexpected': 'param'}) + self.assertEqual(code, 400) + self.assertEqual(resp['error']['error_code'], 'OE0212') + + def test_employee_email_bulk_invalid_params(self): + for param in ['enable', 'disable']: + for value in ['test', 123, {'test': 123}]: + code, resp = self.client.employee_emails_bulk( + self.employee['id'], {param: value}) + self.assertEqual(code, 400) + self.assertEqual(resp['error']['error_code'], 'OE0385') + + def test_employee_email_bulk_invalid_employee(self): + code, resp = self.client.employee_emails_bulk('employee_id', + {'enable': ['test']}) + self.assertEqual(code, 404) + self.assertEqual(resp['error']['error_code'], 'OE0002') + + def test_employee_email_not_allowed(self): + url = self.client.employee_emails_url(self.employee['id']) + code, _ = self.client.patch(url, {}) + self.assertEqual(code, 405) + + code, _ = self.client.post(url, {}) + self.assertEqual(code, 405) + + code, _ = self.client.delete(url, {}) + self.assertEqual(code, 405) + + def test_employee_email_bulk_not_allowed(self): + url = self.client.employee_emails_bulk_url(self.employee['id']) + code, _ = self.client.patch(url, {}) + self.assertEqual(code, 405) + + code, _ = self.client.get(url, {}) + self.assertEqual(code, 405) + + code, _ = self.client.delete(url, {}) + self.assertEqual(code, 405) diff --git a/rest_api/rest_api_server/tests/unittests/test_pools.py b/rest_api/rest_api_server/tests/unittests/test_pools.py index f8de1ac7e..30de01886 100644 --- a/rest_api/rest_api_server/tests/unittests/test_pools.py +++ b/rest_api/rest_api_server/tests/unittests/test_pools.py @@ -868,8 +868,9 @@ def test_delete_pool_reassign_cleanup(self): resource = self._create_resource( cloud_account['id'], employee2['id'], child_pool['id']) self._mock_auth_user(user2_id) - patch('rest_api.rest_api_server.controllers.assignment.AssignmentController.' - '_authorize_action_for_pool', return_value=True).start() + patch('rest_api.rest_api_server.controllers.assignment.' + 'AssignmentController._authorize_action_for_pool', + return_value=True).start() code, request = self.client.assignment_request_create(self.org_id, { 'resource_id': resource['id'], 'approver_id': employee1['id'], @@ -895,20 +896,27 @@ def test_delete_pool_reassign_cleanup(self): 'role_name': 'Manager', 'role_scope': None}] patch( - 'rest_api.rest_api_server.controllers.invite.InviteController.get_invite_expiration_days', + 'rest_api.rest_api_server.controllers.invite.' + 'InviteController.get_invite_expiration_days', return_value=30).start() patch( - 'rest_api.rest_api_server.controllers.invite.InviteController.check_user_exists', + 'rest_api.rest_api_server.controllers.invite.' + 'InviteController.check_user_exists', return_value=(True, {})).start() patch( - 'rest_api.rest_api_server.controllers.invite.InviteController.get_user_auth_assignments', + 'rest_api.rest_api_server.controllers.invite.' + 'InviteController.get_user_auth_assignments', return_value=user_assignments).start() patch( - 'rest_api.rest_api_server.handlers.v1.base.BaseAuthHandler._get_user_info', + 'rest_api.rest_api_server.handlers.v1.base.' + 'BaseAuthHandler._get_user_info', return_value={ 'display_name': 'default', 'email': 'email@email.com' }).start() + patch('rest_api.rest_api_server.controllers.invite.' + 'InviteController.check_user_exists', + return_value=(False, {})).start() code, invites = self.client.invite_create({ 'invites': { 'some@email.com': [ @@ -927,7 +935,8 @@ def test_delete_pool_reassign_cleanup(self): _, ar = self.client.assignment_request_list(self.org_id) self.assertEqual(len(ar['assignment_requests']['outgoing']), 0) patch( - 'rest_api.rest_api_server.handlers.v1.base.BaseAuthHandler._get_user_info', + 'rest_api.rest_api_server.handlers.v1.base.' + 'BaseAuthHandler._get_user_info', return_value={ 'display_name': 'default', 'email': 'some@email.com'