diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index 331267b5..406e8d59 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -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) diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py index d8f2acc4..9e891c6c 100644 --- a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py +++ b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py @@ -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 diff --git a/modules/shared/notifyMandateAdmins.py b/modules/shared/notifyMandateAdmins.py index 6ac6fa53..25acd9a7 100644 --- a/modules/shared/notifyMandateAdmins.py +++ b/modules/shared/notifyMandateAdmins.py @@ -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'''
++ {escaped_url} +
''' + + +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'