Add task category master for service task templates

This commit is contained in:
A R R R Associates
2026-08-30 21:42:32 +05:30
parent 31b576b363
commit ad0815c807
9 changed files with 328 additions and 11 deletions
@@ -0,0 +1,48 @@
"""add service task category master
Revision ID: 20260830_task_category_master
Revises: 20260825_opening_balance_p21a
"""
from alembic import op
import sqlalchemy as sa
revision = "20260830_task_category_master"
down_revision = "20260825_opening_balance_p21a"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"service_task_categories",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("service_catalogue_id", sa.Integer(), sa.ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False),
sa.Column("code", sa.String(50), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="100"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "service_catalogue_id", "code", name="uq_service_task_categories_scope_code"),
)
for col in ("tenant_id", "service_catalogue_id", "code", "name"):
op.create_index(f"ix_service_task_categories_{col}", "service_task_categories", [col])
for table in ("service_default_task_templates", "firm_service_task_templates"):
op.add_column(table, sa.Column("task_category_id", sa.Integer(), sa.ForeignKey("service_task_categories.id", ondelete="SET NULL"), nullable=True))
op.create_index(f"ix_{table}_task_category_id", table, ["task_category_id"])
# Preserve all existing free-text categories by materialising them into the new master.
op.execute("""INSERT INTO service_task_categories (tenant_id, service_catalogue_id, code, name, sort_order, is_active, created_at_utc, updated_at_utc) SELECT DISTINCT ON (d.service_catalogue_id, lower(trim(d.task_category))) NULL, d.service_catalogue_id, substr(regexp_replace(upper(trim(d.task_category)), '[^A-Z0-9]+', '-', 'g'),1,40) || '-' || substr(md5(lower(trim(d.task_category))),1,8), trim(d.task_category), 100, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM service_default_task_templates d WHERE d.task_category IS NOT NULL AND trim(d.task_category) <> '' ORDER BY d.service_catalogue_id, lower(trim(d.task_category)), trim(d.task_category)""")
op.execute("""INSERT INTO service_task_categories (tenant_id, service_catalogue_id, code, name, sort_order, is_active, created_at_utc, updated_at_utc) SELECT DISTINCT ON (f.tenant_id, f.service_catalogue_id, lower(trim(f.task_category))) f.tenant_id, f.service_catalogue_id, substr(regexp_replace(upper(trim(f.task_category)), '[^A-Z0-9]+', '-', 'g'),1,40) || '-' || substr(md5(lower(trim(f.task_category))),1,8), trim(f.task_category), 100, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM firm_service_task_templates f WHERE f.task_category IS NOT NULL AND trim(f.task_category) <> '' ORDER BY f.tenant_id, f.service_catalogue_id, lower(trim(f.task_category)), trim(f.task_category)""")
op.execute("""UPDATE service_default_task_templates d SET task_category_id = c.id FROM service_task_categories c WHERE c.tenant_id IS NULL AND c.service_catalogue_id=d.service_catalogue_id AND lower(c.name)=lower(trim(d.task_category))""")
op.execute("""UPDATE firm_service_task_templates f SET task_category_id = c.id FROM service_task_categories c WHERE c.tenant_id=f.tenant_id AND c.service_catalogue_id=f.service_catalogue_id AND lower(c.name)=lower(trim(f.task_category))""")
def downgrade():
for table in ("firm_service_task_templates", "service_default_task_templates"):
op.drop_index(f"ix_{table}_task_category_id", table_name=table)
op.drop_column(table, "task_category_id")
for col in ("name", "code", "service_catalogue_id", "tenant_id"):
op.drop_index(f"ix_service_task_categories_{col}", table_name="service_task_categories")
op.drop_table("service_task_categories")
+42
View File
@@ -24,6 +24,31 @@ class ServiceCategory(CommonBase):
catalogue_items = relationship("ServiceCatalogue", back_populates="service_category") catalogue_items = relationship("ServiceCatalogue", back_populates="service_category")
class ServiceTaskCategory(CommonBase):
"""Reusable task grouping master for a service.
tenant_id=None stores the system/default category master. A tenant_id value
stores the firm-specific category master. Task templates keep the historical
task_category text snapshot for backward compatibility and execution history.
"""
__tablename__ = "service_task_categories"
__table_args__ = (
UniqueConstraint("tenant_id", "service_catalogue_id", "code", name="uq_service_task_categories_scope_code"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
class ServiceCatalogue(CommonBase): class ServiceCatalogue(CommonBase):
__tablename__ = "service_catalogues" __tablename__ = "service_catalogues"
__table_args__ = ( __table_args__ = (
@@ -116,6 +141,15 @@ class ServiceDefaultTaskTemplate(CommonBase):
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
normal_review_role: Mapped[str | None] = mapped_column(String(30), nullable=True) normal_review_role: Mapped[str | None] = mapped_column(String(30), nullable=True)
# Structured checklist/category configuration. task_category remains the
# persisted display snapshot used by existing execution/work-board logic.
task_category_id: Mapped[int | None] = mapped_column(ForeignKey("service_task_categories.id", ondelete="SET NULL"), nullable=True, index=True)
task_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
response_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
response_type: Mapped[str] = mapped_column(String(20), nullable=False, default="NONE")
evidence_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# AQMM task tags. These flags allow the existing service checklist to become # AQMM task tags. These flags allow the existing service checklist to become
# the engagement quality checklist for assurance engagements, without creating # the engagement quality checklist for assurance engagements, without creating
# a separate duplicate AQMM checklist module. # a separate duplicate AQMM checklist module.
@@ -186,6 +220,14 @@ class FirmServiceTaskTemplate(CommonBase):
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
normal_review_role: Mapped[str | None] = mapped_column(String(30), nullable=True) normal_review_role: Mapped[str | None] = mapped_column(String(30), nullable=True)
# Structured checklist/category configuration.
task_category_id: Mapped[int | None] = mapped_column(ForeignKey("service_task_categories.id", ondelete="SET NULL"), nullable=True, index=True)
task_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
response_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
response_type: Mapped[str] = mapped_column(String(20), nullable=False, default="NONE")
evidence_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# AQMM task tags copied into generated ClientServiceTaskInstance rows. # AQMM task tags copied into generated ClientServiceTaskInstance rows.
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
aqmm_mandatory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) aqmm_mandatory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+58
View File
@@ -11,6 +11,7 @@ from app.modules.services.models import (
ServiceCatalogue, ServiceCatalogue,
ServiceCategory, ServiceCategory,
ServiceDefaultTaskTemplate, ServiceDefaultTaskTemplate,
ServiceTaskCategory,
ServiceDueDateRule, ServiceDueDateRule,
) )
@@ -267,3 +268,60 @@ def get_default_task_template(
ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id, ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id,
) )
).scalar_one_or_none() ).scalar_one_or_none()
def list_task_categories(db: Session, *, catalogue_id: int, tenant_id: int | None, include_inactive: bool = False):
query = select(ServiceTaskCategory).where(
ServiceTaskCategory.service_catalogue_id == catalogue_id,
ServiceTaskCategory.tenant_id.is_(None) if tenant_id is None else ServiceTaskCategory.tenant_id == tenant_id,
)
if not include_inactive:
query = query.where(ServiceTaskCategory.is_active.is_(True))
return db.execute(query.order_by(ServiceTaskCategory.sort_order.asc(), ServiceTaskCategory.name.asc(), ServiceTaskCategory.id.asc())).scalars().all()
def get_task_category(db: Session, *, category_id: int, catalogue_id: int, tenant_id: int | None) -> ServiceTaskCategory | None:
query = select(ServiceTaskCategory).where(
ServiceTaskCategory.id == category_id,
ServiceTaskCategory.service_catalogue_id == catalogue_id,
ServiceTaskCategory.tenant_id.is_(None) if tenant_id is None else ServiceTaskCategory.tenant_id == tenant_id,
)
return db.execute(query).scalar_one_or_none()
def ensure_task_category(db: Session, *, catalogue_id: int, tenant_id: int | None, name: str | None, user_id: int | None = None) -> ServiceTaskCategory | None:
clean_name = (name or "").strip()
if not clean_name:
return None
scope_filter = ServiceTaskCategory.tenant_id.is_(None) if tenant_id is None else ServiceTaskCategory.tenant_id == tenant_id
existing = db.execute(select(ServiceTaskCategory).where(
ServiceTaskCategory.service_catalogue_id == catalogue_id,
scope_filter,
func.lower(ServiceTaskCategory.name) == clean_name.lower(),
)).scalar_one_or_none()
if existing:
return existing
base_code = normalize_code(clean_name)[:50] or "CATEGORY"
code = base_code
suffix = 2
while db.execute(select(ServiceTaskCategory.id).where(
ServiceTaskCategory.service_catalogue_id == catalogue_id,
scope_filter,
ServiceTaskCategory.code == code,
)).scalar_one_or_none() is not None:
tail = f"-{suffix}"
code = f"{base_code[:50-len(tail)]}{tail}"
suffix += 1
row = ServiceTaskCategory(
tenant_id=tenant_id,
service_catalogue_id=catalogue_id,
code=code,
name=clean_name,
sort_order=100,
is_active=True,
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
db.add(row)
db.flush()
return row
@@ -81,7 +81,14 @@
<div class="mt-3 grid gap-4 md:grid-cols-2"> <div class="mt-3 grid gap-4 md:grid-cols-2">
<div> <div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Task Category</label> <label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Task Category</label>
<input type="text" name="task_category" value="{{ task.task_category or '' }}" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm" placeholder="e.g. Accounting Verification"> <select name="task_category" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm">
<option value="">Uncategorised / General Workflow</option>
{% set current_category = task.task_category or '' %}
{% for category in task_categories or [] %}
<option value="{{ category.name }}" {% if current_category == category.name %}selected{% endif %}>{{ category.name }}{% if not category.is_active %} (Inactive){% endif %}</option>
{% endfor %}
{% if current_category and not (task_categories or [])|selectattr('name','equalto',current_category)|list %}<option value="{{ current_category }}" selected>{{ current_category }} (Legacy)</option>{% endif %}
</select>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Response Type</label> <label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Response Type</label>
@@ -6,7 +6,7 @@
<h2 class="text-xl font-semibold text-slate-900">{{ service.service_name }}</h2> <h2 class="text-xl font-semibold text-slate-900">{{ service.service_name }}</h2>
<p class="text-sm text-slate-500">System default task templates for {{ service.service_code }}</p> <p class="text-sm text-slate-500">System default task templates for {{ service.service_code }}</p>
</div> </div>
<div class="flex gap-2"><a href="/services/defaults" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a><a href="/services/catalogue/{{ service.id }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Catalogue Detail</a></div> <div class="flex gap-2"><a href="/services/catalogue/{{ service.id }}/task-categories" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Task Categories</a><a href="/services/defaults" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a><a href="/services/catalogue/{{ service.id }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Catalogue Detail</a></div>
</div> </div>
<section class="rounded-2xl bg-white p-5 shadow-soft"> <section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="space-y-3"> <div class="space-y-3">
@@ -43,6 +43,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div><label class="mb-2 block text-sm font-medium text-slate-700">Task Name</label><input type="text" name="task_name" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div> <div><label class="mb-2 block text-sm font-medium text-slate-700">Task Name</label><input type="text" name="task_name" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
<div><label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label><input type="text" name="default_role_name" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div> <div><label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label><input type="text" name="default_role_name" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
<div><label class="mb-2 block text-sm font-medium text-slate-700">Task Category</label><select name="task_category" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Uncategorised / General Workflow</option>{% for category in task_categories or [] %}{% if category.is_active %}<option value="{{ category.name }}">{{ category.name }}</option>{% endif %}{% endfor %}</select></div>
<div><label class="mb-2 block text-sm font-medium text-slate-700">Sequence No</label><input type="number" min="1" name="sequence_no" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div> <div><label class="mb-2 block text-sm font-medium text-slate-700">Sequence No</label><input type="number" min="1" name="sequence_no" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_mandatory" checked> Mandatory</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="requires_review"> Review</label><label class="inline-flex items-center gap-2">Normal reviewer <select name="normal_review_role" class="rounded border border-slate-300 px-2 py-1 text-xs"><option value="manager">Manager</option><option value="partner">Partner</option><option value="manager_or_partner">Manager / Partner</option></select></label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" checked> Active</label></div> <div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_mandatory" checked> Mandatory</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="requires_review"> Review</label><label class="inline-flex items-center gap-2">Normal reviewer <select name="normal_review_role" class="rounded border border-slate-300 px-2 py-1 text-xs"><option value="manager">Manager</option><option value="partner">Partner</option><option value="manager_or_partner">Manager / Partner</option></select></label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" checked> Active</label></div>
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></textarea></div> <div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></textarea></div>
@@ -81,7 +81,14 @@
<div class="mt-3 grid gap-4 md:grid-cols-2"> <div class="mt-3 grid gap-4 md:grid-cols-2">
<div> <div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Task Category <span class="font-normal normal-case text-emerald-600">(optional)</span></label> <label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Task Category <span class="font-normal normal-case text-emerald-600">(optional)</span></label>
<input type="text" name="task_category" value="{{ task.task_category or '' }}" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm" placeholder="Leave blank for the normal flat checklist"> <select name="task_category" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm">
<option value="">Uncategorised / General Workflow</option>
{% set current_category = task.task_category or '' %}
{% for category in task_categories or [] %}
<option value="{{ category.name }}" {% if current_category == category.name %}selected{% endif %}>{{ category.name }}{% if not category.is_active %} (Inactive){% endif %}</option>
{% endfor %}
{% if current_category and not (task_categories or [])|selectattr('name','equalto',current_category)|list %}<option value="{{ current_category }}" selected>{{ current_category }} (Legacy)</option>{% endif %}
</select>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Response Type</label> <label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Response Type</label>
@@ -0,0 +1,11 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><div><h2 class="text-xl font-semibold text-slate-900">Task Categories</h2><p class="mt-1 text-sm text-slate-500">{{ service.service_code }} · {{ service.service_name }} · {{ 'System defaults' if system_scope else 'Current firm' }}</p></div><a href="{{ '/services/catalogue/' ~ service.id ~ '/defaults' if system_scope else '/services/templates/' ~ service.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Tasks</a></div>
{% if request.query_params.get('created') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">Task category created.</div>{% endif %}
{% if request.query_params.get('updated') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">Task category updated. Linked task templates keep the same grouping name.</div>{% endif %}
{% if request.query_params.get('error') %}<div class="rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800">Unable to save this category. Check the code/name and try again.</div>{% endif %}
{% if can_manage %}<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Add Task Category</h3><form method="post" action="{{ '/services/catalogue/' ~ service.id ~ '/task-categories/new' if system_scope else '/services/templates/' ~ service.id ~ '/categories/new' }}" class="mt-4 grid gap-3 md:grid-cols-4"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input name="name" required placeholder="Category name" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><input name="code" placeholder="Code (auto if blank)" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><input name="sort_order" type="number" value="100" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Add Category</button></form></section>{% endif %}
<section class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Order</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Category</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Status</th><th class="px-4 py-3"></th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr>{% if can_manage %}<form method="post" action="{{ '/services/catalogue/' ~ service.id ~ '/task-categories/' ~ row.id ~ '/update' if system_scope else '/services/templates/' ~ service.id ~ '/categories/' ~ row.id ~ '/update' }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><td class="px-4 py-3"><input name="sort_order" type="number" value="{{ row.sort_order }}" class="w-20 rounded-lg border border-slate-300 px-2 py-1 text-sm"></td><td class="px-4 py-3"><input name="code" value="{{ row.code }}" required class="w-36 rounded-lg border border-slate-300 px-2 py-1 text-sm"></td><td class="px-4 py-3"><input name="name" value="{{ row.name }}" required class="w-full rounded-lg border border-slate-300 px-2 py-1 text-sm"></td><td class="px-4 py-3 text-sm"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if row.is_active %}checked{% endif %}> Active</label></td><td class="px-4 py-3 text-right"><button class="rounded-lg border border-brand-200 bg-brand-50 px-3 py-1.5 text-xs font-semibold text-brand-700">Save</button></td></form>{% else %}<td class="px-4 py-3 text-sm">{{ row.sort_order }}</td><td class="px-4 py-3 text-sm">{{ row.code }}</td><td class="px-4 py-3 text-sm font-medium">{{ row.name }}</td><td class="px-4 py-3 text-sm">{{ 'Active' if row.is_active else 'Inactive' }}</td><td></td>{% endif %}</tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500">No task categories yet. Existing uncategorised tasks continue to work normally.</td></tr>{% endfor %}</tbody></table></section>
</div>
{% endblock %}
@@ -11,6 +11,7 @@
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<a href="/services/templates" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Templates</a> <a href="/services/templates" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Templates</a>
<a href="/services/templates/{{ service.id }}/categories" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Task Categories</a>
<a href="/services/catalogue/{{ service.id }}" class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50">View Catalogue</a> <a href="/services/catalogue/{{ service.id }}" class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50">View Catalogue</a>
</div> </div>
</div> </div>
@@ -81,7 +82,10 @@
</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">Task Category <span class="font-normal normal-case text-slate-400">(optional)</span></label> <label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Task Category <span class="font-normal normal-case text-slate-400">(optional)</span></label>
<input name="task_category" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="e.g. GST, Accounts, Income Tax"> <select name="task_category" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">Uncategorised / General Workflow</option>
{% for category in task_categories or [] %}{% if category.is_active %}<option value="{{ category.name }}">{{ category.name }}</option>{% endif %}{% endfor %}
</select>
</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">Options</label> <label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Options</label>
+146 -7
View File
@@ -15,7 +15,7 @@ from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission from app.modules.core.rbac.permission_guard import require_permission
from app.modules.core.tenancy.models import Branch from app.modules.core.tenancy.models import Branch
from app.modules.registrations.models import RegistrationType from app.modules.registrations.models import RegistrationType
from app.modules.services.models import FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, ServiceDefaultTaskTemplate, ServiceDueDateRule, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate from app.modules.services.models import FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, ServiceDefaultTaskTemplate, ServiceDueDateRule, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate, ServiceTaskCategory
from app.modules.services.bulk_imports import ( from app.modules.services.bulk_imports import (
build_template as build_bulk_import_template, build_template as build_bulk_import_template,
import_client_service_assignments, import_client_service_assignments,
@@ -68,6 +68,9 @@ from app.modules.services.services import (
get_default_task_templates, get_default_task_templates,
get_firm_task_template, get_firm_task_template,
get_default_task_template, get_default_task_template,
list_task_categories,
get_task_category,
ensure_task_category,
) )
router = APIRouter(prefix="/services", tags=["services-ui"]) router = APIRouter(prefix="/services", tags=["services-ui"])
@@ -619,6 +622,7 @@ def catalogue_detail(request: Request, catalogue_id: int):
can_manage_firm_services=_can_manage_firm_services(db, user), can_manage_firm_services=_can_manage_firm_services(db, user),
can_manage_firm_tasks=_can_manage_firm_tasks(db, user), can_manage_firm_tasks=_can_manage_firm_tasks(db, user),
is_system_admin=_is_system_admin(db, user), is_system_admin=_is_system_admin(db, user),
task_categories=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=tenant_id, include_inactive=True),
branches=branches, branches=branches,
current_selection=get_firm_selection(db, tenant_id=tenant_id, catalogue_id=row.id), current_selection=get_firm_selection(db, tenant_id=tenant_id, catalogue_id=row.id),
current_templates=get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=row.id), current_templates=get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=row.id),
@@ -958,6 +962,12 @@ def _copy_system_defaults_if_firm_tasks_empty(
is_mandatory=default.is_mandatory, is_mandatory=default.is_mandatory,
requires_review=default.requires_review, requires_review=default.requires_review,
normal_review_role=getattr(default, "normal_review_role", None), normal_review_role=getattr(default, "normal_review_role", None),
task_category_id=(firm_category.id if (firm_category := ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=tenant_id, name=getattr(default, "task_category", None), user_id=user_id)) else None),
task_category=(firm_category.name if firm_category else None),
response_required=getattr(default, "response_required", False),
response_type=getattr(default, "response_type", "NONE") or "NONE",
evidence_required=getattr(default, "evidence_required", False),
remarks_required_if_no=getattr(default, "remarks_required_if_no", False),
is_aqmm_task=getattr(default, "is_aqmm_task", False), is_aqmm_task=getattr(default, "is_aqmm_task", False),
aqmm_mandatory=getattr(default, "aqmm_mandatory", False), aqmm_mandatory=getattr(default, "aqmm_mandatory", False),
aqmm_evidence_required=getattr(default, "aqmm_evidence_required", False), aqmm_evidence_required=getattr(default, "aqmm_evidence_required", False),
@@ -1245,6 +1255,7 @@ def task_templates_detail(request: Request, catalogue_id: int):
can_manage_tasks=_can_manage_firm_tasks(db, user), can_manage_tasks=_can_manage_firm_tasks(db, user),
default_tasks=get_default_task_templates(db, catalogue_id=catalogue_id), default_tasks=get_default_task_templates(db, catalogue_id=catalogue_id),
is_system_admin=_is_system_admin(db, user), is_system_admin=_is_system_admin(db, user),
task_categories=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=tenant_id, include_inactive=True),
) )
finally: finally:
db.close() db.close()
@@ -1278,7 +1289,8 @@ def task_template_create_submit(request: Request, catalogue_id: int, task_name:
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,
normal_review_role=_normal_review_role_value(normal_review_role, requires_review=requires_review is not None), normal_review_role=_normal_review_role_value(normal_review_role, requires_review=requires_review is not None),
task_category=task_category.strip() or None, task_category_id=(category.id if (category := ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=tenant_id, name=task_category, user_id=user.id)) else None),
task_category=(category.name if category else None),
response_required=response_required is not None, response_required=response_required is not None,
response_type=(response_type.strip().upper() or "NONE"), response_type=(response_type.strip().upper() or "NONE"),
evidence_required=evidence_required is not None, evidence_required=evidence_required is not None,
@@ -1356,7 +1368,7 @@ def default_templates_detail(request: Request, catalogue_id: int):
if not catalogue: if not catalogue:
return RedirectResponse(url='/services/defaults', status_code=303) return RedirectResponse(url='/services/defaults', status_code=303)
defaults = get_default_task_templates(db, catalogue_id=catalogue_id) defaults = get_default_task_templates(db, catalogue_id=catalogue_id)
return _render(request, 'modules/services/templates/services/default_templates_detail.html', db, user, title=f'Default Tasks - {catalogue.service_name}', service=catalogue, default_tasks=defaults) return _render(request, 'modules/services/templates/services/default_templates_detail.html', db, user, title=f'Default Tasks - {catalogue.service_name}', service=catalogue, default_tasks=defaults, task_categories=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=None, include_inactive=True))
finally: finally:
db.close() db.close()
@@ -1375,7 +1387,7 @@ def default_template_create_submit(request: Request, catalogue_id: int, task_nam
catalogue = get_catalogue(db, catalogue_id) catalogue = get_catalogue(db, catalogue_id)
if not catalogue: if not catalogue:
return RedirectResponse(url='/services/defaults', status_code=303) return RedirectResponse(url='/services/defaults', status_code=303)
row = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue_id, task_name=task_name.strip(), description=description.strip() or None, default_role_name=default_role_name.strip() or None, sequence_no=sequence_no or next_default_task_sequence(db, catalogue_id=catalogue_id), is_mandatory=is_mandatory is not None, requires_review=requires_review is not None, normal_review_role=_normal_review_role_value(normal_review_role, requires_review=requires_review is not None), task_category=task_category.strip() or None, response_required=response_required is not None, response_type=(response_type.strip().upper() or "NONE"), evidence_required=evidence_required is not None, remarks_required_if_no=remarks_required_if_no is not None, is_aqmm_task=is_aqmm_task is not None, aqmm_mandatory=aqmm_mandatory is not None, aqmm_evidence_required=aqmm_evidence_required is not None, aqmm_manager_review_required=aqmm_manager_review_required is not None, aqmm_partner_review_required=aqmm_partner_review_required is not None, aqmm_review_partner_required=aqmm_review_partner_required is not None, aqmm_blocks_final_release=aqmm_blocks_final_release is not None, aqmm_reference=aqmm_reference.strip() or None, is_active=is_active is not None) row = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue_id, task_name=task_name.strip(), description=description.strip() or None, default_role_name=default_role_name.strip() or None, sequence_no=sequence_no or next_default_task_sequence(db, catalogue_id=catalogue_id), is_mandatory=is_mandatory is not None, requires_review=requires_review is not None, normal_review_role=_normal_review_role_value(normal_review_role, requires_review=requires_review is not None), task_category_id=(category.id if (category := ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=None, name=task_category, user_id=user.id)) else None), task_category=(category.name if category else None), response_required=response_required is not None, response_type=(response_type.strip().upper() or "NONE"), evidence_required=evidence_required is not None, remarks_required_if_no=remarks_required_if_no is not None, is_aqmm_task=is_aqmm_task is not None, aqmm_mandatory=aqmm_mandatory is not None, aqmm_evidence_required=aqmm_evidence_required is not None, aqmm_manager_review_required=aqmm_manager_review_required is not None, aqmm_partner_review_required=aqmm_partner_review_required is not None, aqmm_review_partner_required=aqmm_review_partner_required is not None, aqmm_blocks_final_release=aqmm_blocks_final_release is not None, aqmm_reference=aqmm_reference.strip() or None, is_active=is_active is not None)
db.add(row); db.commit() db.add(row); db.commit()
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303) return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
finally: finally:
@@ -1415,7 +1427,8 @@ def copy_defaults_to_firm(request: Request, catalogue_id: int, csrf_token: str =
is_mandatory=d.is_mandatory, is_mandatory=d.is_mandatory,
requires_review=d.requires_review, requires_review=d.requires_review,
normal_review_role=getattr(d, "normal_review_role", None), normal_review_role=getattr(d, "normal_review_role", None),
task_category=getattr(d, "task_category", None), task_category_id=(firm_category.id if (firm_category := ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=tenant_id, name=getattr(d, "task_category", None), user_id=user.id)) else None),
task_category=(firm_category.name if firm_category else None),
response_required=getattr(d, "response_required", False), response_required=getattr(d, "response_required", False),
response_type=getattr(d, "response_type", "NONE") or "NONE", response_type=getattr(d, "response_type", "NONE") or "NONE",
evidence_required=getattr(d, "evidence_required", False), evidence_required=getattr(d, "evidence_required", False),
@@ -1760,6 +1773,7 @@ def firm_task_template_edit_page(request: Request, catalogue_id: int, task_id: i
title=f'Edit Firm Task - {catalogue.service_name}', title=f'Edit Firm Task - {catalogue.service_name}',
service=catalogue, service=catalogue,
task=task, task=task,
task_categories=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=tenant_id, include_inactive=True),
) )
finally: finally:
db.close() db.close()
@@ -1825,7 +1839,9 @@ def firm_task_template_edit_submit(
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
task.normal_review_role = _normal_review_role_value(normal_review_role, requires_review=task.requires_review) task.normal_review_role = _normal_review_role_value(normal_review_role, requires_review=task.requires_review)
task.task_category = task_category.strip() or None category = ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=tenant_id, name=task_category, user_id=user.id)
task.task_category_id = category.id if category else None
task.task_category = category.name if category else None
task.response_required = response_required is not None task.response_required = response_required is not None
task.response_type = response_type.strip().upper() or "NONE" task.response_type = response_type.strip().upper() or "NONE"
task.evidence_required = evidence_required is not None task.evidence_required = evidence_required is not None
@@ -1904,6 +1920,7 @@ def default_task_template_edit_page(request: Request, catalogue_id: int, task_id
title=f'Edit Default Task - {catalogue.service_name}', title=f'Edit Default Task - {catalogue.service_name}',
service=catalogue, service=catalogue,
task=task, task=task,
task_categories=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=None, include_inactive=True),
) )
finally: finally:
db.close() db.close()
@@ -1969,7 +1986,9 @@ def default_task_template_edit_submit(
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
task.normal_review_role = _normal_review_role_value(normal_review_role, requires_review=task.requires_review) task.normal_review_role = _normal_review_role_value(normal_review_role, requires_review=task.requires_review)
task.task_category = task_category.strip() or None category = ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=None, name=task_category, user_id=user.id)
task.task_category_id = category.id if category else None
task.task_category = category.name if category else None
task.response_required = response_required is not None task.response_required = response_required is not None
task.response_type = response_type.strip().upper() or "NONE" task.response_type = response_type.strip().upper() or "NONE"
task.evidence_required = evidence_required is not None task.evidence_required = evidence_required is not None
@@ -1989,6 +2008,126 @@ def default_task_template_edit_submit(
finally: finally:
db.close() db.close()
# -----------------------------------------------------------------------------
# Service Task Category Master
# -----------------------------------------------------------------------------
@router.get('/templates/{catalogue_id}/categories')
def firm_task_categories_page(request: Request, catalogue_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url='/login', status_code=303)
if not (_has_perm(db, user, 'service_tasks.view') or _can_manage_firm_tasks(db, user)):
return _redirect_denied()
tenant_id = _active_tenant_id(request, user)
service = get_catalogue(db, catalogue_id)
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
if not service or not selection or not selection.is_enabled:
return RedirectResponse(url='/services/templates', status_code=303)
return _render(request, 'modules/services/templates/services/task_category_list.html', db, user,
title=f'Task Categories - {service.service_name}', service=service,
rows=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=tenant_id, include_inactive=True),
system_scope=False, can_manage=_can_manage_firm_tasks(db, user))
finally:
db.close()
@router.post('/templates/{catalogue_id}/categories/new')
def firm_task_category_create(request: Request, catalogue_id: int, name: str = Form(...), code: str = Form(''), sort_order: int = Form(100), 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)
service = get_catalogue(db, catalogue_id)
if not service: return RedirectResponse(url='/services/templates', status_code=303)
row = ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=tenant_id, name=name, user_id=user.id)
if row:
requested_code = normalize_code(code)[:50] if code.strip() else row.code
duplicate = db.execute(select(ServiceTaskCategory.id).where(ServiceTaskCategory.tenant_id == tenant_id, ServiceTaskCategory.service_catalogue_id == catalogue_id, ServiceTaskCategory.code == requested_code, ServiceTaskCategory.id != row.id)).scalar_one_or_none()
if duplicate is not None: return RedirectResponse(url=f'/services/templates/{catalogue_id}/categories?error=duplicate_code', status_code=303)
row.code = requested_code; row.sort_order = sort_order; row.is_active = True; row.updated_by_user_id = user.id
db.commit()
return RedirectResponse(url=f'/services/templates/{catalogue_id}/categories?created=1', status_code=303)
finally: db.close()
@router.post('/templates/{catalogue_id}/categories/{category_id}/update')
def firm_task_category_update(request: Request, catalogue_id: int, category_id: int, name: str = Form(...), code: str = Form(''), sort_order: int = Form(100), is_active: 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); row=get_task_category(db, category_id=category_id, catalogue_id=catalogue_id, tenant_id=tenant_id)
if not row: return RedirectResponse(url=f'/services/templates/{catalogue_id}/categories?error=not_found', status_code=303)
clean_name=name.strip(); clean_code=normalize_code(code or clean_name)[:50]
duplicate=db.execute(select(ServiceTaskCategory.id).where(ServiceTaskCategory.tenant_id==tenant_id, ServiceTaskCategory.service_catalogue_id==catalogue_id, ServiceTaskCategory.code==clean_code, ServiceTaskCategory.id!=row.id)).scalar_one_or_none()
if duplicate is not None: return RedirectResponse(url=f'/services/templates/{catalogue_id}/categories?error=duplicate_code', status_code=303)
old_name=row.name; row.name=clean_name; row.code=clean_code; row.sort_order=sort_order; row.is_active=is_active is not None; row.updated_by_user_id=user.id
for task in db.execute(select(FirmServiceTaskTemplate).where(FirmServiceTaskTemplate.tenant_id==tenant_id, FirmServiceTaskTemplate.service_catalogue_id==catalogue_id, FirmServiceTaskTemplate.task_category_id==row.id)).scalars().all(): task.task_category=clean_name
# Backward-compatible rows created before category IDs are also renamed when their text exactly matches.
for task in db.execute(select(FirmServiceTaskTemplate).where(FirmServiceTaskTemplate.tenant_id==tenant_id, FirmServiceTaskTemplate.service_catalogue_id==catalogue_id, FirmServiceTaskTemplate.task_category_id.is_(None), FirmServiceTaskTemplate.task_category==old_name)).scalars().all(): task.task_category_id=row.id; task.task_category=clean_name
db.commit(); return RedirectResponse(url=f'/services/templates/{catalogue_id}/categories?updated=1', status_code=303)
finally: db.close()
@router.get('/catalogue/{catalogue_id}/task-categories')
def system_task_categories_page(request: Request, catalogue_id: int):
db=CommonSessionLocal()
try:
user=get_current_user(request, db=db)
if not user: return RedirectResponse(url='/login', status_code=303)
require_permission(db,user,'service_tasks.view')
if not _is_system_admin(db,user): return _redirect_denied()
service=get_catalogue(db,catalogue_id)
if not service: return RedirectResponse(url='/services/defaults', status_code=303)
return _render(request,'modules/services/templates/services/task_category_list.html',db,user,title=f'System Task Categories - {service.service_name}',service=service,rows=list_task_categories(db,catalogue_id=catalogue_id,tenant_id=None,include_inactive=True),system_scope=True,can_manage=_has_perm(db,user,'service_tasks.edit') or _has_perm(db,user,'service_tasks.create'))
finally: db.close()
@router.post('/catalogue/{catalogue_id}/task-categories/new')
def system_task_category_create(request: Request, catalogue_id: int, name: str = Form(...), code: str = Form(''), sort_order: int = Form(100), 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)
require_permission(db,user,'service_tasks.create')
if not _is_system_admin(db,user): return _redirect_denied()
row=ensure_task_category(db,catalogue_id=catalogue_id,tenant_id=None,name=name,user_id=user.id)
if row:
requested_code=normalize_code(code)[:50] if code.strip() else row.code
duplicate=db.execute(select(ServiceTaskCategory.id).where(ServiceTaskCategory.tenant_id.is_(None), ServiceTaskCategory.service_catalogue_id==catalogue_id, ServiceTaskCategory.code==requested_code, ServiceTaskCategory.id!=row.id)).scalar_one_or_none()
if duplicate is not None: return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/task-categories?error=duplicate_code',status_code=303)
row.code=requested_code; row.sort_order=sort_order; row.is_active=True; row.updated_by_user_id=user.id
db.commit(); return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/task-categories?created=1',status_code=303)
finally: db.close()
@router.post('/catalogue/{catalogue_id}/task-categories/{category_id}/update')
def system_task_category_update(request: Request, catalogue_id: int, category_id: int, name: str = Form(...), code: str = Form(''), sort_order: int = Form(100), is_active: 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)
require_permission(db,user,'service_tasks.edit')
if not _is_system_admin(db,user): return _redirect_denied()
row=get_task_category(db,category_id=category_id,catalogue_id=catalogue_id,tenant_id=None)
if not row: return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/task-categories?error=not_found',status_code=303)
clean_name=name.strip(); clean_code=normalize_code(code or clean_name)[:50]
duplicate=db.execute(select(ServiceTaskCategory.id).where(ServiceTaskCategory.tenant_id.is_(None), ServiceTaskCategory.service_catalogue_id==catalogue_id, ServiceTaskCategory.code==clean_code, ServiceTaskCategory.id!=row.id)).scalar_one_or_none()
if duplicate is not None: return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/task-categories?error=duplicate_code',status_code=303)
old_name=row.name; row.name=clean_name; row.code=clean_code; row.sort_order=sort_order; row.is_active=is_active is not None; row.updated_by_user_id=user.id
for task in db.execute(select(ServiceDefaultTaskTemplate).where(ServiceDefaultTaskTemplate.service_catalogue_id==catalogue_id, ServiceDefaultTaskTemplate.task_category_id==row.id)).scalars().all(): task.task_category=row.name
for task in db.execute(select(ServiceDefaultTaskTemplate).where(ServiceDefaultTaskTemplate.service_catalogue_id==catalogue_id, ServiceDefaultTaskTemplate.task_category_id.is_(None), ServiceDefaultTaskTemplate.task_category==old_name)).scalars().all(): task.task_category_id=row.id; task.task_category=row.name
db.commit(); return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/task-categories?updated=1',status_code=303)
finally: db.close()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# S4.5 Bulk import and bulk service assignment routes # S4.5 Bulk import and bulk service assignment routes
# Kept inside existing Services module intentionally; no separate router required. # Kept inside existing Services module intentionally; no separate router required.