Add staff visibility workstation enrollment registration vault and category task views
This commit is contained in:
@@ -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 %}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def dashboard(request: Request, include_archived: bool = False):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/new", response_class=HTMLResponse)
|
@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:
|
with CommonSessionLocal() as db:
|
||||||
user = _user(request, db)
|
user = _user(request, db)
|
||||||
if not can_manage_vault(db, user): raise HTTPException(403, "Only firm managers may create credentials.")
|
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()
|
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()
|
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()
|
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")
|
@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)
|
user = _user(request, db); validate_csrf(request, csrf_token)
|
||||||
if not can_manage_vault(db, user): raise HTTPException(403)
|
if not can_manage_vault(db, user): raise HTTPException(403)
|
||||||
tenant_id = active_tenant_id(request, user)
|
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()
|
log_access(db, request, user, entry, "create", reason="Credential created"); db.commit()
|
||||||
return RedirectResponse(f"/credential-vault/{entry.id}", 303)
|
return RedirectResponse(f"/credential-vault/{entry.id}", 303)
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import io
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
ERP_LOCAL_AGENT_VERSION = "1.21.1"
|
ERP_LOCAL_AGENT_VERSION = "1.22.0"
|
||||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
_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("/")
|
erp_base_url = (erp_base_url or "").strip().rstrip("/")
|
||||||
if erp_base_url.startswith("http://"):
|
if erp_base_url.startswith("http://"):
|
||||||
host = erp_base_url[7:].split("/", 1)[0].split(":", 1)[0].lower()
|
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()
|
storage_root = (storage_root or r"D:\AuditFirmStorage").strip()
|
||||||
return (
|
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"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"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"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"
|
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"
|
AGENT_NAME = "ERP Local Agent"
|
||||||
|
|||||||
@@ -1,96 +1,114 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .config import AgentConfig
|
from .config import AgentConfig
|
||||||
|
|
||||||
|
|
||||||
class ERPClient:
|
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.config = config
|
||||||
|
self.workstation = workstation or {}
|
||||||
|
self.root = root or Path.cwd()
|
||||||
self.session = requests.Session()
|
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:
|
def _url(self, path: str) -> str:
|
||||||
return f"{self.config.erp_base_url}{path}"
|
return f"{self.config.erp_base_url}{path}"
|
||||||
|
|
||||||
def heartbeat(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _auth_path(self) -> Path:
|
||||||
response = self.session.post(
|
path = self.root / "data" / self.AUTH_FILE
|
||||||
self._url("/documents/storage-agent/heartbeat"),
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
json=payload,
|
return path
|
||||||
timeout=self.config.request_timeout_seconds,
|
|
||||||
|
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()
|
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]]:
|
def pending_storage_jobs(self) -> list[dict[str, Any]]:
|
||||||
response = self.session.get(
|
response = self.session.get(self._url("/documents/storage-agent/jobs/pending"), timeout=self.config.request_timeout_seconds)
|
||||||
self._url("/documents/storage-agent/jobs/pending"),
|
response.raise_for_status(); data = response.json(); return data if isinstance(data, list) else data.get("jobs", [])
|
||||||
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):
|
def download_storage_job(self, job_id: int | str):
|
||||||
response = self.session.get(
|
response = self.session.get(self._url(f"/documents/storage-agent/jobs/{job_id}/download"), stream=True, timeout=self.config.request_timeout_seconds)
|
||||||
self._url(f"/documents/storage-agent/jobs/{job_id}/download"),
|
response.raise_for_status(); return response
|
||||||
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]:
|
def acknowledge_storage_job(self, job_id: int | str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
response = self.session.post(
|
response = self.session.post(self._url(f"/documents/storage-agent/jobs/{job_id}/ack"), json=payload, timeout=self.config.request_timeout_seconds)
|
||||||
self._url(f"/documents/storage-agent/jobs/{job_id}/ack"),
|
response.raise_for_status(); return response.json() if response.content else {"status": "ok"}
|
||||||
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]]:
|
def pending_download_requests(self) -> list[dict[str, Any]]:
|
||||||
response = self.session.get(
|
response = self.session.get(self._url("/documents/storage-agent/download-requests/pending"), timeout=self.config.request_timeout_seconds)
|
||||||
self._url("/documents/storage-agent/download-requests/pending"),
|
response.raise_for_status(); data = response.json(); return data if isinstance(data, list) else data.get("requests", [])
|
||||||
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]:
|
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:
|
with file_path.open("rb") as handle:
|
||||||
files = {"file": (file_path.name, handle, "application/octet-stream")}
|
files = {"file": (file_path.name, handle, "application/octet-stream")}
|
||||||
data = {key: str(value) for key, value in extra.items() if value is not None}
|
data = {key: str(value) for key, value in extra.items() if value is not None}
|
||||||
response = self.session.post(
|
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))
|
||||||
self._url(f"/documents/storage-agent/download-requests/{request_id}/upload"),
|
response.raise_for_status(); return response.json() if response.content else {"status": "ok"}
|
||||||
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]:
|
def update_manifest(self) -> dict[str, Any]:
|
||||||
response = self.session.get(
|
response = self.session.get(self._url("/documents/erp-local-agent/update-manifest"), timeout=self.config.request_timeout_seconds)
|
||||||
self._url("/documents/erp-local-agent/update-manifest"),
|
response.raise_for_status(); return response.json()
|
||||||
timeout=self.config.request_timeout_seconds,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def download_update_package(self) -> bytes:
|
def download_update_package(self) -> bytes:
|
||||||
response = self.session.get(
|
response = self.session.get(self._url("/documents/erp-local-agent/update-package"), timeout=max(self.config.request_timeout_seconds, 300))
|
||||||
self._url("/documents/erp-local-agent/update-package"),
|
response.raise_for_status(); return response.content
|
||||||
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_code: str
|
||||||
node_secret: str
|
node_secret: str
|
||||||
storage_root: Path
|
storage_root: Path
|
||||||
|
enrollment_token: str | None = None
|
||||||
|
agent_instance_id: str | None = None
|
||||||
tenant_id: str | None = None
|
tenant_id: str | None = None
|
||||||
branch_id: str | None = None
|
branch_id: str | None = None
|
||||||
poll_interval_seconds: int = 30
|
poll_interval_seconds: int = 30
|
||||||
@@ -34,11 +36,14 @@ class AgentConfig:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def headers(self) -> dict[str, str]:
|
def headers(self) -> dict[str, str]:
|
||||||
return {
|
headers = {
|
||||||
"X-Node-Code": self.node_code,
|
"X-Node-Code": self.node_code,
|
||||||
"X-Node-Secret": self.node_secret,
|
"X-Node-Secret": self.node_secret,
|
||||||
"User-Agent": f"ERPLocalAgent/{__version__}",
|
"User-Agent": f"ERPLocalAgent/{__version__}",
|
||||||
}
|
}
|
||||||
|
if self.agent_instance_id:
|
||||||
|
headers["X-Agent-Instance-ID"] = self.agent_instance_id
|
||||||
|
return headers
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tunnel_url(self) -> str:
|
def tunnel_url(self) -> str:
|
||||||
@@ -49,7 +54,10 @@ class AgentConfig:
|
|||||||
ws_base = "ws://" + base[len("http://"):]
|
ws_base = "ws://" + base[len("http://"):]
|
||||||
else:
|
else:
|
||||||
ws_base = base
|
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}"
|
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("/")
|
erp_base_url = os.getenv("ERP_BASE_URL", "").rstrip("/")
|
||||||
node_code = os.getenv("NODE_CODE", "").strip()
|
node_code = os.getenv("NODE_CODE", "").strip()
|
||||||
node_secret = os.getenv("NODE_SECRET", "").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()
|
storage_root_raw = os.getenv("STORAGE_ROOT", "").strip()
|
||||||
tenant_id = os.getenv("TENANT_ID", os.getenv("AUDIT_FIRM_ID", "")).strip() or None
|
tenant_id = os.getenv("TENANT_ID", os.getenv("AUDIT_FIRM_ID", "")).strip() or None
|
||||||
branch_id = os.getenv("BRANCH_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")
|
missing.append("ERP_BASE_URL")
|
||||||
if not node_code:
|
if not node_code:
|
||||||
missing.append("NODE_CODE")
|
missing.append("NODE_CODE")
|
||||||
if not node_secret:
|
if not node_secret and not enrollment_token:
|
||||||
missing.append("NODE_SECRET")
|
missing.append("NODE_SECRET or ENROLLMENT_TOKEN")
|
||||||
if not storage_root_raw:
|
if not storage_root_raw:
|
||||||
missing.append("STORAGE_ROOT")
|
missing.append("STORAGE_ROOT")
|
||||||
if missing:
|
if missing:
|
||||||
@@ -103,6 +112,7 @@ def load_config(env_file: str | None = None) -> AgentConfig:
|
|||||||
node_code=node_code,
|
node_code=node_code,
|
||||||
node_secret=node_secret,
|
node_secret=node_secret,
|
||||||
storage_root=storage_root,
|
storage_root=storage_root,
|
||||||
|
enrollment_token=enrollment_token,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
branch_id=branch_id,
|
branch_id=branch_id,
|
||||||
poll_interval_seconds=_get_int("POLL_INTERVAL_SECONDS", 30),
|
poll_interval_seconds=_get_int("POLL_INTERVAL_SECONDS", 30),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -42,7 +43,12 @@ def main() -> int:
|
|||||||
db.set_meta("machine_name", workstation["machine_name"])
|
db.set_meta("machine_name", workstation["machine_name"])
|
||||||
db.set_meta("machine_fingerprint", workstation["machine_fingerprint"])
|
db.set_meta("machine_fingerprint", workstation["machine_fingerprint"])
|
||||||
db.record_event("INFO", "agent_started", f"ERP Local Agent {__version__} started")
|
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)
|
agent = StorageAgent(config, client, db, logger)
|
||||||
updater = AgentUpdater(config, client, logger, root, db=db)
|
updater = AgentUpdater(config, client, logger, root, db=db)
|
||||||
dashboard = AgentDashboard(config, db, updater, logger, root)
|
dashboard = AgentDashboard(config, db, updater, logger, root)
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ class ERPWorkstationAgent(CommonBase):
|
|||||||
machine_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
machine_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
platform_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
platform_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
agent_version: Mapped[str | None] = mapped_column(String(40), nullable=True, index=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)
|
capabilities_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
tally_connected: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=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)
|
tally_company_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app.modules.documents.models import (
|
|||||||
DocumentStorageJob,
|
DocumentStorageJob,
|
||||||
EngagementDocument,
|
EngagementDocument,
|
||||||
EngagementDocumentVersion,
|
EngagementDocumentVersion,
|
||||||
|
ERPWorkstationAgent,
|
||||||
PermanentClientDocument,
|
PermanentClientDocument,
|
||||||
PermanentClientDocumentVersion,
|
PermanentClientDocumentVersion,
|
||||||
PermanentDocumentDownloadRequest,
|
PermanentDocumentDownloadRequest,
|
||||||
@@ -813,6 +814,53 @@ def authenticate_storage_node(db: Session, node_code: str | None, secret: str |
|
|||||||
return node
|
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):
|
def list_pending_storage_jobs(db: Session, node: BranchStorageNode, limit: int = 20):
|
||||||
return db.execute(
|
return db.execute(
|
||||||
select(DocumentStorageJob)
|
select(DocumentStorageJob)
|
||||||
|
|||||||
@@ -149,10 +149,16 @@
|
|||||||
{% if node.status == 'disabled_duplicate' %}
|
{% if node.status == 'disabled_duplicate' %}
|
||||||
<span class="text-xs text-slate-500">Duplicate disabled</span>
|
<span class="text-xs text-slate-500">Duplicate disabled</span>
|
||||||
{% else %}
|
{% 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">
|
<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="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="storage_root_path" value="{{ node.storage_root_path or 'D:\AuditFirmStorage' }}">
|
<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>
|
||||||
<form method="post" action="/documents/storage-nodes/{{ node.id }}/toggle">
|
<form method="post" action="/documents/storage-nodes/{{ node.id }}/toggle">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<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="rounded-2xl border bg-white overflow-hidden shadow-sm">
|
||||||
<div class="p-4 border-b">
|
<div class="p-4 border-b">
|
||||||
<h2 class="font-bold text-slate-900">Registered Workstations</h2>
|
<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>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
|
|||||||
+125
-3
@@ -17,6 +17,7 @@ from sqlalchemy.orm import joinedload
|
|||||||
from app.core.db.common import CommonSessionLocal
|
from app.core.db.common import CommonSessionLocal
|
||||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
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.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.core.templating import templates
|
||||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
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.core.rbac.permission_guard import require_permission
|
||||||
@@ -33,6 +34,7 @@ from app.modules.documents.services import (
|
|||||||
cleanup_completed_permanent_storage_job_vps_stage,
|
cleanup_completed_permanent_storage_job_vps_stage,
|
||||||
retry_verified_vps_cleanup_for_node,
|
retry_verified_vps_cleanup_for_node,
|
||||||
authenticate_storage_node,
|
authenticate_storage_node,
|
||||||
|
authenticate_storage_agent,
|
||||||
create_branch_storage_node,
|
create_branch_storage_node,
|
||||||
generate_storage_secret,
|
generate_storage_secret,
|
||||||
hash_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)
|
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")
|
@router.post("/storage-nodes/download-agent-package")
|
||||||
def download_preconfigured_storage_agent(
|
def download_preconfigured_storage_agent(
|
||||||
request: Request,
|
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):
|
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:
|
if not node:
|
||||||
return None, JSONResponse({"ok": False, "error": "invalid_storage_node_credentials"}, status_code=401)
|
return None, JSONResponse({"ok": False, "error": "invalid_storage_node_credentials"}, status_code=401)
|
||||||
return node, None
|
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_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")
|
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()
|
await websocket.accept()
|
||||||
|
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
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:
|
if not node:
|
||||||
await websocket.send_json({"ok": False, "type": "error", "error": "invalid_storage_node_credentials"})
|
await websocket.send_json({"ok": False, "type": "error", "error": "invalid_storage_node_credentials"})
|
||||||
await websocket.close(code=1008)
|
await websocket.close(code=1008)
|
||||||
@@ -1971,7 +2093,7 @@ async def storage_agent_tunnel(websocket: WebSocket):
|
|||||||
|
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
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:
|
if not node:
|
||||||
await websocket.send_json({"ok": False, "type": "error", "error": "node_deactivated_or_invalid"})
|
await websocket.send_json({"ok": False, "type": "error", "error": "node_deactivated_or_invalid"})
|
||||||
await websocket.close(code=1008)
|
await websocket.close(code=1008)
|
||||||
|
|||||||
@@ -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.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.clients.models import Client, ClientBusinessUnit, ClientBranch
|
||||||
from app.modules.documents.models import EngagementDocument
|
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.registrations.models import ClientRegistration, RegistrationType
|
||||||
from app.modules.services.engagement_resources import build_engagement_resource_context
|
from app.modules.services.engagement_resources import build_engagement_resource_context
|
||||||
from app.modules.services.execution import (
|
from app.modules.services.execution import (
|
||||||
@@ -152,11 +152,18 @@ def list_employees(
|
|||||||
q: str = "",
|
q: str = "",
|
||||||
include_inactive: bool = False,
|
include_inactive: bool = False,
|
||||||
link_status: str = "all",
|
link_status: str = "all",
|
||||||
|
status_filter: str | None = None,
|
||||||
) -> list[Employee]:
|
) -> list[Employee]:
|
||||||
stmt = select(Employee).where(Employee.tenant_id == scope.tenant_id)
|
stmt = select(Employee).where(Employee.tenant_id == scope.tenant_id)
|
||||||
if scope.branch_id is not None:
|
if scope.branch_id is not None:
|
||||||
stmt = stmt.where(Employee.branch_id == scope.branch_id)
|
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))
|
stmt = stmt.where(Employee.is_active.is_(True))
|
||||||
|
|
||||||
link_status = (link_status or "all").lower()
|
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:
|
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"
|
return value or "General Workflow"
|
||||||
|
|
||||||
|
|
||||||
@@ -4021,10 +4032,37 @@ def get_employee_engagement_work_board(
|
|||||||
if int(row.id) in assigned_ids
|
if int(row.id) in assigned_ids
|
||||||
or (getattr(row, "default_role_name", None) or "").strip().lower() in reviewer_roles
|
or (getattr(row, "default_role_name", None) or "").strip().lower() in reviewer_roles
|
||||||
]
|
]
|
||||||
use_task_categories = any(
|
# Resolve task categories from the current Task Category master/template when
|
||||||
bool((getattr(row, "task_category", None) or "").strip())
|
# 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
|
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 = {
|
summary = {
|
||||||
"total": len(tasks),
|
"total": len(tasks),
|
||||||
@@ -4056,6 +4094,7 @@ def get_employee_engagement_work_board(
|
|||||||
if category is None:
|
if category is None:
|
||||||
category = {
|
category = {
|
||||||
"name": category_name,
|
"name": category_name,
|
||||||
|
"sort_order": int(getattr(task, "_resolved_task_category_sort", 100) or 100),
|
||||||
"tasks": [],
|
"tasks": [],
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"completed": 0,
|
"completed": 0,
|
||||||
@@ -4075,6 +4114,8 @@ def get_employee_engagement_work_board(
|
|||||||
elif status_code == "in_progress":
|
elif status_code == "in_progress":
|
||||||
category["in_progress"] += 1
|
category["in_progress"] += 1
|
||||||
|
|
||||||
|
categories.sort(key=lambda item: (int(item.get("sort_order", 100)), item["name"].lower()))
|
||||||
|
|
||||||
for category in categories:
|
for category in categories:
|
||||||
weighted_category = _weighted_progress(category["tasks"])
|
weighted_category = _weighted_progress(category["tasks"])
|
||||||
category.update(weighted_category)
|
category.update(weighted_category)
|
||||||
|
|||||||
@@ -22,9 +22,12 @@
|
|||||||
<option value="linked" {% if link_status == 'linked' %}selected{% endif %}>Linked to login user</option>
|
<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>
|
<option value="unlinked" {% if link_status == 'unlinked' %}selected{% endif %}>Not linked to login user</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="inline-flex items-center gap-2 rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
|
<select name="status" class="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
|
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>All staff statuses</option>
|
||||||
</label>
|
<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>
|
<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>
|
</div>
|
||||||
</form>
|
</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="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>
|
<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>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -114,9 +114,12 @@
|
|||||||
<div class="divide-y divide-slate-100">
|
<div class="divide-y divide-slate-100">
|
||||||
{% for category in board.categories %}
|
{% for category in board.categories %}
|
||||||
{% if board.use_task_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">
|
<details class="group" {% if loop.first %}open{% endif %}>
|
||||||
{{ category.name }} · {{ category.completed }}/{{ category.total }}
|
<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">
|
||||||
</div>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% for task in category.tasks %}
|
{% for task in category.tasks %}
|
||||||
@@ -365,6 +368,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if board.use_task_categories %}</div></details>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ def _safe_employee_return_url(value: str | None, fallback: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@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()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user(request, db=db)
|
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")
|
branch_id = request.session.get("active_branch_id")
|
||||||
scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=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"
|
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(
|
rows = list_employees(
|
||||||
db,
|
db,
|
||||||
scope,
|
scope,
|
||||||
q=q,
|
q=q,
|
||||||
include_inactive=bool(include_inactive),
|
include_inactive=(requested_status == "all"),
|
||||||
link_status=normalized_link_status,
|
link_status=normalized_link_status,
|
||||||
|
status_filter=requested_status,
|
||||||
)
|
)
|
||||||
link_summary = get_employee_user_link_summary(db, scope)
|
link_summary = get_employee_user_link_summary(db, scope)
|
||||||
return _render(
|
return _render(
|
||||||
@@ -543,7 +550,8 @@ def employees_list(request: Request, q: str = "", include_inactive: str | None =
|
|||||||
title="Employees",
|
title="Employees",
|
||||||
rows=rows,
|
rows=rows,
|
||||||
q=q,
|
q=q,
|
||||||
include_inactive=bool(include_inactive),
|
include_inactive=(requested_status == "all"),
|
||||||
|
selected_status=requested_status,
|
||||||
link_status=normalized_link_status,
|
link_status=normalized_link_status,
|
||||||
link_summary=link_summary,
|
link_summary=link_summary,
|
||||||
scope=scope,
|
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_id": employee.id if employee else None,
|
||||||
"employee_linked": bool(employee),
|
"employee_linked": bool(employee),
|
||||||
"employee_code": employee.employee_code if employee else None,
|
"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,
|
"is_active": user.is_active,
|
||||||
"allow_login": user.allow_login,
|
"allow_login": user.allow_login,
|
||||||
"is_locked": user.is_locked,
|
"is_locked": user.is_locked,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
{% if user.employee_linked %}
|
{% if user.employee_linked %}
|
||||||
<span class="rounded-full bg-emerald-50 px-2 py-1 font-semibold text-emerald-700">Linked</span>
|
<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>
|
<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 %}
|
{% else %}
|
||||||
<span class="rounded-full bg-amber-50 px-2 py-1 font-semibold text-amber-700">Not linked</span>
|
<span class="rounded-full bg-amber-50 px-2 py-1 font-semibold text-amber-700">Not linked</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from app.modules.services.models import (
|
|||||||
ClientServiceTaskInstance,
|
ClientServiceTaskInstance,
|
||||||
ServiceCatalogue,
|
ServiceCatalogue,
|
||||||
ServiceTaskComment,
|
ServiceTaskComment,
|
||||||
|
FirmServiceTaskTemplate,
|
||||||
|
ServiceTaskCategory,
|
||||||
)
|
)
|
||||||
from app.modules.employees.service import _engagement_sla, _engagement_team, _weighted_progress
|
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)
|
.join(Role, Role.id == UserRole.role_id)
|
||||||
.where(
|
.where(
|
||||||
User.tenant_id == tenant_id,
|
User.tenant_id == tenant_id,
|
||||||
User.is_active.is_(True),
|
|
||||||
Role.is_active.is_(True),
|
Role.is_active.is_(True),
|
||||||
Role.name.in_(("Staff", "Branch Manager")),
|
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,
|
"name": user.full_name or user.email,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"designation": user.designation,
|
"designation": user.designation,
|
||||||
|
"is_active": bool(user.is_active),
|
||||||
"active": stats["active"],
|
"active": stats["active"],
|
||||||
"overdue": stats["overdue"],
|
"overdue": stats["overdue"],
|
||||||
"review": stats["review"],
|
"review": stats["review"],
|
||||||
"client_pending": stats["client_pending"],
|
"client_pending": stats["client_pending"],
|
||||||
"load_status": "Heavy" if total >= 40 else ("Balanced" if total >= 10 else "Light"),
|
"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]
|
return rows[:25]
|
||||||
|
|
||||||
|
|
||||||
@@ -442,7 +444,7 @@ def _display_user(user) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _partner_task_category(task: ClientServiceTaskInstance) -> 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:
|
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())
|
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
|
||||||
)
|
)
|
||||||
tasks = list(db.execute(task_stmt).scalars().all())
|
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]
|
task_rows = [_partner_task_payload(task, subscription, current_user) for task in tasks]
|
||||||
|
|
||||||
categories_by_name: dict[str, dict[str, Any]] = {}
|
categories_by_name: dict[str, dict[str, Any]] = {}
|
||||||
for row in task_rows:
|
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"], {
|
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,
|
"reviewed": 0, "rework": 0, "exceptions": 0, "blockers": 0,
|
||||||
})
|
})
|
||||||
cat["tasks"].append(row)
|
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["review_state"] == "rework": cat["rework"] += 1
|
||||||
if row["is_exception"]: cat["exceptions"] += 1
|
if row["is_exception"]: cat["exceptions"] += 1
|
||||||
if row["blocks_final_release"] and row["aqmm_status"] != "completed": cat["blockers"] += 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:
|
for cat in categories:
|
||||||
denominator = cat["pending"] + cat["reviewed"] + cat["rework"]
|
denominator = cat["pending"] + cat["reviewed"] + cat["rework"]
|
||||||
cat["progress_percent"] = int(round((cat["reviewed"] / denominator) * 100)) if denominator else 100
|
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">
|
<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>
|
<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">
|
<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>
|
</tbody></table></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -10,6 +10,8 @@ from app.core.security.session_auth import get_current_user
|
|||||||
from app.core.templating import templates
|
from app.core.templating import templates
|
||||||
from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
|
from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
|
||||||
from app.modules.consultants.models import ConsultantProfile
|
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.deps import get_user_permissions, get_user_roles
|
||||||
from app.modules.core.rbac.permission_guard import require_permission
|
from app.modules.core.rbac.permission_guard import require_permission
|
||||||
from app.modules.registrations.models import *
|
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()
|
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()
|
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()
|
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")
|
@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(...)):
|
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(...)):
|
||||||
|
|||||||
Reference in New Issue
Block a user