Add staff visibility workstation enrollment registration vault and category task views

This commit is contained in:
A R R R Associates
2026-09-02 14:05:20 +05:30
parent 1fe1597d6f
commit 97777e13a1
22 changed files with 538 additions and 103 deletions
@@ -0,0 +1,23 @@
"""workstation-specific Local Agent enrollment secret
Revision ID: 20260902_ws_enrollment_secret
Revises: 20260831_task_tool_result_bridge
"""
from alembic import op
import sqlalchemy as sa
revision = "20260902_ws_enrollment_secret"
down_revision = "20260831_task_tool_result_bridge"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"erp_workstation_agents",
sa.Column("workstation_secret_hash", sa.String(length=64), nullable=True),
)
def downgrade():
op.drop_column("erp_workstation_agents", "workstation_secret_hash")
@@ -1 +1,74 @@
{% extends "base/layout.html" %}{% block content %}<div class="mx-auto max-w-4xl p-4 sm:p-6"><h1 class="mb-5 text-2xl font-bold">Add encrypted credential</h1><form method="post" class="grid gap-4 rounded-xl bg-white p-6 shadow sm:grid-cols-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><label class="sm:col-span-2">Title<input required name="title" class="mt-1 w-full rounded-lg border p-2"></label><label>Category<select name="category" class="mt-1 w-full rounded-lg border p-2"><option value="government_portal">Government portal</option><option value="banking">Banking</option><option value="email">Email</option><option value="software">Software</option><option value="api_key">API key</option><option value="digital_signature">Digital signature</option><option value="other">Other</option></select></label><label>Sensitivity<select name="sensitivity" class="mt-1 w-full rounded-lg border p-2"><option>high</option><option>critical</option><option>standard</option></select></label><label>Client<select name="client_id" class="mt-1 w-full rounded-lg border p-2"><option value="">Firm-level credential</option>{% for c in clients %}<option value="{{ c.id }}">{{ c.client_name }}</option>{% endfor %}</select></label><label>Registration record<select name="registration_id" class="mt-1 w-full rounded-lg border p-2"><option value="">Not linked</option>{% for r in registrations %}<option value="{{ r.id }}">{{ r.registration_number }}</option>{% endfor %}</select></label><label class="sm:col-span-2">Portal URL<input name="portal_url" type="url" class="mt-1 w-full rounded-lg border p-2"></label><label>Reference number<input name="reference_number" class="mt-1 w-full rounded-lg border p-2"></label><label>Username/login ID<input name="username" autocomplete="off" class="mt-1 w-full rounded-lg border p-2"></label><label>Secret/password/token<input required name="secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2"></label><label>Additional secret/PIN<input name="additional_secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2"></label><label>Expires on<input name="expires_on" type="date" class="mt-1 w-full rounded-lg border p-2"></label><label>Rotation due on<input name="rotation_due_on" type="date" class="mt-1 w-full rounded-lg border p-2"></label><label>Owner<select name="owner_user_id" class="mt-1 w-full rounded-lg border p-2"><option value="">Current user</option>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></label><fieldset><legend>Explicit staff access</legend><div class="mt-1 max-h-32 overflow-auto rounded-lg border p-2">{% for u in users %}<label class="block text-sm"><input type="checkbox" name="allowed_user_ids" value="{{ u.id }}"> {{ u.full_name or u.email }}</label>{% endfor %}</div></fieldset><label class="sm:col-span-2">Encrypted notes<textarea name="notes" rows="3" class="mt-1 w-full rounded-lg border p-2"></textarea></label><div class="sm:col-span-2 flex gap-2"><button class="rounded-lg bg-brand-600 px-4 py-2 font-semibold text-white">Save securely</button><a href="/credential-vault" class="rounded-lg border px-4 py-2">Cancel</a></div></form></div>{% endblock %}
{% extends "base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-4xl p-4 sm:p-6">
<div class="mb-5">
<h1 class="text-2xl font-bold">Add encrypted credential</h1>
{% if selected_registration %}
<p class="mt-1 text-sm text-slate-600">Linked to registration <strong>{{ selected_registration.registration_number }}</strong>. Secrets remain encrypted and reveal access is audited.</p>
{% endif %}
</div>
<form method="post" class="grid gap-4 rounded-xl bg-white p-6 shadow sm:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="sm:col-span-2">Title<input required name="title" class="mt-1 w-full rounded-lg border p-2" placeholder="e.g. GST Portal - Main Login"></label>
<label>Credential type
<select name="category" class="mt-1 w-full rounded-lg border p-2">
<option value="gst_portal">GST Portal</option>
<option value="income_tax_portal">Income Tax Portal</option>
<option value="traces_tds">TRACES / TDS</option>
<option value="mca_portal">MCA Portal</option>
<option value="eway_bill">E-Way Bill</option>
<option value="einvoice">E-Invoice</option>
<option value="government_portal">Other Government Portal</option>
<option value="banking">Banking</option>
<option value="email">Email</option>
<option value="software">Software</option>
<option value="api_key">API key</option>
<option value="digital_signature">Digital signature</option>
<option value="other">Other</option>
</select>
</label>
<label>Sensitivity<select name="sensitivity" class="mt-1 w-full rounded-lg border p-2"><option>high</option><option>critical</option><option>standard</option></select></label>
<label>Client
<select name="client_id" id="vault-client" class="mt-1 w-full rounded-lg border p-2">
<option value="">Firm-level credential</option>
{% for c in clients %}<option value="{{ c.id }}" {% if selected_client_id and c.id == selected_client_id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}
</select>
</label>
<label>Registration record
<select name="registration_id" id="vault-registration" class="mt-1 w-full rounded-lg border p-2">
<option value="">Not linked</option>
{% for r in registrations %}<option value="{{ r.id }}" data-client-id="{{ r.client_id }}" {% if selected_registration_id and r.id == selected_registration_id %}selected{% endif %}>{{ r.registration_number }}</option>{% endfor %}
</select>
</label>
<label class="sm:col-span-2">Portal URL<input name="portal_url" type="url" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Reference number<input name="reference_number" class="mt-1 w-full rounded-lg border p-2" placeholder="GSTIN / TAN / CIN / login reference"></label>
<label>Username/login ID<input name="username" autocomplete="off" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Secret/password/token<input required name="secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Additional secret/PIN<input name="additional_secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Expires on<input name="expires_on" type="date" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Rotation due on<input name="rotation_due_on" type="date" class="mt-1 w-full rounded-lg border p-2"></label>
<label>Owner<select name="owner_user_id" class="mt-1 w-full rounded-lg border p-2"><option value="">Current user</option>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></label>
<fieldset><legend>Explicit staff access</legend><div class="mt-1 max-h-32 overflow-auto rounded-lg border p-2">{% for u in users %}<label class="block text-sm"><input type="checkbox" name="allowed_user_ids" value="{{ u.id }}"> {{ u.full_name or u.email }}</label>{% endfor %}</div></fieldset>
<label class="sm:col-span-2">Encrypted notes<textarea name="notes" rows="3" class="mt-1 w-full rounded-lg border p-2"></textarea></label>
<div class="sm:col-span-2 flex gap-2"><button class="rounded-lg bg-brand-600 px-4 py-2 font-semibold text-white">Save securely</button><a href="/credential-vault" class="rounded-lg border px-4 py-2">Cancel</a></div>
</form>
</div>
<script>
(() => {
const client = document.getElementById('vault-client');
const registration = document.getElementById('vault-registration');
if (!client || !registration) return;
const filterRegistrations = () => {
const cid = client.value;
Array.from(registration.options).forEach((opt, idx) => {
if (idx === 0) { opt.hidden = false; return; }
opt.hidden = !!cid && opt.dataset.clientId !== cid;
});
const selected = registration.selectedOptions[0];
if (selected && selected.hidden) registration.value = '';
};
client.addEventListener('change', filterRegistrations);
filterRegistrations();
})();
</script>
{% endblock %}
+26 -3
View File
@@ -52,7 +52,7 @@ def dashboard(request: Request, include_archived: bool = False):
@router.get("/new", response_class=HTMLResponse)
def new_entry(request: Request):
def new_entry(request: Request, client_id: int | None = None, registration_id: int | None = None):
with CommonSessionLocal() as db:
user = _user(request, db)
if not can_manage_vault(db, user): raise HTTPException(403, "Only firm managers may create credentials.")
@@ -60,7 +60,21 @@ def new_entry(request: Request):
clients = db.execute(select(Client).where(Client.tenant_id == tenant_id, Client.is_active.is_(True)).order_by(Client.client_name)).scalars().all()
users = db.execute(select(User).where(User.tenant_id == tenant_id, User.is_active.is_(True)).order_by(User.full_name)).scalars().all()
registrations = db.execute(select(ClientRegistration).where(ClientRegistration.tenant_id == tenant_id).order_by(ClientRegistration.registration_number)).scalars().all()
return templates.TemplateResponse("modules/credential_vault/templates/credential_vault/form.html", _ctx(request, user, db, entry=None, clients=clients, users=users, registrations=registrations, branch_id=branch_id))
selected_registration = None
if registration_id:
selected_registration = db.get(ClientRegistration, int(registration_id))
if not selected_registration or selected_registration.tenant_id != tenant_id:
raise HTTPException(404, "Registration record was not found in this audit firm.")
client_id = int(selected_registration.client_id)
if client_id and not any(int(c.id) == int(client_id) for c in clients):
raise HTTPException(404, "Client was not found in this audit firm.")
return templates.TemplateResponse(
"modules/credential_vault/templates/credential_vault/form.html",
_ctx(
request, user, db, entry=None, clients=clients, users=users, registrations=registrations, branch_id=branch_id,
selected_client_id=client_id, selected_registration_id=registration_id, selected_registration=selected_registration,
),
)
@router.post("/new")
@@ -69,7 +83,16 @@ async def save_new(request: Request, title: str=Form(...), category: str=Form("g
user = _user(request, db); validate_csrf(request, csrf_token)
if not can_manage_vault(db, user): raise HTTPException(403)
tenant_id = active_tenant_id(request, user)
entry = create_entry(db, tenant_id=tenant_id, branch_id=active_branch_id(request, user), client_id=int(client_id) if client_id else None, registration_id=int(registration_id) if registration_id else None, title=title, category=category, portal_url=portal_url, reference_number=reference_number, username=username, secret=secret, additional_secret=additional_secret, notes=notes, sensitivity=sensitivity, expires_on=_date(expires_on), rotation_due_on=_date(rotation_due_on), owner_user_id=int(owner_user_id) if owner_user_id else user.id, allowed_user_ids_csv=",".join(allowed_user_ids), actor_user_id=user.id)
selected_client_id = int(client_id) if client_id else None
selected_registration_id = int(registration_id) if registration_id else None
if selected_registration_id:
registration = db.get(ClientRegistration, selected_registration_id)
if not registration or registration.tenant_id != tenant_id:
raise HTTPException(400, "Selected registration is not available in this audit firm.")
if selected_client_id and int(registration.client_id) != selected_client_id:
raise HTTPException(400, "Selected registration does not belong to the selected client.")
selected_client_id = int(registration.client_id)
entry = create_entry(db, tenant_id=tenant_id, branch_id=active_branch_id(request, user), client_id=selected_client_id, registration_id=selected_registration_id, title=title, category=category, portal_url=portal_url, reference_number=reference_number, username=username, secret=secret, additional_secret=additional_secret, notes=notes, sensitivity=sensitivity, expires_on=_date(expires_on), rotation_due_on=_date(rotation_due_on), owner_user_id=int(owner_user_id) if owner_user_id else user.id, allowed_user_ids_csv=",".join(allowed_user_ids), actor_user_id=user.id)
log_access(db, request, user, entry, "create", reason="Credential created"); db.commit()
return RedirectResponse(f"/credential-vault/{entry.id}", 303)
+3 -2
View File
@@ -4,13 +4,13 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.21.1"
ERP_LOCAL_AGENT_VERSION = "1.22.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
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:
def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, storage_root: str, tenant_id=None, branch_id=None, enrollment_token: str | None = 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("/")
if erp_base_url.startswith("http://"):
host = erp_base_url[7:].split("/", 1)[0].split(":", 1)[0].lower()
@@ -19,6 +19,7 @@ def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, stor
storage_root = (storage_root or r"D:\AuditFirmStorage").strip()
return (
f"ERP_BASE_URL={erp_base_url}\n" f"NODE_CODE={(node_code or '').strip()}\n" f"NODE_SECRET={(node_secret or '').strip()}\n"
f"ENROLLMENT_TOKEN={(enrollment_token or '').strip()}\n"
f"STORAGE_ROOT={storage_root}\n" f"TENANT_ID={'' if tenant_id is None else tenant_id}\n" f"AUDIT_FIRM_ID={'' if tenant_id is None else tenant_id}\n"
f"BRANCH_ID={'' if branch_id is None else branch_id}\n" f"SYNC_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" f"POLL_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n"
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"
@@ -1,2 +1,2 @@
__version__ = "1.21.1"
__version__ = "1.22.0"
AGENT_NAME = "ERP Local Agent"
@@ -1,96 +1,114 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Iterable
from typing import Any
import requests
from .config import AgentConfig
class ERPClient:
def __init__(self, config: AgentConfig):
AUTH_FILE = "workstation_auth.json"
def __init__(self, config: AgentConfig, *, workstation: dict[str, Any] | None = None, root: Path | None = None):
self.config = config
self.workstation = workstation or {}
self.root = root or Path.cwd()
self.session = requests.Session()
self.session.headers.update(config.headers)
self.node_secret = self._resolve_node_secret()
self.session.headers.update({
"X-Node-Code": self.config.node_code,
"X-Node-Secret": self.node_secret,
"User-Agent": self.config.headers.get("User-Agent", "ERPLocalAgent"),
})
instance_id = str(self.workstation.get("agent_instance_id") or self.config.agent_instance_id or "").strip()
if instance_id:
self.session.headers["X-Agent-Instance-ID"] = instance_id
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,
def _auth_path(self) -> Path:
path = self.root / "data" / self.AUTH_FILE
path.parent.mkdir(parents=True, exist_ok=True)
return path
def _cached_workstation_secret(self) -> str:
path = self._auth_path()
try:
payload = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
except Exception:
return ""
if str(payload.get("node_code") or "") != self.config.node_code:
return ""
expected_instance = str(self.workstation.get("agent_instance_id") or "")
if expected_instance and str(payload.get("agent_instance_id") or "") != expected_instance:
return ""
return str(payload.get("workstation_secret") or "").strip()
def _resolve_node_secret(self) -> str:
if self.config.node_secret:
return self.config.node_secret
cached = self._cached_workstation_secret()
if cached:
return cached
token = (self.config.enrollment_token or "").strip()
if not token:
raise RuntimeError("Local Agent has neither a node secret nor a workstation enrollment token.")
response = requests.post(
self._url("/documents/storage-agent/enroll-workstation"),
json={"enrollment_token": token, "workstation": self.workstation},
timeout=max(30, self.config.request_timeout_seconds),
headers={"User-Agent": self.config.headers.get("User-Agent", "ERPLocalAgent")},
)
response.raise_for_status()
return response.json() if response.content else {"status": "ok"}
data = response.json()
secret = str(data.get("workstation_secret") or "").strip()
if not secret:
raise RuntimeError(data.get("error") or "ERP did not return a workstation credential.")
path = self._auth_path()
payload = {
"node_code": self.config.node_code,
"agent_instance_id": str(self.workstation.get("agent_instance_id") or ""),
"workstation_secret": secret,
}
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(path)
return secret
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", [])
response = self.session.get(self._url("/documents/storage-agent/jobs/pending"), timeout=self.config.request_timeout_seconds)
response.raise_for_status(); data = response.json(); return data if isinstance(data, list) else 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
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"}
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", [])
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(); return data if isinstance(data, list) else 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"}
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()
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
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
@@ -16,6 +16,8 @@ class AgentConfig:
node_code: str
node_secret: str
storage_root: Path
enrollment_token: str | None = None
agent_instance_id: str | None = None
tenant_id: str | None = None
branch_id: str | None = None
poll_interval_seconds: int = 30
@@ -34,11 +36,14 @@ class AgentConfig:
@property
def headers(self) -> dict[str, str]:
return {
headers = {
"X-Node-Code": self.node_code,
"X-Node-Secret": self.node_secret,
"User-Agent": f"ERPLocalAgent/{__version__}",
}
if self.agent_instance_id:
headers["X-Agent-Instance-ID"] = self.agent_instance_id
return headers
@property
def tunnel_url(self) -> str:
@@ -49,7 +54,10 @@ class AgentConfig:
ws_base = "ws://" + base[len("http://"):]
else:
ws_base = base
query = urlencode({"node_code": self.node_code, "node_secret": self.node_secret})
query_data = {"node_code": self.node_code, "node_secret": self.node_secret}
if self.agent_instance_id:
query_data["agent_instance_id"] = self.agent_instance_id
query = urlencode(query_data)
return f"{ws_base}/documents/storage-agent/tunnel?{query}"
@@ -79,6 +87,7 @@ def load_config(env_file: str | None = None) -> AgentConfig:
erp_base_url = os.getenv("ERP_BASE_URL", "").rstrip("/")
node_code = os.getenv("NODE_CODE", "").strip()
node_secret = os.getenv("NODE_SECRET", "").strip()
enrollment_token = os.getenv("ENROLLMENT_TOKEN", "").strip() or None
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
@@ -88,8 +97,8 @@ def load_config(env_file: str | None = None) -> AgentConfig:
missing.append("ERP_BASE_URL")
if not node_code:
missing.append("NODE_CODE")
if not node_secret:
missing.append("NODE_SECRET")
if not node_secret and not enrollment_token:
missing.append("NODE_SECRET or ENROLLMENT_TOKEN")
if not storage_root_raw:
missing.append("STORAGE_ROOT")
if missing:
@@ -103,6 +112,7 @@ def load_config(env_file: str | None = None) -> AgentConfig:
node_code=node_code,
node_secret=node_secret,
storage_root=storage_root,
enrollment_token=enrollment_token,
tenant_id=tenant_id,
branch_id=branch_id,
poll_interval_seconds=_get_int("POLL_INTERVAL_SECONDS", 30),
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import asyncio
from dataclasses import replace
from pathlib import Path
import threading
import time
@@ -42,7 +43,12 @@ def main() -> int:
db.set_meta("machine_name", workstation["machine_name"])
db.set_meta("machine_fingerprint", workstation["machine_fingerprint"])
db.record_event("INFO", "agent_started", f"ERP Local Agent {__version__} started")
client = ERPClient(config)
client = ERPClient(config, workstation=workstation, root=root)
config = replace(
config,
node_secret=client.node_secret,
agent_instance_id=workstation["agent_instance_id"],
)
agent = StorageAgent(config, client, db, logger)
updater = AgentUpdater(config, client, logger, root, db=db)
dashboard = AgentDashboard(config, db, updater, logger, root)
+1
View File
@@ -205,6 +205,7 @@ class ERPWorkstationAgent(CommonBase):
machine_name: Mapped[str] = mapped_column(String(200), nullable=False)
platform_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
agent_version: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
workstation_secret_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
capabilities_json: Mapped[str | None] = mapped_column(Text, nullable=True)
tally_connected: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
tally_company_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+48
View File
@@ -25,6 +25,7 @@ from app.modules.documents.models import (
DocumentStorageJob,
EngagementDocument,
EngagementDocumentVersion,
ERPWorkstationAgent,
PermanentClientDocument,
PermanentClientDocumentVersion,
PermanentDocumentDownloadRequest,
@@ -813,6 +814,53 @@ def authenticate_storage_node(db: Session, node_code: str | None, secret: str |
return node
def authenticate_storage_agent(
db: Session,
node_code: str | None,
secret: str | None,
agent_instance_id: str | None = None,
request=None,
) -> BranchStorageNode | None:
"""Authenticate either a legacy branch-node secret or a workstation-specific secret.
Existing Local Agents continue to use the branch node secret unchanged. New
workstation enrollment packages receive a workstation-only secret so adding a
PC no longer rotates credentials used by already-installed office systems.
"""
node = authenticate_storage_node(db, node_code, secret, request=request)
if node:
return node
instance_id = (agent_instance_id or "").strip()
if not node_code or not secret or not instance_id:
return None
node = db.execute(
select(BranchStorageNode).where(
BranchStorageNode.node_code == node_code,
BranchStorageNode.is_active.is_(True),
)
).scalar_one_or_none()
if not node:
return None
workstation = db.execute(
select(ERPWorkstationAgent).where(
ERPWorkstationAgent.storage_node_id == node.id,
ERPWorkstationAgent.agent_instance_id == instance_id,
ERPWorkstationAgent.is_active.is_(True),
)
).scalar_one_or_none()
if not workstation or not workstation.workstation_secret_hash:
return None
if not hmac.compare_digest(workstation.workstation_secret_hash, hash_storage_secret(secret)):
return None
now = datetime.now(timezone.utc)
node.last_seen_at_utc = now
workstation.last_seen_at_utc = now
ip = request.client.host if request and request.client else None
node.last_seen_ip = ip
workstation.last_seen_ip = ip
return node
def list_pending_storage_jobs(db: Session, node: BranchStorageNode, limit: int = 20):
return db.execute(
select(DocumentStorageJob)
@@ -149,10 +149,16 @@
{% if node.status == 'disabled_duplicate' %}
<span class="text-xs text-slate-500">Duplicate disabled</span>
{% else %}
<form method="post" action="/documents/storage-nodes/{{ node.id }}/download-new-workstation">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="storage_root_path" value="{{ node.storage_root_path or 'D:\AuditFirmStorage' }}">
<button class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white">+ New Workstation</button>
</form>
<div class="max-w-48 text-right text-[11px] text-slate-500">Installs on another office PC without changing credentials used by existing workstations.</div>
<form method="post" action="/documents/storage-nodes/{{ node.id }}/download-agent-package">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="storage_root_path" value="{{ node.storage_root_path or 'D:\AuditFirmStorage' }}">
<button class="text-sm text-emerald-700 font-semibold">Download ERP Local Agent</button>
<button class="text-xs font-semibold text-amber-700">Reissue branch package</button>
</form>
<form method="post" action="/documents/storage-nodes/{{ node.id }}/toggle">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
@@ -173,7 +179,7 @@
<div class="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div class="p-4 border-b">
<h2 class="font-bold text-slate-900">Registered Workstations</h2>
<p class="text-xs text-slate-500 mt-1">Each PC keeps a persistent agent identity. The branch storage node remains unchanged; multiple Tally workstations can report through the same outbound tunnel credentials.</p>
<p class="text-xs text-slate-500 mt-1">Each PC keeps a persistent agent identity. Use <strong>+ New Workstation</strong> on the branch node above for another office PC; it receives its own workstation credential and existing agents keep working.</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
+125 -3
View File
@@ -17,6 +17,7 @@ from sqlalchemy.orm import joinedload
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.security.jwt_tokens import decode_token, encode_access_token
from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
@@ -33,6 +34,7 @@ from app.modules.documents.services import (
cleanup_completed_permanent_storage_job_vps_stage,
retry_verified_vps_cleanup_for_node,
authenticate_storage_node,
authenticate_storage_agent,
create_branch_storage_node,
generate_storage_secret,
hash_storage_secret,
@@ -1578,6 +1580,122 @@ def download_storage_node_env(request: Request, csrf_token: str = Form(...)):
return RedirectResponse(url="/documents/storage-nodes?error=env_download_disabled", status_code=303)
@router.post("/storage-nodes/{node_id}/download-new-workstation")
def download_new_workstation_agent(
request: Request,
node_id: int,
storage_root_path: str = Form(r"D:\AuditFirmStorage"),
csrf_token: str = Form(...),
):
"""Download a short-lived enrollment package without rotating existing agent credentials."""
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "documents.upload")
if response:
return response
scope = build_document_scope(request, db, user)
if not _can_manage_branch_storage(scope):
return _redirect_denied()
node = db.get(BranchStorageNode, node_id)
if not _node_allowed_for_storage_scope(node, user, scope):
return _redirect_denied()
token = encode_access_token(
{
"purpose": "workstation_enrollment",
"node_id": int(node.id),
"node_code": node.node_code,
"tenant_id": int(node.tenant_id),
"branch_id": int(node.branch_id) if node.branch_id is not None else None,
},
expires_minutes=30,
)
env_text = build_agent_env(
erp_base_url=str(request.base_url).rstrip("/"),
node_code=node.node_code,
node_secret="",
enrollment_token=token,
storage_root=_effective_storage_root(node, storage_root_path),
tenant_id=node.tenant_id,
branch_id=node.branch_id,
)
package = build_preconfigured_agent_zip(
env_text=env_text, include_admin_readme=bool(scope.is_system_admin)
)
filename = _agent_download_filename(f"{node.node_code}-NEW-WORKSTATION", ".zip")
return Response(
package,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
finally:
db.close()
@router.post("/storage-agent/enroll-workstation")
async def enroll_workstation(request: Request):
"""Exchange a short-lived enrollment token for a workstation-only agent secret."""
payload = await request.json()
token = str(payload.get("enrollment_token") or "").strip()
workstation_payload = payload.get("workstation") if isinstance(payload.get("workstation"), dict) else {}
try:
claims = decode_token(token)
except Exception:
return JSONResponse({"ok": False, "error": "invalid_or_expired_enrollment_token"}, status_code=401)
if claims.get("purpose") != "workstation_enrollment":
return JSONResponse({"ok": False, "error": "invalid_enrollment_token_type"}, status_code=401)
instance_id = str(workstation_payload.get("agent_instance_id") or "").strip()
fingerprint = str(workstation_payload.get("machine_fingerprint") or "").strip()
machine_name = str(workstation_payload.get("machine_name") or "").strip()
if not instance_id or not fingerprint or not machine_name:
return JSONResponse({"ok": False, "error": "workstation_identity_required"}, status_code=400)
db = CommonSessionLocal()
try:
node = db.get(BranchStorageNode, int(claims.get("node_id") or 0))
if (
not node
or not node.is_active
or node.node_code != claims.get("node_code")
or int(node.tenant_id) != int(claims.get("tenant_id") or 0)
):
return JSONResponse({"ok": False, "error": "enrollment_node_unavailable"}, status_code=403)
row = db.execute(
select(ERPWorkstationAgent).where(
ERPWorkstationAgent.storage_node_id == node.id,
ERPWorkstationAgent.agent_instance_id == instance_id,
)
).scalar_one_or_none()
if row is None:
row = ERPWorkstationAgent(
tenant_id=node.tenant_id,
branch_id=node.branch_id,
storage_node_id=node.id,
agent_instance_id=instance_id,
machine_fingerprint=fingerprint,
machine_name=machine_name[:200],
)
db.add(row)
secret = generate_storage_secret()
row.workstation_secret_hash = hash_storage_secret(secret)
row.machine_fingerprint = fingerprint
row.machine_name = machine_name[:200]
row.platform_name = str(workstation_payload.get("platform_name") or "")[:120] or None
row.agent_version = str(workstation_payload.get("agent_version") or ERP_LOCAL_AGENT_VERSION)[:40] or None
row.is_active = True
row.status = "enrolled"
row.last_seen_at_utc = datetime.now(timezone.utc)
row.last_seen_ip = request.client.host if request.client else None
db.commit()
return {
"ok": True,
"node_code": node.node_code,
"agent_instance_id": instance_id,
"workstation_secret": secret,
}
finally:
db.close()
@router.post("/storage-nodes/download-agent-package")
def download_preconfigured_storage_agent(
request: Request,
@@ -1694,7 +1812,10 @@ def storage_jobs(request: Request, status: str = ""):
def _agent_auth(db, request: Request, x_node_code: str | None, x_node_secret: str | None):
node = authenticate_storage_node(db, x_node_code, x_node_secret, request=request)
instance_id = (request.headers.get("x-agent-instance-id") or "").strip()
node = authenticate_storage_agent(
db, x_node_code, x_node_secret, agent_instance_id=instance_id, request=request
)
if not node:
return None, JSONResponse({"ok": False, "error": "invalid_storage_node_credentials"}, status_code=401)
return node, None
@@ -1942,11 +2063,12 @@ async def storage_agent_tunnel(websocket: WebSocket):
"""
node_code = websocket.query_params.get("node_code") or websocket.headers.get("x-node-code")
node_secret = websocket.query_params.get("node_secret") or websocket.headers.get("x-node-secret")
agent_instance_id = websocket.query_params.get("agent_instance_id") or websocket.headers.get("x-agent-instance-id")
await websocket.accept()
db = CommonSessionLocal()
try:
node = authenticate_storage_node(db, node_code, node_secret, request=None)
node = authenticate_storage_agent(db, node_code, node_secret, agent_instance_id=agent_instance_id, request=None)
if not node:
await websocket.send_json({"ok": False, "type": "error", "error": "invalid_storage_node_credentials"})
await websocket.close(code=1008)
@@ -1971,7 +2093,7 @@ async def storage_agent_tunnel(websocket: WebSocket):
db = CommonSessionLocal()
try:
node = authenticate_storage_node(db, node_code, node_secret, request=None)
node = authenticate_storage_agent(db, node_code, node_secret, agent_instance_id=agent_instance_id, request=None)
if not node:
await websocket.send_json({"ok": False, "type": "error", "error": "node_deactivated_or_invalid"})
await websocket.close(code=1008)
+47 -6
View File
@@ -23,7 +23,7 @@ from app.modules.core.tenancy.settings_models import BranchSettings
from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeLeaveRequest, EmployeeDocumentType, EmployeeDocument, EmployeeOnboardingChecklistItem, EmployeeOnboardingTask, EmployeeOffboardingRequest, EmployeeOffboardingTask, EmployeeSalaryStructure, EmployeePayrollRun, EmployeePayslip
from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
from app.modules.documents.models import EngagementDocument
from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment, FirmTaskDocumentRequirement
from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment, FirmTaskDocumentRequirement, FirmServiceTaskTemplate, ServiceTaskCategory
from app.modules.registrations.models import ClientRegistration, RegistrationType
from app.modules.services.engagement_resources import build_engagement_resource_context
from app.modules.services.execution import (
@@ -152,11 +152,18 @@ def list_employees(
q: str = "",
include_inactive: bool = False,
link_status: str = "all",
status_filter: str | None = None,
) -> list[Employee]:
stmt = select(Employee).where(Employee.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(Employee.branch_id == scope.branch_id)
if not include_inactive:
normalized_status = (status_filter or "").strip().lower()
if normalized_status in {"active", "inactive", "relieved"}:
stmt = stmt.where(Employee.status == normalized_status)
elif normalized_status == "all":
pass
elif not include_inactive:
stmt = stmt.where(Employee.is_active.is_(True))
link_status = (link_status or "all").lower()
@@ -3617,7 +3624,11 @@ def list_employee_engagement_documents(db: Session, scope: EmployeeScope, engage
def _employee_task_category(task: ClientServiceTaskInstance) -> str:
value = (getattr(task, "task_category", None) or "").strip()
value = (
getattr(task, "_resolved_task_category", None)
or getattr(task, "task_category", None)
or ""
).strip()
return value or "General Workflow"
@@ -4021,10 +4032,37 @@ def get_employee_engagement_work_board(
if int(row.id) in assigned_ids
or (getattr(row, "default_role_name", None) or "").strip().lower() in reviewer_roles
]
use_task_categories = any(
bool((getattr(row, "task_category", None) or "").strip())
# Resolve task categories from the current Task Category master/template when
# older generated task instances do not carry the category snapshot. This
# keeps historical engagements category-based without rewriting task history.
template_ids = {
int(row.firm_task_template_id)
for row in tasks
)
if getattr(row, "firm_task_template_id", None) is not None
}
resolved_categories: dict[int, tuple[str, int]] = {}
if template_ids:
category_rows = db.execute(
select(
FirmServiceTaskTemplate.id,
FirmServiceTaskTemplate.task_category,
ServiceTaskCategory.name,
ServiceTaskCategory.sort_order,
)
.outerjoin(ServiceTaskCategory, ServiceTaskCategory.id == FirmServiceTaskTemplate.task_category_id)
.where(FirmServiceTaskTemplate.id.in_(template_ids))
).all()
for template_id, legacy_name, master_name, sort_order in category_rows:
resolved_name = (master_name or legacy_name or "").strip()
if resolved_name:
resolved_categories[int(template_id)] = (resolved_name, int(sort_order or 100))
for row in tasks:
template_id = getattr(row, "firm_task_template_id", None)
resolved = resolved_categories.get(int(template_id)) if template_id is not None else None
row._resolved_task_category = resolved[0] if resolved else ((getattr(row, "task_category", None) or "").strip() or "General Workflow")
row._resolved_task_category_sort = resolved[1] if resolved else 100
use_task_categories = any(_employee_task_category(row) != "General Workflow" for row in tasks)
summary = {
"total": len(tasks),
@@ -4056,6 +4094,7 @@ def get_employee_engagement_work_board(
if category is None:
category = {
"name": category_name,
"sort_order": int(getattr(task, "_resolved_task_category_sort", 100) or 100),
"tasks": [],
"total": 0,
"completed": 0,
@@ -4075,6 +4114,8 @@ def get_employee_engagement_work_board(
elif status_code == "in_progress":
category["in_progress"] += 1
categories.sort(key=lambda item: (int(item.get("sort_order", 100)), item["name"].lower()))
for category in categories:
weighted_category = _weighted_progress(category["tasks"])
category.update(weighted_category)
@@ -22,9 +22,12 @@
<option value="linked" {% if link_status == 'linked' %}selected{% endif %}>Linked to login user</option>
<option value="unlinked" {% if link_status == 'unlinked' %}selected{% endif %}>Not linked to login user</option>
</select>
<label class="inline-flex items-center gap-2 rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
<input type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}> Include inactive
</label>
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>All staff statuses</option>
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if selected_status == 'inactive' %}selected{% endif %}>Inactive</option>
<option value="relieved" {% if selected_status == 'relieved' %}selected{% endif %}>Relieved</option>
</select>
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
</div>
</form>
@@ -41,7 +44,7 @@
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div class="font-semibold">{{ link_summary.unlinked }} employee(s) are not linked to login users.</div>
<p class="mt-1">Employee self-service pages such as My Workspace, attendance, leave, documents and payslips work fully only after the employee master is linked to an IAM user.</p>
<a href="/employees?link_status=unlinked{% if include_inactive %}&include_inactive=1{% endif %}" class="mt-2 inline-flex rounded-lg border border-amber-300 px-3 py-1.5 text-xs font-semibold text-amber-900 hover:bg-amber-100">Show unlinked employees</a>
<a href="/employees?link_status=unlinked&status={{ selected_status or 'all' }}" class="mt-2 inline-flex rounded-lg border border-amber-300 px-3 py-1.5 text-xs font-semibold text-amber-900 hover:bg-amber-100">Show unlinked employees</a>
</div>
{% endif %}
@@ -114,9 +114,12 @@
<div class="divide-y divide-slate-100">
{% for category in board.categories %}
{% if board.use_task_categories %}
<div class="bg-slate-50 px-5 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
{{ category.name }} · {{ category.completed }}/{{ category.total }}
</div>
<details class="group" {% if loop.first %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 bg-slate-50 px-5 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-100">
<span class="flex items-center gap-2"><span class="text-slate-400 transition-transform group-open:rotate-90">▶</span>{{ category.name }}</span>
<span class="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600">{{ category.completed }}/{{ category.total }} completed</span>
</summary>
<div class="divide-y divide-slate-100">
{% endif %}
{% for task in category.tasks %}
@@ -365,6 +368,7 @@
</div>
</div>
{% endfor %}
{% if board.use_task_categories %}</div></details>{% endif %}
{% endfor %}
</div>
</section>
+11 -3
View File
@@ -513,7 +513,7 @@ def _safe_employee_return_url(value: str | None, fallback: str) -> str:
@router.get("")
def employees_list(request: Request, q: str = "", include_inactive: str | None = None, link_status: str = "all"):
def employees_list(request: Request, q: str = "", include_inactive: str | None = None, link_status: str = "all", status: str | None = None):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
@@ -527,12 +527,19 @@ def employees_list(request: Request, q: str = "", include_inactive: str | None =
branch_id = request.session.get("active_branch_id")
scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id)
normalized_link_status = link_status if link_status in ("all", "linked", "unlinked") else "all"
requested_status = (status or "").strip().lower()
if requested_status not in {"all", "active", "inactive", "relieved"}:
requested_status = "all" if (scope.is_firm_admin or scope.is_partner or scope.is_system_admin) else "active"
# Keep the legacy include_inactive query string working for bookmarked URLs.
if include_inactive and status is None:
requested_status = "all"
rows = list_employees(
db,
scope,
q=q,
include_inactive=bool(include_inactive),
include_inactive=(requested_status == "all"),
link_status=normalized_link_status,
status_filter=requested_status,
)
link_summary = get_employee_user_link_summary(db, scope)
return _render(
@@ -543,7 +550,8 @@ def employees_list(request: Request, q: str = "", include_inactive: str | None =
title="Employees",
rows=rows,
q=q,
include_inactive=bool(include_inactive),
include_inactive=(requested_status == "all"),
selected_status=requested_status,
link_status=normalized_link_status,
link_summary=link_summary,
scope=scope,
@@ -173,6 +173,8 @@ def _user_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
"employee_id": employee.id if employee else None,
"employee_linked": bool(employee),
"employee_code": employee.employee_code if employee else None,
"employee_status": employee.status if employee else None,
"employee_is_active": employee.is_active if employee else None,
"is_active": user.is_active,
"allow_login": user.allow_login,
"is_locked": user.is_locked,
@@ -35,6 +35,7 @@
{% if user.employee_linked %}
<span class="rounded-full bg-emerald-50 px-2 py-1 font-semibold text-emerald-700">Linked</span>
<div class="mt-1 text-slate-500">{{ user.employee_code or ('#' ~ user.employee_id) }}</div>
{% if user.employee_status %}<span class="mt-1 inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold {% if user.employee_status == 'active' %}bg-emerald-50 text-emerald-700{% elif user.employee_status == 'relieved' %}bg-amber-50 text-amber-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ user.employee_status|replace('_',' ')|title }}</span>{% endif %}
{% else %}
<span class="rounded-full bg-amber-50 px-2 py-1 font-semibold text-amber-700">Not linked</span>
{% endif %}
+29 -5
View File
@@ -24,6 +24,8 @@ from app.modules.services.models import (
ClientServiceTaskInstance,
ServiceCatalogue,
ServiceTaskComment,
FirmServiceTaskTemplate,
ServiceTaskCategory,
)
from app.modules.employees.service import _engagement_sla, _engagement_team, _weighted_progress
@@ -266,7 +268,6 @@ def _staff_rows(db: Session, tenant_id: int | None, branch_id: int | None, tasks
.join(Role, Role.id == UserRole.role_id)
.where(
User.tenant_id == tenant_id,
User.is_active.is_(True),
Role.is_active.is_(True),
Role.name.in_(("Staff", "Branch Manager")),
)
@@ -300,13 +301,14 @@ def _staff_rows(db: Session, tenant_id: int | None, branch_id: int | None, tasks
"name": user.full_name or user.email,
"email": user.email,
"designation": user.designation,
"is_active": bool(user.is_active),
"active": stats["active"],
"overdue": stats["overdue"],
"review": stats["review"],
"client_pending": stats["client_pending"],
"load_status": "Heavy" if total >= 40 else ("Balanced" if total >= 10 else "Light"),
})
rows.sort(key=lambda r: (r["active"] + r["review"], r["overdue"]), reverse=True)
rows.sort(key=lambda r: (not r["is_active"], -(r["active"] + r["review"]), -r["overdue"], r["name"].lower()))
return rows[:25]
@@ -442,7 +444,7 @@ def _display_user(user) -> str:
def _partner_task_category(task: ClientServiceTaskInstance) -> str:
return (getattr(task, "task_category", None) or "General Workflow").strip() or "General Workflow"
return (getattr(task, "_resolved_task_category", None) or getattr(task, "task_category", None) or "General Workflow").strip() or "General Workflow"
def _partner_review_level_for_task(task: ClientServiceTaskInstance, subscription: ClientServiceSubscription, current_user) -> str | None:
@@ -650,12 +652,34 @@ def get_partner_review_workspace(db: Session, request, current_user, *, subscrip
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
)
tasks = list(db.execute(task_stmt).scalars().all())
template_ids = {int(t.firm_task_template_id) for t in tasks if getattr(t, "firm_task_template_id", None) is not None}
resolved_categories: dict[int, tuple[str, int]] = {}
if template_ids:
for template_id, legacy_name, master_name, sort_order in db.execute(
select(
FirmServiceTaskTemplate.id,
FirmServiceTaskTemplate.task_category,
ServiceTaskCategory.name,
ServiceTaskCategory.sort_order,
)
.outerjoin(ServiceTaskCategory, ServiceTaskCategory.id == FirmServiceTaskTemplate.task_category_id)
.where(FirmServiceTaskTemplate.id.in_(template_ids))
).all():
name = (master_name or legacy_name or "").strip()
if name:
resolved_categories[int(template_id)] = (name, int(sort_order or 100))
for task in tasks:
template_id = getattr(task, "firm_task_template_id", None)
resolved = resolved_categories.get(int(template_id)) if template_id is not None else None
task._resolved_task_category = resolved[0] if resolved else ((getattr(task, "task_category", None) or "").strip() or "General Workflow")
task._resolved_task_category_sort = resolved[1] if resolved else 100
task_rows = [_partner_task_payload(task, subscription, current_user) for task in tasks]
categories_by_name: dict[str, dict[str, Any]] = {}
for row in task_rows:
source_task = next((t for t in tasks if int(t.id) == int(row["id"])), None)
cat = categories_by_name.setdefault(row["category"], {
"name": row["category"], "tasks": [], "total": 0, "pending": 0,
"name": row["category"], "sort_order": int(getattr(source_task, "_resolved_task_category_sort", 100) or 100), "tasks": [], "total": 0, "pending": 0,
"reviewed": 0, "rework": 0, "exceptions": 0, "blockers": 0,
})
cat["tasks"].append(row)
@@ -665,7 +689,7 @@ def get_partner_review_workspace(db: Session, request, current_user, *, subscrip
if row["review_state"] == "rework": cat["rework"] += 1
if row["is_exception"]: cat["exceptions"] += 1
if row["blocks_final_release"] and row["aqmm_status"] != "completed": cat["blockers"] += 1
categories = list(categories_by_name.values())
categories = sorted(categories_by_name.values(), key=lambda item: (int(item.get("sort_order", 100)), item["name"].lower()))
for cat in categories:
denominator = cat["pending"] + cat["reviewed"] + cat["rework"]
cat["progress_percent"] = int(round((cat["reviewed"] / denominator) * 100)) if denominator else 100
@@ -1,6 +1,6 @@
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Branch Staff Workload</h2><p class="mt-1 text-sm text-slate-500">Workload is calculated from current branch task assignments.</p>
<div class="mt-5 overflow-x-auto"><table class="min-w-full text-left text-sm"><thead class="text-xs uppercase tracking-wide text-slate-400"><tr><th class="px-3 py-2">Staff / Manager</th><th class="px-3 py-2">Active</th><th class="px-3 py-2">Overdue</th><th class="px-3 py-2">Client Pending</th><th class="px-3 py-2">Review</th><th class="px-3 py-2">Load</th></tr></thead><tbody class="divide-y divide-slate-100">
{% for s in staff_rows %}<tr class="hover:bg-slate-50"><td class="px-3 py-3"><div class="font-semibold text-slate-900">{{ s.name }}</div><div class="text-xs text-slate-500">{{ s.designation or s.email }}</div></td><td class="px-3 py-3">{{ s.active }}</td><td class="px-3 py-3">{{ s.overdue }}</td><td class="px-3 py-3">{{ s.client_pending }}</td><td class="px-3 py-3">{{ s.review }}</td><td class="px-3 py-3"><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ s.load_status }}</span></td></tr>{% else %}<tr><td colspan="6" class="px-3 py-8 text-center text-slate-500">No active Staff or Branch Manager users found for this branch.</td></tr>{% endfor %}
{% for s in staff_rows %}<tr class="hover:bg-slate-50"><td class="px-3 py-3"><div class="flex flex-wrap items-center gap-2"><div class="font-semibold text-slate-900">{{ s.name }}</div>{% if not s.is_active %}<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-600">Inactive</span>{% endif %}</div><div class="text-xs text-slate-500">{{ s.designation or s.email }}</div></td><td class="px-3 py-3">{{ s.active }}</td><td class="px-3 py-3">{{ s.overdue }}</td><td class="px-3 py-3">{{ s.client_pending }}</td><td class="px-3 py-3">{{ s.review }}</td><td class="px-3 py-3"><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ s.load_status }}</span></td></tr>{% else %}<tr><td colspan="6" class="px-3 py-8 text-center text-slate-500">No Staff or Branch Manager users found for this branch.</td></tr>{% endfor %}
</tbody></table></div>
</div>
File diff suppressed because one or more lines are too long
+22 -1
View File
@@ -10,6 +10,8 @@ from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
from app.modules.consultants.models import ConsultantProfile
from app.modules.credential_vault.models import CredentialVaultEntry
from app.modules.credential_vault.service import can_manage_vault, can_view_entry
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
from app.modules.registrations.models import *
@@ -432,7 +434,26 @@ def client_register(request:Request,client_id:int):
dscs=db.execute(select(ClientDigitalSignature,ClientRelatedPerson).join(ClientRelatedPerson).where(ClientDigitalSignature.client_id==client_id).order_by(ClientDigitalSignature.expires_on)).all()
rules=db.execute(select(RegistrationLifecycleRule).where(or_(RegistrationLifecycleRule.tenant_id.is_(None),RegistrationLifecycleRule.tenant_id==client.tenant_id),RegistrationLifecycleRule.is_active.is_(True))).scalars().all()
consultants=db.execute(select(ConsultantProfile).where(ConsultantProfile.tenant_id==client.tenant_id,ConsultantProfile.is_active.is_(True))).scalars().all()
return templates.TemplateResponse("modules/registrations/templates/registrations/client.html",_ctx(request,user,db,client=client,registrations=regs,people=people,dscs=dscs,types=db.execute(select(RegistrationType).where(RegistrationType.is_active.is_(True)).order_by(RegistrationType.sort_order)).scalars().all(),rules=rules,consultants=consultants,can_manage=_can_manage(db,user)))
vault_rows = db.execute(
select(CredentialVaultEntry).where(
CredentialVaultEntry.tenant_id == client.tenant_id,
CredentialVaultEntry.client_id == client.id,
CredentialVaultEntry.status != "archived",
).order_by(CredentialVaultEntry.title)
).scalars().all()
vault_by_registration = {}
for entry in vault_rows:
if entry.registration_id and can_view_entry(db, user, entry, _branch(request, user)):
vault_by_registration.setdefault(int(entry.registration_id), []).append(entry)
return templates.TemplateResponse(
"modules/registrations/templates/registrations/client.html",
_ctx(
request,user,db,client=client,registrations=regs,people=people,dscs=dscs,
types=db.execute(select(RegistrationType).where(RegistrationType.is_active.is_(True)).order_by(RegistrationType.sort_order)).scalars().all(),
rules=rules,consultants=consultants,can_manage=_can_manage(db,user),
can_manage_vault=can_manage_vault(db,user),vault_by_registration=vault_by_registration,
),
)
@router.post("/clients/{client_id}/people")
async def add_person(request:Request,client_id:int,person_type:str=Form(...),full_name:str=Form(...),designation:str=Form(""),pan:str=Form(""),din:str=Form(""),date_of_birth:str=Form(""),appointment_date:str=Form(""),mobile:str=Form(""),email:str=Form(""),authorised_signatory:bool=Form(False),notes:str=Form(""),csrf_token:str=Form(...)):