gateway/modules/datamodels/datamodelInvitation.py
2026-04-26 22:53:44 +02:00

162 lines
6.2 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Invitation model for self-service onboarding.
Token-basierte Einladungen für neue User zu Mandanten/Features.
"""
import time
import uuid
import secrets
from typing import Optional, List
from pydantic import BaseModel, Field, computed_field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
@i18nModel("Einladung")
class Invitation(PowerOnModel):
"""
Einladungs-Token für neue User.
Ermöglicht Self-Service Onboarding zu Mandanten und Feature-Instanzen.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the invitation",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
token: str = Field(
default_factory=lambda: secrets.token_urlsafe(32),
description="Secure invitation token",
json_schema_extra={"label": "Token", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
mandateId: str = Field(
description="FK → Mandate.id - Target mandate for the invitation",
json_schema_extra={
"label": "Mandant",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
},
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Optional FK → FeatureInstance.id - Direct access to specific feature",
json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
},
)
roleIds: List[str] = Field(
default_factory=list,
description="List of Role IDs to assign to the invited user",
json_schema_extra={"label": "Rollen", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True}
)
targetUsername: Optional[str] = Field(
default=None,
description="Username of the invited user (must match on acceptance)",
json_schema_extra={"label": "Ziel-Benutzername", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
)
email: Optional[str] = Field(
default=None,
description="Email address to send invitation link (optional)",
json_schema_extra={"label": "E-Mail (optional)", "frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
)
expiresAt: float = Field(
description="When the invitation expires (UTC timestamp)",
json_schema_extra={"label": "Gueltig bis", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
)
usedBy: Optional[str] = Field(
default=None,
description="User ID of the person who used the invitation",
json_schema_extra={
"label": "Verwendet von",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
},
)
usedAt: Optional[float] = Field(
default=None,
description="When the invitation was used (UTC timestamp)",
json_schema_extra={"label": "Verwendet am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
revokedAt: Optional[float] = Field(
default=None,
description="When the invitation was revoked (UTC timestamp)",
json_schema_extra={"label": "Widerrufen am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
emailSentFlag: Optional[bool] = Field(
default=False,
description="Whether the invitation email was successfully sent",
json_schema_extra={
"label": "E-Mail gesendet",
"frontend_type": "checkbox",
"frontend_readonly": True,
"frontend_required": False,
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
emailSentAt: Optional[float] = Field(
default=None,
description="Timestamp when the invitation email was sent (UTC, seconds)",
json_schema_extra={
"label": "E-Mail gesendet am",
"frontend_type": "timestamp",
"frontend_readonly": True,
"frontend_required": False,
},
)
maxUses: int = Field(
default=1,
ge=1,
le=100,
description="Maximum number of times this invitation can be used",
json_schema_extra={"label": "Max. Verwendungen", "frontend_type": "number", "frontend_readonly": False, "frontend_required": False}
)
currentUses: int = Field(
default=0,
ge=0,
description="Current number of times this invitation has been used",
json_schema_extra={"label": "Aktuelle Verwendungen", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False}
)
@computed_field( # type: ignore[prop-decorator]
json_schema_extra={
"label": "Abgelaufen",
"frontend_type": "checkbox",
"frontend_readonly": True,
"frontend_required": False,
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
@property
def expiredFlag(self) -> bool:
"""True iff `expiresAt` lies in the past (UTC)."""
if self.expiresAt is None:
return False
return float(self.expiresAt) < time.time()
@computed_field( # type: ignore[prop-decorator]
json_schema_extra={
"label": "Verbraucht",
"frontend_type": "checkbox",
"frontend_readonly": True,
"frontend_required": False,
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
@property
def usedUpFlag(self) -> bool:
"""True iff `currentUses >= maxUses`."""
return (self.currentUses or 0) >= (self.maxUses or 1)