Merge pull request #134 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
This commit is contained in:
commit
7fd942a1b5
33 changed files with 2357 additions and 341 deletions
10
app.py
10
app.py
|
|
@ -310,10 +310,18 @@ async def lifespan(app: FastAPI):
|
||||||
# Register all feature definitions in RBAC catalog (for /api/features/ endpoint)
|
# Register all feature definitions in RBAC catalog (for /api/features/ endpoint)
|
||||||
try:
|
try:
|
||||||
from modules.security.rbacCatalog import getCatalogService
|
from modules.security.rbacCatalog import getCatalogService
|
||||||
from modules.system.registry import registerAllFeaturesInCatalog
|
from modules.system.registry import registerAllFeaturesInCatalog, syncCatalogFeaturesToDb
|
||||||
catalogService = getCatalogService()
|
catalogService = getCatalogService()
|
||||||
registerAllFeaturesInCatalog(catalogService)
|
registerAllFeaturesInCatalog(catalogService)
|
||||||
logger.info("Feature catalog registration completed")
|
logger.info("Feature catalog registration completed")
|
||||||
|
# Persist the in-memory feature registry into the Feature DB-table so
|
||||||
|
# the FeatureInstance.featureCode FK has real targets. Without this
|
||||||
|
# every FeatureInstance row would be flagged as orphan by the
|
||||||
|
# SysAdmin DB-health scan (cf. interfaceFeatures.upsertFeature).
|
||||||
|
try:
|
||||||
|
syncCatalogFeaturesToDb(catalogService)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Feature DB sync failed: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Feature catalog registration failed: {e}")
|
logger.error(f"Feature catalog registration failed: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/conn
|
||||||
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
|
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
|
||||||
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
|
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
|
||||||
STRIPE_API_VERSION = 2026-01-28.clover
|
STRIPE_API_VERSION = 2026-01-28.clover
|
||||||
|
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||||
|
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||||
|
|
||||||
# AI configuration
|
# AI configuration
|
||||||
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
|
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/conn
|
||||||
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
|
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
|
||||||
STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
|
STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
|
||||||
STRIPE_API_VERSION = 2026-01-28.clover
|
STRIPE_API_VERSION = 2026-01-28.clover
|
||||||
|
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||||
|
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||||
|
|
||||||
# AI configuration
|
# AI configuration
|
||||||
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
|
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,9 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/conn
|
||||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
||||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
||||||
STRIPE_API_VERSION = 2026-01-28.clover
|
STRIPE_API_VERSION = 2026-01-28.clover
|
||||||
|
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||||
|
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
||||||
|
|
||||||
|
|
||||||
# AI configuration
|
# AI configuration
|
||||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
|
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
|
||||||
|
|
|
||||||
|
|
@ -537,13 +537,19 @@ class DatabaseConnector:
|
||||||
try:
|
try:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT column_name FROM information_schema.columns
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'
|
WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'
|
||||||
""",
|
""",
|
||||||
(table,),
|
(table,),
|
||||||
)
|
)
|
||||||
|
existing_column_rows = cursor.fetchall()
|
||||||
existing_columns = {
|
existing_columns = {
|
||||||
row["column_name"] for row in cursor.fetchall()
|
row["column_name"] for row in existing_column_rows
|
||||||
|
}
|
||||||
|
existing_column_types = {
|
||||||
|
row["column_name"]: (row["data_type"] or "").lower()
|
||||||
|
for row in existing_column_rows
|
||||||
}
|
}
|
||||||
|
|
||||||
# Desired columns based on model
|
# Desired columns based on model
|
||||||
|
|
@ -569,6 +575,31 @@ class DatabaseConnector:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Could not add column '{col}' to '{table}': {add_err}"
|
f"Could not add column '{col}' to '{table}': {add_err}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Targeted type-downgrade: if a model field has been
|
||||||
|
# changed from a structured type (JSONB) to a plain
|
||||||
|
# TEXT field, alter the column so writes don't fail.
|
||||||
|
# JSONB -> TEXT is a safe, lossless cast (JSONB is
|
||||||
|
# rendered as its JSON-text representation; the
|
||||||
|
# corresponding Pydantic ``@field_validator`` is
|
||||||
|
# responsible for re-decoding legacy data on read).
|
||||||
|
for col in sorted(desired_columns & existing_columns):
|
||||||
|
if col == "id":
|
||||||
|
continue
|
||||||
|
desired_sql = (model_fields.get(col) or "").upper()
|
||||||
|
currentType = existing_column_types.get(col, "")
|
||||||
|
if desired_sql == "TEXT" and currentType == "jsonb":
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" TYPE TEXT USING "{col}"::text'
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Downgraded column '{col}' from JSONB to TEXT on '{table}'"
|
||||||
|
)
|
||||||
|
except Exception as alter_err:
|
||||||
|
logger.warning(
|
||||||
|
f"Could not downgrade column '{col}' on '{table}': {alter_err}"
|
||||||
|
)
|
||||||
except Exception as ensure_err:
|
except Exception as ensure_err:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Could not ensure columns for existing table '{table}': {ensure_err}"
|
f"Could not ensure columns for existing table '{table}': {ensure_err}"
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,21 @@ class ServiceAdapter(ABC):
|
||||||
"""Standardized operations for a single service of a provider."""
|
"""Standardized operations for a single service of a provider."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def browse(self, path: str, filter: Optional[str] = None) -> list:
|
async def browse(
|
||||||
"""List items (files/folders) at the given path."""
|
self,
|
||||||
|
path: str,
|
||||||
|
filter: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> list:
|
||||||
|
"""List items (files/folders) at the given path.
|
||||||
|
|
||||||
|
``limit`` is an optional upper bound for the number of returned entries.
|
||||||
|
Adapters that talk to paginated APIs should keep paging until either
|
||||||
|
the API is exhausted OR ``limit`` is reached. ``None`` means "use the
|
||||||
|
adapter's sensible default" (NOT "unlimited") so an over-eager caller
|
||||||
|
cannot accidentally pull millions of records. Adapters that have no
|
||||||
|
pagination (single page result) may ignore this parameter.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|
@ -39,8 +52,16 @@ class ServiceAdapter(ABC):
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def search(self, query: str, path: Optional[str] = None) -> list:
|
async def search(
|
||||||
"""Search for items matching the query."""
|
self,
|
||||||
|
query: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> list:
|
||||||
|
"""Search for items matching the query.
|
||||||
|
|
||||||
|
See :meth:`browse` for the semantics of ``limit``.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,12 @@ class ClickupListsAdapter(ServiceAdapter):
|
||||||
self._svc = ClickupService(context=None, get_service=lambda _: None)
|
self._svc = ClickupService(context=None, get_service=lambda _: None)
|
||||||
self._svc.setAccessToken(access_token)
|
self._svc.setAccessToken(access_token)
|
||||||
|
|
||||||
async def browse(self, path: str, filter: Optional[str] = None) -> List[ExternalEntry]:
|
async def browse(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
filter: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
p = _norm(path)
|
p = _norm(path)
|
||||||
out: List[ExternalEntry] = []
|
out: List[ExternalEntry] = []
|
||||||
|
|
||||||
|
|
@ -173,7 +178,11 @@ class ClickupListsAdapter(ServiceAdapter):
|
||||||
)
|
)
|
||||||
if len(tasks) < 100:
|
if len(tasks) < 100:
|
||||||
break
|
break
|
||||||
|
if limit is not None and len(out) >= int(limit):
|
||||||
|
break
|
||||||
page += 1
|
page += 1
|
||||||
|
if limit is not None:
|
||||||
|
out = out[: max(1, int(limit))]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
|
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
|
||||||
|
|
@ -213,7 +222,12 @@ class ClickupListsAdapter(ServiceAdapter):
|
||||||
task_id = m.group(3)
|
task_id = m.group(3)
|
||||||
return await self._svc.uploadTaskAttachment(task_id, data, fileName)
|
return await self._svc.uploadTaskAttachment(task_id, data, fileName)
|
||||||
|
|
||||||
async def search(self, query: str, path: Optional[str] = None) -> List[ExternalEntry]:
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
base = _norm(path or "/")
|
base = _norm(path or "/")
|
||||||
team_id: Optional[str] = None
|
team_id: Optional[str] = None
|
||||||
mt = re.match(r"^/team/([^/]+)", base)
|
mt = re.match(r"^/team/([^/]+)", base)
|
||||||
|
|
@ -252,7 +266,11 @@ class ClickupListsAdapter(ServiceAdapter):
|
||||||
)
|
)
|
||||||
if len(tasks) < 25:
|
if len(tasks) < 25:
|
||||||
break
|
break
|
||||||
|
if limit is not None and len(out) >= int(limit):
|
||||||
|
break
|
||||||
page += 1
|
page += 1
|
||||||
|
if limit is not None:
|
||||||
|
out = out[: max(1, int(limit))]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,12 @@ class FtpFilesAdapter(ServiceAdapter):
|
||||||
def __init__(self, accessToken: str):
|
def __init__(self, accessToken: str):
|
||||||
self._accessToken = accessToken
|
self._accessToken = accessToken
|
||||||
|
|
||||||
async def browse(self, path: str, filter: Optional[str] = None) -> List[ExternalEntry]:
|
async def browse(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
filter: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
logger.info(f"FTP browse stub: {path}")
|
logger.info(f"FTP browse stub: {path}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -32,7 +37,12 @@ class FtpFilesAdapter(ServiceAdapter):
|
||||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||||
return {"error": "FTP upload not yet implemented"}
|
return {"error": "FTP upload not yet implemented"}
|
||||||
|
|
||||||
async def search(self, query: str, path: Optional[str] = None) -> List[ExternalEntry]:
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,17 @@ class DriveAdapter(ServiceAdapter):
|
||||||
def __init__(self, accessToken: str):
|
def __init__(self, accessToken: str):
|
||||||
self._token = accessToken
|
self._token = accessToken
|
||||||
|
|
||||||
async def browse(self, path: str, filter: Optional[str] = None) -> List[ExternalEntry]:
|
async def browse(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
filter: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
folderId = (path or "").strip("/") or "root"
|
folderId = (path or "").strip("/") or "root"
|
||||||
query = f"'{folderId}' in parents and trashed=false"
|
query = f"'{folderId}' in parents and trashed=false"
|
||||||
fields = "files(id,name,mimeType,size,modifiedTime,parents)"
|
fields = "files(id,name,mimeType,size,modifiedTime,parents)"
|
||||||
url = f"{_DRIVE_BASE}/files?q={query}&fields={fields}&pageSize=100&orderBy=folder,name"
|
pageSize = max(1, min(int(limit or 100), 1000))
|
||||||
|
url = f"{_DRIVE_BASE}/files?q={query}&fields={fields}&pageSize={pageSize}&orderBy=folder,name"
|
||||||
|
|
||||||
result = await _googleGet(self._token, url)
|
result = await _googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
|
|
@ -111,14 +117,20 @@ class DriveAdapter(ServiceAdapter):
|
||||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||||
return {"error": "Google Drive upload not yet implemented"}
|
return {"error": "Google Drive upload not yet implemented"}
|
||||||
|
|
||||||
async def search(self, query: str, path: Optional[str] = None) -> List[ExternalEntry]:
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
safeQuery = query.replace("'", "\\'")
|
safeQuery = query.replace("'", "\\'")
|
||||||
folderId = (path or "").strip("/")
|
folderId = (path or "").strip("/")
|
||||||
qParts = [f"name contains '{safeQuery}'", "trashed=false"]
|
qParts = [f"name contains '{safeQuery}'", "trashed=false"]
|
||||||
if folderId:
|
if folderId:
|
||||||
qParts.append(f"'{folderId}' in parents")
|
qParts.append(f"'{folderId}' in parents")
|
||||||
qStr = " and ".join(qParts)
|
qStr = " and ".join(qParts)
|
||||||
url = f"{_DRIVE_BASE}/files?q={qStr}&fields=files(id,name,mimeType,size)&pageSize=25"
|
pageSize = max(1, min(int(limit or 100), 1000))
|
||||||
|
url = f"{_DRIVE_BASE}/files?q={qStr}&fields=files(id,name,mimeType,size)&pageSize={pageSize}"
|
||||||
logger.debug(f"Google Drive search: q={qStr}")
|
logger.debug(f"Google Drive search: q={qStr}")
|
||||||
result = await _googleGet(self._token, url)
|
result = await _googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
|
|
@ -140,7 +152,15 @@ class GmailAdapter(ServiceAdapter):
|
||||||
def __init__(self, accessToken: str):
|
def __init__(self, accessToken: str):
|
||||||
self._token = accessToken
|
self._token = accessToken
|
||||||
|
|
||||||
async def browse(self, path: str, filter: Optional[str] = None) -> list:
|
_DEFAULT_MESSAGE_LIMIT = 100
|
||||||
|
_MAX_MESSAGE_LIMIT = 500
|
||||||
|
|
||||||
|
async def browse(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
filter: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> list:
|
||||||
cleanPath = (path or "").strip("/")
|
cleanPath = (path or "").strip("/")
|
||||||
|
|
||||||
if not cleanPath:
|
if not cleanPath:
|
||||||
|
|
@ -165,13 +185,14 @@ class GmailAdapter(ServiceAdapter):
|
||||||
labels.sort(key=lambda e: (0 if e.metadata.get("type") == "system" else 1, e.name))
|
labels.sort(key=lambda e: (0 if e.metadata.get("type") == "system" else 1, e.name))
|
||||||
return labels
|
return labels
|
||||||
|
|
||||||
url = f"{_GMAIL_BASE}/users/me/messages?labelIds={cleanPath}&maxResults=25"
|
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
|
||||||
|
url = f"{_GMAIL_BASE}/users/me/messages?labelIds={cleanPath}&maxResults={effectiveLimit}"
|
||||||
result = await _googleGet(self._token, url)
|
result = await _googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
for msg in result.get("messages", [])[:25]:
|
for msg in result.get("messages", [])[:effectiveLimit]:
|
||||||
msgId = msg.get("id", "")
|
msgId = msg.get("id", "")
|
||||||
detailUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date"
|
detailUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date"
|
||||||
detail = await _googleGet(self._token, detailUrl)
|
detail = await _googleGet(self._token, detailUrl)
|
||||||
|
|
@ -231,8 +252,14 @@ class GmailAdapter(ServiceAdapter):
|
||||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||||
return {"error": "Gmail upload not applicable"}
|
return {"error": "Gmail upload not applicable"}
|
||||||
|
|
||||||
async def search(self, query: str, path: Optional[str] = None) -> list:
|
async def search(
|
||||||
url = f"{_GMAIL_BASE}/users/me/messages?q={query}&maxResults=10"
|
self,
|
||||||
|
query: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> list:
|
||||||
|
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
|
||||||
|
url = f"{_GMAIL_BASE}/users/me/messages?q={query}&maxResults={effectiveLimit}"
|
||||||
result = await _googleGet(self._token, url)
|
result = await _googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,16 @@ async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]:
|
||||||
return {"error": f"{resp.status}: {errorText}"}
|
return {"error": f"{resp.status}: {errorText}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _stripGraphBase(url: str) -> str:
|
||||||
|
"""Convert an absolute Graph URL (used by @odata.nextLink) into the
|
||||||
|
relative endpoint that ``_makeGraphCall`` expects."""
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
if url.startswith(_GRAPH_BASE):
|
||||||
|
return url[len(_GRAPH_BASE):].lstrip("/")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
def _graphItemToExternalEntry(item: Dict[str, Any], basePath: str = "") -> ExternalEntry:
|
def _graphItemToExternalEntry(item: Dict[str, Any], basePath: str = "") -> ExternalEntry:
|
||||||
isFolder = "folder" in item
|
isFolder = "folder" in item
|
||||||
return ExternalEntry(
|
return ExternalEntry(
|
||||||
|
|
@ -128,7 +138,12 @@ def _graphItemToExternalEntry(item: Dict[str, Any], basePath: str = "") -> Exter
|
||||||
class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
"""ServiceAdapter for SharePoint (files, sites) via Microsoft Graph."""
|
"""ServiceAdapter for SharePoint (files, sites) via Microsoft Graph."""
|
||||||
|
|
||||||
async def browse(self, path: str, filter: Optional[str] = None) -> List[ExternalEntry]:
|
async def browse(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
filter: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
"""List items in a SharePoint folder.
|
"""List items in a SharePoint folder.
|
||||||
|
|
||||||
Path format: /sites/<SiteName>/<FolderPath>
|
Path format: /sites/<SiteName>/<FolderPath>
|
||||||
|
|
@ -155,6 +170,8 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
entries = [_graphItemToExternalEntry(item, path) for item in result.get("value", [])]
|
entries = [_graphItemToExternalEntry(item, path) for item in result.get("value", [])]
|
||||||
if filter:
|
if filter:
|
||||||
entries = [e for e in entries if _matchFilter(e, filter)]
|
entries = [e for e in entries if _matchFilter(e, filter)]
|
||||||
|
if limit is not None:
|
||||||
|
entries = entries[: max(1, int(limit))]
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
async def _discoverSites(self) -> List[ExternalEntry]:
|
async def _discoverSites(self) -> List[ExternalEntry]:
|
||||||
|
|
@ -197,7 +214,12 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
result = await self._graphPut(endpoint, data)
|
result = await self._graphPut(endpoint, data)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def search(self, query: str, path: Optional[str] = None) -> List[ExternalEntry]:
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
siteId, _ = _parseSharepointPath(path or "")
|
siteId, _ = _parseSharepointPath(path or "")
|
||||||
if not siteId:
|
if not siteId:
|
||||||
return []
|
return []
|
||||||
|
|
@ -206,7 +228,10 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
result = await self._graphGet(endpoint)
|
result = await self._graphGet(endpoint)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return []
|
return []
|
||||||
return [_graphItemToExternalEntry(item) for item in result.get("value", [])]
|
entries = [_graphItemToExternalEntry(item) for item in result.get("value", [])]
|
||||||
|
if limit is not None:
|
||||||
|
entries = entries[: max(1, int(limit))]
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -216,31 +241,89 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
"""ServiceAdapter for Outlook (mail, calendar) via Microsoft Graph."""
|
"""ServiceAdapter for Outlook (mail, calendar) via Microsoft Graph."""
|
||||||
|
|
||||||
async def browse(self, path: str, filter: Optional[str] = None) -> List[ExternalEntry]:
|
# Default upper bound for messages returned from a single browse() call.
|
||||||
|
# Graph allows $top up to 1000 per page; we keep the default modest so
|
||||||
|
# accidental "browse all" calls don't blow up the LLM context. Callers
|
||||||
|
# (e.g. the agent's browseDataSource tool) can override via ``limit``.
|
||||||
|
_DEFAULT_MESSAGE_LIMIT = 100
|
||||||
|
_MAX_MESSAGE_LIMIT = 1000
|
||||||
|
_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
async def browse(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
filter: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
"""List mail folders or messages.
|
"""List mail folders or messages.
|
||||||
|
|
||||||
path = "" or "/" → list mail folders
|
path = "" or "/" → list ALL top-level mail folders (paginated)
|
||||||
path = "/Inbox" → list messages in Inbox
|
path = "/<folderId>" → list messages in that folder (paginated, up to ``limit``)
|
||||||
"""
|
"""
|
||||||
if not path or path == "/":
|
if not path or path == "/":
|
||||||
result = await self._graphGet("me/mailFolders")
|
# Graph default page size for /me/mailFolders is 10. Mailboxes with
|
||||||
|
# localized + many system folders (Posteingang, Gesendet, Archiv, …)
|
||||||
|
# often exceed that, so the well-known Inbox can fall off the first
|
||||||
|
# page. We page through all results AND hard-fall-back to the
|
||||||
|
# well-known shortcut /me/mailFolders/inbox so the default folder
|
||||||
|
# is always visible regardless of locale/order.
|
||||||
|
folders: List[Dict[str, Any]] = []
|
||||||
|
seenIds: set = set()
|
||||||
|
endpoint: Optional[str] = "me/mailFolders?$top=100"
|
||||||
|
while endpoint:
|
||||||
|
result = await self._graphGet(endpoint)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return []
|
break
|
||||||
|
for f in result.get("value", []):
|
||||||
|
fid = f.get("id")
|
||||||
|
if fid and fid not in seenIds:
|
||||||
|
seenIds.add(fid)
|
||||||
|
folders.append(f)
|
||||||
|
nextLink = result.get("@odata.nextLink")
|
||||||
|
if not nextLink:
|
||||||
|
endpoint = None
|
||||||
|
else:
|
||||||
|
endpoint = _stripGraphBase(nextLink)
|
||||||
|
|
||||||
|
# Guarantee Inbox is present (well-known name, locale-independent)
|
||||||
|
if not any((f.get("displayName") or "").lower() in ("inbox", "posteingang") for f in folders):
|
||||||
|
inbox = await self._graphGet("me/mailFolders/inbox")
|
||||||
|
if "error" not in inbox and inbox.get("id") and inbox.get("id") not in seenIds:
|
||||||
|
folders.insert(0, inbox)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
ExternalEntry(
|
ExternalEntry(
|
||||||
name=f.get("displayName", ""),
|
name=f.get("displayName", ""),
|
||||||
path=f"/{f.get('id', '')}",
|
path=f"/{f.get('id', '')}",
|
||||||
isFolder=True,
|
isFolder=True,
|
||||||
metadata={"id": f.get("id"), "totalItemCount": f.get("totalItemCount")},
|
metadata={
|
||||||
|
"id": f.get("id"),
|
||||||
|
"totalItemCount": f.get("totalItemCount"),
|
||||||
|
"unreadItemCount": f.get("unreadItemCount"),
|
||||||
|
"childFolderCount": f.get("childFolderCount"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
for f in result.get("value", [])
|
for f in folders
|
||||||
]
|
]
|
||||||
|
|
||||||
folderId = path.strip("/")
|
folderId = path.strip("/")
|
||||||
endpoint = f"me/mailFolders/{folderId}/messages?$top=25&$orderby=receivedDateTime desc"
|
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
|
||||||
|
pageSize = min(self._PAGE_SIZE, effectiveLimit)
|
||||||
|
endpoint: Optional[str] = (
|
||||||
|
f"me/mailFolders/{folderId}/messages"
|
||||||
|
f"?$top={pageSize}&$orderby=receivedDateTime desc"
|
||||||
|
)
|
||||||
|
messages: List[Dict[str, Any]] = []
|
||||||
|
while endpoint and len(messages) < effectiveLimit:
|
||||||
result = await self._graphGet(endpoint)
|
result = await self._graphGet(endpoint)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return []
|
break
|
||||||
|
for m in result.get("value", []):
|
||||||
|
messages.append(m)
|
||||||
|
if len(messages) >= effectiveLimit:
|
||||||
|
break
|
||||||
|
nextLink = result.get("@odata.nextLink")
|
||||||
|
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||||
return [
|
return [
|
||||||
ExternalEntry(
|
ExternalEntry(
|
||||||
name=m.get("subject", "(no subject)"),
|
name=m.get("subject", "(no subject)"),
|
||||||
|
|
@ -253,7 +336,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
"hasAttachments": m.get("hasAttachments", False),
|
"hasAttachments": m.get("hasAttachments", False),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
for m in result.get("value", [])
|
for m in messages
|
||||||
]
|
]
|
||||||
|
|
||||||
async def download(self, path: str) -> DownloadResult:
|
async def download(self, path: str) -> DownloadResult:
|
||||||
|
|
@ -279,9 +362,17 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
"""Not applicable for Outlook in the file sense."""
|
"""Not applicable for Outlook in the file sense."""
|
||||||
return {"error": "Upload not supported for Outlook"}
|
return {"error": "Upload not supported for Outlook"}
|
||||||
|
|
||||||
async def search(self, query: str, path: Optional[str] = None) -> List[ExternalEntry]:
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
safeQuery = query.replace("'", "''")
|
safeQuery = query.replace("'", "''")
|
||||||
endpoint = f"me/messages?$search=\"{safeQuery}\"&$top=25"
|
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
|
||||||
|
# NOTE: Graph $search does not support $orderby and may return a single
|
||||||
|
# page (no @odata.nextLink). We still pass $top to lift the implicit 25.
|
||||||
|
endpoint = f"me/messages?$search=\"{safeQuery}\"&$top={effectiveLimit}"
|
||||||
result = await self._graphGet(endpoint)
|
result = await self._graphGet(endpoint)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return []
|
return []
|
||||||
|
|
@ -366,7 +457,12 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
class TeamsAdapter(_GraphApiMixin, ServiceAdapter):
|
class TeamsAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
"""ServiceAdapter for Microsoft Teams -- browse joined teams and channels."""
|
"""ServiceAdapter for Microsoft Teams -- browse joined teams and channels."""
|
||||||
|
|
||||||
async def browse(self, path: str, filter: Optional[str] = None) -> list:
|
async def browse(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
filter: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> list:
|
||||||
cleanPath = (path or "").strip("/")
|
cleanPath = (path or "").strip("/")
|
||||||
|
|
||||||
if not cleanPath:
|
if not cleanPath:
|
||||||
|
|
@ -408,7 +504,12 @@ class TeamsAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||||
return {"error": "Teams upload not implemented"}
|
return {"error": "Teams upload not implemented"}
|
||||||
|
|
||||||
async def search(self, query: str, path: Optional[str] = None) -> list:
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> list:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -419,7 +520,12 @@ class TeamsAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
"""ServiceAdapter stub for OneDrive (personal drive)."""
|
"""ServiceAdapter stub for OneDrive (personal drive)."""
|
||||||
|
|
||||||
async def browse(self, path: str, filter: Optional[str] = None) -> List[ExternalEntry]:
|
async def browse(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
filter: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
cleanPath = (path or "").strip("/")
|
cleanPath = (path or "").strip("/")
|
||||||
if not cleanPath:
|
if not cleanPath:
|
||||||
endpoint = "me/drive/root/children"
|
endpoint = "me/drive/root/children"
|
||||||
|
|
@ -432,6 +538,8 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
entries = [_graphItemToExternalEntry(item, path) for item in result.get("value", [])]
|
entries = [_graphItemToExternalEntry(item, path) for item in result.get("value", [])]
|
||||||
if filter:
|
if filter:
|
||||||
entries = [e for e in entries if _matchFilter(e, filter)]
|
entries = [e for e in entries if _matchFilter(e, filter)]
|
||||||
|
if limit is not None:
|
||||||
|
entries = entries[: max(1, int(limit))]
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
async def download(self, path: str) -> bytes:
|
async def download(self, path: str) -> bytes:
|
||||||
|
|
@ -447,13 +555,21 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
endpoint = f"me/drive/root:/{uploadPath}:/content"
|
endpoint = f"me/drive/root:/{uploadPath}:/content"
|
||||||
return await self._graphPut(endpoint, data)
|
return await self._graphPut(endpoint, data)
|
||||||
|
|
||||||
async def search(self, query: str, path: Optional[str] = None) -> List[ExternalEntry]:
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[ExternalEntry]:
|
||||||
safeQuery = query.replace("'", "''")
|
safeQuery = query.replace("'", "''")
|
||||||
endpoint = f"me/drive/root/search(q='{safeQuery}')"
|
endpoint = f"me/drive/root/search(q='{safeQuery}')"
|
||||||
result = await self._graphGet(endpoint)
|
result = await self._graphGet(endpoint)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return []
|
return []
|
||||||
return [_graphItemToExternalEntry(item) for item in result.get("value", [])]
|
entries = [_graphItemToExternalEntry(item) for item in result.get("value", [])]
|
||||||
|
if limit is not None:
|
||||||
|
entries = entries[: max(1, int(limit))]
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,12 @@ class ChatWorkflow(PowerOnModel):
|
||||||
]})
|
]})
|
||||||
maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"label": "Max. Schritte", "frontend_type": "integer", "frontend_readonly": False, "frontend_required": False})
|
maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"label": "Max. Schritte", "frontend_type": "integer", "frontend_readonly": False, "frontend_required": False})
|
||||||
expectedFormats: Optional[List[str]] = Field(None, description="List of expected file format extensions from user request (e.g., ['xlsx', 'pdf']). Extracted during intent analysis.", json_schema_extra={"label": "Erwartete Formate", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
expectedFormats: Optional[List[str]] = Field(None, description="List of expected file format extensions from user request (e.g., ['xlsx', 'pdf']). Extracted during intent analysis.", json_schema_extra={"label": "Erwartete Formate", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
# Attached data sources (per-chat persistence so the chip-bar of the
|
||||||
|
# WorkspaceInput can be restored when the user re-opens the chat).
|
||||||
|
# Stored as JSONB list of UUIDs. Sources that no longer resolve (DS
|
||||||
|
# deleted in the meantime) are silently dropped on the frontend on load.
|
||||||
|
attachedDataSourceIds: Optional[List[str]] = Field(default_factory=list, description="IDs of DataSource records pinned to this chat (UDB attachments).", json_schema_extra={"label": "Angehängte Datenquellen", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
attachedFeatureDataSourceIds: Optional[List[str]] = Field(default_factory=list, description="IDs of FeatureDataSource records pinned to this chat (UDB feature attachments).", json_schema_extra={"label": "Angehängte Feature-Datenquellen", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
|
||||||
# Helper methods for execution state management
|
# Helper methods for execution state management
|
||||||
def getRoundIndex(self) -> int:
|
def getRoundIndex(self) -> int:
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,57 @@ class UserPermissions(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceAddress(BaseModel):
|
||||||
|
"""
|
||||||
|
Historische strukturierte Rechnungsadresse. NICHT MEHR aktiv verwendet
|
||||||
|
-- die Felder sind seit 2026-04-20 als ``invoiceCompanyName`` /
|
||||||
|
``invoiceLine1`` / ``invoicePostalCode`` / ... direkt auf ``Mandate``
|
||||||
|
deklariert (siehe dort). Diese Klasse bleibt nur noch erhalten, falls
|
||||||
|
Bestandscode irgendwo das Schema dokumentiert oder alte JSONB-Dicts
|
||||||
|
serialisiert; sie wird vom Mandate-Modell nicht mehr referenziert.
|
||||||
|
"""
|
||||||
|
companyName: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Firmenname / Empfaenger der Rechnung (falls abweichend vom Mandate.label)",
|
||||||
|
)
|
||||||
|
contactName: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Ansprechperson (z. B. Buchhaltung)",
|
||||||
|
)
|
||||||
|
email: Optional[EmailStr] = Field(
|
||||||
|
default=None,
|
||||||
|
description="E-Mail-Adresse fuer den Versand der Stripe-Rechnung",
|
||||||
|
)
|
||||||
|
line1: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Strasse + Nr. (Adresszeile 1)",
|
||||||
|
)
|
||||||
|
line2: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Adresszeile 2 (z. B. c/o, Postfach)",
|
||||||
|
)
|
||||||
|
postalCode: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="PLZ",
|
||||||
|
)
|
||||||
|
city: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Ort",
|
||||||
|
)
|
||||||
|
state: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Kanton / Bundesland",
|
||||||
|
)
|
||||||
|
country: Optional[str] = Field(
|
||||||
|
default="CH",
|
||||||
|
description="ISO-3166 Alpha-2 Laendercode (Default: CH)",
|
||||||
|
)
|
||||||
|
vatNumber: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="UID / MWST-Nummer des Empfaengers (z. B. CHE-123.456.789 MWST)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Mandant")
|
@i18nModel("Mandant")
|
||||||
class Mandate(PowerOnModel):
|
class Mandate(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
|
|
@ -123,6 +174,169 @@ class Mandate(PowerOnModel):
|
||||||
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.",
|
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.",
|
||||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gelöscht am"},
|
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gelöscht am"},
|
||||||
)
|
)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Rechnungsadresse (CH-Treuhand-konform, strukturiert)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Einzelne Felder statt eines nested Objekts/Freitexts, damit
|
||||||
|
# (a) der FormGenerator sie automatisch als Eingabezeilen rendert,
|
||||||
|
# (b) der Stripe-Checkout sie 1:1 in `customer.address`,
|
||||||
|
# `customer.email`, `customer.tax_id_data` mappen kann
|
||||||
|
# (Stripe verlangt die Adresse strukturiert, nicht als Freitext).
|
||||||
|
# ``order`` 200-209 gruppiert die Felder visuell am Ende des Formulars.
|
||||||
|
invoiceCompanyName: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Firmenname / Empfaenger der Rechnung (falls abweichend vom Voller Name).",
|
||||||
|
max_length=200,
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Rechnungsadresse - Firma",
|
||||||
|
"order": 200,
|
||||||
|
"placeholder": "Muster Treuhand AG",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
invoiceContactName: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Ansprechperson z. H. (z. B. Buchhaltung).",
|
||||||
|
max_length=200,
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Rechnungsadresse - z. H.",
|
||||||
|
"order": 201,
|
||||||
|
"placeholder": "Buchhaltung",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
invoiceEmail: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="E-Mail-Adresse fuer den Versand der Stripe-Rechnung.",
|
||||||
|
max_length=254,
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "email",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Rechnungsadresse - E-Mail",
|
||||||
|
"order": 202,
|
||||||
|
"placeholder": "rechnungen@firma.ch",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
invoiceLine1: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Adresszeile 1 (Strasse + Nr.). Pflichtfeld fuer Stripe-Customer-Adresse.",
|
||||||
|
max_length=200,
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Rechnungsadresse - Strasse + Nr.",
|
||||||
|
"order": 203,
|
||||||
|
"placeholder": "Bahnhofstrasse 1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
invoiceLine2: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Adresszeile 2 (z. B. c/o, Postfach).",
|
||||||
|
max_length=200,
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Rechnungsadresse - Adresszusatz",
|
||||||
|
"order": 204,
|
||||||
|
"placeholder": "c/o Buchhaltung",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
invoicePostalCode: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="PLZ.",
|
||||||
|
max_length=20,
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Rechnungsadresse - PLZ",
|
||||||
|
"order": 205,
|
||||||
|
"placeholder": "8000",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
invoiceCity: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Ort.",
|
||||||
|
max_length=100,
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Rechnungsadresse - Ort",
|
||||||
|
"order": 206,
|
||||||
|
"placeholder": "Zuerich",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
invoiceState: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Kanton / Bundesland (optional).",
|
||||||
|
max_length=100,
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Rechnungsadresse - Kanton",
|
||||||
|
"order": 207,
|
||||||
|
"placeholder": "ZH",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
invoiceCountry: Optional[str] = Field(
|
||||||
|
default="CH",
|
||||||
|
description="ISO-3166 Alpha-2 Laendercode (Default: CH).",
|
||||||
|
max_length=2,
|
||||||
|
pattern=r"^[A-Z]{2}$",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Rechnungsadresse - Land (ISO)",
|
||||||
|
"order": 208,
|
||||||
|
"placeholder": "CH",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
invoiceVatNumber: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="UID / MWST-Nummer des Empfaengers (z. B. CHE-123.456.789 MWST). Wird Stripe als `tax_id_data` mitgegeben.",
|
||||||
|
max_length=50,
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Rechnungsadresse - UID-Nr.",
|
||||||
|
"order": 209,
|
||||||
|
"placeholder": "CHE-123.456.789 MWST",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"invoiceCompanyName",
|
||||||
|
"invoiceContactName",
|
||||||
|
"invoiceEmail",
|
||||||
|
"invoiceLine1",
|
||||||
|
"invoiceLine2",
|
||||||
|
"invoicePostalCode",
|
||||||
|
"invoiceCity",
|
||||||
|
"invoiceState",
|
||||||
|
"invoiceVatNumber",
|
||||||
|
mode="before",
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def _coerceInvoiceTextField(cls, v):
|
||||||
|
"""Trim incoming address strings; treat empty as ``None``."""
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, str):
|
||||||
|
trimmed = v.strip()
|
||||||
|
return trimmed or None
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("invoiceCountry", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerceInvoiceCountry(cls, v):
|
||||||
|
"""Normalize country code: trim, upper-case, empty -> default ``CH``."""
|
||||||
|
if v is None:
|
||||||
|
return "CH"
|
||||||
|
if isinstance(v, str):
|
||||||
|
trimmed = v.strip().upper()
|
||||||
|
return trimmed or "CH"
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator('isSystem', mode='before')
|
@field_validator('isSystem', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Creates a complete demo environment with two mandates, one user,
|
||||||
and all feature instances needed for the investor live demo.
|
and all feature instances needed for the investor live demo.
|
||||||
|
|
||||||
Mandates:
|
Mandates:
|
||||||
- HappyLife AG (happylife) — Dokumentenablage, Buchhaltung, Automationen, Chatbot, Datenschutz
|
- HappyLife AG (happylife) — Dokumentenablage, Buchhaltung, Automationen, Datenschutz
|
||||||
- Alpina Treuhand AG (alpina) — Dokumentenablage, 3x Treuhand-Kunden, Automationen, Datenschutz
|
- Alpina Treuhand AG (alpina) — Dokumentenablage, 3x Treuhand-Kunden, Automationen, Datenschutz
|
||||||
|
|
||||||
User:
|
User:
|
||||||
|
|
@ -45,7 +45,6 @@ _FEATURES_HAPPYLIFE = [
|
||||||
{"code": "workspace", "label": "Dokumentenablage"},
|
{"code": "workspace", "label": "Dokumentenablage"},
|
||||||
{"code": "trustee", "label": "Buchhaltung"},
|
{"code": "trustee", "label": "Buchhaltung"},
|
||||||
{"code": "graphicalEditor", "label": "Automationen"},
|
{"code": "graphicalEditor", "label": "Automationen"},
|
||||||
{"code": "chatbot", "label": "Chatbot"},
|
|
||||||
{"code": "neutralization", "label": "Datenschutz"},
|
{"code": "neutralization", "label": "Datenschutz"},
|
||||||
]
|
]
|
||||||
_FEATURES_ALPINA = [
|
_FEATURES_ALPINA = [
|
||||||
|
|
@ -63,7 +62,7 @@ class InvestorDemo2026(_BaseDemoConfig):
|
||||||
label = "Investor Demo April 2026"
|
label = "Investor Demo April 2026"
|
||||||
description = (
|
description = (
|
||||||
"Two mandates (HappyLife AG + Alpina Treuhand AG), one SysAdmin user, "
|
"Two mandates (HappyLife AG + Alpina Treuhand AG), one SysAdmin user, "
|
||||||
"trustee with RMA, workspace, graph editor, chatbot, and neutralization."
|
"trustee with RMA, workspace, graph editor, and neutralization."
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,13 @@ UI_OBJECTS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
DATA_OBJECTS = [
|
DATA_OBJECTS = [
|
||||||
|
# ── Record-Hierarchie: Context → Session → Message/Score, Context → Task ──
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingContext",
|
"objectKey": "data.feature.commcoach.CoachingContext",
|
||||||
"label": "Coaching-Kontext",
|
"label": "Coaching-Kontext",
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingContext",
|
"table": "CoachingContext",
|
||||||
"fields": ["id", "title", "category", "status"],
|
"fields": ["id", "title", "category", "status", "lastSessionAt"],
|
||||||
"isParent": True,
|
"isParent": True,
|
||||||
"displayFields": ["title", "category", "status"],
|
"displayFields": ["title", "category", "status"],
|
||||||
}
|
}
|
||||||
|
|
@ -48,45 +49,75 @@ DATA_OBJECTS = [
|
||||||
"label": "Coaching-Session",
|
"label": "Coaching-Session",
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingSession",
|
"table": "CoachingSession",
|
||||||
"fields": ["id", "contextId", "status", "summary"],
|
"fields": ["id", "contextId", "status", "summary", "startedAt", "endedAt", "competenceScore"],
|
||||||
|
"isParent": True,
|
||||||
"parentTable": "CoachingContext",
|
"parentTable": "CoachingContext",
|
||||||
"parentKey": "contextId",
|
"parentKey": "contextId",
|
||||||
|
"displayFields": ["startedAt", "status"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingMessage",
|
"objectKey": "data.feature.commcoach.CoachingMessage",
|
||||||
"label": "Coaching-Nachricht",
|
"label": "Coaching-Nachricht",
|
||||||
"meta": {"table": "CoachingMessage", "fields": ["id", "sessionId", "role", "content"]}
|
"meta": {
|
||||||
|
"table": "CoachingMessage",
|
||||||
|
"fields": ["id", "sessionId", "contextId", "role", "content", "contentType"],
|
||||||
|
"parentTable": "CoachingSession",
|
||||||
|
"parentKey": "sessionId",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "data.feature.commcoach.CoachingScore",
|
||||||
|
"label": "Coaching-Score",
|
||||||
|
"meta": {
|
||||||
|
"table": "CoachingScore",
|
||||||
|
"fields": ["id", "sessionId", "contextId", "dimension", "score", "trend"],
|
||||||
|
"parentTable": "CoachingSession",
|
||||||
|
"parentKey": "sessionId",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingTask",
|
"objectKey": "data.feature.commcoach.CoachingTask",
|
||||||
"label": "Coaching-Aufgabe",
|
"label": "Coaching-Aufgabe",
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingTask",
|
"table": "CoachingTask",
|
||||||
"fields": ["id", "contextId", "title", "status"],
|
"fields": ["id", "contextId", "title", "status", "priority", "dueDate"],
|
||||||
"parentTable": "CoachingContext",
|
"parentTable": "CoachingContext",
|
||||||
"parentKey": "contextId",
|
"parentKey": "contextId",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
# ── Stammdaten (sessionübergreifend, scoped per userId) ──────────────────
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingScore",
|
"objectKey": "data.feature.commcoach.userData",
|
||||||
"label": "Coaching-Score",
|
"label": "Stammdaten",
|
||||||
"meta": {"table": "CoachingScore", "fields": ["id", "dimension", "score", "trend"]}
|
"meta": {"isGroup": True}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingUserProfile",
|
"objectKey": "data.feature.commcoach.CoachingUserProfile",
|
||||||
"label": "Benutzerprofil",
|
"label": "Benutzerprofil",
|
||||||
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "dailyReminderEnabled"]}
|
"meta": {
|
||||||
|
"table": "CoachingUserProfile",
|
||||||
|
"group": "data.feature.commcoach.userData",
|
||||||
|
"fields": ["id", "userId", "dailyReminderEnabled", "streakDays", "totalSessions"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingPersona",
|
"objectKey": "data.feature.commcoach.CoachingPersona",
|
||||||
"label": "Coaching-Persona",
|
"label": "Coaching-Persona",
|
||||||
"meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]}
|
"meta": {
|
||||||
|
"table": "CoachingPersona",
|
||||||
|
"group": "data.feature.commcoach.userData",
|
||||||
|
"fields": ["id", "key", "label", "gender", "category"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingBadge",
|
"objectKey": "data.feature.commcoach.CoachingBadge",
|
||||||
"label": "Coaching-Auszeichnung",
|
"label": "Coaching-Auszeichnung",
|
||||||
"meta": {"table": "CoachingBadge", "fields": ["id", "badgeKey", "awardedAt"]}
|
"meta": {
|
||||||
|
"table": "CoachingBadge",
|
||||||
|
"group": "data.feature.commcoach.userData",
|
||||||
|
"fields": ["id", "badgeKey", "awardedAt"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.*",
|
"objectKey": "data.feature.commcoach.*",
|
||||||
|
|
|
||||||
|
|
@ -23,25 +23,20 @@ UI_OBJECTS = [
|
||||||
"label": "Dashboard",
|
"label": "Dashboard",
|
||||||
"meta": {"area": "dashboard"}
|
"meta": {"area": "dashboard"}
|
||||||
},
|
},
|
||||||
|
# Note: ui.feature.trustee.positions and .documents removed.
|
||||||
|
# Positionen and Dokumente are now consolidated tabs inside the
|
||||||
|
# ui.feature.trustee.data-tables view (TrusteeDataTablesView).
|
||||||
|
# Data-level RBAC (data.feature.trustee.TrusteePosition / .TrusteeDocument)
|
||||||
|
# remains and continues to gate per-row access.
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.trustee.positions",
|
"objectKey": "ui.feature.trustee.data-tables",
|
||||||
"label": "Positionen",
|
"label": "Daten-Tabellen",
|
||||||
"meta": {"area": "positions"}
|
"meta": {"area": "data-tables"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.trustee.documents",
|
"objectKey": "ui.feature.trustee.import-process",
|
||||||
"label": "Dokumente",
|
"label": "Import & Verarbeitung",
|
||||||
"meta": {"area": "documents"}
|
"meta": {"area": "import-process"}
|
||||||
},
|
|
||||||
{
|
|
||||||
"objectKey": "ui.feature.trustee.expense-import",
|
|
||||||
"label": "Spesen Import",
|
|
||||||
"meta": {"area": "expense-import"}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"objectKey": "ui.feature.trustee.scan-upload",
|
|
||||||
"label": "Scannen / Hochladen",
|
|
||||||
"meta": {"area": "scan-upload"}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.trustee.analyse",
|
"objectKey": "ui.feature.trustee.analyse",
|
||||||
|
|
@ -66,72 +61,110 @@ UI_OBJECTS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
# DATA Objects for RBAC catalog (tables/entities)
|
# DATA Objects for RBAC catalog (tables/entities)
|
||||||
# Used for AccessRules on data-level permissions
|
# Used for AccessRules on data-level permissions.
|
||||||
|
# Architecture note: a feature instance IS the organisation. There is no
|
||||||
|
# TrusteeOrganisation parent grouping in the UDB — all tables are scoped
|
||||||
|
# to the feature instance via featureInstanceId.
|
||||||
DATA_OBJECTS = [
|
DATA_OBJECTS = [
|
||||||
|
# ── Categorical Groups (UDB folders) ─────────────────────────────────────
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.TrusteeOrganisation",
|
"objectKey": "data.feature.trustee.localData",
|
||||||
"label": "Organisation",
|
"label": "Lokale Daten",
|
||||||
"meta": {
|
"meta": {"isGroup": True}
|
||||||
"table": "TrusteeOrganisation",
|
|
||||||
"fields": ["id", "label", "enabled"],
|
|
||||||
"isParent": True,
|
|
||||||
"displayFields": ["label"],
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "data.feature.trustee.config",
|
||||||
|
"label": "Konfiguration",
|
||||||
|
"meta": {"isGroup": True}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "data.feature.trustee.accountingData",
|
||||||
|
"label": "Daten aus Buchhaltungssystem",
|
||||||
|
"meta": {"isGroup": True}
|
||||||
|
},
|
||||||
|
# ── Lokale Daten ─────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.TrusteePosition",
|
"objectKey": "data.feature.trustee.TrusteePosition",
|
||||||
"label": "Position",
|
"label": "Position",
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "TrusteePosition",
|
"table": "TrusteePosition",
|
||||||
"fields": ["id", "label", "description", "organisationId"],
|
"group": "data.feature.trustee.localData",
|
||||||
"parentTable": "TrusteeOrganisation",
|
"fields": ["id", "valuta", "company", "desc", "bookingAmount", "bookingCurrency", "debitAccountNumber", "creditAccountNumber"],
|
||||||
"parentKey": "organisationId",
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.TrusteeDocument",
|
"objectKey": "data.feature.trustee.TrusteeDocument",
|
||||||
"label": "Dokument",
|
"label": "Dokument",
|
||||||
"meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]}
|
"meta": {
|
||||||
|
"table": "TrusteeDocument",
|
||||||
|
"group": "data.feature.trustee.localData",
|
||||||
|
"fields": ["id", "documentName", "documentMimeType", "documentType", "sourceType"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
# ── Konfiguration ────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.TrusteeAccountingConfig",
|
"objectKey": "data.feature.trustee.TrusteeAccountingConfig",
|
||||||
"label": "Buchhaltungs-Konfiguration",
|
"label": "Buchhaltungs-Verbindung",
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "TrusteeAccountingConfig",
|
"table": "TrusteeAccountingConfig",
|
||||||
"fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"],
|
"group": "data.feature.trustee.config",
|
||||||
"parentTable": "TrusteeOrganisation",
|
"fields": ["id", "connectorType", "displayLabel", "isActive", "lastSyncAt", "lastSyncStatus"],
|
||||||
"parentKey": "organisationId",
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.TrusteeAccountingSync",
|
"objectKey": "data.feature.trustee.TrusteeAccountingSync",
|
||||||
"label": "Buchhaltungs-Synchronisation",
|
"label": "Sync-Protokoll",
|
||||||
"meta": {"table": "TrusteeAccountingSync", "fields": ["id", "positionId", "syncStatus", "externalId"]}
|
"meta": {
|
||||||
|
"table": "TrusteeAccountingSync",
|
||||||
|
"group": "data.feature.trustee.config",
|
||||||
|
"fields": ["id", "positionId", "syncStatus", "externalId"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
# ── Daten aus Buchhaltungssystem ─────────────────────────────────────────
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.TrusteeDataAccount",
|
"objectKey": "data.feature.trustee.TrusteeDataAccount",
|
||||||
"label": "Kontenplan (Sync)",
|
"label": "Kontenplan",
|
||||||
"meta": {"table": "TrusteeDataAccount", "fields": ["id", "accountNumber", "label", "accountType", "accountGroup", "currency", "isActive"]}
|
"meta": {
|
||||||
|
"table": "TrusteeDataAccount",
|
||||||
|
"group": "data.feature.trustee.accountingData",
|
||||||
|
"fields": ["id", "accountNumber", "label", "accountType", "accountGroup", "currency", "isActive"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.TrusteeDataJournalEntry",
|
"objectKey": "data.feature.trustee.TrusteeDataJournalEntry",
|
||||||
"label": "Buchungen (Sync)",
|
"label": "Buchungen",
|
||||||
"meta": {"table": "TrusteeDataJournalEntry", "fields": ["id", "externalId", "bookingDate", "reference", "description", "currency", "totalAmount"]}
|
"meta": {
|
||||||
|
"table": "TrusteeDataJournalEntry",
|
||||||
|
"group": "data.feature.trustee.accountingData",
|
||||||
|
"fields": ["id", "externalId", "bookingDate", "reference", "description", "currency", "totalAmount"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.TrusteeDataJournalLine",
|
"objectKey": "data.feature.trustee.TrusteeDataJournalLine",
|
||||||
"label": "Buchungszeilen (Sync)",
|
"label": "Buchungszeilen",
|
||||||
"meta": {"table": "TrusteeDataJournalLine", "fields": ["id", "journalEntryId", "accountNumber", "debitAmount", "creditAmount", "currency", "taxCode", "costCenter", "description"]}
|
"meta": {
|
||||||
|
"table": "TrusteeDataJournalLine",
|
||||||
|
"group": "data.feature.trustee.accountingData",
|
||||||
|
"fields": ["id", "journalEntryId", "accountNumber", "debitAmount", "creditAmount", "currency", "taxCode", "costCenter", "description"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.TrusteeDataContact",
|
"objectKey": "data.feature.trustee.TrusteeDataContact",
|
||||||
"label": "Kontakte (Sync)",
|
"label": "Kontakte",
|
||||||
"meta": {"table": "TrusteeDataContact", "fields": ["id", "externalId", "contactType", "contactNumber", "name", "address", "zip", "city", "country", "email", "phone", "vatNumber"]}
|
"meta": {
|
||||||
|
"table": "TrusteeDataContact",
|
||||||
|
"group": "data.feature.trustee.accountingData",
|
||||||
|
"fields": ["id", "externalId", "contactType", "contactNumber", "name", "address", "zip", "city", "country", "email", "phone", "vatNumber"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.TrusteeDataAccountBalance",
|
"objectKey": "data.feature.trustee.TrusteeDataAccountBalance",
|
||||||
"label": "Kontosalden (Sync)",
|
"label": "Kontosalden",
|
||||||
"meta": {"table": "TrusteeDataAccountBalance", "fields": ["id", "accountNumber", "periodYear", "periodMonth", "openingBalance", "debitTotal", "creditTotal", "closingBalance", "currency"]}
|
"meta": {
|
||||||
|
"table": "TrusteeDataAccountBalance",
|
||||||
|
"group": "data.feature.trustee.accountingData",
|
||||||
|
"fields": ["id", "accountNumber", "periodYear", "periodMonth", "openingBalance", "debitTotal", "creditTotal", "closingBalance", "currency"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.trustee.*",
|
"objectKey": "data.feature.trustee.*",
|
||||||
|
|
@ -229,22 +262,10 @@ QUICK_ACTIONS = [
|
||||||
"color": "#4CAF50",
|
"color": "#4CAF50",
|
||||||
"category": "import",
|
"category": "import",
|
||||||
"actionType": "link",
|
"actionType": "link",
|
||||||
"config": {"targetView": "expense-import"},
|
"config": {"targetView": "import-process", "tab": "receipts"},
|
||||||
"requiredRoles": ["trustee-user", "trustee-accountant", "trustee-admin"],
|
"requiredRoles": ["trustee-user", "trustee-accountant", "trustee-admin"],
|
||||||
"sortOrder": 1,
|
"sortOrder": 1,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "trustee-sync-accounting",
|
|
||||||
"label": "Daten synchronisieren",
|
|
||||||
"description": "Buchhaltungsdaten aus dem externen System aktualisieren",
|
|
||||||
"icon": "mdi-sync",
|
|
||||||
"color": "#FF9800",
|
|
||||||
"category": "import",
|
|
||||||
"actionType": "link",
|
|
||||||
"config": {"targetView": "settings"},
|
|
||||||
"requiredRoles": ["trustee-accountant", "trustee-admin"],
|
|
||||||
"sortOrder": 2,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "trustee-upload-receipt",
|
"id": "trustee-upload-receipt",
|
||||||
"label": "Beleg hochladen",
|
"label": "Beleg hochladen",
|
||||||
|
|
@ -253,8 +274,20 @@ QUICK_ACTIONS = [
|
||||||
"color": "#607D8B",
|
"color": "#607D8B",
|
||||||
"category": "import",
|
"category": "import",
|
||||||
"actionType": "link",
|
"actionType": "link",
|
||||||
"config": {"targetView": "scan-upload"},
|
"config": {"targetView": "import-process", "tab": "upload"},
|
||||||
"requiredRoles": ["trustee-user", "trustee-client", "trustee-accountant", "trustee-admin"],
|
"requiredRoles": ["trustee-user", "trustee-client", "trustee-accountant", "trustee-admin"],
|
||||||
|
"sortOrder": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trustee-sync-accounting",
|
||||||
|
"label": "Daten einlesen",
|
||||||
|
"description": "Buchhaltungsdaten aus dem externen System aktualisieren",
|
||||||
|
"icon": "mdi-sync",
|
||||||
|
"color": "#FF9800",
|
||||||
|
"category": "import",
|
||||||
|
"actionType": "link",
|
||||||
|
"config": {"targetView": "settings", "tab": "import-data"},
|
||||||
|
"requiredRoles": ["trustee-accountant", "trustee-admin"],
|
||||||
"sortOrder": 3,
|
"sortOrder": 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -489,8 +522,7 @@ TEMPLATE_ROLES = [
|
||||||
"description": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)",
|
"description": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.data-tables", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
|
||||||
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True},
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
],
|
],
|
||||||
|
|
@ -500,9 +532,8 @@ TEMPLATE_ROLES = [
|
||||||
"description": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten",
|
"description": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.data-tables", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.import-process", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
|
|
||||||
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.execute", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.trustee.workflows.execute", "view": True},
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
|
|
@ -525,8 +556,7 @@ TEMPLATE_ROLES = [
|
||||||
"description": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
|
"description": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.data-tables", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
|
||||||
{"context": "UI", "item": "ui.feature.trustee.analyse", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.analyse", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.abschluss", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.abschluss", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
|
||||||
|
|
@ -542,10 +572,8 @@ TEMPLATE_ROLES = [
|
||||||
"description": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
|
"description": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.data-tables", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.import-process", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
|
|
||||||
{"context": "UI", "item": "ui.feature.trustee.scan-upload", "view": True},
|
|
||||||
{"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
{"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ from .datamodelFeatureTrustee import (
|
||||||
TrusteeContract,
|
TrusteeContract,
|
||||||
TrusteeDocument,
|
TrusteeDocument,
|
||||||
TrusteePosition,
|
TrusteePosition,
|
||||||
|
TrusteeDataAccount,
|
||||||
|
TrusteeDataJournalEntry,
|
||||||
|
TrusteeDataJournalLine,
|
||||||
|
TrusteeDataContact,
|
||||||
|
TrusteeDataAccountBalance,
|
||||||
|
TrusteeAccountingConfig,
|
||||||
|
TrusteeAccountingSync,
|
||||||
)
|
)
|
||||||
from modules.datamodels.datamodelPagination import (
|
from modules.datamodels.datamodelPagination import (
|
||||||
PaginationParams,
|
PaginationParams,
|
||||||
|
|
@ -138,14 +145,15 @@ def getQuickActions(
|
||||||
from .mainTrustee import QUICK_ACTIONS, QUICK_ACTION_CATEGORIES
|
from .mainTrustee import QUICK_ACTIONS, QUICK_ACTION_CATEGORIES
|
||||||
|
|
||||||
userRoleLabels: set = set()
|
userRoleLabels: set = set()
|
||||||
|
rootInterface = getRootInterface()
|
||||||
if context.isPlatformAdmin:
|
if context.isPlatformAdmin:
|
||||||
userRoleLabels.add("trustee-admin")
|
userRoleLabels.add("trustee-admin")
|
||||||
else:
|
|
||||||
rootInterface = getRootInterface()
|
|
||||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||||
for fa in featureAccesses:
|
for fa in featureAccesses:
|
||||||
if str(fa.featureInstanceId) == instanceId and fa.enabled:
|
if str(fa.featureInstanceId) == instanceId and fa.enabled:
|
||||||
roleIds = fa.roleIds if hasattr(fa, "roleIds") and fa.roleIds else []
|
# FeatureAccess (Pydantic) has no `roleIds` field; the join lives in
|
||||||
|
# FeatureAccessRole and must be looked up via the interface helper.
|
||||||
|
roleIds = rootInterface.getRoleIdsForFeatureAccess(str(fa.id))
|
||||||
for rid in roleIds:
|
for rid in roleIds:
|
||||||
role = rootInterface.getRole(str(rid))
|
role = rootInterface.getRole(str(rid))
|
||||||
if role and role.roleLabel:
|
if role and role.roleLabel:
|
||||||
|
|
@ -153,6 +161,8 @@ def getQuickActions(
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import resolveText
|
from modules.shared.i18nRegistry import resolveText
|
||||||
|
|
||||||
|
lang = (language or "de").strip() or "de"
|
||||||
|
|
||||||
filteredActions = []
|
filteredActions = []
|
||||||
for action in QUICK_ACTIONS:
|
for action in QUICK_ACTIONS:
|
||||||
required = set(action.get("requiredRoles", []))
|
required = set(action.get("requiredRoles", []))
|
||||||
|
|
@ -161,8 +171,8 @@ def getQuickActions(
|
||||||
if context.isPlatformAdmin or required.intersection(userRoleLabels):
|
if context.isPlatformAdmin or required.intersection(userRoleLabels):
|
||||||
resolved = {
|
resolved = {
|
||||||
"id": action["id"],
|
"id": action["id"],
|
||||||
"label": resolveText(action.get("label", {})),
|
"label": resolveText(action.get("label", {}), lang=lang),
|
||||||
"description": resolveText(action.get("description", {})),
|
"description": resolveText(action.get("description", {}), lang=lang),
|
||||||
"icon": action.get("icon", ""),
|
"icon": action.get("icon", ""),
|
||||||
"color": action.get("color", ""),
|
"color": action.get("color", ""),
|
||||||
"category": action.get("category", ""),
|
"category": action.get("category", ""),
|
||||||
|
|
@ -173,14 +183,14 @@ def getQuickActions(
|
||||||
if resolved["actionType"] == "agentPrompt" and "config" in resolved:
|
if resolved["actionType"] == "agentPrompt" and "config" in resolved:
|
||||||
cfg = dict(resolved["config"])
|
cfg = dict(resolved["config"])
|
||||||
if "uploadHint" in cfg:
|
if "uploadHint" in cfg:
|
||||||
cfg["uploadHint"] = resolveText(cfg["uploadHint"])
|
cfg["uploadHint"] = resolveText(cfg["uploadHint"], lang=lang)
|
||||||
resolved["config"] = cfg
|
resolved["config"] = cfg
|
||||||
filteredActions.append(resolved)
|
filteredActions.append(resolved)
|
||||||
|
|
||||||
filteredActions.sort(key=lambda a: a["sortOrder"])
|
filteredActions.sort(key=lambda a: a["sortOrder"])
|
||||||
|
|
||||||
resolvedCategories = [
|
resolvedCategories = [
|
||||||
{"id": c["id"], "label": resolveText(c.get("label", {})), "sortOrder": c.get("sortOrder", 99)}
|
{"id": c["id"], "label": resolveText(c.get("label", {}), lang=lang), "sortOrder": c.get("sortOrder", 99)}
|
||||||
for c in QUICK_ACTION_CATEGORIES
|
for c in QUICK_ACTION_CATEGORIES
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -199,6 +209,14 @@ _TRUSTEE_ENTITY_MODELS = {
|
||||||
"TrusteeContract": TrusteeContract,
|
"TrusteeContract": TrusteeContract,
|
||||||
"TrusteeDocument": TrusteeDocument,
|
"TrusteeDocument": TrusteeDocument,
|
||||||
"TrusteePosition": TrusteePosition,
|
"TrusteePosition": TrusteePosition,
|
||||||
|
# Read-only sync tables (TrusteeData*) and accounting bookkeeping
|
||||||
|
"TrusteeDataAccount": TrusteeDataAccount,
|
||||||
|
"TrusteeDataJournalEntry": TrusteeDataJournalEntry,
|
||||||
|
"TrusteeDataJournalLine": TrusteeDataJournalLine,
|
||||||
|
"TrusteeDataContact": TrusteeDataContact,
|
||||||
|
"TrusteeDataAccountBalance": TrusteeDataAccountBalance,
|
||||||
|
"TrusteeAccountingConfig": TrusteeAccountingConfig,
|
||||||
|
"TrusteeAccountingSync": TrusteeAccountingSync,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2097,3 +2115,277 @@ def delete_instance_role_rule(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting AccessRule: {e}")
|
logger.error(f"Error deleting AccessRule: {e}")
|
||||||
raise HTTPException(status_code=400, detail=f"Failed to delete rule: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"Failed to delete rule: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Generic Read-Only Data Tables (consolidated TrusteeDataTablesView)
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# These endpoints expose the seven additional Trustee tables that previously
|
||||||
|
# only had aggregate or specialised views. They are read-only:
|
||||||
|
# - TrusteeData* tables are populated by the accounting sync; manual edits
|
||||||
|
# would be overwritten on the next sync.
|
||||||
|
# - TrusteeAccountingConfig / TrusteeAccountingSync are operational records
|
||||||
|
# maintained by the connector layer.
|
||||||
|
#
|
||||||
|
# All seven endpoints share one helper (`_paginatedReadEndpoint`) that
|
||||||
|
# replicates the established pattern from `get_documents` / `get_positions`
|
||||||
|
# (Unified Filter API: mode=filterValues / mode=ids).
|
||||||
|
|
||||||
|
|
||||||
|
def _paginatedReadEndpoint(
|
||||||
|
*,
|
||||||
|
instanceId: str,
|
||||||
|
context: RequestContext,
|
||||||
|
modelClass,
|
||||||
|
pagination: Optional[str],
|
||||||
|
mode: Optional[str],
|
||||||
|
column: Optional[str],
|
||||||
|
):
|
||||||
|
"""Generic paginated, RBAC-aware GET handler for a Trustee data model.
|
||||||
|
|
||||||
|
Mirrors the pattern used by `get_documents` / `get_positions`:
|
||||||
|
- mode=filterValues: distinct column values for filter UI
|
||||||
|
- mode=ids: full id list for "select all matching"
|
||||||
|
- default: paginated result via `getRecordsetPaginatedWithRBAC`
|
||||||
|
"""
|
||||||
|
from modules.interfaces.interfaceRbac import (
|
||||||
|
getRecordsetPaginatedWithRBAC,
|
||||||
|
getDistinctColumnValuesWithRBAC,
|
||||||
|
)
|
||||||
|
from modules.routes.routeHelpers import (
|
||||||
|
handleFilterValuesInMemory,
|
||||||
|
handleIdsInMemory,
|
||||||
|
parseCrossFilterPagination,
|
||||||
|
)
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
try:
|
||||||
|
crossFilterPagination = parseCrossFilterPagination(column, pagination)
|
||||||
|
values = getDistinctColumnValuesWithRBAC(
|
||||||
|
connector=interface.db,
|
||||||
|
modelClass=modelClass,
|
||||||
|
column=column,
|
||||||
|
currentUser=interface.currentUser,
|
||||||
|
pagination=crossFilterPagination,
|
||||||
|
recordFilter=None,
|
||||||
|
mandateId=interface.mandateId,
|
||||||
|
featureInstanceId=interface.featureInstanceId,
|
||||||
|
featureCode=interface.FEATURE_CODE,
|
||||||
|
)
|
||||||
|
return JSONResponse(content=sorted(values, key=lambda v: str(v).lower()))
|
||||||
|
except Exception:
|
||||||
|
result = getRecordsetPaginatedWithRBAC(
|
||||||
|
connector=interface.db,
|
||||||
|
modelClass=modelClass,
|
||||||
|
currentUser=interface.currentUser,
|
||||||
|
pagination=None,
|
||||||
|
recordFilter=None,
|
||||||
|
mandateId=interface.mandateId,
|
||||||
|
featureInstanceId=interface.featureInstanceId,
|
||||||
|
featureCode=interface.FEATURE_CODE,
|
||||||
|
)
|
||||||
|
items = result.items if hasattr(result, "items") else result
|
||||||
|
items = [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
|
||||||
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
result = getRecordsetPaginatedWithRBAC(
|
||||||
|
connector=interface.db,
|
||||||
|
modelClass=modelClass,
|
||||||
|
currentUser=interface.currentUser,
|
||||||
|
pagination=None,
|
||||||
|
recordFilter=None,
|
||||||
|
mandateId=interface.mandateId,
|
||||||
|
featureInstanceId=interface.featureInstanceId,
|
||||||
|
featureCode=interface.FEATURE_CODE,
|
||||||
|
)
|
||||||
|
items = result.items if hasattr(result, "items") else result
|
||||||
|
items = [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
|
||||||
|
return handleIdsInMemory(items, pagination)
|
||||||
|
|
||||||
|
paginationParams = _parsePagination(pagination)
|
||||||
|
result = getRecordsetPaginatedWithRBAC(
|
||||||
|
connector=interface.db,
|
||||||
|
modelClass=modelClass,
|
||||||
|
currentUser=interface.currentUser,
|
||||||
|
pagination=paginationParams,
|
||||||
|
recordFilter=None,
|
||||||
|
mandateId=interface.mandateId,
|
||||||
|
featureInstanceId=interface.featureInstanceId,
|
||||||
|
featureCode=interface.FEATURE_CODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if paginationParams and hasattr(result, "items"):
|
||||||
|
return PaginatedResponse(
|
||||||
|
items=result.items,
|
||||||
|
pagination=PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page or 1,
|
||||||
|
pageSize=paginationParams.pageSize or 20,
|
||||||
|
totalItems=result.totalItems,
|
||||||
|
totalPages=result.totalPages,
|
||||||
|
sort=paginationParams.sort if paginationParams else [],
|
||||||
|
filters=paginationParams.filters if paginationParams else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
items = result.items if hasattr(result, "items") else result
|
||||||
|
return PaginatedResponse(items=items, pagination=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/data/accounts", response_model=PaginatedResponse[TrusteeDataAccount])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def get_data_accounts(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
pagination: Optional[str] = Query(None),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Read-only list of synced chart-of-accounts entries (TrusteeDataAccount)."""
|
||||||
|
return _paginatedReadEndpoint(
|
||||||
|
instanceId=instanceId,
|
||||||
|
context=context,
|
||||||
|
modelClass=TrusteeDataAccount,
|
||||||
|
pagination=pagination,
|
||||||
|
mode=mode,
|
||||||
|
column=column,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/data/journal-entries", response_model=PaginatedResponse[TrusteeDataJournalEntry])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def get_data_journal_entries(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
pagination: Optional[str] = Query(None),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Read-only list of synced journal entries (TrusteeDataJournalEntry)."""
|
||||||
|
return _paginatedReadEndpoint(
|
||||||
|
instanceId=instanceId,
|
||||||
|
context=context,
|
||||||
|
modelClass=TrusteeDataJournalEntry,
|
||||||
|
pagination=pagination,
|
||||||
|
mode=mode,
|
||||||
|
column=column,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/data/journal-lines", response_model=PaginatedResponse[TrusteeDataJournalLine])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def get_data_journal_lines(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
pagination: Optional[str] = Query(None),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Read-only list of synced journal lines (TrusteeDataJournalLine)."""
|
||||||
|
return _paginatedReadEndpoint(
|
||||||
|
instanceId=instanceId,
|
||||||
|
context=context,
|
||||||
|
modelClass=TrusteeDataJournalLine,
|
||||||
|
pagination=pagination,
|
||||||
|
mode=mode,
|
||||||
|
column=column,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/data/contacts", response_model=PaginatedResponse[TrusteeDataContact])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def get_data_contacts(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
pagination: Optional[str] = Query(None),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Read-only list of synced contacts (TrusteeDataContact)."""
|
||||||
|
return _paginatedReadEndpoint(
|
||||||
|
instanceId=instanceId,
|
||||||
|
context=context,
|
||||||
|
modelClass=TrusteeDataContact,
|
||||||
|
pagination=pagination,
|
||||||
|
mode=mode,
|
||||||
|
column=column,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/data/account-balances", response_model=PaginatedResponse[TrusteeDataAccountBalance])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def get_data_account_balances(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
pagination: Optional[str] = Query(None),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Read-only list of synced account balances (TrusteeDataAccountBalance)."""
|
||||||
|
return _paginatedReadEndpoint(
|
||||||
|
instanceId=instanceId,
|
||||||
|
context=context,
|
||||||
|
modelClass=TrusteeDataAccountBalance,
|
||||||
|
pagination=pagination,
|
||||||
|
mode=mode,
|
||||||
|
column=column,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/accounting/configs", response_model=PaginatedResponse[TrusteeAccountingConfig])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def get_accounting_configs(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
pagination: Optional[str] = Query(None),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Read-only list of accounting connector configurations (TrusteeAccountingConfig).
|
||||||
|
|
||||||
|
Note: secret config fields are stored masked in the underlying record;
|
||||||
|
UI consumers must rely on the dedicated `/accounting/config` endpoint
|
||||||
|
for secret-aware editing.
|
||||||
|
"""
|
||||||
|
return _paginatedReadEndpoint(
|
||||||
|
instanceId=instanceId,
|
||||||
|
context=context,
|
||||||
|
modelClass=TrusteeAccountingConfig,
|
||||||
|
pagination=pagination,
|
||||||
|
mode=mode,
|
||||||
|
column=column,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/accounting/syncs", response_model=PaginatedResponse[TrusteeAccountingSync])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def get_accounting_syncs(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
pagination: Optional[str] = Query(None),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Read-only list of accounting sync records (TrusteeAccountingSync)."""
|
||||||
|
return _paginatedReadEndpoint(
|
||||||
|
instanceId=instanceId,
|
||||||
|
context=context,
|
||||||
|
modelClass=TrusteeAccountingSync,
|
||||||
|
pagination=pagination,
|
||||||
|
mode=mode,
|
||||||
|
column=column,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -603,6 +603,17 @@ async def streamWorkspaceStart(
|
||||||
|
|
||||||
chatInterface.createMessage(userMessageData)
|
chatInterface.createMessage(userMessageData)
|
||||||
|
|
||||||
|
# Persist the attached data sources on the workflow so the chip-bar can
|
||||||
|
# be restored when the user re-opens this chat (per-chat persistence).
|
||||||
|
# Sources that no longer resolve are filtered out client-side on load.
|
||||||
|
try:
|
||||||
|
chatInterface.updateWorkflow(workflowId, {
|
||||||
|
"attachedDataSourceIds": list(userInput.dataSourceIds or []),
|
||||||
|
"attachedFeatureDataSourceIds": list(userInput.featureDataSourceIds or []),
|
||||||
|
})
|
||||||
|
except Exception as persistErr:
|
||||||
|
logger.warning(f"Could not persist chat attachments for {workflowId}: {persistErr}")
|
||||||
|
|
||||||
agentTask = asyncio.ensure_future(
|
agentTask = asyncio.ensure_future(
|
||||||
_runWorkspaceAgent(
|
_runWorkspaceAgent(
|
||||||
workflowId=workflowId,
|
workflowId=workflowId,
|
||||||
|
|
@ -1112,7 +1123,12 @@ async def getWorkspaceMessages(
|
||||||
workflowId: str = Path(...),
|
workflowId: str = Path(...),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""Get all messages for a workspace workflow/conversation."""
|
"""Get all messages for a workspace workflow/conversation.
|
||||||
|
|
||||||
|
Also returns the IDs of data sources that were attached the last time the
|
||||||
|
user sent a message in this chat, so the WorkspaceInput can rehydrate its
|
||||||
|
chip-bar (per-chat attachment persistence).
|
||||||
|
"""
|
||||||
_mandateId, _ = _validateInstanceAccess(instanceId, context)
|
_mandateId, _ = _validateInstanceAccess(instanceId, context)
|
||||||
chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
|
chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
|
||||||
messages = chatInterface.getMessages(workflowId) or []
|
messages = chatInterface.getMessages(workflowId) or []
|
||||||
|
|
@ -1124,7 +1140,62 @@ async def getWorkspaceMessages(
|
||||||
str(m.get("id") or ""),
|
str(m.get("id") or ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return JSONResponse({"messages": items})
|
attachedDsIds: List[str] = []
|
||||||
|
attachedFdsIds: List[str] = []
|
||||||
|
try:
|
||||||
|
wf = chatInterface.getWorkflow(workflowId)
|
||||||
|
if wf:
|
||||||
|
attachedDsIds = list(getattr(wf, "attachedDataSourceIds", None) or [])
|
||||||
|
attachedFdsIds = list(getattr(wf, "attachedFeatureDataSourceIds", None) or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"getWorkspaceMessages: cannot read attachments for {workflowId}: {e}")
|
||||||
|
return JSONResponse({
|
||||||
|
"messages": items,
|
||||||
|
"attachedDataSourceIds": attachedDsIds,
|
||||||
|
"attachedFeatureDataSourceIds": attachedFdsIds,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateChatAttachmentsRequest(BaseModel):
|
||||||
|
"""Body for PATCH /workflows/{workflowId}/attachments.
|
||||||
|
|
||||||
|
Replaces the persisted attachment lists for the chat. Sent when the user
|
||||||
|
detaches a source via the WorkspaceInput chip-bar so the change survives
|
||||||
|
a chat reload without waiting for the next sendMessage round-trip.
|
||||||
|
"""
|
||||||
|
dataSourceIds: Optional[List[str]] = Field(default=None)
|
||||||
|
featureDataSourceIds: Optional[List[str]] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{instanceId}/workflows/{workflowId}/attachments")
|
||||||
|
@limiter.limit("300/minute")
|
||||||
|
async def patchWorkspaceWorkflowAttachments(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(...),
|
||||||
|
workflowId: str = Path(...),
|
||||||
|
body: UpdateChatAttachmentsRequest = Body(...),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Persist the chip-bar attachment IDs for a chat (per-chat sources)."""
|
||||||
|
_mandateId, _ = _validateInstanceAccess(instanceId, context)
|
||||||
|
chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
|
||||||
|
workflow = chatInterface.getWorkflow(workflowId)
|
||||||
|
if not workflow:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found")
|
||||||
|
updateData: Dict[str, Any] = {}
|
||||||
|
if body.dataSourceIds is not None:
|
||||||
|
updateData["attachedDataSourceIds"] = list(body.dataSourceIds)
|
||||||
|
if body.featureDataSourceIds is not None:
|
||||||
|
updateData["attachedFeatureDataSourceIds"] = list(body.featureDataSourceIds)
|
||||||
|
if updateData:
|
||||||
|
chatInterface.updateWorkflow(workflowId, updateData)
|
||||||
|
return JSONResponse({
|
||||||
|
"workflowId": workflowId,
|
||||||
|
"attachedDataSourceIds": updateData.get("attachedDataSourceIds",
|
||||||
|
list(getattr(workflow, "attachedDataSourceIds", None) or [])),
|
||||||
|
"attachedFeatureDataSourceIds": updateData.get("attachedFeatureDataSourceIds",
|
||||||
|
list(getattr(workflow, "attachedFeatureDataSourceIds", None) or [])),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -1461,13 +1532,31 @@ async def listFeatureConnectionTables(
|
||||||
except Exception:
|
except Exception:
|
||||||
accessible = catalog.getDataObjects(inst.featureCode)
|
accessible = catalog.getDataObjects(inst.featureCode)
|
||||||
|
|
||||||
tables = []
|
accessibleKeys = {obj.get("objectKey", "") for obj in accessible}
|
||||||
|
referencedGroups = set()
|
||||||
for obj in accessible:
|
for obj in accessible:
|
||||||
|
meta = obj.get("meta", {})
|
||||||
|
if meta.get("wildcard") or meta.get("isGroup"):
|
||||||
|
continue
|
||||||
|
if meta.get("group"):
|
||||||
|
referencedGroups.add(meta["group"])
|
||||||
|
|
||||||
|
tables = []
|
||||||
|
for obj in catalog.getDataObjects(inst.featureCode):
|
||||||
meta = obj.get("meta", {})
|
meta = obj.get("meta", {})
|
||||||
if meta.get("wildcard"):
|
if meta.get("wildcard"):
|
||||||
continue
|
continue
|
||||||
|
objectKey = obj.get("objectKey", "")
|
||||||
|
if meta.get("isGroup"):
|
||||||
|
# Groups are metadata-only; include if at least one child is accessible
|
||||||
|
# (regardless of whether the group itself was RBAC-granted).
|
||||||
|
if objectKey not in referencedGroups:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if objectKey not in accessibleKeys:
|
||||||
|
continue
|
||||||
node = {
|
node = {
|
||||||
"objectKey": obj.get("objectKey", ""),
|
"objectKey": objectKey,
|
||||||
"tableName": meta.get("table", ""),
|
"tableName": meta.get("table", ""),
|
||||||
"label": resolveText(obj.get("label", "")),
|
"label": resolveText(obj.get("label", "")),
|
||||||
"fields": meta.get("fields", []),
|
"fields": meta.get("fields", []),
|
||||||
|
|
@ -1475,6 +1564,8 @@ async def listFeatureConnectionTables(
|
||||||
"parentTable": meta.get("parentTable") or None,
|
"parentTable": meta.get("parentTable") or None,
|
||||||
"parentKey": meta.get("parentKey") or None,
|
"parentKey": meta.get("parentKey") or None,
|
||||||
"displayFields": meta.get("displayFields", []),
|
"displayFields": meta.get("displayFields", []),
|
||||||
|
"isGroup": bool(meta.get("isGroup", False)),
|
||||||
|
"group": meta.get("group") or None,
|
||||||
}
|
}
|
||||||
tables.append(node)
|
tables.append(node)
|
||||||
|
|
||||||
|
|
@ -1488,9 +1579,15 @@ async def listParentObjects(
|
||||||
instanceId: str = Path(...),
|
instanceId: str = Path(...),
|
||||||
fiId: str = Path(..., description="Feature instance ID"),
|
fiId: str = Path(..., description="Feature instance ID"),
|
||||||
tableName: str = Path(..., description="Parent table name from DATA_OBJECTS"),
|
tableName: str = Path(..., description="Parent table name from DATA_OBJECTS"),
|
||||||
|
parentKey: Optional[str] = Query(None, description="Optional FK column name to filter by ancestor record (nested parent rendering)"),
|
||||||
|
parentValue: Optional[str] = Query(None, description="Optional FK value matching parentKey to filter children of a specific ancestor record"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""List records from a parent table so the user can pick a specific record to scope data."""
|
"""List records from a parent table so the user can pick a specific record to scope data.
|
||||||
|
|
||||||
|
When parentKey + parentValue are provided, results are additionally filtered by that FK,
|
||||||
|
enabling nested record hierarchies (e.g. Sessions OF Context X).
|
||||||
|
"""
|
||||||
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
|
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.security.rbacCatalog import getCatalogService
|
from modules.security.rbacCatalog import getCatalogService
|
||||||
|
|
@ -1561,6 +1658,22 @@ async def listParentObjects(
|
||||||
if hasUserId:
|
if hasUserId:
|
||||||
sql += ' AND "userId" = %s'
|
sql += ' AND "userId" = %s'
|
||||||
params.append(str(context.user.id))
|
params.append(str(context.user.id))
|
||||||
|
|
||||||
|
if parentKey and parentValue:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = 'public' AND LOWER(table_name) = LOWER(%s) "
|
||||||
|
"AND column_name = %s",
|
||||||
|
[tableName, parentKey],
|
||||||
|
)
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
sql += f' AND "{parentKey}" = %s'
|
||||||
|
params.append(parentValue)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"listParentObjects({tableName}): ignoring parentKey '{parentKey}' (column does not exist)"
|
||||||
|
)
|
||||||
|
|
||||||
sql += ' ORDER BY "id" DESC LIMIT 100'
|
sql += ' ORDER BY "id" DESC LIMIT 100'
|
||||||
cur.execute(sql, params)
|
cur.execute(sql, params)
|
||||||
rows = []
|
rows = []
|
||||||
|
|
|
||||||
|
|
@ -1839,10 +1839,14 @@ class BillingObjects:
|
||||||
userId: Optional[str] = None,
|
userId: Optional[str] = None,
|
||||||
startTs: Optional[float] = None,
|
startTs: Optional[float] = None,
|
||||||
endTs: Optional[float] = None,
|
endTs: Optional[float] = None,
|
||||||
period: str = "month",
|
bucketSize: str = "month",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Pure SQL aggregation for statistics. No row-level loading.
|
Pure SQL aggregation for statistics. No row-level loading.
|
||||||
|
|
||||||
|
`bucketSize` controls only the time-series aggregation granularity
|
||||||
|
(`'day' | 'month' | 'year'`); the date range is set via `startTs`/`endTs`.
|
||||||
|
|
||||||
Returns: totalCost, transactionCount, costByProvider, costByModel,
|
Returns: totalCost, transactionCount, costByProvider, costByModel,
|
||||||
costByFeature, costByAccountId, timeSeries
|
costByFeature, costByAccountId, timeSeries
|
||||||
"""
|
"""
|
||||||
|
|
@ -1909,10 +1913,17 @@ class BillingObjects:
|
||||||
]
|
]
|
||||||
|
|
||||||
# 6) Time series via DATE_TRUNC on epoch timestamp
|
# 6) Time series via DATE_TRUNC on epoch timestamp
|
||||||
if period == "day":
|
_bucketSpec = {
|
||||||
truncExpr = "DATE_TRUNC('day', TO_TIMESTAMP(\"sysCreatedAt\"))"
|
"day": ("day", "%Y-%m-%d"),
|
||||||
else:
|
"month": ("month", "%Y-%m"),
|
||||||
truncExpr = "DATE_TRUNC('month', TO_TIMESTAMP(\"sysCreatedAt\"))"
|
"year": ("year", "%Y"),
|
||||||
|
}.get(bucketSize)
|
||||||
|
if _bucketSpec is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid bucketSize: {bucketSize!r} (expected day|month|year)"
|
||||||
|
)
|
||||||
|
_truncUnit, _labelFormat = _bucketSpec
|
||||||
|
truncExpr = f"DATE_TRUNC('{_truncUnit}', TO_TIMESTAMP(\"sysCreatedAt\"))"
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f'SELECT {truncExpr} AS bucket, SUM("amount") AS total, COUNT(*) AS cnt '
|
f'SELECT {truncExpr} AS bucket, SUM("amount") AS total, COUNT(*) AS cnt '
|
||||||
|
|
@ -1923,10 +1934,7 @@ class BillingObjects:
|
||||||
timeSeries = []
|
timeSeries = []
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
bucket = r["bucket"]
|
bucket = r["bucket"]
|
||||||
if period == "day":
|
label = bucket.strftime(_labelFormat) if bucket else "unknown"
|
||||||
label = bucket.strftime("%Y-%m-%d") if bucket else "unknown"
|
|
||||||
else:
|
|
||||||
label = bucket.strftime("%Y-%m") if bucket else "unknown"
|
|
||||||
timeSeries.append({
|
timeSeries.append({
|
||||||
"date": label,
|
"date": label,
|
||||||
"cost": round(float(r["total"]), 4),
|
"cost": round(float(r["total"]), 4),
|
||||||
|
|
|
||||||
|
|
@ -734,7 +734,9 @@ class ChatObjects:
|
||||||
lastActivity=_toFloat(workflow.get("lastActivity")),
|
lastActivity=_toFloat(workflow.get("lastActivity")),
|
||||||
startedAt=_toFloat(workflow.get("startedAt")),
|
startedAt=_toFloat(workflow.get("startedAt")),
|
||||||
logs=logs,
|
logs=logs,
|
||||||
messages=messages
|
messages=messages,
|
||||||
|
attachedDataSourceIds=workflow.get("attachedDataSourceIds") or [],
|
||||||
|
attachedFeatureDataSourceIds=workflow.get("attachedFeatureDataSourceIds") or [],
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"getWorkflow: data validation failed for {workflowId}: {e}")
|
logger.error(f"getWorkflow: data validation failed for {workflowId}: {e}")
|
||||||
|
|
@ -891,7 +893,13 @@ class ChatObjects:
|
||||||
lastActivity=updated.get("lastActivity", workflow.lastActivity),
|
lastActivity=updated.get("lastActivity", workflow.lastActivity),
|
||||||
startedAt=updated.get("startedAt", workflow.startedAt),
|
startedAt=updated.get("startedAt", workflow.startedAt),
|
||||||
logs=logs,
|
logs=logs,
|
||||||
messages=messages
|
messages=messages,
|
||||||
|
attachedDataSourceIds=updated.get("attachedDataSourceIds")
|
||||||
|
if updated.get("attachedDataSourceIds") is not None
|
||||||
|
else (getattr(workflow, "attachedDataSourceIds", None) or []),
|
||||||
|
attachedFeatureDataSourceIds=updated.get("attachedFeatureDataSourceIds")
|
||||||
|
if updated.get("attachedFeatureDataSourceIds") is not None
|
||||||
|
else (getattr(workflow, "attachedFeatureDataSourceIds", None) or []),
|
||||||
)
|
)
|
||||||
|
|
||||||
def deleteWorkflow(self, workflowId: str) -> bool:
|
def deleteWorkflow(self, workflowId: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,49 @@ class FeatureInterface:
|
||||||
logger.error(f"Error creating feature {code}: {e}")
|
logger.error(f"Error creating feature {code}: {e}")
|
||||||
raise ValueError(f"Failed to create feature: {e}")
|
raise ValueError(f"Failed to create feature: {e}")
|
||||||
|
|
||||||
|
def upsertFeature(self, code: str, label: Any, icon: str = "mdi-puzzle") -> str:
|
||||||
|
"""Insert or update a Feature row for ``code``.
|
||||||
|
|
||||||
|
Idempotent counterpart to :meth:`createFeature` used by the boot-time
|
||||||
|
sync (see ``modules.system.registry.syncCatalogFeaturesToDb``) so the
|
||||||
|
``Feature`` DB-table stays consistent with the in-memory feature
|
||||||
|
registry built from the code modules. Without this sync the
|
||||||
|
``FeatureInstance.featureCode`` FK would be dangling for every
|
||||||
|
feature whose definition lives only in code (the user-reported
|
||||||
|
false-positive orphans).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Unique feature code (e.g. ``"trustee"``).
|
||||||
|
label: Either a string (the source label, will be wrapped as
|
||||||
|
``{"xx": label}``), a dict ``{"xx": ..., "de": ..., ...}``
|
||||||
|
or an existing TextMultilingual instance.
|
||||||
|
icon: Icon identifier.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One of ``"created"``, ``"updated"``, ``"unchanged"``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
normalizedLabel = coerce_text_multilingual(label) if not isinstance(label, dict) else label
|
||||||
|
existing = self.getFeature(code)
|
||||||
|
if existing is None:
|
||||||
|
self.createFeature(code, normalizedLabel.model_dump() if hasattr(normalizedLabel, "model_dump") else normalizedLabel, icon)
|
||||||
|
return "created"
|
||||||
|
|
||||||
|
existingLabel = existing.label.model_dump() if hasattr(existing.label, "model_dump") else existing.label
|
||||||
|
desiredLabel = normalizedLabel.model_dump() if hasattr(normalizedLabel, "model_dump") else normalizedLabel
|
||||||
|
updateData: Dict[str, Any] = {}
|
||||||
|
if existingLabel != desiredLabel:
|
||||||
|
updateData["label"] = desiredLabel
|
||||||
|
if (existing.icon or "") != (icon or ""):
|
||||||
|
updateData["icon"] = icon or ""
|
||||||
|
if not updateData:
|
||||||
|
return "unchanged"
|
||||||
|
self.db.recordModify(Feature, code, updateData)
|
||||||
|
return "updated"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error upserting feature {code}: {e}")
|
||||||
|
raise ValueError(f"Failed to upsert feature: {e}")
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Feature Instance Methods
|
# Feature Instance Methods
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -201,8 +244,20 @@ class FeatureInterface:
|
||||||
if copyTemplateRoles:
|
if copyTemplateRoles:
|
||||||
self._copyTemplateRoles(featureCode, mandateId, instanceId)
|
self._copyTemplateRoles(featureCode, mandateId, instanceId)
|
||||||
|
|
||||||
# Copy template workflows (if feature defines TEMPLATE_WORKFLOWS)
|
# Copy template workflows (if feature defines TEMPLATE_WORKFLOWS).
|
||||||
|
# WICHTIG: Workflow-Bootstrap darf die Instanz-Erstellung NICHT killen
|
||||||
|
# (Instanz + Rollen sind primaer; Workflows kann Admin via Sync nachladen).
|
||||||
|
# Fehler werden aber laut geloggt, damit sie nicht unbemerkt bleiben.
|
||||||
|
try:
|
||||||
self._copyTemplateWorkflows(featureCode, mandateId, instanceId)
|
self._copyTemplateWorkflows(featureCode, mandateId, instanceId)
|
||||||
|
except Exception as wfErr:
|
||||||
|
logger.error(
|
||||||
|
f"createFeatureInstance: workflow bootstrap FAILED for feature "
|
||||||
|
f"'{featureCode}' instance {instanceId} — instance was created but "
|
||||||
|
f"workflows are missing. Use POST /api/features/instances/{instanceId}"
|
||||||
|
f"/sync-workflows to recover. Reason: {wfErr}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
cleanedRecord = dict(createdInstance)
|
cleanedRecord = dict(createdInstance)
|
||||||
return FeatureInstance(**cleanedRecord)
|
return FeatureInstance(**cleanedRecord)
|
||||||
|
|
@ -227,31 +282,57 @@ class FeatureInterface:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of workflows copied
|
Number of workflows copied
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If templates exist but cannot be copied.
|
||||||
|
Caller decides whether to swallow or re-raise.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import importlib
|
|
||||||
|
|
||||||
try:
|
|
||||||
from modules.system.registry import loadFeatureMainModules
|
from modules.system.registry import loadFeatureMainModules
|
||||||
mainModules = loadFeatureMainModules()
|
mainModules = loadFeatureMainModules()
|
||||||
featureModule = mainModules.get(featureCode)
|
featureModule = mainModules.get(featureCode)
|
||||||
if not featureModule:
|
if not featureModule:
|
||||||
|
logger.debug(
|
||||||
|
f"_copyTemplateWorkflows: no main module loaded for feature '{featureCode}' — nothing to copy"
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None)
|
getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None)
|
||||||
if not getTemplateWorkflows:
|
if not getTemplateWorkflows:
|
||||||
|
logger.debug(
|
||||||
|
f"_copyTemplateWorkflows: feature '{featureCode}' has no getTemplateWorkflows() — nothing to copy"
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
templateWorkflows = getTemplateWorkflows()
|
try:
|
||||||
|
templateWorkflows = getTemplateWorkflows() or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"_copyTemplateWorkflows: getTemplateWorkflows() raised for feature '{featureCode}': {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Feature '{featureCode}' getTemplateWorkflows() failed: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
if not templateWorkflows:
|
if not templateWorkflows:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"_copyTemplateWorkflows: copying {len(templateWorkflows)} template workflow(s) "
|
||||||
|
f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})"
|
||||||
|
)
|
||||||
|
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.security.rootAccess import getRootUser
|
||||||
rootUser = getRootInterface().currentUser
|
rootUser = getRootUser()
|
||||||
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
|
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
|
||||||
|
|
||||||
copied = 0
|
copied = 0
|
||||||
|
failed = 0
|
||||||
for template in templateWorkflows:
|
for template in templateWorkflows:
|
||||||
|
templateId = template.get("id", "<no-id>")
|
||||||
|
try:
|
||||||
graphJson = json.dumps(template.get("graph", {}))
|
graphJson = json.dumps(template.get("graph", {}))
|
||||||
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
|
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
|
||||||
graph = json.loads(graphJson)
|
graph = json.loads(graphJson)
|
||||||
|
|
@ -263,22 +344,30 @@ class FeatureInterface:
|
||||||
"graph": graph,
|
"graph": graph,
|
||||||
"tags": template.get("tags", [f"feature:{featureCode}"]),
|
"tags": template.get("tags", [f"feature:{featureCode}"]),
|
||||||
"isTemplate": False,
|
"isTemplate": False,
|
||||||
"templateSourceId": template["id"],
|
"templateSourceId": templateId,
|
||||||
"templateScope": "instance",
|
"templateScope": "instance",
|
||||||
"active": True,
|
"active": True,
|
||||||
})
|
})
|
||||||
copied += 1
|
copied += 1
|
||||||
|
|
||||||
if copied > 0:
|
|
||||||
logger.info(f"Feature '{featureCode}': Copied {copied} template workflows to instance {instanceId}")
|
|
||||||
return copied
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
logger.debug(f"No feature module found for '{featureCode}' — skipping workflow bootstrap")
|
|
||||||
return 0
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error copying template workflows for '{featureCode}' instance {instanceId}: {e}")
|
failed += 1
|
||||||
return 0
|
logger.error(
|
||||||
|
f"_copyTemplateWorkflows: failed to create workflow '{templateId}' for "
|
||||||
|
f"feature '{featureCode}' instance {instanceId}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if copied:
|
||||||
|
logger.info(
|
||||||
|
f"_copyTemplateWorkflows: copied {copied}/{len(templateWorkflows)} workflow(s) "
|
||||||
|
f"for feature '{featureCode}' instance {instanceId} (failed={failed})"
|
||||||
|
)
|
||||||
|
if failed:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"_copyTemplateWorkflows: {failed}/{len(templateWorkflows)} workflow(s) failed "
|
||||||
|
f"for feature '{featureCode}' instance {instanceId}"
|
||||||
|
)
|
||||||
|
return copied
|
||||||
|
|
||||||
def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
|
def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@ from modules.auth import limiter
|
||||||
from modules.auth.authentication import requireSysAdmin
|
from modules.auth.authentication import requireSysAdmin
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.system.databaseHealth import (
|
from modules.system.databaseHealth import (
|
||||||
|
OrphanCleanupRefused,
|
||||||
_cleanAllOrphans,
|
_cleanAllOrphans,
|
||||||
_cleanOrphans,
|
_cleanOrphans,
|
||||||
_getTableStats,
|
_getTableStats,
|
||||||
|
_listOrphans,
|
||||||
_scanOrphans,
|
_scanOrphans,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -34,6 +36,19 @@ class OrphanCleanRequest(BaseModel):
|
||||||
db: str = Field(..., description="Source database name (e.g. poweron_app)")
|
db: str = Field(..., description="Source database name (e.g. poweron_app)")
|
||||||
table: str = Field(..., description="Source table (Pydantic model class name)")
|
table: str = Field(..., description="Source table (Pydantic model class name)")
|
||||||
column: str = Field(..., description="FK column on the source table")
|
column: str = Field(..., description="FK column on the source table")
|
||||||
|
force: bool = Field(
|
||||||
|
False,
|
||||||
|
description="Override safety guards (empty target / >50%% of source). Use with care.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrphanCleanAllRequest(BaseModel):
|
||||||
|
"""Body for cleaning all detected orphans."""
|
||||||
|
|
||||||
|
force: bool = Field(
|
||||||
|
False,
|
||||||
|
description="Override safety guards on every relationship. Use with extreme care.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
|
|
@ -60,6 +75,39 @@ def getDatabaseOrphans(
|
||||||
return {"orphans": rows}
|
return {"orphans": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orphans/list")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def getDatabaseOrphansList(
|
||||||
|
request: Request,
|
||||||
|
db: str,
|
||||||
|
table: str,
|
||||||
|
column: str,
|
||||||
|
limit: int = 1000,
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Return up to ``limit`` actual orphan source-rows for one FK relationship.
|
||||||
|
|
||||||
|
Used by the SysAdmin UI's per-row download button: a human can review the
|
||||||
|
orphan list (full source row + the unresolved FK value) before triggering
|
||||||
|
the destructive clean operation.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
records = _listOrphans(db=db, table=table, column=column, limit=limit)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
return {
|
||||||
|
"db": db,
|
||||||
|
"table": table,
|
||||||
|
"column": column,
|
||||||
|
"count": len(records),
|
||||||
|
"limit": limit,
|
||||||
|
"records": records,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/orphans/clean")
|
@router.post("/orphans/clean")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def postDatabaseOrphansClean(
|
def postDatabaseOrphansClean(
|
||||||
|
|
@ -69,19 +117,33 @@ def postDatabaseOrphansClean(
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete orphaned rows for a single FK relationship."""
|
"""Delete orphaned rows for a single FK relationship."""
|
||||||
try:
|
try:
|
||||||
deleted = _cleanOrphans(body.db, body.table, body.column)
|
deleted = _cleanOrphans(body.db, body.table, body.column, force=body.force)
|
||||||
|
except OrphanCleanupRefused as e:
|
||||||
|
logger.warning(
|
||||||
|
"SysAdmin orphan clean REFUSED: user=%s db=%s table=%s column=%s reason=%s",
|
||||||
|
currentUser.username,
|
||||||
|
body.db,
|
||||||
|
body.table,
|
||||||
|
body.column,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={"refused": True, "reason": str(e)},
|
||||||
|
) from e
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=str(e),
|
detail=str(e),
|
||||||
) from e
|
) from e
|
||||||
logger.info(
|
logger.info(
|
||||||
"SysAdmin orphan clean: user=%s db=%s table=%s column=%s deleted=%s",
|
"SysAdmin orphan clean: user=%s db=%s table=%s column=%s deleted=%s force=%s",
|
||||||
currentUser.username,
|
currentUser.username,
|
||||||
body.db,
|
body.db,
|
||||||
body.table,
|
body.table,
|
||||||
body.column,
|
body.column,
|
||||||
deleted,
|
deleted,
|
||||||
|
body.force,
|
||||||
)
|
)
|
||||||
return {"deleted": deleted}
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
@ -90,13 +152,26 @@ def postDatabaseOrphansClean(
|
||||||
@limiter.limit("2/minute")
|
@limiter.limit("2/minute")
|
||||||
def postDatabaseOrphansCleanAll(
|
def postDatabaseOrphansCleanAll(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
body: Optional[OrphanCleanAllRequest] = None,
|
||||||
currentUser: User = Depends(requireSysAdmin),
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Run orphan cleanup for every relationship that currently has orphans."""
|
"""Run orphan cleanup for every relationship that currently has orphans.
|
||||||
results: List[dict] = _cleanAllOrphans()
|
|
||||||
|
Returns per-relationship results. Each entry contains either `deleted` (success),
|
||||||
|
`skipped` (safety guard triggered, no force), or `error` (other failure).
|
||||||
|
"""
|
||||||
|
force = bool(body.force) if body is not None else False
|
||||||
|
results: List[dict] = _cleanAllOrphans(force=force)
|
||||||
|
skipped = sum(1 for r in results if "skipped" in r)
|
||||||
|
errored = sum(1 for r in results if "error" in r)
|
||||||
|
deletedTotal = sum(int(r.get("deleted", 0)) for r in results)
|
||||||
logger.info(
|
logger.info(
|
||||||
"SysAdmin orphan clean-all: user=%s batches=%s",
|
"SysAdmin orphan clean-all: user=%s batches=%s deleted=%s skipped=%s errored=%s force=%s",
|
||||||
currentUser.username,
|
currentUser.username,
|
||||||
len(results),
|
len(results),
|
||||||
|
deletedTotal,
|
||||||
|
skipped,
|
||||||
|
errored,
|
||||||
|
force,
|
||||||
)
|
)
|
||||||
return {"results": results}
|
return {"results": results, "skipped": skipped, "errored": errored, "deleted": deletedTotal}
|
||||||
|
|
|
||||||
|
|
@ -171,9 +171,16 @@ def get_my_feature_instances(
|
||||||
if mandate and not getattr(mandate, "enabled", True):
|
if mandate and not getattr(mandate, "enabled", True):
|
||||||
continue
|
continue
|
||||||
if mandate:
|
if mandate:
|
||||||
|
mandateName = mandate.name if hasattr(mandate, 'name') else mandateId
|
||||||
|
mandateLabel = (
|
||||||
|
mandate.label
|
||||||
|
if hasattr(mandate, 'label') and mandate.label
|
||||||
|
else mandateName
|
||||||
|
)
|
||||||
mandatesMap[mandateId] = {
|
mandatesMap[mandateId] = {
|
||||||
"id": mandateId,
|
"id": mandateId,
|
||||||
"name": mandate.name if hasattr(mandate, 'name') else mandateId,
|
"name": mandateName,
|
||||||
|
"label": mandateLabel,
|
||||||
"code": mandate.code if hasattr(mandate, 'code') else None,
|
"code": mandate.code if hasattr(mandate, 'code') else None,
|
||||||
"features": []
|
"features": []
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +188,7 @@ def get_my_feature_instances(
|
||||||
mandatesMap[mandateId] = {
|
mandatesMap[mandateId] = {
|
||||||
"id": mandateId,
|
"id": mandateId,
|
||||||
"name": mandateId,
|
"name": mandateId,
|
||||||
|
"label": mandateId,
|
||||||
"code": None,
|
"code": None,
|
||||||
"features": []
|
"features": []
|
||||||
}
|
}
|
||||||
|
|
@ -210,6 +218,7 @@ def get_my_feature_instances(
|
||||||
"featureCode": instance.featureCode,
|
"featureCode": instance.featureCode,
|
||||||
"mandateId": mandateId,
|
"mandateId": mandateId,
|
||||||
"mandateName": mandatesMap[mandateId]["name"],
|
"mandateName": mandatesMap[mandateId]["name"],
|
||||||
|
"mandateLabel": mandatesMap[mandateId]["label"],
|
||||||
"instanceLabel": instance.label,
|
"instanceLabel": instance.label,
|
||||||
"userRoles": userRoles,
|
"userRoles": userRoles,
|
||||||
"permissions": permissions
|
"permissions": permissions
|
||||||
|
|
|
||||||
|
|
@ -305,8 +305,10 @@ async def getAuditLog(
|
||||||
async def getAuditStats(
|
async def getAuditStats(
|
||||||
request: Request,
|
request: Request,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
timeRange: int = Query(30, ge=1, le=365, description="Days to aggregate"),
|
dateFrom: str = Query(..., description="ISO YYYY-MM-DD (inclusive)"),
|
||||||
groupBy: str = Query("model", description="Grouping: model, user, feature, day"),
|
dateTo: str = Query(..., description="ISO YYYY-MM-DD (inclusive)"),
|
||||||
|
groupBy: str = Query("model", pattern="^(model|user|feature|day)$",
|
||||||
|
description="Grouping: model, user, feature, day"),
|
||||||
):
|
):
|
||||||
_requireAuditAccess(context)
|
_requireAuditAccess(context)
|
||||||
mandateId = str(context.mandateId) if context.mandateId else ""
|
mandateId = str(context.mandateId) if context.mandateId else ""
|
||||||
|
|
@ -314,7 +316,12 @@ async def getAuditStats(
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
|
raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
|
||||||
|
|
||||||
from modules.shared.aiAuditLogger import aiAuditLogger
|
from modules.shared.aiAuditLogger import aiAuditLogger
|
||||||
return aiAuditLogger.getAiAuditStats(mandateId, timeRangeDays=timeRange, groupBy=groupBy)
|
from modules.shared.dateRange import isoDateRangeToLocalEpoch
|
||||||
|
|
||||||
|
fromTs, toTs = isoDateRangeToLocalEpoch(dateFrom, dateTo)
|
||||||
|
return aiAuditLogger.getAiAuditStats(
|
||||||
|
mandateId, fromTs=fromTs, toTs=toTs, groupBy=groupBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Tab D: Neutralization Mappings ──
|
# ── Tab D: Neutralization Mappings ──
|
||||||
|
|
|
||||||
|
|
@ -258,8 +258,10 @@ class AccountSummary(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class UsageReportResponse(BaseModel):
|
class UsageReportResponse(BaseModel):
|
||||||
"""Usage report for a period."""
|
"""Usage report for an explicit date range."""
|
||||||
period: str
|
dateFrom: str
|
||||||
|
dateTo: str
|
||||||
|
bucketSize: str
|
||||||
totalCost: float
|
totalCost: float
|
||||||
transactionCount: int
|
transactionCount: int
|
||||||
costByProvider: Dict[str, float]
|
costByProvider: Dict[str, float]
|
||||||
|
|
@ -523,78 +525,67 @@ def getTransactions(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/statistics/{period}", response_model=UsageReportResponse)
|
@router.get("/statistics", response_model=UsageReportResponse)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def getStatistics(
|
def getStatistics(
|
||||||
request: Request,
|
request: Request,
|
||||||
period: str = Path(..., description="Period: 'day', 'month', or 'year'"),
|
dateFrom: str = Query(..., description="ISO YYYY-MM-DD (inclusive)"),
|
||||||
year: int = Query(..., description="Year"),
|
dateTo: str = Query(..., description="ISO YYYY-MM-DD (inclusive)"),
|
||||||
month: Optional[int] = Query(None, description="Month (1-12, required for 'day' period)"),
|
bucketSize: str = Query(..., pattern="^(day|month|year)$",
|
||||||
|
description="Time-bucket granularity: day, month, or year"),
|
||||||
ctx: RequestContext = Depends(getRequestContext)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get usage statistics for a period.
|
Get usage statistics for an explicit date range.
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Validate period
|
|
||||||
if period not in ["day", "month", "year"]:
|
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("Invalid period. Use 'day', 'month', or 'year'"))
|
|
||||||
|
|
||||||
if period == "day" and not month:
|
`dateFrom`/`dateTo` are inclusive local-day boundaries.
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("Month is required for 'day' period"))
|
`bucketSize` controls the time-series aggregation granularity and is
|
||||||
|
independent of the chosen range.
|
||||||
|
"""
|
||||||
|
from modules.shared.dateRange import parseIsoDateRange
|
||||||
|
|
||||||
|
try:
|
||||||
|
startDate, toDateInclusive = parseIsoDateRange(dateFrom, dateTo)
|
||||||
|
# `calculateStatisticsFromTransactions` expects a half-open
|
||||||
|
# [startDate, endDate) interval, so widen the upper bound by one day.
|
||||||
|
from datetime import timedelta as _td
|
||||||
|
endDate = toDateInclusive + _td(days=1)
|
||||||
|
|
||||||
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
||||||
settings = billingInterface.getSettings(ctx.mandateId)
|
settings = billingInterface.getSettings(ctx.mandateId)
|
||||||
|
|
||||||
if not settings:
|
emptyResponse = UsageReportResponse(
|
||||||
return UsageReportResponse(
|
dateFrom=dateFrom,
|
||||||
period=period,
|
dateTo=dateTo,
|
||||||
|
bucketSize=bucketSize,
|
||||||
totalCost=0.0,
|
totalCost=0.0,
|
||||||
transactionCount=0,
|
transactionCount=0,
|
||||||
costByProvider={},
|
costByProvider={},
|
||||||
costByFeature={}
|
costByFeature={},
|
||||||
)
|
)
|
||||||
|
if not settings:
|
||||||
|
return emptyResponse
|
||||||
|
|
||||||
# Transactions are always on user accounts (audit trail)
|
# Transactions are always on user accounts (audit trail)
|
||||||
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
|
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
return UsageReportResponse(
|
return emptyResponse
|
||||||
period=period,
|
|
||||||
totalCost=0.0,
|
|
||||||
transactionCount=0,
|
|
||||||
costByProvider={},
|
|
||||||
costByFeature={}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate date range
|
|
||||||
if period == "day":
|
|
||||||
startDate = date(year, month, 1)
|
|
||||||
if month == 12:
|
|
||||||
endDate = date(year + 1, 1, 1)
|
|
||||||
else:
|
|
||||||
endDate = date(year, month + 1, 1)
|
|
||||||
elif period == "month":
|
|
||||||
startDate = date(year, 1, 1)
|
|
||||||
endDate = date(year + 1, 1, 1)
|
|
||||||
else: # year
|
|
||||||
startDate = date(year, 1, 1)
|
|
||||||
endDate = date(year + 1, 1, 1)
|
|
||||||
|
|
||||||
# Get statistics from transactions
|
|
||||||
stats = billingInterface.calculateStatisticsFromTransactions(
|
stats = billingInterface.calculateStatisticsFromTransactions(
|
||||||
account["id"],
|
account["id"],
|
||||||
startDate,
|
startDate,
|
||||||
endDate
|
endDate,
|
||||||
)
|
)
|
||||||
|
|
||||||
return UsageReportResponse(
|
return UsageReportResponse(
|
||||||
period=period,
|
dateFrom=dateFrom,
|
||||||
|
dateTo=dateTo,
|
||||||
|
bucketSize=bucketSize,
|
||||||
totalCost=stats.get("totalCostCHF", 0.0),
|
totalCost=stats.get("totalCostCHF", 0.0),
|
||||||
transactionCount=stats.get("transactionCount", 0),
|
transactionCount=stats.get("transactionCount", 0),
|
||||||
costByProvider=stats.get("costByProvider", {}),
|
costByProvider=stats.get("costByProvider", {}),
|
||||||
costByModel=stats.get("costByModel", {}),
|
costByModel=stats.get("costByModel", {}),
|
||||||
costByFeature=stats.get("costByFeature", {})
|
costByFeature=stats.get("costByFeature", {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -778,6 +769,21 @@ def addCredit(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/checkout/amounts", response_model=List[float])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def getCheckoutAmounts(
|
||||||
|
request: Request,
|
||||||
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return the server-side allow-list of CHF top-up amounts for Stripe Checkout.
|
||||||
|
The frontend must populate its dropdown from this list — values not in
|
||||||
|
the list are rejected by `create_checkout_session` (server-side validation).
|
||||||
|
"""
|
||||||
|
from modules.serviceCenter.services.serviceBilling.stripeCheckout import ALLOWED_AMOUNTS_CHF
|
||||||
|
return [float(a) for a in ALLOWED_AMOUNTS_CHF]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/checkout/create/{targetMandateId}", response_model=CheckoutCreateResponse)
|
@router.post("/checkout/create/{targetMandateId}", response_model=CheckoutCreateResponse)
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def createCheckoutSession(
|
def createCheckoutSession(
|
||||||
|
|
@ -800,12 +806,37 @@ def createCheckoutSession(
|
||||||
if not _isAdminOfMandate(ctx, targetMandateId):
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Mandate admin role required to load mandate credit"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Mandate admin role required to load mandate credit"))
|
||||||
|
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
appInterface = getAppInterface(ctx.user, mandateId=targetMandateId)
|
||||||
|
mandateRecord = appInterface.getMandate(targetMandateId)
|
||||||
|
if mandateRecord is not None:
|
||||||
|
mandateLabel = getattr(mandateRecord, "label", None) or getattr(mandateRecord, "name", None) or targetMandateId
|
||||||
|
invoiceAddress = {
|
||||||
|
"companyName": getattr(mandateRecord, "invoiceCompanyName", None),
|
||||||
|
"contactName": getattr(mandateRecord, "invoiceContactName", None),
|
||||||
|
"email": getattr(mandateRecord, "invoiceEmail", None),
|
||||||
|
"line1": getattr(mandateRecord, "invoiceLine1", None),
|
||||||
|
"line2": getattr(mandateRecord, "invoiceLine2", None),
|
||||||
|
"postalCode": getattr(mandateRecord, "invoicePostalCode", None),
|
||||||
|
"city": getattr(mandateRecord, "invoiceCity", None),
|
||||||
|
"state": getattr(mandateRecord, "invoiceState", None),
|
||||||
|
"country": getattr(mandateRecord, "invoiceCountry", None) or "CH",
|
||||||
|
"vatNumber": getattr(mandateRecord, "invoiceVatNumber", None),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
mandateLabel = targetMandateId
|
||||||
|
invoiceAddress = None
|
||||||
|
|
||||||
from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session
|
from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session
|
||||||
redirect_url = create_checkout_session(
|
redirect_url = create_checkout_session(
|
||||||
mandate_id=targetMandateId,
|
mandate_id=targetMandateId,
|
||||||
user_id=checkoutRequest.userId,
|
user_id=checkoutRequest.userId,
|
||||||
amount_chf=checkoutRequest.amount,
|
amount_chf=checkoutRequest.amount,
|
||||||
return_url=checkoutRequest.returnUrl
|
return_url=checkoutRequest.returnUrl,
|
||||||
|
mandate_label=mandateLabel,
|
||||||
|
invoice_address=invoiceAddress,
|
||||||
|
settings=settings,
|
||||||
|
billing_interface=billingInterface,
|
||||||
)
|
)
|
||||||
return CheckoutCreateResponse(redirectUrl=redirect_url)
|
return CheckoutCreateResponse(redirectUrl=redirect_url)
|
||||||
|
|
||||||
|
|
@ -1620,9 +1651,10 @@ class ViewStatisticsResponse(BaseModel):
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def getUserViewStatistics(
|
def getUserViewStatistics(
|
||||||
request: Request,
|
request: Request,
|
||||||
period: str = Query(default="month", description="Period: 'day' or 'month'"),
|
dateFrom: str = Query(..., description="ISO YYYY-MM-DD (inclusive)"),
|
||||||
year: int = Query(default=None, description="Year"),
|
dateTo: str = Query(..., description="ISO YYYY-MM-DD (inclusive)"),
|
||||||
month: Optional[int] = Query(None, description="Month (1-12, required for period='day')"),
|
bucketSize: str = Query(..., pattern="^(day|month|year)$",
|
||||||
|
description="Time-bucket granularity: day, month, or year"),
|
||||||
scope: str = Query(default="all", description="Scope: 'personal' (own costs only), 'mandate' (filter by mandateId), 'all' (RBAC-filtered)"),
|
scope: str = Query(default="all", description="Scope: 'personal' (own costs only), 'mandate' (filter by mandateId), 'all' (RBAC-filtered)"),
|
||||||
mandateId: Optional[str] = Query(None, description="Mandate ID filter (used with scope='mandate')"),
|
mandateId: Optional[str] = Query(None, description="Mandate ID filter (used with scope='mandate')"),
|
||||||
onlyMine: Optional[bool] = Query(None, description="Additional filter: restrict to current user's transactions within the selected scope"),
|
onlyMine: Optional[bool] = Query(None, description="Additional filter: restrict to current user's transactions within the selected scope"),
|
||||||
|
|
@ -1639,15 +1671,14 @@ def getUserViewStatistics(
|
||||||
onlyMine: additional filter that restricts results to the current user's
|
onlyMine: additional filter that restricts results to the current user's
|
||||||
transactions while keeping the scope-based mandate selection.
|
transactions while keeping the scope-based mandate selection.
|
||||||
|
|
||||||
- period='month': returns monthly time series for the given year
|
`dateFrom`/`dateTo` are inclusive local-day boundaries. `bucketSize`
|
||||||
- period='day': returns daily time series for the given month/year
|
controls the time-series aggregation granularity and is independent of
|
||||||
|
the chosen range.
|
||||||
"""
|
"""
|
||||||
try:
|
from modules.shared.dateRange import isoDateRangeToLocalEpoch
|
||||||
if year is None:
|
|
||||||
year = datetime.now().year
|
|
||||||
|
|
||||||
if period == "day" and not month:
|
try:
|
||||||
month = datetime.now().month
|
startTs, endTs = isoDateRangeToLocalEpoch(dateFrom, dateTo)
|
||||||
|
|
||||||
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
||||||
|
|
||||||
|
|
@ -1666,28 +1697,19 @@ def getUserViewStatistics(
|
||||||
|
|
||||||
personalUserId = str(ctx.user.id) if (scope == "personal" or onlyMine) else None
|
personalUserId = str(ctx.user.id) if (scope == "personal" or onlyMine) else None
|
||||||
|
|
||||||
if period == "day":
|
|
||||||
startDate = date(year, month, 1)
|
|
||||||
endDate = date(year + 1, 1, 1) if month == 12 else date(year, month + 1, 1)
|
|
||||||
else:
|
|
||||||
startDate = date(year, 1, 1)
|
|
||||||
endDate = date(year + 1, 1, 1)
|
|
||||||
|
|
||||||
startTs = datetime.combine(startDate, datetime.min.time()).timestamp()
|
|
||||||
endTs = datetime.combine(endDate, datetime.min.time()).timestamp()
|
|
||||||
|
|
||||||
agg = billingInterface.getTransactionStatisticsAggregated(
|
agg = billingInterface.getTransactionStatisticsAggregated(
|
||||||
mandateIds=loadMandateIds,
|
mandateIds=loadMandateIds,
|
||||||
scope=scope,
|
scope=scope,
|
||||||
userId=personalUserId,
|
userId=personalUserId,
|
||||||
startTs=startTs,
|
startTs=startTs,
|
||||||
endTs=endTs,
|
endTs=endTs,
|
||||||
period=period,
|
bucketSize=bucketSize,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"View statistics (SQL-aggregated): totalCost={agg['totalCost']}, "
|
f"View statistics (SQL-aggregated): totalCost={agg['totalCost']}, "
|
||||||
f"count={agg['transactionCount']}, period={period}, year={year}, month={month}"
|
f"count={agg['transactionCount']}, dateFrom={dateFrom}, dateTo={dateTo}, "
|
||||||
|
f"bucketSize={bucketSize}"
|
||||||
)
|
)
|
||||||
|
|
||||||
allAccounts = agg.get("_allAccounts", [])
|
allAccounts = agg.get("_allAccounts", [])
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,19 @@ def create_mandate(
|
||||||
detail=f"Failed to create mandate: {str(e)}"
|
detail=f"Failed to create mandate: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
_MANDATE_ADMIN_EDITABLE_FIELDS = {"label"}
|
_MANDATE_ADMIN_EDITABLE_FIELDS = {
|
||||||
|
"label",
|
||||||
|
"invoiceCompanyName",
|
||||||
|
"invoiceContactName",
|
||||||
|
"invoiceEmail",
|
||||||
|
"invoiceLine1",
|
||||||
|
"invoiceLine2",
|
||||||
|
"invoicePostalCode",
|
||||||
|
"invoiceCity",
|
||||||
|
"invoiceState",
|
||||||
|
"invoiceCountry",
|
||||||
|
"invoiceVatNumber",
|
||||||
|
}
|
||||||
|
|
||||||
def _isUserAdminOfMandate(userId: str, targetMandateId: str) -> bool:
|
def _isUserAdminOfMandate(userId: str, targetMandateId: str) -> bool:
|
||||||
"""Check mandate-admin without RequestContext (avoids Header param conflicts)."""
|
"""Check mandate-admin without RequestContext (avoids Header param conflicts)."""
|
||||||
|
|
|
||||||
|
|
@ -833,7 +833,7 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]:
|
||||||
userId=userId,
|
userId=userId,
|
||||||
startTs=startTs,
|
startTs=startTs,
|
||||||
endTs=now,
|
endTs=now,
|
||||||
period="month",
|
bucketSize="month",
|
||||||
)
|
)
|
||||||
liveStats["aiCallCount"] = stats.get("transactionCount", 0)
|
liveStats["aiCallCount"] = stats.get("transactionCount", 0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,11 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
subPath = args.get("subPath", "")
|
subPath = args.get("subPath", "")
|
||||||
directConnId = args.get("connectionId", "")
|
directConnId = args.get("connectionId", "")
|
||||||
directService = args.get("service", "")
|
directService = args.get("service", "")
|
||||||
|
rawLimit = args.get("limit")
|
||||||
|
try:
|
||||||
|
limit = int(rawLimit) if rawLimit is not None and str(rawLimit) != "" else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = None
|
||||||
if not dsId and not (directConnId and directService):
|
if not dsId and not (directConnId and directService):
|
||||||
return ToolResult(toolCallId="", toolName="browseDataSource", success=False,
|
return ToolResult(toolCallId="", toolName="browseDataSource", success=False,
|
||||||
error="Provide either dataSourceId OR connectionId+service")
|
error="Provide either dataSourceId OR connectionId+service")
|
||||||
|
|
@ -92,7 +97,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
_buildResolverDbFromServices(services),
|
_buildResolverDbFromServices(services),
|
||||||
)
|
)
|
||||||
adapter = await resolver.resolveService(connectionId, service)
|
adapter = await resolver.resolveService(connectionId, service)
|
||||||
entries = await adapter.browse(browsePath, filter=args.get("filter"))
|
entries = await adapter.browse(browsePath, filter=args.get("filter"), limit=limit)
|
||||||
if not entries:
|
if not entries:
|
||||||
return ToolResult(toolCallId="", toolName="browseDataSource", success=True, data="Empty directory.")
|
return ToolResult(toolCallId="", toolName="browseDataSource", success=True, data="Empty directory.")
|
||||||
lines = []
|
lines = []
|
||||||
|
|
@ -101,6 +106,11 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
sizeInfo = f" ({e.size} bytes)" if e.size else ""
|
sizeInfo = f" ({e.size} bytes)" if e.size else ""
|
||||||
lines.append(f"- {prefix} {e.name}{sizeInfo} path: {e.path}")
|
lines.append(f"- {prefix} {e.name}{sizeInfo} path: {e.path}")
|
||||||
result = "\n".join(lines)
|
result = "\n".join(lines)
|
||||||
|
countLine = f"\n\n({len(entries)} entries returned"
|
||||||
|
if limit is not None:
|
||||||
|
countLine += f", limit={limit}"
|
||||||
|
countLine += ")"
|
||||||
|
result += countLine
|
||||||
if service in _MAIL_SERVICES:
|
if service in _MAIL_SERVICES:
|
||||||
result += "\n\nIMPORTANT: These are email subjects only. To read the full email content, use downloadFromDataSource with the path, then readFile on the returned file ID."
|
result += "\n\nIMPORTANT: These are email subjects only. To read the full email content, use downloadFromDataSource with the path, then readFile on the returned file ID."
|
||||||
return ToolResult(toolCallId="", toolName="browseDataSource", success=True, data=result)
|
return ToolResult(toolCallId="", toolName="browseDataSource", success=True, data=result)
|
||||||
|
|
@ -112,6 +122,11 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
directConnId = args.get("connectionId", "")
|
directConnId = args.get("connectionId", "")
|
||||||
directService = args.get("service", "")
|
directService = args.get("service", "")
|
||||||
query = args.get("query", "")
|
query = args.get("query", "")
|
||||||
|
rawLimit = args.get("limit")
|
||||||
|
try:
|
||||||
|
limit = int(rawLimit) if rawLimit is not None and str(rawLimit) != "" else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = None
|
||||||
if not query:
|
if not query:
|
||||||
return ToolResult(toolCallId="", toolName="searchDataSource", success=False, error="query is required")
|
return ToolResult(toolCallId="", toolName="searchDataSource", success=False, error="query is required")
|
||||||
if not dsId and not (directConnId and directService):
|
if not dsId and not (directConnId and directService):
|
||||||
|
|
@ -128,11 +143,16 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
_buildResolverDbFromServices(services),
|
_buildResolverDbFromServices(services),
|
||||||
)
|
)
|
||||||
adapter = await resolver.resolveService(connectionId, service)
|
adapter = await resolver.resolveService(connectionId, service)
|
||||||
entries = await adapter.search(query, path=basePath)
|
entries = await adapter.search(query, path=basePath, limit=limit)
|
||||||
if not entries:
|
if not entries:
|
||||||
return ToolResult(toolCallId="", toolName="searchDataSource", success=True, data="No results found.")
|
return ToolResult(toolCallId="", toolName="searchDataSource", success=True, data="No results found.")
|
||||||
lines = [f"- {e.name} (path: {e.path})" for e in entries]
|
lines = [f"- {e.name} (path: {e.path})" for e in entries]
|
||||||
result = "\n".join(lines)
|
result = "\n".join(lines)
|
||||||
|
countLine = f"\n\n({len(entries)} entries returned"
|
||||||
|
if limit is not None:
|
||||||
|
countLine += f", limit={limit}"
|
||||||
|
countLine += ")"
|
||||||
|
result += countLine
|
||||||
if service in _MAIL_SERVICES:
|
if service in _MAIL_SERVICES:
|
||||||
result += "\n\nIMPORTANT: These are email subjects only. To read the full email content, use downloadFromDataSource with the path, then readFile on the returned file ID."
|
result += "\n\nIMPORTANT: These are email subjects only. To read the full email content, use downloadFromDataSource with the path, then readFile on the returned file ID."
|
||||||
return ToolResult(toolCallId="", toolName="searchDataSource", success=True, data=result)
|
return ToolResult(toolCallId="", toolName="searchDataSource", success=True, data=result)
|
||||||
|
|
@ -217,7 +237,9 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
description=(
|
description=(
|
||||||
"Browse files and folders in a data source. Accepts either:\n"
|
"Browse files and folders in a data source. Accepts either:\n"
|
||||||
"- dataSourceId (for attached data sources shown in the prompt), OR\n"
|
"- dataSourceId (for attached data sources shown in the prompt), OR\n"
|
||||||
"- connectionId + service (for direct connection access via listConnections)."
|
"- connectionId + service (for direct connection access via listConnections).\n"
|
||||||
|
"Default page size is connector-specific (~100 entries). Use the `limit` parameter "
|
||||||
|
"to request more (e.g. when the user explicitly asks for ALL items in a folder)."
|
||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -228,6 +250,15 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
"path": {"type": "string", "description": "Root path (used with connectionId+service)"},
|
"path": {"type": "string", "description": "Root path (used with connectionId+service)"},
|
||||||
"subPath": {"type": "string", "description": "Sub-path within the data source to browse"},
|
"subPath": {"type": "string", "description": "Sub-path within the data source to browse"},
|
||||||
"filter": {"type": "string", "description": "Filter pattern (e.g. '*.pdf')"},
|
"filter": {"type": "string", "description": "Filter pattern (e.g. '*.pdf')"},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": (
|
||||||
|
"Maximum number of entries to return (max 1000 for mail, "
|
||||||
|
"connector-specific elsewhere). Omit for the connector's default."
|
||||||
|
),
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 1000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
readOnly=True,
|
readOnly=True,
|
||||||
|
|
@ -237,6 +268,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
"searchDataSource", _searchDataSource,
|
"searchDataSource", _searchDataSource,
|
||||||
description=(
|
description=(
|
||||||
"Search for files within a data source. Accepts either dataSourceId OR connectionId+service. "
|
"Search for files within a data source. Accepts either dataSourceId OR connectionId+service. "
|
||||||
|
"Use the `limit` parameter to control how many hits are returned."
|
||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -246,6 +278,12 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
"service": {"type": "string", "description": "Service name (alternative to dataSourceId)"},
|
"service": {"type": "string", "description": "Service name (alternative to dataSourceId)"},
|
||||||
"path": {"type": "string", "description": "Scope path (used with connectionId+service)"},
|
"path": {"type": "string", "description": "Scope path (used with connectionId+service)"},
|
||||||
"query": {"type": "string", "description": "Search query"},
|
"query": {"type": "string", "description": "Search query"},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of search results (default ~100, max 1000).",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 1000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["query"],
|
"required": ["query"],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,22 @@
|
||||||
"""
|
"""
|
||||||
Stripe Checkout service for billing credit top-ups.
|
Stripe Checkout service for billing credit top-ups.
|
||||||
Creates Checkout Sessions for redirect-based payment flow.
|
Creates Checkout Sessions for redirect-based payment flow.
|
||||||
|
|
||||||
|
CH-Treuhand-Konformitaet (Issue 2026-04-20):
|
||||||
|
- Bei jedem Checkout wird ein Stripe-Customer mit der Mandanten-Rechnungsadresse
|
||||||
|
angelegt/aktualisiert (Name, Adresse, E-Mail, optional UID/MWST-Nr).
|
||||||
|
- Auf dem Checkout wird `invoice_creation` aktiviert, damit Stripe automatisch
|
||||||
|
eine Rechnung mit Status `paid` erzeugt (statt nur einer Quittung). Die Rechnung
|
||||||
|
enthaelt die volle Empfaengeradresse und einen Footer-Hinweis "bezahlt via
|
||||||
|
Kreditkarte am ...".
|
||||||
|
- MWST 8.1% (CH) wird ueber `automatic_tax: enabled=true` aufgeschlagen, sofern
|
||||||
|
Stripe Tax fuer den Account aktiviert ist (siehe wiki/d-guides/stripe-ch-vat.md).
|
||||||
|
Alternativ kann ueber APP_CONFIG `STRIPE_TAX_RATE_ID_CH_VAT` ein vordefinierter
|
||||||
|
Tax-Rate angehaengt werden (8.1% inclusive=false).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
@ -17,6 +29,155 @@ logger = logging.getLogger(__name__)
|
||||||
ALLOWED_AMOUNTS_CHF = [10, 25, 50, 100, 250, 500]
|
ALLOWED_AMOUNTS_CHF = [10, 25, 50, 100, 250, 500]
|
||||||
|
|
||||||
|
|
||||||
|
def _str(value: Any) -> Optional[str]:
|
||||||
|
"""Trim ``value`` to a non-empty string or return ``None``."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = str(value)
|
||||||
|
trimmed = value.strip()
|
||||||
|
return trimmed or None
|
||||||
|
|
||||||
|
|
||||||
|
def _buildStripeAddress(invoiceAddress: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
|
||||||
|
"""Translate the structured Mandate invoice fields into a Stripe ``address`` object.
|
||||||
|
|
||||||
|
Returns ``None`` when the address is not complete enough for Stripe
|
||||||
|
(line1 + city are the practical minimum); the caller then falls back
|
||||||
|
to a guest checkout where Stripe collects the address from the user.
|
||||||
|
"""
|
||||||
|
if not invoiceAddress:
|
||||||
|
return None
|
||||||
|
address: Dict[str, Optional[str]] = {
|
||||||
|
"line1": _str(invoiceAddress.get("line1")),
|
||||||
|
"line2": _str(invoiceAddress.get("line2")),
|
||||||
|
"postal_code": _str(invoiceAddress.get("postalCode")),
|
||||||
|
"city": _str(invoiceAddress.get("city")),
|
||||||
|
"state": _str(invoiceAddress.get("state")),
|
||||||
|
"country": (_str(invoiceAddress.get("country")) or "CH").upper()[:2],
|
||||||
|
}
|
||||||
|
cleaned = {k: v for k, v in address.items() if v}
|
||||||
|
if not cleaned.get("line1") or not cleaned.get("city"):
|
||||||
|
return None
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _buildStripeTaxIdData(invoiceAddress: Optional[Dict[str, Any]]) -> List[Dict[str, str]]:
|
||||||
|
"""Translate UID/MWST-Nr into Stripe ``tax_id_data`` (CHE -> ``ch_vat``)."""
|
||||||
|
if not invoiceAddress:
|
||||||
|
return []
|
||||||
|
vat = _str(invoiceAddress.get("vatNumber"))
|
||||||
|
if not vat:
|
||||||
|
return []
|
||||||
|
upper = vat.upper()
|
||||||
|
if upper.startswith("CHE"):
|
||||||
|
return [{"type": "ch_vat", "value": vat[:50]}]
|
||||||
|
if upper.startswith("LI"):
|
||||||
|
return [{"type": "li_uid", "value": vat[:50]}]
|
||||||
|
if len(upper) >= 4 and upper[:2].isalpha() and any(c.isdigit() for c in upper):
|
||||||
|
return [{"type": "eu_vat", "value": vat[:50]}]
|
||||||
|
logger.info("Skipping unrecognized invoice VAT number format: %s", vat)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _ensureStripeCustomer(
|
||||||
|
mandateId: str,
|
||||||
|
mandateLabel: str,
|
||||||
|
invoiceAddress: Optional[Dict[str, Any]],
|
||||||
|
settings: Optional[Dict[str, Any]],
|
||||||
|
billingInterface,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Create or update the Stripe Customer for the given mandate.
|
||||||
|
|
||||||
|
Maps the structured invoice fields from ``Mandate`` to:
|
||||||
|
- ``customer.name`` (companyName, fallback mandateLabel)
|
||||||
|
- ``customer.email`` (if invoiceEmail is set)
|
||||||
|
- ``customer.address`` (line1/line2/postal_code/city/state/country)
|
||||||
|
- ``customer.shipping`` (mirrors address with contactName, when set)
|
||||||
|
- ``customer.tax_id_data`` (UID/MWST-Nr; only on create -- Stripe API
|
||||||
|
does not allow modifying tax_id_data via Customer.modify, the
|
||||||
|
existing tax IDs would have to be replaced via tax_ids.create).
|
||||||
|
|
||||||
|
Returns the Stripe customer id (or ``None`` if creation failed and we
|
||||||
|
should fall back to a guest checkout).
|
||||||
|
"""
|
||||||
|
from modules.shared.stripeClient import getStripeClient, stripeToDict
|
||||||
|
stripe = getStripeClient()
|
||||||
|
|
||||||
|
address = _buildStripeAddress(invoiceAddress)
|
||||||
|
name = _str((invoiceAddress or {}).get("companyName")) or mandateLabel
|
||||||
|
email = _str((invoiceAddress or {}).get("email"))
|
||||||
|
contactName = _str((invoiceAddress or {}).get("contactName"))
|
||||||
|
vatNumber = _str((invoiceAddress or {}).get("vatNumber"))
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"mandateLabel": mandateLabel,
|
||||||
|
}
|
||||||
|
if vatNumber:
|
||||||
|
metadata["vatNumber"] = vatNumber
|
||||||
|
if contactName:
|
||||||
|
metadata["contactName"] = contactName
|
||||||
|
|
||||||
|
customerId = (settings or {}).get("stripeCustomerId") if settings else None
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
if email:
|
||||||
|
payload["email"] = email
|
||||||
|
if address:
|
||||||
|
payload["address"] = address
|
||||||
|
if contactName:
|
||||||
|
payload["shipping"] = {
|
||||||
|
"name": f"{contactName} ({name})" if name else contactName,
|
||||||
|
"address": address,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if customerId:
|
||||||
|
stripe.Customer.modify(customerId, **payload)
|
||||||
|
else:
|
||||||
|
taxIdData = _buildStripeTaxIdData(invoiceAddress)
|
||||||
|
createPayload = dict(payload)
|
||||||
|
if taxIdData:
|
||||||
|
createPayload["tax_id_data"] = taxIdData
|
||||||
|
customer = stripe.Customer.create(**createPayload)
|
||||||
|
customerId = stripeToDict(customer).get("id") or getattr(customer, "id", None)
|
||||||
|
if customerId and billingInterface is not None and settings is not None:
|
||||||
|
try:
|
||||||
|
billingInterface.updateSettings(settings["id"], {"stripeCustomerId": customerId})
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("Failed to persist stripeCustomerId for mandate %s: %s", mandateId, ex)
|
||||||
|
return customerId
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error("Stripe Customer create/update failed for mandate %s: %s", mandateId, ex)
|
||||||
|
return customerId # may be None, falls back to guest checkout
|
||||||
|
|
||||||
|
|
||||||
|
def _resolveCheckoutTaxRates() -> List[str]:
|
||||||
|
"""Return tax-rate IDs to apply manually if Stripe Tax is not enabled."""
|
||||||
|
raw = APP_CONFIG.get("STRIPE_TAX_RATE_ID_CH_VAT") or ""
|
||||||
|
return [r.strip() for r in str(raw).split(",") if r.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _isAutomaticTaxEnabled() -> bool:
|
||||||
|
"""Read STRIPE_AUTOMATIC_TAX_ENABLED as a real boolean.
|
||||||
|
|
||||||
|
APP_CONFIG._loadEnv stores all .env values as raw strings, so a naive
|
||||||
|
``bool(APP_CONFIG.get(key, False))`` would return True for the strings
|
||||||
|
``"false"``, ``"0"`` or ``"no"`` (any non-empty string is truthy in
|
||||||
|
Python). We therefore parse the value explicitly.
|
||||||
|
"""
|
||||||
|
raw = APP_CONFIG.get("STRIPE_AUTOMATIC_TAX_ENABLED", False)
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return raw
|
||||||
|
if raw is None:
|
||||||
|
return False
|
||||||
|
return str(raw).strip().lower() in {"true", "1", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
def _normalizeReturnUrl(returnUrl: str) -> str:
|
def _normalizeReturnUrl(returnUrl: str) -> str:
|
||||||
"""
|
"""
|
||||||
Validate and normalize an absolute frontend return URL.
|
Validate and normalize an absolute frontend return URL.
|
||||||
|
|
@ -55,7 +216,11 @@ def create_checkout_session(
|
||||||
mandate_id: str,
|
mandate_id: str,
|
||||||
user_id: Optional[str],
|
user_id: Optional[str],
|
||||||
amount_chf: float,
|
amount_chf: float,
|
||||||
return_url: str
|
return_url: str,
|
||||||
|
mandate_label: Optional[str] = None,
|
||||||
|
invoice_address: Optional[Dict[str, Any]] = None,
|
||||||
|
settings: Optional[Dict[str, Any]] = None,
|
||||||
|
billing_interface=None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Create a Stripe Checkout Session for credit top-up.
|
Create a Stripe Checkout Session for credit top-up.
|
||||||
|
|
@ -63,10 +228,27 @@ def create_checkout_session(
|
||||||
Amount and currency are validated server-side. The client-provided amount
|
Amount and currency are validated server-side. The client-provided amount
|
||||||
must match an allowed preset.
|
must match an allowed preset.
|
||||||
|
|
||||||
|
CH-Treuhand-Konformitaet:
|
||||||
|
- Reuses or creates a Stripe Customer with the mandate's invoice address.
|
||||||
|
- Activates `invoice_creation` so Stripe issues a proper invoice (status
|
||||||
|
`paid`) carrying the full recipient address and VAT.
|
||||||
|
- Adds 8.1% Swiss VAT either via Stripe Tax (`automatic_tax`) or via a
|
||||||
|
manually configured `STRIPE_TAX_RATE_ID_CH_VAT` tax-rate.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mandate_id: Target mandate ID
|
mandate_id: Target mandate ID
|
||||||
user_id: Target user ID for audit trail (optional)
|
user_id: Target user ID for audit trail (optional)
|
||||||
amount_chf: Amount in CHF (must be in ALLOWED_AMOUNTS_CHF)
|
amount_chf: Amount in CHF (must be in ALLOWED_AMOUNTS_CHF)
|
||||||
|
return_url: Absolute frontend URL used for Stripe success/cancel redirects
|
||||||
|
mandate_label: Human-readable mandate name (used as Customer name fallback)
|
||||||
|
invoice_address: Dict assembled in routeBilling from the structured
|
||||||
|
``Mandate.invoice*`` fields. Recognised keys: companyName,
|
||||||
|
contactName, email, line1, line2, postalCode, city, state,
|
||||||
|
country, vatNumber. Pass ``None`` for guest checkout (Stripe
|
||||||
|
then collects line1/postal_code/city itself via
|
||||||
|
``billing_address_collection: required``).
|
||||||
|
settings: Mandate billing settings (carries `stripeCustomerId`, `id`)
|
||||||
|
billing_interface: Billing interface (for persisting stripeCustomerId)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Stripe Checkout Session URL for redirect
|
Stripe Checkout Session URL for redirect
|
||||||
|
|
@ -74,9 +256,6 @@ def create_checkout_session(
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If amount is invalid
|
ValueError: If amount is invalid
|
||||||
"""
|
"""
|
||||||
import stripe
|
|
||||||
|
|
||||||
# Validate amount server-side
|
|
||||||
if amount_chf not in ALLOWED_AMOUNTS_CHF:
|
if amount_chf not in ALLOWED_AMOUNTS_CHF:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid amount {amount_chf} CHF. Allowed: {ALLOWED_AMOUNTS_CHF}"
|
f"Invalid amount {amount_chf} CHF. Allowed: {ALLOWED_AMOUNTS_CHF}"
|
||||||
|
|
@ -90,7 +269,6 @@ def create_checkout_session(
|
||||||
success_url = f"{base_return_url}{query_separator}success=true&session_id={{CHECKOUT_SESSION_ID}}"
|
success_url = f"{base_return_url}{query_separator}success=true&session_id={{CHECKOUT_SESSION_ID}}"
|
||||||
cancel_url = f"{base_return_url}{query_separator}canceled=true"
|
cancel_url = f"{base_return_url}{query_separator}canceled=true"
|
||||||
|
|
||||||
# Amount in cents for Stripe (CHF uses 2 decimal places)
|
|
||||||
amount_cents = int(round(amount_chf * 100))
|
amount_cents = int(round(amount_chf * 100))
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
|
|
@ -100,32 +278,81 @@ def create_checkout_session(
|
||||||
if user_id:
|
if user_id:
|
||||||
metadata["userId"] = user_id
|
metadata["userId"] = user_id
|
||||||
|
|
||||||
session = stripe.checkout.Session.create(
|
customerId = _ensureStripeCustomer(
|
||||||
mode="payment",
|
mandateId=mandate_id,
|
||||||
line_items=[
|
mandateLabel=mandate_label or mandate_id,
|
||||||
{
|
invoiceAddress=invoice_address,
|
||||||
|
settings=settings,
|
||||||
|
billingInterface=billing_interface,
|
||||||
|
)
|
||||||
|
|
||||||
|
taxRateIds = _resolveCheckoutTaxRates()
|
||||||
|
autoTaxEnabled = _isAutomaticTaxEnabled()
|
||||||
|
|
||||||
|
line_item: Dict[str, Any] = {
|
||||||
"price_data": {
|
"price_data": {
|
||||||
"currency": "chf",
|
"currency": "chf",
|
||||||
"unit_amount": amount_cents,
|
"unit_amount": amount_cents,
|
||||||
"product_data": {
|
"product_data": {
|
||||||
"name": "Guthaben aufladen",
|
"name": "Guthaben aufladen",
|
||||||
"description": "AI Service Guthaben (CHF)",
|
"description": "AI Service Guthaben (CHF) inkl. MWST 8.1%",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
}
|
}
|
||||||
],
|
if taxRateIds and not autoTaxEnabled:
|
||||||
success_url=success_url,
|
line_item["tax_rates"] = taxRateIds
|
||||||
cancel_url=cancel_url,
|
|
||||||
metadata=metadata,
|
invoice_data: Dict[str, Any] = {
|
||||||
)
|
"description": f"Guthaben-Aufladung {amount_chf:.2f} CHF (Mandant: {mandate_label or mandate_id})",
|
||||||
|
"metadata": metadata,
|
||||||
|
"footer": (
|
||||||
|
"Diese Rechnung wurde bereits via Kreditkarte bezahlt. "
|
||||||
|
"MWST-Nr. PowerOn: siehe Stripe-Rechnungs-Template. "
|
||||||
|
"Bei Fragen: billing@poweron-center.net"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
customFields: List[Dict[str, str]] = []
|
||||||
|
if invoice_address:
|
||||||
|
vat = _str(invoice_address.get("vatNumber"))
|
||||||
|
if vat:
|
||||||
|
customFields.append({"name": "UID-Nr. Empfaenger", "value": vat[:30]})
|
||||||
|
contactName = _str(invoice_address.get("contactName"))
|
||||||
|
if contactName:
|
||||||
|
customFields.append({"name": "z. H.", "value": contactName[:30]})
|
||||||
|
if customFields:
|
||||||
|
invoice_data["custom_fields"] = customFields[:4]
|
||||||
|
|
||||||
|
sessionPayload: Dict[str, Any] = {
|
||||||
|
"mode": "payment",
|
||||||
|
"line_items": [line_item],
|
||||||
|
"success_url": success_url,
|
||||||
|
"cancel_url": cancel_url,
|
||||||
|
"metadata": metadata,
|
||||||
|
"invoice_creation": {
|
||||||
|
"enabled": True,
|
||||||
|
"invoice_data": invoice_data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if customerId:
|
||||||
|
sessionPayload["customer"] = customerId
|
||||||
|
sessionPayload["customer_update"] = {"address": "auto", "name": "auto", "shipping": "auto"}
|
||||||
|
else:
|
||||||
|
sessionPayload["billing_address_collection"] = "required"
|
||||||
|
if invoice_address and _str(invoice_address.get("email")):
|
||||||
|
sessionPayload["customer_email"] = _str(invoice_address.get("email"))
|
||||||
|
if autoTaxEnabled:
|
||||||
|
sessionPayload["automatic_tax"] = {"enabled": True}
|
||||||
|
|
||||||
|
session = stripe.checkout.Session.create(**sessionPayload)
|
||||||
|
|
||||||
if not session or not session.url:
|
if not session or not session.url:
|
||||||
raise ValueError("Stripe Checkout Session creation failed")
|
raise ValueError("Stripe Checkout Session creation failed")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created Stripe Checkout Session {session.id} for mandate {mandate_id}, "
|
f"Created Stripe Checkout Session {session.id} for mandate {mandate_id}, "
|
||||||
f"amount {amount_chf} CHF"
|
f"amount {amount_chf} CHF, customer={customerId or 'guest'}, "
|
||||||
|
f"taxRates={taxRateIds}, autoTax={autoTaxEnabled}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return session.url
|
return session.url
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Usage:
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
@ -196,22 +196,28 @@ class AiAuditLogger:
|
||||||
self,
|
self,
|
||||||
mandateId: str,
|
mandateId: str,
|
||||||
*,
|
*,
|
||||||
timeRangeDays: int = 30,
|
fromTs: float,
|
||||||
|
toTs: float,
|
||||||
groupBy: str = "model",
|
groupBy: str = "model",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Aggregate statistics for Tab C."""
|
"""Aggregate statistics for Tab C over an explicit timestamp range.
|
||||||
|
|
||||||
|
`fromTs`/`toTs` are inclusive epoch-second boundaries (see
|
||||||
|
`dateRange.isoDateRangeToLocalEpoch`). Both are required.
|
||||||
|
"""
|
||||||
self._ensureInitialized()
|
self._ensureInitialized()
|
||||||
if not self._db:
|
if not self._db:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
from modules.datamodels.datamodelAiAudit import AiAuditLogEntry
|
from modules.datamodels.datamodelAiAudit import AiAuditLogEntry
|
||||||
|
|
||||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, timeRangeDays))).timestamp()
|
|
||||||
|
|
||||||
allRecords = self._db.getRecordset(
|
allRecords = self._db.getRecordset(
|
||||||
AiAuditLogEntry, recordFilter={"mandateId": mandateId}
|
AiAuditLogEntry, recordFilter={"mandateId": mandateId}
|
||||||
)
|
)
|
||||||
records = [r for r in allRecords if (r.get("timestamp") or 0) >= cutoff]
|
records = [
|
||||||
|
r for r in allRecords
|
||||||
|
if fromTs <= (r.get("timestamp") or 0) <= toTs
|
||||||
|
]
|
||||||
|
|
||||||
callsByDay: Dict[str, int] = defaultdict(int)
|
callsByDay: Dict[str, int] = defaultdict(int)
|
||||||
callsByModel: Dict[str, int] = defaultdict(int)
|
callsByModel: Dict[str, int] = defaultdict(int)
|
||||||
|
|
@ -237,10 +243,13 @@ class AiAuditLogger:
|
||||||
|
|
||||||
sortedDays = sorted(callsByDay.keys())
|
sortedDays = sorted(callsByDay.keys())
|
||||||
neutralizationPercent = round(100.0 * neutralizationCount / totalCalls, 1) if totalCalls else 0.0
|
neutralizationPercent = round(100.0 * neutralizationCount / totalCalls, 1) if totalCalls else 0.0
|
||||||
|
days = max(1, int((toTs - fromTs) / 86400) + 1)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"totalCalls": totalCalls,
|
"totalCalls": totalCalls,
|
||||||
"timeRangeDays": timeRangeDays,
|
"fromTs": fromTs,
|
||||||
|
"toTs": toTs,
|
||||||
|
"days": days,
|
||||||
"callsPerDay": [{"date": d, "calls": callsByDay[d]} for d in sortedDays],
|
"callsPerDay": [{"date": d, "calls": callsByDay[d]} for d in sortedDays],
|
||||||
"costPerDay": [{"date": d, "cost": round(costByDay[d], 4)} for d in sortedDays],
|
"costPerDay": [{"date": d, "cost": round(costByDay[d], 4)} for d in sortedDays],
|
||||||
"callsByModel": dict(sorted(callsByModel.items(), key=lambda x: -x[1])),
|
"callsByModel": dict(sorted(callsByModel.items(), key=lambda x: -x[1])),
|
||||||
|
|
|
||||||
80
modules/shared/dateRange.py
Normal file
80
modules/shared/dateRange.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Date-range parsing for API endpoints that accept user-provided
|
||||||
|
`dateFrom`/`dateTo` query params (ISO `YYYY-MM-DD`).
|
||||||
|
|
||||||
|
Centralizes:
|
||||||
|
- Parsing (HTTPException 400 on invalid format)
|
||||||
|
- Validation (`dateFrom <= dateTo`)
|
||||||
|
- Conversion to inclusive epoch boundaries for downstream filters
|
||||||
|
(`dateFrom` -> `00:00:00.000` of that local day,
|
||||||
|
`dateTo` -> `23:59:59.999` of that local day).
|
||||||
|
|
||||||
|
Local-day semantics intentionally follow `datetime.combine(d, time.min).timestamp()`
|
||||||
|
which the legacy billing/audit code used; this matches the user's calendar
|
||||||
|
expectation ("01.04 - 03.04" = three full local days). Servers running in a
|
||||||
|
timezone other than the user's will see consistent boundaries because the
|
||||||
|
date string carries no timezone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime, time as dtTime
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def parseIsoDate(value: str, fieldName: str) -> date:
|
||||||
|
"""Parse an ISO `YYYY-MM-DD` string.
|
||||||
|
|
||||||
|
Raises HTTPException(400) on invalid input.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"{fieldName} is required (ISO YYYY-MM-DD)",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"{fieldName} is not a valid ISO date (YYYY-MM-DD): {value}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
def parseIsoDateRange(dateFrom: str, dateTo: str) -> Tuple[date, date]:
|
||||||
|
"""Parse and validate an inclusive ISO date range.
|
||||||
|
|
||||||
|
Raises HTTPException(400) on invalid format or `from > to`.
|
||||||
|
"""
|
||||||
|
fromDate = parseIsoDate(dateFrom, "dateFrom")
|
||||||
|
toDate = parseIsoDate(dateTo, "dateTo")
|
||||||
|
if fromDate > toDate:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"dateFrom must be <= dateTo (got {dateFrom} > {dateTo})",
|
||||||
|
)
|
||||||
|
return fromDate, toDate
|
||||||
|
|
||||||
|
|
||||||
|
def isoDateRangeToLocalEpoch(dateFrom: str, dateTo: str) -> Tuple[float, float]:
|
||||||
|
"""Convert an inclusive ISO date range to local-time epoch seconds.
|
||||||
|
|
||||||
|
`dateTo` boundary is end-of-day inclusive (23:59:59.999999), so a single-day
|
||||||
|
range `dateFrom == dateTo` covers the full 24h of that local day.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(fromTs, toTs) - both float epoch seconds, suitable for `ts >= fromTs`
|
||||||
|
and `ts <= toTs` filters.
|
||||||
|
"""
|
||||||
|
fromDate, toDate = parseIsoDateRange(dateFrom, dateTo)
|
||||||
|
fromTs = datetime.combine(fromDate, dtTime.min).timestamp()
|
||||||
|
toTs = datetime.combine(toDate, dtTime.max).timestamp()
|
||||||
|
return fromTs, toTs
|
||||||
|
|
||||||
|
|
||||||
|
def daysInRange(dateFrom: str, dateTo: str) -> int:
|
||||||
|
"""Inclusive day count for a date range. `from == to` returns 1."""
|
||||||
|
fromDate, toDate = parseIsoDateRange(dateFrom, dateTo)
|
||||||
|
return (toDate - fromDate).days + 1
|
||||||
|
|
@ -50,6 +50,42 @@ class OrphanResult:
|
||||||
targetTable: str
|
targetTable: str
|
||||||
targetColumn: str
|
targetColumn: str
|
||||||
orphanCount: int
|
orphanCount: int
|
||||||
|
sourceRowCount: int = 0
|
||||||
|
targetRowCount: int = 0
|
||||||
|
targetEmpty: bool = False
|
||||||
|
wouldDeleteAll: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrphanRecord:
|
||||||
|
"""A single orphan source-row -- includes the unresolved FK value plus the full row data.
|
||||||
|
|
||||||
|
Used by the SysAdmin UI download button so the human can verify the orphan
|
||||||
|
list before pressing "clean".
|
||||||
|
"""
|
||||||
|
sourceDb: str
|
||||||
|
sourceTable: str
|
||||||
|
sourceColumn: str
|
||||||
|
targetDb: str
|
||||||
|
targetTable: str
|
||||||
|
targetColumn: str
|
||||||
|
orphanFkValue: str
|
||||||
|
rowId: Optional[str]
|
||||||
|
row: Dict
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Safety thresholds for cleanup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# If a single cleanup would delete more than this fraction of the source table,
|
||||||
|
# refuse without an explicit force=True. Protects against catastrophic wipes
|
||||||
|
# caused by misconfigured / empty target tables.
|
||||||
|
_MAX_CLEANUP_FRACTION = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class OrphanCleanupRefused(Exception):
|
||||||
|
"""Raised when a cleanup is refused for safety reasons (use force=True to override)."""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -147,6 +183,60 @@ def _loadParentIds(conn, tableName: str, columnName: str) -> Set[str]:
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _loadPhysicalColumns(conn, tableName: str) -> Set[str]:
|
||||||
|
"""Return the set of physical (scalar) columns present on a table.
|
||||||
|
|
||||||
|
Used by the orphan scanner to skip FK relationships whose ``sourceColumn``
|
||||||
|
is annotated on the Pydantic model but does NOT exist as a physical column
|
||||||
|
-- e.g. virtual / computed fields, or fields that the database interface
|
||||||
|
decided to fold into a JSONB blob (List/Dict typed fields). Comparing a
|
||||||
|
JSONB array against a scalar via ``=`` always fails and would otherwise
|
||||||
|
flag every single source row as an orphan (the user-reported "false
|
||||||
|
positives").
|
||||||
|
"""
|
||||||
|
cols: Set[str] = set()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = %s
|
||||||
|
""",
|
||||||
|
(tableName,),
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
cols.add(row["column_name"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def _countRows(conn, tableName: str) -> int:
|
||||||
|
"""Count physical rows in a table. Returns 0 on any error."""
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(f'SELECT COUNT(*) AS cnt FROM "{tableName}"')
|
||||||
|
return int(cur.fetchone()["cnt"])
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _countNonNullSource(conn, tableName: str, columnName: str) -> int:
|
||||||
|
"""Count source rows where the FK column is non-null/non-empty."""
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM "{tableName}"
|
||||||
|
WHERE "{columnName}" IS NOT NULL
|
||||||
|
AND "{columnName}" != ''
|
||||||
|
""")
|
||||||
|
return int(cur.fetchone()["cnt"])
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _countOrphansSameDb(
|
def _countOrphansSameDb(
|
||||||
conn, sourceTable: str, sourceColumn: str,
|
conn, sourceTable: str, sourceColumn: str,
|
||||||
targetTable: str, targetColumn: str,
|
targetTable: str, targetColumn: str,
|
||||||
|
|
@ -213,6 +303,7 @@ def _scanOrphans(dbFilter: Optional[str] = None) -> List[dict]:
|
||||||
|
|
||||||
connCache: Dict[str, any] = {}
|
connCache: Dict[str, any] = {}
|
||||||
tableCache: Dict[str, Set[str]] = {}
|
tableCache: Dict[str, Set[str]] = {}
|
||||||
|
columnCache: Dict[str, Set[str]] = {}
|
||||||
parentIdCache: Dict[str, Set[str]] = {}
|
parentIdCache: Dict[str, Set[str]] = {}
|
||||||
results: List[dict] = []
|
results: List[dict] = []
|
||||||
|
|
||||||
|
|
@ -236,6 +327,15 @@ def _scanOrphans(dbFilter: Optional[str] = None) -> List[dict]:
|
||||||
tableCache[dbName] = set()
|
tableCache[dbName] = set()
|
||||||
return tableCache[dbName]
|
return tableCache[dbName]
|
||||||
|
|
||||||
|
def _existingColumns(dbName: str, tableName: str) -> Set[str]:
|
||||||
|
cacheKey = f"{dbName}.{tableName}"
|
||||||
|
if cacheKey not in columnCache:
|
||||||
|
try:
|
||||||
|
columnCache[cacheKey] = _loadPhysicalColumns(_ensureConn(dbName), tableName)
|
||||||
|
except Exception:
|
||||||
|
columnCache[cacheKey] = set()
|
||||||
|
return columnCache[cacheKey]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for rel in relationships:
|
for rel in relationships:
|
||||||
try:
|
try:
|
||||||
|
|
@ -251,17 +351,39 @@ def _scanOrphans(dbFilter: Optional[str] = None) -> List[dict]:
|
||||||
if rel.targetTable not in targetTables:
|
if rel.targetTable not in targetTables:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip FK annotations whose source column is not a physical
|
||||||
|
# scalar column (virtual / JSONB-resident / computed field).
|
||||||
|
# See _loadPhysicalColumns docstring for why this matters.
|
||||||
|
sourceColumns = _existingColumns(rel.sourceDb, rel.sourceTable)
|
||||||
|
if rel.sourceColumn not in sourceColumns:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping FK %s.%s.%s -- column not present as physical column",
|
||||||
|
rel.sourceDb, rel.sourceTable, rel.sourceColumn,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
targetColumns = _existingColumns(rel.targetDb, rel.targetTable)
|
||||||
|
if rel.targetColumn not in targetColumns:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping FK %s.%s.%s -> %s.%s.%s -- target column not present",
|
||||||
|
rel.sourceDb, rel.sourceTable, rel.sourceColumn,
|
||||||
|
rel.targetDb, rel.targetTable, rel.targetColumn,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
sourceConn = _ensureConn(rel.sourceDb)
|
sourceConn = _ensureConn(rel.sourceDb)
|
||||||
|
|
||||||
if rel.sourceDb == rel.targetDb:
|
if rel.sourceDb == rel.targetDb:
|
||||||
|
targetRowCount = _countRows(sourceConn, rel.targetTable)
|
||||||
count = _countOrphansSameDb(
|
count = _countOrphansSameDb(
|
||||||
sourceConn, rel.sourceTable, rel.sourceColumn,
|
sourceConn, rel.sourceTable, rel.sourceColumn,
|
||||||
rel.targetTable, rel.targetColumn,
|
rel.targetTable, rel.targetColumn,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
targetConn = _ensureConn(rel.targetDb)
|
||||||
|
targetRowCount = _countRows(targetConn, rel.targetTable)
|
||||||
|
|
||||||
parentKey = f"{rel.targetDb}.{rel.targetTable}.{rel.targetColumn}"
|
parentKey = f"{rel.targetDb}.{rel.targetTable}.{rel.targetColumn}"
|
||||||
if parentKey not in parentIdCache:
|
if parentKey not in parentIdCache:
|
||||||
targetConn = _ensureConn(rel.targetDb)
|
|
||||||
parentIdCache[parentKey] = _loadParentIds(
|
parentIdCache[parentKey] = _loadParentIds(
|
||||||
targetConn, rel.targetTable, rel.targetColumn,
|
targetConn, rel.targetTable, rel.targetColumn,
|
||||||
)
|
)
|
||||||
|
|
@ -271,6 +393,12 @@ def _scanOrphans(dbFilter: Optional[str] = None) -> List[dict]:
|
||||||
parentIdCache[parentKey],
|
parentIdCache[parentKey],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sourceRowCount = _countNonNullSource(
|
||||||
|
sourceConn, rel.sourceTable, rel.sourceColumn,
|
||||||
|
)
|
||||||
|
wouldDeleteAll = (count > 0 and count >= sourceRowCount)
|
||||||
|
targetEmpty = (targetRowCount == 0)
|
||||||
|
|
||||||
results.append(asdict(OrphanResult(
|
results.append(asdict(OrphanResult(
|
||||||
sourceDb=rel.sourceDb,
|
sourceDb=rel.sourceDb,
|
||||||
sourceTable=rel.sourceTable,
|
sourceTable=rel.sourceTable,
|
||||||
|
|
@ -279,6 +407,10 @@ def _scanOrphans(dbFilter: Optional[str] = None) -> List[dict]:
|
||||||
targetTable=rel.targetTable,
|
targetTable=rel.targetTable,
|
||||||
targetColumn=rel.targetColumn,
|
targetColumn=rel.targetColumn,
|
||||||
orphanCount=count,
|
orphanCount=count,
|
||||||
|
sourceRowCount=sourceRowCount,
|
||||||
|
targetRowCount=targetRowCount,
|
||||||
|
targetEmpty=targetEmpty,
|
||||||
|
wouldDeleteAll=wouldDeleteAll,
|
||||||
)))
|
)))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -308,8 +440,16 @@ def _scanOrphans(dbFilter: Optional[str] = None) -> List[dict]:
|
||||||
# Orphan cleanup
|
# Orphan cleanup
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _cleanOrphans(db: str, table: str, column: str) -> int:
|
def _cleanOrphans(db: str, table: str, column: str, force: bool = False) -> int:
|
||||||
"""Delete orphaned records for a single FK relationship. Returns count deleted."""
|
"""Delete orphaned records for a single FK relationship. Returns count deleted.
|
||||||
|
|
||||||
|
Safety guards (require force=True to override):
|
||||||
|
- Refuses if the target table is empty (likely misconfiguration / lazy table).
|
||||||
|
- Refuses if the cleanup would delete >= _MAX_CLEANUP_FRACTION of the source rows.
|
||||||
|
|
||||||
|
These guards prevent catastrophic wipes (e.g. emptying FeatureInstance because
|
||||||
|
the User table happened to be empty in the wrong DB at scan time).
|
||||||
|
"""
|
||||||
relationships = _getFkRelationships()
|
relationships = _getFkRelationships()
|
||||||
rel = next(
|
rel = next(
|
||||||
(r for r in relationships
|
(r for r in relationships
|
||||||
|
|
@ -320,7 +460,65 @@ def _cleanOrphans(db: str, table: str, column: str) -> int:
|
||||||
raise ValueError(f"No FK relationship found for {db}.{table}.{column}")
|
raise ValueError(f"No FK relationship found for {db}.{table}.{column}")
|
||||||
|
|
||||||
conn = _getConnection(rel.sourceDb)
|
conn = _getConnection(rel.sourceDb)
|
||||||
|
targetConn = None
|
||||||
try:
|
try:
|
||||||
|
if rel.sourceDb == rel.targetDb:
|
||||||
|
targetRowCount = _countRows(conn, rel.targetTable)
|
||||||
|
parentIds: Optional[Set[str]] = None
|
||||||
|
else:
|
||||||
|
targetConn = _getConnection(rel.targetDb)
|
||||||
|
targetRowCount = _countRows(targetConn, rel.targetTable)
|
||||||
|
parentIds = _loadParentIds(targetConn, rel.targetTable, rel.targetColumn)
|
||||||
|
|
||||||
|
sourceRowCount = _countNonNullSource(conn, rel.sourceTable, rel.sourceColumn)
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
if targetRowCount == 0 and sourceRowCount > 0:
|
||||||
|
raise OrphanCleanupRefused(
|
||||||
|
f"Refusing cleanup: target table '{rel.targetDb}.{rel.targetTable}' "
|
||||||
|
f"is empty but source '{rel.sourceDb}.{rel.sourceTable}' has "
|
||||||
|
f"{sourceRowCount} rows with non-null '{rel.sourceColumn}'. "
|
||||||
|
f"This likely indicates a misconfiguration. Use force=True to override."
|
||||||
|
)
|
||||||
|
|
||||||
|
if rel.sourceDb == rel.targetDb:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM "{rel.sourceTable}" s
|
||||||
|
WHERE s."{rel.sourceColumn}" IS NOT NULL
|
||||||
|
AND s."{rel.sourceColumn}" != ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "{rel.targetTable}" t
|
||||||
|
WHERE t."{rel.targetColumn}" = s."{rel.sourceColumn}"
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
wouldDelete = int(cur.fetchone()["cnt"])
|
||||||
|
else:
|
||||||
|
if not parentIds:
|
||||||
|
wouldDelete = sourceRowCount
|
||||||
|
else:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM "{rel.sourceTable}"
|
||||||
|
WHERE "{rel.sourceColumn}" IS NOT NULL
|
||||||
|
AND "{rel.sourceColumn}" != ''
|
||||||
|
AND "{rel.sourceColumn}" NOT IN (
|
||||||
|
SELECT unnest(%(ids)s::text[])
|
||||||
|
)
|
||||||
|
""", {"ids": list(parentIds)})
|
||||||
|
wouldDelete = int(cur.fetchone()["cnt"])
|
||||||
|
|
||||||
|
if not force and sourceRowCount > 0:
|
||||||
|
fraction = wouldDelete / sourceRowCount
|
||||||
|
if fraction >= _MAX_CLEANUP_FRACTION:
|
||||||
|
raise OrphanCleanupRefused(
|
||||||
|
f"Refusing cleanup: would delete {wouldDelete} of {sourceRowCount} "
|
||||||
|
f"non-null rows ({fraction:.0%}) from '{rel.sourceDb}.{rel.sourceTable}'. "
|
||||||
|
f"Threshold is {_MAX_CLEANUP_FRACTION:.0%}. Use force=True to override."
|
||||||
|
)
|
||||||
|
|
||||||
if rel.sourceDb == rel.targetDb:
|
if rel.sourceDb == rel.targetDb:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
|
|
@ -335,12 +533,6 @@ def _cleanOrphans(db: str, table: str, column: str) -> int:
|
||||||
deleted = cur.rowcount
|
deleted = cur.rowcount
|
||||||
conn.commit()
|
conn.commit()
|
||||||
else:
|
else:
|
||||||
targetConn = _getConnection(rel.targetDb)
|
|
||||||
try:
|
|
||||||
parentIds = _loadParentIds(targetConn, rel.targetTable, rel.targetColumn)
|
|
||||||
finally:
|
|
||||||
targetConn.close()
|
|
||||||
|
|
||||||
if not parentIds:
|
if not parentIds:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
|
|
@ -365,26 +557,56 @@ def _cleanOrphans(db: str, table: str, column: str) -> int:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
if targetConn is not None:
|
||||||
|
try:
|
||||||
|
targetConn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
_invalidateOrphanCache()
|
_invalidateOrphanCache()
|
||||||
logger.info(f"Cleaned {deleted} orphans from {db}.{table}.{column}")
|
logger.info(
|
||||||
|
f"Cleaned {deleted} orphans from {db}.{table}.{column} (force={force})"
|
||||||
|
)
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
def _cleanAllOrphans() -> List[dict]:
|
def _cleanAllOrphans(force: bool = False) -> List[dict]:
|
||||||
"""Clean all detected orphans. Returns list of {db, table, column, deleted}."""
|
"""Clean all detected orphans. Returns list of {db, table, column, deleted, [error|skipped]}.
|
||||||
|
|
||||||
|
Safety: each individual cleanup re-validates target row counts at delete-time
|
||||||
|
to avoid cascading wipes (e.g. one delete emptying a target table that the
|
||||||
|
next iteration depends on). Without force=True, dangerous cleanups are skipped.
|
||||||
|
"""
|
||||||
orphans = _scanOrphans()
|
orphans = _scanOrphans()
|
||||||
results = []
|
results = []
|
||||||
for orphan in orphans:
|
for orphan in orphans:
|
||||||
|
if orphan.get("orphanCount", 0) <= 0:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
deleted = _cleanOrphans(orphan["sourceDb"], orphan["sourceTable"], orphan["sourceColumn"])
|
deleted = _cleanOrphans(
|
||||||
|
orphan["sourceDb"],
|
||||||
|
orphan["sourceTable"],
|
||||||
|
orphan["sourceColumn"],
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
results.append({
|
results.append({
|
||||||
"db": orphan["sourceDb"],
|
"db": orphan["sourceDb"],
|
||||||
"table": orphan["sourceTable"],
|
"table": orphan["sourceTable"],
|
||||||
"column": orphan["sourceColumn"],
|
"column": orphan["sourceColumn"],
|
||||||
"deleted": deleted,
|
"deleted": deleted,
|
||||||
})
|
})
|
||||||
|
except OrphanCleanupRefused as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Skipping orphan cleanup for {orphan['sourceDb']}.{orphan['sourceTable']}.{orphan['sourceColumn']}: {e}"
|
||||||
|
)
|
||||||
|
results.append({
|
||||||
|
"db": orphan["sourceDb"],
|
||||||
|
"table": orphan["sourceTable"],
|
||||||
|
"column": orphan["sourceColumn"],
|
||||||
|
"deleted": 0,
|
||||||
|
"skipped": str(e),
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to clean orphans for {orphan['sourceDb']}.{orphan['sourceTable']}.{orphan['sourceColumn']}: {e}"
|
f"Failed to clean orphans for {orphan['sourceDb']}.{orphan['sourceTable']}.{orphan['sourceColumn']}: {e}"
|
||||||
|
|
@ -403,3 +625,132 @@ def _invalidateOrphanCache() -> None:
|
||||||
global _orphanCache
|
global _orphanCache
|
||||||
with _orphanCacheLock:
|
with _orphanCacheLock:
|
||||||
_orphanCache = None
|
_orphanCache = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Listing orphans (for SysAdmin "download / inspect" workflow)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _listOrphans(
|
||||||
|
db: str,
|
||||||
|
table: str,
|
||||||
|
column: str,
|
||||||
|
limit: int = 1000,
|
||||||
|
) -> List[dict]:
|
||||||
|
"""Return up to ``limit`` actual orphan source-rows for one FK relationship.
|
||||||
|
|
||||||
|
Each entry is ``{"orphanFkValue": str, "rowId": str|None, "row": dict}`` so
|
||||||
|
the SysAdmin UI can present them as a download (CSV/JSON) for review before
|
||||||
|
the destructive cleanup is triggered.
|
||||||
|
"""
|
||||||
|
relationships = _getFkRelationships()
|
||||||
|
rel = next(
|
||||||
|
(r for r in relationships
|
||||||
|
if r.sourceDb == db and r.sourceTable == table and r.sourceColumn == column),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if rel is None:
|
||||||
|
raise ValueError(f"No FK relationship found for {db}.{table}.{column}")
|
||||||
|
|
||||||
|
safeLimit = max(1, min(int(limit), 10000))
|
||||||
|
|
||||||
|
sourceConn = _getConnection(rel.sourceDb)
|
||||||
|
targetConn = None
|
||||||
|
try:
|
||||||
|
sourceColumns = _loadPhysicalColumns(sourceConn, rel.sourceTable)
|
||||||
|
if rel.sourceColumn not in sourceColumns:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if rel.sourceDb == rel.targetDb:
|
||||||
|
targetColumns = _loadPhysicalColumns(sourceConn, rel.targetTable)
|
||||||
|
if rel.targetColumn not in targetColumns:
|
||||||
|
return []
|
||||||
|
with sourceConn.cursor() as cur:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT s.*
|
||||||
|
FROM "{rel.sourceTable}" s
|
||||||
|
WHERE s."{rel.sourceColumn}" IS NOT NULL
|
||||||
|
AND s."{rel.sourceColumn}" != ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "{rel.targetTable}" t
|
||||||
|
WHERE t."{rel.targetColumn}" = s."{rel.sourceColumn}"
|
||||||
|
)
|
||||||
|
LIMIT %s
|
||||||
|
""", (safeLimit,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
else:
|
||||||
|
targetConn = _getConnection(rel.targetDb)
|
||||||
|
targetColumns = _loadPhysicalColumns(targetConn, rel.targetTable)
|
||||||
|
if rel.targetColumn not in targetColumns:
|
||||||
|
return []
|
||||||
|
parentIds = _loadParentIds(targetConn, rel.targetTable, rel.targetColumn)
|
||||||
|
with sourceConn.cursor() as cur:
|
||||||
|
if not parentIds:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT *
|
||||||
|
FROM "{rel.sourceTable}"
|
||||||
|
WHERE "{rel.sourceColumn}" IS NOT NULL
|
||||||
|
AND "{rel.sourceColumn}" != ''
|
||||||
|
LIMIT %s
|
||||||
|
""", (safeLimit,))
|
||||||
|
else:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT *
|
||||||
|
FROM "{rel.sourceTable}"
|
||||||
|
WHERE "{rel.sourceColumn}" IS NOT NULL
|
||||||
|
AND "{rel.sourceColumn}" != ''
|
||||||
|
AND "{rel.sourceColumn}" NOT IN (
|
||||||
|
SELECT unnest(%(ids)s::text[])
|
||||||
|
)
|
||||||
|
LIMIT %(lim)s
|
||||||
|
""", {"ids": list(parentIds), "lim": safeLimit})
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
if targetConn is not None:
|
||||||
|
try:
|
||||||
|
targetConn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sourceConn.close()
|
||||||
|
|
||||||
|
out: List[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
rowDict = {k: _jsonSafe(v) for k, v in dict(row).items()}
|
||||||
|
out.append(asdict(OrphanRecord(
|
||||||
|
sourceDb=rel.sourceDb,
|
||||||
|
sourceTable=rel.sourceTable,
|
||||||
|
sourceColumn=rel.sourceColumn,
|
||||||
|
targetDb=rel.targetDb,
|
||||||
|
targetTable=rel.targetTable,
|
||||||
|
targetColumn=rel.targetColumn,
|
||||||
|
orphanFkValue=str(rowDict.get(rel.sourceColumn, "")),
|
||||||
|
rowId=str(rowDict.get("id")) if rowDict.get("id") is not None else None,
|
||||||
|
row=rowDict,
|
||||||
|
)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonSafe(v):
|
||||||
|
"""Coerce psycopg2 row values into JSON-serialisable primitives."""
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
if v is None or isinstance(v, (str, int, float, bool)):
|
||||||
|
return v
|
||||||
|
if isinstance(v, (datetime.datetime, datetime.date, datetime.time)):
|
||||||
|
return v.isoformat()
|
||||||
|
if isinstance(v, decimal.Decimal):
|
||||||
|
return float(v)
|
||||||
|
if isinstance(v, uuid.UUID):
|
||||||
|
return str(v)
|
||||||
|
if isinstance(v, (list, tuple)):
|
||||||
|
return [_jsonSafe(x) for x in v]
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return {str(k): _jsonSafe(val) for k, val in v.items()}
|
||||||
|
if isinstance(v, (bytes, bytearray, memoryview)):
|
||||||
|
try:
|
||||||
|
return bytes(v).decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
return repr(v)
|
||||||
|
return str(v)
|
||||||
|
|
|
||||||
|
|
@ -192,3 +192,69 @@ def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]:
|
||||||
results[featureName] = False
|
results[featureName] = False
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def syncCatalogFeaturesToDb(catalogService) -> Dict[str, str]:
|
||||||
|
"""Persist all in-memory feature definitions into the ``Feature`` DB table.
|
||||||
|
|
||||||
|
PowerOn discovers Features as Python modules at boot time and registers
|
||||||
|
them only in the in-memory ``RbacCatalog._featureDefinitions`` dict (see
|
||||||
|
``registerAllFeaturesInCatalog`` above). However, the ``FeatureInstance``
|
||||||
|
Pydantic model declares ``featureCode`` as an FK into the ``Feature`` DB
|
||||||
|
table. If the ``Feature`` table is not kept in sync with the code-side
|
||||||
|
registry, every ``FeatureInstance`` row appears as a foreign-key orphan
|
||||||
|
in the SysAdmin DB-health scan -- even though the feature very much
|
||||||
|
exists at runtime.
|
||||||
|
|
||||||
|
This function bridges that gap: after the catalog has been built it walks
|
||||||
|
every registered feature definition and idempotently upserts it into the
|
||||||
|
``Feature`` table (insert-if-missing, update label/icon if drifted).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict ``{featureCode: action}`` with action in
|
||||||
|
``{"created", "updated", "unchanged", "error"}``.
|
||||||
|
"""
|
||||||
|
actions: Dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
from modules.security.rootAccess import getRootDbAppConnector
|
||||||
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"syncCatalogFeaturesToDb: dependency import failed: {e}")
|
||||||
|
return actions
|
||||||
|
|
||||||
|
try:
|
||||||
|
rootDb = getRootDbAppConnector()
|
||||||
|
featuresIf = getFeatureInterface(rootDb)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"syncCatalogFeaturesToDb: cannot obtain feature interface: {e}")
|
||||||
|
return actions
|
||||||
|
|
||||||
|
try:
|
||||||
|
definitions = catalogService.getFeatureDefinitions() or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"syncCatalogFeaturesToDb: cannot list feature definitions: {e}")
|
||||||
|
return actions
|
||||||
|
|
||||||
|
for defn in definitions:
|
||||||
|
code = (defn or {}).get("code")
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
action = featuresIf.upsertFeature(
|
||||||
|
code=code,
|
||||||
|
label=defn.get("label") or code,
|
||||||
|
icon=defn.get("icon") or "",
|
||||||
|
)
|
||||||
|
actions[code] = action
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"syncCatalogFeaturesToDb: failed to upsert {code}: {e}")
|
||||||
|
actions[code] = "error"
|
||||||
|
|
||||||
|
created = sum(1 for v in actions.values() if v == "created")
|
||||||
|
updated = sum(1 for v in actions.values() if v == "updated")
|
||||||
|
errors = sum(1 for v in actions.values() if v == "error")
|
||||||
|
logger.info(
|
||||||
|
f"Feature DB sync: {len(actions)} definitions processed, "
|
||||||
|
f"{created} created, {updated} updated, {errors} errors"
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|
|
||||||
94
tests/test_dateRange.py
Normal file
94
tests/test_dateRange.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Unit tests for `modules.shared.dateRange`.
|
||||||
|
Pure-Python, no DB / no API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime, time as dtTime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from modules.shared.dateRange import (
|
||||||
|
daysInRange,
|
||||||
|
isoDateRangeToLocalEpoch,
|
||||||
|
parseIsoDate,
|
||||||
|
parseIsoDateRange,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseIsoDate:
|
||||||
|
def test_validIsoDate(self):
|
||||||
|
assert parseIsoDate("2026-04-15", "dateFrom") == date(2026, 4, 15)
|
||||||
|
|
||||||
|
def test_emptyStringRaises400(self):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
parseIsoDate("", "dateFrom")
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "dateFrom" in exc.value.detail
|
||||||
|
|
||||||
|
def test_invalidFormatRaises400(self):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
parseIsoDate("15.04.2026", "dateTo")
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "dateTo" in exc.value.detail
|
||||||
|
assert "15.04.2026" in exc.value.detail
|
||||||
|
|
||||||
|
def test_nonStringRaises400(self):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
parseIsoDate(None, "dateFrom") # type: ignore[arg-type]
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseIsoDateRange:
|
||||||
|
def test_validRange(self):
|
||||||
|
f, t = parseIsoDateRange("2026-04-01", "2026-04-15")
|
||||||
|
assert f == date(2026, 4, 1)
|
||||||
|
assert t == date(2026, 4, 15)
|
||||||
|
|
||||||
|
def test_sameDayIsValid(self):
|
||||||
|
f, t = parseIsoDateRange("2026-04-15", "2026-04-15")
|
||||||
|
assert f == t
|
||||||
|
|
||||||
|
def test_invertedRangeRaises400(self):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
parseIsoDateRange("2026-04-15", "2026-04-01")
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "dateFrom must be <= dateTo" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsoDateRangeToLocalEpoch:
|
||||||
|
def test_inclusiveEndOfDay(self):
|
||||||
|
"""`dateTo` boundary covers full last day (23:59:59.999999 local)."""
|
||||||
|
fromTs, toTs = isoDateRangeToLocalEpoch("2026-04-15", "2026-04-15")
|
||||||
|
startOfDay = datetime.combine(date(2026, 4, 15), dtTime.min).timestamp()
|
||||||
|
endOfDay = datetime.combine(date(2026, 4, 15), dtTime.max).timestamp()
|
||||||
|
assert fromTs == startOfDay
|
||||||
|
assert toTs == endOfDay
|
||||||
|
# Single-day range covers ~24h - 1 microsecond.
|
||||||
|
assert (toTs - fromTs) > (24 * 3600 - 1)
|
||||||
|
|
||||||
|
def test_multiDayRange(self):
|
||||||
|
fromTs, toTs = isoDateRangeToLocalEpoch("2026-04-01", "2026-04-03")
|
||||||
|
# Three local days, end-inclusive: ~3 * 86400 seconds (- 1us).
|
||||||
|
assert 3 * 86400 - 1 < (toTs - fromTs) < 3 * 86400 + 1
|
||||||
|
|
||||||
|
def test_invalidRaises400(self):
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
isoDateRangeToLocalEpoch("not-a-date", "2026-04-03")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDaysInRange:
|
||||||
|
def test_singleDayIsOne(self):
|
||||||
|
assert daysInRange("2026-04-15", "2026-04-15") == 1
|
||||||
|
|
||||||
|
def test_threeDaysInclusive(self):
|
||||||
|
assert daysInRange("2026-04-01", "2026-04-03") == 3
|
||||||
|
|
||||||
|
def test_yearSpan(self):
|
||||||
|
assert daysInRange("2026-01-01", "2026-12-31") == 365
|
||||||
|
|
||||||
|
def test_invertedRangeRaises400(self):
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
daysInRange("2026-04-15", "2026-04-01")
|
||||||
Loading…
Reference in a new issue