Compare commits
13
Commits
8232f4d8f7
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dc0d9588f | ||
|
|
78f1a68438 | ||
|
|
6f0d0a952d | ||
|
|
574e3cb6c4 | ||
|
|
0739de4b44 | ||
|
|
a54f26f808 | ||
|
|
b5f1e7a469 | ||
|
|
e72a66fe94 | ||
|
|
8f92e53fa8 | ||
|
|
1c789aa8d5 | ||
|
|
a8c95e9df5 | ||
|
|
e0081e6d46 | ||
|
|
1038b4ff05 |
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
|
||||
+3
-1
@@ -22,6 +22,8 @@ FLASK_DEBUG=true
|
||||
# Development Mode - enables fake auth and mocking
|
||||
DEV_MODE=true
|
||||
|
||||
APP_NAME=formeleon_dev
|
||||
|
||||
# Application port (default: 5000)
|
||||
# PORT=5000
|
||||
|
||||
@@ -30,7 +32,7 @@ DEV_MODE=true
|
||||
# =============================================================================
|
||||
|
||||
# SQLite (default for development)
|
||||
# SQLALCHEMY_DATABASE_URI=sqlite:///formeleon.db
|
||||
# SQLALCHEMY_DATABASE_URI=sqlite:///instance/formeleon.db
|
||||
|
||||
# PostgreSQL
|
||||
# SQLALCHEMY_DATABASE_URI=postgresql://username:password@localhost/formeleon
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Formeleon Deployment Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- stable
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------
|
||||
# 1. DEPLOYMENT TO DEVELOPMENT (branch: main)
|
||||
# ---------------------------------------------------
|
||||
deploy-dev:
|
||||
name: Deploy to Dev Environment
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy via SSH to Dev
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
# Flexibel über "Settings -> Actions -> Variables" editierbar:
|
||||
host: ${{ vars.DEV_HOST }}
|
||||
username: ${{ vars.DEV_DEPLOY_USER }}
|
||||
|
||||
# Passwörter/Keys weiterhin über "Secrets":
|
||||
key: ${{ secrets.DEV_SSH_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
cd /cryptDisk/formeleon-dev
|
||||
git pull origin main
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
|
||||
# ---------------------------------------------------
|
||||
# 2. DEPLOYMENT TO PRODUCTION (branch: stable)
|
||||
# ---------------------------------------------------
|
||||
deploy-prod:
|
||||
name: Deploy to Production Environment
|
||||
if: github.ref == 'refs/heads/stable'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy via SSH to Prod
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ vars.PROD_HOST }}
|
||||
username: ${{ vars.PROD_DEPLOY_USER }}
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
cd /cryptDisk/formeleon-prod
|
||||
git pull origin stable
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
+50
-25
@@ -2,52 +2,77 @@
|
||||
# Formeleon - Multi-tenant Form Generator
|
||||
# Author: Björn Runden <br@runden.net>
|
||||
# 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
|
||||
FROM python:3.13-slim
|
||||
# =============================================================================
|
||||
# Builder Stage: Install dependencies in a virtual environment
|
||||
# =============================================================================
|
||||
FROM python:3.13-slim as builder
|
||||
|
||||
# Set environment variables
|
||||
# Set environment variables for builder
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
FLASK_ENV=production \
|
||||
FLASK_APP=app.py
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies (einschließlich curl für Healthcheck)
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements file first to leverage Docker cache
|
||||
COPY requirements.txt .
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
# Create and activate virtual environment
|
||||
RUN python -m venv /opt/venv
|
||||
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 . .
|
||||
|
||||
# Create uploads directory
|
||||
# Create necessary runtime directories
|
||||
RUN mkdir -p /tmp/formeleon_uploads && \
|
||||
mkdir -p /app/data && \
|
||||
mkdir -p /app/logs && \
|
||||
touch /tmp/formeleon_uploads/.gitkeep
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
|
||||
# Health check
|
||||
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
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--threads", "2", "--worker-class", "gthread", "app:app"]
|
||||
|
||||
# For development (uncomment and use docker-compose)
|
||||
# CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["server"]
|
||||
@@ -21,27 +21,31 @@ Formeleon ist ein zentral gehosteter Service (ChamäleonCloud), der es Webseiten
|
||||
|
||||
## 🛠️ 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
|
||||
* **Authentication:** OIDC via Zitadel (Admin-Panel)
|
||||
* **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:**
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
python -m venv venv
|
||||
|
||||
# Auf Linux/Mac:
|
||||
source venv/bin/activate
|
||||
# Auf Windows:
|
||||
venv\Scripts\activate
|
||||
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **Umgebungsvariablen konfigurieren:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# DEV_MODE=true ist standardmäßig aktiviert
|
||||
```
|
||||
2. **Umgebungsvariablen (optional):**
|
||||
*(Formeleon startet standardmäßig im Dev-Mode mit einer lokalen SQLite-DB `formeleon.db`)*
|
||||
|
||||
3. **Datenbank initialisieren & starten:**
|
||||
```bash
|
||||
@@ -49,18 +53,57 @@ Formeleon ist ein zentral gehosteter Service (ChamäleonCloud), der es Webseiten
|
||||
flask db init
|
||||
|
||||
# Datenbank-Schema erstellen
|
||||
flask db migrate -m "initial migration"
|
||||
flask db upgrade
|
||||
|
||||
# Optionales Seeding mit Testdaten
|
||||
flask seed-db
|
||||
|
||||
# Anwendung starten
|
||||
flask run --port=5000
|
||||
# Anwendung starten (auf Port 5000 mit Hot-Reload)
|
||||
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
|
||||
|
||||
* [`AGENTS.md`](./AGENTS.md) – Regelwerk und Anweisungen für KI-Coding-Agenten.
|
||||
|
||||
@@ -39,6 +39,7 @@ from utils.cli import register_cli_commands
|
||||
from utils.middleware import setup_dev_mode_middleware, get_current_user, get_current_tenant
|
||||
from utils.helpers import get_request_context
|
||||
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
# Initialize extensions globally (will be bound to app in create_app)
|
||||
migrate = Migrate()
|
||||
@@ -66,6 +67,12 @@ def create_app(config_class: Optional[type] = None) -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class or get_config_class())
|
||||
|
||||
|
||||
# Vertraue 1 Reverse Proxy (NPM) für X-Forwarded-For, Proto, Host, Port
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
|
||||
)
|
||||
|
||||
# Set up logging
|
||||
setup_logging(app)
|
||||
|
||||
@@ -229,4 +236,4 @@ app = create_app()
|
||||
if __name__ == '__main__':
|
||||
# This allows running the app directly with python app.py
|
||||
# In production, use gunicorn or other WSGI servers
|
||||
app.run(host='0.0.0.0', port=5000, debug=app.config['DEBUG'])
|
||||
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 5000)), debug=app.config['DEBUG'])
|
||||
@@ -38,6 +38,8 @@ import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
class Config:
|
||||
"""
|
||||
Base configuration class with default values.
|
||||
@@ -60,10 +62,11 @@ class Config:
|
||||
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',
|
||||
'sqlite:///formeleon.db'
|
||||
)
|
||||
if 'SQLALCHEMY_DATABASE_URI' in os.environ:
|
||||
SQLALCHEMY_DATABASE_URI: str = os.environ['SQLALCHEMY_DATABASE_URI']
|
||||
else:
|
||||
SQLALCHEMY_DATABASE_URI: str = 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'
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
+12
-74
@@ -1,83 +1,21 @@
|
||||
# =============================================================================
|
||||
# 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:
|
||||
# Formeleon Application
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: formeleon-app
|
||||
container_name: "${APP_NAME:-formeleon}"
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "${PORT:-5000}:5000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./formeleon.db:/app/formeleon.db
|
||||
# Mount entire project directory for hot-reload in development
|
||||
# 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
|
||||
- ./instance:/app/instance
|
||||
- ./logs:/app/logs
|
||||
- /tmp/formeleon_uploads:/tmp/formeleon_uploads
|
||||
environment:
|
||||
- FLASK_ENV=development
|
||||
- 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
|
||||
# - ./formeleon.db:/app/formeleon.db
|
||||
env_file:
|
||||
- .env
|
||||
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
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -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.
|
||||
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)
|
||||
```yaml
|
||||
version: '3.8'
|
||||
| Problem | Lösung |
|
||||
|---------|--------|
|
||||
| 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:
|
||||
web:
|
||||
build: .
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- sqlite_data:/app/data
|
||||
### Debug-Modus
|
||||
|
||||
volumes:
|
||||
sqlite_data:
|
||||
```bash
|
||||
# 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 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__)
|
||||
|
||||
@@ -155,12 +159,7 @@ def list_tenants():
|
||||
"""List all tenants."""
|
||||
user = get_current_user()
|
||||
|
||||
# Filter tenants based on user role
|
||||
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 []
|
||||
tenants = TenantAdminService.get_tenants(user)
|
||||
|
||||
return render_template('admin/tenants/list.html',
|
||||
user=user,
|
||||
@@ -181,27 +180,8 @@ def create_tenant():
|
||||
allowed_origins = request.form.get('allowed_origins', '*').strip()
|
||||
status = request.form.get('status', 'active')
|
||||
|
||||
# Generate tenant ID
|
||||
tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
|
||||
|
||||
# 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}
|
||||
tenant = TenantAdminService.create_tenant(
|
||||
name, public_key, allowed_origins, status, user
|
||||
)
|
||||
|
||||
flash('Tenant created successfully!', 'success')
|
||||
@@ -230,24 +210,13 @@ def view_tenant(tenant_id):
|
||||
if not user.is_admin():
|
||||
abort(403, description='Admin privileges required to edit tenant')
|
||||
|
||||
# Update tenant
|
||||
tenant.name = request.form.get('name', tenant.name)
|
||||
tenant.public_key = request.form.get('public_key', tenant.public_key)
|
||||
tenant.allowed_origins = request.form.get('allowed_origins', tenant.allowed_origins)
|
||||
|
||||
status = request.form.get('status')
|
||||
if status in [s.value for s in TenantStatus]:
|
||||
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}
|
||||
TenantAdminService.update_tenant(
|
||||
tenant,
|
||||
request.form.get('name'),
|
||||
request.form.get('public_key'),
|
||||
request.form.get('allowed_origins'),
|
||||
request.form.get('status'),
|
||||
user
|
||||
)
|
||||
|
||||
flash('Tenant updated successfully!', 'success')
|
||||
@@ -276,18 +245,7 @@ def delete_tenant(tenant_id):
|
||||
if not tenant:
|
||||
abort(404, description='Tenant not found')
|
||||
|
||||
# Delete tenant (cascade will handle related forms and submissions)
|
||||
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}
|
||||
)
|
||||
TenantAdminService.delete_tenant(tenant, user)
|
||||
|
||||
flash('Tenant deleted successfully!', 'success')
|
||||
return redirect(url_for('admin.list_tenants'))
|
||||
@@ -300,19 +258,7 @@ def list_forms():
|
||||
user = get_current_user()
|
||||
tenant = get_current_tenant()
|
||||
|
||||
# Get forms based on user access
|
||||
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 = []
|
||||
forms = FormAdminService.get_forms(user, tenant)
|
||||
|
||||
return render_template('admin/forms/list.html',
|
||||
user=user,
|
||||
@@ -353,30 +299,10 @@ def create_form():
|
||||
except json.JSONDecodeError:
|
||||
settings = {}
|
||||
|
||||
# Generate form ID
|
||||
form_id = f'form_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
|
||||
|
||||
# Create form
|
||||
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}
|
||||
form = FormAdminService.create_form(
|
||||
tenant, name, description, form_structure, settings,
|
||||
request.form.get('is_active', 'true').lower() == 'true',
|
||||
user
|
||||
)
|
||||
|
||||
flash('Form created successfully!', 'success')
|
||||
@@ -438,37 +364,26 @@ def view_form(form_id):
|
||||
abort(403, description='Access denied')
|
||||
|
||||
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 = None
|
||||
try:
|
||||
import json
|
||||
form.form_structure = json.loads(form_structure_str)
|
||||
form_structure = json.loads(form_structure_str)
|
||||
except json.JSONDecodeError:
|
||||
pass # Keep existing structure
|
||||
|
||||
# Update settings
|
||||
pass
|
||||
|
||||
settings_str = request.form.get('settings', '{}')
|
||||
settings = None
|
||||
try:
|
||||
import json
|
||||
form.settings = json.loads(settings_str)
|
||||
settings = json.loads(settings_str)
|
||||
except json.JSONDecodeError:
|
||||
pass # Keep existing settings
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Audit log
|
||||
AuditLog.log_action(
|
||||
action='update',
|
||||
resource_type='form',
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
resource_id=form.id,
|
||||
details={'name': form.name, 'is_active': form.is_active}
|
||||
pass
|
||||
|
||||
FormAdminService.update_form(
|
||||
form, request.form.get('name'), request.form.get('description'),
|
||||
request.form.get('is_active', 'true').lower() == 'true',
|
||||
form_structure, settings, user, tenant
|
||||
)
|
||||
|
||||
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:
|
||||
abort(403, description='Access denied')
|
||||
|
||||
# Delete form (cascade will handle submissions)
|
||||
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}
|
||||
)
|
||||
FormAdminService.delete_form(form, user, tenant)
|
||||
|
||||
flash('Form deleted successfully!', 'success')
|
||||
return redirect(url_for('admin.list_forms'))
|
||||
@@ -622,7 +525,7 @@ def update_submission_status(submission_id):
|
||||
def list_users():
|
||||
"""List all users (admin only)."""
|
||||
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',
|
||||
user=user,
|
||||
@@ -641,23 +544,9 @@ def view_user(user_id):
|
||||
abort(404, description='User not found')
|
||||
|
||||
if request.method == 'POST':
|
||||
# Update user
|
||||
target_user.email = request.form.get('email', target_user.email)
|
||||
target_user.name = request.form.get('name', target_user.name)
|
||||
|
||||
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}
|
||||
UserAdminService.update_user(
|
||||
target_user, request.form.get('email'), request.form.get('name'),
|
||||
request.form.get('role'), user
|
||||
)
|
||||
|
||||
flash('User updated successfully!', 'success')
|
||||
@@ -689,17 +578,7 @@ def delete_user(user_id):
|
||||
flash('Cannot delete your own account!', 'error')
|
||||
return redirect(url_for('admin.view_user', user_id=target_user.id))
|
||||
|
||||
db.session.delete(target_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}
|
||||
)
|
||||
UserAdminService.delete_user(target_user, user)
|
||||
|
||||
flash('User deleted successfully!', 'success')
|
||||
return redirect(url_for('admin.list_users'))
|
||||
|
||||
+2
-2
@@ -57,14 +57,14 @@ 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_response()
|
||||
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 jsonify({'status': 'ok'}), 200
|
||||
return current_app.make_default_options_response()
|
||||
|
||||
|
||||
@api_bp.errorhandler(BadRequest)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import Optional, List
|
||||
from models import db, Tenant, FormConfig, Submission, UserRole
|
||||
from models import AuditLog
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class FormAdminService:
|
||||
@staticmethod
|
||||
def get_forms(user, tenant):
|
||||
if user.role == UserRole.ADMIN:
|
||||
return FormConfig.query.join(Tenant).order_by(FormConfig.created_at.desc()).all()
|
||||
elif tenant:
|
||||
return FormConfig.query.filter_by(tenant_id=tenant.id).order_by(FormConfig.created_at.desc()).all()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def create_form(tenant, name, description, form_structure, settings, is_active, user):
|
||||
form_id = f'form_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
|
||||
form = FormConfig(
|
||||
id=form_id,
|
||||
tenant_id=tenant.id,
|
||||
name=name,
|
||||
description=description,
|
||||
form_structure=form_structure,
|
||||
settings=settings,
|
||||
is_active=is_active
|
||||
)
|
||||
db.session.add(form)
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log_action(
|
||||
action='create',
|
||||
resource_type='form',
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
resource_id=form.id,
|
||||
details={'name': name, 'tenant_id': tenant.id}
|
||||
)
|
||||
return form
|
||||
|
||||
@staticmethod
|
||||
def update_form(form, name, description, is_active, form_structure, settings, user, tenant):
|
||||
form.name = name if name else form.name
|
||||
form.description = description if description else form.description
|
||||
form.is_active = is_active
|
||||
if form_structure is not None:
|
||||
form.form_structure = form_structure
|
||||
if settings is not None:
|
||||
form.settings = settings
|
||||
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log_action(
|
||||
action='update',
|
||||
resource_type='form',
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
resource_id=form.id,
|
||||
details={'name': form.name, 'is_active': form.is_active}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_form(form, user, tenant):
|
||||
db.session.delete(form)
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log_action(
|
||||
action='delete',
|
||||
resource_type='form',
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
resource_id=form.id,
|
||||
details={'name': form.name}
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
from typing import Optional, List
|
||||
from models import db, Tenant, FormConfig, Submission, UserRole, TenantStatus
|
||||
from models import AuditLog
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TenantAdminService:
|
||||
@staticmethod
|
||||
def get_tenants(user):
|
||||
if user.role == UserRole.ADMIN:
|
||||
return Tenant.query.order_by(Tenant.created_at.desc()).all()
|
||||
return user.tenants if user else []
|
||||
|
||||
@staticmethod
|
||||
def create_tenant(name, public_key, allowed_origins, status, user):
|
||||
tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
|
||||
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()
|
||||
|
||||
AuditLog.log_action(
|
||||
action='create',
|
||||
resource_type='tenant',
|
||||
user=user,
|
||||
resource_id=tenant.id,
|
||||
details={'name': name, 'public_key': public_key}
|
||||
)
|
||||
return tenant
|
||||
|
||||
@staticmethod
|
||||
def update_tenant(tenant, name, public_key, allowed_origins, status, user):
|
||||
tenant.name = name if name else tenant.name
|
||||
tenant.public_key = public_key if public_key else tenant.public_key
|
||||
tenant.allowed_origins = allowed_origins if allowed_origins else tenant.allowed_origins
|
||||
if status in [s.value for s in TenantStatus]:
|
||||
tenant.status = TenantStatus(status)
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log_action(
|
||||
action='update',
|
||||
resource_type='tenant',
|
||||
user=user,
|
||||
resource_id=tenant.id,
|
||||
details={'name': tenant.name, 'status': tenant.status.value}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_tenant(tenant, user):
|
||||
db.session.delete(tenant)
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log_action(
|
||||
action='delete',
|
||||
resource_type='tenant',
|
||||
user=user,
|
||||
resource_id=tenant.id,
|
||||
details={'name': tenant.name}
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
from typing import Optional, List
|
||||
from models import db, User, UserRole
|
||||
from models import AuditLog
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class UserAdminService:
|
||||
@staticmethod
|
||||
def get_users():
|
||||
return User.query.order_by(User.created_at.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
def update_user(target_user, email, name, role, admin_user):
|
||||
target_user.email = email if email else target_user.email
|
||||
target_user.name = name if name else target_user.name
|
||||
if role in [r.value for r in UserRole]:
|
||||
target_user.role = UserRole(role)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log_action(
|
||||
action='update',
|
||||
resource_type='user',
|
||||
user=admin_user,
|
||||
resource_id=target_user.id,
|
||||
details={'email': target_user.email, 'role': target_user.role.value}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_user(target_user, admin_user):
|
||||
db.session.delete(target_user)
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log_action(
|
||||
action='delete',
|
||||
resource_type='user',
|
||||
user=admin_user,
|
||||
resource_id=target_user.id,
|
||||
details={'email': target_user.email}
|
||||
)
|
||||
Reference in new issue
Block a user