From c2ce4cb8ca79f4c649df6cae8bce1d5886006296 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Wed, 7 Feb 2024 12:37:33 -0500 Subject: [PATCH 01/26] Add new file slack_service.py --- src/nsls2api/services/slack_service.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/nsls2api/services/slack_service.py diff --git a/src/nsls2api/services/slack_service.py b/src/nsls2api/services/slack_service.py new file mode 100644 index 00000000..e69de29b From 54944497b53b7950e828ddb5829b90e5e5a89120 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Thu, 25 Apr 2024 09:53:33 -0400 Subject: [PATCH 02/26] Initial work on slack service --- requirements.in | 1 + src/nsls2api/infrastructure/config.py | 4 +++ src/nsls2api/services/slack_service.py | 38 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/requirements.in b/requirements.in index 4acf08fc..38e7a111 100644 --- a/requirements.in +++ b/requirements.in @@ -14,6 +14,7 @@ pydantic pydantic-settings python-multipart rich +slack_bolt textual typer uuid diff --git a/src/nsls2api/infrastructure/config.py b/src/nsls2api/infrastructure/config.py index 1b167bee..a5616482 100644 --- a/src/nsls2api/infrastructure/config.py +++ b/src/nsls2api/infrastructure/config.py @@ -52,6 +52,10 @@ class Settings(BaseSettings): use_socks_proxy: bool = False socks_proxy: str + # Slack settings + slack_bot_token: str + slack_signing_secret: str + model_config = SettingsConfigDict( env_file=str(Path(__file__).parent.parent / ".env") ) diff --git a/src/nsls2api/services/slack_service.py b/src/nsls2api/services/slack_service.py index e69de29b..b2996f47 100644 --- a/src/nsls2api/services/slack_service.py +++ b/src/nsls2api/services/slack_service.py @@ -0,0 +1,38 @@ +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 + +settings = get_settings() + + +def create_channel(name: str, is_private: bool = False) -> str | None: + client = WebClient(token=settings.slack_bot_token) + app = App( + token=settings.slack_bot_token, signing_secret=settings.slack_signing_secret + ) + try: + response = app.client.conversations_create(name=name, is_private=is_private) + return response["channel"]["id"] + except SlackApiError as e: + logger.error(f"Error creating channel: {e}") + return None + + +def find_channel(name: str) -> str | None: + client = WebClient(token=settings.slack_bot_token) + app = App( + token=settings.slack_bot_token, signing_secret=settings.slack_signing_secret + ) + try: + response = app.client.conversations_list() + for channel in response["channels"]: + print(channel) + if channel["name"] == name: + return channel["id"] + return None + except SlackApiError as e: + logger.error(f"Error finding channel: {e}") + return None From 8facbd8fab6f0e66bf4ad9f9346e52cb7fd3caf6 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Sat, 4 May 2024 13:32:43 -0400 Subject: [PATCH 03/26] Admin endpoint to create proposal slack channels --- src/nsls2api/api/v1/admin_api.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/nsls2api/api/v1/admin_api.py b/src/nsls2api/api/v1/admin_api.py index a845d465..7f236631 100644 --- a/src/nsls2api/api/v1/admin_api.py +++ b/src/nsls2api/api/v1/admin_api.py @@ -1,7 +1,7 @@ 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.security import ( @@ -9,7 +9,7 @@ generate_api_key, ) from nsls2api.models.apikeys import ApiUser -from nsls2api.services import background_service +from nsls2api.services import proposal_service, slack_service # router = fastapi.APIRouter() router = fastapi.APIRouter( @@ -24,7 +24,7 @@ async def info(settings: Annotated[config.Settings, Depends(config.get_settings) @router.get("/admin/validate", response_model=str) async def check_admin_validation( - admin_user: Annotated[ApiUser, Depends(validate_admin_role)] = None, + admin_user: Annotated[ApiUser, Depends(validate_admin_role)] = None, ): """ :return: str - The username of the validated admin user. @@ -50,3 +50,27 @@ 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): + proposal = 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 found for proposal {proposal_id}"}, + status_code=404, ) + + status = slack_service.create_channel(channel_name, True, + description=f"Discussion related to proposal {proposal_id}") + + if status is None: + return fastapi.responses.JSONResponse({"error": f"Slack channel creation failed"}, status_code=500) + + # Store the created slack channel ID + proposal.slack_channel_id = channel_name From fed534cf46dfcd80c8afdc810f16293f4b592b21 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Sat, 4 May 2024 13:33:17 -0400 Subject: [PATCH 04/26] Add additional parameters required for slack --- src/nsls2api/infrastructure/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/nsls2api/infrastructure/config.py b/src/nsls2api/infrastructure/config.py index a5616482..59c443d5 100644 --- a/src/nsls2api/infrastructure/config.py +++ b/src/nsls2api/infrastructure/config.py @@ -54,7 +54,9 @@ class Settings(BaseSettings): # 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") From 7c7e3afef77a9bb5ac2672c96a71f0b763c38926 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Sat, 4 May 2024 13:33:36 -0400 Subject: [PATCH 05/26] Method to return name of slack channel for a proposal --- src/nsls2api/services/proposal_service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index 2704db3f..ec18be49 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -94,6 +94,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]: """ From c0bae1f13c70a65b14a2410b32f108178391afe9 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Sat, 4 May 2024 13:33:50 -0400 Subject: [PATCH 06/26] Initial work on slack_service --- src/nsls2api/services/slack_service.py | 41 +++++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/nsls2api/services/slack_service.py b/src/nsls2api/services/slack_service.py index b2996f47..d8ec5b9d 100644 --- a/src/nsls2api/services/slack_service.py +++ b/src/nsls2api/services/slack_service.py @@ -6,20 +6,24 @@ from nsls2api.infrastructure.logging import logger settings = get_settings() +app = App(token=settings.slack_bot_token, signing_secret=settings.slack_signing_secret) -def create_channel(name: str, is_private: bool = False) -> str | None: - client = WebClient(token=settings.slack_bot_token) - app = App( - token=settings.slack_bot_token, signing_secret=settings.slack_signing_secret - ) +def create_channel(name: str, is_private: bool = False, description: str = None) -> str | None: + super_client = WebClient(token=settings.superadmin_slack_user_token) try: - response = app.client.conversations_create(name=name, is_private=is_private) - return response["channel"]["id"] + response = super_client.admin_conversations_create(name=name, is_private=is_private, description=description, + team_id=settings.nsls2_workspace_team_id) + # Now lets try and add our 'bot' to the channel + created_channel_id = response.data["channel_id"] + logger.info(f"Created channel {created_channel_id}") + app.client.conversations_join(channel=created_channel_id) except SlackApiError as e: - logger.error(f"Error creating channel: {e}") + logger.exception(f"Error creating channel: {e}") return None + return created_channel_id + def find_channel(name: str) -> str | None: client = WebClient(token=settings.slack_bot_token) @@ -36,3 +40,24 @@ def find_channel(name: str) -> str | None: except SlackApiError as e: logger.error(f"Error finding channel: {e}") return None + + +def channel_id_from_name(name: str) -> str | None: + 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: + client = WebClient(token=settings.slack_bot_token) + channel_id = channel_id_from_name(name) + if channel_id is None: + raise Exception(f"Channel {name} not found.") + + response = client.conversations_rename(channel=channel_id, name=new_name) + + if response["ok"] != True: + raise Exception(f"Failed to rename channel {name} to {new_name}") From 71fe60d706736680027ed16647349968a906fe18 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Sat, 4 May 2024 13:34:05 -0400 Subject: [PATCH 07/26] General package upgrades --- requirements-dev.txt | 70 +++++++++++++++++------------------- requirements.txt | 84 ++++++++++++++++++++++++++++++-------------- 2 files changed, 90 insertions(+), 64 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index d73e5437..9567aca4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ # This file was autogenerated by uv via the following command: # uv pip compile requirements-dev.in -o requirements-dev.txt -aiohttp==3.9.4 +aiohttp==3.9.5 # via textual-dev aiosignal==1.3.1 # via aiohttp @@ -10,18 +10,15 @@ anyio==4.3.0 # via asyncer asttokens==2.4.1 # via stack-data -asyncer==0.0.5 - # via -r requirements-dev.in +asyncer==0.0.7 attrs==23.2.0 # via aiohttp -black==24.3.0 - # via -r requirements-dev.in -blinker==1.7.0 +black==24.4.2 +blinker==1.8.1 # via flask brotli==1.1.0 # via geventhttpclient bunnet==1.3.0 - # via -r requirements-dev.in certifi==2024.2.2 # via # geventhttpclient @@ -42,9 +39,8 @@ dnspython==2.6.1 # via pymongo executing==2.0.1 # via stack-data -faker==24.4.0 - # via -r requirements-dev.in -flask==3.0.2 +faker==25.0.1 +flask==3.0.3 # via # flask-cors # flask-login @@ -61,7 +57,7 @@ gevent==24.2.1 # via # geventhttpclient # locust -geventhttpclient==2.0.12 +geventhttpclient==2.2.1 # via locust greenlet==3.0.3 # via gevent @@ -72,9 +68,8 @@ idna==3.7 # yarl iniconfig==2.0.0 # via pytest -ipython==8.23.0 - # via -r requirements-dev.in -itsdangerous==2.1.2 +ipython==8.24.0 +itsdangerous==2.2.0 # via flask jedi==0.19.1 # via ipython @@ -84,9 +79,8 @@ lazy-model==0.2.0 # via bunnet linkify-it-py==2.0.3 # via markdown-it-py -locust==2.24.1 - # via -r requirements-dev.in -markdown-it-py[linkify,plugins]==3.0.0 +locust==2.26.0 +markdown-it-py==3.0.0 # via # mdit-py-plugins # rich @@ -95,7 +89,7 @@ markupsafe==2.1.5 # via # jinja2 # werkzeug -matplotlib-inline==0.1.6 +matplotlib-inline==0.1.7 # via ipython mdit-py-plugins==0.4.0 # via markdown-it-py @@ -115,15 +109,15 @@ packaging==24.0 # via # black # pytest -parso==0.8.3 +parso==0.8.4 # via jedi pathspec==0.12.1 # via black pexpect==4.9.0 # via ipython -platformdirs==4.2.0 +platformdirs==4.2.1 # via black -pluggy==1.4.0 +pluggy==1.5.0 # via pytest prompt-toolkit==3.0.43 # via ipython @@ -133,23 +127,22 @@ ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data -pydantic==2.6.4 +pydantic==2.7.1 # via # bunnet # lazy-model -pydantic-core==2.16.3 +pydantic-core==2.18.2 # via pydantic -pygments==2.17.2 +pygments==2.18.0 # via # ipython # rich -pymongo==4.6.3 +pymongo==4.7.1 # via bunnet -pytest==8.1.1 - # via -r requirements-dev.in +pytest==8.2.0 python-dateutil==2.9.0.post0 # via faker -pyzmq==25.1.2 +pyzmq==26.0.3 # via locust requests==2.31.0 # via locust @@ -157,28 +150,29 @@ rich==13.7.1 # via textual roundrobin==0.0.4 # via locust -ruff==0.3.5 - # via -r requirements-dev.in +ruff==0.4.3 +setuptools==69.5.1 + # via + # zope-event + # zope-interface six==1.16.0 # via # asttokens - # geventhttpclient # python-dateutil sniffio==1.3.1 # via anyio stack-data==0.6.3 # via ipython -textual==0.55.1 +textual==0.58.1 # via textual-dev textual-dev==1.5.1 - # via -r requirements-dev.in toml==0.10.2 # via bunnet -traitlets==5.14.2 +traitlets==5.14.3 # via # ipython # matplotlib-inline -typing-extensions==4.10.0 +typing-extensions==4.11.0 # via # pydantic # pydantic-core @@ -187,7 +181,9 @@ typing-extensions==4.10.0 uc-micro-py==1.0.3 # via linkify-it-py urllib3==2.2.1 - # via requests + # via + # geventhttpclient + # requests wcwidth==0.2.13 # via prompt-toolkit werkzeug==3.0.2 @@ -199,5 +195,5 @@ yarl==1.9.4 # via aiohttp zope-event==5.0 # via gevent -zope-interface==6.2 +zope-interface==6.3 # via gevent diff --git a/requirements.txt b/requirements.txt index 02493d73..1e066da5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,17 +3,18 @@ aiofiles==23.2.1 annotated-types==0.6.0 # via pydantic -anyio==3.7.1 +anyio==4.3.0 # via # httpx # starlette + # watchfiles argon2-cffi==23.1.0 argon2-cffi-bindings==21.2.0 # via argon2-cffi asgi-correlation-id==4.3.1 async-timeout==4.0.3 # via httpx-socks -beanie==1.25.0 +beanie==1.26.0 certifi==2024.2.2 # via # httpcore @@ -23,17 +24,23 @@ cffi==1.16.0 click==8.1.7 # via # beanie - # typer-slim + # typer # uvicorn decorator==5.1.1 # via gssapi dnspython==2.6.1 - # via pymongo -fastapi==0.110.1 + # via + # email-validator + # pymongo +email-validator==2.1.1 + # via fastapi +fastapi==0.111.0 + # via fastapi-cli +fastapi-cli==0.0.2 + # via fastapi gssapi==1.8.3 # via n2snusertools gunicorn==22.0.0 - # via -r requirements.in h11==0.14.0 # via # httpcore @@ -42,16 +49,23 @@ httpcore==1.0.5 # via # httpx # httpx-socks +httptools==0.6.1 + # via uvicorn httpx==0.27.0 - # via httpx-socks + # via + # fastapi + # httpx-socks httpx-socks==0.9.1 idna==3.7 # via # anyio + # email-validator # httpx -jinja-partials==0.2.0 +jinja-partials==0.2.1 jinja2==3.1.3 - # via jinja-partials + # via + # fastapi + # jinja-partials lazy-model==0.2.0 # via beanie ldap3==2.9.1 @@ -74,6 +88,8 @@ mdurl==0.1.2 motor==3.4.0 # via beanie n2snusertools==0.3.7 +orjson==3.10.3 + # via fastapi packaging==24.0 # via gunicorn passlib==1.7.4 @@ -83,32 +99,40 @@ pyasn1==0.6.0 # via ldap3 pycparser==2.22 # via cffi -pydantic==2.6.4 +pydantic==2.7.1 # via # beanie # fastapi # lazy-model # pydantic-settings -pydantic-core==2.16.3 +pydantic-core==2.18.2 # via pydantic pydantic-settings==2.2.1 -pygments==2.17.2 +pygments==2.18.0 # via rich -pymongo==4.6.3 +pymongo==4.7.1 # via motor python-dotenv==1.0.1 - # via pydantic-settings + # via + # pydantic-settings + # uvicorn python-multipart==0.0.9 + # via fastapi python-socks==2.4.4 # via httpx-socks pyyaml==6.0.1 - # via n2snusertools + # via + # n2snusertools + # uvicorn rich==13.7.1 # via # textual - # typer-slim + # typer shellingham==1.5.4 - # via typer-slim + # via typer +slack-bolt==1.18.1 +slack-sdk==3.27.1 + # via slack-bolt sniffio==1.3.1 # via # anyio @@ -117,27 +141,33 @@ starlette==0.37.2 # via # asgi-correlation-id # fastapi -textual==0.55.1 +textual==0.58.1 toml==0.10.2 # via beanie -typer==0.12.0 -typer-cli==0.12.0 - # via typer -typer-slim==0.12.0 - # via - # typer - # typer-cli -typing-extensions==4.10.0 +typer==0.12.3 + # via fastapi-cli +typing-extensions==4.11.0 # via # fastapi # pydantic # pydantic-core # textual - # typer-slim + # typer uc-micro-py==1.0.3 # via linkify-it-py +ujson==5.9.0 + # via fastapi uuid==1.30 uvicorn==0.29.0 + # via + # fastapi + # fastapi-cli +uvloop==0.19.0 + # via uvicorn +watchfiles==0.21.0 + # via uvicorn wcwidth==0.2.13 # via prettytable +websockets==12.0 + # via uvicorn werkzeug==3.0.2 From b92a6b15c01afadb5506daaca4d141b180aa1f10 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Thu, 9 May 2024 20:58:08 -0400 Subject: [PATCH 08/26] Add slack_channel_id field to proposal document --- src/nsls2api/models/proposals.py | 1 + 1 file changed, 1 insertion(+) 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 ) From 7028a6bb0fa3a93fcfe09905a9f99f895878297c Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Thu, 9 May 2024 20:58:37 -0400 Subject: [PATCH 09/26] Add field to store slack admins --- src/nsls2api/models/beamlines.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/nsls2api/models/beamlines.py b/src/nsls2api/models/beamlines.py index 764ce6c8..e5891f5b 100644 --- a/src/nsls2api/models/beamlines.py +++ b/src/nsls2api/models/beamlines.py @@ -128,11 +128,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_admins: Optional[list[str]] = [] + data_admins: Optional[list[str]] = None 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]] = [] From c2e2c200c080b54056b42b02b1298fe6c4feb3af Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Thu, 9 May 2024 20:59:01 -0400 Subject: [PATCH 10/26] Make testing prefix allowable by slack --- src/nsls2api/services/proposal_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nsls2api/services/proposal_service.py b/src/nsls2api/services/proposal_service.py index be9f9f93..a69ecfde 100644 --- a/src/nsls2api/services/proposal_service.py +++ b/src/nsls2api/services/proposal_service.py @@ -87,7 +87,7 @@ def generate_data_session_for_proposal(proposal_id: int) -> str: 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)}" + return f"test-sic-{str(proposal_id)}" async def proposal_by_id(proposal_id: int) -> Optional[Proposal]: """ From 5735262b56677b9ad5246ba4d6a261e9fd63ab01 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Thu, 9 May 2024 20:59:14 -0400 Subject: [PATCH 11/26] Add model for SlackBot --- src/nsls2api/models/slack_models.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/nsls2api/models/slack_models.py diff --git a/src/nsls2api/models/slack_models.py b/src/nsls2api/models/slack_models.py new file mode 100644 index 00000000..83b5289c --- /dev/null +++ b/src/nsls2api/models/slack_models.py @@ -0,0 +1,6 @@ +import pydantic + +class SlackBot(pydantic.BaseModel): + username: str + user_id: str + bot_id: str From 68f0a91223a6f0e5bdca729caddf616d3fdd5e73 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Thu, 9 May 2024 20:59:47 -0400 Subject: [PATCH 12/26] Initial version of slack_service --- src/nsls2api/services/slack_service.py | 97 +++++++++++++++++--------- 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/src/nsls2api/services/slack_service.py b/src/nsls2api/services/slack_service.py index d8ec5b9d..5b16b522 100644 --- a/src/nsls2api/services/slack_service.py +++ b/src/nsls2api/services/slack_service.py @@ -4,42 +4,63 @@ 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: + response = app.client.auth_test() -def create_channel(name: str, is_private: bool = False, description: str = None) -> str | None: - super_client = WebClient(token=settings.superadmin_slack_user_token) - try: - response = super_client.admin_conversations_create(name=name, is_private=is_private, description=description, - team_id=settings.nsls2_workspace_team_id) - # Now lets try and add our 'bot' to the channel - created_channel_id = response.data["channel_id"] - logger.info(f"Created channel {created_channel_id}") - app.client.conversations_join(channel=created_channel_id) - except SlackApiError as e: - logger.exception(f"Error creating channel: {e}") - return None - - return created_channel_id - - -def find_channel(name: str) -> str | None: - client = WebClient(token=settings.slack_bot_token) - app = App( - token=settings.slack_bot_token, signing_secret=settings.slack_signing_secret + return SlackBot( + username=response.data["user"], + user_id=response.data["user_id"], + bot_id=response.data["bot_id"], ) + + +def add_bot_to_channel(channel_name: str): + bot = get_bot_details() + channel_id = channel_id_from_name(channel_name) + client = WebClient(token=settings.superadmin_slack_user_token) + logger.info(f"Inviting {bot.username} ({bot.user_id}) to channel {channel_id}.") try: - response = app.client.conversations_list() - for channel in response["channels"]: - print(channel) - if channel["name"] == name: - return channel["id"] - return None - except SlackApiError as e: - logger.error(f"Error finding channel: {e}") - return None + response = client.admin_conversations_invite(channel_id=channel_id, user_ids=[bot.user_id]) + logger.info(response) + except SlackApiError as error: + logger.error(error) + + +async def create_channel( + name: str, is_private: bool = False, description: str = None +) -> str | None: + 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}.") + else: + try: + logger.info(f"Trying to create channel called '{name}'.") + response = super_client.admin_conversations_create( + name=name, + is_private=is_private, + description=description, + 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(name) + + return channel_id def channel_id_from_name(name: str) -> str | None: @@ -52,12 +73,24 @@ def channel_id_from_name(name: str) -> str | None: def rename_channel(name: str, new_name: str) -> str | None: - client = WebClient(token=settings.slack_bot_token) channel_id = channel_id_from_name(name) if channel_id is None: raise Exception(f"Channel {name} not found.") - response = client.conversations_rename(channel=channel_id, name=new_name) + response = app.client.conversations_rename(channel=channel_id, name=new_name) - if response["ok"] != True: + 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: + 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: + response = app.client.users_lookupByEmail(email=email) + if response.data["ok"] is True: + return response.data["user"]["name"] + From 6c7fcf3a94a1946ac3a776962e03c011adeb417a Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Thu, 9 May 2024 21:00:04 -0400 Subject: [PATCH 13/26] Endpoint to create proposal slack channel --- src/nsls2api/api/v1/admin_api.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/nsls2api/api/v1/admin_api.py b/src/nsls2api/api/v1/admin_api.py index 7f236631..7bd29008 100644 --- a/src/nsls2api/api/v1/admin_api.py +++ b/src/nsls2api/api/v1/admin_api.py @@ -4,6 +4,7 @@ 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, @@ -50,9 +51,9 @@ async def generate_user_apikey(username: str): return await generate_api_key(username) -@router.post("/admin/slack/create-proposal-channel/{proposal_id") +@router.post("/admin/slack/create-proposal-channel/{proposal_id}") async def create_slack_channel(proposal_id: str): - proposal = proposal_service.proposal_by_id(int(proposal_id)) + proposal = await proposal_service.proposal_by_id(int(proposal_id)) if proposal is None: return fastapi.responses.JSONResponse( @@ -66,11 +67,14 @@ async def create_slack_channel(proposal_id: str): {"error": f"Slack channel name cannot be found for proposal {proposal_id}"}, status_code=404, ) - status = slack_service.create_channel(channel_name, True, + channel_id = await slack_service.create_channel(channel_name, True, description=f"Discussion related to proposal {proposal_id}") - if status is None: - return fastapi.responses.JSONResponse({"error": f"Slack channel creation failed"}, status_code=500) + 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_name + proposal.slack_channel_id = channel_id + await proposal.save() From 3f4977d418c3e6d9b9b680f11ea4ce147ca12b48 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 10 May 2024 19:13:31 -0400 Subject: [PATCH 14/26] feat: Add SlackChannelManagersView model This commit adds the SlackChannelManagersView model to the beamlines.py file in the src/nsls2api/models directory. The model includes a list of slack_channel_managers and a projection setting for the corresponding database field. This model will be used to manage the Slack channel managers for each beamline. --- src/nsls2api/models/beamlines.py | 7 ++++++- src/nsls2api/services/beamline_service.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/nsls2api/models/beamlines.py b/src/nsls2api/models/beamlines.py index e5891f5b..78054d31 100644 --- a/src/nsls2api/models/beamlines.py +++ b/src/nsls2api/models/beamlines.py @@ -70,7 +70,6 @@ class IOCServiceAccountView(pydantic.BaseModel): class Settings: projection = {"username": "$service_accounts.ioc"} - class BlueskyServiceAccountView(pydantic.BaseModel): username: str @@ -112,6 +111,12 @@ class DataRootDirectoryView(pydantic.BaseModel): 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 diff --git a/src/nsls2api/services/beamline_service.py b/src/nsls2api/services/beamline_service.py index a923f8d1..894e1b3a 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, @@ -345,3 +346,13 @@ async def uses_synchweb(name: str) -> bool: return True else: return False + + +async def slack_channel_managers(beamline_name: str) -> list[str]: + beamline = await Beamline.find_one(Beamline.name == beamline_name.upper()).project( + SlackChannelManagersView + ) + if beamline is None: + return None + + return beamline.slack_channel_managers From a87ec153019c4ee1ee21e5241fd32b2c42667039 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 10 May 2024 19:15:04 -0400 Subject: [PATCH 15/26] feat: Add endpoint to retrieve Slack channel managers for a beamline The commit adds a new endpoint `/beamline/{name}/slack-channel-managers` to the `beamline_api.py` file in the `src/nsls2api/api/v1` directory. This endpoint allows users to retrieve the Slack channel managers for a specific beamline. If the beamline is not found, a 404 error is returned. --- src/nsls2api/api/v1/beamline_api.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 7906241feefad5d42bb75c81030e3765f2bdaead Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 10 May 2024 21:15:31 -0400 Subject: [PATCH 16/26] Code formating and linting changes --- src/nsls2api/api/v1/proposal_api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 - From 3aeaa86dbc3c50faa6c09be0e8ea40663a3fa2eb Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 10 May 2024 21:15:47 -0400 Subject: [PATCH 17/26] feat: Add SlackUser model This commit adds the SlackUser model to the `slack_models.py` file in the `src/nsls2api/models` directory. The model includes fields for `user_id`, `username`, and `email`. This model will be used to represent a Slack user in the application. --- src/nsls2api/models/slack_models.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/nsls2api/models/slack_models.py b/src/nsls2api/models/slack_models.py index 83b5289c..60edf132 100644 --- a/src/nsls2api/models/slack_models.py +++ b/src/nsls2api/models/slack_models.py @@ -1,6 +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 | None = None + channel_name: str | None = None + beamline_slack_managers: list[str] | None = [] + user_ids: list[str] | None = [] + message: str | None = None From 701568fe5b46b5c5612d21fce4a1dae2e41307f1 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 10 May 2024 21:16:19 -0400 Subject: [PATCH 18/26] feat: Retrieve Slack user IDs of channel managers for a beamline This commit adds functionality to retrieve the Slack user IDs of the channel managers for a given beamline. It introduces a new function `slack_channel_managers` in the `beamline_service.py` file. The function takes the name of the beamline as input and returns a list of Slack user IDs. It also includes necessary error handling if the beamline is not found. --- src/nsls2api/services/beamline_service.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/nsls2api/services/beamline_service.py b/src/nsls2api/services/beamline_service.py index 894e1b3a..756c53ff 100644 --- a/src/nsls2api/services/beamline_service.py +++ b/src/nsls2api/services/beamline_service.py @@ -24,6 +24,7 @@ DataRootDirectoryView, LsdcServiceAccountView, ) +from nsls2api.services import slack_service async def beamline_count() -> int: @@ -349,10 +350,28 @@ async def uses_synchweb(name: str) -> bool: 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 - return beamline.slack_channel_managers + 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 From 9a9bf34b7a42c52f5120e64504d5667c993f1a71 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 10 May 2024 21:17:30 -0400 Subject: [PATCH 19/26] feat: Add functionality to add users to a channel. Also added various docs --- src/nsls2api/services/slack_service.py | 145 +++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 9 deletions(-) diff --git a/src/nsls2api/services/slack_service.py b/src/nsls2api/services/slack_service.py index 5b16b522..18c3b412 100644 --- a/src/nsls2api/services/slack_service.py +++ b/src/nsls2api/services/slack_service.py @@ -8,9 +8,19 @@ 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) +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( @@ -20,26 +30,83 @@ def get_bot_details() -> SlackBot: ) -def add_bot_to_channel(channel_name: str): +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() - channel_id = channel_id_from_name(channel_name) 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]) + response = client.admin_conversations_invite( + channel_id=channel_id, user_ids=[bot.user_id] + ) logger.info(response) except SlackApiError as error: - logger.error(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 create_channel( - name: str, is_private: bool = False, description: str = None + name: str, is_private: bool = True, description: str = None ) -> str | None: + """ + Creates a new Slack channel with the given name, privacy settings, and description. + + Args: + name (str): The name of the channel to be created. + is_private (bool, optional): Whether the channel should be private. Defaults to True. + description (str, optional): The description of the channel. Defaults to None. + + 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}.") else: @@ -58,12 +125,21 @@ async def create_channel( return None # Now lets add our 'bot' to the channel - add_bot_to_channel(name) + 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) @@ -71,8 +147,21 @@ def channel_id_from_name(name: str) -> str | None: 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.") @@ -84,13 +173,51 @@ def rename_channel(name: str, new_name: str) -> str | None: 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 + + \ No newline at end of file From d2363c01ce8d9c7838430e2ef029f0c09a07f56a Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 10 May 2024 21:18:01 -0400 Subject: [PATCH 20/26] feat: Add functionality to create Slack channel for proposal --- src/nsls2api/api/v1/admin_api.py | 59 ++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/src/nsls2api/api/v1/admin_api.py b/src/nsls2api/api/v1/admin_api.py index 7bd29008..b31d835e 100644 --- a/src/nsls2api/api/v1/admin_api.py +++ b/src/nsls2api/api/v1/admin_api.py @@ -10,7 +10,8 @@ generate_api_key, ) from nsls2api.models.apikeys import ApiUser -from nsls2api.services import proposal_service, slack_service +from nsls2api.models.slack_models import SlackChannelCreationResponseModel +from nsls2api.services import beamline_service, proposal_service, slack_service # router = fastapi.APIRouter() router = fastapi.APIRouter( @@ -25,7 +26,7 @@ async def info(settings: Annotated[config.Settings, Depends(config.get_settings) @router.get("/admin/validate", response_model=str) async def check_admin_validation( - admin_user: Annotated[ApiUser, Depends(validate_admin_role)] = None, + admin_user: Annotated[ApiUser, Depends(validate_admin_role)] = None, ): """ :return: str - The username of the validated admin user. @@ -52,7 +53,7 @@ async def generate_user_apikey(username: str): @router.post("/admin/slack/create-proposal-channel/{proposal_id}") -async def create_slack_channel(proposal_id: str): +async def create_slack_channel(proposal_id: str) -> SlackChannelCreationResponseModel: proposal = await proposal_service.proposal_by_id(int(proposal_id)) if proposal is None: @@ -65,16 +66,60 @@ async def create_slack_channel(proposal_id: str): if channel_name is None: return fastapi.responses.JSONResponse( {"error": f"Slack channel name cannot be found for proposal {proposal_id}"}, - status_code=404, ) + status_code=404, + ) - channel_id = await slack_service.create_channel(channel_name, True, - description=f"Discussion related to proposal {proposal_id}") + channel_id = await slack_service.create_channel( + channel_name, True, description=f"Discussion related to proposal {proposal_id}" + ) if channel_id is None: - return fastapi.responses.JSONResponse({"error": f"Slack channel creation failed for proposal {proposal_id}"}, status_code=500) + 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 From 98b8768c6eca2c2ddd281a188b3113215c68d6b9 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Tue, 7 May 2024 15:33:29 -0400 Subject: [PATCH 21/26] Upgrade project dependencies --- requirements-dev.txt | 10 +++++----- requirements.txt | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 9567aca4..4e44857f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,7 @@ asyncer==0.0.7 attrs==23.2.0 # via aiohttp black==24.4.2 -blinker==1.8.1 +blinker==1.8.2 # via flask brotli==1.1.0 # via geventhttpclient @@ -45,7 +45,7 @@ flask==3.0.3 # flask-cors # flask-login # locust -flask-cors==4.0.0 +flask-cors==4.0.1 # via locust flask-login==0.6.3 # via locust @@ -73,13 +73,13 @@ itsdangerous==2.2.0 # via flask jedi==0.19.1 # via ipython -jinja2==3.1.3 +jinja2==3.1.4 # via flask lazy-model==0.2.0 # via bunnet linkify-it-py==2.0.3 # via markdown-it-py -locust==2.26.0 +locust==2.27.0 markdown-it-py==3.0.0 # via # mdit-py-plugins @@ -186,7 +186,7 @@ urllib3==2.2.1 # requests wcwidth==0.2.13 # via prompt-toolkit -werkzeug==3.0.2 +werkzeug==3.0.3 # via # flask # flask-login diff --git a/requirements.txt b/requirements.txt index 1e066da5..f8eb32ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,7 +36,7 @@ email-validator==2.1.1 # via fastapi fastapi==0.111.0 # via fastapi-cli -fastapi-cli==0.0.2 +fastapi-cli==0.0.3 # via fastapi gssapi==1.8.3 # via n2snusertools @@ -62,7 +62,7 @@ idna==3.7 # email-validator # httpx jinja-partials==0.2.1 -jinja2==3.1.3 +jinja2==3.1.4 # via # fastapi # jinja-partials @@ -170,4 +170,4 @@ wcwidth==0.2.13 # via prettytable websockets==12.0 # via uvicorn -werkzeug==3.0.2 +werkzeug==3.0.3 From 2d6f3f0180e922e3344c559cb7ee5801bdb6b8ff Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 17 May 2024 16:34:30 -0400 Subject: [PATCH 22/26] Updated create_channel function to enforce privacy setting and removed description parameter. --- src/nsls2api/api/v1/admin_api.py | 11 ++-- src/nsls2api/services/slack_service.py | 74 +++++++++++++++++++++----- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/src/nsls2api/api/v1/admin_api.py b/src/nsls2api/api/v1/admin_api.py index b31d835e..4dcd98ee 100644 --- a/src/nsls2api/api/v1/admin_api.py +++ b/src/nsls2api/api/v1/admin_api.py @@ -70,7 +70,8 @@ async def create_slack_channel(proposal_id: str) -> SlackChannelCreationResponse ) channel_id = await slack_service.create_channel( - channel_name, True, description=f"Discussion related to proposal {proposal_id}" + channel_name, + is_private=True, ) if channel_id is None: @@ -89,7 +90,9 @@ async def create_slack_channel(proposal_id: str) -> SlackChannelCreationResponse 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}].") + 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 @@ -105,7 +108,9 @@ async def create_slack_channel(proposal_id: str) -> SlackChannelCreationResponse 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...") + logger.info( + f"Adding user {user.username} ({user_slack_id}) to slack channel..." + ) proposal_user_ids.append(user_slack_id) logger.info( diff --git a/src/nsls2api/services/slack_service.py b/src/nsls2api/services/slack_service.py index 18c3b412..6b9a067b 100644 --- a/src/nsls2api/services/slack_service.py +++ b/src/nsls2api/services/slack_service.py @@ -32,8 +32,8 @@ def get_bot_details() -> SlackBot: 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 + 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: @@ -88,16 +88,32 @@ def add_bot_to_channel(channel_id: str): 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, description: str = None + name: str, + is_private: bool = True, ) -> str | None: """ - Creates a new Slack channel with the given name, privacy settings, and description. + 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. - description (str, optional): The description of the channel. Defaults to None. Returns: str | None: The ID of the created channel if successful, None otherwise. @@ -109,13 +125,41 @@ async def create_channel( 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, - description=description, team_id=settings.nsls2_workspace_team_id, ) logger.info(f"Response from creating slack channel: {response}") @@ -147,6 +191,7 @@ def channel_id_from_name(name: str) -> str | None: if channel["name"] == name: return channel["id"] + def rename_channel(name: str, new_name: str) -> str | None: """ Renames a Slack channel. @@ -157,7 +202,7 @@ def rename_channel(name: str, new_name: str) -> str | None: 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. @@ -201,23 +246,24 @@ def lookup_username_by_email(email: str) -> str | None: 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 - ) + 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}.") + 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}." ) + logger.error( + f"Failed to add user {failed_user_id} to channel {channel_id}." + ) else: logger.exception(error) raise Exception(error) from error - - \ No newline at end of file From a65318fa11032a5fb57c36e996d891f0cadf8532 Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 17 May 2024 16:41:51 -0400 Subject: [PATCH 23/26] Correct field name --- src/nsls2api/models/beamlines.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/nsls2api/models/beamlines.py b/src/nsls2api/models/beamlines.py index 78054d31..f8e68711 100644 --- a/src/nsls2api/models/beamlines.py +++ b/src/nsls2api/models/beamlines.py @@ -70,6 +70,7 @@ class IOCServiceAccountView(pydantic.BaseModel): class Settings: projection = {"username": "$service_accounts.ioc"} + class BlueskyServiceAccountView(pydantic.BaseModel): username: str @@ -111,11 +112,12 @@ class DataRootDirectoryView(pydantic.BaseModel): 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"} + projection = {"slack_channel_managers": "$slack_channel_managers"} class EndStation(pydantic.BaseModel): @@ -134,7 +136,7 @@ class Beamline(beanie.Document): nsls2_redhat_satellite_location_name: Optional[str] service_accounts: ServiceAccounts | None = None endstations: Optional[list[EndStation]] = [] - slack_channel_admins: Optional[list[str]] = [] + slack_channel_managers: Optional[list[str]] = [] data_admins: Optional[list[str]] = None custom_data_admin_group: Optional[str] = None github_org: Optional[str] = None From 1827b70195767d8e82ab19c46e9a9ddd96977b4a Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 17 May 2024 16:42:56 -0400 Subject: [PATCH 24/26] Update src/nsls2api/api/v1/admin_api.py Co-authored-by: Padraic Shafer <76011594+padraic-shafer@users.noreply.github.com> --- src/nsls2api/api/v1/admin_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nsls2api/api/v1/admin_api.py b/src/nsls2api/api/v1/admin_api.py index 4dcd98ee..be80d9da 100644 --- a/src/nsls2api/api/v1/admin_api.py +++ b/src/nsls2api/api/v1/admin_api.py @@ -65,7 +65,7 @@ async def create_slack_channel(proposal_id: str) -> SlackChannelCreationResponse if channel_name is None: return fastapi.responses.JSONResponse( - {"error": f"Slack channel name cannot be found for proposal {proposal_id}"}, + {"error": f"Slack channel name cannot be generated for proposal {proposal_id}"}, status_code=404, ) From 53044599fec9b99581b061bea29a17a39c7a409b Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 17 May 2024 17:09:15 -0400 Subject: [PATCH 25/26] Change default for data_admins be [] --- src/nsls2api/models/beamlines.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nsls2api/models/beamlines.py b/src/nsls2api/models/beamlines.py index f8e68711..9b57f5dd 100644 --- a/src/nsls2api/models/beamlines.py +++ b/src/nsls2api/models/beamlines.py @@ -137,7 +137,7 @@ class Beamline(beanie.Document): service_accounts: ServiceAccounts | None = None endstations: Optional[list[EndStation]] = [] slack_channel_managers: Optional[list[str]] = [] - data_admins: Optional[list[str]] = None + data_admins: Optional[list[str]] = [] custom_data_admin_group: Optional[str] = None github_org: Optional[str] = None ups_id: Optional[str] = None From b6c931c724fb9373757806658869a6c0259ed3eb Mon Sep 17 00:00:00 2001 From: Stuart Campbell Date: Fri, 17 May 2024 17:12:37 -0400 Subject: [PATCH 26/26] Made channel_id and name mandatory fields in response model. --- src/nsls2api/models/slack_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nsls2api/models/slack_models.py b/src/nsls2api/models/slack_models.py index 60edf132..549e2c9f 100644 --- a/src/nsls2api/models/slack_models.py +++ b/src/nsls2api/models/slack_models.py @@ -14,8 +14,8 @@ class SlackUser(pydantic.BaseModel): class SlackChannelCreationResponseModel(pydantic.BaseModel): - channel_id: str | None = None - channel_name: str | None = None + channel_id: str + channel_name: str beamline_slack_managers: list[str] | None = [] user_ids: list[str] | None = [] message: str | None = None