dyn options in api
This commit is contained in:
parent
362080791a
commit
f02ebead7c
7 changed files with 187 additions and 397 deletions
|
|
@ -100,11 +100,11 @@ registerModelLabels(
|
||||||
class UserConnection(BaseModel):
|
class UserConnection(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "auth.authority"})
|
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"})
|
||||||
externalId: str = Field(description="User ID in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
externalId: str = Field(description="User ID in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
externalUsername: str = Field(description="Username in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
externalUsername: str = Field(description="Username in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
||||||
externalEmail: Optional[EmailStr] = Field(None, description="Email in the external system", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False})
|
externalEmail: Optional[EmailStr] = Field(None, description="Email in the external system", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False})
|
||||||
status: ConnectionStatus = Field(default=ConnectionStatus.ACTIVE, description="Connection status", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": "connection.status"})
|
status: ConnectionStatus = Field(default=ConnectionStatus.ACTIVE, description="Connection status", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": "/api/connections/statuses/options"})
|
||||||
connectedAt: float = Field(default_factory=getUtcTimestamp, description="When the connection was established (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
connectedAt: float = Field(default_factory=getUtcTimestamp, description="When the connection was established (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
||||||
lastChecked: float = Field(default_factory=getUtcTimestamp, description="When the connection was last verified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
lastChecked: float = Field(default_factory=getUtcTimestamp, description="When the connection was last verified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
||||||
expiresAt: Optional[float] = Field(None, description="When the connection expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
expiresAt: Optional[float] = Field(None, description="When the connection expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
|
@ -198,7 +198,7 @@ class User(BaseModel):
|
||||||
authenticationAuthority: AuthAuthority = Field(
|
authenticationAuthority: AuthAuthority = Field(
|
||||||
default=AuthAuthority.LOCAL,
|
default=AuthAuthority.LOCAL,
|
||||||
description="Primary authentication authority",
|
description="Primary authentication authority",
|
||||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "auth.authority"}
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ class TrusteeAccess(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteeOrganisation"
|
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
roleId: str = Field(
|
roleId: str = Field(
|
||||||
|
|
@ -147,7 +147,7 @@ class TrusteeAccess(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteeRole"
|
"frontend_options": "/api/trustee/{instanceId}/roles/options"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
|
|
@ -156,7 +156,7 @@ class TrusteeAccess(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "User"
|
"frontend_options": "/api/users/options"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
contractId: Optional[str] = Field(
|
contractId: Optional[str] = Field(
|
||||||
|
|
@ -166,7 +166,7 @@ class TrusteeAccess(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"frontend_options": "TrusteeContract",
|
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
|
||||||
"frontend_depends_on": "organisationId"
|
"frontend_depends_on": "organisationId"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -223,7 +223,7 @@ class TrusteeContract(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False, # Editable at creation, then readonly
|
"frontend_readonly": False, # Editable at creation, then readonly
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteeOrganisation"
|
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
label: str = Field(
|
label: str = Field(
|
||||||
|
|
@ -295,7 +295,7 @@ class TrusteeDocument(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteeOrganisation"
|
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
contractId: str = Field(
|
contractId: str = Field(
|
||||||
|
|
@ -304,7 +304,7 @@ class TrusteeDocument(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteeContract",
|
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
|
||||||
"frontend_depends_on": "organisationId"
|
"frontend_depends_on": "organisationId"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -394,7 +394,7 @@ class TrusteePosition(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteeOrganisation"
|
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
contractId: str = Field(
|
contractId: str = Field(
|
||||||
|
|
@ -403,7 +403,7 @@ class TrusteePosition(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteeContract",
|
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
|
||||||
"frontend_depends_on": "organisationId"
|
"frontend_depends_on": "organisationId"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -580,7 +580,7 @@ class TrusteePositionDocument(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteeOrganisation"
|
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
contractId: str = Field(
|
contractId: str = Field(
|
||||||
|
|
@ -589,7 +589,7 @@ class TrusteePositionDocument(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteeContract",
|
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
|
||||||
"frontend_depends_on": "organisationId"
|
"frontend_depends_on": "organisationId"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -599,7 +599,7 @@ class TrusteePositionDocument(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteeDocument",
|
"frontend_options": "/api/trustee/{instanceId}/documents/options",
|
||||||
"frontend_depends_on": "contractId"
|
"frontend_depends_on": "contractId"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -609,7 +609,7 @@ class TrusteePositionDocument(BaseModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "TrusteePosition",
|
"frontend_options": "/api/trustee/{instanceId}/positions/options",
|
||||||
"frontend_depends_on": "contractId"
|
"frontend_depends_on": "contractId"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,89 @@ async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> s
|
||||||
return str(instance.mandateId)
|
return str(instance.mandateId)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# OPTIONS ENDPOINTS (for dropdowns)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/organisations/options", response_model=List[Dict[str, Any]])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getOrganisationOptions(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get organisation options for select dropdowns. Returns: [{ value, label }]"""
|
||||||
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
result = interface.getAllOrganisations(None)
|
||||||
|
items = result.items if hasattr(result, 'items') else result
|
||||||
|
return [{"value": org.id, "label": org.label or org.id} for org in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/roles/options", response_model=List[Dict[str, Any]])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getRoleOptions(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get role options for select dropdowns. Returns: [{ value, label }]"""
|
||||||
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
result = interface.getAllRoles(None)
|
||||||
|
items = result.items if hasattr(result, 'items') else result
|
||||||
|
return [{"value": role.id, "label": role.desc or role.id} for role in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/contracts/options", response_model=List[Dict[str, Any]])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getContractOptions(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get contract options for select dropdowns. Returns: [{ value, label }]"""
|
||||||
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
result = interface.getAllContracts(None)
|
||||||
|
items = result.items if hasattr(result, 'items') else result
|
||||||
|
return [{"value": c.id, "label": c.label or c.name or c.id} for c in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/documents/options", response_model=List[Dict[str, Any]])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getDocumentOptions(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get document options for select dropdowns. Returns: [{ value, label }]"""
|
||||||
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
result = interface.getAllDocuments(None)
|
||||||
|
items = result.items if hasattr(result, 'items') else result
|
||||||
|
return [{"value": d.id, "label": d.name or d.id} for d in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/positions/options", response_model=List[Dict[str, Any]])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getPositionOptions(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get position options for select dropdowns. Returns: [{ value, label }]"""
|
||||||
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
result = interface.getAllPositions(None)
|
||||||
|
items = result.items if hasattr(result, 'items') else result
|
||||||
|
return [{"value": p.id, "label": p.title or p.id} for p in items]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CRUD ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
# ===== Organisation Routes =====
|
# ===== Organisation Routes =====
|
||||||
|
|
||||||
@router.get("/{instanceId}/organisations", response_model=PaginatedResponse[TrusteeOrganisation])
|
@router.get("/{instanceId}/organisations", response_model=PaginatedResponse[TrusteeOrganisation])
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,52 @@ router = APIRouter(
|
||||||
responses={404: {"description": "Not found"}}
|
responses={404: {"description": "Not found"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# OPTIONS ENDPOINTS (for dropdowns)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/statuses/options", response_model=List[Dict[str, Any]])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getConnectionStatusOptions(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get connection status options for select dropdowns.
|
||||||
|
Returns standardized format: [{ value, label }]
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{"value": status.value, "label": status.value.capitalize()}
|
||||||
|
for status in ConnectionStatus
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authorities/options", response_model=List[Dict[str, Any]])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getAuthAuthorityOptions(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get authentication authority options for select dropdowns.
|
||||||
|
Returns standardized format: [{ value, label }]
|
||||||
|
"""
|
||||||
|
authorityLabels = {
|
||||||
|
"local": "Local",
|
||||||
|
"google": "Google",
|
||||||
|
"msft": "Microsoft"
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{"value": auth.value, "label": authorityLabels.get(auth.value, auth.value)}
|
||||||
|
for auth in AuthAuthority
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CRUD ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
@router.get("/", response_model=PaginatedResponse[UserConnection])
|
@router.get("/", response_model=PaginatedResponse[UserConnection])
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def get_connections(
|
async def get_connections(
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,48 @@ router = APIRouter(
|
||||||
responses={404: {"description": "Not found"}}
|
responses={404: {"description": "Not found"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# OPTIONS ENDPOINTS (for dropdowns)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/options", response_model=List[Dict[str, Any]])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getUserOptions(
|
||||||
|
request: Request,
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get user options for select dropdowns.
|
||||||
|
MULTI-TENANT: mandateId from X-Mandate-Id header determines scope.
|
||||||
|
Returns standardized format: [{ value, label }]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
appInterface = interfaceDbApp.getInterface(context.user)
|
||||||
|
|
||||||
|
if context.mandateId:
|
||||||
|
result = appInterface.getUsersByMandate(str(context.mandateId), None)
|
||||||
|
users = result.items if hasattr(result, 'items') else result
|
||||||
|
elif context.isSysAdmin:
|
||||||
|
users = appInterface.getAllUsers()
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"value": user.id, "label": user.fullName or user.username or user.email or user.id}
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user options: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get user options: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CRUD ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
@router.get("/", response_model=PaginatedResponse[User])
|
@router.get("/", response_model=PaginatedResponse[User])
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def get_users(
|
async def get_users(
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""
|
|
||||||
Type definitions and utilities for frontend_options attribute.
|
|
||||||
|
|
||||||
The frontend_options attribute supports two formats:
|
|
||||||
1. Static List: A list of option dictionaries for static options
|
|
||||||
2. String Reference: A string identifier that references dynamic options from /api/options/{optionsName}
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import List, Dict, Any, Union
|
|
||||||
|
|
||||||
try:
|
|
||||||
from typing import TypeAlias # Python 3.10+
|
|
||||||
except ImportError:
|
|
||||||
from typing_extensions import TypeAlias # Python < 3.10
|
|
||||||
|
|
||||||
# Type definition for a single option item
|
|
||||||
OptionItem: TypeAlias = Dict[str, Any]
|
|
||||||
"""
|
|
||||||
Single option item format:
|
|
||||||
{
|
|
||||||
"value": str, # The value to be stored/returned
|
|
||||||
"label": { # Multilingual labels
|
|
||||||
"en": str,
|
|
||||||
"fr": str,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Type definition for frontend_options - can be either a list or string reference
|
|
||||||
FrontendOptions: TypeAlias = Union[List[OptionItem], str]
|
|
||||||
"""
|
|
||||||
frontend_options can be either:
|
|
||||||
1. List[OptionItem]: Static list of options
|
|
||||||
Example: [{"value": "a", "label": {"en": "All", "fr": "Tous"}}]
|
|
||||||
|
|
||||||
2. str: String reference to dynamic options API
|
|
||||||
Example: "user.role" -> Frontend fetches from /api/options/user.role
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def isStringReference(frontendOptions: FrontendOptions) -> bool:
|
|
||||||
"""
|
|
||||||
Check if frontend_options is a string reference (dynamic) or a list (static).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frontendOptions: The frontend_options value to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if it's a string reference, False if it's a list
|
|
||||||
"""
|
|
||||||
return isinstance(frontendOptions, str)
|
|
||||||
|
|
||||||
|
|
||||||
def isStaticList(frontendOptions: FrontendOptions) -> bool:
|
|
||||||
"""
|
|
||||||
Check if frontend_options is a static list or a string reference.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frontendOptions: The frontend_options value to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if it's a static list, False if it's a string reference
|
|
||||||
"""
|
|
||||||
return isinstance(frontendOptions, list)
|
|
||||||
|
|
||||||
|
|
||||||
def validateFrontendOptions(frontendOptions: FrontendOptions) -> bool:
|
|
||||||
"""
|
|
||||||
Validate that frontend_options is in the correct format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frontendOptions: The frontend_options value to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if valid, False otherwise
|
|
||||||
"""
|
|
||||||
if isinstance(frontendOptions, str):
|
|
||||||
# String reference: should be a non-empty string
|
|
||||||
return bool(frontendOptions.strip())
|
|
||||||
|
|
||||||
elif isinstance(frontendOptions, list):
|
|
||||||
# Static list: should contain option dictionaries
|
|
||||||
if not frontendOptions:
|
|
||||||
return True # Empty list is valid (no options)
|
|
||||||
|
|
||||||
for option in frontendOptions:
|
|
||||||
if not isinstance(option, dict):
|
|
||||||
return False
|
|
||||||
if "value" not in option:
|
|
||||||
return False
|
|
||||||
if "label" not in option:
|
|
||||||
return False
|
|
||||||
if not isinstance(option["label"], dict):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def getOptionsName(frontendOptions: FrontendOptions) -> str:
|
|
||||||
"""
|
|
||||||
Get the options name from a string reference.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frontendOptions: The frontend_options value (must be a string reference)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The options name (e.g., "user.role")
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If frontendOptions is not a string reference
|
|
||||||
"""
|
|
||||||
if not isStringReference(frontendOptions):
|
|
||||||
raise ValueError(f"frontend_options is not a string reference: {type(frontendOptions)}")
|
|
||||||
return frontendOptions
|
|
||||||
|
|
||||||
|
|
||||||
def getStaticOptions(frontendOptions: FrontendOptions) -> List[OptionItem]:
|
|
||||||
"""
|
|
||||||
Get the static options list.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frontendOptions: The frontend_options value (must be a static list)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The list of option items
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If frontendOptions is not a static list
|
|
||||||
"""
|
|
||||||
if not isStaticList(frontendOptions):
|
|
||||||
raise ValueError(f"frontend_options is not a static list: {type(frontendOptions)}")
|
|
||||||
return frontendOptions
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""
|
|
||||||
Integration tests for Options API endpoints.
|
|
||||||
Tests the actual API endpoints with real database connections.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import secrets
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from modules.datamodels.datamodelUam import User
|
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app():
|
|
||||||
"""Create FastAPI app instance for testing."""
|
|
||||||
from app import app as fastapi_app
|
|
||||||
return fastapi_app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def testClient(app):
|
|
||||||
"""Create test client for API testing."""
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def csrfToken():
|
|
||||||
"""Generate a valid CSRF token for testing."""
|
|
||||||
# Generate a hex string between 16-64 characters (CSRF validation requirement)
|
|
||||||
return secrets.token_hex(16) # 32 character hex string
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def testUser() -> User:
|
|
||||||
"""Create a test user for API testing."""
|
|
||||||
# Use getRootInterface for system operations like user creation
|
|
||||||
# The root interface automatically uses the root mandate
|
|
||||||
rootInterface = getRootInterface()
|
|
||||||
user = rootInterface.createUser(
|
|
||||||
username="testuser_options",
|
|
||||||
email="testuser_options@example.com",
|
|
||||||
password="testpass123",
|
|
||||||
roleLabels=["user"]
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
class TestOptionsAPI:
|
|
||||||
"""Test Options API endpoints."""
|
|
||||||
|
|
||||||
def testGetOptionsUserRole(self, testClient, testUser, csrfToken):
|
|
||||||
"""Test GET /api/options/user.role endpoint."""
|
|
||||||
# Get auth token (stored in cookie)
|
|
||||||
response = testClient.post(
|
|
||||||
"/api/local/login",
|
|
||||||
data={"username": testUser.username, "password": "testpass123"},
|
|
||||||
headers={"X-CSRF-Token": csrfToken}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Extract token from cookie for Bearer header
|
|
||||||
token = response.cookies.get("auth_token")
|
|
||||||
assert token is not None
|
|
||||||
|
|
||||||
# Get options
|
|
||||||
response = testClient.get(
|
|
||||||
"/api/options/user.role",
|
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
options = response.json()
|
|
||||||
|
|
||||||
assert isinstance(options, list)
|
|
||||||
assert len(options) >= 4 # At least sysadmin, admin, user, viewer
|
|
||||||
|
|
||||||
# Check structure
|
|
||||||
for option in options:
|
|
||||||
assert "value" in option
|
|
||||||
assert "label" in option
|
|
||||||
assert isinstance(option["label"], dict)
|
|
||||||
|
|
||||||
# Check specific values
|
|
||||||
values = [opt["value"] for opt in options]
|
|
||||||
assert "sysadmin" in values
|
|
||||||
assert "admin" in values
|
|
||||||
assert "user" in values
|
|
||||||
assert "viewer" in values
|
|
||||||
|
|
||||||
def testGetOptionsAuthAuthority(self, testClient, testUser, csrfToken):
|
|
||||||
"""Test GET /api/options/auth.authority endpoint."""
|
|
||||||
# Get auth token (stored in cookie)
|
|
||||||
response = testClient.post(
|
|
||||||
"/api/local/login",
|
|
||||||
data={"username": testUser.username, "password": "testpass123"},
|
|
||||||
headers={"X-CSRF-Token": csrfToken}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Extract token from cookie for Bearer header
|
|
||||||
token = response.cookies.get("auth_token")
|
|
||||||
assert token is not None
|
|
||||||
|
|
||||||
# Get options
|
|
||||||
response = testClient.get(
|
|
||||||
"/api/options/auth.authority",
|
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
options = response.json()
|
|
||||||
|
|
||||||
assert isinstance(options, list)
|
|
||||||
assert len(options) == 3 # local, google, msft
|
|
||||||
|
|
||||||
# Check structure
|
|
||||||
for option in options:
|
|
||||||
assert "value" in option
|
|
||||||
assert "label" in option
|
|
||||||
|
|
||||||
# Check specific values
|
|
||||||
values = [opt["value"] for opt in options]
|
|
||||||
assert "local" in values
|
|
||||||
assert "google" in values
|
|
||||||
assert "msft" in values
|
|
||||||
|
|
||||||
def testGetOptionsConnectionStatus(self, testClient, testUser, csrfToken):
|
|
||||||
"""Test GET /api/options/connection.status endpoint."""
|
|
||||||
# Get auth token (stored in cookie)
|
|
||||||
response = testClient.post(
|
|
||||||
"/api/local/login",
|
|
||||||
data={"username": testUser.username, "password": "testpass123"},
|
|
||||||
headers={"X-CSRF-Token": csrfToken}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Extract token from cookie for Bearer header
|
|
||||||
token = response.cookies.get("auth_token")
|
|
||||||
assert token is not None
|
|
||||||
|
|
||||||
# Get options
|
|
||||||
response = testClient.get(
|
|
||||||
"/api/options/connection.status",
|
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
options = response.json()
|
|
||||||
|
|
||||||
assert isinstance(options, list)
|
|
||||||
assert len(options) >= 4 # active, inactive, expired, pending, revoked, error
|
|
||||||
|
|
||||||
# Check structure
|
|
||||||
for option in options:
|
|
||||||
assert "value" in option
|
|
||||||
assert "label" in option
|
|
||||||
|
|
||||||
def testGetOptionsUserConnection(self, testClient, testUser, csrfToken):
|
|
||||||
"""Test GET /api/options/user.connection endpoint (context-aware)."""
|
|
||||||
# Get auth token (stored in cookie)
|
|
||||||
response = testClient.post(
|
|
||||||
"/api/local/login",
|
|
||||||
data={"username": testUser.username, "password": "testpass123"},
|
|
||||||
headers={"X-CSRF-Token": csrfToken}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Extract token from cookie for Bearer header
|
|
||||||
token = response.cookies.get("auth_token")
|
|
||||||
assert token is not None
|
|
||||||
|
|
||||||
# Get options (should return empty list if no connections)
|
|
||||||
response = testClient.get(
|
|
||||||
"/api/options/user.connection",
|
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
options = response.json()
|
|
||||||
|
|
||||||
# Should return a list (may be empty)
|
|
||||||
assert isinstance(options, list)
|
|
||||||
|
|
||||||
def testGetOptionsList(self, testClient, testUser, csrfToken):
|
|
||||||
"""Test GET /api/options/ endpoint (list all available options)."""
|
|
||||||
# Get auth token (stored in cookie)
|
|
||||||
response = testClient.post(
|
|
||||||
"/api/local/login",
|
|
||||||
data={"username": testUser.username, "password": "testpass123"},
|
|
||||||
headers={"X-CSRF-Token": csrfToken}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Extract token from cookie for Bearer header
|
|
||||||
token = response.cookies.get("auth_token")
|
|
||||||
assert token is not None
|
|
||||||
|
|
||||||
# Get available options names
|
|
||||||
response = testClient.get(
|
|
||||||
"/api/options/",
|
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
optionsNames = response.json()
|
|
||||||
|
|
||||||
assert isinstance(optionsNames, list)
|
|
||||||
assert "user.role" in optionsNames
|
|
||||||
assert "auth.authority" in optionsNames
|
|
||||||
assert "connection.status" in optionsNames
|
|
||||||
assert "user.connection" in optionsNames
|
|
||||||
|
|
||||||
def testGetOptionsUnknown(self, testClient, testUser, csrfToken):
|
|
||||||
"""Test GET /api/options/unknown.options endpoint (should return 400)."""
|
|
||||||
# Get auth token (stored in cookie)
|
|
||||||
response = testClient.post(
|
|
||||||
"/api/local/login",
|
|
||||||
data={"username": testUser.username, "password": "testpass123"},
|
|
||||||
headers={"X-CSRF-Token": csrfToken}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Extract token from cookie for Bearer header
|
|
||||||
token = response.cookies.get("auth_token")
|
|
||||||
assert token is not None
|
|
||||||
|
|
||||||
# Get unknown options (should return error)
|
|
||||||
response = testClient.get(
|
|
||||||
"/api/options/unknown.options",
|
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def testGetOptionsUnauthorized(self, testClient):
|
|
||||||
"""Test GET /api/options/user.role without authentication."""
|
|
||||||
# Try to get options without auth token
|
|
||||||
response = testClient.get("/api/options/user.role")
|
|
||||||
|
|
||||||
# Should require authentication
|
|
||||||
assert response.status_code == 401
|
|
||||||
Loading…
Reference in a new issue