diff --git a/slacker/slacker_server/controllers/send_message.py b/slacker/slacker_server/controllers/send_message.py index 8f487e4f3..6b8caebe4 100644 --- a/slacker/slacker_server/controllers/send_message.py +++ b/slacker/slacker_server/controllers/send_message.py @@ -13,7 +13,8 @@ from slacker.slacker_server.message_templates.env_alerts import ( get_property_updated_message, get_message_changed_active_state, get_message_acquired, get_message_released) -from slacker.slacker_server.message_templates.warnings import get_archived_message_block +from slacker.slacker_server.message_templates.warnings import ( + get_archived_message_block) LOG = logging.getLogger(__name__) @@ -39,16 +40,24 @@ def send_message(self, **kwargs): parameters = kwargs.get('parameters', {}) warning = parameters.pop('warning', None) warning_params = parameters.pop('warning_params', None) + teams_channels = set() if auth_user_id: - user = self.session.query(User).filter( + users = self.session.query(User).filter( User.auth_user_id == auth_user_id, User.deleted.is_(False), - ).one_or_none() - if not user: + ).all() + if not users: raise NotFoundException(Err.OS0016, ['auth_user_id', auth_user_id]) - team_id = user.slack_team_id - channel_id = user.slack_channel_id + for user in users: + teams_channels.add((user.slack_channel_id, user.slack_team_id)) + if team_id or channel_id: + teams_channels.add((channel_id, team_id)) + if channel_id and channel_id.startswith('C'): + # public or private channel, not direct message + channels = self.app.client.get_bot_conversations(team_id=team_id) + if channel_id not in [x['id'] for x in channels]: + raise NotFoundException(Err.OS0020, [channel_id]) template_func = self.MESSAGE_TEMPLATES.get(type_) if template_func is None: @@ -64,9 +73,11 @@ def send_message(self, **kwargs): **warning_params) + message['blocks'] try: - self.app.client.chat_post( - channel_id=channel_id, team_id=team_id, - **message) + for data in teams_channels: + channel_id, team_id = data + self.app.client.chat_post( + channel_id=channel_id, team_id=team_id, + **message) except TypeError as exc: LOG.error('Failed to send message: %s', exc) raise WrongArgumentsException(Err.OS0011, ['parameters']) diff --git a/slacker/slacker_server/controllers/slack.py b/slacker/slacker_server/controllers/slack.py index 4843c8601..6b6c33664 100644 --- a/slacker/slacker_server/controllers/slack.py +++ b/slacker/slacker_server/controllers/slack.py @@ -5,7 +5,6 @@ from datetime import datetime, timedelta from requests import HTTPError -from retrying import Retrying from slack_sdk.errors import SlackApiError from sqlalchemy.exc import IntegrityError @@ -16,7 +15,8 @@ get_add_constraint_envs_alert_modal) from slacker.slacker_server.message_templates.bookings import ( get_add_bookings_form, get_booking_details_message) -from slacker.slacker_server.message_templates.connect import get_welcome_message +from slacker.slacker_server.message_templates.connect import ( + get_welcome_message) from slacker.slacker_server.message_templates.constraints import ( get_update_ttl_form, get_constraint_updated) from slacker.slacker_server.message_templates.disconnect import ( @@ -24,45 +24,23 @@ from slacker.slacker_server.message_templates.envs import get_envs_message from slacker.slacker_server.message_templates.org import ( get_org_switch_message, get_org_switch_completed_message) -from slacker.slacker_server.message_templates.resources import get_resources_message +from slacker.slacker_server.message_templates.resources import ( + get_resources_message) from slacker.slacker_server.message_templates.resource_details import ( get_resource_details_message) from slacker.slacker_server.message_templates.errors import ( get_ca_not_connected_message, get_not_have_slack_permissions_message) from slacker.slacker_server.models.models import User -from slacker.slacker_server.utils import gen_id +from slacker.slacker_server.utils import gen_id, retry_too_many_requests from tools.optscale_time import utcfromtimestamp, utcnow_timestamp LOG = logging.getLogger(__name__) TTL_LIMIT_TO_SHOW = 72 EXPENSE_LIMIT_TO_SHOW = 0.9 -MS_IN_SEC = 1000 SEC_IN_HRS = 3600 MAX_MSG_ENVS_LENGTH = 10 -def retry_too_many_requests(f, *args, **kwargs): - try: - return f(*args, **kwargs) - except Exception as exc: - if retriable_slack_api_error(exc): - f_retry = Retrying( - retry_on_exception=retriable_slack_api_error, - wait_fixed=int(exc.response.headers['Retry-After']) * MS_IN_SEC, - stop_max_attempt_number=5) - res = f_retry.call(f, *args, **kwargs) - return res - else: - raise exc - - -def retriable_slack_api_error(exc): - if (isinstance(exc, SlackApiError) and - exc.response.headers.get('Retry-After')): - return True - return False - - class MetaSlackController: """ Using it to keep common logic between handler controllers and slack event @@ -326,7 +304,8 @@ def _check_expense_limit(expense_constr): def resource_details(self, ack, say, action, body, logger): slack_user_id = body['user']['id'] user = self.get_user(slack_user_id) - if user is None or user.auth_user_id is None or user.organization_id is None: + if (user is None or user.auth_user_id is None + or user.organization_id is None): ack() return target_resource_id = action['value'] @@ -336,20 +315,35 @@ def resource_details(self, ack, say, action, body, logger): _, resource = rest_cl.cloud_resource_get( target_resource_id, details=True) _, org = rest_cl.organization_get(user.organization_id) + _, response = rest_cl.resource_limit_hits_list(target_resource_id) + limit_hits = response.get('limit_hits', []) + tel_enabled = self.total_expense_limit_enabled(user.organization_id) constraint_types = ['ttl', 'daily_expense_limit'] if tel_enabled: constraint_types.append('total_expense_limit') + constraints = {} - for constraint in constraint_types: - if resource['details']['constraints'].get(constraint): - constraints[constraint] = resource['details']['constraints'][ - constraint] - constraints[constraint]['constraint_type'] = 'resource specific' - elif resource['details']['policies'].get(constraint, {}).get('active'): - constraints[constraint] = resource['details']['policies'][ - constraint] - constraints[constraint]['constraint_type'] = 'pool policy' + for constraint_type in constraint_types: + constraint = {} + last_hit = next((x for x in limit_hits + if x['type'] == constraint_type), None) + if constraint_type in resource['details']['constraints']: + constraint = resource['details']['constraints'][ + constraint_type] + constraint['constraint_type'] = 'resource specific' + if (last_hit and not last_hit['pool_id'] + and last_hit['state'] == 'red'): + constraint['last_hit'] = last_hit + elif resource['details']['policies'].get(constraint_type, {}).get( + 'active'): + constraint = resource['details']['policies'][constraint_type] + constraint['constraint_type'] = 'pool policy' + if (last_hit and last_hit['pool_id'] == resource['pool_id'] + and last_hit['state'] == 'red'): + constraint['last_hit'] = last_hit + constraints[constraint_type] = constraint + resource['constraints'] = constraints current_booking = None if resource['details'].get('shareable_bookings'): @@ -367,7 +361,8 @@ def resource_details(self, ack, say, action, body, logger): say(get_resource_details_message( resource=resource, org_id=user.organization_id, public_ip=self.config_cl.public_ip(), booking=current_booking, - currency=org['currency'], total_expense_limit_enabled=tel_enabled)) + currency=org['currency'], total_expense_limit_enabled=tel_enabled + )) def create_update_ttl_view(self, ack, action, client, body, say, logger): slack_user_id = body['user']['id'] diff --git a/slacker/slacker_server/exceptions.py b/slacker/slacker_server/exceptions.py index deddcdb50..d9297a82f 100644 --- a/slacker/slacker_server/exceptions.py +++ b/slacker/slacker_server/exceptions.py @@ -54,9 +54,10 @@ class Err(enum.Enum): ["channel_id and auth_user_id could not be provided at the same time"] ] OS0016 = [ - "User with %s %s were not found", + "User with %s %s was not found", ["auth_user_id", "02430e6b-6975-4535-8bc6-7a7b52938014"], - ["User with auth_user_id 02430e6b-6975-4535-8bc6-7a7b52938014 were not found"] + ["User with auth_user_id 02430e6b-6975-4535-8bc6-7a7b52938014 was not " + "found"] ] OS0017 = [ "%s should provide only with %s", @@ -71,3 +72,8 @@ class Err(enum.Enum): ['channel_id'], ['Target slack channel FFFFFFFFF is archived'] ] + OS0020 = [ + "Slack app is not added to channel %s", + ['channel_id'], + ['Slack app is not added to channel C000000000'] + ] diff --git a/slacker/slacker_server/handlers/v2/send_message.py b/slacker/slacker_server/handlers/v2/send_message.py index b88660194..d1c326952 100644 --- a/slacker/slacker_server/handlers/v2/send_message.py +++ b/slacker/slacker_server/handlers/v2/send_message.py @@ -1,6 +1,9 @@ from tools.optscale_exceptions.http_exc import OptHTTPError +from tools.optscale_exceptions.common_exc import NotFoundException -from slacker.slacker_server.controllers.send_message import SendMessageAsyncController +from slacker.slacker_server.controllers.send_message import ( + SendMessageAsyncController +) from slacker.slacker_server.exceptions import Err from slacker.slacker_server.handlers.v2.base import BaseHandler @@ -132,7 +135,7 @@ async def post(self, **kwargs): - OS0012: Duplicated parameters in path and body - OS0014: channel_id with team_id or auth_user_id should be provided - OS0015: channel_id and auth_user_id could not be provided at the same time - - OS0016: User not found + - OS0016: User with auth_user_id was not found - OS0017: channel_id should provide only with team_id - OS0019: Target slack channel is archived 401: @@ -150,7 +153,10 @@ async def post(self, **kwargs): data = self._request_body() data.update(kwargs) await self.validate_params(**data) - await self.controller.send_message(**data) + try: + await self.controller.send_message(**data) + except NotFoundException as exc: + raise OptHTTPError.from_opt_exception(404, exc) self.write_json({}) self.set_status(201) diff --git a/slacker/slacker_server/message_templates/resource_details.py b/slacker/slacker_server/message_templates/resource_details.py index 29d32bfde..4680b1496 100644 --- a/slacker/slacker_server/message_templates/resource_details.py +++ b/slacker/slacker_server/message_templates/resource_details.py @@ -54,16 +54,18 @@ def get_resource_details_block(resource, org_id, public_ip): def _get_expense_limit_msg(c_sign, total_cost, expense): expense_msg = "Not set" if expense: - if expense['limit'] < total_cost: - expense_msg = ":exclamation:*{0}{1}*".format( - c_sign, expense['limit']) - elif total_cost / expense['limit'] >= EXPENSE_LIMIT_TO_SHOW: - expense_msg = ":warning:{0}{1}".format( - c_sign, expense['limit']) - elif total_cost / expense['limit'] < EXPENSE_LIMIT_TO_SHOW: - expense_msg = "{0}{1}".format(c_sign, expense['limit']) + last_hit = expense.get('last_hit', {}) + if last_hit and last_hit['state'] == 'red': + if expense['limit'] < total_cost: + expense_msg = ":exclamation:*{0}{1}*".format( + c_sign, expense['limit']) + elif total_cost / expense['limit'] >= EXPENSE_LIMIT_TO_SHOW: + expense_msg = ":warning:{0}{1}".format( + c_sign, expense['limit']) elif expense['limit'] == 0: expense_msg = ":warning:No limit" + else: + expense_msg = "{0}{1}".format(c_sign, expense['limit']) return expense_msg @@ -79,21 +81,13 @@ def get_resource_details_message( total_cost = details.get('total_cost', 0) month_cost = details.get('cost', 0) tags = resource.get('tags', {}) + env_properties = resource.get('env_properties') constraint_types = ['ttl', 'daily_expense_limit'] if total_expense_limit_enabled: constraint_types.append('total_expense_limit') - constraints = {} - for constraint in constraint_types: - if details['constraints'].get(constraint): - constraints[constraint] = details['constraints'][constraint] - constraints[constraint]['constraint_type'] = '_(resource specific)_' - elif details['policies'].get(constraint, {}).get('active'): - constraints[constraint] = details['policies'][constraint] - constraints[constraint]['constraint_type'] = '_(pool policy)_' - else: - constraints[constraint] = {} + constraints = resource.get('constraints', {}) ttl = constraints.get('ttl') if ttl: hrs = (ttl['limit'] - utcnow_timestamp()) / SEC_IN_HRS @@ -113,11 +107,18 @@ def get_resource_details_message( else: ttl_msg = 'Not set' + ttl_constraint_type = constraints.get('ttl', {}).get('constraint_type', '') + if ttl_constraint_type: + ttl_constraint_type = f"_({ttl_constraint_type})_" + daily_expense = constraints.get('daily_expense_limit') daily_expense_msg = _get_expense_limit_msg(c_sign, total_cost, daily_expense) - daily_constaint_type = constraints['daily_expense_limit'].get( + + daily_constaint_type = constraints.get('daily_expense_limit', {}).get( 'constraint_type', '') + if daily_constaint_type: + daily_constaint_type = f"_({daily_constaint_type})_" header_blocks = [{ "type": "section", @@ -188,7 +189,7 @@ def get_resource_details_message( "text": { "type": "mrkdwn", "text": f"TTL\t\t\t\t\t\t\t{ttl_msg} " - f"{constraints['ttl'].get('constraint_type', '')}" + f"{ttl_constraint_type}" }, "accessory": { "type": "button", @@ -215,8 +216,10 @@ def get_resource_details_message( total_expense = constraints.get('total_expense_limit') total_expense_msg = _get_expense_limit_msg(c_sign, total_cost, total_expense) - total_constaint_type = constraints['total_expense_limit'].get( + total_constaint_type = constraints.get('total_expense_limit', {}).get( 'constraint_type', '') + if total_constaint_type: + total_constaint_type = f"_({total_constaint_type})_" resource_blocks.append( { "type": "section", @@ -276,6 +279,24 @@ def get_resource_details_message( } } ] + env_prop_block = [] + if env_properties: + env_prop_block = [{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\n*Environment properties:*" + } + }] + env_prop_block.extend( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"{k}: {v}" + } + } for k, v in env_properties.items() + ) footer_blocks = [{ "type": "divider" @@ -296,6 +317,6 @@ def get_resource_details_message( return { "text": "Here are the details of the resource you asked", "blocks": (header_blocks + tags_blocks + resource_blocks + - booking_blocks + footer_blocks), + env_prop_block + booking_blocks + footer_blocks), "unfurl_links": False } diff --git a/slacker/slacker_server/message_templates/resources.py b/slacker/slacker_server/message_templates/resources.py index 4ef76a65e..1903ec758 100644 --- a/slacker/slacker_server/message_templates/resources.py +++ b/slacker/slacker_server/message_templates/resources.py @@ -7,7 +7,7 @@ def get_resource_blocks(resource_data, public_ip, org_id, currency='USD'): c_sign = CURRENCY_MAP.get(currency, '') r_id = resource_data['resource_id'] - r_cid = resource_data['cloud_resource_id'] + r_cid = resource_data['cloud_resource_id'].split('/')[-1] short_id = r_id[:4] r_name = resource_data.get('resource_name', '') r_ttl_constr = resource_data.get('ttl') diff --git a/slacker/slacker_server/slack_client.py b/slacker/slacker_server/slack_client.py index 1d0eadc21..55c4f7466 100644 --- a/slacker/slacker_server/slack_client.py +++ b/slacker/slacker_server/slack_client.py @@ -1,6 +1,8 @@ import logging from slack_sdk.web.client import WebClient +from slacker.slacker_server.utils import retry_too_many_requests + LOG = logging.getLogger(__name__) @@ -9,8 +11,26 @@ def __init__(self, installation_store, **kwargs): self._installation_store = installation_store super().__init__(**kwargs) - def chat_post(self, *, channel_id=None, team_id=None, **kwargs): + def get_client(self, team_id=None): bot = self._installation_store.find_bot( team_id=team_id, enterprise_id=None) - client = WebClient(token=bot.bot_token) + return WebClient(token=bot.bot_token) + + def get_bot_conversations(self, team_id=None, exclude_archived=True, + types='public_channel, private_channel'): + client = self.get_client(team_id=team_id) + conversation_list = [] + cursor = '' + while True: + resp = retry_too_many_requests( + client.users_conversations, cursor=cursor, team_id=team_id, + types=types, limit=1000, exclude_archived=exclude_archived) + cursor = resp['response_metadata']['next_cursor'] + conversation_list.extend(resp['channels']) + if not cursor: + break + return conversation_list + + def chat_post(self, *, channel_id=None, team_id=None, **kwargs): + client = self.get_client(team_id=team_id) return client.chat_postMessage(channel=channel_id, **kwargs) diff --git a/slacker/slacker_server/utils.py b/slacker/slacker_server/utils.py index 0d850f4d0..9f65ddcdc 100644 --- a/slacker/slacker_server/utils.py +++ b/slacker/slacker_server/utils.py @@ -5,8 +5,11 @@ import json import logging import uuid +from retrying import Retrying +from slack_sdk.errors import SlackApiError +MS_IN_SEC = 1000 LOG = logging.getLogger(__name__) tp_executor = ThreadPoolExecutor(30) @@ -34,3 +37,26 @@ def default(self, obj): def gen_id(): return str(uuid.uuid4()) + + +def retriable_slack_api_error(exc): + if (isinstance(exc, SlackApiError) and + exc.response.headers.get('Retry-After')): + return True + return False + + +def retry_too_many_requests(f, *args, **kwargs): + try: + return f(*args, **kwargs) + except Exception as exc: + if retriable_slack_api_error(exc): + f_retry = Retrying( + retry_on_exception=retriable_slack_api_error, + wait_fixed=int( + exc.response.headers['Retry-After']) * MS_IN_SEC, + stop_max_attempt_number=5) + res = f_retry.call(f, *args, **kwargs) + return res + else: + raise exc