diff --git a/modules/datamodels/datamodelNavigation.py b/modules/datamodels/datamodelNavigation.py index 101cef99..977e801c 100644 --- a/modules/datamodels/datamodelNavigation.py +++ b/modules/datamodels/datamodelNavigation.py @@ -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", diff --git a/modules/routes/routeAdminSessions.py b/modules/routes/routeAdminSessions.py index d962a9ea..7503cd59 100644 --- a/modules/routes/routeAdminSessions.py +++ b/modules/routes/routeAdminSessions.py @@ -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} diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 1d1441c4..84dc5c7b 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -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, ), diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 745027bc..2d20f5c2 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -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") diff --git a/modules/routes/routeJobs.py b/modules/routes/routeJobs.py index c98c7bd0..7ef8c52b 100644 --- a/modules/routes/routeJobs.py +++ b/modules/routes/routeJobs.py @@ -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]} diff --git a/modules/routes/routeVoiceGoogle.py b/modules/routes/routeVoiceGoogle.py index ada38504..addf7e47 100644 --- a/modules/routes/routeVoiceGoogle.py +++ b/modules/routes/routeVoiceGoogle.py @@ -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 # ========================================================================= diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py index 619928f9..09dc311d 100644 --- a/modules/routes/routeWorkflowAutomation.py +++ b/modules/routes/routeWorkflowAutomation.py @@ -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 # ---------------------------------------------------------------------------