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
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 57s
Deploy Plattform-Core (Int) / deploy (push) Successful in 8s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ValueOn AG 2026-06-12 00:35:52 +02:00
parent 29de7e9915
commit 66b44e5c78
7 changed files with 81 additions and 577 deletions

View file

@ -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",

View file

@ -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}

View file

@ -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)
_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,
),

View file

@ -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")

View file

@ -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]}

View file

@ -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
# =========================================================================

View file

@ -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
# ---------------------------------------------------------------------------