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
|
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
||||||
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
|
# Recover background jobs that were RUNNING when the previous worker died
|
||||||
try:
|
try:
|
||||||
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (
|
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,9 @@ class ModelSelector:
|
||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
elif requestedPriority == PriorityEnum.SPEED:
|
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:
|
elif requestedPriority == PriorityEnum.QUALITY:
|
||||||
return model.qualityRating / 10.0
|
return model.qualityRating / 10.0
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ StripePlanPrice (persisted Stripe IDs per plan).
|
||||||
State Machine: see wiki/concepts/Subscription-State-Machine.md
|
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 enum import Enum
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -284,12 +284,63 @@ class MandateSubscription(PowerOnModel):
|
||||||
json_schema_extra={"label": "Stripe-Item (Instanzen)"},
|
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)
|
# Built-in plan catalog (static, no env dependency)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
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(
|
"ROOT": SubscriptionPlan(
|
||||||
planKey="ROOT",
|
planKey="ROOT",
|
||||||
selectableByUser=False,
|
selectableByUser=False,
|
||||||
|
|
@ -415,3 +466,35 @@ def getPlan(planKey: str) -> Optional[SubscriptionPlan]:
|
||||||
def _getSelectablePlans() -> List[SubscriptionPlan]:
|
def _getSelectablePlans() -> List[SubscriptionPlan]:
|
||||||
"""Return plans that users can choose in the UI."""
|
"""Return plans that users can choose in the UI."""
|
||||||
return [p for p in BUILTIN_PLANS.values() if p.selectableByUser]
|
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"),
|
"label": t("Session", context="UI"),
|
||||||
"meta": {"area": "session"}
|
"meta": {"area": "session"}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"objectKey": "ui.feature.commcoach.dossier",
|
|
||||||
"label": t("Dossier", context="UI"),
|
|
||||||
"meta": {"area": "dossier"}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.commcoach.settings",
|
"objectKey": "ui.feature.commcoach.settings",
|
||||||
"label": t("Einstellungen", context="UI"),
|
"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.assistant", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.modules", "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.session", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
|
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.settings", "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": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
{"context": "RESOURCE", "item": None, "view": False},
|
{"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.assistant", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.modules", "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.session", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
|
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.settings", "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.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"},
|
{"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
|
return history
|
||||||
|
|
||||||
|
|
||||||
_TTS_WORD_LIMIT = 200
|
_TTS_WORD_LIMIT = 80
|
||||||
|
|
||||||
|
|
||||||
async def _prepareSpeechText(fullText: str, callAiFn) -> str:
|
async def _prepareSpeechText(fullText: str, callAiFn) -> str:
|
||||||
|
|
@ -906,10 +906,14 @@ class CommcoachService:
|
||||||
)
|
)
|
||||||
agentService = getService("agent", serviceContext)
|
agentService = getService("agent", serviceContext)
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelAi import PriorityEnum, OperationTypeEnum
|
||||||
config = AgentConfig(
|
config = AgentConfig(
|
||||||
toolSet="commcoach" if useTools else "none",
|
toolSet="commcoach" if useTools else "none",
|
||||||
maxRounds=3 if useTools else 1,
|
maxRounds=3 if useTools else 1,
|
||||||
temperature=0.4,
|
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(
|
buildRagContextFn = _createCommcoachRagFn(
|
||||||
|
|
@ -989,10 +993,14 @@ class CommcoachService:
|
||||||
)
|
)
|
||||||
|
|
||||||
isFirstSession = not previousSessionSummaries or len(previousSessionSummaries) == 0
|
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":
|
if persona and persona.get("key") != "coach":
|
||||||
personaLabel = persona.get("label", "Gesprächspartner")
|
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:
|
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."
|
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:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -908,18 +908,22 @@ class BillingObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]:
|
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)
|
settings = self.getSettings(mandateId)
|
||||||
if not settings:
|
if not settings:
|
||||||
return None
|
return None
|
||||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
||||||
from modules.datamodels.datamodelSubscription import getPlan
|
from modules.datamodels.datamodelSubscription import getPlan, getEffectiveLimits
|
||||||
|
|
||||||
subIface = _getSubRoot()
|
subIface = _getSubRoot()
|
||||||
usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
|
usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
|
||||||
sub = subIface.getOperativeForMandate(mandateId)
|
sub = subIface.getOperativeForMandate(mandateId)
|
||||||
|
if sub and sub.get("isEnterprise"):
|
||||||
|
return None
|
||||||
plan = getPlan(sub.get("planKey", "")) if sub else 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:
|
if includedMB is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -966,27 +970,37 @@ class BillingObjects:
|
||||||
# Subscription AI-Budget Credit
|
# 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.
|
"""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).
|
Should be called once per billing period (initial activation + each invoice.paid).
|
||||||
Returns the created CREDIT transaction or None if budget is 0."""
|
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)
|
plan = getPlan(planKey)
|
||||||
if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0:
|
if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
||||||
subRoot = _getSubRoot()
|
subRoot = _getSubRoot()
|
||||||
activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
|
activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
|
||||||
amount = plan.budgetAiPerUserCHF * activeUsers
|
amount = plan.budgetAiPerUserCHF * activeUsers
|
||||||
|
description = f"AI-Budget ({planKey}, {activeUsers} User)"
|
||||||
|
if periodLabel:
|
||||||
|
description += f" – {periodLabel}"
|
||||||
|
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
description = f"AI-Budget ({planKey}, {activeUsers} User)"
|
|
||||||
if periodLabel:
|
|
||||||
description += f" – {periodLabel}"
|
|
||||||
|
|
||||||
transaction = BillingTransaction(
|
transaction = BillingTransaction(
|
||||||
accountId=poolAccount["id"],
|
accountId=poolAccount["id"],
|
||||||
|
|
@ -998,8 +1012,8 @@ class BillingObjects:
|
||||||
)
|
)
|
||||||
created = self.createTransaction(transaction)
|
created = self.createTransaction(transaction)
|
||||||
logger.info(
|
logger.info(
|
||||||
"AI-Budget credited mandate=%s plan=%s users=%d amount=%.2f CHF",
|
"AI-Budget credited mandate=%s plan=%s amount=%.2f CHF",
|
||||||
mandateId, planKey, activeUsers, amount,
|
mandateId, planKey, amount,
|
||||||
)
|
)
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
@ -1027,7 +1041,8 @@ class BillingObjects:
|
||||||
|
|
||||||
delta > 0: user added -> CREDIT pro-rata portion
|
delta > 0: user added -> CREDIT pro-rata portion
|
||||||
delta < 0: user removed -> DEBIT 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
|
from modules.datamodels.datamodelSubscription import getPlan
|
||||||
|
|
||||||
plan = getPlan(planKey)
|
plan = getPlan(planKey)
|
||||||
|
|
@ -1039,6 +1054,8 @@ class BillingObjects:
|
||||||
operative = subRoot.getOperativeForMandate(mandateId)
|
operative = subRoot.getOperativeForMandate(mandateId)
|
||||||
if not operative:
|
if not operative:
|
||||||
return None
|
return None
|
||||||
|
if operative.get("isEnterprise"):
|
||||||
|
return None
|
||||||
|
|
||||||
periodStart = operative.get("currentPeriodStart")
|
periodStart = operative.get("currentPeriodStart")
|
||||||
periodEnd = operative.get("currentPeriodEnd")
|
periodEnd = operative.get("currentPeriodEnd")
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,6 @@ logger = logging.getLogger(__name__)
|
||||||
managementDatabase = "poweron_management"
|
managementDatabase = "poweron_management"
|
||||||
registerDatabase(managementDatabase)
|
registerDatabase(managementDatabase)
|
||||||
|
|
||||||
# Singleton factory for Management instances with AI service per context
|
|
||||||
_instancesManagement = {}
|
|
||||||
|
|
||||||
# Custom exceptions for file handling
|
# Custom exceptions for file handling
|
||||||
class FileError(Exception):
|
class FileError(Exception):
|
||||||
|
|
@ -124,14 +122,12 @@ class ComponentObjects:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def __del__(self):
|
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:
|
if hasattr(self, 'db') and self.db is not None:
|
||||||
try:
|
try:
|
||||||
self.db.close()
|
self.db.close()
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"Error closing database connection: {e}")
|
pass
|
||||||
|
|
||||||
logger.debug(f"User context set: userId={self.userId}")
|
|
||||||
|
|
||||||
def _initializeDatabase(self):
|
def _initializeDatabase(self):
|
||||||
"""Initializes the database connection directly."""
|
"""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':
|
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects':
|
||||||
"""
|
"""
|
||||||
Returns a ComponentObjects instance.
|
Returns a ComponentObjects instance scoped to the given user/mandate/featureInstance.
|
||||||
If currentUser is provided, initializes with user context.
|
|
||||||
Otherwise, returns an instance with only database access.
|
Each call creates a lightweight instance whose DB connector is already
|
||||||
|
cached inside ``getCachedConnector``, so the overhead is minimal.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currentUser: The authenticated user
|
currentUser: The authenticated user
|
||||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
|
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
|
effectiveMandateId = str(mandateId) if mandateId else None
|
||||||
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
|
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
|
||||||
|
|
||||||
# Create new instance if not exists
|
interface = ComponentObjects()
|
||||||
if "default" not in _instancesManagement:
|
|
||||||
_instancesManagement["default"] = ComponentObjects()
|
|
||||||
|
|
||||||
interface = _instancesManagement["default"]
|
|
||||||
|
|
||||||
if currentUser:
|
if currentUser:
|
||||||
interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
||||||
else:
|
else:
|
||||||
logger.info("Returning interface without user context")
|
logger.info("Returning interface without user context")
|
||||||
|
|
||||||
return interface
|
return interface
|
||||||
|
|
@ -27,6 +27,7 @@ from modules.datamodels.datamodelSubscription import (
|
||||||
BUILTIN_PLANS,
|
BUILTIN_PLANS,
|
||||||
getPlan as getPlanFromCatalog,
|
getPlan as getPlanFromCatalog,
|
||||||
_getSelectablePlans,
|
_getSelectablePlans,
|
||||||
|
getEffectiveLimits,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -276,33 +277,42 @@ class SubscriptionObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
plan = self.getPlan(sub.get("planKey", ""))
|
plan = self.getPlan(sub.get("planKey", ""))
|
||||||
if not plan:
|
limits = getEffectiveLimits(sub, plan)
|
||||||
return True
|
isEnterprise = sub.get("isEnterprise", False)
|
||||||
|
|
||||||
if resourceType == "users":
|
if resourceType == "users":
|
||||||
cap = plan.maxUsers
|
cap = limits["maxUsers"]
|
||||||
if cap is None:
|
if cap is None:
|
||||||
return True
|
return True
|
||||||
current = self.countActiveUsers(mandateId)
|
current = self.countActiveUsers(mandateId)
|
||||||
if current + delta > cap:
|
if current + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
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":
|
elif resourceType == "featureInstances":
|
||||||
cap = plan.maxFeatureInstances
|
cap = limits["maxFeatureInstances"]
|
||||||
if cap is None:
|
if cap is None:
|
||||||
return True
|
return True
|
||||||
current = self.countActiveFeatureInstances(mandateId)
|
current = self.countActiveFeatureInstances(mandateId)
|
||||||
if current + delta > cap:
|
if current + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
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":
|
elif resourceType == "dataVolumeMB":
|
||||||
cap = plan.maxDataVolumeMB
|
cap = limits["maxDataVolumeMB"]
|
||||||
if cap is None:
|
if cap is None:
|
||||||
return True
|
return True
|
||||||
currentMB = self.getMandateDataVolumeMB(mandateId)
|
currentMB = self.getMandateDataVolumeMB(mandateId)
|
||||||
if currentMB + delta > cap:
|
if currentMB + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
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
|
return True
|
||||||
|
|
||||||
|
|
@ -325,10 +335,11 @@ class SubscriptionObjects:
|
||||||
if not sub:
|
if not sub:
|
||||||
return None
|
return None
|
||||||
plan = self.getPlan(sub.get("planKey", ""))
|
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
|
return None
|
||||||
usedMB = self.getMandateDataVolumeMB(mandateId)
|
usedMB = self.getMandateDataVolumeMB(mandateId)
|
||||||
limitMB = plan.maxDataVolumeMB
|
|
||||||
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
|
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
|
||||||
if percent >= 80:
|
if percent >= 80:
|
||||||
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True}
|
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True}
|
||||||
|
|
|
||||||
|
|
@ -106,13 +106,14 @@ class SubscriptionStatusResponse(BaseModel):
|
||||||
usage: Optional[SubscriptionUsage] = None
|
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."""
|
"""Compute current usage metrics for a mandate's subscription."""
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.datamodels.datamodelMembership import UserMandate
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
|
from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
|
||||||
|
from modules.datamodels.datamodelSubscription import getEffectiveLimits
|
||||||
|
|
||||||
rootIf = getRootInterface()
|
rootIf = getRootInterface()
|
||||||
|
|
||||||
|
|
@ -128,7 +129,8 @@ def _computeUsage(mandateId: str, plan) -> SubscriptionUsage:
|
||||||
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
||||||
usedMB = round(ragBytes / (1024 * 1024), 2)
|
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
|
storagePercent = round((usedMB / maxMB) * 100, 1) if maxMB else None
|
||||||
|
|
||||||
return SubscriptionUsage(
|
return SubscriptionUsage(
|
||||||
|
|
@ -207,7 +209,7 @@ def getStatus(request: Request, context: RequestContext = Depends(getRequestCont
|
||||||
|
|
||||||
plan = subService.getPlan(operative.get("planKey", ""))
|
plan = subService.getPlan(operative.get("planKey", ""))
|
||||||
|
|
||||||
usage = _computeUsage(mandateId, plan)
|
usage = _computeUsage(mandateId, plan, operative)
|
||||||
|
|
||||||
return SubscriptionStatusResponse(
|
return SubscriptionStatusResponse(
|
||||||
active=True,
|
active=True,
|
||||||
|
|
@ -451,13 +453,16 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
|
||||||
sub["planTitle"] = resolveText(plan.title) if plan else planKey
|
sub["planTitle"] = resolveText(plan.title) if plan else planKey
|
||||||
|
|
||||||
if sub.get("status") in operativeValues:
|
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)
|
userCount = userCountMap.get(mid, 0)
|
||||||
instanceCount = instanceCountMap.get(mid, 0)
|
instanceCount = instanceCountMap.get(mid, 0)
|
||||||
includedModules = plan.includedModules if plan else 0
|
if sub.get("isEnterprise"):
|
||||||
billableModules = max(0, instanceCount - includedModules)
|
sub["monthlyRevenueCHF"] = round(sub.get("enterpriseFlatPriceCHF") or 0, 2)
|
||||||
sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 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["activeUsers"] = userCount
|
||||||
sub["activeInstances"] = instanceCount
|
sub["activeInstances"] = instanceCount
|
||||||
else:
|
else:
|
||||||
|
|
@ -570,13 +575,16 @@ def _getDataVolumeUsage(
|
||||||
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
||||||
ragMB = round(ragBytes / (1024 * 1024), 2)
|
ragMB = round(ragBytes / (1024 * 1024), 2)
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelSubscription import getEffectiveLimits
|
||||||
|
|
||||||
maxMB = None
|
maxMB = None
|
||||||
subIf = _getSubRootIf()
|
subIf = _getSubRootIf()
|
||||||
operative = subIf.getOperativeForMandate(mandateId)
|
operative = subIf.getOperativeForMandate(mandateId)
|
||||||
if operative:
|
if operative:
|
||||||
plan = subIf.getPlan(operative.get("planKey") or "")
|
plan = subIf.getPlan(operative.get("planKey") or "")
|
||||||
if plan and plan.maxDataVolumeMB is not None:
|
limits = getEffectiveLimits(operative, plan)
|
||||||
maxMB = int(plan.maxDataVolumeMB)
|
if limits["maxDataVolumeMB"] is not None:
|
||||||
|
maxMB = int(limits["maxDataVolumeMB"])
|
||||||
|
|
||||||
usedMB = ragMB
|
usedMB = ragMB
|
||||||
percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
|
percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
|
||||||
|
|
@ -593,3 +601,147 @@ def _getDataVolumeUsage(
|
||||||
"percentUsed": percentUsed,
|
"percentUsed": percentUsed,
|
||||||
"warning": (percentUsed or 0) >= 80,
|
"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__)
|
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(
|
async def runAgentLoop(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
toolRegistry: ToolRegistry,
|
toolRegistry: ToolRegistry,
|
||||||
|
|
@ -75,15 +113,20 @@ async def runAgentLoop(
|
||||||
featureInstanceId=featureInstanceId
|
featureInstanceId=featureInstanceId
|
||||||
)
|
)
|
||||||
|
|
||||||
activeToolSet = config.toolSet if config else None
|
if config and config.excludeAllTools:
|
||||||
tools = toolRegistry.getTools(toolSet=activeToolSet)
|
tools = []
|
||||||
toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet)
|
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
|
# Text-based tool descriptions are ONLY used as fallback when native function
|
||||||
# calling is unavailable. Including both creates conflicting instructions
|
# calling is unavailable. Including both creates conflicting instructions
|
||||||
# (text ```tool_call format vs native tool_use blocks) and can cause the model
|
# (text ```tool_call format vs native tool_use blocks) and can cause the model
|
||||||
# to respond with plain text instead of actual tool calls.
|
# to respond with plain text instead of actual tool calls.
|
||||||
toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet)
|
toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet)
|
||||||
|
|
||||||
if systemPromptOverride:
|
if systemPromptOverride:
|
||||||
systemPrompt = systemPromptOverride
|
systemPrompt = systemPromptOverride
|
||||||
|
|
@ -100,7 +143,7 @@ async def runAgentLoop(
|
||||||
roundStartTime = time.time()
|
roundStartTime = time.time()
|
||||||
roundLog = AgentRoundLog(roundNumber=state.currentRound)
|
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:
|
if buildRagContextFn:
|
||||||
try:
|
try:
|
||||||
latestUserMsg = ""
|
latestUserMsg = ""
|
||||||
|
|
@ -108,9 +151,12 @@ async def runAgentLoop(
|
||||||
if msg.get("role") == "user":
|
if msg.get("role") == "user":
|
||||||
latestUserMsg = msg.get("content", "")
|
latestUserMsg = msg.get("content", "")
|
||||||
break
|
break
|
||||||
ragContext = await buildRagContextFn(
|
isConversational = config and config.excludeAllTools
|
||||||
|
ragContext = await _getOrRefreshRag(
|
||||||
|
workflowId,
|
||||||
|
buildRagContextFn,
|
||||||
|
forceRefresh=not isConversational,
|
||||||
currentPrompt=latestUserMsg or prompt,
|
currentPrompt=latestUserMsg or prompt,
|
||||||
workflowId=workflowId,
|
|
||||||
userId=userId,
|
userId=userId,
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=featureInstanceId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
|
|
@ -166,12 +212,15 @@ async def runAgentLoop(
|
||||||
)
|
)
|
||||||
|
|
||||||
# AI call
|
# AI call
|
||||||
|
aiOptions = AiCallOptions(
|
||||||
|
operationType=config.operationType or OperationTypeEnum.AGENT,
|
||||||
|
temperature=config.temperature,
|
||||||
|
)
|
||||||
|
if config.priority:
|
||||||
|
aiOptions.priority = config.priority
|
||||||
aiRequest = AiCallRequest(
|
aiRequest = AiCallRequest(
|
||||||
prompt="",
|
prompt="",
|
||||||
options=AiCallOptions(
|
options=aiOptions,
|
||||||
operationType=config.operationType or OperationTypeEnum.AGENT,
|
|
||||||
temperature=config.temperature
|
|
||||||
),
|
|
||||||
messages=conversation.messages,
|
messages=conversation.messages,
|
||||||
tools=toolDefinitions if toolDefinitions else None,
|
tools=toolDefinitions if toolDefinitions else None,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import List, Dict, Any, Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
from modules.datamodels.datamodelAi import OperationTypeEnum
|
from modules.datamodels.datamodelAi import OperationTypeEnum, PriorityEnum
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,6 +101,18 @@ class AgentConfig(BaseModel):
|
||||||
"manipulate the workflow graph, not execute its actions."
|
"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):
|
class AgentState(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -118,12 +118,28 @@ class BaseRenderer(ABC):
|
||||||
para = style["paragraph"]
|
para = style["paragraph"]
|
||||||
lst = style["list"]
|
lst = style["list"]
|
||||||
cb = style["codeBlock"]
|
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 {
|
return {
|
||||||
"title": {
|
"title": {
|
||||||
"font_size": h1["sizePt"], "color": h1["color"],
|
"font_size": titleSizePt,
|
||||||
"bold": h1.get("weight") == "bold", "align": "left",
|
"color": titleColor,
|
||||||
"space_before": 0,
|
"bold": titleBold,
|
||||||
"space_after": h1.get("spaceAfterPt", 8),
|
"align": titleAlign,
|
||||||
|
"space_before": titleSpaceBefore,
|
||||||
|
"space_after": titleSpaceAfter,
|
||||||
},
|
},
|
||||||
"heading1": {
|
"heading1": {
|
||||||
"font_size": h1["sizePt"], "color": h1["color"],
|
"font_size": h1["sizePt"], "color": h1["color"],
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,8 @@ class RendererHtml(BaseRenderer):
|
||||||
htmlParts.append('</head>')
|
htmlParts.append('</head>')
|
||||||
htmlParts.append('<body>')
|
htmlParts.append('<body>')
|
||||||
|
|
||||||
# Document header
|
# Document header (not an h1 — body headings keep a single outline level scale)
|
||||||
htmlParts.append(f'<header><h1 class="document-title">{documentTitle}</h1></header>')
|
htmlParts.append(f'<header><p class="document-title">{documentTitle}</p></header>')
|
||||||
|
|
||||||
# Main content
|
# Main content
|
||||||
htmlParts.append('<main>')
|
htmlParts.append('<main>')
|
||||||
|
|
@ -412,16 +412,27 @@ class RendererHtml(BaseRenderer):
|
||||||
css_parts.append(" margin: 0; padding: 20px;")
|
css_parts.append(" margin: 0; padding: 20px;")
|
||||||
css_parts.append("}")
|
css_parts.append("}")
|
||||||
|
|
||||||
# Document title (uses h1 style)
|
docTitle = style.get("documentTitle") if isinstance(style.get("documentTitle"), dict) else {}
|
||||||
h1 = headings.get("h1", {})
|
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(".document-title {")
|
||||||
css_parts.append(f" font-size: {h1.get('sizePt', 24)}pt;")
|
css_parts.append(f" font-size: {dtSize}pt;")
|
||||||
css_parts.append(f" color: {h1.get('color', primaryColor)};")
|
css_parts.append(f" color: {dtColor};")
|
||||||
css_parts.append(f" font-weight: {h1.get('weight', 'bold')};")
|
css_parts.append(f" font-weight: {dtWeight};")
|
||||||
css_parts.append(" margin: 0 0 1em 0;")
|
css_parts.append(f" text-align: {dtAlign};")
|
||||||
|
css_parts.append(" margin: 0;")
|
||||||
|
css_parts.append(f" margin-bottom: {dtSpaceAfter}pt;")
|
||||||
css_parts.append("}")
|
css_parts.append("}")
|
||||||
|
|
||||||
# Headings h1-h4
|
# Headings h1-h4
|
||||||
|
h1 = headings.get("h1", {})
|
||||||
for level in range(1, 5):
|
for level in range(1, 5):
|
||||||
key = f"h{level}"
|
key = f"h{level}"
|
||||||
h = headings.get(key, h1 if level == 1 else headings.get(f"h{level-1}", {}))
|
h = headings.get(key, h1 if level == 1 else headings.get(f"h{level-1}", {}))
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,8 @@ class RendererMarkdown(BaseRenderer):
|
||||||
|
|
||||||
if text:
|
if text:
|
||||||
level = max(1, min(6, level))
|
level = max(1, min(6, level))
|
||||||
return f"{'#' * level} {text}"
|
md_level = min(6, level + 1)
|
||||||
|
return f"{'#' * md_level} {text}"
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,7 @@ class RendererPdf(BaseRenderer):
|
||||||
|
|
||||||
# Extract sections and metadata from standardized schema
|
# Extract sections and metadata from standardized schema
|
||||||
sections = self._extractSections(json_content)
|
sections = self._extractSections(json_content)
|
||||||
|
metadata = self._extractMetadata(json_content)
|
||||||
|
|
||||||
# Create a buffer to hold the PDF
|
# Create a buffer to hold the PDF
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
|
|
@ -204,8 +205,13 @@ class RendererPdf(BaseRenderer):
|
||||||
else:
|
else:
|
||||||
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18)
|
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 = []
|
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)
|
# Process each section (sections already extracted above)
|
||||||
self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER")
|
self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER")
|
||||||
|
|
@ -561,6 +567,22 @@ class RendererPdf(BaseRenderer):
|
||||||
"space_before": sb,
|
"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:
|
def _createHeadingStyle(self, styles: Dict[str, Any], level: int) -> ParagraphStyle:
|
||||||
"""Create heading style from style definitions."""
|
"""Create heading style from style definitions."""
|
||||||
heading_key = f"heading{level}"
|
heading_key = f"heading{level}"
|
||||||
|
|
|
||||||
|
|
@ -340,11 +340,8 @@ class RendererText(BaseRenderer):
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
level_i = 1
|
level_i = 1
|
||||||
level_i = max(1, min(6, level_i))
|
level_i = max(1, min(6, level_i))
|
||||||
if level_i == 1:
|
md_level = min(6, level_i + 1)
|
||||||
return f"{text}\n{'=' * len(text)}"
|
return f"{'#' * md_level} {text}"
|
||||||
if level_i == 2:
|
|
||||||
return f"{text}\n{'-' * len(text)}"
|
|
||||||
return f"{'#' * level_i} {text}"
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Error rendering heading: {str(e)}")
|
self.logger.warning(f"Error rendering heading: {str(e)}")
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,16 @@ DEFAULT_STYLE: Dict[str, Any] = {
|
||||||
"accent": "#2980B9",
|
"accent": "#2980B9",
|
||||||
"background": "#FFFFFF",
|
"background": "#FFFFFF",
|
||||||
},
|
},
|
||||||
|
"documentTitle": {
|
||||||
|
"sizePt": 28,
|
||||||
|
"weight": "bold",
|
||||||
|
"color": "#1F3864",
|
||||||
|
"spaceBeforePt": 0,
|
||||||
|
"spaceAfterPt": 18,
|
||||||
|
"align": "center",
|
||||||
|
},
|
||||||
"headings": {
|
"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},
|
"h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6},
|
||||||
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4},
|
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4},
|
||||||
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3},
|
"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,
|
getInterface as getSubscriptionInterface,
|
||||||
InvalidTransitionError,
|
InvalidTransitionError,
|
||||||
)
|
)
|
||||||
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -581,6 +582,256 @@ class SubscriptionService:
|
||||||
def syncStripeQuantity(self, subscriptionId: str):
|
def syncStripeQuantity(self, subscriptionId: str):
|
||||||
self._interface.syncQuantityToStripe(subscriptionId)
|
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
|
# Notifications
|
||||||
|
|
@ -608,66 +859,66 @@ def _notifySubscriptionChange(
|
||||||
|
|
||||||
templates: Dict[str, Dict[str, Any]] = {
|
templates: Dict[str, Dict[str, Any]] = {
|
||||||
"activated": {
|
"activated": {
|
||||||
"subject": f"[PowerOn] Abonnement aktiviert — {planLabel}",
|
"subject": f"[PowerOn] {t('Abonnement aktiviert')} — {planLabel}",
|
||||||
"headline": "Abonnement aktiviert",
|
"headline": t("Abonnement aktiviert"),
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
p for p in [
|
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,
|
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
|
] if p
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"cancelled": {
|
"cancelled": {
|
||||||
"subject": f"[PowerOn] Abonnement gekündigt — {planLabel}",
|
"subject": f"[PowerOn] {t('Abonnement gekündigt')} — {planLabel}",
|
||||||
"headline": "Abonnement gekündigt",
|
"headline": t("Abonnement gekündigt"),
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
p for p in [
|
p for p in [
|
||||||
f"Das Abonnement «{planLabel}» wurde gekündigt.",
|
t("Das Abonnement «{planLabel}» wurde gekündigt.").format(planLabel=planLabel),
|
||||||
platformHint,
|
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
|
] if p
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"force_cancelled": {
|
"force_cancelled": {
|
||||||
"subject": f"[PowerOn] Abonnement sofort beendet — {planLabel}",
|
"subject": f"[PowerOn] {t('Abonnement sofort beendet')} — {planLabel}",
|
||||||
"headline": "Abonnement sofort beendet",
|
"headline": t("Abonnement sofort beendet"),
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
p for p in [
|
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,
|
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
|
] if p
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"trial_expired": {
|
"trial_expired": {
|
||||||
"subject": "[PowerOn] Testphase abgelaufen",
|
"subject": f"[PowerOn] {t('Testphase abgelaufen')}",
|
||||||
"headline": "Testphase abgelaufen",
|
"headline": t("Testphase abgelaufen"),
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
p for p in [
|
p for p in [
|
||||||
"Die kostenlose Testphase ist abgelaufen.",
|
t("Die kostenlose Testphase ist abgelaufen."),
|
||||||
platformHint,
|
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
|
] if p
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"payment_failed": {
|
"payment_failed": {
|
||||||
"subject": f"[PowerOn] Zahlung fehlgeschlagen — {planLabel}",
|
"subject": f"[PowerOn] {t('Zahlung fehlgeschlagen')} — {planLabel}",
|
||||||
"headline": "Zahlung fehlgeschlagen",
|
"headline": t("Zahlung fehlgeschlagen"),
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
p for p in [
|
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,
|
platformHint,
|
||||||
"Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung.",
|
t("Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung."),
|
||||||
] if p
|
] if p
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
tpl = templates.get(event, {
|
tpl = templates.get(event, {
|
||||||
"subject": f"[PowerOn] Abonnement-Änderung — {planLabel}",
|
"subject": f"[PowerOn] {t('Abonnement-Änderung')} — {planLabel}",
|
||||||
"headline": "Abonnement-Änderung",
|
"headline": t("Abonnement-Änderung"),
|
||||||
"paragraphs": [f"Änderung am Abonnement «{planLabel}»."],
|
"paragraphs": [t("Änderung am Abonnement «{planLabel}».").format(planLabel=planLabel)],
|
||||||
})
|
})
|
||||||
|
|
||||||
notifyMandateAdmins(
|
notifyMandateAdmins(
|
||||||
|
|
@ -699,7 +950,7 @@ def _buildInvoiceSummaryHtml(
|
||||||
instanceTotal = billableModules * instancePrice
|
instanceTotal = billableModules * instancePrice
|
||||||
netTotal = userTotal + instanceTotal
|
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:
|
def _chf(amount: float) -> str:
|
||||||
return f"CHF {amount:,.2f}".replace(",", "'")
|
return f"CHF {amount:,.2f}".replace(",", "'")
|
||||||
|
|
@ -707,13 +958,13 @@ def _buildInvoiceSummaryHtml(
|
||||||
rows = ""
|
rows = ""
|
||||||
if userPrice > 0:
|
if userPrice > 0:
|
||||||
rows += (
|
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 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'
|
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:
|
if instancePrice > 0 and billableModules > 0:
|
||||||
rows += (
|
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 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'
|
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 = (
|
invoiceLink = (
|
||||||
f'<p style="margin:12px 0 0 0;font-size:14px;">'
|
f'<p style="margin:12px 0 0 0;font-size:14px;">'
|
||||||
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
|
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:
|
except Exception as e:
|
||||||
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
|
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
|
||||||
|
|
@ -741,13 +992,13 @@ def _buildInvoiceSummaryHtml(
|
||||||
return (
|
return (
|
||||||
f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
|
f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
|
||||||
f'<thead><tr style="border-bottom:2px solid #e5e7eb;">'
|
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: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;">Menge × Preis</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;">Total</th>'
|
f'<th style="text-align:right;padding:8px 0;color:#6b7280;font-weight:500;">{t("Total")}</th>'
|
||||||
f'</tr></thead>'
|
f'</tr></thead>'
|
||||||
f'<tbody>{rows}</tbody>'
|
f'<tbody>{rows}</tbody>'
|
||||||
f'<tfoot><tr style="border-top:2px solid #1a1a2e;">'
|
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></td>'
|
||||||
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">{_chf(netTotal)}</td>'
|
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">{_chf(netTotal)}</td>'
|
||||||
f'</tr></tfoot>'
|
f'</tr></tfoot>'
|
||||||
|
|
@ -776,7 +1027,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
|
||||||
parts.append(
|
parts.append(
|
||||||
f'<p style="margin:4px 0;font-size:14px;">'
|
f'<p style="margin:4px 0;font-size:14px;">'
|
||||||
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
|
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:
|
except Exception as e:
|
||||||
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, 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.mandateId = mandateId
|
||||||
self.reason = _subscriptionReasonForStatus(status)
|
self.reason = _subscriptionReasonForStatus(status)
|
||||||
self.userAction = _subscriptionUserActionForStatus(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."
|
"Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing."
|
||||||
)
|
)
|
||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
|
|
@ -837,47 +1088,62 @@ class SubscriptionInactiveException(Exception):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
_SUBSCRIPTION_LIMITS_UI_HINT_DE = (
|
SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN"
|
||||||
" Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
|
|
||||||
"Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
|
|
||||||
)
|
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):
|
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.resourceType = resourceType
|
||||||
self.currentCount = currentCount
|
self.currentCount = currentCount
|
||||||
self.maxAllowed = maxAllowed
|
self.maxAllowed = maxAllowed
|
||||||
|
self.isEnterprise = isEnterprise
|
||||||
|
hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint()
|
||||||
if message is not None:
|
if message is not None:
|
||||||
self.message = message
|
self.message = message
|
||||||
elif resourceType == "users":
|
elif resourceType == "users":
|
||||||
self.message = (
|
self.message = t(
|
||||||
f"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
|
"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
|
||||||
f"Benutzer zulässig (derzeit {currentCount}). "
|
"Benutzer zulässig (derzeit {currentCount}). "
|
||||||
f"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
|
"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
|
||||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||||
elif resourceType == "featureInstances":
|
elif resourceType == "featureInstances":
|
||||||
self.message = (
|
self.message = t(
|
||||||
f"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
|
"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
|
||||||
f"Bitte Abonnement erweitern oder ein Modul entfernen."
|
"Bitte Abonnement erweitern oder ein Modul entfernen."
|
||||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||||
elif resourceType == "dataVolumeMB":
|
elif resourceType == "dataVolumeMB":
|
||||||
self.message = (
|
self.message = t(
|
||||||
f"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
|
"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
|
||||||
f"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
|
"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
|
||||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||||
else:
|
else:
|
||||||
self.message = (
|
self.message = t(
|
||||||
f"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
|
"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
|
||||||
f"aktuell {currentCount}, erlaubt {maxAllowed})."
|
"aktuell {currentCount}, erlaubt {maxAllowed})."
|
||||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint
|
||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
|
|
||||||
def toClientDict(self) -> Dict[str, Any]:
|
def toClientDict(self) -> Dict[str, Any]:
|
||||||
|
action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE
|
||||||
return {
|
return {
|
||||||
"error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT",
|
"error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT",
|
||||||
"currentCount": self.currentCount, "maxAllowed": self.maxAllowed,
|
"currentCount": self.currentCount, "maxAllowed": self.maxAllowed,
|
||||||
"message": self.message, "userAction": SUBSCRIPTION_USER_ACTION_UPGRADE,
|
"message": self.message, "userAction": action,
|
||||||
"subscriptionUiPath": "/admin/billing?tab=subscription",
|
"subscriptionUiPath": "/admin/billing?tab=subscription",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,3 +37,10 @@ def test_full_style_passthrough():
|
||||||
result = resolveStyle(custom)
|
result = resolveStyle(custom)
|
||||||
assert result["fonts"]["primary"] == "Helvetica"
|
assert result["fonts"]["primary"] == "Helvetica"
|
||||||
assert result["fonts"]["monospace"] == "Monaco"
|
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