feat: billing admin link in exhaust notification email + requestFrontendUrl middleware
This commit is contained in:
parent
14714f3ee2
commit
bf261d6566
6 changed files with 90 additions and 2 deletions
2
app.py
2
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
"""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'<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 +256,7 @@ def renderHtmlEmail(
|
|||
<div style="font-size: 15px; line-height: 1.6;">
|
||||
{paragraphsHtml}
|
||||
{rawBlock}
|
||||
{actionBlock}
|
||||
</div>
|
||||
{footer}
|
||||
</td></tr>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue