Auto-sync system default tasks to firms and open engagements

This commit is contained in:
A R R R Associates
2026-09-19 12:47:53 +05:30
parent f6776cc96a
commit 2d6043962b
9 changed files with 857 additions and 313 deletions
@@ -0,0 +1,80 @@
"""System default task inheritance and firm update review.
Revision ID: 20260919_task_inheritance
Revises: 20260918_accounting_mirror_ver
"""
from alembic import op
import sqlalchemy as sa
revision = "20260919_task_inheritance"
down_revision = "20260918_accounting_mirror_ver"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {c["name"] for c in inspector.get_columns("firm_service_task_templates")}
fks = {fk.get("name") for fk in inspector.get_foreign_keys("firm_service_task_templates")}
# batch_alter_table keeps this migration compatible with both production
# PostgreSQL and the project's supported SQLite development database.
with op.batch_alter_table("firm_service_task_templates") as batch:
if "source_system_task_id" not in columns:
batch.add_column(sa.Column("source_system_task_id", sa.Integer(), nullable=True))
if "is_customized" not in columns:
batch.add_column(sa.Column("is_customized", sa.Boolean(), nullable=False, server_default=sa.false()))
if "last_synced_system_hash" not in columns:
batch.add_column(sa.Column("last_synced_system_hash", sa.String(length=64), nullable=True))
if "last_reviewed_system_hash" not in columns:
batch.add_column(sa.Column("last_reviewed_system_hash", sa.String(length=64), nullable=True))
if "system_update_available" not in columns:
batch.add_column(sa.Column("system_update_available", sa.Boolean(), nullable=False, server_default=sa.false()))
if "system_update_detected_at_utc" not in columns:
batch.add_column(sa.Column("system_update_detected_at_utc", sa.DateTime(timezone=True), nullable=True))
if "fk_firm_task_source_system_task" not in fks:
batch.create_foreign_key(
"fk_firm_task_source_system_task",
"service_default_task_templates",
["source_system_task_id"],
["id"],
ondelete="SET NULL",
)
inspector = sa.inspect(bind)
indexes = {i.get("name") for i in inspector.get_indexes("firm_service_task_templates")}
if "ix_firm_service_task_templates_source_system_task_id" not in indexes:
op.create_index("ix_firm_service_task_templates_source_system_task_id", "firm_service_task_templates", ["source_system_task_id"], unique=False)
if "ix_firm_service_task_templates_is_customized" not in indexes:
op.create_index("ix_firm_service_task_templates_is_customized", "firm_service_task_templates", ["is_customized"], unique=False)
if "ix_firm_service_task_templates_system_update_available" not in indexes:
op.create_index("ix_firm_service_task_templates_system_update_available", "firm_service_task_templates", ["system_update_available"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
indexes = {i.get("name") for i in inspector.get_indexes("firm_service_task_templates")}
if "ix_firm_service_task_templates_system_update_available" in indexes:
op.drop_index("ix_firm_service_task_templates_system_update_available", table_name="firm_service_task_templates")
if "ix_firm_service_task_templates_is_customized" in indexes:
op.drop_index("ix_firm_service_task_templates_is_customized", table_name="firm_service_task_templates")
if "ix_firm_service_task_templates_source_system_task_id" in indexes:
op.drop_index("ix_firm_service_task_templates_source_system_task_id", table_name="firm_service_task_templates")
columns = {c["name"] for c in sa.inspect(bind).get_columns("firm_service_task_templates")}
fks = {fk.get("name") for fk in sa.inspect(bind).get_foreign_keys("firm_service_task_templates")}
with op.batch_alter_table("firm_service_task_templates") as batch:
if "fk_firm_task_source_system_task" in fks:
batch.drop_constraint("fk_firm_task_source_system_task", type_="foreignkey")
for name in (
"system_update_detected_at_utc",
"system_update_available",
"last_reviewed_system_hash",
"last_synced_system_hash",
"is_customized",
"source_system_task_id",
):
if name in columns:
batch.drop_column(name)
+37 -1
View File
@@ -1066,6 +1066,7 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
return {"created": 0, "updated": 0, "skipped": 0, "errors": [{"row": 1, "message": f"Missing columns: {', '.join(missing)}"}]} return {"created": 0, "updated": 0, "skipped": 0, "errors": [{"row": 1, "message": f"Missing columns: {', '.join(missing)}"}]}
created = updated = skipped = 0 created = updated = skipped = 0
errors: list[dict] = [] errors: list[dict] = []
touched_catalogue_ids: set[int] = set()
for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
if not any(v not in (None, "") for v in row): if not any(v not in (None, "") for v in row):
continue continue
@@ -1078,6 +1079,7 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
catalogue = find_service(db, service_code=service_code) catalogue = find_service(db, service_code=service_code)
if not catalogue: if not catalogue:
raise ValueError("Service code not found in service catalogue.") raise ValueError("Service code not found in service catalogue.")
touched_catalogue_ids.add(int(catalogue.id))
if not sequence_no or not task_name: if not sequence_no or not task_name:
raise ValueError("sequence_no and task_name are required.") raise ValueError("sequence_no and task_name are required.")
task = db.execute( task = db.execute(
@@ -1128,11 +1130,42 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
task.is_active = _bool(_cell(row, headers, "is_active"), True) task.is_active = _bool(_cell(row, headers, "is_active"), True)
except Exception as exc: except Exception as exc:
errors.append({"row": row_no, "message": str(exc)}) errors.append({"row": row_no, "message": str(exc)})
rollout_summary = {
"firms_processed": 0,
"firms_changed": 0,
"custom_updates_available": 0,
"engagement_created": 0,
"engagement_updated_pending": 0,
"engagement_deactivated_pending": 0,
}
if not errors: if not errors:
# Apply the same transaction to every enabled firm and safely refresh only
# unstarted tasks in open/unlocked engagements. Customized firm tasks are
# never overwritten; they receive an update-available decision instead.
from app.modules.services.default_task_sync import sync_system_defaults_to_all_firms
for catalogue_id in sorted(touched_catalogue_ids):
rollout = sync_system_defaults_to_all_firms(
db,
service_catalogue_id=catalogue_id,
updated_by_user_id=current_user.id,
sync_open_engagements=True,
)
rollout_summary["firms_processed"] += rollout.firms_processed
rollout_summary["firms_changed"] += rollout.firms_changed
rollout_summary["custom_updates_available"] += rollout.custom_updates_available
rollout_summary["engagement_created"] += rollout.engagement_created
rollout_summary["engagement_updated_pending"] += rollout.engagement_updated_pending
rollout_summary["engagement_deactivated_pending"] += rollout.engagement_deactivated_pending
db.commit() db.commit()
else: else:
db.rollback() db.rollback()
return {"created": created if not errors else 0, "updated": updated if not errors else 0, "skipped": skipped, "errors": errors} return {
"created": created if not errors else 0,
"updated": updated if not errors else 0,
"skipped": skipped,
"errors": errors,
**(rollout_summary if not errors else {}),
}
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: 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:
@@ -1210,6 +1243,9 @@ def import_firm_task_templates(db: Session, *, current_user, tenant_id: int, fil
task.aqmm_blocks_final_release = _bool(_cell(row, headers, "aqmm_blocks_final_release"), False) task.aqmm_blocks_final_release = _bool(_cell(row, headers, "aqmm_blocks_final_release"), False)
task.aqmm_reference = _clean(_cell(row, headers, "aqmm_reference")) or None task.aqmm_reference = _clean(_cell(row, headers, "aqmm_reference")) or None
task.is_active = _bool(_cell(row, headers, "is_active"), True) task.is_active = _bool(_cell(row, headers, "is_active"), True)
# A firm-level workbook import is an explicit firm customization. Linked
# system defaults remain available for later Firm Admin upgrade review.
task.is_customized = True
task.updated_by_user_id = current_user.id task.updated_by_user_id = current_user.id
except Exception as exc: except Exception as exc:
errors.append({"row": row_no, "message": str(exc)}) errors.append({"row": row_no, "message": str(exc)})
+488 -210
View File
@@ -1,129 +1,285 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from dataclasses import dataclass, field
from dataclasses import dataclass from datetime import datetime, timezone
import hashlib
import json
import re import re
from typing import Any from typing import Any
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.inspection import inspect as sa_inspect
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.modules.services.models import ( from app.modules.services.models import (
ClientServiceTaskInstance, ClientServiceTaskInstance,
FirmServiceSelection,
FirmServiceTaskTemplate, FirmServiceTaskTemplate,
ServiceDefaultTaskTemplate, ServiceDefaultTaskTemplate,
ServiceTaskCategory,
) )
# Fields that define the centrally maintained system-default task snapshot.
# task_category_id is deliberately excluded because system and firm category
# masters use different scoped rows; task_category text is the portable value.
SYSTEM_TASK_FIELDS: tuple[str, ...] = (
"task_name",
"description",
"sequence_no",
"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",
)
_SPACE_RE = re.compile(r"\s+")
@dataclass @dataclass
class FirmDefaultTaskSyncResult: class FirmDefaultTaskSyncResult:
created: int = 0 created: int = 0
updated: int = 0 updated: int = 0
duplicates_disabled: int = 0 duplicates_disabled: int = 0
unchanged: int = 0 unchanged: int = 0
custom_updates_available: int = 0
linked_legacy: int = 0
engagement_created: int = 0
engagement_updated_pending: int = 0
engagement_deactivated_pending: int = 0
engagement_preserved_history: int = 0
@property @property
def active_total_change(self) -> int: def active_total_change(self) -> int:
return self.created - self.duplicates_disabled return self.created - self.duplicates_disabled
_SPACE_RE = re.compile(r"\s+") @dataclass
class SystemDefaultRolloutResult:
firms_processed: int = 0
firms_changed: int = 0
created: int = 0
updated: int = 0
unchanged: int = 0
custom_updates_available: int = 0
duplicates_disabled: int = 0
engagement_created: int = 0
engagement_updated_pending: int = 0
engagement_deactivated_pending: int = 0
engagement_preserved_history: int = 0
tenant_results: dict[int, FirmDefaultTaskSyncResult] = field(default_factory=dict)
def _normalise_name(value: str | None) -> str: def _normalise_name(value: str | None) -> str:
return _SPACE_RE.sub(" ", (value or "").strip()).casefold() return _SPACE_RE.sub(" ", (value or "").strip()).casefold()
def _column_names(model: type[Any]) -> set[str]: def _portable_value(value: Any) -> Any:
return {column.key for column in sa_inspect(model).mapper.column_attrs} if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def _snapshot_dict(row: Any) -> dict[str, Any]:
return {name: _portable_value(getattr(row, name, None)) for name in SYSTEM_TASK_FIELDS}
def system_task_hash(source: ServiceDefaultTaskTemplate) -> str:
payload = json.dumps(_snapshot_dict(source), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def firm_task_hash(target: FirmServiceTaskTemplate) -> str:
payload = json.dumps(_snapshot_dict(target), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def system_task_diff(source: ServiceDefaultTaskTemplate, target: FirmServiceTaskTemplate) -> list[dict[str, Any]]:
labels = {
"task_name": "Task name",
"description": "Description",
"sequence_no": "Sequence",
"default_role_name": "Default role",
"eligible_role_names": "Eligible roles",
"is_mandatory": "Mandatory",
"requires_review": "Review required",
"normal_review_role": "Normal reviewer",
"task_category": "Task category",
"response_required": "Response required",
"response_type": "Response type",
"evidence_required": "Evidence required",
"remarks_required_if_no": "Remarks required if No",
"task_tool_code": "Task tool",
"is_aqmm_task": "AQMM task",
"aqmm_mandatory": "AQMM mandatory",
"aqmm_evidence_required": "AQMM evidence",
"aqmm_manager_review_required": "AQMM manager review",
"aqmm_partner_review_required": "AQMM partner review",
"aqmm_review_partner_required": "AQMM review partner",
"aqmm_blocks_final_release": "Blocks final release",
"aqmm_reference": "AQMM reference",
"is_active": "Active",
}
changes: list[dict[str, Any]] = []
for name in SYSTEM_TASK_FIELDS:
old = getattr(target, name, None)
new = getattr(source, name, None)
if old != new:
changes.append({"field": name, "label": labels.get(name, name), "firm": old, "system": new})
return changes
def mark_firm_task_customized(task: FirmServiceTaskTemplate) -> None:
"""Mark an explicitly edited firm task as protected from automatic overwrite."""
task.is_customized = True
# Do not clear an already pending system update. If there is no pending update,
# the next system revision/hash change will create one automatically.
def _ensure_firm_category(
db: Session,
*,
source: ServiceDefaultTaskTemplate,
tenant_id: int,
user_id: int | None,
) -> ServiceTaskCategory | None:
name = (getattr(source, "task_category", None) or "").strip()
if not name:
return None
system_category = None
source_category_id = getattr(source, "task_category_id", None)
if source_category_id:
system_category = db.get(ServiceTaskCategory, int(source_category_id))
code = ((getattr(system_category, "code", None) or "").strip().upper() if system_category else "")
if code:
existing = db.execute(
select(ServiceTaskCategory).where(
ServiceTaskCategory.tenant_id == tenant_id,
ServiceTaskCategory.service_catalogue_id == source.service_catalogue_id,
ServiceTaskCategory.code == code,
)
).scalar_one_or_none()
else:
existing = db.execute(
select(ServiceTaskCategory).where(
ServiceTaskCategory.tenant_id == tenant_id,
ServiceTaskCategory.service_catalogue_id == source.service_catalogue_id,
func.lower(ServiceTaskCategory.name) == name.lower(),
)
).scalar_one_or_none()
if existing:
# Central category rename/status/order should remain aligned for inherited use.
# Firm category rows are shared by firm templates, so update only metadata that
# does not destroy task history.
if system_category:
existing.name = system_category.name
existing.sort_order = system_category.sort_order
existing.is_active = system_category.is_active
if user_id is not None:
existing.updated_by_user_id = user_id
return existing
if not code:
# Match the application's existing category-code convention sufficiently for
# automatic inherited categories without importing services.py (avoids cycles).
code = re.sub(r"[^A-Z0-9]+", "_", name.upper()).strip("_")[:50] or "CATEGORY"
base = code
suffix = 2
while db.execute(
select(ServiceTaskCategory.id).where(
ServiceTaskCategory.tenant_id == tenant_id,
ServiceTaskCategory.service_catalogue_id == source.service_catalogue_id,
ServiceTaskCategory.code == code,
)
).first():
code = f"{base[:45]}_{suffix}"
suffix += 1
row = ServiceTaskCategory(
tenant_id=tenant_id,
service_catalogue_id=source.service_catalogue_id,
code=code,
name=(system_category.name if system_category else name),
sort_order=(system_category.sort_order if system_category else 100),
is_active=(system_category.is_active if system_category else True),
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
db.add(row)
db.flush()
return row
def _copy_default_columns( def _copy_default_columns(
db: Session,
source: ServiceDefaultTaskTemplate, source: ServiceDefaultTaskTemplate,
target: FirmServiceTaskTemplate, target: FirmServiceTaskTemplate,
*,
tenant_id: int,
user_id: int | None,
allow_sequence_change: bool = True,
) -> bool: ) -> bool:
"""Copy only columns that exist on both system and firm task models.
Firm-only identity/audit fields are deliberately excluded. Related firm
requirement/template-file records are untouched.
"""
source_cols = _column_names(ServiceDefaultTaskTemplate)
target_cols = _column_names(FirmServiceTaskTemplate)
excluded = {
"id",
"tenant_id",
"service_catalogue_id",
"created_at",
"created_at_utc",
"created_by_user_id",
"updated_at",
"updated_at_utc",
"updated_by_user_id",
}
changed = False changed = False
for name in sorted((source_cols & target_cols) - excluded): for name in SYSTEM_TASK_FIELDS:
if name == "sequence_no" and not allow_sequence_change:
continue
new_value = getattr(source, name, None) new_value = getattr(source, name, None)
if getattr(target, name, None) != new_value: if getattr(target, name, None) != new_value:
setattr(target, name, new_value) setattr(target, name, new_value)
changed = True changed = True
# IMPORTANT: category = _ensure_firm_category(db, source=source, tenant_id=tenant_id, user_id=user_id)
# is_active is copied from ServiceDefaultTaskTemplate above because it is category_id = category.id if category else None
# a shared mapped column. Do NOT force FirmServiceTaskTemplate back to True. category_name = category.name if category else None
# if getattr(target, "task_category_id", None) != category_id:
# This makes System Default activation state authoritative during sync: target.task_category_id = category_id
# System Default disabled -> Firm Task disabled changed = True
# System Default enabled -> Firm Task enabled if getattr(target, "task_category", None) != category_name:
# target.task_category = category_name
# Firm-only/custom tasks remain untouched because this function is called changed = True
# only for tasks matched to current System Defaults.
return changed return changed
def _reference_counts( def _reference_counts(db: Session, *, firm_task_ids: list[int]) -> dict[int, int]:
db: Session,
*,
firm_task_ids: list[int],
) -> dict[int, int]:
if not firm_task_ids: if not firm_task_ids:
return {} return {}
rows = db.execute( rows = db.execute(
select( select(ClientServiceTaskInstance.firm_task_template_id, func.count(ClientServiceTaskInstance.id))
ClientServiceTaskInstance.firm_task_template_id,
func.count(ClientServiceTaskInstance.id),
)
.where(ClientServiceTaskInstance.firm_task_template_id.in_(firm_task_ids)) .where(ClientServiceTaskInstance.firm_task_template_id.in_(firm_task_ids))
.group_by(ClientServiceTaskInstance.firm_task_template_id) .group_by(ClientServiceTaskInstance.firm_task_template_id)
).all() ).all()
return {int(template_id): int(count) for template_id, count in rows if template_id is not None}
return {
int(template_id): int(count)
for template_id, count in rows
if template_id is not None
}
def _choose_canonical( def _choose_canonical(candidates: list[FirmServiceTaskTemplate], *, reference_counts: dict[int, int]) -> FirmServiceTaskTemplate:
candidates: list[FirmServiceTaskTemplate], return sorted(candidates, key=lambda row: (-reference_counts.get(int(row.id), 0), int(row.id)))[0]
*,
reference_counts: dict[int, int],
) -> FirmServiceTaskTemplate:
"""Prefer the row already used by the most engagement task instances.
This avoids breaking historical links. If usage is equal, keep the oldest
row (smallest id). def _next_free_sequence(used: set[int], preferred: int) -> int:
""" if preferred > 0 and preferred not in used:
return sorted( return preferred
candidates, candidate = max(used or {0}) + 1
key=lambda row: ( while candidate in used:
-reference_counts.get(int(row.id), 0), candidate += 1
int(row.id), return candidate
),
)[0]
def sync_firm_tasks_from_system_defaults( def sync_firm_tasks_from_system_defaults(
@@ -132,183 +288,305 @@ def sync_firm_tasks_from_system_defaults(
tenant_id: int, tenant_id: int,
service_catalogue_id: int, service_catalogue_id: int,
updated_by_user_id: int | None = None, updated_by_user_id: int | None = None,
sync_open_engagements: bool = False,
) -> FirmDefaultTaskSyncResult: ) -> FirmDefaultTaskSyncResult:
"""Idempotently synchronise system default tasks into one firm's template set. """Synchronise one firm's task templates from centrally maintained defaults.
Behaviour: Inherited tasks are updated automatically. Explicitly customized linked tasks are
* existing firm task with the same normalised task name -> UPDATE, never INSERT; never overwritten; a system_update_available flag is raised for Firm Admin review.
* duplicate firm rows with the same task name -> retain one canonical active row, Firm-only tasks remain untouched. Legacy rows are linked by exact normalized name.
mark the additional template rows inactive;
* missing system default -> create one firm task;
* unique firm-only/custom tasks are preserved;
* existing ClientServiceTaskInstance rows are never deleted or reassigned.
The function may safely be called repeatedly.
""" """
defaults = db.execute( defaults = db.execute(
select(ServiceDefaultTaskTemplate) select(ServiceDefaultTaskTemplate)
.where( .where(ServiceDefaultTaskTemplate.service_catalogue_id == service_catalogue_id)
ServiceDefaultTaskTemplate.service_catalogue_id == service_catalogue_id, .order_by(ServiceDefaultTaskTemplate.sequence_no.asc(), ServiceDefaultTaskTemplate.id.asc())
)
.order_by(
ServiceDefaultTaskTemplate.sequence_no.asc(),
ServiceDefaultTaskTemplate.id.asc(),
)
).scalars().all() ).scalars().all()
firm_rows = db.execute( firm_rows = db.execute(
select(FirmServiceTaskTemplate) select(FirmServiceTaskTemplate)
.where( .where(
FirmServiceTaskTemplate.tenant_id == tenant_id, FirmServiceTaskTemplate.tenant_id == tenant_id,
FirmServiceTaskTemplate.service_catalogue_id == service_catalogue_id, FirmServiceTaskTemplate.service_catalogue_id == service_catalogue_id,
) )
.order_by( .order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc())
FirmServiceTaskTemplate.sequence_no.asc(),
FirmServiceTaskTemplate.id.asc(),
)
).scalars().all() ).scalars().all()
result = FirmDefaultTaskSyncResult() result = FirmDefaultTaskSyncResult()
reference_counts = _reference_counts(db, firm_task_ids=[int(r.id) for r in firm_rows if r.id])
by_name: dict[str, list[FirmServiceTaskTemplate]] = defaultdict(list) by_source: dict[int, list[FirmServiceTaskTemplate]] = {}
legacy_by_name: dict[str, list[FirmServiceTaskTemplate]] = {}
for row in firm_rows: for row in firm_rows:
key = _normalise_name(getattr(row, "task_name", None)) if row.source_system_task_id:
by_source.setdefault(int(row.source_system_task_id), []).append(row)
else:
key = _normalise_name(row.task_name)
if key: if key:
by_name[key].append(row) legacy_by_name.setdefault(key, []).append(row)
reference_counts = _reference_counts( used_sequences = {int(r.sequence_no) for r in firm_rows if r.sequence_no is not None}
db,
firm_task_ids=[int(row.id) for row in firm_rows if getattr(row, "id", None)],
)
# ------------------------------------------------------------------ for source in defaults:
# IMPORTANT: two-phase sequence re-numbering latest_hash = system_task_hash(source)
# candidates = by_source.get(int(source.id), [])
# The database enforces a UNIQUE constraint on: linked_from_legacy = False
# (tenant_id, service_catalogue_id, sequence_no) if not candidates:
# legacy_candidates = legacy_by_name.get(_normalise_name(source.task_name), [])
# Existing duplicate imports may contain several generations of the same if not legacy_candidates:
# task at different sequence numbers. During a sync, moving a canonical # One-time migration fallback for a firm that renamed a previously
# row directly onto the latest system-default sequence can therefore # copied default before provenance columns existed. Sequence is used
# collide with another historical row that still owns that sequence. # only when there is exactly one unlinked candidate at that position.
# legacy_candidates = [
# Marking that historical row inactive does NOT release the unique key. row for row in firm_rows
# Park every existing firm template on a guaranteed-unique temporary if row.source_system_task_id is None
# sequence first, flush, and only then apply final/default sequences. and int(row.sequence_no or 0) == int(source.sequence_no or 0)
# ------------------------------------------------------------------ ]
max_existing_sequence = max( if len(legacy_candidates) != 1:
[int(getattr(row, "sequence_no", 0) or 0) for row in firm_rows] + [0] legacy_candidates = []
) if legacy_candidates:
max_default_sequence = max( target = _choose_canonical(legacy_candidates, reference_counts=reference_counts)
[int(getattr(row, "sequence_no", 0) or 0) for row in defaults] + [0] candidates = [target]
) target.source_system_task_id = source.id
parking_base = max(max_existing_sequence, max_default_sequence, 0) + 100000 linked_from_legacy = True
result.linked_legacy += 1
by_source.setdefault(int(source.id), []).append(target)
# Remove from future legacy matching.
key = _normalise_name(target.task_name)
if key in legacy_by_name:
legacy_by_name[key] = [r for r in legacy_by_name[key] if r.id != target.id]
for offset, row in enumerate(sorted(firm_rows, key=lambda item: int(item.id)), start=1): if candidates:
row.sequence_no = parking_base + offset target = _choose_canonical(candidates, reference_counts=reference_counts)
# This flush is intentional and must happen before any canonical row is
# assigned a system-default sequence number.
db.flush()
# First clean pre-existing duplicate groups, regardless of whether the task
# remains in the current system defaults. A unique firm-only task is untouched.
for candidates in by_name.values():
if len(candidates) <= 1:
continue
canonical = _choose_canonical(
candidates,
reference_counts=reference_counts,
)
for duplicate in candidates: for duplicate in candidates:
if duplicate.id == canonical.id: if duplicate.id == target.id:
continue continue
if duplicate.is_active:
# Duplicate rows are retained for historical FK safety but remain
# on their unique parked sequence number. The UNIQUE sequence
# constraint therefore remains satisfied even after deactivation.
if getattr(duplicate, "is_active", True):
duplicate.is_active = False duplicate.is_active = False
if ( if updated_by_user_id is not None:
updated_by_user_id is not None
and hasattr(duplicate, "updated_by_user_id")
):
duplicate.updated_by_user_id = updated_by_user_id duplicate.updated_by_user_id = updated_by_user_id
result.duplicates_disabled += 1 result.duplicates_disabled += 1
# Upsert current system defaults. # For legacy rows with no inheritance history, infer whether they were
for default in defaults: # already firm-customized by comparing the full portable snapshot.
key = _normalise_name(getattr(default, "task_name", None)) if linked_from_legacy and target.last_synced_system_hash is None:
candidates = by_name.get(key, []) if key else [] target.is_customized = firm_task_hash(target) != latest_hash
if not target.is_customized:
target.last_synced_system_hash = latest_hash
target.last_reviewed_system_hash = latest_hash
if candidates: # Detect out-of-band edits to a linked inherited row as customization.
target = _choose_canonical(
candidates,
reference_counts=reference_counts,
)
changed = _copy_default_columns(default, target)
if ( if (
updated_by_user_id is not None not target.is_customized
and hasattr(target, "updated_by_user_id") and target.last_synced_system_hash
and firm_task_hash(target) != target.last_synced_system_hash
): ):
target.updated_by_user_id = updated_by_user_id target.is_customized = True
if changed:
result.updated += 1 if target.is_customized:
pending = target.last_reviewed_system_hash != latest_hash
target.system_update_available = pending
target.system_update_detected_at_utc = datetime.now(timezone.utc) if pending else None
if pending:
result.custom_updates_available += 1
else: else:
result.unchanged += 1 result.unchanged += 1
continue continue
# No name match. A unique sequence match is a conservative fallback for a # Inherited task: copy system values. Sequence changes are applied when
# system-default rename while still avoiding arbitrary replacement. # the desired number is free or currently owned by this same row. If a
# Do NOT use the pre-sync sequence number as an identity fallback here. # firm-only/custom row owns it, preserve that row and place this inherited
# All existing rows have deliberately been parked on temporary sequence # task at the next free sequence instead of overwriting customization.
# numbers to satisfy the DB unique constraint. More importantly, desired_seq = int(source.sequence_no or 0)
# sequence number is ordering metadata, not a stable task identity. current_seq = int(target.sequence_no or 0)
# A renamed system-default task without a name match is therefore allow_seq = desired_seq == current_seq or desired_seq not in (used_sequences - {current_seq})
# treated as a new task rather than risking replacement of the wrong if allow_seq:
# firm-specific task. used_sequences.discard(current_seq)
used_sequences.add(desired_seq)
changed = _copy_default_columns(
db,
source,
target,
tenant_id=tenant_id,
user_id=updated_by_user_id,
allow_sequence_change=allow_seq,
)
target.source_system_task_id = source.id
target.last_synced_system_hash = latest_hash
target.last_reviewed_system_hash = latest_hash
target.system_update_available = False
target.system_update_detected_at_utc = None
if updated_by_user_id is not None:
target.updated_by_user_id = updated_by_user_id
result.updated += 1 if changed else 0
result.unchanged += 0 if changed else 1
continue
# Missing task: construct from the intersection of mapped columns. # Missing default: create a new inherited row. Avoid colliding with a
source_cols = _column_names(ServiceDefaultTaskTemplate) # firm-only sequence; identity is source_system_task_id, not sequence number.
target_cols = _column_names(FirmServiceTaskTemplate) preferred = int(source.sequence_no or 0)
excluded = { seq = _next_free_sequence(used_sequences, preferred)
"id", used_sequences.add(seq)
"tenant_id", row = FirmServiceTaskTemplate(
"service_catalogue_id", tenant_id=tenant_id,
"created_at", service_catalogue_id=service_catalogue_id,
"created_at_utc", task_name=source.task_name,
"created_by_user_id", sequence_no=seq,
"updated_at", source_system_task_id=source.id,
"updated_at_utc", is_customized=False,
"updated_by_user_id", last_synced_system_hash=latest_hash,
} last_reviewed_system_hash=latest_hash,
payload = { system_update_available=False,
name: getattr(default, name, None) created_by_user_id=updated_by_user_id,
for name in sorted((source_cols & target_cols) - excluded) updated_by_user_id=updated_by_user_id,
} )
payload["tenant_id"] = tenant_id db.add(row)
payload["service_catalogue_id"] = service_catalogue_id
if "is_active" in target_cols:
payload["is_active"] = True
if (
updated_by_user_id is not None
and "created_by_user_id" in target_cols
):
payload["created_by_user_id"] = updated_by_user_id
if (
updated_by_user_id is not None
and "updated_by_user_id" in target_cols
):
payload["updated_by_user_id"] = updated_by_user_id
new_row = FirmServiceTaskTemplate(**payload)
db.add(new_row)
db.flush() db.flush()
_copy_default_columns(
firm_rows.append(new_row) db,
if key: source,
by_name[key].append(new_row) row,
tenant_id=tenant_id,
user_id=updated_by_user_id,
allow_sequence_change=(seq == preferred),
)
result.created += 1 result.created += 1
firm_rows.append(row)
by_source.setdefault(int(source.id), []).append(row)
db.flush() db.flush()
if sync_open_engagements:
from app.modules.services.execution import sync_open_engagement_tasks_for_service
engagement = sync_open_engagement_tasks_for_service(
db,
tenant_id=tenant_id,
catalogue_id=service_catalogue_id,
user_id=updated_by_user_id or 0,
include_started_open_tasks=False,
safe_system_rollout=True,
)
result.engagement_created = engagement.get("created", 0)
result.engagement_updated_pending = engagement.get("updated_pending", 0)
result.engagement_deactivated_pending = engagement.get("deactivated_pending", 0)
result.engagement_preserved_history = engagement.get("preserved_history", 0)
return result return result
def sync_system_defaults_to_all_firms(
db: Session,
*,
service_catalogue_id: int,
updated_by_user_id: int | None = None,
sync_open_engagements: bool = True,
) -> SystemDefaultRolloutResult:
"""Roll a system-default service checklist to every firm that enabled it."""
selections = db.execute(
select(FirmServiceSelection).where(
FirmServiceSelection.service_catalogue_id == service_catalogue_id,
FirmServiceSelection.is_enabled.is_(True),
)
).scalars().all()
aggregate = SystemDefaultRolloutResult()
for selection in selections:
result = sync_firm_tasks_from_system_defaults(
db,
tenant_id=int(selection.tenant_id),
service_catalogue_id=service_catalogue_id,
updated_by_user_id=updated_by_user_id,
sync_open_engagements=sync_open_engagements,
)
aggregate.firms_processed += 1
if result.created or result.updated or result.duplicates_disabled or result.custom_updates_available:
aggregate.firms_changed += 1
aggregate.created += result.created
aggregate.updated += result.updated
aggregate.unchanged += result.unchanged
aggregate.custom_updates_available += result.custom_updates_available
aggregate.duplicates_disabled += result.duplicates_disabled
aggregate.engagement_created += result.engagement_created
aggregate.engagement_updated_pending += result.engagement_updated_pending
aggregate.engagement_deactivated_pending += result.engagement_deactivated_pending
aggregate.engagement_preserved_history += result.engagement_preserved_history
aggregate.tenant_results[int(selection.tenant_id)] = result
return aggregate
def accept_system_update_for_firm_task(
db: Session,
*,
task: FirmServiceTaskTemplate,
user_id: int,
sync_open_engagements: bool = True,
) -> FirmDefaultTaskSyncResult:
if not task.source_system_task_id:
raise ValueError("This firm task is not linked to a system default.")
source = db.get(ServiceDefaultTaskTemplate, int(task.source_system_task_id))
if source is None:
raise ValueError("The linked system default no longer exists.")
latest_hash = system_task_hash(source)
used = {
int(v)
for v in db.scalars(
select(FirmServiceTaskTemplate.sequence_no).where(
FirmServiceTaskTemplate.tenant_id == task.tenant_id,
FirmServiceTaskTemplate.service_catalogue_id == task.service_catalogue_id,
FirmServiceTaskTemplate.id != task.id,
)
).all()
if v is not None
}
desired = int(source.sequence_no or 0)
allow_seq = desired not in used
changed = _copy_default_columns(
db,
source,
task,
tenant_id=int(task.tenant_id),
user_id=user_id,
allow_sequence_change=allow_seq,
)
task.is_customized = False
task.last_synced_system_hash = latest_hash
task.last_reviewed_system_hash = latest_hash
task.system_update_available = False
task.system_update_detected_at_utc = None
task.updated_by_user_id = user_id
db.flush()
result = FirmDefaultTaskSyncResult(updated=1 if changed else 0, unchanged=0 if changed else 1)
if sync_open_engagements:
from app.modules.services.execution import sync_open_engagement_tasks_for_service
engagement = sync_open_engagement_tasks_for_service(
db,
tenant_id=int(task.tenant_id),
catalogue_id=int(task.service_catalogue_id),
user_id=user_id,
include_started_open_tasks=False,
safe_system_rollout=True,
)
result.engagement_created = engagement.get("created", 0)
result.engagement_updated_pending = engagement.get("updated_pending", 0)
result.engagement_deactivated_pending = engagement.get("deactivated_pending", 0)
result.engagement_preserved_history = engagement.get("preserved_history", 0)
return result
def keep_firm_customization_for_system_revision(
db: Session,
*,
task: FirmServiceTaskTemplate,
user_id: int,
) -> None:
if not task.source_system_task_id:
raise ValueError("This firm task is not linked to a system default.")
source = db.get(ServiceDefaultTaskTemplate, int(task.source_system_task_id))
if source is None:
raise ValueError("The linked system default no longer exists.")
task.is_customized = True
task.last_reviewed_system_hash = system_task_hash(source)
task.system_update_available = False
task.system_update_detected_at_utc = None
task.updated_by_user_id = user_id
db.flush()
+43 -4
View File
@@ -442,16 +442,18 @@ def sync_open_engagement_tasks_for_service(
catalogue_id: int, catalogue_id: int,
user_id: int, user_id: int,
include_started_open_tasks: bool = False, include_started_open_tasks: bool = False,
safe_system_rollout: bool = False,
) -> dict[str, int]: ) -> dict[str, int]:
templates = db.execute( all_templates = db.execute(
select(FirmServiceTaskTemplate) select(FirmServiceTaskTemplate)
.where( .where(
FirmServiceTaskTemplate.tenant_id == tenant_id, FirmServiceTaskTemplate.tenant_id == tenant_id,
FirmServiceTaskTemplate.service_catalogue_id == catalogue_id, FirmServiceTaskTemplate.service_catalogue_id == catalogue_id,
FirmServiceTaskTemplate.is_active.is_(True),
) )
.order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc()) .order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc())
).scalars().all() ).scalars().all()
templates = [row for row in all_templates if bool(row.is_active)]
inactive_templates = [row for row in all_templates if not bool(row.is_active)]
engagements = db.execute( engagements = db.execute(
select(ClientServiceSubscription) select(ClientServiceSubscription)
@@ -470,10 +472,21 @@ def sync_open_engagement_tasks_for_service(
"created": 0, "created": 0,
"updated_pending": 0, "updated_pending": 0,
"updated_started": 0, "updated_started": 0,
"deactivated_pending": 0,
"preserved_history": 0, "preserved_history": 0,
} }
for subscription in engagements: for subscription in engagements:
if (
safe_system_rollout
and quality_required_for_engagement(subscription.engagement_type)
and getattr(subscription, "quality_acceptance_status", None) != QUALITY_APPROVED
):
# Preserve the existing assurance acceptance gate during automatic
# system-default rollout. Tasks will be generated by the normal flow
# once quality acceptance is approved.
continue
existing = db.execute( existing = db.execute(
select(ClientServiceTaskInstance).where( select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.subscription_id == subscription.id, ClientServiceTaskInstance.subscription_id == subscription.id,
@@ -549,6 +562,7 @@ def sync_open_engagement_tasks_for_service(
and task.completed_at_utc is None and task.completed_at_utc is None
and task.submitted_for_review_at_utc is None and task.submitted_for_review_at_utc is None
and (task.rework_status or "none") == "none" and (task.rework_status or "none") == "none"
and not bool(getattr(task, "is_locked", False))
) )
if never_started: if never_started:
@@ -558,6 +572,8 @@ def sync_open_engagement_tasks_for_service(
subscription=subscription, subscription=subscription,
update_assignment=True, update_assignment=True,
) )
if safe_system_rollout:
task.is_active = True
task.updated_by_user_id = user_id task.updated_by_user_id = user_id
result["updated_pending"] += 1 result["updated_pending"] += 1
elif include_started_open_tasks and status in {"in_progress", "blocked"} and task.completed_at_utc is None: elif include_started_open_tasks and status in {"in_progress", "blocked"} and task.completed_at_utc is None:
@@ -571,10 +587,33 @@ def sync_open_engagement_tasks_for_service(
task.updated_by_user_id = user_id task.updated_by_user_id = user_id
result["updated_started"] += 1 result["updated_started"] += 1
else: else:
# Preserve task execution/history, but allow an explicit template sync to if not safe_system_rollout:
# update only the separated normal-review configuration for existing tasks. # Preserve the pre-existing explicit/manual sync behaviour.
_sync_normal_review_snapshot_preserving_history(task, template=template) _sync_normal_review_snapshot_preserving_history(task, template=template)
task.updated_by_user_id = user_id task.updated_by_user_id = user_id
# Automatic system rollout never rewrites a started/completed/reviewed
# task snapshot. Audit/review history remains exactly as performed.
result["preserved_history"] += 1
if safe_system_rollout and inactive_templates:
inactive_ids = {int(row.id) for row in inactive_templates}
for task in existing:
if task.firm_task_template_id is None or int(task.firm_task_template_id) not in inactive_ids:
continue
status = (task.status or "pending").strip().lower()
never_started = (
status == "pending"
and task.started_at_utc is None
and task.completed_at_utc is None
and task.submitted_for_review_at_utc is None
and (task.rework_status or "none") == "none"
and not bool(getattr(task, "is_locked", False))
)
if never_started and bool(getattr(task, "is_active", True)):
task.is_active = False
task.updated_by_user_id = user_id
result["deactivated_pending"] += 1
elif not never_started:
result["preserved_history"] += 1 result["preserved_history"] += 1
return result return result
+12
View File
@@ -242,6 +242,17 @@ class FirmServiceTaskTemplate(CommonBase):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# System-default inheritance metadata. Firm-only tasks keep source_system_task_id=None.
# Linked tasks remain centrally synchronized until the firm explicitly customizes them.
source_system_task_id: Mapped[int | None] = mapped_column(
ForeignKey("service_default_task_templates.id", ondelete="SET NULL"), nullable=True, index=True
)
is_customized: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
last_synced_system_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_reviewed_system_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
system_update_available: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
system_update_detected_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
@@ -256,6 +267,7 @@ class FirmServiceTaskTemplate(CommonBase):
) )
catalogue = relationship("ServiceCatalogue", back_populates="task_templates") catalogue = relationship("ServiceCatalogue", back_populates="task_templates")
source_system_task = relationship("ServiceDefaultTaskTemplate", foreign_keys=[source_system_task_id])
document_requirements = relationship( document_requirements = relationship(
"FirmTaskDocumentRequirement", "FirmTaskDocumentRequirement",
back_populates="task_template", back_populates="task_template",
@@ -183,6 +183,7 @@ def import_task_categories(
created = 0 created = 0
updated = 0 updated = 0
errors: list[dict] = [] errors: list[dict] = []
touched_service_ids: set[int] = set()
expected = normalize_code(expected_service_code) if expected_service_code else "" expected = normalize_code(expected_service_code) if expected_service_code else ""
for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
@@ -205,6 +206,7 @@ def import_task_categories(
).scalar_one_or_none() ).scalar_one_or_none()
if service is None: if service is None:
raise ValueError("Service code not found in Service Catalogue.") raise ValueError("Service code not found in Service Catalogue.")
touched_service_ids.add(int(service.id))
scope_filter = ( scope_filter = (
ServiceTaskCategory.tenant_id.is_(None) ServiceTaskCategory.tenant_id.is_(None)
@@ -275,11 +277,22 @@ def import_task_categories(
if old_name != category_name: if old_name != category_name:
for task in linked: for task in linked:
task.task_category = category_name task.task_category = category_name
if tenant_id is not None:
task.is_customized = True
except Exception as exc: except Exception as exc:
errors.append({"row": row_no, "message": str(exc)}) errors.append({"row": row_no, "message": str(exc)})
if errors: if errors:
db.rollback() db.rollback()
return {"created": 0, "updated": 0, "errors": errors} return {"created": 0, "updated": 0, "errors": errors}
if tenant_id is None and touched_service_ids:
from app.modules.services.default_task_sync import sync_system_defaults_to_all_firms
for service_id in sorted(touched_service_ids):
sync_system_defaults_to_all_firms(
db,
service_catalogue_id=service_id,
updated_by_user_id=current_user.id,
sync_open_engagements=True,
)
db.commit() db.commit()
return {"created": created, "updated": updated, "errors": []} return {"created": created, "updated": updated, "errors": []}
@@ -277,6 +277,7 @@ async def task_category_import_submit(
if template is None or int(template.tenant_id) != tenant_id: if template is None or int(template.tenant_id) != tenant_id:
raise ValueError(f"Row {result.row_no}: task template changed during import. Please retry.") raise ValueError(f"Row {result.row_no}: task template changed during import. Please retry.")
template.task_category = result.task_category template.task_category = result.task_category
template.is_customized = True
if update_existing_tasks: if update_existing_tasks:
existing = db.execute( existing = db.execute(
@@ -25,6 +25,9 @@
{% if request.query_params.get('move_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Task could not be moved.</div>{% endif %} {% if request.query_params.get('move_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Task could not be moved.</div>{% endif %}
{% if request.query_params.get('imported') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Excel import completed: {{ request.query_params.get('created','0') }} created, {{ request.query_params.get('updated','0') }} updated.</div>{% endif %} {% if request.query_params.get('imported') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Excel import completed: {{ request.query_params.get('created','0') }} created, {{ request.query_params.get('updated','0') }} updated.</div>{% endif %}
{% if request.query_params.get('import_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Excel import failed validation. No partial update was committed.</div>{% endif %} {% if request.query_params.get('import_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Excel import failed validation. No partial update was committed.</div>{% endif %}
{% if request.query_params.get('system_upgraded') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">System update applied to the firm task. Safe unstarted tasks in open engagements were refreshed automatically.</div>{% endif %}
{% if request.query_params.get('system_kept') %}<div class="rounded-2xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">Firm customization retained for this system revision. You will be prompted again when the system default changes in a later revision.</div>{% endif %}
{% if request.query_params.get('sync_success') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">System defaults synchronized: {{ request.query_params.get('created','0') }} created, {{ request.query_params.get('updated','0') }} updated, {{ request.query_params.get('custom_updates','0') }} customized task(s) awaiting review.</div>{% endif %}
{% if can_manage_tasks %} {% if can_manage_tasks %}
@@ -46,7 +49,7 @@
<div class="rounded-2xl bg-white p-5 shadow-soft"> <div class="rounded-2xl bg-white p-5 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">System Defaults</div> <div class="text-xs font-semibold uppercase tracking-wide text-slate-500">System Defaults</div>
<div class="mt-2 text-lg font-semibold text-slate-900">{{ default_tasks|length }}</div> <div class="mt-2 text-lg font-semibold text-slate-900">{{ default_tasks|length }}</div>
<p class="mt-1 text-sm text-slate-500">Defaults may be copied and customised for the firm.</p> <p class="mt-1 text-sm text-slate-500">Inherited defaults stay synchronized automatically; customized tasks require Firm Admin approval before a later system update is applied.</p>
</div> </div>
</section> </section>
@@ -60,7 +63,7 @@
{% if default_tasks|length > 0 %} {% if default_tasks|length > 0 %}
<form method="post" action="/services/catalogue/{{ service.id }}/firm-tasks/sync-system-defaults"> <form method="post" action="/services/catalogue/{{ service.id }}/firm-tasks/sync-system-defaults">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50" type="submit">Copy System Defaults</button> <button class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50" type="submit">Sync System Defaults</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@@ -205,6 +208,40 @@
</td> </td>
<td class="px-4 py-3 text-sm text-slate-700"> <td class="px-4 py-3 text-sm text-slate-700">
<div class="font-medium text-slate-900">{{ task.task_name }}</div> <div class="font-medium text-slate-900">{{ task.task_name }}</div>
<div class="mt-1 flex flex-wrap gap-1">
{% if task.source_system_task_id %}
{% if task.is_customized %}<span class="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700">Customized</span>{% else %}<span class="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold text-emerald-700">System inherited</span>{% endif %}
{% else %}
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-600">Firm-only</span>
{% endif %}
{% if task.system_update_available %}<span class="rounded-full bg-violet-100 px-2 py-0.5 text-[10px] font-semibold text-violet-800">System update available</span>{% endif %}
</div>
{% if task.system_update_available %}
{% set changes = system_update_diff_map.get(task.id, []) if system_update_diff_map else [] %}
<div class="mt-2 rounded-xl border border-violet-200 bg-violet-50 p-3 text-xs text-violet-950">
<div class="font-semibold">A newer system default is available. Your firm customization has not been overwritten.</div>
{% if changes %}
<div class="mt-2 space-y-1">
{% for change in changes[:5] %}
<div><span class="font-medium">{{ change.label }}:</span> {{ change.firm if change.firm is not none else '-' }} → {{ change.system if change.system is not none else '-' }}</div>
{% endfor %}
{% if changes|length > 5 %}<div class="text-violet-700">+ {{ changes|length - 5 }} more change(s)</div>{% endif %}
</div>
{% endif %}
{% if can_manage_tasks %}
<div class="mt-3 flex flex-wrap gap-2">
<form method="post" action="/services/templates/{{ service.id }}/tasks/{{ task.id }}/system-update/upgrade" onsubmit="return confirm('Upgrade this customized firm task to the latest system default? Safe unstarted tasks in open engagements will also be refreshed.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-lg bg-violet-700 px-3 py-1.5 text-[11px] font-semibold text-white hover:bg-violet-800">Upgrade to System</button>
</form>
<form method="post" action="/services/templates/{{ service.id }}/tasks/{{ task.id }}/system-update/keep-firm">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-lg border border-violet-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-violet-800 hover:bg-violet-100">Keep Firm Version</button>
</form>
</div>
{% endif %}
</div>
{% endif %}
{% if task.task_category %}<div class="mt-1 text-[11px] font-semibold uppercase tracking-wide text-brand-600">Category: {{ task.task_category }}</div>{% endif %} {% if task.task_category %}<div class="mt-1 text-[11px] font-semibold uppercase tracking-wide text-brand-600">Category: {{ task.task_category }}</div>{% endif %}
{% if task.description %}<div class="mt-1 text-xs text-slate-500">{{ task.description }}</div>{% endif %} {% if task.description %}<div class="mt-1 text-xs text-slate-500">{{ task.description }}</div>{% endif %}
</td> </td>
+127 -79
View File
@@ -27,6 +27,14 @@ from app.modules.services.bulk_imports import (
from app.modules.services.catalogue_export import build_service_catalogue_export 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.default_tasks_export import build_default_tasks_export
from app.modules.services.firm_tasks_export import build_firm_tasks_export from app.modules.services.firm_tasks_export import build_firm_tasks_export
from app.modules.services.default_task_sync import (
accept_system_update_for_firm_task,
keep_firm_customization_for_system_revision,
mark_firm_task_customized,
sync_firm_tasks_from_system_defaults,
sync_system_defaults_to_all_firms,
system_task_diff,
)
from app.modules.services.task_categories_excel import build_task_categories_export, import_task_categories from app.modules.services.task_categories_excel import build_task_categories_export, import_task_categories
from app.modules.services.task_tools import list_task_tools, normalize_task_tool_code 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.task_duplicates import find_duplicate_pairs, merge_default_tasks, merge_firm_tasks
@@ -944,51 +952,21 @@ def _copy_system_defaults_if_firm_tasks_empty(
catalogue_id: int, catalogue_id: int,
user_id: int, user_id: int,
) -> tuple[int, bool]: ) -> tuple[int, bool]:
"""Initialise firm task templates once without overwriting custom firm tasks.""" """Initialise/synchronise inherited firm tasks from the current system defaults."""
existing_tasks = get_firm_task_templates( existing_tasks = get_firm_task_templates(
db, db,
tenant_id=tenant_id, tenant_id=tenant_id,
catalogue_id=catalogue_id, catalogue_id=catalogue_id,
) )
if existing_tasks: had_existing = bool(existing_tasks)
return 0, True result = sync_firm_tasks_from_system_defaults(
db,
defaults = get_default_task_templates(db, catalogue_id=catalogue_id)
copied_count = 0
for default in defaults:
db.add(FirmServiceTaskTemplate(
tenant_id=tenant_id, tenant_id=tenant_id,
service_catalogue_id=catalogue_id, service_catalogue_id=catalogue_id,
task_name=default.task_name,
description=default.description,
sequence_no=default.sequence_no,
default_role_name=default.default_role_name,
eligible_role_names=getattr(default, "eligible_role_names", None) or default.default_role_name,
is_mandatory=default.is_mandatory,
requires_review=default.requires_review,
normal_review_role=getattr(default, "normal_review_role", None),
task_category_id=(firm_category.id if (firm_category := ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=tenant_id, name=getattr(default, "task_category", None), user_id=user_id)) else None),
task_category=(firm_category.name if firm_category else None),
response_required=getattr(default, "response_required", False),
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),
aqmm_manager_review_required=getattr(default, "aqmm_manager_review_required", False),
aqmm_partner_review_required=getattr(default, "aqmm_partner_review_required", False),
aqmm_review_partner_required=getattr(default, "aqmm_review_partner_required", False),
aqmm_blocks_final_release=getattr(default, "aqmm_blocks_final_release", False),
aqmm_reference=getattr(default, "aqmm_reference", None),
is_active=default.is_active,
created_by_user_id=user_id,
updated_by_user_id=user_id, updated_by_user_id=user_id,
)) sync_open_engagements=False,
copied_count += 1 )
return result.created, had_existing
return copied_count, False
@router.post('/catalogue/bulk-selection') @router.post('/catalogue/bulk-selection')
@@ -1239,6 +1217,12 @@ def task_templates_detail(request: Request, catalogue_id: int):
if not selection or not selection.is_enabled: if not selection or not selection.is_enabled:
return RedirectResponse(url='/services', status_code=303) return RedirectResponse(url='/services', status_code=303)
tasks = get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=catalogue_id) tasks = get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
system_update_diff_map = {}
for firm_task in tasks:
if getattr(firm_task, "system_update_available", False) and getattr(firm_task, "source_system_task_id", None):
system_task = db.get(ServiceDefaultTaskTemplate, int(firm_task.source_system_task_id))
if system_task is not None:
system_update_diff_map[firm_task.id] = system_task_diff(system_task, firm_task)
task_requirement_map = { task_requirement_map = {
task.id: list_task_document_requirements(db, tenant_id=tenant_id, firm_task_template_id=task.id) task.id: list_task_document_requirements(db, tenant_id=tenant_id, firm_task_template_id=task.id)
for task in tasks for task in tasks
@@ -1258,6 +1242,7 @@ def task_templates_detail(request: Request, catalogue_id: int):
task_templates=tasks, task_templates=tasks,
task_requirement_map=task_requirement_map, task_requirement_map=task_requirement_map,
task_template_file_map=task_template_file_map, task_template_file_map=task_template_file_map,
system_update_diff_map=system_update_diff_map,
can_manage_tasks=_can_manage_firm_tasks(db, user), can_manage_tasks=_can_manage_firm_tasks(db, user),
default_tasks=get_default_task_templates(db, catalogue_id=catalogue_id), default_tasks=get_default_task_templates(db, catalogue_id=catalogue_id),
is_system_admin=_is_system_admin(db, user), is_system_admin=_is_system_admin(db, user),
@@ -1311,6 +1296,7 @@ def task_template_create_submit(request: Request, catalogue_id: int, task_name:
aqmm_blocks_final_release=aqmm_blocks_final_release is not None, aqmm_blocks_final_release=aqmm_blocks_final_release is not None,
aqmm_reference=aqmm_reference.strip() or None, aqmm_reference=aqmm_reference.strip() or None,
is_active=is_active is not None, is_active=is_active is not None,
is_customized=True,
created_by_user_id=user.id, created_by_user_id=user.id,
updated_by_user_id=user.id, updated_by_user_id=user.id,
) )
@@ -1395,7 +1381,15 @@ def default_template_create_submit(request: Request, catalogue_id: int, task_nam
if not catalogue: if not catalogue:
return RedirectResponse(url='/services/defaults', status_code=303) 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"), 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) 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() db.add(row)
db.flush()
sync_system_defaults_to_all_firms(
db,
service_catalogue_id=catalogue_id,
updated_by_user_id=user.id,
sync_open_engagements=True,
)
db.commit()
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303) return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
finally: finally:
db.close() db.close()
@@ -1415,46 +1409,22 @@ def copy_defaults_to_firm(request: Request, catalogue_id: int, csrf_token: str =
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
if not selection or not selection.is_enabled: if not selection or not selection.is_enabled:
return RedirectResponse(url='/services', status_code=303) 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)} result = sync_firm_tasks_from_system_defaults(
defaults = get_default_task_templates(db, catalogue_id=catalogue_id) db,
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, tenant_id=tenant_id,
service_catalogue_id=catalogue_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,
normal_review_role=getattr(d, "normal_review_role", None),
task_category_id=(firm_category.id if (firm_category := ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=tenant_id, name=getattr(d, "task_category", None), user_id=user.id)) else None),
task_category=(firm_category.name if firm_category else None),
response_required=getattr(d, "response_required", False),
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),
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, updated_by_user_id=user.id,
)) sync_open_engagements=True,
)
db.commit() db.commit()
return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) return RedirectResponse(
url=(
f'/services/templates/{catalogue_id}?sync_success=1'
f'&created={result.created}&updated={result.updated}'
f'&custom_updates={result.custom_updates_available}'
),
status_code=303,
)
finally: finally:
db.close() db.close()
@@ -1631,6 +1601,7 @@ def firm_task_template_toggle_active(
) )
task.is_active = not bool(task.is_active) task.is_active = not bool(task.is_active)
mark_firm_task_customized(task)
task.updated_by_user_id = user.id task.updated_by_user_id = user.id
db.commit() db.commit()
@@ -1643,6 +1614,70 @@ def firm_task_template_toggle_active(
db.close() db.close()
@router.post('/templates/{catalogue_id}/tasks/{task_id}/system-update/upgrade')
def firm_task_accept_system_update(
request: Request,
catalogue_id: int,
task_id: int,
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url='/login', status_code=303)
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 or not getattr(task, 'system_update_available', False):
return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=system_update_missing', status_code=303)
result = accept_system_update_for_firm_task(db, task=task, user_id=user.id, sync_open_engagements=True)
db.commit()
return RedirectResponse(
url=(f'/services/templates/{catalogue_id}?system_upgraded=1'
f'&engagement_created={result.engagement_created}'
f'&engagement_updated={result.engagement_updated_pending}'
f'&engagement_deactivated={result.engagement_deactivated_pending}'),
status_code=303,
)
except ValueError:
db.rollback()
return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=system_update_missing', status_code=303)
finally:
db.close()
@router.post('/templates/{catalogue_id}/tasks/{task_id}/system-update/keep-firm')
def firm_task_keep_customization(
request: Request,
catalogue_id: int,
task_id: int,
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url='/login', status_code=303)
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 or not getattr(task, 'system_update_available', False):
return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=system_update_missing', status_code=303)
keep_firm_customization_for_system_revision(db, task=task, user_id=user.id)
db.commit()
return RedirectResponse(url=f'/services/templates/{catalogue_id}?system_kept=1', status_code=303)
except ValueError:
db.rollback()
return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=system_update_missing', status_code=303)
finally:
db.close()
@router.post('/templates/{catalogue_id}/sync-open-engagements') @router.post('/templates/{catalogue_id}/sync-open-engagements')
def sync_firm_tasks_to_open_engagements( def sync_firm_tasks_to_open_engagements(
request: Request, request: Request,
@@ -1728,6 +1763,8 @@ def firm_task_template_move(
# Swap safely across the existing unique tenant/service/sequence constraint. # Swap safely across the existing unique tenant/service/sequence constraint.
min_seq = min(int(row.sequence_no) for row in tasks) min_seq = min(int(row.sequence_no) for row in tasks)
mark_firm_task_customized(current)
mark_firm_task_customized(other)
current.sequence_no = min_seq - 1000000 - int(current.id) current.sequence_no = min_seq - 1000000 - int(current.id)
current.updated_by_user_id = user.id current.updated_by_user_id = user.id
db.flush() db.flush()
@@ -1865,6 +1902,7 @@ def firm_task_template_edit_submit(
task.aqmm_blocks_final_release = aqmm_blocks_final_release is not None task.aqmm_blocks_final_release = aqmm_blocks_final_release is not None
task.aqmm_reference = aqmm_reference.strip() or None task.aqmm_reference = aqmm_reference.strip() or None
task.is_active = is_active is not None task.is_active = is_active is not None
mark_firm_task_customized(task)
task.updated_by_user_id = user.id task.updated_by_user_id = user.id
sync_result = None sync_result = None
@@ -2015,6 +2053,13 @@ def default_task_template_edit_submit(
task.aqmm_reference = aqmm_reference.strip() or None task.aqmm_reference = aqmm_reference.strip() or None
task.is_active = is_active is not None task.is_active = is_active is not None
db.flush()
sync_system_defaults_to_all_firms(
db,
service_catalogue_id=catalogue_id,
updated_by_user_id=user.id,
sync_open_engagements=True,
)
db.commit() db.commit()
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303) return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
finally: finally:
@@ -2102,7 +2147,9 @@ def default_task_duplicates_merge(request: Request,catalogue_id:int,master_task_
if not user: return RedirectResponse(url='/login',status_code=303) if not user: return RedirectResponse(url='/login',status_code=303)
require_permission(db,user,'service_tasks.edit') require_permission(db,user,'service_tasks.edit')
if not _is_system_admin(db,user): return _redirect_denied() 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() count=merge_default_tasks(db,catalogue_id=catalogue_id,master_task_id=master_task_id,source_task_ids=source_task_ids)
sync_system_defaults_to_all_firms(db,service_catalogue_id=catalogue_id,updated_by_user_id=user.id,sync_open_engagements=True)
db.commit()
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults/duplicates?merged={count}',status_code=303) return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults/duplicates?merged={count}',status_code=303)
except ValueError: except ValueError:
db.rollback(); return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults/duplicates?merge_error=1',status_code=303) db.rollback(); return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults/duplicates?merge_error=1',status_code=303)
@@ -2237,9 +2284,11 @@ def firm_task_category_update(request: Request, catalogue_id: int, category_id:
duplicate=db.execute(select(ServiceTaskCategory.id).where(ServiceTaskCategory.tenant_id==tenant_id, ServiceTaskCategory.service_catalogue_id==catalogue_id, ServiceTaskCategory.code==clean_code, ServiceTaskCategory.id!=row.id)).scalar_one_or_none() duplicate=db.execute(select(ServiceTaskCategory.id).where(ServiceTaskCategory.tenant_id==tenant_id, ServiceTaskCategory.service_catalogue_id==catalogue_id, ServiceTaskCategory.code==clean_code, ServiceTaskCategory.id!=row.id)).scalar_one_or_none()
if duplicate is not None: return RedirectResponse(url=f'/services/templates/{catalogue_id}/categories?error=duplicate_code', status_code=303) if duplicate is not None: return RedirectResponse(url=f'/services/templates/{catalogue_id}/categories?error=duplicate_code', status_code=303)
old_name=row.name; row.name=clean_name; row.code=clean_code; row.sort_order=sort_order; row.is_active=is_active is not None; row.updated_by_user_id=user.id old_name=row.name; row.name=clean_name; row.code=clean_code; row.sort_order=sort_order; row.is_active=is_active is not None; row.updated_by_user_id=user.id
for task in db.execute(select(FirmServiceTaskTemplate).where(FirmServiceTaskTemplate.tenant_id==tenant_id, FirmServiceTaskTemplate.service_catalogue_id==catalogue_id, FirmServiceTaskTemplate.task_category_id==row.id)).scalars().all(): task.task_category=clean_name for task in db.execute(select(FirmServiceTaskTemplate).where(FirmServiceTaskTemplate.tenant_id==tenant_id, FirmServiceTaskTemplate.service_catalogue_id==catalogue_id, FirmServiceTaskTemplate.task_category_id==row.id)).scalars().all():
task.task_category=clean_name; mark_firm_task_customized(task)
# Backward-compatible rows created before category IDs are also renamed when their text exactly matches. # Backward-compatible rows created before category IDs are also renamed when their text exactly matches.
for task in db.execute(select(FirmServiceTaskTemplate).where(FirmServiceTaskTemplate.tenant_id==tenant_id, FirmServiceTaskTemplate.service_catalogue_id==catalogue_id, FirmServiceTaskTemplate.task_category_id.is_(None), FirmServiceTaskTemplate.task_category==old_name)).scalars().all(): task.task_category_id=row.id; task.task_category=clean_name for task in db.execute(select(FirmServiceTaskTemplate).where(FirmServiceTaskTemplate.tenant_id==tenant_id, FirmServiceTaskTemplate.service_catalogue_id==catalogue_id, FirmServiceTaskTemplate.task_category_id.is_(None), FirmServiceTaskTemplate.task_category==old_name)).scalars().all():
task.task_category_id=row.id; task.task_category=clean_name; mark_firm_task_customized(task)
db.commit(); return RedirectResponse(url=f'/services/templates/{catalogue_id}/categories?updated=1', status_code=303) db.commit(); return RedirectResponse(url=f'/services/templates/{catalogue_id}/categories?updated=1', status_code=303)
finally: db.close() finally: db.close()
@@ -2334,6 +2383,7 @@ def system_task_category_update(request: Request, catalogue_id: int, category_id
old_name=row.name; row.name=clean_name; row.code=clean_code; row.sort_order=sort_order; row.is_active=is_active is not None; row.updated_by_user_id=user.id old_name=row.name; row.name=clean_name; row.code=clean_code; row.sort_order=sort_order; row.is_active=is_active is not None; row.updated_by_user_id=user.id
for task in db.execute(select(ServiceDefaultTaskTemplate).where(ServiceDefaultTaskTemplate.service_catalogue_id==catalogue_id, ServiceDefaultTaskTemplate.task_category_id==row.id)).scalars().all(): task.task_category=row.name for task in db.execute(select(ServiceDefaultTaskTemplate).where(ServiceDefaultTaskTemplate.service_catalogue_id==catalogue_id, ServiceDefaultTaskTemplate.task_category_id==row.id)).scalars().all(): task.task_category=row.name
for task in db.execute(select(ServiceDefaultTaskTemplate).where(ServiceDefaultTaskTemplate.service_catalogue_id==catalogue_id, ServiceDefaultTaskTemplate.task_category_id.is_(None), ServiceDefaultTaskTemplate.task_category==old_name)).scalars().all(): task.task_category_id=row.id; task.task_category=row.name for task in db.execute(select(ServiceDefaultTaskTemplate).where(ServiceDefaultTaskTemplate.service_catalogue_id==catalogue_id, ServiceDefaultTaskTemplate.task_category_id.is_(None), ServiceDefaultTaskTemplate.task_category==old_name)).scalars().all(): task.task_category_id=row.id; task.task_category=row.name
sync_system_defaults_to_all_firms(db,service_catalogue_id=catalogue_id,updated_by_user_id=user.id,sync_open_engagements=True)
db.commit(); return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/task-categories?updated=1',status_code=303) db.commit(); return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/task-categories?updated=1',status_code=303)
finally: db.close() finally: db.close()
@@ -2528,8 +2578,6 @@ def upload_firm_task_templates(request: Request, file: UploadFile = File(...), u
@router.post("/catalogue/{catalogue_id}/firm-tasks/sync-system-defaults") @router.post("/catalogue/{catalogue_id}/firm-tasks/sync-system-defaults")
def sync_system_default_tasks_for_firm(request: Request, catalogue_id: int): def sync_system_default_tasks_for_firm(request: Request, catalogue_id: int):
"""Idempotent replacement for the old insert-only 'Copy System Default Tasks' action.""" """Idempotent replacement for the old insert-only 'Copy System Default Tasks' action."""
from app.modules.services.default_task_sync import sync_firm_tasks_from_system_defaults
db = CommonSessionLocal() db = CommonSessionLocal()
try: try:
current_user = get_current_user(request, db=db) current_user = get_current_user(request, db=db)