Add task role eligibility and safe open engagement sync

This commit is contained in:
A R R R Associates
2026-08-08 15:38:05 +05:30
parent 1a02af213d
commit 01a1c1abc1
7 changed files with 452 additions and 13 deletions
@@ -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")
+230 -5
View File
@@ -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(
+3
View File
@@ -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)
@@ -18,8 +18,28 @@
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label>
<input type="text" name="default_role_name" value="{{ task.default_role_name or '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<label class="mb-2 block text-sm font-medium text-slate-700">Default Assignee Role</label>
<select name="default_role_name" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">Automatic / existing fallback</option>
{% for role in task_execution_roles %}
<option value="{{ role }}" {% if (task.default_role_name or '') == role %}selected{% endif %}>{{ role }}</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-slate-500">This role receives the task first. If unavailable, another eligible role can be used.</p>
</div>
<div class="md:col-span-2 rounded-xl border border-blue-200 bg-blue-50 p-4">
<div class="text-sm font-semibold text-blue-950">Eligible Roles</div>
<p class="mt-1 text-xs text-blue-800">Select every role allowed to perform this task. For Manager or Partner, select both.</p>
{% set eligible_text = task.eligible_role_names or task.default_role_name or '' %}
<div class="mt-3 flex flex-wrap gap-5 text-sm text-slate-700">
{% for role in task_execution_roles %}
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="eligible_roles" value="{{ role }}" {% if role in eligible_text.split(',') or role == task.default_role_name %}checked{% endif %}>
{{ role }}
</label>
{% endfor %}
</div>
</div>
<div>
@@ -18,8 +18,28 @@
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label>
<input type="text" name="default_role_name" value="{{ task.default_role_name or '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<label class="mb-2 block text-sm font-medium text-slate-700">Default Assignee Role</label>
<select name="default_role_name" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">Automatic / existing fallback</option>
{% for role in task_execution_roles %}
<option value="{{ role }}" {% if (task.default_role_name or '') == role %}selected{% endif %}>{{ role }}</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-slate-500">This role receives the task first. If unavailable, another eligible role can be used.</p>
</div>
<div class="md:col-span-2 rounded-xl border border-blue-200 bg-blue-50 p-4">
<div class="text-sm font-semibold text-blue-950">Eligible Roles</div>
<p class="mt-1 text-xs text-blue-800">Select every role allowed to perform this task. For Manager or Partner, select both.</p>
{% set eligible_text = task.eligible_role_names or task.default_role_name or '' %}
<div class="mt-3 flex flex-wrap gap-5 text-sm text-slate-700">
{% for role in task_execution_roles %}
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="eligible_roles" value="{{ role }}" {% if role in eligible_text.split(',') or role == task.default_role_name %}checked{% endif %}>
{{ role }}
</label>
{% endfor %}
</div>
</div>
<div>
@@ -89,6 +109,15 @@
</div>
</div>
<div class="md:col-span-2 rounded-xl border border-violet-200 bg-violet-50 p-4">
<div class="text-sm font-semibold text-violet-950">Existing Open Engagements</div>
<p class="mt-1 text-xs text-violet-800">Optional. Add missing tasks and refresh pending/not-started task role/assignee from the current firm template. Completed/history stays unchanged.</p>
<div class="mt-3 space-y-2 text-sm text-slate-700">
<label class="flex items-start gap-2"><input type="checkbox" name="sync_open_engagements" class="mt-1"><span><strong>Sync open engagements after Save</strong></span></label>
<label class="flex items-start gap-2"><input type="checkbox" name="sync_started_open_tasks" class="mt-1"><span><strong>Also update assignee for In Progress / Blocked tasks</strong> without changing status, evidence, comments or history.</span></label>
</div>
</div>
<div class="md:col-span-2 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
To deactivate this task, untick <strong>Active</strong> and save. No hard delete is used.
</div>
@@ -59,8 +59,19 @@
<input name="task_name" required class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="e.g. Verify books with GSTR-2B">
</div>
<div class="lg:col-span-3">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Default Role</label>
<input name="default_role_name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Staff / Manager / Partner">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Default Assignee Role</label>
<select name="default_role_name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">Automatic</option>
{% for role in task_execution_roles %}<option value="{{ role }}">{{ role }}</option>{% endfor %}
</select>
</div>
<div class="lg:col-span-12 rounded-xl border border-blue-200 bg-blue-50 p-3">
<div class="text-xs font-semibold uppercase tracking-wide text-blue-800">Eligible Roles</div>
<div class="mt-2 flex flex-wrap gap-4 text-xs text-slate-700">
{% for role in task_execution_roles %}
<label class="inline-flex items-center gap-1"><input type="checkbox" name="eligible_roles" value="{{ role }}"> {{ role }}</label>
{% endfor %}
</div>
</div>
<div class="lg:col-span-2">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Sequence</label>
@@ -99,6 +110,32 @@
</section>
{% endif %}
{% if request.query_params.get('synced') %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
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.
</div>
{% endif %}
{% if can_manage_tasks %}
<section class="rounded-2xl border border-violet-200 bg-violet-50 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-violet-950">Sync Firm Tasks to Open Engagements</h3>
<p class="mt-1 text-xs text-violet-800">Adds missing active tasks and refreshes pending/not-started role and assignee. Completed tasks, evidence, comments, review history and locked engagements are preserved.</p>
</div>
<form method="post" action="/services/templates/{{ service.id }}/sync-open-engagements" class="flex flex-wrap items-center gap-3" onsubmit="return confirm('Sync current firm task templates to open engagements?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="inline-flex items-center gap-2 text-xs text-violet-900"><input type="checkbox" name="include_started_open_tasks"> Also reassign In Progress / Blocked</label>
<button type="submit" class="rounded-xl bg-violet-600 px-4 py-2 text-sm font-semibold text-white hover:bg-violet-700">Sync Open Engagements</button>
</form>
</div>
</section>
{% endif %}
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-100 px-5 py-4">
<h3 class="text-sm font-semibold text-slate-900">Firm Task List</h3>
@@ -124,7 +161,7 @@
<div class="font-medium text-slate-900">{{ task.task_name }}</div>
{% if task.description %}<div class="mt-1 text-xs text-slate-500">{{ task.description }}</div>{% endif %}
</td>
<td class="px-4 py-3 text-sm text-slate-700">{{ task.default_role_name or '-' }}</td>
<td class="px-4 py-3 text-sm text-slate-700"><div>{{ task.default_role_name or '-' }}</div><div class="mt-1 text-xs text-slate-500">Eligible: {{ task.eligible_role_names or task.default_role_name or '-' }}</div></td>
<td class="px-4 py-3 text-xs text-slate-600">
<div class="flex flex-wrap gap-1">
{% if task.is_mandatory %}<span class="rounded-full bg-slate-100 px-2 py-1">Mandatory</span>{% endif %}
+78 -1
View File
@@ -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