Add task category master for service task templates
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,7 +81,14 @@
|
||||
<div class="mt-3 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<p class="text-sm text-slate-500">System default task templates for {{ service.service_code }}</p>
|
||||
</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>
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="space-y-3">
|
||||
@@ -43,6 +43,7 @@
|
||||
<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">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 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>
|
||||
|
||||
@@ -81,7 +81,14 @@
|
||||
<div class="mt-3 grid gap-4 md:grid-cols-2">
|
||||
<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>
|
||||
<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>
|
||||
<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 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/{{ 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,7 +82,10 @@
|
||||
</div>
|
||||
<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>
|
||||
<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 class="lg:col-span-3">
|
||||
<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.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.
|
||||
|
||||
Reference in New Issue
Block a user