Skip to content

Commit e07247d

Browse files
Mpt 5362 as a fin ops operator i can invite users to join my organisation (#25)
* Add API to decline an invitation and to register an invited user. Add new tests to cover the new functionalities. * WIP. Add functions to check if a user can be safely deleted as a result of a declined invitations. Add tests to cover the new funzionalities Add API method to remove users from OptScale * Add pydantic v2 SettingsConfigDict and ConfigDict to models and config. Improve Test coverage and density. Complete functions/endpoints documentations. Add JTW to decline an invitation. * Add admin_api_key as additional parameters to the remove_user function. Update tests * Remove invitation from omit * Add invite validation before registering an invited user. Remove JTW from /decline because the token is provided by the UI. Updated the tests. Rename external_services.py to invitations.py
1 parent c9818d5 commit e07247d

27 files changed

+1357
-191
lines changed

app/core/api_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ async def _make_request(
8484
}
8585
else:
8686
# Handle non-JSON response
87+
if response.status_code == 204:
88+
return {}
8789
logger.warning(
8890
"Response is not JSON as indicated by Content-Type header."
8991
)

app/core/config.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
from pydantic_settings import BaseSettings
1+
import pathlib
2+
3+
from pydantic_settings import BaseSettings, SettingsConfigDict
4+
5+
PROJECT_ROOT = pathlib.Path(__file__).parent.parent
26

37

48
class Settings(BaseSettings):
@@ -18,5 +22,7 @@ class Settings(BaseSettings):
1822
leeway: float = 30.0
1923
default_request_timeout: int = 10 # API Client
2024

21-
class Config:
22-
env_file = "/app/.env.test"
25+
model_config = SettingsConfigDict(
26+
env_file=PROJECT_ROOT / ".env",
27+
env_file_encoding="utf-8",
28+
)

app/core/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ class UserOrgCreationError(Exception):
2828
pass
2929

3030

31+
class InvitationDoesNotExist(Exception):
32+
"""Custom exception for validating a user invitation ."""
33+
34+
pass
35+
36+
3137
class OptScaleAPIResponseError(Exception):
3238
"""
3339
Custom exception class for handling errors in the OptScale API responses.

app/invitations/api.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import logging
2+
3+
from fastapi import APIRouter, BackgroundTasks, Depends
4+
from fastapi import status as http_status
5+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
6+
from starlette.responses import JSONResponse
7+
8+
from app import settings
9+
from app.core.exceptions import (
10+
InvitationDoesNotExist,
11+
OptScaleAPIResponseError,
12+
handle_exception,
13+
)
14+
from app.invitations.model import (
15+
DeclineInvitation,
16+
RegisteredInvitedUserResponse,
17+
RegisterInvitedUser,
18+
)
19+
from app.invitations.services.invitations import (
20+
register_invited_user_on_optscale,
21+
remove_user,
22+
)
23+
from app.optscale_api.invitation_api import OptScaleInvitationAPI
24+
from app.optscale_api.orgs_api import OptScaleOrgAPI
25+
from app.optscale_api.users_api import OptScaleUserAPI
26+
27+
# HTTPBearer scheme to parse Authorization header
28+
bearer_scheme = HTTPBearer()
29+
logger = logging.getLogger(__name__)
30+
router = APIRouter()
31+
32+
33+
def get_bearer_token(
34+
auth: HTTPAuthorizationCredentials = Depends(bearer_scheme),
35+
) -> str:
36+
return auth.credentials # Return the raw token
37+
38+
39+
def get_optscale_user_api() -> OptScaleUserAPI:
40+
return OptScaleUserAPI()
41+
42+
43+
@router.post(
44+
path="/users",
45+
status_code=http_status.HTTP_201_CREATED,
46+
response_model=RegisteredInvitedUserResponse,
47+
)
48+
async def register_invited(
49+
data: RegisterInvitedUser,
50+
user_api: OptScaleUserAPI = Depends(get_optscale_user_api),
51+
): # noqa: E501
52+
try:
53+
response = await register_invited_user_on_optscale(
54+
email=str(data.email),
55+
display_name=data.display_name,
56+
password=data.password,
57+
user_api=user_api,
58+
)
59+
return JSONResponse(
60+
status_code=response.get("status_code", http_status.HTTP_201_CREATED),
61+
content=response.get("data", {}),
62+
)
63+
64+
except (OptScaleAPIResponseError, InvitationDoesNotExist) as error:
65+
handle_exception(error=error)
66+
67+
68+
@router.post(
69+
path="/users/invites/{invite_id}/decline",
70+
status_code=http_status.HTTP_200_OK,
71+
)
72+
async def decline_invitation(
73+
invite_id: str,
74+
data: DeclineInvitation,
75+
background_task: BackgroundTasks,
76+
invitation_api: OptScaleInvitationAPI = Depends(),
77+
org_api: OptScaleOrgAPI = Depends(),
78+
user_api: OptScaleUserAPI = Depends(),
79+
invited_user_token: str = Depends(get_bearer_token),
80+
):
81+
try:
82+
user_id = data.user_id
83+
response = await invitation_api.decline_invitation(
84+
user_access_token=invited_user_token, invitation_id=invite_id
85+
)
86+
87+
background_task.add_task(
88+
remove_user,
89+
user_access_token=invited_user_token,
90+
user_id=user_id,
91+
invitation_api=invitation_api,
92+
org_api=org_api,
93+
user_api=user_api,
94+
admin_api_key=settings.admin_token,
95+
)
96+
return JSONResponse(
97+
status_code=response.get("status_code", http_status.HTTP_200_OK),
98+
content={"response": "Invitation declined"},
99+
)
100+
except OptScaleAPIResponseError as error:
101+
handle_exception(error=error)

app/invitations/model.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
from pydantic import BaseModel, ConfigDict, EmailStr, constr
4+
5+
6+
class RegisterInvitedUser(BaseModel):
7+
email: EmailStr
8+
display_name: str
9+
password: constr(min_length=8)
10+
model_config = ConfigDict(
11+
json_schema_extra={
12+
"example": [
13+
{
14+
"email": "peter.parker@spiderman.com",
15+
"display_name": "Peter Parker",
16+
"password": "superC00lPassword123",
17+
}
18+
]
19+
}
20+
)
21+
22+
23+
class DeclineInvitation(BaseModel):
24+
user_id: str
25+
26+
27+
class RegisteredInvitedUserResponse(BaseModel):
28+
id: str
29+
display_name: str
30+
is_active: bool
31+
type_id: int
32+
email: EmailStr
33+
verified: bool
34+
scope_id: str | None
35+
slack_connected: bool
36+
is_password_autogenerated: bool
37+
jira_connected: bool
38+
token: None
39+
model_config = ConfigDict(
40+
json_schema_extra={
41+
"examples": [
42+
{
43+
"created_at": 1730126521,
44+
"deleted_at": 0,
45+
"id": "f0bd0c4a-7c55-45b7-8b58-27740e38789a",
46+
"display_name": "Spider Man",
47+
"is_active": True,
48+
"type_id": 1,
49+
"email": "peter.parker@iamspiderman.com",
50+
"verified": False,
51+
"scope_id": None,
52+
"slack_connected": False,
53+
"is_password_autogenerated": False,
54+
"jira_connected": False,
55+
"token": "JTW_TOKEN",
56+
}
57+
]
58+
}
59+
)

app/invitations/services/__init__.py

Whitespace-only changes.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import functools
5+
import logging
6+
7+
from fastapi import Depends
8+
9+
from app import settings
10+
from app.core.exceptions import InvitationDoesNotExist, OptScaleAPIResponseError
11+
from app.optscale_api.invitation_api import OptScaleInvitationAPI
12+
from app.optscale_api.orgs_api import OptScaleOrgAPI
13+
from app.optscale_api.users_api import OptScaleUserAPI
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
def validate_invitation(): # noqa: E501
19+
def decorator(func):
20+
@functools.wraps(func)
21+
async def wrapper(*args, **kwargs):
22+
email = kwargs.get("email")
23+
invitation_api = OptScaleInvitationAPI()
24+
response = await invitation_api.get_list_of_invitations(email=email)
25+
no_invitations = {"invites": []}
26+
if response.get("data", {}) == no_invitations:
27+
# there is no invitation
28+
raise InvitationDoesNotExist("Invitation not found")
29+
return await func(*args, **kwargs)
30+
31+
return wrapper
32+
33+
return decorator
34+
35+
36+
@validate_invitation()
37+
async def register_invited_user_on_optscale(
38+
email: str,
39+
display_name: str,
40+
password: str,
41+
user_api: OptScaleUserAPI,
42+
) -> dict[str, str] | Exception:
43+
"""
44+
Registers invited users to OptScale, without
45+
verification.
46+
47+
:param user_api: an instance of OptScaleUserAPI
48+
:param email: The email of the given user to register
49+
:param display_name: The display name of the user
50+
:param password: The password of the user
51+
:return: dict[str, str] : User information.
52+
:raises: OptScaleAPIResponseError if any error occurs
53+
contacting the OptScale APIs
54+
"""
55+
try:
56+
response = await user_api.create_user(
57+
email=email,
58+
display_name=display_name,
59+
password=password,
60+
admin_api_key=settings.admin_token,
61+
verified=False,
62+
)
63+
logger.info(f"Invited User successfully registered: {response}")
64+
return response
65+
except OptScaleAPIResponseError as error:
66+
logger.error(f"An error {error} occurred registering the invited user {email}")
67+
raise
68+
except InvitationDoesNotExist:
69+
logger.error(f"There is no invitation for this email {email}")
70+
raise
71+
72+
73+
async def validate_user_delete(
74+
user_token: str,
75+
invitation_api: OptScaleInvitationAPI = Depends(),
76+
org_api: OptScaleOrgAPI = Depends(),
77+
) -> bool:
78+
"""
79+
Validates if a user can be deleted by checking invitations and organizations.
80+
81+
:param invitation_api: An instance of OptScaleInvitationAPI
82+
:param org_api: An instance of OptScaleOrgAPI
83+
:param user_token: The Access Token of the user to be deleted
84+
:return: True if the user has no invitations and organizations. False, otherwise
85+
:raises An Exception if an error occurs.
86+
"""
87+
try:
88+
response_invitation, response_organization = await asyncio.gather(
89+
invitation_api.get_list_of_invitations(user_access_token=user_token),
90+
org_api.get_user_org_list(user_access_token=user_token),
91+
)
92+
no_org_response = {"organizations": []}
93+
no_invitations_response = {"invites": []}
94+
return (
95+
response_invitation.get("data", {}) == no_invitations_response
96+
and response_organization.get("data", {}) == no_org_response
97+
)
98+
except Exception as error:
99+
logger.error(f"Exception during deletion user validation:{error}")
100+
return False
101+
102+
103+
async def remove_user(
104+
user_id: str,
105+
user_access_token: str,
106+
admin_api_key: str,
107+
invitation_api: OptScaleInvitationAPI = Depends(),
108+
org_api: OptScaleOrgAPI = Depends(),
109+
user_api: OptScaleUserAPI = Depends(),
110+
) -> bool:
111+
"""
112+
Removes a user if they have no invitations or organizations.
113+
:param admin_api_key: The Secret admin key to add to the Headers
114+
:param user_id: the user ID to be deleted
115+
:param user_access_token: The Access Token of the given User
116+
:param invitation_api: An instance of the OptScaleInvitationAPI
117+
:param org_api: an instance of the OptScaleOrgAPI
118+
:param user_api: An instance of OptScaleUserAPI
119+
:return: True if the user was successfully deleted, False otherwise.
120+
:raises OptScaleAPIResponseError if any error occurs
121+
contacting the OptScale APIs
122+
"""
123+
validate_delete = await validate_user_delete(
124+
user_token=user_access_token, invitation_api=invitation_api, org_api=org_api
125+
)
126+
if validate_delete:
127+
try:
128+
await user_api.delete_user(
129+
user_id=user_id,
130+
admin_api_key=admin_api_key,
131+
)
132+
logger.info(f"The user {user_id} was successfully deleted")
133+
return True
134+
except OptScaleAPIResponseError:
135+
logger.error(f"Error deleting user:{user_id}")
136+
return False
137+
logger.info(f"The user {user_id} cannot be deleted.")
138+
return False

0 commit comments

Comments
 (0)