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
+497 -219
View File
@@ -1,129 +1,285 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timezone
import hashlib
import json
import re
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.inspection import inspect as sa_inspect
from sqlalchemy.orm import Session
from app.modules.services.models import (
ClientServiceTaskInstance,
FirmServiceSelection,
FirmServiceTaskTemplate,
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
class FirmDefaultTaskSyncResult:
created: int = 0
updated: int = 0
duplicates_disabled: 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
def active_total_change(self) -> int:
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:
return _SPACE_RE.sub(" ", (value or "").strip()).casefold()
def _column_names(model: type[Any]) -> set[str]:
return {column.key for column in sa_inspect(model).mapper.column_attrs}
def _portable_value(value: Any) -> Any:
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(
db: Session,
source: ServiceDefaultTaskTemplate,
target: FirmServiceTaskTemplate,
*,
tenant_id: int,
user_id: int | None,
allow_sequence_change: bool = True,
) -> 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
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)
if getattr(target, name, None) != new_value:
setattr(target, name, new_value)
changed = True
# IMPORTANT:
# is_active is copied from ServiceDefaultTaskTemplate above because it is
# a shared mapped column. Do NOT force FirmServiceTaskTemplate back to True.
#
# This makes System Default activation state authoritative during sync:
# System Default disabled -> Firm Task disabled
# System Default enabled -> Firm Task enabled
#
# Firm-only/custom tasks remain untouched because this function is called
# only for tasks matched to current System Defaults.
category = _ensure_firm_category(db, source=source, tenant_id=tenant_id, user_id=user_id)
category_id = category.id if category else None
category_name = category.name if category else None
if getattr(target, "task_category_id", None) != category_id:
target.task_category_id = category_id
changed = True
if getattr(target, "task_category", None) != category_name:
target.task_category = category_name
changed = True
return changed
def _reference_counts(
db: Session,
*,
firm_task_ids: list[int],
) -> dict[int, int]:
def _reference_counts(db: Session, *, firm_task_ids: list[int]) -> dict[int, int]:
if not firm_task_ids:
return {}
rows = db.execute(
select(
ClientServiceTaskInstance.firm_task_template_id,
func.count(ClientServiceTaskInstance.id),
)
select(ClientServiceTaskInstance.firm_task_template_id, func.count(ClientServiceTaskInstance.id))
.where(ClientServiceTaskInstance.firm_task_template_id.in_(firm_task_ids))
.group_by(ClientServiceTaskInstance.firm_task_template_id)
).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(
candidates: list[FirmServiceTaskTemplate],
*,
reference_counts: dict[int, int],
) -> FirmServiceTaskTemplate:
"""Prefer the row already used by the most engagement task instances.
def _choose_canonical(candidates: list[FirmServiceTaskTemplate], *, reference_counts: dict[int, int]) -> FirmServiceTaskTemplate:
return sorted(candidates, key=lambda row: (-reference_counts.get(int(row.id), 0), int(row.id)))[0]
This avoids breaking historical links. If usage is equal, keep the oldest
row (smallest id).
"""
return sorted(
candidates,
key=lambda row: (
-reference_counts.get(int(row.id), 0),
int(row.id),
),
)[0]
def _next_free_sequence(used: set[int], preferred: int) -> int:
if preferred > 0 and preferred not in used:
return preferred
candidate = max(used or {0}) + 1
while candidate in used:
candidate += 1
return candidate
def sync_firm_tasks_from_system_defaults(
@@ -132,183 +288,305 @@ def sync_firm_tasks_from_system_defaults(
tenant_id: int,
service_catalogue_id: int,
updated_by_user_id: int | None = None,
sync_open_engagements: bool = False,
) -> FirmDefaultTaskSyncResult:
"""Idempotently synchronise system default tasks into one firm's template set.
"""Synchronise one firm's task templates from centrally maintained defaults.
Behaviour:
* existing firm task with the same normalised task name -> UPDATE, never INSERT;
* duplicate firm rows with the same task name -> retain one canonical active row,
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.
Inherited tasks are updated automatically. Explicitly customized linked tasks are
never overwritten; a system_update_available flag is raised for Firm Admin review.
Firm-only tasks remain untouched. Legacy rows are linked by exact normalized name.
"""
defaults = db.execute(
select(ServiceDefaultTaskTemplate)
.where(
ServiceDefaultTaskTemplate.service_catalogue_id == service_catalogue_id,
)
.order_by(
ServiceDefaultTaskTemplate.sequence_no.asc(),
ServiceDefaultTaskTemplate.id.asc(),
)
.where(ServiceDefaultTaskTemplate.service_catalogue_id == service_catalogue_id)
.order_by(ServiceDefaultTaskTemplate.sequence_no.asc(), ServiceDefaultTaskTemplate.id.asc())
).scalars().all()
firm_rows = db.execute(
select(FirmServiceTaskTemplate)
.where(
FirmServiceTaskTemplate.tenant_id == tenant_id,
FirmServiceTaskTemplate.service_catalogue_id == service_catalogue_id,
)
.order_by(
FirmServiceTaskTemplate.sequence_no.asc(),
FirmServiceTaskTemplate.id.asc(),
)
.order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc())
).scalars().all()
result = FirmDefaultTaskSyncResult()
by_name: dict[str, list[FirmServiceTaskTemplate]] = defaultdict(list)
reference_counts = _reference_counts(db, firm_task_ids=[int(r.id) for r in firm_rows if r.id])
by_source: dict[int, list[FirmServiceTaskTemplate]] = {}
legacy_by_name: dict[str, list[FirmServiceTaskTemplate]] = {}
for row in firm_rows:
key = _normalise_name(getattr(row, "task_name", None))
if key:
by_name[key].append(row)
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:
legacy_by_name.setdefault(key, []).append(row)
reference_counts = _reference_counts(
db,
firm_task_ids=[int(row.id) for row in firm_rows if getattr(row, "id", None)],
)
used_sequences = {int(r.sequence_no) for r in firm_rows if r.sequence_no is not None}
# ------------------------------------------------------------------
# IMPORTANT: two-phase sequence re-numbering
#
# The database enforces a UNIQUE constraint on:
# (tenant_id, service_catalogue_id, sequence_no)
#
# Existing duplicate imports may contain several generations of the same
# task at different sequence numbers. During a sync, moving a canonical
# row directly onto the latest system-default sequence can therefore
# collide with another historical row that still owns that sequence.
#
# Marking that historical row inactive does NOT release the unique key.
# Park every existing firm template on a guaranteed-unique temporary
# sequence first, flush, and only then apply final/default sequences.
# ------------------------------------------------------------------
max_existing_sequence = max(
[int(getattr(row, "sequence_no", 0) or 0) for row in firm_rows] + [0]
)
max_default_sequence = max(
[int(getattr(row, "sequence_no", 0) or 0) for row in defaults] + [0]
)
parking_base = max(max_existing_sequence, max_default_sequence, 0) + 100000
for offset, row in enumerate(sorted(firm_rows, key=lambda item: int(item.id)), start=1):
row.sequence_no = parking_base + offset
# 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:
if duplicate.id == canonical.id:
continue
# 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
if (
updated_by_user_id is not None
and hasattr(duplicate, "updated_by_user_id")
):
duplicate.updated_by_user_id = updated_by_user_id
result.duplicates_disabled += 1
# Upsert current system defaults.
for default in defaults:
key = _normalise_name(getattr(default, "task_name", None))
candidates = by_name.get(key, []) if key else []
for source in defaults:
latest_hash = system_task_hash(source)
candidates = by_source.get(int(source.id), [])
linked_from_legacy = False
if not candidates:
legacy_candidates = legacy_by_name.get(_normalise_name(source.task_name), [])
if not legacy_candidates:
# One-time migration fallback for a firm that renamed a previously
# copied default before provenance columns existed. Sequence is used
# only when there is exactly one unlinked candidate at that position.
legacy_candidates = [
row for row in firm_rows
if row.source_system_task_id is None
and int(row.sequence_no or 0) == int(source.sequence_no or 0)
]
if len(legacy_candidates) != 1:
legacy_candidates = []
if legacy_candidates:
target = _choose_canonical(legacy_candidates, reference_counts=reference_counts)
candidates = [target]
target.source_system_task_id = source.id
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]
if candidates:
target = _choose_canonical(
candidates,
reference_counts=reference_counts,
)
changed = _copy_default_columns(default, target)
target = _choose_canonical(candidates, reference_counts=reference_counts)
for duplicate in candidates:
if duplicate.id == target.id:
continue
if duplicate.is_active:
duplicate.is_active = False
if updated_by_user_id is not None:
duplicate.updated_by_user_id = updated_by_user_id
result.duplicates_disabled += 1
# For legacy rows with no inheritance history, infer whether they were
# already firm-customized by comparing the full portable snapshot.
if linked_from_legacy and target.last_synced_system_hash is None:
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
# Detect out-of-band edits to a linked inherited row as customization.
if (
updated_by_user_id is not None
and hasattr(target, "updated_by_user_id")
not target.is_customized
and target.last_synced_system_hash
and firm_task_hash(target) != target.last_synced_system_hash
):
target.is_customized = True
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:
result.unchanged += 1
continue
# Inherited task: copy system values. Sequence changes are applied when
# the desired number is free or currently owned by this same row. If a
# firm-only/custom row owns it, preserve that row and place this inherited
# task at the next free sequence instead of overwriting customization.
desired_seq = int(source.sequence_no or 0)
current_seq = int(target.sequence_no or 0)
allow_seq = desired_seq == current_seq or desired_seq not in (used_sequences - {current_seq})
if allow_seq:
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
if changed:
result.updated += 1
else:
result.unchanged += 1
result.updated += 1 if changed else 0
result.unchanged += 0 if changed else 1
continue
# No name match. A unique sequence match is a conservative fallback for a
# system-default rename while still avoiding arbitrary replacement.
# Do NOT use the pre-sync sequence number as an identity fallback here.
# All existing rows have deliberately been parked on temporary sequence
# numbers to satisfy the DB unique constraint. More importantly,
# sequence number is ordering metadata, not a stable task identity.
# A renamed system-default task without a name match is therefore
# treated as a new task rather than risking replacement of the wrong
# firm-specific task.
# Missing task: construct from the intersection of mapped columns.
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",
}
payload = {
name: getattr(default, name, None)
for name in sorted((source_cols & target_cols) - excluded)
}
payload["tenant_id"] = tenant_id
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)
# Missing default: create a new inherited row. Avoid colliding with a
# firm-only sequence; identity is source_system_task_id, not sequence number.
preferred = int(source.sequence_no or 0)
seq = _next_free_sequence(used_sequences, preferred)
used_sequences.add(seq)
row = FirmServiceTaskTemplate(
tenant_id=tenant_id,
service_catalogue_id=service_catalogue_id,
task_name=source.task_name,
sequence_no=seq,
source_system_task_id=source.id,
is_customized=False,
last_synced_system_hash=latest_hash,
last_reviewed_system_hash=latest_hash,
system_update_available=False,
created_by_user_id=updated_by_user_id,
updated_by_user_id=updated_by_user_id,
)
db.add(row)
db.flush()
firm_rows.append(new_row)
if key:
by_name[key].append(new_row)
_copy_default_columns(
db,
source,
row,
tenant_id=tenant_id,
user_id=updated_by_user_id,
allow_sequence_change=(seq == preferred),
)
result.created += 1
firm_rows.append(row)
by_source.setdefault(int(source.id), []).append(row)
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
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()