Merge pull request #158 from valueonag/feat/demo-system-readieness
abo enterprise, ai agent fixes
This commit is contained in:
commit
fac191fc77
20 changed files with 902 additions and 161 deletions
4
app.py
4
app.py
|
|
@ -396,6 +396,10 @@ async def lifespan(app: FastAPI):
|
|||
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
||||
registerAuditLogCleanupScheduler()
|
||||
|
||||
# Register enterprise subscription auto-renewal scheduler
|
||||
from modules.serviceCenter.services.serviceSubscription.enterpriseRenewalScheduler import registerEnterpriseRenewalScheduler
|
||||
registerEnterpriseRenewalScheduler()
|
||||
|
||||
# Recover background jobs that were RUNNING when the previous worker died
|
||||
try:
|
||||
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (
|
||||
|
|
|
|||
|
|
@ -272,7 +272,9 @@ class ModelSelector:
|
|||
return 1.0
|
||||
|
||||
elif requestedPriority == PriorityEnum.SPEED:
|
||||
return model.speedRating / 10.0
|
||||
# Scale to same magnitude as operation type (x1000) so speed
|
||||
# can meaningfully influence model ranking across tiers.
|
||||
return model.speedRating * 100.0
|
||||
|
||||
elif requestedPriority == PriorityEnum.QUALITY:
|
||||
return model.qualityRating / 10.0
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ StripePlanPrice (persisted Stripe IDs per plan).
|
|||
State Machine: see wiki/concepts/Subscription-State-Machine.md
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from enum import Enum
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -284,12 +284,63 @@ class MandateSubscription(PowerOnModel):
|
|||
json_schema_extra={"label": "Stripe-Item (Instanzen)"},
|
||||
)
|
||||
|
||||
# Enterprise subscription fields (custom limits, no Stripe billing)
|
||||
isEnterprise: bool = Field(
|
||||
default=False,
|
||||
description="True for enterprise subscriptions managed by sysadmin with flat pricing",
|
||||
json_schema_extra={"label": "Enterprise-Abo"},
|
||||
)
|
||||
enterpriseFlatPriceCHF: Optional[float] = Field(
|
||||
None,
|
||||
description="Flat price per period (CHF) for enterprise subscriptions",
|
||||
json_schema_extra={"label": "Pauschale (CHF)"},
|
||||
)
|
||||
enterpriseMaxUsers: Optional[int] = Field(
|
||||
None,
|
||||
description="Custom user limit for enterprise (None = unlimited)",
|
||||
json_schema_extra={"label": "Enterprise Max. Benutzer"},
|
||||
)
|
||||
enterpriseMaxFeatureInstances: Optional[int] = Field(
|
||||
None,
|
||||
description="Custom feature instance limit for enterprise (None = unlimited)",
|
||||
json_schema_extra={"label": "Enterprise Max. Module"},
|
||||
)
|
||||
enterpriseMaxDataVolumeMB: Optional[int] = Field(
|
||||
None,
|
||||
description="Custom storage limit in MB for enterprise (None = unlimited)",
|
||||
json_schema_extra={"label": "Enterprise Datenvolumen (MB)"},
|
||||
)
|
||||
enterpriseBudgetAiCHF: Optional[float] = Field(
|
||||
None,
|
||||
description="Fixed AI budget per period (CHF) for enterprise subscriptions",
|
||||
json_schema_extra={"label": "Enterprise AI-Budget (CHF)"},
|
||||
)
|
||||
enterpriseNote: Optional[str] = Field(
|
||||
None,
|
||||
description="Free-text note (e.g. contract reference) for enterprise subscriptions",
|
||||
json_schema_extra={"label": "Enterprise Notiz"},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Built-in plan catalog (static, no env dependency)
|
||||
# ============================================================================
|
||||
|
||||
BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
||||
"ENTERPRISE": SubscriptionPlan(
|
||||
planKey="ENTERPRISE",
|
||||
selectableByUser=False,
|
||||
title=t("Enterprise"),
|
||||
description=t("Individuelles Pauschalen-Abonnement — Limiten und Preis vom Sysadmin festgelegt."),
|
||||
billingPeriod=BillingPeriodEnum.NONE,
|
||||
autoRenew=False,
|
||||
maxUsers=None,
|
||||
maxFeatureInstances=None,
|
||||
includedModules=0,
|
||||
maxDataVolumeMB=None,
|
||||
budgetAiCHF=0.0,
|
||||
budgetAiPerUserCHF=0.0,
|
||||
),
|
||||
"ROOT": SubscriptionPlan(
|
||||
planKey="ROOT",
|
||||
selectableByUser=False,
|
||||
|
|
@ -415,3 +466,35 @@ def getPlan(planKey: str) -> Optional[SubscriptionPlan]:
|
|||
def _getSelectablePlans() -> List[SubscriptionPlan]:
|
||||
"""Return plans that users can choose in the UI."""
|
||||
return [p for p in BUILTIN_PLANS.values() if p.selectableByUser]
|
||||
|
||||
|
||||
def getEffectiveLimits(sub: Dict[str, Any], plan: Optional[SubscriptionPlan] = None) -> Dict[str, Any]:
|
||||
"""Resolve effective limits for a subscription.
|
||||
|
||||
For enterprise subscriptions the custom enterprise* fields on the subscription
|
||||
record take precedence. For standard subscriptions the plan catalog values are
|
||||
returned. Falls back to unlimited (None / 0) when neither source provides a
|
||||
value."""
|
||||
if sub.get("isEnterprise"):
|
||||
return {
|
||||
"maxUsers": sub.get("enterpriseMaxUsers"),
|
||||
"maxFeatureInstances": sub.get("enterpriseMaxFeatureInstances"),
|
||||
"maxDataVolumeMB": sub.get("enterpriseMaxDataVolumeMB"),
|
||||
"budgetAiCHF": sub.get("enterpriseBudgetAiCHF") or 0.0,
|
||||
"includedModules": sub.get("enterpriseMaxFeatureInstances") or 0,
|
||||
}
|
||||
if plan:
|
||||
return {
|
||||
"maxUsers": plan.maxUsers,
|
||||
"maxFeatureInstances": plan.maxFeatureInstances,
|
||||
"maxDataVolumeMB": plan.maxDataVolumeMB,
|
||||
"budgetAiCHF": plan.budgetAiCHF,
|
||||
"includedModules": plan.includedModules,
|
||||
}
|
||||
return {
|
||||
"maxUsers": None,
|
||||
"maxFeatureInstances": None,
|
||||
"maxDataVolumeMB": None,
|
||||
"budgetAiCHF": 0.0,
|
||||
"includedModules": 0,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,11 +37,6 @@ UI_OBJECTS = [
|
|||
"label": t("Session", context="UI"),
|
||||
"meta": {"area": "session"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.commcoach.dossier",
|
||||
"label": t("Dossier", context="UI"),
|
||||
"meta": {"area": "dossier"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.commcoach.settings",
|
||||
"label": t("Einstellungen", context="UI"),
|
||||
|
|
@ -199,7 +194,6 @@ TEMPLATE_ROLES = [
|
|||
{"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.commcoach.modules", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.commcoach.session", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
|
||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||
{"context": "RESOURCE", "item": None, "view": False},
|
||||
|
|
@ -213,7 +207,6 @@ TEMPLATE_ROLES = [
|
|||
{"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.commcoach.modules", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.commcoach.session", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
|
||||
{"context": "DATA", "item": "data.feature.commcoach.TrainingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
||||
{"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||
|
|
|
|||
|
|
@ -690,7 +690,7 @@ def _buildConversationHistory(messages: List[Dict[str, Any]]) -> List[Dict[str,
|
|||
return history
|
||||
|
||||
|
||||
_TTS_WORD_LIMIT = 200
|
||||
_TTS_WORD_LIMIT = 80
|
||||
|
||||
|
||||
async def _prepareSpeechText(fullText: str, callAiFn) -> str:
|
||||
|
|
@ -906,10 +906,14 @@ class CommcoachService:
|
|||
)
|
||||
agentService = getService("agent", serviceContext)
|
||||
|
||||
from modules.datamodels.datamodelAi import PriorityEnum, OperationTypeEnum
|
||||
config = AgentConfig(
|
||||
toolSet="commcoach" if useTools else "none",
|
||||
maxRounds=3 if useTools else 1,
|
||||
temperature=0.4,
|
||||
excludeAllTools=not useTools,
|
||||
priority=PriorityEnum.SPEED if not useTools else None,
|
||||
operationType=OperationTypeEnum.DATA_QUERY if not useTools else None,
|
||||
)
|
||||
|
||||
buildRagContextFn = _createCommcoachRagFn(
|
||||
|
|
@ -989,10 +993,14 @@ class CommcoachService:
|
|||
)
|
||||
|
||||
isFirstSession = not previousSessionSummaries or len(previousSessionSummaries) == 0
|
||||
logger.info(f"Session opening {sessionId}: isFirstSession={isFirstSession}, previousSessions={len(previousSessionSummaries) if previousSessionSummaries else 0}, persona={persona.get('key') if persona else None}")
|
||||
|
||||
if persona and persona.get("key") != "coach":
|
||||
personaLabel = persona.get("label", "Gesprächspartner")
|
||||
openingUserPrompt = f"Beginne das Gespräch in deiner Rolle als {personaLabel}. Stelle dich kurz vor und eröffne die Situation gemäss deiner Rollenbeschreibung."
|
||||
if isFirstSession:
|
||||
openingUserPrompt = f"Beginne das Gespräch in deiner Rolle als {personaLabel}. Stelle dich kurz vor und eröffne die Situation gemäss deiner Rollenbeschreibung."
|
||||
else:
|
||||
openingUserPrompt = f"Du bist weiterhin in deiner Rolle als {personaLabel}. Der Benutzer kehrt zu einem Folgegespräch zurück. Begrüsse ihn kurz zurück, beziehe dich auf das letzte Gespräch (siehe bisherige Sessions) und knüpfe dort an. Stelle dich NICHT erneut vor."
|
||||
elif isFirstSession:
|
||||
openingUserPrompt = "Dies ist die ERSTE Session zu diesem Thema. Begrüsse den Benutzer, stelle das Thema kurz vor und stelle eine offene Einstiegsfrage. Erfinde KEINE vorherigen Gespräche oder Zusammenfassungen."
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -908,18 +908,22 @@ class BillingObjects:
|
|||
)
|
||||
|
||||
def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]:
|
||||
"""Debit prepay pool for new storage overage using period high-watermark (no credit on delete)."""
|
||||
"""Debit prepay pool for new storage overage using period high-watermark (no credit on delete).
|
||||
Skipped for enterprise subscriptions (hard-block via assertCapacity instead)."""
|
||||
settings = self.getSettings(mandateId)
|
||||
if not settings:
|
||||
return None
|
||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
||||
from modules.datamodels.datamodelSubscription import getPlan
|
||||
from modules.datamodels.datamodelSubscription import getPlan, getEffectiveLimits
|
||||
|
||||
subIface = _getSubRoot()
|
||||
usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
|
||||
sub = subIface.getOperativeForMandate(mandateId)
|
||||
if sub and sub.get("isEnterprise"):
|
||||
return None
|
||||
plan = getPlan(sub.get("planKey", "")) if sub else None
|
||||
includedMB = plan.maxDataVolumeMB if plan and plan.maxDataVolumeMB is not None else None
|
||||
limits = getEffectiveLimits(sub, plan) if sub else {}
|
||||
includedMB = limits.get("maxDataVolumeMB")
|
||||
if includedMB is None:
|
||||
return None
|
||||
|
||||
|
|
@ -966,27 +970,37 @@ class BillingObjects:
|
|||
# Subscription AI-Budget Credit
|
||||
# =========================================================================
|
||||
|
||||
def creditSubscriptionBudget(self, mandateId: str, planKey: str, periodLabel: str = "") -> Optional[Dict[str, Any]]:
|
||||
def creditSubscriptionBudget(
|
||||
self, mandateId: str, planKey: str, periodLabel: str = "",
|
||||
enterpriseBudgetOverride: Optional[float] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Credit AI budget to the mandate pool account.
|
||||
|
||||
Amount = budgetAiPerUserCHF * activeUsers (dynamic, not the static plan.budgetAiCHF).
|
||||
For standard plans: amount = budgetAiPerUserCHF * activeUsers.
|
||||
For enterprise: uses the fixed ``enterpriseBudgetOverride`` amount.
|
||||
Should be called once per billing period (initial activation + each invoice.paid).
|
||||
Returns the created CREDIT transaction or None if budget is 0."""
|
||||
from modules.datamodels.datamodelSubscription import getPlan
|
||||
if enterpriseBudgetOverride is not None and enterpriseBudgetOverride > 0:
|
||||
amount = enterpriseBudgetOverride
|
||||
description = f"AI-Budget Enterprise ({planKey})"
|
||||
if periodLabel:
|
||||
description += f" – {periodLabel}"
|
||||
else:
|
||||
from modules.datamodels.datamodelSubscription import getPlan
|
||||
|
||||
plan = getPlan(planKey)
|
||||
if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0:
|
||||
return None
|
||||
plan = getPlan(planKey)
|
||||
if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0:
|
||||
return None
|
||||
|
||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
||||
subRoot = _getSubRoot()
|
||||
activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
|
||||
amount = plan.budgetAiPerUserCHF * activeUsers
|
||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
||||
subRoot = _getSubRoot()
|
||||
activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
|
||||
amount = plan.budgetAiPerUserCHF * activeUsers
|
||||
description = f"AI-Budget ({planKey}, {activeUsers} User)"
|
||||
if periodLabel:
|
||||
description += f" – {periodLabel}"
|
||||
|
||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||
description = f"AI-Budget ({planKey}, {activeUsers} User)"
|
||||
if periodLabel:
|
||||
description += f" – {periodLabel}"
|
||||
|
||||
transaction = BillingTransaction(
|
||||
accountId=poolAccount["id"],
|
||||
|
|
@ -998,8 +1012,8 @@ class BillingObjects:
|
|||
)
|
||||
created = self.createTransaction(transaction)
|
||||
logger.info(
|
||||
"AI-Budget credited mandate=%s plan=%s users=%d amount=%.2f CHF",
|
||||
mandateId, planKey, activeUsers, amount,
|
||||
"AI-Budget credited mandate=%s plan=%s amount=%.2f CHF",
|
||||
mandateId, planKey, amount,
|
||||
)
|
||||
return created
|
||||
|
||||
|
|
@ -1027,7 +1041,8 @@ class BillingObjects:
|
|||
|
||||
delta > 0: user added -> CREDIT pro-rata portion
|
||||
delta < 0: user removed -> DEBIT pro-rata portion
|
||||
"""
|
||||
|
||||
Skipped for enterprise subscriptions (fixed budget, no pro-rata)."""
|
||||
from modules.datamodels.datamodelSubscription import getPlan
|
||||
|
||||
plan = getPlan(planKey)
|
||||
|
|
@ -1039,6 +1054,8 @@ class BillingObjects:
|
|||
operative = subRoot.getOperativeForMandate(mandateId)
|
||||
if not operative:
|
||||
return None
|
||||
if operative.get("isEnterprise"):
|
||||
return None
|
||||
|
||||
periodStart = operative.get("currentPeriodStart")
|
||||
periodEnd = operative.get("currentPeriodEnd")
|
||||
|
|
|
|||
|
|
@ -37,8 +37,6 @@ logger = logging.getLogger(__name__)
|
|||
managementDatabase = "poweron_management"
|
||||
registerDatabase(managementDatabase)
|
||||
|
||||
# Singleton factory for Management instances with AI service per context
|
||||
_instancesManagement = {}
|
||||
|
||||
# Custom exceptions for file handling
|
||||
class FileError(Exception):
|
||||
|
|
@ -124,14 +122,12 @@ class ComponentObjects:
|
|||
return None
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup method to close database connection."""
|
||||
"""Release the database connector reference (shared connectors stay open)."""
|
||||
if hasattr(self, 'db') and self.db is not None:
|
||||
try:
|
||||
self.db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing database connection: {e}")
|
||||
|
||||
logger.debug(f"User context set: userId={self.userId}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _initializeDatabase(self):
|
||||
"""Initializes the database connection directly."""
|
||||
|
|
@ -2273,10 +2269,11 @@ class ComponentObjects:
|
|||
|
||||
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects':
|
||||
"""
|
||||
Returns a ComponentObjects instance.
|
||||
If currentUser is provided, initializes with user context.
|
||||
Otherwise, returns an instance with only database access.
|
||||
|
||||
Returns a ComponentObjects instance scoped to the given user/mandate/featureInstance.
|
||||
|
||||
Each call creates a lightweight instance whose DB connector is already
|
||||
cached inside ``getCachedConnector``, so the overhead is minimal.
|
||||
|
||||
Args:
|
||||
currentUser: The authenticated user
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
|
||||
|
|
@ -2284,16 +2281,12 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] =
|
|||
"""
|
||||
effectiveMandateId = str(mandateId) if mandateId else None
|
||||
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
|
||||
|
||||
# Create new instance if not exists
|
||||
if "default" not in _instancesManagement:
|
||||
_instancesManagement["default"] = ComponentObjects()
|
||||
|
||||
interface = _instancesManagement["default"]
|
||||
|
||||
|
||||
interface = ComponentObjects()
|
||||
|
||||
if currentUser:
|
||||
interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
||||
else:
|
||||
logger.info("Returning interface without user context")
|
||||
|
||||
|
||||
return interface
|
||||
|
|
@ -27,6 +27,7 @@ from modules.datamodels.datamodelSubscription import (
|
|||
BUILTIN_PLANS,
|
||||
getPlan as getPlanFromCatalog,
|
||||
_getSelectablePlans,
|
||||
getEffectiveLimits,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -276,33 +277,42 @@ class SubscriptionObjects:
|
|||
)
|
||||
|
||||
plan = self.getPlan(sub.get("planKey", ""))
|
||||
if not plan:
|
||||
return True
|
||||
limits = getEffectiveLimits(sub, plan)
|
||||
isEnterprise = sub.get("isEnterprise", False)
|
||||
|
||||
if resourceType == "users":
|
||||
cap = plan.maxUsers
|
||||
cap = limits["maxUsers"]
|
||||
if cap is None:
|
||||
return True
|
||||
current = self.countActiveUsers(mandateId)
|
||||
if current + delta > cap:
|
||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
||||
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap)
|
||||
raise SubscriptionCapacityException(
|
||||
resourceType=resourceType, currentCount=current, maxAllowed=cap,
|
||||
isEnterprise=isEnterprise,
|
||||
)
|
||||
elif resourceType == "featureInstances":
|
||||
cap = plan.maxFeatureInstances
|
||||
cap = limits["maxFeatureInstances"]
|
||||
if cap is None:
|
||||
return True
|
||||
current = self.countActiveFeatureInstances(mandateId)
|
||||
if current + delta > cap:
|
||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
||||
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap)
|
||||
raise SubscriptionCapacityException(
|
||||
resourceType=resourceType, currentCount=current, maxAllowed=cap,
|
||||
isEnterprise=isEnterprise,
|
||||
)
|
||||
elif resourceType == "dataVolumeMB":
|
||||
cap = plan.maxDataVolumeMB
|
||||
cap = limits["maxDataVolumeMB"]
|
||||
if cap is None:
|
||||
return True
|
||||
currentMB = self.getMandateDataVolumeMB(mandateId)
|
||||
if currentMB + delta > cap:
|
||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
||||
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap)
|
||||
raise SubscriptionCapacityException(
|
||||
resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap,
|
||||
isEnterprise=isEnterprise,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
|
@ -325,10 +335,11 @@ class SubscriptionObjects:
|
|||
if not sub:
|
||||
return None
|
||||
plan = self.getPlan(sub.get("planKey", ""))
|
||||
if not plan or not plan.maxDataVolumeMB:
|
||||
limits = getEffectiveLimits(sub, plan)
|
||||
limitMB = limits["maxDataVolumeMB"]
|
||||
if not limitMB:
|
||||
return None
|
||||
usedMB = self.getMandateDataVolumeMB(mandateId)
|
||||
limitMB = plan.maxDataVolumeMB
|
||||
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
|
||||
if percent >= 80:
|
||||
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True}
|
||||
|
|
|
|||
|
|
@ -106,13 +106,14 @@ class SubscriptionStatusResponse(BaseModel):
|
|||
usage: Optional[SubscriptionUsage] = None
|
||||
|
||||
|
||||
def _computeUsage(mandateId: str, plan) -> SubscriptionUsage:
|
||||
def _computeUsage(mandateId: str, plan, operative: Optional[Dict[str, Any]] = None) -> SubscriptionUsage:
|
||||
"""Compute current usage metrics for a mandate's subscription."""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||
from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
|
||||
from modules.datamodels.datamodelSubscription import getEffectiveLimits
|
||||
|
||||
rootIf = getRootInterface()
|
||||
|
||||
|
|
@ -128,7 +129,8 @@ def _computeUsage(mandateId: str, plan) -> SubscriptionUsage:
|
|||
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
||||
usedMB = round(ragBytes / (1024 * 1024), 2)
|
||||
|
||||
maxMB = plan.maxDataVolumeMB if plan else None
|
||||
limits = getEffectiveLimits(operative, plan) if operative else {}
|
||||
maxMB = limits.get("maxDataVolumeMB") if limits else (plan.maxDataVolumeMB if plan else None)
|
||||
storagePercent = round((usedMB / maxMB) * 100, 1) if maxMB else None
|
||||
|
||||
return SubscriptionUsage(
|
||||
|
|
@ -207,7 +209,7 @@ def getStatus(request: Request, context: RequestContext = Depends(getRequestCont
|
|||
|
||||
plan = subService.getPlan(operative.get("planKey", ""))
|
||||
|
||||
usage = _computeUsage(mandateId, plan)
|
||||
usage = _computeUsage(mandateId, plan, operative)
|
||||
|
||||
return SubscriptionStatusResponse(
|
||||
active=True,
|
||||
|
|
@ -451,13 +453,16 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
|
|||
sub["planTitle"] = resolveText(plan.title) if plan else planKey
|
||||
|
||||
if sub.get("status") in operativeValues:
|
||||
userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0
|
||||
instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0
|
||||
userCount = userCountMap.get(mid, 0)
|
||||
instanceCount = instanceCountMap.get(mid, 0)
|
||||
includedModules = plan.includedModules if plan else 0
|
||||
billableModules = max(0, instanceCount - includedModules)
|
||||
sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2)
|
||||
if sub.get("isEnterprise"):
|
||||
sub["monthlyRevenueCHF"] = round(sub.get("enterpriseFlatPriceCHF") or 0, 2)
|
||||
else:
|
||||
userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0
|
||||
instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0
|
||||
includedModules = plan.includedModules if plan else 0
|
||||
billableModules = max(0, instanceCount - includedModules)
|
||||
sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2)
|
||||
sub["activeUsers"] = userCount
|
||||
sub["activeInstances"] = instanceCount
|
||||
else:
|
||||
|
|
@ -570,13 +575,16 @@ def _getDataVolumeUsage(
|
|||
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
||||
ragMB = round(ragBytes / (1024 * 1024), 2)
|
||||
|
||||
from modules.datamodels.datamodelSubscription import getEffectiveLimits
|
||||
|
||||
maxMB = None
|
||||
subIf = _getSubRootIf()
|
||||
operative = subIf.getOperativeForMandate(mandateId)
|
||||
if operative:
|
||||
plan = subIf.getPlan(operative.get("planKey") or "")
|
||||
if plan and plan.maxDataVolumeMB is not None:
|
||||
maxMB = int(plan.maxDataVolumeMB)
|
||||
limits = getEffectiveLimits(operative, plan)
|
||||
if limits["maxDataVolumeMB"] is not None:
|
||||
maxMB = int(limits["maxDataVolumeMB"])
|
||||
|
||||
usedMB = ragMB
|
||||
percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
|
||||
|
|
@ -593,3 +601,147 @@ def _getDataVolumeUsage(
|
|||
"percentUsed": percentUsed,
|
||||
"warning": (percentUsed or 0) >= 80,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enterprise Subscription (SysAdmin-only)
|
||||
# =============================================================================
|
||||
|
||||
class EnterpriseCreateRequest(BaseModel):
|
||||
mandateId: str = Field(..., description="Target mandate ID")
|
||||
startDate: float = Field(..., description="Period start (UTC unix timestamp)")
|
||||
endDate: float = Field(..., description="Period end (UTC unix timestamp)")
|
||||
autoRenew: bool = Field(default=False, description="Auto-renew at period end")
|
||||
flatPriceCHF: float = Field(..., description="Flat price per period (CHF)")
|
||||
maxUsers: Optional[int] = Field(None, description="Max users (None = unlimited)")
|
||||
maxFeatureInstances: Optional[int] = Field(None, description="Max feature instances (None = unlimited)")
|
||||
maxDataVolumeMB: Optional[int] = Field(None, description="Max storage in MB (None = unlimited)")
|
||||
budgetAiCHF: Optional[float] = Field(None, description="Fixed AI budget per period (CHF)")
|
||||
note: Optional[str] = Field(None, description="Free-text note (e.g. contract reference)")
|
||||
|
||||
|
||||
class EnterpriseRenewRequest(BaseModel):
|
||||
subscriptionId: str = Field(..., description="ID of the enterprise subscription to renew")
|
||||
newEndDate: float = Field(..., description="New period end (UTC unix timestamp)")
|
||||
autoRenew: Optional[bool] = Field(None, description="Override auto-renew flag")
|
||||
flatPriceCHF: Optional[float] = Field(None, description="Override flat price (CHF)")
|
||||
maxUsers: Optional[int] = Field(None, description="Override max users")
|
||||
maxFeatureInstances: Optional[int] = Field(None, description="Override max feature instances")
|
||||
maxDataVolumeMB: Optional[int] = Field(None, description="Override max storage (MB)")
|
||||
budgetAiCHF: Optional[float] = Field(None, description="Override AI budget (CHF)")
|
||||
note: Optional[str] = Field(None, description="Override note")
|
||||
|
||||
|
||||
class EnterpriseUpdateRequest(BaseModel):
|
||||
subscriptionId: str = Field(..., description="ID of the enterprise subscription to update")
|
||||
enterpriseFlatPriceCHF: Optional[float] = Field(None, description="New flat price (CHF)")
|
||||
enterpriseMaxUsers: Optional[int] = Field(None, description="New max users")
|
||||
enterpriseMaxFeatureInstances: Optional[int] = Field(None, description="New max feature instances")
|
||||
enterpriseMaxDataVolumeMB: Optional[int] = Field(None, description="New max storage (MB)")
|
||||
enterpriseBudgetAiCHF: Optional[float] = Field(None, description="New AI budget (CHF)")
|
||||
enterpriseNote: Optional[str] = Field(None, description="New note")
|
||||
recurring: Optional[bool] = Field(None, description="Update auto-renew flag")
|
||||
|
||||
|
||||
@router.post("/enterprise/create", response_model=Dict[str, Any])
|
||||
@limiter.limit("10/minute")
|
||||
def createEnterprise(
|
||||
request: Request,
|
||||
data: EnterpriseCreateRequest,
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""SysAdmin: create an enterprise subscription with custom flat pricing and limits."""
|
||||
if not context.isPlatformAdmin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
|
||||
|
||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
|
||||
getService as getSubscriptionService,
|
||||
)
|
||||
try:
|
||||
subService = getSubscriptionService(context.user, data.mandateId)
|
||||
return subService.createEnterprise(
|
||||
mandateId=data.mandateId,
|
||||
startDate=data.startDate,
|
||||
endDate=data.endDate,
|
||||
autoRenew=data.autoRenew,
|
||||
flatPriceCHF=data.flatPriceCHF,
|
||||
maxUsers=data.maxUsers,
|
||||
maxFeatureInstances=data.maxFeatureInstances,
|
||||
maxDataVolumeMB=data.maxDataVolumeMB,
|
||||
budgetAiCHF=data.budgetAiCHF,
|
||||
note=data.note,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error creating enterprise subscription: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/enterprise/renew", response_model=Dict[str, Any])
|
||||
@limiter.limit("10/minute")
|
||||
def renewEnterprise(
|
||||
request: Request,
|
||||
data: EnterpriseRenewRequest,
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""SysAdmin: renew an enterprise subscription (expire old, create new with same or overridden params)."""
|
||||
if not context.isPlatformAdmin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
|
||||
|
||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
|
||||
getService as getSubscriptionService,
|
||||
)
|
||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface
|
||||
sub = getSubRootInterface().getById(data.subscriptionId)
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail=routeApiMsg("Subscription not found"))
|
||||
mandateId = sub["mandateId"]
|
||||
|
||||
overrides = {}
|
||||
for field in ("autoRenew", "flatPriceCHF", "maxUsers", "maxFeatureInstances",
|
||||
"maxDataVolumeMB", "budgetAiCHF", "note"):
|
||||
val = getattr(data, field, None)
|
||||
if val is not None:
|
||||
overrides[field] = val
|
||||
|
||||
try:
|
||||
subService = getSubscriptionService(context.user, mandateId)
|
||||
return subService.renewEnterprise(data.subscriptionId, data.newEndDate, overrides or None)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error renewing enterprise subscription %s: %s", data.subscriptionId, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/enterprise/update", response_model=Dict[str, Any])
|
||||
@limiter.limit("30/minute")
|
||||
def updateEnterprise(
|
||||
request: Request,
|
||||
data: EnterpriseUpdateRequest,
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""SysAdmin: update enterprise subscription parameters (limits, price, note)."""
|
||||
if not context.isPlatformAdmin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
|
||||
|
||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
|
||||
getService as getSubscriptionService,
|
||||
)
|
||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface
|
||||
sub = getSubRootInterface().getById(data.subscriptionId)
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail=routeApiMsg("Subscription not found"))
|
||||
mandateId = sub["mandateId"]
|
||||
|
||||
changes = {k: v for k, v in data.model_dump(exclude={"subscriptionId"}).items() if v is not None}
|
||||
|
||||
try:
|
||||
subService = getSubscriptionService(context.user, mandateId)
|
||||
return subService.updateEnterprise(data.subscriptionId, changes)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error updating enterprise subscription %s: %s", data.subscriptionId, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
|
|||
|
|
@ -32,6 +32,44 @@ from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RAG session cache -- avoids repeated embedding + vector search per turn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RAG_CACHE_TTL_S = 120.0
|
||||
_RAG_CACHE_MAX_MSGS = 5
|
||||
_RAG_CACHE_MAX_ENTRIES = 200
|
||||
|
||||
_ragCache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
async def _getOrRefreshRag(
|
||||
workflowId: str,
|
||||
buildRagContextFn,
|
||||
*,
|
||||
forceRefresh: bool = False,
|
||||
**ragKwargs,
|
||||
) -> str:
|
||||
"""Return cached RAG context or compute fresh. Thread-safe via GIL for dict ops."""
|
||||
now = time.time()
|
||||
cached = _ragCache.get(workflowId)
|
||||
if cached and not forceRefresh:
|
||||
age = now - cached["ts"]
|
||||
if age < _RAG_CACHE_TTL_S and cached["msgs"] < _RAG_CACHE_MAX_MSGS:
|
||||
cached["msgs"] += 1
|
||||
return cached["ctx"]
|
||||
|
||||
ragKwargs["workflowId"] = workflowId
|
||||
ctx = await buildRagContextFn(**ragKwargs)
|
||||
|
||||
if len(_ragCache) >= _RAG_CACHE_MAX_ENTRIES:
|
||||
oldest = min(_ragCache, key=lambda k: _ragCache[k]["ts"])
|
||||
_ragCache.pop(oldest, None)
|
||||
|
||||
_ragCache[workflowId] = {"ctx": ctx or "", "ts": now, "msgs": 0}
|
||||
return ctx or ""
|
||||
|
||||
|
||||
async def runAgentLoop(
|
||||
prompt: str,
|
||||
toolRegistry: ToolRegistry,
|
||||
|
|
@ -75,15 +113,20 @@ async def runAgentLoop(
|
|||
featureInstanceId=featureInstanceId
|
||||
)
|
||||
|
||||
activeToolSet = config.toolSet if config else None
|
||||
tools = toolRegistry.getTools(toolSet=activeToolSet)
|
||||
toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet)
|
||||
if config and config.excludeAllTools:
|
||||
tools = []
|
||||
toolDefinitions = None
|
||||
toolsText = ""
|
||||
else:
|
||||
activeToolSet = config.toolSet if config else None
|
||||
tools = toolRegistry.getTools(toolSet=activeToolSet)
|
||||
toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet)
|
||||
|
||||
# Text-based tool descriptions are ONLY used as fallback when native function
|
||||
# calling is unavailable. Including both creates conflicting instructions
|
||||
# (text ```tool_call format vs native tool_use blocks) and can cause the model
|
||||
# to respond with plain text instead of actual tool calls.
|
||||
toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet)
|
||||
# Text-based tool descriptions are ONLY used as fallback when native function
|
||||
# calling is unavailable. Including both creates conflicting instructions
|
||||
# (text ```tool_call format vs native tool_use blocks) and can cause the model
|
||||
# to respond with plain text instead of actual tool calls.
|
||||
toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet)
|
||||
|
||||
if systemPromptOverride:
|
||||
systemPrompt = systemPromptOverride
|
||||
|
|
@ -100,7 +143,7 @@ async def runAgentLoop(
|
|||
roundStartTime = time.time()
|
||||
roundLog = AgentRoundLog(roundNumber=state.currentRound)
|
||||
|
||||
# RAG context injection (before each round for fresh relevance)
|
||||
# RAG context injection (cached for conversational turns, fresh for tool turns)
|
||||
if buildRagContextFn:
|
||||
try:
|
||||
latestUserMsg = ""
|
||||
|
|
@ -108,9 +151,12 @@ async def runAgentLoop(
|
|||
if msg.get("role") == "user":
|
||||
latestUserMsg = msg.get("content", "")
|
||||
break
|
||||
ragContext = await buildRagContextFn(
|
||||
isConversational = config and config.excludeAllTools
|
||||
ragContext = await _getOrRefreshRag(
|
||||
workflowId,
|
||||
buildRagContextFn,
|
||||
forceRefresh=not isConversational,
|
||||
currentPrompt=latestUserMsg or prompt,
|
||||
workflowId=workflowId,
|
||||
userId=userId,
|
||||
featureInstanceId=featureInstanceId,
|
||||
mandateId=mandateId,
|
||||
|
|
@ -166,12 +212,15 @@ async def runAgentLoop(
|
|||
)
|
||||
|
||||
# AI call
|
||||
aiOptions = AiCallOptions(
|
||||
operationType=config.operationType or OperationTypeEnum.AGENT,
|
||||
temperature=config.temperature,
|
||||
)
|
||||
if config.priority:
|
||||
aiOptions.priority = config.priority
|
||||
aiRequest = AiCallRequest(
|
||||
prompt="",
|
||||
options=AiCallOptions(
|
||||
operationType=config.operationType or OperationTypeEnum.AGENT,
|
||||
temperature=config.temperature
|
||||
),
|
||||
options=aiOptions,
|
||||
messages=conversation.messages,
|
||||
tools=toolDefinitions if toolDefinitions else None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from typing import List, Dict, Any, Optional
|
|||
from enum import Enum
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.datamodels.datamodelAi import OperationTypeEnum
|
||||
from modules.datamodels.datamodelAi import OperationTypeEnum, PriorityEnum
|
||||
import uuid
|
||||
|
||||
|
||||
|
|
@ -101,6 +101,18 @@ class AgentConfig(BaseModel):
|
|||
"manipulate the workflow graph, not execute its actions."
|
||||
),
|
||||
)
|
||||
excludeAllTools: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"If True, send no tool definitions to the LLM at all. "
|
||||
"Used for pure conversational turns (e.g. CommCoach coaching chat) "
|
||||
"where tools are not needed and would only add latency."
|
||||
),
|
||||
)
|
||||
priority: Optional[PriorityEnum] = Field(
|
||||
default=None,
|
||||
description="Model selection priority: speed | quality | cost | balanced. None = use default (balanced).",
|
||||
)
|
||||
|
||||
|
||||
class AgentState(BaseModel):
|
||||
|
|
|
|||
|
|
@ -118,12 +118,28 @@ class BaseRenderer(ABC):
|
|||
para = style["paragraph"]
|
||||
lst = style["list"]
|
||||
cb = style["codeBlock"]
|
||||
colors = style.get("colors") if isinstance(style.get("colors"), dict) else {}
|
||||
primaryColor = colors.get("primary", "#1F3864")
|
||||
rawDocTitle = style.get("documentTitle")
|
||||
docTitle = rawDocTitle if isinstance(rawDocTitle, dict) else {}
|
||||
titleSizePt = docTitle.get("sizePt")
|
||||
if titleSizePt is None:
|
||||
titleSizePt = max(int(h1["sizePt"]) + 4, 26)
|
||||
titleColor = docTitle.get("color", primaryColor)
|
||||
titleBold = docTitle.get("weight", "bold") == "bold"
|
||||
titleAlign = docTitle.get("align", "center")
|
||||
if titleAlign not in ("left", "center", "right"):
|
||||
titleAlign = "center"
|
||||
titleSpaceBefore = docTitle.get("spaceBeforePt", 0)
|
||||
titleSpaceAfter = docTitle.get("spaceAfterPt", 18)
|
||||
return {
|
||||
"title": {
|
||||
"font_size": h1["sizePt"], "color": h1["color"],
|
||||
"bold": h1.get("weight") == "bold", "align": "left",
|
||||
"space_before": 0,
|
||||
"space_after": h1.get("spaceAfterPt", 8),
|
||||
"font_size": titleSizePt,
|
||||
"color": titleColor,
|
||||
"bold": titleBold,
|
||||
"align": titleAlign,
|
||||
"space_before": titleSpaceBefore,
|
||||
"space_after": titleSpaceAfter,
|
||||
},
|
||||
"heading1": {
|
||||
"font_size": h1["sizePt"], "color": h1["color"],
|
||||
|
|
|
|||
|
|
@ -146,8 +146,8 @@ class RendererHtml(BaseRenderer):
|
|||
htmlParts.append('</head>')
|
||||
htmlParts.append('<body>')
|
||||
|
||||
# Document header
|
||||
htmlParts.append(f'<header><h1 class="document-title">{documentTitle}</h1></header>')
|
||||
# Document header (not an h1 — body headings keep a single outline level scale)
|
||||
htmlParts.append(f'<header><p class="document-title">{documentTitle}</p></header>')
|
||||
|
||||
# Main content
|
||||
htmlParts.append('<main>')
|
||||
|
|
@ -412,16 +412,27 @@ class RendererHtml(BaseRenderer):
|
|||
css_parts.append(" margin: 0; padding: 20px;")
|
||||
css_parts.append("}")
|
||||
|
||||
# Document title (uses h1 style)
|
||||
h1 = headings.get("h1", {})
|
||||
docTitle = style.get("documentTitle") if isinstance(style.get("documentTitle"), dict) else {}
|
||||
dtSize = docTitle.get("sizePt")
|
||||
if dtSize is None:
|
||||
dtSize = max(headings.get("h1", {}).get("sizePt", 22) + 4, 26)
|
||||
dtColor = docTitle.get("color", primaryColor)
|
||||
dtWeight = docTitle.get("weight", "bold")
|
||||
dtAlign = docTitle.get("align", "center")
|
||||
if dtAlign not in ("left", "center", "right"):
|
||||
dtAlign = "center"
|
||||
dtSpaceAfter = docTitle.get("spaceAfterPt", 18)
|
||||
css_parts.append(".document-title {")
|
||||
css_parts.append(f" font-size: {h1.get('sizePt', 24)}pt;")
|
||||
css_parts.append(f" color: {h1.get('color', primaryColor)};")
|
||||
css_parts.append(f" font-weight: {h1.get('weight', 'bold')};")
|
||||
css_parts.append(" margin: 0 0 1em 0;")
|
||||
css_parts.append(f" font-size: {dtSize}pt;")
|
||||
css_parts.append(f" color: {dtColor};")
|
||||
css_parts.append(f" font-weight: {dtWeight};")
|
||||
css_parts.append(f" text-align: {dtAlign};")
|
||||
css_parts.append(" margin: 0;")
|
||||
css_parts.append(f" margin-bottom: {dtSpaceAfter}pt;")
|
||||
css_parts.append("}")
|
||||
|
||||
# Headings h1-h4
|
||||
h1 = headings.get("h1", {})
|
||||
for level in range(1, 5):
|
||||
key = f"h{level}"
|
||||
h = headings.get(key, h1 if level == 1 else headings.get(f"h{level-1}", {}))
|
||||
|
|
|
|||
|
|
@ -289,7 +289,8 @@ class RendererMarkdown(BaseRenderer):
|
|||
|
||||
if text:
|
||||
level = max(1, min(6, level))
|
||||
return f"{'#' * level} {text}"
|
||||
md_level = min(6, level + 1)
|
||||
return f"{'#' * md_level} {text}"
|
||||
|
||||
return ""
|
||||
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ class RendererPdf(BaseRenderer):
|
|||
|
||||
# Extract sections and metadata from standardized schema
|
||||
sections = self._extractSections(json_content)
|
||||
metadata = self._extractMetadata(json_content)
|
||||
|
||||
# Create a buffer to hold the PDF
|
||||
buffer = io.BytesIO()
|
||||
|
|
@ -204,8 +205,13 @@ class RendererPdf(BaseRenderer):
|
|||
else:
|
||||
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18)
|
||||
|
||||
# Build PDF content (no cover page — body starts on page 1; filename still uses `title`)
|
||||
# Body starts on page 1 — optional document title uses styles["title"] (distinct from H1)
|
||||
story = []
|
||||
document_title = (title or "").strip()
|
||||
if not document_title and isinstance(metadata, dict):
|
||||
document_title = (metadata.get("title") or "").strip()
|
||||
if document_title:
|
||||
story.append(self._paragraphFromInlineMarkdown(document_title, self._createDocumentTitleStyle(styles)))
|
||||
|
||||
# Process each section (sections already extracted above)
|
||||
self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER")
|
||||
|
|
@ -561,6 +567,22 @@ class RendererPdf(BaseRenderer):
|
|||
"space_before": sb,
|
||||
}
|
||||
|
||||
def _createDocumentTitleStyle(self, styles: Dict[str, Any]) -> ParagraphStyle:
|
||||
"""Paragraph style for the document title (metadata/doc title — not heading level 1)."""
|
||||
title_style_def = styles.get("title") or {}
|
||||
fs = title_style_def.get("font_size", 26)
|
||||
bold = title_style_def.get("bold", True)
|
||||
return ParagraphStyle(
|
||||
"DocumentTitle",
|
||||
fontName="Helvetica-Bold" if bold else "Helvetica",
|
||||
fontSize=fs,
|
||||
spaceAfter=title_style_def.get("space_after", 18),
|
||||
spaceBefore=title_style_def.get("space_before", 0),
|
||||
alignment=self._getAlignment(title_style_def.get("align", "center")),
|
||||
textColor=self._hexToColor(title_style_def.get("color", "#1F3864")),
|
||||
leading=fs * 1.25,
|
||||
)
|
||||
|
||||
def _createHeadingStyle(self, styles: Dict[str, Any], level: int) -> ParagraphStyle:
|
||||
"""Create heading style from style definitions."""
|
||||
heading_key = f"heading{level}"
|
||||
|
|
|
|||
|
|
@ -340,11 +340,8 @@ class RendererText(BaseRenderer):
|
|||
except (TypeError, ValueError):
|
||||
level_i = 1
|
||||
level_i = max(1, min(6, level_i))
|
||||
if level_i == 1:
|
||||
return f"{text}\n{'=' * len(text)}"
|
||||
if level_i == 2:
|
||||
return f"{text}\n{'-' * len(text)}"
|
||||
return f"{'#' * level_i} {text}"
|
||||
md_level = min(6, level_i + 1)
|
||||
return f"{'#' * md_level} {text}"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error rendering heading: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -16,8 +16,16 @@ DEFAULT_STYLE: Dict[str, Any] = {
|
|||
"accent": "#2980B9",
|
||||
"background": "#FFFFFF",
|
||||
},
|
||||
"documentTitle": {
|
||||
"sizePt": 28,
|
||||
"weight": "bold",
|
||||
"color": "#1F3864",
|
||||
"spaceBeforePt": 0,
|
||||
"spaceAfterPt": 18,
|
||||
"align": "center",
|
||||
},
|
||||
"headings": {
|
||||
"h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 24, "spaceAfterPt": 8},
|
||||
"h1": {"sizePt": 22, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 22, "spaceAfterPt": 8},
|
||||
"h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6},
|
||||
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4},
|
||||
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Enterprise subscription auto-renewal scheduler.
|
||||
|
||||
Runs daily via eventManager (APScheduler). Checks all enterprise subscriptions
|
||||
with autoRenew=True whose period has ended and renews them automatically
|
||||
(old -> EXPIRED, new -> ACTIVE with same duration and params, budget credit,
|
||||
invoice email).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _runEnterpriseAutoRenewal() -> None:
|
||||
"""Scheduled task: auto-renew enterprise subscriptions whose period has ended."""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
||||
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum
|
||||
|
||||
subIface = _getSubRoot()
|
||||
allSubs = subIface.listAll([SubscriptionStatusEnum.ACTIVE])
|
||||
|
||||
nowTs = datetime.now(timezone.utc).timestamp()
|
||||
renewed = 0
|
||||
|
||||
for sub in allSubs:
|
||||
if not sub.get("isEnterprise"):
|
||||
continue
|
||||
if not sub.get("recurring"):
|
||||
continue
|
||||
periodEnd = sub.get("currentPeriodEnd")
|
||||
if not periodEnd or periodEnd > nowTs:
|
||||
continue
|
||||
|
||||
mandateId = sub["mandateId"]
|
||||
subId = sub["id"]
|
||||
periodStart = sub.get("currentPeriodStart") or sub.get("startedAt") or nowTs
|
||||
periodDuration = periodEnd - periodStart
|
||||
if periodDuration <= 0:
|
||||
periodDuration = 30 * 86400
|
||||
newEndDate = nowTs + periodDuration
|
||||
|
||||
try:
|
||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
|
||||
getService as getSubscriptionService,
|
||||
)
|
||||
from modules.security.rootAccess import getRootUser
|
||||
rootUser = getRootUser()
|
||||
subService = getSubscriptionService(rootUser, mandateId)
|
||||
subService.renewEnterprise(subId, newEndDate)
|
||||
renewed += 1
|
||||
logger.info(
|
||||
"Auto-renewed enterprise subscription %s for mandate %s (new end: %s)",
|
||||
subId, mandateId,
|
||||
datetime.fromtimestamp(newEndDate, tz=timezone.utc).isoformat(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Auto-renewal failed for enterprise subscription %s mandate %s: %s",
|
||||
subId, mandateId, e,
|
||||
)
|
||||
|
||||
if renewed:
|
||||
logger.info("Enterprise auto-renewal completed: %d subscription(s) renewed", renewed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Enterprise auto-renewal task failed: %s", e)
|
||||
|
||||
|
||||
def registerEnterpriseRenewalScheduler() -> None:
|
||||
"""Register the enterprise auto-renewal cron job (daily at 06:00 UTC)."""
|
||||
try:
|
||||
from modules.shared.eventManagement import eventManager
|
||||
|
||||
eventManager.registerCron(
|
||||
jobId="enterprise_auto_renewal",
|
||||
func=_runEnterpriseAutoRenewal,
|
||||
cronKwargs={
|
||||
"hour": "6",
|
||||
"minute": "0",
|
||||
},
|
||||
)
|
||||
logger.info("Enterprise auto-renewal scheduler registered (daily at 06:00 UTC)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to register enterprise auto-renewal scheduler: %s", e)
|
||||
|
|
@ -26,6 +26,7 @@ from modules.interfaces.interfaceDbSubscription import (
|
|||
getInterface as getSubscriptionInterface,
|
||||
InvalidTransitionError,
|
||||
)
|
||||
from modules.shared.i18nRegistry import t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -581,6 +582,256 @@ class SubscriptionService:
|
|||
def syncStripeQuantity(self, subscriptionId: str):
|
||||
self._interface.syncQuantityToStripe(subscriptionId)
|
||||
|
||||
# =========================================================================
|
||||
# Enterprise subscription management (sysadmin-only)
|
||||
# =========================================================================
|
||||
|
||||
def createEnterprise(
|
||||
self, mandateId: str,
|
||||
startDate: float, endDate: float, autoRenew: bool,
|
||||
flatPriceCHF: float,
|
||||
maxUsers: Optional[int], maxFeatureInstances: Optional[int],
|
||||
maxDataVolumeMB: Optional[int], budgetAiCHF: Optional[float],
|
||||
note: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new enterprise subscription with custom flat pricing and limits.
|
||||
|
||||
1. Cleanup PENDING/SCHEDULED predecessors
|
||||
2. Expire current operative subscription (no Stripe cancel)
|
||||
3. Create ACTIVE MandateSubscription with enterprise fields
|
||||
4. Credit fixed AI budget to mandate pool
|
||||
5. Send invoice email to mandate admins
|
||||
"""
|
||||
self._cleanupPreparatorySubscriptions(mandateId)
|
||||
|
||||
currentOperative = self._interface.getOperativeForMandate(mandateId)
|
||||
if currentOperative:
|
||||
self._expireOperative(currentOperative["id"], mandateId)
|
||||
|
||||
sub = MandateSubscription(
|
||||
mandateId=mandateId,
|
||||
planKey="ENTERPRISE",
|
||||
status=SubscriptionStatusEnum.ACTIVE,
|
||||
recurring=autoRenew,
|
||||
startedAt=datetime.now(timezone.utc).timestamp(),
|
||||
currentPeriodStart=startDate,
|
||||
currentPeriodEnd=endDate,
|
||||
isEnterprise=True,
|
||||
enterpriseFlatPriceCHF=flatPriceCHF,
|
||||
enterpriseMaxUsers=maxUsers,
|
||||
enterpriseMaxFeatureInstances=maxFeatureInstances,
|
||||
enterpriseMaxDataVolumeMB=maxDataVolumeMB,
|
||||
enterpriseBudgetAiCHF=budgetAiCHF,
|
||||
enterpriseNote=note,
|
||||
)
|
||||
|
||||
created = self._interface.createSubscription(sub)
|
||||
self.invalidateCache(mandateId)
|
||||
|
||||
self._creditEnterpriseBudget(mandateId, budgetAiCHF, "Erstaktivierung")
|
||||
_notifyEnterpriseInvoice(mandateId, created)
|
||||
|
||||
logger.info("Enterprise subscription created for mandate %s: id=%s", mandateId, created["id"])
|
||||
return created
|
||||
|
||||
def renewEnterprise(
|
||||
self, subscriptionId: str, newEndDate: float,
|
||||
overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Renew an enterprise subscription: expire old, create new with same or overridden params.
|
||||
|
||||
1. Load + validate old subscription
|
||||
2. Expire old subscription
|
||||
3. Create new ACTIVE subscription (clone params, apply overrides)
|
||||
4. Credit AI budget
|
||||
5. Send invoice email
|
||||
"""
|
||||
oldSub = self._interface.getById(subscriptionId)
|
||||
if not oldSub:
|
||||
raise ValueError(f"Subscription {subscriptionId} not found")
|
||||
if not oldSub.get("isEnterprise"):
|
||||
raise ValueError(f"Subscription {subscriptionId} is not an enterprise subscription")
|
||||
|
||||
mandateId = oldSub["mandateId"]
|
||||
self._interface.forceExpire(subscriptionId)
|
||||
self.invalidateCache(mandateId)
|
||||
|
||||
overrides = overrides or {}
|
||||
nowTs = datetime.now(timezone.utc).timestamp()
|
||||
startDate = nowTs
|
||||
autoRenew = overrides.get("autoRenew", oldSub.get("recurring", False))
|
||||
flatPriceCHF = overrides.get("flatPriceCHF", oldSub.get("enterpriseFlatPriceCHF"))
|
||||
maxUsers = overrides.get("maxUsers", oldSub.get("enterpriseMaxUsers"))
|
||||
maxFeatureInstances = overrides.get("maxFeatureInstances", oldSub.get("enterpriseMaxFeatureInstances"))
|
||||
maxDataVolumeMB = overrides.get("maxDataVolumeMB", oldSub.get("enterpriseMaxDataVolumeMB"))
|
||||
budgetAiCHF = overrides.get("budgetAiCHF", oldSub.get("enterpriseBudgetAiCHF"))
|
||||
note = overrides.get("note", oldSub.get("enterpriseNote"))
|
||||
|
||||
sub = MandateSubscription(
|
||||
mandateId=mandateId,
|
||||
planKey="ENTERPRISE",
|
||||
status=SubscriptionStatusEnum.ACTIVE,
|
||||
recurring=autoRenew,
|
||||
startedAt=nowTs,
|
||||
currentPeriodStart=startDate,
|
||||
currentPeriodEnd=newEndDate,
|
||||
isEnterprise=True,
|
||||
enterpriseFlatPriceCHF=flatPriceCHF,
|
||||
enterpriseMaxUsers=maxUsers,
|
||||
enterpriseMaxFeatureInstances=maxFeatureInstances,
|
||||
enterpriseMaxDataVolumeMB=maxDataVolumeMB,
|
||||
enterpriseBudgetAiCHF=budgetAiCHF,
|
||||
enterpriseNote=note,
|
||||
)
|
||||
|
||||
created = self._interface.createSubscription(sub)
|
||||
self.invalidateCache(mandateId)
|
||||
|
||||
self._creditEnterpriseBudget(mandateId, budgetAiCHF, "Erneuerung")
|
||||
_notifyEnterpriseInvoice(mandateId, created)
|
||||
|
||||
logger.info(
|
||||
"Enterprise subscription renewed for mandate %s: old=%s -> new=%s",
|
||||
mandateId, subscriptionId, created["id"],
|
||||
)
|
||||
return created
|
||||
|
||||
def updateEnterprise(self, subscriptionId: str, changes: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Update enterprise subscription parameters (limits, note, flat price).
|
||||
|
||||
Only enterprise-specific fields are allowed. No status change."""
|
||||
sub = self._interface.getById(subscriptionId)
|
||||
if not sub:
|
||||
raise ValueError(f"Subscription {subscriptionId} not found")
|
||||
if not sub.get("isEnterprise"):
|
||||
raise ValueError(f"Subscription {subscriptionId} is not an enterprise subscription")
|
||||
|
||||
allowedFields = {
|
||||
"enterpriseFlatPriceCHF", "enterpriseMaxUsers", "enterpriseMaxFeatureInstances",
|
||||
"enterpriseMaxDataVolumeMB", "enterpriseBudgetAiCHF", "enterpriseNote",
|
||||
"recurring",
|
||||
}
|
||||
updateData = {k: v for k, v in changes.items() if k in allowedFields}
|
||||
if not updateData:
|
||||
raise ValueError("No valid enterprise fields to update")
|
||||
|
||||
result = self._interface.updateFields(subscriptionId, updateData)
|
||||
self.invalidateCache(sub["mandateId"])
|
||||
logger.info("Enterprise subscription %s updated: %s", subscriptionId, list(updateData.keys()))
|
||||
return result
|
||||
|
||||
def _creditEnterpriseBudget(
|
||||
self, mandateId: str, budgetAiCHF: Optional[float], periodLabel: str,
|
||||
) -> None:
|
||||
if not budgetAiCHF or budgetAiCHF <= 0:
|
||||
return
|
||||
try:
|
||||
from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot
|
||||
_getBillingRoot().creditSubscriptionBudget(
|
||||
mandateId, "ENTERPRISE", periodLabel=periodLabel,
|
||||
enterpriseBudgetOverride=budgetAiCHF,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Enterprise budget credit failed for mandate %s: %s", mandateId, e)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Enterprise Invoice Email
|
||||
# ============================================================================
|
||||
|
||||
def _notifyEnterpriseInvoice(mandateId: str, subRecord: Dict[str, Any]) -> None:
|
||||
"""Send enterprise invoice email to mandate admins."""
|
||||
try:
|
||||
from modules.shared.notifyMandateAdmins import notifyMandateAdmins
|
||||
|
||||
rawHtml = _buildEnterpriseInvoiceHtml(subRecord)
|
||||
flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0
|
||||
notifyMandateAdmins(
|
||||
mandateId,
|
||||
t("[PowerOn] Enterprise-Abonnement — Rechnung") + f" (CHF {flatPrice:,.2f})",
|
||||
t("Enterprise-Abonnement — Rechnung"),
|
||||
[
|
||||
t("Das Enterprise-Abonnement wurde aktiviert."),
|
||||
t("Bitte begleichen Sie den Rechnungsbetrag innert 10 Tagen."),
|
||||
t("Details zum Abonnement finden Sie unter Billing-Verwaltung."),
|
||||
],
|
||||
rawHtmlBlock=rawHtml,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Enterprise invoice email failed for mandate %s: %s", mandateId, e)
|
||||
|
||||
|
||||
def _buildEnterpriseInvoiceHtml(subRecord: Dict[str, Any]) -> str:
|
||||
"""Build HTML invoice summary for enterprise subscription email."""
|
||||
flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0
|
||||
maxUsers = subRecord.get("enterpriseMaxUsers")
|
||||
maxFeatures = subRecord.get("enterpriseMaxFeatureInstances")
|
||||
maxStorageMB = subRecord.get("enterpriseMaxDataVolumeMB")
|
||||
budgetAi = subRecord.get("enterpriseBudgetAiCHF")
|
||||
note = subRecord.get("enterpriseNote") or ""
|
||||
periodStart = subRecord.get("currentPeriodStart")
|
||||
periodEnd = subRecord.get("currentPeriodEnd")
|
||||
|
||||
def _chf(amount: float) -> str:
|
||||
return f"CHF {amount:,.2f}".replace(",", "'")
|
||||
|
||||
def _fmtDate(ts: Optional[float]) -> str:
|
||||
if not ts:
|
||||
return "—"
|
||||
from datetime import datetime, timezone
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%d.%m.%Y")
|
||||
|
||||
detailRows = ""
|
||||
if maxUsers is not None:
|
||||
detailRows += (
|
||||
f'<tr><td style="padding:4px 0;color:#555;">{t("Benutzer")}</td>'
|
||||
f'<td style="padding:4px 0;text-align:right;color:#333;">max. {maxUsers}</td></tr>'
|
||||
)
|
||||
if maxFeatures is not None:
|
||||
detailRows += (
|
||||
f'<tr><td style="padding:4px 0;color:#555;">{t("Module")}</td>'
|
||||
f'<td style="padding:4px 0;text-align:right;color:#333;">max. {maxFeatures}</td></tr>'
|
||||
)
|
||||
if maxStorageMB is not None:
|
||||
storageLabel = f"{maxStorageMB} MB" if maxStorageMB < 1024 else f"{maxStorageMB / 1024:.1f} GB"
|
||||
detailRows += (
|
||||
f'<tr><td style="padding:4px 0;color:#555;">{t("Datenvolumen")}</td>'
|
||||
f'<td style="padding:4px 0;text-align:right;color:#333;">max. {storageLabel}</td></tr>'
|
||||
)
|
||||
if budgetAi is not None and budgetAi > 0:
|
||||
detailRows += (
|
||||
f'<tr><td style="padding:4px 0;color:#555;">{t("KI-Budget")}</td>'
|
||||
f'<td style="padding:4px 0;text-align:right;color:#333;">{_chf(budgetAi)}</td></tr>'
|
||||
)
|
||||
|
||||
noteHtml = ""
|
||||
if note:
|
||||
import html as htmlmod
|
||||
noteHtml = (
|
||||
f'<p style="margin:8px 0 0 0;font-size:13px;color:#6b7280;">'
|
||||
f'{t("Notiz")}: {htmlmod.escape(note)}</p>'
|
||||
)
|
||||
|
||||
return (
|
||||
f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
|
||||
f'<tbody>'
|
||||
f'<tr style="border-bottom:1px solid #e5e7eb;">'
|
||||
f'<td style="padding:6px 0;color:#555;">{t("Zeitraum")}</td>'
|
||||
f'<td style="padding:6px 0;text-align:right;color:#333;">{_fmtDate(periodStart)} – {_fmtDate(periodEnd)}</td>'
|
||||
f'</tr>'
|
||||
f'{detailRows}'
|
||||
f'<tr style="border-top:2px solid #1a1a2e;">'
|
||||
f'<td style="padding:10px 0;font-weight:700;color:#1a1a2e;">{t("Pauschale")}</td>'
|
||||
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">'
|
||||
f'{_chf(flatPrice)}</td>'
|
||||
f'</tr>'
|
||||
f'</tbody>'
|
||||
f'</table>'
|
||||
f'<p style="margin:8px 0 0 0;font-size:13px;color:#6b7280;">'
|
||||
f'{t("Zahlungsfrist")}: {t("10 Tage")}</p>'
|
||||
f'{noteHtml}'
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Notifications
|
||||
|
|
@ -608,66 +859,66 @@ def _notifySubscriptionChange(
|
|||
|
||||
templates: Dict[str, Dict[str, Any]] = {
|
||||
"activated": {
|
||||
"subject": f"[PowerOn] Abonnement aktiviert — {planLabel}",
|
||||
"headline": "Abonnement aktiviert",
|
||||
"subject": f"[PowerOn] {t('Abonnement aktiviert')} — {planLabel}",
|
||||
"headline": t("Abonnement aktiviert"),
|
||||
"paragraphs": [
|
||||
p for p in [
|
||||
f"Das Abonnement wurde auf den Plan «{planLabel}» aktiviert.",
|
||||
t("Das Abonnement wurde auf den Plan «{planLabel}» aktiviert.").format(planLabel=planLabel),
|
||||
platformHint,
|
||||
"Sie können Ihr Abonnement jederzeit unter Billing-Verwaltung › Abonnement einsehen und verwalten.",
|
||||
t("Sie können Ihr Abonnement jederzeit unter Billing-Verwaltung › Abonnement einsehen und verwalten."),
|
||||
] if p
|
||||
],
|
||||
},
|
||||
"cancelled": {
|
||||
"subject": f"[PowerOn] Abonnement gekündigt — {planLabel}",
|
||||
"headline": "Abonnement gekündigt",
|
||||
"subject": f"[PowerOn] {t('Abonnement gekündigt')} — {planLabel}",
|
||||
"headline": t("Abonnement gekündigt"),
|
||||
"paragraphs": [
|
||||
p for p in [
|
||||
f"Das Abonnement «{planLabel}» wurde gekündigt.",
|
||||
t("Das Abonnement «{planLabel}» wurde gekündigt.").format(planLabel=planLabel),
|
||||
platformHint,
|
||||
"Die Kündigung wird zum Ende der aktuellen bezahlten Periode wirksam. Bis dahin bleibt der volle Zugang bestehen.",
|
||||
t("Die Kündigung wird zum Ende der aktuellen bezahlten Periode wirksam. Bis dahin bleibt der volle Zugang bestehen."),
|
||||
] if p
|
||||
],
|
||||
},
|
||||
"force_cancelled": {
|
||||
"subject": f"[PowerOn] Abonnement sofort beendet — {planLabel}",
|
||||
"headline": "Abonnement sofort beendet",
|
||||
"subject": f"[PowerOn] {t('Abonnement sofort beendet')} — {planLabel}",
|
||||
"headline": t("Abonnement sofort beendet"),
|
||||
"paragraphs": [
|
||||
p for p in [
|
||||
f"Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.",
|
||||
t("Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.").format(planLabel=planLabel),
|
||||
platformHint,
|
||||
"Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support.",
|
||||
t("Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support."),
|
||||
] if p
|
||||
],
|
||||
},
|
||||
"trial_expired": {
|
||||
"subject": "[PowerOn] Testphase abgelaufen",
|
||||
"headline": "Testphase abgelaufen",
|
||||
"subject": f"[PowerOn] {t('Testphase abgelaufen')}",
|
||||
"headline": t("Testphase abgelaufen"),
|
||||
"paragraphs": [
|
||||
p for p in [
|
||||
"Die kostenlose Testphase ist abgelaufen.",
|
||||
t("Die kostenlose Testphase ist abgelaufen."),
|
||||
platformHint,
|
||||
"Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement, damit der Zugang nicht unterbrochen wird.",
|
||||
t("Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement, damit der Zugang nicht unterbrochen wird."),
|
||||
] if p
|
||||
],
|
||||
},
|
||||
"payment_failed": {
|
||||
"subject": f"[PowerOn] Zahlung fehlgeschlagen — {planLabel}",
|
||||
"headline": "Zahlung fehlgeschlagen",
|
||||
"subject": f"[PowerOn] {t('Zahlung fehlgeschlagen')} — {planLabel}",
|
||||
"headline": t("Zahlung fehlgeschlagen"),
|
||||
"paragraphs": [
|
||||
p for p in [
|
||||
f"Die Zahlung für das Abonnement «{planLabel}» ist fehlgeschlagen.",
|
||||
t("Die Zahlung für das Abonnement «{planLabel}» ist fehlgeschlagen.").format(planLabel=planLabel),
|
||||
platformHint,
|
||||
"Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung.",
|
||||
t("Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung."),
|
||||
] if p
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
tpl = templates.get(event, {
|
||||
"subject": f"[PowerOn] Abonnement-Änderung — {planLabel}",
|
||||
"headline": "Abonnement-Änderung",
|
||||
"paragraphs": [f"Änderung am Abonnement «{planLabel}»."],
|
||||
"subject": f"[PowerOn] {t('Abonnement-Änderung')} — {planLabel}",
|
||||
"headline": t("Abonnement-Änderung"),
|
||||
"paragraphs": [t("Änderung am Abonnement «{planLabel}».").format(planLabel=planLabel)],
|
||||
})
|
||||
|
||||
notifyMandateAdmins(
|
||||
|
|
@ -699,7 +950,7 @@ def _buildInvoiceSummaryHtml(
|
|||
instanceTotal = billableModules * instancePrice
|
||||
netTotal = userTotal + instanceTotal
|
||||
|
||||
periodLabel = {"MONTHLY": "Monatlich", "YEARLY": "Jährlich"}.get(plan.billingPeriod, plan.billingPeriod)
|
||||
periodLabel = {"MONTHLY": t("Monatlich"), "YEARLY": t("Jährlich")}.get(plan.billingPeriod, plan.billingPeriod)
|
||||
|
||||
def _chf(amount: float) -> str:
|
||||
return f"CHF {amount:,.2f}".replace(",", "'")
|
||||
|
|
@ -707,13 +958,13 @@ def _buildInvoiceSummaryHtml(
|
|||
rows = ""
|
||||
if userPrice > 0:
|
||||
rows += (
|
||||
f'<tr><td style="padding:6px 0;color:#333;">Benutzer-Lizenzen</td>'
|
||||
f'<tr><td style="padding:6px 0;color:#333;">{t("Benutzer-Lizenzen")}</td>'
|
||||
f'<td style="padding:6px 8px;color:#555;text-align:right;">{userCount} × {_chf(userPrice)}</td>'
|
||||
f'<td style="padding:6px 0;color:#333;text-align:right;font-weight:600;">{_chf(userTotal)}</td></tr>\n'
|
||||
)
|
||||
if instancePrice > 0 and billableModules > 0:
|
||||
rows += (
|
||||
f'<tr><td style="padding:6px 0;color:#333;">Module ({instanceCount} total, {plan.includedModules} inkl.)</td>'
|
||||
f'<tr><td style="padding:6px 0;color:#333;">{t("Module")} ({instanceCount} total, {plan.includedModules} {t("inkl.")})</td>'
|
||||
f'<td style="padding:6px 8px;color:#555;text-align:right;">{billableModules} × {_chf(instancePrice)}</td>'
|
||||
f'<td style="padding:6px 0;color:#333;text-align:right;font-weight:600;">{_chf(instanceTotal)}</td></tr>\n'
|
||||
)
|
||||
|
|
@ -733,7 +984,7 @@ def _buildInvoiceSummaryHtml(
|
|||
invoiceLink = (
|
||||
f'<p style="margin:12px 0 0 0;font-size:14px;">'
|
||||
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
|
||||
f'Vollständige Rechnung mit MwSt-Ausweis anzeigen</a></p>\n'
|
||||
f'{t("Vollständige Rechnung mit MwSt-Ausweis anzeigen")}</a></p>\n'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
|
||||
|
|
@ -741,13 +992,13 @@ def _buildInvoiceSummaryHtml(
|
|||
return (
|
||||
f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
|
||||
f'<thead><tr style="border-bottom:2px solid #e5e7eb;">'
|
||||
f'<th style="text-align:left;padding:8px 0;color:#6b7280;font-weight:500;">Position</th>'
|
||||
f'<th style="text-align:right;padding:8px;color:#6b7280;font-weight:500;">Menge × Preis</th>'
|
||||
f'<th style="text-align:right;padding:8px 0;color:#6b7280;font-weight:500;">Total</th>'
|
||||
f'<th style="text-align:left;padding:8px 0;color:#6b7280;font-weight:500;">{t("Position")}</th>'
|
||||
f'<th style="text-align:right;padding:8px;color:#6b7280;font-weight:500;">{t("Menge")} × {t("Preis")}</th>'
|
||||
f'<th style="text-align:right;padding:8px 0;color:#6b7280;font-weight:500;">{t("Total")}</th>'
|
||||
f'</tr></thead>'
|
||||
f'<tbody>{rows}</tbody>'
|
||||
f'<tfoot><tr style="border-top:2px solid #1a1a2e;">'
|
||||
f'<td style="padding:10px 0;font-weight:700;color:#1a1a2e;">Netto-Total ({periodLabel})</td>'
|
||||
f'<td style="padding:10px 0;font-weight:700;color:#1a1a2e;">{t("Netto-Total")} ({periodLabel})</td>'
|
||||
f'<td></td>'
|
||||
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">{_chf(netTotal)}</td>'
|
||||
f'</tr></tfoot>'
|
||||
|
|
@ -776,7 +1027,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
|
|||
parts.append(
|
||||
f'<p style="margin:4px 0;font-size:14px;">'
|
||||
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
|
||||
f'Letzte Stripe-Rechnung anzeigen</a></p>'
|
||||
f'{t("Letzte Stripe-Rechnung anzeigen")}</a></p>'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
|
||||
|
|
@ -822,7 +1073,7 @@ class SubscriptionInactiveException(Exception):
|
|||
self.mandateId = mandateId
|
||||
self.reason = _subscriptionReasonForStatus(status)
|
||||
self.userAction = _subscriptionUserActionForStatus(status)
|
||||
self.message = message or (
|
||||
self.message = message or t(
|
||||
"Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing."
|
||||
)
|
||||
super().__init__(self.message)
|
||||
|
|
@ -837,47 +1088,62 @@ class SubscriptionInactiveException(Exception):
|
|||
return out
|
||||
|
||||
|
||||
_SUBSCRIPTION_LIMITS_UI_HINT_DE = (
|
||||
" Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
|
||||
"Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
|
||||
)
|
||||
SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN"
|
||||
|
||||
|
||||
def _subscriptionLimitsHint() -> str:
|
||||
return " " + t(
|
||||
"Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
|
||||
"Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
|
||||
)
|
||||
|
||||
|
||||
def _enterpriseLimitsHint() -> str:
|
||||
return " " + t(
|
||||
"Ihr Enterprise-Abonnement wird vom Plattform-Administrator verwaltet. "
|
||||
"Bitte kontaktieren Sie den Administrator für eine Anpassung der Limiten."
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionCapacityException(Exception):
|
||||
def __init__(self, resourceType: str, currentCount: int, maxAllowed: int, message: Optional[str] = None):
|
||||
def __init__(self, resourceType: str, currentCount: int, maxAllowed: int,
|
||||
message: Optional[str] = None, isEnterprise: bool = False):
|
||||
self.resourceType = resourceType
|
||||
self.currentCount = currentCount
|
||||
self.maxAllowed = maxAllowed
|
||||
self.isEnterprise = isEnterprise
|
||||
hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint()
|
||||
if message is not None:
|
||||
self.message = message
|
||||
elif resourceType == "users":
|
||||
self.message = (
|
||||
f"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
|
||||
f"Benutzer zulässig (derzeit {currentCount}). "
|
||||
f"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
|
||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
||||
self.message = t(
|
||||
"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
|
||||
"Benutzer zulässig (derzeit {currentCount}). "
|
||||
"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
|
||||
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||
elif resourceType == "featureInstances":
|
||||
self.message = (
|
||||
f"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
|
||||
f"Bitte Abonnement erweitern oder ein Modul entfernen."
|
||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
||||
self.message = t(
|
||||
"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
|
||||
"Bitte Abonnement erweitern oder ein Modul entfernen."
|
||||
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||
elif resourceType == "dataVolumeMB":
|
||||
self.message = (
|
||||
f"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
|
||||
f"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
|
||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
||||
self.message = t(
|
||||
"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
|
||||
"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
|
||||
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||
else:
|
||||
self.message = (
|
||||
f"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
|
||||
f"aktuell {currentCount}, erlaubt {maxAllowed})."
|
||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
||||
self.message = t(
|
||||
"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
|
||||
"aktuell {currentCount}, erlaubt {maxAllowed})."
|
||||
).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint
|
||||
super().__init__(self.message)
|
||||
|
||||
def toClientDict(self) -> Dict[str, Any]:
|
||||
action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE
|
||||
return {
|
||||
"error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT",
|
||||
"currentCount": self.currentCount, "maxAllowed": self.maxAllowed,
|
||||
"message": self.message, "userAction": SUBSCRIPTION_USER_ACTION_UPGRADE,
|
||||
"message": self.message, "userAction": action,
|
||||
"subscriptionUiPath": "/admin/billing?tab=subscription",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,3 +37,10 @@ def test_full_style_passthrough():
|
|||
result = resolveStyle(custom)
|
||||
assert result["fonts"]["primary"] == "Helvetica"
|
||||
assert result["fonts"]["monospace"] == "Monaco"
|
||||
|
||||
|
||||
def test_override_document_title_partial_merge():
|
||||
result = resolveStyle({"documentTitle": {"sizePt": 32}})
|
||||
assert result["documentTitle"]["sizePt"] == 32
|
||||
assert result["documentTitle"]["align"] == "center"
|
||||
assert result["headings"]["h1"]["sizePt"] == DEFAULT_STYLE["headings"]["h1"]["sizePt"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue