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 }}

-
BackCatalogue Detail
+
Task CategoriesBackCatalogue Detail
@@ -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 %}

Add Task Category

{% endif %} +
{% for row in rows %}{% if can_manage %}{% else %}{% endif %}{% else %}{% endfor %}
OrderCodeCategoryStatus
{{ row.sort_order }}{{ row.code }}{{ row.name }}{{ 'Active' if row.is_active else 'Inactive' }}
No task categories yet. Existing uncategorised tasks continue to work normally.
+
+{% 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.