Add task tool result bridge and evidence attachment workflow
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
"""task tool result bridge
|
||||||
|
|
||||||
|
Revision ID: 20260831_task_tool_result_bridge
|
||||||
|
Revises: 20260830_task_admin_tools
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260831_task_tool_result_bridge"
|
||||||
|
down_revision = "20260830_task_admin_tools"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"service_task_tool_runs",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("task_instance_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("client_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tool_code", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), server_default="started", nullable=False),
|
||||||
|
sa.Column("summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("result_json", sa.Text(), nullable=True),
|
||||||
|
sa.Column("exception_count", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("clean_pass", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("output_filename", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("output_content_type", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("output_bytes", sa.LargeBinary(), nullable=True),
|
||||||
|
sa.Column("started_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("completed_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("started_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["task_instance_id"], ["client_service_task_instances.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["started_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["completed_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_service_task_tool_runs_tenant_id", "service_task_tool_runs", ["tenant_id"], unique=False)
|
||||||
|
op.create_index("ix_service_task_tool_runs_task_instance_id", "service_task_tool_runs", ["task_instance_id"], unique=False)
|
||||||
|
op.create_index("ix_service_task_tool_runs_client_id", "service_task_tool_runs", ["client_id"], unique=False)
|
||||||
|
op.create_index("ix_service_task_tool_runs_tool_code", "service_task_tool_runs", ["tool_code"], unique=False)
|
||||||
|
op.create_index("ix_service_task_tool_runs_status", "service_task_tool_runs", ["status"], unique=False)
|
||||||
|
op.create_index("ix_service_task_tool_runs_started_by_user_id", "service_task_tool_runs", ["started_by_user_id"], unique=False)
|
||||||
|
op.create_index("ix_service_task_tool_runs_completed_by_user_id", "service_task_tool_runs", ["completed_by_user_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_service_task_tool_runs_completed_by_user_id", table_name="service_task_tool_runs")
|
||||||
|
op.drop_index("ix_service_task_tool_runs_started_by_user_id", table_name="service_task_tool_runs")
|
||||||
|
op.drop_index("ix_service_task_tool_runs_status", table_name="service_task_tool_runs")
|
||||||
|
op.drop_index("ix_service_task_tool_runs_tool_code", table_name="service_task_tool_runs")
|
||||||
|
op.drop_index("ix_service_task_tool_runs_client_id", table_name="service_task_tool_runs")
|
||||||
|
op.drop_index("ix_service_task_tool_runs_task_instance_id", table_name="service_task_tool_runs")
|
||||||
|
op.drop_index("ix_service_task_tool_runs_tenant_id", table_name="service_task_tool_runs")
|
||||||
|
op.drop_table("service_task_tool_runs")
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
|
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, LargeBinary, Numeric, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.db.common import CommonBase
|
from app.core.db.common import CommonBase
|
||||||
@@ -940,6 +940,40 @@ class ServiceTaskDocumentRequest(CommonBase):
|
|||||||
verified_by = relationship("User", foreign_keys=[verified_by_user_id])
|
verified_by = relationship("User", foreign_keys=[verified_by_user_id])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceTaskToolRun(CommonBase):
|
||||||
|
"""Persisted execution/result record for an ERP tool launched from a service task.
|
||||||
|
|
||||||
|
The result attachment is stored with the run so a tool can return its generated
|
||||||
|
workbook/PDF/JSON to the originating task without changing the existing document
|
||||||
|
module or historical EngagementDocument records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "service_task_tool_runs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
task_instance_id: Mapped[int] = mapped_column(ForeignKey("client_service_task_instances.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
tool_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, default="started", index=True)
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
exception_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
clean_pass: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
output_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
output_content_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
output_bytes: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||||
|
started_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
completed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
started_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
|
||||||
|
task = relationship("ClientServiceTaskInstance", foreign_keys=[task_instance_id])
|
||||||
|
|
||||||
class ServiceTaskComment(CommonBase):
|
class ServiceTaskComment(CommonBase):
|
||||||
"""Communication timeline entry linked to a service task instance."""
|
"""Communication timeline entry linked to a service task instance."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.services.models import ClientServiceTaskInstance, ServiceTaskToolRun
|
||||||
|
from app.modules.services.task_tools import get_task_tool, normalize_task_tool_code
|
||||||
|
|
||||||
|
MAX_TOOL_OUTPUT_BYTES = 25 * 1024 * 1024
|
||||||
|
VALID_RESULT_STATUSES = {"completed", "completed_with_exceptions", "failed", "cancelled"}
|
||||||
|
|
||||||
|
|
||||||
|
def start_task_tool_run(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
task: ClientServiceTaskInstance,
|
||||||
|
user_id: int | None,
|
||||||
|
) -> ServiceTaskToolRun:
|
||||||
|
tool_code = normalize_task_tool_code(getattr(task, "task_tool_code", "NONE"))
|
||||||
|
if tool_code == "NONE":
|
||||||
|
raise ValueError("This task does not have an ERP tool linked.")
|
||||||
|
get_task_tool(tool_code)
|
||||||
|
run = ServiceTaskToolRun(
|
||||||
|
tenant_id=int(task.tenant_id),
|
||||||
|
task_instance_id=int(task.id),
|
||||||
|
client_id=int(task.client_id),
|
||||||
|
tool_code=tool_code,
|
||||||
|
status="started",
|
||||||
|
started_by_user_id=user_id,
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
db.flush()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def list_task_tool_runs(db: Session, *, tenant_id: int, task_id: int) -> list[ServiceTaskToolRun]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(ServiceTaskToolRun)
|
||||||
|
.where(
|
||||||
|
ServiceTaskToolRun.tenant_id == int(tenant_id),
|
||||||
|
ServiceTaskToolRun.task_instance_id == int(task_id),
|
||||||
|
)
|
||||||
|
.order_by(ServiceTaskToolRun.started_at_utc.desc(), ServiceTaskToolRun.id.desc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_tool_run(db: Session, *, tenant_id: int, run_id: int) -> ServiceTaskToolRun | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(ServiceTaskToolRun).where(
|
||||||
|
ServiceTaskToolRun.id == int(run_id),
|
||||||
|
ServiceTaskToolRun.tenant_id == int(tenant_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_text(payload: Any) -> str | None:
|
||||||
|
if payload is None or payload == "":
|
||||||
|
return None
|
||||||
|
if isinstance(payload, str):
|
||||||
|
text = payload.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
return json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"value": text}, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
return json.dumps(payload, ensure_ascii=False, default=str, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def record_task_tool_result(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
run: ServiceTaskToolRun,
|
||||||
|
status: str,
|
||||||
|
summary: str | None = None,
|
||||||
|
result_payload: Any = None,
|
||||||
|
clean_pass: bool | None = None,
|
||||||
|
exception_count: int | None = None,
|
||||||
|
output_filename: str | None = None,
|
||||||
|
output_content_type: str | None = None,
|
||||||
|
output_bytes: bytes | None = None,
|
||||||
|
completed_by_user_id: int | None = None,
|
||||||
|
) -> ServiceTaskToolRun:
|
||||||
|
normalized_status = (status or "completed").strip().lower()
|
||||||
|
if normalized_status not in VALID_RESULT_STATUSES:
|
||||||
|
raise ValueError(f"Unsupported tool result status: {normalized_status}")
|
||||||
|
if output_bytes is not None and len(output_bytes) > MAX_TOOL_OUTPUT_BYTES:
|
||||||
|
raise ValueError("Tool output exceeds the 25 MB task-result attachment limit.")
|
||||||
|
|
||||||
|
run.status = normalized_status
|
||||||
|
run.summary = (summary or "").strip() or None
|
||||||
|
run.result_json = _json_text(result_payload)
|
||||||
|
run.clean_pass = clean_pass
|
||||||
|
run.exception_count = exception_count
|
||||||
|
run.output_filename = (output_filename or "").strip() or None
|
||||||
|
run.output_content_type = (output_content_type or "").strip() or None
|
||||||
|
run.output_bytes = output_bytes
|
||||||
|
run.completed_by_user_id = completed_by_user_id
|
||||||
|
run.completed_at_utc = datetime.now(timezone.utc)
|
||||||
|
db.add(run)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def record_task_tool_result_by_id(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
run_id: int,
|
||||||
|
status: str,
|
||||||
|
summary: str | None = None,
|
||||||
|
result_payload: Any = None,
|
||||||
|
clean_pass: bool | None = None,
|
||||||
|
exception_count: int | None = None,
|
||||||
|
output_filename: str | None = None,
|
||||||
|
output_content_type: str | None = None,
|
||||||
|
output_bytes: bytes | None = None,
|
||||||
|
completed_by_user_id: int | None = None,
|
||||||
|
) -> ServiceTaskToolRun:
|
||||||
|
run = get_task_tool_run(db, tenant_id=tenant_id, run_id=run_id)
|
||||||
|
if run is None:
|
||||||
|
raise ValueError("Task tool run not found.")
|
||||||
|
return record_task_tool_result(
|
||||||
|
db,
|
||||||
|
run=run,
|
||||||
|
status=status,
|
||||||
|
summary=summary,
|
||||||
|
result_payload=result_payload,
|
||||||
|
clean_pass=clean_pass,
|
||||||
|
exception_count=exception_count,
|
||||||
|
output_filename=output_filename,
|
||||||
|
output_content_type=output_content_type,
|
||||||
|
output_bytes=output_bytes,
|
||||||
|
completed_by_user_id=completed_by_user_id,
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -46,3 +47,25 @@ def normalize_task_tool_code(code: str | None) -> str:
|
|||||||
if value not in valid:
|
if value not in valid:
|
||||||
raise ValueError(f"Unsupported task tool: {value}")
|
raise ValueError(f"Unsupported task tool: {value}")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def build_task_tool_launch_url(
|
||||||
|
tool: TaskToolDefinition,
|
||||||
|
*,
|
||||||
|
client_id: int,
|
||||||
|
task_id: int,
|
||||||
|
tool_run_id: int,
|
||||||
|
) -> str:
|
||||||
|
if not tool.launch_url:
|
||||||
|
raise ValueError("This ERP task tool does not expose a launch URL.")
|
||||||
|
query = urlencode(
|
||||||
|
{
|
||||||
|
"client_id": int(client_id),
|
||||||
|
"task_id": int(task_id),
|
||||||
|
"task_tool_run_id": int(tool_run_id),
|
||||||
|
"task_return_url": f"/services/work-tracker/tasks/{int(task_id)}/edit",
|
||||||
|
"task_result_url": f"/services/work-tracker/tool-runs/{int(tool_run_id)}/result",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
separator = "&" if "?" in tool.launch_url else "?"
|
||||||
|
return f"{tool.launch_url}{separator}{query}"
|
||||||
|
|||||||
@@ -88,8 +88,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if task_tool and task_tool.code != 'NONE' %}
|
{% if task_tool and task_tool.code != 'NONE' %}
|
||||||
<div class="md:col-span-2 rounded-2xl border border-violet-200 bg-violet-50 p-4">
|
<div class="md:col-span-2 rounded-2xl border border-violet-200 bg-violet-50 p-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><div class="text-sm font-semibold text-violet-950">ERP Task Tool · {{ task_tool.name }}</div><p class="mt-1 text-xs text-violet-800">{{ task_tool.description }}</p></div>{% if task_tool.launch_url %}<a href="{{ task_tool.launch_url }}{% if task.client_id %}?client_id={{ task.client_id }}{% endif %}" class="rounded-xl bg-violet-700 px-4 py-2 text-sm font-semibold text-white hover:bg-violet-800">Open Tool</a>{% endif %}</div>
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p class="mt-2 text-xs text-violet-700">Opening the tool does not automatically mark this task complete. Save the task response/evidence after completing the verification.</p>
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-violet-950">ERP Task Tool · {{ task_tool.name }}</div>
|
||||||
|
<p class="mt-1 text-xs text-violet-800">{{ task_tool.description }}</p>
|
||||||
|
</div>
|
||||||
|
{% if task_tool.launch_url %}
|
||||||
|
<button type="submit" formaction="/services/work-tracker/tasks/{{ task.id }}/tool/start" formmethod="post" class="rounded-xl bg-violet-700 px-4 py-2 text-sm font-semibold text-white hover:bg-violet-800">Run Tool</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-violet-700">A Tool Run is created before launch. The linked tool receives the task, client, run and return/result URLs so its generated output can be returned to this task without changing the normal checklist workflow.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if (task.response_type or 'NONE') != 'NONE' or task.response_required or task.evidence_required %}
|
{% if (task.response_type or 'NONE') != 'NONE' or task.response_required or task.evidence_required %}
|
||||||
@@ -156,6 +164,72 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if task_tool and task_tool.code != 'NONE' %}
|
||||||
|
<div class="mx-auto mt-6 max-w-3xl rounded-2xl bg-white p-6 shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-slate-900">Tool Output & Evidence</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Every run is retained against this task. Output returned by the tool is stored with the run and can be downloaded from this task later.</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full bg-violet-100 px-3 py-1 text-xs font-medium text-violet-800">{{ tool_runs|length }} run{% if tool_runs|length != 1 %}s{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if request.query_params.get('tool_saved') %}<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Tool result attached to this task.</div>{% endif %}
|
||||||
|
{% if request.query_params.get('tool_error') %}<div class="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">The tool result could not be saved. Check the selected run and output size.</div>{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-4">
|
||||||
|
{% if tool_runs %}
|
||||||
|
{% for run in tool_runs %}
|
||||||
|
<div class="rounded-2xl border border-slate-200 p-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-slate-900">{{ task_tool.name }} · Run #{{ run.id }}</div>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">Started {{ run.started_at_utc }}{% if run.completed_at_utc %} · Completed {{ run.completed_at_utc }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full px-3 py-1 text-xs font-medium {% if run.status == 'completed' %}bg-emerald-100 text-emerald-800{% elif run.status == 'completed_with_exceptions' %}bg-amber-100 text-amber-800{% elif run.status == 'failed' %}bg-red-100 text-red-800{% else %}bg-slate-100 text-slate-700{% endif %}">{{ run.status.replace('_',' ').title() }}</span>
|
||||||
|
</div>
|
||||||
|
{% if run.summary %}<p class="mt-3 whitespace-pre-wrap text-sm text-slate-700">{{ run.summary }}</p>{% endif %}
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
|
{% if run.clean_pass is not none %}<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">Clean Pass: {{ 'Yes' if run.clean_pass else 'No' }}</span>{% endif %}
|
||||||
|
{% if run.exception_count is not none %}<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">Exceptions: {{ run.exception_count }}</span>{% endif %}
|
||||||
|
{% if run.output_filename %}<a href="/services/work-tracker/tool-runs/{{ run.id }}/output" class="rounded-full bg-indigo-100 px-2 py-1 font-medium text-indigo-800">Download {{ run.output_filename }}</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if run.status == 'started' %}
|
||||||
|
<form method="post" enctype="multipart/form-data" action="/services/work-tracker/tool-runs/{{ run.id }}/result" class="mt-4 grid gap-3 rounded-xl bg-slate-50 p-4 md:grid-cols-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-slate-700">Result Status</label>
|
||||||
|
<select name="status" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"><option value="completed">Completed</option><option value="completed_with_exceptions">Completed with Exceptions</option><option value="failed">Failed</option><option value="cancelled">Cancelled</option></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-slate-700">Output File</label>
|
||||||
|
<input type="file" name="output_file" class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="mb-1 block text-xs font-medium text-slate-700">Result Summary</label>
|
||||||
|
<textarea name="summary" rows="2" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="Tool result / exceptions summary"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-slate-700">Clean Pass</label>
|
||||||
|
<select name="clean_pass" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"><option value="">Not stated</option><option value="true">Yes</option><option value="false">No</option></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-slate-700">Exception Count</label>
|
||||||
|
<input type="number" min="0" name="exception_count" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2 flex justify-end"><button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white">Attach Result</button></div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-xl border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">No tool run has been started for this task yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if task.is_aqmm_task %}
|
{% if task.is_aqmm_task %}
|
||||||
<div class="mx-auto mt-6 max-w-3xl rounded-2xl bg-white p-6 shadow-soft">
|
<div class="mx-auto mt-6 max-w-3xl rounded-2xl bg-white p-6 shadow-soft">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse, Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.db.common import CommonSessionLocal
|
from app.core.db.common import CommonSessionLocal
|
||||||
@@ -11,7 +11,13 @@ 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.iam.models import User
|
from app.modules.core.iam.models import User
|
||||||
from app.modules.core.rbac.permission_guard import require_permission
|
from app.modules.core.rbac.permission_guard import require_permission
|
||||||
from app.modules.services.task_tools import get_task_tool
|
from app.modules.services.task_tools import build_task_tool_launch_url, get_task_tool
|
||||||
|
from app.modules.services.task_tool_results import (
|
||||||
|
get_task_tool_run,
|
||||||
|
list_task_tool_runs,
|
||||||
|
record_task_tool_result,
|
||||||
|
start_task_tool_run,
|
||||||
|
)
|
||||||
from app.modules.services.execution import (
|
from app.modules.services.execution import (
|
||||||
TASK_COMMENT_TYPES,
|
TASK_COMMENT_TYPES,
|
||||||
TASK_COMMENT_VISIBILITIES,
|
TASK_COMMENT_VISIBILITIES,
|
||||||
@@ -445,6 +451,154 @@ def task_edit_page(request: Request, task_id: int):
|
|||||||
can_manage_fields=_can_bulk_manage_tasks(db, user),
|
can_manage_fields=_can_bulk_manage_tasks(db, user),
|
||||||
can_reassign=_can_assign_staff(db, user),
|
can_reassign=_can_assign_staff(db, user),
|
||||||
task_tool=get_task_tool(getattr(task, "task_tool_code", "NONE")),
|
task_tool=get_task_tool(getattr(task, "task_tool_code", "NONE")),
|
||||||
|
tool_runs=list_task_tool_runs(db, tenant_id=tenant_id, task_id=task.id),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/tool/start")
|
||||||
|
def task_tool_start(request: Request, task_id: int, csrf_token: str = Form(...)):
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
tenant_id = _active_tenant_id(request, user)
|
||||||
|
branch_id = _active_branch_id(request, user, db)
|
||||||
|
task = get_task(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
branch_id=branch_id,
|
||||||
|
assigned_to_user_id=_assigned_user_filter(db, user),
|
||||||
|
partner_user_id=_partner_visibility_user_id(db, user),
|
||||||
|
financial_year=_active_financial_year(request),
|
||||||
|
)
|
||||||
|
if not task:
|
||||||
|
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||||
|
tool = get_task_tool(getattr(task, "task_tool_code", "NONE"))
|
||||||
|
if tool.code == "NONE" or not tool.launch_url:
|
||||||
|
return RedirectResponse(url=f"/services/work-tracker/tasks/{task.id}/edit?tool_error=not_configured", status_code=303)
|
||||||
|
run = start_task_tool_run(db, task=task, user_id=int(user.id))
|
||||||
|
launch_url = build_task_tool_launch_url(
|
||||||
|
tool,
|
||||||
|
client_id=int(task.client_id),
|
||||||
|
task_id=int(task.id),
|
||||||
|
tool_run_id=int(run.id),
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=launch_url, status_code=303)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tool-runs/{run_id}/result")
|
||||||
|
def task_tool_result_submit(
|
||||||
|
request: Request,
|
||||||
|
run_id: int,
|
||||||
|
status: str = Form("completed"),
|
||||||
|
summary: str = Form(""),
|
||||||
|
result_json: str = Form(""),
|
||||||
|
clean_pass: str = Form(""),
|
||||||
|
exception_count: str = Form(""),
|
||||||
|
output_file: UploadFile | None = File(None),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
):
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
tenant_id = _active_tenant_id(request, user)
|
||||||
|
run = get_task_tool_run(db, tenant_id=tenant_id, run_id=run_id)
|
||||||
|
if not run:
|
||||||
|
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||||
|
branch_id = _active_branch_id(request, user, db)
|
||||||
|
task = get_task(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=int(run.task_instance_id),
|
||||||
|
branch_id=branch_id,
|
||||||
|
assigned_to_user_id=_assigned_user_filter(db, user),
|
||||||
|
partner_user_id=_partner_visibility_user_id(db, user),
|
||||||
|
financial_year=_active_financial_year(request),
|
||||||
|
)
|
||||||
|
if not task:
|
||||||
|
return _redirect_denied()
|
||||||
|
|
||||||
|
clean_value = None
|
||||||
|
if clean_pass.strip().lower() in {"1", "true", "yes", "y", "on"}:
|
||||||
|
clean_value = True
|
||||||
|
elif clean_pass.strip().lower() in {"0", "false", "no", "n", "off"}:
|
||||||
|
clean_value = False
|
||||||
|
|
||||||
|
exception_value = None
|
||||||
|
if exception_count.strip():
|
||||||
|
try:
|
||||||
|
exception_value = max(0, int(exception_count))
|
||||||
|
except ValueError:
|
||||||
|
return RedirectResponse(url=f"/services/work-tracker/tasks/{task.id}/edit?tool_error=invalid_exception_count", status_code=303)
|
||||||
|
|
||||||
|
file_bytes = None
|
||||||
|
filename = None
|
||||||
|
content_type = None
|
||||||
|
if output_file is not None and getattr(output_file, "filename", None):
|
||||||
|
file_bytes = output_file.file.read()
|
||||||
|
filename = str(output_file.filename or "tool-output.bin")
|
||||||
|
content_type = str(output_file.content_type or "application/octet-stream")
|
||||||
|
|
||||||
|
try:
|
||||||
|
record_task_tool_result(
|
||||||
|
db,
|
||||||
|
run=run,
|
||||||
|
status=status,
|
||||||
|
summary=summary,
|
||||||
|
result_payload=result_json,
|
||||||
|
clean_pass=clean_value,
|
||||||
|
exception_count=exception_value,
|
||||||
|
output_filename=filename,
|
||||||
|
output_content_type=content_type,
|
||||||
|
output_bytes=file_bytes,
|
||||||
|
completed_by_user_id=int(user.id),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
db.rollback()
|
||||||
|
return RedirectResponse(url=f"/services/work-tracker/tasks/{task.id}/edit?tool_error=result_rejected", status_code=303)
|
||||||
|
return RedirectResponse(url=f"/services/work-tracker/tasks/{task.id}/edit?tool_saved=1", status_code=303)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tool-runs/{run_id}/output")
|
||||||
|
def task_tool_result_download(request: Request, run_id: int):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
tenant_id = _active_tenant_id(request, user)
|
||||||
|
run = get_task_tool_run(db, tenant_id=tenant_id, run_id=run_id)
|
||||||
|
if not run or not run.output_bytes:
|
||||||
|
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||||
|
branch_id = _active_branch_id(request, user, db)
|
||||||
|
task = get_task(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=int(run.task_instance_id),
|
||||||
|
branch_id=branch_id,
|
||||||
|
assigned_to_user_id=_assigned_user_filter(db, user),
|
||||||
|
partner_user_id=_partner_visibility_user_id(db, user),
|
||||||
|
financial_year=_active_financial_year(request),
|
||||||
|
)
|
||||||
|
if not task:
|
||||||
|
return _redirect_denied()
|
||||||
|
filename = (run.output_filename or f"task-tool-run-{run.id}.bin").replace('"', "")
|
||||||
|
return Response(
|
||||||
|
content=bytes(run.output_bytes),
|
||||||
|
media_type=run.output_content_type or "application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user