Auto-sync system default tasks to firms and open engagements

This commit is contained in:
A R R R Associates
2026-09-19 12:47:53 +05:30
parent f6776cc96a
commit 2d6043962b
9 changed files with 857 additions and 313 deletions
+133 -85
View File
@@ -27,6 +27,14 @@ from app.modules.services.bulk_imports import (
from app.modules.services.catalogue_export import build_service_catalogue_export
from app.modules.services.default_tasks_export import build_default_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_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
@@ -944,51 +952,21 @@ def _copy_system_defaults_if_firm_tasks_empty(
catalogue_id: int,
user_id: int,
) -> 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(
db,
tenant_id=tenant_id,
catalogue_id=catalogue_id,
)
if existing_tasks:
return 0, True
defaults = get_default_task_templates(db, catalogue_id=catalogue_id)
copied_count = 0
for default in defaults:
db.add(FirmServiceTaskTemplate(
tenant_id=tenant_id,
service_catalogue_id=catalogue_id,
task_name=default.task_name,
description=default.description,
sequence_no=default.sequence_no,
default_role_name=default.default_role_name,
eligible_role_names=getattr(default, "eligible_role_names", None) or default.default_role_name,
is_mandatory=default.is_mandatory,
requires_review=default.requires_review,
normal_review_role=getattr(default, "normal_review_role", None),
task_category_id=(firm_category.id if (firm_category := ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=tenant_id, name=getattr(default, "task_category", None), user_id=user_id)) else None),
task_category=(firm_category.name if firm_category else None),
response_required=getattr(default, "response_required", False),
response_type=getattr(default, "response_type", "NONE") or "NONE",
evidence_required=getattr(default, "evidence_required", False),
remarks_required_if_no=getattr(default, "remarks_required_if_no", False),
task_tool_code=getattr(default, "task_tool_code", "NONE") or "NONE",
is_aqmm_task=getattr(default, "is_aqmm_task", False),
aqmm_mandatory=getattr(default, "aqmm_mandatory", False),
aqmm_evidence_required=getattr(default, "aqmm_evidence_required", False),
aqmm_manager_review_required=getattr(default, "aqmm_manager_review_required", False),
aqmm_partner_review_required=getattr(default, "aqmm_partner_review_required", False),
aqmm_review_partner_required=getattr(default, "aqmm_review_partner_required", False),
aqmm_blocks_final_release=getattr(default, "aqmm_blocks_final_release", False),
aqmm_reference=getattr(default, "aqmm_reference", None),
is_active=default.is_active,
created_by_user_id=user_id,
updated_by_user_id=user_id,
))
copied_count += 1
return copied_count, False
had_existing = bool(existing_tasks)
result = sync_firm_tasks_from_system_defaults(
db,
tenant_id=tenant_id,
service_catalogue_id=catalogue_id,
updated_by_user_id=user_id,
sync_open_engagements=False,
)
return result.created, had_existing
@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:
return RedirectResponse(url='/services', status_code=303)
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.id: list_task_document_requirements(db, tenant_id=tenant_id, firm_task_template_id=task.id)
for task in tasks
@@ -1258,6 +1242,7 @@ def task_templates_detail(request: Request, catalogue_id: int):
task_templates=tasks,
task_requirement_map=task_requirement_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),
default_tasks=get_default_task_templates(db, catalogue_id=catalogue_id),
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_reference=aqmm_reference.strip() or None,
is_active=is_active is not None,
is_customized=True,
created_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:
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)
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)
finally:
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)
if not selection or not selection.is_enabled:
return RedirectResponse(url='/services', status_code=303)
existing_sequences = {t.sequence_no for t in get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=catalogue_id)}
defaults = get_default_task_templates(db, catalogue_id=catalogue_id)
next_seq = next_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
for d in defaults:
seq = d.sequence_no
if seq in existing_sequences:
seq = next_seq
next_seq += 1
existing_sequences.add(seq)
db.add(FirmServiceTaskTemplate(
tenant_id=tenant_id,
service_catalogue_id=catalogue_id,
task_name=d.task_name,
description=d.description,
sequence_no=seq,
default_role_name=d.default_role_name,
is_mandatory=d.is_mandatory,
requires_review=d.requires_review,
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,
))
result = sync_firm_tasks_from_system_defaults(
db,
tenant_id=tenant_id,
service_catalogue_id=catalogue_id,
updated_by_user_id=user.id,
sync_open_engagements=True,
)
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:
db.close()
@@ -1631,6 +1601,7 @@ def firm_task_template_toggle_active(
)
task.is_active = not bool(task.is_active)
mark_firm_task_customized(task)
task.updated_by_user_id = user.id
db.commit()
@@ -1643,6 +1614,70 @@ def firm_task_template_toggle_active(
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')
def sync_firm_tasks_to_open_engagements(
request: Request,
@@ -1728,6 +1763,8 @@ def firm_task_template_move(
# Swap safely across the existing unique tenant/service/sequence constraint.
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.updated_by_user_id = user.id
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_reference = aqmm_reference.strip() or None
task.is_active = is_active is not None
mark_firm_task_customized(task)
task.updated_by_user_id = user.id
sync_result = None
@@ -2015,6 +2053,13 @@ def default_task_template_edit_submit(
task.aqmm_reference = aqmm_reference.strip() or 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()
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
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)
require_permission(db,user,'service_tasks.edit')
if not _is_system_admin(db,user): return _redirect_denied()
count=merge_default_tasks(db,catalogue_id=catalogue_id,master_task_id=master_task_id,source_task_ids=source_task_ids); db.commit()
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)
except ValueError:
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()
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
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.
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)
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
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
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)
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")
def sync_system_default_tasks_for_firm(request: Request, catalogue_id: int):
"""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()
try:
current_user = get_current_user(request, db=db)