diff --git a/requirements-dev.txt b/requirements-dev.txt index d5956aa4..d8c7ca8c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -155,6 +155,8 @@ requests==2.31.0 # via locust rich==13.7.1 # via textual +roundrobin==0.0.4 + # via locust ruff==0.4.4 setuptools==69.5.1 # via diff --git a/src/nsls2api/api/v1/admin_api.py b/src/nsls2api/api/v1/admin_api.py index 4b4ffa3a..2ccf84af 100644 --- a/src/nsls2api/api/v1/admin_api.py +++ b/src/nsls2api/api/v1/admin_api.py @@ -1,15 +1,17 @@ from typing import Annotated import fastapi -from fastapi import Depends, HTTPException, Request +from fastapi import Depends, HTTPException from nsls2api.infrastructure import config +from nsls2api.infrastructure.logging import logger from nsls2api.infrastructure.security import ( validate_admin_role, generate_api_key, ) from nsls2api.models.apikeys import ApiUser -from nsls2api.services import background_service +from nsls2api.models.slack_models import SlackChannelCreationResponseModel +from nsls2api.services import beamline_service, proposal_service, slack_service # router = fastapi.APIRouter() router = fastapi.APIRouter( @@ -50,3 +52,79 @@ async def generate_user_apikey(username: str): return await generate_api_key(username) +@router.post("/admin/slack/create-proposal-channel/{proposal_id}") +async def create_slack_channel(proposal_id: str) -> SlackChannelCreationResponseModel: + proposal = await proposal_service.proposal_by_id(int(proposal_id)) + + if proposal is None: + return fastapi.responses.JSONResponse( + {"error": f"Proposal {proposal_id} not found"}, status_code=404 + ) + + channel_name = proposal_service.slack_channel_name_for_proposal(proposal_id) + + if channel_name is None: + return fastapi.responses.JSONResponse( + {"error": f"Slack channel name cannot be generated for proposal {proposal_id}"}, + status_code=404, + ) + + channel_id = await slack_service.create_channel( + channel_name, + is_private=True, + ) + + if channel_id is None: + return fastapi.responses.JSONResponse( + {"error": f"Slack channel creation failed for proposal {proposal_id}"}, + status_code=500, + ) + + logger.info(f"Created slack channel '{channel_name}' for proposal {proposal_id}.") + + # Store the created slack channel ID + proposal.slack_channel_id = channel_id + await proposal.save() + + # Add the beamline slack channel managers to the channel + slack_managers_added = [] + for beamline in proposal.instruments: + slack_managers = await beamline_service.slack_channel_managers(beamline) + logger.info( + f"Adding Slack channel managers for {beamline} beamline [{slack_managers}]." + ) + if len(slack_managers) > 0: + slack_service.add_users_to_channel( + channel_id=channel_id, user_ids=slack_managers + ) + slack_managers_added.append(slack_managers) + + # Add the users on the proposal to the channel + proposal_user_ids = [] + for user in proposal.users: + # If username is populated then user has an account + if user.username is not None: + user_slack_id = slack_service.lookup_userid_by_email(user.email) + if user_slack_id is None: + logger.info(f"User {user.username} does not have a slack_id") + else: + logger.info( + f"Adding user {user.username} ({user_slack_id}) to slack channel..." + ) + proposal_user_ids.append(user_slack_id) + + logger.info( + f"Slack users {proposal_user_ids} will be added to the proposal channel" + ) + + # TODO: Uncomment to actually add the users when we are sure!! + # slack_service.add_users_to_channel(channel_id=channel_id, user_ids=proposal_user_ids) + + response_model = SlackChannelCreationResponseModel( + channel_id=channel_id, + channel_name=channel_name, + beamline_slack_managers=slack_managers_added, + user_ids=proposal_user_ids, + ) + + return response_model diff --git a/src/nsls2api/api/v1/beamline_api.py b/src/nsls2api/api/v1/beamline_api.py index 8f5e31e5..3da02a0f 100644 --- a/src/nsls2api/api/v1/beamline_api.py +++ b/src/nsls2api/api/v1/beamline_api.py @@ -35,6 +35,14 @@ async def get_beamline_accounts(name: str, api_key: APIKey = Depends(get_current ) return service_accounts +@router.get("/beamline/{name}/slack-channel-managers") +async def get_beamline_slack_channel_managers(name: str, api_key: APIKey = Depends(get_current_user)): + slack_channel_managers = await beamline_service.slack_channel_managers(name) + if slack_channel_managers is None: + raise HTTPException( + status_code=404, detail=f"Beamline named {name} could not be found" + ) + return slack_channel_managers @router.get( "/beamline/{name}/detectors", response_model=DetectorList, include_in_schema=True diff --git a/src/nsls2api/api/v1/proposal_api.py b/src/nsls2api/api/v1/proposal_api.py index 0c962dd7..159fc15d 100644 --- a/src/nsls2api/api/v1/proposal_api.py +++ b/src/nsls2api/api/v1/proposal_api.py @@ -1,6 +1,6 @@ -from typing import Annotated, Optional +from typing import Annotated import fastapi -from fastapi import Depends, Query, Request +from fastapi import Depends, Query from nsls2api.api.models.proposal_model import ( CommissioningProposalsList, @@ -223,4 +223,3 @@ async def get_proposal_directories(proposal_id: int) -> ProposalDirectoriesList: directory_count=len(directories), ) return response_model - diff --git a/src/nsls2api/infrastructure/config.py b/src/nsls2api/infrastructure/config.py index 1b167bee..59c443d5 100644 --- a/src/nsls2api/infrastructure/config.py +++ b/src/nsls2api/infrastructure/config.py @@ -52,6 +52,12 @@ class Settings(BaseSettings): use_socks_proxy: bool = False socks_proxy: str + # Slack settings + slack_bot_token: str + superadmin_slack_user_token: str + slack_signing_secret: str + nsls2_workspace_team_id: str + model_config = SettingsConfigDict( env_file=str(Path(__file__).parent.parent / ".env") ) diff --git a/src/nsls2api/models/beamlines.py b/src/nsls2api/models/beamlines.py index 764ce6c8..9b57f5dd 100644 --- a/src/nsls2api/models/beamlines.py +++ b/src/nsls2api/models/beamlines.py @@ -113,6 +113,13 @@ class Settings: projection = {"data_root": "$custom_root_directory"} +class SlackChannelManagersView(pydantic.BaseModel): + slack_channel_managers: list[str] | None = [] + + class Settings: + projection = {"slack_channel_managers": "$slack_channel_managers"} + + class EndStation(pydantic.BaseModel): name: str service_accounts: Optional[ServiceAccounts] = None @@ -128,11 +135,12 @@ class Beamline(beanie.Document): pass_id: Optional[str] nsls2_redhat_satellite_location_name: Optional[str] service_accounts: ServiceAccounts | None = None - endstations: Optional[list[EndStation]] - data_admins: Optional[list[str]] + endstations: Optional[list[EndStation]] = [] + slack_channel_managers: Optional[list[str]] = [] + data_admins: Optional[list[str]] = [] custom_data_admin_group: Optional[str] = None - github_org: Optional[str] - ups_id: Optional[str] + github_org: Optional[str] = None + ups_id: Optional[str] = None data_root: Optional[str] = None services: Optional[list[BeamlineService]] = [] detectors: Optional[list[Detector]] = [] diff --git a/src/nsls2api/models/proposals.py b/src/nsls2api/models/proposals.py index 0387ec38..679ce39b 100644 --- a/src/nsls2api/models/proposals.py +++ b/src/nsls2api/models/proposals.py @@ -31,6 +31,7 @@ class Proposal(beanie.Document): cycles: Optional[list[str]] = [] users: Optional[list[User]] = [] safs: Optional[list[SafetyForm]] = [] + slack_channel_id: Optional[str] = None created_on: datetime.datetime = pydantic.Field( default_factory=datetime.datetime.now ) diff --git a/src/nsls2api/models/slack_models.py b/src/nsls2api/models/slack_models.py new file mode 100644 index 00000000..549e2c9f --- /dev/null +++ b/src/nsls2api/models/slack_models.py @@ -0,0 +1,21 @@ +import pydantic + + +class SlackBot(pydantic.BaseModel): + username: str + user_id: str + bot_id: str + + +class SlackUser(pydantic.BaseModel): + user_id: str + username: str + email: str + + +class SlackChannelCreationResponseModel(pydantic.BaseModel): + channel_id: str + channel_name: str + beamline_slack_managers: list[str] | None = [] + user_ids: list[str] | None = [] + message: str | None = None diff --git a/src/nsls2api/services/beamline_service.py b/src/nsls2api/services/beamline_service.py index 705bd01e..71b6ba18 100644 --- a/src/nsls2api/services/beamline_service.py +++ b/src/nsls2api/services/beamline_service.py @@ -15,6 +15,7 @@ ServicesOnly, ServiceAccounts, ServiceAccountsView, + SlackChannelManagersView, WorkflowServiceAccountView, IOCServiceAccountView, EpicsServicesServiceAccountView, @@ -23,6 +24,7 @@ DataRootDirectoryView, LsdcServiceAccountView, ) +from nsls2api.services import slack_service async def beamline_count() -> int: @@ -352,3 +354,31 @@ async def uses_synchweb(name: str) -> bool: return True else: return False + + +async def slack_channel_managers(beamline_name: str) -> list[str]: + """ + Retrieves the Slack user IDs of the channel managers for a given beamline. + + Args: + beamline_name (str): The name of the beamline. + + Returns: + list[str]: A list of Slack user IDs of the channel managers. + + """ + beamline = await Beamline.find_one(Beamline.name == beamline_name.upper()).project( + SlackChannelManagersView + ) + if beamline is None: + return None + + slack_ids = [] + for user in beamline.slack_channel_managers: + # Staff have to have a BNL email account + email = f"{user}@bnl.gov" + user_id = slack_service.lookup_userid_by_email(email=email) + if user_id: + slack_ids.append(user_id) + + return slack_ids diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index 9a13cf0f..a69ecfde 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -85,6 +85,9 @@ async def fetch_data_sessions_for_username(username: str) -> list[str]: def generate_data_session_for_proposal(proposal_id: int) -> str: return f"pass-{str(proposal_id)}" +def slack_channel_name_for_proposal(proposal_id: str) -> str: + #TODO: Actually make this configurable and more sensible + return f"test-sic-{str(proposal_id)}" async def proposal_by_id(proposal_id: int) -> Optional[Proposal]: """ diff --git a/src/nsls2api/services/slack_service.py b/src/nsls2api/services/slack_service.py new file mode 100644 index 00000000..6b9a067b --- /dev/null +++ b/src/nsls2api/services/slack_service.py @@ -0,0 +1,269 @@ +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from nsls2api.infrastructure.config import get_settings +from nsls2api.infrastructure.logging import logger +from nsls2api.models.slack_models import SlackBot + +settings = get_settings() +app = App(token=settings.slack_bot_token, signing_secret=settings.slack_signing_secret) +super_app = App( + token=settings.superadmin_slack_user_token, + signing_secret=settings.slack_signing_secret, +) + + +def get_bot_details() -> SlackBot: + """ + Retrieves the details of the Slack bot. + + Returns: + SlackBot: An instance of the SlackBot class containing the bot details. + """ + response = app.client.auth_test() + + return SlackBot( + username=response.data["user"], + user_id=response.data["user_id"], + bot_id=response.data["bot_id"], + ) + + +def get_channel_members(channel_id: str) -> list[str]: + """ + Retrieves the members of a Slack channel. + It assumes that the bot is already a member of the + channel (if it is a private channel) + + Args: + channel_id (str): The ID of the Slack channel. + + Returns: + list[str]: A list of member IDs in the channel. + """ + try: + response = app.client.conversations_members(channel=channel_id) + except SlackApiError as error: + logger.exception(error) + return [] + return response.data["members"] + + +def add_bot_to_channel(channel_id: str): + """ + Adds the bot to the specified channel. + + Args: + channel_id (str): The ID of the channel to add the bot to. + + Raises: + Exception: If an error occurs while adding the bot to the channel. + + Returns: + None + """ + bot = get_bot_details() + client = WebClient(token=settings.superadmin_slack_user_token) + logger.info(f"Inviting {bot.username} ({bot.user_id}) to channel {channel_id}.") + try: + response = client.admin_conversations_invite( + channel_id=channel_id, user_ids=[bot.user_id] + ) + logger.info(response) + except SlackApiError as error: + if error.response["error"] == "failed_for_some_users": + channel_members = get_channel_members(channel_id) + if "failed_user_ids" in error.response: + if (bot.user_id in error.response["failed_user_ids"]) and ( + bot.user_id in channel_members + ): + logger.info(f"{bot.username} is already in channel {channel_id}.") + else: + logger.error( + f"Failed to add bot {bot.username} to channel {channel_id}." + ) + else: + logger.exception(error) + raise Exception(error) from error + + +async def is_channel_private(channel_id: str) -> bool: + """ + Checks if a Slack channel is private. + + Args: + channel_id (str): The ID of the channel to check. + + Returns: + bool: True if the channel is private, False otherwise. + """ + response = await app.client.conversations_info(channel=channel_id) + return response.data["channel"]["is_private"] + + +async def create_channel( + name: str, + is_private: bool = True, +) -> str | None: + """ + Creates a new Slack channel with the given name and privacy settings. If the channel + already exists, it will convert the channel to the desired privacy setting and invite + the necessary bot user to the channel. + + Args: + name (str): The name of the channel to be created. + is_private (bool, optional): Whether the channel should be private. Defaults to True. + + Returns: + str | None: The ID of the created channel if successful, None otherwise. + """ + super_client = WebClient(token=settings.superadmin_slack_user_token) + + # Does the channel already exist? + channel_id = channel_id_from_name(name) + + if channel_id: + logger.info(f"Found existing channel called {name}.") + if is_private: + if await is_channel_private(channel_id): + logger.info("Channel is already private.") + else: + try: + logger.info(f"Trying to convert channel {name} to private.") + response = super_client.admin_conversations_convertToPrivate( + channel_id=channel_id + ) + logger.info( + f"Response from converting channel to private: {response}" + ) + except SlackApiError as e: + logger.exception(f"Error converting channel to private: {e}") + return None + else: + if await is_channel_private(channel_id): + try: + logger.info(f"Trying to convert channel {name} to public.") + response = super_client.admin_conversations_convertToPublic( + channel_id=channel_id + ) + logger.info( + f"Response from converting channel to public: {response}" + ) + except SlackApiError as e: + logger.exception(f"Error converting channel to public: {e}") + return None + + else: + try: + logger.info(f"Trying to create channel called '{name}'.") + response = super_client.admin_conversations_create( + name=name, + is_private=is_private, + team_id=settings.nsls2_workspace_team_id, + ) + logger.info(f"Response from creating slack channel: {response}") + + except SlackApiError as e: + logger.exception(f"Error creating channel: {e}") + return None + + # Now lets add our 'bot' to the channel + add_bot_to_channel(channel_id) + + return channel_id + + +def channel_id_from_name(name: str) -> str | None: + """ + Retrieves the channel ID for a given channel name. + + Args: + name (str): The name of the channel. + + Returns: + str | None: The ID of the channel if found, None otherwise. + """ + client = WebClient(token=settings.superadmin_slack_user_token) + response = client.admin_conversations_search(query=name) + # This returns a list of channels, find the one with the exact name (just in case we get more than one returned) + for channel in response["conversations"]: + if channel["name"] == name: + return channel["id"] + + +def rename_channel(name: str, new_name: str) -> str | None: + """ + Renames a Slack channel. + + Args: + name (str): The current name of the channel. + new_name (str): The new name for the channel. + + Returns: + str | None: The ID of the renamed channel, or None if the channel was not found. + + Raises: + Exception: If the channel with the given name is not found. + Exception: If the channel renaming fails. + """ + channel_id = channel_id_from_name(name) + if channel_id is None: + raise Exception(f"Channel {name} not found.") + + response = app.client.conversations_rename(channel=channel_id, name=new_name) + + if response.data["ok"] is not True: + raise Exception(f"Failed to rename channel {name} to {new_name}") + + +def lookup_userid_by_email(email: str) -> str | None: + """ + Looks up the user ID associated with the given email address. + + Args: + email (str): The email address of the user. + + Returns: + str | None: The user ID if found, None otherwise. + """ + response = app.client.users_lookupByEmail(email=email) + if response.data["ok"] is True: + return response.data["user"]["id"] + + +def lookup_username_by_email(email: str) -> str | None: + """ + Looks up the username associated with the given email address. + + Args: + email (str): The email address to look up. + + Returns: + str | None: The username associated with the email address, or None if not found. + """ + response = app.client.users_lookupByEmail(email=email) + if response.data["ok"] is True: + return response.data["user"]["name"] + + +def add_users_to_channel(channel_id: str, user_ids: list[str]): + try: + userlist = ",".join(user_ids) + app.client.conversations_invite(channel=channel_id, users=userlist) + except SlackApiError as error: + if error.response["error"] == "failed_for_some_users": + channel_members = get_channel_members(channel_id) + if "failed_user_ids" in error.response: + for failed_user_id in error.response["failed_user_ids"]: + if failed_user_id in channel_members: + logger.info( + f"{failed_user_id} is already in channel {channel_id}." + ) + else: + logger.error( + f"Failed to add user {failed_user_id} to channel {channel_id}." + ) + else: + logger.exception(error) + raise Exception(error) from error