# ============================================================================== # Formeleon - Multi-tenant Form Generator # Author: Björn Runden # Module: models.py # Description: SQLAlchemy database models for multi-tenant form management. # Includes Tenant, FormConfig, Submission, and User models. # ============================================================================== """ Database models for Formeleon. This module defines the core data models using Flask-SQLAlchemy: - Tenant: Mandant/Client information with public keys and allowed origins - FormConfig: Dynamic form configurations with JSON structure - Submission: Form submissions with JSON payload data - User: Admin users for the management interface - AuditLog: Tracking of administrative actions All models support Flask-Migrate for database migrations. """ import json from datetime import datetime, timezone from typing import Optional, Any from enum import Enum from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash # Initialize SQLAlchemy without binding to a Flask app yet # This will be bound in app.py after configuration is loaded db = SQLAlchemy() class UserRole(Enum): """ Enumeration of user roles for access control. Roles: ADMIN: Full access to all tenants and configurations TENANT_ADMIN: Admin access limited to specific tenant(s) USER: Standard user with form creation/view permissions VIEWER: Read-only access to forms and submissions """ ADMIN = 'admin' TENANT_ADMIN = 'tenant_admin' USER = 'user' VIEWER = 'viewer' class SubmissionStatus(Enum): """ Enumeration of submission processing statuses. Statuses: PENDING: Submission received but not yet processed PROCESSED: Submission has been processed successfully FAILED: Submission processing failed SPAM: Submission flagged as spam (reCAPTCHA failed) ARCHIVED: Submission has been archived (hidden from default view) """ PENDING = 'pending' PROCESSED = 'processed' FAILED = 'failed' SPAM = 'spam' ARCHIVED = 'archived' class TenantStatus(Enum): """ Enumeration of tenant statuses. Statuses: ACTIVE: Tenant is active and can receive submissions SUSPENDED: Tenant is temporarily suspended INACTIVE: Tenant is inactive/disabled """ ACTIVE = 'active' SUSPENDED = 'suspended' INACTIVE = 'inactive' # Association table for many-to-many relationship between User and Tenant user_tenant_association = db.Table( 'user_tenant_association', db.Column('user_id', db.String(64), db.ForeignKey('user.id'), primary_key=True), db.Column('tenant_id', db.String(64), db.ForeignKey('tenant.id'), primary_key=True), db.Column('created_at', db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) ) class Tenant(db.Model): """ Tenant model representing a client/mandant in the multi-tenant system. Each tenant has: - Unique identifier and name - Public API key for form submissions - Allowed origins for CORS/handshake validation - Status and configuration settings Attributes: id: Unique tenant identifier name: Display name of the tenant public_key: API key used by client snippets for authentication allowed_origins: Comma-separated list of allowed origin domains status: Current status of the tenant config: Additional JSON configuration created_at: Timestamp when tenant was created updated_at: Timestamp when tenant was last updated """ __tablename__ = 'tenant' id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False) name = db.Column(db.String(255), nullable=False) public_key = db.Column(db.String(128), unique=True, nullable=False) allowed_origins = db.Column(db.Text, nullable=False, default='*') status = db.Column(db.Enum(TenantStatus), nullable=False, default=TenantStatus.ACTIVE) config = db.Column(db.JSON, nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) updated_at = db.Column( db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False ) # Relationships forms = db.relationship('FormConfig', backref='tenant', lazy=True, cascade='all, delete-orphan') submissions = db.relationship('Submission', backref='tenant', lazy=True) users = db.relationship('User', secondary=user_tenant_association, backref='tenants', lazy=True) def __init__(self, id: str, name: str, public_key: str, allowed_origins: str = '*', status: TenantStatus = TenantStatus.ACTIVE, config: Optional[dict] = None): """ Initialize a new Tenant instance. :param id: Unique tenant identifier :param name: Display name of the tenant :param public_key: API key for client authentication :param allowed_origins: Comma-separated allowed origins :param status: Initial tenant status :param config: Additional configuration as JSON """ self.id = id self.name = name self.public_key = public_key self.allowed_origins = allowed_origins self.status = status self.config = config or {} def get_allowed_origins_list(self) -> list: """ Get allowed origins as a list. :return: List of allowed origin strings """ return [origin.strip() for origin in self.allowed_origins.split(',') if origin.strip()] def is_origin_allowed(self, origin: str) -> bool: """ Check if a given origin is allowed for this tenant. :param origin: Origin to check :return: True if origin is allowed, False otherwise """ allowed = self.get_allowed_origins_list() if '*' in allowed: return True return origin in allowed def is_active(self) -> bool: """ Check if tenant is active. :return: True if tenant status is ACTIVE, False otherwise """ return self.status == TenantStatus.ACTIVE def to_dict(self) -> dict: """ Convert tenant to dictionary for API responses. :return: Dictionary representation of tenant """ return { 'id': self.id, 'name': self.name, 'public_key': self.public_key, 'allowed_origins': self.allowed_origins, 'status': self.status.value, 'config': self.config or {}, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None } def __repr__(self) -> str: return f'' class FormConfig(db.Model): """ Form configuration model storing dynamic form definitions. Each form configuration has: - Unique identifier - Reference to tenant - Name and description - JSON structure defining form fields and validation - Status and settings Attributes: id: Unique form identifier tenant_id: Foreign key to tenant name: Display name of the form description: Optional description form_structure: JSON defining form fields, types, and validation settings: Additional form settings (email notifications, etc.) is_active: Whether the form is active and accepting submissions created_at: Timestamp when form was created updated_at: Timestamp when form was last updated """ __tablename__ = 'form_config' id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False) tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=False) name = db.Column(db.String(255), nullable=False) description = db.Column(db.Text, nullable=True) form_structure = db.Column(db.JSON, nullable=False) settings = db.Column(db.JSON, nullable=True) is_active = db.Column(db.Boolean, default=True, nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) updated_at = db.Column( db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False ) # Relationships submissions = db.relationship('Submission', backref='form_config', lazy=True, cascade='all, delete-orphan') def __init__(self, id: str, tenant_id: str, name: str, form_structure: dict, description: Optional[str] = None, settings: Optional[dict] = None, is_active: bool = True): """ Initialize a new FormConfig instance. :param id: Unique form identifier :param tenant_id: Tenant this form belongs to :param name: Display name of the form :param form_structure: JSON defining form fields and structure :param description: Optional description :param settings: Additional form settings :param is_active: Whether form is active """ self.id = id self.tenant_id = tenant_id self.name = name self.description = description self.form_structure = form_structure self.settings = settings or {} self.is_active = is_active def get_field_definitions(self) -> list: """ Get the list of field definitions from form structure. :return: List of field definition dictionaries """ return self.form_structure.get('fields', []) def get_field_names(self) -> list: """ Get list of field names from form structure. :return: List of field name strings """ return [field.get('name') for field in self.get_field_definitions() if field.get('name')] def to_dict(self) -> dict: """ Convert form config to dictionary for API responses. :return: Dictionary representation of form config """ return { 'id': self.id, 'tenant_id': self.tenant_id, 'name': self.name, 'description': self.description, 'form_structure': self.form_structure, 'settings': self.settings or {}, 'is_active': self.is_active, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None } def __repr__(self) -> str: return f'' class Submission(db.Model): """ Submission model storing form submission data. Each submission has: - Unique identifier - Reference to form configuration - JSON payload with submitted data - IP hash for privacy-preserving identification - Status and submission_metadata Attributes: id: Unique submission identifier form_config_id: Foreign key to form configuration tenant_id: Foreign key to tenant (denormalized for easier queries) payload: JSON containing submitted form data ip_hash: Hash of submitter's IP address for rate limiting/duplicate detection user_agent: User agent string of submitter referer: Referer header from submission request recaptcha_score: reCAPTCHA score if validation was performed recaptcha_passed: Whether reCAPTCHA validation passed status: Current processing status submission_metadata: Additional metadata (headers, etc.) created_at: Timestamp when submission was created """ __tablename__ = 'submission' id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False) form_config_id = db.Column(db.String(64), db.ForeignKey('form_config.id'), nullable=False) tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=False) payload = db.Column(db.JSON, nullable=False) ip_hash = db.Column(db.String(64), nullable=True) user_agent = db.Column(db.Text, nullable=True) referer = db.Column(db.Text, nullable=True) recaptcha_score = db.Column(db.Float, nullable=True) recaptcha_passed = db.Column(db.Boolean, nullable=True) status = db.Column(db.Enum(SubmissionStatus), nullable=False, default=SubmissionStatus.PENDING) submission_metadata = db.Column(db.JSON, nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) def __init__(self, id: str, form_config_id: str, tenant_id: str, payload: dict, ip_hash: Optional[str] = None, user_agent: Optional[str] = None, referer: Optional[str] = None, recaptcha_score: Optional[float] = None, recaptcha_passed: Optional[bool] = None, submission_metadata: Optional[dict] = None, status: SubmissionStatus = SubmissionStatus.PENDING): """ Initialize a new Submission instance. :param id: Unique submission identifier :param form_config_id: Form configuration this submission belongs to :param tenant_id: Tenant this submission belongs to :param payload: JSON containing submitted form data :param ip_hash: Hash of submitter's IP :param user_agent: User agent string :param referer: Referer header :param recaptcha_score: reCAPTCHA score :param recaptcha_passed: Whether reCAPTCHA passed :param submission_metadata: Additional submission_metadata :param status: Initial submission status """ self.id = id self.form_config_id = form_config_id self.tenant_id = tenant_id self.payload = payload self.ip_hash = ip_hash self.user_agent = user_agent self.referer = referer self.recaptcha_score = recaptcha_score self.recaptcha_passed = recaptcha_passed self.submission_metadata = submission_metadata or {} self.status = status def get_field_value(self, field_name: str) -> Any: """ Get the value of a specific field from the payload. :param field_name: Name of the field to retrieve :return: Value of the field or None if not found """ return self.payload.get(field_name) def mark_as_processed(self) -> None: """ Mark submission as processed. """ self.status = SubmissionStatus.PROCESSED db.session.commit() def mark_as_spam(self) -> None: """ Mark submission as spam. """ self.status = SubmissionStatus.SPAM db.session.commit() def mark_as_failed(self) -> None: """ Mark submission as failed. """ self.status = SubmissionStatus.FAILED db.session.commit() def to_dict(self) -> dict: """ Convert submission to dictionary for API responses. :return: Dictionary representation of submission """ return { 'id': self.id, 'form_config_id': self.form_config_id, 'tenant_id': self.tenant_id, 'payload': self.payload, 'ip_hash': self.ip_hash, 'user_agent': self.user_agent, 'referer': self.referer, 'recaptcha_score': self.recaptcha_score, 'recaptcha_passed': self.recaptcha_passed, 'status': self.status.value, 'submission_metadata': self.submission_metadata or {}, 'created_at': self.created_at.isoformat() if self.created_at else None } def __repr__(self) -> str: return f'' class User(db.Model): """ User model for authentication and authorization. Users authenticate via Zitadel OIDC in production, with a dev mode bypass. Each user has: - Unique identifier (from OIDC or dev mode) - Email and name - Role for access control - Session management fields Attributes: id: Unique user identifier email: User email address name: Display name role: User role for access control sub: OIDC subject identifier (from Zitadel) last_login: Timestamp of last successful login created_at: Timestamp when user was created updated_at: Timestamp when user was last updated """ __tablename__ = 'user' id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False) email = db.Column(db.String(255), unique=True, nullable=False) name = db.Column(db.String(255), nullable=False) role = db.Column(db.Enum(UserRole), nullable=False, default=UserRole.USER) sub = db.Column(db.String(255), unique=True, nullable=True) last_login = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) updated_at = db.Column( db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False ) def __init__(self, id: str, email: str, name: str, role: UserRole = UserRole.USER, sub: Optional[str] = None): """ Initialize a new User instance. :param id: Unique user identifier :param email: User email address :param name: Display name :param role: User role :param sub: OIDC subject identifier """ self.id = id self.email = email self.name = name self.role = role self.sub = sub def update_last_login(self) -> None: """ Update the last login timestamp to now. """ self.last_login = datetime.now(timezone.utc) db.session.commit() def has_role(self, role: UserRole) -> bool: """ Check if user has a specific role or higher privilege. :param role: Role to check :return: True if user has the role or a higher-privilege role """ role_hierarchy = { UserRole.ADMIN: 4, UserRole.TENANT_ADMIN: 3, UserRole.USER: 2, UserRole.VIEWER: 1 } return role_hierarchy.get(self.role, 0) >= role_hierarchy.get(role, 0) def is_admin(self) -> bool: """ Check if user has admin privileges. :return: True if user is ADMIN or TENANT_ADMIN """ return self.role in [UserRole.ADMIN, UserRole.TENANT_ADMIN] def to_dict(self) -> dict: """ Convert user to dictionary for API responses. :return: Dictionary representation of user """ return { 'id': self.id, 'email': self.email, 'name': self.name, 'role': self.role.value, 'sub': self.sub, 'last_login': self.last_login.isoformat() if self.last_login else None, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None } def __repr__(self) -> str: return f'' class AuditLog(db.Model): """ Audit log model for tracking administrative actions. Each audit log entry records: - Who performed an action - What action was performed - On which resource - With what result Attributes: id: Unique log entry identifier user_id: User who performed the action (nullable for system actions) tenant_id: Tenant context (nullable for global actions) action: Type of action performed resource_type: Type of resource affected resource_id: Identifier of resource affected details: Additional details about the action ip_address: IP address of requester user_agent: User agent string status: Whether action succeeded or failed created_at: Timestamp when action was logged """ __tablename__ = 'audit_log' id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False) user_id = db.Column(db.String(64), db.ForeignKey('user.id'), nullable=True) tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=True) action = db.Column(db.String(64), nullable=False) resource_type = db.Column(db.String(64), nullable=False) resource_id = db.Column(db.String(64), nullable=True) details = db.Column(db.JSON, nullable=True) ip_address = db.Column(db.String(64), nullable=True) user_agent = db.Column(db.Text, nullable=True) status = db.Column(db.String(16), nullable=False, default='success') created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) def __init__(self, id: str, action: str, resource_type: str, user_id: Optional[str] = None, tenant_id: Optional[str] = None, resource_id: Optional[str] = None, details: Optional[dict] = None, ip_address: Optional[str] = None, user_agent: Optional[str] = None, status: str = 'success'): """ Initialize a new AuditLog instance. :param id: Unique log identifier :param action: Type of action performed :param resource_type: Type of resource affected :param user_id: User who performed the action :param tenant_id: Tenant context :param resource_id: Resource identifier :param details: Additional details :param ip_address: Requester IP address :param user_agent: Requester user agent :param status: Action status (success/failure) """ self.id = id self.user_id = user_id self.tenant_id = tenant_id self.action = action self.resource_type = resource_type self.resource_id = resource_id self.details = details or {} self.ip_address = ip_address self.user_agent = user_agent self.status = status @classmethod def log_action(cls, action: str, resource_type: str, user: Optional[User] = None, tenant: Optional[Tenant] = None, resource_id: Optional[str] = None, details: Optional[dict] = None, request_context: Optional[dict] = None, status: str = 'success') -> 'AuditLog': """ Convenience method to create an audit log entry. :param action: Type of action :param resource_type: Type of resource :param user: User performing the action :param tenant: Tenant context :param resource_id: Resource identifier :param details: Additional details :param request_context: Request context (ip_address, user_agent) :param status: Action status :return: Created audit log entry """ import uuid log_entry = cls( id=str(uuid.uuid4()), action=action, resource_type=resource_type, user_id=user.id if user else None, tenant_id=tenant.id if tenant else None, resource_id=resource_id, details=details, ip_address=request_context.get('ip_address') if request_context else None, user_agent=request_context.get('user_agent') if request_context else None, status=status ) db.session.add(log_entry) db.session.commit() return log_entry def __repr__(self) -> str: return f''