""" Django settings for pyrunner project. Generated by 'django-admin startproject' using Django 6.0.1. For more information on this file, see https://docs.djangoproject.com/en/6.0/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/6.0/ref/settings/ """ import os import shutil from pathlib import Path from django.core.exceptions import ImproperlyConfigured from dotenv import load_dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent # Load environment variables from .env file load_dotenv(BASE_DIR / ".env") # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! # SECRET_KEY is required - generate one with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" SECRET_KEY = os.environ["SECRET_KEY"] # SECURITY WARNING: don't run with debug turned on in production! # Defaults to False for security - set DEBUG=True explicitly for development DEBUG = os.environ.get("DEBUG", "False").lower() == "true" ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") # Application definition INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", # Third party "django_q", "tailwind", "theme", "axes", # Local apps "core", ] # Tailwind CSS TAILWIND_APP_NAME = "theme" INTERNAL_IPS = ["127.0.0.1"] NPM_BIN_PATH = os.environ.get("NPM_BIN_PATH", shutil.which("npm") or "npm") MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "pyrunner.middleware.NoCacheMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "axes.middleware.AxesMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "core.middleware.SetupWizardMiddleware", ] ROOT_URLCONF = "pyrunner.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "core.context_processors.pyrunner_version", ], }, }, ] WSGI_APPLICATION = "pyrunner.wsgi.application" # Database # https://docs.djangoproject.com/en/6.0/ref/settings/#databases DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "data" / "db.sqlite3", "OPTIONS": { "timeout": 30, # Wait up to 30 seconds for database lock }, } } # Enable SQLite WAL mode for better concurrency in multi-process environments def _enable_sqlite_wal(sender, connection, **kwargs): if connection.vendor == "sqlite": cursor = connection.cursor() cursor.execute("PRAGMA journal_mode=WAL;") cursor.execute("PRAGMA busy_timeout=30000;") from django.db.backends.signals import connection_created connection_created.connect(_enable_sqlite_wal) # Custom User Model AUTH_USER_MODEL = "core.User" # Auth URLs LOGIN_URL = "auth:login" LOGIN_REDIRECT_URL = "cpanel:dashboard" # Password validation # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Authentication backends (django-axes for brute force protection) AUTHENTICATION_BACKENDS = [ "axes.backends.AxesStandaloneBackend", "django.contrib.auth.backends.ModelBackend", ] # Django Axes Configuration (login rate limiting) AXES_FAILURE_LIMIT = int(os.environ.get("AXES_FAILURE_LIMIT", "5")) # Lock after 5 failed attempts AXES_COOLOFF_TIME = 0.25 # 15 minutes lockout (in hours) AXES_RESET_ON_SUCCESS = True # Reset failed attempts after successful login AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]] # Combined lockout by username AND IP AXES_ENABLE_ACCESS_FAILURE_LOG = True # Log failed attempts AXES_VERBOSE = False # Don't log every access attempt # Internationalization # https://docs.djangoproject.com/en/6.0/topics/i18n/ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/6.0/howto/static-files/ STATIC_URL = "static/" STATICFILES_DIRS = [BASE_DIR / "static"] STATIC_ROOT = BASE_DIR / "staticfiles" # WhiteNoise for serving static files in production STORAGES = { "default": { "BACKEND": "django.core.files.storage.FileSystemStorage", }, "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", }, } # Default primary key field type # https://docs.djangoproject.com/en/6.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Email Configuration # Console backend for development, configure SMTP in production EMAIL_BACKEND = os.environ.get( "EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend", ) DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@pyrunner.local") # Resend API Configuration (Production) RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") USE_RESEND = os.environ.get("USE_RESEND", "False").lower() == "true" # django-q2 Configuration def _get_q_cluster_config(): """ Get Q_CLUSTER configuration from database with environment fallbacks. This is called at module load time. Database values take precedence over environment variables when available. """ # Default values (from env vars or hardcoded defaults) config = { "name": "PyRunner", "workers": int(os.environ.get("Q_WORKERS", 2)), "timeout": 600 if os.name != "nt" else 0, "retry": 660, "queue_limit": 20, "bulk": 5, "orm": "default", "catch_up": False, } # Try to load from database (only if apps are ready to avoid initialization warnings) try: from django.apps import apps if not apps.ready: return config # Return defaults during app initialization from django.db import connection from django.db.utils import OperationalError, ProgrammingError with connection.cursor() as cursor: cursor.execute( "SELECT q_workers, q_timeout, q_retry, q_queue_limit " "FROM global_settings WHERE id = 1" ) row = cursor.fetchone() if row: config["workers"] = row[0] or config["workers"] # On Windows, force timeout to 0 regardless of DB value if os.name == "nt": config["timeout"] = 0 else: config["timeout"] = row[1] if row[1] is not None else config["timeout"] config["retry"] = row[2] or config["retry"] config["queue_limit"] = row[3] or config["queue_limit"] except (OperationalError, ProgrammingError, Exception): # Database not ready yet (migrations not run), use defaults pass return config Q_CLUSTER = _get_q_cluster_config() # PyRunner Configuration DATA_DIR = BASE_DIR / "data" ENVIRONMENTS_ROOT = DATA_DIR / "environments" SCRIPTS_WORKDIR = DATA_DIR / "workdir" # Ensure directories exist DATA_DIR.mkdir(exist_ok=True) ENVIRONMENTS_ROOT.mkdir(exist_ok=True) SCRIPTS_WORKDIR.mkdir(exist_ok=True) # Secrets Encryption # Generate a key with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" ENCRYPTION_KEY = os.environ.get("ENCRYPTION_KEY", "") # Validate ENCRYPTION_KEY if not ENCRYPTION_KEY: if not DEBUG: raise ImproperlyConfigured( "ENCRYPTION_KEY is required in production. " "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" ) else: # Validate key format try: from cryptography.fernet import Fernet Fernet(ENCRYPTION_KEY.encode() if isinstance(ENCRYPTION_KEY, str) else ENCRYPTION_KEY) except Exception: raise ImproperlyConfigured("ENCRYPTION_KEY is invalid. Must be a valid Fernet key.") # Logging Configuration LOGS_DIR = DATA_DIR / "logs" LOGS_DIR.mkdir(exist_ok=True) LOGGING = { "version": 1, "disable_existing_loggers": False, "formatters": { "verbose": { "format": "[{asctime}] {levelname} {name} {message}", "style": "{", "datefmt": "%Y-%m-%d %H:%M:%S", }, "json": { "()": "core.logging.JsonFormatter", }, }, "handlers": { "console": { "class": "logging.StreamHandler", "formatter": "verbose", }, "file": { "class": "logging.handlers.RotatingFileHandler", "filename": LOGS_DIR / "pyrunner.log", "maxBytes": 10 * 1024 * 1024, # 10 MB "backupCount": 5, "formatter": "json", "encoding": "utf-8", }, }, "root": { "handlers": ["console", "file"], "level": "INFO", }, "loggers": { "django": { "handlers": ["console", "file"], "level": "WARNING", "propagate": False, }, "core": { "handlers": ["console", "file"], "level": "INFO", "propagate": False, }, }, } # Security Settings # These settings are applied when DEBUG=False (production) if not DEBUG: # HTTPS/SSL Settings SECURE_SSL_REDIRECT = os.environ.get("SECURE_SSL_REDIRECT", "True").lower() == "true" SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # HSTS - HTTP Strict Transport Security # Start with a shorter duration, increase to 31536000 (1 year) once confirmed working SECURE_HSTS_SECONDS = int(os.environ.get("SECURE_HSTS_SECONDS", "2592000")) # 30 days default SECURE_HSTS_INCLUDE_SUBDOMAINS = os.environ.get("SECURE_HSTS_INCLUDE_SUBDOMAINS", "True").lower() == "true" SECURE_HSTS_PRELOAD = os.environ.get("SECURE_HSTS_PRELOAD", "False").lower() == "true" # Cookie Security SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True # Always-on security settings (regardless of DEBUG) SECURE_CONTENT_TYPE_NOSNIFF = True X_FRAME_OPTIONS = "DENY" # Note: For Content Security Policy (CSP), install django-csp and add to middleware: # pip install django-csp # Add 'csp.middleware.CSPMiddleware' to MIDDLEWARE # Then configure CSP_* settings as needed # ============================================================================= # API Settings # ============================================================================= # Rate limiting for API requests (requests per minute per token) API_RATE_LIMIT = int(os.environ.get("API_RATE_LIMIT", "60")) # CORS settings for API endpoints # Set to "*" to allow all origins (suitable for self-hosted), or comma-separated origins API_CORS_ORIGINS = os.environ.get("API_CORS_ORIGINS", "*")