dead endpoint cleanup + admin sessions UX: remove unused endpoints (billing/balances, files/attributes, files/bulk, voice-google duplicates, workflow-automation/runs, jobs list), fix billing transaction filter for enriched columns, admin sessions use revokeTokenById instead of delete, add per-device revoke endpoint, add sessions nav item
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
29de7e9915
commit
66b44e5c78
7 changed files with 81 additions and 577 deletions
|
|
@ -320,6 +320,16 @@ NAVIGATION_SECTIONS = [
|
|||
"adminOnly": True,
|
||||
"sysAdminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-sessions",
|
||||
"objectKey": "ui.admin.sessions",
|
||||
"label": t("Sessions & Geräte"),
|
||||
"icon": "FaDesktop",
|
||||
"path": "/admin/sessions",
|
||||
"order": 92,
|
||||
"adminOnly": True,
|
||||
"sysAdminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-languages",
|
||||
"objectKey": "ui.admin.languages",
|
||||
|
|
|
|||
|
|
@ -81,23 +81,28 @@ def revokeSession(
|
|||
sessionId: str,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Revoke a single session by sessionId."""
|
||||
"""Revoke a single session by sessionId (sets status=REVOKED, not delete)."""
|
||||
_requireAdmin(currentUser)
|
||||
rootInterface = getRootInterface()
|
||||
adminId = str(currentUser.id)
|
||||
|
||||
tokens = rootInterface.db.getRecordset(
|
||||
Token,
|
||||
recordFilter={"sessionId": sessionId, "tokenPurpose": TokenPurpose.AUTH_SESSION.value},
|
||||
recordFilter={
|
||||
"sessionId": sessionId,
|
||||
"tokenPurpose": TokenPurpose.AUTH_SESSION.value,
|
||||
"status": TokenStatus.ACTIVE.value,
|
||||
},
|
||||
)
|
||||
count = 0
|
||||
for t in tokens:
|
||||
rootInterface.db.recordDelete(Token, t["id"])
|
||||
rootInterface.revokeTokenById(t["id"], revokedBy=adminId, reason="admin session revoke")
|
||||
count += 1
|
||||
|
||||
if count == 0:
|
||||
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
|
||||
|
||||
logger.info(f"Admin {currentUser.username} revoked session {sessionId} ({count} token(s))")
|
||||
logger.info("Admin %s revoked session %s (%d token(s))", currentUser.username, sessionId, count)
|
||||
return {"revoked": count, "sessionId": sessionId}
|
||||
|
||||
|
||||
|
|
@ -111,20 +116,13 @@ def revokeAllSessions(
|
|||
"""Revoke ALL active sessions for a user (force logout everywhere)."""
|
||||
_requireAdmin(currentUser)
|
||||
rootInterface = getRootInterface()
|
||||
adminId = str(currentUser.id)
|
||||
|
||||
tokens = rootInterface.db.getRecordset(
|
||||
Token,
|
||||
recordFilter={
|
||||
"userId": userId,
|
||||
"tokenPurpose": TokenPurpose.AUTH_SESSION.value,
|
||||
},
|
||||
count = rootInterface.revokeTokensByUser(
|
||||
userId, revokedBy=adminId, reason="admin revoke all sessions",
|
||||
)
|
||||
count = 0
|
||||
for t in tokens:
|
||||
rootInterface.db.recordDelete(Token, t["id"])
|
||||
count += 1
|
||||
|
||||
logger.info(f"Admin {currentUser.username} revoked all sessions for userId={userId} ({count} token(s))")
|
||||
logger.info("Admin %s revoked all sessions for userId=%s (%d token(s))", currentUser.username, userId, count)
|
||||
return {"revoked": count, "userId": userId}
|
||||
|
||||
|
||||
|
|
@ -156,7 +154,7 @@ def listTrustedDevices(
|
|||
result = []
|
||||
for d in devices:
|
||||
result.append({
|
||||
"id": d.get("id", "")[:8] + "...",
|
||||
"id": d.get("id", ""),
|
||||
"trustedUntil": d.get("trustedUntil"),
|
||||
"isExpired": d.get("trustedUntil", 0) < now,
|
||||
"userAgent": d.get("userAgent"),
|
||||
|
|
@ -167,6 +165,26 @@ def listTrustedDevices(
|
|||
return result
|
||||
|
||||
|
||||
@trustedDeviceRouter.delete("/{deviceId}")
|
||||
@limiter.limit("30/minute")
|
||||
def revokeTrustedDevice(
|
||||
request: Request,
|
||||
deviceId: str,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Revoke a single trusted device by ID."""
|
||||
_requireAdmin(currentUser)
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
existing = rootInterface.db.getRecord(TrustedDevice, deviceId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=routeApiMsg("Trusted device not found"))
|
||||
|
||||
rootInterface.db.recordDelete(TrustedDevice, deviceId)
|
||||
logger.info("Admin %s revoked trusted device %s", currentUser.username, deviceId)
|
||||
return {"revoked": 1, "deviceId": deviceId}
|
||||
|
||||
|
||||
@trustedDeviceRouter.delete("")
|
||||
@limiter.limit("10/minute")
|
||||
def revokeAllTrustedDevices(
|
||||
|
|
@ -181,5 +199,5 @@ def revokeAllTrustedDevices(
|
|||
from modules.auth.trustedDeviceService import revokeTrustedDevices
|
||||
count = revokeTrustedDevices(userId, rootInterface.db)
|
||||
|
||||
logger.info(f"Admin {currentUser.username} revoked all trusted devices for userId={userId} ({count})")
|
||||
logger.info("Admin %s revoked all trusted devices for userId=%s (%d)", currentUser.username, userId, count)
|
||||
return {"revoked": count, "userId": userId}
|
||||
|
|
|
|||
|
|
@ -290,19 +290,6 @@ class MandateBalanceResponse(BaseModel):
|
|||
warningThresholdPercent: float
|
||||
|
||||
|
||||
class UserBalanceResponse(BaseModel):
|
||||
"""User-level balance summary."""
|
||||
accountId: str
|
||||
mandateId: str
|
||||
mandateName: str
|
||||
userId: str
|
||||
userName: str
|
||||
balance: float
|
||||
warningThreshold: float
|
||||
isWarning: bool
|
||||
enabled: bool
|
||||
|
||||
|
||||
class UserTransactionResponse(BaseModel):
|
||||
"""User-level transaction with user context."""
|
||||
id: str
|
||||
|
|
@ -429,10 +416,6 @@ def _normalize_billing_tx_dict(t: Dict[str, Any]) -> Dict[str, Any]:
|
|||
return r
|
||||
|
||||
|
||||
def _load_billing_user_transactions_normalized(billingService) -> List[Dict[str, Any]]:
|
||||
raw = billingService.getTransactionHistory(limit=5000)
|
||||
return [_normalize_billing_tx_dict(t) for t in raw]
|
||||
|
||||
|
||||
def _view_user_transactions_filtered_list(
|
||||
billing_interface,
|
||||
|
|
@ -464,147 +447,6 @@ def _view_user_transactions_filtered_list(
|
|||
return all_items
|
||||
|
||||
|
||||
@router.get("/transactions")
|
||||
@limiter.limit("30/minute")
|
||||
def getTransactions(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
pagination: Optional[str] = Query(
|
||||
None,
|
||||
description="JSON PaginationParams for table UI (filters, sort, viewKey, groupByLevels).",
|
||||
),
|
||||
mode: Optional[str] = Query(None, description="'filterValues' | 'ids' with pagination"),
|
||||
column: Optional[str] = Query(None, description="Column for mode=filterValues"),
|
||||
ctx: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""
|
||||
Get transaction history across all mandates the user belongs to.
|
||||
|
||||
Without ``pagination`` query: legacy behaviour — returns a JSON array of
|
||||
transactions (`limit`/`offset` window).
|
||||
|
||||
With ``pagination`` JSON: returns ``{ items, pagination, groupLayout?, appliedView? }``.
|
||||
Table list views use contextKey ``billing/transactions``.
|
||||
"""
|
||||
try:
|
||||
billingService = getBillingService(
|
||||
ctx.user,
|
||||
ctx.mandateId,
|
||||
featureCode="billing",
|
||||
)
|
||||
|
||||
if pagination:
|
||||
from modules.interfaces.interfaceTableHelpers import (
|
||||
applyViewToParams,
|
||||
buildGroupLayout,
|
||||
effective_group_by_levels,
|
||||
resolveView,
|
||||
)
|
||||
from modules.dbHelpers.paginationHelpers import (
|
||||
handleFilterValuesInMemory,
|
||||
handleIdsInMemory,
|
||||
)
|
||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||
|
||||
CONTEXT_KEY = "billing/transactions"
|
||||
|
||||
try:
|
||||
paginationDict = json.loads(pagination)
|
||||
if not paginationDict:
|
||||
raise ValueError("empty pagination")
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
paginationParams = PaginationParams(**paginationDict)
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||
|
||||
appInterface = getAppInterface(ctx.user)
|
||||
viewKey = paginationParams.viewKey
|
||||
viewConfig, viewDisplayName = resolveView(appInterface, CONTEXT_KEY, viewKey)
|
||||
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
|
||||
paginationParams = applyViewToParams(paginationParams, viewConfig)
|
||||
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
|
||||
|
||||
all_items = _load_billing_user_transactions_normalized(billingService)
|
||||
|
||||
if mode == "filterValues":
|
||||
if not column:
|
||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||
return handleFilterValuesInMemory(all_items, column, pagination)
|
||||
|
||||
if mode == "ids":
|
||||
return handleIdsInMemory(all_items, pagination)
|
||||
|
||||
comp = ComponentObjects()
|
||||
comp.setUserContext(ctx.user)
|
||||
if paginationParams.filters:
|
||||
all_items = comp._applyFilters(all_items, paginationParams.filters)
|
||||
if paginationParams.sort:
|
||||
all_items = comp._applySorting(all_items, paginationParams.sort)
|
||||
|
||||
totalItems = len(all_items)
|
||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||
|
||||
if not groupByLevels:
|
||||
pstart = (paginationParams.page - 1) * paginationParams.pageSize
|
||||
page_items = all_items[pstart : pstart + paginationParams.pageSize]
|
||||
group_layout = None
|
||||
else:
|
||||
page_items, group_layout = buildGroupLayout(
|
||||
all_items,
|
||||
groupByLevels,
|
||||
paginationParams.page,
|
||||
paginationParams.pageSize,
|
||||
)
|
||||
|
||||
resp: Dict[str, Any] = {
|
||||
"items": page_items,
|
||||
"pagination": PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters,
|
||||
).model_dump(),
|
||||
}
|
||||
if group_layout:
|
||||
resp["groupLayout"] = group_layout.model_dump()
|
||||
if viewMeta:
|
||||
resp["appliedView"] = viewMeta.model_dump()
|
||||
return JSONResponse(content=resp)
|
||||
|
||||
transactions = billingService.getTransactionHistory(limit=offset + limit)
|
||||
result: List[TransactionResponse] = []
|
||||
for t in transactions[offset : offset + limit]:
|
||||
result.append(
|
||||
TransactionResponse(
|
||||
id=t.get("id"),
|
||||
accountId=t.get("accountId"),
|
||||
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
||||
amount=t.get("amount", 0.0),
|
||||
description=t.get("description", ""),
|
||||
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
||||
workflowId=t.get("workflowId"),
|
||||
featureCode=t.get("featureCode"),
|
||||
featureInstanceId=t.get("featureInstanceId"),
|
||||
aicoreProvider=t.get("aicoreProvider"),
|
||||
aicoreModel=t.get("aicoreModel"),
|
||||
createdByUserId=t.get("createdByUserId"),
|
||||
sysCreatedAt=t.get("sysCreatedAt"),
|
||||
mandateId=t.get("mandateId"),
|
||||
mandateName=t.get("mandateName"),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting billing transactions: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=UsageReportResponse)
|
||||
@limiter.limit("30/minute")
|
||||
|
|
@ -1373,51 +1215,6 @@ def getMandateViewTransactions(
|
|||
# User View Endpoints (RBAC-based)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/view/users/balances", response_model=List[UserBalanceResponse])
|
||||
@limiter.limit("30/minute")
|
||||
def getUserViewBalances(
|
||||
request: Request,
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
):
|
||||
"""
|
||||
Get user-level balances.
|
||||
|
||||
RBAC filtering:
|
||||
- SysAdmin: sees all user balances across all mandates
|
||||
- Mandate-Admin: sees user balances for mandates they administrate
|
||||
- Regular user: sees only their own balances
|
||||
"""
|
||||
try:
|
||||
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
||||
|
||||
# Evaluate RBAC scope
|
||||
scope = _getBillingDataScope(ctx.user)
|
||||
|
||||
# Determine mandate IDs for data loading
|
||||
if scope.isGlobalAdmin:
|
||||
mandateIds = None
|
||||
else:
|
||||
mandateIds = scope.adminMandateIds + scope.memberMandateIds
|
||||
if not mandateIds:
|
||||
return []
|
||||
|
||||
allBalances = billingInterface.getUserBalancesForMandates(mandateIds)
|
||||
|
||||
# RBAC filter: mandate admins see all in their mandates, regular users only own
|
||||
if not scope.isGlobalAdmin:
|
||||
adminMandateSet = set(scope.adminMandateIds)
|
||||
allBalances = [
|
||||
b for b in allBalances
|
||||
if b.get("mandateId") in adminMandateSet or b.get("userId") == scope.userId
|
||||
]
|
||||
|
||||
return [UserBalanceResponse(**b) for b in allBalances]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user view balances: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class ViewStatisticsResponse(BaseModel):
|
||||
"""Aggregated statistics across all user's mandates."""
|
||||
totalCost: float = 0.0
|
||||
|
|
@ -1730,25 +1527,48 @@ def getUserViewTransactions(
|
|||
resp["appliedView"] = viewMeta.model_dump(mode="json")
|
||||
return JSONResponse(content=resp)
|
||||
|
||||
result = billingInterface.getTransactionsForMandatesPaginated(
|
||||
mandateIds=loadMandateIds,
|
||||
pagination=paginationParams,
|
||||
scope=effectiveScope,
|
||||
userId=personalUserId,
|
||||
_ENRICHED_FILTER_COLS = {"mandateName", "userName", "mandateId", "userId"}
|
||||
_hasEnrichedFilters = paginationParams.filters and any(
|
||||
k in _ENRICHED_FILTER_COLS for k in paginationParams.filters
|
||||
)
|
||||
|
||||
if _hasEnrichedFilters:
|
||||
all_items = _view_user_transactions_filtered_list(
|
||||
billingInterface,
|
||||
loadMandateIds,
|
||||
effectiveScope,
|
||||
personalUserId,
|
||||
paginationParams,
|
||||
ctx.user,
|
||||
)
|
||||
totalItems = len(all_items)
|
||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||
pstart = (paginationParams.page - 1) * paginationParams.pageSize
|
||||
page_items = all_items[pstart : pstart + paginationParams.pageSize]
|
||||
else:
|
||||
result = billingInterface.getTransactionsForMandatesPaginated(
|
||||
mandateIds=loadMandateIds,
|
||||
pagination=paginationParams,
|
||||
scope=effectiveScope,
|
||||
userId=personalUserId,
|
||||
)
|
||||
page_items = result.items
|
||||
totalItems = result.totalItems
|
||||
totalPages = result.totalPages
|
||||
|
||||
logger.debug(
|
||||
f"SQL-paginated {result.totalItems} transactions for user {ctx.user.id} "
|
||||
f"(scope={scope}, mandateId={mandateId}, page={paginationParams.page})"
|
||||
f"Paginated {totalItems} transactions for user {ctx.user.id} "
|
||||
f"(scope={scope}, mandateId={mandateId}, page={paginationParams.page}, "
|
||||
f"enrichedFilter={_hasEnrichedFilters})"
|
||||
)
|
||||
|
||||
return PaginatedResponse(
|
||||
items=[_toResponse(d) for d in result.items],
|
||||
items=[_toResponse(d) for d in page_items],
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -311,72 +311,6 @@ def get_folder_tree(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/attributes")
|
||||
@limiter.limit("120/minute")
|
||||
def getAttributesForIds(
|
||||
request: Request,
|
||||
body: Dict[str, Any] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Return current attribute values (neutralize, scope, ragIndexEnabled) for
|
||||
a list of node IDs. For folder IDs, computes 'mixed' by checking direct
|
||||
children. The frontend sends this after every toggle to refresh visible
|
||||
nodes without reloading the tree structure."""
|
||||
ids = body.get("ids", [])
|
||||
if not isinstance(ids, list) or len(ids) == 0:
|
||||
return {}
|
||||
if len(ids) > 500:
|
||||
raise HTTPException(status_code=400, detail="Max 500 IDs per request")
|
||||
|
||||
try:
|
||||
managementInterface = interfaceDbManagement.getInterface(
|
||||
currentUser,
|
||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||
)
|
||||
db = managementInterface.db
|
||||
userId = str(currentUser.id)
|
||||
|
||||
allFolders = db.getRecordset(FileFolder, recordFilter={"sysCreatedBy": userId}) or []
|
||||
allFiles = db.getRecordset(FileItem, recordFilter={"sysCreatedBy": userId}) or []
|
||||
|
||||
folderById = {f["id"]: f for f in allFolders}
|
||||
fileById = {f["id"]: f for f in allFiles}
|
||||
|
||||
logger.info(
|
||||
"getAttributesForIds: %d ids requested, %d folders found, %d files found",
|
||||
len(ids), len(allFolders), len(allFiles),
|
||||
)
|
||||
|
||||
result: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for nodeId in ids:
|
||||
if nodeId.startswith("__filesRoot:"):
|
||||
attrs = _computeSyntheticRootAttrs(allFolders, allFiles)
|
||||
result[nodeId] = attrs
|
||||
elif nodeId in folderById:
|
||||
folder = folderById[nodeId]
|
||||
attrs = _computeFolderAttrs(folder, allFolders, allFiles)
|
||||
result[nodeId] = attrs
|
||||
elif nodeId in fileById:
|
||||
f = fileById[nodeId]
|
||||
result[nodeId] = {
|
||||
"neutralize": bool(f.get("neutralize", False)),
|
||||
"scope": f.get("scope", "personal"),
|
||||
}
|
||||
else:
|
||||
logger.debug("getAttributesForIds: unknown id=%s", nodeId)
|
||||
|
||||
logger.info("getAttributesForIds: returning %d entries", len(result))
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"getAttributesForIds error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _enrichFoldersWithMixed(
|
||||
db, userId: str, folders: List[Dict[str, Any]], ownerMode: str,
|
||||
) -> None:
|
||||
|
|
@ -480,43 +414,6 @@ def _effectiveScope(
|
|||
return childVals.pop()
|
||||
|
||||
|
||||
def _computeSyntheticRootAttrs(
|
||||
allFolders: List[Dict[str, Any]],
|
||||
allFiles: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Compute attributes for the synthetic root by recursively checking the
|
||||
entire tree. If ANY item at any depth diverges, root shows 'mixed'."""
|
||||
topFolders = [f for f in allFolders if not f.get("parentId")]
|
||||
topFiles = [f for f in allFiles if not f.get("folderId")]
|
||||
|
||||
neutralizeVals = set()
|
||||
scopeVals = set()
|
||||
for cf in topFolders:
|
||||
nEff = _effectiveNeutralize(cf["id"], allFolders, allFiles)
|
||||
if nEff == "mixed":
|
||||
neutralizeVals.add(True)
|
||||
neutralizeVals.add(False)
|
||||
else:
|
||||
neutralizeVals.add(nEff)
|
||||
sEff = _effectiveScope(cf["id"], allFolders, allFiles)
|
||||
if sEff == "mixed":
|
||||
scopeVals.add("__mixed_a__")
|
||||
scopeVals.add("__mixed_b__")
|
||||
else:
|
||||
scopeVals.add(sEff)
|
||||
for cf in topFiles:
|
||||
neutralizeVals.add(bool(cf.get("neutralize", False)))
|
||||
scopeVals.add(cf.get("scope", "personal"))
|
||||
|
||||
if not neutralizeVals and not scopeVals:
|
||||
return {"neutralize": False, "scope": "personal"}
|
||||
|
||||
return {
|
||||
"neutralize": "mixed" if len(neutralizeVals) > 1 else (neutralizeVals.pop() if neutralizeVals else False),
|
||||
"scope": "mixed" if len(scopeVals) > 1 else (scopeVals.pop() if scopeVals else "personal"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/folders", status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("30/minute")
|
||||
def create_folder(
|
||||
|
|
@ -1133,132 +1030,6 @@ def batchDownload(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── Bulk file operations (replace former group-based bulk routes) ─────────────
|
||||
|
||||
@router.post("/bulk/scope")
|
||||
@limiter.limit("30/minute")
|
||||
def bulk_set_scope(
|
||||
request: Request,
|
||||
body: dict = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Set scope for a list of files by their IDs."""
|
||||
fileIds: list = body.get("fileIds") or []
|
||||
scope: str = body.get("scope") or ""
|
||||
if not fileIds:
|
||||
raise HTTPException(status_code=400, detail="fileIds is required")
|
||||
validScopes = {"personal", "featureInstance", "mandate", "global"}
|
||||
if scope not in validScopes:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid scope. Must be one of {validScopes}")
|
||||
if scope == "global" and not context.isSysAdmin:
|
||||
raise HTTPException(status_code=403, detail="Only sysadmins can set global scope")
|
||||
try:
|
||||
managementInterface = interfaceDbManagement.getInterface(
|
||||
currentUser,
|
||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||
)
|
||||
updated = 0
|
||||
for fid in fileIds:
|
||||
try:
|
||||
managementInterface.updateFile(fid, {"scope": scope})
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.error(f"bulk_set_scope: failed for file {fid}: {e}")
|
||||
return {"scope": scope, "filesUpdated": updated}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"bulk_set_scope error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/bulk/neutralize")
|
||||
@limiter.limit("30/minute")
|
||||
def bulk_set_neutralize(
|
||||
request: Request,
|
||||
body: dict = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Set neutralize flag for a list of files by their IDs (incl. knowledge purge/reindex)."""
|
||||
fileIds: list = body.get("fileIds") or []
|
||||
neutralize = body.get("neutralize")
|
||||
if not fileIds:
|
||||
raise HTTPException(status_code=400, detail="fileIds is required")
|
||||
if neutralize is None:
|
||||
raise HTTPException(status_code=400, detail="neutralize is required")
|
||||
try:
|
||||
managementInterface = interfaceDbManagement.getInterface(
|
||||
currentUser,
|
||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||
)
|
||||
updated = 0
|
||||
for fid in fileIds:
|
||||
try:
|
||||
managementInterface.updateFile(fid, {"neutralize": neutralize})
|
||||
if not neutralize:
|
||||
try:
|
||||
kIface = interfaceDbKnowledge.getInterface(currentUser)
|
||||
kIface.purgeFileKnowledge(fid)
|
||||
except Exception as ke:
|
||||
logger.warning(f"bulk_set_neutralize: knowledge purge failed for {fid}: {ke}")
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.error(f"bulk_set_neutralize: failed for file {fid}: {e}")
|
||||
return {"neutralize": neutralize, "filesUpdated": updated}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"bulk_set_neutralize error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/bulk/download-zip")
|
||||
@limiter.limit("10/minute")
|
||||
async def bulk_download_zip(
|
||||
request: Request,
|
||||
body: dict = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Download a list of files as a ZIP archive."""
|
||||
fileIds: list = body.get("fileIds") or []
|
||||
if not fileIds:
|
||||
raise HTTPException(status_code=400, detail="fileIds is required")
|
||||
try:
|
||||
managementInterface = interfaceDbManagement.getInterface(
|
||||
currentUser,
|
||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for fid in fileIds:
|
||||
try:
|
||||
fileMeta = managementInterface.getFile(fid)
|
||||
fileData = managementInterface.getFileData(fid)
|
||||
if fileMeta and fileData:
|
||||
name = (getattr(fileMeta, "fileName", None) or fid)
|
||||
zf.writestr(name, fileData)
|
||||
except Exception as fe:
|
||||
logger.warning(f"bulk_download_zip: skipping file {fid}: {fe}")
|
||||
buf.seek(0)
|
||||
from fastapi.responses import StreamingResponse
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="files.zip"'},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"bulk_download_zip error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── Scope & neutralize tagging endpoints (before /{fileId} catch-all) ─────────
|
||||
|
||||
@router.patch("/{fileId}/scope")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
Endpoints:
|
||||
- GET /api/jobs/{jobId} -> single job status
|
||||
- GET /api/jobs -> list (filter by jobType, instanceId)
|
||||
|
||||
Access control: a caller may read a job iff they are a member of its mandate
|
||||
(or PlatformAdmin). Jobs without a mandateId (system-wide) are restricted to
|
||||
|
|
@ -12,14 +11,13 @@ PlatformAdmin only.
|
|||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request
|
||||
|
||||
from modules.auth import getRequestContext, RequestContext, limiter
|
||||
from modules.serviceCenter.services.serviceBackgroundJobs import (
|
||||
getJobStatus,
|
||||
listJobs,
|
||||
)
|
||||
from modules.shared.i18nRegistry import apiRouteContext, resolveJobMessage
|
||||
|
||||
|
|
@ -91,29 +89,3 @@ def get_job(
|
|||
return _serialiseJob(job)
|
||||
|
||||
|
||||
@router.get("")
|
||||
@limiter.limit("30/minute")
|
||||
def list_jobs(
|
||||
request: Request,
|
||||
jobType: Optional[str] = Query(None),
|
||||
mandateId: Optional[str] = Query(None),
|
||||
instanceId: Optional[str] = Query(None, description="Feature instance scope"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""List recent jobs filtered by scope. Newest first."""
|
||||
if mandateId is None:
|
||||
if not context or not context.isPlatformAdmin:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=routeApiMsg("mandateId is required (only PlatformAdmin may list system-wide)"),
|
||||
)
|
||||
elif not _userHasMandateAccess(context, mandateId):
|
||||
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
|
||||
jobs = listJobs(
|
||||
mandateId=mandateId,
|
||||
featureInstanceId=instanceId,
|
||||
jobType=jobType,
|
||||
limit=limit,
|
||||
)
|
||||
return {"items": [_serialiseJob(j) for j in jobs]}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,8 @@ import secrets
|
|||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
||||
from typing import Optional, Dict, Any, List
|
||||
from modules.auth import getCurrentUser, getRequestContext, RequestContext, limiter
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.auth import getRequestContext, RequestContext, limiter
|
||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface, VoiceObjects
|
||||
from modules.shared.voiceCatalog import getCatalogPayload
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/voice-google", tags=["Voice Google"])
|
||||
|
||||
|
|
@ -49,70 +47,6 @@ class ConnectionManager:
|
|||
|
||||
manager = ConnectionManager()
|
||||
|
||||
def _getVoiceInterface(currentUser: User) -> VoiceObjects:
|
||||
"""Get voice interface instance with user context."""
|
||||
try:
|
||||
return getVoiceInterface(currentUser)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize voice interface: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to initialize voice interface: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/languages")
|
||||
async def get_available_languages(currentUser: User = Depends(getCurrentUser)):
|
||||
"""Return the curated voice/language catalog (single source of truth).
|
||||
|
||||
Each entry: {bcp47, iso, label, flag, defaultVoice}. Same payload as
|
||||
/api/voice/languages — both endpoints back the same catalog.
|
||||
"""
|
||||
return {
|
||||
"success": True,
|
||||
"languages": getCatalogPayload(),
|
||||
}
|
||||
|
||||
@router.get("/voices")
|
||||
async def get_available_voices(
|
||||
languageCode: Optional[str] = None,
|
||||
language_code: Optional[str] = None, # Accept both camelCase and snake_case
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""
|
||||
Get available voices from Google Cloud Text-to-Speech.
|
||||
Accepts languageCode (camelCase) or language_code (snake_case) query parameter.
|
||||
"""
|
||||
# Use language_code if provided (frontend sends this), otherwise use languageCode
|
||||
if language_code:
|
||||
languageCode = language_code
|
||||
|
||||
try:
|
||||
logger.info(f"🎤 Getting available voices, language filter: {languageCode}")
|
||||
|
||||
voiceInterface = _getVoiceInterface(currentUser)
|
||||
result = await voiceInterface.getAvailableVoices(languageCode=languageCode)
|
||||
|
||||
if result["success"]:
|
||||
return {
|
||||
"success": True,
|
||||
"voices": result["voices"],
|
||||
"language_filter": languageCode
|
||||
}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to get voices: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Get voices error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get available voices: {str(e)}"
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# STT Streaming WebSocket — generic, used by all features
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -207,27 +207,6 @@ async def _listRuns(
|
|||
db.close()
|
||||
|
||||
|
||||
@router.get("/runs/{runId}")
|
||||
async def _getRun(
|
||||
runId: str,
|
||||
request: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
db = _getWorkflowAutomationDb()
|
||||
try:
|
||||
db._ensureTableExists(AutoRun)
|
||||
run = db.getRecord(AutoRun, runId)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
wfId = run.get("workflowId")
|
||||
if wfId:
|
||||
wf = db.getRecord(AutoWorkflow, wfId)
|
||||
_validateWorkflowAccess(request, wf, "read")
|
||||
return run
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue