Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
364 KiB
JSON

{"repo": "anioko/archie-ea", "files": {"config.py": {"3571dfac9ad1eea74a1dbda82193021ee41f7390": {"t": 1785745455.5445857, "n": 21096, "h": "11e3d14837367662", "c": "import os\nfrom datetime import timedelta\n\n# Optional raygun import\ntry:\n from raygun4py.middleware import flask as flask_raygun\n\n HAS_RAYGUN = True\nexcept ImportError:\n HAS_RAYGUN = False\n flask_raygun = None\n\n# Python 3 only (Python 2 is no longer supported)\nimport urllib.parse\n\nbasedir = os.path.abspath(os.path.dirname(__file__))\n\n# Canonical module configuration. The v2 (\"guardrail\") modules are the active code\n# path (see CLAUDE.md), and the navigation/templates reference their endpoints, so\n# they MUST be registered by default. Without these flags a fresh install registers\n# only legacy/partial blueprints and every authenticated page 500s on a sidebar\n# url_for to an unregistered endpoint (e.g. solution_design.list_solutions). Using\n# setdefault means an explicit env value (true/false) is still respected.\nfor _canonical_flag in (\n \"USE_SOLUTIONS_STRATEGIC_GUARDRAILS\",\n \"USE_NEW_SOLUTIONS_STRATEGIC\",\n \"USE_CAPABILITIES_GUARDRAILS\",\n \"USE_NEW_CAPABILITIES\",\n \"USE_DASHBOARD_GUARDRAILS\",\n \"USE_NEW_DASHBOARD\",\n \"USE_APPLICATIONS_GUARDRAILS\",\n \"USE_NEW_APPLICATIONS\",\n \"USE_ARCHITECTURE_GUARDRAILS\",\n \"USE_NEW_ARCHITECTURE\",\n \"USE_AI_CHAT_GUARDRAILS\",\n \"USE_NEW_AI_CHAT\",\n # NOTE: vendors are intentionally NOT defaulted to the v2/guardrail path.\n # The always-on unified vendor blueprints serve the vendor pages AND the\n # /api/vendors/* JSON the vendor dashboard widgets call (list, ranking).\n # Forcing USE_VENDORS_GUARDRAILS switches to v2-only and drops the unified\n # API, 404-ing those widgets (\"not found\" in the UI).\n):\n os.environ.setdefault(_canonical_flag, \"true\")\n\n\ndef _env_bool(name: str, default: bool) -> bool:\n value = os.environ.get(name)\n if value is None:\n return default\n return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n# Load environment variables from .env file\nfrom dotenv import load_dotenv\n\n# Load from .env file using explicit path (ensures it works regardless of working directory)\nenv_path = os.path.join(basedir, \".env\")\nif os.path.exists(env_path):\n load_dotenv(env_path)\n print(f\"Loaded environment from: {env_path}\")\nelse:\n # Fallback to default behavior\n load_dotenv()\n print(\"Loaded environment from default location\")\n\n# Verify critical API keys are loaded\n_api_keys_loaded = []\nfor key in [\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"GEMINI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"HUGGINGFACE_API_KEY\",\n]:\n if os.getenv(key):\n _api_keys_loaded.append(key)\nif _api_keys_loaded:\n print(f\"API keys loaded: {', '.join(_api_keys_loaded)}\")\nelse:\n print(\"WARNING: No LLM API keys found in environment!\")\n\n\nclass Config:\n APP_NAME = os.environ.get(\"APP_NAME\", \"A.R.C.H.I.E.\")\n SECRET_KEY = os.environ.get(\"SECRET_KEY\")\n if not SECRET_KEY:\n import secrets\n\n SECRET_KEY = secrets.token_hex(32)\n print(\n \"WARNING: SECRET_KEY env var not set. A random key has been generated \"\n \"for this session. Sessions will NOT persist across restarts. \"\n \"Set SECRET_KEY in your .env file for production use.\"\n )\n SQLALCHEMY_COMMIT_ON_TEARDOWN = True\n\n # Session security — 8-hour session lifetime, 30-day remember-me cookie\n PERMANENT_SESSION_LIFETIME = timedelta(hours=8)\n REMEMBER_COOKIE_DURATION = timedelta(days=30)\n SESSION_REFRESH_EACH_REQUEST = True # FAR-012: Ensure session is refreshed on every request\n REMEMBER_COOKIE_REFRESH_EACH_REQUEST = True # FAR-012: Refresh remember-me cookie on activity\n\n # JWT Configuration\n JWT_TOKEN_LOCATION = [\"headers\"]\n JWT_SECRET_KEY = os.environ.get(\"JWT_SECRET_KEY\") or SECRET_KEY\n JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)\n\n # Email\n MAIL_SERVER = os.environ.get(\"MAIL_SERVER\", \"smtp.sendgrid.net\")\n MAIL_PORT = os.environ.get(\"MAIL_PORT\", 587)\n MAIL_USE_TLS = os.environ.get(\"MAIL_USE_TLS\", True)\n MAIL_USE_SSL = os.environ.get(\"MAIL_USE_SSL\", False)\n MAIL_USERNAME = os.environ.get(\"MAIL_USERNAME\")\n MAIL_PASSWORD = os.environ.get(\"MAIL_PASSWORD\")\n MAIL_DEFAULT_SENDER = os.environ.get(\"MAIL_DEFAULT_SENDER\")\n\n # Analytics\n GOOGLE_ANALYTICS_ID = os.environ.get(\"GOOGLE_ANALYTICS_ID\", \"\")\n SEGMENT_API_KEY = os.environ.get(\"SEGMENT_API_KEY\", \"\")\n\n # Admin account\n ADMIN_PASSWORD = os.environ.get(\"ADMIN_PASSWORD\")\n if not ADMIN_PASSWORD:\n import secrets\n\n ADMIN_PASSWORD = secrets.token_urlsafe(16)\n print(\n \"WARNING: ADMIN_PASSWORD env var not set. Generated random password. \"\n \"Set ADMIN_PASSWORD in your .env file for production use.\"\n )\n ADMIN_EMAIL = os.environ.get(\"ADMIN_EMAIL\", \"flask-base-admin@example.com\")\n EMAIL_SUBJECT_PREFIX = \"[{}]\".format(APP_NAME)\n EMAIL_SENDER = \"{app_name} Admin <{email}>\".format(\n app_name=APP_NAME, email=MAIL_USERNAME\n )\n\n # ARCHIE Deploy: Credential encryption + Coolify PaaS + n8n connector sync\n CREDENTIAL_ENCRYPTION_KEY = os.environ.get(\"CREDENTIAL_ENCRYPTION_KEY\", \"\")\n COOLIFY_API_URL = os.environ.get(\"COOLIFY_API_URL\", \"http://localhost:8000\")\n COOLIFY_API_TOKEN = os.environ.get(\"COOLIFY_API_TOKEN\", \"\")\n COOLIFY_DOMAIN_SUFFIX = os.environ.get(\"COOLIFY_DOMAIN_SUFFIX\", \"archie.example.com\")\n N8N_API_URL = os.environ.get(\"N8N_API_URL\", \"http://localhost:5678\")\n N8N_API_TOKEN = os.environ.get(\"N8N_API_TOKEN\", \"\")\n\n REDIS_URL = os.getenv(\"REDIS_URL\") or os.getenv(\n \"REDISTOGO_URL\", \"redis://localhost:6379/0\"\n )\n\n RAYGUN_APIKEY = os.environ.get(\"RAYGUN_APIKEY\")\n\n # Stripe Billing (COM-001) — platform works without these; set in production .env\n STRIPE_SECRET_KEY = os.environ.get(\"STRIPE_SECRET_KEY\", \"\")\n STRIPE_WEBHOOK_SECRET = os.environ.get(\"STRIPE_WEBHOOK_SECRET\", \"\")\n STRIPE_PRICE_PRO = os.environ.get(\"STRIPE_PRICE_PRO\", \"\")\n STRIPE_PRICE_ENTERPRISE = os.environ.get(\"STRIPE_PRICE_ENTERPRISE\", \"\")\n\n # Parse the REDIS_URL to set RQ config variables\n urllib.parse.uses_netloc.append(\"redis\")\n url = urllib.parse.urlparse(REDIS_URL)\n\n RQ_DEFAULT_HOST = url.hostname\n RQ_DEFAULT_PORT = url.port\n RQ_DEFAULT_PASSWORD = url.password\n RQ_DEFAULT_DB = 0\n\n # Celery configuration (async task queue)\n # Feature-flagged: set CELERY_ENABLED=true to enable async batch import\n CELERY_ENABLED = os.environ.get(\"CELERY_ENABLED\", \"false\").lower() == \"true\"\n CELERY_BROKER_URL = os.environ.get(\"CELERY_BROKER_URL\", REDIS_URL)\n CELERY_RESULT_BACKEND = os.environ.get(\"CELERY_RESULT_BACKEND\", REDIS_URL)\n CELERY_TASK_SERIALIZER = \"json\"\n CELERY_ACCEPT_CONTENT = [\"json\"]\n CELERY_RESULT_SERIALIZER = \"json\"\n CELERY_TIMEZONE = \"UTC\"\n CELERY_ENABLE_UTC = True\n\n # SSO / Enterprise Identity (S0-01)\n # Supports Azure AD, Okta (OIDC) and any SAML 2.0 IdP (PLT-030).\n # Feature-flagged: SSO routes only active when FeatureFlag(key='sso_authentication') is enabled.\n SSO_PROVIDERS = {\n \"azure\": {\n \"client_id\": os.environ.get(\"AZURE_AD_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"AZURE_AD_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\n \"AZURE_AD_METADATA_URL\",\n \"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration\".format(\n tenant=os.environ.get(\"AZURE_AD_TENANT_ID\", \"common\")\n ),\n ),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n \"okta\": {\n \"client_id\": os.environ.get(\"OKTA_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"OKTA_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\"OKTA_METADATA_URL\", \"\"),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n }\n\n # SAML 2.0 IdP configuration (PLT-030)\n # Set SAML_IDP_SSO_URL + SAML_SP_ENTITY_ID to enable SAML alongside OIDC.\n # Supported IdPs: ADFS, PingFederate, Shibboleth, Okta (SAML), Azure AD (SAML).\n # Routes registered at /account/saml/login, /account/saml/acs, /account/saml/metadata.\n SAML_IDP_SSO_URL = os.environ.get(\"SAML_IDP_SSO_URL\", \"\")\n # IdP Entity ID / Issuer URI (validated against Assertion Issuer element)\n SAML_IDP_ENTITY_ID = os.environ.get(\"SAML_IDP_ENTITY_ID\", \"\")\n # IdP X.509 signing certificate — PEM body without -----BEGIN/END----- headers.\n # Required for production signature validation (via xmlsec / python3-saml).\n SAML_IDP_CERT = os.environ.get(\"SAML_IDP_CERT\", \"\")\n # SP Entity ID — typically the platform's base URL\n SAML_SP_ENTITY_ID = os.environ.get(\"SAML_SP_ENTITY_ID\", \"\")\n # SP ACS URL — leave empty to auto-derive from url_for('account.saml_acs')\n SAML_SP_ACS_URL = os.environ.get(\"SAML_SP_ACS_URL\", \"\")\n\n # Internationalization (S2-01) — date/number/currency formatting\n # Full string translation (gettext) is Phase 2.\n BABEL_DEFAULT_LOCALE = os.environ.get(\"BABEL_DEFAULT_LOCALE\", \"en\")\n BABEL_DEFAULT_TIMEZONE = os.environ.get(\"BABEL_DEFAULT_TIMEZONE\", \"UTC\")\n\n # Read Replica Configuration (ENH-012)\n # Set DATABASE_READ_REPLICA_URL to a replica DB connection string to offload\n # dashboard and portfolio GET queries from the primary. When not configured,\n # all queries use the default SQLALCHEMY_DATABASE_URI (no-op).\n DATABASE_READ_REPLICA_URL = os.environ.get(\"DATABASE_READ_REPLICA_URL\", \"\")\n USE_READ_REPLICA_FOR_DASHBOARD = (\n os.environ.get(\"USE_READ_REPLICA_FOR_DASHBOARD\", \"false\").lower() == \"true\"\n )\n\n # AI-originated CRUD approval gate (A95-008)\n # When true, AI CRUD endpoints return 202 pending_approval instead of writing directly.\n REQUIRE_AI_APPROVAL = os.environ.get(\"REQUIRE_AI_APPROVAL\", \"false\").lower() == \"true\"\n\n # North Star Navigation (NORTH-STAR-001)\n # Enterprise-grade navigation for Fortune 500 TOGAF/ArchiMate practitioners.\n # When enabled: renamed navigation items, persona-based filtering, hidden admin items.\n # Rollback: set to false and restart service (zero risk, instant revert).\n # ENABLED 2026-04-02: Quick wins from NORTH_STAR_ASSESSMENT.md Part 10\n ENABLE_NORTH_STAR_NAV = _env_bool(\"ENABLE_NORTH_STAR_NAV\", True)\n\n # North Star Navigation Phase 2 (NORTH-STAR-002)\n # Complete ArchiMate 3.2 layer-based navigation with all 55 element types.\n # When enabled: 6-section IA, layer-first architecture, Portfolio/Architecture separation.\n # Depends on: ENABLE_NORTH_STAR_NAV=True (Phase 1 must be active)\n # Rollback: set to false and restart service.\n ENABLE_NORTH_STAR_PHASE2 = _env_bool(\"ENABLE_NORTH_STAR_PHASE2\", False)\n\n # Page-aware AI guide is fail-closed by default and only activates when\n # this flag is explicitly enabled and an LLM provider is configured.\n AI_PAGE_GUIDE_ENABLED = _env_bool(\"AI_PAGE_GUIDE_ENABLED\", False)\n\n # File Upload Settings\n MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size\n ALLOWED_EXTENSIONS = {\n \"pdf\",\n \"docx\",\n \"doc\",\n \"xlsx\",\n \"xls\",\n \"png\",\n \"jpg\",\n \"jpeg\",\n \"vsdx\",\n \"txt\",\n \"csv\",\n \"md\",\n }\n UPLOAD_FOLDER = os.path.join(basedir, \"uploads\", \"documents\")\n\n @staticmethod\n def init_app(app):\n \"\"\"Base class hook — subclasses override to customize app initialization.\"\"\"\n # Behind a TLS-terminating reverse proxy (Caddy/nginx/Traefik), trust the\n # X-Forwarded-* headers so request.scheme/host reflect the real https URL.\n # Without this the app builds http:// URLs behind https, causing\n # mixed-content blocking. Opt-in via TRUST_PROXY so forged headers are not\n # trusted when the app is exposed directly.\n if os.environ.get(\"TRUST_PROXY\", \"false\").lower() in (\"1\", \"true\", \"yes\") \\\n and not getattr(app, \"_proxyfix_applied\", False):\n from werkzeug.middleware.proxy_fix import ProxyFix\n app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)\n app._proxyfix_applied = True\n return\n\n\nclass DevelopmentConfig(Config):\n DEBUG = True\n ASSETS_DEBUG = True\n\n # N+1 query detection — set SQLALCHEMY_ECHO=true in .env to enable query logging\n SQLALCHEMY_ECHO = os.environ.get(\"SQLALCHEMY_ECHO\", \"false\").lower() == \"true\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL configuration (REQUIRED - validated at runtime)\n # Default to a local Postgres instance for development when DATABASE_URL is not set.\n # Standard port 5432; set DATABASE_URL in .env to override (e.g. Windows installs\n # that run Postgres on 5439).\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie\", # secrets-safety-ok\n )\n\n # SERVER_NAME must NOT be hardcoded — Flask-WTF CSRF validates the Referer\n # header against SERVER_NAME, so a hardcoded value causes \"CSRF token is missing\"\n # when the browser omits the Referer header (privacy settings, extensions, etc.).\n # Only set when explicitly provided via environment variable.\n SERVER_NAME = os.environ.get(\"SERVER_NAME\") or None\n PREFERRED_URL_SCHEME = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\")\n APPLICATION_ROOT = os.environ.get(\"APPLICATION_ROOT\", \"/\")\n\n # PostgreSQL connection options (optimized for local development)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Ensure a sensible default exists for development (Postgres is the standard)\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n app.logger.warning(\n \"No DATABASE_URL provided; falling back to local Postgres default.\"\n )\n\n\nclass TestingConfig(Config):\n TESTING = True\n WTF_CSRF_ENABLED = False\n\n # PostgreSQL REQUIRED for tests (matches production behavior)\n # SQLite is NOT supported - tests must use PostgreSQL for consistency\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"TEST_DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie_test\", # secrets-safety-ok\n )\n\n # PostgreSQL connection options for testing\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate PostgreSQL is being used (not SQLite)\n db_uri = app.config.get(\"SQLALCHEMY_DATABASE_URI\", \"\")\n if \"sqlite\" in db_uri.lower():\n raise ValueError(\n \"TEST_DATABASE_URL must be PostgreSQL, not SQLite. \"\n \"Set TEST_DATABASE_URL to a PostgreSQL connection string. \"\n \"Example: postgresql://postgres:postgres@localhost:5432/flask_test\" # secrets-safety-ok\n )\n\n if not db_uri:\n app.logger.warning(\n \"No TEST_DATABASE_URL provided; using default local PostgreSQL test database.\"\n )\n\n print(\"THIS APP IS IN TESTING MODE. YOU SHOULD NOT SEE THIS IN PRODUCTION.\")\n\n\nclass ProductionConfig(Config):\n DEBUG = False\n USE_RELOADER = False\n\n # Session cookie security\n _cookie_secure_default = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\").lower() == \"https\"\n SESSION_COOKIE_SECURE = _env_bool(\"SESSION_COOKIE_SECURE\", _cookie_secure_default)\n SESSION_COOKIE_HTTPONLY = True\n SESSION_COOKIE_SAMESITE = \"Lax\"\n REMEMBER_COOKIE_SECURE = _env_bool(\"REMEMBER_COOKIE_SECURE\", _cookie_secure_default)\n REMEMBER_COOKIE_HTTPONLY = True\n\n # PostgreSQL for production (REQUIRED - validated at runtime)\n SQLALCHEMY_DATABASE_URI = os.environ.get(\"DATABASE_URL\")\n SSL_DISABLE = os.environ.get(\"SSL_DISABLE\", \"False\") == \"True\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL connection options (optimized for production)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 20,\n \"max_overflow\": 30,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate DATABASE_URL is set\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n raise ValueError(\n \"DATABASE_URL environment variable is required. PostgreSQL must be configured for production.\"\n )\n\n Config.init_app(app)\n assert os.environ.get(\"SECRET_KEY\"), \"SECRET_KEY IS NOT SET!\"\n\n # Trust the nginx reverse proxy's forwarded headers (X-Forwarded-Proto /\n # -For) so request.scheme/host reflect the real client-facing protocol.\n # Without this, behind TLS the app still builds http:// external URLs\n # (breaking OAuth redirect URIs, email links) and treats requests as\n # insecure. nginx is the single trusted hop. Idempotent so a subclass\n # (Heroku/Unix) re-running init_app cannot double-wrap.\n if not getattr(app, \"_proxyfix_applied\", False):\n from werkzeug.middleware.proxy_fix import ProxyFix\n\n app.wsgi_app = ProxyFix(\n app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1\n )\n app._proxyfix_applied = True\n\n if HAS_RAYGUN and flask_raygun and app.config.get(\"RAYGUN_APIKEY\"):\n flask_raygun.Provider(app, app.config[\"RAYGUN_APIKEY\"]).attach()\n\n\nclass HerokuConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n # ProductionConfig.init_app applies ProxyFix (idempotently); no need to\n # re-wrap here.\n ProductionConfig.init_app(app)\n\n\nclass UnixConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n ProductionConfig.init_app(app)\n\n # Log to syslog\n import logging\n from logging.handlers import SysLogHandler\n\n syslog_handler = SysLogHandler()\n syslog_handler.setLevel(logging.WARNING)\n app.logger.addHandler(syslog_handler)\n\n\n# Currency Configuration System\nclass CurrencyConfig:\n \"\"\"Centralized currency configuration for the application\"\"\"\n\n DEFAULT_CURRENCY = os.environ.get(\"DEFAULT_CURRENCY\", \"GBP\")\n\n SUPPORTED_CURRENCIES = {\n \"GBP\": {\n \"symbol\": \"£\",\n \"code\": \"GBP\",\n \"position\": \"prefix\", # prefix or suffix\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"British Pound Sterling\",\n },\n \"USD\": {\n \"symbol\": \"$\",\n \"code\": \"USD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"United States Dollar\",\n },\n \"EUR\": {\n \"symbol\": \"€\",\n \"code\": \"EUR\",\n \"position\": \"suffix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \".\",\n \"name\": \"Euro\",\n },\n \"JPY\": {\n \"symbol\": \"¥\",\n \"code\": \"JPY\",\n \"position\": \"prefix\",\n \"decimal_places\": 0,\n \"thousands_separator\": \",\",\n \"name\": \"Japanese Yen\",\n },\n \"CAD\": {\n \"symbol\": \"C$\",\n \"code\": \"CAD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Canadian Dollar\",\n },\n \"AUD\": {\n \"symbol\": \"A$\",\n \"code\": \"AUD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Australian Dollar\",\n },\n }\n\n @classmethod\n def get_currency_config(cls, currency_code=None):\n \"\"\"Get currency configuration for specified currency code\"\"\"\n if not currency_code:\n currency_code = cls.DEFAULT_CURRENCY\n\n config = cls.SUPPORTED_CURRENCIES.get(currency_code)\n if not config:\n # Fallback to default currency instead of crashing\n config = cls.SUPPORTED_CURRENCIES.get(cls.DEFAULT_CURRENCY)\n\n return config\n\n @classmethod\n def is_supported(cls, currency_code):\n \"\"\"Check if currency is supported\"\"\"\n return currency_code in cls.SUPPORTED_CURRENCIES\n\n @classmethod\n def get_all_supported_codes(cls):\n \"\"\"Get list of all supported currency codes\"\"\"\n return list(cls.SUPPORTED_CURRENCIES.keys())\n\n\nconfig = {\n \"development\": DevelopmentConfig,\n \"testing\": TestingConfig,\n \"production\": ProductionConfig,\n \"default\": DevelopmentConfig,\n \"heroku\": HerokuConfig,\n \"unix\": UnixConfig,\n}\n"}, "3289a9251ce21ff4373486164e966b4bd0c6f400": {"t": 1785753429.935331, "n": 21566, "h": "d9fd972e9853166c", "c": "import os\nfrom datetime import timedelta\n\n# Optional raygun import\ntry:\n from raygun4py.middleware import flask as flask_raygun\n\n HAS_RAYGUN = True\nexcept ImportError:\n HAS_RAYGUN = False\n flask_raygun = None\n\n# Python 3 only (Python 2 is no longer supported)\nimport urllib.parse\n\nbasedir = os.path.abspath(os.path.dirname(__file__))\n\n# Canonical module configuration. The v2 (\"guardrail\") modules are the active code\n# path (see CLAUDE.md), and the navigation/templates reference their endpoints, so\n# they MUST be registered by default. Without these flags a fresh install registers\n# only legacy/partial blueprints and every authenticated page 500s on a sidebar\n# url_for to an unregistered endpoint (e.g. solution_design.list_solutions). Using\n# setdefault means an explicit env value (true/false) is still respected.\nfor _canonical_flag in (\n \"USE_SOLUTIONS_STRATEGIC_GUARDRAILS\",\n \"USE_NEW_SOLUTIONS_STRATEGIC\",\n \"USE_CAPABILITIES_GUARDRAILS\",\n \"USE_NEW_CAPABILITIES\",\n \"USE_DASHBOARD_GUARDRAILS\",\n \"USE_NEW_DASHBOARD\",\n \"USE_APPLICATIONS_GUARDRAILS\",\n \"USE_NEW_APPLICATIONS\",\n \"USE_ARCHITECTURE_GUARDRAILS\",\n \"USE_NEW_ARCHITECTURE\",\n \"USE_AI_CHAT_GUARDRAILS\",\n \"USE_NEW_AI_CHAT\",\n # NOTE: vendors are intentionally NOT defaulted to the v2/guardrail path.\n # The always-on unified vendor blueprints serve the vendor pages AND the\n # /api/vendors/* JSON the vendor dashboard widgets call (list, ranking).\n # Forcing USE_VENDORS_GUARDRAILS switches to v2-only and drops the unified\n # API, 404-ing those widgets (\"not found\" in the UI).\n):\n os.environ.setdefault(_canonical_flag, \"true\")\n\n\ndef _env_bool(name: str, default: bool) -> bool:\n value = os.environ.get(name)\n if value is None:\n return default\n return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n# Load environment variables from .env file\nfrom dotenv import load_dotenv\n\n# Load from .env file using explicit path (ensures it works regardless of working directory)\nenv_path = os.path.join(basedir, \".env\")\nif os.path.exists(env_path):\n load_dotenv(env_path)\n print(f\"Loaded environment from: {env_path}\")\nelse:\n # Fallback to default behavior\n load_dotenv()\n print(\"Loaded environment from default location\")\n\n# Verify critical API keys are loaded\n_api_keys_loaded = []\nfor key in [\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"GEMINI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"HUGGINGFACE_API_KEY\",\n]:\n if os.getenv(key):\n _api_keys_loaded.append(key)\nif _api_keys_loaded:\n print(f\"API keys loaded: {', '.join(_api_keys_loaded)}\")\nelse:\n print(\"WARNING: No LLM API keys found in environment!\")\n\n\nclass Config:\n APP_NAME = os.environ.get(\"APP_NAME\", \"A.R.C.H.I.E.\")\n SECRET_KEY = os.environ.get(\"SECRET_KEY\")\n if not SECRET_KEY:\n import secrets\n\n SECRET_KEY = secrets.token_hex(32)\n print(\n \"WARNING: SECRET_KEY env var not set. A random key has been generated \"\n \"for this session. Sessions will NOT persist across restarts. \"\n \"Set SECRET_KEY in your .env file for production use.\"\n )\n SQLALCHEMY_COMMIT_ON_TEARDOWN = True\n\n # Session security — 8-hour session lifetime, 30-day remember-me cookie\n PERMANENT_SESSION_LIFETIME = timedelta(hours=8)\n REMEMBER_COOKIE_DURATION = timedelta(days=30)\n SESSION_REFRESH_EACH_REQUEST = True # FAR-012: Ensure session is refreshed on every request\n REMEMBER_COOKIE_REFRESH_EACH_REQUEST = True # FAR-012: Refresh remember-me cookie on activity\n\n # JWT Configuration\n JWT_TOKEN_LOCATION = [\"headers\"]\n JWT_SECRET_KEY = os.environ.get(\"JWT_SECRET_KEY\") or SECRET_KEY\n JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)\n\n # Email\n MAIL_SERVER = os.environ.get(\"MAIL_SERVER\", \"smtp.sendgrid.net\")\n MAIL_PORT = os.environ.get(\"MAIL_PORT\", 587)\n MAIL_USE_TLS = os.environ.get(\"MAIL_USE_TLS\", True)\n MAIL_USE_SSL = os.environ.get(\"MAIL_USE_SSL\", False)\n MAIL_USERNAME = os.environ.get(\"MAIL_USERNAME\")\n MAIL_PASSWORD = os.environ.get(\"MAIL_PASSWORD\")\n MAIL_DEFAULT_SENDER = os.environ.get(\"MAIL_DEFAULT_SENDER\")\n\n # Analytics\n GOOGLE_ANALYTICS_ID = os.environ.get(\"GOOGLE_ANALYTICS_ID\", \"\")\n SEGMENT_API_KEY = os.environ.get(\"SEGMENT_API_KEY\", \"\")\n\n # Admin account\n ADMIN_PASSWORD = os.environ.get(\"ADMIN_PASSWORD\")\n if not ADMIN_PASSWORD:\n import secrets\n\n ADMIN_PASSWORD = secrets.token_urlsafe(16)\n print(\n \"WARNING: ADMIN_PASSWORD env var not set. Generated random password. \"\n \"Set ADMIN_PASSWORD in your .env file for production use.\"\n )\n ADMIN_EMAIL = os.environ.get(\"ADMIN_EMAIL\", \"flask-base-admin@example.com\")\n EMAIL_SUBJECT_PREFIX = \"[{}]\".format(APP_NAME)\n EMAIL_SENDER = \"{app_name} Admin <{email}>\".format(\n app_name=APP_NAME, email=MAIL_USERNAME\n )\n\n # ARCHIE Deploy: Credential encryption + Coolify PaaS + n8n connector sync\n CREDENTIAL_ENCRYPTION_KEY = os.environ.get(\"CREDENTIAL_ENCRYPTION_KEY\", \"\")\n COOLIFY_API_URL = os.environ.get(\"COOLIFY_API_URL\", \"http://localhost:8000\")\n COOLIFY_API_TOKEN = os.environ.get(\"COOLIFY_API_TOKEN\", \"\")\n COOLIFY_DOMAIN_SUFFIX = os.environ.get(\"COOLIFY_DOMAIN_SUFFIX\", \"archie.example.com\")\n N8N_API_URL = os.environ.get(\"N8N_API_URL\", \"http://localhost:5678\")\n N8N_API_TOKEN = os.environ.get(\"N8N_API_TOKEN\", \"\")\n\n REDIS_URL = os.getenv(\"REDIS_URL\") or os.getenv(\n \"REDISTOGO_URL\", \"redis://localhost:6379/0\"\n )\n\n RAYGUN_APIKEY = os.environ.get(\"RAYGUN_APIKEY\")\n\n # Stripe Billing (COM-001) — platform works without these; set in production .env\n STRIPE_SECRET_KEY = os.environ.get(\"STRIPE_SECRET_KEY\", \"\")\n STRIPE_WEBHOOK_SECRET = os.environ.get(\"STRIPE_WEBHOOK_SECRET\", \"\")\n STRIPE_PRICE_PRO = os.environ.get(\"STRIPE_PRICE_PRO\", \"\")\n STRIPE_PRICE_ENTERPRISE = os.environ.get(\"STRIPE_PRICE_ENTERPRISE\", \"\")\n\n # Parse the REDIS_URL to set RQ config variables\n urllib.parse.uses_netloc.append(\"redis\")\n url = urllib.parse.urlparse(REDIS_URL)\n\n RQ_DEFAULT_HOST = url.hostname\n RQ_DEFAULT_PORT = url.port\n RQ_DEFAULT_PASSWORD = url.password\n RQ_DEFAULT_DB = 0\n\n # Celery configuration (async task queue)\n # Feature-flagged: set CELERY_ENABLED=true to enable async batch import\n CELERY_ENABLED = os.environ.get(\"CELERY_ENABLED\", \"false\").lower() == \"true\"\n CELERY_BROKER_URL = os.environ.get(\"CELERY_BROKER_URL\", REDIS_URL)\n CELERY_RESULT_BACKEND = os.environ.get(\"CELERY_RESULT_BACKEND\", REDIS_URL)\n CELERY_TASK_SERIALIZER = \"json\"\n CELERY_ACCEPT_CONTENT = [\"json\"]\n CELERY_RESULT_SERIALIZER = \"json\"\n CELERY_TIMEZONE = \"UTC\"\n CELERY_ENABLE_UTC = True\n\n # SSO / Enterprise Identity (S0-01)\n # Supports Azure AD, Okta (OIDC) and any SAML 2.0 IdP (PLT-030).\n # Feature-flagged: SSO routes only active when FeatureFlag(key='sso_authentication') is enabled.\n SSO_PROVIDERS = {\n \"azure\": {\n \"client_id\": os.environ.get(\"AZURE_AD_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"AZURE_AD_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\n \"AZURE_AD_METADATA_URL\",\n \"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration\".format(\n tenant=os.environ.get(\"AZURE_AD_TENANT_ID\", \"common\")\n ),\n ),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n \"okta\": {\n \"client_id\": os.environ.get(\"OKTA_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"OKTA_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\"OKTA_METADATA_URL\", \"\"),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n }\n\n # SAML 2.0 IdP configuration (PLT-030)\n # Set SAML_IDP_SSO_URL + SAML_SP_ENTITY_ID to enable SAML alongside OIDC.\n # Supported IdPs: ADFS, PingFederate, Shibboleth, Okta (SAML), Azure AD (SAML).\n # Routes registered at /account/saml/login, /account/saml/acs, /account/saml/metadata.\n SAML_IDP_SSO_URL = os.environ.get(\"SAML_IDP_SSO_URL\", \"\")\n # IdP Entity ID / Issuer URI (validated against Assertion Issuer element)\n SAML_IDP_ENTITY_ID = os.environ.get(\"SAML_IDP_ENTITY_ID\", \"\")\n # IdP X.509 signing certificate — PEM body without -----BEGIN/END----- headers.\n # Required for production signature validation (via xmlsec / python3-saml).\n SAML_IDP_CERT = os.environ.get(\"SAML_IDP_CERT\", \"\")\n # SP Entity ID — typically the platform's base URL\n SAML_SP_ENTITY_ID = os.environ.get(\"SAML_SP_ENTITY_ID\", \"\")\n # SP ACS URL — leave empty to auto-derive from url_for('account.saml_acs')\n SAML_SP_ACS_URL = os.environ.get(\"SAML_SP_ACS_URL\", \"\")\n\n # Internationalization (S2-01) — date/number/currency formatting\n # Full string translation (gettext) is Phase 2.\n BABEL_DEFAULT_LOCALE = os.environ.get(\"BABEL_DEFAULT_LOCALE\", \"en\")\n BABEL_DEFAULT_TIMEZONE = os.environ.get(\"BABEL_DEFAULT_TIMEZONE\", \"UTC\")\n\n # Read Replica Configuration (ENH-012)\n # Set DATABASE_READ_REPLICA_URL to a replica DB connection string to offload\n # dashboard and portfolio GET queries from the primary. When not configured,\n # all queries use the default SQLALCHEMY_DATABASE_URI (no-op).\n DATABASE_READ_REPLICA_URL = os.environ.get(\"DATABASE_READ_REPLICA_URL\", \"\")\n USE_READ_REPLICA_FOR_DASHBOARD = (\n os.environ.get(\"USE_READ_REPLICA_FOR_DASHBOARD\", \"false\").lower() == \"true\"\n )\n\n # AI-originated CRUD approval gate (A95-008)\n # When true, AI CRUD endpoints return 202 pending_approval instead of writing directly.\n REQUIRE_AI_APPROVAL = os.environ.get(\"REQUIRE_AI_APPROVAL\", \"false\").lower() == \"true\"\n\n # North Star Navigation (NORTH-STAR-001)\n # Enterprise-grade navigation for Fortune 500 TOGAF/ArchiMate practitioners.\n # When enabled: renamed navigation items, persona-based filtering, hidden admin items.\n # Rollback: set to false and restart service (zero risk, instant revert).\n # ENABLED 2026-04-02: Quick wins from NORTH_STAR_ASSESSMENT.md Part 10\n ENABLE_NORTH_STAR_NAV = _env_bool(\"ENABLE_NORTH_STAR_NAV\", True)\n\n # North Star Navigation Phase 2 (NORTH-STAR-002)\n # Complete ArchiMate 3.2 layer-based navigation with all 55 element types.\n # When enabled: 6-section IA, layer-first architecture, Portfolio/Architecture separation.\n # Depends on: ENABLE_NORTH_STAR_NAV=True (Phase 1 must be active)\n # Rollback: set to false and restart service.\n ENABLE_NORTH_STAR_PHASE2 = _env_bool(\"ENABLE_NORTH_STAR_PHASE2\", False)\n\n # Page-aware AI guide is fail-closed by default and only activates when\n # this flag is explicitly enabled and an LLM provider is configured.\n AI_PAGE_GUIDE_ENABLED = _env_bool(\"AI_PAGE_GUIDE_ENABLED\", False)\n\n # File Upload Settings\n MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size\n ALLOWED_EXTENSIONS = {\n \"pdf\",\n \"docx\",\n \"doc\",\n \"xlsx\",\n \"xls\",\n \"png\",\n \"jpg\",\n \"jpeg\",\n \"vsdx\",\n \"txt\",\n \"csv\",\n \"md\",\n }\n UPLOAD_FOLDER = os.path.join(basedir, \"uploads\", \"documents\")\n\n @staticmethod\n def init_app(app):\n \"\"\"Base class hook — subclasses override to customize app initialization.\"\"\"\n # Behind a TLS-terminating reverse proxy (Caddy/nginx/Traefik), trust the\n # X-Forwarded-* headers so request.scheme/host reflect the real https URL.\n # Without this the app builds http:// URLs behind https, causing\n # mixed-content blocking. Opt-in via TRUST_PROXY so forged headers are not\n # trusted when the app is exposed directly.\n if os.environ.get(\"TRUST_PROXY\", \"false\").lower() in (\"1\", \"true\", \"yes\") \\\n and not getattr(app, \"_proxyfix_applied\", False):\n from werkzeug.middleware.proxy_fix import ProxyFix\n app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)\n app._proxyfix_applied = True\n return\n\n\nclass DevelopmentConfig(Config):\n DEBUG = True\n ASSETS_DEBUG = True\n\n # N+1 query detection — set SQLALCHEMY_ECHO=true in .env to enable query logging\n SQLALCHEMY_ECHO = os.environ.get(\"SQLALCHEMY_ECHO\", \"false\").lower() == \"true\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL configuration (REQUIRED - validated at runtime)\n # Default to a local Postgres instance for development when DATABASE_URL is not set.\n # Standard port 5432; set DATABASE_URL in .env to override (e.g. Windows installs\n # that run Postgres on 5439).\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie\", # secrets-safety-ok\n )\n\n # SERVER_NAME must NOT be hardcoded — Flask-WTF CSRF validates the Referer\n # header against SERVER_NAME, so a hardcoded value causes \"CSRF token is missing\"\n # when the browser omits the Referer header (privacy settings, extensions, etc.).\n # Only set when explicitly provided via environment variable.\n SERVER_NAME = os.environ.get(\"SERVER_NAME\") or None\n PREFERRED_URL_SCHEME = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\")\n APPLICATION_ROOT = os.environ.get(\"APPLICATION_ROOT\", \"/\")\n\n # PostgreSQL connection options (optimized for local development)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Ensure a sensible default exists for development (Postgres is the standard)\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n app.logger.warning(\n \"No DATABASE_URL provided; falling back to local Postgres default.\"\n )\n\n\nclass TestingConfig(Config):\n TESTING = True\n WTF_CSRF_ENABLED = False\n\n # PostgreSQL REQUIRED for tests (matches production behavior)\n # SQLite is NOT supported - tests must use PostgreSQL for consistency\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"TEST_DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie_test\", # secrets-safety-ok\n )\n\n # PostgreSQL connection options for testing\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate PostgreSQL is being used (not SQLite)\n db_uri = app.config.get(\"SQLALCHEMY_DATABASE_URI\", \"\")\n if \"sqlite\" in db_uri.lower():\n raise ValueError(\n \"TEST_DATABASE_URL must be PostgreSQL, not SQLite. \"\n \"Set TEST_DATABASE_URL to a PostgreSQL connection string. \"\n \"Example: postgresql://postgres:postgres@localhost:5432/flask_test\" # secrets-safety-ok\n )\n\n if not db_uri:\n app.logger.warning(\n \"No TEST_DATABASE_URL provided; using default local PostgreSQL test database.\"\n )\n\n print(\"THIS APP IS IN TESTING MODE. YOU SHOULD NOT SEE THIS IN PRODUCTION.\")\n\n\nclass ProductionConfig(Config):\n DEBUG = False\n USE_RELOADER = False\n\n # Session cookie security\n _cookie_secure_default = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\").lower() == \"https\"\n SESSION_COOKIE_SECURE = _env_bool(\"SESSION_COOKIE_SECURE\", _cookie_secure_default)\n SESSION_COOKIE_HTTPONLY = True\n SESSION_COOKIE_SAMESITE = \"Lax\"\n REMEMBER_COOKIE_SECURE = _env_bool(\"REMEMBER_COOKIE_SECURE\", _cookie_secure_default)\n REMEMBER_COOKIE_HTTPONLY = True\n\n # PostgreSQL for production (REQUIRED - validated at runtime)\n SQLALCHEMY_DATABASE_URI = os.environ.get(\"DATABASE_URL\")\n SSL_DISABLE = os.environ.get(\"SSL_DISABLE\", \"False\") == \"True\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL connection options (optimized for production)\n # Sized per-process, not per-deployment: preload_app=True plus the\n # post_fork db.engine.dispose() hook means every gunicorn worker and the\n # RQ worker gets its own full pool. The hardcoded 20+30 let 2 workers +\n # RQ worker demand 150 connections against max_connections=100. Env-\n # overridable so the pool can track GUNICORN_THREADS without a code change.\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": int(os.environ.get(\"SQLALCHEMY_POOL_SIZE\", 20)),\n \"max_overflow\": int(os.environ.get(\"SQLALCHEMY_MAX_OVERFLOW\", 30)),\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate DATABASE_URL is set\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n raise ValueError(\n \"DATABASE_URL environment variable is required. PostgreSQL must be configured for production.\"\n )\n\n Config.init_app(app)\n assert os.environ.get(\"SECRET_KEY\"), \"SECRET_KEY IS NOT SET!\"\n\n # Trust the nginx reverse proxy's forwarded headers (X-Forwarded-Proto /\n # -For) so request.scheme/host reflect the real client-facing protocol.\n # Without this, behind TLS the app still builds http:// external URLs\n # (breaking OAuth redirect URIs, email links) and treats requests as\n # insecure. nginx is the single trusted hop. Idempotent so a subclass\n # (Heroku/Unix) re-running init_app cannot double-wrap.\n if not getattr(app, \"_proxyfix_applied\", False):\n from werkzeug.middleware.proxy_fix import ProxyFix\n\n app.wsgi_app = ProxyFix(\n app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1\n )\n app._proxyfix_applied = True\n\n if HAS_RAYGUN and flask_raygun and app.config.get(\"RAYGUN_APIKEY\"):\n flask_raygun.Provider(app, app.config[\"RAYGUN_APIKEY\"]).attach()\n\n\nclass HerokuConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n # ProductionConfig.init_app applies ProxyFix (idempotently); no need to\n # re-wrap here.\n ProductionConfig.init_app(app)\n\n\nclass UnixConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n ProductionConfig.init_app(app)\n\n # Log to syslog\n import logging\n from logging.handlers import SysLogHandler\n\n syslog_handler = SysLogHandler()\n syslog_handler.setLevel(logging.WARNING)\n app.logger.addHandler(syslog_handler)\n\n\n# Currency Configuration System\nclass CurrencyConfig:\n \"\"\"Centralized currency configuration for the application\"\"\"\n\n DEFAULT_CURRENCY = os.environ.get(\"DEFAULT_CURRENCY\", \"GBP\")\n\n SUPPORTED_CURRENCIES = {\n \"GBP\": {\n \"symbol\": \"£\",\n \"code\": \"GBP\",\n \"position\": \"prefix\", # prefix or suffix\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"British Pound Sterling\",\n },\n \"USD\": {\n \"symbol\": \"$\",\n \"code\": \"USD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"United States Dollar\",\n },\n \"EUR\": {\n \"symbol\": \"€\",\n \"code\": \"EUR\",\n \"position\": \"suffix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \".\",\n \"name\": \"Euro\",\n },\n \"JPY\": {\n \"symbol\": \"¥\",\n \"code\": \"JPY\",\n \"position\": \"prefix\",\n \"decimal_places\": 0,\n \"thousands_separator\": \",\",\n \"name\": \"Japanese Yen\",\n },\n \"CAD\": {\n \"symbol\": \"C$\",\n \"code\": \"CAD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Canadian Dollar\",\n },\n \"AUD\": {\n \"symbol\": \"A$\",\n \"code\": \"AUD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Australian Dollar\",\n },\n }\n\n @classmethod\n def get_currency_config(cls, currency_code=None):\n \"\"\"Get currency configuration for specified currency code\"\"\"\n if not currency_code:\n currency_code = cls.DEFAULT_CURRENCY\n\n config = cls.SUPPORTED_CURRENCIES.get(currency_code)\n if not config:\n # Fallback to default currency instead of crashing\n config = cls.SUPPORTED_CURRENCIES.get(cls.DEFAULT_CURRENCY)\n\n return config\n\n @classmethod\n def is_supported(cls, currency_code):\n \"\"\"Check if currency is supported\"\"\"\n return currency_code in cls.SUPPORTED_CURRENCIES\n\n @classmethod\n def get_all_supported_codes(cls):\n \"\"\"Get list of all supported currency codes\"\"\"\n return list(cls.SUPPORTED_CURRENCIES.keys())\n\n\nconfig = {\n \"development\": DevelopmentConfig,\n \"testing\": TestingConfig,\n \"production\": ProductionConfig,\n \"default\": DevelopmentConfig,\n \"heroku\": HerokuConfig,\n \"unix\": UnixConfig,\n}\n"}, "9493e69d966587b7f8b4ef3f40535c797fe31b46": {"t": 1785753431.8356373, "n": 21096, "h": "11e3d14837367662", "c": "import os\nfrom datetime import timedelta\n\n# Optional raygun import\ntry:\n from raygun4py.middleware import flask as flask_raygun\n\n HAS_RAYGUN = True\nexcept ImportError:\n HAS_RAYGUN = False\n flask_raygun = None\n\n# Python 3 only (Python 2 is no longer supported)\nimport urllib.parse\n\nbasedir = os.path.abspath(os.path.dirname(__file__))\n\n# Canonical module configuration. The v2 (\"guardrail\") modules are the active code\n# path (see CLAUDE.md), and the navigation/templates reference their endpoints, so\n# they MUST be registered by default. Without these flags a fresh install registers\n# only legacy/partial blueprints and every authenticated page 500s on a sidebar\n# url_for to an unregistered endpoint (e.g. solution_design.list_solutions). Using\n# setdefault means an explicit env value (true/false) is still respected.\nfor _canonical_flag in (\n \"USE_SOLUTIONS_STRATEGIC_GUARDRAILS\",\n \"USE_NEW_SOLUTIONS_STRATEGIC\",\n \"USE_CAPABILITIES_GUARDRAILS\",\n \"USE_NEW_CAPABILITIES\",\n \"USE_DASHBOARD_GUARDRAILS\",\n \"USE_NEW_DASHBOARD\",\n \"USE_APPLICATIONS_GUARDRAILS\",\n \"USE_NEW_APPLICATIONS\",\n \"USE_ARCHITECTURE_GUARDRAILS\",\n \"USE_NEW_ARCHITECTURE\",\n \"USE_AI_CHAT_GUARDRAILS\",\n \"USE_NEW_AI_CHAT\",\n # NOTE: vendors are intentionally NOT defaulted to the v2/guardrail path.\n # The always-on unified vendor blueprints serve the vendor pages AND the\n # /api/vendors/* JSON the vendor dashboard widgets call (list, ranking).\n # Forcing USE_VENDORS_GUARDRAILS switches to v2-only and drops the unified\n # API, 404-ing those widgets (\"not found\" in the UI).\n):\n os.environ.setdefault(_canonical_flag, \"true\")\n\n\ndef _env_bool(name: str, default: bool) -> bool:\n value = os.environ.get(name)\n if value is None:\n return default\n return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n# Load environment variables from .env file\nfrom dotenv import load_dotenv\n\n# Load from .env file using explicit path (ensures it works regardless of working directory)\nenv_path = os.path.join(basedir, \".env\")\nif os.path.exists(env_path):\n load_dotenv(env_path)\n print(f\"Loaded environment from: {env_path}\")\nelse:\n # Fallback to default behavior\n load_dotenv()\n print(\"Loaded environment from default location\")\n\n# Verify critical API keys are loaded\n_api_keys_loaded = []\nfor key in [\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"GEMINI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"HUGGINGFACE_API_KEY\",\n]:\n if os.getenv(key):\n _api_keys_loaded.append(key)\nif _api_keys_loaded:\n print(f\"API keys loaded: {', '.join(_api_keys_loaded)}\")\nelse:\n print(\"WARNING: No LLM API keys found in environment!\")\n\n\nclass Config:\n APP_NAME = os.environ.get(\"APP_NAME\", \"A.R.C.H.I.E.\")\n SECRET_KEY = os.environ.get(\"SECRET_KEY\")\n if not SECRET_KEY:\n import secrets\n\n SECRET_KEY = secrets.token_hex(32)\n print(\n \"WARNING: SECRET_KEY env var not set. A random key has been generated \"\n \"for this session. Sessions will NOT persist across restarts. \"\n \"Set SECRET_KEY in your .env file for production use.\"\n )\n SQLALCHEMY_COMMIT_ON_TEARDOWN = True\n\n # Session security — 8-hour session lifetime, 30-day remember-me cookie\n PERMANENT_SESSION_LIFETIME = timedelta(hours=8)\n REMEMBER_COOKIE_DURATION = timedelta(days=30)\n SESSION_REFRESH_EACH_REQUEST = True # FAR-012: Ensure session is refreshed on every request\n REMEMBER_COOKIE_REFRESH_EACH_REQUEST = True # FAR-012: Refresh remember-me cookie on activity\n\n # JWT Configuration\n JWT_TOKEN_LOCATION = [\"headers\"]\n JWT_SECRET_KEY = os.environ.get(\"JWT_SECRET_KEY\") or SECRET_KEY\n JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)\n\n # Email\n MAIL_SERVER = os.environ.get(\"MAIL_SERVER\", \"smtp.sendgrid.net\")\n MAIL_PORT = os.environ.get(\"MAIL_PORT\", 587)\n MAIL_USE_TLS = os.environ.get(\"MAIL_USE_TLS\", True)\n MAIL_USE_SSL = os.environ.get(\"MAIL_USE_SSL\", False)\n MAIL_USERNAME = os.environ.get(\"MAIL_USERNAME\")\n MAIL_PASSWORD = os.environ.get(\"MAIL_PASSWORD\")\n MAIL_DEFAULT_SENDER = os.environ.get(\"MAIL_DEFAULT_SENDER\")\n\n # Analytics\n GOOGLE_ANALYTICS_ID = os.environ.get(\"GOOGLE_ANALYTICS_ID\", \"\")\n SEGMENT_API_KEY = os.environ.get(\"SEGMENT_API_KEY\", \"\")\n\n # Admin account\n ADMIN_PASSWORD = os.environ.get(\"ADMIN_PASSWORD\")\n if not ADMIN_PASSWORD:\n import secrets\n\n ADMIN_PASSWORD = secrets.token_urlsafe(16)\n print(\n \"WARNING: ADMIN_PASSWORD env var not set. Generated random password. \"\n \"Set ADMIN_PASSWORD in your .env file for production use.\"\n )\n ADMIN_EMAIL = os.environ.get(\"ADMIN_EMAIL\", \"flask-base-admin@example.com\")\n EMAIL_SUBJECT_PREFIX = \"[{}]\".format(APP_NAME)\n EMAIL_SENDER = \"{app_name} Admin <{email}>\".format(\n app_name=APP_NAME, email=MAIL_USERNAME\n )\n\n # ARCHIE Deploy: Credential encryption + Coolify PaaS + n8n connector sync\n CREDENTIAL_ENCRYPTION_KEY = os.environ.get(\"CREDENTIAL_ENCRYPTION_KEY\", \"\")\n COOLIFY_API_URL = os.environ.get(\"COOLIFY_API_URL\", \"http://localhost:8000\")\n COOLIFY_API_TOKEN = os.environ.get(\"COOLIFY_API_TOKEN\", \"\")\n COOLIFY_DOMAIN_SUFFIX = os.environ.get(\"COOLIFY_DOMAIN_SUFFIX\", \"archie.example.com\")\n N8N_API_URL = os.environ.get(\"N8N_API_URL\", \"http://localhost:5678\")\n N8N_API_TOKEN = os.environ.get(\"N8N_API_TOKEN\", \"\")\n\n REDIS_URL = os.getenv(\"REDIS_URL\") or os.getenv(\n \"REDISTOGO_URL\", \"redis://localhost:6379/0\"\n )\n\n RAYGUN_APIKEY = os.environ.get(\"RAYGUN_APIKEY\")\n\n # Stripe Billing (COM-001) — platform works without these; set in production .env\n STRIPE_SECRET_KEY = os.environ.get(\"STRIPE_SECRET_KEY\", \"\")\n STRIPE_WEBHOOK_SECRET = os.environ.get(\"STRIPE_WEBHOOK_SECRET\", \"\")\n STRIPE_PRICE_PRO = os.environ.get(\"STRIPE_PRICE_PRO\", \"\")\n STRIPE_PRICE_ENTERPRISE = os.environ.get(\"STRIPE_PRICE_ENTERPRISE\", \"\")\n\n # Parse the REDIS_URL to set RQ config variables\n urllib.parse.uses_netloc.append(\"redis\")\n url = urllib.parse.urlparse(REDIS_URL)\n\n RQ_DEFAULT_HOST = url.hostname\n RQ_DEFAULT_PORT = url.port\n RQ_DEFAULT_PASSWORD = url.password\n RQ_DEFAULT_DB = 0\n\n # Celery configuration (async task queue)\n # Feature-flagged: set CELERY_ENABLED=true to enable async batch import\n CELERY_ENABLED = os.environ.get(\"CELERY_ENABLED\", \"false\").lower() == \"true\"\n CELERY_BROKER_URL = os.environ.get(\"CELERY_BROKER_URL\", REDIS_URL)\n CELERY_RESULT_BACKEND = os.environ.get(\"CELERY_RESULT_BACKEND\", REDIS_URL)\n CELERY_TASK_SERIALIZER = \"json\"\n CELERY_ACCEPT_CONTENT = [\"json\"]\n CELERY_RESULT_SERIALIZER = \"json\"\n CELERY_TIMEZONE = \"UTC\"\n CELERY_ENABLE_UTC = True\n\n # SSO / Enterprise Identity (S0-01)\n # Supports Azure AD, Okta (OIDC) and any SAML 2.0 IdP (PLT-030).\n # Feature-flagged: SSO routes only active when FeatureFlag(key='sso_authentication') is enabled.\n SSO_PROVIDERS = {\n \"azure\": {\n \"client_id\": os.environ.get(\"AZURE_AD_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"AZURE_AD_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\n \"AZURE_AD_METADATA_URL\",\n \"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration\".format(\n tenant=os.environ.get(\"AZURE_AD_TENANT_ID\", \"common\")\n ),\n ),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n \"okta\": {\n \"client_id\": os.environ.get(\"OKTA_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"OKTA_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\"OKTA_METADATA_URL\", \"\"),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n }\n\n # SAML 2.0 IdP configuration (PLT-030)\n # Set SAML_IDP_SSO_URL + SAML_SP_ENTITY_ID to enable SAML alongside OIDC.\n # Supported IdPs: ADFS, PingFederate, Shibboleth, Okta (SAML), Azure AD (SAML).\n # Routes registered at /account/saml/login, /account/saml/acs, /account/saml/metadata.\n SAML_IDP_SSO_URL = os.environ.get(\"SAML_IDP_SSO_URL\", \"\")\n # IdP Entity ID / Issuer URI (validated against Assertion Issuer element)\n SAML_IDP_ENTITY_ID = os.environ.get(\"SAML_IDP_ENTITY_ID\", \"\")\n # IdP X.509 signing certificate — PEM body without -----BEGIN/END----- headers.\n # Required for production signature validation (via xmlsec / python3-saml).\n SAML_IDP_CERT = os.environ.get(\"SAML_IDP_CERT\", \"\")\n # SP Entity ID — typically the platform's base URL\n SAML_SP_ENTITY_ID = os.environ.get(\"SAML_SP_ENTITY_ID\", \"\")\n # SP ACS URL — leave empty to auto-derive from url_for('account.saml_acs')\n SAML_SP_ACS_URL = os.environ.get(\"SAML_SP_ACS_URL\", \"\")\n\n # Internationalization (S2-01) — date/number/currency formatting\n # Full string translation (gettext) is Phase 2.\n BABEL_DEFAULT_LOCALE = os.environ.get(\"BABEL_DEFAULT_LOCALE\", \"en\")\n BABEL_DEFAULT_TIMEZONE = os.environ.get(\"BABEL_DEFAULT_TIMEZONE\", \"UTC\")\n\n # Read Replica Configuration (ENH-012)\n # Set DATABASE_READ_REPLICA_URL to a replica DB connection string to offload\n # dashboard and portfolio GET queries from the primary. When not configured,\n # all queries use the default SQLALCHEMY_DATABASE_URI (no-op).\n DATABASE_READ_REPLICA_URL = os.environ.get(\"DATABASE_READ_REPLICA_URL\", \"\")\n USE_READ_REPLICA_FOR_DASHBOARD = (\n os.environ.get(\"USE_READ_REPLICA_FOR_DASHBOARD\", \"false\").lower() == \"true\"\n )\n\n # AI-originated CRUD approval gate (A95-008)\n # When true, AI CRUD endpoints return 202 pending_approval instead of writing directly.\n REQUIRE_AI_APPROVAL = os.environ.get(\"REQUIRE_AI_APPROVAL\", \"false\").lower() == \"true\"\n\n # North Star Navigation (NORTH-STAR-001)\n # Enterprise-grade navigation for Fortune 500 TOGAF/ArchiMate practitioners.\n # When enabled: renamed navigation items, persona-based filtering, hidden admin items.\n # Rollback: set to false and restart service (zero risk, instant revert).\n # ENABLED 2026-04-02: Quick wins from NORTH_STAR_ASSESSMENT.md Part 10\n ENABLE_NORTH_STAR_NAV = _env_bool(\"ENABLE_NORTH_STAR_NAV\", True)\n\n # North Star Navigation Phase 2 (NORTH-STAR-002)\n # Complete ArchiMate 3.2 layer-based navigation with all 55 element types.\n # When enabled: 6-section IA, layer-first architecture, Portfolio/Architecture separation.\n # Depends on: ENABLE_NORTH_STAR_NAV=True (Phase 1 must be active)\n # Rollback: set to false and restart service.\n ENABLE_NORTH_STAR_PHASE2 = _env_bool(\"ENABLE_NORTH_STAR_PHASE2\", False)\n\n # Page-aware AI guide is fail-closed by default and only activates when\n # this flag is explicitly enabled and an LLM provider is configured.\n AI_PAGE_GUIDE_ENABLED = _env_bool(\"AI_PAGE_GUIDE_ENABLED\", False)\n\n # File Upload Settings\n MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size\n ALLOWED_EXTENSIONS = {\n \"pdf\",\n \"docx\",\n \"doc\",\n \"xlsx\",\n \"xls\",\n \"png\",\n \"jpg\",\n \"jpeg\",\n \"vsdx\",\n \"txt\",\n \"csv\",\n \"md\",\n }\n UPLOAD_FOLDER = os.path.join(basedir, \"uploads\", \"documents\")\n\n @staticmethod\n def init_app(app):\n \"\"\"Base class hook — subclasses override to customize app initialization.\"\"\"\n # Behind a TLS-terminating reverse proxy (Caddy/nginx/Traefik), trust the\n # X-Forwarded-* headers so request.scheme/host reflect the real https URL.\n # Without this the app builds http:// URLs behind https, causing\n # mixed-content blocking. Opt-in via TRUST_PROXY so forged headers are not\n # trusted when the app is exposed directly.\n if os.environ.get(\"TRUST_PROXY\", \"false\").lower() in (\"1\", \"true\", \"yes\") \\\n and not getattr(app, \"_proxyfix_applied\", False):\n from werkzeug.middleware.proxy_fix import ProxyFix\n app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)\n app._proxyfix_applied = True\n return\n\n\nclass DevelopmentConfig(Config):\n DEBUG = True\n ASSETS_DEBUG = True\n\n # N+1 query detection — set SQLALCHEMY_ECHO=true in .env to enable query logging\n SQLALCHEMY_ECHO = os.environ.get(\"SQLALCHEMY_ECHO\", \"false\").lower() == \"true\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL configuration (REQUIRED - validated at runtime)\n # Default to a local Postgres instance for development when DATABASE_URL is not set.\n # Standard port 5432; set DATABASE_URL in .env to override (e.g. Windows installs\n # that run Postgres on 5439).\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie\", # secrets-safety-ok\n )\n\n # SERVER_NAME must NOT be hardcoded — Flask-WTF CSRF validates the Referer\n # header against SERVER_NAME, so a hardcoded value causes \"CSRF token is missing\"\n # when the browser omits the Referer header (privacy settings, extensions, etc.).\n # Only set when explicitly provided via environment variable.\n SERVER_NAME = os.environ.get(\"SERVER_NAME\") or None\n PREFERRED_URL_SCHEME = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\")\n APPLICATION_ROOT = os.environ.get(\"APPLICATION_ROOT\", \"/\")\n\n # PostgreSQL connection options (optimized for local development)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Ensure a sensible default exists for development (Postgres is the standard)\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n app.logger.warning(\n \"No DATABASE_URL provided; falling back to local Postgres default.\"\n )\n\n\nclass TestingConfig(Config):\n TESTING = True\n WTF_CSRF_ENABLED = False\n\n # PostgreSQL REQUIRED for tests (matches production behavior)\n # SQLite is NOT supported - tests must use PostgreSQL for consistency\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"TEST_DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie_test\", # secrets-safety-ok\n )\n\n # PostgreSQL connection options for testing\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate PostgreSQL is being used (not SQLite)\n db_uri = app.config.get(\"SQLALCHEMY_DATABASE_URI\", \"\")\n if \"sqlite\" in db_uri.lower():\n raise ValueError(\n \"TEST_DATABASE_URL must be PostgreSQL, not SQLite. \"\n \"Set TEST_DATABASE_URL to a PostgreSQL connection string. \"\n \"Example: postgresql://postgres:postgres@localhost:5432/flask_test\" # secrets-safety-ok\n )\n\n if not db_uri:\n app.logger.warning(\n \"No TEST_DATABASE_URL provided; using default local PostgreSQL test database.\"\n )\n\n print(\"THIS APP IS IN TESTING MODE. YOU SHOULD NOT SEE THIS IN PRODUCTION.\")\n\n\nclass ProductionConfig(Config):\n DEBUG = False\n USE_RELOADER = False\n\n # Session cookie security\n _cookie_secure_default = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\").lower() == \"https\"\n SESSION_COOKIE_SECURE = _env_bool(\"SESSION_COOKIE_SECURE\", _cookie_secure_default)\n SESSION_COOKIE_HTTPONLY = True\n SESSION_COOKIE_SAMESITE = \"Lax\"\n REMEMBER_COOKIE_SECURE = _env_bool(\"REMEMBER_COOKIE_SECURE\", _cookie_secure_default)\n REMEMBER_COOKIE_HTTPONLY = True\n\n # PostgreSQL for production (REQUIRED - validated at runtime)\n SQLALCHEMY_DATABASE_URI = os.environ.get(\"DATABASE_URL\")\n SSL_DISABLE = os.environ.get(\"SSL_DISABLE\", \"False\") == \"True\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL connection options (optimized for production)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 20,\n \"max_overflow\": 30,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate DATABASE_URL is set\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n raise ValueError(\n \"DATABASE_URL environment variable is required. PostgreSQL must be configured for production.\"\n )\n\n Config.init_app(app)\n assert os.environ.get(\"SECRET_KEY\"), \"SECRET_KEY IS NOT SET!\"\n\n # Trust the nginx reverse proxy's forwarded headers (X-Forwarded-Proto /\n # -For) so request.scheme/host reflect the real client-facing protocol.\n # Without this, behind TLS the app still builds http:// external URLs\n # (breaking OAuth redirect URIs, email links) and treats requests as\n # insecure. nginx is the single trusted hop. Idempotent so a subclass\n # (Heroku/Unix) re-running init_app cannot double-wrap.\n if not getattr(app, \"_proxyfix_applied\", False):\n from werkzeug.middleware.proxy_fix import ProxyFix\n\n app.wsgi_app = ProxyFix(\n app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1\n )\n app._proxyfix_applied = True\n\n if HAS_RAYGUN and flask_raygun and app.config.get(\"RAYGUN_APIKEY\"):\n flask_raygun.Provider(app, app.config[\"RAYGUN_APIKEY\"]).attach()\n\n\nclass HerokuConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n # ProductionConfig.init_app applies ProxyFix (idempotently); no need to\n # re-wrap here.\n ProductionConfig.init_app(app)\n\n\nclass UnixConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n ProductionConfig.init_app(app)\n\n # Log to syslog\n import logging\n from logging.handlers import SysLogHandler\n\n syslog_handler = SysLogHandler()\n syslog_handler.setLevel(logging.WARNING)\n app.logger.addHandler(syslog_handler)\n\n\n# Currency Configuration System\nclass CurrencyConfig:\n \"\"\"Centralized currency configuration for the application\"\"\"\n\n DEFAULT_CURRENCY = os.environ.get(\"DEFAULT_CURRENCY\", \"GBP\")\n\n SUPPORTED_CURRENCIES = {\n \"GBP\": {\n \"symbol\": \"£\",\n \"code\": \"GBP\",\n \"position\": \"prefix\", # prefix or suffix\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"British Pound Sterling\",\n },\n \"USD\": {\n \"symbol\": \"$\",\n \"code\": \"USD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"United States Dollar\",\n },\n \"EUR\": {\n \"symbol\": \"€\",\n \"code\": \"EUR\",\n \"position\": \"suffix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \".\",\n \"name\": \"Euro\",\n },\n \"JPY\": {\n \"symbol\": \"¥\",\n \"code\": \"JPY\",\n \"position\": \"prefix\",\n \"decimal_places\": 0,\n \"thousands_separator\": \",\",\n \"name\": \"Japanese Yen\",\n },\n \"CAD\": {\n \"symbol\": \"C$\",\n \"code\": \"CAD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Canadian Dollar\",\n },\n \"AUD\": {\n \"symbol\": \"A$\",\n \"code\": \"AUD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Australian Dollar\",\n },\n }\n\n @classmethod\n def get_currency_config(cls, currency_code=None):\n \"\"\"Get currency configuration for specified currency code\"\"\"\n if not currency_code:\n currency_code = cls.DEFAULT_CURRENCY\n\n config = cls.SUPPORTED_CURRENCIES.get(currency_code)\n if not config:\n # Fallback to default currency instead of crashing\n config = cls.SUPPORTED_CURRENCIES.get(cls.DEFAULT_CURRENCY)\n\n return config\n\n @classmethod\n def is_supported(cls, currency_code):\n \"\"\"Check if currency is supported\"\"\"\n return currency_code in cls.SUPPORTED_CURRENCIES\n\n @classmethod\n def get_all_supported_codes(cls):\n \"\"\"Get list of all supported currency codes\"\"\"\n return list(cls.SUPPORTED_CURRENCIES.keys())\n\n\nconfig = {\n \"development\": DevelopmentConfig,\n \"testing\": TestingConfig,\n \"production\": ProductionConfig,\n \"default\": DevelopmentConfig,\n \"heroku\": HerokuConfig,\n \"unix\": UnixConfig,\n}\n"}, "597c03acbd7898d5f47c4248081bcf1c1db27094": {"t": 1785753433.5703597, "n": 20377, "h": "dc659668737ec67c", "c": "import os\nfrom datetime import timedelta\n\n# Optional raygun import\ntry:\n from raygun4py.middleware import flask as flask_raygun\n\n HAS_RAYGUN = True\nexcept ImportError:\n HAS_RAYGUN = False\n flask_raygun = None\n\n# Python 3 only (Python 2 is no longer supported)\nimport urllib.parse\n\nbasedir = os.path.abspath(os.path.dirname(__file__))\n\n# Canonical module configuration. The v2 (\"guardrail\") modules are the active code\n# path (see CLAUDE.md), and the navigation/templates reference their endpoints, so\n# they MUST be registered by default. Without these flags a fresh install registers\n# only legacy/partial blueprints and every authenticated page 500s on a sidebar\n# url_for to an unregistered endpoint (e.g. solution_design.list_solutions). Using\n# setdefault means an explicit env value (true/false) is still respected.\nfor _canonical_flag in (\n \"USE_SOLUTIONS_STRATEGIC_GUARDRAILS\",\n \"USE_NEW_SOLUTIONS_STRATEGIC\",\n \"USE_CAPABILITIES_GUARDRAILS\",\n \"USE_NEW_CAPABILITIES\",\n \"USE_DASHBOARD_GUARDRAILS\",\n \"USE_NEW_DASHBOARD\",\n \"USE_APPLICATIONS_GUARDRAILS\",\n \"USE_NEW_APPLICATIONS\",\n \"USE_ARCHITECTURE_GUARDRAILS\",\n \"USE_NEW_ARCHITECTURE\",\n \"USE_AI_CHAT_GUARDRAILS\",\n \"USE_NEW_AI_CHAT\",\n # NOTE: vendors are intentionally NOT defaulted to the v2/guardrail path.\n # The always-on unified vendor blueprints serve the vendor pages AND the\n # /api/vendors/* JSON the vendor dashboard widgets call (list, ranking).\n # Forcing USE_VENDORS_GUARDRAILS switches to v2-only and drops the unified\n # API, 404-ing those widgets (\"not found\" in the UI).\n):\n os.environ.setdefault(_canonical_flag, \"true\")\n\n\ndef _env_bool(name: str, default: bool) -> bool:\n value = os.environ.get(name)\n if value is None:\n return default\n return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n# Load environment variables from .env file\nfrom dotenv import load_dotenv\n\n# Load from .env file using explicit path (ensures it works regardless of working directory)\nenv_path = os.path.join(basedir, \".env\")\nif os.path.exists(env_path):\n load_dotenv(env_path)\n print(f\"Loaded environment from: {env_path}\")\nelse:\n # Fallback to default behavior\n load_dotenv()\n print(\"Loaded environment from default location\")\n\n# Verify critical API keys are loaded\n_api_keys_loaded = []\nfor key in [\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"GEMINI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"HUGGINGFACE_API_KEY\",\n]:\n if os.getenv(key):\n _api_keys_loaded.append(key)\nif _api_keys_loaded:\n print(f\"API keys loaded: {', '.join(_api_keys_loaded)}\")\nelse:\n print(\"WARNING: No LLM API keys found in environment!\")\n\n\nclass Config:\n APP_NAME = os.environ.get(\"APP_NAME\", \"A.R.C.H.I.E.\")\n SECRET_KEY = os.environ.get(\"SECRET_KEY\")\n if not SECRET_KEY:\n import secrets\n\n SECRET_KEY = secrets.token_hex(32)\n print(\n \"WARNING: SECRET_KEY env var not set. A random key has been generated \"\n \"for this session. Sessions will NOT persist across restarts. \"\n \"Set SECRET_KEY in your .env file for production use.\"\n )\n SQLALCHEMY_COMMIT_ON_TEARDOWN = True\n\n # Session security — 8-hour session lifetime, 30-day remember-me cookie\n PERMANENT_SESSION_LIFETIME = timedelta(hours=8)\n REMEMBER_COOKIE_DURATION = timedelta(days=30)\n SESSION_REFRESH_EACH_REQUEST = True # FAR-012: Ensure session is refreshed on every request\n REMEMBER_COOKIE_REFRESH_EACH_REQUEST = True # FAR-012: Refresh remember-me cookie on activity\n\n # JWT Configuration\n JWT_TOKEN_LOCATION = [\"headers\"]\n JWT_SECRET_KEY = os.environ.get(\"JWT_SECRET_KEY\") or SECRET_KEY\n JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)\n\n # Email\n MAIL_SERVER = os.environ.get(\"MAIL_SERVER\", \"smtp.sendgrid.net\")\n MAIL_PORT = os.environ.get(\"MAIL_PORT\", 587)\n MAIL_USE_TLS = os.environ.get(\"MAIL_USE_TLS\", True)\n MAIL_USE_SSL = os.environ.get(\"MAIL_USE_SSL\", False)\n MAIL_USERNAME = os.environ.get(\"MAIL_USERNAME\")\n MAIL_PASSWORD = os.environ.get(\"MAIL_PASSWORD\")\n MAIL_DEFAULT_SENDER = os.environ.get(\"MAIL_DEFAULT_SENDER\")\n\n # Analytics\n GOOGLE_ANALYTICS_ID = os.environ.get(\"GOOGLE_ANALYTICS_ID\", \"\")\n SEGMENT_API_KEY = os.environ.get(\"SEGMENT_API_KEY\", \"\")\n\n # Admin account\n ADMIN_PASSWORD = os.environ.get(\"ADMIN_PASSWORD\")\n if not ADMIN_PASSWORD:\n import secrets\n\n ADMIN_PASSWORD = secrets.token_urlsafe(16)\n print(\n \"WARNING: ADMIN_PASSWORD env var not set. Generated random password. \"\n \"Set ADMIN_PASSWORD in your .env file for production use.\"\n )\n ADMIN_EMAIL = os.environ.get(\"ADMIN_EMAIL\", \"flask-base-admin@example.com\")\n EMAIL_SUBJECT_PREFIX = \"[{}]\".format(APP_NAME)\n EMAIL_SENDER = \"{app_name} Admin <{email}>\".format(\n app_name=APP_NAME, email=MAIL_USERNAME\n )\n\n # ARCHIE Deploy: Credential encryption + Coolify PaaS + n8n connector sync\n CREDENTIAL_ENCRYPTION_KEY = os.environ.get(\"CREDENTIAL_ENCRYPTION_KEY\", \"\")\n COOLIFY_API_URL = os.environ.get(\"COOLIFY_API_URL\", \"http://localhost:8000\")\n COOLIFY_API_TOKEN = os.environ.get(\"COOLIFY_API_TOKEN\", \"\")\n COOLIFY_DOMAIN_SUFFIX = os.environ.get(\"COOLIFY_DOMAIN_SUFFIX\", \"archie.example.com\")\n N8N_API_URL = os.environ.get(\"N8N_API_URL\", \"http://localhost:5678\")\n N8N_API_TOKEN = os.environ.get(\"N8N_API_TOKEN\", \"\")\n\n REDIS_URL = os.getenv(\"REDIS_URL\") or os.getenv(\n \"REDISTOGO_URL\", \"redis://localhost:6379/0\"\n )\n\n RAYGUN_APIKEY = os.environ.get(\"RAYGUN_APIKEY\")\n\n # Stripe Billing (COM-001) — platform works without these; set in production .env\n STRIPE_SECRET_KEY = os.environ.get(\"STRIPE_SECRET_KEY\", \"\")\n STRIPE_WEBHOOK_SECRET = os.environ.get(\"STRIPE_WEBHOOK_SECRET\", \"\")\n STRIPE_PRICE_PRO = os.environ.get(\"STRIPE_PRICE_PRO\", \"\")\n STRIPE_PRICE_ENTERPRISE = os.environ.get(\"STRIPE_PRICE_ENTERPRISE\", \"\")\n\n # Parse the REDIS_URL to set RQ config variables\n urllib.parse.uses_netloc.append(\"redis\")\n url = urllib.parse.urlparse(REDIS_URL)\n\n RQ_DEFAULT_HOST = url.hostname\n RQ_DEFAULT_PORT = url.port\n RQ_DEFAULT_PASSWORD = url.password\n RQ_DEFAULT_DB = 0\n\n # Celery configuration (async task queue)\n # Feature-flagged: set CELERY_ENABLED=true to enable async batch import\n CELERY_ENABLED = os.environ.get(\"CELERY_ENABLED\", \"false\").lower() == \"true\"\n CELERY_BROKER_URL = os.environ.get(\"CELERY_BROKER_URL\", REDIS_URL)\n CELERY_RESULT_BACKEND = os.environ.get(\"CELERY_RESULT_BACKEND\", REDIS_URL)\n CELERY_TASK_SERIALIZER = \"json\"\n CELERY_ACCEPT_CONTENT = [\"json\"]\n CELERY_RESULT_SERIALIZER = \"json\"\n CELERY_TIMEZONE = \"UTC\"\n CELERY_ENABLE_UTC = True\n\n # SSO / Enterprise Identity (S0-01)\n # Supports Azure AD, Okta (OIDC) and any SAML 2.0 IdP (PLT-030).\n # Feature-flagged: SSO routes only active when FeatureFlag(key='sso_authentication') is enabled.\n SSO_PROVIDERS = {\n \"azure\": {\n \"client_id\": os.environ.get(\"AZURE_AD_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"AZURE_AD_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\n \"AZURE_AD_METADATA_URL\",\n \"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration\".format(\n tenant=os.environ.get(\"AZURE_AD_TENANT_ID\", \"common\")\n ),\n ),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n \"okta\": {\n \"client_id\": os.environ.get(\"OKTA_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"OKTA_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\"OKTA_METADATA_URL\", \"\"),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n }\n\n # SAML 2.0 IdP configuration (PLT-030)\n # Set SAML_IDP_SSO_URL + SAML_SP_ENTITY_ID to enable SAML alongside OIDC.\n # Supported IdPs: ADFS, PingFederate, Shibboleth, Okta (SAML), Azure AD (SAML).\n # Routes registered at /account/saml/login, /account/saml/acs, /account/saml/metadata.\n SAML_IDP_SSO_URL = os.environ.get(\"SAML_IDP_SSO_URL\", \"\")\n # IdP Entity ID / Issuer URI (validated against Assertion Issuer element)\n SAML_IDP_ENTITY_ID = os.environ.get(\"SAML_IDP_ENTITY_ID\", \"\")\n # IdP X.509 signing certificate — PEM body without -----BEGIN/END----- headers.\n # Required for production signature validation (via xmlsec / python3-saml).\n SAML_IDP_CERT = os.environ.get(\"SAML_IDP_CERT\", \"\")\n # SP Entity ID — typically the platform's base URL\n SAML_SP_ENTITY_ID = os.environ.get(\"SAML_SP_ENTITY_ID\", \"\")\n # SP ACS URL — leave empty to auto-derive from url_for('account.saml_acs')\n SAML_SP_ACS_URL = os.environ.get(\"SAML_SP_ACS_URL\", \"\")\n\n # Internationalization (S2-01) — date/number/currency formatting\n # Full string translation (gettext) is Phase 2.\n BABEL_DEFAULT_LOCALE = os.environ.get(\"BABEL_DEFAULT_LOCALE\", \"en\")\n BABEL_DEFAULT_TIMEZONE = os.environ.get(\"BABEL_DEFAULT_TIMEZONE\", \"UTC\")\n\n # Read Replica Configuration (ENH-012)\n # Set DATABASE_READ_REPLICA_URL to a replica DB connection string to offload\n # dashboard and portfolio GET queries from the primary. When not configured,\n # all queries use the default SQLALCHEMY_DATABASE_URI (no-op).\n DATABASE_READ_REPLICA_URL = os.environ.get(\"DATABASE_READ_REPLICA_URL\", \"\")\n USE_READ_REPLICA_FOR_DASHBOARD = (\n os.environ.get(\"USE_READ_REPLICA_FOR_DASHBOARD\", \"false\").lower() == \"true\"\n )\n\n # AI-originated CRUD approval gate (A95-008)\n # When true, AI CRUD endpoints return 202 pending_approval instead of writing directly.\n REQUIRE_AI_APPROVAL = os.environ.get(\"REQUIRE_AI_APPROVAL\", \"false\").lower() == \"true\"\n\n # North Star Navigation (NORTH-STAR-001)\n # Enterprise-grade navigation for Fortune 500 TOGAF/ArchiMate practitioners.\n # When enabled: renamed navigation items, persona-based filtering, hidden admin items.\n # Rollback: set to false and restart service (zero risk, instant revert).\n # ENABLED 2026-04-02: Quick wins from NORTH_STAR_ASSESSMENT.md Part 10\n ENABLE_NORTH_STAR_NAV = _env_bool(\"ENABLE_NORTH_STAR_NAV\", True)\n\n # North Star Navigation Phase 2 (NORTH-STAR-002)\n # Complete ArchiMate 3.2 layer-based navigation with all 55 element types.\n # When enabled: 6-section IA, layer-first architecture, Portfolio/Architecture separation.\n # Depends on: ENABLE_NORTH_STAR_NAV=True (Phase 1 must be active)\n # Rollback: set to false and restart service.\n ENABLE_NORTH_STAR_PHASE2 = _env_bool(\"ENABLE_NORTH_STAR_PHASE2\", False)\n\n # Page-aware AI guide is fail-closed by default and only activates when\n # this flag is explicitly enabled and an LLM provider is configured.\n AI_PAGE_GUIDE_ENABLED = _env_bool(\"AI_PAGE_GUIDE_ENABLED\", False)\n\n # File Upload Settings\n MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size\n ALLOWED_EXTENSIONS = {\n \"pdf\",\n \"docx\",\n \"doc\",\n \"xlsx\",\n \"xls\",\n \"png\",\n \"jpg\",\n \"jpeg\",\n \"vsdx\",\n \"txt\",\n \"csv\",\n \"md\",\n }\n UPLOAD_FOLDER = os.path.join(basedir, \"uploads\", \"documents\")\n\n @staticmethod\n def init_app(app):\n \"\"\"Base class hook — subclasses override to customize app initialization.\"\"\"\n return\n\n\nclass DevelopmentConfig(Config):\n DEBUG = True\n ASSETS_DEBUG = True\n\n # N+1 query detection — set SQLALCHEMY_ECHO=true in .env to enable query logging\n SQLALCHEMY_ECHO = os.environ.get(\"SQLALCHEMY_ECHO\", \"false\").lower() == \"true\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL configuration (REQUIRED - validated at runtime)\n # Default to a local Postgres instance for development when DATABASE_URL is not set.\n # Standard port 5432; set DATABASE_URL in .env to override (e.g. Windows installs\n # that run Postgres on 5439).\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie\", # secrets-safety-ok\n )\n\n # SERVER_NAME must NOT be hardcoded — Flask-WTF CSRF validates the Referer\n # header against SERVER_NAME, so a hardcoded value causes \"CSRF token is missing\"\n # when the browser omits the Referer header (privacy settings, extensions, etc.).\n # Only set when explicitly provided via environment variable.\n SERVER_NAME = os.environ.get(\"SERVER_NAME\") or None\n PREFERRED_URL_SCHEME = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\")\n APPLICATION_ROOT = os.environ.get(\"APPLICATION_ROOT\", \"/\")\n\n # PostgreSQL connection options (optimized for local development)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Ensure a sensible default exists for development (Postgres is the standard)\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n app.logger.warning(\n \"No DATABASE_URL provided; falling back to local Postgres default.\"\n )\n\n\nclass TestingConfig(Config):\n TESTING = True\n WTF_CSRF_ENABLED = False\n\n # PostgreSQL REQUIRED for tests (matches production behavior)\n # SQLite is NOT supported - tests must use PostgreSQL for consistency\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"TEST_DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie_test\", # secrets-safety-ok\n )\n\n # PostgreSQL connection options for testing\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate PostgreSQL is being used (not SQLite)\n db_uri = app.config.get(\"SQLALCHEMY_DATABASE_URI\", \"\")\n if \"sqlite\" in db_uri.lower():\n raise ValueError(\n \"TEST_DATABASE_URL must be PostgreSQL, not SQLite. \"\n \"Set TEST_DATABASE_URL to a PostgreSQL connection string. \"\n \"Example: postgresql://postgres:postgres@localhost:5432/flask_test\" # secrets-safety-ok\n )\n\n if not db_uri:\n app.logger.warning(\n \"No TEST_DATABASE_URL provided; using default local PostgreSQL test database.\"\n )\n\n print(\"THIS APP IS IN TESTING MODE. YOU SHOULD NOT SEE THIS IN PRODUCTION.\")\n\n\nclass ProductionConfig(Config):\n DEBUG = False\n USE_RELOADER = False\n\n # Session cookie security\n _cookie_secure_default = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\").lower() == \"https\"\n SESSION_COOKIE_SECURE = _env_bool(\"SESSION_COOKIE_SECURE\", _cookie_secure_default)\n SESSION_COOKIE_HTTPONLY = True\n SESSION_COOKIE_SAMESITE = \"Lax\"\n REMEMBER_COOKIE_SECURE = _env_bool(\"REMEMBER_COOKIE_SECURE\", _cookie_secure_default)\n REMEMBER_COOKIE_HTTPONLY = True\n\n # PostgreSQL for production (REQUIRED - validated at runtime)\n SQLALCHEMY_DATABASE_URI = os.environ.get(\"DATABASE_URL\")\n SSL_DISABLE = os.environ.get(\"SSL_DISABLE\", \"False\") == \"True\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL connection options (optimized for production)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 20,\n \"max_overflow\": 30,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate DATABASE_URL is set\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n raise ValueError(\n \"DATABASE_URL environment variable is required. PostgreSQL must be configured for production.\"\n )\n\n Config.init_app(app)\n assert os.environ.get(\"SECRET_KEY\"), \"SECRET_KEY IS NOT SET!\"\n\n # Trust the nginx reverse proxy's forwarded headers (X-Forwarded-Proto /\n # -For) so request.scheme/host reflect the real client-facing protocol.\n # Without this, behind TLS the app still builds http:// external URLs\n # (breaking OAuth redirect URIs, email links) and treats requests as\n # insecure. nginx is the single trusted hop. Idempotent so a subclass\n # (Heroku/Unix) re-running init_app cannot double-wrap.\n if not getattr(app, \"_proxyfix_applied\", False):\n from werkzeug.middleware.proxy_fix import ProxyFix\n\n app.wsgi_app = ProxyFix(\n app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1\n )\n app._proxyfix_applied = True\n\n if HAS_RAYGUN and flask_raygun and app.config.get(\"RAYGUN_APIKEY\"):\n flask_raygun.Provider(app, app.config[\"RAYGUN_APIKEY\"]).attach()\n\n\nclass HerokuConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n # ProductionConfig.init_app applies ProxyFix (idempotently); no need to\n # re-wrap here.\n ProductionConfig.init_app(app)\n\n\nclass UnixConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n ProductionConfig.init_app(app)\n\n # Log to syslog\n import logging\n from logging.handlers import SysLogHandler\n\n syslog_handler = SysLogHandler()\n syslog_handler.setLevel(logging.WARNING)\n app.logger.addHandler(syslog_handler)\n\n\n# Currency Configuration System\nclass CurrencyConfig:\n \"\"\"Centralized currency configuration for the application\"\"\"\n\n DEFAULT_CURRENCY = os.environ.get(\"DEFAULT_CURRENCY\", \"GBP\")\n\n SUPPORTED_CURRENCIES = {\n \"GBP\": {\n \"symbol\": \"£\",\n \"code\": \"GBP\",\n \"position\": \"prefix\", # prefix or suffix\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"British Pound Sterling\",\n },\n \"USD\": {\n \"symbol\": \"$\",\n \"code\": \"USD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"United States Dollar\",\n },\n \"EUR\": {\n \"symbol\": \"€\",\n \"code\": \"EUR\",\n \"position\": \"suffix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \".\",\n \"name\": \"Euro\",\n },\n \"JPY\": {\n \"symbol\": \"¥\",\n \"code\": \"JPY\",\n \"position\": \"prefix\",\n \"decimal_places\": 0,\n \"thousands_separator\": \",\",\n \"name\": \"Japanese Yen\",\n },\n \"CAD\": {\n \"symbol\": \"C$\",\n \"code\": \"CAD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Canadian Dollar\",\n },\n \"AUD\": {\n \"symbol\": \"A$\",\n \"code\": \"AUD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Australian Dollar\",\n },\n }\n\n @classmethod\n def get_currency_config(cls, currency_code=None):\n \"\"\"Get currency configuration for specified currency code\"\"\"\n if not currency_code:\n currency_code = cls.DEFAULT_CURRENCY\n\n config = cls.SUPPORTED_CURRENCIES.get(currency_code)\n if not config:\n # Fallback to default currency instead of crashing\n config = cls.SUPPORTED_CURRENCIES.get(cls.DEFAULT_CURRENCY)\n\n return config\n\n @classmethod\n def is_supported(cls, currency_code):\n \"\"\"Check if currency is supported\"\"\"\n return currency_code in cls.SUPPORTED_CURRENCIES\n\n @classmethod\n def get_all_supported_codes(cls):\n \"\"\"Get list of all supported currency codes\"\"\"\n return list(cls.SUPPORTED_CURRENCIES.keys())\n\n\nconfig = {\n \"development\": DevelopmentConfig,\n \"testing\": TestingConfig,\n \"production\": ProductionConfig,\n \"default\": DevelopmentConfig,\n \"heroku\": HerokuConfig,\n \"unix\": UnixConfig,\n}\n"}, "aff7e1b147b4aeaa276d3ef3dff7c220af81791d": {"t": 1785753435.9463027, "n": 20061, "h": "bb62076d8144dda5", "c": "import os\nfrom datetime import timedelta\n\n# Optional raygun import\ntry:\n from raygun4py.middleware import flask as flask_raygun\n\n HAS_RAYGUN = True\nexcept ImportError:\n HAS_RAYGUN = False\n flask_raygun = None\n\n# Python 3 only (Python 2 is no longer supported)\nimport urllib.parse\n\nbasedir = os.path.abspath(os.path.dirname(__file__))\n\n# Canonical module configuration. The v2 (\"guardrail\") modules are the active code\n# path (see CLAUDE.md), and the navigation/templates reference their endpoints, so\n# they MUST be registered by default. Without these flags a fresh install registers\n# only legacy/partial blueprints and every authenticated page 500s on a sidebar\n# url_for to an unregistered endpoint (e.g. solution_design.list_solutions). Using\n# setdefault means an explicit env value (true/false) is still respected.\nfor _canonical_flag in (\n \"USE_SOLUTIONS_STRATEGIC_GUARDRAILS\",\n \"USE_NEW_SOLUTIONS_STRATEGIC\",\n \"USE_CAPABILITIES_GUARDRAILS\",\n \"USE_NEW_CAPABILITIES\",\n \"USE_DASHBOARD_GUARDRAILS\",\n \"USE_NEW_DASHBOARD\",\n \"USE_APPLICATIONS_GUARDRAILS\",\n \"USE_NEW_APPLICATIONS\",\n \"USE_ARCHITECTURE_GUARDRAILS\",\n \"USE_NEW_ARCHITECTURE\",\n \"USE_AI_CHAT_GUARDRAILS\",\n \"USE_NEW_AI_CHAT\",\n \"USE_VENDORS_GUARDRAILS\",\n \"USE_NEW_VENDORS\",\n):\n os.environ.setdefault(_canonical_flag, \"true\")\n\n\ndef _env_bool(name: str, default: bool) -> bool:\n value = os.environ.get(name)\n if value is None:\n return default\n return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n# Load environment variables from .env file\nfrom dotenv import load_dotenv\n\n# Load from .env file using explicit path (ensures it works regardless of working directory)\nenv_path = os.path.join(basedir, \".env\")\nif os.path.exists(env_path):\n load_dotenv(env_path)\n print(f\"Loaded environment from: {env_path}\")\nelse:\n # Fallback to default behavior\n load_dotenv()\n print(\"Loaded environment from default location\")\n\n# Verify critical API keys are loaded\n_api_keys_loaded = []\nfor key in [\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"GEMINI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"HUGGINGFACE_API_KEY\",\n]:\n if os.getenv(key):\n _api_keys_loaded.append(key)\nif _api_keys_loaded:\n print(f\"API keys loaded: {', '.join(_api_keys_loaded)}\")\nelse:\n print(\"WARNING: No LLM API keys found in environment!\")\n\n\nclass Config:\n APP_NAME = os.environ.get(\"APP_NAME\", \"A.R.C.H.I.E.\")\n SECRET_KEY = os.environ.get(\"SECRET_KEY\")\n if not SECRET_KEY:\n import secrets\n\n SECRET_KEY = secrets.token_hex(32)\n print(\n \"WARNING: SECRET_KEY env var not set. A random key has been generated \"\n \"for this session. Sessions will NOT persist across restarts. \"\n \"Set SECRET_KEY in your .env file for production use.\"\n )\n SQLALCHEMY_COMMIT_ON_TEARDOWN = True\n\n # Session security — 8-hour session lifetime, 30-day remember-me cookie\n PERMANENT_SESSION_LIFETIME = timedelta(hours=8)\n REMEMBER_COOKIE_DURATION = timedelta(days=30)\n SESSION_REFRESH_EACH_REQUEST = True # FAR-012: Ensure session is refreshed on every request\n REMEMBER_COOKIE_REFRESH_EACH_REQUEST = True # FAR-012: Refresh remember-me cookie on activity\n\n # JWT Configuration\n JWT_TOKEN_LOCATION = [\"headers\"]\n JWT_SECRET_KEY = os.environ.get(\"JWT_SECRET_KEY\") or SECRET_KEY\n JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)\n\n # Email\n MAIL_SERVER = os.environ.get(\"MAIL_SERVER\", \"smtp.sendgrid.net\")\n MAIL_PORT = os.environ.get(\"MAIL_PORT\", 587)\n MAIL_USE_TLS = os.environ.get(\"MAIL_USE_TLS\", True)\n MAIL_USE_SSL = os.environ.get(\"MAIL_USE_SSL\", False)\n MAIL_USERNAME = os.environ.get(\"MAIL_USERNAME\")\n MAIL_PASSWORD = os.environ.get(\"MAIL_PASSWORD\")\n MAIL_DEFAULT_SENDER = os.environ.get(\"MAIL_DEFAULT_SENDER\")\n\n # Analytics\n GOOGLE_ANALYTICS_ID = os.environ.get(\"GOOGLE_ANALYTICS_ID\", \"\")\n SEGMENT_API_KEY = os.environ.get(\"SEGMENT_API_KEY\", \"\")\n\n # Admin account\n ADMIN_PASSWORD = os.environ.get(\"ADMIN_PASSWORD\")\n if not ADMIN_PASSWORD:\n import secrets\n\n ADMIN_PASSWORD = secrets.token_urlsafe(16)\n print(\n \"WARNING: ADMIN_PASSWORD env var not set. Generated random password. \"\n \"Set ADMIN_PASSWORD in your .env file for production use.\"\n )\n ADMIN_EMAIL = os.environ.get(\"ADMIN_EMAIL\", \"flask-base-admin@example.com\")\n EMAIL_SUBJECT_PREFIX = \"[{}]\".format(APP_NAME)\n EMAIL_SENDER = \"{app_name} Admin <{email}>\".format(\n app_name=APP_NAME, email=MAIL_USERNAME\n )\n\n # ARCHIE Deploy: Credential encryption + Coolify PaaS + n8n connector sync\n CREDENTIAL_ENCRYPTION_KEY = os.environ.get(\"CREDENTIAL_ENCRYPTION_KEY\", \"\")\n COOLIFY_API_URL = os.environ.get(\"COOLIFY_API_URL\", \"http://localhost:8000\")\n COOLIFY_API_TOKEN = os.environ.get(\"COOLIFY_API_TOKEN\", \"\")\n COOLIFY_DOMAIN_SUFFIX = os.environ.get(\"COOLIFY_DOMAIN_SUFFIX\", \"archie.example.com\")\n N8N_API_URL = os.environ.get(\"N8N_API_URL\", \"http://localhost:5678\")\n N8N_API_TOKEN = os.environ.get(\"N8N_API_TOKEN\", \"\")\n\n REDIS_URL = os.getenv(\"REDIS_URL\") or os.getenv(\n \"REDISTOGO_URL\", \"redis://localhost:6379/0\"\n )\n\n RAYGUN_APIKEY = os.environ.get(\"RAYGUN_APIKEY\")\n\n # Stripe Billing (COM-001) — platform works without these; set in production .env\n STRIPE_SECRET_KEY = os.environ.get(\"STRIPE_SECRET_KEY\", \"\")\n STRIPE_WEBHOOK_SECRET = os.environ.get(\"STRIPE_WEBHOOK_SECRET\", \"\")\n STRIPE_PRICE_PRO = os.environ.get(\"STRIPE_PRICE_PRO\", \"\")\n STRIPE_PRICE_ENTERPRISE = os.environ.get(\"STRIPE_PRICE_ENTERPRISE\", \"\")\n\n # Parse the REDIS_URL to set RQ config variables\n urllib.parse.uses_netloc.append(\"redis\")\n url = urllib.parse.urlparse(REDIS_URL)\n\n RQ_DEFAULT_HOST = url.hostname\n RQ_DEFAULT_PORT = url.port\n RQ_DEFAULT_PASSWORD = url.password\n RQ_DEFAULT_DB = 0\n\n # Celery configuration (async task queue)\n # Feature-flagged: set CELERY_ENABLED=true to enable async batch import\n CELERY_ENABLED = os.environ.get(\"CELERY_ENABLED\", \"false\").lower() == \"true\"\n CELERY_BROKER_URL = os.environ.get(\"CELERY_BROKER_URL\", REDIS_URL)\n CELERY_RESULT_BACKEND = os.environ.get(\"CELERY_RESULT_BACKEND\", REDIS_URL)\n CELERY_TASK_SERIALIZER = \"json\"\n CELERY_ACCEPT_CONTENT = [\"json\"]\n CELERY_RESULT_SERIALIZER = \"json\"\n CELERY_TIMEZONE = \"UTC\"\n CELERY_ENABLE_UTC = True\n\n # SSO / Enterprise Identity (S0-01)\n # Supports Azure AD, Okta (OIDC) and any SAML 2.0 IdP (PLT-030).\n # Feature-flagged: SSO routes only active when FeatureFlag(key='sso_authentication') is enabled.\n SSO_PROVIDERS = {\n \"azure\": {\n \"client_id\": os.environ.get(\"AZURE_AD_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"AZURE_AD_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\n \"AZURE_AD_METADATA_URL\",\n \"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration\".format(\n tenant=os.environ.get(\"AZURE_AD_TENANT_ID\", \"common\")\n ),\n ),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n \"okta\": {\n \"client_id\": os.environ.get(\"OKTA_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"OKTA_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\"OKTA_METADATA_URL\", \"\"),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n }\n\n # SAML 2.0 IdP configuration (PLT-030)\n # Set SAML_IDP_SSO_URL + SAML_SP_ENTITY_ID to enable SAML alongside OIDC.\n # Supported IdPs: ADFS, PingFederate, Shibboleth, Okta (SAML), Azure AD (SAML).\n # Routes registered at /account/saml/login, /account/saml/acs, /account/saml/metadata.\n SAML_IDP_SSO_URL = os.environ.get(\"SAML_IDP_SSO_URL\", \"\")\n # IdP Entity ID / Issuer URI (validated against Assertion Issuer element)\n SAML_IDP_ENTITY_ID = os.environ.get(\"SAML_IDP_ENTITY_ID\", \"\")\n # IdP X.509 signing certificate — PEM body without -----BEGIN/END----- headers.\n # Required for production signature validation (via xmlsec / python3-saml).\n SAML_IDP_CERT = os.environ.get(\"SAML_IDP_CERT\", \"\")\n # SP Entity ID — typically the platform's base URL\n SAML_SP_ENTITY_ID = os.environ.get(\"SAML_SP_ENTITY_ID\", \"\")\n # SP ACS URL — leave empty to auto-derive from url_for('account.saml_acs')\n SAML_SP_ACS_URL = os.environ.get(\"SAML_SP_ACS_URL\", \"\")\n\n # Internationalization (S2-01) — date/number/currency formatting\n # Full string translation (gettext) is Phase 2.\n BABEL_DEFAULT_LOCALE = os.environ.get(\"BABEL_DEFAULT_LOCALE\", \"en\")\n BABEL_DEFAULT_TIMEZONE = os.environ.get(\"BABEL_DEFAULT_TIMEZONE\", \"UTC\")\n\n # Read Replica Configuration (ENH-012)\n # Set DATABASE_READ_REPLICA_URL to a replica DB connection string to offload\n # dashboard and portfolio GET queries from the primary. When not configured,\n # all queries use the default SQLALCHEMY_DATABASE_URI (no-op).\n DATABASE_READ_REPLICA_URL = os.environ.get(\"DATABASE_READ_REPLICA_URL\", \"\")\n USE_READ_REPLICA_FOR_DASHBOARD = (\n os.environ.get(\"USE_READ_REPLICA_FOR_DASHBOARD\", \"false\").lower() == \"true\"\n )\n\n # AI-originated CRUD approval gate (A95-008)\n # When true, AI CRUD endpoints return 202 pending_approval instead of writing directly.\n REQUIRE_AI_APPROVAL = os.environ.get(\"REQUIRE_AI_APPROVAL\", \"false\").lower() == \"true\"\n\n # North Star Navigation (NORTH-STAR-001)\n # Enterprise-grade navigation for Fortune 500 TOGAF/ArchiMate practitioners.\n # When enabled: renamed navigation items, persona-based filtering, hidden admin items.\n # Rollback: set to false and restart service (zero risk, instant revert).\n # ENABLED 2026-04-02: Quick wins from NORTH_STAR_ASSESSMENT.md Part 10\n ENABLE_NORTH_STAR_NAV = _env_bool(\"ENABLE_NORTH_STAR_NAV\", True)\n\n # North Star Navigation Phase 2 (NORTH-STAR-002)\n # Complete ArchiMate 3.2 layer-based navigation with all 55 element types.\n # When enabled: 6-section IA, layer-first architecture, Portfolio/Architecture separation.\n # Depends on: ENABLE_NORTH_STAR_NAV=True (Phase 1 must be active)\n # Rollback: set to false and restart service.\n ENABLE_NORTH_STAR_PHASE2 = _env_bool(\"ENABLE_NORTH_STAR_PHASE2\", False)\n\n # Page-aware AI guide is fail-closed by default and only activates when\n # this flag is explicitly enabled and an LLM provider is configured.\n AI_PAGE_GUIDE_ENABLED = _env_bool(\"AI_PAGE_GUIDE_ENABLED\", False)\n\n # File Upload Settings\n MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size\n ALLOWED_EXTENSIONS = {\n \"pdf\",\n \"docx\",\n \"doc\",\n \"xlsx\",\n \"xls\",\n \"png\",\n \"jpg\",\n \"jpeg\",\n \"vsdx\",\n \"txt\",\n \"csv\",\n \"md\",\n }\n UPLOAD_FOLDER = os.path.join(basedir, \"uploads\", \"documents\")\n\n @staticmethod\n def init_app(app):\n \"\"\"Base class hook — subclasses override to customize app initialization.\"\"\"\n return\n\n\nclass DevelopmentConfig(Config):\n DEBUG = True\n ASSETS_DEBUG = True\n\n # N+1 query detection — set SQLALCHEMY_ECHO=true in .env to enable query logging\n SQLALCHEMY_ECHO = os.environ.get(\"SQLALCHEMY_ECHO\", \"false\").lower() == \"true\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL configuration (REQUIRED - validated at runtime)\n # Default to a local Postgres instance for development when DATABASE_URL is not set.\n # Standard port 5432; set DATABASE_URL in .env to override (e.g. Windows installs\n # that run Postgres on 5439).\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie\", # secrets-safety-ok\n )\n\n # SERVER_NAME must NOT be hardcoded — Flask-WTF CSRF validates the Referer\n # header against SERVER_NAME, so a hardcoded value causes \"CSRF token is missing\"\n # when the browser omits the Referer header (privacy settings, extensions, etc.).\n # Only set when explicitly provided via environment variable.\n SERVER_NAME = os.environ.get(\"SERVER_NAME\") or None\n PREFERRED_URL_SCHEME = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\")\n APPLICATION_ROOT = os.environ.get(\"APPLICATION_ROOT\", \"/\")\n\n # PostgreSQL connection options (optimized for local development)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Ensure a sensible default exists for development (Postgres is the standard)\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n app.logger.warning(\n \"No DATABASE_URL provided; falling back to local Postgres default.\"\n )\n\n\nclass TestingConfig(Config):\n TESTING = True\n WTF_CSRF_ENABLED = False\n\n # PostgreSQL REQUIRED for tests (matches production behavior)\n # SQLite is NOT supported - tests must use PostgreSQL for consistency\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"TEST_DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie_test\", # secrets-safety-ok\n )\n\n # PostgreSQL connection options for testing\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate PostgreSQL is being used (not SQLite)\n db_uri = app.config.get(\"SQLALCHEMY_DATABASE_URI\", \"\")\n if \"sqlite\" in db_uri.lower():\n raise ValueError(\n \"TEST_DATABASE_URL must be PostgreSQL, not SQLite. \"\n \"Set TEST_DATABASE_URL to a PostgreSQL connection string. \"\n \"Example: postgresql://postgres:postgres@localhost:5432/flask_test\" # secrets-safety-ok\n )\n\n if not db_uri:\n app.logger.warning(\n \"No TEST_DATABASE_URL provided; using default local PostgreSQL test database.\"\n )\n\n print(\"THIS APP IS IN TESTING MODE. YOU SHOULD NOT SEE THIS IN PRODUCTION.\")\n\n\nclass ProductionConfig(Config):\n DEBUG = False\n USE_RELOADER = False\n\n # Session cookie security\n _cookie_secure_default = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\").lower() == \"https\"\n SESSION_COOKIE_SECURE = _env_bool(\"SESSION_COOKIE_SECURE\", _cookie_secure_default)\n SESSION_COOKIE_HTTPONLY = True\n SESSION_COOKIE_SAMESITE = \"Lax\"\n REMEMBER_COOKIE_SECURE = _env_bool(\"REMEMBER_COOKIE_SECURE\", _cookie_secure_default)\n REMEMBER_COOKIE_HTTPONLY = True\n\n # PostgreSQL for production (REQUIRED - validated at runtime)\n SQLALCHEMY_DATABASE_URI = os.environ.get(\"DATABASE_URL\")\n SSL_DISABLE = os.environ.get(\"SSL_DISABLE\", \"False\") == \"True\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL connection options (optimized for production)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 20,\n \"max_overflow\": 30,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate DATABASE_URL is set\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n raise ValueError(\n \"DATABASE_URL environment variable is required. PostgreSQL must be configured for production.\"\n )\n\n Config.init_app(app)\n assert os.environ.get(\"SECRET_KEY\"), \"SECRET_KEY IS NOT SET!\"\n\n # Trust the nginx reverse proxy's forwarded headers (X-Forwarded-Proto /\n # -For) so request.scheme/host reflect the real client-facing protocol.\n # Without this, behind TLS the app still builds http:// external URLs\n # (breaking OAuth redirect URIs, email links) and treats requests as\n # insecure. nginx is the single trusted hop. Idempotent so a subclass\n # (Heroku/Unix) re-running init_app cannot double-wrap.\n if not getattr(app, \"_proxyfix_applied\", False):\n from werkzeug.middleware.proxy_fix import ProxyFix\n\n app.wsgi_app = ProxyFix(\n app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1\n )\n app._proxyfix_applied = True\n\n if HAS_RAYGUN and flask_raygun and app.config.get(\"RAYGUN_APIKEY\"):\n flask_raygun.Provider(app, app.config[\"RAYGUN_APIKEY\"]).attach()\n\n\nclass HerokuConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n # ProductionConfig.init_app applies ProxyFix (idempotently); no need to\n # re-wrap here.\n ProductionConfig.init_app(app)\n\n\nclass UnixConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n ProductionConfig.init_app(app)\n\n # Log to syslog\n import logging\n from logging.handlers import SysLogHandler\n\n syslog_handler = SysLogHandler()\n syslog_handler.setLevel(logging.WARNING)\n app.logger.addHandler(syslog_handler)\n\n\n# Currency Configuration System\nclass CurrencyConfig:\n \"\"\"Centralized currency configuration for the application\"\"\"\n\n DEFAULT_CURRENCY = os.environ.get(\"DEFAULT_CURRENCY\", \"GBP\")\n\n SUPPORTED_CURRENCIES = {\n \"GBP\": {\n \"symbol\": \"£\",\n \"code\": \"GBP\",\n \"position\": \"prefix\", # prefix or suffix\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"British Pound Sterling\",\n },\n \"USD\": {\n \"symbol\": \"$\",\n \"code\": \"USD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"United States Dollar\",\n },\n \"EUR\": {\n \"symbol\": \"€\",\n \"code\": \"EUR\",\n \"position\": \"suffix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \".\",\n \"name\": \"Euro\",\n },\n \"JPY\": {\n \"symbol\": \"¥\",\n \"code\": \"JPY\",\n \"position\": \"prefix\",\n \"decimal_places\": 0,\n \"thousands_separator\": \",\",\n \"name\": \"Japanese Yen\",\n },\n \"CAD\": {\n \"symbol\": \"C$\",\n \"code\": \"CAD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Canadian Dollar\",\n },\n \"AUD\": {\n \"symbol\": \"A$\",\n \"code\": \"AUD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Australian Dollar\",\n },\n }\n\n @classmethod\n def get_currency_config(cls, currency_code=None):\n \"\"\"Get currency configuration for specified currency code\"\"\"\n if not currency_code:\n currency_code = cls.DEFAULT_CURRENCY\n\n config = cls.SUPPORTED_CURRENCIES.get(currency_code)\n if not config:\n # Fallback to default currency instead of crashing\n config = cls.SUPPORTED_CURRENCIES.get(cls.DEFAULT_CURRENCY)\n\n return config\n\n @classmethod\n def is_supported(cls, currency_code):\n \"\"\"Check if currency is supported\"\"\"\n return currency_code in cls.SUPPORTED_CURRENCIES\n\n @classmethod\n def get_all_supported_codes(cls):\n \"\"\"Get list of all supported currency codes\"\"\"\n return list(cls.SUPPORTED_CURRENCIES.keys())\n\n\nconfig = {\n \"development\": DevelopmentConfig,\n \"testing\": TestingConfig,\n \"production\": ProductionConfig,\n \"default\": DevelopmentConfig,\n \"heroku\": HerokuConfig,\n \"unix\": UnixConfig,\n}\n"}, "33f31e704f1a301da1e85f5d4bbe39993fa44934": {"t": 1785753437.5126364, "n": 19065, "h": "4de1badadec46cea", "c": "import os\nfrom datetime import timedelta\n\n# Optional raygun import\ntry:\n from raygun4py.middleware import flask as flask_raygun\n\n HAS_RAYGUN = True\nexcept ImportError:\n HAS_RAYGUN = False\n flask_raygun = None\n\n# Python 3 only (Python 2 is no longer supported)\nimport urllib.parse\n\nbasedir = os.path.abspath(os.path.dirname(__file__))\n\n\ndef _env_bool(name: str, default: bool) -> bool:\n value = os.environ.get(name)\n if value is None:\n return default\n return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n# Load environment variables from .env file\nfrom dotenv import load_dotenv\n\n# Load from .env file using explicit path (ensures it works regardless of working directory)\nenv_path = os.path.join(basedir, \".env\")\nif os.path.exists(env_path):\n load_dotenv(env_path)\n print(f\"Loaded environment from: {env_path}\")\nelse:\n # Fallback to default behavior\n load_dotenv()\n print(\"Loaded environment from default location\")\n\n# Verify critical API keys are loaded\n_api_keys_loaded = []\nfor key in [\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"GEMINI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"HUGGINGFACE_API_KEY\",\n]:\n if os.getenv(key):\n _api_keys_loaded.append(key)\nif _api_keys_loaded:\n print(f\"API keys loaded: {', '.join(_api_keys_loaded)}\")\nelse:\n print(\"WARNING: No LLM API keys found in environment!\")\n\n\nclass Config:\n APP_NAME = os.environ.get(\"APP_NAME\", \"A.R.C.H.I.E.\")\n SECRET_KEY = os.environ.get(\"SECRET_KEY\")\n if not SECRET_KEY:\n import secrets\n\n SECRET_KEY = secrets.token_hex(32)\n print(\n \"WARNING: SECRET_KEY env var not set. A random key has been generated \"\n \"for this session. Sessions will NOT persist across restarts. \"\n \"Set SECRET_KEY in your .env file for production use.\"\n )\n SQLALCHEMY_COMMIT_ON_TEARDOWN = True\n\n # Session security — 8-hour session lifetime, 30-day remember-me cookie\n PERMANENT_SESSION_LIFETIME = timedelta(hours=8)\n REMEMBER_COOKIE_DURATION = timedelta(days=30)\n SESSION_REFRESH_EACH_REQUEST = True # FAR-012: Ensure session is refreshed on every request\n REMEMBER_COOKIE_REFRESH_EACH_REQUEST = True # FAR-012: Refresh remember-me cookie on activity\n\n # JWT Configuration\n JWT_TOKEN_LOCATION = [\"headers\"]\n JWT_SECRET_KEY = os.environ.get(\"JWT_SECRET_KEY\") or SECRET_KEY\n JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)\n\n # Email\n MAIL_SERVER = os.environ.get(\"MAIL_SERVER\", \"smtp.sendgrid.net\")\n MAIL_PORT = os.environ.get(\"MAIL_PORT\", 587)\n MAIL_USE_TLS = os.environ.get(\"MAIL_USE_TLS\", True)\n MAIL_USE_SSL = os.environ.get(\"MAIL_USE_SSL\", False)\n MAIL_USERNAME = os.environ.get(\"MAIL_USERNAME\")\n MAIL_PASSWORD = os.environ.get(\"MAIL_PASSWORD\")\n MAIL_DEFAULT_SENDER = os.environ.get(\"MAIL_DEFAULT_SENDER\")\n\n # Analytics\n GOOGLE_ANALYTICS_ID = os.environ.get(\"GOOGLE_ANALYTICS_ID\", \"\")\n SEGMENT_API_KEY = os.environ.get(\"SEGMENT_API_KEY\", \"\")\n\n # Admin account\n ADMIN_PASSWORD = os.environ.get(\"ADMIN_PASSWORD\")\n if not ADMIN_PASSWORD:\n import secrets\n\n ADMIN_PASSWORD = secrets.token_urlsafe(16)\n print(\n \"WARNING: ADMIN_PASSWORD env var not set. Generated random password. \"\n \"Set ADMIN_PASSWORD in your .env file for production use.\"\n )\n ADMIN_EMAIL = os.environ.get(\"ADMIN_EMAIL\", \"flask-base-admin@example.com\")\n EMAIL_SUBJECT_PREFIX = \"[{}]\".format(APP_NAME)\n EMAIL_SENDER = \"{app_name} Admin <{email}>\".format(\n app_name=APP_NAME, email=MAIL_USERNAME\n )\n\n # ARCHIE Deploy: Credential encryption + Coolify PaaS + n8n connector sync\n CREDENTIAL_ENCRYPTION_KEY = os.environ.get(\"CREDENTIAL_ENCRYPTION_KEY\", \"\")\n COOLIFY_API_URL = os.environ.get(\"COOLIFY_API_URL\", \"http://localhost:8000\")\n COOLIFY_API_TOKEN = os.environ.get(\"COOLIFY_API_TOKEN\", \"\")\n COOLIFY_DOMAIN_SUFFIX = os.environ.get(\"COOLIFY_DOMAIN_SUFFIX\", \"archie.example.com\")\n N8N_API_URL = os.environ.get(\"N8N_API_URL\", \"http://localhost:5678\")\n N8N_API_TOKEN = os.environ.get(\"N8N_API_TOKEN\", \"\")\n\n REDIS_URL = os.getenv(\"REDIS_URL\") or os.getenv(\n \"REDISTOGO_URL\", \"redis://localhost:6379/0\"\n )\n\n RAYGUN_APIKEY = os.environ.get(\"RAYGUN_APIKEY\")\n\n # Stripe Billing (COM-001) — platform works without these; set in production .env\n STRIPE_SECRET_KEY = os.environ.get(\"STRIPE_SECRET_KEY\", \"\")\n STRIPE_WEBHOOK_SECRET = os.environ.get(\"STRIPE_WEBHOOK_SECRET\", \"\")\n STRIPE_PRICE_PRO = os.environ.get(\"STRIPE_PRICE_PRO\", \"\")\n STRIPE_PRICE_ENTERPRISE = os.environ.get(\"STRIPE_PRICE_ENTERPRISE\", \"\")\n\n # Parse the REDIS_URL to set RQ config variables\n urllib.parse.uses_netloc.append(\"redis\")\n url = urllib.parse.urlparse(REDIS_URL)\n\n RQ_DEFAULT_HOST = url.hostname\n RQ_DEFAULT_PORT = url.port\n RQ_DEFAULT_PASSWORD = url.password\n RQ_DEFAULT_DB = 0\n\n # Celery configuration (async task queue)\n # Feature-flagged: set CELERY_ENABLED=true to enable async batch import\n CELERY_ENABLED = os.environ.get(\"CELERY_ENABLED\", \"false\").lower() == \"true\"\n CELERY_BROKER_URL = os.environ.get(\"CELERY_BROKER_URL\", REDIS_URL)\n CELERY_RESULT_BACKEND = os.environ.get(\"CELERY_RESULT_BACKEND\", REDIS_URL)\n CELERY_TASK_SERIALIZER = \"json\"\n CELERY_ACCEPT_CONTENT = [\"json\"]\n CELERY_RESULT_SERIALIZER = \"json\"\n CELERY_TIMEZONE = \"UTC\"\n CELERY_ENABLE_UTC = True\n\n # SSO / Enterprise Identity (S0-01)\n # Supports Azure AD, Okta (OIDC) and any SAML 2.0 IdP (PLT-030).\n # Feature-flagged: SSO routes only active when FeatureFlag(key='sso_authentication') is enabled.\n SSO_PROVIDERS = {\n \"azure\": {\n \"client_id\": os.environ.get(\"AZURE_AD_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"AZURE_AD_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\n \"AZURE_AD_METADATA_URL\",\n \"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration\".format(\n tenant=os.environ.get(\"AZURE_AD_TENANT_ID\", \"common\")\n ),\n ),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n \"okta\": {\n \"client_id\": os.environ.get(\"OKTA_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"OKTA_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\"OKTA_METADATA_URL\", \"\"),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n }\n\n # SAML 2.0 IdP configuration (PLT-030)\n # Set SAML_IDP_SSO_URL + SAML_SP_ENTITY_ID to enable SAML alongside OIDC.\n # Supported IdPs: ADFS, PingFederate, Shibboleth, Okta (SAML), Azure AD (SAML).\n # Routes registered at /account/saml/login, /account/saml/acs, /account/saml/metadata.\n SAML_IDP_SSO_URL = os.environ.get(\"SAML_IDP_SSO_URL\", \"\")\n # IdP Entity ID / Issuer URI (validated against Assertion Issuer element)\n SAML_IDP_ENTITY_ID = os.environ.get(\"SAML_IDP_ENTITY_ID\", \"\")\n # IdP X.509 signing certificate — PEM body without -----BEGIN/END----- headers.\n # Required for production signature validation (via xmlsec / python3-saml).\n SAML_IDP_CERT = os.environ.get(\"SAML_IDP_CERT\", \"\")\n # SP Entity ID — typically the platform's base URL\n SAML_SP_ENTITY_ID = os.environ.get(\"SAML_SP_ENTITY_ID\", \"\")\n # SP ACS URL — leave empty to auto-derive from url_for('account.saml_acs')\n SAML_SP_ACS_URL = os.environ.get(\"SAML_SP_ACS_URL\", \"\")\n\n # Internationalization (S2-01) — date/number/currency formatting\n # Full string translation (gettext) is Phase 2.\n BABEL_DEFAULT_LOCALE = os.environ.get(\"BABEL_DEFAULT_LOCALE\", \"en\")\n BABEL_DEFAULT_TIMEZONE = os.environ.get(\"BABEL_DEFAULT_TIMEZONE\", \"UTC\")\n\n # Read Replica Configuration (ENH-012)\n # Set DATABASE_READ_REPLICA_URL to a replica DB connection string to offload\n # dashboard and portfolio GET queries from the primary. When not configured,\n # all queries use the default SQLALCHEMY_DATABASE_URI (no-op).\n DATABASE_READ_REPLICA_URL = os.environ.get(\"DATABASE_READ_REPLICA_URL\", \"\")\n USE_READ_REPLICA_FOR_DASHBOARD = (\n os.environ.get(\"USE_READ_REPLICA_FOR_DASHBOARD\", \"false\").lower() == \"true\"\n )\n\n # AI-originated CRUD approval gate (A95-008)\n # When true, AI CRUD endpoints return 202 pending_approval instead of writing directly.\n REQUIRE_AI_APPROVAL = os.environ.get(\"REQUIRE_AI_APPROVAL\", \"false\").lower() == \"true\"\n\n # North Star Navigation (NORTH-STAR-001)\n # Enterprise-grade navigation for Fortune 500 TOGAF/ArchiMate practitioners.\n # When enabled: renamed navigation items, persona-based filtering, hidden admin items.\n # Rollback: set to false and restart service (zero risk, instant revert).\n # ENABLED 2026-04-02: Quick wins from NORTH_STAR_ASSESSMENT.md Part 10\n ENABLE_NORTH_STAR_NAV = _env_bool(\"ENABLE_NORTH_STAR_NAV\", True)\n\n # North Star Navigation Phase 2 (NORTH-STAR-002)\n # Complete ArchiMate 3.2 layer-based navigation with all 55 element types.\n # When enabled: 6-section IA, layer-first architecture, Portfolio/Architecture separation.\n # Depends on: ENABLE_NORTH_STAR_NAV=True (Phase 1 must be active)\n # Rollback: set to false and restart service.\n ENABLE_NORTH_STAR_PHASE2 = _env_bool(\"ENABLE_NORTH_STAR_PHASE2\", False)\n\n # Page-aware AI guide is fail-closed by default and only activates when\n # this flag is explicitly enabled and an LLM provider is configured.\n AI_PAGE_GUIDE_ENABLED = _env_bool(\"AI_PAGE_GUIDE_ENABLED\", False)\n\n # File Upload Settings\n MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size\n ALLOWED_EXTENSIONS = {\n \"pdf\",\n \"docx\",\n \"doc\",\n \"xlsx\",\n \"xls\",\n \"png\",\n \"jpg\",\n \"jpeg\",\n \"vsdx\",\n \"txt\",\n \"csv\",\n \"md\",\n }\n UPLOAD_FOLDER = os.path.join(basedir, \"uploads\", \"documents\")\n\n @staticmethod\n def init_app(app):\n \"\"\"Base class hook — subclasses override to customize app initialization.\"\"\"\n return\n\n\nclass DevelopmentConfig(Config):\n DEBUG = True\n ASSETS_DEBUG = True\n\n # N+1 query detection — set SQLALCHEMY_ECHO=true in .env to enable query logging\n SQLALCHEMY_ECHO = os.environ.get(\"SQLALCHEMY_ECHO\", \"false\").lower() == \"true\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL configuration (REQUIRED - validated at runtime)\n # Default to a local Postgres instance for development when DATABASE_URL is not set.\n # Standard port 5432; set DATABASE_URL in .env to override (e.g. Windows installs\n # that run Postgres on 5439).\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie\", # secrets-safety-ok\n )\n\n # SERVER_NAME must NOT be hardcoded — Flask-WTF CSRF validates the Referer\n # header against SERVER_NAME, so a hardcoded value causes \"CSRF token is missing\"\n # when the browser omits the Referer header (privacy settings, extensions, etc.).\n # Only set when explicitly provided via environment variable.\n SERVER_NAME = os.environ.get(\"SERVER_NAME\") or None\n PREFERRED_URL_SCHEME = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\")\n APPLICATION_ROOT = os.environ.get(\"APPLICATION_ROOT\", \"/\")\n\n # PostgreSQL connection options (optimized for local development)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Ensure a sensible default exists for development (Postgres is the standard)\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n app.logger.warning(\n \"No DATABASE_URL provided; falling back to local Postgres default.\"\n )\n\n\nclass TestingConfig(Config):\n TESTING = True\n WTF_CSRF_ENABLED = False\n\n # PostgreSQL REQUIRED for tests (matches production behavior)\n # SQLite is NOT supported - tests must use PostgreSQL for consistency\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"TEST_DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5432/archie_test\", # secrets-safety-ok\n )\n\n # PostgreSQL connection options for testing\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate PostgreSQL is being used (not SQLite)\n db_uri = app.config.get(\"SQLALCHEMY_DATABASE_URI\", \"\")\n if \"sqlite\" in db_uri.lower():\n raise ValueError(\n \"TEST_DATABASE_URL must be PostgreSQL, not SQLite. \"\n \"Set TEST_DATABASE_URL to a PostgreSQL connection string. \"\n \"Example: postgresql://postgres:postgres@localhost:5432/flask_test\" # secrets-safety-ok\n )\n\n if not db_uri:\n app.logger.warning(\n \"No TEST_DATABASE_URL provided; using default local PostgreSQL test database.\"\n )\n\n print(\"THIS APP IS IN TESTING MODE. YOU SHOULD NOT SEE THIS IN PRODUCTION.\")\n\n\nclass ProductionConfig(Config):\n DEBUG = False\n USE_RELOADER = False\n\n # Session cookie security\n _cookie_secure_default = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\").lower() == \"https\"\n SESSION_COOKIE_SECURE = _env_bool(\"SESSION_COOKIE_SECURE\", _cookie_secure_default)\n SESSION_COOKIE_HTTPONLY = True\n SESSION_COOKIE_SAMESITE = \"Lax\"\n REMEMBER_COOKIE_SECURE = _env_bool(\"REMEMBER_COOKIE_SECURE\", _cookie_secure_default)\n REMEMBER_COOKIE_HTTPONLY = True\n\n # PostgreSQL for production (REQUIRED - validated at runtime)\n SQLALCHEMY_DATABASE_URI = os.environ.get(\"DATABASE_URL\")\n SSL_DISABLE = os.environ.get(\"SSL_DISABLE\", \"False\") == \"True\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL connection options (optimized for production)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 20,\n \"max_overflow\": 30,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate DATABASE_URL is set\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n raise ValueError(\n \"DATABASE_URL environment variable is required. PostgreSQL must be configured for production.\"\n )\n\n Config.init_app(app)\n assert os.environ.get(\"SECRET_KEY\"), \"SECRET_KEY IS NOT SET!\"\n\n # Trust the nginx reverse proxy's forwarded headers (X-Forwarded-Proto /\n # -For) so request.scheme/host reflect the real client-facing protocol.\n # Without this, behind TLS the app still builds http:// external URLs\n # (breaking OAuth redirect URIs, email links) and treats requests as\n # insecure. nginx is the single trusted hop. Idempotent so a subclass\n # (Heroku/Unix) re-running init_app cannot double-wrap.\n if not getattr(app, \"_proxyfix_applied\", False):\n from werkzeug.middleware.proxy_fix import ProxyFix\n\n app.wsgi_app = ProxyFix(\n app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1\n )\n app._proxyfix_applied = True\n\n if HAS_RAYGUN and flask_raygun and app.config.get(\"RAYGUN_APIKEY\"):\n flask_raygun.Provider(app, app.config[\"RAYGUN_APIKEY\"]).attach()\n\n\nclass HerokuConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n # ProductionConfig.init_app applies ProxyFix (idempotently); no need to\n # re-wrap here.\n ProductionConfig.init_app(app)\n\n\nclass UnixConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n ProductionConfig.init_app(app)\n\n # Log to syslog\n import logging\n from logging.handlers import SysLogHandler\n\n syslog_handler = SysLogHandler()\n syslog_handler.setLevel(logging.WARNING)\n app.logger.addHandler(syslog_handler)\n\n\n# Currency Configuration System\nclass CurrencyConfig:\n \"\"\"Centralized currency configuration for the application\"\"\"\n\n DEFAULT_CURRENCY = os.environ.get(\"DEFAULT_CURRENCY\", \"GBP\")\n\n SUPPORTED_CURRENCIES = {\n \"GBP\": {\n \"symbol\": \"£\",\n \"code\": \"GBP\",\n \"position\": \"prefix\", # prefix or suffix\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"British Pound Sterling\",\n },\n \"USD\": {\n \"symbol\": \"$\",\n \"code\": \"USD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"United States Dollar\",\n },\n \"EUR\": {\n \"symbol\": \"€\",\n \"code\": \"EUR\",\n \"position\": \"suffix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \".\",\n \"name\": \"Euro\",\n },\n \"JPY\": {\n \"symbol\": \"¥\",\n \"code\": \"JPY\",\n \"position\": \"prefix\",\n \"decimal_places\": 0,\n \"thousands_separator\": \",\",\n \"name\": \"Japanese Yen\",\n },\n \"CAD\": {\n \"symbol\": \"C$\",\n \"code\": \"CAD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Canadian Dollar\",\n },\n \"AUD\": {\n \"symbol\": \"A$\",\n \"code\": \"AUD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Australian Dollar\",\n },\n }\n\n @classmethod\n def get_currency_config(cls, currency_code=None):\n \"\"\"Get currency configuration for specified currency code\"\"\"\n if not currency_code:\n currency_code = cls.DEFAULT_CURRENCY\n\n config = cls.SUPPORTED_CURRENCIES.get(currency_code)\n if not config:\n # Fallback to default currency instead of crashing\n config = cls.SUPPORTED_CURRENCIES.get(cls.DEFAULT_CURRENCY)\n\n return config\n\n @classmethod\n def is_supported(cls, currency_code):\n \"\"\"Check if currency is supported\"\"\"\n return currency_code in cls.SUPPORTED_CURRENCIES\n\n @classmethod\n def get_all_supported_codes(cls):\n \"\"\"Get list of all supported currency codes\"\"\"\n return list(cls.SUPPORTED_CURRENCIES.keys())\n\n\nconfig = {\n \"development\": DevelopmentConfig,\n \"testing\": TestingConfig,\n \"production\": ProductionConfig,\n \"default\": DevelopmentConfig,\n \"heroku\": HerokuConfig,\n \"unix\": UnixConfig,\n}\n"}, "f35ff1a649ef9a3bf31df9887690e5070a38cd11": {"t": 1785753440.1442654, "n": 19219, "h": "082695e8549c72d6", "c": "import os\nfrom datetime import timedelta\n\n# Optional raygun import\ntry:\n from raygun4py.middleware import flask as flask_raygun\n\n HAS_RAYGUN = True\nexcept ImportError:\n HAS_RAYGUN = False\n flask_raygun = None\n\n# Python 3 only (Python 2 is no longer supported)\nimport urllib.parse\n\nbasedir = os.path.abspath(os.path.dirname(__file__))\n\n\ndef _env_bool(name: str, default: bool) -> bool:\n value = os.environ.get(name)\n if value is None:\n return default\n return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n# Load environment variables from .env file\nfrom dotenv import load_dotenv\n\n# Load from .env file using explicit path (ensures it works regardless of working directory)\nenv_path = os.path.join(basedir, \".env\")\nif os.path.exists(env_path):\n load_dotenv(env_path)\n print(f\"Loaded environment from: {env_path}\")\nelse:\n # Fallback to default behavior\n load_dotenv()\n print(\"Loaded environment from default location\")\n\n# Verify critical API keys are loaded\n_api_keys_loaded = []\nfor key in [\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"GEMINI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"HUGGINGFACE_API_KEY\",\n]:\n if os.getenv(key):\n _api_keys_loaded.append(key)\nif _api_keys_loaded:\n print(f\"API keys loaded: {', '.join(_api_keys_loaded)}\")\nelse:\n print(\"WARNING: No LLM API keys found in environment!\")\n\n\nclass Config:\n APP_NAME = os.environ.get(\"APP_NAME\", \"A.R.C.H.I.E.\")\n SECRET_KEY = os.environ.get(\"SECRET_KEY\")\n if not SECRET_KEY:\n import secrets\n\n SECRET_KEY = secrets.token_hex(32)\n print(\n \"WARNING: SECRET_KEY env var not set. A random key has been generated \"\n \"for this session. Sessions will NOT persist across restarts. \"\n \"Set SECRET_KEY in your .env file for production use.\"\n )\n SQLALCHEMY_COMMIT_ON_TEARDOWN = True\n\n # Session security — 8-hour session lifetime, 30-day remember-me cookie\n PERMANENT_SESSION_LIFETIME = timedelta(hours=8)\n REMEMBER_COOKIE_DURATION = timedelta(days=30)\n SESSION_REFRESH_EACH_REQUEST = True # FAR-012: Ensure session is refreshed on every request\n REMEMBER_COOKIE_REFRESH_EACH_REQUEST = True # FAR-012: Refresh remember-me cookie on activity\n\n # JWT Configuration\n JWT_TOKEN_LOCATION = [\"headers\"]\n JWT_SECRET_KEY = os.environ.get(\"JWT_SECRET_KEY\") or SECRET_KEY\n JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)\n\n # Email\n MAIL_SERVER = os.environ.get(\"MAIL_SERVER\", \"smtp.sendgrid.net\")\n MAIL_PORT = os.environ.get(\"MAIL_PORT\", 587)\n MAIL_USE_TLS = os.environ.get(\"MAIL_USE_TLS\", True)\n MAIL_USE_SSL = os.environ.get(\"MAIL_USE_SSL\", False)\n MAIL_USERNAME = os.environ.get(\"MAIL_USERNAME\")\n MAIL_PASSWORD = os.environ.get(\"MAIL_PASSWORD\")\n MAIL_DEFAULT_SENDER = os.environ.get(\"MAIL_DEFAULT_SENDER\")\n\n # Analytics\n GOOGLE_ANALYTICS_ID = os.environ.get(\"GOOGLE_ANALYTICS_ID\", \"\")\n SEGMENT_API_KEY = os.environ.get(\"SEGMENT_API_KEY\", \"\")\n\n # Admin account\n ADMIN_PASSWORD = os.environ.get(\"ADMIN_PASSWORD\")\n if not ADMIN_PASSWORD:\n import secrets\n\n ADMIN_PASSWORD = secrets.token_urlsafe(16)\n print(\n \"WARNING: ADMIN_PASSWORD env var not set. Generated random password. \"\n \"Set ADMIN_PASSWORD in your .env file for production use.\"\n )\n ADMIN_EMAIL = os.environ.get(\"ADMIN_EMAIL\", \"flask-base-admin@example.com\")\n EMAIL_SUBJECT_PREFIX = \"[{}]\".format(APP_NAME)\n EMAIL_SENDER = \"{app_name} Admin <{email}>\".format(\n app_name=APP_NAME, email=MAIL_USERNAME\n )\n\n # ARCHIE Deploy: Credential encryption + Coolify PaaS + n8n connector sync\n CREDENTIAL_ENCRYPTION_KEY = os.environ.get(\"CREDENTIAL_ENCRYPTION_KEY\", \"\")\n COOLIFY_API_URL = os.environ.get(\"COOLIFY_API_URL\", \"http://localhost:8000\")\n COOLIFY_API_TOKEN = os.environ.get(\"COOLIFY_API_TOKEN\", \"\")\n COOLIFY_DOMAIN_SUFFIX = os.environ.get(\"COOLIFY_DOMAIN_SUFFIX\", \"archie.example.com\")\n N8N_API_URL = os.environ.get(\"N8N_API_URL\", \"http://localhost:5678\")\n N8N_API_TOKEN = os.environ.get(\"N8N_API_TOKEN\", \"\")\n\n REDIS_URL = os.getenv(\"REDIS_URL\") or os.getenv(\n \"REDISTOGO_URL\", \"redis://localhost:6379/0\"\n )\n\n RAYGUN_APIKEY = os.environ.get(\"RAYGUN_APIKEY\")\n\n # Stripe Billing (COM-001) — platform works without these; set in production .env\n STRIPE_SECRET_KEY = os.environ.get(\"STRIPE_SECRET_KEY\", \"\")\n STRIPE_WEBHOOK_SECRET = os.environ.get(\"STRIPE_WEBHOOK_SECRET\", \"\")\n STRIPE_PRICE_PRO = os.environ.get(\"STRIPE_PRICE_PRO\", \"\")\n STRIPE_PRICE_ENTERPRISE = os.environ.get(\"STRIPE_PRICE_ENTERPRISE\", \"\")\n\n # Parse the REDIS_URL to set RQ config variables\n urllib.parse.uses_netloc.append(\"redis\")\n url = urllib.parse.urlparse(REDIS_URL)\n\n RQ_DEFAULT_HOST = url.hostname\n RQ_DEFAULT_PORT = url.port\n RQ_DEFAULT_PASSWORD = url.password\n RQ_DEFAULT_DB = 0\n\n # Celery configuration (async task queue)\n # Feature-flagged: set CELERY_ENABLED=true to enable async batch import\n CELERY_ENABLED = os.environ.get(\"CELERY_ENABLED\", \"false\").lower() == \"true\"\n CELERY_BROKER_URL = os.environ.get(\"CELERY_BROKER_URL\", REDIS_URL)\n CELERY_RESULT_BACKEND = os.environ.get(\"CELERY_RESULT_BACKEND\", REDIS_URL)\n CELERY_TASK_SERIALIZER = \"json\"\n CELERY_ACCEPT_CONTENT = [\"json\"]\n CELERY_RESULT_SERIALIZER = \"json\"\n CELERY_TIMEZONE = \"UTC\"\n CELERY_ENABLE_UTC = True\n\n # SSO / Enterprise Identity (S0-01)\n # Supports Azure AD, Okta (OIDC) and any SAML 2.0 IdP (PLT-030).\n # Feature-flagged: SSO routes only active when FeatureFlag(key='sso_authentication') is enabled.\n SSO_PROVIDERS = {\n \"azure\": {\n \"client_id\": os.environ.get(\"AZURE_AD_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"AZURE_AD_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\n \"AZURE_AD_METADATA_URL\",\n \"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration\".format(\n tenant=os.environ.get(\"AZURE_AD_TENANT_ID\", \"common\")\n ),\n ),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n \"okta\": {\n \"client_id\": os.environ.get(\"OKTA_CLIENT_ID\", \"\"),\n \"client_secret\": os.environ.get(\"OKTA_CLIENT_SECRET\", \"\"),\n \"server_metadata_url\": os.environ.get(\"OKTA_METADATA_URL\", \"\"),\n \"client_kwargs\": {\"scope\": \"openid email profile\"},\n },\n }\n\n # SAML 2.0 IdP configuration (PLT-030)\n # Set SAML_IDP_SSO_URL + SAML_SP_ENTITY_ID to enable SAML alongside OIDC.\n # Supported IdPs: ADFS, PingFederate, Shibboleth, Okta (SAML), Azure AD (SAML).\n # Routes registered at /account/saml/login, /account/saml/acs, /account/saml/metadata.\n SAML_IDP_SSO_URL = os.environ.get(\"SAML_IDP_SSO_URL\", \"\")\n # IdP Entity ID / Issuer URI (validated against Assertion Issuer element)\n SAML_IDP_ENTITY_ID = os.environ.get(\"SAML_IDP_ENTITY_ID\", \"\")\n # IdP X.509 signing certificate — PEM body without -----BEGIN/END----- headers.\n # Required for production signature validation (via xmlsec / python3-saml).\n SAML_IDP_CERT = os.environ.get(\"SAML_IDP_CERT\", \"\")\n # SP Entity ID — typically the platform's base URL\n SAML_SP_ENTITY_ID = os.environ.get(\"SAML_SP_ENTITY_ID\", \"\")\n # SP ACS URL — leave empty to auto-derive from url_for('account.saml_acs')\n SAML_SP_ACS_URL = os.environ.get(\"SAML_SP_ACS_URL\", \"\")\n\n # Internationalization (S2-01) — date/number/currency formatting\n # Full string translation (gettext) is Phase 2.\n BABEL_DEFAULT_LOCALE = os.environ.get(\"BABEL_DEFAULT_LOCALE\", \"en\")\n BABEL_DEFAULT_TIMEZONE = os.environ.get(\"BABEL_DEFAULT_TIMEZONE\", \"UTC\")\n\n # Read Replica Configuration (ENH-012)\n # Set DATABASE_READ_REPLICA_URL to a replica DB connection string to offload\n # dashboard and portfolio GET queries from the primary. When not configured,\n # all queries use the default SQLALCHEMY_DATABASE_URI (no-op).\n DATABASE_READ_REPLICA_URL = os.environ.get(\"DATABASE_READ_REPLICA_URL\", \"\")\n USE_READ_REPLICA_FOR_DASHBOARD = (\n os.environ.get(\"USE_READ_REPLICA_FOR_DASHBOARD\", \"false\").lower() == \"true\"\n )\n\n # AI-originated CRUD approval gate (A95-008)\n # When true, AI CRUD endpoints return 202 pending_approval instead of writing directly.\n REQUIRE_AI_APPROVAL = os.environ.get(\"REQUIRE_AI_APPROVAL\", \"false\").lower() == \"true\"\n\n # North Star Navigation (NORTH-STAR-001)\n # Enterprise-grade navigation for Fortune 500 TOGAF/ArchiMate practitioners.\n # When enabled: renamed navigation items, persona-based filtering, hidden admin items.\n # Rollback: set to false and restart service (zero risk, instant revert).\n # ENABLED 2026-04-02: Quick wins from NORTH_STAR_ASSESSMENT.md Part 10\n ENABLE_NORTH_STAR_NAV = _env_bool(\"ENABLE_NORTH_STAR_NAV\", True)\n\n # North Star Navigation Phase 2 (NORTH-STAR-002)\n # Complete ArchiMate 3.2 layer-based navigation with all 55 element types.\n # When enabled: 6-section IA, layer-first architecture, Portfolio/Architecture separation.\n # Depends on: ENABLE_NORTH_STAR_NAV=True (Phase 1 must be active)\n # Rollback: set to false and restart service.\n ENABLE_NORTH_STAR_PHASE2 = _env_bool(\"ENABLE_NORTH_STAR_PHASE2\", False)\n\n # Page-aware AI guide is fail-closed by default and only activates when\n # this flag is explicitly enabled and an LLM provider is configured.\n AI_PAGE_GUIDE_ENABLED = _env_bool(\"AI_PAGE_GUIDE_ENABLED\", False)\n\n # File Upload Settings\n MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size\n ALLOWED_EXTENSIONS = {\n \"pdf\",\n \"docx\",\n \"doc\",\n \"xlsx\",\n \"xls\",\n \"png\",\n \"jpg\",\n \"jpeg\",\n \"vsdx\",\n \"txt\",\n \"csv\",\n \"md\",\n }\n UPLOAD_FOLDER = os.path.join(basedir, \"uploads\", \"documents\")\n\n @staticmethod\n def init_app(app):\n \"\"\"Base class hook — subclasses override to customize app initialization.\"\"\"\n return\n\n\nclass DevelopmentConfig(Config):\n DEBUG = True\n ASSETS_DEBUG = True\n\n # N+1 query detection — set SQLALCHEMY_ECHO=true in .env to enable query logging\n SQLALCHEMY_ECHO = os.environ.get(\"SQLALCHEMY_ECHO\", \"false\").lower() == \"true\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL configuration (REQUIRED - validated at runtime)\n # Default to a local Postgres instance for development when DATABASE_URL is not set\n # Note: Default port is 5439 for local Windows PostgreSQL installation\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5439/flask_app\", # secrets-safety-ok\n )\n\n # SERVER_NAME must NOT be hardcoded — Flask-WTF CSRF validates the Referer\n # header against SERVER_NAME, so a hardcoded value causes \"CSRF token is missing\"\n # when the browser omits the Referer header (privacy settings, extensions, etc.).\n # Only set when explicitly provided via environment variable.\n SERVER_NAME = os.environ.get(\"SERVER_NAME\") or None\n PREFERRED_URL_SCHEME = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\")\n APPLICATION_ROOT = os.environ.get(\"APPLICATION_ROOT\", \"/\")\n\n # PostgreSQL connection options (optimized for local development)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Ensure a sensible default exists for development (Postgres is the standard)\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n app.logger.warning(\n \"No DATABASE_URL provided; falling back to local Postgres default.\"\n )\n print(\n \"THIS APP IS IN DEBUG MODE. \\\n YOU SHOULD NOT SEE THIS IN PRODUCTION.\"\n )\n\n\nclass TestingConfig(Config):\n TESTING = True\n WTF_CSRF_ENABLED = False\n\n # PostgreSQL REQUIRED for tests (matches production behavior)\n # SQLite is NOT supported - tests must use PostgreSQL for consistency\n # Note: Default port is 5439 for local Windows PostgreSQL installation\n SQLALCHEMY_DATABASE_URI = os.environ.get(\n \"TEST_DATABASE_URL\",\n \"postgresql://postgres:postgres@127.0.0.1:5439/flask_test\", # secrets-safety-ok\n )\n\n # PostgreSQL connection options for testing\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_size\": 5,\n \"max_overflow\": 10,\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate PostgreSQL is being used (not SQLite)\n db_uri = app.config.get(\"SQLALCHEMY_DATABASE_URI\", \"\")\n if \"sqlite\" in db_uri.lower():\n raise ValueError(\n \"TEST_DATABASE_URL must be PostgreSQL, not SQLite. \"\n \"Set TEST_DATABASE_URL to a PostgreSQL connection string. \"\n \"Example: postgresql://postgres:postgres@localhost:5432/flask_test\" # secrets-safety-ok\n )\n\n if not db_uri:\n app.logger.warning(\n \"No TEST_DATABASE_URL provided; using default local PostgreSQL test database.\"\n )\n\n print(\"THIS APP IS IN TESTING MODE. YOU SHOULD NOT SEE THIS IN PRODUCTION.\")\n\n\nclass ProductionConfig(Config):\n DEBUG = False\n USE_RELOADER = False\n\n # Session cookie security\n _cookie_secure_default = os.environ.get(\"PREFERRED_URL_SCHEME\", \"http\").lower() == \"https\"\n SESSION_COOKIE_SECURE = _env_bool(\"SESSION_COOKIE_SECURE\", _cookie_secure_default)\n SESSION_COOKIE_HTTPONLY = True\n SESSION_COOKIE_SAMESITE = \"Lax\"\n REMEMBER_COOKIE_SECURE = _env_bool(\"REMEMBER_COOKIE_SECURE\", _cookie_secure_default)\n REMEMBER_COOKIE_HTTPONLY = True\n\n # PostgreSQL for production (REQUIRED - validated at runtime)\n SQLALCHEMY_DATABASE_URI = os.environ.get(\"DATABASE_URL\")\n SSL_DISABLE = os.environ.get(\"SSL_DISABLE\", \"False\") == \"True\"\n JIRA_AUTO_PUSH = os.environ.get(\"JIRA_AUTO_PUSH\", \"false\").lower() == \"true\"\n\n # PostgreSQL connection options (optimized for production)\n SQLALCHEMY_ENGINE_OPTIONS = {\n \"pool_pre_ping\": True,\n \"pool_recycle\": 300,\n \"pool_size\": 20,\n \"max_overflow\": 30,\n \"pool_timeout\": 30,\n \"connect_args\": {\n \"client_encoding\": \"utf8\",\n \"options\": \"-c client_encoding=utf8\",\n },\n \"pool_reset_on_return\": \"commit\",\n }\n\n @classmethod\n def init_app(cls, app):\n # Validate DATABASE_URL is set\n if not app.config.get(\"SQLALCHEMY_DATABASE_URI\"):\n raise ValueError(\n \"DATABASE_URL environment variable is required. PostgreSQL must be configured for production.\"\n )\n\n Config.init_app(app)\n assert os.environ.get(\"SECRET_KEY\"), \"SECRET_KEY IS NOT SET!\"\n\n # Trust the nginx reverse proxy's forwarded headers (X-Forwarded-Proto /\n # -For) so request.scheme/host reflect the real client-facing protocol.\n # Without this, behind TLS the app still builds http:// external URLs\n # (breaking OAuth redirect URIs, email links) and treats requests as\n # insecure. nginx is the single trusted hop. Idempotent so a subclass\n # (Heroku/Unix) re-running init_app cannot double-wrap.\n if not getattr(app, \"_proxyfix_applied\", False):\n from werkzeug.middleware.proxy_fix import ProxyFix\n\n app.wsgi_app = ProxyFix(\n app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1\n )\n app._proxyfix_applied = True\n\n if HAS_RAYGUN and flask_raygun and app.config.get(\"RAYGUN_APIKEY\"):\n flask_raygun.Provider(app, app.config[\"RAYGUN_APIKEY\"]).attach()\n\n\nclass HerokuConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n # ProductionConfig.init_app applies ProxyFix (idempotently); no need to\n # re-wrap here.\n ProductionConfig.init_app(app)\n\n\nclass UnixConfig(ProductionConfig):\n @classmethod\n def init_app(cls, app):\n ProductionConfig.init_app(app)\n\n # Log to syslog\n import logging\n from logging.handlers import SysLogHandler\n\n syslog_handler = SysLogHandler()\n syslog_handler.setLevel(logging.WARNING)\n app.logger.addHandler(syslog_handler)\n\n\n# Currency Configuration System\nclass CurrencyConfig:\n \"\"\"Centralized currency configuration for the application\"\"\"\n\n DEFAULT_CURRENCY = os.environ.get(\"DEFAULT_CURRENCY\", \"GBP\")\n\n SUPPORTED_CURRENCIES = {\n \"GBP\": {\n \"symbol\": \"£\",\n \"code\": \"GBP\",\n \"position\": \"prefix\", # prefix or suffix\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"British Pound Sterling\",\n },\n \"USD\": {\n \"symbol\": \"$\",\n \"code\": \"USD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"United States Dollar\",\n },\n \"EUR\": {\n \"symbol\": \"€\",\n \"code\": \"EUR\",\n \"position\": \"suffix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \".\",\n \"name\": \"Euro\",\n },\n \"JPY\": {\n \"symbol\": \"¥\",\n \"code\": \"JPY\",\n \"position\": \"prefix\",\n \"decimal_places\": 0,\n \"thousands_separator\": \",\",\n \"name\": \"Japanese Yen\",\n },\n \"CAD\": {\n \"symbol\": \"C$\",\n \"code\": \"CAD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Canadian Dollar\",\n },\n \"AUD\": {\n \"symbol\": \"A$\",\n \"code\": \"AUD\",\n \"position\": \"prefix\",\n \"decimal_places\": 2,\n \"thousands_separator\": \",\",\n \"name\": \"Australian Dollar\",\n },\n }\n\n @classmethod\n def get_currency_config(cls, currency_code=None):\n \"\"\"Get currency configuration for specified currency code\"\"\"\n if not currency_code:\n currency_code = cls.DEFAULT_CURRENCY\n\n config = cls.SUPPORTED_CURRENCIES.get(currency_code)\n if not config:\n # Fallback to default currency instead of crashing\n config = cls.SUPPORTED_CURRENCIES.get(cls.DEFAULT_CURRENCY)\n\n return config\n\n @classmethod\n def is_supported(cls, currency_code):\n \"\"\"Check if currency is supported\"\"\"\n return currency_code in cls.SUPPORTED_CURRENCIES\n\n @classmethod\n def get_all_supported_codes(cls):\n \"\"\"Get list of all supported currency codes\"\"\"\n return list(cls.SUPPORTED_CURRENCIES.keys())\n\n\nconfig = {\n \"development\": DevelopmentConfig,\n \"testing\": TestingConfig,\n \"production\": ProductionConfig,\n \"default\": DevelopmentConfig,\n \"heroku\": HerokuConfig,\n \"unix\": UnixConfig,\n}\n"}}, "app/modules/admin/v2/routes/admin_routes.py": {"2c8c6955833bedccbf935f6f69f6be51ab3c38c3": {"t": 1786068625.6929004, "n": 208734, "h": "bf54c76cb344cb8f", "c": "\"\"\"\nAdmin Routes v2 — guardrail-enabled.\n\nUses the new architecture:\n- @timed_route for automatic metrics collection on all endpoints\n- Observability (request_id in response headers)\n- Consistent error handling via exception mappers\n\nURL prefix preserved: /admin (applied via register() in v2/__init__.py)\nBlueprint name: admin (same as v1 for url_for compatibility — cross-module\nrefs exist in app/application_mgmt/impact_routes.py calling\nurl_for(\"admin.api_settings\"))\n\nAll routes preserved exactly from v1 admin_routes.py.\n\"\"\"\n\nimport logging\nimport os\n\nfrom werkzeug.utils import secure_filename\n\nfrom flask import (\n Blueprint,\n flash,\n jsonify,\n redirect,\n render_template,\n request,\n url_for,\n)\nfrom flask_login import current_user, login_required\nfrom sqlalchemy.orm import aliased\n\ntry:\n from flask_rq import get_queue\n\n HAS_RQ = True\nexcept ImportError:\n HAS_RQ = False\n get_queue = None\n\nimport hmac\nimport hashlib\nimport json\nfrom datetime import datetime, timedelta\n\nfrom app import csrf\nfrom app.extensions import db\nfrom app.core.compat import mark_blueprint_guardrailed\nfrom app.core.decorators import timed_route\nfrom ...forms.admin_forms import (\n APISettingsForm,\n ChangeAccountTypeForm,\n ChangeUserEmailForm,\n FeatureFlagForm,\n InviteUserForm,\n NewUserForm,\n)\nfrom app.modules.account.forms.account_forms import CreatePasswordForm\nfrom app.decorators import admin_required, audit_log\nfrom app.middleware.tenant_decorators import org_admin_required, platform_admin_required\nfrom app.models import APISettings, EditableHTML, Role, User\nfrom app.models.organization import Organization\nfrom app.models.ai_service import AIPromptTemplate\nfrom app.models.feature_flags import FeatureFlag, FeatureState, FeatureType\nfrom app.modules.admin.v2.services.llm_service_v2 import test_api_key\nfrom app.utils.sidebar_parser import SidebarSubmenu, parse_sidebar_template\nfrom app.modules.admin.v2.services.admin_user_service_v2 import AdminUserService\n\n# Blueprint name MUST be \"admin\" (not \"admin_v2\") because cross-module code\n# uses url_for(\"admin.api_settings\") etc. The 3-tier fallback in\n# _bootstrap/blueprints.py guarantees only one tier is active at a time,\n# so there is no name collision.\nadmin_bp_v2 = Blueprint(\"admin\", __name__)\nmark_blueprint_guardrailed(admin_bp_v2)\n\nlogger = logging.getLogger(__name__)\n\n_svc = AdminUserService\n\n\n@admin_bp_v2.route(\"/send-digest\", methods=[\"POST\"])\n@login_required\n@admin_required\n@timed_route\ndef send_digest():\n \"\"\"PLT-009/PLT-031: Manual trigger for weekly digest emails.\"\"\"\n from flask import current_app\n from app._bootstrap._digest_emails import (\n send_data_maturity_digest,\n send_executive_summary,\n )\n\n digest_type = request.form.get(\"type\", \"both\")\n results = {}\n\n if digest_type in (\"maturity\", \"both\"):\n data = send_data_maturity_digest(current_app._get_current_object())\n results[\"maturity\"] = {\n \"total_solutions\": data[\"total\"],\n \"avg_score\": data[\"avg_score\"],\n \"zero_connections\": len(data[\"zero_connections\"]),\n }\n\n if digest_type in (\"executive\", \"both\"):\n data = send_executive_summary(current_app._get_current_object())\n results[\"executive\"] = {\n \"total_solutions\": data[\"total_solutions\"],\n \"new_this_week\": data[\"new_solutions_count\"],\n \"arb_decisions\": data[\"arb_decisions_count\"],\n }\n\n return jsonify({\"success\": True, \"results\": results})\n\n\n_TEST_ARTIFACT_PATTERNS = [\n \"J1-AutoTest-%\", \"New Solution%\", \"J1 Bootstrap%\",\n \"J1 Test%\", \"J1 Regression%\", \"J1-Debug%\", \"J1-Test-%\",\n \"%E2E Test%\", \"%Journey Test%\", \"AI Test%\", \"Minimal Test%\",\n \"QA Test%\", \"Driver Test%\", \"Blueprint Test%\", \"%JDD Test%\",\n \"%Gap2 Persistence%\", \"Post-Deploy%\", \"%Smoke Test%\",\n \"% Test Solution%\", \"% Test Solution\", \"%Audit Test%\",\n \"%Forensic Audit%\", \"%Verification Test%\", \"%Uniformity Verification%\",\n \"% Test Programme%\", \"%PESTLE News Analyser%\", \"MDM Test%\",\n \"Create an architecture for%\",\n]\n\n\ndef _get_solutions_portfolio_stats() -> dict:\n \"\"\"ENT-013: Compute solutions portfolio stats for admin dashboard.\"\"\"\n try:\n from app.models.solution_models import Solution\n from app.models.solution_lifecycle_models import SolutionRisk\n from sqlalchemy import not_, or_\n _real_filter = [\n Solution.name.isnot(None),\n not_(or_(*[Solution.name.like(p) for p in _TEST_ARTIFACT_PATTERNS])),\n ]\n total = Solution.query.filter(*_real_filter).count()\n phases = {}\n for sol in Solution.query.filter(*_real_filter).with_entities(Solution.adm_phase).all():\n p = sol.adm_phase or \"A\"\n phases[p] = phases.get(p, 0) + 1\n pending_arb = Solution.query.filter(*_real_filter).filter(\n Solution.governance_status.in_([\"pending_review\", \"in_review\"])\n ).count()\n top_risks = (\n SolutionRisk.query\n .filter(SolutionRisk.impact.in_([\"critical\", \"high\"]))\n .filter_by(status=\"open\")\n .order_by(SolutionRisk.created_at.desc())\n .limit(3)\n .all()\n )\n return {\n \"total\": total,\n \"phases\": phases,\n \"pending_arb\": pending_arb,\n \"top_risks\": [{\"description\": r.risk_description[:80], \"impact\": r.impact} for r in top_risks],\n }\n except Exception as e:\n logger.warning(f\"Could not compute solution portfolio stats: {e}\")\n return {\"total\": 0, \"phases\": {}, \"pending_arb\": 0, \"top_risks\": []}\n\n\ndef _get_data_maturity_stats() -> dict:\n \"\"\"PLT-006: Compute portfolio-wide data maturity metrics for admin dashboard.\"\"\"\n try:\n from app.models.solution_models import Solution\n from sqlalchemy import not_, or_\n\n solutions = Solution.query.filter(\n Solution.name.isnot(None),\n not_(or_(*[Solution.name.like(p) for p in _TEST_ARTIFACT_PATTERNS])),\n ).limit(50).all()\n total = len(solutions)\n if total == 0:\n return {\n \"total\": 0,\n \"avg_score\": 0,\n \"buckets\": {\"minimal\": 0, \"partial\": 0, \"good\": 0},\n \"top5\": [],\n \"bottom5\": [],\n \"junction_fill_rates\": {},\n }\n\n scores = []\n for sol in solutions:\n try:\n cs = sol.architecture_completeness_score\n scores.append({\n \"id\": sol.id,\n \"name\": sol.name or f\"Solution #{sol.id}\",\n \"score\": cs[\"score\"],\n \"filled\": cs[\"filled\"],\n \"filled_count\": cs[\"filled_count\"],\n \"total_junctions\": cs[\"total\"],\n })\n except Exception:\n scores.append({\n \"id\": sol.id,\n \"name\": sol.name or f\"Solution #{sol.id}\",\n \"score\": 0,\n \"filled\": [],\n \"filled_count\": 0,\n \"total_junctions\": 14,\n })\n\n avg_score = round(sum(s[\"score\"] for s in scores) / total) if total else 0\n\n minimal = sum(1 for s in scores if s[\"score\"] < 25)\n partial = sum(1 for s in scores if 25 <= s[\"score\"] <= 75)\n good = sum(1 for s in scores if s[\"score\"] > 75)\n\n sorted_asc = sorted(scores, key=lambda s: s[\"score\"])\n sorted_desc = sorted(scores, key=lambda s: s[\"score\"], reverse=True)\n top5 = sorted_desc[:5]\n bottom5 = sorted_asc[:5]\n\n # Junction fill rates: how many solutions have each junction populated\n junction_names = [\n \"drivers\", \"goals\", \"constraints\", \"requirements\",\n \"applications\", \"archimate_elements\", \"vendor_products\",\n \"recommendations\", \"risks\", \"tco_items\", \"metrics\",\n \"plateaus\", \"apqc_processes\", \"capability_mappings\",\n ]\n junction_fill = {}\n for jname in junction_names:\n count = sum(1 for s in scores if jname in s[\"filled\"])\n junction_fill[jname] = {\n \"count\": count,\n \"total\": total,\n \"pct\": round(count / total * 100) if total else 0,\n }\n\n return {\n \"total\": total,\n \"avg_score\": avg_score,\n \"buckets\": {\"minimal\": minimal, \"partial\": partial, \"good\": good},\n \"top5\": top5,\n \"bottom5\": bottom5,\n \"junction_fill_rates\": junction_fill,\n }\n except Exception as e:\n logger.warning(f\"Could not compute data maturity stats: {e}\")\n return {\n \"total\": 0,\n \"avg_score\": 0,\n \"buckets\": {\"minimal\": 0, \"partial\": 0, \"good\": 0},\n \"top5\": [],\n \"bottom5\": [],\n \"junction_fill_rates\": {},\n }\n\n\n# ============================================================================\n# Dashboard & Index\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/\")\n@timed_route\n@login_required\n@admin_required\ndef index():\n \"\"\"Admin command center — system health, config status, user activity.\"\"\"\n ctx = {}\n\n # System health\n health = {\"db\": \"ok\", \"redis\": \"ok\"}\n try:\n db.session.execute(db.text(\"SELECT 1\"))\n except Exception:\n health[\"db\"] = \"error\"\n try:\n import redis as _redis, os as _os\n _r = _redis.from_url(_os.environ.get(\"REDIS_URL\", \"redis://localhost:6379/0\"))\n _r.ping()\n except Exception:\n health[\"redis\"] = \"error\"\n ctx[\"health\"] = health\n\n # API key status\n try:\n api_keys = APISettings.query.order_by(APISettings.provider).all()\n except Exception:\n api_keys = []\n ctx[\"api_keys\"] = api_keys\n\n # Feature flags summary\n try:\n flags_enabled = FeatureFlag.query.filter_by(enabled=True).count()\n flags_disabled = FeatureFlag.query.filter_by(enabled=False).count()\n flags_total = flags_enabled + flags_disabled\n except Exception:\n flags_enabled = flags_disabled = flags_total = 0\n ctx[\"flags\"] = {\"enabled\": flags_enabled, \"disabled\": flags_disabled, \"total\": flags_total}\n\n # Recent users\n try:\n recent_users = User.query.order_by(User.id.desc()).limit(8).all()\n total_users = User.query.count()\n unconfirmed = User.query.filter_by(confirmed=False).count()\n except Exception:\n recent_users = []\n total_users = unconfirmed = 0\n ctx[\"recent_users\"] = recent_users\n ctx[\"total_users\"] = total_users\n ctx[\"unconfirmed_users\"] = unconfirmed\n\n return render_template(\"admin/index.html\", **ctx)\n\n\n@admin_bp_v2.route(\"/dashboard\")\n@timed_route\n@login_required\n@admin_required\ndef dashboard():\n \"\"\"Admin dashboard with stats and overview.\"\"\"\n page = request.args.get(\"page\", 1, type=int)\n per_page = request.args.get(\"per_page\", 10, type=int)\n search_query = request.args.get(\"search\", \"\")\n\n pagination = _svc.get_paginated_users(page, per_page, search_query)\n users = pagination.items\n roles = _svc.get_all_roles()\n\n # PLT-006: Data maturity stats\n data_maturity = _get_data_maturity_stats()\n\n # Overview stats — admin/index.html (shared template) renders flag/user/key\n # cards, so supply them here too (otherwise the template hits undefined vars).\n try:\n api_keys = APISettings.query.order_by(APISettings.provider).all()\n except Exception:\n api_keys = []\n try:\n flags_enabled = FeatureFlag.query.filter_by(enabled=True).count()\n flags_disabled = FeatureFlag.query.filter_by(enabled=False).count()\n except Exception:\n flags_enabled = flags_disabled = 0\n try:\n recent_users = User.query.order_by(User.id.desc()).limit(8).all()\n total_users = User.query.count()\n unconfirmed_users = User.query.filter_by(confirmed=False).count()\n except Exception:\n recent_users = []\n total_users = unconfirmed_users = 0\n\n return render_template(\n \"admin/index.html\",\n users=users,\n roles=roles,\n pagination=pagination,\n search_query=search_query,\n per_page=per_page,\n data_maturity=data_maturity,\n api_keys=api_keys,\n flags={\n \"enabled\": flags_enabled,\n \"disabled\": flags_disabled,\n \"total\": flags_enabled + flags_disabled,\n },\n recent_users=recent_users,\n total_users=total_users,\n unconfirmed_users=unconfirmed_users,\n )\n\n\n# ============================================================================\n# User Management (thin routes -> AdminUserService)\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/new-user\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"create_user\")\ndef new_user():\n \"\"\"Create a new user.\"\"\"\n form = NewUserForm()\n if form.validate_on_submit():\n user = _svc.create_user(\n first_name=form.first_name.data,\n last_name=form.last_name.data,\n email=form.email.data,\n password=form.password.data,\n role=form.role.data,\n )\n flash(\"User {} successfully created\".format(user.full_name()), \"form-success\")\n return render_template(\"admin/new_user.html\", form=form)\n\n\n@admin_bp_v2.route(\"/invite-user\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"invite_user\")\ndef invite_user():\n \"\"\"Invites a new user to create an account and set their own password.\"\"\"\n form = InviteUserForm()\n if form.validate_on_submit():\n user = _svc.invite_user(\n first_name=form.first_name.data,\n last_name=form.last_name.data,\n email=form.email.data,\n role=form.role.data,\n )\n flash(\"User {} successfully invited\".format(user.full_name()), \"form-success\")\n return render_template(\"admin/new_user.html\", form=form)\n\n\n@admin_bp_v2.route(\"/manage-users\")\ndef manage_users_redirect():\n \"\"\"Redirect legacy /admin/manage-users to canonical /admin/users.\"\"\"\n return redirect(url_for(\"admin.registered_users\"), code=301)\n\n\n@admin_bp_v2.route(\"/users\")\n@timed_route\n@login_required\n@admin_required\ndef registered_users():\n \"\"\"View all registered users.\"\"\"\n users = _svc.get_all_users()\n roles = _svc.get_all_roles()\n return render_template(\"admin/registered_users.html\", users=users, roles=roles)\n\n\n@admin_bp_v2.route(\"/user/<int:user_id>\")\n@admin_bp_v2.route(\"/user/<int:user_id>/info\")\n@timed_route\n@login_required\n@admin_required\ndef user_info(user_id):\n \"\"\"View a user's profile.\"\"\"\n user = _svc.get_user_or_404(user_id)\n return render_template(\"admin/manage_user.html\", user=user)\n\n\n@admin_bp_v2.route(\"/user/<int:user_id>/change-email\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"change_user_email\")\ndef change_user_email(user_id):\n \"\"\"Change a user's email.\"\"\"\n user = _svc.get_user_or_404(user_id)\n form = ChangeUserEmailForm()\n if form.validate_on_submit():\n _svc.change_user_email(user, form.email.data)\n flash(\n \"Email for user {} successfully changed to {}.\".format(\n user.full_name(), user.email\n ),\n \"form-success\",\n )\n return render_template(\"admin/manage_user.html\", user=user, form=form)\n\n\n@admin_bp_v2.route(\"/user/<int:user_id>/change-account-type\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"change_account_type\")\ndef change_account_type(user_id):\n \"\"\"Change a user's account type.\"\"\"\n if current_user.id == user_id:\n flash(\n \"You cannot change the type of your own account. Please ask \"\n \"another administrator to do this.\",\n \"error\",\n )\n return redirect(url_for(\"admin.user_info\", user_id=user_id))\n\n user = _svc.get_user_or_404(user_id)\n form = ChangeAccountTypeForm()\n if form.validate_on_submit():\n _svc.change_user_role(user, form.role.data)\n role_name = user.role.name if user.role else \"No Role\"\n flash(\n \"Role for user {} successfully changed to {}.\".format(\n user.full_name(), role_name\n ),\n \"form-success\",\n )\n return render_template(\"admin/manage_user.html\", user=user, form=form)\n\n\n@admin_bp_v2.route(\"/user/<int:user_id>/set-password\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"set_user_password\")\ndef set_user_password(user_id):\n \"\"\"Set or reset a user's password.\"\"\"\n user = _svc.get_user_or_404(user_id)\n form = CreatePasswordForm()\n if form.validate_on_submit():\n _svc.set_user_password(user, form.password.data)\n flash(\n \"Password for user {} successfully updated. The account is now login-ready.\".format(\n user.full_name()\n ),\n \"form-success\",\n )\n return redirect(url_for(\"admin.user_info\", user_id=user_id))\n return render_template(\"admin/manage_user.html\", user=user, form=form)\n\n\n@admin_bp_v2.route(\"/user/<int:user_id>/delete\")\n@timed_route\n@login_required\n@admin_required\ndef delete_user_request(user_id):\n \"\"\"Request deletion of a user's account.\"\"\"\n user = _svc.get_user_or_404(user_id)\n return render_template(\"admin/manage_user.html\", user=user)\n\n\n@admin_bp_v2.route(\"/user/<int:user_id>/_delete\")\n@timed_route\n@login_required\n@admin_required\ndef delete_user(user_id):\n \"\"\"Delete a user's account.\"\"\"\n if current_user.id == user_id:\n flash(\n \"You cannot delete your own account. Please ask another \"\n \"administrator to do this.\",\n \"error\",\n )\n else:\n user = _svc.get_user_or_404(user_id)\n success, message = _svc.delete_user(user)\n flash(message, \"success\")\n return redirect(url_for(\"admin.registered_users\"))\n\n\n# ============================================================================\n# Editor Contents\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/_update_editor_contents\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"update_editor_contents\")\ndef update_editor_contents():\n \"\"\"Update the contents of an editor.\"\"\"\n edit_data = request.form.get(\"edit_data\")\n editor_name = request.form.get(\"editor_name\")\n\n editor_contents = EditableHTML.query.filter_by(editor_name=editor_name).first()\n if editor_contents is None:\n editor_contents = EditableHTML(editor_name=editor_name)\n editor_contents.value = edit_data\n\n db.session.add(editor_contents)\n db.session.commit()\n return \"OK\", 200\n\n\n# ============================================================================\n# API Settings\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/api-settings\", methods=[\"GET\", \"POST\"])\n@timed_route\n@org_admin_required\n@admin_required\n@audit_log(\"update_api_settings\")\ndef api_settings():\n \"\"\"Manage API settings for LLM providers.\"\"\"\n all_settings = APISettings.query.order_by(APISettings.provider, APISettings.key_label).all()\n edit_id = request.args.get(\"edit\", type=int)\n edit_settings = APISettings.query.get(edit_id) if edit_id else None\n form = APISettingsForm()\n\n if form.validate_on_submit():\n provider = form.provider.data\n key_label = (form.key_label.data or \"\").strip()\n # For edit, look up by ID; for create, look up by (provider, key_label) pair\n if edit_settings:\n existing = edit_settings\n else:\n existing = APISettings.query.filter_by(provider=provider, key_label=key_label).first()\n\n if existing:\n if form.api_key.data and form.api_key.data.strip():\n existing.api_key = form.api_key.data\n existing.key_label = key_label\n existing.enabled = form.enabled.data\n existing.default_model = form.default_model.data or None\n existing.max_tokens = form.max_tokens.data or 4000\n existing.temperature = form.temperature.data or 0.7\n existing.jira_url = form.jira_url.data or None\n existing.jira_email = form.jira_email.data or None\n existing.hf_model_id = form.hf_model_id.data or None\n existing.hf_endpoint_url = form.hf_endpoint_url.data or None\n existing.custom_endpoint_url = form.custom_endpoint_url.data or None\n existing.custom_auth_method = form.custom_auth_method.data or \"bearer\"\n existing.custom_headers = form.custom_headers.data or None\n existing.updated_at = datetime.utcnow()\n existing.updated_by_id = current_user.id\n settings = existing\n else:\n if not form.api_key.data or not form.api_key.data.strip():\n flash(\"API key is required when creating new settings.\", \"error\")\n return render_template(\n \"admin/api_settings.html\",\n form=form,\n all_settings=all_settings,\n edit_id=edit_id,\n edit_settings=edit_settings,\n )\n\n settings = APISettings(\n provider=provider,\n key_label=key_label,\n api_key=form.api_key.data,\n enabled=form.enabled.data,\n default_model=form.default_model.data or None,\n max_tokens=form.max_tokens.data or 4000,\n temperature=form.temperature.data or 0.7,\n jira_url=form.jira_url.data or None,\n jira_email=form.jira_email.data or None,\n hf_model_id=form.hf_model_id.data or None,\n hf_endpoint_url=form.hf_endpoint_url.data or None,\n custom_endpoint_url=form.custom_endpoint_url.data or None,\n custom_auth_method=form.custom_auth_method.data or \"bearer\",\n custom_headers=form.custom_headers.data or None,\n updated_by_id=current_user.id,\n )\n db.session.add(settings)\n\n api_key_to_test = (\n form.api_key.data\n if form.api_key.data and form.api_key.data.strip()\n else settings.api_key\n )\n if form.test.data:\n if not api_key_to_test:\n flash(\"No API key available to test.\", \"error\")\n return redirect(url_for(\"admin.api_settings\"))\n test_result = test_api_key(provider, api_key_to_test)\n settings.test_status = \"success\" if test_result[\"success\"] else \"failed\"\n settings.test_message = test_result.get(\"message\", \"\")\n settings.last_tested_at = datetime.utcnow()\n\n if test_result[\"success\"]:\n flash(\n f\"Connection test successful: {test_result['message']}\", \"success\"\n )\n else:\n flash(\n f\"Connection test failed: {test_result.get('message', 'Unknown error')}\",\n \"error\",\n )\n else:\n label_suffix = f\" ({key_label})\" if key_label else \"\"\n flash(f\"API settings for {provider}{label_suffix} saved successfully.\", \"success\")\n\n db.session.commit()\n return redirect(url_for(\"admin.api_settings\"))\n\n if edit_settings:\n form.provider.data = edit_settings.provider\n form.key_label.data = edit_settings.key_label or \"\"\n form.settings_id.data = str(edit_settings.id)\n form.api_key.data = edit_settings.api_key\n form.enabled.data = edit_settings.enabled\n form.default_model.data = edit_settings.default_model or \"\"\n form.max_tokens.data = edit_settings.max_tokens or 4000\n form.temperature.data = edit_settings.temperature or 0.7\n form.jira_url.data = edit_settings.jira_url or \"\"\n form.jira_email.data = edit_settings.jira_email or \"\"\n form.hf_model_id.data = edit_settings.hf_model_id or \"\"\n form.hf_endpoint_url.data = edit_settings.hf_endpoint_url or \"\"\n form.custom_endpoint_url.data = edit_settings.custom_endpoint_url or \"\"\n form.custom_auth_method.data = edit_settings.custom_auth_method or \"bearer\"\n form.custom_headers.data = edit_settings.custom_headers or \"\"\n\n return render_template(\n \"admin/api_settings.html\",\n form=form,\n all_settings=all_settings,\n edit_id=edit_id,\n edit_settings=edit_settings,\n )\n\n\n@admin_bp_v2.route(\"/api-settings/<int:settings_id>/delete\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"delete_api_settings\")\ndef delete_api_settings(settings_id):\n \"\"\"Delete API settings.\"\"\"\n settings = APISettings.query.get_or_404(settings_id)\n provider = settings.provider\n db.session.delete(settings)\n db.session.commit()\n flash(f\"API settings for {provider} deleted successfully.\", \"success\")\n return redirect(url_for(\"admin.api_settings\"))\n\n\n@admin_bp_v2.route(\"/api-settings/<int:settings_id>/test\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"test_api_settings\")\ndef test_api_settings(settings_id):\n \"\"\"Test API settings.\"\"\"\n settings = APISettings.query.get_or_404(settings_id)\n\n try:\n result = test_api_key(settings.api_key, settings.api_provider)\n flash(f\"API test successful: {result}\", \"success\")\n except Exception as e:\n flash(\"API test failed. Please try again.\", \"error\")\n\n return redirect(url_for(\"admin.api_settings\"))\n\n\n@admin_bp_v2.route(\"/api-settings/env-keys\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef preview_env_keys():\n \"\"\"Preview API keys found in environment variables but not yet in the database.\"\"\"\n import os\n\n env_key_map = {\n \"OPENAI_API_KEY\": \"openai\",\n \"ANTHROPIC_API_KEY\": \"anthropic\",\n \"GOOGLE_API_KEY\": \"gemini\",\n \"GEMINI_API_KEY\": \"gemini\",\n \"DEEPSEEK_API_KEY\": \"deepseek\",\n \"HUGGINGFACE_API_KEY\": \"huggingface\",\n \"HF_TOKEN\": \"huggingface\",\n \"AZURE_OPENAI_API_KEY\": \"azure\",\n \"OPENROUTER_API_KEY\": \"openrouter\",\n }\n\n default_models = {\n \"openai\": \"gpt-4o\",\n \"anthropic\": \"claude-3-5-sonnet-20241022\",\n \"gemini\": \"gemini-2.0-flash-exp\",\n \"deepseek\": \"deepseek-chat\",\n \"huggingface\": \"meta-llama/Llama-3.1-8B-Instruct\",\n \"azure\": \"gpt-4\",\n \"openrouter\": \"google/gemini-2.5-flash-preview:free\",\n }\n\n existing = {s.provider for s in APISettings.query.all()}\n\n found_keys = []\n for env_var, provider in env_key_map.items():\n value = os.environ.get(env_var, \"\")\n if value and value.strip():\n masked = value[:8] + \"...\" + value[-4:] if len(value) > 12 else \"****\"\n found_keys.append(\n {\n \"env_var\": env_var,\n \"provider\": provider,\n \"masked_key\": masked,\n \"in_database\": provider in existing,\n \"default_model\": default_models.get(provider, \"\"),\n }\n )\n\n return jsonify(\n {\n \"success\": True,\n \"keys\": found_keys,\n \"count\": len(found_keys),\n \"new_count\": sum(1 for k in found_keys if not k[\"in_database\"]),\n }\n )\n\n\n@admin_bp_v2.route(\"/api-settings/update-model\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"update_provider_model\")\ndef update_provider_model():\n \"\"\"Quick-update the default model for a provider without the full form.\"\"\"\n data = request.get_json()\n if not data or \"provider\" not in data or \"model\" not in data:\n return jsonify(\n {\"success\": False, \"error\": \"provider and model are required\"}\n ), 400\n\n provider = data[\"provider\"]\n model = data[\"model\"].strip()\n\n settings = APISettings.query.filter_by(provider=provider).first()\n if not settings:\n return jsonify(\n {\"success\": False, \"error\": f\"No settings found for {provider}\"}\n ), 404\n\n settings.default_model = model\n settings.updated_at = datetime.utcnow()\n settings.updated_by_id = current_user.id\n db.session.commit()\n\n return jsonify({\"success\": True, \"provider\": provider, \"model\": model})\n\n\n@admin_bp_v2.route(\"/api-settings/load-env\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"load_env_keys\")\ndef load_env_keys():\n \"\"\"Import selected API keys from environment variables into the database.\"\"\"\n import os\n\n data = request.get_json()\n if not data or \"keys\" not in data:\n return jsonify({\"success\": False, \"error\": \"No keys specified\"}), 400\n\n env_key_map = {\n \"OPENAI_API_KEY\": \"openai\",\n \"ANTHROPIC_API_KEY\": \"anthropic\",\n \"GOOGLE_API_KEY\": \"gemini\",\n \"GEMINI_API_KEY\": \"gemini\",\n \"DEEPSEEK_API_KEY\": \"deepseek\",\n \"HUGGINGFACE_API_KEY\": \"huggingface\",\n \"HF_TOKEN\": \"huggingface\",\n \"AZURE_OPENAI_API_KEY\": \"azure\",\n \"OPENROUTER_API_KEY\": \"openrouter\",\n }\n\n default_models = {\n \"openai\": \"gpt-4o\",\n \"anthropic\": \"claude-3-5-sonnet-20241022\",\n \"gemini\": \"gemini-2.0-flash-exp\",\n \"deepseek\": \"deepseek-chat\",\n \"huggingface\": \"meta-llama/Llama-3.1-8B-Instruct\",\n \"azure\": \"gpt-4\",\n \"openrouter\": \"google/gemini-2.5-flash-preview:free\",\n }\n\n requested_keys = data[\"keys\"]\n update_existing = data.get(\"update_existing\", False)\n\n imported = []\n skipped = []\n errors = []\n\n existing_map = {s.provider: s for s in APISettings.query.all()}\n\n for env_var in requested_keys:\n provider = env_key_map.get(env_var)\n if not provider:\n errors.append(f\"Unknown env var: {env_var}\")\n continue\n\n api_key = os.environ.get(env_var, \"\").strip()\n if not api_key:\n errors.append(f\"{env_var} is empty\")\n continue\n\n if provider in existing_map and not update_existing:\n skipped.append(f\"{provider} already in database (skipped)\")\n continue\n\n if provider in existing_map:\n existing_map[provider].api_key = api_key\n existing_map[provider].enabled = True\n existing_map[provider].updated_at = datetime.utcnow()\n existing_map[provider].updated_by_id = current_user.id\n imported.append(f\"{provider} (updated)\")\n else:\n settings = APISettings(\n provider=provider,\n api_key=api_key,\n enabled=True,\n default_model=default_models.get(provider, \"\"),\n max_tokens=4000,\n temperature=0.7,\n updated_by_id=current_user.id,\n )\n db.session.add(settings)\n imported.append(f\"{provider} (created)\")\n\n db.session.commit()\n\n return jsonify(\n {\n \"success\": True,\n \"imported\": imported,\n \"skipped\": skipped,\n \"errors\": errors,\n \"message\": f\"Imported {len(imported)} key(s)\",\n }\n )\n\n\n# ============================================================================\n# Consolidation Status\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/consolidation\")\n@timed_route\n@login_required\n@admin_required\ndef consolidation_status():\n \"\"\"Blueprint Consolidation Status Dashboard\"\"\"\n from app.api_registry import (\n API_REGISTRY,\n CONSOLIDATION_TARGETS,\n BlueprintStatus,\n get_consolidation_status,\n )\n\n status = get_consolidation_status()\n\n blueprints = []\n for name, bp_info in API_REGISTRY.items():\n bp_data = {\n \"name\": name,\n \"status\": bp_info.status.value,\n \"url_prefix\": bp_info.url_prefix,\n \"endpoints_count\": bp_info.endpoints_count,\n \"consolidate_into\": bp_info.consolidate_into,\n \"notes\": bp_info.notes or \"\",\n \"is_active\": bp_info.status == BlueprintStatus.ACTIVE,\n \"is_deprecated\": bp_info.status == BlueprintStatus.DEPRECATED,\n \"is_consolidated\": bp_info.status == BlueprintStatus.CONSOLIDATED,\n }\n blueprints.append(bp_data)\n\n status_order = {\"deprecated\": 0, \"active\": 1, \"consolidated\": 2, \"legacy\": 3}\n blueprints.sort(key=lambda x: (status_order.get(x[\"status\"], 4), x[\"name\"]))\n\n targets = []\n for target_name, target_info in CONSOLIDATION_TARGETS.items():\n keep_bp = target_info[\"keep\"]\n consolidate_list = target_info[\"consolidate\"]\n priority = target_info[\"priority\"]\n\n completed_count = 0\n total_count = len(consolidate_list)\n\n for bp_name in consolidate_list:\n if bp_name in API_REGISTRY:\n bp_info = API_REGISTRY[bp_name]\n if bp_info.status in [\n BlueprintStatus.CONSOLIDATED,\n BlueprintStatus.DEPRECATED,\n ]:\n completed_count += 1\n\n targets.append(\n {\n \"name\": target_name,\n \"keep_blueprint\": keep_bp,\n \"consolidate_blueprints\": consolidate_list,\n \"priority\": priority,\n \"completed_count\": completed_count,\n \"total_count\": total_count,\n \"progress_percentage\": (completed_count / total_count * 100)\n if total_count > 0\n else 100,\n \"is_complete\": completed_count == total_count,\n \"estimated_effort\": target_info.get(\"estimated_effort\", \"unknown\"),\n }\n )\n\n priority_order = {\"HIGH\": 0, \"MEDIUM\": 1, \"LOW\": 2}\n targets.sort(\n key=lambda x: (priority_order.get(x[\"priority\"], 3), -x[\"progress_percentage\"])\n )\n\n return render_template(\n \"admin/consolidation_status.html\",\n status=status,\n blueprints=blueprints,\n targets=targets,\n BlueprintStatus=BlueprintStatus,\n )\n\n\n# ============================================================================\n# Feature Flags Management\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/feature-flags\")\n@timed_route\n@platform_admin_required\n@admin_required\ndef feature_flags():\n \"\"\"Feature flags management page with pagination.\"\"\"\n page = request.args.get(\"page\", 1, type=int)\n per_page = request.args.get(\"per_page\", 50, type=int)\n search = request.args.get(\"search\", \"\")\n filter_type = request.args.get(\"type\", \"\")\n filter_state = request.args.get(\"state\", \"\")\n filter_status = request.args.get(\"status\", \"\")\n filter_hierarchy = request.args.get(\"hierarchy\", \"\")\n\n query = FeatureFlag.query\n\n if search:\n search_term = f\"%{search}%\"\n query = query.filter(\n db.or_(\n FeatureFlag.name.ilike(search_term),\n FeatureFlag.key.ilike(search_term),\n FeatureFlag.description.ilike(search_term),\n )\n )\n\n if filter_type:\n query = query.filter(FeatureFlag.feature_type == filter_type)\n\n if filter_state:\n query = query.filter(FeatureFlag.state == filter_state)\n\n if filter_status:\n if filter_status == \"enabled\":\n query = query.filter(FeatureFlag.enabled == True)\n elif filter_status == \"disabled\":\n query = query.filter(FeatureFlag.enabled == False)\n\n if filter_hierarchy:\n if filter_hierarchy == \"parent\":\n query = query.filter(FeatureFlag.parent_id == None)\n elif filter_hierarchy == \"child\":\n parent_table = aliased(FeatureFlag)\n query = query.join(\n parent_table, FeatureFlag.parent_id == parent_table.id\n ).filter(FeatureFlag.parent_id != None, parent_table.parent_id == None)\n elif filter_hierarchy == \"grandchild\":\n parent_table = aliased(FeatureFlag)\n query = query.join(\n parent_table, FeatureFlag.parent_id == parent_table.id\n ).filter(FeatureFlag.parent_id != None, parent_table.parent_id != None)\n\n query = query.order_by(FeatureFlag.sort_order, FeatureFlag.name)\n pagination = query.paginate(page=page, per_page=per_page, error_out=False)\n\n if pagination.total == 0 and not search:\n flash(\n \"No features found. Run 'flask discover-features' to auto-discover routes, \"\n \"or create features manually.\",\n \"info\",\n )\n\n # Build sections for grouped view using parent_id hierarchy\n # Top-level = sidebar_section with no parent\n top_sections = (\n FeatureFlag.query.filter(\n FeatureFlag.feature_type == FeatureType.SIDEBAR_SECTION,\n FeatureFlag.parent_id.is_(None),\n )\n .order_by(FeatureFlag.sort_order, FeatureFlag.name)\n .all()\n )\n sections = []\n for sec in top_sections:\n children = (\n FeatureFlag.query.filter(FeatureFlag.parent_id == sec.id)\n .order_by(FeatureFlag.sort_order, FeatureFlag.name)\n .all()\n )\n sections.append({\"section\": sec, \"children\": children})\n\n # Default to list view when filters are active, otherwise grouped\n view_mode = \"grouped\"\n if search or filter_type or filter_state or filter_status or filter_hierarchy:\n view_mode = \"list\"\n\n return render_template(\n \"admin/feature_flags.html\",\n features=pagination.items,\n pagination=pagination,\n sections=sections,\n view_mode=view_mode,\n search=search,\n filter_type=filter_type,\n filter_state=filter_state,\n filter_status=filter_status,\n filter_hierarchy=filter_hierarchy,\n FeatureType=FeatureType,\n FeatureState=FeatureState,\n )\n\n\n@admin_bp_v2.route(\"/feature-flags/new\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"create_feature_flag\")\ndef feature_flag_new():\n \"\"\"Create new feature flag.\"\"\"\n form = FeatureFlagForm()\n\n if form.validate_on_submit():\n try:\n routes_data = None\n if form.routes.data and form.routes.data.strip():\n try:\n routes_data = json.loads(form.routes.data)\n except json.JSONDecodeError:\n flash(\"Invalid JSON format for routes\", \"error\")\n return render_template(\n \"admin/feature_flag_form.html\", form=form, action=\"New\"\n )\n\n feature = FeatureFlag(\n key=form.key.data,\n name=form.name.data,\n description=form.description.data,\n feature_type=FeatureType(form.feature_type.data),\n state=FeatureState(form.state.data),\n enabled=form.enabled.data,\n sidebar_label=form.sidebar_label.data,\n sidebar_icon=form.sidebar_icon.data,\n routes=routes_data,\n parent_id=form.parent_id.data or None,\n sort_order=form.sort_order.data or 0,\n last_modified_by=current_user.id,\n )\n\n db.session.add(feature)\n db.session.commit()\n\n flash(f\"Feature flag '{feature.name}' created successfully\", \"success\")\n return redirect(url_for(\"admin.feature_flags\"))\n\n except Exception as e:\n db.session.rollback()\n flash(\"Error creating feature flag. Please try again.\", \"error\")\n\n return render_template(\"admin/feature_flag_form.html\", form=form, action=\"New\")\n\n\n@admin_bp_v2.route(\"/feature-flags/<int:id>/edit\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"edit_feature_flag\")\ndef feature_flag_edit(id):\n \"\"\"Edit feature flag.\"\"\"\n feature = FeatureFlag.query.get_or_404(id)\n form = FeatureFlagForm(obj=feature)\n\n if form.validate_on_submit():\n try:\n routes_data = None\n if form.routes.data and form.routes.data.strip():\n try:\n routes_data = json.loads(form.routes.data)\n except json.JSONDecodeError:\n flash(\"Invalid JSON format for routes\", \"error\")\n return render_template(\n \"admin/feature_flag_form.html\",\n form=form,\n feature=feature,\n action=\"Edit\",\n )\n\n feature.key = form.key.data\n feature.name = form.name.data\n feature.description = form.description.data\n feature.feature_type = FeatureType(form.feature_type.data)\n feature.state = FeatureState(form.state.data)\n feature.enabled = form.enabled.data\n feature.sidebar_label = form.sidebar_label.data\n feature.sidebar_icon = form.sidebar_icon.data\n feature.routes = routes_data\n feature.parent_id = form.parent_id.data or None\n feature.sort_order = form.sort_order.data or 0\n feature.last_modified_by = current_user.id\n\n db.session.commit()\n\n flash(f\"Feature flag '{feature.name}' updated successfully\", \"success\")\n return redirect(url_for(\"admin.feature_flags\"))\n\n except Exception as e:\n db.session.rollback()\n flash(\"Error updating feature flag. Please try again.\", \"error\")\n\n if feature.routes and not form.routes.data:\n form.routes.data = json.dumps(feature.routes, indent=2)\n elif not feature.routes:\n form.routes.data = \"\"\n\n return render_template(\n \"admin/feature_flag_form.html\", form=form, feature=feature, action=\"Edit\"\n )\n\n\n@admin_bp_v2.route(\"/feature-flags/<int:id>/toggle\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"toggle_feature_flag\")\ndef feature_flag_toggle(id):\n \"\"\"Quick toggle feature enabled/disabled.\"\"\"\n feature = FeatureFlag.query.get_or_404(id)\n\n try:\n feature.enabled = not feature.enabled\n feature.last_modified_by = current_user.id\n db.session.commit()\n\n status = \"enabled\" if feature.enabled else \"disabled\"\n return jsonify(\n {\n \"success\": True,\n \"enabled\": feature.enabled,\n \"message\": f\"Feature {status}\",\n }\n )\n except Exception as e:\n db.session.rollback()\n return jsonify({\"success\": False, \"error\": \"An internal error occurred\"}), 500\n\n\n@admin_bp_v2.route(\"/feature-flags/<int:id>/delete\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"delete_feature_flag\")\ndef feature_flag_delete(id):\n \"\"\"Delete feature flag.\"\"\"\n feature = FeatureFlag.query.get_or_404(id)\n\n try:\n db.session.delete(feature)\n db.session.commit()\n flash(f\"Feature flag '{feature.name}' deleted successfully\", \"success\")\n except Exception as e:\n db.session.rollback()\n flash(\"Error deleting feature flag. Please try again.\", \"error\")\n\n return redirect(url_for(\"admin.feature_flags\"))\n\n\n@admin_bp_v2.route(\"/feature-flags/discover-sidebar\")\n@timed_route\n@login_required\n@admin_required\ndef feature_flags_discover_sidebar():\n \"\"\"Discover sidebar menu items for feature flagging.\"\"\"\n try:\n parser = parse_sidebar_template()\n existing_flags = {ff.key: ff for ff in FeatureFlag.query.all()}\n\n all_items = []\n for section_title, submenu_title, link in parser.get_all_links():\n feature_key = link.feature_key\n if submenu_title:\n feature_key = f\"{SidebarSubmenu(submenu_title, None, section_title, []).feature_key}_{link.feature_key}\"\n\n all_items.append(\n {\n \"section\": section_title,\n \"submenu\": submenu_title,\n \"text\": link.text,\n \"href\": link.href,\n \"icon\": link.icon,\n \"endpoint\": link.endpoint,\n \"feature_key\": feature_key,\n \"already_flagged\": feature_key in existing_flags,\n \"flag_id\": existing_flags[feature_key].id\n if feature_key in existing_flags\n else None,\n }\n )\n\n return render_template(\n \"admin/discover_sidebar.html\",\n items=all_items,\n sections=parser.sections,\n parser_data=parser.to_dict(),\n )\n\n except Exception as e:\n flash(\"Error parsing sidebar. Please try again.\", \"error\")\n return redirect(url_for(\"admin.feature_flags\"))\n\n\n@admin_bp_v2.route(\"/feature-flags/discover-sidebar/create\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"create_feature_flags_from_sidebar\")\ndef feature_flags_create_from_sidebar():\n \"\"\"Create feature flags from selected sidebar items.\"\"\"\n try:\n selected_keys = request.form.getlist(\"selected_items\")\n\n if not selected_keys:\n flash(\"No items selected\", \"warning\")\n return redirect(url_for(\"admin.feature_flags_discover_sidebar\"))\n\n parser = parse_sidebar_template()\n\n items_map = {}\n for section_title, submenu_title, link in parser.get_all_links():\n feature_key = link.feature_key\n if submenu_title:\n submenu_obj = SidebarSubmenu(submenu_title, None, section_title, [])\n feature_key = f\"{submenu_obj.feature_key}_{link.feature_key}\"\n\n items_map[feature_key] = {\n \"section\": section_title,\n \"submenu\": submenu_title,\n \"link\": link,\n }\n\n created_count = 0\n skipped_count = 0\n\n for key in selected_keys:\n existing = FeatureFlag.query.filter_by(key=key).first()\n if existing:\n skipped_count += 1\n continue\n\n item_data = items_map.get(key)\n if not item_data:\n continue\n\n link = item_data[\"link\"]\n submenu = item_data[\"submenu\"]\n section = item_data[\"section\"]\n\n feature = FeatureFlag(\n key=key,\n name=link.text,\n description=f\"Controls visibility of '{link.text}' in {section}\"\n + (f\" > {submenu}\" if submenu else \"\"),\n feature_type=FeatureType.SIDEBAR_LINK,\n state=FeatureState.BETA,\n enabled=True,\n sidebar_label=link.text,\n sidebar_icon=link.icon,\n routes=[link.endpoint] if link.endpoint else [],\n last_modified_by=current_user.id,\n )\n\n db.session.add(feature)\n created_count += 1\n\n db.session.commit()\n\n message = f\"Created {created_count} feature flag(s)\"\n if skipped_count > 0:\n message += f\", skipped {skipped_count} existing\"\n\n flash(message, \"success\")\n return redirect(url_for(\"admin.feature_flags\"))\n\n except Exception as e:\n db.session.rollback()\n flash(\"Error creating feature flags. Please try again.\", \"error\")\n return redirect(url_for(\"admin.feature_flags_discover_sidebar\"))\n\n\n# ============================================================================\n# Abacus Integration Management\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/abacus-settings\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"update_abacus_settings\")\ndef abacus_settings():\n \"\"\"Manage Abacus connector configuration.\"\"\"\n from flask_wtf import FlaskForm\n from wtforms import BooleanField, IntegerField, PasswordField, StringField\n from wtforms.validators import URL, DataRequired\n\n from app.models.models import ExternalSystem\n\n class AbacusSettingsForm(FlaskForm):\n base_url = StringField(\"Base URL\", validators=[DataRequired(), URL()])\n client_id = StringField(\"Client ID\", validators=[DataRequired()])\n client_secret = PasswordField(\"Client Secret\")\n enabled = BooleanField(\"Enable Integration\", default=False)\n sync_enabled = BooleanField(\"Enable Auto-Sync\", default=False)\n sync_interval_minutes = IntegerField(\"Sync Interval (minutes)\", default=1440)\n filter_countries = StringField(\"Country Filter\", default=\"\")\n\n abacus_config = ExternalSystem.query.filter_by(\n system_name=\"abacus\", system_type=\"ea_tool\"\n ).first()\n\n form = AbacusSettingsForm()\n\n if form.validate_on_submit():\n import os\n\n credentials = {}\n if form.client_secret.data and form.client_secret.data.strip():\n credentials = {\n \"client_id\": form.client_id.data,\n \"client_secret\": form.client_secret.data,\n }\n elif abacus_config and abacus_config.credentials:\n try:\n existing_creds = json.loads(abacus_config.credentials)\n credentials = {\n \"client_id\": form.client_id.data,\n \"client_secret\": existing_creds.get(\"client_secret\", \"\"),\n }\n except (json.JSONDecodeError, KeyError, TypeError):\n logger.exception(\"Failed to JSON parsing\")\n pass\n\n if abacus_config:\n abacus_config.base_url = form.base_url.data\n abacus_config.api_endpoint = f\"{form.base_url.data}/api\"\n abacus_config.auth_type = \"oauth2\"\n if credentials:\n abacus_config.credentials = json.dumps(credentials)\n\n filter_countries = (form.filter_countries.data or \"\").strip()\n # Preserve existing dynamic filters from config_json\n existing_config = {}\n if abacus_config.config_json:\n try:\n existing_config = json.loads(abacus_config.config_json)\n except (json.JSONDecodeError, TypeError):\n logger.exception(\"Failed to JSON parsing\")\n pass\n config_data = {\n \"filter_countries\": filter_countries,\n \"filters\": existing_config.get(\"filters\", []),\n \"discovered_dimensions\": existing_config.get(\"discovered_dimensions\", {}),\n }\n # Also accept dynamic filters from form if posted as JSON\n dynamic_filters_json = request.form.get(\"dynamic_filters\", \"\")\n if dynamic_filters_json:\n try:\n config_data[\"filters\"] = json.loads(dynamic_filters_json)\n except (json.JSONDecodeError, TypeError):\n logger.exception(\"Failed to parse dynamic_filters JSON\")\n pass\n # Save selected entity types\n entity_types_json = request.form.get(\"sync_entity_types\", \"\")\n if entity_types_json:\n try:\n config_data[\"sync_entity_types\"] = json.loads(entity_types_json)\n except (json.JSONDecodeError, TypeError):\n pass\n else:\n config_data[\"sync_entity_types\"] = existing_config.get(\"sync_entity_types\", [])\n # Save sync_relationships flag\n config_data[\"sync_relationships\"] = request.form.get(\"sync_relationships\") == \"true\"\n abacus_config.config_json = json.dumps(config_data)\n os.environ[\"ABACUS_FILTER_COUNTRIES\"] = filter_countries\n\n abacus_config.enabled = form.enabled.data\n abacus_config.sync_enabled = form.sync_enabled.data\n abacus_config.sync_interval_minutes = form.sync_interval_minutes.data\n abacus_config.updated_by_id = current_user.id\n abacus_config.updated_at = datetime.utcnow()\n\n flash(\"Abacus settings updated successfully.\", \"success\")\n else:\n if not credentials or not credentials.get(\"client_secret\"):\n flash(\"Client secret is required for new configuration.\", \"error\")\n return render_template(\n \"admin/abacus_settings.html\", form=form, abacus_config=None\n )\n\n filter_countries = (form.filter_countries.data or \"\").strip()\n config_data = {\"filter_countries\": filter_countries}\n os.environ[\"ABACUS_FILTER_COUNTRIES\"] = filter_countries\n\n abacus_config = ExternalSystem(\n system_name=\"abacus\",\n system_type=\"ea_tool\",\n base_url=form.base_url.data,\n api_endpoint=f\"{form.base_url.data}/api\",\n auth_type=\"oauth2\",\n credentials=json.dumps(credentials),\n config_json=json.dumps(config_data),\n enabled=form.enabled.data,\n sync_enabled=form.sync_enabled.data,\n sync_interval_minutes=form.sync_interval_minutes.data,\n updated_by_id=current_user.id,\n )\n db.session.add(abacus_config)\n\n flash(\"Abacus settings created successfully.\", \"success\")\n\n db.session.commit()\n return redirect(url_for(\"admin.abacus_settings\"))\n\n env_config = None\n imported_stats = {\"applications\": 0, \"capabilities\": 0}\n\n if request.method == \"GET\":\n if abacus_config or env_config:\n try:\n from app.models.application_portfolio import ApplicationComponent\n from app.models.business_capabilities import BusinessCapability\n\n abacus_apps = ApplicationComponent.query.filter_by(\n abacus_source=True\n ).count()\n imported_stats[\"applications\"] = abacus_apps\n\n abacus_caps = BusinessCapability.query.filter(\n BusinessCapability.discovery_source == \"abacus\",\n ).count()\n imported_stats[\"capabilities\"] = abacus_caps\n except Exception as e:\n logger.error(f\"Failed to fetch Abacus imported data counts: {e}\")\n\n if abacus_config:\n form.base_url.data = abacus_config.base_url\n form.enabled.data = abacus_config.enabled\n form.sync_enabled.data = abacus_config.sync_enabled\n form.sync_interval_minutes.data = (\n abacus_config.sync_interval_minutes or 1440\n )\n\n if abacus_config.config_json:\n try:\n config_data = json.loads(abacus_config.config_json)\n form.filter_countries.data = config_data.get(\"filter_countries\", \"\")\n except (json.JSONDecodeError, KeyError, TypeError):\n form.filter_countries.data = \"\"\n\n if abacus_config.credentials:\n try:\n creds = json.loads(abacus_config.credentials)\n form.client_id.data = creds.get(\"client_id\", \"\")\n except (json.JSONDecodeError, KeyError, TypeError):\n logger.exception(\"Failed to parse credentials JSON\")\n pass\n\n # Load filter dimensions from config_json if previously discovered\n filter_dimensions = {}\n if abacus_config and abacus_config.config_json:\n try:\n cfg = json.loads(abacus_config.config_json)\n filter_dimensions = cfg.get(\"discovered_dimensions\", {})\n except (json.JSONDecodeError, TypeError):\n pass\n else:\n import os\n\n env_base_url = os.getenv(\"ABACUS_BASE_URL\")\n env_client_id = os.getenv(\"ABACUS_CLIENT_ID\")\n env_client_secret = os.getenv(\"ABACUS_CLIENT_SECRET\")\n env_enabled = os.getenv(\"ABACUS_ENABLED\", \"false\").lower() == \"true\"\n env_filter_countries = os.getenv(\"ABACUS_FILTER_COUNTRIES\", \"\")\n\n if all([env_base_url, env_client_id, env_client_secret]):\n form.base_url.data = env_base_url\n form.client_id.data = env_client_id\n form.enabled.data = env_enabled\n form.sync_enabled.data = False\n form.sync_interval_minutes.data = 1440\n form.filter_countries.data = env_filter_countries\n\n env_config = {\n \"base_url\": env_base_url,\n \"client_id\": env_client_id,\n \"enabled\": env_enabled,\n \"filter_countries\": env_filter_countries,\n }\n\n return render_template(\n \"admin/abacus_settings.html\",\n form=form,\n abacus_config=abacus_config,\n env_config=env_config,\n imported_stats=imported_stats,\n filter_dimensions=filter_dimensions,\n )\n\n\n@admin_bp_v2.route(\"/abacus-settings/test-connection\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"test_abacus_connection\")\ndef test_abacus_connection():\n \"\"\"Test Abacus connection.\"\"\"\n import asyncio\n import os\n\n from app.models.models import ExternalSystem\n\n abacus_config = ExternalSystem.query.filter_by(\n system_name=\"abacus\", system_type=\"ea_tool\"\n ).first()\n\n if not abacus_config:\n env_base_url = os.getenv(\"ABACUS_BASE_URL\")\n env_client_id = os.getenv(\"ABACUS_CLIENT_ID\")\n env_client_secret = os.getenv(\"ABACUS_CLIENT_SECRET\")\n\n if not all([env_base_url, env_client_id, env_client_secret]):\n flash(\n \"No Abacus configuration found in database or .env file. Please configure first.\",\n \"error\",\n )\n return redirect(url_for(\"admin.abacus_settings\"))\n\n class TempConfig:\n def __init__(self):\n self.base_url = env_base_url\n self.credentials = None\n\n abacus_config = TempConfig()\n creds = {\"client_id\": env_client_id, \"client_secret\": env_client_secret}\n else:\n creds = (\n json.loads(abacus_config.credentials) if abacus_config.credentials else {}\n )\n\n try:\n from app.connectors.abacus import create_abacus_connector\n from app.modules.admin.v2.services.connector_framework_v2 import ConnectorConfig\n\n config = ConnectorConfig(\n name=\"abacus\",\n connector_type=\"ea_tool\",\n config={\n \"base_url\": abacus_config.base_url,\n \"client_id\": creds.get(\"client_id\"),\n \"client_secret\": creds.get(\"client_secret\"),\n },\n )\n\n connector = create_abacus_connector(config)\n\n loop = asyncio.new_event_loop()\n asyncio.set_event_loop(loop)\n result = loop.run_until_complete(connector.test_connection())\n loop.close()\n\n if hasattr(abacus_config, \"last_connection_test\"):\n abacus_config.last_connection_test = datetime.utcnow()\n abacus_config.connection_status = \"connected\" if result else \"error\"\n abacus_config.last_error = None if result else \"Connection test failed\"\n db.session.commit()\n\n if result:\n flash(\"Connection test successful! Abacus API is accessible.\", \"success\")\n else:\n flash(\n \"Connection test failed. Please check your credentials and URL.\",\n \"error\",\n )\n\n except Exception as e:\n logger.error(f\"Abacus connection test exception: {e}\", exc_info=True)\n\n if hasattr(abacus_config, \"last_connection_test\"):\n abacus_config.last_connection_test = datetime.utcnow()\n abacus_config.connection_status = \"error\"\n abacus_config.last_error = str(e)\n db.session.commit()\n\n flash(\"Connection test failed. Please try again.\", \"error\")\n\n return redirect(url_for(\"admin.abacus_settings\"))\n\n\n@admin_bp_v2.route(\"/abacus-settings/trigger-sync\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"trigger_abacus_sync\")\ndef trigger_abacus_sync():\n \"\"\"Trigger manual Abacus synchronization.\"\"\"\n import os\n\n from app.models.models import ExternalSystem\n\n abacus_config = ExternalSystem.query.filter_by(system_name=\"abacus\").first()\n\n if not abacus_config:\n env_base_url = os.getenv(\"ABACUS_BASE_URL\")\n env_client_id = os.getenv(\"ABACUS_CLIENT_ID\")\n env_client_secret = os.getenv(\"ABACUS_CLIENT_SECRET\")\n\n if not all([env_base_url, env_client_id, env_client_secret]):\n flash(\n \"No Abacus configuration found in database or .env file. Please configure first.\",\n \"error\",\n )\n return redirect(url_for(\"admin.abacus_settings\"))\n\n flash(\"Using Abacus credentials from .env file.\", \"info\")\n elif not abacus_config.enabled:\n flash(\"Abacus integration is disabled. Enable it first.\", \"error\")\n return redirect(url_for(\"admin.abacus_settings\"))\n\n try:\n from app.modules.admin.v2.services.job_queue_service_v2 import (\n get_job_queue_service,\n )\n\n job_queue = get_job_queue_service()\n job = job_queue.create_job(\n name=\"Abacus Full Sync\", task=\"abacus_sync\", payload={\"sync_type\": \"full\"}\n )\n\n flash(\n f\"Sync job created (ID: {job.id}). \"\n \"The sync will run in the background. Check the status below.\",\n \"info\",\n )\n\n except Exception as e:\n flash(\"Failed to create sync job. Please try again.\", \"error\")\n\n return redirect(url_for(\"admin.abacus_settings\"))\n\n\n@admin_bp_v2.route(\"/abacus-settings/sync-status\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef abacus_sync_status():\n \"\"\"API endpoint to check current sync job status.\"\"\"\n from app.models import Job\n\n latest_job = (\n Job.query.filter_by(task=\"abacus_sync\").order_by(Job.created_at.desc()).first()\n )\n\n if not latest_job:\n return jsonify({\"status\": \"no_jobs\", \"message\": \"No sync jobs found\"})\n\n return jsonify(\n {\n \"job_id\": latest_job.id,\n \"status\": latest_job.status,\n \"created_at\": latest_job.created_at.isoformat()\n if latest_job.created_at\n else None,\n \"started_at\": latest_job.started_at.isoformat()\n if latest_job.started_at\n else None,\n \"finished_at\": latest_job.finished_at.isoformat()\n if latest_job.finished_at\n else None,\n \"result\": latest_job.result,\n \"error\": latest_job.error,\n \"name\": latest_job.name,\n }\n )\n\n\n@admin_bp_v2.route(\"/abacus-settings/cancel-job/<int:job_id>\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"cancel_abacus_job\")\ndef cancel_abacus_job(job_id):\n \"\"\"Cancel a running or pending Abacus sync job.\"\"\"\n try:\n from app.modules.admin.v2.services.job_queue_service_v2 import (\n get_job_queue_service,\n )\n\n service = get_job_queue_service()\n success = service.cancel_job(job_id)\n\n if success:\n return (\n jsonify(\n {\"success\": True, \"message\": f\"Job {job_id} cancelled successfully\"}\n ),\n 200,\n )\n else:\n return (\n jsonify(\n {\n \"success\": False,\n \"message\": f\"Job {job_id} is already finished or cannot be cancelled\",\n }\n ),\n 400,\n )\n except Exception as e:\n logger.error(f\"Error cancelling job {job_id}: {e}\", exc_info=True)\n return jsonify({\"success\": False, \"message\": \"An internal error occurred\"}), 500\n\n\n@admin_bp_v2.route(\"/abacus-settings/clear-stale-jobs\", methods=[\"POST\"])\n@login_required\n@admin_required\ndef clear_stale_abacus_jobs():\n \"\"\"Force-clear sync jobs stuck in_progress for more than 1 hour.\"\"\"\n from app.models import Job # noqa: local import to match pattern\n\n cutoff = datetime.utcnow() - timedelta(hours=1)\n stale_jobs = Job.query.filter(\n Job.task == \"abacus_sync\",\n Job.status.in_([\"in_progress\", \"pending\"]),\n Job.created_at < cutoff,\n ).all()\n\n cleared = 0\n for job in stale_jobs:\n job.status = \"failed\"\n job.finished_at = datetime.utcnow()\n job.error = \"Force-cleared: job was stale (>1 hour with no completion)\"\n cleared += 1\n\n if cleared:\n db.session.commit()\n\n return jsonify({\"success\": True, \"cleared\": cleared})\n\n\n@admin_bp_v2.route(\"/abacus-settings/discover-types\", methods=[\"POST\"])\n@login_required\n@admin_required\ndef discover_abacus_types():\n \"\"\"Discover available ComponentType names from the Abacus API.\"\"\"\n import asyncio\n\n from app.models.models import ExternalSystem\n\n try:\n abacus_config = ExternalSystem.query.filter_by(system_name=\"abacus\").first()\n if not abacus_config:\n return jsonify({\"success\": False, \"error\": \"No Abacus configuration found\"}), 400\n\n credentials = json.loads(abacus_config.credentials)\n\n class TempConfig:\n def __init__(self):\n self.config = {\n \"base_url\": abacus_config.base_url,\n \"client_id\": credentials.get(\"client_id\"),\n \"client_secret\": credentials.get(\"client_secret\"),\n }\n\n from app.connectors.abacus import AbacusConnector\n\n connector = AbacusConnector(TempConfig())\n\n loop = asyncio.new_event_loop()\n asyncio.set_event_loop(loop)\n try:\n types = loop.run_until_complete(connector.discover_component_types())\n finally:\n loop.close()\n\n return jsonify({\"success\": True, \"types\": types})\n\n except Exception as e:\n logger.error(\"discover_abacus_types failed: %s\", e, exc_info=True)\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\n@admin_bp_v2.route(\"/abacus-settings/stats\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef abacus_stats():\n \"\"\"Get Abacus import statistics.\"\"\"\n try:\n from app.models.application_portfolio import ApplicationComponent\n from app.models.business_capabilities import BusinessCapability\n\n abacus_apps = ApplicationComponent.query.filter_by(abacus_source=True).count()\n\n abacus_caps = BusinessCapability.query.filter(\n BusinessCapability.discovery_source == \"abacus\",\n ).count()\n\n return (\n jsonify(\n {\n \"success\": True,\n \"applications\": abacus_apps,\n \"capabilities\": abacus_caps,\n }\n ),\n 200,\n )\n\n except Exception as e:\n logger.error(f\"Error fetching Abacus stats: {e}\", exc_info=True)\n return (\n jsonify(\n {\n \"success\": False,\n \"applications\": 0,\n \"capabilities\": 0,\n \"error\": str(e),\n }\n ),\n 500,\n )\n\n\n@admin_bp_v2.route(\"/abacus-settings/discover-filters\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef discover_abacus_filters():\n \"\"\"Discover available filter dimensions from the Abacus API.\n\n Fetches a sample of 50 apps and extracts all unique ConnectionTypeName\n values with their SinkComponentName values. Returns the options so the\n admin can configure dynamic sync filters.\n \"\"\"\n import asyncio\n\n from app.models.models import ExternalSystem\n\n try:\n abacus_config = ExternalSystem.query.filter_by(system_name=\"abacus\").first()\n if not abacus_config:\n return jsonify({\"success\": False, \"error\": \"No Abacus configuration found\"}), 400\n\n credentials = json.loads(abacus_config.credentials)\n\n class TempConfig:\n def __init__(self):\n self.config = {\n \"base_url\": abacus_config.base_url,\n \"client_id\": credentials.get(\"client_id\"),\n \"client_secret\": credentials.get(\"client_secret\"),\n }\n\n from app.connectors.abacus import AbacusConnector\n\n connector = AbacusConnector(TempConfig())\n\n loop = asyncio.new_event_loop()\n asyncio.set_event_loop(loop)\n try:\n options = loop.run_until_complete(connector.discover_filter_options())\n finally:\n loop.close()\n\n # Persist discovered dimensions in config_json so they render on page load\n if options and abacus_config:\n try:\n cfg = json.loads(abacus_config.config_json or \"{}\")\n cfg[\"discovered_dimensions\"] = options\n abacus_config.config_json = json.dumps(cfg)\n db.session.commit()\n except Exception:\n logger.exception(\"Failed to persist discovered dimensions\")\n\n return jsonify({\"success\": True, \"dimensions\": options})\n\n except Exception as e:\n logger.error(\"discover_abacus_filters failed: %s\", e, exc_info=True)\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\n@admin_bp_v2.route(\"/abacus-dashboard\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef abacus_dashboard():\n \"\"\"Display Abacus sync dashboard with health metrics and statistics.\"\"\"\n from app.models.application_portfolio import ApplicationComponent\n from app.models.business_capabilities import BusinessCapability\n from app.models.models import ExternalSystem\n from app.modules.admin.v2.services.abacus_sync_service_v2 import get_sync_service\n\n sync_service = get_sync_service()\n sync_status = sync_service.get_sync_status()\n\n stats = {\n \"applications_synced\": 0,\n \"applications_created\": 0,\n \"applications_updated\": 0,\n \"applications_skipped\": 0,\n \"capabilities_synced\": 0,\n \"capabilities_created\": 0,\n \"capabilities_updated\": 0,\n \"capabilities_skipped\": 0,\n }\n\n try:\n abacus_apps = ApplicationComponent.query.filter_by(abacus_source=True).count()\n stats[\"applications_synced\"] = abacus_apps\n\n abacus_caps = BusinessCapability.query.filter(\n BusinessCapability.discovery_source == \"abacus\",\n ).count()\n stats[\"capabilities_synced\"] = abacus_caps\n\n external_system = ExternalSystem.query.filter_by(system_name=\"abacus\").first()\n if external_system and external_system.metadata:\n try:\n metadata = (\n json.loads(external_system.metadata)\n if isinstance(external_system.metadata, str)\n else external_system.metadata\n )\n last_stats = metadata.get(\"last_sync_stats\", {})\n if last_stats:\n stats.update(\n {\n \"applications_created\": last_stats.get(\n \"applications_created\", 0\n ),\n \"applications_updated\": last_stats.get(\n \"applications_updated\", 0\n ),\n \"applications_skipped\": last_stats.get(\n \"applications_skipped\", 0\n ),\n \"capabilities_created\": last_stats.get(\n \"capabilities_created\", 0\n ),\n \"capabilities_updated\": last_stats.get(\n \"capabilities_updated\", 0\n ),\n \"capabilities_skipped\": last_stats.get(\n \"capabilities_skipped\", 0\n ),\n }\n )\n except (json.JSONDecodeError, TypeError, AttributeError):\n logger.exception(\"Failed to operation\")\n pass\n\n except Exception as e:\n logger.error(f\"Failed to fetch sync statistics: {e}\", exc_info=True)\n\n return render_template(\n \"admin/abacus_dashboard.html\", sync_status=sync_status, stats=stats\n )\n\n\n# ============================================================================\n# Abacus Relationship Mapping Configuration (BPP-011)\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/abacus-settings/save-relationship-mappings\", methods=[\"POST\"])\n@login_required\n@admin_required\ndef save_relationship_mappings():\n \"\"\"Save custom OutConnection → ArchiMate relationship mappings.\"\"\"\n from app.config.abacus_field_mapping import save_outconnection_mappings\n from app.models.models import ExternalSystem\n\n try:\n data = request.get_json(silent=True) or {}\n mappings = data.get(\"mappings\", {})\n\n abacus = ExternalSystem.query.filter_by(system_name=\"abacus\").first()\n if not abacus:\n return jsonify({\"error\": \"Abacus integration not configured\"}), 404\n\n save_outconnection_mappings(abacus, mappings)\n db.session.commit()\n\n return jsonify({\"success\": True, \"saved_count\": len(mappings)})\n\n except Exception as e:\n db.session.rollback()\n logger.error(f\"Failed to save relationship mappings: {e}\")\n return jsonify({\"error\": str(e)}), 500\n\n\n@admin_bp_v2.route(\"/abacus-settings/relationship-mappings\", methods=[\"GET\"])\n@login_required\n@admin_required\ndef get_relationship_mappings():\n \"\"\"Get current OutConnection → ArchiMate relationship mappings.\"\"\"\n from app.config.abacus_field_mapping import (\n DEFAULT_OUTCONNECTION_MAPPINGS,\n get_outconnection_mappings,\n )\n from app.models.models import ExternalSystem\n\n try:\n abacus = ExternalSystem.query.filter_by(system_name=\"abacus\").first()\n mappings = get_outconnection_mappings(abacus)\n\n return jsonify({\n \"mappings\": mappings,\n \"is_default\": mappings == DEFAULT_OUTCONNECTION_MAPPINGS,\n \"total\": len(mappings),\n })\n\n except Exception as e:\n logger.error(f\"Failed to get relationship mappings: {e}\")\n return jsonify({\"error\": str(e)}), 500\n\n\n# ============================================================================\n# SSO Group-to-Role Mapping (PLT-033)\n# ============================================================================\n\n_VALID_ROLES = [\n \"solution_architect\",\n \"enterprise_architect\",\n \"business_architect\",\n \"arb_member\",\n \"portfolio_manager\",\n \"platform_admin\",\n]\n\n\n@admin_bp_v2.route(\"/sso-settings\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"update_sso_settings\")\ndef sso_settings():\n \"\"\"Manage SSO group-to-role mappings stored in the database.\"\"\"\n from app.models.miscellaneous import SSOGroupRoleMapping\n\n if request.method == \"POST\":\n action = request.form.get(\"action\")\n\n if action == \"add\":\n group_name = (request.form.get(\"sso_group_name\") or \"\").strip()\n role_name = request.form.get(\"role_name\", \"\").strip()\n description = (request.form.get(\"description\") or \"\").strip()\n\n if not group_name:\n flash(\"Group name is required.\", \"error\")\n return redirect(url_for(\"admin.sso_settings\"))\n\n if role_name not in _VALID_ROLES:\n flash(f\"Invalid role: {role_name!r}.\", \"error\")\n return redirect(url_for(\"admin.sso_settings\"))\n\n existing = SSOGroupRoleMapping.query.filter_by(\n sso_group_name=group_name\n ).first()\n if existing:\n flash(\n f\"A mapping for group '{group_name}' already exists. Edit it instead.\",\n \"error\",\n )\n return redirect(url_for(\"admin.sso_settings\"))\n\n mapping = SSOGroupRoleMapping(\n sso_group_name=group_name,\n role_name=role_name,\n description=description or None,\n is_active=True,\n )\n db.session.add(mapping)\n db.session.commit()\n logger.info(\n \"SSO mapping added: group=%r role=%r by user=%s\",\n group_name,\n role_name,\n current_user.email,\n )\n flash(f\"Mapping '{group_name}' → '{role_name}' added.\", \"success\")\n\n elif action == \"delete\":\n mapping_id = request.form.get(\"mapping_id\", type=int)\n mapping = SSOGroupRoleMapping.query.get(mapping_id)\n if not mapping:\n flash(\"Mapping not found.\", \"error\")\n return redirect(url_for(\"admin.sso_settings\"))\n group_name = mapping.sso_group_name\n db.session.delete(mapping)\n db.session.commit()\n logger.info(\n \"SSO mapping deleted: group=%r by user=%s\",\n group_name,\n current_user.email,\n )\n flash(f\"Mapping for group '{group_name}' deleted.\", \"success\")\n\n elif action == \"toggle\":\n mapping_id = request.form.get(\"mapping_id\", type=int)\n mapping = SSOGroupRoleMapping.query.get(mapping_id)\n if not mapping:\n flash(\"Mapping not found.\", \"error\")\n return redirect(url_for(\"admin.sso_settings\"))\n mapping.is_active = not mapping.is_active\n db.session.commit()\n state = \"enabled\" if mapping.is_active else \"disabled\"\n flash(f\"Mapping for group '{mapping.sso_group_name}' {state}.\", \"success\")\n\n elif action == \"sync_all\":\n # Re-apply current DB mappings to all SSO users.\n from app.models.user import User\n from app.auth.sso import sso_service\n\n sso_users = User.query.filter(User.sso_provider.isnot(None)).all()\n updated = 0\n for user in sso_users:\n # Re-evaluate via sso_service which now reads DB mappings.\n # We pass the user's stored external groups claim if available;\n # since we don't persist groups, re-evaluate by username match only.\n # A full re-evaluation requires an active SSO session. Here we\n # apply the highest-priority DB mapping that matches any group\n # whose name equals the user's sso_provider+email pattern.\n # Best-effort: if user has no stored groups we skip.\n pass\n\n # Refresh sso_service in-memory cache from DB.\n db_mappings = SSOGroupRoleMapping.query.filter_by(is_active=True).all()\n sso_service._group_role_map = {\n m.sso_group_name: m.role_name for m in db_mappings\n }\n flash(\n f\"SSO role map refreshed from database ({len(db_mappings)} active mappings). \"\n \"Roles will be applied on next SSO login.\",\n \"success\",\n )\n logger.info(\n \"SSO group-role map refreshed from DB by user=%s (%d mappings)\",\n current_user.email,\n len(db_mappings),\n )\n\n else:\n flash(\"Unknown action.\", \"error\")\n\n return redirect(url_for(\"admin.sso_settings\"))\n\n # GET\n mappings = SSOGroupRoleMapping.query.order_by(\n SSOGroupRoleMapping.sso_group_name\n ).all()\n return render_template(\n \"admin/sso_settings.html\",\n mappings=mappings,\n valid_roles=_VALID_ROLES,\n )\n\n\n# ============================================================================\n# Jira Push Integration\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/jira-settings\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef jira_settings():\n \"\"\"Manage Jira push integration configuration.\"\"\"\n from flask_wtf import FlaskForm\n from wtforms import BooleanField, PasswordField, StringField\n from wtforms.validators import DataRequired\n\n from app.models.models import ExternalSystem\n\n class JiraSettingsForm(FlaskForm):\n base_url = StringField(\"Jira Base URL\", validators=[DataRequired()])\n username = StringField(\"Username / Email\", validators=[DataRequired()])\n api_token = PasswordField(\"API Token\")\n project_key = StringField(\"Project Key\", validators=[DataRequired()])\n issue_type = StringField(\"Issue Type\", default=\"Task\")\n filter_countries = StringField(\"Country Filter\", default=\"United Kingdom\")\n enabled = BooleanField(\"Enable Integration\", default=False)\n\n jira_config = ExternalSystem.query.filter_by(\n system_name=\"jira\", system_type=\"alm\"\n ).first()\n\n form = JiraSettingsForm()\n\n if form.validate_on_submit():\n credentials = {}\n if form.api_token.data and form.api_token.data.strip():\n credentials = {\n \"username\": form.username.data,\n \"api_token\": form.api_token.data,\n }\n elif jira_config and jira_config.credentials:\n try:\n existing_creds = json.loads(jira_config.credentials)\n credentials = {\n \"username\": form.username.data,\n \"api_token\": existing_creds.get(\"api_token\", \"\"),\n }\n except (json.JSONDecodeError, TypeError):\n logger.exception(\"Failed to JSON parsing\")\n pass\n\n filter_countries = (form.filter_countries.data or \"\").strip()\n config_data = {\n \"project_key\": form.project_key.data,\n \"issue_type\": form.issue_type.data or \"Task\",\n \"filter_countries\": [c.strip() for c in filter_countries.split(\",\") if c.strip()],\n \"custom_field_map\": {},\n }\n\n if jira_config:\n jira_config.base_url = form.base_url.data\n jira_config.api_endpoint = f\"{form.base_url.data}/rest/api/3\"\n jira_config.auth_type = \"basic\"\n if credentials:\n jira_config.credentials = json.dumps(credentials)\n jira_config.config_json = json.dumps(config_data)\n jira_config.enabled = form.enabled.data\n jira_config.updated_by_id = current_user.id\n jira_config.updated_at = datetime.utcnow()\n flash(\"Jira settings updated successfully.\", \"success\")\n else:\n if not credentials or not credentials.get(\"api_token\"):\n flash(\"API token is required for new configuration.\", \"error\")\n _env_url = os.environ.get(\"JIRA_BASE_URL\", \"\").strip()\n _env_user = os.environ.get(\"JIRA_USERNAME\", \"\").strip()\n _env_project = os.environ.get(\"JIRA_PROJECT_KEY\", \"\").strip()\n _env_configured = bool(_env_url and _env_user and _env_project)\n return render_template(\n \"admin/jira_settings.html\", form=form, jira_config=None, push_status=None,\n env_configured=_env_configured,\n )\n jira_config = ExternalSystem(\n system_name=\"jira\",\n system_type=\"alm\",\n base_url=form.base_url.data,\n api_endpoint=f\"{form.base_url.data}/rest/api/3\",\n auth_type=\"basic\",\n credentials=json.dumps(credentials),\n config_json=json.dumps(config_data),\n enabled=form.enabled.data,\n updated_by_id=current_user.id,\n )\n db.session.add(jira_config)\n flash(\"Jira settings created successfully.\", \"success\")\n\n db.session.commit()\n return redirect(url_for(\"admin.jira_settings\"))\n\n # Detect .env configuration regardless of DB record\n # Any non-empty JIRA_BASE_URL indicates env-based config; JIRA_API_TOKEN may load at boot\n env_jira_url = os.environ.get(\"JIRA_BASE_URL\", \"\").strip()\n env_jira_user = os.environ.get(\"JIRA_USERNAME\", \"\").strip()\n env_jira_token = os.environ.get(\"JIRA_API_TOKEN\", \"\").strip()\n env_jira_project = os.environ.get(\"JIRA_PROJECT_KEY\", \"\").strip()\n # Fallback: check if the connector can initialize (handles .env loading internally)\n env_configured = bool(env_jira_url and env_jira_user and env_jira_project)\n\n push_status = None\n if request.method == \"GET\":\n if jira_config:\n try:\n creds = json.loads(jira_config.credentials) if jira_config.credentials else {}\n except (json.JSONDecodeError, TypeError):\n creds = {}\n try:\n cfg = json.loads(jira_config.config_json) if jira_config.config_json else {}\n except (json.JSONDecodeError, TypeError):\n cfg = {}\n\n form.base_url.data = jira_config.base_url\n form.username.data = creds.get(\"username\", \"\")\n form.project_key.data = cfg.get(\"project_key\", \"\")\n form.issue_type.data = cfg.get(\"issue_type\", \"Task\")\n countries = cfg.get(\"filter_countries\", [])\n form.filter_countries.data = \", \".join(countries) if isinstance(countries, list) else str(countries)\n form.enabled.data = jira_config.enabled\n else:\n # Pre-populate from .env when no DB record exists yet\n form.base_url.data = os.environ.get(\"JIRA_BASE_URL\", \"\")\n form.username.data = os.environ.get(\"JIRA_USERNAME\", \"\")\n form.project_key.data = os.environ.get(\"JIRA_PROJECT_KEY\", \"\")\n\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n push_status = svc.get_push_status()\n if push_status and jira_config and jira_config.config_json:\n cfg_data = json.loads(jira_config.config_json) if isinstance(jira_config.config_json, str) else {}\n last_wh = cfg_data.get(\"last_webhook_at\")\n if last_wh:\n push_status[\"last_webhook_at\"] = last_wh\n except Exception:\n push_status = None\n\n return render_template(\n \"admin/jira_settings.html\",\n form=form,\n jira_config=jira_config,\n push_status=push_status,\n env_configured=env_configured,\n )\n\n\n@admin_bp_v2.route(\"/jira-settings/test-connection\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef jira_test_connection():\n \"\"\"Test Jira API connectivity.\"\"\"\n import asyncio\n\n from app.models.models import ExternalSystem\n\n jira_config = ExternalSystem.query.filter_by(\n system_name=\"jira\", system_type=\"alm\"\n ).first()\n\n if not jira_config:\n # Try .env fallback — connector handles it via _initialize_connector()\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n if not svc._initialize_connector():\n return jsonify({\"success\": False, \"message\": \"Jira not configured. Please fill in and save your Jira credentials first, then test the connection.\"}), 400\n loop = asyncio.new_event_loop()\n try:\n connected = loop.run_until_complete(svc.connector.test_connection())\n finally:\n loop.close()\n return jsonify({\n \"success\": connected,\n \"message\": \"Connection successful (via .env)\" if connected else \"Connection failed — check .env credentials\",\n })\n except Exception as e:\n return jsonify({\"success\": False, \"message\": str(e)}), 500\n\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n if not svc._initialize_connector():\n return jsonify({\n \"success\": False,\n \"message\": \"Failed to connect: Jira credentials are saved but invalid or incomplete. Check the URL, username, API token and project key.\"\n }), 400\n\n loop = asyncio.new_event_loop()\n try:\n connected = loop.run_until_complete(svc.connector.test_connection())\n finally:\n loop.close()\n\n jira_config.last_connection_test = datetime.utcnow()\n jira_config.connection_status = \"connected\" if connected else \"error\"\n db.session.commit()\n\n return jsonify({\n \"success\": connected,\n \"message\": \"Connection successful\" if connected else \"Connection failed\",\n })\n except Exception as e:\n jira_config.connection_status = \"error\"\n jira_config.last_error = str(e)\n db.session.commit()\n return jsonify({\"success\": False, \"message\": str(e)}), 500\n\n\n@admin_bp_v2.route(\"/jira-settings/webhook\", methods=[\"POST\"])\n@login_required\n# csrf.exempt: webhook receiver — external systems cannot include CSRF tokens\n@csrf.exempt\ndef jira_webhook():\n \"\"\"Receive Jira webhook events and sync status back to KanbanCard.\"\"\"\n from app.models.models import ExternalSystem\n\n # HMAC verification\n secret = os.environ.get(\"JIRA_WEBHOOK_SECRET\", \"\")\n if secret:\n sig = request.headers.get(\"X-Hub-Signature\", \"\")\n expected = \"sha256=\" + hmac.new(secret.encode(), request.data, hashlib.sha256).hexdigest()\n if not hmac.compare_digest(expected, sig):\n return jsonify({\"error\": \"Invalid signature\"}), 403\n\n data = request.get_json(silent=True) or {}\n issue_key = data.get(\"issue\", {}).get(\"key\", \"\")\n if not issue_key:\n return jsonify({\"ok\": True}), 200\n\n # Extract status change from changelog\n changelog = data.get(\"changelog\", {})\n items = changelog.get(\"items\", [])\n new_status = None\n for item in items:\n if item.get(\"field\") == \"status\":\n new_status = item.get(\"toString\")\n break\n\n # Reverse-map Jira status to platform column\n if new_status:\n try:\n from app.config.kanban_jira_field_mapping import COLUMN_TO_JIRA_STATUS\n from app.models.adm_kanban_models import KanbanCard\n jira_to_column = {v: k for k, v in COLUMN_TO_JIRA_STATUS.items()}\n new_column = jira_to_column.get(new_status)\n if new_column:\n card = KanbanCard.query.filter_by(jira_issue_key=issue_key).first()\n if card:\n card.status = new_column\n card.updated_at = datetime.utcnow()\n db.session.commit()\n except Exception as _card_exc:\n logger.warning(\"Jira webhook card update failed for %s: %s\", issue_key, _card_exc)\n\n # Store last webhook timestamp\n try:\n jira_config = ExternalSystem.query.filter_by(system_name=\"jira\").first()\n if jira_config:\n cfg = json.loads(jira_config.config_json) if jira_config.config_json else {}\n cfg[\"last_webhook_at\"] = datetime.utcnow().isoformat()\n jira_config.config_json = json.dumps(cfg)\n db.session.commit()\n except Exception as _ts_exc:\n logger.warning(\"Jira webhook timestamp update failed: %s\", _ts_exc)\n\n return jsonify({\"ok\": True}), 200\n\n\n@admin_bp_v2.route(\"/jira-settings/save-env-config\", methods=[\"POST\"])\n@login_required\n@admin_required\ndef save_env_jira_config():\n \"\"\"Save .env Jira credentials to database.\"\"\"\n import os\n\n from app.models.models import ExternalSystem\n\n url = os.environ.get(\"JIRA_BASE_URL\", \"\").strip()\n user = os.environ.get(\"JIRA_USERNAME\", \"\").strip()\n token = os.environ.get(\"JIRA_API_TOKEN\", \"\").strip()\n proj = os.environ.get(\"JIRA_PROJECT_KEY\", \"\").strip()\n\n missing = [k for k, v in [\n (\"JIRA_BASE_URL\", url), (\"JIRA_USERNAME\", user),\n (\"JIRA_API_TOKEN\", token), (\"JIRA_PROJECT_KEY\", proj),\n ] if not v]\n if missing:\n flash(f\"Missing required Jira environment variables: {', '.join(missing)}\", \"error\")\n return redirect(url_for(\"admin.jira_settings\"))\n\n jira_config = ExternalSystem.query.filter_by(\n system_name=\"jira\", system_type=\"alm\"\n ).first()\n if jira_config is None:\n jira_config = ExternalSystem(system_name=\"jira\", system_type=\"alm\")\n db.session.add(jira_config)\n\n jira_config.credentials = json.dumps({\n \"base_url\": url,\n \"username\": user,\n \"api_token\": token,\n \"project_key\": proj,\n })\n jira_config.enabled = True\n db.session.commit()\n\n flash(\"Jira configuration saved to database.\", \"success\")\n return redirect(url_for(\"admin.jira_settings\"))\n\n\n@admin_bp_v2.route(\"/jira-settings/trigger-push\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef jira_trigger_push():\n \"\"\"Create a Job and start pushing applications to Jira.\"\"\"\n from app.models.job import Job, JobStatus\n\n force = request.json.get(\"force\", False) if request.is_json else False\n app_ids = request.json.get(\"app_ids\") if request.is_json else None\n\n job = Job(\n name=\"jira_push\",\n task=\"push_applications\",\n payload={\"app_ids\": app_ids, \"force\": force},\n status=JobStatus.IN_PROGRESS.value,\n started_at=datetime.utcnow(),\n )\n db.session.add(job)\n db.session.commit()\n\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n result = svc.push_applications(app_ids=app_ids, force=force)\n\n job.status = JobStatus.COMPLETED.value\n job.result = result.as_dict()\n job.finished_at = datetime.utcnow()\n db.session.commit()\n\n return jsonify({\"success\": True, \"job_id\": job.id, \"result\": result.as_dict()})\n except Exception as e:\n job.status = JobStatus.FAILED.value\n job.error = str(e)\n job.finished_at = datetime.utcnow()\n db.session.commit()\n return jsonify({\"success\": False, \"job_id\": job.id, \"error\": str(e)}), 500\n\n\n@admin_bp_v2.route(\"/jira-settings/push-status\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef jira_push_status():\n \"\"\"Return JSON push status for polling.\"\"\"\n from app.models.job import Job\n\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n status = svc.get_push_status()\n except Exception:\n status = {}\n\n latest_job = (\n Job.query.filter_by(name=\"jira_push\")\n .order_by(Job.created_at.desc())\n .first()\n )\n\n return jsonify({\n \"push_status\": status,\n \"latest_job\": latest_job.as_dict() if latest_job else None,\n })\n\n\n@admin_bp_v2.route(\"/jira-settings/kanban-push-status\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef jira_kanban_push_status():\n \"\"\"Return JSON kanban push status for polling.\"\"\"\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n kanban_status = svc.get_kanban_push_status()\n return jsonify({\"success\": True, \"kanban_status\": kanban_status})\n except Exception as exc:\n logger.error(\"Kanban push status error: %s\", exc)\n return jsonify({\"success\": False, \"error\": str(exc)}), 500\n\n\n@admin_bp_v2.route(\"/jira-settings/trigger-kanban-push\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef jira_trigger_kanban_push():\n \"\"\"Push all unpushed KanbanCard rows to Jira.\"\"\"\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n result = svc.push_all_unpushed_cards()\n return jsonify({\"success\": True, **result})\n except Exception as exc:\n logger.error(\"Trigger kanban push error: %s\", exc)\n return jsonify({\"success\": False, \"error\": str(exc)}), 500\n\n\n@admin_bp_v2.route(\"/jira-settings/push-epics\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef jira_push_epics():\n \"\"\"Create one Jira Epic per ADM phase as an ArchiMate Plateau.\"\"\"\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n result = svc.push_adm_phase_epics()\n return jsonify({\"success\": True, **result})\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\n@admin_bp_v2.route(\"/jira-settings/push-applications\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef jira_push_applications():\n \"\"\"Push ApplicationComponents (ArchiMate Application Layer, Phase C/D) to Jira.\"\"\"\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n result = svc.push_applications()\n return jsonify({\"success\": True, **result.as_dict()})\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\n@admin_bp_v2.route(\"/jira-settings/push-dependencies\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef jira_push_dependencies():\n \"\"\"Create Jira Subtasks from KanbanCard.depends_on (ArchiMate TriggeringRelationship).\n\n Also creates Blocks issue links from KanbanCard.blocks (ArchiMate AssociationRelationship).\n \"\"\"\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n result = svc.push_card_dependencies()\n return jsonify({\"success\": True, **result})\n except Exception as e:\n return jsonify({\"success\": False, \"error\": str(e)}), 500\n\n\n@admin_bp_v2.route(\"/jira-settings/field-discovery\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef jira_field_discovery():\n \"\"\"Trigger discover_fields and return available Jira fields.\"\"\"\n import asyncio\n\n from app.models.models import ExternalSystem\n\n jira_config = ExternalSystem.query.filter_by(\n system_name=\"jira\", system_type=\"alm\"\n ).first()\n\n if not jira_config:\n return jsonify({\"success\": False, \"message\": \"Jira not configured\"}), 400\n\n try:\n cfg = json.loads(jira_config.config_json) if jira_config.config_json else {}\n except (json.JSONDecodeError, TypeError):\n cfg = {}\n\n project_key = cfg.get(\"project_key\", \"EA\")\n issue_type_id = request.args.get(\"issue_type_id\", \"10001\")\n\n try:\n from app.services.jira_push_service import get_jira_push_service\n svc = get_jira_push_service()\n if not svc._initialize_connector():\n return jsonify({\"success\": False, \"message\": \"Connector init failed\"}), 400\n\n loop = asyncio.new_event_loop()\n try:\n fields = loop.run_until_complete(\n svc.connector.discover_fields(project_key, issue_type_id)\n )\n finally:\n loop.close()\n\n return jsonify({\"success\": True, \"fields\": fields})\n except Exception as e:\n return jsonify({\"success\": False, \"message\": str(e)}), 500\n\n\n# ============================================================================\n# Seed Management\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/seed-management\")\n@timed_route\n@login_required\n@admin_required\ndef seed_management():\n \"\"\"Seed management dashboard.\"\"\"\n from app.modules.admin.v2.services.seed_management_service_v2 import (\n SeedManagementService,\n )\n\n service = SeedManagementService()\n categories = service.get_seed_status()\n\n return render_template(\"admin/seed_management.html\", categories=categories)\n\n\n@admin_bp_v2.route(\"/api/seed-status\")\n@timed_route\n@login_required\n@admin_required\ndef seed_status():\n \"\"\"API: Get current seed status.\"\"\"\n from app.modules.admin.v2.services.seed_management_service_v2 import (\n SeedManagementService,\n )\n\n service = SeedManagementService()\n categories = service.get_seed_status()\n\n return jsonify({\"success\": True, \"categories\": categories})\n\n\n@admin_bp_v2.route(\"/api/seed/<key>\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"seed_data\")\ndef seed(key):\n \"\"\"API: Trigger seeding for a specific category.\"\"\"\n from app.modules.admin.v2.services.seed_management_service_v2 import (\n SeedManagementService,\n )\n\n service = SeedManagementService()\n result = service.seed_category(key)\n status_code = 200 if result.get(\"success\") else 400\n return jsonify(result), status_code\n\n\n@admin_bp_v2.route(\"/api/seed-all\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"seed_all_data\")\ndef seed_all():\n \"\"\"API: Trigger all seeders.\"\"\"\n from app.modules.admin.v2.services.seed_management_service_v2 import (\n SeedManagementService,\n )\n\n service = SeedManagementService()\n result = service.seed_all()\n status_code = 200 if result.get(\"success\") else 400\n return jsonify(result), status_code\n\n\n# ============================================================================\n# Feature Auto-Discovery Helper\n# ============================================================================\n\n\ndef _auto_discover_features(app):\n \"\"\"Auto-discover features from Flask app blueprints and routes.\n\n Creates a hierarchical feature tree:\n - Level 1: Blueprints (parent features)\n - Level 2: Routes within blueprints (child features)\n - Level 3: Sub-routes (grandchild features)\n\n Filters out infrastructure routes to reduce noise.\n\n Args:\n app: Flask application instance\n\n Returns:\n int: Number of features created\n \"\"\"\n from collections import defaultdict\n\n created_count = 0\n blueprint_features = {}\n\n existing_keys = {f.key for f in FeatureFlag.query.all()}\n\n SKIP_ENDPOINTS = {\n \"static\",\n \"_debug_toolbar\",\n \"debugtoolbar\",\n \"health\",\n \"metrics\",\n \"status\",\n }\n SKIP_PREFIXES = (\"_\", \"static\", \"/health\", \"/metrics\", \"/_\", \"/api/\")\n SKIP_BLUEPRINTS = {\"debugtoolbar\", \"static\"}\n\n routes_by_blueprint = defaultdict(list)\n skipped_count = 0\n total_count = 0\n\n for rule in app.url_map.iter_rules():\n total_count += 1\n\n if any(rule.endpoint.startswith(skip) for skip in SKIP_ENDPOINTS):\n skipped_count += 1\n continue\n if any(rule.rule.startswith(skip) for skip in SKIP_PREFIXES):\n skipped_count += 1\n continue\n\n parts = rule.endpoint.split(\".\")\n if len(parts) >= 2:\n blueprint_name = parts[0]\n\n if blueprint_name in SKIP_BLUEPRINTS:\n skipped_count += 1\n continue\n\n routes_by_blueprint[blueprint_name].append(\n {\n \"endpoint\": rule.endpoint,\n \"rule\": rule.rule,\n \"methods\": list(rule.methods - {\"HEAD\", \"OPTIONS\"}),\n }\n )\n else:\n skipped_count += 1\n\n app.logger.info(\n f\"Route discovery: {total_count} total routes, {skipped_count} skipped, {len(routes_by_blueprint)} blueprints found\"\n )\n\n for blueprint_name in sorted(routes_by_blueprint.keys()):\n blueprint_key = f\"blueprint_{blueprint_name}\"\n\n if blueprint_key in existing_keys:\n parent_feature = FeatureFlag.query.filter_by(key=blueprint_key).first()\n blueprint_features[blueprint_name] = parent_feature\n continue\n\n display_name = blueprint_name.replace(\"_\", \" \").title()\n\n parent_feature = FeatureFlag(\n key=blueprint_key,\n name=f\"{display_name} Module\",\n description=f\"All features within the {display_name} blueprint\",\n feature_type=FeatureType.BLUEPRINT,\n state=FeatureState.STABLE,\n enabled=True,\n sort_order=created_count,\n )\n\n try:\n db.session.add(parent_feature)\n db.session.flush()\n blueprint_features[blueprint_name] = parent_feature\n existing_keys.add(blueprint_key)\n created_count += 1\n except Exception as e:\n db.session.rollback()\n app.logger.warning(\n f\"Failed to create blueprint feature {blueprint_key}: {e}\"\n )\n parent_feature = FeatureFlag.query.filter_by(key=blueprint_key).first()\n if parent_feature:\n blueprint_features[blueprint_name] = parent_feature\n continue\n\n base_paths = defaultdict(list)\n for route_info in routes_by_blueprint[blueprint_name]:\n rule = route_info[\"rule\"]\n path_parts = [p for p in rule.split(\"/\") if p and not p.startswith(\"<\")]\n base_path = \"/\" + \"/\".join(path_parts[:3]) if len(path_parts) >= 2 else rule\n base_paths[base_path].append(route_info)\n\n for base_path, route_list in sorted(base_paths.items()):\n if len(route_list) == 1 and route_list[0][\"rule\"] == base_path:\n route_info = route_list[0]\n route_key = f\"route_{route_info['endpoint'].replace('.', '_')}\"\n\n if route_key in existing_keys:\n continue\n\n function_name = route_info[\"endpoint\"].split(\".\")[-1]\n display_name = function_name.replace(\"_\", \" \").title()\n\n route_feature = FeatureFlag(\n key=route_key,\n name=display_name,\n description=f\"{display_name} ({route_info['rule']})\",\n feature_type=FeatureType.ROUTE,\n state=FeatureState.STABLE,\n enabled=True,\n routes=[route_info[\"rule\"]],\n parent_id=parent_feature.id,\n sort_order=created_count,\n )\n\n try:\n db.session.add(route_feature)\n existing_keys.add(route_key)\n created_count += 1\n except Exception as e:\n db.session.rollback()\n app.logger.warning(\n f\"Failed to create route feature {route_key}: {e}\"\n )\n continue\n else:\n section_key = (\n f\"section_{blueprint_name}_{base_path.replace('/', '_').strip('_')}\"\n )\n\n section_feature = None\n if section_key in existing_keys:\n section_feature = FeatureFlag.query.filter_by(\n key=section_key\n ).first()\n else:\n section_name = (\n base_path.split(\"/\")[-1]\n .replace(\"-\", \" \")\n .replace(\"_\", \" \")\n .title()\n )\n\n section_feature = FeatureFlag(\n key=section_key,\n name=f\"{section_name} Section\",\n description=f\"All routes under {base_path}\",\n feature_type=FeatureType.SIDEBAR_SECTION,\n state=FeatureState.STABLE,\n enabled=True,\n routes=[base_path + \"*\"],\n parent_id=parent_feature.id,\n sort_order=created_count,\n )\n\n try:\n db.session.add(section_feature)\n db.session.flush()\n existing_keys.add(section_key)\n created_count += 1\n except Exception as e:\n db.session.rollback()\n app.logger.warning(\n f\"Failed to create section feature {section_key}: {e}\"\n )\n section_feature = FeatureFlag.query.filter_by(\n key=section_key\n ).first()\n\n if not section_feature:\n continue\n\n for route_info in route_list:\n route_key = f\"route_{route_info['endpoint'].replace('.', '_')}\"\n\n if route_key in existing_keys:\n continue\n\n function_name = route_info[\"endpoint\"].split(\".\")[-1]\n display_name = function_name.replace(\"_\", \" \").title()\n\n route_feature = FeatureFlag(\n key=route_key,\n name=display_name,\n description=f\"{display_name} ({route_info['rule']}) - {', '.join(route_info['methods'])}\",\n feature_type=FeatureType.ROUTE,\n state=FeatureState.STABLE,\n enabled=True,\n routes=[route_info[\"rule\"]],\n parent_id=section_feature.id,\n sort_order=created_count,\n )\n\n try:\n db.session.add(route_feature)\n existing_keys.add(route_key)\n created_count += 1\n except Exception as e:\n db.session.rollback()\n app.logger.warning(\n f\"Failed to create route feature {route_key}: {e}\"\n )\n continue\n\n try:\n db.session.commit()\n except Exception as e:\n db.session.rollback()\n app.logger.error(f\"Failed to commit feature discovery: {e}\")\n raise\n\n return created_count\n\n\n# ============================================================================\n# Users Table API — canonical data table endpoints\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/api/users\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef api_list_users():\n \"\"\"Paginated user list API for canonical data table.\"\"\"\n from sqlalchemy.orm import joinedload\n\n page = request.args.get(\"page\", 1, type=int)\n per_page = min(request.args.get(\"per_page\", 25, type=int), 100)\n search = request.args.get(\"q\") or request.args.get(\"search\", \"\")\n role_filter = request.args.get(\"role\", \"\")\n sort_by = request.args.get(\"sort\", \"id\")\n sort_dir = request.args.get(\"dir\", \"desc\")\n\n ALLOWED_SORT = {\"id\", \"first_name\", \"last_name\", \"email\", \"created_at\"}\n if sort_by not in ALLOWED_SORT:\n sort_by = \"id\"\n\n query = User.query.options(joinedload(User.role))\n if search:\n term = f\"%{search}%\"\n query = query.filter(\n User.first_name.ilike(term) | User.last_name.ilike(term) | User.email.ilike(term)\n )\n if role_filter:\n query = query.join(User.role).filter(Role.name == role_filter)\n\n sort_col = getattr(User, sort_by, User.id)\n if sort_dir == \"asc\":\n query = query.order_by(sort_col.asc())\n else:\n query = query.order_by(sort_col.desc())\n\n paginated = query.paginate(page=page, per_page=per_page, error_out=False)\n\n items = []\n for idx, u in enumerate(paginated.items):\n items.append({\n \"id\": u.id,\n \"row_number\": (page - 1) * per_page + idx + 1,\n \"first_name\": u.first_name or \"\",\n \"last_name\": u.last_name or \"\",\n \"email\": u.email,\n \"role\": u.role.name if u.role else \"No Role\",\n \"confirmed\": u.confirmed,\n })\n\n return jsonify({\n \"users\": items,\n \"total\": paginated.total,\n \"page\": page,\n \"pages\": paginated.pages,\n \"per_page\": per_page,\n })\n\n\n@admin_bp_v2.route(\"/api/users/bulk\", methods=[\"DELETE\"])\n@timed_route\n@login_required\n@admin_required\ndef api_bulk_delete_users():\n \"\"\"Bulk delete users by IDs (cannot delete yourself).\"\"\"\n data = request.get_json() or {}\n ids = data.get(\"ids\", [])\n if not ids or not isinstance(ids, list):\n return jsonify({\"error\": \"ids list required\"}), 400\n safe_ids = [i for i in ids if i != current_user.id]\n deleted = User.query.filter(User.id.in_(safe_ids)).delete(synchronize_session=False)\n db.session.commit()\n return jsonify({\"deleted\": deleted})\n\n\n# ── Roles API ──────────────────────────────────────────────────────────────────\n\n@admin_bp_v2.route(\"/api/roles\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef api_list_roles():\n \"\"\"List all roles with user counts and permission flags.\"\"\"\n roles = Role.query.order_by(Role.name).all()\n items = []\n for role in roles:\n users = User.query.filter_by(role_id=role.id).all()\n items.append({\n \"id\": role.id,\n \"name\": role.name,\n \"type\": \"system\" if role.name in (\"Administrator\", \"User\") else \"custom\",\n \"status\": \"active\",\n \"description\": f\"{'Full system access and administration.' if role.name == 'Administrator' else 'Standard user access.'}\" if role.name in (\"Administrator\", \"User\") else \"\",\n \"permissions\": {\n \"administer\": bool(role.permissions and (role.permissions & 0xFF) == 0xFF),\n \"general\": bool(role.permissions and role.permissions & 0x01),\n },\n \"userCount\": len(users),\n \"users\": [u.email for u in users[:10]],\n \"created\": None,\n \"lastModified\": None,\n })\n return jsonify({\"success\": True, \"roles\": items})\n\n\n@admin_bp_v2.route(\"/api/roles/<int:role_id>\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef api_get_role(role_id):\n \"\"\"Get a single role by ID.\"\"\"\n role = Role.query.get_or_404(role_id)\n users = User.query.filter_by(role_id=role.id).all()\n return jsonify({\n \"success\": True,\n \"role\": {\n \"id\": role.id,\n \"name\": role.name,\n \"permissions\": {\n \"administer\": bool(role.permissions and (role.permissions & 0xFF) == 0xFF),\n \"general\": bool(role.permissions and role.permissions & 0x01),\n },\n \"userCount\": len(users),\n \"users\": [{\"id\": u.id, \"email\": u.email, \"name\": f\"{u.first_name or ''} {u.last_name or ''}\".strip()} for u in users],\n }\n })\n\n\n@admin_bp_v2.route(\"/api/roles\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef api_create_role():\n \"\"\"Create a new custom role.\"\"\"\n data = request.get_json() or {}\n name = (data.get(\"name\") or \"\").strip()\n if not name:\n return jsonify({\"success\": False, \"error\": \"Role name is required\"}), 400\n if Role.query.filter_by(name=name).first():\n return jsonify({\"success\": False, \"error\": f\"Role '{name}' already exists\"}), 409\n from app.models.user import Permission as PermBits\n perms = PermBits.ADMINISTER if data.get(\"is_admin\") else PermBits.GENERAL\n role = Role(name=name, permissions=perms, index=\"main\", default=False)\n db.session.add(role)\n db.session.commit()\n return jsonify({\"success\": True, \"role\": {\"id\": role.id, \"name\": role.name}}), 201\n\n\n@admin_bp_v2.route(\"/api/roles/<int:role_id>\", methods=[\"PUT\"])\n@timed_route\n@login_required\n@admin_required\ndef api_update_role(role_id):\n \"\"\"Update a role name or permissions.\"\"\"\n role = Role.query.get_or_404(role_id)\n if role.name in (\"Administrator\", \"User\"):\n return jsonify({\"success\": False, \"error\": \"System roles cannot be modified\"}), 403\n data = request.get_json() or {}\n if \"name\" in data and data[\"name\"].strip():\n role.name = data[\"name\"].strip()\n from app.models.user import Permission as PermBits\n if \"is_admin\" in data:\n role.permissions = PermBits.ADMINISTER if data[\"is_admin\"] else PermBits.GENERAL\n db.session.commit()\n return jsonify({\"success\": True})\n\n\n@admin_bp_v2.route(\"/api/roles/<int:role_id>\", methods=[\"DELETE\"])\n@timed_route\n@login_required\n@admin_required\ndef api_delete_role(role_id):\n \"\"\"Delete a custom role. Reassigns users to the default User role.\"\"\"\n role = Role.query.get_or_404(role_id)\n if role.name in (\"Administrator\", \"User\"):\n return jsonify({\"success\": False, \"error\": \"System roles cannot be deleted\"}), 403\n default_role = Role.query.filter_by(default=True).first()\n User.query.filter_by(role_id=role.id).update({\"role_id\": default_role.id if default_role else None})\n db.session.delete(role)\n db.session.commit()\n return jsonify({\"success\": True})\n\n\n# ── Permissions / Enterprise Roles API ────────────────────────────────────────\n\n@admin_bp_v2.route(\"/api/enterprise-roles\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef api_list_enterprise_roles():\n \"\"\"List enterprise role assignments across all users.\"\"\"\n from app.models.user import VALID_ROLES\n role_counts = {}\n for r in VALID_ROLES:\n role_counts[r] = User.query.filter_by(enterprise_role=r).count()\n\n items = []\n for r in VALID_ROLES:\n label = r.replace(\"_\", \" \").title()\n items.append({\n \"id\": r,\n \"name\": label,\n \"type\": \"enterprise\",\n \"resource\": \"platform\",\n \"action\": \"access\",\n \"scope\": \"organization\",\n \"description\": f\"Enterprise platform role: {label}\",\n \"level\": 1,\n \"userCount\": role_counts.get(r, 0),\n })\n return jsonify({\"success\": True, \"permissions\": items})\n\n\n@admin_bp_v2.route(\"/api/enterprise-roles/users\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef api_enterprise_role_users():\n \"\"\"List all users with their enterprise role assignments.\"\"\"\n users = User.query.order_by(User.last_name, User.first_name).all()\n items = []\n for u in users:\n items.append({\n \"id\": u.id,\n \"email\": u.email,\n \"name\": f\"{u.first_name or ''} {u.last_name or ''}\".strip() or u.email,\n \"enterprise_role\": u.enterprise_role,\n \"role\": u.role.name if u.role else \"User\",\n })\n return jsonify({\"success\": True, \"users\": items})\n\n\n@admin_bp_v2.route(\"/api/enterprise-roles/assign\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef api_assign_enterprise_role():\n \"\"\"Assign an enterprise role to a user.\"\"\"\n from app.models.user import VALID_ROLES\n data = request.get_json() or {}\n user_id = data.get(\"user_id\")\n role = data.get(\"role\")\n if not user_id or not role:\n return jsonify({\"success\": False, \"error\": \"user_id and role are required\"}), 400\n if role not in VALID_ROLES:\n return jsonify({\"success\": False, \"error\": f\"Invalid role. Must be one of: {', '.join(VALID_ROLES)}\"}), 400\n user = User.query.get_or_404(user_id)\n user.enterprise_role = role\n db.session.commit()\n return jsonify({\"success\": True, \"user_id\": user_id, \"role\": role})\n\n\n# =============================================================================\n# AUDIT LOG VIEWER (PLT-032)\n# =============================================================================\n\n\n@admin_bp_v2.route(\"/audit-log\")\n@login_required\n@admin_required\ndef audit_log_viewer():\n \"\"\"PLT-032: Admin audit log query UI for SOX/HIPAA compliance.\"\"\"\n import logging\n from datetime import datetime as dt\n\n logger = logging.getLogger(__name__)\n\n page = request.args.get(\"page\", 1, type=int)\n per_page = min(request.args.get(\"per_page\", 50, type=int), 200)\n date_from = request.args.get(\"date_from\", \"\")\n date_to = request.args.get(\"date_to\", \"\")\n user_email = request.args.get(\"user_email\", \"\").strip()\n action_filter = request.args.get(\"action\", \"\").strip()\n entity_type_filter = request.args.get(\"entity_type\", \"\").strip()\n search_q = request.args.get(\"q\", \"\").strip()\n export_csv = request.args.get(\"export\") == \"csv\"\n\n entries = []\n total = 0\n action_types = []\n entity_types = []\n\n try:\n from app.models.audit_log import AuditLog\n\n # NOTE: AuditLog's real columns are created_at / user_id / table_name /\n # record_id (no timestamp/user_email/entity_type/description/is_deleted).\n # Filters below use the real columns; the model exposes the old names as\n # read-only display properties for the template/CSV.\n query = AuditLog.query\n\n if date_from:\n try:\n query = query.filter(AuditLog.created_at >= dt.fromisoformat(date_from))\n except ValueError:\n logger.debug(\"PLT-032: invalid date_from: %s\", date_from)\n\n if date_to:\n try:\n to_dt = dt.fromisoformat(date_to).replace(hour=23, minute=59, second=59)\n query = query.filter(AuditLog.created_at <= to_dt)\n except ValueError:\n logger.debug(\"PLT-032: invalid date_to: %s\", date_to)\n\n if user_email:\n # AuditLog stores user_id, not email — resolve matching users first.\n from app.models.user import User\n\n _uids = [u.id for u in User.query.filter(User.email.ilike(f\"%{user_email}%\")).all()]\n query = query.filter(AuditLog.user_id.in_(_uids or [-1]))\n\n if action_filter:\n query = query.filter(AuditLog.action == action_filter)\n\n if entity_type_filter:\n query = query.filter(AuditLog.table_name == entity_type_filter)\n\n if search_q:\n query = query.filter(\n db.or_(\n AuditLog.table_name.ilike(f\"%{search_q}%\"),\n AuditLog.action.ilike(f\"%{search_q}%\"),\n )\n )\n\n query = query.order_by(AuditLog.created_at.desc())\n\n # Get distinct action types and entity types (table names) for dropdowns\n action_types = [\n r[0] for r in db.session.query(AuditLog.action).distinct().order_by(AuditLog.action).all() if r[0]\n ]\n entity_types = [\n r[0] for r in db.session.query(AuditLog.table_name).distinct().order_by(AuditLog.table_name).all() if r[0]\n ]\n\n if export_csv:\n import csv\n import io\n\n rows = query.limit(10000).all()\n output = io.StringIO()\n writer = csv.writer(output)\n writer.writerow([\"timestamp\", \"user_email\", \"action\", \"entity_type\", \"entity_id\", \"entity_name\", \"description\", \"status\"])\n for row in rows:\n writer.writerow([\n row.timestamp.isoformat() if row.timestamp else \"\",\n row.user_email or \"\",\n row.action,\n row.entity_type,\n row.entity_id or \"\",\n row.entity_name or \"\",\n row.description or \"\",\n row.status or \"\",\n ])\n\n from flask import Response\n return Response(\n output.getvalue(),\n mimetype=\"text/csv\",\n headers={\"Content-Disposition\": \"attachment; filename=audit_log_export.csv\"},\n )\n\n total = query.count()\n entries = query.offset((page - 1) * per_page).limit(per_page).all()\n\n except Exception as exc:\n logger.warning(\"PLT-032: AuditLog query failed: %s\", exc)\n\n total_pages = (total + per_page - 1) // per_page if per_page else 1\n\n if request.accept_mimetypes.best == \"application/json\":\n return jsonify({\n \"entries\": [e.to_dict() for e in entries],\n \"total\": total,\n \"page\": page,\n \"per_page\": per_page,\n \"total_pages\": total_pages,\n })\n\n return render_template(\n \"admin/audit_log.html\",\n entries=entries,\n total=total,\n page=page,\n per_page=per_page,\n total_pages=total_pages,\n date_from=date_from,\n date_to=date_to,\n user_email=user_email,\n action_filter=action_filter,\n entity_type_filter=entity_type_filter,\n search_q=search_q,\n action_types=action_types,\n entity_types=entity_types,\n )\n\n\n# =============================================================================\n# APPLICATION REPORT BUILDER (PLT-025)\n# =============================================================================\n\n\n@admin_bp_v2.route(\"/reports\")\n@login_required\n@admin_required\ndef report_builder():\n \"\"\"PLT-025: Self-service parameterized report builder for applications.\"\"\"\n import logging\n\n logger = logging.getLogger(__name__)\n\n page = request.args.get(\"page\", 1, type=int)\n per_page = min(request.args.get(\"per_page\", 50, type=int), 200)\n lifecycle = request.args.get(\"lifecycle\", \"\").strip()\n vendor_filter = request.args.get(\"vendor\", \"\").strip()\n search_q = request.args.get(\"q\", \"\").strip()\n date_from = request.args.get(\"date_from\", \"\")\n date_to = request.args.get(\"date_to\", \"\")\n export_csv = request.args.get(\"export\") == \"csv\"\n\n apps = []\n total = 0\n lifecycle_options = []\n vendor_options = []\n\n try:\n from app.models.application_portfolio import ApplicationComponent\n\n query = ApplicationComponent.query\n\n if lifecycle:\n query = query.filter(ApplicationComponent.lifecycle_status == lifecycle)\n\n if vendor_filter:\n query = query.filter(ApplicationComponent.name.ilike(f\"%{vendor_filter}%\"))\n\n if search_q:\n query = query.filter(\n db.or_(\n ApplicationComponent.name.ilike(f\"%{search_q}%\"),\n ApplicationComponent.description.ilike(f\"%{search_q}%\"),\n )\n )\n\n if date_from:\n from datetime import datetime as dt\n try:\n query = query.filter(ApplicationComponent.created_at >= dt.fromisoformat(date_from))\n except ValueError:\n logger.debug(\"PLT-025: invalid date_from: %s\", date_from)\n\n if date_to:\n from datetime import datetime as dt\n try:\n to_dt = dt.fromisoformat(date_to).replace(hour=23, minute=59, second=59)\n query = query.filter(ApplicationComponent.created_at <= to_dt)\n except ValueError:\n logger.debug(\"PLT-025: invalid date_to: %s\", date_to)\n\n query = query.order_by(ApplicationComponent.name)\n\n # Get filter options\n lifecycle_options = [\n r[0] for r in db.session.query(ApplicationComponent.lifecycle_status).distinct().order_by(\n ApplicationComponent.lifecycle_status\n ).all() if r[0]\n ]\n vendor_options = [] # Populated from VendorOrganization if needed\n\n if export_csv:\n import csv\n import io\n\n rows = query.limit(10000).all()\n output = io.StringIO()\n writer = csv.writer(output)\n writer.writerow([\"id\", \"name\", \"description\", \"lifecycle_status\", \"technology_stack\", \"created_at\"])\n for row in rows:\n writer.writerow([\n row.id,\n row.name or \"\",\n (row.description or \"\")[:200],\n row.lifecycle_status or \"\",\n row.technology_stack or \"\",\n row.created_at.isoformat() if row.created_at else \"\",\n ])\n\n from flask import Response\n return Response(\n output.getvalue(),\n mimetype=\"text/csv\",\n headers={\"Content-Disposition\": \"attachment; filename=application_report.csv\"},\n )\n\n total = query.count()\n apps = query.offset((page - 1) * per_page).limit(per_page).all()\n\n except Exception as exc:\n logger.warning(\"PLT-025: Application query failed: %s\", exc)\n\n total_pages = (total + per_page - 1) // per_page if per_page else 1\n\n if request.accept_mimetypes.best == \"application/json\":\n return jsonify({\n \"applications\": [a.to_dict() if hasattr(a, 'to_dict') else {\"id\": a.id, \"name\": a.name} for a in apps],\n \"total\": total,\n \"page\": page,\n \"per_page\": per_page,\n \"total_pages\": total_pages,\n })\n\n return render_template(\n \"admin/report_builder.html\",\n apps=apps,\n total=total,\n page=page,\n per_page=per_page,\n total_pages=total_pages,\n lifecycle=lifecycle,\n vendor_filter=vendor_filter,\n search_q=search_q,\n date_from=date_from,\n date_to=date_to,\n lifecycle_options=lifecycle_options,\n )\n\n\n# =============================================================================\n# WEBHOOK SETTINGS (PLT-015)\n# =============================================================================\n\n\n@admin_bp_v2.route(\"/webhook-settings\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"update_webhook_settings\")\ndef webhook_settings():\n \"\"\"PLT-015: Manage Slack/Teams webhook subscriptions and notification settings.\"\"\"\n from app.models.webhook import WebhookSubscription\n\n VALID_EVENTS = [\n \"solution.created\",\n \"solution.updated\",\n \"solution.approved\",\n \"solution.rejected\",\n \"solution.archived\",\n \"application.created\",\n \"application.updated\",\n \"application.retired\",\n \"arb.submitted\",\n \"arb.decision\",\n \"risk.raised\",\n \"risk.resolved\",\n ]\n\n if request.method == \"POST\":\n action = request.form.get(\"action\", \"create\")\n\n if action == \"delete\":\n sub_id = request.form.get(\"subscription_id\", \"\").strip()\n if sub_id:\n sub = WebhookSubscription.query.get(sub_id)\n if sub:\n sub.is_active = False\n sub.updated_at = datetime.utcnow()\n db.session.commit()\n flash(\"Webhook subscription deleted.\", \"success\")\n else:\n flash(\"Subscription not found.\", \"error\")\n return redirect(url_for(\"admin.webhook_settings\"))\n\n url = request.form.get(\"url\", \"\").strip()\n description = request.form.get(\"description\", \"\").strip()\n webhook_type = request.form.get(\"webhook_type\", \"generic\").strip()\n secret = request.form.get(\"secret\", \"\").strip() or None\n selected_events = request.form.getlist(\"events\")\n\n if not url:\n flash(\"Webhook URL is required.\", \"error\")\n subscriptions = WebhookSubscription.query.filter_by(is_active=True).order_by(\n WebhookSubscription.created_at.desc()\n ).all()\n return render_template(\n \"admin/webhook_settings.html\",\n subscriptions=subscriptions,\n valid_events=VALID_EVENTS,\n )\n\n if webhook_type not in (\"generic\", \"teams\", \"slack\"):\n webhook_type = \"generic\"\n\n if not selected_events:\n selected_events = [\"*\"]\n\n import uuid as _uuid\n\n sub = WebhookSubscription(\n id=str(_uuid.uuid4()),\n user_id=str(current_user.id),\n url=url,\n description=description,\n webhook_type=webhook_type,\n events=selected_events,\n secret=secret,\n filters={},\n headers={},\n is_active=True,\n created_at=datetime.utcnow(),\n updated_at=datetime.utcnow(),\n )\n db.session.add(sub)\n db.session.commit()\n flash(\"Webhook subscription created successfully.\", \"success\")\n return redirect(url_for(\"admin.webhook_settings\"))\n\n subscriptions = WebhookSubscription.query.filter_by(is_active=True).order_by(\n WebhookSubscription.created_at.desc()\n ).all()\n return render_template(\n \"admin/webhook_settings.html\",\n subscriptions=subscriptions,\n valid_events=VALID_EVENTS,\n )\n\n\n@admin_bp_v2.route(\"/webhook-settings/test/<string:subscription_id>\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef webhook_settings_test(subscription_id: str):\n \"\"\"PLT-015: Send a test payload to a webhook subscription.\"\"\"\n from app.models.webhook import WebhookSubscription\n from app.services.webhook_service import WebhookService\n\n sub = WebhookSubscription.query.filter_by(id=subscription_id, is_active=True).first()\n if not sub:\n flash(\"Subscription not found.\", \"error\")\n return redirect(url_for(\"admin.webhook_settings\"))\n\n try:\n svc = WebhookService()\n result = svc.test_subscription(subscription_id, str(current_user.id))\n if result and result.get(\"success\"):\n flash(\"Test payload delivered successfully.\", \"success\")\n else:\n flash(\"Test delivery failed — check the webhook URL and try again.\", \"error\")\n except Exception as exc:\n logger.error(\"PLT-015: webhook test failed for %s: %s\", subscription_id, exc)\n flash(f\"Test delivery error: {exc}\", \"error\")\n\n return redirect(url_for(\"admin.webhook_settings\"))\n\n\n# =============================================================================\n# CONNECTION GAPS REPORT (PLT-010)\n# =============================================================================\n\n\n@admin_bp_v2.route(\"/connection-gaps\")\n@timed_route\n@login_required\n@admin_required\ndef connection_gaps():\n \"\"\"PLT-010: Portfolio-wide connection gaps report.\n\n Shows all solutions sorted by architecture completeness score (ascending),\n with per-junction breakdown, filters, and CSV export.\n \"\"\"\n import csv\n import io\n\n from flask import Response\n from app.models.solution_models import Solution\n\n page = request.args.get(\"page\", 1, type=int)\n per_page = min(request.args.get(\"per_page\", 50, type=int), 200)\n domain_filter = request.args.get(\"domain\", \"\").strip()\n type_filter = request.args.get(\"type\", \"\").strip()\n score_min = request.args.get(\"score_min\", None, type=int)\n score_max = request.args.get(\"score_max\", None, type=int)\n export_csv = request.args.get(\"export\") == \"csv\"\n\n # Junction category labels for display\n junction_labels = {\n \"drivers\": \"Drivers\",\n \"goals\": \"Goals\",\n \"constraints\": \"Constraints\",\n \"requirements\": \"Requirements\",\n \"applications\": \"Applications\",\n \"archimate_elements\": \"ArchiMate Elements\",\n \"vendor_products\": \"Vendor Products\",\n \"recommendations\": \"Recommendations\",\n \"risks\": \"Risks\",\n \"tco_items\": \"TCO Items\",\n \"metrics\": \"Metrics\",\n \"plateaus\": \"Plateaus\",\n \"apqc_processes\": \"APQC Processes\",\n \"capability_mappings\": \"Capability Mappings\",\n }\n\n solutions_data = []\n total = 0\n domain_options = []\n type_options = []\n aggregate_stats = {\n \"below_25\": 0,\n \"below_50\": 0,\n \"no_phase_a\": 0,\n \"total\": 0,\n \"avg_score\": 0,\n \"junction_gap_counts\": {},\n }\n\n try:\n query = Solution.query\n\n if domain_filter:\n query = query.filter(Solution.business_domain == domain_filter)\n if type_filter:\n query = query.filter(Solution.solution_type == type_filter)\n\n # Get filter options from all solutions\n domain_options = [\n r[0] for r in db.session.query(Solution.business_domain)\n .distinct()\n .order_by(Solution.business_domain)\n .all()\n if r[0]\n ]\n type_options = [\n r[0] for r in db.session.query(Solution.solution_type)\n .distinct()\n .order_by(Solution.solution_type)\n .all()\n if r[0]\n ]\n\n # Fetch all matching solutions and compute scores\n all_solutions = query.all()\n scored = []\n gap_counts = {k: 0 for k in junction_labels}\n\n for sol in all_solutions:\n try:\n comp = sol.architecture_completeness_score\n except Exception:\n comp = {\"score\": 0, \"filled\": [], \"missing\": list(junction_labels.keys()),\n \"filled_count\": 0, \"total\": 14}\n\n score = comp.get(\"score\", 0)\n\n # Apply score range filter\n if score_min is not None and score < score_min:\n continue\n if score_max is not None and score > score_max:\n continue\n\n missing_set = set(comp.get(\"missing\", []))\n filled_set = set(comp.get(\"filled\", []))\n\n # Track aggregate gap counts\n for junction_key in junction_labels:\n if junction_key in missing_set:\n gap_counts[junction_key] = gap_counts.get(junction_key, 0) + 1\n\n scored.append({\n \"id\": sol.id,\n \"name\": sol.name or \"(Unnamed)\",\n \"business_domain\": sol.business_domain or \"\",\n \"solution_type\": sol.solution_type or \"\",\n \"score\": score,\n \"filled\": filled_set,\n \"missing\": missing_set,\n \"filled_count\": comp.get(\"filled_count\", 0),\n \"total_junctions\": comp.get(\"total\", 14),\n })\n\n # Sort by score ascending (worst first)\n scored.sort(key=lambda s: s[\"score\"])\n\n total = len(scored)\n\n # Aggregate stats\n aggregate_stats[\"total\"] = total\n aggregate_stats[\"below_25\"] = sum(1 for s in scored if s[\"score\"] < 25)\n aggregate_stats[\"below_50\"] = sum(1 for s in scored if s[\"score\"] < 50)\n phase_a_junctions = {\"drivers\", \"goals\", \"constraints\"}\n aggregate_stats[\"no_phase_a\"] = sum(\n 1 for s in scored\n if not any(j in s[\"filled\"] for j in phase_a_junctions)\n )\n aggregate_stats[\"avg_score\"] = (\n round(sum(s[\"score\"] for s in scored) / total) if total > 0 else 0\n )\n aggregate_stats[\"junction_gap_counts\"] = gap_counts\n\n # CSV export (before pagination)\n if export_csv:\n output = io.StringIO()\n writer = csv.writer(output)\n header = [\"Solution\", \"Domain\", \"Type\", \"Score (%)\"]\n header.extend(junction_labels.values())\n writer.writerow(header)\n for s in scored[:10000]:\n row = [s[\"name\"], s[\"business_domain\"], s[\"solution_type\"], s[\"score\"]]\n for jk in junction_labels:\n row.append(\"Yes\" if jk in s[\"filled\"] else \"No\")\n writer.writerow(row)\n return Response(\n output.getvalue(),\n mimetype=\"text/csv\",\n headers={\"Content-Disposition\": \"attachment; filename=connection_gaps_report.csv\"},\n )\n\n # Paginate\n start = (page - 1) * per_page\n solutions_data = scored[start:start + per_page]\n\n except Exception as exc:\n logger.warning(\"PLT-010: Connection gaps query failed: %s\", exc)\n\n total_pages = (total + per_page - 1) // per_page if per_page else 1\n\n return render_template(\n \"admin/connection_gaps.html\",\n solutions=solutions_data,\n total=total,\n page=page,\n per_page=per_page,\n total_pages=total_pages,\n domain_filter=domain_filter,\n type_filter=type_filter,\n score_min=score_min if score_min is not None else \"\",\n score_max=score_max if score_max is not None else \"\",\n domain_options=domain_options,\n type_options=type_options,\n junction_labels=junction_labels,\n aggregate_stats=aggregate_stats,\n )\n\n\n# ============================================================================\n# PLT-024 / PLT-034: Portfolio Export (PDF print page + PPTX download)\n# ============================================================================\n\n\ndef _get_portfolio_export_data() -> dict:\n \"\"\"Gather portfolio data for PDF and PPTX exports.\n\n Returns a dict with solutions_by_status, top10, bottom10, junction_coverage,\n total_solutions, and avg_completeness.\n \"\"\"\n from app.models.solution_models import Solution\n\n solutions = Solution.query.limit(500).all()\n total = len(solutions)\n if total == 0:\n return {\n \"total_solutions\": 0,\n \"avg_completeness\": 0,\n \"solutions_by_status\": {},\n \"top10\": [],\n \"bottom10\": [],\n \"junction_coverage\": {},\n \"generated_at\": datetime.utcnow().strftime(\"%Y-%m-%d %H:%M UTC\"),\n }\n\n scored = []\n for sol in solutions:\n try:\n cs = sol.architecture_completeness_score\n score = cs[\"score\"]\n filled = cs[\"filled\"]\n except Exception:\n score = 0\n filled = []\n scored.append({\n \"name\": sol.name or f\"Solution #{sol.id}\",\n \"governance_status\": sol.governance_status or \"draft\",\n \"score\": score,\n \"filled\": filled,\n })\n\n avg_completeness = round(sum(s[\"score\"] for s in scored) / total) if total else 0\n\n # Solutions by governance status\n status_counts = {}\n for s in scored:\n st = s[\"governance_status\"]\n status_counts[st] = status_counts.get(st, 0) + 1\n\n sorted_desc = sorted(scored, key=lambda s: s[\"score\"], reverse=True)\n sorted_asc = sorted(scored, key=lambda s: s[\"score\"])\n\n junction_names = [\n \"drivers\", \"goals\", \"constraints\", \"requirements\",\n \"applications\", \"archimate_elements\", \"vendor_products\",\n \"recommendations\", \"risks\", \"tco_items\", \"metrics\",\n \"plateaus\", \"apqc_processes\", \"capability_mappings\",\n ]\n junction_coverage = {}\n for jname in junction_names:\n with_count = sum(1 for s in scored if jname in s[\"filled\"])\n junction_coverage[jname] = {\n \"with\": with_count,\n \"without\": total - with_count,\n }\n\n return {\n \"total_solutions\": total,\n \"avg_completeness\": avg_completeness,\n \"solutions_by_status\": status_counts,\n \"top10\": [{\"name\": s[\"name\"], \"score\": s[\"score\"]} for s in sorted_desc[:10]],\n \"bottom10\": [{\"name\": s[\"name\"], \"score\": s[\"score\"]} for s in sorted_asc[:10]],\n \"junction_coverage\": junction_coverage,\n \"generated_at\": datetime.utcnow().strftime(\"%Y-%m-%d %H:%M UTC\"),\n }\n\n\n@admin_bp_v2.route(\"/export-portfolio-pdf\")\n@timed_route\n@login_required\n@admin_required\ndef export_portfolio_pdf():\n \"\"\"PLT-024: Render a print-optimised HTML page for board-ready portfolio PDF.\n\n Uses window.print() with @media print CSS -- no new dependencies.\n \"\"\"\n try:\n data = _get_portfolio_export_data()\n except Exception as exc:\n logger.warning(\"PLT-024: Portfolio PDF data gathering failed: %s\", exc)\n data = {\n \"total_solutions\": 0,\n \"avg_completeness\": 0,\n \"solutions_by_status\": {},\n \"top10\": [],\n \"bottom10\": [],\n \"junction_coverage\": {},\n \"generated_at\": datetime.utcnow().strftime(\"%Y-%m-%d %H:%M UTC\"),\n }\n\n # Render inline HTML (no new template per constraint)\n html_parts = []\n html_parts.append(\"\"\"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Architecture Portfolio Summary</title>\n<style>\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n margin: 2rem; color: #1a1a2e; }\nh1 { font-size: 1.75rem; margin-bottom: 0.25rem; }\nh2 { font-size: 1.2rem; margin-top: 2rem; border-bottom: 2px solid #3b82f6;\n padding-bottom: 0.25rem; color: #1e3a5f; }\n.subtitle { color: #6b7280; font-size: 0.9rem; margin-bottom: 1.5rem; }\n.kpi-row { display: flex; gap: 2rem; margin-bottom: 1.5rem; }\n.kpi { text-align: center; }\n.kpi .value { font-size: 2rem; font-weight: 700; }\n.kpi .label { font-size: 0.8rem; color: #6b7280; }\ntable { width: 100%; border-collapse: collapse; margin-top: 0.5rem; font-size: 0.85rem; }\nth, td { border: 1px solid #d1d5db; padding: 0.4rem 0.75rem; text-align: left; }\nth { background: #f1f5f9; font-weight: 600; }\ntr:nth-child(even) { background: #f9fafb; }\n.confidential { color: #9ca3af; font-size: 0.7rem; text-align: center;\n margin-top: 2rem; border-top: 1px solid #e5e7eb; padding-top: 0.5rem; }\n.print-btn { background: #3b82f6; color: #fff; border: none; padding: 0.5rem 1.5rem;\n border-radius: 0.375rem; font-size: 0.9rem; cursor: pointer; margin-bottom: 1rem; }\n.print-btn:hover { background: #2563eb; }\n.back-link { margin-right: 1rem; color: #3b82f6; text-decoration: none; font-size: 0.9rem; }\n@media print {\n .no-print { display: none !important; }\n body { margin: 0; font-size: 11pt; }\n h1 { font-size: 16pt; }\n h2 { font-size: 13pt; }\n}\n</style>\n</head>\n<body>\n<div class=\"no-print\" style=\"margin-bottom:1rem;\">\n <a href=\"/admin\" class=\"back-link\">&larr; Back to Admin</a>\n <button class=\"print-btn\" onclick=\"window.print()\">Print / Save as PDF</button>\n</div>\n\"\"\")\n\n html_parts.append(f\"\"\"<h1>Architecture Portfolio Summary</h1>\n<p class=\"subtitle\">Generated {data['generated_at']} &mdash; CONFIDENTIAL</p>\n\n<div class=\"kpi-row\">\n <div class=\"kpi\"><div class=\"value\">{data['total_solutions']}</div><div class=\"label\">Total Solutions</div></div>\n <div class=\"kpi\"><div class=\"value\">{data['avg_completeness']}%</div><div class=\"label\">Avg Completeness</div></div>\n <div class=\"kpi\"><div class=\"value\">{len(data['solutions_by_status'])}</div><div class=\"label\">Status Categories</div></div>\n</div>\n\"\"\")\n\n # Solutions by status table\n html_parts.append(\"<h2>Solutions by Governance Status</h2><table><tr><th>Status</th><th>Count</th></tr>\")\n if data[\"solutions_by_status\"]:\n for status, count in sorted(data[\"solutions_by_status\"].items()):\n label = status.replace(\"_\", \" \").title()\n html_parts.append(f\"<tr><td>{label}</td><td>{count}</td></tr>\")\n else:\n html_parts.append(\"<tr><td colspan='2'>No data available</td></tr>\")\n html_parts.append(\"</table>\")\n\n # Top 10\n html_parts.append(\"<h2>Top 10 Highest Completeness</h2><table><tr><th>#</th><th>Solution</th><th>Score</th></tr>\")\n if data[\"top10\"]:\n for i, s in enumerate(data[\"top10\"], 1):\n html_parts.append(f\"<tr><td>{i}</td><td>{s['name']}</td><td>{s['score']}%</td></tr>\")\n else:\n html_parts.append(\"<tr><td colspan='3'>No data available</td></tr>\")\n html_parts.append(\"</table>\")\n\n # Bottom 10\n html_parts.append(\"<h2>Top 10 Gaps (Lowest Completeness)</h2><table><tr><th>#</th><th>Solution</th><th>Score</th></tr>\")\n if data[\"bottom10\"]:\n for i, s in enumerate(data[\"bottom10\"], 1):\n html_parts.append(f\"<tr><td>{i}</td><td>{s['name']}</td><td>{s['score']}%</td></tr>\")\n else:\n html_parts.append(\"<tr><td colspan='3'>No data available</td></tr>\")\n html_parts.append(\"</table>\")\n\n # Junction coverage\n html_parts.append(\"<h2>Junction Coverage</h2><table><tr><th>Junction Type</th><th>With Data</th><th>Without Data</th></tr>\")\n if data[\"junction_coverage\"]:\n for jname, jdata in data[\"junction_coverage\"].items():\n label = jname.replace(\"_\", \" \").title()\n html_parts.append(f\"<tr><td>{label}</td><td>{jdata['with']}</td><td>{jdata['without']}</td></tr>\")\n else:\n html_parts.append(\"<tr><td colspan='3'>No data available</td></tr>\")\n html_parts.append(\"</table>\")\n\n html_parts.append('<p class=\"confidential\">CONFIDENTIAL &mdash; For authorised recipients only</p>')\n html_parts.append(\"</body></html>\")\n\n return \"\".join(html_parts)\n\n\n@admin_bp_v2.route(\"/export-portfolio-pptx\")\n@timed_route\n@login_required\n@admin_required\ndef export_portfolio_pptx():\n \"\"\"PLT-034: Generate a PowerPoint deck for portfolio summary.\n\n 5 slides: Title, Key Metrics, Top 10, Bottom 10, Junction Coverage.\n \"\"\"\n import io\n\n try:\n from pptx import Presentation\n from pptx.util import Inches, Pt, Emu\n from pptx.dml.color import RGBColor\n from pptx.enum.text import PP_ALIGN\n except ImportError:\n logger.error(\"PLT-034: python-pptx not available\")\n return jsonify({\"error\": \"PowerPoint export unavailable — python-pptx not installed\"}), 500\n\n try:\n data = _get_portfolio_export_data()\n except Exception as exc:\n logger.warning(\"PLT-034: Portfolio PPTX data gathering failed: %s\", exc)\n data = {\n \"total_solutions\": 0,\n \"avg_completeness\": 0,\n \"solutions_by_status\": {},\n \"top10\": [],\n \"bottom10\": [],\n \"junction_coverage\": {},\n \"generated_at\": datetime.utcnow().strftime(\"%Y-%m-%d %H:%M UTC\"),\n }\n\n BRAND_BLUE = RGBColor(0x1E, 0x3A, 0x5F)\n ACCENT_BLUE = RGBColor(0x3B, 0x82, 0xF6)\n WHITE = RGBColor(0xFF, 0xFF, 0xFF)\n DARK = RGBColor(0x1A, 0x1A, 0x2E)\n GRAY = RGBColor(0x6B, 0x72, 0x80)\n HEADER_BG = RGBColor(0xF1, 0xF5, 0xF9)\n\n prs = Presentation()\n prs.slide_width = Inches(13.333)\n prs.slide_height = Inches(7.5)\n\n def _add_title_footer(slide, footer_text=\"CONFIDENTIAL\"):\n \"\"\"Add a small footer to a slide.\"\"\"\n left = Inches(0.5)\n top = Inches(7.0)\n width = Inches(12)\n height = Inches(0.4)\n txBox = slide.shapes.add_textbox(left, top, width, height)\n tf = txBox.text_frame\n p = tf.paragraphs[0]\n p.text = footer_text\n p.font.size = Pt(8)\n p.font.color.rgb = GRAY\n p.alignment = PP_ALIGN.CENTER\n\n def _set_cell(table, row, col, text, bold=False, font_size=11, color=None, alignment=None):\n \"\"\"Set a table cell's text and formatting.\"\"\"\n cell = table.cell(row, col)\n cell.text = str(text)\n for paragraph in cell.text_frame.paragraphs:\n paragraph.font.size = Pt(font_size)\n paragraph.font.bold = bold\n if color:\n paragraph.font.color.rgb = color\n if alignment:\n paragraph.alignment = alignment\n\n # ------------------------------------------------------------------\n # Slide 1: Title\n # ------------------------------------------------------------------\n slide_layout = prs.slide_layouts[6] # blank layout\n slide1 = prs.slides.add_slide(slide_layout)\n\n # Title text box\n left = Inches(1)\n top = Inches(2.5)\n txBox = slide1.shapes.add_textbox(left, top, Inches(11), Inches(1.5))\n tf = txBox.text_frame\n p = tf.paragraphs[0]\n p.text = \"Architecture Portfolio Summary\"\n p.font.size = Pt(36)\n p.font.bold = True\n p.font.color.rgb = BRAND_BLUE\n p.alignment = PP_ALIGN.CENTER\n\n # Subtitle / date\n p2 = tf.add_paragraph()\n p2.text = data[\"generated_at\"]\n p2.font.size = Pt(16)\n p2.font.color.rgb = GRAY\n p2.alignment = PP_ALIGN.CENTER\n\n _add_title_footer(slide1)\n\n # ------------------------------------------------------------------\n # Slide 2: Key Metrics + Solutions by Status\n # ------------------------------------------------------------------\n slide2 = prs.slides.add_slide(slide_layout)\n txBox = slide2.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(12), Inches(0.7))\n tf = txBox.text_frame\n p = tf.paragraphs[0]\n p.text = \"Key Metrics\"\n p.font.size = Pt(28)\n p.font.bold = True\n p.font.color.rgb = BRAND_BLUE\n\n # KPI row\n kpis = [\n (\"Total Solutions\", str(data[\"total_solutions\"])),\n (\"Avg Completeness\", f\"{data['avg_completeness']}%\"),\n ]\n for i, (label, value) in enumerate(kpis):\n left = Inches(1 + i * 4)\n txBox = slide2.shapes.add_textbox(left, Inches(1.3), Inches(3.5), Inches(1))\n tf = txBox.text_frame\n p = tf.paragraphs[0]\n p.text = value\n p.font.size = Pt(40)\n p.font.bold = True\n p.font.color.rgb = ACCENT_BLUE\n p.alignment = PP_ALIGN.CENTER\n p2 = tf.add_paragraph()\n p2.text = label\n p2.font.size = Pt(14)\n p2.font.color.rgb = GRAY\n p2.alignment = PP_ALIGN.CENTER\n\n # Status table\n status_items = sorted(data[\"solutions_by_status\"].items()) if data[\"solutions_by_status\"] else [(\"No data\", 0)]\n rows = len(status_items) + 1\n tbl = slide2.shapes.add_table(rows, 2, Inches(1), Inches(3.2), Inches(6), Inches(0.4 * rows)).table\n tbl.columns[0].width = Inches(4)\n tbl.columns[1].width = Inches(2)\n _set_cell(tbl, 0, 0, \"Governance Status\", bold=True, font_size=12)\n _set_cell(tbl, 0, 1, \"Count\", bold=True, font_size=12)\n for i, (status, count) in enumerate(status_items, 1):\n label = status.replace(\"_\", \" \").title() if isinstance(status, str) else str(status)\n _set_cell(tbl, i, 0, label, font_size=11)\n _set_cell(tbl, i, 1, str(count), font_size=11)\n\n _add_title_footer(slide2)\n\n # ------------------------------------------------------------------\n # Slide 3: Top 10 Highest Completeness\n # ------------------------------------------------------------------\n slide3 = prs.slides.add_slide(slide_layout)\n txBox = slide3.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(12), Inches(0.7))\n tf = txBox.text_frame\n p = tf.paragraphs[0]\n p.text = \"Top 10 — Highest Completeness\"\n p.font.size = Pt(28)\n p.font.bold = True\n p.font.color.rgb = BRAND_BLUE\n\n top_items = data[\"top10\"] if data[\"top10\"] else [{\"name\": \"No data available\", \"score\": \"-\"}]\n rows = len(top_items) + 1\n tbl = slide3.shapes.add_table(rows, 3, Inches(1), Inches(1.3), Inches(10), Inches(0.4 * rows)).table\n tbl.columns[0].width = Inches(1)\n tbl.columns[1].width = Inches(7)\n tbl.columns[2].width = Inches(2)\n _set_cell(tbl, 0, 0, \"#\", bold=True, font_size=12)\n _set_cell(tbl, 0, 1, \"Solution\", bold=True, font_size=12)\n _set_cell(tbl, 0, 2, \"Score\", bold=True, font_size=12)\n for i, s in enumerate(top_items, 1):\n _set_cell(tbl, i, 0, str(i), font_size=11)\n _set_cell(tbl, i, 1, s[\"name\"], font_size=11)\n score_text = f\"{s['score']}%\" if isinstance(s[\"score\"], (int, float)) else str(s[\"score\"])\n _set_cell(tbl, i, 2, score_text, font_size=11)\n\n _add_title_footer(slide3)\n\n # ------------------------------------------------------------------\n # Slide 4: Top 10 Gaps (Lowest Completeness)\n # ------------------------------------------------------------------\n slide4 = prs.slides.add_slide(slide_layout)\n txBox = slide4.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(12), Inches(0.7))\n tf = txBox.text_frame\n p = tf.paragraphs[0]\n p.text = \"Top 10 Gaps — Lowest Completeness\"\n p.font.size = Pt(28)\n p.font.bold = True\n p.font.color.rgb = BRAND_BLUE\n\n bottom_items = data[\"bottom10\"] if data[\"bottom10\"] else [{\"name\": \"No data available\", \"score\": \"-\"}]\n rows = len(bottom_items) + 1\n tbl = slide4.shapes.add_table(rows, 3, Inches(1), Inches(1.3), Inches(10), Inches(0.4 * rows)).table\n tbl.columns[0].width = Inches(1)\n tbl.columns[1].width = Inches(7)\n tbl.columns[2].width = Inches(2)\n _set_cell(tbl, 0, 0, \"#\", bold=True, font_size=12)\n _set_cell(tbl, 0, 1, \"Solution\", bold=True, font_size=12)\n _set_cell(tbl, 0, 2, \"Score\", bold=True, font_size=12)\n for i, s in enumerate(bottom_items, 1):\n _set_cell(tbl, i, 0, str(i), font_size=11)\n _set_cell(tbl, i, 1, s[\"name\"], font_size=11)\n score_text = f\"{s['score']}%\" if isinstance(s[\"score\"], (int, float)) else str(s[\"score\"])\n _set_cell(tbl, i, 2, score_text, font_size=11)\n\n _add_title_footer(slide4)\n\n # ------------------------------------------------------------------\n # Slide 5: Junction Coverage\n # ------------------------------------------------------------------\n slide5 = prs.slides.add_slide(slide_layout)\n txBox = slide5.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(12), Inches(0.7))\n tf = txBox.text_frame\n p = tf.paragraphs[0]\n p.text = \"Junction Coverage (14 Types)\"\n p.font.size = Pt(28)\n p.font.bold = True\n p.font.color.rgb = BRAND_BLUE\n\n jc = data[\"junction_coverage\"]\n jc_items = list(jc.items()) if jc else [(\"No data\", {\"with\": 0, \"without\": 0})]\n rows = len(jc_items) + 1\n tbl = slide5.shapes.add_table(rows, 3, Inches(1), Inches(1.3), Inches(10), Inches(0.35 * rows)).table\n tbl.columns[0].width = Inches(5)\n tbl.columns[1].width = Inches(2.5)\n tbl.columns[2].width = Inches(2.5)\n _set_cell(tbl, 0, 0, \"Junction Type\", bold=True, font_size=12)\n _set_cell(tbl, 0, 1, \"Solutions With\", bold=True, font_size=12)\n _set_cell(tbl, 0, 2, \"Solutions Without\", bold=True, font_size=12)\n for i, (jname, jdata) in enumerate(jc_items, 1):\n label = jname.replace(\"_\", \" \").title()\n _set_cell(tbl, i, 0, label, font_size=11)\n _set_cell(tbl, i, 1, str(jdata[\"with\"]), font_size=11)\n _set_cell(tbl, i, 2, str(jdata[\"without\"]), font_size=11)\n\n _add_title_footer(slide5)\n\n # ------------------------------------------------------------------\n # Serialize and return\n # ------------------------------------------------------------------\n buf = io.BytesIO()\n prs.save(buf)\n buf.seek(0)\n\n from flask import send_file\n filename = f\"portfolio_summary_{datetime.utcnow().strftime('%Y%m%d')}.pptx\"\n return send_file(\n buf,\n mimetype=\"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n as_attachment=True,\n download_name=filename,\n )\n\n\n# ---------------------------------------------------------------------------\n# AI Confidence Calibration (ARC-I02)\n# ---------------------------------------------------------------------------\n@admin_bp_v2.route(\"/ai-confidence\")\n@login_required\n@admin_required\n@timed_route\ndef ai_confidence_calibration():\n \"\"\"AI confidence calibration dashboard — tracks whether AI confidence\n labels match actual architect acceptance rates.\"\"\"\n from app.models.ai_suggestion import AISuggestion\n from sqlalchemy import func, case\n\n results = db.session.query(\n case(\n (AISuggestion.confidence >= 0.8, 'High'),\n (AISuggestion.confidence >= 0.5, 'Medium'),\n else_='Low'\n ).label('tier'),\n func.count().label('total'),\n func.sum(case((AISuggestion.architect_verdict == 'accepted', 1), else_=0)).label('accepted'),\n func.sum(case((AISuggestion.architect_verdict == 'modified', 1), else_=0)).label('modified'),\n func.sum(case((AISuggestion.architect_verdict == 'rejected', 1), else_=0)).label('rejected'),\n func.sum(case((AISuggestion.architect_verdict.is_(None), 1), else_=0)).label('no_verdict'),\n ).group_by('tier').all()\n\n # Ensure all three tiers are present, even with zero rows\n tier_order = ['High', 'Medium', 'Low']\n tier_map = {}\n for row in results:\n accepted = row.accepted or 0\n modified = row.modified or 0\n rejected = row.rejected or 0\n no_verdict = row.no_verdict or 0\n total_with_verdict = accepted + modified + rejected\n rate = round(accepted / total_with_verdict * 100) if total_with_verdict > 0 else 0\n tier_map[row.tier] = {\n 'name': row.tier,\n 'total': row.total,\n 'accepted': accepted,\n 'modified': modified,\n 'rejected': rejected,\n 'no_verdict': no_verdict,\n 'acceptance_rate': rate,\n }\n\n calibration_data = []\n for tier_name in tier_order:\n if tier_name in tier_map:\n calibration_data.append(tier_map[tier_name])\n else:\n calibration_data.append({\n 'name': tier_name,\n 'total': 0,\n 'accepted': 0,\n 'modified': 0,\n 'rejected': 0,\n 'no_verdict': 0,\n 'acceptance_rate': 0,\n })\n\n return render_template(\n 'admin/ai_confidence_calibration.html',\n calibration_data=calibration_data,\n )\n\n\n# ============================================================================\n# Solution AI Prompt Management\n# ============================================================================\n\n# Registry of default prompts — keys match class attributes on SolutionAIOrchestrator\n_SOLUTION_PROMPT_DEFAULTS = None\n\n\ndef _get_prompt_defaults():\n \"\"\"Lazy-load default prompts from the orchestrator class attributes.\"\"\"\n global _SOLUTION_PROMPT_DEFAULTS\n if _SOLUTION_PROMPT_DEFAULTS is not None:\n return _SOLUTION_PROMPT_DEFAULTS\n\n from app.modules.solutions_strategic.v2.services.solution_ai_orchestrator import (\n SolutionAIOrchestrator,\n )\n\n _SOLUTION_PROMPT_DEFAULTS = {\n \"draft_architecture\": {\n \"name\": \"Draft Architecture (Phase A)\",\n \"description\": \"Generates the complete TOGAF ADM Motivation Layer with all ArchiMate 3.2 element types — stakeholders, drivers, assessments, goals, outcomes, principles, requirements, constraints, values, and risks.\",\n \"prompt_text\": SolutionAIOrchestrator.DRAFT_ARCHITECTURE_PROMPT,\n \"category\": \"Phase A\",\n \"variables\": \"solution_name, solution_type, business_domain, complexity, adm_phase, problem_statement, current_state, budget_range, timeline_months, compliance_needs, key_stakeholders, industry_context, technology_preferences, org_context\",\n },\n \"architecture_variants\": {\n \"name\": \"Architecture Variants\",\n \"description\": \"Produces 3 alternative architecture options: cost-optimized, timeline-optimized, and risk-balanced, each with entities and trade-offs.\",\n \"prompt_text\": SolutionAIOrchestrator.ARCHITECTURE_VARIANTS_PROMPT,\n \"category\": \"Phase A\",\n \"variables\": \"solution_name, business_domain, problem_statement\",\n },\n \"strategy_specialist\": {\n \"name\": \"Strategy Layer (Phase B)\",\n \"description\": \"ArchiMate Strategy specialist — generates courses of action, value streams, capability gap analysis, and resources from motivation layer and selected capabilities.\",\n \"prompt_text\": SolutionAIOrchestrator.STRATEGY_SPECIALIST_PROMPT,\n \"category\": \"Phase B\",\n \"variables\": \"solution_name, business_domain, problem_statement, stakeholders_json, drivers_json, assessments_json, goals_json, outcomes_json, principles_json, requirements_json, constraints_json, values_json, capability_count, capabilities_json, nfr_checklist\",\n },\n \"business_specialist\": {\n \"name\": \"Business Layer (Phase C)\",\n \"description\": \"ArchiMate Business specialist — generates business actors, processes, services, roles, objects, and events from strategy and motivation layers.\",\n \"prompt_text\": SolutionAIOrchestrator.BUSINESS_SPECIALIST_PROMPT,\n \"category\": \"Phase C\",\n \"variables\": \"solution_name, business_domain, stakeholders_json, drivers_json, goals_json, principles_json, constraints_json, capabilities_json, courses_of_action_json, existing_business_json, nfr_checklist, advanced_business_schema\",\n },\n \"application_specialist\": {\n \"name\": \"Application Layer (Phase C)\",\n \"description\": \"ArchiMate Application specialist — generates application components (matching existing portfolio apps), services, data objects, and interfaces.\",\n \"prompt_text\": SolutionAIOrchestrator.APPLICATION_SPECIALIST_PROMPT,\n \"category\": \"Phase C\",\n \"variables\": \"solution_name, business_domain, requirements_json, principles_json, constraints_json, capabilities_json, business_services_json, app_count, existing_apps_json, nfr_checklist\",\n },\n \"technology_specialist\": {\n \"name\": \"Technology Layer (Phase D)\",\n \"description\": \"ArchiMate Technology specialist — generates nodes, system software, technology services, networks, and deployment artifacts.\",\n \"prompt_text\": SolutionAIOrchestrator.TECHNOLOGY_SPECIALIST_PROMPT,\n \"category\": \"Phase D\",\n \"variables\": \"solution_name, business_domain, tech_preferences, constraints_json, principles_json, app_components_json, existing_infra_json, nfr_checklist\",\n },\n \"implementation_specialist\": {\n \"name\": \"Implementation & Migration (Phase F)\",\n \"description\": \"TOGAF Phase F Migration Planning — generates plateaus, gaps, work packages, deliverables, and implementation milestones.\",\n \"prompt_text\": SolutionAIOrchestrator.IMPLEMENTATION_SPECIALIST_PROMPT,\n \"category\": \"Phase F\",\n \"variables\": \"solution_name, business_domain, timeline_constraint, budget_constraint, goals_json, stakeholders_json, principles_json, gaps_json, capabilities_json, plateaus_json, nfr_checklist\",\n },\n \"capability_suggestion\": {\n \"name\": \"Capability Suggestions (Step 2)\",\n \"description\": \"Maps business problems to APQC capabilities — suggests which capabilities from the catalog a solution should address.\",\n \"prompt_text\": _get_capability_suggestion_default(),\n \"category\": \"Step 2\",\n \"variables\": \"solution_description, solution_type, business_domain, capability_catalog\",\n },\n }\n return _SOLUTION_PROMPT_DEFAULTS\n\n\ndef _get_capability_suggestion_default():\n \"\"\"Get the default capability suggestion prompt from SolutionAIService.\"\"\"\n try:\n from app.modules.ai_chat.services.solution_ai_service import SolutionAIService\n return SolutionAIService.CAPABILITY_SUGGESTION_PROMPT\n except Exception:\n return \"(Could not load default prompt)\"\n\n\ndef _override_key(prompt_key):\n return f\"solution_prompt_{prompt_key}\"\n\n\n@admin_bp_v2.route(\"/solution-prompts\")\n@timed_route\n@login_required\n@admin_required\ndef solution_prompts_page():\n \"\"\"Render the solution AI prompt management page.\"\"\"\n return render_template(\"admin/solution_prompts.html\")\n\n\n@admin_bp_v2.route(\"/solution-prompts/data\")\n@login_required\n@admin_required\ndef solution_prompts_data():\n \"\"\"JSON API: return all solution prompt configs merged with DB overrides.\"\"\"\n defaults = _get_prompt_defaults()\n prompts = []\n\n for key, config in defaults.items():\n override_name = _override_key(key)\n override = AIPromptTemplate.query.filter_by(name=override_name).first()\n\n prompts.append({\n \"key\": key,\n \"name\": config[\"name\"],\n \"description\": config[\"description\"],\n \"category\": config[\"category\"],\n \"variables\": config[\"variables\"],\n \"default_prompt\": config[\"prompt_text\"],\n \"current_prompt\": override.system_prompt if override else config[\"prompt_text\"],\n \"has_override\": override is not None,\n })\n\n return jsonify({\"prompts\": prompts})\n\n\n@admin_bp_v2.route(\"/solution-prompts/<prompt_key>/update\", methods=[\"POST\"])\n@login_required\n@admin_required\n@audit_log(\"update_solution_prompt\")\ndef solution_prompt_update(prompt_key):\n \"\"\"Save a custom override for a solution prompt.\"\"\"\n defaults = _get_prompt_defaults()\n if prompt_key not in defaults:\n return jsonify({\"error\": f\"Unknown prompt: {prompt_key}\"}), 404\n\n payload = request.get_json(silent=True) or {}\n prompt_text = (payload.get(\"prompt_text\") or \"\").strip()\n\n if not prompt_text:\n return jsonify({\"error\": \"Prompt text cannot be empty\"}), 400\n\n override_name = _override_key(prompt_key)\n override = AIPromptTemplate.query.filter_by(name=override_name).first()\n\n if not override:\n override = AIPromptTemplate(\n name=override_name,\n description=defaults[prompt_key][\"description\"],\n system_prompt=prompt_text,\n user_prompt_template=\"\",\n category=\"solution_prompt\",\n )\n db.session.add(override)\n else:\n override.system_prompt = prompt_text\n override.updated_at = datetime.utcnow()\n\n try:\n db.session.commit()\n logger.info(\"Solution prompt override saved for %s by user %s\", prompt_key, current_user.id)\n except Exception:\n db.session.rollback()\n logger.exception(\"Failed to save solution prompt override for %s\", prompt_key)\n return jsonify({\"error\": \"Database error saving override\"}), 500\n\n config = defaults[prompt_key]\n return jsonify({\n \"success\": True,\n \"prompt\": {\n \"key\": prompt_key,\n \"name\": config[\"name\"],\n \"description\": config[\"description\"],\n \"category\": config[\"category\"],\n \"variables\": config[\"variables\"],\n \"default_prompt\": config[\"prompt_text\"],\n \"current_prompt\": override.system_prompt,\n \"has_override\": True,\n },\n })\n\n\n@admin_bp_v2.route(\"/solution-prompts/<prompt_key>/reset\", methods=[\"POST\"])\n@login_required\n@admin_required\n@audit_log(\"reset_solution_prompt\")\ndef solution_prompt_reset(prompt_key):\n \"\"\"Remove custom override, reverting to hardcoded default.\"\"\"\n defaults = _get_prompt_defaults()\n if prompt_key not in defaults:\n return jsonify({\"error\": f\"Unknown prompt: {prompt_key}\"}), 404\n\n override_name = _override_key(prompt_key)\n override = AIPromptTemplate.query.filter_by(name=override_name).first()\n\n if override:\n try:\n db.session.delete(override)\n db.session.commit()\n logger.info(\"Solution prompt override reset for %s by user %s\", prompt_key, current_user.id)\n except Exception:\n db.session.rollback()\n logger.exception(\"Failed to reset solution prompt for %s\", prompt_key)\n return jsonify({\"error\": \"Database error resetting prompt\"}), 500\n\n config = defaults[prompt_key]\n return jsonify({\n \"success\": True,\n \"prompt\": {\n \"key\": prompt_key,\n \"name\": config[\"name\"],\n \"description\": config[\"description\"],\n \"category\": config[\"category\"],\n \"variables\": config[\"variables\"],\n \"default_prompt\": config[\"prompt_text\"],\n \"current_prompt\": config[\"prompt_text\"],\n \"has_override\": False,\n },\n })\n\n\n# ─── Vendor Pricing Import ──────────────────────────────────────────────\n@admin_bp_v2.route(\"/vendor-pricing/import\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef vendor_pricing_import():\n \"\"\"Admin page for importing vendor pricing from CSV/Excel or contracts.\"\"\"\n staging_items = []\n upload_error = None\n\n if request.method == \"POST\":\n file = request.files.get(\"file\")\n if not file or file.filename == \"\":\n flash(\"No file selected\", \"error\")\n return render_template(\"admin/vendor_pricing_import.html\",\n staging_items=[], upload_error=\"No file selected\")\n\n filename = secure_filename(file.filename)\n ext = filename.rsplit(\".\", 1)[-1].lower() if \".\" in filename else \"\"\n\n if ext in (\"csv\", \"xlsx\", \"xls\"):\n # Path A: Structured upload — parse with ImportFileParser\n try:\n from app.services.import_core.file_parser import ImportFileParser\n parser = ImportFileParser()\n rows, errors = parser.parse_file(file, filename)\n if errors:\n upload_error = \"; \".join(errors[:5])\n else:\n required = {\"vendor_name\", \"product_name\", \"tier\", \"annual_cost\"}\n validation = parser.validate_file_structure(\n rows, list(rows[0].keys()) if rows else [],\n required\n )\n if not validation.get(\"valid\"):\n upload_error = \"; \".join(validation.get(\"errors\", [])[:5])\n else:\n staging_items = rows\n except Exception as e:\n upload_error = f\"Parse error: {str(e)}\"\n\n elif ext in (\"pdf\", \"docx\", \"txt\", \"md\"):\n # Path B: Document ingestion — LLM extraction\n try:\n from app.modules.vendors.services.pricing_extraction_service import PricingExtractionService\n svc = PricingExtractionService()\n result = svc.extract_from_file(file)\n if result[\"success\"]:\n staging_items = result[\"items\"]\n else:\n upload_error = result[\"error\"]\n except Exception as e:\n upload_error = f\"Extraction error: {str(e)}\"\n else:\n upload_error = f\"Unsupported file type: .{ext}. Use CSV, XLSX, PDF, or DOCX.\"\n\n return render_template(\"admin/vendor_pricing_import.html\",\n staging_items=staging_items, upload_error=upload_error)\n\n\n@admin_bp_v2.route(\"/vendor-pricing/confirm\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef vendor_pricing_confirm():\n \"\"\"Confirm staged pricing items — write to VendorProductPricing as contract_verified.\"\"\"\n from difflib import SequenceMatcher\n from datetime import datetime, timezone\n from app.models.vendor.vendor_organization import (\n VendorOrganization, VendorProduct, VendorProductPricing,\n )\n\n data = request.get_json()\n if not data or \"items\" not in data:\n return jsonify({\"error\": \"No items to confirm\"}), 400\n\n created = 0\n updated = 0\n errors = []\n\n for item in data[\"items\"]:\n vendor_name = item.get(\"vendor_name\", \"\").strip()\n product_name = item.get(\"product_name\", \"\").strip()\n if not vendor_name or not product_name:\n errors.append(\"Missing vendor/product name in item\")\n continue\n\n # Exact match first, then fuzzy match vendor\n vendor = VendorOrganization.query.filter(\n db.func.lower(VendorOrganization.name) == vendor_name.lower()\n ).first()\n if not vendor:\n all_vendors = VendorOrganization.query.all()\n best_match = None\n best_score = 0\n for v in all_vendors:\n score = SequenceMatcher(None, vendor_name.lower(), v.name.lower()).ratio()\n if score > best_score:\n best_score = score\n best_match = v\n if best_match and best_score >= 0.7:\n vendor = best_match\n else:\n errors.append(f\"Vendor not found: {vendor_name}\")\n continue\n\n # Exact match first, then fuzzy match product\n product = VendorProduct.query.filter(\n VendorProduct.vendor_organization_id == vendor.id,\n db.func.lower(VendorProduct.name) == product_name.lower()\n ).first()\n if not product:\n products = VendorProduct.query.filter_by(vendor_organization_id=vendor.id).all()\n best_match = None\n best_score = 0\n for p in products:\n score = SequenceMatcher(None, product_name.lower(), p.name.lower()).ratio()\n if score > best_score:\n best_score = score\n best_match = p\n if best_match and best_score >= 0.6:\n product = best_match\n else:\n errors.append(f\"Product not found: {vendor_name} / {product_name}\")\n continue\n\n tier = item.get(\"tier\", \"Standard\")\n annual_cost = item.get(\"annual_cost\")\n\n # Check for existing pricing row (same product + tier)\n existing = VendorProductPricing.query.filter_by(\n product_id=product.id, tier_name=tier\n ).first()\n\n if existing:\n existing.list_price_annual = annual_cost\n existing.data_source_type = \"contract_verified\"\n existing.typical_discount_percent = item.get(\"discount_percent\")\n existing.contract_term_months = item.get(\"contract_term_months\", 12)\n existing.setup_fee = item.get(\"setup_fee\")\n existing.implementation_fee = item.get(\"implementation_fee\")\n existing.source = \"Contract Import\"\n existing.last_verified_at = datetime.now(timezone.utc)\n existing.organization_id = 1\n if item.get(\"expiry_date\"):\n try:\n from datetime import date\n existing.expiry_date = date.fromisoformat(item[\"expiry_date\"])\n except (ValueError, TypeError):\n logger.exception(\"Failed to operation\")\n pass\n updated += 1\n else:\n pricing = VendorProductPricing(\n product_id=product.id,\n pricing_model=item.get(\"unit_type\", \"per_user\"),\n tier_name=tier,\n list_price_annual=annual_cost,\n min_users=1,\n max_users=item.get(\"unit_quantity\"),\n currency=item.get(\"currency\", \"USD\"),\n source=\"Contract Import\",\n billing_frequency=\"annual\",\n contract_term_months=item.get(\"contract_term_months\", 12),\n setup_fee=item.get(\"setup_fee\"),\n implementation_fee=item.get(\"implementation_fee\"),\n typical_discount_percent=item.get(\"discount_percent\"),\n data_source_type=\"contract_verified\",\n last_verified_at=datetime.now(timezone.utc),\n organization_id=1,\n created_by_id=current_user.id,\n )\n if item.get(\"expiry_date\"):\n try:\n from datetime import date\n pricing.expiry_date = date.fromisoformat(item[\"expiry_date\"])\n except (ValueError, TypeError):\n logger.exception(\"Failed to operation\")\n pass\n db.session.add(pricing)\n created += 1\n\n db.session.commit()\n return jsonify({\n \"success\": True,\n \"created\": created,\n \"updated\": updated,\n \"errors\": errors,\n }), 200\n\n\n# ─── Vendor Pricing Analytics ──────────────────────────────────────────────\n@admin_bp_v2.route(\"/pricing-analytics\")\n@timed_route\n@login_required\n@admin_required\ndef pricing_analytics():\n \"\"\"Vendor pricing analytics dashboard.\"\"\"\n from app.modules.vendors.services.confidence_engine import ConfidenceEngine\n engine = ConfidenceEngine()\n analytics = engine.get_analytics()\n return render_template(\"admin/pricing_analytics.html\", analytics=analytics)\n\n\n# ============================================================================\n# Governance Gates Management (GOV-03)\n# ============================================================================\n\n\n@admin_bp_v2.route(\"/governance-gates\")\n@timed_route\n@login_required\n@admin_required\ndef governance_gates():\n \"\"\"Governance gates configuration page.\"\"\"\n from app.modules.solutions_strategic.v2.services.governance_gate_service import (\n DEFAULT_GATES,\n )\n\n return render_template(\n \"admin/governance_gates.html\",\n default_gates=DEFAULT_GATES,\n )\n\n\n@admin_bp_v2.route(\"/api/governance-gates\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef governance_gates_list():\n \"\"\"List all governance gates from DB.\"\"\"\n from app.models.governance_gates import GovernanceGate\n\n try:\n gates = GovernanceGate.query.order_by(GovernanceGate.gate_name).all()\n return jsonify({\"success\": True, \"gates\": [g.to_dict() for g in gates]})\n except Exception as e:\n logger.error(\"Error listing governance gates: %s\", e, exc_info=True)\n return jsonify({\"success\": False, \"error\": \"Failed to load gates\"}), 500\n\n\n@admin_bp_v2.route(\"/api/governance-gates\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"admin_governance_gate_create\")\ndef governance_gates_create():\n \"\"\"Create a new governance gate.\"\"\"\n from app.models.governance_gates import GovernanceGate\n\n data = request.get_json()\n if not data or not data.get(\"gate_name\"):\n return jsonify({\"success\": False, \"error\": \"gate_name is required\"}), 400\n\n gate_name = data[\"gate_name\"].strip()\n if GovernanceGate.query.filter_by(gate_name=gate_name).first():\n return jsonify({\"success\": False, \"error\": f\"Gate '{gate_name}' already exists\"}), 409\n\n try:\n gate = GovernanceGate(\n gate_name=gate_name,\n description=data.get(\"description\", \"\"),\n required_sections=data.get(\"required_sections\", []),\n min_completeness=int(data.get(\"min_completeness\", 60)),\n required_decisions_count=int(data.get(\"required_decisions_count\", 0)),\n require_risk_mitigations=bool(data.get(\"require_risk_mitigations\", False)),\n enabled=bool(data.get(\"enabled\", True)),\n )\n db.session.add(gate)\n db.session.commit()\n return jsonify({\"success\": True, \"gate\": gate.to_dict()}), 201\n except Exception as e:\n db.session.rollback()\n logger.error(\"Error creating governance gate: %s\", e, exc_info=True)\n return jsonify({\"success\": False, \"error\": \"Failed to create gate\"}), 500\n\n\n@admin_bp_v2.route(\"/api/governance-gates/<int:gate_id>\", methods=[\"PUT\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"admin_governance_gate_update\")\ndef governance_gates_update(gate_id):\n \"\"\"Update an existing governance gate.\"\"\"\n from app.models.governance_gates import GovernanceGate\n\n gate = GovernanceGate.query.get_or_404(gate_id)\n data = request.get_json()\n if not data:\n return jsonify({\"success\": False, \"error\": \"No data provided\"}), 400\n\n try:\n if \"gate_name\" in data:\n new_name = data[\"gate_name\"].strip()\n existing = GovernanceGate.query.filter_by(gate_name=new_name).first()\n if existing and existing.id != gate_id:\n return jsonify({\"success\": False, \"error\": f\"Gate '{new_name}' already exists\"}), 409\n gate.gate_name = new_name\n if \"description\" in data:\n gate.description = data[\"description\"]\n if \"required_sections\" in data:\n gate.required_sections = data[\"required_sections\"]\n if \"min_completeness\" in data:\n gate.min_completeness = int(data[\"min_completeness\"])\n if \"required_decisions_count\" in data:\n gate.required_decisions_count = int(data[\"required_decisions_count\"])\n if \"require_risk_mitigations\" in data:\n gate.require_risk_mitigations = bool(data[\"require_risk_mitigations\"])\n if \"enabled\" in data:\n gate.enabled = bool(data[\"enabled\"])\n\n db.session.commit()\n return jsonify({\"success\": True, \"gate\": gate.to_dict()})\n except Exception as e:\n db.session.rollback()\n logger.error(\"Error updating governance gate %s: %s\", gate_id, e, exc_info=True)\n return jsonify({\"success\": False, \"error\": \"Failed to update gate\"}), 500\n\n\n@admin_bp_v2.route(\"/api/governance-gates/<int:gate_id>\", methods=[\"DELETE\"])\n@timed_route\n@login_required\n@admin_required\n@audit_log(\"admin_governance_gate_delete\")\ndef governance_gates_delete(gate_id):\n \"\"\"Soft-delete a governance gate by disabling it.\"\"\"\n from app.models.governance_gates import GovernanceGate\n\n gate = GovernanceGate.query.get_or_404(gate_id)\n try:\n gate.enabled = False\n db.session.commit()\n return jsonify({\"success\": True, \"message\": f\"Gate '{gate.gate_name}' disabled\"})\n except Exception as e:\n db.session.rollback()\n logger.error(\"Error disabling governance gate %s: %s\", gate_id, e, exc_info=True)\n return jsonify({\"success\": False, \"error\": \"Failed to disable gate\"}), 500\n\n\n# ─── Organization Management (multi-tenancy) ───────────────────────────\n\n\n@admin_bp_v2.route(\"/organizations\")\n@timed_route\n@login_required\n@platform_admin_required\ndef organizations_list():\n \"\"\"List all organizations with user counts.\"\"\"\n orgs = Organization.query.order_by(Organization.name).all()\n org_data = []\n for org in orgs:\n user_count = User.query.filter_by(organization_id=org.id).count()\n org_data.append({\"org\": org, \"user_count\": user_count})\n return render_template(\"admin/organizations/list.html\", organizations=org_data)\n\n\n@admin_bp_v2.route(\"/organizations/new\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@platform_admin_required\ndef organization_create():\n \"\"\"Create a new organization.\"\"\"\n if request.method == \"POST\":\n name = request.form.get(\"name\", \"\").strip()\n slug = request.form.get(\"slug\", \"\").strip() or name.lower().replace(\" \", \"-\")\n plan = request.form.get(\"plan\", \"free\")\n max_users = int(request.form.get(\"max_users\") or 10)\n\n if not name:\n flash(\"Organization name is required.\", \"error\")\n return render_template(\"admin/organizations/form.html\", org=None)\n\n if Organization.query.filter_by(slug=slug).first():\n flash(f'An organization with slug \"{slug}\" already exists.', \"error\")\n return render_template(\"admin/organizations/form.html\", org=None)\n\n org = Organization(name=name, slug=slug, plan=plan, max_users=max_users)\n db.session.add(org)\n db.session.commit()\n flash(f'Organization \"{name}\" created.', \"success\")\n return redirect(url_for(\"admin.organizations_list\"))\n\n return render_template(\"admin/organizations/form.html\", org=None)\n\n\n@admin_bp_v2.route(\"/organizations/<int:org_id>\")\n@timed_route\n@login_required\n@platform_admin_required\ndef organization_detail(org_id):\n \"\"\"View organization details and its users.\"\"\"\n org = Organization.query.get_or_404(org_id)\n users = User.query.filter_by(organization_id=org.id).all()\n return render_template(\"admin/organizations/detail.html\", org=org, users=users)\n\n\n@admin_bp_v2.route(\"/organizations/<int:org_id>/edit\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@platform_admin_required\ndef organization_edit(org_id):\n \"\"\"Edit an existing organization.\"\"\"\n org = Organization.query.get_or_404(org_id)\n\n if request.method == \"POST\":\n org.name = request.form.get(\"name\", \"\").strip() or org.name\n new_slug = request.form.get(\"slug\", \"\").strip()\n if new_slug and new_slug != org.slug:\n existing = Organization.query.filter_by(slug=new_slug).first()\n if existing and existing.id != org.id:\n flash(f'Slug \"{new_slug}\" is already taken.', \"error\")\n return render_template(\"admin/organizations/form.html\", org=org)\n org.slug = new_slug\n org.plan = request.form.get(\"plan\", org.plan)\n org.max_users = int(request.form.get(\"max_users\") or org.max_users)\n db.session.commit()\n flash(f'Organization \"{org.name}\" updated.', \"success\")\n return redirect(url_for(\"admin.organization_detail\", org_id=org.id))\n\n return render_template(\"admin/organizations/form.html\", org=org)\n\n\n@admin_bp_v2.route(\"/organizations/<int:org_id>/toggle\", methods=[\"POST\"])\n@timed_route\n@login_required\n@platform_admin_required\ndef organization_toggle(org_id):\n \"\"\"Activate or deactivate an organization.\"\"\"\n org = Organization.query.get_or_404(org_id)\n org.is_active = not org.is_active\n db.session.commit()\n status = \"activated\" if org.is_active else \"deactivated\"\n flash(f'Organization \"{org.name}\" {status}.', \"success\")\n return redirect(url_for(\"admin.organization_detail\", org_id=org.id))\n\n\n@admin_bp_v2.route(\n \"/organizations/<int:org_id>/users/<int:user_id>/toggle-admin\",\n methods=[\"POST\"],\n)\n@timed_route\n@login_required\n@platform_admin_required\ndef toggle_org_admin(org_id, user_id):\n \"\"\"Toggle org-admin status for a user within an organization.\"\"\"\n user = User.query.get_or_404(user_id)\n if user.organization_id != org_id:\n flash(\"User does not belong to this organization.\", \"error\")\n return redirect(url_for(\"admin.organization_detail\", org_id=org_id))\n user.is_org_admin = not user.is_org_admin\n db.session.commit()\n role_label = \"granted\" if user.is_org_admin else \"revoked\"\n flash(f'Org-admin role {role_label} for {user.full_name() or user.email}.', \"success\")\n return redirect(url_for(\"admin.organization_detail\", org_id=org_id))\n\n\n@admin_bp_v2.route(\"/organizations/<int:org_id>/delete\", methods=[\"POST\"])\n@timed_route\n@login_required\n@platform_admin_required\ndef organization_delete(org_id):\n \"\"\"Delete an organization. Moves all users to the Default org first.\"\"\"\n org = Organization.query.get_or_404(org_id)\n\n if org.slug == \"default\":\n flash(\"Cannot delete the Default organization.\", \"error\")\n return redirect(url_for(\"admin.organization_detail\", org_id=org_id))\n\n # Move all users to Default org before deleting\n default_org = Organization.query.filter_by(slug=\"default\").first()\n if not default_org:\n flash(\"Cannot delete — no Default organization to reassign users.\", \"error\")\n return redirect(url_for(\"admin.organization_detail\", org_id=org_id))\n\n moved = User.query.filter_by(organization_id=org.id).update(\n {\"organization_id\": default_org.id, \"is_org_admin\": False},\n synchronize_session=False,\n )\n\n org_name = org.name\n db.session.delete(org)\n db.session.commit()\n flash(f'Organization \"{org_name}\" deleted. {moved} user(s) moved to Default.', \"success\")\n return redirect(url_for(\"admin.organizations_list\"))\n\n\n@admin_bp_v2.route(\n \"/organizations/<int:org_id>/users/<int:user_id>/remove\",\n methods=[\"POST\"],\n)\n@timed_route\n@login_required\n@platform_admin_required\ndef remove_user_from_org(org_id, user_id):\n \"\"\"Remove a user from this organization (moves to Default org).\"\"\"\n user = User.query.get_or_404(user_id)\n if user.organization_id != org_id:\n flash(\"User does not belong to this organization.\", \"error\")\n return redirect(url_for(\"admin.organization_detail\", org_id=org_id))\n\n default_org = Organization.query.filter_by(slug=\"default\").first()\n if not default_org or default_org.id == org_id:\n flash(\"Cannot remove user — no other organization to move them to.\", \"error\")\n return redirect(url_for(\"admin.organization_detail\", org_id=org_id))\n\n user.organization_id = default_org.id\n user.is_org_admin = False\n db.session.commit()\n flash(f'{user.full_name() or user.email} moved to Default organization.', \"success\")\n return redirect(url_for(\"admin.organization_detail\", org_id=org_id))\n\n\n# ---------------------------------------------------------------------------\n# Power Platform CoE Integration\n# ---------------------------------------------------------------------------\n\n_PP_PROVIDER = \"power_platform_coe\"\n_PP_LABEL = \"default\"\n\n# ── Salesforce Org Discovery (PROG-003) — mirrors the Power Platform block ──\n_SF_PROVIDER = \"salesforce_org\"\n_SF_LABEL = \"default\"\n\n\ndef _sf_settings_row():\n \"\"\"Return the APISettings row for Salesforce org discovery, or None.\"\"\"\n from app.models.models import APISettings\n return APISettings.query.filter_by(\n provider=_SF_PROVIDER, key_label=_SF_LABEL\n ).first()\n\n\n@admin_bp_v2.route(\"/integrations/salesforce\", methods=[\"GET\"])\n@timed_route\n@login_required\ndef salesforce_integration():\n \"\"\"GET /admin/integrations/salesforce — org discovery configuration UI.\"\"\"\n row = _sf_settings_row()\n sf_config = {}\n if row:\n sf_config = {\n \"instance_url\": row.jira_url or \"\",\n \"client_id\": row.jira_email or \"\",\n \"configured\": bool(row.jira_url),\n }\n # Programme picker for import targeting\n from app.modules.solutions_strategic.v2.services.programme_governance_service import (\n ProgrammeGovernanceService,\n )\n programmes = ProgrammeGovernanceService.list_programmes()\n return render_template(\n \"admin/integrations_salesforce.html\", sf_config=sf_config, programmes=programmes\n )\n\n\n@admin_bp_v2.route(\"/integrations/salesforce/save\", methods=[\"POST\"])\n@timed_route\n@login_required\ndef salesforce_save_credentials():\n \"\"\"POST /admin/integrations/salesforce/save — persist credentials.\"\"\"\n from app.modules.solutions_strategic.v2.services.salesforce_discovery_service import (\n SalesforceDiscoveryService,\n )\n data = request.get_json() or request.form\n instance_url = (data.get(\"instance_url\") or \"\").strip()\n client_id = (data.get(\"client_id\") or \"\").strip()\n client_secret = (data.get(\"client_secret\") or \"\").strip()\n if not instance_url or not client_id:\n return jsonify({\"status\": \"error\", \"error\": \"instance_url and client_id are required\"}), 400\n SalesforceDiscoveryService.save_settings(instance_url, client_id, client_secret)\n return jsonify({\"success\": True, \"status\": \"ok\", \"message\": \"Credentials saved.\"})\n\n\n@admin_bp_v2.route(\"/integrations/salesforce/test\", methods=[\"POST\"])\n@timed_route\n@login_required\ndef salesforce_test_connection():\n \"\"\"POST /admin/integrations/salesforce/test — test credentials.\"\"\"\n from app.modules.solutions_strategic.v2.services.salesforce_discovery_service import (\n SalesforceDiscoveryService,\n )\n data = request.get_json() or request.form\n instance_url = (data.get(\"instance_url\") or \"\").strip()\n client_id = (data.get(\"client_id\") or \"\").strip()\n client_secret = (data.get(\"client_secret\") or \"\").strip()\n if not all([instance_url, client_id, client_secret]):\n return jsonify({\"status\": \"error\", \"error\": \"instance_url, client_id, and client_secret are required\"}), 400\n result = SalesforceDiscoveryService.test_connection(instance_url, client_id, client_secret)\n return jsonify(result), 200 if result.get(\"status\") == \"ok\" else 400\n\n\n@admin_bp_v2.route(\"/integrations/salesforce/discover\", methods=[\"POST\"])\n@timed_route\n@login_required\ndef salesforce_discover():\n \"\"\"POST /admin/integrations/salesforce/discover — list org apps + packages.\"\"\"\n from app.models.application_portfolio import ApplicationComponent\n from app.modules.solutions_strategic.v2.services.salesforce_discovery_service import (\n SalesforceDiscoveryService,\n )\n row = _sf_settings_row()\n if not row or not row.jira_url:\n return jsonify({\"error\": \"Salesforce credentials not configured. Save credentials first.\"}), 400\n apps = SalesforceDiscoveryService.discover_apps(\n row.jira_url or \"\", row.jira_email or \"\", row.api_key or \"\"\n )\n linked_ids = {\n r.source_identifier\n for r in ApplicationComponent.query.filter(\n ApplicationComponent.source_identifier.isnot(None)\n ).with_entities(ApplicationComponent.source_identifier).all()\n }\n for app in apps:\n app[\"status\"] = \"linked\" if app[\"id\"] in linked_ids else \"unregistered\"\n return jsonify({\n \"apps\": apps,\n \"total\": len(apps),\n \"ungoverned\": sum(1 for a in apps if not a.get(\"owner_email\")),\n \"linked\": sum(1 for a in apps if a[\"status\"] == \"linked\"),\n })\n\n\n@admin_bp_v2.route(\"/integrations/salesforce/import\", methods=[\"POST\"])\n@timed_route\n@login_required\ndef salesforce_import():\n \"\"\"POST /admin/integrations/salesforce/import — import selected app_ids\n with optional programme_initiative_id baseline linkage.\"\"\"\n from app.modules.solutions_strategic.v2.services.salesforce_discovery_service import (\n SalesforceDiscoveryService,\n )\n data = request.get_json() or request.form\n app_ids = data.get(\"app_ids\") or []\n if isinstance(app_ids, str):\n import json as _json\n try:\n app_ids = _json.loads(app_ids)\n except Exception:\n app_ids = [app_ids]\n if not app_ids:\n return jsonify({\"error\": \"app_ids is required and must be non-empty\"}), 400\n row = _sf_settings_row()\n if not row or not row.jira_url:\n return jsonify({\"error\": \"Salesforce credentials not configured.\"}), 400\n discovered = SalesforceDiscoveryService.discover_apps(\n row.jira_url or \"\", row.jira_email or \"\", row.api_key or \"\"\n )\n result = SalesforceDiscoveryService.import_apps(\n app_ids,\n discovered,\n user_id=current_user.id,\n programme_initiative_id=data.get(\"programme_initiative_id\"),\n )\n return jsonify(result)\n\n\ndef _pp_settings_row():\n \"\"\"Return the APISettings row for Power Platform CoE, or None.\"\"\"\n from app.models.models import APISettings\n return APISettings.query.filter_by(\n provider=_PP_PROVIDER, key_label=_PP_LABEL\n ).first()\n\n\n@admin_bp_v2.route(\"/integrations/power-platform\", methods=[\"GET\"])\n@timed_route\n@login_required\ndef power_platform_integration():\n \"\"\"GET /admin/integrations/power-platform — CoE configuration and discovery UI.\"\"\"\n row = _pp_settings_row()\n config = {}\n if row:\n config = {\n \"tenant_id\": row.jira_url or \"\",\n \"client_id\": row.jira_email or \"\",\n \"env_url\": row.custom_endpoint_url or \"\",\n \"configured\": bool(row.jira_url),\n }\n return render_template(\"admin/integrations_power_platform.html\", config=config)\n\n\n@admin_bp_v2.route(\"/integrations/power-platform/save\", methods=[\"POST\"])\n@timed_route\n@login_required\ndef power_platform_save_credentials():\n \"\"\"POST /admin/integrations/power-platform/save — persist credentials to api_settings.\"\"\"\n from app.models.models import APISettings\n data = request.get_json() or request.form\n tenant_id = (data.get(\"tenant_id\") or \"\").strip()\n client_id = (data.get(\"client_id\") or \"\").strip()\n client_secret = (data.get(\"client_secret\") or \"\").strip()\n env_url = (data.get(\"env_url\") or \"\").strip()\n row = _pp_settings_row()\n if not row:\n row = APISettings(provider=_PP_PROVIDER, key_label=_PP_LABEL)\n db.session.add(row)\n row.jira_url = tenant_id\n row.jira_email = client_id\n if client_secret:\n row.api_key = client_secret\n row.custom_endpoint_url = env_url\n db.session.commit()\n return jsonify({\"success\": True, \"status\": \"ok\", \"message\": \"Credentials saved.\"})\n\n\n@admin_bp_v2.route(\"/integrations/power-platform/test\", methods=[\"POST\"])\n@timed_route\n@login_required\ndef power_platform_test_connection():\n \"\"\"POST /admin/integrations/power-platform/test — test credentials.\"\"\"\n from app.modules.solutions_strategic.v2.services.power_platform_coe_service import (\n PowerPlatformCoeService,\n )\n data = request.get_json() or request.form\n tenant_id = (data.get(\"tenant_id\") or \"\").strip()\n client_id = (data.get(\"client_id\") or \"\").strip()\n client_secret = (data.get(\"client_secret\") or \"\").strip()\n env_url = (data.get(\"env_url\") or \"\").strip()\n if not all([tenant_id, client_id, client_secret]):\n return jsonify({\"status\": \"error\", \"error\": \"tenant_id, client_id, and client_secret are required\"}), 400\n result = PowerPlatformCoeService.test_connection(tenant_id, client_id, client_secret, env_url)\n http_status = 200 if result.get(\"status\") == \"ok\" else 400\n return jsonify(result), http_status\n\n\n@admin_bp_v2.route(\"/integrations/power-platform/discover\", methods=[\"POST\"])\n@timed_route\n@login_required\ndef power_platform_discover():\n \"\"\"POST /admin/integrations/power-platform/discover — trigger discovery, return app list.\"\"\"\n from app.modules.solutions_strategic.v2.services.power_platform_coe_service import (\n PowerPlatformCoeService,\n )\n row = _pp_settings_row()\n if not row or not row.jira_url:\n return jsonify({\"error\": \"Power Platform credentials not configured. Save credentials first.\"}), 400\n tenant_id = row.jira_url or \"\"\n client_id = row.jira_email or \"\"\n client_secret = row.api_key or \"\"\n apps = PowerPlatformCoeService.discover_apps(tenant_id, client_id, client_secret)\n\n # Annotate with ARCHIE link status (same as v1)\n from app.models.application_portfolio import ApplicationComponent\n linked_ids = {\n r.source_identifier\n for r in ApplicationComponent.query.filter(\n ApplicationComponent.source_identifier.isnot(None)\n ).with_entities(ApplicationComponent.source_identifier).all()\n }\n for app in apps:\n app[\"status\"] = \"linked\" if app[\"id\"] in linked_ids else \"unregistered\"\n\n ungoverned = sum(1 for a in apps if not a.get(\"owner_email\"))\n linked = sum(1 for a in apps if a[\"status\"] == \"linked\")\n\n return jsonify({\"apps\": apps, \"total\": len(apps), \"ungoverned\": ungoverned, \"linked\": linked})\n\n\n@admin_bp_v2.route(\"/integrations/power-platform/import\", methods=[\"POST\"])\n@timed_route\n@login_required\ndef power_platform_import():\n \"\"\"POST /admin/integrations/power-platform/import — import selected app_ids.\"\"\"\n from app.modules.solutions_strategic.v2.services.power_platform_coe_service import (\n PowerPlatformCoeService,\n )\n data = request.get_json() or request.form\n app_ids = data.get(\"app_ids\") or []\n if isinstance(app_ids, str):\n import json as _json\n try:\n app_ids = _json.loads(app_ids)\n except Exception:\n app_ids = [app_ids]\n if not app_ids:\n return jsonify({\"error\": \"app_ids is required and must be non-empty\"}), 400\n row = _pp_settings_row()\n if not row or not row.jira_url:\n return jsonify({\"error\": \"Power Platform credentials not configured.\"}), 400\n tenant_id = row.jira_url or \"\"\n client_id = row.jira_email or \"\"\n client_secret = row.api_key or \"\"\n discovered = PowerPlatformCoeService.discover_apps(tenant_id, client_id, client_secret)\n from flask_login import current_user\n result = PowerPlatformCoeService.import_apps(app_ids, discovered, current_user.id)\n return jsonify(result)\n\n\n# ---------------------------------------------------------------------------\n# Slack AI Architect Integration\n# ---------------------------------------------------------------------------\n\n@admin_bp_v2.route(\"/integrations/slack\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef slack_integration():\n \"\"\"GET/POST /admin/integrations/slack — Slack bot configuration.\"\"\"\n from app.services.slack_architect_service import SlackArchitectService\n\n if request.method == \"POST\":\n bot_token = (request.form.get(\"bot_token\") or \"\").strip()\n signing_secret = (request.form.get(\"signing_secret\") or \"\").strip()\n monitored_channels = (request.form.get(\"monitored_channels\") or \"\").strip()\n portfolio_scan = request.form.get(\"portfolio_scan\") == \"1\"\n mention_response = request.form.get(\"mention_response\") == \"1\"\n SlackArchitectService.save_config(\n bot_token, signing_secret, monitored_channels, portfolio_scan, mention_response\n )\n flash(\"Slack configuration saved.\", \"success\")\n return redirect(url_for(\"admin.slack_integration\"))\n\n config = SlackArchitectService.get_config()\n return render_template(\"admin/integrations_slack.html\", config=config)\n\n\n@admin_bp_v2.route(\"/integrations/slack/test\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef slack_test_connection():\n \"\"\"POST /admin/integrations/slack/test — test bot token.\"\"\"\n from app.services.slack_architect_service import SlackArchitectService\n\n data = request.get_json() or request.form\n bot_token = (data.get(\"bot_token\") or \"\").strip()\n if not bot_token:\n cfg = SlackArchitectService.get_config()\n bot_token = cfg.get(\"bot_token\", \"\")\n if not bot_token:\n return jsonify({\"status\": \"error\", \"error\": \"Bot token is required.\"}), 400\n result = SlackArchitectService.test_connection(bot_token)\n return jsonify(result), 200 if result.get(\"status\") == \"ok\" else 400\n\n\n# ---------------------------------------------------------------------------\n# Teams Meeting Intelligence\n# ---------------------------------------------------------------------------\n\n@admin_bp_v2.route(\"/integrations/teams-meetings\", methods=[\"GET\", \"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef teams_meetings_integration():\n \"\"\"GET/POST /admin/integrations/teams-meetings — meeting transcript config.\"\"\"\n from app.services.teams_meeting_service import TeamsMeetingService\n\n if request.method == \"POST\":\n notification_url = (request.form.get(\"notification_url\") or \"\").strip()\n transcript_analysis = request.form.get(\"transcript_analysis\") == \"1\"\n signal_creation = request.form.get(\"signal_creation\") == \"1\"\n TeamsMeetingService.save_config(notification_url, transcript_analysis, signal_creation)\n flash(\"Teams Meetings configuration saved.\", \"success\")\n return redirect(url_for(\"admin.teams_meetings_integration\"))\n\n config = TeamsMeetingService.get_config()\n return render_template(\"admin/integrations_teams_meetings.html\", config=config)\n\n\n@admin_bp_v2.route(\"/ai-corrections\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef ai_corrections():\n \"\"\"Batch review and correct AI-generated ArchiMate elements.\"\"\"\n from app.models.application_portfolio import ApplicationComponent\n\n try:\n from app.models.models import ArchiMateElement\n except ImportError:\n ArchiMateElement = None # type: ignore[assignment,misc]\n\n from app.modules.architecture.services.feedback_learning_service import ExtractionFeedback\n\n # Get apps that have AI-generated ArchiMate elements\n apps_with_elements = (\n ApplicationComponent.query\n .filter(ApplicationComponent.archimate_element_id.isnot(None))\n .order_by(ApplicationComponent.updated_at.desc())\n .limit(50)\n .all()\n )\n\n rows = []\n for app in apps_with_elements:\n elem = ArchiMateElement.query.get(app.archimate_element_id) if ArchiMateElement else None\n if elem:\n rows.append({\n \"app_id\": app.id,\n \"app_name\": app.name,\n \"element_id\": elem.id,\n \"element_name\": elem.name,\n \"element_type\": elem.type,\n \"element_layer\": elem.layer,\n \"completeness\": app.completeness_score,\n })\n\n # Count existing corrections\n correction_count = ExtractionFeedback.query.count()\n\n return render_template(\n \"admin/ai_corrections.html\",\n rows=rows,\n correction_count=correction_count,\n total_with_elements=len(rows),\n )\n\n\n@admin_bp_v2.route(\"/sap-clean-core\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef sap_clean_core_dashboard():\n \"\"\"GET /admin/sap-clean-core — portfolio-level SAP clean-core compliance overview.\"\"\"\n from app.services.sap_clean_core_service import SAPCleanCoreService\n scan = SAPCleanCoreService.quick_scan_portfolio(limit=30)\n return jsonify(scan)\n\n\n@admin_bp_v2.route(\"/sap-clean-core/<int:solution_id>\", methods=[\"GET\"])\n@timed_route\n@login_required\n@admin_required\ndef sap_clean_core_solution(solution_id):\n \"\"\"GET /admin/sap-clean-core/<id> — validate a single solution.\"\"\"\n from app.services.sap_clean_core_service import SAPCleanCoreService\n result = SAPCleanCoreService.validate_solution(solution_id)\n return jsonify(result)\n\n\n@admin_bp_v2.route(\"/integrations/teams-meetings/subscribe\", methods=[\"POST\"])\n@timed_route\n@login_required\n@admin_required\ndef teams_meetings_subscribe():\n \"\"\"POST /admin/integrations/teams-meetings/subscribe — register Graph subscription.\"\"\"\n from app.services.teams_meeting_service import TeamsMeetingService\n\n data = request.get_json() or request.form\n notification_url = (data.get(\"notification_url\") or \"\").strip()\n if not notification_url:\n cfg = TeamsMeetingService.get_config()\n notification_url = cfg.get(\"notification_url\", \"\")\n if not notification_url:\n return jsonify({\"status\": \"error\", \"error\": \"notification_url is required.\"}), 400\n result = TeamsMeetingService.subscribe_to_call_records(notification_url)\n return jsonify(result), 200 if result.get(\"status\") == \"ok\" else 400\n"}}}}