feat: billing admin link in exhaust notification email + requestFrontendUrl middleware
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m1s
Deploy Plattform-Core (Int) / deploy (push) Successful in 10s

This commit is contained in:
Ida 2026-05-28 15:54:41 +02:00
parent 14714f3ee2
commit bf261d6566
6 changed files with 90 additions and 2 deletions

2
app.py
View file

@ -564,6 +564,7 @@ from modules.auth import (
# without having to thread them through every call site. # without having to thread them through every call site.
from modules.shared.i18nRegistry import setLanguage, normalizePrimaryLanguageTag from modules.shared.i18nRegistry import setLanguage, normalizePrimaryLanguageTag
from modules.shared.timeUtils import setRequestTimezone from modules.shared.timeUtils import setRequestTimezone
from modules.shared.requestFrontendUrl import resolveFrontendUrlFromRequest, setRequestFrontendUrl
@app.middleware("http") @app.middleware("http")
async def _requestContextMiddleware(request: Request, call_next): async def _requestContextMiddleware(request: Request, call_next):
@ -571,6 +572,7 @@ async def _requestContextMiddleware(request: Request, call_next):
lang = normalizePrimaryLanguageTag(acceptLang, "de") lang = normalizePrimaryLanguageTag(acceptLang, "de")
setLanguage(lang) setLanguage(lang)
setRequestTimezone(request.headers.get("X-User-Timezone", "")) setRequestTimezone(request.headers.get("X-User-Timezone", ""))
setRequestFrontendUrl(resolveFrontendUrlFromRequest(request))
return await call_next(request) return await call_next(request)
app.add_middleware(CSRFMiddleware) app.add_middleware(CSRFMiddleware)

View file

@ -4,7 +4,12 @@
APP_ENV_TYPE = dev APP_ENV_TYPE = dev
APP_ENV_LABEL = Development Instance Patrick APP_ENV_LABEL = Development Instance Patrick
APP_API_URL = http://localhost:8000 APP_API_URL = http://localhost:8000
<<<<<<< Updated upstream
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron-swiss/local/notes/key.txt 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_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9 APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9

View file

@ -4,6 +4,7 @@
APP_ENV_TYPE = int APP_ENV_TYPE = int
APP_ENV_LABEL = Integration Instance APP_ENV_LABEL = Integration Instance
APP_API_URL = https://api-int.poweron.swiss 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:// # 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_COOKIE_SECURE = true
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt

View file

@ -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_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://api.poweron.swiss APP_API_URL = https://api.poweron.swiss
APP_FRONTEND_URL = https://nyla.poweron.swiss
# PostgreSQL DB Host (porta-main-db on Infomaniak Public Cloud) # PostgreSQL DB Host (porta-main-db on Infomaniak Public Cloud)
DB_HOST=db.poweron.swiss DB_HOST=db.poweron.swiss

View file

@ -11,7 +11,7 @@ from __future__ import annotations
import logging import logging
import time import time
from typing import Dict from typing import Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,6 +27,7 @@ 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.
@ -38,6 +39,7 @@ 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 (defaults to request Origin).
""" """
if not mandateId: if not mandateId:
return return
@ -67,6 +69,9 @@ 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

View file

@ -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'''<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 _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]: def _getOperatorInfo() -> Dict[str, str]:
"""Load operator company data from config.ini.""" """Load operator company data from config.ini."""
try: try:
@ -147,11 +198,13 @@ 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)
@ -165,6 +218,10 @@ 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 = (
@ -199,6 +256,7 @@ 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>
@ -229,6 +287,9 @@ 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.
@ -240,6 +301,9 @@ 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 (like auth emails); defaults to per-request Origin / X-Frontend-Url.
Returns: Returns:
Number of recipients that were successfully notified. Number of recipients that were successfully notified.
@ -257,7 +321,17 @@ def notifyMandateAdmins(
return 0 return 0
mandateName = resolveMandateName(mandateId) 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() messaging = getMessagingInterface()
successCount = 0 successCount = 0