Merge remote-tracking branch 'origin/int'
Some checks failed
Deploy Plattform-Core / deploy (push) Blocked by required conditions
Deploy Plattform-Core (Int) / test (push) Waiting to run
Deploy Plattform-Core (Int) / deploy (push) Blocked by required conditions
Deploy Plattform-Core / test (push) Has been cancelled

This commit is contained in:
ValueOn AG 2026-06-01 00:02:22 +02:00
commit a7b5192e25
3 changed files with 160 additions and 11 deletions

View file

@ -1,12 +1,13 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional, Tuple
from fastapi import status
from fastapi.responses import JSONResponse
import logging
import json
import math
from copy import deepcopy
# Import auth module
from modules.auth import limiter, getCurrentUser
@ -74,6 +75,46 @@ def get_prompts(
paginationParams = applyViewToParams(paginationParams, viewConfig)
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
def _getVisiblePromptSearchFields() -> List[str]:
"""Return Prompt fields considered visible/searchable in the table."""
fields: List[str] = []
for fieldName, fieldInfo in Prompt.model_fields.items():
if fieldName == "id":
continue
jsonExtra = fieldInfo.json_schema_extra or {}
if jsonExtra.get("frontend_visible", True) is False:
continue
if jsonExtra.get("system", False):
continue
fields.append(fieldName)
return fields
visibleSearchFields = _getVisiblePromptSearchFields()
def _applyPromptSearchOnVisibleFields(rows: List[Dict[str, Any]], searchValue: Any) -> List[Dict[str, Any]]:
"""Apply global prompt search only on visible Prompt fields."""
searchTerm = str(searchValue or "").strip().lower()
if not searchTerm:
return rows
filteredRows: List[Dict[str, Any]] = []
for row in rows:
for fieldName in visibleSearchFields:
value = row.get(fieldName)
if value is None:
continue
if searchTerm in str(value).lower():
filteredRows.append(row)
break
return filteredRows
def _extractSearchAndRemoveFromFilters(filters: Optional[Dict[str, Any]]) -> Tuple[Any, Optional[Dict[str, Any]]]:
"""Extract generic search from filters and return remaining filters."""
if not filters:
return None, None
cleanedFilters = deepcopy(filters)
searchValue = cleanedFilters.pop("search", None)
return searchValue, (cleanedFilters if cleanedFilters else None)
def _promptsToEnrichedDicts(promptItems):
dicts = [r.model_dump() if hasattr(r, 'model_dump') else (dict(r) if not isinstance(r, dict) else r) for r in promptItems]
enrichRowsWithFkLabels(dicts, Prompt)
@ -115,21 +156,57 @@ def get_prompts(
if not groupByLevels:
# No grouping: let DB handle pagination directly
result = managementInterface.getAllPrompts(pagination=paginationParams)
if paginationParams and hasattr(result, 'items'):
response: dict = {
"items": _promptsToEnrichedDicts(result.items),
promptSearchValue = None
remainingFilters = paginationParams.filters if paginationParams else None
if paginationParams and paginationParams.filters:
promptSearchValue, remainingFilters = _extractSearchAndRemoveFromFilters(paginationParams.filters)
# For prompt search, we must filter before page slicing.
if paginationParams and promptSearchValue is not None:
result = managementInterface.getAllPrompts(pagination=None)
allItems = _promptsToEnrichedDicts(result if isinstance(result, list) else (result.items if hasattr(result, 'items') else []))
allItems = _applyPromptSearchOnVisibleFields(allItems, promptSearchValue)
if remainingFilters or paginationParams.sort:
from modules.interfaces.interfaceDbManagement import ComponentObjects
comp = ComponentObjects()
comp.setUserContext(currentUser)
if remainingFilters:
allItems = comp._applyFilters(allItems, remainingFilters)
if paginationParams.sort:
allItems = comp._applySorting(allItems, paginationParams.sort)
totalItems = len(allItems)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
response = {
"items": allItems[startIdx:endIdx],
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
}
else:
response = {"items": _promptsToEnrichedDicts(result if isinstance(result, list) else [result]), "pagination": None}
result = managementInterface.getAllPrompts(pagination=paginationParams)
if paginationParams and hasattr(result, 'items'):
response = {
"items": _promptsToEnrichedDicts(result.items),
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
}
else:
response = {"items": _promptsToEnrichedDicts(result if isinstance(result, list) else [result]), "pagination": None}
if viewMeta:
response["appliedView"] = viewMeta.model_dump()
return response
@ -148,8 +225,14 @@ def get_prompts(
from modules.interfaces.interfaceDbManagement import ComponentObjects
comp = ComponentObjects()
comp.setUserContext(currentUser)
filtersForGenericApply = paginationParams.filters
promptSearchValue = None
if paginationParams.filters:
allItems = comp._applyFilters(allItems, paginationParams.filters)
promptSearchValue, filtersForGenericApply = _extractSearchAndRemoveFromFilters(paginationParams.filters)
if promptSearchValue is not None:
allItems = _applyPromptSearchOnVisibleFields(allItems, promptSearchValue)
if filtersForGenericApply:
allItems = comp._applyFilters(allItems, filtersForGenericApply)
if paginationParams.sort:
allItems = comp._applySorting(allItems, paginationParams.sort)

View file

@ -11,7 +11,7 @@ from __future__ import annotations
import logging
import time
from typing import Dict
from typing import Dict, Optional
logger = logging.getLogger(__name__)
@ -27,6 +27,7 @@ def maybeEmailMandatePoolExhausted(
currentBalance: float,
requiredAmount: float,
cooldownSec: float = _DEFAULT_COOLDOWN_SEC,
frontendUrl: Optional[str] = None,
) -> None:
"""
Send one notification per mandate per cooldown window when the pool is exhausted.
@ -38,6 +39,7 @@ def maybeEmailMandatePoolExhausted(
currentBalance: Pool balance (CHF).
requiredAmount: Minimum required (CHF).
cooldownSec: Minimum seconds between emails for this mandate.
frontendUrl: Optional frontend base URL for /billing/admin link (omit to send email without CTA).
"""
if not mandateId:
return
@ -67,6 +69,9 @@ def maybeEmailMandatePoolExhausted(
"Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, "
"damit Benutzer wieder AI-Funktionen nutzen können.",
],
includeBillingAdminLink=True,
billingAdminButtonText="Guthaben aufladen",
frontendUrl=frontendUrl,
)
if sent > 0:
_poolExhaustedEmailLastSent[mandateId] = now

View file

@ -128,6 +128,44 @@ def resolveMandateName(mandateId: str) -> str:
# ============================================================================
def buildBillingAdminUrl(frontendUrl: str) -> str:
"""Absolute URL to /billing/admin for the given frontend base (no trailing slash on base)."""
base = (frontendUrl or "").strip().rstrip("/")
if not base:
return ""
return f"{base}/billing/admin"
def buildActionLinkHtmlBlock(url: str, buttonText: str) -> str:
"""Render a CTA button plus the same URL as a plain link (auth-email style)."""
if not url or not buttonText:
return ""
escaped_url = html.escape(url)
escaped_label = html.escape(buttonText)
return f'''<div style="text-align: center; margin: 24px 0 8px 0;">
<a href="{escaped_url}"
style="display: inline-block; background-color: #2563eb; color: #ffffff;
font-size: 15px; font-weight: 600; text-decoration: none;
padding: 12px 32px; border-radius: 6px; mso-padding-alt: 0;">
{escaped_label}
</a>
</div>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #9ca3af; word-break: break-all; text-align: center;">
{escaped_url}
</p>'''
def buildBillingAdminActionBlock(
frontendUrl: str,
buttonText: str = "Billing-Verwaltung öffnen",
) -> str:
"""CTA block linking to /billing/admin (auth-email style button + plain link)."""
admin_url = buildBillingAdminUrl(frontendUrl)
if not admin_url:
return ""
return buildActionLinkHtmlBlock(admin_url, buttonText)
def _getOperatorInfo() -> Dict[str, str]:
"""Load operator company data from config.ini."""
try:
@ -147,11 +185,13 @@ def renderHtmlEmail(
mandateName: str,
footerNote: Optional[str] = None,
rawHtmlBlock: Optional[str] = None,
actionHtmlBlock: Optional[str] = None,
) -> str:
"""Render a clean, professional HTML notification email.
Args:
rawHtmlBlock: Optional pre-formatted HTML inserted after bodyParagraphs (e.g. invoice table).
actionHtmlBlock: Optional CTA block (e.g. button + link to billing admin).
"""
hl = html.escape(headline)
mn = html.escape(mandateName)
@ -165,6 +205,10 @@ def renderHtmlEmail(
if rawHtmlBlock:
rawBlock = f'<div style="margin: 16px 0;">{rawHtmlBlock}</div>\n'
actionBlock = ""
if actionHtmlBlock:
actionBlock = f'<div style="margin: 8px 0 0 0;">{actionHtmlBlock}</div>\n'
footer = ""
if footerNote:
footer = (
@ -199,6 +243,7 @@ def renderHtmlEmail(
<div style="font-size: 15px; line-height: 1.6;">
{paragraphsHtml}
{rawBlock}
{actionBlock}
</div>
{footer}
</td></tr>
@ -229,6 +274,9 @@ def notifyMandateAdmins(
*,
footerNote: Optional[str] = None,
rawHtmlBlock: Optional[str] = None,
includeBillingAdminLink: bool = False,
billingAdminButtonText: str = "Billing-Verwaltung öffnen",
frontendUrl: Optional[str] = None,
) -> int:
"""
Send a styled HTML notification to all mandate admins.
@ -240,6 +288,9 @@ def notifyMandateAdmins(
bodyParagraphs: List of paragraph strings (plain text, auto-escaped).
footerNote: Optional small-print note below the main content.
rawHtmlBlock: Optional pre-formatted HTML block (e.g. invoice summary table).
includeBillingAdminLink: When True, append button + link to {frontendUrl}/billing/admin.
billingAdminButtonText: Label for the billing admin CTA button.
frontendUrl: Frontend base URL for the billing admin link (required when includeBillingAdminLink is True).
Returns:
Number of recipients that were successfully notified.
@ -257,7 +308,17 @@ def notifyMandateAdmins(
return 0
mandateName = resolveMandateName(mandateId)
htmlMessage = renderHtmlEmail(headline, bodyParagraphs, mandateName, footerNote, rawHtmlBlock)
actionHtmlBlock = None
if includeBillingAdminLink:
resolved_frontend = (frontendUrl or "").strip().rstrip("/")
actionHtmlBlock = buildBillingAdminActionBlock(resolved_frontend, billingAdminButtonText) or None
if not actionHtmlBlock:
logger.warning(
"notifyMandateAdmins: billing admin link omitted (no frontendUrl provided)"
)
htmlMessage = renderHtmlEmail(
headline, bodyParagraphs, mandateName, footerNote, rawHtmlBlock, actionHtmlBlock,
)
messaging = getMessagingInterface()
successCount = 0