dockerized läuft
This commit is contained in:
1 parent
a8c95e9df5
commit
1c789aa8d5
10 files changed
+735
-286
No files matched your search
@@ -0,0 +1,97 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Formeleon - Docker Ignore File
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# Description: Specifies files and directories to exclude from Docker build context.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
.logs/
|
||||||
|
|
||||||
|
# Database files (persistent, handled by volumes)
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
formeleon.db
|
||||||
|
|
||||||
|
# Local instance files
|
||||||
|
#instance/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
docker-compose*.yml
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.gitmodules
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local development files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Data directories
|
||||||
|
data/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Note: static/, templates/, migrations/ are handled by volumes in development
|
||||||
|
# but need to be copied in Dockerfile for production builds
|
||||||
+1
-1
@@ -30,7 +30,7 @@ DEV_MODE=true
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# SQLite (default for development)
|
# SQLite (default for development)
|
||||||
# SQLALCHEMY_DATABASE_URI=sqlite:///formeleon.db
|
# SQLALCHEMY_DATABASE_URI=sqlite:///instance/formeleon.db
|
||||||
|
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
# SQLALCHEMY_DATABASE_URI=postgresql://username:password@localhost/formeleon
|
# SQLALCHEMY_DATABASE_URI=postgresql://username:password@localhost/formeleon
|
||||||
|
|||||||
+50
-25
@@ -2,52 +2,77 @@
|
|||||||
# Formeleon - Multi-tenant Form Generator
|
# Formeleon - Multi-tenant Form Generator
|
||||||
# Author: Björn Runden <br@runden.net>
|
# Author: Björn Runden <br@runden.net>
|
||||||
# File: Dockerfile
|
# File: Dockerfile
|
||||||
# Description: Docker container configuration for Formeleon application.
|
# Description: Multi-stage Docker build for development and production.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Use official Python runtime as parent image
|
# =============================================================================
|
||||||
# Use official Python 3.13 runtime as parent image
|
# Builder Stage: Install dependencies in a virtual environment
|
||||||
FROM python:3.13-slim
|
# =============================================================================
|
||||||
|
FROM python:3.13-slim as builder
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables for builder
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
FLASK_ENV=production \
|
PIP_NO_CACHE_DIR=1 \
|
||||||
FLASK_APP=app.py
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
# Set working directory
|
# Install build dependencies
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install system dependencies (einschließlich curl für Healthcheck)
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements file first to leverage Docker cache
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
# Install Python dependencies
|
# Create and activate virtual environment
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
RUN python -m venv /opt/venv
|
||||||
pip install --no-cache-dir -r requirements.txt
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Install python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --upgrade pip && \
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Final Stage: Lean runtime image
|
||||||
|
# =============================================================================
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# Set environment variables for runtime
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
FLASK_APP=app.py \
|
||||||
|
PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Install required runtime system dependencies (curl for healthcheck)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
libpq5 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the virtual environment from builder stage
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
|
||||||
|
# Copy entrypoint script and ensure it's executable
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create uploads directory
|
# Create necessary runtime directories
|
||||||
RUN mkdir -p /tmp/formeleon_uploads && \
|
RUN mkdir -p /tmp/formeleon_uploads && \
|
||||||
|
mkdir -p /app/data && \
|
||||||
|
mkdir -p /app/logs && \
|
||||||
touch /tmp/formeleon_uploads/.gitkeep
|
touch /tmp/formeleon_uploads/.gitkeep
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s \
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
CMD curl -f http://localhost:5000/api/v1/status || exit 1
|
CMD curl -f http://127.0.0.1:5000/ || exit 1
|
||||||
|
|
||||||
# Run the application with gthread workers for high concurrency
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--threads", "2", "--worker-class", "gthread", "app:app"]
|
CMD ["server"]
|
||||||
|
|
||||||
# For development (uncomment and use docker-compose)
|
|
||||||
# CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]
|
|
||||||
@@ -21,27 +21,31 @@ Formeleon ist ein zentral gehosteter Service (ChamäleonCloud), der es Webseiten
|
|||||||
|
|
||||||
## 🛠️ Tech-Stack
|
## 🛠️ Tech-Stack
|
||||||
|
|
||||||
* **Backend:** Python 3.12, Flask (Blueprint-Architektur), Flask-SQLAlchemy, Flask-Migrate (Alembic)
|
* **Backend:** Python 3.13, Flask (Blueprint-Architektur), Flask-SQLAlchemy, Flask-Migrate (Alembic)
|
||||||
* **Frontend (Admin-Panel):** Bootstrap 5, Mithril.js / HTML5
|
* **Frontend (Admin-Panel):** Bootstrap 5, Mithril.js / HTML5
|
||||||
* **Authentication:** OIDC via Zitadel (Admin-Panel)
|
* **Authentication:** OIDC via Zitadel (Admin-Panel)
|
||||||
* **Deployment:** Docker, Gunicorn, Gitea Actions (Runner)
|
* **Deployment:** Docker, Gunicorn, Gitea Actions (Runner)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Schnellstart (Dev-Mode)
|
## 🚀 Schnellstart (Lokal / Docker-less)
|
||||||
|
|
||||||
|
Das Projekt kann auf Windows/Linux komplett ohne Docker in einer Virtual Environment entwickelt werden:
|
||||||
|
|
||||||
1. **Repository klonen & Umgebung einrichten:**
|
1. **Repository klonen & Umgebung einrichten:**
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv venv
|
python -m venv venv
|
||||||
|
|
||||||
|
# Auf Linux/Mac:
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
|
# Auf Windows:
|
||||||
|
venv\Scripts\activate
|
||||||
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Umgebungsvariablen konfigurieren:**
|
2. **Umgebungsvariablen (optional):**
|
||||||
```bash
|
*(Formeleon startet standardmäßig im Dev-Mode mit einer lokalen SQLite-DB `formeleon.db`)*
|
||||||
cp .env.example .env
|
|
||||||
# DEV_MODE=true ist standardmäßig aktiviert
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Datenbank initialisieren & starten:**
|
3. **Datenbank initialisieren & starten:**
|
||||||
```bash
|
```bash
|
||||||
@@ -49,18 +53,57 @@ Formeleon ist ein zentral gehosteter Service (ChamäleonCloud), der es Webseiten
|
|||||||
flask db init
|
flask db init
|
||||||
|
|
||||||
# Datenbank-Schema erstellen
|
# Datenbank-Schema erstellen
|
||||||
flask db migrate -m "initial migration"
|
|
||||||
flask db upgrade
|
flask db upgrade
|
||||||
|
|
||||||
# Optionales Seeding mit Testdaten
|
# Optionales Seeding mit Testdaten
|
||||||
flask seed-db
|
flask seed-db
|
||||||
|
|
||||||
# Anwendung starten
|
# Anwendung starten (auf Port 5000 mit Hot-Reload)
|
||||||
flask run --port=5000
|
flask run --port=5000 --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🐳 Schnellstart (Docker)
|
||||||
|
|
||||||
|
Für das vollständige Linux- / Staging-Gefühl kannst du Docker nutzen. Durch Volume-Mounts bleibt der Code synchron, und du kannst im Editor (Windows/Mac) programmieren.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Image bauen und Container starten
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
# 2. Anwendung beenden
|
||||||
|
# (Strg+C oder:)
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# 3. Datenbank und Volumes komplett zurücksetzen
|
||||||
|
docker-compose down -v && docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker CLI-Kommandos ausführen
|
||||||
|
Du kannst `flask` Kommandos direkt gegen den laufenden Container feuern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Datenbank Migrationen anlegen
|
||||||
|
docker-compose run --rm app flask db migrate -m "neue migration"
|
||||||
|
|
||||||
|
# Datenbank-Schema updaten
|
||||||
|
docker-compose run --rm app flask db upgrade
|
||||||
|
|
||||||
|
# Datenbank mit Testdaten füllen
|
||||||
|
docker-compose run --rm app flask seed-db
|
||||||
|
|
||||||
|
# Shell im Container öffnen
|
||||||
|
docker-compose run --rm app bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zugriff auf die Anwendung
|
||||||
|
- **Admin Dashboard:** http://localhost:5000/admin/
|
||||||
|
- **API Endpunkt:** http://localhost:5000/api/v1/
|
||||||
|
- **Health Check:** http://localhost:5000/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📚 Dokumentation
|
## 📚 Dokumentation
|
||||||
|
|
||||||
* [`AGENTS.md`](./AGENTS.md) – Regelwerk und Anweisungen für KI-Coding-Agenten.
|
* [`AGENTS.md`](./AGENTS.md) – Regelwerk und Anweisungen für KI-Coding-Agenten.
|
||||||
|
|||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# Module: config.py
|
||||||
|
# Description: Application configuration with environment variable support.
|
||||||
|
# Handles development mode, reCAPTCHA, Zitadel OIDC, and database settings.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
Application configuration module for Formeleon.
|
||||||
|
|
||||||
|
This module centralizes all configuration settings for the Flask application,
|
||||||
|
loading from environment variables with sensible defaults for development.
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
- DEV_MODE: Enable development mode (default: False)
|
||||||
|
- SECRET_KEY: Flask secret key for session management
|
||||||
|
- SQLALCHEMY_DATABASE_URI: Database connection string
|
||||||
|
- SQLALCHEMY_TRACK_MODIFICATIONS: Track modifications (default: False)
|
||||||
|
|
||||||
|
- ZITADEL_ISSUER: OIDC issuer URL for Zitadel
|
||||||
|
- ZITADEL_CLIENT_ID: OIDC client ID
|
||||||
|
- ZITADEL_CLIENT_SECRET: OIDC client secret
|
||||||
|
- ZITADEL_REDIRECT_URI: OIDC redirect URI
|
||||||
|
|
||||||
|
- RECAPTCHA_SECRET_KEY: reCAPTCHA v3 secret key
|
||||||
|
- RECAPTCHA_SITE_KEY: reCAPTCHA v3 site key
|
||||||
|
- RECAPTCHA_THRESHOLD: Minimum score threshold (default: 0.5)
|
||||||
|
- MOCK_RECAPTCHA_SCORE: Mock score for dev mode (default: 0.9)
|
||||||
|
|
||||||
|
- DEV_USER_ROLE: Role for dev mode user (default: 'admin')
|
||||||
|
- DEV_TENANT_ID: Tenant ID for dev mode (default: 'tenant_dev_123')
|
||||||
|
- DEV_USER_NAME: Username for dev mode (default: 'Dev Admin')
|
||||||
|
- DEV_USER_EMAIL: Email for dev mode (default: 'dev@formeleon.local')
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""
|
||||||
|
Base configuration class with default values.
|
||||||
|
|
||||||
|
All configuration values can be overridden via environment variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Flask Core Settings
|
||||||
|
SECRET_KEY: str = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||||
|
DEBUG: bool = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
|
||||||
|
TESTING: bool = os.environ.get('FLASK_TESTING', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
# Development Mode
|
||||||
|
DEV_MODE: bool = os.environ.get('DEV_MODE', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
# Dev User Settings
|
||||||
|
DEV_USER_ROLE: str = os.environ.get('DEV_USER_ROLE', 'admin')
|
||||||
|
DEV_TENANT_ID: str = os.environ.get('DEV_TENANT_ID', 'tenant_dev_123')
|
||||||
|
DEV_USER_NAME: str = os.environ.get('DEV_USER_NAME', 'Dev Admin')
|
||||||
|
DEV_USER_EMAIL: str = os.environ.get('DEV_USER_EMAIL', 'dev@formeleon.local')
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
SQLALCHEMY_DATABASE_URI: str = os.environ.get(
|
||||||
|
'SQLALCHEMY_DATABASE_URI',
|
||||||
|
f'sqlite:///{os.path.join(BASE_DIR, "instance", "formeleon.db")}'
|
||||||
|
)
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
|
||||||
|
SQLALCHEMY_ECHO: bool = os.environ.get('SQLALCHEMY_ECHO', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
# Zitadel OIDC Configuration
|
||||||
|
ZITADEL_ISSUER: Optional[str] = os.environ.get('ZITADEL_ISSUER')
|
||||||
|
ZITADEL_CLIENT_ID: Optional[str] = os.environ.get('ZITADEL_CLIENT_ID')
|
||||||
|
ZITADEL_CLIENT_SECRET: Optional[str] = os.environ.get('ZITADEL_CLIENT_SECRET')
|
||||||
|
ZITADEL_REDIRECT_URI: Optional[str] = os.environ.get(
|
||||||
|
'ZITADEL_REDIRECT_URI',
|
||||||
|
'http://localhost:5000/auth/callback'
|
||||||
|
)
|
||||||
|
ZITADEL_SCOPES: list = ['openid', 'profile', 'email']
|
||||||
|
|
||||||
|
# reCAPTCHA v3 Configuration
|
||||||
|
RECAPTCHA_SECRET_KEY: Optional[str] = os.environ.get('RECAPTCHA_SECRET_KEY')
|
||||||
|
RECAPTCHA_SITE_KEY: Optional[str] = os.environ.get('RECAPTCHA_SITE_KEY')
|
||||||
|
RECAPTCHA_THRESHOLD: float = float(os.environ.get('RECAPTCHA_THRESHOLD', '0.5'))
|
||||||
|
MOCK_RECAPTCHA_SCORE: float = float(os.environ.get('MOCK_RECAPTCHA_SCORE', '0.9'))
|
||||||
|
RECAPTCHA_VERIFY_URL: str = 'https://www.google.com/recaptcha/api/siteverify'
|
||||||
|
|
||||||
|
# Application Settings
|
||||||
|
DEFAULT_PAGE_SIZE: int = 20
|
||||||
|
MAX_PAGE_SIZE: int = 100
|
||||||
|
|
||||||
|
# Form Submission Settings
|
||||||
|
ALLOWED_ORIGINS: list = os.environ.get('ALLOWED_ORIGINS', '*').split(',')
|
||||||
|
ENABLE_CORS: bool = os.environ.get('ENABLE_CORS', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# File Upload Settings
|
||||||
|
MAX_CONTENT_LENGTH: int = 16 * 1024 * 1024 # 16MB
|
||||||
|
UPLOAD_FOLDER: str = os.environ.get('UPLOAD_FOLDER', '/tmp/formeleon_uploads')
|
||||||
|
ALLOWED_EXTENSIONS: set = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'txt'}
|
||||||
|
|
||||||
|
# Session Configuration
|
||||||
|
SESSION_COOKIE_SECURE: bool = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
|
||||||
|
SESSION_COOKIE_HTTPONLY: bool = True
|
||||||
|
SESSION_COOKIE_SAMESITE: str = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
|
||||||
|
PERMANENT_SESSION_LIFETIME: int = 3600 # 1 hour in seconds
|
||||||
|
|
||||||
|
def is_dev_mode(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if application is running in development mode.
|
||||||
|
|
||||||
|
:return: True if DEV_MODE is enabled, False otherwise.
|
||||||
|
"""
|
||||||
|
return self.DEV_MODE
|
||||||
|
|
||||||
|
def is_recaptcha_enabled(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if reCAPTCHA is properly configured.
|
||||||
|
|
||||||
|
:return: True if both secret and site keys are configured, False otherwise.
|
||||||
|
"""
|
||||||
|
return (self.RECAPTCHA_SECRET_KEY is not None and
|
||||||
|
self.RECAPTCHA_SITE_KEY is not None)
|
||||||
|
|
||||||
|
def is_zitadel_enabled(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Zitadel OIDC is properly configured.
|
||||||
|
|
||||||
|
:return: True if all required Zitadel settings are present, False otherwise.
|
||||||
|
"""
|
||||||
|
return (self.ZITADEL_ISSUER is not None and
|
||||||
|
self.ZITADEL_CLIENT_ID is not None and
|
||||||
|
self.ZITADEL_CLIENT_SECRET is not None)
|
||||||
|
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
"""
|
||||||
|
Development-specific configuration.
|
||||||
|
|
||||||
|
Overrides base configuration with development-friendly settings.
|
||||||
|
"""
|
||||||
|
DEBUG: bool = True
|
||||||
|
DEV_MODE: bool = True
|
||||||
|
SQLALCHEMY_ECHO: bool = True
|
||||||
|
SESSION_COOKIE_SECURE: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
"""
|
||||||
|
Production-specific configuration.
|
||||||
|
|
||||||
|
Overrides base configuration with production-safe settings.
|
||||||
|
"""
|
||||||
|
DEBUG: bool = False
|
||||||
|
DEV_MODE: bool = False
|
||||||
|
SQLALCHEMY_ECHO: bool = False
|
||||||
|
SESSION_COOKIE_SECURE: bool = True
|
||||||
|
SESSION_COOKIE_SAMESITE: str = 'Strict'
|
||||||
|
|
||||||
|
|
||||||
|
class TestingConfig(Config):
|
||||||
|
"""
|
||||||
|
Testing-specific configuration.
|
||||||
|
|
||||||
|
Overrides base configuration with test-friendly settings.
|
||||||
|
"""
|
||||||
|
TESTING: bool = True
|
||||||
|
SQLALCHEMY_DATABASE_URI: str = 'sqlite:///:memory:'
|
||||||
|
WTF_CSRF_ENABLED: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration mapping for easy access
|
||||||
|
config_map: dict = {
|
||||||
|
'development': DevelopmentConfig,
|
||||||
|
'production': ProductionConfig,
|
||||||
|
'testing': TestingConfig,
|
||||||
|
'default': DevelopmentConfig if os.environ.get('FLASK_ENV', 'development') == 'development' else ProductionConfig
|
||||||
|
}
|
||||||
+9
-72
@@ -1,14 +1,4 @@
|
|||||||
# =============================================================================
|
|
||||||
# Formeleon - Multi-tenant Form Generator
|
|
||||||
# Author: Björn Runden <br@runden.net>
|
|
||||||
# File: docker-compose.yml
|
|
||||||
# Description: Docker Compose configuration for local development.
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Formeleon Application
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -17,67 +7,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
# Mount entire project directory for hot-reload in development
|
||||||
- ./formeleon.db:/app/formeleon.db
|
# Exclude the virtual environment if it exists locally to prevent conflicts with container venv
|
||||||
|
- ./:/app
|
||||||
|
- /app/venv
|
||||||
|
# Persistent data volumes mapping to host
|
||||||
|
# - ./data:/app/data
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
- /tmp/formeleon_uploads:/tmp/formeleon_uploads
|
# - ./formeleon.db:/app/formeleon.db
|
||||||
environment:
|
env_file:
|
||||||
- FLASK_ENV=development
|
- .env
|
||||||
- FLASK_DEBUG=1
|
|
||||||
- DEV_MODE=true
|
|
||||||
- SECRET_KEY=dev-secret-key-change-in-production
|
|
||||||
- SQLALCHEMY_DATABASE_URI=sqlite:///formeleon.db
|
|
||||||
- ZITADEL_ISSUER=
|
|
||||||
- ZITADEL_CLIENT_ID=
|
|
||||||
- ZITADEL_CLIENT_SECRET=
|
|
||||||
- RECAPTCHA_SITE_KEY=
|
|
||||||
- RECAPTCHA_SECRET_KEY=
|
|
||||||
- RECAPTCHA_THRESHOLD=0.5
|
|
||||||
- MOCK_RECAPTCHA_SCORE=0.9
|
|
||||||
- DEV_USER_ROLE=admin
|
|
||||||
- DEV_TENANT_ID=tenant_dev_123
|
|
||||||
- DEV_USER_NAME=Dev Admin
|
|
||||||
- DEV_USER_EMAIL=dev@formeleon.local
|
|
||||||
- ENABLE_CORS=true
|
|
||||||
- ALLOWED_ORIGINS=*
|
|
||||||
command: flask run --host=0.0.0.0 --port=5000
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
|
||||||
- formeleon-network
|
|
||||||
|
|
||||||
# PostgreSQL Database (optional, for production-like development)
|
|
||||||
# Uncomment to use PostgreSQL instead of SQLite
|
|
||||||
# postgres:
|
|
||||||
# image: postgres:15-alpine
|
|
||||||
# container_name: formeleon-postgres
|
|
||||||
# environment:
|
|
||||||
# - POSTGRES_DB=formeleon
|
|
||||||
# - POSTGRES_USER=formeleon
|
|
||||||
# - POSTGRES_PASSWORD=formeleon
|
|
||||||
# volumes:
|
|
||||||
# - postgres_data:/var/lib/postgresql/data
|
|
||||||
# ports:
|
|
||||||
# - "5432:5432"
|
|
||||||
# restart: unless-stopped
|
|
||||||
# networks:
|
|
||||||
# - formeleon-network
|
|
||||||
|
|
||||||
# Redis for Session Storage (optional)
|
|
||||||
# redis:
|
|
||||||
# image: redis:7-alpine
|
|
||||||
# container_name: formeleon-redis
|
|
||||||
# ports:
|
|
||||||
# - "6379:6379"
|
|
||||||
# volumes:
|
|
||||||
# - redis_data:/data
|
|
||||||
# restart: unless-stopped
|
|
||||||
# networks:
|
|
||||||
# - formeleon-network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
formeleon-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
# volumes:
|
|
||||||
# postgres_data:
|
|
||||||
# redis_data:
|
|
||||||
+236
-18
@@ -1,6 +1,181 @@
|
|||||||
# Deployment & CI/CD Guide
|
# Deployment & CI/CD Guide
|
||||||
|
|
||||||
## 1. Gitea Actions Workflow
|
## 1. Docker Development Cycle
|
||||||
|
|
||||||
|
Der empfohlene Entwicklungs-Workflow nutzt Docker Compose für eine isolierte Umgebung mit Hot-Reload.
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
- Docker (v20.10+)
|
||||||
|
- Docker Compose (v2.0+)
|
||||||
|
|
||||||
|
### Ersts Start (Build & Initialize)
|
||||||
|
```bash
|
||||||
|
# Navigiere zum Projektverzeichnis
|
||||||
|
cd formeleon
|
||||||
|
|
||||||
|
# Build Images und Start Container
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Entrypoint-Script führt automatisch folgende Schritte aus:
|
||||||
|
1. Prüft Python-Abhängigkeiten
|
||||||
|
2. Führt Datenbank-Migrationen aus (`flask db upgrade`)
|
||||||
|
3. Seeded die Datenbank mit Testdaten (`flask seed-db`) - nur bei leerer DB
|
||||||
|
4. Startet den Flask-Entwicklungsserver mit Hot-Reload
|
||||||
|
|
||||||
|
### Täglicher Entwicklungs-Workflow
|
||||||
|
|
||||||
|
#### Container starten/stoppen
|
||||||
|
```bash
|
||||||
|
# Starten (im Hintergrund)
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Stoppen
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Neu starten (nach Code-Änderungen)
|
||||||
|
docker-compose restart app
|
||||||
|
|
||||||
|
# Logs anzeigen
|
||||||
|
docker-compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Code-Änderungen testen
|
||||||
|
Dank der Volume-Mounts in `docker-compose.yml` werden Änderungen an folgenden Verzeichnissen sofort im Container sichtbar:
|
||||||
|
- `app.py`, `config.py`, `models.py`
|
||||||
|
- `routes/`
|
||||||
|
- `services/`
|
||||||
|
- `utils/`
|
||||||
|
- `templates/`
|
||||||
|
- `static/`
|
||||||
|
- `migrations/`
|
||||||
|
|
||||||
|
**Kein Container-Rebuild nötig!** Der Flask-Dev-Server erkennt Änderungen und lädt neu.
|
||||||
|
|
||||||
|
### Flask CLI-Kommandos in Docker
|
||||||
|
|
||||||
|
Führe Flask-Kommandos im laufenden Container aus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Allgemeine Syntax
|
||||||
|
docker-compose run --rm app flask <kommando>
|
||||||
|
|
||||||
|
# Beispiele:
|
||||||
|
# Migration erstellen
|
||||||
|
docker-compose run --rm app flask db migrate -m "add user table"
|
||||||
|
|
||||||
|
# Migration anwenden
|
||||||
|
docker-compose run --rm app flask db upgrade
|
||||||
|
|
||||||
|
# Migration zurücksetzen
|
||||||
|
docker-compose run --rm app flask db downgrade
|
||||||
|
|
||||||
|
# Datenbank mit Testdaten füllen
|
||||||
|
docker-compose run --rm app flask seed-db
|
||||||
|
|
||||||
|
# Mandanten verwalten
|
||||||
|
docker-compose run --rm app flask create-tenant "Test" "public-key-123"
|
||||||
|
|
||||||
|
# Benutzer verwalten
|
||||||
|
docker-compose run --rm app flask create-user "test@example.com" "Test User" --role=admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shell im Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interaktive Shell
|
||||||
|
docker-compose exec app bash
|
||||||
|
|
||||||
|
# Python Shell
|
||||||
|
docker-compose exec app python
|
||||||
|
|
||||||
|
# Flask Shell (mit App-Kontext)
|
||||||
|
docker-compose exec app flask shell
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenbank zurücksetzen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container stoppen und Volumes löschen (ACHTUNG: Alle Daten gehen verloren!)
|
||||||
|
docker-compose down -v
|
||||||
|
|
||||||
|
# Neu starten mit frischer Datenbank
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Docker Setup für Produktion
|
||||||
|
|
||||||
|
Für die Produktion sollte ein separates `docker-compose.prod.yml` erstellt werden.
|
||||||
|
|
||||||
|
### `docker-compose.prod.yml` (Beispiel)
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: formeleon-web
|
||||||
|
ports:
|
||||||
|
- "8000:5000"
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=production
|
||||||
|
- FLASK_DEBUG=0
|
||||||
|
- DEV_MODE=false
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- SQLALCHEMY_DATABASE_URI=${DATABASE_URL}
|
||||||
|
- ZITADEL_ISSUER=${ZITADEL_ISSUER}
|
||||||
|
- ZITADEL_CLIENT_ID=${ZITADEL_CLIENT_ID}
|
||||||
|
- ZITADEL_CLIENT_SECRET=${ZITADEL_CLIENT_SECRET}
|
||||||
|
- RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY}
|
||||||
|
- RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: formeleon-postgres
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=formeleon
|
||||||
|
- POSTGRES_USER=formeleon
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U formeleon"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Produktion starten
|
||||||
|
```bash
|
||||||
|
# Mit kombinierter Konfiguration
|
||||||
|
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
|
||||||
|
# Logs anzeigen
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Status prüfen
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Gitea Actions Workflow
|
||||||
|
|
||||||
Bei einem Push auf den `main`-Branch oder einem Release-Tag führt der Gitea-Runner automatisiert folgende Schritte auf dem Zielserver aus:
|
Bei einem Push auf den `main`-Branch oder einem Release-Tag führt der Gitea-Runner automatisiert folgende Schritte auf dem Zielserver aus:
|
||||||
|
|
||||||
@@ -8,27 +183,70 @@ Bei einem Push auf den `main`-Branch oder einem Release-Tag führt der Gitea-Run
|
|||||||
2. Erstellen/Aktualisieren der Docker-Images.
|
2. Erstellen/Aktualisieren der Docker-Images.
|
||||||
3. Zero-Downtime Restart der Container via Docker Compose.
|
3. Zero-Downtime Restart der Container via Docker Compose.
|
||||||
|
|
||||||
|
### Beispiel `.gitea/workflows/deploy.yml`
|
||||||
|
```yaml
|
||||||
|
name: Deploy to Production
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Stop services
|
||||||
|
run: docker-compose -f docker-compose.yml -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
- name: Pull latest images
|
||||||
|
run: docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
|
- name: Build and start
|
||||||
|
run: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: docker-compose -f docker-compose.yml -f docker-compose.prod.yml run --rm web flask db upgrade
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
run: docker system prune -f
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Docker Setup
|
## 4. Docker Troubleshooting
|
||||||
|
|
||||||
Das System läuft in einem schlanken Gunicorn-Container hinter einem Reverse Proxy.
|
### Häufige Probleme & Lösungen
|
||||||
|
|
||||||
### `docker-compose.yml` (Auszug)
|
| Problem | Lösung |
|
||||||
```yaml
|
|---------|--------|
|
||||||
version: '3.8'
|
| Port 5000 bereits belegt | `docker-compose down` und neu starten |
|
||||||
|
| Datenbank-Fehler | `docker-compose down -v && docker-compose up --build` |
|
||||||
|
| Python-Module nicht gefunden | `docker-compose build --no-cache && docker-compose up` |
|
||||||
|
| Container startet nicht | `docker-compose logs app` prüfen |
|
||||||
|
| Hot-Reload funktioniert nicht | Volume-Mounts in docker-compose.yml prüfen |
|
||||||
|
|
||||||
services:
|
### Debug-Modus
|
||||||
web:
|
|
||||||
build: .
|
|
||||||
restart: always
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
volumes:
|
|
||||||
- sqlite_data:/app/data
|
|
||||||
|
|
||||||
volumes:
|
```bash
|
||||||
sqlite_data:
|
# Container im Debug-Modus starten
|
||||||
|
docker-compose up --build --abort-on-container-exit
|
||||||
|
|
||||||
|
# Oder mit Shell
|
||||||
|
ENTERPOINT_OVERRIDE=bash docker-compose run --rm --service-ports app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache leeren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker Cache leeren
|
||||||
|
docker builder prune
|
||||||
|
|
||||||
|
# Unused Images entfernen
|
||||||
|
docker image prune -a
|
||||||
|
|
||||||
|
# System cleanup
|
||||||
|
docker system prune -a --volumes
|
||||||
```
|
```
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# Formeleon - Entrypoint Script
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# Description: Entrypoint script for Docker containers.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_NAME="formeleon"
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo "[$(date +'%Y-%m-%d %H:%M:%S')] [INFO] [${APP_NAME}] $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo "[$(date +'%Y-%m-%d %H:%M:%S')] [ERROR] [${APP_NAME}] $1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# If the first argument is a known flask or system command, run it directly
|
||||||
|
if [ "$1" = "flask" ] || [ "$1" = "bash" ] || [ "$1" = "sh" ] || [ "$1" = "python" ]; then
|
||||||
|
log_info "Executing command: $*"
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If the user passed "server" (default from Dockerfile CMD), start the app
|
||||||
|
if [ "$1" = "server" ]; then
|
||||||
|
|
||||||
|
# Optional DB Migration check on startup for Dev Mode (convenience)
|
||||||
|
if [ "${DEV_MODE:-false}" = "true" ] || [ "${FLASK_ENV:-development}" = "development" ]; then
|
||||||
|
log_info "Development mode detected. Ensuring DB schema is up to date..."
|
||||||
|
|
||||||
|
# Initialize migrations if folder doesn't exist
|
||||||
|
if [ ! -d "migrations" ]; then
|
||||||
|
log_info "Initializing migrations..."
|
||||||
|
flask db init
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Always try to upgrade to latest
|
||||||
|
flask db upgrade || log_error "Failed to upgrade DB, skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the actual server
|
||||||
|
if [ "${FLASK_ENV:-development}" = "development" ]; then
|
||||||
|
log_info "Starting Flask development server with hot reload..."
|
||||||
|
exec flask run --host=0.0.0.0 --port=5000 --debug
|
||||||
|
else
|
||||||
|
log_info "Starting Gunicorn production server..."
|
||||||
|
exec gunicorn --bind 0.0.0.0:5000 --workers 4 --threads 2 --worker-class gthread app:app
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: just execute whatever was passed
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
--- routes/api.py
|
||||||
|
+++ routes/api.py
|
||||||
|
@@ -58,15 +58,6 @@
|
||||||
|
def before_api_request():
|
||||||
|
"""Before request handler for API routes."""
|
||||||
|
- # Set CORS headers for API routes
|
||||||
|
- if current_app.config.get('ENABLE_CORS', True):
|
||||||
|
- response = current_app.make_default_options_response()
|
||||||
|
- response.headers['Access-Control-Allow-Origin'] = '*'
|
||||||
|
- response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
|
||||||
|
- response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Public-Key, X-Request-ID'
|
||||||
|
-
|
||||||
|
# Handle OPTIONS requests
|
||||||
|
if request.method == 'OPTIONS':
|
||||||
|
- return response
|
||||||
|
+ return current_app.make_default_options_response()
|
||||||
|
|
||||||
|
# Skip authentication for status endpoint and options requests
|
||||||
+38
-159
@@ -43,6 +43,10 @@ from models import db, Tenant, FormConfig, Submission, User, UserRole, TenantSta
|
|||||||
from models import AuditLog
|
from models import AuditLog
|
||||||
from services.recaptcha import RecaptchaService
|
from services.recaptcha import RecaptchaService
|
||||||
|
|
||||||
|
from services.tenant_admin_service import TenantAdminService
|
||||||
|
from services.form_admin_service import FormAdminService
|
||||||
|
from services.user_admin_service import UserAdminService
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -155,12 +159,7 @@ def list_tenants():
|
|||||||
"""List all tenants."""
|
"""List all tenants."""
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
|
|
||||||
# Filter tenants based on user role
|
tenants = TenantAdminService.get_tenants(user)
|
||||||
if user.role == UserRole.ADMIN:
|
|
||||||
tenants = Tenant.query.order_by(Tenant.created_at.desc()).all()
|
|
||||||
else:
|
|
||||||
# Non-admin users can only see their assigned tenants
|
|
||||||
tenants = user.tenants if user else []
|
|
||||||
|
|
||||||
return render_template('admin/tenants/list.html',
|
return render_template('admin/tenants/list.html',
|
||||||
user=user,
|
user=user,
|
||||||
@@ -181,27 +180,8 @@ def create_tenant():
|
|||||||
allowed_origins = request.form.get('allowed_origins', '*').strip()
|
allowed_origins = request.form.get('allowed_origins', '*').strip()
|
||||||
status = request.form.get('status', 'active')
|
status = request.form.get('status', 'active')
|
||||||
|
|
||||||
# Generate tenant ID
|
tenant = TenantAdminService.create_tenant(
|
||||||
tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
|
name, public_key, allowed_origins, status, user
|
||||||
|
|
||||||
# Create tenant
|
|
||||||
tenant = Tenant(
|
|
||||||
id=tenant_id,
|
|
||||||
name=name,
|
|
||||||
public_key=public_key or str(uuid.uuid4()).hex[:32],
|
|
||||||
allowed_origins=allowed_origins,
|
|
||||||
status=TenantStatus(status) if status in [s.value for s in TenantStatus] else TenantStatus.ACTIVE
|
|
||||||
)
|
|
||||||
db.session.add(tenant)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
AuditLog.log_action(
|
|
||||||
action='create',
|
|
||||||
resource_type='tenant',
|
|
||||||
user=user,
|
|
||||||
resource_id=tenant.id,
|
|
||||||
details={'name': name, 'public_key': public_key}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
flash('Tenant created successfully!', 'success')
|
flash('Tenant created successfully!', 'success')
|
||||||
@@ -230,24 +210,13 @@ def view_tenant(tenant_id):
|
|||||||
if not user.is_admin():
|
if not user.is_admin():
|
||||||
abort(403, description='Admin privileges required to edit tenant')
|
abort(403, description='Admin privileges required to edit tenant')
|
||||||
|
|
||||||
# Update tenant
|
TenantAdminService.update_tenant(
|
||||||
tenant.name = request.form.get('name', tenant.name)
|
tenant,
|
||||||
tenant.public_key = request.form.get('public_key', tenant.public_key)
|
request.form.get('name'),
|
||||||
tenant.allowed_origins = request.form.get('allowed_origins', tenant.allowed_origins)
|
request.form.get('public_key'),
|
||||||
|
request.form.get('allowed_origins'),
|
||||||
status = request.form.get('status')
|
request.form.get('status'),
|
||||||
if status in [s.value for s in TenantStatus]:
|
user
|
||||||
tenant.status = TenantStatus(status)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
AuditLog.log_action(
|
|
||||||
action='update',
|
|
||||||
resource_type='tenant',
|
|
||||||
user=user,
|
|
||||||
resource_id=tenant.id,
|
|
||||||
details={'name': tenant.name, 'status': tenant.status.value}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
flash('Tenant updated successfully!', 'success')
|
flash('Tenant updated successfully!', 'success')
|
||||||
@@ -276,18 +245,7 @@ def delete_tenant(tenant_id):
|
|||||||
if not tenant:
|
if not tenant:
|
||||||
abort(404, description='Tenant not found')
|
abort(404, description='Tenant not found')
|
||||||
|
|
||||||
# Delete tenant (cascade will handle related forms and submissions)
|
TenantAdminService.delete_tenant(tenant, user)
|
||||||
db.session.delete(tenant)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
AuditLog.log_action(
|
|
||||||
action='delete',
|
|
||||||
resource_type='tenant',
|
|
||||||
user=user,
|
|
||||||
resource_id=tenant.id,
|
|
||||||
details={'name': tenant.name}
|
|
||||||
)
|
|
||||||
|
|
||||||
flash('Tenant deleted successfully!', 'success')
|
flash('Tenant deleted successfully!', 'success')
|
||||||
return redirect(url_for('admin.list_tenants'))
|
return redirect(url_for('admin.list_tenants'))
|
||||||
@@ -300,19 +258,7 @@ def list_forms():
|
|||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
tenant = get_current_tenant()
|
tenant = get_current_tenant()
|
||||||
|
|
||||||
# Get forms based on user access
|
forms = FormAdminService.get_forms(user, tenant)
|
||||||
if user.role == UserRole.ADMIN:
|
|
||||||
# Admin can see all forms
|
|
||||||
forms = FormConfig.query.join(Tenant).order_by(
|
|
||||||
FormConfig.created_at.desc()
|
|
||||||
).all()
|
|
||||||
elif tenant:
|
|
||||||
# Tenant user sees only their tenant's forms
|
|
||||||
forms = FormConfig.query.filter_by(tenant_id=tenant.id).order_by(
|
|
||||||
FormConfig.created_at.desc()
|
|
||||||
).all()
|
|
||||||
else:
|
|
||||||
forms = []
|
|
||||||
|
|
||||||
return render_template('admin/forms/list.html',
|
return render_template('admin/forms/list.html',
|
||||||
user=user,
|
user=user,
|
||||||
@@ -353,30 +299,10 @@ def create_form():
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
settings = {}
|
settings = {}
|
||||||
|
|
||||||
# Generate form ID
|
form = FormAdminService.create_form(
|
||||||
form_id = f'form_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
|
tenant, name, description, form_structure, settings,
|
||||||
|
request.form.get('is_active', 'true').lower() == 'true',
|
||||||
# Create form
|
user
|
||||||
form = FormConfig(
|
|
||||||
id=form_id,
|
|
||||||
tenant_id=tenant.id,
|
|
||||||
name=name,
|
|
||||||
description=description,
|
|
||||||
form_structure=form_structure,
|
|
||||||
settings=settings,
|
|
||||||
is_active=request.form.get('is_active', 'true').lower() == 'true'
|
|
||||||
)
|
|
||||||
db.session.add(form)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
AuditLog.log_action(
|
|
||||||
action='create',
|
|
||||||
resource_type='form',
|
|
||||||
user=user,
|
|
||||||
tenant=tenant,
|
|
||||||
resource_id=form.id,
|
|
||||||
details={'name': name, 'tenant_id': tenant.id}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
flash('Form created successfully!', 'success')
|
flash('Form created successfully!', 'success')
|
||||||
@@ -438,37 +364,26 @@ def view_form(form_id):
|
|||||||
abort(403, description='Access denied')
|
abort(403, description='Access denied')
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Update form
|
|
||||||
form.name = request.form.get('name', form.name)
|
|
||||||
form.description = request.form.get('description', form.description)
|
|
||||||
form.is_active = request.form.get('is_active', 'true').lower() == 'true'
|
|
||||||
|
|
||||||
# Update form structure
|
|
||||||
form_structure_str = request.form.get('form_structure', '{}')
|
form_structure_str = request.form.get('form_structure', '{}')
|
||||||
|
form_structure = None
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
form.form_structure = json.loads(form_structure_str)
|
form_structure = json.loads(form_structure_str)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass # Keep existing structure
|
pass
|
||||||
|
|
||||||
# Update settings
|
|
||||||
settings_str = request.form.get('settings', '{}')
|
settings_str = request.form.get('settings', '{}')
|
||||||
|
settings = None
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
form.settings = json.loads(settings_str)
|
settings = json.loads(settings_str)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass # Keep existing settings
|
pass
|
||||||
|
|
||||||
db.session.commit()
|
FormAdminService.update_form(
|
||||||
|
form, request.form.get('name'), request.form.get('description'),
|
||||||
# Audit log
|
request.form.get('is_active', 'true').lower() == 'true',
|
||||||
AuditLog.log_action(
|
form_structure, settings, user, tenant
|
||||||
action='update',
|
|
||||||
resource_type='form',
|
|
||||||
user=user,
|
|
||||||
tenant=tenant,
|
|
||||||
resource_id=form.id,
|
|
||||||
details={'name': form.name, 'is_active': form.is_active}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
flash('Form updated successfully!', 'success')
|
flash('Form updated successfully!', 'success')
|
||||||
@@ -506,19 +421,7 @@ def delete_form(form_id):
|
|||||||
if user.role != UserRole.ADMIN and tenant not in user.tenants:
|
if user.role != UserRole.ADMIN and tenant not in user.tenants:
|
||||||
abort(403, description='Access denied')
|
abort(403, description='Access denied')
|
||||||
|
|
||||||
# Delete form (cascade will handle submissions)
|
FormAdminService.delete_form(form, user, tenant)
|
||||||
db.session.delete(form)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
AuditLog.log_action(
|
|
||||||
action='delete',
|
|
||||||
resource_type='form',
|
|
||||||
user=user,
|
|
||||||
tenant=tenant,
|
|
||||||
resource_id=form.id,
|
|
||||||
details={'name': form.name}
|
|
||||||
)
|
|
||||||
|
|
||||||
flash('Form deleted successfully!', 'success')
|
flash('Form deleted successfully!', 'success')
|
||||||
return redirect(url_for('admin.list_forms'))
|
return redirect(url_for('admin.list_forms'))
|
||||||
@@ -622,7 +525,7 @@ def update_submission_status(submission_id):
|
|||||||
def list_users():
|
def list_users():
|
||||||
"""List all users (admin only)."""
|
"""List all users (admin only)."""
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
users = User.query.order_by(User.created_at.desc()).all()
|
users = UserAdminService.get_users()
|
||||||
|
|
||||||
return render_template('admin/users/list.html',
|
return render_template('admin/users/list.html',
|
||||||
user=user,
|
user=user,
|
||||||
@@ -641,23 +544,9 @@ def view_user(user_id):
|
|||||||
abort(404, description='User not found')
|
abort(404, description='User not found')
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Update user
|
UserAdminService.update_user(
|
||||||
target_user.email = request.form.get('email', target_user.email)
|
target_user, request.form.get('email'), request.form.get('name'),
|
||||||
target_user.name = request.form.get('name', target_user.name)
|
request.form.get('role'), user
|
||||||
|
|
||||||
role = request.form.get('role')
|
|
||||||
if role in [r.value for r in UserRole]:
|
|
||||||
target_user.role = UserRole(role)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
AuditLog.log_action(
|
|
||||||
action='update',
|
|
||||||
resource_type='user',
|
|
||||||
user=user,
|
|
||||||
resource_id=target_user.id,
|
|
||||||
details={'email': target_user.email, 'role': target_user.role.value}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
flash('User updated successfully!', 'success')
|
flash('User updated successfully!', 'success')
|
||||||
@@ -689,17 +578,7 @@ def delete_user(user_id):
|
|||||||
flash('Cannot delete your own account!', 'error')
|
flash('Cannot delete your own account!', 'error')
|
||||||
return redirect(url_for('admin.view_user', user_id=target_user.id))
|
return redirect(url_for('admin.view_user', user_id=target_user.id))
|
||||||
|
|
||||||
db.session.delete(target_user)
|
UserAdminService.delete_user(target_user, user)
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
AuditLog.log_action(
|
|
||||||
action='delete',
|
|
||||||
resource_type='user',
|
|
||||||
user=user,
|
|
||||||
resource_id=target_user.id,
|
|
||||||
details={'email': target_user.email}
|
|
||||||
)
|
|
||||||
|
|
||||||
flash('User deleted successfully!', 'success')
|
flash('User deleted successfully!', 'success')
|
||||||
return redirect(url_for('admin.list_users'))
|
return redirect(url_for('admin.list_users'))
|
||||||
|
|||||||
Reference in new issue
Block a user