Add task role eligibility and safe open engagement sync
This commit is contained in:
@@ -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")
|
||||||
@@ -103,14 +103,70 @@ def parse_date_value(value: str | None) -> date | None:
|
|||||||
return date.fromisoformat(text)
|
return date.fromisoformat(text)
|
||||||
|
|
||||||
|
|
||||||
def _default_assignee_for_template(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> int | None:
|
TASK_EXECUTION_ROLES = ("Partner", "Manager", "Staff")
|
||||||
role = (template.default_role_name or "").strip().lower()
|
|
||||||
if "partner" in role:
|
|
||||||
|
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
|
return subscription.assigned_partner_user_id
|
||||||
if "manager" in role:
|
if role == "Manager":
|
||||||
return subscription.assigned_manager_user_id
|
return subscription.assigned_manager_user_id
|
||||||
if "staff" in role or "employee" in role:
|
if role == "Staff":
|
||||||
return subscription.assigned_staff_user_id
|
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
|
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,
|
description=template.description,
|
||||||
sequence_no=template.sequence_no,
|
sequence_no=template.sequence_no,
|
||||||
default_role_name=template.default_role_name,
|
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),
|
assigned_to_user_id=_default_assignee_for_template(subscription, template),
|
||||||
internal_target_date=_default_internal_target_date(subscription, template),
|
internal_target_date=_default_internal_target_date(subscription, template),
|
||||||
status="pending",
|
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]:
|
def _latest_task_documents(db: Session, task_id: int) -> list[EngagementDocument]:
|
||||||
return db.execute(
|
return db.execute(
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ class ServiceDefaultTaskTemplate(CommonBase):
|
|||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
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)
|
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, 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)
|
sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
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)
|
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, 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)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
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)
|
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)
|
execution_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="internal", index=True)
|
||||||
|
|||||||
@@ -18,8 +18,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label>
|
<label class="mb-2 block text-sm font-medium text-slate-700">Default Assignee 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">
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -18,8 +18,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label>
|
<label class="mb-2 block text-sm font-medium text-slate-700">Default Assignee 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">
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -89,6 +109,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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.
|
To deactivate this task, untick <strong>Active</strong> and save. No hard delete is used.
|
||||||
</div>
|
</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">
|
<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>
|
||||||
<div class="lg:col-span-3">
|
<div class="lg:col-span-3">
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Default Role</label>
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Default Assignee 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">
|
<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>
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Sequence</label>
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Sequence</label>
|
||||||
@@ -99,6 +110,32 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% 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">
|
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||||
<div class="border-b border-slate-100 px-5 py-4">
|
<div class="border-b border-slate-100 px-5 py-4">
|
||||||
<h3 class="text-sm font-semibold text-slate-900">Firm Task List</h3>
|
<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>
|
<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 %}
|
{% if task.description %}<div class="mt-1 text-xs text-slate-500">{{ task.description }}</div>{% endif %}
|
||||||
</td>
|
</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">
|
<td class="px-4 py-3 text-xs text-slate-600">
|
||||||
<div class="flex flex-wrap gap-1">
|
<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 %}
|
{% if task.is_mandatory %}<span class="rounded-full bg-slate-100 px-2 py-1">Mandatory</span>{% endif %}
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ from app.modules.services.due_dates import (
|
|||||||
parse_optional_date,
|
parse_optional_date,
|
||||||
)
|
)
|
||||||
from app.modules.services.catalogue_merge import build_merge_preview, merge_service_catalogues
|
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 (
|
from app.modules.services.task_documents import (
|
||||||
create_task_document_requirement,
|
create_task_document_requirement,
|
||||||
get_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,
|
"engagement_type_choices": ENGAGEMENT_TYPE_CHOICES,
|
||||||
"due_period_types": DUE_PERIOD_TYPES,
|
"due_period_types": DUE_PERIOD_TYPES,
|
||||||
"due_year_basis_choices": DUE_YEAR_BASIS_CHOICES,
|
"due_year_basis_choices": DUE_YEAR_BASIS_CHOICES,
|
||||||
|
"task_execution_roles": TASK_EXECUTION_ROLES,
|
||||||
}
|
}
|
||||||
base.update(ctx)
|
base.update(ctx)
|
||||||
return base
|
return base
|
||||||
@@ -939,6 +945,7 @@ def _copy_system_defaults_if_firm_tasks_empty(
|
|||||||
description=default.description,
|
description=default.description,
|
||||||
sequence_no=default.sequence_no,
|
sequence_no=default.sequence_no,
|
||||||
default_role_name=default.default_role_name,
|
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,
|
is_mandatory=default.is_mandatory,
|
||||||
requires_review=default.requires_review,
|
requires_review=default.requires_review,
|
||||||
is_aqmm_task=getattr(default, "is_aqmm_task", False),
|
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')
|
@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)
|
validate_csrf(request, csrf_token)
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -1256,6 +1263,7 @@ def task_template_create_submit(request: Request, catalogue_id: int, task_name:
|
|||||||
task_name=task_name.strip(),
|
task_name=task_name.strip(),
|
||||||
description=description.strip() or None,
|
description=description.strip() or None,
|
||||||
default_role_name=default_role_name.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),
|
sequence_no=sequence_no or next_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id),
|
||||||
is_mandatory=is_mandatory is not None,
|
is_mandatory=is_mandatory is not None,
|
||||||
requires_review=requires_review is not None,
|
requires_review=requires_review is not None,
|
||||||
@@ -1602,6 +1610,48 @@ def firm_task_template_toggle_active(
|
|||||||
db.close()
|
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')
|
@router.get('/templates/{catalogue_id}/tasks/{task_id}/edit')
|
||||||
def firm_task_template_edit_page(request: Request, catalogue_id: int, task_id: int):
|
def firm_task_template_edit_page(request: Request, catalogue_id: int, task_id: int):
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
@@ -1653,6 +1703,7 @@ def firm_task_template_edit_submit(
|
|||||||
task_name: str = Form(...),
|
task_name: str = Form(...),
|
||||||
description: str = Form(''),
|
description: str = Form(''),
|
||||||
default_role_name: str = Form(''),
|
default_role_name: str = Form(''),
|
||||||
|
eligible_roles: list[str] = Form([]),
|
||||||
sequence_no: int = Form(1),
|
sequence_no: int = Form(1),
|
||||||
is_mandatory: str | None = Form(None),
|
is_mandatory: str | None = Form(None),
|
||||||
requires_review: 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_blocks_final_release: str | None = Form(None),
|
||||||
aqmm_reference: str = Form(''),
|
aqmm_reference: str = Form(''),
|
||||||
is_active: str | None = Form(None),
|
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(...),
|
csrf_token: str = Form(...),
|
||||||
):
|
):
|
||||||
validate_csrf(request, csrf_token)
|
validate_csrf(request, csrf_token)
|
||||||
@@ -1696,6 +1749,7 @@ def firm_task_template_edit_submit(
|
|||||||
task.task_name = task_name.strip()
|
task.task_name = task_name.strip()
|
||||||
task.description = description.strip() or None
|
task.description = description.strip() or None
|
||||||
task.default_role_name = default_role_name.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.sequence_no = sequence_no
|
||||||
task.is_mandatory = is_mandatory is not None
|
task.is_mandatory = is_mandatory is not None
|
||||||
task.requires_review = requires_review 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.is_active = is_active is not None
|
||||||
task.updated_by_user_id = user.id
|
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()
|
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)
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -1770,6 +1845,7 @@ def default_task_template_edit_submit(
|
|||||||
task_name: str = Form(...),
|
task_name: str = Form(...),
|
||||||
description: str = Form(''),
|
description: str = Form(''),
|
||||||
default_role_name: str = Form(''),
|
default_role_name: str = Form(''),
|
||||||
|
eligible_roles: list[str] = Form([]),
|
||||||
sequence_no: int = Form(1),
|
sequence_no: int = Form(1),
|
||||||
is_mandatory: str | None = Form(None),
|
is_mandatory: str | None = Form(None),
|
||||||
requires_review: 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.task_name = task_name.strip()
|
||||||
task.description = description.strip() or None
|
task.description = description.strip() or None
|
||||||
task.default_role_name = default_role_name.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.sequence_no = sequence_no
|
||||||
task.is_mandatory = is_mandatory is not None
|
task.is_mandatory = is_mandatory is not None
|
||||||
task.requires_review = requires_review is not None
|
task.requires_review = requires_review is not None
|
||||||
|
|||||||
Reference in New Issue
Block a user