From f79862203bf1efb00c27b57694ae93990ed2063e Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sun, 9 Aug 2026 12:45:44 +0530 Subject: [PATCH] Add ERP Local Agent secure self update framework --- app/modules/documents/agent_package.py | 581 +----------------- .../local_agent_runtime/check_config.bat | 10 + .../local_agent_runtime/check_config.py | 21 + .../erp_local_agent/__init__.py | 2 + .../erp_local_agent/client.py | 96 +++ .../erp_local_agent/config.py | 112 ++++ .../local_agent_runtime/erp_local_agent/db.py | 67 ++ .../erp_local_agent/logger.py | 27 + .../erp_local_agent/main.py | 58 ++ .../erp_local_agent/storage.py | 62 ++ .../erp_local_agent/storage_identity.py | 105 ++++ .../erp_local_agent/sync.py | 152 +++++ .../erp_local_agent/tunnel.py | 74 +++ .../erp_local_agent/updater.py | 100 +++ .../install_task_scheduler.bat | 21 + .../local_agent_runtime/requirements.txt | 3 + .../local_agent_runtime/run_agent.bat | 3 + .../local_agent_runtime/run_once.bat | 3 + .../start_task_scheduler.bat | 2 + .../status_task_scheduler.bat | 3 + .../stop_task_scheduler.bat | 2 + .../uninstall_task_scheduler.bat | 3 + .../templates/documents/storage_nodes.html | 15 +- app/modules/documents/ui.py | 37 +- app/ui/templates/base/layout.html | 35 +- 25 files changed, 1011 insertions(+), 583 deletions(-) create mode 100644 app/modules/documents/local_agent_runtime/check_config.bat create mode 100644 app/modules/documents/local_agent_runtime/check_config.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/client.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/config.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/db.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/logger.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/main.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/storage.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/storage_identity.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/sync.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py create mode 100644 app/modules/documents/local_agent_runtime/erp_local_agent/updater.py create mode 100644 app/modules/documents/local_agent_runtime/install_task_scheduler.bat create mode 100644 app/modules/documents/local_agent_runtime/requirements.txt create mode 100644 app/modules/documents/local_agent_runtime/run_agent.bat create mode 100644 app/modules/documents/local_agent_runtime/run_once.bat create mode 100644 app/modules/documents/local_agent_runtime/start_task_scheduler.bat create mode 100644 app/modules/documents/local_agent_runtime/status_task_scheduler.bat create mode 100644 app/modules/documents/local_agent_runtime/stop_task_scheduler.bat create mode 100644 app/modules/documents/local_agent_runtime/uninstall_task_scheduler.bat diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 50bfd81..543bb43 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -1,484 +1,16 @@ from __future__ import annotations -import base64 import io +from pathlib import Path import zipfile -# Embedded LSA4 tunnel-enabled Task Scheduler local storage agent package. -# Uses Windows Task Scheduler instead of pywin32 Windows Service. -# This is used by the ERP Branch Storage Dashboard to generate branch-specific -# pre-configured agent ZIP files with .env already filled. -LSA4_ZIP_B64 = """ -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 -zy8Tas8vE2p1eAsAAQQAAAAABAAAAABAZWNobyBvZmYKY2QgL2QgJX5kcDAKY2FsbCBydW5fYWdlbnQuYmF0CnBhdXNlClBLAwQUAAAACACqiLhcc9tLXl4A -AABmAAAADAAcAHJ1bl9vbmNlLmJhdFVUCQADzy8Tas8vE2p1eAsAAQQAAAAABAAAAAANyTEOgCAMAMC9r+jiKPoE/+BKYmqpSiKUSCVx8e063HST8KGo2wYc -cAjYvaGMwHSe6Jrk5me+YrHqiS02MnErGZTHDs3YJ6Q7RFuq6UW7LL9sLlH8r9fMAoXuKvABUEsDBAoAAAAAAKqIuFzlgx33OgAAADoAAAAYABwAc3RhcnRf -dGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAEBlY2hvIG9mZgpzY2h0YXNrcyAvUnVuIC9UTiAiQXVkaXRGaXJtU3RvcmFn -ZUFnZW50IgpwYXVzZQpQSwMECgAAAAAAqoi4XDqIHG1IAAAASAAAABkAHABzdGF0dXNfdGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAA -AAAEAAAAAEBlY2hvIG9mZgpzY2h0YXNrcyAvUXVlcnkgL1ROICJBdWRpdEZpcm1TdG9yYWdlQWdlbnQiIC9WIC9GTyBMSVNUCnBhdXNlClBLAwQKAAAAAACq -iLhcQl08MToAAAA6AAAAFwAcAHN0b3BfdGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAEBlY2hvIG9mZgpzY2h0YXNrcyAv -RW5kIC9UTiAiQXVkaXRGaXJtU3RvcmFnZUFnZW50IgpwYXVzZQpQSwMEFAAAAAgAqoi4XLi315BRAAAAdQAAABwAHAB1bmluc3RhbGxfdGFza19zY2hlZHVs -ZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAHNITc7IV8hPS+MqTs4oSSzOLlbQd81LUdAP8VNQcixNySxxyyzKDS7JL0pMT3VMT80rUVKwyyvN -UTACkUiaXFJzUktS8erTd+MqSCwtTuUCAFBLAQIeAxQAAAAIAKqIuFw8Po1A0QAAAD4BAAAMABgAAAAAAAEAAACkgQAAAAAuZW52LmV4YW1wbGVVVAUAA9Av -E2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcUizhRVgCAAA6BAAACgAYAAAAAAABAAAApIEXAQAAUkVBRE1FLnR4dFVUBQAD0C8TanV4CwABBAAA -AAAEAAAAAFBLAQIeAxQAAAAIAKqIuFyR0y7TkwMAADkHAAAjABgAAAAAAAEAAACkgbMDAABSRUFETUVfTFNBMV9MT0NBTF9TVE9SQUdFX0FHRU5ULnR4dFVU -BQADzy8TanV4CwABBAAAAAAEAAAAAFBLAQIeAwoAAAAAAKuIuFwAAAAAAAAAAAAAAAAUABgAAAAAAAAAEADtQaMHAABhdWRpdF9zdG9yYWdlX2FnZW50L1VU -BQAD0S8TanV4CwABBAAAAAAEAAAAAFBLAQIeAwoAAAAAAKqIuFxraGOJJQAAACUAAAAfABgAAAAAAAEAAACkgfEHAABhdWRpdF9zdG9yYWdlX2FnZW50L19f -aW5pdF9fLnB5VVQFAAPPLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XGFbMtTgAgAA8AsAAB0AGAAAAAAAAQAAAKSBbwgAAGF1ZGl0X3N0b3Jh -Z2VfYWdlbnQvY2xpZW50LnB5VVQFAAPPLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XGb0X0DJBAAA5g0AAB0AGAAAAAAAAQAAAKSBpgsAAGF1 -ZGl0X3N0b3JhZ2VfYWdlbnQvY29uZmlnLnB5VVQFAAPQLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XIrppAA2AgAAQgkAABkAGAAAAAAAAQAA -AKSBxhAAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvZGIucHlVVAUAA88vE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcMiXO1IcBAAA/AwAAHQAYAAAA -AAABAAAApIFPEwAAYXVkaXRfc3RvcmFnZV9hZ2VudC9sb2dnZXIucHlVVAUAA88vE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhc605uolECAAB9 -BQAAGwAYAAAAAAABAAAApIEtFQAAYXVkaXRfc3RvcmFnZV9hZ2VudC9tYWluLnB5VVQFAAPQLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XFPu -e8JJAwAA5AcAAB4AGAAAAAAAAQAAAKSB0xcAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvc3RvcmFnZS5weVVUBQADzy8TanV4CwABBAAAAAAEAAAAAFBLAQIeAxQA -AAAIAKqIuFx7PTmzXwUAANMOAAAnABgAAAAAAAEAAACkgXQbAABhdWRpdF9zdG9yYWdlX2FnZW50L3N0b3JhZ2VfaWRlbnRpdHkucHlVVAUAA88vE2p1eAsA -AQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcnDkQJcEGAAB6GgAAGwAYAAAAAAABAAAApIE0IQAAYXVkaXRfc3RvcmFnZV9hZ2VudC9zeW5jLnB5VVQFAAPQ -LxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XDByUh2HAwAA9QoAAB0AGAAAAAAAAQAAAKSBSigAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvdHVubmVs -LnB5VVQFAAPQLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DCgAAAAAAq4i4XAAAAAAAAAAAAAAAACAAGAAAAAAAAAAQAO1BKCwAAGF1ZGl0X3N0b3JhZ2VfYWdl -bnQvX19weWNhY2hlX18vVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4XAW4epmaAAAAvgAAADgAGAAAAAAAAAAAAKSBgiwAAGF1ZGl0 -X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vX19pbml0X18uY3B5dGhvbi0zMTMucHljVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4 -XNpcCab4CAAANBUAADYAGAAAAAAAAAAAAKSBji0AAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vY2xpZW50LmNweXRob24tMzEzLnB5Y1VUBQAD -0S8TanV4CwABBAAAAAAEAAAAAFBLAQIeAxQAAAAIAKuIuFz3aglpvAsAAGsWAAA2ABgAAAAAAAAAAACkgfY2AABhdWRpdF9zdG9yYWdlX2FnZW50L19fcHlj -YWNoZV9fL2NvbmZpZy5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACriLhc5UFNhAoGAABmDwAAMgAYAAAAAAAA -AAAApIEiQwAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9kYi5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMU -AAAACACriLhcLV0uZJQDAAAOBgAANgAYAAAAAAAAAAAApIGYSQAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9sb2dnZXIuY3B5dGhvbi0zMTMu -cHljVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4XG4hMUfhBQAAvwkAADQAGAAAAAAAAAAAAKSBnE0AAGF1ZGl0X3N0b3JhZ2VfYWdl -bnQvX19weWNhY2hlX18vbWFpbi5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACriLhcWJ9BapoIAACeDwAANwAY -AAAAAAAAAAAApIHrUwAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9zdG9yYWdlLmNweXRob24tMzEzLnB5Y1VUBQAD0S8TanV4CwABBAAAAAAE -AAAAAFBLAQIeAxQAAAAIAKuIuFwpZ4K0XgsAAGoVAABAABgAAAAAAAAAAACkgfZcAABhdWRpdF9zdG9yYWdlX2FnZW50L19fcHljYWNoZV9fL3N0b3JhZ2Vf -aWRlbnRpdHkuY3B5dGhvbi0zMTMucHljVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4XHr6MhnMEgAA6SoAADQAGAAAAAAAAAAAAKSB -zmgAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vc3luYy5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAA -CACriLhcHRn93QsLAAAuFQAANgAYAAAAAAAAAAAApIEIfAAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy90dW5uZWwuY3B5dGhvbi0zMTMucHlj -VVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XApngiOWAAAA2QAAABAAGAAAAAAAAQAAAKSBg4cAAGNoZWNrX2NvbmZpZy5iYXRVVAUA -A88vE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcjA5piscBAAArBAAADwAYAAAAAAABAAAApIFjiAAAY2hlY2tfY29uZmlnLnB5VVQFAAPPLxNq -dXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XPoxAmEiAQAA6AEAAA8AGAAAAAAAAQAAAKSBc4oAAGluc3RhbGxfZGV2LnBzMVVUBQADzy8TanV4CwAB -BAAAAAAEAAAAAFBLAQIeAxQAAAAIAKqIuFxDbXcK+gEAAJwDAAAaABgAAAAAAAEAAACkgd6LAABpbnN0YWxsX3Rhc2tfc2NoZWR1bGVyLmJhdFVUBQADzy8T -anV4CwABBAAAAAAEAAAAAFBLAQIeAxQAAAAIAKqIuFxOD3XONQAAADcAAAAQABgAAAAAAAEAAACkgSyOAAByZXF1aXJlbWVudHMudHh0VVQFAAPQLxNqdXgL -AAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XHw7z7v8AAAAlAEAAA0AGAAAAAAAAQAAAKSBq44AAHJ1bl9hZ2VudC5iYXRVVAUAA88vE2p1eAsAAQQAAAAA -BAAAAABQSwECHgMKAAAAAACqiLhcraKIbC8AAAAvAAAAEwAYAAAAAAABAAAApIHujwAAcnVuX2FnZW50X2RlYnVnLmJhdFVUBQADzy8TanV4CwABBAAAAAAE -AAAAAFBLAQIeAxQAAAAIAKqIuFxz20teXgAAAGYAAAAMABgAAAAAAAEAAACkgWqQAABydW5fb25jZS5iYXRVVAUAA88vE2p1eAsAAQQAAAAABAAAAABQSwEC -HgMKAAAAAACqiLhc5YMd9zoAAAA6AAAAGAAYAAAAAAABAAAApIEOkQAAc3RhcnRfdGFza19zY2hlZHVsZXIuYmF0VVQFAAPPLxNqdXgLAAEEAAAAAAQAAAAA -UEsBAh4DCgAAAAAAqoi4XDqIHG1IAAAASAAAABkAGAAAAAAAAQAAAKSBmpEAAHN0YXR1c190YXNrX3NjaGVkdWxlci5iYXRVVAUAA88vE2p1eAsAAQQAAAAA -BAAAAABQSwECHgMKAAAAAACqiLhcQl08MToAAAA6AAAAFwAYAAAAAAABAAAApIE1kgAAc3RvcF90YXNrX3NjaGVkdWxlci5iYXRVVAUAA88vE2p1eAsAAQQA -AAAABAAAAABQSwECHgMUAAAACACqiLhcuLfXkFEAAAB1AAAAHAAYAAAAAAABAAAApIHAkgAAdW5pbnN0YWxsX3Rhc2tfc2NoZWR1bGVyLmJhdFVUBQADzy8T -anV4CwABBAAAAAAEAAAAAFBLBQYAAAAAJQAlALsOAABnkwAAAAA= -""" +ERP_LOCAL_AGENT_VERSION = "1.0.0" +ERP_LOCAL_AGENT_NAME = "ERP Local Agent" +RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" -def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, storage_root: str, tenant_id: int | str | None = None, branch_id: int | str | None = None, sync_interval_seconds: int = 30, request_timeout_seconds: int = 60, tunnel_enabled: bool = True, tunnel_reconnect_seconds: int = 10) -> str: +def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, storage_root: str, tenant_id=None, branch_id=None, sync_interval_seconds: int = 30, request_timeout_seconds: int = 60, tunnel_enabled: bool = True, tunnel_reconnect_seconds: int = 10) -> str: erp_base_url = (erp_base_url or "").strip().rstrip("/") - # Coolify/Traefik may forward the application request internally as HTTP even - # when the public ERP URL is HTTPS. Public agent packages must use HTTPS so - # tunnel.py derives a valid wss:// endpoint. Preserve explicit local test URLs. if erp_base_url.startswith("http://"): host = erp_base_url[7:].split("/", 1)[0].split(":", 1)[0].lower() if host not in {"localhost", "127.0.0.1", "::1"}: @@ -497,93 +29,32 @@ def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, stor f"REQUEST_TIMEOUT_SECONDS={int(request_timeout_seconds or 60)}\n" f"TUNNEL_ENABLED={str(bool(tunnel_enabled)).lower()}\n" f"TUNNEL_RECONNECT_SECONDS={int(tunnel_reconnect_seconds or 10)}\n" + "AUTO_UPDATE=true\n" + "UPDATE_CHECK_INTERVAL_SECONDS=300\n" ) -def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes: - """Build branch-specific LSA4 tunnel-enabled ZIP. - - Partner/Branch Admin packages intentionally hide roadmap/readme text files and - .env.example. The package still contains requirements.txt because pip needs it. - """ - base = base64.b64decode(LSA4_ZIP_B64) - src = zipfile.ZipFile(io.BytesIO(base), "r") +def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: bool) -> bytes: + if not RUNTIME_ROOT.exists(): + raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}") buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as dst: - replacements = { - "run_agent.bat": ( - "@echo off\r\n" - "setlocal\r\n" - "cd /d \"%~dp0\"\r\n" - "if not exist \".env\" (\r\n" - " echo ERROR: .env file is missing. Generate/download the configured agent package from ERP.\r\n" - " exit /b 1\r\n" - ")\r\n" - "if not exist \".venv\\Scripts\\python.exe\" (\r\n" - " echo ERROR: Python virtual environment is missing. Run install_task_scheduler.bat first.\r\n" - " exit /b 1\r\n" - ")\r\n" - "\".venv\\Scripts\\python.exe\" -m audit_storage_agent.main\r\n" - ).encode("utf-8"), - "install_task_scheduler.bat": ( - "@echo off\r\n" - "setlocal\r\n" - "cd /d \"%~dp0\"\r\n" - "if not exist \".env\" (\r\n" - " echo ERROR: .env file is missing. Generate/download the configured agent package from ERP.\r\n" - " pause\r\n" - " exit /b 1\r\n" - ")\r\n" - "if not exist \".venv\\Scripts\\python.exe\" (\r\n" - " echo Creating virtual environment...\r\n" - " py -3 -m venv .venv\r\n" - " if errorlevel 1 (\r\n" - " echo ERROR: Unable to create the Python virtual environment.\r\n" - " pause\r\n" - " exit /b 1\r\n" - " )\r\n" - ")\r\n" - "\".venv\\Scripts\\python.exe\" -m pip install --upgrade pip\r\n" - "if errorlevel 1 goto :install_failed\r\n" - "\".venv\\Scripts\\python.exe\" -m pip install -r requirements.txt\r\n" - "if errorlevel 1 goto :install_failed\r\n" - "set \"AGENT_DIR=%~dp0\"\r\n" - "set \"AGENT_PYTHON=%~dp0.venv\\Scripts\\python.exe\"\r\n" - "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; $taskName='AuditFirmStorageAgent'; $agentDir=$env:AGENT_DIR.TrimEnd('\\'); $python=$env:AGENT_PYTHON; $action=New-ScheduledTaskAction -Execute $python -Argument '-m audit_storage_agent.main' -WorkingDirectory $agentDir; $trigger=New-ScheduledTaskTrigger -AtStartup; $principal=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $settings=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null; Start-ScheduledTask -TaskName $taskName\"\r\n" - "if errorlevel 1 (\r\n" - " echo ERROR: Task Scheduler installation failed. Run this file as Administrator.\r\n" - " pause\r\n" - " exit /b 1\r\n" - ")\r\n" - "echo Audit Firm Storage Agent scheduled task installed and started successfully.\r\n" - "echo The agent now runs in the background; no command window needs to remain open.\r\n" - "pause\r\n" - "exit /b 0\r\n" - ":install_failed\r\n" - "echo ERROR: Python dependency installation failed.\r\n" - "pause\r\n" - "exit /b 1\r\n" - ).encode("utf-8"), - } - for info in src.infolist(): - name = info.filename.replace("\\", "/") - lower = name.lower() - if name == ".env" or lower.endswith(".env.example"): + for path in sorted(RUNTIME_ROOT.rglob("*")): + if not path.is_file() or "__pycache__" in path.parts: continue - if (not include_admin_readme) and lower.endswith(".txt") and lower != "requirements.txt": - continue - dst.writestr(info, replacements.get(name, src.read(info.filename))) - dst.writestr(".env", env_text) + name = path.relative_to(RUNTIME_ROOT).as_posix() + dst.writestr(name, path.read_bytes()) + if include_env and env_text is not None: + dst.writestr(".env", env_text) if include_admin_readme: - dst.writestr( - "README_AUTO_SETUP.txt", - "This LSA4 tunnel-enabled Task Scheduler agent package was generated from Audit ERP Branch Storage Auto Setup.\n" - "The .env file is pre-filled for this branch storage node.\n\n" - "Recommended setup on branch storage computer:\n" - "1. Extract this ZIP to C:\\AuditFirmStorageAgent or any fixed folder.\n" - "2. Right-click Command Prompt / PowerShell and Run as Administrator.\n" - "3. Run check_config.bat.\n" - "4. Run install_task_scheduler.bat.\n" - "5. Check Documents -> Branch Storage Dashboard in ERP for Online/Tunnel status.\n" - ) + dst.writestr("README_ERP_LOCAL_AGENT.txt", f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\nStorage behaviour and D:\\AuditFirmStorage are preserved. Future compatible releases self-update securely from ERP.\n") return buffer.getvalue() + + +def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes: + return _build_zip(env_text=env_text, include_env=True, include_admin_readme=include_admin_readme) + + +def build_agent_update_zip() -> bytes: + # Update package deliberately excludes .env, .venv, data, logs and client files. + return _build_zip(env_text=None, include_env=False, include_admin_readme=False) diff --git a/app/modules/documents/local_agent_runtime/check_config.bat b/app/modules/documents/local_agent_runtime/check_config.bat new file mode 100644 index 0000000..0ce1d60 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/check_config.bat @@ -0,0 +1,10 @@ +@echo off +cd /d %~dp0 +if not exist .venv\Scripts\python.exe ( + py -3 -m venv .venv +) +call .venv\Scripts\activate.bat +python -m pip install --upgrade pip +pip install -r requirements.txt +python check_config.py +pause diff --git a/app/modules/documents/local_agent_runtime/check_config.py b/app/modules/documents/local_agent_runtime/check_config.py new file mode 100644 index 0000000..c391c51 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/check_config.py @@ -0,0 +1,21 @@ +from pathlib import Path +from audit_storage_agent.config import load_config +from audit_storage_agent.client import ERPClient +from audit_storage_agent.logger import setup_logger +from audit_storage_agent.storage_identity import ensure_storage_identity + +base_dir = Path(__file__).resolve().parent +logger = setup_logger(base_dir) +config = load_config(str(base_dir / ".env")) +logger.info("Configuration loaded successfully") +logger.info("ERP_BASE_URL=%s", config.erp_base_url) +logger.info("NODE_CODE=%s", config.node_code) +logger.info("STORAGE_ROOT=%s", config.storage_root) +logger.info("TENANT_ID=%s", config.tenant_id or "-") +logger.info("BRANCH_ID=%s", config.branch_id or "-") +identity_path = ensure_storage_identity(config) +logger.info("Storage identity file verified: %s", identity_path) +client = ERPClient(config) +response = client.heartbeat({"status": "CONFIG_TEST", "storage_root": str(config.storage_root), "agent_version": "0.2.0"}) +logger.info("ERP heartbeat test response: %s", response) +print("Configuration and ERP connection test completed successfully.") diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py new file mode 100644 index 0000000..212ad5f --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -0,0 +1,2 @@ +__version__ = "1.0.0" +AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/client.py b/app/modules/documents/local_agent_runtime/erp_local_agent/client.py new file mode 100644 index 0000000..67a433e --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/client.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Iterable +import requests + +from .config import AgentConfig + + +class ERPClient: + def __init__(self, config: AgentConfig): + self.config = config + self.session = requests.Session() + self.session.headers.update(config.headers) + + def _url(self, path: str) -> str: + return f"{self.config.erp_base_url}{path}" + + def heartbeat(self, payload: dict[str, Any]) -> dict[str, Any]: + response = self.session.post( + self._url("/documents/storage-agent/heartbeat"), + json=payload, + timeout=self.config.request_timeout_seconds, + ) + response.raise_for_status() + return response.json() if response.content else {"status": "ok"} + + def pending_storage_jobs(self) -> list[dict[str, Any]]: + response = self.session.get( + self._url("/documents/storage-agent/jobs/pending"), + timeout=self.config.request_timeout_seconds, + ) + response.raise_for_status() + data = response.json() + if isinstance(data, list): + return data + return data.get("jobs", []) + + def download_storage_job(self, job_id: int | str): + response = self.session.get( + self._url(f"/documents/storage-agent/jobs/{job_id}/download"), + stream=True, + timeout=self.config.request_timeout_seconds, + ) + response.raise_for_status() + return response + + def acknowledge_storage_job(self, job_id: int | str, payload: dict[str, Any]) -> dict[str, Any]: + response = self.session.post( + self._url(f"/documents/storage-agent/jobs/{job_id}/ack"), + json=payload, + timeout=self.config.request_timeout_seconds, + ) + response.raise_for_status() + return response.json() if response.content else {"status": "ok"} + + def pending_download_requests(self) -> list[dict[str, Any]]: + response = self.session.get( + self._url("/documents/storage-agent/download-requests/pending"), + timeout=self.config.request_timeout_seconds, + ) + response.raise_for_status() + data = response.json() + if isinstance(data, list): + return data + return data.get("requests", []) + + def upload_download_request_file(self, request_id: int | str, file_path: Path, extra: dict[str, Any]) -> dict[str, Any]: + with file_path.open("rb") as handle: + files = {"file": (file_path.name, handle, "application/octet-stream")} + data = {key: str(value) for key, value in extra.items() if value is not None} + response = self.session.post( + self._url(f"/documents/storage-agent/download-requests/{request_id}/upload"), + files=files, + data=data, + timeout=max(self.config.request_timeout_seconds, 300), + ) + response.raise_for_status() + return response.json() if response.content else {"status": "ok"} + + + def update_manifest(self) -> dict[str, Any]: + response = self.session.get( + self._url("/documents/erp-local-agent/update-manifest"), + timeout=self.config.request_timeout_seconds, + ) + response.raise_for_status() + return response.json() + + def download_update_package(self) -> bytes: + response = self.session.get( + self._url("/documents/erp-local-agent/update-package"), + timeout=max(self.config.request_timeout_seconds, 300), + ) + response.raise_for_status() + return response.content diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/config.py b/app/modules/documents/local_agent_runtime/erp_local_agent/config.py new file mode 100644 index 0000000..1fac679 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/config.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from urllib.parse import urlencode +from pathlib import Path +from dotenv import load_dotenv + +from . import __version__ + + +@dataclass(frozen=True) +class AgentConfig: + erp_base_url: str + node_code: str + node_secret: str + storage_root: Path + tenant_id: str | None = None + branch_id: str | None = None + poll_interval_seconds: int = 30 + download_request_interval_seconds: int = 30 + heartbeat_interval_seconds: int = 60 + request_timeout_seconds: int = 60 + max_retries: int = 3 + tunnel_enabled: bool = True + tunnel_reconnect_seconds: int = 10 + auto_update: bool = True + update_check_interval_seconds: int = 300 + + @property + def headers(self) -> dict[str, str]: + return { + "X-Node-Code": self.node_code, + "X-Node-Secret": self.node_secret, + "User-Agent": f"ERPLocalAgent/{__version__}", + } + + @property + def tunnel_url(self) -> str: + base = self.erp_base_url.rstrip("/") + if base.startswith("https://"): + ws_base = "wss://" + base[len("https://"):] + elif base.startswith("http://"): + ws_base = "ws://" + base[len("http://"):] + else: + ws_base = base + query = urlencode({"node_code": self.node_code, "node_secret": self.node_secret}) + return f"{ws_base}/documents/storage-agent/tunnel?{query}" + + +def _get_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None or str(raw).strip() == "": + return default + return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _get_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or str(raw).strip() == "": + return default + try: + return int(raw) + except ValueError as exc: + raise RuntimeError(f"Invalid integer for {name}: {raw!r}") from exc + + +def load_config(env_file: str | None = None) -> AgentConfig: + if env_file: + load_dotenv(env_file) + else: + load_dotenv() + + erp_base_url = os.getenv("ERP_BASE_URL", "").rstrip("/") + node_code = os.getenv("NODE_CODE", "").strip() + node_secret = os.getenv("NODE_SECRET", "").strip() + storage_root_raw = os.getenv("STORAGE_ROOT", "").strip() + tenant_id = os.getenv("TENANT_ID", os.getenv("AUDIT_FIRM_ID", "")).strip() or None + branch_id = os.getenv("BRANCH_ID", "").strip() or None + + missing = [] + if not erp_base_url: + missing.append("ERP_BASE_URL") + if not node_code: + missing.append("NODE_CODE") + if not node_secret: + missing.append("NODE_SECRET") + if not storage_root_raw: + missing.append("STORAGE_ROOT") + if missing: + raise RuntimeError("Missing required configuration: " + ", ".join(missing)) + + storage_root = Path(storage_root_raw).expanduser().resolve() + storage_root.mkdir(parents=True, exist_ok=True) + + return AgentConfig( + erp_base_url=erp_base_url, + node_code=node_code, + node_secret=node_secret, + storage_root=storage_root, + tenant_id=tenant_id, + branch_id=branch_id, + poll_interval_seconds=_get_int("POLL_INTERVAL_SECONDS", 30), + download_request_interval_seconds=_get_int("DOWNLOAD_REQUEST_INTERVAL_SECONDS", 30), + heartbeat_interval_seconds=_get_int("HEARTBEAT_INTERVAL_SECONDS", 60), + request_timeout_seconds=_get_int("REQUEST_TIMEOUT_SECONDS", 60), + max_retries=_get_int("MAX_RETRIES", 3), + tunnel_enabled=_get_bool("TUNNEL_ENABLED", True), + tunnel_reconnect_seconds=_get_int("TUNNEL_RECONNECT_SECONDS", 10), + auto_update=_get_bool("AUTO_UPDATE", True), + update_check_interval_seconds=max(60, _get_int("UPDATE_CHECK_INTERVAL_SECONDS", 300)), + ) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/db.py b/app/modules/documents/local_agent_runtime/erp_local_agent/db.py new file mode 100644 index 0000000..25188e1 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/db.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from datetime import datetime, timezone + + +class LocalDB: + def __init__(self, db_path: Path): + db_path.parent.mkdir(parents=True, exist_ok=True) + self.db_path = db_path + self._init() + + def connect(self): + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def _init(self) -> None: + with self.connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS processed_storage_jobs ( + job_id TEXT PRIMARY KEY, + local_path TEXT NOT NULL, + sha256 TEXT NOT NULL, + file_size INTEGER NOT NULL, + processed_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS processed_download_requests ( + request_id TEXT PRIMARY KEY, + local_path TEXT NOT NULL, + sha256 TEXT NOT NULL, + file_size INTEGER NOT NULL, + processed_at TEXT NOT NULL + ) + """ + ) + conn.commit() + + def record_storage_job(self, job_id: str, local_path: str, sha256: str, file_size: int) -> None: + with self.connect() as conn: + conn.execute( + """ + INSERT OR REPLACE INTO processed_storage_jobs + (job_id, local_path, sha256, file_size, processed_at) + VALUES (?, ?, ?, ?, ?) + """, + (str(job_id), local_path, sha256, int(file_size), datetime.now(timezone.utc).isoformat()), + ) + conn.commit() + + def record_download_request(self, request_id: str, local_path: str, sha256: str, file_size: int) -> None: + with self.connect() as conn: + conn.execute( + """ + INSERT OR REPLACE INTO processed_download_requests + (request_id, local_path, sha256, file_size, processed_at) + VALUES (?, ?, ?, ?, ?) + """, + (str(request_id), local_path, sha256, int(file_size), datetime.now(timezone.utc).isoformat()), + ) + conn.commit() diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/logger.py b/app/modules/documents/local_agent_runtime/erp_local_agent/logger.py new file mode 100644 index 0000000..e4607dc --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/logger.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + + +def setup_logger(base_dir: Path | None = None) -> logging.Logger: + root = base_dir or Path.cwd() + logs_dir = root / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger("audit_storage_agent") + logger.setLevel(logging.INFO) + logger.handlers.clear() + + formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s") + + console = logging.StreamHandler() + console.setFormatter(formatter) + logger.addHandler(console) + + file_handler = RotatingFileHandler(logs_dir / "agent.log", maxBytes=2_000_000, backupCount=5, encoding="utf-8") + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/main.py b/app/modules/documents/local_agent_runtime/erp_local_agent/main.py new file mode 100644 index 0000000..74b26a5 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/main.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import argparse +import asyncio +from pathlib import Path +import threading +import time + +from .client import ERPClient +from .config import load_config +from .db import LocalDB +from .logger import setup_logger +from .sync import StorageAgent +from .tunnel import StorageAgentTunnel +from .updater import AgentUpdater + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="ERP Local Agent - storage and local ERP tool runtime") + parser.add_argument("--env", default=None) + parser.add_argument("--once", action="store_true") + return parser + + +def main() -> int: + args = build_parser().parse_args() + root = Path.cwd() + logger = setup_logger(root) + try: + config = load_config(args.env) + db = LocalDB(root / "data" / "agent.db") + client = ERPClient(config) + agent = StorageAgent(config, client, db, logger) + updater = AgentUpdater(config, client, logger, root) + if args.once: + updater.maybe_update(force=True) + agent.run_once() + return 0 + def update_worker(): + while True: + updater.maybe_update() + time.sleep(max(60, config.update_check_interval_seconds)) + threading.Thread(target=update_worker, name="erp-local-agent-updater", daemon=True).start() + if config.tunnel_enabled: + asyncio.run(StorageAgentTunnel(agent).run_forever()) + else: + agent.run_forever() + return 0 + except KeyboardInterrupt: + logger.info("ERP Local Agent stopped by user") + return 0 + except Exception as exc: + logger.exception("ERP Local Agent failed: %s", exc) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/storage.py b/app/modules/documents/local_agent_runtime/erp_local_agent/storage.py new file mode 100644 index 0000000..90eeb79 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/storage.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import hashlib +import os +import re +from pathlib import Path +from typing import BinaryIO + +_SAFE_SEGMENT = re.compile(r"[^A-Za-z0-9._ -]+") + + +def sanitize_segment(value: str, fallback: str = "item") -> str: + value = (value or "").strip().replace("/", "_").replace("\\", "_") + value = _SAFE_SEGMENT.sub("_", value) + value = value.strip(" ._") + return value or fallback + + +def safe_relative_path(raw_path: str | None, fallback_filename: str) -> Path: + """Return a safe relative path, preventing path traversal outside storage root.""" + if raw_path: + parts = [] + for part in Path(str(raw_path).replace("\\", "/")).parts: + if part in ("", ".", ".."): + continue + parts.append(sanitize_segment(part)) + if parts: + return Path(*parts) + return Path(sanitize_segment(fallback_filename, "document.bin")) + + +def resolve_under_root(storage_root: Path, relative_path: Path) -> Path: + target = (storage_root / relative_path).resolve() + root = storage_root.resolve() + if os.path.commonpath([str(root), str(target)]) != str(root): + raise RuntimeError("Refusing to write/read outside storage root") + target.parent.mkdir(parents=True, exist_ok=True) + return target + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_stream_to_file(stream: BinaryIO, destination: Path) -> tuple[int, str]: + digest = hashlib.sha256() + total = 0 + tmp = destination.with_suffix(destination.suffix + ".part") + with tmp.open("wb") as out: + while True: + chunk = stream.read(1024 * 1024) + if not chunk: + break + out.write(chunk) + digest.update(chunk) + total += len(chunk) + tmp.replace(destination) + return total, digest.hexdigest() diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/storage_identity.py b/app/modules/documents/local_agent_runtime/erp_local_agent/storage_identity.py new file mode 100644 index 0000000..9d26706 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/storage_identity.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +IDENTITY_FILENAME = ".audit_storage_node.json" + + +@dataclass(frozen=True) +class StorageIdentity: + erp_base_url: str + node_code: str + tenant_id: str | None + branch_id: str | None + storage_root: str + agent_identity_version: int = 1 + + +def _normalise(value: Any) -> str: + return str(value or "").strip().rstrip("/") + + +def _normalise_optional(value: Any) -> str | None: + value = str(value or "").strip() + return value or None + + +def identity_path(storage_root: Path) -> Path: + return storage_root / IDENTITY_FILENAME + + +def build_identity(config) -> StorageIdentity: + return StorageIdentity( + erp_base_url=_normalise(config.erp_base_url), + node_code=_normalise(config.node_code), + tenant_id=_normalise_optional(getattr(config, "tenant_id", None)), + branch_id=_normalise_optional(getattr(config, "branch_id", None)), + storage_root=str(config.storage_root.resolve()), + ) + + +def ensure_storage_identity(config, *, allow_initialise: bool = True) -> Path: + """Create/validate one identity file in the selected storage root. + + This protects the branch storage folder from accidentally being reused by a + different ERP node/branch, and gives the branch one stable storage root. + It does not scan the whole machine; ERP still remains the final authority + for enforcing one active node and one configured root per branch. + """ + root = config.storage_root.resolve() + root.mkdir(parents=True, exist_ok=True) + path = identity_path(root) + expected = build_identity(config) + + if not path.exists(): + if not allow_initialise: + raise RuntimeError( + f"Storage identity file is missing at {path}. Initialise the branch storage root first." + ) + path.write_text(json.dumps(asdict(expected), indent=2), encoding="utf-8") + return path + + try: + existing = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + raise RuntimeError(f"Storage identity file is unreadable/corrupt: {path}") from exc + + checks = { + "erp_base_url": expected.erp_base_url, + "node_code": expected.node_code, + "tenant_id": expected.tenant_id, + "branch_id": expected.branch_id, + } + conflicts: list[str] = [] + for key, expected_value in checks.items(): + existing_value = existing.get(key) + # Older packages may not have tenant_id/branch_id. Allow blank existing + # values to be upgraded below, but never allow contradictory values. + if key in {"tenant_id", "branch_id"} and not existing_value: + continue + if str(existing_value or "").strip().rstrip("/") != str(expected_value or "").strip().rstrip("/"): + conflicts.append(f"{key}: existing={existing_value!r}, expected={expected_value!r}") + + if conflicts: + raise RuntimeError( + "This storage folder is already linked to a different ERP storage node/branch. " + "Use the existing branch storage package or reset/change the storage root from ERP. " + + "; ".join(conflicts) + ) + + # Backfill missing tenant/branch fields and refresh resolved root path. + changed = False + updated = dict(existing) + for key, value in asdict(expected).items(): + if updated.get(key) in (None, "") and value not in (None, ""): + updated[key] = value + changed = True + if updated.get("storage_root") != expected.storage_root: + updated["storage_root"] = expected.storage_root + changed = True + if changed: + path.write_text(json.dumps(updated, indent=2), encoding="utf-8") + return path diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/sync.py b/app/modules/documents/local_agent_runtime/erp_local_agent/sync.py new file mode 100644 index 0000000..a0428ed --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/sync.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import shutil +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .client import ERPClient +from .config import AgentConfig +from .db import LocalDB +from .storage import resolve_under_root, safe_relative_path, sha256_file, sanitize_segment, write_stream_to_file +from .storage_identity import ensure_storage_identity +from . import __version__ + + +class StorageAgent: + def __init__(self, config: AgentConfig, client: ERPClient, db: LocalDB, logger): + self.config = config + self.client = client + self.db = db + self.logger = logger + self.last_heartbeat = 0.0 + self.identity_file = ensure_storage_identity(config) + self.logger.info("Storage root identity verified: %s", self.identity_file) + + def run_forever(self) -> None: + self.logger.info("Starting ERP Local Agent for node=%s", self.config.node_code) + while True: + self.run_once() + time.sleep(max(5, self.config.poll_interval_seconds)) + + def run_once(self) -> None: + self._maybe_heartbeat() + self.process_storage_jobs() + self.process_download_requests() + + def _maybe_heartbeat(self, force: bool = False) -> None: + now = time.time() + if not force and now - self.last_heartbeat < self.config.heartbeat_interval_seconds: + return + try: + total, used, free = shutil.disk_usage(self.config.storage_root) + payload = { + "status": "ONLINE", + "storage_root": str(self.config.storage_root), + "total_bytes": total, + "used_bytes": used, + "free_bytes": free, + "agent_version": __version__, + "agent_name": "ERP Local Agent", + "capabilities": ["storage"], + "agent_time_utc": datetime.now(timezone.utc).isoformat(), + } + self.client.heartbeat(payload) + self.last_heartbeat = now + self.logger.info("Heartbeat sent. free_gb=%.2f", free / (1024 ** 3)) + except Exception as exc: + self.logger.exception("Heartbeat failed: %s", exc) + + def process_storage_jobs(self) -> None: + try: + jobs = self.client.pending_storage_jobs() + except Exception as exc: + self.logger.exception("Could not fetch pending storage jobs: %s", exc) + return + + self.process_storage_jobs_from_payload(jobs) + + def process_storage_jobs_from_payload(self, jobs: list[dict[str, Any]]) -> None: + if not jobs: + return + self.logger.info("Found %s pending storage job(s)", len(jobs)) + for job in jobs: + try: + self._process_storage_job(job) + except Exception as exc: + self.logger.exception("Storage job failed job=%s error=%s", job.get("id") or job.get("job_id"), exc) + + def _process_storage_job(self, job: dict[str, Any]) -> None: + job_id = job.get("id") or job.get("job_id") + if job_id is None: + raise RuntimeError(f"Storage job missing id: {job}") + + original_filename = job.get("original_filename") or job.get("filename") or f"job_{job_id}.bin" + relative_path_raw = job.get("local_relative_path") or job.get("relative_path") + relative_path = safe_relative_path(relative_path_raw, original_filename) + destination = resolve_under_root(self.config.storage_root, relative_path) + + self.logger.info("Downloading storage job=%s to %s", job_id, destination) + response = self.client.download_storage_job(job_id) + file_size, digest = write_stream_to_file(response.raw, destination) + + payload = { + "status": "COMPLETED", + "sha256_hash": digest, + "file_hash": digest, + "file_size": file_size, + "local_relative_path": str(relative_path).replace("\\", "/"), + "stored_at_utc": datetime.now(timezone.utc).isoformat(), + } + self.client.acknowledge_storage_job(job_id, payload) + self.db.record_storage_job(str(job_id), str(relative_path).replace("\\", "/"), digest, file_size) + self.logger.info("Storage job completed job=%s size=%s sha256=%s", job_id, file_size, digest) + + def process_download_requests(self) -> None: + try: + requests_ = self.client.pending_download_requests() + except Exception as exc: + self.logger.exception("Could not fetch pending download requests: %s", exc) + return + + self.process_download_requests_from_payload(requests_) + + def process_download_requests_from_payload(self, requests_: list[dict[str, Any]]) -> None: + if not requests_: + return + self.logger.info("Found %s pending download request(s)", len(requests_)) + for request in requests_: + try: + self._process_download_request(request) + except Exception as exc: + self.logger.exception("Download request failed request=%s error=%s", request.get("id") or request.get("request_id"), exc) + + def _process_download_request(self, request: dict[str, Any]) -> None: + request_id = request.get("id") or request.get("request_id") + if request_id is None: + raise RuntimeError(f"Download request missing id: {request}") + + relative_path_raw = request.get("local_relative_path") or request.get("relative_path") + filename = request.get("original_filename") or request.get("filename") or f"download_request_{request_id}.bin" + relative_path = safe_relative_path(relative_path_raw, filename) + local_file = resolve_under_root(self.config.storage_root, relative_path) + if not local_file.exists() or not local_file.is_file(): + raise FileNotFoundError(f"Requested file is not found locally: {local_file}") + + file_size = local_file.stat().st_size + digest = sha256_file(local_file) + expected_hash = request.get("sha256_hash") or request.get("file_hash") + if expected_hash and str(expected_hash).lower() != digest.lower(): + raise RuntimeError(f"SHA256 mismatch before upload. expected={expected_hash} actual={digest}") + + self.logger.info("Uploading file for download request=%s path=%s", request_id, local_file) + extra = { + "sha256_hash": digest, + "file_hash": digest, + "file_size": file_size, + "local_relative_path": str(relative_path).replace("\\", "/"), + } + self.client.upload_download_request_file(request_id, local_file, extra) + self.db.record_download_request(str(request_id), str(relative_path).replace("\\", "/"), digest, file_size) + self.logger.info("Download request fulfilled request=%s size=%s sha256=%s", request_id, file_size, digest) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py new file mode 100644 index 0000000..790b5e3 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any + +import websockets + +from .sync import StorageAgent + + +class StorageAgentTunnel: + def __init__(self, agent: StorageAgent): + self.agent = agent + self.config = agent.config + self.logger = agent.logger + + async def run_forever(self) -> None: + self.logger.info("Starting tunnel mode for node=%s", self.config.node_code) + while True: + try: + await self._connect_once() + except KeyboardInterrupt: + raise + except Exception as exc: + self.logger.exception("Tunnel disconnected/error: %s", exc) + await asyncio.sleep(max(5, self.config.tunnel_reconnect_seconds)) + + async def _connect_once(self) -> None: + async with websockets.connect( + self.config.tunnel_url, + ping_interval=30, + ping_timeout=30, + close_timeout=10, + max_size=1024 * 1024, + ) as websocket: + await websocket.send(self._json_text({"type": "ready", "agent_time_utc": self._now()})) + self.agent._maybe_heartbeat(force=True) + async for raw in websocket: + message = self._parse_json(raw) + if not isinstance(message, dict): + continue + msg_type = message.get("type") + if msg_type == "connected": + self.logger.info("Tunnel connected: %s", message.get("node_code")) + continue + if msg_type == "sync": + jobs = message.get("jobs") or [] + requests_ = message.get("download_requests") or message.get("requests") or [] + await asyncio.to_thread(self.agent.process_storage_jobs_from_payload, jobs) + await asyncio.to_thread(self.agent.process_download_requests_from_payload, requests_) + await websocket.send(self._json_text({ + "type": "agent_status", + "jobs_seen": len(jobs), + "requests_seen": len(requests_), + "agent_time_utc": self._now(), + })) + continue + if msg_type == "error": + self.logger.error("Tunnel server error: %s", message.get("error")) + + def _now(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _json_text(self, payload: dict[str, Any]) -> str: + import json + return json.dumps(payload, separators=(",", ":")) + + def _parse_json(self, raw: str) -> Any: + import json + try: + return json.loads(raw) + except Exception: + return None diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py b/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py new file mode 100644 index 0000000..d9386d1 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import time +import zipfile + +from . import __version__ + + +class AgentUpdater: + def __init__(self, config, client, logger, install_dir: Path): + self.config = config + self.client = client + self.logger = logger + self.install_dir = install_dir.resolve() + self.last_check = 0.0 + self.busy = False + + def maybe_update(self, force: bool = False) -> bool: + if not self.config.auto_update or self.busy: + return False + now = time.time() + if not force and now - self.last_check < self.config.update_check_interval_seconds: + return False + self.last_check = now + try: + manifest = self.client.update_manifest() + latest = str(manifest.get("latest_version") or "").strip() + if not latest or latest == __version__: + return False + expected = str(manifest.get("sha256") or "").lower().strip() + package = self.client.download_update_package() + actual = hashlib.sha256(package).hexdigest().lower() + if len(expected) != 64 or actual != expected: + raise RuntimeError("ERP Local Agent update package SHA256 verification failed.") + self._stage_and_restart(latest, package) + return True + except Exception as exc: + self.logger.exception("ERP Local Agent update check failed: %s", exc) + return False + + def _stage_and_restart(self, latest: str, package: bytes) -> None: + self.busy = True + updates = self.install_dir / "updates" + updates.mkdir(parents=True, exist_ok=True) + safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update" + package_path = updates / f"ERP_Local_Agent_{safe}.zip" + package_path.write_bytes(package) + staged = updates / f"staged_{safe}" + shutil.rmtree(staged, ignore_errors=True) + staged.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(package_path, "r") as archive: + archive.extractall(staged) + if not (staged / "erp_local_agent" / "__init__.py").exists(): + raise RuntimeError("ERP Local Agent update package is incomplete.") + script_path = updates / f"apply_{safe}.ps1" + script_path.write_text(self._powershell_update_script(staged), encoding="utf-8") + flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0) + subprocess.Popen( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path), "-ParentPid", str(os.getpid())], + cwd=str(self.install_dir), creationflags=flags, close_fds=True, + ) + self.logger.warning("ERP Local Agent update %s staged; restarting.", latest) + os._exit(0) + + def _powershell_update_script(self, staged: Path) -> str: + install = str(self.install_dir).replace("'", "''") + stage = str(staged.resolve()).replace("'", "''") + lines = [ + "param([int]$ParentPid)", + '$ErrorActionPreference = "Stop"', + "$InstallDir = '" + install + "'", + "$StagedDir = '" + stage + "'", + "$TaskName = 'ERP Local Agent'", + "$BackupDir = Join-Path $InstallDir ('updates\\backup_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))", + "try {", + " if ($ParentPid -gt 0) { try { Wait-Process -Id $ParentPid -Timeout 120 -ErrorAction SilentlyContinue } catch {} }", + " try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch {}", + " New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null", + " if (Test-Path (Join-Path $InstallDir 'erp_local_agent')) { Copy-Item (Join-Path $InstallDir 'erp_local_agent') -Destination $BackupDir -Recurse -Force }", + " Remove-Item (Join-Path $InstallDir 'erp_local_agent') -Recurse -Force -ErrorAction SilentlyContinue", + " Copy-Item (Join-Path $StagedDir 'erp_local_agent') -Destination $InstallDir -Recurse -Force", + " foreach ($Item in @('requirements.txt','run_agent.bat','run_once.bat','check_config.py','check_config.bat','start_task_scheduler.bat','stop_task_scheduler.bat','status_task_scheduler.bat','install_task_scheduler.bat','uninstall_task_scheduler.bat')) { $Source=Join-Path $StagedDir $Item; if (Test-Path $Source) { Copy-Item $Source -Destination (Join-Path $InstallDir $Item) -Force } }", + " $Python = Join-Path $InstallDir '.venv\\Scripts\\python.exe'", + " if (-not (Test-Path $Python)) { throw 'ERP Local Agent virtual environment is missing.' }", + " & $Python -m pip install -r (Join-Path $InstallDir 'requirements.txt')", + " if ($LASTEXITCODE -ne 0) { throw 'Dependency update failed.' }", + " Start-ScheduledTask -TaskName $TaskName", + "} catch {", + " try { if (Test-Path (Join-Path $BackupDir 'erp_local_agent')) { Remove-Item (Join-Path $InstallDir 'erp_local_agent') -Recurse -Force -ErrorAction SilentlyContinue; Copy-Item (Join-Path $BackupDir 'erp_local_agent') -Destination $InstallDir -Recurse -Force }; Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch {}", + " New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'logs') | Out-Null", + " Add-Content -Path (Join-Path $InstallDir 'logs\\update_error.log') -Value ((Get-Date).ToString('s') + ' ' + $_.Exception.Message)", + " exit 1", + "}", + ] + return "\r\n".join(lines) + "\r\n" diff --git a/app/modules/documents/local_agent_runtime/install_task_scheduler.bat b/app/modules/documents/local_agent_runtime/install_task_scheduler.bat new file mode 100644 index 0000000..dd589e6 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/install_task_scheduler.bat @@ -0,0 +1,21 @@ +@echo off +setlocal +cd /d "%~dp0" +if not exist ".env" ( echo ERROR: .env file is missing. Download the configured ERP Local Agent package from ERP. & pause & exit /b 1 ) +if not exist ".venv\Scripts\python.exe" ( py -3 -m venv .venv & if errorlevel 1 ( echo ERROR: Unable to create Python virtual environment. & pause & exit /b 1 ) ) +".venv\Scripts\python.exe" -m pip install --upgrade pip +if errorlevel 1 goto :failed +".venv\Scripts\python.exe" -m pip install -r requirements.txt +if errorlevel 1 goto :failed +set "AGENT_DIR=%~dp0" +set "AGENT_PYTHON=%~dp0.venv\Scripts\python.exe" +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; try { Stop-ScheduledTask -TaskName 'AuditFirmStorageAgent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHON; $a=New-ScheduledTaskAction -Execute $p -Argument '-m erp_local_agent.main' -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $s -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'" +if errorlevel 1 goto :failed +echo ERP Local Agent installed and started successfully. +echo Future compatible releases will update automatically from ERP. +pause +exit /b 0 +:failed +echo ERROR: ERP Local Agent installation failed. Run this file as Administrator. +pause +exit /b 1 diff --git a/app/modules/documents/local_agent_runtime/requirements.txt b/app/modules/documents/local_agent_runtime/requirements.txt new file mode 100644 index 0000000..2ce79e4 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/requirements.txt @@ -0,0 +1,3 @@ +requests==2.32.3 +python-dotenv==1.0.1 +websockets==12.0 diff --git a/app/modules/documents/local_agent_runtime/run_agent.bat b/app/modules/documents/local_agent_runtime/run_agent.bat new file mode 100644 index 0000000..a60871d --- /dev/null +++ b/app/modules/documents/local_agent_runtime/run_agent.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +".venv\Scripts\python.exe" -m erp_local_agent.main diff --git a/app/modules/documents/local_agent_runtime/run_once.bat b/app/modules/documents/local_agent_runtime/run_once.bat new file mode 100644 index 0000000..06862a1 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/run_once.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +".venv\Scripts\python.exe" -m erp_local_agent.main --once diff --git a/app/modules/documents/local_agent_runtime/start_task_scheduler.bat b/app/modules/documents/local_agent_runtime/start_task_scheduler.bat new file mode 100644 index 0000000..dcd3414 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/start_task_scheduler.bat @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Start-ScheduledTask -TaskName 'ERP Local Agent'" diff --git a/app/modules/documents/local_agent_runtime/status_task_scheduler.bat b/app/modules/documents/local_agent_runtime/status_task_scheduler.bat new file mode 100644 index 0000000..209f927 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/status_task_scheduler.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Get-ScheduledTask -TaskName 'ERP Local Agent' | Get-ScheduledTaskInfo | Format-List *" +pause diff --git a/app/modules/documents/local_agent_runtime/stop_task_scheduler.bat b/app/modules/documents/local_agent_runtime/stop_task_scheduler.bat new file mode 100644 index 0000000..8d132aa --- /dev/null +++ b/app/modules/documents/local_agent_runtime/stop_task_scheduler.bat @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Stop-ScheduledTask -TaskName 'ERP Local Agent'" diff --git a/app/modules/documents/local_agent_runtime/uninstall_task_scheduler.bat b/app/modules/documents/local_agent_runtime/uninstall_task_scheduler.bat new file mode 100644 index 0000000..7aa43b8 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/uninstall_task_scheduler.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue; Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue" +pause diff --git a/app/modules/documents/templates/documents/storage_nodes.html b/app/modules/documents/templates/documents/storage_nodes.html index 22a5e8b..318372b 100644 --- a/app/modules/documents/templates/documents/storage_nodes.html +++ b/app/modules/documents/templates/documents/storage_nodes.html @@ -3,9 +3,10 @@
-

Branch Local Storage

-

Partner/branch controlled storage setup for engagement document streaming.

+

ERP Local Agent

+

Branch-local ERP runtime. Existing storage remains unchanged; future Tally/GST/local tools will use the same agent.

Scope: {{ storage_scope_title or '-' }}

+

ERP Local Agent release: {{ erp_local_agent_version or '-' }} · automatic updates enabled

Back to Documents @@ -41,13 +42,13 @@ {% if request.query_params.get('created') == '1' %}
- Storage node created. Use the Download Agent Package button in the node row to generate the branch package. Secret and standalone .env are not displayed. + Storage node created. Use the Download ERP Local Agent button in the node row to generate the branch package. Secret and standalone .env are not displayed.
{% endif %} {% if request.query_params.get('error') == 'env_download_disabled' %}
- Standalone .env download is disabled for security. Download the pre-configured agent ZIP instead. + Standalone .env download is disabled for security. Download the pre-configured ERP Local Agent ZIP instead.
{% endif %} @@ -79,7 +80,7 @@

For a new branch setup only. Existing branch nodes keep their already configured root.

- +
@@ -123,7 +124,7 @@
-

Configured storage nodes

+

Configured ERP Local Agents

Partners/Branch Managers see only their managed branch. One branch should have one active node and one fixed storage root. Existing node secrets are not shown again.

@@ -147,7 +148,7 @@ - +
diff --git a/app/modules/documents/ui.py b/app/modules/documents/ui.py index 6b7ba3b..6855c74 100644 --- a/app/modules/documents/ui.py +++ b/app/modules/documents/ui.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from datetime import date, datetime, timezone import asyncio @@ -80,7 +80,7 @@ from app.modules.documents.services import ( from app.modules.services.models import ClientServiceSubscription from app.modules.core.tenancy.models import Branch, Tenant from app.modules.core.tenancy.year_control import is_row_financial_year_locked -from app.modules.documents.agent_package import build_agent_env, build_preconfigured_agent_zip +from app.modules.documents.agent_package import ERP_LOCAL_AGENT_VERSION, build_agent_env, build_preconfigured_agent_zip, build_agent_update_zip from app.modules.documents.models import BranchStorageNode from app.modules.services.task_documents import ( @@ -1073,7 +1073,7 @@ def _default_node_name(db, tenant_id: int, branch_id: int | None) -> str: def _agent_download_filename(node_code: str, suffix: str) -> str: safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", node_code or "storage_node") - return f"AuditFirmStorageAgent_{safe}{suffix}" + return f"ERPLocalAgent_{safe}{suffix}" def _find_existing_storage_node(db, tenant_id: int, branch_id: int | None) -> BranchStorageNode | None: @@ -1226,7 +1226,7 @@ def _branch_name_map(db, branches=None): def _storage_scope_title(user, scope) -> str: if scope.is_system_admin: - return "All audit firms — monitoring only" + return "All audit firms — monitoring only" if scope.is_firm_admin: return "All branches of your audit firm" if _is_partner_branch_storage_scope(scope): @@ -1282,6 +1282,7 @@ def storage_nodes(request: Request): recent_download_requests=requests, generated_secret=None, storage_scope_title=_storage_scope_title(user, scope), + erp_local_agent_version=ERP_LOCAL_AGENT_VERSION, can_manage_branch_storage=_can_manage_branch_storage(scope), forced_branch_id=branch_filter, ) @@ -1575,6 +1576,33 @@ def _agent_auth(db, request: Request, x_node_code: str | None, x_node_secret: st return node, None +@router.get("/erp-local-agent/update-manifest") +def erp_local_agent_update_manifest(request: Request, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)): + import hashlib + db = CommonSessionLocal() + try: + node, error = _agent_auth(db, request, x_node_code, x_node_secret) + if error: + return error + package = build_agent_update_zip() + db.commit() + return {"ok": True, "agent_name": "ERP Local Agent", "latest_version": ERP_LOCAL_AGENT_VERSION, "sha256": hashlib.sha256(package).hexdigest(), "size_bytes": len(package), "download_url": "/documents/erp-local-agent/update-package", "channel": "stable"} + finally: + db.close() + +@router.get("/erp-local-agent/update-package") +def erp_local_agent_update_package(request: Request, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)): + db = CommonSessionLocal() + try: + node, error = _agent_auth(db, request, x_node_code, x_node_secret) + if error: + return error + package = build_agent_update_zip() + db.commit() + filename = f"ERP_Local_Agent_{ERP_LOCAL_AGENT_VERSION}.zip" + return Response(package, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}"'}) + finally: + db.close() @router.post("/storage-agent/heartbeat") async def storage_agent_heartbeat(request: Request, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)): db = CommonSessionLocal() @@ -1891,3 +1919,4 @@ def delete_document(request: Request, document_id: int, csrf_token: str = Form(. return RedirectResponse(url=f"/documents/engagements/{document.engagement_id}?deleted=1", status_code=303) finally: db.close() + diff --git a/app/ui/templates/base/layout.html b/app/ui/templates/base/layout.html index f369c3c..8dbf15f 100644 --- a/app/ui/templates/base/layout.html +++ b/app/ui/templates/base/layout.html @@ -1,4 +1,4 @@ - + @@ -108,7 +108,7 @@ {% set is_system_admin_user = full_auth and ("System Admin" in ui_roles) %} {% set can_manage_local_storage_agent = full_auth and can_view_documents(current_user, ui_perms, ui_roles) and can_upload_documents(current_user, ui_perms, ui_roles) and (ui_roles|select("in", ["Firm Admin", "Partner", "Branch Manager"])|list|length > 0) %} - {# UI/UX V2 Phase 1G — Global Role Navigation Host. + {# UI/UX V2 Phase 1G — Global Role Navigation Host. Role navigation is rendered once, immediately below the shared header, so it remains available on dashboards, shared modules, forms, detail pages and submenu pages. Existing page-level navigation includes are suppressed by the @@ -165,7 +165,7 @@ {% if firm_branding.logo_url %}{{ current_firm_name }} logo{% else %}
{{ (current_firm_name[:2] if current_firm_name else 'AF')|upper }}
{% endif %}
{{ current_firm_name }}
-
{% if full_auth %}{{ current_branch_name }}{% if current_branch_name and current_branch_name != "-" %} Branch{% endif %} • Workspace{% elif domain_context.is_resolved %}{{ current_branch_name or 'Domain Workspace' }}{% else %}Secure Practice Workspace{% endif %}
+
{% if full_auth %}{{ current_branch_name }}{% if current_branch_name and current_branch_name != "-" %} Branch{% endif %} • Workspace{% elif domain_context.is_resolved %}{{ current_branch_name or 'Domain Workspace' }}{% else %}Secure Practice Workspace{% endif %}
@@ -210,7 +210,7 @@
My Workspace - + ›
{% if can_view_employee_portal(current_user, ui_perms, ui_roles) %} @@ -243,7 +243,7 @@
Partner Workspace - + ›
Overview @@ -258,7 +258,7 @@
Team Workspace - + ›
{% if can_manage_employee_work(current_user, ui_perms, ui_roles) %} @@ -283,7 +283,7 @@
Billing - + ›
Invoices @@ -301,7 +301,7 @@
Team Administration - + ›
{% if can_view_employee_dashboard(current_user, ui_perms, ui_roles) %}HR Dashboard{% endif %} @@ -339,7 +339,7 @@
Firm Administration - + ›
{% if "Firm Admin" in ui_roles or is_system_admin_user %} @@ -387,7 +387,7 @@ {% endif %} {% if can_manage_local_storage_agent %}
Local Storage
- Local Storage Agent + ERP Local Agent {% endif %}
@@ -397,7 +397,7 @@
Platform - + ›
Domain Configuration
@@ -419,7 +419,7 @@
Core Setup - + ›
{% if can_view_settings(current_user, ui_perms, ui_roles) %} @@ -490,11 +490,11 @@
{{ current_user.full_name or current_user.email }}
{{ current_user.email }}
{% if current_user.qualification or current_user.designation %} -
{{ current_user.qualification or '' }}{% if current_user.qualification and current_user.designation %} • {% endif %}{{ current_user.designation or '' }}
+
{{ current_user.qualification or '' }}{% if current_user.qualification and current_user.designation %} • {% endif %}{{ current_user.designation or '' }}
{% endif %}
Your Firm: {{ current_firm_name }} - • Branch: {{ current_branch_name }}{% if active_financial_year %} • FY: {{ active_financial_year }}{% endif %} + • Branch: {{ current_branch_name }}{% if active_financial_year %} • FY: {{ active_financial_year }}{% endif %}
{% include "ui/templates/components/workspace_switcher.html" %}
@@ -517,7 +517,7 @@
{% else %} {% if domain_context.is_resolved %} -
{{ current_firm_name }}{% if current_branch_name %} • {{ current_branch_name }}{% endif %}
+
{{ current_firm_name }}{% if current_branch_name %} • {{ current_branch_name }}{% endif %}
{% endif %} Login {% endif %} @@ -566,7 +566,7 @@
Active Scope: {{ current_firm_name }} - • {{ current_branch_name if active_branch_id else "All Branches" }}{% if active_financial_year %} • FY {{ active_financial_year }}{% endif %} + • {{ current_branch_name if active_branch_id else "All Branches" }}{% if active_financial_year %} • FY {{ active_financial_year }}{% endif %}
{% endif %} @@ -650,7 +650,7 @@ wrapper.className = "overflow-hidden rounded-2xl border shadow-soft " + priorityClasses(alert.priority); wrapper.innerHTML = `
-
🔔
+
🔔
New Alert
@@ -771,3 +771,4 @@ +