Add task administration upgrade with duplicate merge and task tools
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"""task administration tools and task-tool snapshot
|
||||
|
||||
Revision ID: 20260830_task_admin_tools
|
||||
Revises: 20260830_service_task_category_master
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "20260830_task_admin_tools"
|
||||
down_revision = "20260830_task_category_master"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
for table in ("service_default_task_templates", "firm_service_task_templates", "client_service_task_instances"):
|
||||
op.add_column(table, sa.Column("task_tool_code", sa.String(length=80), server_default="NONE", nullable=False))
|
||||
op.create_index(f"ix_{table}_task_tool_code", table, ["task_tool_code"], unique=False)
|
||||
|
||||
def downgrade():
|
||||
for table in reversed(("service_default_task_templates", "firm_service_task_templates", "client_service_task_instances")):
|
||||
op.drop_index(f"ix_{table}_task_tool_code", table_name=table)
|
||||
op.drop_column(table, "task_tool_code")
|
||||
@@ -22,7 +22,8 @@ from app.modules.services.models import (
|
||||
ServiceDueDateExtension,
|
||||
ServiceDueDateRule,
|
||||
)
|
||||
from app.modules.services.services import normalize_code, normalize_engagement_type
|
||||
from app.modules.services.task_tools import normalize_task_tool_code
|
||||
from app.modules.services.services import ensure_task_category, normalize_code, normalize_engagement_type
|
||||
from app.modules.services.client_services import (
|
||||
assessment_year_from_financial_year,
|
||||
attach_engagement_to_plan,
|
||||
@@ -1014,7 +1015,7 @@ def import_due_date_extensions(
|
||||
return {"created": created if not errors else 0, "updated": updated if not errors else 0, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes, update_existing: bool = True) -> dict:
|
||||
def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes, update_existing: bool = True, expected_service_code: str | None = None) -> dict:
|
||||
ws, headers = _load_sheet(file_bytes, "system_default_tasks")
|
||||
missing = _validate_headers(headers, ["service_code", "sequence_no", "task_name"])
|
||||
if missing:
|
||||
@@ -1026,6 +1027,8 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
|
||||
continue
|
||||
try:
|
||||
service_code = normalize_code(_cell(row, headers, "service_code"))
|
||||
if expected_service_code and service_code != normalize_code(expected_service_code):
|
||||
raise ValueError(f"This import accepts only service code {normalize_code(expected_service_code)}.")
|
||||
sequence_no = _int(_cell(row, headers, "sequence_no"), None)
|
||||
task_name = _clean(_cell(row, headers, "task_name"))
|
||||
catalogue = find_service(db, service_code=service_code)
|
||||
@@ -1054,12 +1057,16 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
|
||||
task.is_mandatory = _bool(_cell(row, headers, "is_mandatory"), True)
|
||||
task.requires_review = _bool(_cell(row, headers, "requires_review"), False)
|
||||
task.task_category = _clean(_cell(row, headers, "task_category")) or None
|
||||
category = ensure_task_category(db, catalogue_id=catalogue.id, tenant_id=None, name=task.task_category, user_id=current_user.id)
|
||||
task.task_category_id = category.id if category else None
|
||||
task.task_category = category.name if category else None
|
||||
task.response_required = _bool(_cell(row, headers, "response_required"), False)
|
||||
task.response_type = (_clean(_cell(row, headers, "response_type")) or "NONE").upper()
|
||||
if task.response_type not in {"NONE", "YES_NO_NA", "YES_NO", "TEXT", "NUMBER", "DATE"}:
|
||||
raise ValueError("response_type must be NONE, YES_NO_NA, YES_NO, TEXT, NUMBER or DATE.")
|
||||
task.evidence_required = _bool(_cell(row, headers, "evidence_required"), False)
|
||||
task.remarks_required_if_no = _bool(_cell(row, headers, "remarks_required_if_no"), False)
|
||||
task.task_tool_code = normalize_task_tool_code(_clean(_cell(row, headers, "task_tool_code")) or "NONE")
|
||||
task.is_aqmm_task = _bool(_cell(row, headers, "is_aqmm_task"), False)
|
||||
task.aqmm_mandatory = _bool(_cell(row, headers, "aqmm_mandatory"), False)
|
||||
task.aqmm_evidence_required = _bool(_cell(row, headers, "aqmm_evidence_required"), False)
|
||||
@@ -1078,7 +1085,7 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
|
||||
return {"created": created if not errors else 0, "updated": updated if not errors else 0, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
def import_firm_task_templates(db: Session, *, current_user, tenant_id: int, file_bytes: bytes, update_existing: bool = True) -> dict:
|
||||
def import_firm_task_templates(db: Session, *, current_user, tenant_id: int, file_bytes: bytes, update_existing: bool = True, expected_service_code: str | None = None) -> dict:
|
||||
ws, headers = _load_sheet(file_bytes, "firm_task_templates")
|
||||
missing = _validate_headers(headers, ["service_code", "sequence_no", "task_name"])
|
||||
if missing:
|
||||
@@ -1090,6 +1097,8 @@ def import_firm_task_templates(db: Session, *, current_user, tenant_id: int, fil
|
||||
continue
|
||||
try:
|
||||
service_code = normalize_code(_cell(row, headers, "service_code"))
|
||||
if expected_service_code and service_code != normalize_code(expected_service_code):
|
||||
raise ValueError(f"This import accepts only service code {normalize_code(expected_service_code)}.")
|
||||
sequence_no = _int(_cell(row, headers, "sequence_no"), None)
|
||||
task_name = _clean(_cell(row, headers, "task_name"))
|
||||
enabled = find_enabled_service(db, tenant_id=tenant_id, service_code=service_code)
|
||||
@@ -1126,12 +1135,16 @@ def import_firm_task_templates(db: Session, *, current_user, tenant_id: int, fil
|
||||
task.is_mandatory = _bool(_cell(row, headers, "is_mandatory"), True)
|
||||
task.requires_review = _bool(_cell(row, headers, "requires_review"), False)
|
||||
task.task_category = _clean(_cell(row, headers, "task_category")) or None
|
||||
category = ensure_task_category(db, catalogue_id=catalogue.id, tenant_id=tenant_id, name=task.task_category, user_id=current_user.id)
|
||||
task.task_category_id = category.id if category else None
|
||||
task.task_category = category.name if category else None
|
||||
task.response_required = _bool(_cell(row, headers, "response_required"), False)
|
||||
task.response_type = (_clean(_cell(row, headers, "response_type")) or "NONE").upper()
|
||||
if task.response_type not in {"NONE", "YES_NO_NA", "YES_NO", "TEXT", "NUMBER", "DATE"}:
|
||||
raise ValueError("response_type must be NONE, YES_NO_NA, YES_NO, TEXT, NUMBER or DATE.")
|
||||
task.evidence_required = _bool(_cell(row, headers, "evidence_required"), False)
|
||||
task.remarks_required_if_no = _bool(_cell(row, headers, "remarks_required_if_no"), False)
|
||||
task.task_tool_code = normalize_task_tool_code(_clean(_cell(row, headers, "task_tool_code")) or "NONE")
|
||||
task.is_aqmm_task = _bool(_cell(row, headers, "is_aqmm_task"), False)
|
||||
task.aqmm_mandatory = _bool(_cell(row, headers, "aqmm_mandatory"), False)
|
||||
task.aqmm_evidence_required = _bool(_cell(row, headers, "aqmm_evidence_required"), False)
|
||||
|
||||
@@ -26,6 +26,7 @@ _HEADERS = [
|
||||
"response_type",
|
||||
"evidence_required",
|
||||
"remarks_required_if_no",
|
||||
"task_tool_code",
|
||||
"is_aqmm_task",
|
||||
"aqmm_mandatory",
|
||||
"aqmm_evidence_required",
|
||||
@@ -50,6 +51,7 @@ _IMPORT_HEADERS = [
|
||||
"response_type",
|
||||
"evidence_required",
|
||||
"remarks_required_if_no",
|
||||
"task_tool_code",
|
||||
"is_aqmm_task",
|
||||
"aqmm_mandatory",
|
||||
"aqmm_evidence_required",
|
||||
@@ -94,11 +96,13 @@ def _style_sheet(ws, widths: dict[int, int]) -> None:
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
|
||||
|
||||
def _query_active_services(db: Session) -> list[ServiceCatalogue]:
|
||||
def _query_active_services(db: Session, *, catalogue_id: int | None = None) -> list[ServiceCatalogue]:
|
||||
stmt = select(ServiceCatalogue).where(ServiceCatalogue.is_active.is_(True))
|
||||
if catalogue_id is not None:
|
||||
stmt = stmt.where(ServiceCatalogue.id == catalogue_id)
|
||||
return list(
|
||||
db.execute(
|
||||
select(ServiceCatalogue)
|
||||
.where(ServiceCatalogue.is_active.is_(True))
|
||||
stmt
|
||||
.options(
|
||||
selectinload(ServiceCatalogue.service_category),
|
||||
selectinload(ServiceCatalogue.default_task_templates),
|
||||
@@ -112,8 +116,8 @@ def _query_active_services(db: Session) -> list[ServiceCatalogue]:
|
||||
)
|
||||
|
||||
|
||||
def build_default_tasks_export(db: Session) -> bytes:
|
||||
services = _query_active_services(db)
|
||||
def build_default_tasks_export(db: Session, *, catalogue_id: int | None = None) -> bytes:
|
||||
services = _query_active_services(db, catalogue_id=catalogue_id)
|
||||
|
||||
workbook = Workbook()
|
||||
all_tasks_ws = workbook.active
|
||||
@@ -169,6 +173,7 @@ def build_default_tasks_export(db: Session) -> bytes:
|
||||
task.response_type or "NONE",
|
||||
_yes_no(task.evidence_required),
|
||||
_yes_no(task.remarks_required_if_no),
|
||||
getattr(task, "task_tool_code", "NONE") or "NONE",
|
||||
_yes_no(task.is_aqmm_task),
|
||||
_yes_no(task.aqmm_mandatory),
|
||||
_yes_no(task.aqmm_evidence_required),
|
||||
@@ -192,6 +197,7 @@ def build_default_tasks_export(db: Session) -> bytes:
|
||||
task.response_type or "NONE",
|
||||
_yes_no(task.evidence_required),
|
||||
_yes_no(task.remarks_required_if_no),
|
||||
getattr(task, "task_tool_code", "NONE") or "NONE",
|
||||
_yes_no(task.is_aqmm_task),
|
||||
_yes_no(task.aqmm_mandatory),
|
||||
_yes_no(task.aqmm_evidence_required),
|
||||
|
||||
@@ -330,6 +330,7 @@ def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceS
|
||||
response_type=(getattr(template, "response_type", "NONE") or "NONE").upper(),
|
||||
evidence_required=getattr(template, "evidence_required", False),
|
||||
remarks_required_if_no=getattr(template, "remarks_required_if_no", False),
|
||||
task_tool_code=getattr(template, "task_tool_code", "NONE") or "NONE",
|
||||
normal_review_required=bool(getattr(template, "requires_review", False)),
|
||||
normal_review_role=normal_review_role_for_template(template),
|
||||
normal_review_status="not_ready" if getattr(template, "requires_review", False) else "not_required",
|
||||
@@ -420,6 +421,7 @@ def _copy_template_snapshot_to_task(
|
||||
task.response_type = (getattr(template, "response_type", "NONE") or "NONE").upper()
|
||||
task.evidence_required = getattr(template, "evidence_required", False)
|
||||
task.remarks_required_if_no = getattr(template, "remarks_required_if_no", False)
|
||||
task.task_tool_code = getattr(template, "task_tool_code", "NONE") or "NONE"
|
||||
_sync_normal_review_snapshot_preserving_history(task, template=template)
|
||||
task.is_aqmm_task = getattr(template, "is_aqmm_task", False)
|
||||
task.aqmm_mandatory = getattr(template, "aqmm_mandatory", False)
|
||||
@@ -514,6 +516,7 @@ def sync_open_engagement_tasks_for_service(
|
||||
response_type=(getattr(template, "response_type", "NONE") or "NONE").upper(),
|
||||
evidence_required=getattr(template, "evidence_required", False),
|
||||
remarks_required_if_no=getattr(template, "remarks_required_if_no", False),
|
||||
task_tool_code=getattr(template, "task_tool_code", "NONE") or "NONE",
|
||||
normal_review_required=bool(getattr(template, "requires_review", False)),
|
||||
normal_review_role=normal_review_role_for_template(template),
|
||||
normal_review_status="not_ready" if getattr(template, "requires_review", False) else "not_required",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.services.models import FirmServiceTaskTemplate, ServiceCatalogue
|
||||
|
||||
HEADERS = [
|
||||
"service_code", "sequence_no", "task_name", "default_role_name", "eligible_role_names",
|
||||
"is_mandatory", "requires_review", "normal_review_role", "task_category", "response_required",
|
||||
"response_type", "evidence_required", "remarks_required_if_no", "task_tool_code",
|
||||
"is_aqmm_task", "aqmm_mandatory", "aqmm_evidence_required", "aqmm_manager_review_required",
|
||||
"aqmm_partner_review_required", "aqmm_review_partner_required", "aqmm_blocks_final_release",
|
||||
"aqmm_reference", "is_active", "description",
|
||||
]
|
||||
|
||||
def _yn(value): return "TRUE" if bool(value) else "FALSE"
|
||||
|
||||
def build_firm_tasks_export(db: Session, *, tenant_id: int, catalogue_id: int) -> bytes:
|
||||
service = db.execute(select(ServiceCatalogue).where(ServiceCatalogue.id == catalogue_id)).scalar_one()
|
||||
tasks = list(db.execute(select(FirmServiceTaskTemplate).where(
|
||||
FirmServiceTaskTemplate.tenant_id == tenant_id,
|
||||
FirmServiceTaskTemplate.service_catalogue_id == catalogue_id,
|
||||
).order_by(FirmServiceTaskTemplate.sequence_no, FirmServiceTaskTemplate.id)).scalars().all())
|
||||
wb = Workbook(); ws = wb.active; ws.title = "Firm Task Templates"; ws.append(HEADERS)
|
||||
for task in tasks:
|
||||
ws.append([service.service_code, task.sequence_no, task.task_name, task.default_role_name or "", task.eligible_role_names or "",
|
||||
_yn(task.is_mandatory), _yn(task.requires_review), task.normal_review_role or "", task.task_category or "",
|
||||
_yn(task.response_required), task.response_type or "NONE", _yn(task.evidence_required), _yn(task.remarks_required_if_no),
|
||||
getattr(task, "task_tool_code", "NONE") or "NONE", _yn(task.is_aqmm_task), _yn(task.aqmm_mandatory),
|
||||
_yn(task.aqmm_evidence_required), _yn(task.aqmm_manager_review_required), _yn(task.aqmm_partner_review_required),
|
||||
_yn(task.aqmm_review_partner_required), _yn(task.aqmm_blocks_final_release), task.aqmm_reference or "",
|
||||
_yn(task.is_active), task.description or ""])
|
||||
fill=PatternFill("solid", fgColor="1F4E78"); font=Font(color="FFFFFF", bold=True)
|
||||
for c in ws[1]: c.fill=fill; c.font=font; c.alignment=Alignment(horizontal="center", wrap_text=True)
|
||||
ws.freeze_panes="A2"; ws.auto_filter.ref=ws.dimensions
|
||||
for i in range(1, len(HEADERS)+1): ws.column_dimensions[get_column_letter(i)].width = 20
|
||||
ws.column_dimensions['C'].width=46; ws.column_dimensions['X'].width=70
|
||||
out=BytesIO(); wb.save(out); return out.getvalue()
|
||||
@@ -149,6 +149,7 @@ class ServiceDefaultTaskTemplate(CommonBase):
|
||||
response_type: Mapped[str] = mapped_column(String(20), nullable=False, default="NONE")
|
||||
evidence_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
task_tool_code: Mapped[str] = mapped_column(String(80), nullable=False, default="NONE", server_default="NONE", index=True)
|
||||
|
||||
# AQMM task tags. These flags allow the existing service checklist to become
|
||||
# the engagement quality checklist for assurance engagements, without creating
|
||||
@@ -227,6 +228,7 @@ class FirmServiceTaskTemplate(CommonBase):
|
||||
response_type: Mapped[str] = mapped_column(String(20), nullable=False, default="NONE")
|
||||
evidence_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
task_tool_code: Mapped[str] = mapped_column(String(80), nullable=False, default="NONE", server_default="NONE", index=True)
|
||||
|
||||
# AQMM task tags copied into generated ClientServiceTaskInstance rows.
|
||||
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
@@ -799,6 +801,7 @@ class ClientServiceTaskInstance(CommonBase):
|
||||
response_type: Mapped[str] = mapped_column(String(20), nullable=False, default="NONE")
|
||||
evidence_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
task_tool_code: Mapped[str] = mapped_column(String(80), nullable=False, default="NONE", server_default="NONE", index=True)
|
||||
checklist_response: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
checklist_text_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
checklist_number_response: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.services.models import (
|
||||
FirmServiceTaskTemplate, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate,
|
||||
ServiceDefaultTaskTemplate,
|
||||
)
|
||||
|
||||
_STOP_WORDS = {"the", "a", "an", "and", "of", "for", "to", "with", "as", "per", "verify", "verification", "check", "checking"}
|
||||
|
||||
|
||||
def _normalise(name: str | None) -> str:
|
||||
words = re.findall(r"[a-z0-9]+", (name or "").lower())
|
||||
reduced = [w for w in words if w not in _STOP_WORDS]
|
||||
return " ".join(reduced or words)
|
||||
|
||||
|
||||
def _similarity(a: str | None, b: str | None) -> float:
|
||||
na, nb = _normalise(a), _normalise(b)
|
||||
if not na or not nb:
|
||||
return 0.0
|
||||
if na == nb:
|
||||
return 1.0
|
||||
ta, tb = set(na.split()), set(nb.split())
|
||||
token_score = len(ta & tb) / max(len(ta | tb), 1)
|
||||
sequence_score = SequenceMatcher(None, na, nb).ratio()
|
||||
return max(sequence_score, token_score)
|
||||
|
||||
|
||||
def find_duplicate_pairs(tasks, *, threshold: float = 0.78) -> list[dict]:
|
||||
active = [t for t in tasks if bool(getattr(t, "is_active", True))]
|
||||
pairs: list[dict] = []
|
||||
for i, left in enumerate(active):
|
||||
for right in active[i + 1:]:
|
||||
score = _similarity(left.task_name, right.task_name)
|
||||
if score >= threshold:
|
||||
pairs.append({"left": left, "right": right, "score": round(score * 100, 1)})
|
||||
return sorted(pairs, key=lambda row: (-row["score"], row["left"].sequence_no, row["right"].sequence_no))
|
||||
|
||||
|
||||
def merge_default_tasks(db: Session, *, catalogue_id: int, master_task_id: int, source_task_ids: list[int]) -> int:
|
||||
master = db.execute(select(ServiceDefaultTaskTemplate).where(
|
||||
ServiceDefaultTaskTemplate.id == master_task_id,
|
||||
ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id,
|
||||
)).scalar_one_or_none()
|
||||
if master is None:
|
||||
raise ValueError("Master task not found.")
|
||||
source_ids = {int(x) for x in source_task_ids if int(x) != master_task_id}
|
||||
if not source_ids:
|
||||
raise ValueError("Select at least one duplicate task to merge.")
|
||||
sources = list(db.execute(select(ServiceDefaultTaskTemplate).where(
|
||||
ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id,
|
||||
ServiceDefaultTaskTemplate.id.in_(source_ids),
|
||||
)).scalars().all())
|
||||
if len(sources) != len(source_ids):
|
||||
raise ValueError("One or more duplicate tasks were not found.")
|
||||
for source in sources:
|
||||
source.is_active = False
|
||||
db.flush()
|
||||
return len(sources)
|
||||
|
||||
|
||||
def merge_firm_tasks(db: Session, *, tenant_id: int, catalogue_id: int, master_task_id: int, source_task_ids: list[int], user_id: int) -> int:
|
||||
master = db.execute(select(FirmServiceTaskTemplate).where(
|
||||
FirmServiceTaskTemplate.id == master_task_id,
|
||||
FirmServiceTaskTemplate.tenant_id == tenant_id,
|
||||
FirmServiceTaskTemplate.service_catalogue_id == catalogue_id,
|
||||
)).scalar_one_or_none()
|
||||
if master is None:
|
||||
raise ValueError("Master task not found.")
|
||||
source_ids = {int(x) for x in source_task_ids if int(x) != master_task_id}
|
||||
if not source_ids:
|
||||
raise ValueError("Select at least one duplicate task to merge.")
|
||||
sources = list(db.execute(select(FirmServiceTaskTemplate).where(
|
||||
FirmServiceTaskTemplate.tenant_id == tenant_id,
|
||||
FirmServiceTaskTemplate.service_catalogue_id == catalogue_id,
|
||||
FirmServiceTaskTemplate.id.in_(source_ids),
|
||||
)).scalars().all())
|
||||
if len(sources) != len(source_ids):
|
||||
raise ValueError("One or more duplicate tasks were not found.")
|
||||
|
||||
existing_req_names = {
|
||||
(r.document_name or "").strip().lower()
|
||||
for r in db.execute(select(FirmTaskDocumentRequirement).where(
|
||||
FirmTaskDocumentRequirement.tenant_id == tenant_id,
|
||||
FirmTaskDocumentRequirement.firm_task_template_id == master.id,
|
||||
)).scalars().all()
|
||||
}
|
||||
for source in sources:
|
||||
for req in db.execute(select(FirmTaskDocumentRequirement).where(
|
||||
FirmTaskDocumentRequirement.tenant_id == tenant_id,
|
||||
FirmTaskDocumentRequirement.firm_task_template_id == source.id,
|
||||
)).scalars().all():
|
||||
key = (req.document_name or "").strip().lower()
|
||||
if key and key not in existing_req_names:
|
||||
req.firm_task_template_id = master.id
|
||||
req.updated_by_user_id = user_id
|
||||
existing_req_names.add(key)
|
||||
for template in db.execute(select(FirmTaskDocumentTemplate).where(
|
||||
FirmTaskDocumentTemplate.tenant_id == tenant_id,
|
||||
FirmTaskDocumentTemplate.firm_task_template_id == source.id,
|
||||
)).scalars().all():
|
||||
template.firm_task_template_id = master.id
|
||||
source.is_active = False
|
||||
source.updated_by_user_id = user_id
|
||||
db.flush()
|
||||
return len(sources)
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskToolDefinition:
|
||||
code: str
|
||||
name: str
|
||||
description: str
|
||||
launch_url: str | None = None
|
||||
|
||||
|
||||
_TASK_TOOLS: tuple[TaskToolDefinition, ...] = (
|
||||
TaskToolDefinition(code="NONE", name="None", description="Normal task workflow without an attached ERP tool."),
|
||||
TaskToolDefinition(
|
||||
code="ACCOUNTING_OPENING_BALANCE",
|
||||
name="Opening Balance Review",
|
||||
description="Launch the existing Tally opening-balance comparison and verification tool.",
|
||||
launch_url="/tools/accounting/opening-balances",
|
||||
),
|
||||
TaskToolDefinition(
|
||||
code="ACCOUNTING_BANK_RECONCILIATION",
|
||||
name="Bank Reconciliation",
|
||||
description="Launch the existing accounting bank-reconciliation workspace.",
|
||||
launch_url="/tools/accounting/bank-reconciliation",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def list_task_tools() -> list[TaskToolDefinition]:
|
||||
return list(_TASK_TOOLS)
|
||||
|
||||
|
||||
def get_task_tool(code: str | None) -> TaskToolDefinition:
|
||||
normalized = normalize_task_tool_code(code)
|
||||
for tool in _TASK_TOOLS:
|
||||
if tool.code == normalized:
|
||||
return tool
|
||||
return _TASK_TOOLS[0]
|
||||
|
||||
|
||||
def normalize_task_tool_code(code: str | None) -> str:
|
||||
value = (code or "NONE").strip().upper() or "NONE"
|
||||
valid = {tool.code for tool in _TASK_TOOLS}
|
||||
if value not in valid:
|
||||
raise ValueError(f"Unsupported task tool: {value}")
|
||||
return value
|
||||
@@ -98,6 +98,11 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Task Tool</label>
|
||||
<select name="task_tool_code" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm">{% for tool in task_tools %}<option value="{{ tool.code }}" {% if (task.task_tool_code or 'NONE') == tool.code %}selected{% endif %}>{{ tool.name }}</option>{% endfor %}</select>
|
||||
<p class="mt-1 text-xs text-emerald-700">Links an existing ERP verification workspace to this task; normal checklist behavior is preserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 text-sm text-slate-700 md:grid-cols-3">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="response_required" {% if task.response_required %}checked{% endif %}> Response required</label>
|
||||
|
||||
@@ -6,8 +6,15 @@
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ service.service_name }}</h2>
|
||||
<p class="text-sm text-slate-500">System default task templates for {{ service.service_code }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2"><a href="/services/catalogue/{{ service.id }}/task-categories" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Task Categories</a><a href="/services/defaults" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a><a href="/services/catalogue/{{ service.id }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Catalogue Detail</a></div>
|
||||
<div class="flex flex-wrap gap-2"><a href="/services/catalogue/{{ service.id }}/defaults/export" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 hover:bg-emerald-100">Export Tasks</a><a href="/services/catalogue/{{ service.id }}/defaults/duplicates" class="rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-100">Duplicate Review</a><a href="/services/catalogue/{{ service.id }}/task-categories" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Task Categories</a><a href="/services/defaults" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a><a href="/services/catalogue/{{ service.id }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Catalogue Detail</a></div>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('imported') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Excel import completed: {{ request.query_params.get('created','0') }} created, {{ request.query_params.get('updated','0') }} updated.</div>{% endif %}
|
||||
{% if request.query_params.get('import_error') %}<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Excel import failed validation. No partial update was committed. Review the workbook and try again.</div>{% endif %}
|
||||
<section class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><div><h3 class="text-sm font-semibold text-emerald-950">Export · Edit · Re-import This Service</h3><p class="mt-1 text-xs text-emerald-800">Export this service's current tasks, edit the Import Ready sheet, then re-import. Matching remains service code + sequence number.</p></div>
|
||||
<form method="post" action="/services/catalogue/{{ service.id }}/defaults/import" enctype="multipart/form-data" class="flex flex-wrap items-center gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="file" name="file" accept=".xlsx" required class="max-w-xs rounded-xl border border-emerald-200 bg-white px-3 py-2 text-xs"><button class="rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">Import Updated Excel</button></form></div>
|
||||
</section>
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="space-y-3">
|
||||
{% for task in default_tasks %}
|
||||
@@ -43,7 +50,7 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Task Name</label><input type="text" name="task_name" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label><input type="text" name="default_role_name" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Task Category</label><select name="task_category" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Uncategorised / General Workflow</option>{% for category in task_categories or [] %}{% if category.is_active %}<option value="{{ category.name }}">{{ category.name }}</option>{% endif %}{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Task Category</label><select name="task_category" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Uncategorised / General Workflow</option>{% for category in task_categories or [] %}{% if category.is_active %}<option value="{{ category.name }}">{{ category.name }}</option>{% endif %}{% endfor %}</select></div><div><label class="mb-2 block text-sm font-medium text-slate-700">Task Tool</label><select name="task_tool_code" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{% for tool in task_tools %}<option value="{{ tool.code }}">{{ tool.name }}</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">Optional ERP tool linked to the task. Existing task workflow remains unchanged.</p></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Sequence No</label><input type="number" min="1" name="sequence_no" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_mandatory" checked> Mandatory</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="requires_review"> Review</label><label class="inline-flex items-center gap-2">Normal reviewer <select name="normal_review_role" class="rounded border border-slate-300 px-2 py-1 text-xs"><option value="manager">Manager</option><option value="partner">Partner</option><option value="manager_or_partner">Manager / Partner</option></select></label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" checked> Active</label></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></textarea></div>
|
||||
|
||||
@@ -98,6 +98,11 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Task Tool</label>
|
||||
<select name="task_tool_code" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm">{% for tool in task_tools %}<option value="{{ tool.code }}" {% if (task.task_tool_code or 'NONE') == tool.code %}selected{% endif %}>{{ tool.name }}</option>{% endfor %}</select>
|
||||
<p class="mt-1 text-xs text-emerald-700">Links an existing ERP verification workspace to this task; normal checklist behavior is preserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 text-sm text-slate-700 md:grid-cols-3">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="response_required" {% if task.response_required %}checked{% endif %}> Response required</label>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div><h2 class="text-xl font-semibold text-slate-900">Duplicate Task Review</h2><p class="mt-1 text-sm text-slate-500">{{ service.service_code }} · {{ service.service_name }} · {{ 'System defaults' if scope == 'default' else 'Firm task templates' }}</p></div>
|
||||
<a href="{% if scope == 'default' %}/services/catalogue/{{ service.id }}/defaults{% else %}/services/templates/{{ service.id }}{% endif %}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Tasks</a>
|
||||
</div>
|
||||
{% if request.query_params.get('merged') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{{ request.query_params.get('merged') }} duplicate template(s) merged safely. Source templates were deactivated; historical engagement tasks were not changed.</div>{% endif %}
|
||||
{% if request.query_params.get('merge_error') %}<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Merge could not be completed. Select one master task and at least one different duplicate task.</div>{% endif %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Likely Duplicates</h3><p class="mt-1 text-sm text-slate-500">Suggestions use task-name similarity only. Review the meaning before merging.</p>
|
||||
<div class="mt-4 space-y-2">{% for pair in pairs %}<div class="grid gap-2 rounded-xl border border-slate-200 p-3 text-sm md:grid-cols-[1fr_auto_1fr]"><div><span class="font-semibold">{{ pair.left.sequence_no }}.</span> {{ pair.left.task_name }}</div><div class="rounded-full bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-800">{{ pair.score }}%</div><div><span class="font-semibold">{{ pair.right.sequence_no }}.</span> {{ pair.right.task_name }}</div></div>{% else %}<div class="rounded-xl border border-dashed border-slate-300 p-5 text-sm text-slate-500">No high-confidence duplicate pairs detected automatically. You can still merge manually below.</div>{% endfor %}</div>
|
||||
</section>
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Merge Selected Templates</h3><p class="mt-1 text-sm text-slate-500">The master template remains active and unchanged. Selected source templates are deactivated, not deleted. Existing engagement task history is preserved.{% if scope == 'firm' %} Unique document requirements and uploaded task templates are reassigned to the master where safe.{% endif %}</p>
|
||||
<form method="post" action="{% if scope == 'default' %}/services/catalogue/{{ service.id }}/defaults/duplicates/merge{% else %}/services/templates/{{ service.id }}/duplicates/merge{% endif %}" class="mt-5 space-y-5" onsubmit="return confirm('Merge the selected duplicate task templates? Source templates will be deactivated and historical engagement work will remain unchanged.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Master Task to Keep</label><select name="master_task_id" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select master task</option>{% for task in tasks %}{% if task.is_active %}<option value="{{ task.id }}">{{ task.sequence_no }}. {{ task.task_name }}</option>{% endif %}{% endfor %}</select></div>
|
||||
<div><div class="mb-2 text-sm font-medium text-slate-700">Duplicate Tasks to Merge Into Master</div><div class="max-h-96 space-y-2 overflow-y-auto rounded-xl border border-slate-200 p-3">{% for task in tasks %}{% if task.is_active %}<label class="flex items-start gap-3 rounded-lg px-2 py-2 hover:bg-slate-50"><input type="checkbox" name="source_task_ids" value="{{ task.id }}" class="mt-1"><span class="text-sm"><strong>{{ task.sequence_no }}.</strong> {{ task.task_name }}{% if task.task_category %}<span class="ml-2 text-xs text-slate-500">{{ task.task_category }}</span>{% endif %}</span></label>{% endif %}{% endfor %}</div></div>
|
||||
<div class="flex justify-end"><button class="rounded-xl bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-700">Merge Selected Tasks</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -11,6 +11,8 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/services/templates" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Templates</a>
|
||||
<a href="/services/templates/{{ service.id }}/export" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 hover:bg-emerald-100">Export Tasks</a>
|
||||
<a href="/services/templates/{{ service.id }}/duplicates" class="rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-100">Duplicate Review</a>
|
||||
<a href="/services/templates/{{ service.id }}/categories" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Task Categories</a>
|
||||
<a href="/services/catalogue/{{ service.id }}" class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50">View Catalogue</a>
|
||||
</div>
|
||||
@@ -21,7 +23,15 @@
|
||||
{% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Action failed. Please check the selected task, file and permissions.</div>{% endif %}
|
||||
{% if request.query_params.get('moved') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Task sequence updated. Use Sync Firm Tasks to Open Engagements if you want safe pending/open task snapshots refreshed.</div>{% endif %}
|
||||
{% if request.query_params.get('move_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Task could not be moved.</div>{% endif %}
|
||||
{% if request.query_params.get('imported') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Excel import completed: {{ request.query_params.get('created','0') }} created, {{ request.query_params.get('updated','0') }} updated.</div>{% endif %}
|
||||
{% if request.query_params.get('import_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Excel import failed validation. No partial update was committed.</div>{% endif %}
|
||||
|
||||
|
||||
{% if can_manage_tasks %}
|
||||
<section class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><div><h3 class="text-sm font-semibold text-emerald-950">Export · Edit · Re-import Firm Tasks</h3><p class="mt-1 text-xs text-emerald-800">Use the exported workbook for this service. Re-import updates by service code + sequence number and preserves all other ERP features.</p></div><form method="post" action="/services/templates/{{ service.id }}/import" enctype="multipart/form-data" class="flex flex-wrap items-center gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="file" name="file" accept=".xlsx" required class="max-w-xs rounded-xl border border-emerald-200 bg-white px-3 py-2 text-xs"><button class="rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">Import Updated Excel</button></form></div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="grid gap-4 md:grid-cols-3">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Firm Status</div>
|
||||
@@ -87,6 +97,10 @@
|
||||
{% for category in task_categories or [] %}{% if category.is_active %}<option value="{{ category.name }}">{{ category.name }}</option>{% endif %}{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="lg:col-span-3">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Task Tool</label>
|
||||
<select name="task_tool_code" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for tool in task_tools %}<option value="{{ tool.code }}">{{ tool.name }}</option>{% endfor %}</select>
|
||||
</div>
|
||||
<div class="lg:col-span-3">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Options</label>
|
||||
<div class="flex flex-wrap gap-3 rounded-xl border border-slate-200 px-3 py-2 text-xs text-slate-700">
|
||||
@@ -197,7 +211,7 @@
|
||||
<td class="px-4 py-3 text-sm text-slate-700"><div>{{ task.default_role_name or '-' }}</div><div class="mt-1 text-xs text-slate-500">Eligible: {{ task.eligible_role_names or task.default_role_name or '-' }}</div></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% if task.task_category %}<span class="rounded-full bg-blue-50 px-2 py-1 text-blue-700">{{ task.task_category }}</span>{% endif %}
|
||||
{% if task.task_category %}<span class="rounded-full bg-blue-50 px-2 py-1 text-blue-700">{{ task.task_category }}</span>{% endif %}{% if task.task_tool_code and task.task_tool_code != "NONE" %}<span class="rounded-full bg-violet-50 px-2 py-1 text-violet-700">Tool: {{ task.task_tool_code|replace("_"," ")|title }}</span>{% endif %}
|
||||
{% if task.is_mandatory %}<span class="rounded-full bg-slate-100 px-2 py-1">Mandatory</span>{% endif %}
|
||||
{% if task.requires_review %}<span class="rounded-full bg-amber-100 px-2 py-1 text-amber-800">Review: {{ (task.normal_review_role or "manager").replace("_"," ").title() }}</span>{% endif %}
|
||||
{% if task.is_aqmm_task %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">AQMM</span>{% endif %}
|
||||
|
||||
@@ -86,6 +86,12 @@
|
||||
{% if not can_reassign and task.assigned_to %}<p class="mt-1 text-xs text-slate-500">Assignee changes are restricted for this role.</p>{% endif %}
|
||||
{% if can_edit and not can_manage_fields %}<p class="mt-1 text-xs text-slate-500">You can update status and work note only for your own assigned task.</p>{% endif %}
|
||||
</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>
|
||||
{% endif %}
|
||||
{% if (task.response_type or 'NONE') != 'NONE' or task.response_required or task.evidence_required %}
|
||||
<div class="md:col-span-2 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
|
||||
+126
-3
@@ -26,6 +26,9 @@ from app.modules.services.bulk_imports import (
|
||||
)
|
||||
from app.modules.services.catalogue_export import build_service_catalogue_export
|
||||
from app.modules.services.default_tasks_export import build_default_tasks_export
|
||||
from app.modules.services.firm_tasks_export import build_firm_tasks_export
|
||||
from app.modules.services.task_tools import list_task_tools, normalize_task_tool_code
|
||||
from app.modules.services.task_duplicates import find_duplicate_pairs, merge_default_tasks, merge_firm_tasks
|
||||
from app.modules.services.due_dates import (
|
||||
DUE_PERIOD_TYPES,
|
||||
DUE_YEAR_BASIS_CHOICES,
|
||||
@@ -88,6 +91,7 @@ def _base_ctx(request: Request, user, db, **ctx):
|
||||
"due_period_types": DUE_PERIOD_TYPES,
|
||||
"due_year_basis_choices": DUE_YEAR_BASIS_CHOICES,
|
||||
"task_execution_roles": TASK_EXECUTION_ROLES,
|
||||
"task_tools": list_task_tools(),
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
@@ -968,6 +972,7 @@ def _copy_system_defaults_if_firm_tasks_empty(
|
||||
response_type=getattr(default, "response_type", "NONE") or "NONE",
|
||||
evidence_required=getattr(default, "evidence_required", False),
|
||||
remarks_required_if_no=getattr(default, "remarks_required_if_no", False),
|
||||
task_tool_code=getattr(default, "task_tool_code", "NONE") or "NONE",
|
||||
is_aqmm_task=getattr(default, "is_aqmm_task", False),
|
||||
aqmm_mandatory=getattr(default, "aqmm_mandatory", False),
|
||||
aqmm_evidence_required=getattr(default, "aqmm_evidence_required", False),
|
||||
@@ -1262,7 +1267,7 @@ def task_templates_detail(request: Request, catalogue_id: int):
|
||||
|
||||
|
||||
@router.post('/templates/{catalogue_id}/tasks/new')
|
||||
def task_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), eligible_roles: list[str] = Form([]), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), normal_review_role: str = Form('manager'), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)):
|
||||
def task_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), eligible_roles: list[str] = Form([]), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), normal_review_role: str = Form('manager'), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), task_tool_code: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
@@ -1293,6 +1298,7 @@ def task_template_create_submit(request: Request, catalogue_id: int, task_name:
|
||||
task_category=(category.name if category else None),
|
||||
response_required=response_required is not None,
|
||||
response_type=(response_type.strip().upper() or "NONE"),
|
||||
task_tool_code=normalize_task_tool_code(task_tool_code),
|
||||
evidence_required=evidence_required is not None,
|
||||
remarks_required_if_no=remarks_required_if_no is not None,
|
||||
is_aqmm_task=is_aqmm_task is not None,
|
||||
@@ -1374,7 +1380,7 @@ def default_templates_detail(request: Request, catalogue_id: int):
|
||||
|
||||
|
||||
@router.post('/catalogue/{catalogue_id}/defaults/new')
|
||||
def default_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), normal_review_role: str = Form('manager'), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)):
|
||||
def default_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), normal_review_role: str = Form('manager'), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), task_tool_code: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
@@ -1387,7 +1393,7 @@ def default_template_create_submit(request: Request, catalogue_id: int, task_nam
|
||||
catalogue = get_catalogue(db, catalogue_id)
|
||||
if not catalogue:
|
||||
return RedirectResponse(url='/services/defaults', status_code=303)
|
||||
row = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue_id, task_name=task_name.strip(), description=description.strip() or None, default_role_name=default_role_name.strip() or None, sequence_no=sequence_no or next_default_task_sequence(db, catalogue_id=catalogue_id), is_mandatory=is_mandatory is not None, requires_review=requires_review is not None, normal_review_role=_normal_review_role_value(normal_review_role, requires_review=requires_review is not None), task_category_id=(category.id if (category := ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=None, name=task_category, user_id=user.id)) else None), task_category=(category.name if category else None), response_required=response_required is not None, response_type=(response_type.strip().upper() or "NONE"), evidence_required=evidence_required is not None, remarks_required_if_no=remarks_required_if_no is not None, is_aqmm_task=is_aqmm_task is not None, aqmm_mandatory=aqmm_mandatory is not None, aqmm_evidence_required=aqmm_evidence_required is not None, aqmm_manager_review_required=aqmm_manager_review_required is not None, aqmm_partner_review_required=aqmm_partner_review_required is not None, aqmm_review_partner_required=aqmm_review_partner_required is not None, aqmm_blocks_final_release=aqmm_blocks_final_release is not None, aqmm_reference=aqmm_reference.strip() or None, is_active=is_active is not None)
|
||||
row = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue_id, task_name=task_name.strip(), description=description.strip() or None, default_role_name=default_role_name.strip() or None, sequence_no=sequence_no or next_default_task_sequence(db, catalogue_id=catalogue_id), is_mandatory=is_mandatory is not None, requires_review=requires_review is not None, normal_review_role=_normal_review_role_value(normal_review_role, requires_review=requires_review is not None), task_category_id=(category.id if (category := ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=None, name=task_category, user_id=user.id)) else None), task_category=(category.name if category else None), response_required=response_required is not None, response_type=(response_type.strip().upper() or "NONE"), task_tool_code=normalize_task_tool_code(task_tool_code), evidence_required=evidence_required is not None, remarks_required_if_no=remarks_required_if_no is not None, is_aqmm_task=is_aqmm_task is not None, aqmm_mandatory=aqmm_mandatory is not None, aqmm_evidence_required=aqmm_evidence_required is not None, aqmm_manager_review_required=aqmm_manager_review_required is not None, aqmm_partner_review_required=aqmm_partner_review_required is not None, aqmm_review_partner_required=aqmm_review_partner_required is not None, aqmm_blocks_final_release=aqmm_blocks_final_release is not None, aqmm_reference=aqmm_reference.strip() or None, is_active=is_active is not None)
|
||||
db.add(row); db.commit()
|
||||
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
|
||||
finally:
|
||||
@@ -1433,6 +1439,7 @@ def copy_defaults_to_firm(request: Request, catalogue_id: int, csrf_token: str =
|
||||
response_type=getattr(d, "response_type", "NONE") or "NONE",
|
||||
evidence_required=getattr(d, "evidence_required", False),
|
||||
remarks_required_if_no=getattr(d, "remarks_required_if_no", False),
|
||||
task_tool_code=getattr(d, "task_tool_code", "NONE") or "NONE",
|
||||
is_aqmm_task=getattr(d, "is_aqmm_task", False),
|
||||
aqmm_mandatory=getattr(d, "aqmm_mandatory", False),
|
||||
aqmm_evidence_required=getattr(d, "aqmm_evidence_required", False),
|
||||
@@ -1795,6 +1802,7 @@ def firm_task_template_edit_submit(
|
||||
task_category: str = Form(''),
|
||||
response_required: str | None = Form(None),
|
||||
response_type: str = Form('NONE'),
|
||||
task_tool_code: str = Form('NONE'),
|
||||
evidence_required: str | None = Form(None),
|
||||
remarks_required_if_no: str | None = Form(None),
|
||||
is_aqmm_task: str | None = Form(None),
|
||||
@@ -1844,6 +1852,7 @@ def firm_task_template_edit_submit(
|
||||
task.task_category = category.name if category else None
|
||||
task.response_required = response_required is not None
|
||||
task.response_type = response_type.strip().upper() or "NONE"
|
||||
task.task_tool_code = normalize_task_tool_code(task_tool_code)
|
||||
task.evidence_required = evidence_required is not None
|
||||
task.remarks_required_if_no = remarks_required_if_no is not None
|
||||
task.is_aqmm_task = is_aqmm_task is not None
|
||||
@@ -1942,6 +1951,7 @@ def default_task_template_edit_submit(
|
||||
task_category: str = Form(''),
|
||||
response_required: str | None = Form(None),
|
||||
response_type: str = Form('NONE'),
|
||||
task_tool_code: str = Form('NONE'),
|
||||
evidence_required: str | None = Form(None),
|
||||
remarks_required_if_no: str | None = Form(None),
|
||||
is_aqmm_task: str | None = Form(None),
|
||||
@@ -1991,6 +2001,7 @@ def default_task_template_edit_submit(
|
||||
task.task_category = category.name if category else None
|
||||
task.response_required = response_required is not None
|
||||
task.response_type = response_type.strip().upper() or "NONE"
|
||||
task.task_tool_code = normalize_task_tool_code(task_tool_code)
|
||||
task.evidence_required = evidence_required is not None
|
||||
task.remarks_required_if_no = remarks_required_if_no is not None
|
||||
task.is_aqmm_task = is_aqmm_task is not None
|
||||
@@ -2008,6 +2019,118 @@ def default_task_template_edit_submit(
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Task administration: service export/import and safe duplicate consolidation
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@router.get('/catalogue/{catalogue_id}/defaults/export')
|
||||
def default_tasks_service_export(request: Request, catalogue_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user: return RedirectResponse(url='/login', status_code=303)
|
||||
require_permission(db, user, 'service_tasks.view')
|
||||
if not _is_system_admin(db, user): return _redirect_denied()
|
||||
catalogue = get_catalogue(db, catalogue_id)
|
||||
if not catalogue: return RedirectResponse(url='/services/defaults', status_code=303)
|
||||
payload = build_default_tasks_export(db, catalogue_id=catalogue_id)
|
||||
filename = f"{catalogue.service_code}_default_tasks_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return StreamingResponse(iter([payload]), media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', headers={'Content-Disposition': f'attachment; filename="{filename}"'})
|
||||
finally: db.close()
|
||||
|
||||
@router.post('/catalogue/{catalogue_id}/defaults/import')
|
||||
def default_tasks_service_import(request: Request, catalogue_id: int, file: UploadFile = File(...), 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)
|
||||
require_permission(db,user,'service_tasks.edit')
|
||||
if not _is_system_admin(db,user): return _redirect_denied()
|
||||
catalogue=get_catalogue(db,catalogue_id)
|
||||
if not catalogue: return RedirectResponse(url='/services/defaults',status_code=303)
|
||||
result=import_system_default_tasks(db,current_user=user,file_bytes=file.file.read(),update_existing=True,expected_service_code=catalogue.service_code)
|
||||
if result.get('errors'): return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults?import_error=1',status_code=303)
|
||||
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults?imported=1&created={result.get("created",0)}&updated={result.get("updated",0)}',status_code=303)
|
||||
finally: db.close()
|
||||
|
||||
@router.get('/templates/{catalogue_id}/export')
|
||||
def firm_tasks_service_export(request: Request, catalogue_id: int):
|
||||
db=CommonSessionLocal()
|
||||
try:
|
||||
user=get_current_user(request,db=db)
|
||||
if not user: return RedirectResponse(url='/login',status_code=303)
|
||||
if not _can_manage_firm_tasks(db,user): return _redirect_denied()
|
||||
tenant_id=_active_tenant_id(request,user); catalogue=get_catalogue(db,catalogue_id)
|
||||
if not catalogue: return RedirectResponse(url='/services/templates',status_code=303)
|
||||
payload=build_firm_tasks_export(db,tenant_id=tenant_id,catalogue_id=catalogue_id)
|
||||
filename=f"{catalogue.service_code}_firm_tasks_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return StreamingResponse(iter([payload]),media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',headers={'Content-Disposition':f'attachment; filename="{filename}"'})
|
||||
finally: db.close()
|
||||
|
||||
@router.post('/templates/{catalogue_id}/import')
|
||||
def firm_tasks_service_import(request: Request, catalogue_id: int, file: UploadFile = File(...), 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)
|
||||
if not _can_manage_firm_tasks(db,user): return _redirect_denied()
|
||||
tenant_id=_active_tenant_id(request,user); catalogue=get_catalogue(db,catalogue_id)
|
||||
if not catalogue: return RedirectResponse(url='/services/templates',status_code=303)
|
||||
result=import_firm_task_templates(db,current_user=user,tenant_id=tenant_id,file_bytes=file.file.read(),update_existing=True,expected_service_code=catalogue.service_code)
|
||||
if result.get('errors'): return RedirectResponse(url=f'/services/templates/{catalogue_id}?import_error=1',status_code=303)
|
||||
return RedirectResponse(url=f'/services/templates/{catalogue_id}?imported=1&created={result.get("created",0)}&updated={result.get("updated",0)}',status_code=303)
|
||||
finally: db.close()
|
||||
|
||||
@router.get('/catalogue/{catalogue_id}/defaults/duplicates')
|
||||
def default_task_duplicates_page(request: Request, catalogue_id: int):
|
||||
db=CommonSessionLocal()
|
||||
try:
|
||||
user=get_current_user(request,db=db)
|
||||
if not user: return RedirectResponse(url='/login',status_code=303)
|
||||
require_permission(db,user,'service_tasks.edit')
|
||||
if not _is_system_admin(db,user): return _redirect_denied()
|
||||
service=get_catalogue(db,catalogue_id); tasks=get_default_task_templates(db,catalogue_id=catalogue_id)
|
||||
return _render(request,'modules/services/templates/services/task_duplicate_review.html',db,user,title=f'Duplicate Tasks - {service.service_name}',service=service,tasks=tasks,pairs=find_duplicate_pairs(tasks),scope='default')
|
||||
finally: db.close()
|
||||
|
||||
@router.post('/catalogue/{catalogue_id}/defaults/duplicates/merge')
|
||||
def default_task_duplicates_merge(request: Request,catalogue_id:int,master_task_id:int=Form(...),source_task_ids:list[int]=Form([]),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)
|
||||
require_permission(db,user,'service_tasks.edit')
|
||||
if not _is_system_admin(db,user): return _redirect_denied()
|
||||
count=merge_default_tasks(db,catalogue_id=catalogue_id,master_task_id=master_task_id,source_task_ids=source_task_ids); db.commit()
|
||||
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults/duplicates?merged={count}',status_code=303)
|
||||
except ValueError:
|
||||
db.rollback(); return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults/duplicates?merge_error=1',status_code=303)
|
||||
finally: db.close()
|
||||
|
||||
@router.get('/templates/{catalogue_id}/duplicates')
|
||||
def firm_task_duplicates_page(request: Request,catalogue_id:int):
|
||||
db=CommonSessionLocal()
|
||||
try:
|
||||
user=get_current_user(request,db=db)
|
||||
if not user: return RedirectResponse(url='/login',status_code=303)
|
||||
if not _can_manage_firm_tasks(db,user): return _redirect_denied()
|
||||
tenant_id=_active_tenant_id(request,user); service=get_catalogue(db,catalogue_id); tasks=get_firm_task_templates(db,tenant_id=tenant_id,catalogue_id=catalogue_id)
|
||||
return _render(request,'modules/services/templates/services/task_duplicate_review.html',db,user,title=f'Duplicate Firm Tasks - {service.service_name}',service=service,tasks=tasks,pairs=find_duplicate_pairs(tasks),scope='firm')
|
||||
finally: db.close()
|
||||
|
||||
@router.post('/templates/{catalogue_id}/duplicates/merge')
|
||||
def firm_task_duplicates_merge(request: Request,catalogue_id:int,master_task_id:int=Form(...),source_task_ids:list[int]=Form([]),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)
|
||||
if not _can_manage_firm_tasks(db,user): return _redirect_denied()
|
||||
tenant_id=_active_tenant_id(request,user); count=merge_firm_tasks(db,tenant_id=tenant_id,catalogue_id=catalogue_id,master_task_id=master_task_id,source_task_ids=source_task_ids,user_id=user.id); db.commit()
|
||||
return RedirectResponse(url=f'/services/templates/{catalogue_id}/duplicates?merged={count}',status_code=303)
|
||||
except ValueError:
|
||||
db.rollback(); return RedirectResponse(url=f'/services/templates/{catalogue_id}/duplicates?merge_error=1',status_code=303)
|
||||
finally: db.close()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Service Task Category Master
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ 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.execution import (
|
||||
TASK_COMMENT_TYPES,
|
||||
TASK_COMMENT_VISIBILITIES,
|
||||
@@ -443,6 +444,7 @@ def task_edit_page(request: Request, task_id: int):
|
||||
can_review_partner_review=_can_review_partner_review_task(db, user, task),
|
||||
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")),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user