diff --git a/alembic/versions/20260808_task_role_eligibility.py b/alembic/versions/20260808_task_role_eligibility.py new file mode 100644 index 0000000..211d7ad --- /dev/null +++ b/alembic/versions/20260808_task_role_eligibility.py @@ -0,0 +1,48 @@ +"""Add eligible task roles to templates and engagement task snapshots.""" + +from alembic import op +import sqlalchemy as sa + +revision = "20260808_task_role_eligibility" +down_revision = "20260807_service_reg_master" +branch_labels = None +depends_on = None + +TABLES = ( + "service_default_task_templates", + "firm_service_task_templates", + "client_service_task_instances", +) + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + for table_name in TABLES: + if table_name not in tables: + continue + columns = {c["name"] for c in inspector.get_columns(table_name)} + if "eligible_role_names" not in columns: + with op.batch_alter_table(table_name) as batch: + batch.add_column(sa.Column("eligible_role_names", sa.String(length=200), nullable=True)) + bind.execute(sa.text( + f""" + UPDATE {table_name} + SET eligible_role_names = default_role_name + WHERE (eligible_role_names IS NULL OR TRIM(eligible_role_names) = '') + AND default_role_name IS NOT NULL + AND TRIM(default_role_name) <> '' + """ + )) + +def downgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + for table_name in reversed(TABLES): + if table_name not in tables: + continue + columns = {c["name"] for c in inspector.get_columns(table_name)} + if "eligible_role_names" in columns: + with op.batch_alter_table(table_name) as batch: + batch.drop_column("eligible_role_names") diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py index 8bc1741..08df63f 100644 --- a/app/modules/services/execution.py +++ b/app/modules/services/execution.py @@ -103,14 +103,70 @@ def parse_date_value(value: str | None) -> date | None: return date.fromisoformat(text) -def _default_assignee_for_template(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> int | None: - role = (template.default_role_name or "").strip().lower() - if "partner" in role: +TASK_EXECUTION_ROLES = ("Partner", "Manager", "Staff") + + +def normalise_task_execution_role(value: str | None) -> str | None: + text = (value or "").strip().lower() + aliases = { + "partner": "Partner", + "engagement partner": "Partner", + "manager": "Manager", + "branch manager": "Manager", + "staff": "Staff", + "employee": "Staff", + } + return aliases.get(text) + + +def normalise_eligible_task_roles(value: str | None, default_role_name: str | None = None) -> list[str]: + roles: list[str] = [] + for piece in (value or "").replace("|", ",").replace(";", ",").split(","): + role = normalise_task_execution_role(piece) + if role and role not in roles: + roles.append(role) + default_role = normalise_task_execution_role(default_role_name) + if default_role and default_role not in roles: + roles.insert(0, default_role) + return roles + + +def serialise_eligible_task_roles(roles, default_role_name: str | None = None) -> str | None: + values: list[str] = [] + for raw in roles or []: + role = normalise_task_execution_role(raw) + if role and role not in values: + values.append(role) + default_role = normalise_task_execution_role(default_role_name) + if default_role and default_role not in values: + values.insert(0, default_role) + return ",".join(values) if values else (default_role or None) + + +def _assignee_for_role(subscription: ClientServiceSubscription, role: str | None) -> int | None: + role = normalise_task_execution_role(role) + if role == "Partner": return subscription.assigned_partner_user_id - if "manager" in role: + if role == "Manager": return subscription.assigned_manager_user_id - if "staff" in role or "employee" in role: + if role == "Staff": return subscription.assigned_staff_user_id + return None + + +def _default_assignee_for_template(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> int | None: + default_role = normalise_task_execution_role(template.default_role_name) + eligible = normalise_eligible_task_roles(getattr(template, "eligible_role_names", None), template.default_role_name) + + assignee = _assignee_for_role(subscription, default_role) + if assignee: + return assignee + + for role in eligible: + assignee = _assignee_for_role(subscription, role) + if assignee: + return assignee + return subscription.assigned_staff_user_id or subscription.assigned_manager_user_id or subscription.assigned_partner_user_id @@ -241,6 +297,7 @@ def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceS description=template.description, sequence_no=template.sequence_no, default_role_name=template.default_role_name, + eligible_role_names=serialise_eligible_task_roles((getattr(template, "eligible_role_names", None) or "").split(","), template.default_role_name), assigned_to_user_id=_default_assignee_for_template(subscription, template), internal_target_date=_default_internal_target_date(subscription, template), status="pending", @@ -295,6 +352,174 @@ def generate_tasks_for_subscription_if_ready( ) +def _copy_template_snapshot_to_task( + task: ClientServiceTaskInstance, + *, + template: FirmServiceTaskTemplate, + subscription: ClientServiceSubscription, + update_assignment: bool, +) -> None: + task.task_name = template.task_name + task.description = template.description + task.sequence_no = template.sequence_no + task.default_role_name = template.default_role_name + task.eligible_role_names = serialise_eligible_task_roles( + (getattr(template, "eligible_role_names", None) or "").split(","), + template.default_role_name, + ) + task.task_category = getattr(template, "task_category", None) + task.response_required = getattr(template, "response_required", False) + task.response_type = (getattr(template, "response_type", "NONE") or "NONE").upper() + task.evidence_required = getattr(template, "evidence_required", False) + task.remarks_required_if_no = getattr(template, "remarks_required_if_no", False) + task.is_aqmm_task = getattr(template, "is_aqmm_task", False) + task.aqmm_mandatory = getattr(template, "aqmm_mandatory", False) + task.aqmm_evidence_required = getattr(template, "aqmm_evidence_required", False) + task.aqmm_manager_review_required = getattr(template, "aqmm_manager_review_required", False) + task.aqmm_partner_review_required = getattr(template, "aqmm_partner_review_required", False) + task.aqmm_review_partner_required = getattr(template, "aqmm_review_partner_required", False) + task.aqmm_blocks_final_release = getattr(template, "aqmm_blocks_final_release", False) + task.aqmm_reference = getattr(template, "aqmm_reference", None) + if update_assignment: + task.assigned_to_user_id = _default_assignee_for_template(subscription, template) + + +def sync_open_engagement_tasks_for_service( + db: Session, + *, + tenant_id: int, + catalogue_id: int, + user_id: int, + include_started_open_tasks: bool = False, +) -> dict[str, int]: + templates = db.execute( + select(FirmServiceTaskTemplate) + .where( + FirmServiceTaskTemplate.tenant_id == tenant_id, + FirmServiceTaskTemplate.service_catalogue_id == catalogue_id, + FirmServiceTaskTemplate.is_active.is_(True), + ) + .order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc()) + ).scalars().all() + + engagements = db.execute( + select(ClientServiceSubscription) + .where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.service_catalogue_id == catalogue_id, + ClientServiceSubscription.is_active.is_(True), + ClientServiceSubscription.status == "active", + ClientServiceSubscription.is_locked.is_(False), + ) + .order_by(ClientServiceSubscription.id.asc()) + ).scalars().all() + + result = { + "engagements": len(engagements), + "created": 0, + "updated_pending": 0, + "updated_started": 0, + "preserved_history": 0, + } + + for subscription in engagements: + existing = db.execute( + select(ClientServiceTaskInstance).where( + ClientServiceTaskInstance.subscription_id == subscription.id, + ClientServiceTaskInstance.financial_year == subscription.financial_year, + ) + ).scalars().all() + by_template = { + int(row.firm_task_template_id): row + for row in existing + if row.firm_task_template_id is not None + } + + for template in templates: + task = by_template.get(int(template.id)) + if task is None: + db.add( + ClientServiceTaskInstance( + tenant_id=subscription.tenant_id, + branch_id=subscription.branch_id, + subscription_id=subscription.id, + client_id=subscription.client_id, + service_catalogue_id=subscription.service_catalogue_id, + firm_task_template_id=template.id, + financial_year=subscription.financial_year, + assessment_year=subscription.assessment_year, + task_name=template.task_name, + description=template.description, + sequence_no=template.sequence_no, + default_role_name=template.default_role_name, + eligible_role_names=serialise_eligible_task_roles( + (getattr(template, "eligible_role_names", None) or "").split(","), + template.default_role_name, + ), + assigned_to_user_id=_default_assignee_for_template(subscription, template), + internal_target_date=_default_internal_target_date(subscription, template), + status="pending", + priority="normal", + task_category=getattr(template, "task_category", None), + response_required=getattr(template, "response_required", False), + response_type=(getattr(template, "response_type", "NONE") or "NONE").upper(), + evidence_required=getattr(template, "evidence_required", False), + remarks_required_if_no=getattr(template, "remarks_required_if_no", False), + is_aqmm_task=getattr(template, "is_aqmm_task", False), + aqmm_mandatory=getattr(template, "aqmm_mandatory", False), + aqmm_evidence_required=getattr(template, "aqmm_evidence_required", False), + aqmm_manager_review_required=getattr(template, "aqmm_manager_review_required", False), + aqmm_partner_review_required=getattr(template, "aqmm_partner_review_required", False), + aqmm_review_partner_required=getattr(template, "aqmm_review_partner_required", False), + aqmm_blocks_final_release=getattr(template, "aqmm_blocks_final_release", False), + aqmm_reference=getattr(template, "aqmm_reference", None), + aqmm_status="pending" if getattr(template, "is_aqmm_task", False) else "not_required", + aqmm_review_status="pending_review" if ( + getattr(template, "aqmm_manager_review_required", False) + or getattr(template, "aqmm_partner_review_required", False) + or getattr(template, "aqmm_review_partner_required", False) + ) else "not_required", + is_active=True, + created_by_user_id=user_id, + updated_by_user_id=user_id, + ) + ) + result["created"] += 1 + 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" + ) + + if never_started: + _copy_template_snapshot_to_task( + task, + template=template, + subscription=subscription, + update_assignment=True, + ) + task.updated_by_user_id = user_id + result["updated_pending"] += 1 + elif include_started_open_tasks and status in {"in_progress", "blocked"} and task.completed_at_utc is None: + task.default_role_name = template.default_role_name + task.eligible_role_names = serialise_eligible_task_roles( + (getattr(template, "eligible_role_names", None) or "").split(","), + template.default_role_name, + ) + task.assigned_to_user_id = _default_assignee_for_template(subscription, template) + task.updated_by_user_id = user_id + result["updated_started"] += 1 + else: + result["preserved_history"] += 1 + + return result + + def _latest_task_documents(db: Session, task_id: int) -> list[EngagementDocument]: return db.execute( diff --git a/app/modules/services/models.py b/app/modules/services/models.py index e95d4e4..212a7a6 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -111,6 +111,7 @@ class ServiceDefaultTaskTemplate(CommonBase): description: Mapped[str | None] = mapped_column(Text, nullable=True) sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1) default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + eligible_role_names: Mapped[str | None] = mapped_column(String(200), nullable=True) is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) @@ -179,6 +180,7 @@ class FirmServiceTaskTemplate(CommonBase): sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1) default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + eligible_role_names: Mapped[str | None] = mapped_column(String(200), nullable=True) is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) @@ -724,6 +726,7 @@ class ClientServiceTaskInstance(CommonBase): description: Mapped[str | None] = mapped_column(Text, nullable=True) sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1) default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + eligible_role_names: Mapped[str | None] = mapped_column(String(200), nullable=True) assigned_to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) execution_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="internal", index=True) diff --git a/app/modules/services/templates/services/default_task_form.html b/app/modules/services/templates/services/default_task_form.html index 53bf130..25b80e6 100644 --- a/app/modules/services/templates/services/default_task_form.html +++ b/app/modules/services/templates/services/default_task_form.html @@ -18,8 +18,28 @@
- - + + +

This role receives the task first. If unavailable, another eligible role can be used.

+
+ +
+
Eligible Roles
+

Select every role allowed to perform this task. For Manager or Partner, select both.

+ {% set eligible_text = task.eligible_role_names or task.default_role_name or '' %} +
+ {% for role in task_execution_roles %} + + {% endfor %} +
diff --git a/app/modules/services/templates/services/firm_task_form.html b/app/modules/services/templates/services/firm_task_form.html index ba0a18c..d347da5 100644 --- a/app/modules/services/templates/services/firm_task_form.html +++ b/app/modules/services/templates/services/firm_task_form.html @@ -18,8 +18,28 @@
- - + + +

This role receives the task first. If unavailable, another eligible role can be used.

+
+ +
+
Eligible Roles
+

Select every role allowed to perform this task. For Manager or Partner, select both.

+ {% set eligible_text = task.eligible_role_names or task.default_role_name or '' %} +
+ {% for role in task_execution_roles %} + + {% endfor %} +
@@ -89,6 +109,15 @@
+
+
Existing Open Engagements
+

Optional. Add missing tasks and refresh pending/not-started task role/assignee from the current firm template. Completed/history stays unchanged.

+
+ + +
+
+
To deactivate this task, untick Active and save. No hard delete is used.
diff --git a/app/modules/services/templates/services/task_template_detail.html b/app/modules/services/templates/services/task_template_detail.html index 8d11db8..1e8860a 100644 --- a/app/modules/services/templates/services/task_template_detail.html +++ b/app/modules/services/templates/services/task_template_detail.html @@ -59,8 +59,19 @@
- - + + +
+
+
Eligible Roles
+
+ {% for role in task_execution_roles %} + + {% endfor %} +
@@ -99,6 +110,32 @@ {% endif %} + {% if request.query_params.get('synced') %} +
+ Open engagement sync completed: + {{ request.query_params.get('created', '0') }} missing task(s) added, + {{ request.query_params.get('updated_pending', '0') }} pending task(s) refreshed, + {{ request.query_params.get('updated_started', '0') }} started task assignee(s) refreshed, + {{ request.query_params.get('preserved', '0') }} historical/unsafe task(s) preserved. +
+ {% endif %} + + {% if can_manage_tasks %} +
+
+
+

Sync Firm Tasks to Open Engagements

+

Adds missing active tasks and refreshes pending/not-started role and assignee. Completed tasks, evidence, comments, review history and locked engagements are preserved.

+
+
+ + + +
+
+
+ {% endif %} +

Firm Task List

@@ -124,7 +161,7 @@
{{ task.task_name }}
{% if task.description %}
{{ task.description }}
{% endif %} - {{ task.default_role_name or '-' }} +
{{ task.default_role_name or '-' }}
Eligible: {{ task.eligible_role_names or task.default_role_name or '-' }}
{% if task.is_mandatory %}Mandatory{% endif %} diff --git a/app/modules/services/ui.py b/app/modules/services/ui.py index a8ffa68..48a9eab 100644 --- a/app/modules/services/ui.py +++ b/app/modules/services/ui.py @@ -36,6 +36,11 @@ from app.modules.services.due_dates import ( parse_optional_date, ) from app.modules.services.catalogue_merge import build_merge_preview, merge_service_catalogues +from app.modules.services.execution import ( + TASK_EXECUTION_ROLES, + serialise_eligible_task_roles, + sync_open_engagement_tasks_for_service, +) from app.modules.services.task_documents import ( create_task_document_requirement, get_task_document_requirement, @@ -79,6 +84,7 @@ def _base_ctx(request: Request, user, db, **ctx): "engagement_type_choices": ENGAGEMENT_TYPE_CHOICES, "due_period_types": DUE_PERIOD_TYPES, "due_year_basis_choices": DUE_YEAR_BASIS_CHOICES, + "task_execution_roles": TASK_EXECUTION_ROLES, } base.update(ctx) return base @@ -939,6 +945,7 @@ def _copy_system_defaults_if_firm_tasks_empty( 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, is_aqmm_task=getattr(default, "is_aqmm_task", False), @@ -1234,7 +1241,7 @@ def task_templates_detail(request: Request, catalogue_id: int): @router.post('/templates/{catalogue_id}/tasks/new') -def task_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)): +def task_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), eligible_roles: list[str] = Form([]), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: @@ -1256,6 +1263,7 @@ def task_template_create_submit(request: Request, catalogue_id: int, task_name: task_name=task_name.strip(), description=description.strip() or None, default_role_name=default_role_name.strip() or None, + eligible_role_names=serialise_eligible_task_roles(eligible_roles, default_role_name), sequence_no=sequence_no or next_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id), is_mandatory=is_mandatory is not None, requires_review=requires_review is not None, @@ -1602,6 +1610,48 @@ def firm_task_template_toggle_active( db.close() +@router.post('/templates/{catalogue_id}/sync-open-engagements') +def sync_firm_tasks_to_open_engagements( + request: Request, + catalogue_id: int, + include_started_open_tasks: str | None = Form(None), + 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) + 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) + + result = sync_open_engagement_tasks_for_service( + db, + tenant_id=tenant_id, + catalogue_id=catalogue_id, + user_id=user.id, + include_started_open_tasks=include_started_open_tasks is not None, + ) + db.commit() + return RedirectResponse( + url=( + f'/services/templates/{catalogue_id}' + f'?synced=1&created={result["created"]}' + f'&updated_pending={result["updated_pending"]}' + f'&updated_started={result["updated_started"]}' + f'&preserved={result["preserved_history"]}' + ), + status_code=303, + ) + finally: + db.close() + + @router.get('/templates/{catalogue_id}/tasks/{task_id}/edit') def firm_task_template_edit_page(request: Request, catalogue_id: int, task_id: int): db = CommonSessionLocal() @@ -1653,6 +1703,7 @@ def firm_task_template_edit_submit( task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), + eligible_roles: list[str] = Form([]), sequence_no: int = Form(1), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), @@ -1670,6 +1721,8 @@ def firm_task_template_edit_submit( aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), + sync_open_engagements: str | None = Form(None), + sync_started_open_tasks: str | None = Form(None), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) @@ -1696,6 +1749,7 @@ def firm_task_template_edit_submit( task.task_name = task_name.strip() task.description = description.strip() or None task.default_role_name = default_role_name.strip() or None + task.eligible_role_names = serialise_eligible_task_roles(eligible_roles, default_role_name) task.sequence_no = sequence_no task.is_mandatory = is_mandatory is not None task.requires_review = requires_review is not None @@ -1715,7 +1769,28 @@ def firm_task_template_edit_submit( task.is_active = is_active is not None task.updated_by_user_id = user.id + sync_result = None + if sync_open_engagements is not None: + sync_result = sync_open_engagement_tasks_for_service( + db, + tenant_id=tenant_id, + catalogue_id=catalogue_id, + user_id=user.id, + include_started_open_tasks=sync_started_open_tasks is not None, + ) + db.commit() + if sync_result is not None: + return RedirectResponse( + url=( + f'/services/templates/{catalogue_id}' + f'?synced=1&created={sync_result["created"]}' + f'&updated_pending={sync_result["updated_pending"]}' + f'&updated_started={sync_result["updated_started"]}' + f'&preserved={sync_result["preserved_history"]}' + ), + status_code=303, + ) return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) finally: db.close() @@ -1770,6 +1845,7 @@ def default_task_template_edit_submit( task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), + eligible_roles: list[str] = Form([]), sequence_no: int = Form(1), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), @@ -1815,6 +1891,7 @@ def default_task_template_edit_submit( task.task_name = task_name.strip() task.description = description.strip() or None task.default_role_name = default_role_name.strip() or None + task.eligible_role_names = serialise_eligible_task_roles(eligible_roles, default_role_name) task.sequence_no = sequence_no task.is_mandatory = is_mandatory is not None task.requires_review = requires_review is not None