Auto-sync system default tasks to firms and open engagements
This commit is contained in:
@@ -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)
|
||||||
@@ -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)})
|
||||||
|
|||||||
@@ -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:
|
||||||
if key:
|
by_source.setdefault(int(row.source_system_task_id), []).append(row)
|
||||||
by_name[key].append(row)
|
else:
|
||||||
|
key = _normalise_name(row.task_name)
|
||||||
|
if key:
|
||||||
|
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
|
||||||
for offset, row in enumerate(sorted(firm_rows, key=lambda item: int(item.id)), start=1):
|
by_source.setdefault(int(source.id), []).append(target)
|
||||||
row.sequence_no = parking_base + offset
|
# Remove from future legacy matching.
|
||||||
|
key = _normalise_name(target.task_name)
|
||||||
# This flush is intentional and must happen before any canonical row is
|
if key in legacy_by_name:
|
||||||
# assigned a system-default sequence number.
|
legacy_by_name[key] = [r for r in legacy_by_name[key] if r.id != target.id]
|
||||||
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 []
|
|
||||||
|
|
||||||
if candidates:
|
if candidates:
|
||||||
target = _choose_canonical(
|
target = _choose_canonical(candidates, reference_counts=reference_counts)
|
||||||
candidates,
|
for duplicate in candidates:
|
||||||
reference_counts=reference_counts,
|
if duplicate.id == target.id:
|
||||||
)
|
continue
|
||||||
changed = _copy_default_columns(default, target)
|
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 (
|
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.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
|
target.updated_by_user_id = updated_by_user_id
|
||||||
if changed:
|
result.updated += 1 if changed else 0
|
||||||
result.updated += 1
|
result.unchanged += 0 if changed else 1
|
||||||
else:
|
|
||||||
result.unchanged += 1
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# No name match. A unique sequence match is a conservative fallback for a
|
# Missing default: create a new inherited row. Avoid colliding with a
|
||||||
# system-default rename while still avoiding arbitrary replacement.
|
# firm-only sequence; identity is source_system_task_id, not sequence number.
|
||||||
# Do NOT use the pre-sync sequence number as an identity fallback here.
|
preferred = int(source.sequence_no or 0)
|
||||||
# All existing rows have deliberately been parked on temporary sequence
|
seq = _next_free_sequence(used_sequences, preferred)
|
||||||
# numbers to satisfy the DB unique constraint. More importantly,
|
used_sequences.add(seq)
|
||||||
# sequence number is ordering metadata, not a stable task identity.
|
row = FirmServiceTaskTemplate(
|
||||||
# A renamed system-default task without a name match is therefore
|
tenant_id=tenant_id,
|
||||||
# treated as a new task rather than risking replacement of the wrong
|
service_catalogue_id=service_catalogue_id,
|
||||||
# firm-specific task.
|
task_name=source.task_name,
|
||||||
|
sequence_no=seq,
|
||||||
# Missing task: construct from the intersection of mapped columns.
|
source_system_task_id=source.id,
|
||||||
source_cols = _column_names(ServiceDefaultTaskTemplate)
|
is_customized=False,
|
||||||
target_cols = _column_names(FirmServiceTaskTemplate)
|
last_synced_system_hash=latest_hash,
|
||||||
excluded = {
|
last_reviewed_system_hash=latest_hash,
|
||||||
"id",
|
system_update_available=False,
|
||||||
"tenant_id",
|
created_by_user_id=updated_by_user_id,
|
||||||
"service_catalogue_id",
|
updated_by_user_id=updated_by_user_id,
|
||||||
"created_at",
|
)
|
||||||
"created_at_utc",
|
db.add(row)
|
||||||
"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)
|
|
||||||
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()
|
||||||
|
|||||||
@@ -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,12 +587,35 @@ 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
|
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
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+133
-85
@@ -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)
|
tenant_id=tenant_id,
|
||||||
copied_count = 0
|
service_catalogue_id=catalogue_id,
|
||||||
for default in defaults:
|
updated_by_user_id=user_id,
|
||||||
db.add(FirmServiceTaskTemplate(
|
sync_open_engagements=False,
|
||||||
tenant_id=tenant_id,
|
)
|
||||||
service_catalogue_id=catalogue_id,
|
return result.created, had_existing
|
||||||
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,
|
|
||||||
))
|
|
||||||
copied_count += 1
|
|
||||||
|
|
||||||
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)
|
tenant_id=tenant_id,
|
||||||
for d in defaults:
|
service_catalogue_id=catalogue_id,
|
||||||
seq = d.sequence_no
|
updated_by_user_id=user.id,
|
||||||
if seq in existing_sequences:
|
sync_open_engagements=True,
|
||||||
seq = next_seq
|
)
|
||||||
next_seq += 1
|
|
||||||
existing_sequences.add(seq)
|
|
||||||
db.add(FirmServiceTaskTemplate(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
service_catalogue_id=catalogue_id,
|
|
||||||
task_name=d.task_name,
|
|
||||||
description=d.description,
|
|
||||||
sequence_no=seq,
|
|
||||||
default_role_name=d.default_role_name,
|
|
||||||
is_mandatory=d.is_mandatory,
|
|
||||||
requires_review=d.requires_review,
|
|
||||||
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,
|
|
||||||
))
|
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user