trustee connections
This commit is contained in:
parent
899636424b
commit
e1557e9cc9
9 changed files with 151 additions and 26 deletions
|
|
@ -44,8 +44,14 @@ class AccountingBridge:
|
|||
from modules.shared.configuration import decryptValue
|
||||
import json
|
||||
try:
|
||||
if not encryptedConfig:
|
||||
logger.error("Accounting config encryptedConfig is empty")
|
||||
return {}
|
||||
decrypted = decryptValue(encryptedConfig, keyName="accountingConfig")
|
||||
return json.loads(decrypted) if isinstance(decrypted, str) else decrypted
|
||||
result = json.loads(decrypted) if isinstance(decrypted, str) else decrypted
|
||||
configKeys = list(result.keys()) if isinstance(result, dict) else []
|
||||
logger.info(f"Decrypted accounting config successfully, keys: {configKeys}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt accounting config: {e}")
|
||||
return {}
|
||||
|
|
@ -54,15 +60,19 @@ class AccountingBridge:
|
|||
"""Load config, decrypt, resolve connector. Returns (connector, plainConfig, accountingConfigRecord)."""
|
||||
configRecord = await self.getActiveConfig(featureInstanceId)
|
||||
if not configRecord:
|
||||
logger.warning(f"No active accounting config for instance {featureInstanceId}")
|
||||
return None, None, None
|
||||
|
||||
connectorType = configRecord.get("connectorType")
|
||||
logger.info(f"Resolving connector '{connectorType}' for instance {featureInstanceId}")
|
||||
connector = self._registry.getConnector(connectorType)
|
||||
if not connector:
|
||||
logger.error(f"Accounting connector '{connectorType}' not found")
|
||||
logger.error(f"Accounting connector '{connectorType}' not found in registry")
|
||||
return None, None, configRecord
|
||||
|
||||
plainConfig = self._decryptConfig(configRecord.get("encryptedConfig", ""))
|
||||
if not plainConfig:
|
||||
logger.error(f"Decrypted config is empty for connector '{connectorType}'")
|
||||
return connector, plainConfig, configRecord
|
||||
|
||||
def _buildBookingFromPosition(self, position: Dict[str, Any]) -> AccountingBooking:
|
||||
|
|
|
|||
|
|
@ -51,25 +51,35 @@ class AccountingConnectorRma(BaseAccountingConnector):
|
|||
]
|
||||
|
||||
def _buildUrl(self, config: Dict[str, Any], resource: str) -> str:
|
||||
clientName = config["clientName"]
|
||||
clientName = config.get("clientName", "")
|
||||
return f"{_BASE_URL}/{clientName}/{resource}"
|
||||
|
||||
def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""PAT must not be in query params; RMA expects Authorization header."""
|
||||
apiKey = config.get("apiKey", "")
|
||||
return {
|
||||
"X-RMA-KEY": config["apiKey"],
|
||||
"Authorization": f"Bearer {apiKey}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
|
||||
clientName = config.get("clientName", "")
|
||||
apiKey = config.get("apiKey", "")
|
||||
|
||||
if not clientName or not apiKey:
|
||||
return SyncResult(success=False, errorMessage=f"Missing credentials: clientName={bool(clientName)}, apiKey={bool(apiKey)}")
|
||||
|
||||
url = self._buildUrl(config, "customers")
|
||||
headers = self._buildHeaders(config)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = self._buildUrl(config, "customers")
|
||||
async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
if resp.status == 200:
|
||||
logger.info("RMA connection successful")
|
||||
return SyncResult(success=True)
|
||||
body = await resp.text()
|
||||
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:200]}")
|
||||
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
|
||||
except Exception as e:
|
||||
return SyncResult(success=False, errorMessage=str(e))
|
||||
|
||||
|
|
@ -181,7 +191,8 @@ class AccountingConnectorRma(BaseAccountingConnector):
|
|||
formData = aiohttp.FormData()
|
||||
formData.add_field("Filedata", fileContent, filename=fileName, content_type=mimeType)
|
||||
|
||||
headers = {"X-RMA-KEY": config["apiKey"], "Accept": "application/json"}
|
||||
headers = self._buildHeaders(config)
|
||||
headers.pop("Content-Type", None) # let aiohttp set multipart boundary
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = self._buildUrl(config, "belege")
|
||||
async with session.post(url, headers=headers, data=formData, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ UI_OBJECTS = [
|
|||
"label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"},
|
||||
"meta": {"area": "expense-import"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.trustee.settings",
|
||||
"label": {"en": "Accounting Settings", "de": "Buchhaltungs-Einstellungen", "fr": "Paramètres comptables"},
|
||||
"meta": {"area": "settings", "admin_only": True}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.trustee.instance-roles",
|
||||
"label": {"en": "Instance Roles & Permissions", "de": "Instanz-Rollen & Berechtigungen", "fr": "Rôles et permissions d'instance"},
|
||||
|
|
@ -61,7 +66,7 @@ DATA_OBJECTS = [
|
|||
{
|
||||
"objectKey": "data.feature.trustee.TrusteeAccountingConfig",
|
||||
"label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"},
|
||||
"meta": {"table": "TrusteeAccountingConfig", "fields": ["id", "connectorType", "displayLabel", "isActive"]}
|
||||
"meta": {"table": "TrusteeAccountingConfig", "fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"]}
|
||||
},
|
||||
{
|
||||
"objectKey": "data.feature.trustee.TrusteeAccountingSync",
|
||||
|
|
@ -163,8 +168,12 @@ TEMPLATE_ROLES = [
|
|||
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
|
||||
# Group-level DATA access
|
||||
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
|
||||
# Accounting sync permission
|
||||
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True},
|
||||
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Quer
|
|||
from fastapi.responses import StreamingResponse
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
from pydantic import BaseModel, Field
|
||||
import logging
|
||||
import json
|
||||
import io
|
||||
|
|
@ -1179,6 +1180,23 @@ def get_available_accounting_connectors(
|
|||
return _getAccountingRegistry().getAvailableConnectors()
|
||||
|
||||
|
||||
# Placeholder returned for secret config fields so frontend can prefill form without sending real secrets.
|
||||
_CONFIG_PLACEHOLDER = "***"
|
||||
|
||||
|
||||
def _getConfigMasked(connectorType: str, plainConfig: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Build config with secret values replaced by placeholder for GET response."""
|
||||
from .accounting.accountingRegistry import _getAccountingRegistry
|
||||
connector = _getAccountingRegistry().getConnector(connectorType)
|
||||
if not connector:
|
||||
return {k: (v if isinstance(v, str) else str(v)) for k, v in (plainConfig or {}).items()}
|
||||
secretKeys = {f.key for f in connector.getRequiredConfigFields() if f.secret}
|
||||
return {
|
||||
k: _CONFIG_PLACEHOLDER if k in secretKeys else (v if isinstance(v, str) else str(v) if v is not None else "")
|
||||
for k, v in (plainConfig or {}).items()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{instanceId}/accounting/config")
|
||||
@limiter.limit("30/minute")
|
||||
def get_accounting_config(
|
||||
|
|
@ -1186,57 +1204,104 @@ def get_accounting_config(
|
|||
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the active accounting config for this instance (credentials redacted)."""
|
||||
"""Get the active accounting config for this instance. Credentials are masked (secret fields = ***) for form prefill."""
|
||||
mandateId = _validateInstanceAccess(instanceId, context)
|
||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||
from .datamodelFeatureTrustee import TrusteeAccountingConfig
|
||||
from modules.shared.configuration import decryptValue
|
||||
records = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True})
|
||||
if not records:
|
||||
return {"configured": False}
|
||||
record = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
||||
record.pop("encryptedConfig", None)
|
||||
encryptedConfig = record.pop("encryptedConfig", None)
|
||||
record["configured"] = True
|
||||
if encryptedConfig:
|
||||
try:
|
||||
import json
|
||||
plain = json.loads(decryptValue(encryptedConfig, keyName="accountingConfig"))
|
||||
record["configMasked"] = _getConfigMasked(record.get("connectorType", ""), plain)
|
||||
except Exception:
|
||||
record["configMasked"] = {}
|
||||
else:
|
||||
record["configMasked"] = {}
|
||||
return record
|
||||
|
||||
|
||||
class SaveAccountingConfigBody(BaseModel):
|
||||
"""Request body for saving accounting config. Ensures 'config' is present and used."""
|
||||
connectorType: str = ""
|
||||
displayLabel: str = ""
|
||||
config: Dict[str, Any] = Field(default_factory=dict, description="Connector credentials (e.g. clientName, apiKey)")
|
||||
|
||||
|
||||
@router.post("/{instanceId}/accounting/config", status_code=201)
|
||||
@limiter.limit("5/minute")
|
||||
def save_accounting_config(
|
||||
request: Request,
|
||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||
data: Dict[str, Any] = Body(...),
|
||||
body: SaveAccountingConfigBody = Body(...),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Save or update the accounting config for this instance.
|
||||
|
||||
Body: { connectorType, displayLabel, config: { ... plain credentials ... } }
|
||||
Body: { connectorType, displayLabel, config: { clientName, apiKey, ... } }
|
||||
The 'config' object is stored encrypted; without it credentials would be empty in DB.
|
||||
"""
|
||||
mandateId = _validateInstanceAccess(instanceId, context)
|
||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||
|
||||
from .datamodelFeatureTrustee import TrusteeAccountingConfig
|
||||
from modules.shared.configuration import encryptValue
|
||||
import json, uuid as _uuid
|
||||
import uuid as _uuid
|
||||
|
||||
plainConfig = data.get("config", {})
|
||||
encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
|
||||
plainConfig = body.config if isinstance(body.config, dict) else {}
|
||||
# When updating, empty config is normal (frontend never receives credentials from GET).
|
||||
# Do not overwrite encryptedConfig with empty – keep existing credentials.
|
||||
if not plainConfig and body.connectorType:
|
||||
logger.warning("Accounting config save: config is empty (credentials will not be stored or updated)")
|
||||
else:
|
||||
logger.info(
|
||||
"Accounting config save: instanceId=%s connectorType=%s configKeys=%s",
|
||||
instanceId, body.connectorType, list(plainConfig.keys())
|
||||
)
|
||||
|
||||
existing = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId})
|
||||
if existing:
|
||||
configId = existing[0].get("id")
|
||||
interface.db.recordModify(TrusteeAccountingConfig, configId, {
|
||||
"connectorType": data.get("connectorType", ""),
|
||||
"displayLabel": data.get("displayLabel", ""),
|
||||
"encryptedConfig": encryptedConfig,
|
||||
updatePayload = {
|
||||
"connectorType": body.connectorType or "",
|
||||
"displayLabel": body.displayLabel or "",
|
||||
"isActive": True,
|
||||
})
|
||||
}
|
||||
if plainConfig:
|
||||
# Merge with existing: placeholder or empty = keep existing value (so form prefill does not overwrite secrets).
|
||||
from modules.shared.configuration import decryptValue
|
||||
existingEnc = existing[0].get("encryptedConfig") or ""
|
||||
merged = {}
|
||||
if existingEnc:
|
||||
try:
|
||||
merged = json.loads(decryptValue(existingEnc, keyName="accountingConfig"))
|
||||
except Exception:
|
||||
pass
|
||||
for k, v in plainConfig.items():
|
||||
if v is not None and str(v).strip() and str(v).strip() != _CONFIG_PLACEHOLDER:
|
||||
merged[k] = v
|
||||
updatePayload["encryptedConfig"] = encryptValue(json.dumps(merged), keyName="accountingConfig")
|
||||
interface.db.recordModify(TrusteeAccountingConfig, configId, updatePayload)
|
||||
return {"message": "Accounting config updated", "id": configId}
|
||||
|
||||
if not plainConfig:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="config is required for new integration (e.g. clientName, apiKey)."
|
||||
)
|
||||
encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
|
||||
|
||||
configRecord = {
|
||||
"id": str(_uuid.uuid4()),
|
||||
"featureInstanceId": instanceId,
|
||||
"connectorType": data.get("connectorType", ""),
|
||||
"displayLabel": data.get("displayLabel", ""),
|
||||
"connectorType": body.connectorType or "",
|
||||
"displayLabel": body.displayLabel or "",
|
||||
"encryptedConfig": encryptedConfig,
|
||||
"isActive": True,
|
||||
"mandateId": mandateId,
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ def get_my_feature_instances(
|
|||
mandatesMap: Dict[str, Dict[str, Any]] = {}
|
||||
featuresMap: Dict[str, Dict[str, Any]] = {} # key: mandateId_featureCode
|
||||
|
||||
catalogService = getCatalogService()
|
||||
for access in featureAccesses:
|
||||
if not access.enabled:
|
||||
continue
|
||||
|
|
@ -137,6 +138,10 @@ def get_my_feature_instances(
|
|||
if not instance or not instance.enabled:
|
||||
continue
|
||||
|
||||
# Only show features that exist in this app's catalog (e.g. PowerOn vs Actan share DB)
|
||||
if not catalogService.getFeatureDefinition(instance.featureCode):
|
||||
continue
|
||||
|
||||
# Get mandate info
|
||||
mandateId = str(instance.mandateId)
|
||||
if mandateId not in mandatesMap:
|
||||
|
|
|
|||
|
|
@ -174,8 +174,8 @@ def login(
|
|||
# Save access token
|
||||
userInterface.saveAccessToken(token)
|
||||
|
||||
# Log successful login
|
||||
# MULTI-TENANT: Login is a system-level function, no mandate context
|
||||
# Log successful login (app log file + audit DB for traceability)
|
||||
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
|
||||
try:
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logUserAccess(
|
||||
|
|
|
|||
|
|
@ -5,10 +5,16 @@
|
|||
import logging
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache for discoverSites() to avoid hitting Graph API on every folder-options call (e.g. when UI loads site list).
|
||||
# Key: token prefix (per user), Value: (expiry_ts, sites). TTL 5 minutes.
|
||||
_discoverSitesCache: Dict[str, tuple] = {}
|
||||
_DISCOVER_SITES_TTL_SEC = 300
|
||||
|
||||
|
||||
class SharepointService:
|
||||
"""SharePoint connector using Microsoft Graph API for reliable authentication."""
|
||||
|
|
|
|||
|
|
@ -633,7 +633,7 @@ def _saveToTrusteePosition(
|
|||
except Exception as e:
|
||||
logger.error(f"Error creating TrusteeDocument: {str(e)}")
|
||||
|
||||
# Step 2: Save positions with direct documentId reference
|
||||
# Step 2: Save positions with direct documentId reference and accounting fields
|
||||
for record in records:
|
||||
try:
|
||||
position = {
|
||||
|
|
@ -649,6 +649,11 @@ def _saveToTrusteePosition(
|
|||
"originalAmount": _parseFloat(record.get("originalAmount", 0)) or _parseFloat(record.get("bookingAmount", 0)),
|
||||
"vatPercentage": _parseFloat(record.get("vatPercentage", 0)),
|
||||
"vatAmount": _parseFloat(record.get("vatAmount", 0)),
|
||||
"debitAccountNumber": record.get("debitAccountNumber") or None,
|
||||
"creditAccountNumber": record.get("creditAccountNumber") or None,
|
||||
"taxCode": record.get("taxCode") or None,
|
||||
"costCenter": record.get("costCenter") or None,
|
||||
"bookingReference": record.get("bookingReference") or None,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"mandateId": mandateId
|
||||
}
|
||||
|
|
@ -662,6 +667,19 @@ def _saveToTrusteePosition(
|
|||
except Exception as e:
|
||||
logger.error(f"Failed to save position: {str(e)}")
|
||||
|
||||
# Step 3: Auto-sync to accounting system if configured
|
||||
if savedCount > 0 and savedPositionIds:
|
||||
try:
|
||||
from modules.features.trustee.accounting.accountingBridge import AccountingBridge
|
||||
bridge = AccountingBridge(trusteeInterface)
|
||||
configRecord = await bridge.getActiveConfig(featureInstanceId)
|
||||
if configRecord:
|
||||
syncResults = await bridge.pushBatchToAccounting(featureInstanceId, savedPositionIds)
|
||||
syncedCount = sum(1 for r in syncResults if r.success)
|
||||
logger.info(f"Auto-synced {syncedCount}/{len(savedPositionIds)} positions to accounting system")
|
||||
except Exception as e:
|
||||
logger.warning(f"Accounting auto-sync skipped (non-critical): {e}")
|
||||
|
||||
return savedCount
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ MIGRATION_MAP: Dict[str, Dict[str, str]] = {
|
|||
"dashboard": "ui.feature.trustee.dashboard",
|
||||
"positions": "ui.feature.trustee.positions",
|
||||
"documents": "ui.feature.trustee.documents",
|
||||
"settings": "ui.feature.trustee.settings",
|
||||
"instance-roles": "ui.feature.trustee.instance-roles",
|
||||
# RESOURCE items
|
||||
"instance-roles.manage": "resource.feature.trustee.instance-roles.manage",
|
||||
|
|
|
|||
Loading…
Reference in a new issue