diff --git a/alembic/versions/20260830_task_admin_tools.py b/alembic/versions/20260830_task_admin_tools.py new file mode 100644 index 0000000..2865ba2 --- /dev/null +++ b/alembic/versions/20260830_task_admin_tools.py @@ -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") diff --git a/app/modules/services/bulk_imports.py b/app/modules/services/bulk_imports.py index 8058fb6..9c4f365 100644 --- a/app/modules/services/bulk_imports.py +++ b/app/modules/services/bulk_imports.py @@ -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) diff --git a/app/modules/services/default_tasks_export.py b/app/modules/services/default_tasks_export.py index dff97c3..99ef3e6 100644 --- a/app/modules/services/default_tasks_export.py +++ b/app/modules/services/default_tasks_export.py @@ -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), diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py index fc0ca44..df61032 100644 --- a/app/modules/services/execution.py +++ b/app/modules/services/execution.py @@ -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", diff --git a/app/modules/services/firm_tasks_export.py b/app/modules/services/firm_tasks_export.py new file mode 100644 index 0000000..448a02d --- /dev/null +++ b/app/modules/services/firm_tasks_export.py @@ -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() diff --git a/app/modules/services/models.py b/app/modules/services/models.py index 4099f04..b4a4e84 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -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) diff --git a/app/modules/services/task_duplicates.py b/app/modules/services/task_duplicates.py new file mode 100644 index 0000000..46e2126 --- /dev/null +++ b/app/modules/services/task_duplicates.py @@ -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) diff --git a/app/modules/services/task_tools.py b/app/modules/services/task_tools.py new file mode 100644 index 0000000..b649835 --- /dev/null +++ b/app/modules/services/task_tools.py @@ -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 diff --git a/app/modules/services/templates/services/default_task_form.html b/app/modules/services/templates/services/default_task_form.html index 085f63d..2c96a61 100644 --- a/app/modules/services/templates/services/default_task_form.html +++ b/app/modules/services/templates/services/default_task_form.html @@ -98,6 +98,11 @@ {% endfor %} +
+ + +

Links an existing ERP verification workspace to this task; normal checklist behavior is preserved.

+
diff --git a/app/modules/services/templates/services/default_templates_detail.html b/app/modules/services/templates/services/default_templates_detail.html index 9163776..33f289e 100644 --- a/app/modules/services/templates/services/default_templates_detail.html +++ b/app/modules/services/templates/services/default_templates_detail.html @@ -6,8 +6,15 @@

{{ service.service_name }}

System default task templates for {{ service.service_code }}

-
Task CategoriesBackCatalogue Detail
+
Export TasksDuplicate ReviewTask CategoriesBackCatalogue Detail
+ + {% if request.query_params.get('imported') %}
Excel import completed: {{ request.query_params.get('created','0') }} created, {{ request.query_params.get('updated','0') }} updated.
{% endif %} + {% if request.query_params.get('import_error') %}
Excel import failed validation. No partial update was committed. Review the workbook and try again.
{% endif %} +
+

Export · Edit · Re-import This Service

Export this service's current tasks, edit the Import Ready sheet, then re-import. Matching remains service code + sequence number.

+
+
{% for task in default_tasks %} @@ -43,7 +50,7 @@
-
+

Optional ERP tool linked to the task. Existing task workflow remains unchanged.

diff --git a/app/modules/services/templates/services/firm_task_form.html b/app/modules/services/templates/services/firm_task_form.html index 90ccdc1..16c5dd4 100644 --- a/app/modules/services/templates/services/firm_task_form.html +++ b/app/modules/services/templates/services/firm_task_form.html @@ -98,6 +98,11 @@ {% endfor %}
+
+ + +

Links an existing ERP verification workspace to this task; normal checklist behavior is preserved.

+
diff --git a/app/modules/services/templates/services/task_duplicate_review.html b/app/modules/services/templates/services/task_duplicate_review.html new file mode 100644 index 0000000..bac0539 --- /dev/null +++ b/app/modules/services/templates/services/task_duplicate_review.html @@ -0,0 +1,24 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Duplicate Task Review

{{ service.service_code }} · {{ service.service_name }} · {{ 'System defaults' if scope == 'default' else 'Firm task templates' }}

+ Back to Tasks +
+ {% if request.query_params.get('merged') %}
{{ request.query_params.get('merged') }} duplicate template(s) merged safely. Source templates were deactivated; historical engagement tasks were not changed.
{% endif %} + {% if request.query_params.get('merge_error') %}
Merge could not be completed. Select one master task and at least one different duplicate task.
{% endif %} +
+

Likely Duplicates

Suggestions use task-name similarity only. Review the meaning before merging.

+
{% for pair in pairs %}
{{ pair.left.sequence_no }}. {{ pair.left.task_name }}
{{ pair.score }}%
{{ pair.right.sequence_no }}. {{ pair.right.task_name }}
{% else %}
No high-confidence duplicate pairs detected automatically. You can still merge manually below.
{% endfor %}
+
+
+

Merge Selected Templates

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 %}

+
+ +
+
Duplicate Tasks to Merge Into Master
{% for task in tasks %}{% if task.is_active %}{% endif %}{% endfor %}
+
+
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/task_template_detail.html b/app/modules/services/templates/services/task_template_detail.html index 703c9cd..7a5ef01 100644 --- a/app/modules/services/templates/services/task_template_detail.html +++ b/app/modules/services/templates/services/task_template_detail.html @@ -11,6 +11,8 @@
Back to Templates + Export Tasks + Duplicate Review Task Categories View Catalogue
@@ -21,7 +23,15 @@ {% if request.query_params.get('error') %}
Action failed. Please check the selected task, file and permissions.
{% endif %} {% if request.query_params.get('moved') %}
Task sequence updated. Use Sync Firm Tasks to Open Engagements if you want safe pending/open task snapshots refreshed.
{% endif %} {% if request.query_params.get('move_error') %}
Task could not be moved.
{% endif %} + {% if request.query_params.get('imported') %}
Excel import completed: {{ request.query_params.get('created','0') }} created, {{ request.query_params.get('updated','0') }} updated.
{% endif %} + {% if request.query_params.get('import_error') %}
Excel import failed validation. No partial update was committed.
{% endif %} + + {% if can_manage_tasks %} +
+

Export · Edit · Re-import Firm Tasks

Use the exported workbook for this service. Re-import updates by service code + sequence number and preserves all other ERP features.

+
+ {% endif %}
Firm Status
@@ -87,6 +97,10 @@ {% for category in task_categories or [] %}{% if category.is_active %}{% endif %}{% endfor %}
+
+ + +
@@ -197,7 +211,7 @@
{{ task.default_role_name or '-' }}
Eligible: {{ task.eligible_role_names or task.default_role_name or '-' }}
- {% if task.task_category %}{{ task.task_category }}{% endif %} + {% if task.task_category %}{{ task.task_category }}{% endif %}{% if task.task_tool_code and task.task_tool_code != "NONE" %}Tool: {{ task.task_tool_code|replace("_"," ")|title }}{% endif %} {% if task.is_mandatory %}Mandatory{% endif %} {% if task.requires_review %}Review: {{ (task.normal_review_role or "manager").replace("_"," ").title() }}{% endif %} {% if task.is_aqmm_task %}AQMM{% endif %} diff --git a/app/modules/services/templates/services/work_tracker/task_form.html b/app/modules/services/templates/services/work_tracker/task_form.html index b45d5ec..6c89b3e 100644 --- a/app/modules/services/templates/services/work_tracker/task_form.html +++ b/app/modules/services/templates/services/work_tracker/task_form.html @@ -86,6 +86,12 @@ {% if not can_reassign and task.assigned_to %}

Assignee changes are restricted for this role.

{% endif %} {% if can_edit and not can_manage_fields %}

You can update status and work note only for your own assigned task.

{% endif %}
+ {% if task_tool and task_tool.code != 'NONE' %} +
+
ERP Task Tool · {{ task_tool.name }}

{{ task_tool.description }}

{% if task_tool.launch_url %}Open Tool{% endif %}
+

Opening the tool does not automatically mark this task complete. Save the task response/evidence after completing the verification.

+
+ {% endif %} {% if (task.response_type or 'NONE') != 'NONE' or task.response_required or task.evidence_required %}
diff --git a/app/modules/services/ui.py b/app/modules/services/ui.py index dc5e688..969e84e 100644 --- a/app/modules/services/ui.py +++ b/app/modules/services/ui.py @@ -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 # ----------------------------------------------------------------------------- diff --git a/app/modules/services/ui.py.bak_20260718_122052 b/app/modules/services/ui.py.bak_20260718_122052 new file mode 100644 index 0000000..29085f8 --- /dev/null +++ b/app/modules/services/ui.py.bak_20260718_122052 @@ -0,0 +1,1622 @@ +from __future__ import annotations + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse +from sqlalchemy import select + +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token, validate_csrf +from app.core.security.session_auth import get_current_user +from app.core.templating import templates +from app.modules.core.audit.service import model_snapshot, pair_before_after, write_audit_log +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.tenancy.models import Branch +from app.modules.services.models import FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, ServiceDefaultTaskTemplate, ServiceDueDateRule, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate +from app.modules.services.bulk_imports import ( + build_template as build_bulk_import_template, + import_client_service_assignments, + import_firm_task_templates, + import_service_master, + import_system_default_tasks, + import_due_date_extensions, +) +from app.modules.services.catalogue_export import build_service_catalogue_export +from app.modules.services.due_dates import ( + DUE_PERIOD_TYPES, + DUE_YEAR_BASIS_CHOICES, + create_due_date_extension, + get_due_rule, + list_due_extensions, + list_due_rules, + parse_optional_date, +) +from app.modules.services.catalogue_merge import build_merge_preview, merge_service_catalogues +from app.modules.services.task_documents import ( + create_task_document_requirement, + get_task_document_requirement, + list_task_document_requirements, + list_task_document_templates, + save_task_document_template, + template_absolute_path, + update_task_document_requirement, +) +from app.modules.services.services import ( + RECURRENCE_CHOICES, + ENGAGEMENT_TYPE_CHOICES, + normalize_engagement_type, + get_catalogue, + get_category, + get_firm_selection, + get_firm_task_templates, + list_catalogue_payload, + list_categories, + list_disabled_catalogues, + list_firm_services_payload, + next_task_sequence, + next_default_task_sequence, + normalize_code, + get_default_task_templates, + get_firm_task_template, + get_default_task_template, +) + +router = APIRouter(prefix="/services", tags=["services-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "request": request, + "current_user": user, + "current_user_roles": get_user_roles(db, user.id), + "current_user_permissions": get_user_permissions(db, user.id), + "csrf_token": get_or_create_csrf_token(request), + "recurrence_choices": RECURRENCE_CHOICES, + "engagement_type_choices": ENGAGEMENT_TYPE_CHOICES, + "due_period_types": DUE_PERIOD_TYPES, + "due_year_basis_choices": DUE_YEAR_BASIS_CHOICES, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + from app.core.http_responses import ui_access_denied + return ui_access_denied() + + +def _has_perm(db, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + +def _role_names(db, user) -> set[str]: + try: + return set(get_user_roles(db, user.id)) + except Exception: + return set() + + +def _has_role(db, user, *names: str) -> bool: + roles = _role_names(db, user) + return any(name in roles for name in names) + + +def _can_view_services(db, user) -> bool: + return _has_perm(db, user, "services.view") or _has_role(db, user, "System Admin", "Firm Admin", "Partner", "Branch Manager") + + +def _is_system_admin(db, user) -> bool: + return _has_perm(db, user, "services.cross_tenant") + + +def _can_manage_firm_services(db, user) -> bool: + return _has_perm(db, user, "services.edit") or _has_role(db, user, "Firm Admin", "Partner") + + +def _can_manage_firm_tasks(db, user) -> bool: + return _has_perm(db, user, "service_tasks.create") or _has_perm(db, user, "service_tasks.edit") or _has_role(db, user, "Firm Admin", "Partner") + + +def _active_tenant_id(request: Request, user) -> int: + value = ( + request.session.get("active_tenant_id") + or request.session.get("selected_tenant_id") + or request.session.get("tenant_id") + or user.tenant_id + ) + return int(value) + + +@router.get("") +def services_home(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "services.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + + enabled_rows = list_firm_services_payload(db, tenant_id=tenant_id, q=q) + disabled_rows = list_disabled_catalogues(db, tenant_id=tenant_id, q=q) + branches = db.execute( + select(Branch).where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True)).order_by(Branch.name.asc()) + ).scalars().all() + return _render( + request, + "modules/services/templates/services/list.html", + db, + user, + title="Services", + q=q, + enabled_rows=enabled_rows, + disabled_rows=disabled_rows, + branches=branches, + can_manage_catalogue=_is_system_admin(db, user), + can_manage_firm_services=_can_manage_firm_services(db, user), + can_manage_firm_tasks=_can_manage_firm_tasks(db, user), + ) + finally: + db.close() + + +@router.get('/categories') +def category_list(request: Request, q: str = ''): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_view_services(db, user): + return _redirect_denied() + rows = list_categories(db, q=q) + return _render(request, 'modules/services/templates/services/category_list.html', db, user, title='Service Categories', q=q, rows=rows, can_create=(_has_perm(db, user, 'services.create') and _is_system_admin(db, user))) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.get('/categories/new') +def category_create_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + require_permission(db, user, 'services.create') + if not _is_system_admin(db, user): + return _redirect_denied() + return _render(request, 'modules/services/templates/services/category_form.html', db, user, title='Create Service Category', mode='create', category=None) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.post('/categories/new') +def category_create_submit( + request: Request, + code: str = Form(...), + name: str = Form(...), + sort_order: int = Form(100), + is_active: str | None = Form(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) + + require_permission(db, user, 'services.create') + + if not _is_system_admin(db, user): + return _redirect_denied() + + row = ServiceCategory( + code=normalize_code(code), + name=name.strip(), + sort_order=sort_order, + is_active=is_active is not None, + ) + + db.add(row) + db.commit() + db.refresh(row) + + return RedirectResponse(url='/services/categories', status_code=303) + finally: + db.close() + +@router.get('/categories/{category_id}/edit') +def category_edit_page(request: Request, category_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, 'services.edit') + if not _is_system_admin(db, user): + return _redirect_denied() + row = get_category(db, category_id) + if not row: + return RedirectResponse(url='/services/categories', status_code=303) + return _render(request, 'modules/services/templates/services/category_form.html', db, user, title='Edit Service Category', mode='edit', category=row) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.post('/categories/{category_id}/edit') +def category_edit_submit(request: Request, category_id: int, name: str = Form(...), sort_order: int = Form(100), is_active: str | None = Form(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) + require_permission(db, user, 'services.edit') + if not _is_system_admin(db, user): + return _redirect_denied() + row = get_category(db, category_id) + if not row: + return RedirectResponse(url='/services/categories', status_code=303) + row.name = name.strip() + row.sort_order = sort_order + row.is_active = is_active is not None + db.commit() + return RedirectResponse(url='/services/categories', status_code=303) + finally: + db.close() + + +@router.get('/catalogue') +def catalogue_list(request: Request, q: str = '', category_id: int | None = None, recurrence_type: str = '', engagement_type: str = '', page: int = 1, per_page: int = 20): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_view_services(db, user): + return _redirect_denied() + payload = list_catalogue_payload(db, q=q, category_id=category_id, recurrence_type=recurrence_type, engagement_type=engagement_type, page=page, per_page=per_page) + tenant_id = _active_tenant_id(request, user) + can_create_catalogue = _is_system_admin(db, user) and _has_perm(db, user, 'services.create') + can_manage_firm_services = _can_manage_firm_services(db, user) + + catalogue_ids = [row.id for row in payload.get('rows', [])] + firm_selection_by_catalogue = {} + if catalogue_ids: + selections = db.execute( + select(FirmServiceSelection).where( + FirmServiceSelection.tenant_id == tenant_id, + FirmServiceSelection.service_catalogue_id.in_(catalogue_ids), + ) + ).scalars().all() + firm_selection_by_catalogue = {selection.service_catalogue_id: selection for selection in selections} + + branches = db.execute( + select(Branch) + .where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True)) + .order_by(Branch.name.asc()) + ).scalars().all() + + return _render( + request, + 'modules/services/templates/services/catalogue_list.html', + db, + user, + title='Service Catalogue', + can_create=can_create_catalogue, + can_manage_firm_services=can_manage_firm_services, + categories=list_categories(db), + branches=branches, + tenant_id=tenant_id, + firm_selection_by_catalogue=firm_selection_by_catalogue, + **payload, + ) + finally: + db.close() + + +@router.get('/catalogue/export') +def catalogue_export(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.view') + + workbook_bytes = build_service_catalogue_export(db) + filename = f"service_catalogue_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.xlsx" + return StreamingResponse( + iter([workbook_bytes]), + media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f'attachment; filename="{filename}"'}, + ) + finally: + db.close() + + +@router.get('/catalogue/merge') +def catalogue_merge_page(request: Request, source_id: int | None = None, target_id: int | None = None): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + + services = db.execute( + select(ServiceCatalogue) + .where(ServiceCatalogue.is_active.is_(True)) + .order_by(ServiceCatalogue.service_code.asc()) + ).scalars().all() + preview = None + error = None + if source_id and target_id: + try: + preview = build_merge_preview(db, source_id=source_id, target_id=target_id) + except ValueError as exc: + error = str(exc) + + return _render( + request, + 'modules/services/templates/services/catalogue_merge.html', + db, + user, + title='Merge Service Catalogue', + services=services, + source_id=source_id, + target_id=target_id, + preview=preview, + error=error, + ) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.post('/catalogue/merge') +def catalogue_merge_submit( + request: Request, + source_id: int = Form(...), + target_id: int = Form(...), + confirmation: str = 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 _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + + preview = build_merge_preview(db, source_id=source_id, target_id=target_id) + expected = f"MERGE {preview.source.service_code} INTO {preview.target.service_code}" + if confirmation.strip().upper() != expected.upper(): + return RedirectResponse( + url=f'/services/catalogue/merge?source_id={source_id}&target_id={target_id}&error=confirmation', + status_code=303, + ) + + result = merge_service_catalogues( + db, + source_id=source_id, + target_id=target_id, + actor_user_id=user.id, + ) + db.commit() + + write_audit_log( + db, + action='services.catalogue.merged', + entity_type='service_catalogue', + entity_id=target_id, + entity_name=result['target_code'], + actor=user, + request=request, + details=result, + ) + return RedirectResponse(url=f'/services/catalogue/{target_id}?merged=1', status_code=303) + except ValueError: + db.rollback() + return RedirectResponse(url='/services/catalogue/merge?error=invalid', status_code=303) + except Exception: + db.rollback() + raise + finally: + db.close() + + +@router.get('/catalogue/new') +def catalogue_create_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.create') + return _render(request, 'modules/services/templates/services/catalogue_form.html', db, user, title='Create Service Catalogue', mode='create', catalogue=None, categories=list_categories(db)) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.post('/catalogue/new') +def catalogue_create_submit(request: Request, service_code: str = Form(...), service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(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) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.create') + selected_category = get_category(db, int(category_id)) if str(category_id).strip() else None + row = ServiceCatalogue( + service_code=normalize_code(service_code), + service_name=service_name.strip(), + category_id=selected_category.id if selected_category else None, + category=selected_category.name if selected_category else None, + recurrence_type=recurrence_type.strip() or None, + engagement_type=normalize_engagement_type(engagement_type), + sort_order=sort_order, + description=description.strip() or None, + applicable_individual=applicable_individual is not None, + applicable_proprietorship=applicable_proprietorship is not None, + applicable_partnership=applicable_partnership is not None, + applicable_llp=applicable_llp is not None, + applicable_company=applicable_company is not None, + applicable_trust=applicable_trust is not None, + applicable_society=applicable_society is not None, + is_active=is_active is not None, + is_client_requestable=is_client_requestable is not None, + is_consultant_requestable=is_consultant_requestable is not None, + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(row); db.commit(); db.refresh(row) + return RedirectResponse(url=f'/services/catalogue/{row.id}', status_code=303) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}') +def catalogue_detail(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_view_services(db, user): + return _redirect_denied() + row = get_catalogue(db, catalogue_id) + if not row or not row.is_active: + return RedirectResponse(url='/services/catalogue', status_code=303) + tenant_id = _active_tenant_id(request, user) + can_edit = _is_system_admin(db, user) and _has_perm(db, user, 'services.edit') + branches = db.execute( + select(Branch) + .where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True)) + .order_by(Branch.name.asc()) + ).scalars().all() + return _render( + request, + 'modules/services/templates/services/catalogue_detail.html', + db, + user, + title=f'Service Catalogue - {row.service_name}', + catalogue=row, + can_edit=can_edit, + can_manage_firm_services=_can_manage_firm_services(db, user), + branches=branches, + current_selection=get_firm_selection(db, tenant_id=tenant_id, catalogue_id=row.id), + current_templates=get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=row.id), + default_templates=get_default_task_templates(db, catalogue_id=row.id), + due_rules=list_due_rules(db, catalogue_id=row.id), + due_extensions=list_due_extensions(db, catalogue_id=row.id, tenant_id=tenant_id), + tenant_id=tenant_id, + ) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/due-rules/new') +def due_rule_create_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 _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/catalogue', status_code=303) + return _render( + request, + 'modules/services/templates/services/due_dates/rule_form.html', + db, + user, + title='Add Due Date Rule', + mode='create', + catalogue=catalogue, + rule=None, + ) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/due-rules/new') +def due_rule_create_submit( + request: Request, + catalogue_id: int, + rule_name: str = Form(...), + period_type: str = Form('yearly'), + due_year_basis: str = Form('assessment_year_start'), + due_day: str = Form(''), + due_month: str = Form(''), + due_month_offset: int = Form(0), + days_offset_after_event: str = Form(''), + renewal_days_before_expiry: str = Form(''), + sort_order: int = Form(100), + is_active: str | None = Form(None), + remarks: str = 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 _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/catalogue', status_code=303) + row = ServiceDueDateRule( + service_catalogue_id=catalogue_id, + rule_name=rule_name.strip(), + period_type=(period_type or 'yearly').strip(), + due_year_basis=(due_year_basis or 'assessment_year_start').strip(), + due_day=int(due_day) if str(due_day).strip() else None, + due_month=int(due_month) if str(due_month).strip() else None, + due_month_offset=int(due_month_offset or 0), + days_offset_after_event=int(days_offset_after_event) if str(days_offset_after_event).strip() else None, + renewal_days_before_expiry=int(renewal_days_before_expiry) if str(renewal_days_before_expiry).strip() else None, + sort_order=sort_order, + is_active=is_active is not None, + remarks=remarks.strip() or None, + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(row) + db.commit() + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/due-rules/{rule_id}/edit') +def due_rule_edit_page(request: Request, catalogue_id: int, rule_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + catalogue = get_catalogue(db, catalogue_id) + rule = get_due_rule(db, rule_id=rule_id, catalogue_id=catalogue_id) + if not catalogue or not rule: + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303) + return _render( + request, + 'modules/services/templates/services/due_dates/rule_form.html', + db, + user, + title='Edit Due Date Rule', + mode='edit', + catalogue=catalogue, + rule=rule, + ) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/due-rules/{rule_id}/edit') +def due_rule_edit_submit( + request: Request, + catalogue_id: int, + rule_id: int, + rule_name: str = Form(...), + period_type: str = Form('yearly'), + due_year_basis: str = Form('assessment_year_start'), + due_day: str = Form(''), + due_month: str = Form(''), + due_month_offset: int = Form(0), + days_offset_after_event: str = Form(''), + renewal_days_before_expiry: str = Form(''), + sort_order: int = Form(100), + is_active: str | None = Form(None), + remarks: str = 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 _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + rule = get_due_rule(db, rule_id=rule_id, catalogue_id=catalogue_id) + if not rule: + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303) + rule.rule_name = rule_name.strip() + rule.period_type = (period_type or 'yearly').strip() + rule.due_year_basis = (due_year_basis or 'assessment_year_start').strip() + rule.due_day = int(due_day) if str(due_day).strip() else None + rule.due_month = int(due_month) if str(due_month).strip() else None + rule.due_month_offset = int(due_month_offset or 0) + rule.days_offset_after_event = int(days_offset_after_event) if str(days_offset_after_event).strip() else None + rule.renewal_days_before_expiry = int(renewal_days_before_expiry) if str(renewal_days_before_expiry).strip() else None + rule.sort_order = sort_order + rule.is_active = is_active is not None + rule.remarks = remarks.strip() or None + rule.updated_by_user_id = user.id + db.commit() + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/due-extensions/new') +def due_extension_create_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 _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/catalogue', status_code=303) + return _render( + request, + 'modules/services/templates/services/due_dates/extension_form.html', + db, + user, + title='Add Due Date Extension', + catalogue=catalogue, + rules=list_due_rules(db, catalogue_id=catalogue_id, include_inactive=False), + ) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/due-extensions/new') +def due_extension_create_submit( + request: Request, + catalogue_id: int, + due_date_rule_id: str = Form(''), + financial_year: str = Form(...), + assessment_year: str = Form(''), + period_label: str = Form(''), + extended_due_date: str = Form(...), + notification_reference: str = Form(''), + notification_date: str = Form(''), + remarks: str = 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 _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/catalogue', status_code=303) + tenant_id = _active_tenant_id(request, user) + create_due_date_extension( + db, + tenant_id=tenant_id, + catalogue_id=catalogue_id, + due_date_rule_id=int(due_date_rule_id) if str(due_date_rule_id).strip() else None, + financial_year=financial_year.strip(), + assessment_year=assessment_year.strip() or None, + period_label=period_label.strip() or None, + extended_due_date=parse_optional_date(extended_due_date), + notification_reference=notification_reference, + notification_date=parse_optional_date(notification_date), + remarks=remarks, + user_id=user.id, + ) + db.commit() + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/edit') +def catalogue_edit_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 _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + row = get_catalogue(db, catalogue_id) + if not row or not row.is_active: + return RedirectResponse(url='/services/catalogue', status_code=303) + return _render(request, 'modules/services/templates/services/catalogue_form.html', db, user, title='Edit Service Catalogue', mode='edit', catalogue=row, categories=list_categories(db)) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/edit') +def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(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) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + row = get_catalogue(db, catalogue_id) + if not row or not row.is_active: + return RedirectResponse(url='/services/catalogue', status_code=303) + selected_category = get_category(db, int(category_id)) if str(category_id).strip() else None + row.service_name = service_name.strip() + row.category_id = selected_category.id if selected_category else None + row.category = selected_category.name if selected_category else None + row.recurrence_type = recurrence_type.strip() or None + row.engagement_type = normalize_engagement_type(engagement_type) + row.sort_order = sort_order + row.applicable_individual = applicable_individual is not None + row.applicable_proprietorship = applicable_proprietorship is not None + row.applicable_partnership = applicable_partnership is not None + row.applicable_llp = applicable_llp is not None + row.applicable_company = applicable_company is not None + row.applicable_trust = applicable_trust is not None + row.applicable_society = applicable_society is not None + row.is_active = is_active is not None + row.is_client_requestable = is_client_requestable is not None + row.is_consultant_requestable = is_consultant_requestable is not None + row.updated_by_user_id = user.id + db.commit() + return RedirectResponse(url=f'/services/catalogue/{row.id}', status_code=303) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/toggle') +def toggle_firm_service(request: Request, catalogue_id: int, default_branch_id: str = 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_services(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + row = get_catalogue(db, catalogue_id) + if not row or not row.is_active: + return RedirectResponse(url='/services/catalogue', status_code=303) + selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not selection: + selection = FirmServiceSelection( + tenant_id=tenant_id, + service_catalogue_id=catalogue_id, + is_enabled=True, + default_branch_id=int(default_branch_id) if str(default_branch_id).strip() else None, + activated_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(selection) + else: + selection.is_enabled = not bool(selection.is_enabled) + if str(default_branch_id).strip(): + selection.default_branch_id = int(default_branch_id) + selection.updated_by_user_id = user.id + db.commit() + return RedirectResponse(url='/services', status_code=303) + finally: + db.close() + + +@router.get('/templates') +def task_templates_list(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not (_has_perm(db, user, 'service_tasks.view') or _can_manage_firm_tasks(db, user)): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + enabled_rows = list_firm_services_payload(db, tenant_id=tenant_id) + for row in enabled_rows: + row['task_count'] = len(get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=row['catalogue'].id)) + return _render(request, 'modules/services/templates/services/task_templates_list.html', db, user, title='Firm Service Task Templates', rows=enabled_rows, can_manage_tasks=_can_manage_firm_tasks(db, user)) + finally: + db.close() + + +@router.get('/templates/{catalogue_id}') +def task_templates_detail(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 (_has_perm(db, user, 'service_tasks.view') or _can_manage_firm_tasks(db, user)): + return _redirect_denied() + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/templates', status_code=303) + tenant_id = _active_tenant_id(request, user) + selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not selection or not selection.is_enabled: + return RedirectResponse(url='/services', status_code=303) + tasks = get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + task_requirement_map = { + task.id: list_task_document_requirements(db, tenant_id=tenant_id, firm_task_template_id=task.id) + for task in tasks + } + task_template_file_map = { + task.id: list_task_document_templates(db, tenant_id=tenant_id, firm_task_template_id=task.id) + for task in tasks + } + return _render( + request, + 'modules/services/templates/services/task_template_detail.html', + db, + user, + title=f'Firm Task Templates - {catalogue.service_name}', + service=catalogue, + selection=selection, + task_templates=tasks, + task_requirement_map=task_requirement_map, + task_template_file_map=task_template_file_map, + can_manage_tasks=_can_manage_firm_tasks(db, user), + default_tasks=get_default_task_templates(db, catalogue_id=catalogue_id), + is_system_admin=_is_system_admin(db, user), + ) + finally: + db.close() + + +@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(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: 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: + 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() + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/templates', status_code=303) + tenant_id = _active_tenant_id(request, user) + selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not selection or not selection.is_enabled: + return RedirectResponse(url='/services', status_code=303) + row = FirmServiceTaskTemplate( + tenant_id=tenant_id, + 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_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id), + is_mandatory=is_mandatory is not None, + requires_review=requires_review 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, + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(row); db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.get('/defaults') +def default_templates_catalogue_list(request: Request): + 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() + payload = list_catalogue_payload(db, page=1, per_page=500) + rows = payload['rows'] + return _render(request, 'modules/services/templates/services/default_templates_list.html', db, user, title='Default Task Templates', rows=rows, can_manage_defaults=_has_perm(db, user, 'service_tasks.create')) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/defaults') +def default_templates_detail(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) + defaults = get_default_task_templates(db, catalogue_id=catalogue_id) + return _render(request, 'modules/services/templates/services/default_templates_detail.html', db, user, title=f'Default Tasks - {catalogue.service_name}', service=catalogue, default_tasks=defaults) + finally: + db.close() + + +@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), 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: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + require_permission(db, user, 'service_tasks.create') + 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) + 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, 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: + db.close() + + +@router.post('/catalogue/{catalogue_id}/defaults/copy-to-firm') +def copy_defaults_to_firm(request: Request, catalogue_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) + if not _can_manage_firm_tasks(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not selection or not selection.is_enabled: + return RedirectResponse(url='/services', status_code=303) + existing_sequences = {t.sequence_no for t in get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=catalogue_id)} + defaults = get_default_task_templates(db, catalogue_id=catalogue_id) + next_seq = next_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + for d in defaults: + seq = d.sequence_no + if seq in existing_sequences: + seq = next_seq + next_seq += 1 + existing_sequences.add(seq) + db.add(FirmServiceTaskTemplate( + tenant_id=tenant_id, + service_catalogue_id=catalogue_id, + task_name=d.task_name, + description=d.description, + sequence_no=seq, + default_role_name=d.default_role_name, + is_mandatory=d.is_mandatory, + requires_review=d.requires_review, + 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), + aqmm_manager_review_required=getattr(d, "aqmm_manager_review_required", False), + aqmm_partner_review_required=getattr(d, "aqmm_partner_review_required", False), + aqmm_review_partner_required=getattr(d, "aqmm_review_partner_required", False), + aqmm_blocks_final_release=getattr(d, "aqmm_blocks_final_release", False), + aqmm_reference=getattr(d, "aqmm_reference", None), + is_active=d.is_active, + created_by_user_id=user.id, + updated_by_user_id=user.id, + )) + db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.post('/templates/{catalogue_id}/tasks/{task_id}/document-requirements/new') +def firm_task_document_requirement_create( + request: Request, + catalogue_id: int, + task_id: int, + document_name: str = Form(...), + document_type: str = Form('GENERAL'), + is_mandatory: str | None = Form(None), + allowed_file_types: str = Form(''), + instructions: str = Form(''), + sort_order: int = Form(100), + 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) + task = get_firm_task_template(db, task_id=task_id, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not task: + return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=task_missing', status_code=303) + create_task_document_requirement( + db, + task_template=task, + document_name=document_name, + document_type=document_type, + is_mandatory=is_mandatory is not None, + allowed_file_types=allowed_file_types, + instructions=instructions, + sort_order=sort_order, + user=user, + ) + db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}?requirement_added=1', status_code=303) + finally: + db.close() + + +@router.post('/templates/{catalogue_id}/tasks/{task_id}/document-requirements/{requirement_id}/toggle') +def firm_task_document_requirement_toggle( + request: Request, + catalogue_id: int, + task_id: int, + requirement_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) + if not _can_manage_firm_tasks(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + task = get_firm_task_template(db, task_id=task_id, tenant_id=tenant_id, catalogue_id=catalogue_id) + requirement = get_task_document_requirement(db, requirement_id=requirement_id, tenant_id=tenant_id) + if not task or not requirement or requirement.firm_task_template_id != task.id: + return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=requirement_missing', status_code=303) + requirement.is_active = not bool(requirement.is_active) + requirement.updated_by_user_id = user.id + db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}?requirement_updated=1', status_code=303) + finally: + db.close() + + +@router.post('/templates/{catalogue_id}/tasks/{task_id}/document-templates/upload') +def firm_task_document_template_upload( + request: Request, + catalogue_id: int, + task_id: int, + template_name: str = Form(''), + template_category: str = Form(''), + description: str = Form(''), + 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) + task = get_firm_task_template(db, task_id=task_id, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not task: + return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=task_missing', status_code=303) + if not file or not file.filename: + return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=missing_file', status_code=303) + save_task_document_template( + db, + task_template=task, + template_name=template_name or file.filename, + template_category=template_category, + description=description, + upload_file=file, + user=user, + ) + db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}?template_uploaded=1', status_code=303) + finally: + db.close() + + +@router.get('/document-templates/{template_id}/download') +def firm_task_document_template_download(request: Request, template_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not (_has_perm(db, user, 'service_tasks.view') or _can_manage_firm_tasks(db, user)): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + template = db.get(FirmTaskDocumentTemplate, template_id) + if not template or template.tenant_id != tenant_id or not template.is_active: + return _redirect_denied() + path = template_absolute_path(template) + if not path.exists(): + return RedirectResponse(url=f'/services/templates/{template.service_catalogue_id}?error=template_file_missing', status_code=303) + return FileResponse(path, filename=template.original_filename, media_type=template.content_type or 'application/octet-stream') + finally: + db.close() + + +@router.get('/templates/{catalogue_id}/tasks/{task_id}/edit') +def firm_task_template_edit_page(request: Request, catalogue_id: int, task_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) + + selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not selection or not selection.is_enabled: + return RedirectResponse(url='/services', status_code=303) + + task = get_firm_task_template( + db, + tenant_id=tenant_id, + catalogue_id=catalogue_id, + task_id=task_id, + ) + if not task: + return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) + + return _render( + request, + 'modules/services/templates/services/firm_task_form.html', + db, + user, + title=f'Edit Firm Task - {catalogue.service_name}', + service=catalogue, + task=task, + ) + finally: + db.close() + + +@router.post('/templates/{catalogue_id}/tasks/{task_id}/edit') +def firm_task_template_edit_submit( + request: Request, + catalogue_id: int, + task_id: int, + task_name: str = Form(...), + description: str = Form(''), + default_role_name: str = Form(''), + sequence_no: int = Form(1), + is_mandatory: str | None = Form(None), + requires_review: 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: + 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) + + task = get_firm_task_template( + db, + tenant_id=tenant_id, + catalogue_id=catalogue_id, + task_id=task_id, + ) + if not task: + return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) + + task.task_name = task_name.strip() + task.description = description.strip() or None + task.default_role_name = default_role_name.strip() or None + task.sequence_no = sequence_no + task.is_mandatory = is_mandatory is not None + task.requires_review = requires_review is not None + task.is_aqmm_task = is_aqmm_task is not None + task.aqmm_mandatory = aqmm_mandatory is not None + task.aqmm_evidence_required = aqmm_evidence_required is not None + task.aqmm_manager_review_required = aqmm_manager_review_required is not None + task.aqmm_partner_review_required = aqmm_partner_review_required is not None + task.aqmm_review_partner_required = aqmm_review_partner_required is not None + task.aqmm_blocks_final_release = aqmm_blocks_final_release is not None + task.aqmm_reference = aqmm_reference.strip() or None + task.is_active = is_active is not None + task.updated_by_user_id = user.id + + db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/defaults/{task_id}/edit') +def default_task_template_edit_page(request: Request, catalogue_id: int, task_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + + try: + require_permission(db, user, 'service_tasks.edit') + except Exception: + return _redirect_denied() + + 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) + + task = get_default_task_template( + db, + catalogue_id=catalogue_id, + task_id=task_id, + ) + if not task: + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303) + + return _render( + request, + 'modules/services/templates/services/default_task_form.html', + db, + user, + title=f'Edit Default Task - {catalogue.service_name}', + service=catalogue, + task=task, + ) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/defaults/{task_id}/edit') +def default_task_template_edit_submit( + request: Request, + catalogue_id: int, + task_id: int, + task_name: str = Form(...), + description: str = Form(''), + default_role_name: str = Form(''), + sequence_no: int = Form(1), + is_mandatory: str | None = Form(None), + requires_review: 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: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + + try: + require_permission(db, user, 'service_tasks.edit') + except Exception: + return _redirect_denied() + + if not _is_system_admin(db, user): + return _redirect_denied() + + task = get_default_task_template( + db, + catalogue_id=catalogue_id, + task_id=task_id, + ) + if not task: + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303) + + task.task_name = task_name.strip() + task.description = description.strip() or None + task.default_role_name = default_role_name.strip() or None + task.sequence_no = sequence_no + task.is_mandatory = is_mandatory is not None + task.requires_review = requires_review is not None + task.is_aqmm_task = is_aqmm_task is not None + task.aqmm_mandatory = aqmm_mandatory is not None + task.aqmm_evidence_required = aqmm_evidence_required is not None + task.aqmm_manager_review_required = aqmm_manager_review_required is not None + task.aqmm_partner_review_required = aqmm_partner_review_required is not None + task.aqmm_review_partner_required = aqmm_review_partner_required is not None + task.aqmm_blocks_final_release = aqmm_blocks_final_release is not None + task.aqmm_reference = aqmm_reference.strip() or None + task.is_active = is_active is not None + + db.commit() + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303) + finally: + db.close() + +# ----------------------------------------------------------------------------- +# S4.5 Bulk import and bulk service assignment routes +# Kept inside existing Services module intentionally; no separate router required. +# ----------------------------------------------------------------------------- + +def _locked_partner_id(db, user) -> int | None: + return int(user.id) if _has_perm(db, user, "clients.view.own_only") else None + + +def _bulk_template_response(template_type: str, filename: str): + data = build_bulk_import_template(template_type) + return StreamingResponse( + iter([data]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/bulk-imports") +def services_bulk_import_home(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + can_client_assignment = _has_perm(db, user, "clients.edit") + can_firm_tasks = _has_perm(db, user, "service_tasks.create") + can_system_import = _is_system_admin(db, user) + can_due_date_extensions = _can_manage_firm_services(db, user) or can_system_import + if not (can_client_assignment or can_firm_tasks or can_system_import or can_due_date_extensions): + return _redirect_denied() + return _render( + request, + "modules/services/templates/services/bulk_imports/index.html", + db, + user, + title="Services Bulk Import", + can_client_assignment=can_client_assignment, + can_firm_tasks=can_firm_tasks, + can_system_import=can_system_import, + can_due_date_extensions=can_due_date_extensions, + ) + finally: + db.close() + + +@router.get("/bulk-imports/templates/engagement-assignments.xlsx") +def download_client_service_assignment_template(request: Request): + return _bulk_template_response("client_service_assignments", "client_service_assignments_template.xlsx") + + +@router.get("/bulk-imports/templates/service-master.xlsx") +def download_service_master_template(request: Request): + return _bulk_template_response("service_master", "service_master_template.xlsx") + + +@router.get("/bulk-imports/templates/system-default-tasks.xlsx") +def download_system_default_tasks_template(request: Request): + return _bulk_template_response("system_default_tasks", "system_default_tasks_template.xlsx") + + +@router.get("/bulk-imports/templates/due-date-extensions.xlsx") +def download_due_date_extensions_template(request: Request): + return _bulk_template_response("due_date_extensions", "due_date_extensions_template.xlsx") + + +@router.get("/bulk-imports/templates/firm-task-templates.xlsx") +def download_firm_task_templates_template(request: Request): + return _bulk_template_response("firm_task_templates", "firm_task_templates_template.xlsx") + + +@router.post("/bulk-imports/engagement-assignments") +def upload_client_service_assignments( + request: Request, + file: UploadFile = File(...), + update_existing: str | None = Form(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) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + result = import_client_service_assignments( + db, + current_user=user, + tenant_id=_active_tenant_id(request, user), + locked_partner_id=_locked_partner_id(db, user), + file_bytes=file.file.read(), + update_existing=update_existing is not None, + ) + return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Engagement Assignment Import Result", result=result, back_url="/services/bulk-imports") + finally: + db.close() + + +@router.post("/bulk-imports/service-master") +def upload_service_master(request: Request, file: UploadFile = File(...), update_existing: str | None = Form(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) + if not _is_system_admin(db, user): + return _redirect_denied() + try: + require_permission(db, user, "services.create") + except Exception: + return _redirect_denied() + result = import_service_master( + db, + current_user=user, + tenant_id=_active_tenant_id(request, user), + file_bytes=file.file.read(), + update_existing=update_existing is not None, + ) + return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Service Master Import Result", result=result, back_url="/services/bulk-imports") + finally: + db.close() + + +@router.post("/bulk-imports/system-default-tasks") +def upload_system_default_tasks(request: Request, file: UploadFile = File(...), update_existing: str | None = Form(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) + if not _is_system_admin(db, user): + return _redirect_denied() + try: + require_permission(db, user, "service_tasks.create") + except Exception: + return _redirect_denied() + result = import_system_default_tasks(db, current_user=user, file_bytes=file.file.read(), update_existing=update_existing is not None) + return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="System Default Tasks Import Result", result=result, back_url="/services/bulk-imports") + finally: + db.close() + + +@router.post("/bulk-imports/due-date-extensions") +def upload_due_date_extensions(request: Request, file: UploadFile = File(...), update_existing: str | None = Form(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) + if not (_can_manage_firm_services(db, user) or _is_system_admin(db, user)): + return _redirect_denied() + result = import_due_date_extensions( + db, + current_user=user, + tenant_id=_active_tenant_id(request, user), + file_bytes=file.file.read(), + update_existing=update_existing is not None, + ) + return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Due Date Extension Import Result", result=result, back_url="/services/bulk-imports") + finally: + db.close() + + +@router.post("/bulk-imports/firm-task-templates") +def upload_firm_task_templates(request: Request, file: UploadFile = File(...), update_existing: str | None = Form(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) + try: + require_permission(db, user, "service_tasks.create") + except Exception: + return _redirect_denied() + result = import_firm_task_templates(db, current_user=user, tenant_id=_active_tenant_id(request, user), file_bytes=file.file.read(), update_existing=update_existing is not None) + return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Firm Task Templates Import Result", result=result, back_url="/services/bulk-imports") + finally: + db.close() diff --git a/app/modules/services/work_tracker_ui.py b/app/modules/services/work_tracker_ui.py index 4c652a7..43ceb62 100644 --- a/app/modules/services/work_tracker_ui.py +++ b/app/modules/services/work_tracker_ui.py @@ -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()