Implement organizations service and API routes; refactor Singul integration to use dynamic client creation (#458)
taylor_socfortress committed
Jun 18, 2025 at 14:17 UTC
677c0e5442dff92c67368fdaecb15bb3d885ff67
6 files changed
+392
-18
backend/app/connectors/shuffle/routes/organizations.py
new
+128
@@ -0,0 +1,128 @@
1
+from fastapi import APIRouter
2
+from fastapi import Depends
3
+from fastapi import HTTPException
4
+from fastapi import Query
5
+from loguru import logger
6
+
7
+from app.auth.utils import AuthHandler
8
+from app.connectors.shuffle.schema.organizations import OrganizationResponse
9
+from app.connectors.shuffle.schema.organizations import OrganizationsListResponse
10
+from app.connectors.shuffle.services.organizations import OrganizationsService
11
+
12
+# Router Configuration
13
+shuffle_organizations_router = APIRouter()
14
+
15
+# Auth handler
16
+auth_handler = AuthHandler()
17
+
18
+
19
+@shuffle_organizations_router.get(
20
+ "/organizations",
21
+ response_model=OrganizationsListResponse,
22
+ description="Retrieve all organizations from Shuffle",
23
+ dependencies=[Depends(auth_handler.require_any_scope("admin", "analyst"))],
24
+)
25
+async def list_organizations(
26
+ connector_name: str = Query("Shuffle", description="Name of the Shuffle connector to use")
27
+):
28
+ """
29
+ Retrieve all organizations from Shuffle.
30
+
31
+ Args:
32
+ connector_name (str): Name of the Shuffle connector to use.
33
+
34
+ Returns:
35
+ OrganizationsListResponse: List of all organizations.
36
+ """
37
+ logger.info(f"Request to list all organizations using connector: {connector_name}")
38
+
39
+ try:
40
+ organizations = await OrganizationsService.list_organizations(connector_name)
41
+ logger.info(f"Successfully retrieved {organizations.total_count} organizations")
42
+ return organizations
43
+ except HTTPException:
44
+ raise
45
+ except Exception as e:
46
+ logger.error(f"Unexpected error in list_organizations endpoint: {e}")
47
+ raise HTTPException(
48
+ status_code=500,
49
+ detail=f"Internal server error: {str(e)}"
50
+ )
51
+
52
+
53
+@shuffle_organizations_router.get(
54
+ "/organizations/{org_id}",
55
+ response_model=OrganizationResponse,
56
+ description="Retrieve a specific organization by ID",
57
+ dependencies=[Depends(auth_handler.require_any_scope("admin", "analyst"))],
58
+)
59
+async def get_organization_by_id(
60
+ org_id: str,
61
+ connector_name: str = Query("Shuffle", description="Name of the Shuffle connector to use")
62
+):
63
+ """
64
+ Retrieve a specific organization by ID.
65
+
66
+ Args:
67
+ org_id (str): The organization ID to retrieve.
68
+ connector_name (str): Name of the Shuffle connector to use.
69
+
70
+ Returns:
71
+ OrganizationResponse: The organization data.
72
+ """
73
+ logger.info(f"Request to get organization with ID: {org_id} using connector: {connector_name}")
74
+
75
+ try:
76
+ organization = await OrganizationsService.get_organization_by_id(org_id, connector_name)
77
+ return OrganizationResponse(
78
+ success=True,
79
+ message=f"Successfully retrieved organization: {organization.name}",
80
+ data=organization
81
+ )
82
+ except HTTPException:
83
+ raise
84
+ except Exception as e:
85
+ logger.error(f"Unexpected error in get_organization_by_id endpoint: {e}")
86
+ raise HTTPException(
87
+ status_code=500,
88
+ detail=f"Internal server error: {str(e)}"
89
+ )
90
+
91
+
92
+@shuffle_organizations_router.get(
93
+ "/organizations/name/{org_name}",
94
+ response_model=OrganizationResponse,
95
+ description="Retrieve a specific organization by name",
96
+ dependencies=[Depends(auth_handler.require_any_scope("admin", "analyst"))],
97
+)
98
+async def get_organization_by_name(
99
+ org_name: str,
100
+ connector_name: str = Query("Shuffle", description="Name of the Shuffle connector to use")
101
+):
102
+ """
103
+ Retrieve a specific organization by name.
104
+
105
+ Args:
106
+ org_name (str): The organization name to retrieve.
107
+ connector_name (str): Name of the Shuffle connector to use.
108
+
109
+ Returns:
110
+ OrganizationResponse: The organization data.
111
+ """
112
+ logger.info(f"Request to get organization with name: {org_name} using connector: {connector_name}")
113
+
114
+ try:
115
+ organization = await OrganizationsService.get_organization_by_name(org_name, connector_name)
116
+ return OrganizationResponse(
117
+ success=True,
118
+ message=f"Successfully retrieved organization: {organization.name}",
119
+ data=organization
120
+ )
121
+ except HTTPException:
122
+ raise
123
+ except Exception as e:
124
+ logger.error(f"Unexpected error in get_organization_by_name endpoint: {e}")
125
+ raise HTTPException(
126
+ status_code=500,
127
+ detail=f"Internal server error: {str(e)}"
128
+ )
backend/app/connectors/shuffle/schema/organizations.py
new
+68
@@ -0,0 +1,68 @@
1
+from typing import Any
2
+from typing import Dict
3
+from typing import List
4
+from typing import Optional
5
+
6
+from pydantic import BaseModel
7
+from pydantic import Field
8
+
9
+
10
+class SyncConfig(BaseModel):
11
+ interval: int = 0
12
+ api_key: str = ""
13
+ source: str = ""
14
+
15
+
16
+class PartnerInfo(BaseModel):
17
+ reseller: bool = False
18
+ reseller_level: str = ""
19
+
20
+
21
+class SSOConfig(BaseModel):
22
+ sso_entrypoint: str = ""
23
+ sso_certificate: str = ""
24
+ client_id: str = ""
25
+ client_secret: str = ""
26
+ openid_authorization: str = ""
27
+ openid_token: str = ""
28
+
29
+
30
+class Organization(BaseModel):
31
+ name: str
32
+ description: str
33
+ company_type: str = ""
34
+ image: str = ""
35
+ id: str
36
+ org: str
37
+ users: List[Any] = []
38
+ role: str = ""
39
+ roles: List[str] = []
40
+ active_apps: List[str] = []
41
+ cloud_sync: bool = False
42
+ cloud_sync_active: bool = True
43
+ sync_config: SyncConfig = Field(default_factory=SyncConfig)
44
+ sync_features: Dict[str, Any] = Field(default_factory=dict)
45
+ invites: Optional[Any] = None
46
+ child_orgs: List[str] = []
47
+ manager_orgs: Optional[List[str]] = None
48
+ creator_org: Optional[str] = None
49
+ disabled: bool = False
50
+ partner_info: PartnerInfo = Field(default_factory=PartnerInfo)
51
+ sso_config: SSOConfig = Field(default_factory=SSOConfig)
52
+ main_priority: str = ""
53
+ region: str = ""
54
+ region_url: str = ""
55
+ tutorials: List[Any] = []
56
+
57
+
58
+class OrganizationsListResponse(BaseModel):
59
+ success: bool
60
+ message: str
61
+ data: List[Organization]
62
+ total_count: int = Field(description="Total number of organizations")
63
+
64
+
65
+class OrganizationResponse(BaseModel):
66
+ success: bool
67
+ message: str
68
+ data: Optional[Organization] = None
backend/app/connectors/shuffle/services/organizations.py
new
+135
@@ -0,0 +1,135 @@
1
+from typing import Dict
2
+from typing import List
3
+
4
+from fastapi import HTTPException
5
+from loguru import logger
6
+
7
+from app.connectors.shuffle.schema.organizations import Organization
8
+from app.connectors.shuffle.schema.organizations import OrganizationsListResponse
9
+from app.connectors.shuffle.utils.universal import send_get_request
10
+
11
+
12
+class OrganizationsService:
13
+ """Service class for handling Shuffle Organizations operations."""
14
+
15
+ @staticmethod
16
+ async def list_organizations(connector_name: str = "Shuffle") -> OrganizationsListResponse:
17
+ """
18
+ List all organizations from Shuffle.
19
+
20
+ Args:
21
+ connector_name (str, optional): The name of the connector to use. Defaults to "Shuffle".
22
+
23
+ Returns:
24
+ OrganizationsListResponse: Response containing list of organizations.
25
+ """
26
+ logger.info("Fetching organizations from Shuffle")
27
+
28
+ try:
29
+ # Send GET request to Shuffle API
30
+ response = await send_get_request(
31
+ endpoint="/api/v1/orgs",
32
+ connector_name=connector_name
33
+ )
34
+
35
+ if not response.get("success", False):
36
+ logger.error(f"Failed to fetch organizations: {response.get('message', 'Unknown error')}")
37
+ raise HTTPException(
38
+ status_code=500,
39
+ detail=f"Failed to fetch organizations: {response.get('message', 'Unknown error')}"
40
+ )
41
+
42
+ # Parse the response data
43
+ organizations_data = response.get("data", [])
44
+
45
+ if not isinstance(organizations_data, list):
46
+ logger.error("Invalid response format: expected list of organizations")
47
+ raise HTTPException(
48
+ status_code=500,
49
+ detail="Invalid response format from Shuffle API"
50
+ )
51
+
52
+ # Convert to Organization models
53
+ organizations = []
54
+ for org_data in organizations_data:
55
+ try:
56
+ organization = Organization(**org_data)
57
+ organizations.append(organization)
58
+ except Exception as e:
59
+ logger.warning(f"Failed to parse organization data: {org_data}. Error: {e}")
60
+ continue
61
+
62
+ logger.info(f"Successfully fetched {len(organizations)} organizations")
63
+
64
+ return OrganizationsListResponse(
65
+ success=True,
66
+ message=f"Successfully retrieved {len(organizations)} organizations",
67
+ data=organizations,
68
+ total_count=len(organizations)
69
+ )
70
+
71
+ except HTTPException:
72
+ # Re-raise HTTPExceptions as-is
73
+ raise
74
+ except Exception as e:
75
+ logger.error(f"Unexpected error while fetching organizations: {e}")
76
+ raise HTTPException(
77
+ status_code=500,
78
+ detail=f"Unexpected error while fetching organizations: {str(e)}"
79
+ )
80
+
81
+ @staticmethod
82
+ async def get_organization_by_id(org_id: str, connector_name: str = "Shuffle") -> Organization:
83
+ """
84
+ Get a specific organization by ID.
85
+
86
+ Args:
87
+ org_id (str): The organization ID to retrieve.
88
+ connector_name (str, optional): The name of the connector to use. Defaults to "Shuffle".
89
+
90
+ Returns:
91
+ Organization: The organization data.
92
+ """
93
+ logger.info(f"Fetching organization with ID: {org_id}")
94
+
95
+ # Get all organizations and filter by ID
96
+ organizations_response = await OrganizationsService.list_organizations(connector_name)
97
+
98
+ for org in organizations_response.data:
99
+ if org.id == org_id:
100
+ logger.info(f"Found organization: {org.name}")
101
+ return org
102
+
103
+ logger.error(f"Organization with ID {org_id} not found")
104
+ raise HTTPException(
105
+ status_code=404,
106
+ detail=f"Organization with ID {org_id} not found"
107
+ )
108
+
109
+ @staticmethod
110
+ async def get_organization_by_name(org_name: str, connector_name: str = "Shuffle") -> Organization:
111
+ """
112
+ Get a specific organization by name.
113
+
114
+ Args:
115
+ org_name (str): The organization name to retrieve.
116
+ connector_name (str, optional): The name of the connector to use. Defaults to "Shuffle".
117
+
118
+ Returns:
119
+ Organization: The organization data.
120
+ """
121
+ logger.info(f"Fetching organization with name: {org_name}")
122
+
123
+ # Get all organizations and filter by name
124
+ organizations_response = await OrganizationsService.list_organizations(connector_name)
125
+
126
+ for org in organizations_response.data:
127
+ if org.name.lower() == org_name.lower():
128
+ logger.info(f"Found organization: {org.name} (ID: {org.id})")
129
+ return org
130
+
131
+ logger.error(f"Organization with name '{org_name}' not found")
132
+ raise HTTPException(
133
+ status_code=404,
134
+ detail=f"Organization with name '{org_name}' not found"
135
+ )
backend/app/connectors/shuffle/services/singul.py
+26
-18
@@ -1,9 +1,7 @@
1
from loguru import logger
2
-from shufflepy import Singul
2
3
from app.connectors.shuffle.schema.singul import SingulRequest
5
-
6
-singul = Singul(auth="REPLACE_ME", url="https://california.shuffler.io")
4
+from app.connectors.shuffle.utils.universal import get_singul_client
5
6
7
async def execute_singul(
@@ -13,23 +11,33 @@ async def execute_singul(
11
Execute a Singul integration.
12
13
Args:
16
- request (IntegrationRequest): The request object containing the workflow ID.
14
+ request (SingulRequest): The request object containing the workflow ID.
15
16
Returns:
17
dict: The response containing the execution ID.
18
"""
19
logger.info("Executing Singul integration")
22
- response = singul.communication.send_message(
23
- app=request.app,
24
- fields=[
25
- {"key": "to", "value": "walton.taylor23@gmail.com"},
26
- {"key": "subject", "value": "Test Email from Singul"},
27
- {"key": "body", "value": "This is a test email sent from Singul."},
28
- ],
29
- )
30
- logger.info(f"Singul response: {response}")
31
- logger.info(f"Singul response: {response.get('success', 'unknown')}")
32
- return {
33
- "executionId": response.get("id", "unknown"),
34
- "message": "Singul integration executed successfully",
35
- }
20
+
21
+ # Get Singul client from database credentials
22
+ singul = await get_singul_client()
23
+
24
+ try:
25
+ response = singul.communication.send_message(
26
+ app=request.app,
27
+ org_id=request.org_id,
28
+ fields=[
29
+ {"key": "to", "value": "walton.taylor23@gmail.com"},
30
+ {"key": "subject", "value": "Test Email from Singul"},
31
+ {"key": "body", "value": "This is a test email sent from Singul."},
32
+ ],
33
+ )
34
+ logger.info(f"Singul response: {response}")
35
+ logger.info(f"Singul response success: {response.get('success', 'unknown')}")
36
+
37
+ return {
38
+ "executionId": response.get("id", "unknown"),
39
+ "message": "Singul integration executed successfully",
40
+ }
41
+ except Exception as e:
42
+ logger.error(f"Failed to execute Singul integration: {e}")
43
+ return {"executionId": "unknown", "message": f"Singul integration failed: {e}", "success": False}
backend/app/connectors/shuffle/utils/universal.py
+28
@@ -5,6 +5,7 @@ from typing import Optional
5
import requests
6
from fastapi import HTTPException
7
from loguru import logger
8
+from shufflepy import Singul
9
10
from app.connectors.utils import get_connector_info_from_db
11
from app.db.db_session import get_db_session
@@ -277,3 +278,30 @@ def send_put_request(
278
status_code=500,
279
detail=f"Failed to send PUT request to {endpoint} with error: {e}",
280
)
281
+
282
+
283
+async def get_singul_client(connector_name: str = "Shuffle") -> Singul:
284
+ """
285
+ Create and return a Singul client using database credentials.
286
+
287
+ Args:
288
+ connector_name (str, optional): The name of the connector to use. Defaults to "Shuffle".
289
+
290
+ Returns:
291
+ Singul: An initialized Singul client instance.
292
+ """
293
+ logger.info("Creating Singul client from database credentials")
294
+ async with get_db_session() as session:
295
+ attributes = await get_connector_info_from_db(connector_name, session)
296
+
297
+ if attributes is None:
298
+ logger.error("No Shuffle connector found in the database")
299
+ raise HTTPException(status_code=404, detail="Shuffle connector not found in database")
300
+
301
+ try:
302
+ singul_client = Singul(auth=attributes["connector_api_key"], url=attributes["connector_url"])
303
+ logger.info(f"Singul client created successfully for {attributes['connector_url']}")
304
+ return singul_client
305
+ except Exception as e:
306
+ logger.error(f"Failed to create Singul client: {e}")
307
+ raise HTTPException(status_code=500, detail=f"Failed to create Singul client: {e}")
backend/app/routers/shuffle.py
+7
@@ -1,6 +1,7 @@
1
from fastapi import APIRouter
2
3
from app.connectors.shuffle.routes.integrations import shuffle_integrations_router
4
+from app.connectors.shuffle.routes.organizations import shuffle_organizations_router
5
from app.connectors.shuffle.routes.singul import shuffle_singul_router
6
from app.connectors.shuffle.routes.workflows import shuffle_workflows_router
7
@@ -25,3 +26,9 @@ router.include_router(
26
prefix="/shuffle/singul",
27
tags=["shuffle-singul"],
28
)
29
+
30
+router.include_router(
31
+ shuffle_organizations_router,
32
+ prefix="/shuffle/organizations",
33
+ tags=["shuffle-organizations"],
34
+)