Compare commits
No commits in common. "a7b5192e25fb8cfd166728c6ccdbba1308f86fb7" and "92a4c27afe02edc12dd1a0ea867fd8b29100187e" have entirely different histories.
a7b5192e25
...
92a4c27afe
3 changed files with 11 additions and 160 deletions
|
|
@ -1,13 +1,12 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from copy import deepcopy
|
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getCurrentUser
|
||||||
|
|
@ -75,46 +74,6 @@ def get_prompts(
|
||||||
paginationParams = applyViewToParams(paginationParams, viewConfig)
|
paginationParams = applyViewToParams(paginationParams, viewConfig)
|
||||||
groupByLevels = effective_group_by_levels(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):
|
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]
|
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)
|
enrichRowsWithFkLabels(dicts, Prompt)
|
||||||
|
|
@ -156,57 +115,21 @@ def get_prompts(
|
||||||
|
|
||||||
if not groupByLevels:
|
if not groupByLevels:
|
||||||
# No grouping: let DB handle pagination directly
|
# No grouping: let DB handle pagination directly
|
||||||
promptSearchValue = None
|
result = managementInterface.getAllPrompts(pagination=paginationParams)
|
||||||
remainingFilters = paginationParams.filters if paginationParams else None
|
if paginationParams and hasattr(result, 'items'):
|
||||||
if paginationParams and paginationParams.filters:
|
response: dict = {
|
||||||
promptSearchValue, remainingFilters = _extractSearchAndRemoveFromFilters(paginationParams.filters)
|
"items": _promptsToEnrichedDicts(result.items),
|
||||||
|
|
||||||
# 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(
|
"pagination": PaginationMetadata(
|
||||||
currentPage=paginationParams.page,
|
currentPage=paginationParams.page,
|
||||||
pageSize=paginationParams.pageSize,
|
pageSize=paginationParams.pageSize,
|
||||||
totalItems=totalItems,
|
totalItems=result.totalItems,
|
||||||
totalPages=totalPages,
|
totalPages=result.totalPages,
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
result = managementInterface.getAllPrompts(pagination=paginationParams)
|
response = {"items": _promptsToEnrichedDicts(result if isinstance(result, list) else [result]), "pagination": None}
|
||||||
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:
|
if viewMeta:
|
||||||
response["appliedView"] = viewMeta.model_dump()
|
response["appliedView"] = viewMeta.model_dump()
|
||||||
return response
|
return response
|
||||||
|
|
@ -225,14 +148,8 @@ def get_prompts(
|
||||||
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
comp = ComponentObjects()
|
comp = ComponentObjects()
|
||||||
comp.setUserContext(currentUser)
|
comp.setUserContext(currentUser)
|
||||||
filtersForGenericApply = paginationParams.filters
|
|
||||||
promptSearchValue = None
|
|
||||||
if paginationParams.filters:
|
if paginationParams.filters:
|
||||||
promptSearchValue, filtersForGenericApply = _extractSearchAndRemoveFromFilters(paginationParams.filters)
|
allItems = comp._applyFilters(allItems, paginationParams.filters)
|
||||||
if promptSearchValue is not None:
|
|
||||||
allItems = _applyPromptSearchOnVisibleFields(allItems, promptSearchValue)
|
|
||||||
if filtersForGenericApply:
|
|
||||||
allItems = comp._applyFilters(allItems, filtersForGenericApply)
|
|
||||||
if paginationParams.sort:
|
if paginationParams.sort:
|
||||||
allItems = comp._applySorting(allItems, paginationParams.sort)
|
allItems = comp._applySorting(allItems, paginationParams.sort)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Optional
|
from typing import Dict
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -27,7 +27,6 @@ def maybeEmailMandatePoolExhausted(
|
||||||
currentBalance: float,
|
currentBalance: float,
|
||||||
requiredAmount: float,
|
requiredAmount: float,
|
||||||
cooldownSec: float = _DEFAULT_COOLDOWN_SEC,
|
cooldownSec: float = _DEFAULT_COOLDOWN_SEC,
|
||||||
frontendUrl: Optional[str] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Send one notification per mandate per cooldown window when the pool is exhausted.
|
Send one notification per mandate per cooldown window when the pool is exhausted.
|
||||||
|
|
@ -39,7 +38,6 @@ def maybeEmailMandatePoolExhausted(
|
||||||
currentBalance: Pool balance (CHF).
|
currentBalance: Pool balance (CHF).
|
||||||
requiredAmount: Minimum required (CHF).
|
requiredAmount: Minimum required (CHF).
|
||||||
cooldownSec: Minimum seconds between emails for this mandate.
|
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:
|
if not mandateId:
|
||||||
return
|
return
|
||||||
|
|
@ -69,9 +67,6 @@ def maybeEmailMandatePoolExhausted(
|
||||||
"Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, "
|
"Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, "
|
||||||
"damit Benutzer wieder AI-Funktionen nutzen können.",
|
"damit Benutzer wieder AI-Funktionen nutzen können.",
|
||||||
],
|
],
|
||||||
includeBillingAdminLink=True,
|
|
||||||
billingAdminButtonText="Guthaben aufladen",
|
|
||||||
frontendUrl=frontendUrl,
|
|
||||||
)
|
)
|
||||||
if sent > 0:
|
if sent > 0:
|
||||||
_poolExhaustedEmailLastSent[mandateId] = now
|
_poolExhaustedEmailLastSent[mandateId] = now
|
||||||
|
|
|
||||||
|
|
@ -128,44 +128,6 @@ 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]:
|
def _getOperatorInfo() -> Dict[str, str]:
|
||||||
"""Load operator company data from config.ini."""
|
"""Load operator company data from config.ini."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -185,13 +147,11 @@ def renderHtmlEmail(
|
||||||
mandateName: str,
|
mandateName: str,
|
||||||
footerNote: Optional[str] = None,
|
footerNote: Optional[str] = None,
|
||||||
rawHtmlBlock: Optional[str] = None,
|
rawHtmlBlock: Optional[str] = None,
|
||||||
actionHtmlBlock: Optional[str] = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a clean, professional HTML notification email.
|
"""Render a clean, professional HTML notification email.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
rawHtmlBlock: Optional pre-formatted HTML inserted after bodyParagraphs (e.g. invoice table).
|
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)
|
hl = html.escape(headline)
|
||||||
mn = html.escape(mandateName)
|
mn = html.escape(mandateName)
|
||||||
|
|
@ -205,10 +165,6 @@ def renderHtmlEmail(
|
||||||
if rawHtmlBlock:
|
if rawHtmlBlock:
|
||||||
rawBlock = f'<div style="margin: 16px 0;">{rawHtmlBlock}</div>\n'
|
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 = ""
|
footer = ""
|
||||||
if footerNote:
|
if footerNote:
|
||||||
footer = (
|
footer = (
|
||||||
|
|
@ -243,7 +199,6 @@ def renderHtmlEmail(
|
||||||
<div style="font-size: 15px; line-height: 1.6;">
|
<div style="font-size: 15px; line-height: 1.6;">
|
||||||
{paragraphsHtml}
|
{paragraphsHtml}
|
||||||
{rawBlock}
|
{rawBlock}
|
||||||
{actionBlock}
|
|
||||||
</div>
|
</div>
|
||||||
{footer}
|
{footer}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
@ -274,9 +229,6 @@ def notifyMandateAdmins(
|
||||||
*,
|
*,
|
||||||
footerNote: Optional[str] = None,
|
footerNote: Optional[str] = None,
|
||||||
rawHtmlBlock: Optional[str] = None,
|
rawHtmlBlock: Optional[str] = None,
|
||||||
includeBillingAdminLink: bool = False,
|
|
||||||
billingAdminButtonText: str = "Billing-Verwaltung öffnen",
|
|
||||||
frontendUrl: Optional[str] = None,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Send a styled HTML notification to all mandate admins.
|
Send a styled HTML notification to all mandate admins.
|
||||||
|
|
@ -288,9 +240,6 @@ def notifyMandateAdmins(
|
||||||
bodyParagraphs: List of paragraph strings (plain text, auto-escaped).
|
bodyParagraphs: List of paragraph strings (plain text, auto-escaped).
|
||||||
footerNote: Optional small-print note below the main content.
|
footerNote: Optional small-print note below the main content.
|
||||||
rawHtmlBlock: Optional pre-formatted HTML block (e.g. invoice summary table).
|
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:
|
Returns:
|
||||||
Number of recipients that were successfully notified.
|
Number of recipients that were successfully notified.
|
||||||
|
|
@ -308,17 +257,7 @@ def notifyMandateAdmins(
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
mandateName = resolveMandateName(mandateId)
|
mandateName = resolveMandateName(mandateId)
|
||||||
actionHtmlBlock = None
|
htmlMessage = renderHtmlEmail(headline, bodyParagraphs, mandateName, footerNote, rawHtmlBlock)
|
||||||
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()
|
messaging = getMessagingInterface()
|
||||||
successCount = 0
|
successCount = 0
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue