Add task tool result bridge and evidence attachment workflow

This commit is contained in:
A R R R Associates
2026-08-31 15:24:09 +05:30
parent fbb8607842
commit a362cf438c
6 changed files with 497 additions and 6 deletions
@@ -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")
+35 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
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 app.core.db.common import CommonBase
@@ -940,6 +940,40 @@ class ServiceTaskDocumentRequest(CommonBase):
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):
"""Communication timeline entry linked to a service task instance."""
+145
View File
@@ -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,
)
+23
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from urllib.parse import urlencode
@dataclass(frozen=True)
@@ -46,3 +47,25 @@ def normalize_task_tool_code(code: str | None) -> str:
if value not in valid:
raise ValueError(f"Unsupported task tool: {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>
{% 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="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>
<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 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 %}
<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>
{% endif %}
{% if (task.response_type or 'NONE') != 'NONE' or task.response_required or task.evidence_required %}
@@ -156,6 +164,72 @@
</form>
</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 %}
<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">
+157 -3
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import RedirectResponse, Response
from sqlalchemy import select
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.iam.models import User
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 (
TASK_COMMENT_TYPES,
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_reassign=_can_assign_staff(db, user),
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:
db.close()