From b04211bed40349b010ca65a89c17113b7aca3e6d Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 28 May 2026 10:57:30 +0200 Subject: [PATCH 1/3] =?UTF-8?q?bugfix=20PRM-04:=20Suchfunktion=20filtert?= =?UTF-8?q?=20zuerst,=20dann=20sortiert=20und=20dann=20paginiert,=20sonst?= =?UTF-8?q?=20fehlerhafte=20r=C3=BCckgaben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/routes/routeDataPrompts.py | 101 ++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 9 deletions(-) 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) From bf261d656629fd1ecee6d650321dbdfdd9752d66 Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 28 May 2026 15:54:41 +0200 Subject: [PATCH 2/3] feat: billing admin link in exhaust notification email + requestFrontendUrl middleware --- app.py | 2 + env-dev.env | 5 ++ env-int.env | 1 + env-prod.env | 1 + .../serviceBilling/billingExhaustedNotify.py | 7 +- modules/shared/notifyMandateAdmins.py | 76 ++++++++++++++++++- 6 files changed, 90 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index a69e9a7e..ac390317 100644 --- a/app.py +++ b/app.py @@ -564,6 +564,7 @@ from modules.auth import ( # without having to thread them through every call site. from modules.shared.i18nRegistry import setLanguage, normalizePrimaryLanguageTag from modules.shared.timeUtils import setRequestTimezone +from modules.shared.requestFrontendUrl import resolveFrontendUrlFromRequest, setRequestFrontendUrl @app.middleware("http") async def _requestContextMiddleware(request: Request, call_next): @@ -571,6 +572,7 @@ async def _requestContextMiddleware(request: Request, call_next): lang = normalizePrimaryLanguageTag(acceptLang, "de") setLanguage(lang) setRequestTimezone(request.headers.get("X-User-Timezone", "")) + setRequestFrontendUrl(resolveFrontendUrlFromRequest(request)) return await call_next(request) app.add_middleware(CSRFMiddleware) diff --git a/env-dev.env b/env-dev.env index 467f70b4..d8a1e9d5 100644 --- a/env-dev.env +++ b/env-dev.env @@ -4,7 +4,12 @@ APP_ENV_TYPE = dev APP_ENV_LABEL = Development Instance Patrick APP_API_URL = http://localhost:8000 +<<<<<<< Updated upstream APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron-swiss/local/notes/key.txt +======= +APP_FRONTEND_URL = http://localhost:5176 +APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt +>>>>>>> Stashed changes APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9 APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9 diff --git a/env-int.env b/env-int.env index 72099314..1ce618f9 100644 --- a/env-int.env +++ b/env-int.env @@ -4,6 +4,7 @@ APP_ENV_TYPE = int APP_ENV_LABEL = Integration Instance APP_API_URL = https://api-int.poweron.swiss +APP_FRONTEND_URL = https://nyla-int.poweron.swiss # Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https:// APP_COOKIE_SECURE = true APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt diff --git a/env-prod.env b/env-prod.env index d268450c..6f97cee8 100644 --- a/env-prod.env +++ b/env-prod.env @@ -7,6 +7,7 @@ APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09 APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 APP_API_URL = https://api.poweron.swiss +APP_FRONTEND_URL = https://nyla.poweron.swiss # PostgreSQL DB Host (porta-main-db on Infomaniak Public Cloud) DB_HOST=db.poweron.swiss diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py index d8f2acc4..b5ab9e53 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 (defaults to request Origin). """ 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..081ebdda 100644 --- a/modules/shared/notifyMandateAdmins.py +++ b/modules/shared/notifyMandateAdmins.py @@ -128,6 +128,57 @@ 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_label} + +
+

+ {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 _resolveBillingAdminFrontendUrl(frontendUrl: Optional[str]) -> str: + """Explicit frontendUrl param, else per-request value from middleware.""" + explicit = (frontendUrl or "").strip().rstrip("/") + if explicit: + return explicit + try: + from modules.shared.requestFrontendUrl import getRequestFrontendUrl + + return getRequestFrontendUrl() + except Exception: + return "" + + def _getOperatorInfo() -> Dict[str, str]: """Load operator company data from config.ini.""" try: @@ -147,11 +198,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 +218,10 @@ def renderHtmlEmail( if rawHtmlBlock: rawBlock = f'
{rawHtmlBlock}
\n' + actionBlock = "" + if actionHtmlBlock: + actionBlock = f'
{actionHtmlBlock}
\n' + footer = "" if footerNote: footer = ( @@ -199,6 +256,7 @@ def renderHtmlEmail(
{paragraphsHtml} {rawBlock} + {actionBlock}
{footer} @@ -229,6 +287,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 +301,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 (like auth emails); defaults to per-request Origin / X-Frontend-Url. Returns: Number of recipients that were successfully notified. @@ -257,7 +321,17 @@ def notifyMandateAdmins( return 0 mandateName = resolveMandateName(mandateId) - htmlMessage = renderHtmlEmail(headline, bodyParagraphs, mandateName, footerNote, rawHtmlBlock) + actionHtmlBlock = None + if includeBillingAdminLink: + resolved_frontend = _resolveBillingAdminFrontendUrl(frontendUrl) + actionHtmlBlock = buildBillingAdminActionBlock(resolved_frontend, billingAdminButtonText) or None + if not actionHtmlBlock: + logger.warning( + "notifyMandateAdmins: billing admin link omitted (no frontendUrl on request)" + ) + htmlMessage = renderHtmlEmail( + headline, bodyParagraphs, mandateName, footerNote, rawHtmlBlock, actionHtmlBlock, + ) messaging = getMessagingInterface() successCount = 0 From 3b0881b0ca238313f440bf2c45ade58f56812ee8 Mon Sep 17 00:00:00 2001 From: Ida Date: Fri, 29 May 2026 07:19:30 +0200 Subject: [PATCH 3/3] fix: crash upon startup --- app.py | 2 -- env-dev.env | 5 ----- env-int.env | 1 - env-prod.env | 1 - .../serviceBilling/billingExhaustedNotify.py | 2 +- modules/shared/notifyMandateAdmins.py | 19 +++---------------- 6 files changed, 4 insertions(+), 26 deletions(-) diff --git a/app.py b/app.py index ac390317..a69e9a7e 100644 --- a/app.py +++ b/app.py @@ -564,7 +564,6 @@ from modules.auth import ( # without having to thread them through every call site. from modules.shared.i18nRegistry import setLanguage, normalizePrimaryLanguageTag from modules.shared.timeUtils import setRequestTimezone -from modules.shared.requestFrontendUrl import resolveFrontendUrlFromRequest, setRequestFrontendUrl @app.middleware("http") async def _requestContextMiddleware(request: Request, call_next): @@ -572,7 +571,6 @@ async def _requestContextMiddleware(request: Request, call_next): lang = normalizePrimaryLanguageTag(acceptLang, "de") setLanguage(lang) setRequestTimezone(request.headers.get("X-User-Timezone", "")) - setRequestFrontendUrl(resolveFrontendUrlFromRequest(request)) return await call_next(request) app.add_middleware(CSRFMiddleware) diff --git a/env-dev.env b/env-dev.env index d8a1e9d5..467f70b4 100644 --- a/env-dev.env +++ b/env-dev.env @@ -4,12 +4,7 @@ APP_ENV_TYPE = dev APP_ENV_LABEL = Development Instance Patrick APP_API_URL = http://localhost:8000 -<<<<<<< Updated upstream APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron-swiss/local/notes/key.txt -======= -APP_FRONTEND_URL = http://localhost:5176 -APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt ->>>>>>> Stashed changes APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9 APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9 diff --git a/env-int.env b/env-int.env index 1ce618f9..72099314 100644 --- a/env-int.env +++ b/env-int.env @@ -4,7 +4,6 @@ APP_ENV_TYPE = int APP_ENV_LABEL = Integration Instance APP_API_URL = https://api-int.poweron.swiss -APP_FRONTEND_URL = https://nyla-int.poweron.swiss # Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https:// APP_COOKIE_SECURE = true APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt diff --git a/env-prod.env b/env-prod.env index 6f97cee8..d268450c 100644 --- a/env-prod.env +++ b/env-prod.env @@ -7,7 +7,6 @@ APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09 APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 APP_API_URL = https://api.poweron.swiss -APP_FRONTEND_URL = https://nyla.poweron.swiss # PostgreSQL DB Host (porta-main-db on Infomaniak Public Cloud) DB_HOST=db.poweron.swiss diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py index b5ab9e53..9e891c6c 100644 --- a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py +++ b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py @@ -39,7 +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 (defaults to request Origin). + frontendUrl: Optional frontend base URL for /billing/admin link (omit to send email without CTA). """ if not mandateId: return diff --git a/modules/shared/notifyMandateAdmins.py b/modules/shared/notifyMandateAdmins.py index 081ebdda..25acd9a7 100644 --- a/modules/shared/notifyMandateAdmins.py +++ b/modules/shared/notifyMandateAdmins.py @@ -166,19 +166,6 @@ def buildBillingAdminActionBlock( return buildActionLinkHtmlBlock(admin_url, buttonText) -def _resolveBillingAdminFrontendUrl(frontendUrl: Optional[str]) -> str: - """Explicit frontendUrl param, else per-request value from middleware.""" - explicit = (frontendUrl or "").strip().rstrip("/") - if explicit: - return explicit - try: - from modules.shared.requestFrontendUrl import getRequestFrontendUrl - - return getRequestFrontendUrl() - except Exception: - return "" - - def _getOperatorInfo() -> Dict[str, str]: """Load operator company data from config.ini.""" try: @@ -303,7 +290,7 @@ def notifyMandateAdmins( 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 (like auth emails); defaults to per-request Origin / X-Frontend-Url. + frontendUrl: Frontend base URL for the billing admin link (required when includeBillingAdminLink is True). Returns: Number of recipients that were successfully notified. @@ -323,11 +310,11 @@ def notifyMandateAdmins( mandateName = resolveMandateName(mandateId) actionHtmlBlock = None if includeBillingAdminLink: - resolved_frontend = _resolveBillingAdminFrontendUrl(frontendUrl) + 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 on request)" + "notifyMandateAdmins: billing admin link omitted (no frontendUrl provided)" ) htmlMessage = renderHtmlEmail( headline, bodyParagraphs, mandateName, footerNote, rawHtmlBlock, actionHtmlBlock,