From 97777e13a1900576eaf1ad02419df5cd5d7149ca Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Wed, 2 Sep 2026 14:05:20 +0530 Subject: [PATCH] Add staff visibility workstation enrollment registration vault and category task views --- .../versions/20260902_ws_enrollment_secret.py | 23 +++ .../templates/credential_vault/form.html | 75 ++++++++- app/modules/credential_vault/ui.py | 29 +++- app/modules/documents/agent_package.py | 5 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/client.py | 142 ++++++++++-------- .../erp_local_agent/config.py | 18 ++- .../erp_local_agent/main.py | 8 +- app/modules/documents/models.py | 1 + app/modules/documents/services.py | 48 ++++++ .../templates/documents/storage_nodes.html | 10 +- app/modules/documents/ui.py | 128 +++++++++++++++- app/modules/employees/service.py | 53 ++++++- .../employees/templates/employees/list.html | 11 +- .../employees/work_engagement_board.html | 10 +- app/modules/employees/ui.py | 14 +- app/modules/firm_admin_dashboard/service.py | 2 + .../firm_admin_dashboard/partials/users.html | 1 + app/modules/partner_dashboard/service.py | 34 ++++- .../partner_dashboard/partials/staff.html | 2 +- .../templates/registrations/client.html | 2 +- app/modules/registrations/ui.py | 23 ++- 22 files changed, 538 insertions(+), 103 deletions(-) create mode 100644 alembic/versions/20260902_ws_enrollment_secret.py diff --git a/alembic/versions/20260902_ws_enrollment_secret.py b/alembic/versions/20260902_ws_enrollment_secret.py new file mode 100644 index 0000000..c5324cc --- /dev/null +++ b/alembic/versions/20260902_ws_enrollment_secret.py @@ -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") diff --git a/app/modules/credential_vault/templates/credential_vault/form.html b/app/modules/credential_vault/templates/credential_vault/form.html index c1f2e42..905f509 100644 --- a/app/modules/credential_vault/templates/credential_vault/form.html +++ b/app/modules/credential_vault/templates/credential_vault/form.html @@ -1 +1,74 @@ -{% extends "base/layout.html" %}{% block content %}

Add encrypted credential

Explicit staff access
{% for u in users %}{% endfor %}
Cancel
{% endblock %} +{% extends "base/layout.html" %} +{% block content %} +
+
+

Add encrypted credential

+ {% if selected_registration %} +

Linked to registration {{ selected_registration.registration_number }}. Secrets remain encrypted and reveal access is audited.

+ {% endif %} +
+
+ + + + + + + + + + + + + + +
Explicit staff access
{% for u in users %}{% endfor %}
+ +
Cancel
+
+
+ +{% endblock %} diff --git a/app/modules/credential_vault/ui.py b/app/modules/credential_vault/ui.py index e1fac3b..311c026 100644 --- a/app/modules/credential_vault/ui.py +++ b/app/modules/credential_vault/ui.py @@ -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) diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index be191ff..e078257 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -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" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 8d9c67e..2259918 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.21.1" +__version__ = "1.22.0" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/client.py b/app/modules/documents/local_agent_runtime/erp_local_agent/client.py index 67a433e..5c2439c 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/client.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/client.py @@ -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 diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/config.py b/app/modules/documents/local_agent_runtime/erp_local_agent/config.py index 3ada930..12a0f6f 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/config.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/config.py @@ -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), diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/main.py b/app/modules/documents/local_agent_runtime/erp_local_agent/main.py index b387c56..b8b81c1 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/main.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/main.py @@ -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) diff --git a/app/modules/documents/models.py b/app/modules/documents/models.py index 8c98e60..460f857 100644 --- a/app/modules/documents/models.py +++ b/app/modules/documents/models.py @@ -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) diff --git a/app/modules/documents/services.py b/app/modules/documents/services.py index 0f4cccc..e86bdb7 100644 --- a/app/modules/documents/services.py +++ b/app/modules/documents/services.py @@ -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) diff --git a/app/modules/documents/templates/documents/storage_nodes.html b/app/modules/documents/templates/documents/storage_nodes.html index 1f51a32..2d05440 100644 --- a/app/modules/documents/templates/documents/storage_nodes.html +++ b/app/modules/documents/templates/documents/storage_nodes.html @@ -149,10 +149,16 @@ {% if node.status == 'disabled_duplicate' %} Duplicate disabled {% else %} +
+ + + +
+
Installs on another office PC without changing credentials used by existing workstations.
- +
@@ -173,7 +179,7 @@

Registered Workstations

-

Each PC keeps a persistent agent identity. The branch storage node remains unchanged; multiple Tally workstations can report through the same outbound tunnel credentials.

+

Each PC keeps a persistent agent identity. Use + New Workstation on the branch node above for another office PC; it receives its own workstation credential and existing agents keep working.

diff --git a/app/modules/documents/ui.py b/app/modules/documents/ui.py index b884f48..30c9545 100644 --- a/app/modules/documents/ui.py +++ b/app/modules/documents/ui.py @@ -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) diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py index 0380cde..a09e78e 100644 --- a/app/modules/employees/service.py +++ b/app/modules/employees/service.py @@ -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) diff --git a/app/modules/employees/templates/employees/list.html b/app/modules/employees/templates/employees/list.html index 62e74d7..2f45ac4 100644 --- a/app/modules/employees/templates/employees/list.html +++ b/app/modules/employees/templates/employees/list.html @@ -22,9 +22,12 @@ - + @@ -41,7 +44,7 @@
{{ link_summary.unlinked }} employee(s) are not linked to login users.

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.

- Show unlinked employees + Show unlinked employees
{% endif %} diff --git a/app/modules/employees/templates/employees/work_engagement_board.html b/app/modules/employees/templates/employees/work_engagement_board.html index b2196c4..95f0eee 100644 --- a/app/modules/employees/templates/employees/work_engagement_board.html +++ b/app/modules/employees/templates/employees/work_engagement_board.html @@ -114,9 +114,12 @@
{% for category in board.categories %} {% if board.use_task_categories %} -
- {{ category.name }} · {{ category.completed }}/{{ category.total }} -
+
+ + ▶{{ category.name }} + {{ category.completed }}/{{ category.total }} completed + +
{% endif %} {% for task in category.tasks %} @@ -365,6 +368,7 @@
{% endfor %} + {% if board.use_task_categories %}{% endif %} {% endfor %} diff --git a/app/modules/employees/ui.py b/app/modules/employees/ui.py index caf83c6..c0f62b6 100644 --- a/app/modules/employees/ui.py +++ b/app/modules/employees/ui.py @@ -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, diff --git a/app/modules/firm_admin_dashboard/service.py b/app/modules/firm_admin_dashboard/service.py index 5d07d87..1de7434 100644 --- a/app/modules/firm_admin_dashboard/service.py +++ b/app/modules/firm_admin_dashboard/service.py @@ -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, diff --git a/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html index 66872e6..dc155c0 100644 --- a/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html +++ b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html @@ -35,6 +35,7 @@ {% if user.employee_linked %} Linked
{{ user.employee_code or ('#' ~ user.employee_id) }}
+ {% if user.employee_status %}{{ user.employee_status|replace('_',' ')|title }}{% endif %} {% else %} Not linked {% endif %} diff --git a/app/modules/partner_dashboard/service.py b/app/modules/partner_dashboard/service.py index afaac6c..bd18feb 100644 --- a/app/modules/partner_dashboard/service.py +++ b/app/modules/partner_dashboard/service.py @@ -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 diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html index b45adf9..07aaca8 100644 --- a/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html +++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html @@ -1,6 +1,6 @@

Branch Staff Workload

Workload is calculated from current branch task assignments.

- {% for s in staff_rows %}{% else %}{% endfor %} + {% for s in staff_rows %}{% else %}{% endfor %}
Staff / ManagerActiveOverdueClient PendingReviewLoad
{{ s.name }}
{{ s.designation or s.email }}
{{ s.active }}{{ s.overdue }}{{ s.client_pending }}{{ s.review }}{{ s.load_status }}
No active Staff or Branch Manager users found for this branch.
{{ s.name }}
{% if not s.is_active %}Inactive{% endif %}
{{ s.designation or s.email }}
{{ s.active }}{{ s.overdue }}{{ s.client_pending }}{{ s.review }}{{ s.load_status }}
No Staff or Branch Manager users found for this branch.
diff --git a/app/modules/registrations/templates/registrations/client.html b/app/modules/registrations/templates/registrations/client.html index aff3cd7..80d02e4 100644 --- a/app/modules/registrations/templates/registrations/client.html +++ b/app/modules/registrations/templates/registrations/client.html @@ -1 +1 @@ -{% extends "ui/templates/base/layout.html" %}{% block content %}

{{client.client_name}} — Registrations

← Dashboard

Registration records

{% for r,t in registrations %}
{{t.name}} — {{r.registration_number}}
Valid until: {{r.valid_until or '—'}} · Next action: {{r.next_action_date or '—'}} · {{r.status}}
{% else %}

No records.

{% endfor %}

Related persons and DSC

{% for p in people %}
{{p.full_name}} — {{p.person_type}}{% if p.din %} · DIN {{p.din}}{% endif %}
{% endfor %}{% for d,p in dscs %}
DSC: {{p.full_name}} · expires {{d.expires_on}} · {{d.status}}
{% endfor %}
{% if can_manage %}

Add Related Person

Add Registration

Add DSC

{% endif %}
{% endblock %} +{% extends "ui/templates/base/layout.html" %}{% block content %}

{{client.client_name}} — Registrations

← Dashboard

Registration records

{% for r,t in registrations %}
{{t.name}} — {{r.registration_number}}
Valid until: {{r.valid_until or '—'}} · Next action: {{r.next_action_date or '—'}} · {{r.status}}
{% if can_manage_vault %}+ Credential{% endif %}
{% set vault_entries = vault_by_registration.get(r.id, []) if vault_by_registration else [] %}{% if vault_entries %}
{% for v in vault_entries %}🔐 {{ v.title }}{% endfor %}
{% endif %}
{% else %}

No records.

{% endfor %}

Related persons and DSC

{% for p in people %}
{{p.full_name}} — {{p.person_type}}{% if p.din %} · DIN {{p.din}}{% endif %}
{% endfor %}{% for d,p in dscs %}
DSC: {{p.full_name}} · expires {{d.expires_on}} · {{d.status}}
{% endfor %}
{% if can_manage %}

Add Related Person

Add Registration

Add DSC

{% endif %}
{% endblock %} diff --git a/app/modules/registrations/ui.py b/app/modules/registrations/ui.py index 7cfa957..735b934 100644 --- a/app/modules/registrations/ui.py +++ b/app/modules/registrations/ui.py @@ -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(...)):