diff --git a/alembic/versions/20260830_service_task_category_master.py b/alembic/versions/20260830_service_task_category_master.py
new file mode 100644
index 0000000..21508cd
--- /dev/null
+++ b/alembic/versions/20260830_service_task_category_master.py
@@ -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")
diff --git a/app/modules/services/models.py b/app/modules/services/models.py
index b93b0cc..4099f04 100644
--- a/app/modules/services/models.py
+++ b/app/modules/services/models.py
@@ -24,6 +24,31 @@ class ServiceCategory(CommonBase):
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):
__tablename__ = "service_catalogues"
__table_args__ = (
@@ -116,6 +141,15 @@ class ServiceDefaultTaskTemplate(CommonBase):
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
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
# the engagement quality checklist for assurance engagements, without creating
# a separate duplicate AQMM checklist module.
@@ -186,6 +220,14 @@ class FirmServiceTaskTemplate(CommonBase):
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
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.
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)
diff --git a/app/modules/services/services.py b/app/modules/services/services.py
index c3320fd..ead707a 100644
--- a/app/modules/services/services.py
+++ b/app/modules/services/services.py
@@ -11,6 +11,7 @@ from app.modules.services.models import (
ServiceCatalogue,
ServiceCategory,
ServiceDefaultTaskTemplate,
+ ServiceTaskCategory,
ServiceDueDateRule,
)
@@ -267,3 +268,60 @@ def get_default_task_template(
ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id,
)
).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
diff --git a/app/modules/services/templates/services/default_task_form.html b/app/modules/services/templates/services/default_task_form.html
index e4848a3..085f63d 100644
--- a/app/modules/services/templates/services/default_task_form.html
+++ b/app/modules/services/templates/services/default_task_form.html
@@ -81,7 +81,14 @@
-
+
diff --git a/app/modules/services/templates/services/default_templates_detail.html b/app/modules/services/templates/services/default_templates_detail.html
index 2027141..9163776 100644
--- a/app/modules/services/templates/services/default_templates_detail.html
+++ b/app/modules/services/templates/services/default_templates_detail.html
@@ -6,7 +6,7 @@
{{ service.service_name }}
System default task templates for {{ service.service_code }}
-
+
@@ -43,6 +43,7 @@
+
diff --git a/app/modules/services/templates/services/firm_task_form.html b/app/modules/services/templates/services/firm_task_form.html
index 25ec367..90ccdc1 100644
--- a/app/modules/services/templates/services/firm_task_form.html
+++ b/app/modules/services/templates/services/firm_task_form.html
@@ -81,7 +81,14 @@
-
+
diff --git a/app/modules/services/templates/services/task_category_list.html b/app/modules/services/templates/services/task_category_list.html
new file mode 100644
index 0000000..689c70c
--- /dev/null
+++ b/app/modules/services/templates/services/task_category_list.html
@@ -0,0 +1,11 @@
+{% extends "ui/templates/base/layout.html" %}
+{% block content %}
+
+
Task Categories
{{ service.service_code }} · {{ service.service_name }} · {{ 'System defaults' if system_scope else 'Current firm' }}
Back to Tasks
+ {% if request.query_params.get('created') %}
Task category created.
{% endif %}
+ {% if request.query_params.get('updated') %}
Task category updated. Linked task templates keep the same grouping name.
{% endif %}
+ {% if request.query_params.get('error') %}
Unable to save this category. Check the code/name and try again.
{% endif %}
+ {% if can_manage %}
{% endif %}
+
| Order | Code | Category | Status | |
{% for row in rows %}{% if can_manage %}{% else %}| {{ row.sort_order }} | {{ row.code }} | {{ row.name }} | {{ 'Active' if row.is_active else 'Inactive' }} | | {% endif %}
{% else %}| No task categories yet. Existing uncategorised tasks continue to work normally. |
{% endfor %}
+
+{% endblock %}
diff --git a/app/modules/services/templates/services/task_template_detail.html b/app/modules/services/templates/services/task_template_detail.html
index 0b71190..703c9cd 100644
--- a/app/modules/services/templates/services/task_template_detail.html
+++ b/app/modules/services/templates/services/task_template_detail.html
@@ -11,6 +11,7 @@
@@ -81,7 +82,10 @@
-
+
diff --git a/app/modules/services/ui.py b/app/modules/services/ui.py
index 088c219..dc5e688 100644
--- a/app/modules/services/ui.py
+++ b/app/modules/services/ui.py
@@ -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.tenancy.models import Branch
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 (
build_template as build_bulk_import_template,
import_client_service_assignments,
@@ -68,6 +68,9 @@ from app.modules.services.services import (
get_default_task_templates,
get_firm_task_template,
get_default_task_template,
+ list_task_categories,
+ get_task_category,
+ ensure_task_category,
)
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_tasks=_can_manage_firm_tasks(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,
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),
@@ -958,6 +962,12 @@ def _copy_system_defaults_if_firm_tasks_empty(
is_mandatory=default.is_mandatory,
requires_review=default.requires_review,
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),
aqmm_mandatory=getattr(default, "aqmm_mandatory", 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),
default_tasks=get_default_task_templates(db, catalogue_id=catalogue_id),
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:
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,
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_type=(response_type.strip().upper() or "NONE"),
evidence_required=evidence_required is not None,
@@ -1356,7 +1368,7 @@ def default_templates_detail(request: Request, catalogue_id: int):
if not catalogue:
return RedirectResponse(url='/services/defaults', status_code=303)
defaults = get_default_task_templates(db, catalogue_id=catalogue_id)
- return _render(request, 'modules/services/templates/services/default_templates_detail.html', db, user, title=f'Default Tasks - {catalogue.service_name}', service=catalogue, default_tasks=defaults)
+ 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:
db.close()
@@ -1375,7 +1387,7 @@ def default_template_create_submit(request: Request, catalogue_id: int, task_nam
catalogue = get_catalogue(db, catalogue_id)
if not catalogue:
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()
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
finally:
@@ -1415,7 +1427,8 @@ def copy_defaults_to_firm(request: Request, catalogue_id: int, csrf_token: str =
is_mandatory=d.is_mandatory,
requires_review=d.requires_review,
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_type=getattr(d, "response_type", "NONE") or "NONE",
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}',
service=catalogue,
task=task,
+ task_categories=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=tenant_id, include_inactive=True),
)
finally:
db.close()
@@ -1825,7 +1839,9 @@ def firm_task_template_edit_submit(
task.is_mandatory = is_mandatory 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.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_type = response_type.strip().upper() or "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}',
service=catalogue,
task=task,
+ task_categories=list_task_categories(db, catalogue_id=catalogue_id, tenant_id=None, include_inactive=True),
)
finally:
db.close()
@@ -1969,7 +1986,9 @@ def default_task_template_edit_submit(
task.is_mandatory = is_mandatory 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.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_type = response_type.strip().upper() or "NONE"
task.evidence_required = evidence_required is not None
@@ -1989,6 +2008,126 @@ def default_task_template_edit_submit(
finally:
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
# Kept inside existing Services module intentionally; no separate router required.