From cc8691d4cd7e923ccb89d12a4d6e6312f07d195b Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sat, 19 Sep 2026 15:11:56 +0530 Subject: [PATCH] Fix retirement propagation and task counts --- app/modules/services/default_task_sync.py | 45 ++++++++++++++++++- .../services/default_templates_detail.html | 14 ++++-- .../services/task_template_detail.html | 32 +++++++------ app/modules/services/ui.py | 38 ++++++++++++++-- 4 files changed, 107 insertions(+), 22 deletions(-) diff --git a/app/modules/services/default_task_sync.py b/app/modules/services/default_task_sync.py index 1975862..27bfec1 100644 --- a/app/modules/services/default_task_sync.py +++ b/app/modules/services/default_task_sync.py @@ -109,6 +109,29 @@ def firm_task_hash(target: FirmServiceTaskTemplate) -> str: return hashlib.sha256(payload.encode("utf-8")).hexdigest() +def _legacy_retirement_equivalent( + source: ServiceDefaultTaskTemplate, + target: FirmServiceTaskTemplate, +) -> bool: + """Return True when a legacy firm row differs only because the system row was retired. + + Full-sync retirement moves omitted system rows into a high temporary sequence range + before setting is_active=False. Legacy firm rows created before provenance tracking still + carry their old active flag and old sequence. Those two differences must not make an + otherwise inherited task look like a deliberate firm customization. + + Any substantive field difference still protects the firm row as customized. + """ + source_snapshot = _snapshot_dict(source) + target_snapshot = _snapshot_dict(target) + source_snapshot.pop("is_active", None) + target_snapshot.pop("is_active", None) + if int(getattr(source, "sequence_no", 0) or 0) >= 100000: + source_snapshot.pop("sequence_no", None) + target_snapshot.pop("sequence_no", None) + return source_snapshot == target_snapshot + + def system_task_diff(source: ServiceDefaultTaskTemplate, target: FirmServiceTaskTemplate) -> list[dict[str, Any]]: labels = { "task_name": "Task name", @@ -367,11 +390,31 @@ def sync_firm_tasks_from_system_defaults( # 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 source.is_active and _legacy_retirement_equivalent(source, target): + # This was an inherited legacy row whose system source has just been + # retired by Full Synchronization. Do not misclassify the active/old + # sequence difference as a firm customization; allow normal retirement + # propagation below. + target.is_customized = False + else: + target.is_customized = firm_task_hash(target) != latest_hash if not target.is_customized: target.last_synced_system_hash = latest_hash target.last_reviewed_system_hash = latest_hash + # Repair rows linked by an earlier rollout where retirement-only differences + # were incorrectly interpreted as customization. This is deliberately limited + # to rows that have never had a successful inherited snapshot recorded. + if ( + not source.is_active + and target.is_customized + and target.last_synced_system_hash is None + and _legacy_retirement_equivalent(source, target) + ): + target.is_customized = False + target.system_update_available = False + target.system_update_detected_at_utc = None + # Detect out-of-band edits to a linked inherited row as customization. if ( not target.is_customized diff --git a/app/modules/services/templates/services/default_templates_detail.html b/app/modules/services/templates/services/default_templates_detail.html index 6f366dd..e80dbc0 100644 --- a/app/modules/services/templates/services/default_templates_detail.html +++ b/app/modules/services/templates/services/default_templates_detail.html @@ -11,14 +11,21 @@ {% if request.query_params.get('imported') %}
Excel import completed: {{ request.query_params.get('created','0') }} created, {{ request.query_params.get('updated','0') }} updated, {{ request.query_params.get('retired','0') }} retired.{% if request.query_params.get('full_sync') == '1' %} Full Synchronization was applied.{% endif %}
{% endif %} {% if request.query_params.get('import_error') %}
Excel import failed validation. No partial update was committed. Review the workbook and try again.
{% endif %} +
+
Active System Defaults
{{ active_default_task_count }}

These tasks are available for synchronization to firms and new engagement work.

+
Retired System Defaults
{{ retired_default_task_count }}

Soft-retired tasks remain in the database so historical engagement and audit records are preserved.

+

Export · Edit · Re-import This Service

Export this service's current tasks, edit the Import Ready sheet, then re-import. Existing exports now carry a stable system_task_id so renaming and resequencing update the correct task.

+
+

System Default Task List

Active tasks are shown by default. Retired tasks remain available for audit/history review.

+ {% if show_retired %}Hide Retired{% else %}Show Retired ({{ retired_default_task_count }}){% endif %} +
- {% for task in default_tasks %} - {% if task.is_active or is_system_admin %} + {% for task in display_default_tasks %}
@@ -40,8 +47,7 @@

{{ task.description }}

{% endif %}
- {% endif %} - {% else %}
No default tasks configured yet.
{% endfor %} + {% else %}
No active default tasks configured. Use Show Retired to view inactive historical defaults.
{% endfor %}
diff --git a/app/modules/services/templates/services/task_template_detail.html b/app/modules/services/templates/services/task_template_detail.html index f909e1d..1299b4e 100644 --- a/app/modules/services/templates/services/task_template_detail.html +++ b/app/modules/services/templates/services/task_template_detail.html @@ -43,13 +43,13 @@
Firm Tasks
-
{{ task_templates|length }}
-

Tasks customised for the active firm.

+
{{ active_task_count }}
+

Active tasks for the firm. {{ retired_task_count }} retired task(s) are preserved for history.

System Defaults
-
{{ default_tasks|length }}
-

Inherited defaults stay synchronized automatically; customized tasks require Firm Admin approval before a later system update is applied.

+
{{ active_default_task_count }}
+

Active inherited system defaults. {{ retired_default_task_count }} retired default(s) are preserved for history; customized tasks require Firm Admin approval before a later system update is applied.

@@ -60,7 +60,7 @@

Add Firm Task

Create a task template for this firm and service.

- {% if default_tasks|length > 0 %} + {% if active_default_task_count > 0 %}
@@ -164,9 +164,12 @@ {% endif %}
-
-

Firm Task List

-

These tasks will be used when work is generated for this firm.

+
+
+

Firm Task List

+

Active tasks are used for new work. Retired tasks remain stored for historical references.

+
+ {% if show_retired %}Hide Retired{% else %}Show Retired ({{ retired_task_count }}){% endif %}
@@ -181,7 +184,7 @@ - {% for task in task_templates %} + {% for task in display_task_templates %} {% else %} - + {% endfor %}
@@ -219,7 +222,8 @@ {% if task.system_update_available %} {% set changes = system_update_diff_map.get(task.id, []) if system_update_diff_map else [] %}
-
A newer system default is available. Your firm customization has not been overwritten.
+ {% set retirement_update = system_update_retirement_map.get(task.id, false) if system_update_retirement_map else false %} +
{% if retirement_update %}This system default has been retired. Your customized firm task has not been disabled automatically.{% else %}A newer system default is available. Your firm customization has not been overwritten.{% endif %}
{% if changes %}
{% for change in changes[:5] %} @@ -230,13 +234,13 @@ {% endif %} {% if can_manage_tasks %}
- + - +
- +
{% endif %} @@ -326,7 +330,7 @@
No firm task templates yet. Copy defaults or add tasks manually.
No active firm task templates yet. Use Show Retired to view historical inactive tasks, or sync/add tasks as needed.
diff --git a/app/modules/services/ui.py b/app/modules/services/ui.py index 8a5af86..3884199 100644 --- a/app/modules/services/ui.py +++ b/app/modules/services/ui.py @@ -1194,7 +1194,7 @@ def task_templates_list(request: Request): tenant_id = _active_tenant_id(request, user) enabled_rows = list_firm_services_payload(db, tenant_id=tenant_id) for row in enabled_rows: - row['task_count'] = len(get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=row['catalogue'].id)) + row['task_count'] = sum(1 for task in get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=row['catalogue'].id) if task.is_active) return _render(request, 'modules/services/templates/services/task_templates_list.html', db, user, title='Firm Service Task Templates', rows=enabled_rows, can_manage_tasks=_can_manage_firm_tasks(db, user)) finally: db.close() @@ -1218,11 +1218,13 @@ def task_templates_detail(request: Request, catalogue_id: int): 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 = {} + system_update_retirement_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) + system_update_retirement_map[firm_task.id] = not bool(system_task.is_active) task_requirement_map = { task.id: list_task_document_requirements(db, tenant_id=tenant_id, firm_task_template_id=task.id) for task in tasks @@ -1231,6 +1233,9 @@ def task_templates_detail(request: Request, catalogue_id: int): task.id: list_task_document_templates(db, tenant_id=tenant_id, firm_task_template_id=task.id) for task in tasks } + default_tasks = get_default_task_templates(db, catalogue_id=catalogue_id) + show_retired = request.query_params.get("show_retired") == "1" + display_tasks = tasks if show_retired else [task for task in tasks if task.is_active] return _render( request, 'modules/services/templates/services/task_template_detail.html', @@ -1240,11 +1245,20 @@ def task_templates_detail(request: Request, catalogue_id: int): service=catalogue, selection=selection, task_templates=tasks, + display_task_templates=display_tasks, + show_retired=show_retired, + active_task_templates=[task for task in tasks if task.is_active], + retired_task_templates=[task for task in tasks if not task.is_active], + active_task_count=sum(1 for task in tasks if task.is_active), + retired_task_count=sum(1 for task in tasks if not task.is_active), task_requirement_map=task_requirement_map, task_template_file_map=task_template_file_map, system_update_diff_map=system_update_diff_map, + system_update_retirement_map=system_update_retirement_map, can_manage_tasks=_can_manage_firm_tasks(db, user), - default_tasks=get_default_task_templates(db, catalogue_id=catalogue_id), + default_tasks=default_tasks, + active_default_task_count=sum(1 for task in default_tasks if task.is_active), + retired_default_task_count=sum(1 for task in default_tasks if not task.is_active), is_system_admin=_is_system_admin(db, user), task_categories=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=tenant_id, include_inactive=True), ) @@ -1361,7 +1375,24 @@ def default_templates_detail(request: Request, catalogue_id: int): if not catalogue: return RedirectResponse(url='/services/defaults', status_code=303) defaults = get_default_task_templates(db, catalogue_id=catalogue_id) - return _render(request, 'modules/services/templates/services/default_templates_detail.html', db, user, title=f'Default Tasks - {catalogue.service_name}', service=catalogue, default_tasks=defaults, task_categories=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=None, include_inactive=True)) + show_retired = request.query_params.get("show_retired") == "1" + display_defaults = defaults if show_retired else [task for task in defaults if task.is_active] + return _render( + request, + 'modules/services/templates/services/default_templates_detail.html', + db, + user, + title=f'Default Tasks - {catalogue.service_name}', + service=catalogue, + default_tasks=defaults, + display_default_tasks=display_defaults, + show_retired=show_retired, + active_default_tasks=[task for task in defaults if task.is_active], + retired_default_tasks=[task for task in defaults if not task.is_active], + active_default_task_count=sum(1 for task in defaults if task.is_active), + retired_default_task_count=sum(1 for task in defaults if not task.is_active), + task_categories=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=None, include_inactive=True), + ) finally: db.close() @@ -2628,6 +2659,7 @@ def sync_system_default_tasks_for_firm(request: Request, catalogue_id: int): "updated": str(result.updated), "unchanged": str(result.unchanged), "duplicates_disabled": str(result.duplicates_disabled), + "custom_updates": str(result.custom_updates_available), } )