Add task category master for service task templates
This commit is contained in:
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user