2103 lines
88 KiB
Python
2103 lines
88 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, File, Form, Request, UploadFile
|
|
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
|
|
from sqlalchemy import select
|
|
|
|
from app.core.db.common import CommonSessionLocal
|
|
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
|
from app.core.security.session_auth import get_current_user
|
|
from app.core.templating import templates
|
|
from app.modules.core.audit.service import model_snapshot, pair_before_after, write_audit_log
|
|
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.bulk_imports import (
|
|
build_template as build_bulk_import_template,
|
|
import_client_service_assignments,
|
|
import_firm_task_templates,
|
|
import_service_master,
|
|
import_system_default_tasks,
|
|
import_due_date_extensions,
|
|
)
|
|
from app.modules.services.catalogue_export import build_service_catalogue_export
|
|
from app.modules.services.default_tasks_export import build_default_tasks_export
|
|
from app.modules.services.due_dates import (
|
|
DUE_PERIOD_TYPES,
|
|
DUE_YEAR_BASIS_CHOICES,
|
|
create_due_date_extension,
|
|
get_due_rule,
|
|
list_due_extensions,
|
|
list_due_rules,
|
|
parse_optional_date,
|
|
)
|
|
from app.modules.services.catalogue_merge import build_merge_preview, merge_service_catalogues
|
|
from app.modules.services.execution import (
|
|
TASK_EXECUTION_ROLES,
|
|
serialise_eligible_task_roles,
|
|
sync_open_engagement_tasks_for_service,
|
|
)
|
|
from app.modules.services.task_documents import (
|
|
create_task_document_requirement,
|
|
get_task_document_requirement,
|
|
list_task_document_requirements,
|
|
list_task_document_templates,
|
|
save_task_document_template,
|
|
template_absolute_path,
|
|
update_task_document_requirement,
|
|
)
|
|
from app.modules.services.services import (
|
|
RECURRENCE_CHOICES,
|
|
ENGAGEMENT_TYPE_CHOICES,
|
|
normalize_engagement_type,
|
|
get_catalogue,
|
|
get_category,
|
|
get_firm_selection,
|
|
get_firm_task_templates,
|
|
list_catalogue_payload,
|
|
list_categories,
|
|
list_disabled_catalogues,
|
|
list_firm_services_payload,
|
|
next_task_sequence,
|
|
next_default_task_sequence,
|
|
normalize_code,
|
|
get_default_task_templates,
|
|
get_firm_task_template,
|
|
get_default_task_template,
|
|
)
|
|
|
|
router = APIRouter(prefix="/services", tags=["services-ui"])
|
|
|
|
|
|
def _base_ctx(request: Request, user, db, **ctx):
|
|
base = {
|
|
"request": request,
|
|
"current_user": user,
|
|
"current_user_roles": get_user_roles(db, user.id),
|
|
"current_user_permissions": get_user_permissions(db, user.id),
|
|
"csrf_token": get_or_create_csrf_token(request),
|
|
"recurrence_choices": RECURRENCE_CHOICES,
|
|
"engagement_type_choices": ENGAGEMENT_TYPE_CHOICES,
|
|
"due_period_types": DUE_PERIOD_TYPES,
|
|
"due_year_basis_choices": DUE_YEAR_BASIS_CHOICES,
|
|
"task_execution_roles": TASK_EXECUTION_ROLES,
|
|
}
|
|
base.update(ctx)
|
|
return base
|
|
|
|
|
|
def _render(request: Request, template: str, db, user, **ctx):
|
|
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
|
|
|
|
|
|
def _active_registration_types(db):
|
|
"""Return the active Registration Type master rows used by Service Catalogue forms."""
|
|
return db.execute(
|
|
select(RegistrationType)
|
|
.where(RegistrationType.is_active.is_(True))
|
|
.order_by(RegistrationType.sort_order, RegistrationType.name, RegistrationType.code)
|
|
).scalars().all()
|
|
|
|
|
|
def _registration_type_form_context(db):
|
|
registration_types = _active_registration_types(db)
|
|
return {
|
|
"registration_types": registration_types,
|
|
"registration_type_codes": [row.code.upper() for row in registration_types],
|
|
}
|
|
|
|
|
|
def _validated_registration_type_code(db, value: str, *, existing_value: str | None = None) -> str | None:
|
|
"""Validate a submitted catalogue registration code against Registration Type master.
|
|
|
|
Existing non-master legacy values are allowed only when left unchanged so editing an
|
|
unrelated field never destroys historical catalogue data. New/changed values must be
|
|
active Registration Type master codes.
|
|
"""
|
|
code = (value or "").strip().upper()
|
|
if not code:
|
|
return None
|
|
|
|
exists = db.execute(
|
|
select(RegistrationType.id).where(
|
|
RegistrationType.code == code,
|
|
RegistrationType.is_active.is_(True),
|
|
)
|
|
).scalar_one_or_none()
|
|
if exists is not None:
|
|
return code
|
|
|
|
current = (existing_value or "").strip().upper()
|
|
if current and code == current:
|
|
return code
|
|
|
|
raise ValueError("registration_type")
|
|
|
|
|
|
def _redirect_denied():
|
|
from app.core.http_responses import ui_access_denied
|
|
return ui_access_denied()
|
|
|
|
|
|
def _has_perm(db, user, code: str) -> bool:
|
|
try:
|
|
require_permission(db, user, code)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _role_names(db, user) -> set[str]:
|
|
try:
|
|
return set(get_user_roles(db, user.id))
|
|
except Exception:
|
|
return set()
|
|
|
|
|
|
def _has_role(db, user, *names: str) -> bool:
|
|
roles = _role_names(db, user)
|
|
return any(name in roles for name in names)
|
|
|
|
|
|
def _can_view_services(db, user) -> bool:
|
|
return _has_perm(db, user, "services.view") or _has_role(db, user, "System Admin", "Firm Admin", "Partner", "Branch Manager")
|
|
|
|
|
|
def _is_system_admin(db, user) -> bool:
|
|
return _has_perm(db, user, "services.cross_tenant")
|
|
|
|
|
|
def _can_manage_firm_services(db, user) -> bool:
|
|
return _has_perm(db, user, "services.edit") or _has_role(db, user, "Firm Admin", "Partner")
|
|
|
|
|
|
def _can_manage_firm_tasks(db, user) -> bool:
|
|
return _has_perm(db, user, "service_tasks.create") or _has_perm(db, user, "service_tasks.edit") or _has_role(db, user, "Firm Admin", "Partner")
|
|
|
|
|
|
def _active_tenant_id(request: Request, user) -> int:
|
|
value = (
|
|
request.session.get("active_tenant_id")
|
|
or request.session.get("selected_tenant_id")
|
|
or request.session.get("tenant_id")
|
|
or user.tenant_id
|
|
)
|
|
return int(value)
|
|
|
|
|
|
@router.get("")
|
|
def services_home(request: Request, q: str = ""):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
try:
|
|
require_permission(db, user, "services.view")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
|
|
tenant_id = _active_tenant_id(request, user)
|
|
|
|
enabled_rows = list_firm_services_payload(db, tenant_id=tenant_id, q=q)
|
|
disabled_rows = list_disabled_catalogues(db, tenant_id=tenant_id, q=q)
|
|
branches = db.execute(
|
|
select(Branch).where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True)).order_by(Branch.name.asc())
|
|
).scalars().all()
|
|
return _render(
|
|
request,
|
|
"modules/services/templates/services/list.html",
|
|
db,
|
|
user,
|
|
title="Services",
|
|
q=q,
|
|
enabled_rows=enabled_rows,
|
|
disabled_rows=disabled_rows,
|
|
branches=branches,
|
|
can_manage_catalogue=_is_system_admin(db, user),
|
|
can_manage_firm_services=_can_manage_firm_services(db, user),
|
|
can_manage_firm_tasks=_can_manage_firm_tasks(db, user),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/categories')
|
|
def category_list(request: Request, q: str = ''):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse(url='/login', status_code=303)
|
|
if not _can_view_services(db, user):
|
|
return _redirect_denied()
|
|
rows = list_categories(db, q=q)
|
|
return _render(request, 'modules/services/templates/services/category_list.html', db, user, title='Service Categories', q=q, rows=rows, can_create=(_has_perm(db, user, 'services.create') and _is_system_admin(db, user)))
|
|
except Exception:
|
|
return _redirect_denied()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/categories/new')
|
|
def category_create_page(request: Request):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse(url='/login', status_code=303)
|
|
require_permission(db, user, 'services.create')
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
return _render(request, 'modules/services/templates/services/category_form.html', db, user, title='Create Service Category', mode='create', category=None)
|
|
except Exception:
|
|
return _redirect_denied()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/categories/new')
|
|
def category_create_submit(
|
|
request: Request,
|
|
code: str = Form(...),
|
|
name: 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, 'services.create')
|
|
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
|
|
row = ServiceCategory(
|
|
code=normalize_code(code),
|
|
name=name.strip(),
|
|
sort_order=sort_order,
|
|
is_active=is_active is not None,
|
|
)
|
|
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
|
|
return RedirectResponse(url='/services/categories', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
@router.get('/categories/{category_id}/edit')
|
|
def category_edit_page(request: Request, category_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, 'services.edit')
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
row = get_category(db, category_id)
|
|
if not row:
|
|
return RedirectResponse(url='/services/categories', status_code=303)
|
|
return _render(request, 'modules/services/templates/services/category_form.html', db, user, title='Edit Service Category', mode='edit', category=row)
|
|
except Exception:
|
|
return _redirect_denied()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/categories/{category_id}/edit')
|
|
def category_edit_submit(request: Request, category_id: int, name: 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, 'services.edit')
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
row = get_category(db, category_id)
|
|
if not row:
|
|
return RedirectResponse(url='/services/categories', status_code=303)
|
|
row.name = name.strip()
|
|
row.sort_order = sort_order
|
|
row.is_active = is_active is not None
|
|
db.commit()
|
|
return RedirectResponse(url='/services/categories', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue')
|
|
def catalogue_list(request: Request, q: str = '', category_id: str = '', recurrence_type: str = '', engagement_type: str = '', page: int = 1, per_page: int = 20):
|
|
category_id_value: int | None = None
|
|
category_id_text = (category_id or '').strip()
|
|
if category_id_text:
|
|
try:
|
|
parsed_category_id = int(category_id_text)
|
|
if parsed_category_id > 0:
|
|
category_id_value = parsed_category_id
|
|
except (TypeError, ValueError):
|
|
category_id_value = None
|
|
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse(url='/login', status_code=303)
|
|
if not _can_view_services(db, user):
|
|
return _redirect_denied()
|
|
payload = list_catalogue_payload(db, q=q, category_id=category_id_value, recurrence_type=recurrence_type, engagement_type=engagement_type, page=page, per_page=per_page)
|
|
tenant_id = _active_tenant_id(request, user)
|
|
can_create_catalogue = _is_system_admin(db, user) and _has_perm(db, user, 'services.create')
|
|
can_manage_firm_services = _can_manage_firm_services(db, user)
|
|
|
|
catalogue_ids = [row.id for row in payload.get('rows', [])]
|
|
firm_selection_by_catalogue = {}
|
|
if catalogue_ids:
|
|
selections = db.execute(
|
|
select(FirmServiceSelection).where(
|
|
FirmServiceSelection.tenant_id == tenant_id,
|
|
FirmServiceSelection.service_catalogue_id.in_(catalogue_ids),
|
|
)
|
|
).scalars().all()
|
|
firm_selection_by_catalogue = {selection.service_catalogue_id: selection for selection in selections}
|
|
|
|
branches = db.execute(
|
|
select(Branch)
|
|
.where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True))
|
|
.order_by(Branch.name.asc())
|
|
).scalars().all()
|
|
|
|
return _render(
|
|
request,
|
|
'modules/services/templates/services/catalogue_list.html',
|
|
db,
|
|
user,
|
|
title='Service Catalogue',
|
|
can_create=can_create_catalogue,
|
|
can_manage_firm_services=can_manage_firm_services,
|
|
categories=list_categories(db),
|
|
branches=branches,
|
|
tenant_id=tenant_id,
|
|
firm_selection_by_catalogue=firm_selection_by_catalogue,
|
|
**payload,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue/export')
|
|
def catalogue_export(request: Request):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse(url='/login', status_code=303)
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.view')
|
|
|
|
workbook_bytes = build_service_catalogue_export(db)
|
|
filename = f"service_catalogue_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.xlsx"
|
|
return StreamingResponse(
|
|
iter([workbook_bytes]),
|
|
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue/merge')
|
|
def catalogue_merge_page(request: Request, source_id: int | None = None, target_id: int | None = None):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse(url='/login', status_code=303)
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.edit')
|
|
|
|
services = db.execute(
|
|
select(ServiceCatalogue)
|
|
.where(ServiceCatalogue.is_active.is_(True))
|
|
.order_by(ServiceCatalogue.service_code.asc())
|
|
).scalars().all()
|
|
preview = None
|
|
error = None
|
|
if source_id and target_id:
|
|
try:
|
|
preview = build_merge_preview(db, source_id=source_id, target_id=target_id)
|
|
except ValueError as exc:
|
|
error = str(exc)
|
|
|
|
return _render(
|
|
request,
|
|
'modules/services/templates/services/catalogue_merge.html',
|
|
db,
|
|
user,
|
|
title='Merge Service Catalogue',
|
|
services=services,
|
|
source_id=source_id,
|
|
target_id=target_id,
|
|
preview=preview,
|
|
error=error,
|
|
)
|
|
except Exception:
|
|
return _redirect_denied()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/catalogue/merge')
|
|
def catalogue_merge_submit(
|
|
request: Request,
|
|
source_id: int = Form(...),
|
|
target_id: int = Form(...),
|
|
confirmation: str = Form(...),
|
|
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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.edit')
|
|
|
|
preview = build_merge_preview(db, source_id=source_id, target_id=target_id)
|
|
expected = f"MERGE {preview.source.service_code} INTO {preview.target.service_code}"
|
|
if confirmation.strip().upper() != expected.upper():
|
|
return RedirectResponse(
|
|
url=f'/services/catalogue/merge?source_id={source_id}&target_id={target_id}&error=confirmation',
|
|
status_code=303,
|
|
)
|
|
|
|
result = merge_service_catalogues(
|
|
db,
|
|
source_id=source_id,
|
|
target_id=target_id,
|
|
actor_user_id=user.id,
|
|
)
|
|
db.commit()
|
|
|
|
write_audit_log(
|
|
db,
|
|
action='services.catalogue.merged',
|
|
entity_type='service_catalogue',
|
|
entity_id=target_id,
|
|
entity_name=result['target_code'],
|
|
actor=user,
|
|
request=request,
|
|
details=result,
|
|
)
|
|
return RedirectResponse(url=f'/services/catalogue/{target_id}?merged=1', status_code=303)
|
|
except ValueError:
|
|
db.rollback()
|
|
return RedirectResponse(url='/services/catalogue/merge?error=invalid', status_code=303)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue/new')
|
|
def catalogue_create_page(request: Request):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse(url='/login', status_code=303)
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.create')
|
|
return _render(request, 'modules/services/templates/services/catalogue_form.html', db, user, title='Create Service Catalogue', mode='create', catalogue=None, categories=list_categories(db), **_registration_type_form_context(db))
|
|
except Exception:
|
|
return _redirect_denied()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/catalogue/new')
|
|
def catalogue_create_submit(request: Request, service_code: str = Form(...), service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), service_scope_type: str = Form('client'), required_registration_type: str = Form(''), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: 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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.create')
|
|
selected_category = get_category(db, int(category_id)) if str(category_id).strip() else None
|
|
try:
|
|
registration_type_code = _validated_registration_type_code(db, required_registration_type)
|
|
except ValueError:
|
|
return RedirectResponse(url='/services/catalogue/new?error=registration_type', status_code=303)
|
|
row = ServiceCatalogue(
|
|
service_code=normalize_code(service_code),
|
|
service_name=service_name.strip(),
|
|
category_id=selected_category.id if selected_category else None,
|
|
category=selected_category.name if selected_category else None,
|
|
recurrence_type=recurrence_type.strip() or None,
|
|
engagement_type=normalize_engagement_type(engagement_type),
|
|
service_scope_type=(service_scope_type or 'client').strip().lower(),
|
|
required_registration_type=registration_type_code,
|
|
sort_order=sort_order,
|
|
description=description.strip() or None,
|
|
applicable_individual=applicable_individual is not None,
|
|
applicable_proprietorship=applicable_proprietorship is not None,
|
|
applicable_partnership=applicable_partnership is not None,
|
|
applicable_llp=applicable_llp is not None,
|
|
applicable_company=applicable_company is not None,
|
|
applicable_trust=applicable_trust is not None,
|
|
applicable_society=applicable_society is not None,
|
|
is_active=is_active is not None,
|
|
is_client_requestable=is_client_requestable is not None,
|
|
is_consultant_requestable=is_consultant_requestable is not None,
|
|
created_by_user_id=user.id,
|
|
updated_by_user_id=user.id,
|
|
)
|
|
db.add(row); db.commit(); db.refresh(row)
|
|
return RedirectResponse(url=f'/services/catalogue/{row.id}', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue/{catalogue_id}')
|
|
def catalogue_detail(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 _can_view_services(db, user):
|
|
return _redirect_denied()
|
|
row = get_catalogue(db, catalogue_id)
|
|
if not row or not row.is_active:
|
|
return RedirectResponse(url='/services/catalogue', status_code=303)
|
|
tenant_id = _active_tenant_id(request, user)
|
|
can_edit = _is_system_admin(db, user) and _has_perm(db, user, 'services.edit')
|
|
branches = db.execute(
|
|
select(Branch)
|
|
.where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True))
|
|
.order_by(Branch.name.asc())
|
|
).scalars().all()
|
|
return _render(
|
|
request,
|
|
'modules/services/templates/services/catalogue_detail.html',
|
|
db,
|
|
user,
|
|
title=f'Service Catalogue - {row.service_name}',
|
|
catalogue=row,
|
|
can_edit=can_edit,
|
|
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),
|
|
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),
|
|
default_templates=get_default_task_templates(db, catalogue_id=row.id),
|
|
due_rules=list_due_rules(db, catalogue_id=row.id),
|
|
due_extensions=list_due_extensions(db, catalogue_id=row.id, tenant_id=tenant_id),
|
|
tenant_id=tenant_id,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue/{catalogue_id}/due-rules/new')
|
|
def due_rule_create_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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.edit')
|
|
catalogue = get_catalogue(db, catalogue_id)
|
|
if not catalogue:
|
|
return RedirectResponse(url='/services/catalogue', status_code=303)
|
|
return _render(
|
|
request,
|
|
'modules/services/templates/services/due_dates/rule_form.html',
|
|
db,
|
|
user,
|
|
title='Add Due Date Rule',
|
|
mode='create',
|
|
catalogue=catalogue,
|
|
rule=None,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/catalogue/{catalogue_id}/due-rules/new')
|
|
def due_rule_create_submit(
|
|
request: Request,
|
|
catalogue_id: int,
|
|
rule_name: str = Form(...),
|
|
period_type: str = Form('yearly'),
|
|
due_year_basis: str = Form('assessment_year_start'),
|
|
due_day: str = Form(''),
|
|
due_month: str = Form(''),
|
|
due_month_offset: int = Form(0),
|
|
days_offset_after_event: str = Form(''),
|
|
renewal_days_before_expiry: str = Form(''),
|
|
sort_order: int = Form(100),
|
|
is_active: str | None = Form(None),
|
|
remarks: str = Form(''),
|
|
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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.edit')
|
|
catalogue = get_catalogue(db, catalogue_id)
|
|
if not catalogue:
|
|
return RedirectResponse(url='/services/catalogue', status_code=303)
|
|
row = ServiceDueDateRule(
|
|
service_catalogue_id=catalogue_id,
|
|
rule_name=rule_name.strip(),
|
|
period_type=(period_type or 'yearly').strip(),
|
|
due_year_basis=(due_year_basis or 'assessment_year_start').strip(),
|
|
due_day=int(due_day) if str(due_day).strip() else None,
|
|
due_month=int(due_month) if str(due_month).strip() else None,
|
|
due_month_offset=int(due_month_offset or 0),
|
|
days_offset_after_event=int(days_offset_after_event) if str(days_offset_after_event).strip() else None,
|
|
renewal_days_before_expiry=int(renewal_days_before_expiry) if str(renewal_days_before_expiry).strip() else None,
|
|
sort_order=sort_order,
|
|
is_active=is_active is not None,
|
|
remarks=remarks.strip() or None,
|
|
created_by_user_id=user.id,
|
|
updated_by_user_id=user.id,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue/{catalogue_id}/due-rules/{rule_id}/edit')
|
|
def due_rule_edit_page(request: Request, catalogue_id: int, rule_id: int):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse(url='/login', status_code=303)
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.edit')
|
|
catalogue = get_catalogue(db, catalogue_id)
|
|
rule = get_due_rule(db, rule_id=rule_id, catalogue_id=catalogue_id)
|
|
if not catalogue or not rule:
|
|
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303)
|
|
return _render(
|
|
request,
|
|
'modules/services/templates/services/due_dates/rule_form.html',
|
|
db,
|
|
user,
|
|
title='Edit Due Date Rule',
|
|
mode='edit',
|
|
catalogue=catalogue,
|
|
rule=rule,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/catalogue/{catalogue_id}/due-rules/{rule_id}/edit')
|
|
def due_rule_edit_submit(
|
|
request: Request,
|
|
catalogue_id: int,
|
|
rule_id: int,
|
|
rule_name: str = Form(...),
|
|
period_type: str = Form('yearly'),
|
|
due_year_basis: str = Form('assessment_year_start'),
|
|
due_day: str = Form(''),
|
|
due_month: str = Form(''),
|
|
due_month_offset: int = Form(0),
|
|
days_offset_after_event: str = Form(''),
|
|
renewal_days_before_expiry: str = Form(''),
|
|
sort_order: int = Form(100),
|
|
is_active: str | None = Form(None),
|
|
remarks: str = Form(''),
|
|
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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.edit')
|
|
rule = get_due_rule(db, rule_id=rule_id, catalogue_id=catalogue_id)
|
|
if not rule:
|
|
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303)
|
|
rule.rule_name = rule_name.strip()
|
|
rule.period_type = (period_type or 'yearly').strip()
|
|
rule.due_year_basis = (due_year_basis or 'assessment_year_start').strip()
|
|
rule.due_day = int(due_day) if str(due_day).strip() else None
|
|
rule.due_month = int(due_month) if str(due_month).strip() else None
|
|
rule.due_month_offset = int(due_month_offset or 0)
|
|
rule.days_offset_after_event = int(days_offset_after_event) if str(days_offset_after_event).strip() else None
|
|
rule.renewal_days_before_expiry = int(renewal_days_before_expiry) if str(renewal_days_before_expiry).strip() else None
|
|
rule.sort_order = sort_order
|
|
rule.is_active = is_active is not None
|
|
rule.remarks = remarks.strip() or None
|
|
rule.updated_by_user_id = user.id
|
|
db.commit()
|
|
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue/{catalogue_id}/due-extensions/new')
|
|
def due_extension_create_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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.edit')
|
|
catalogue = get_catalogue(db, catalogue_id)
|
|
if not catalogue:
|
|
return RedirectResponse(url='/services/catalogue', status_code=303)
|
|
return _render(
|
|
request,
|
|
'modules/services/templates/services/due_dates/extension_form.html',
|
|
db,
|
|
user,
|
|
title='Add Due Date Extension',
|
|
catalogue=catalogue,
|
|
rules=list_due_rules(db, catalogue_id=catalogue_id, include_inactive=False),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/catalogue/{catalogue_id}/due-extensions/new')
|
|
def due_extension_create_submit(
|
|
request: Request,
|
|
catalogue_id: int,
|
|
due_date_rule_id: str = Form(''),
|
|
financial_year: str = Form(...),
|
|
assessment_year: str = Form(''),
|
|
period_label: str = Form(''),
|
|
extended_due_date: str = Form(...),
|
|
notification_reference: str = Form(''),
|
|
notification_date: str = Form(''),
|
|
remarks: str = Form(''),
|
|
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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.edit')
|
|
catalogue = get_catalogue(db, catalogue_id)
|
|
if not catalogue:
|
|
return RedirectResponse(url='/services/catalogue', status_code=303)
|
|
tenant_id = _active_tenant_id(request, user)
|
|
create_due_date_extension(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
catalogue_id=catalogue_id,
|
|
due_date_rule_id=int(due_date_rule_id) if str(due_date_rule_id).strip() else None,
|
|
financial_year=financial_year.strip(),
|
|
assessment_year=assessment_year.strip() or None,
|
|
period_label=period_label.strip() or None,
|
|
extended_due_date=parse_optional_date(extended_due_date),
|
|
notification_reference=notification_reference,
|
|
notification_date=parse_optional_date(notification_date),
|
|
remarks=remarks,
|
|
user_id=user.id,
|
|
)
|
|
db.commit()
|
|
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue/{catalogue_id}/edit')
|
|
def catalogue_edit_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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.edit')
|
|
row = get_catalogue(db, catalogue_id)
|
|
if not row or not row.is_active:
|
|
return RedirectResponse(url='/services/catalogue', status_code=303)
|
|
return _render(request, 'modules/services/templates/services/catalogue_form.html', db, user, title='Edit Service Catalogue', mode='edit', catalogue=row, categories=list_categories(db), **_registration_type_form_context(db))
|
|
except Exception:
|
|
return _redirect_denied()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/catalogue/{catalogue_id}/edit')
|
|
def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), service_scope_type: str = Form('client'), required_registration_type: str = Form(''), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: 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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
require_permission(db, user, 'services.edit')
|
|
row = get_catalogue(db, catalogue_id)
|
|
if not row or not row.is_active:
|
|
return RedirectResponse(url='/services/catalogue', status_code=303)
|
|
selected_category = get_category(db, int(category_id)) if str(category_id).strip() else None
|
|
try:
|
|
registration_type_code = _validated_registration_type_code(
|
|
db, required_registration_type, existing_value=row.required_registration_type
|
|
)
|
|
except ValueError:
|
|
return RedirectResponse(
|
|
url=f'/services/catalogue/{catalogue_id}/edit?error=registration_type',
|
|
status_code=303,
|
|
)
|
|
row.service_name = service_name.strip()
|
|
row.category_id = selected_category.id if selected_category else None
|
|
row.category = selected_category.name if selected_category else None
|
|
row.recurrence_type = recurrence_type.strip() or None
|
|
row.engagement_type = normalize_engagement_type(engagement_type)
|
|
row.service_scope_type = (service_scope_type or 'client').strip().lower()
|
|
row.required_registration_type = registration_type_code
|
|
row.sort_order = sort_order
|
|
row.applicable_individual = applicable_individual is not None
|
|
row.applicable_proprietorship = applicable_proprietorship is not None
|
|
row.applicable_partnership = applicable_partnership is not None
|
|
row.applicable_llp = applicable_llp is not None
|
|
row.applicable_company = applicable_company is not None
|
|
row.applicable_trust = applicable_trust is not None
|
|
row.applicable_society = applicable_society is not None
|
|
row.is_active = is_active is not None
|
|
row.is_client_requestable = is_client_requestable is not None
|
|
row.is_consultant_requestable = is_consultant_requestable is not None
|
|
row.updated_by_user_id = user.id
|
|
db.commit()
|
|
return RedirectResponse(url=f'/services/catalogue/{row.id}', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _copy_system_defaults_if_firm_tasks_empty(
|
|
db,
|
|
*,
|
|
tenant_id: int,
|
|
catalogue_id: int,
|
|
user_id: int,
|
|
) -> tuple[int, bool]:
|
|
"""Initialise firm task templates once without overwriting custom firm tasks."""
|
|
existing_tasks = get_firm_task_templates(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
catalogue_id=catalogue_id,
|
|
)
|
|
if existing_tasks:
|
|
return 0, True
|
|
|
|
defaults = get_default_task_templates(db, catalogue_id=catalogue_id)
|
|
copied_count = 0
|
|
for default in defaults:
|
|
db.add(FirmServiceTaskTemplate(
|
|
tenant_id=tenant_id,
|
|
service_catalogue_id=catalogue_id,
|
|
task_name=default.task_name,
|
|
description=default.description,
|
|
sequence_no=default.sequence_no,
|
|
default_role_name=default.default_role_name,
|
|
eligible_role_names=getattr(default, "eligible_role_names", None) or default.default_role_name,
|
|
is_mandatory=default.is_mandatory,
|
|
requires_review=default.requires_review,
|
|
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),
|
|
aqmm_manager_review_required=getattr(default, "aqmm_manager_review_required", False),
|
|
aqmm_partner_review_required=getattr(default, "aqmm_partner_review_required", False),
|
|
aqmm_review_partner_required=getattr(default, "aqmm_review_partner_required", False),
|
|
aqmm_blocks_final_release=getattr(default, "aqmm_blocks_final_release", False),
|
|
aqmm_reference=getattr(default, "aqmm_reference", None),
|
|
is_active=default.is_active,
|
|
created_by_user_id=user_id,
|
|
updated_by_user_id=user_id,
|
|
))
|
|
copied_count += 1
|
|
|
|
return copied_count, False
|
|
|
|
|
|
@router.post('/catalogue/bulk-selection')
|
|
def bulk_update_firm_services(
|
|
request: Request,
|
|
service_ids: list[int] = Form([]),
|
|
bulk_action: str = Form(...),
|
|
default_branch_id: str = Form(''),
|
|
return_q: str = Form(''),
|
|
return_category_id: str = Form(''),
|
|
return_recurrence_type: str = Form(''),
|
|
return_engagement_type: str = Form(''),
|
|
return_page: int = Form(1),
|
|
return_per_page: int = Form(20),
|
|
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_services(db, user):
|
|
return _redirect_denied()
|
|
|
|
tenant_id = _active_tenant_id(request, user)
|
|
action = (bulk_action or '').strip().lower()
|
|
if action not in {'enable', 'disable'}:
|
|
action = 'invalid'
|
|
|
|
unique_service_ids = list(dict.fromkeys(service_id for service_id in service_ids if service_id > 0))
|
|
branch_id: int | None = None
|
|
branch_value = str(default_branch_id or '').strip()
|
|
if branch_value:
|
|
try:
|
|
parsed_branch_id = int(branch_value)
|
|
except (TypeError, ValueError):
|
|
parsed_branch_id = 0
|
|
branch = db.execute(
|
|
select(Branch).where(
|
|
Branch.id == parsed_branch_id,
|
|
Branch.tenant_id == tenant_id,
|
|
Branch.is_active.is_(True),
|
|
)
|
|
).scalar_one_or_none()
|
|
if not branch:
|
|
action = 'invalid_branch'
|
|
else:
|
|
branch_id = branch.id
|
|
|
|
updated_count = 0
|
|
unchanged_count = 0
|
|
skipped_count = 0
|
|
tasks_copied_count = 0
|
|
services_with_existing_tasks = 0
|
|
services_without_defaults = 0
|
|
|
|
if action in {'enable', 'disable'} and unique_service_ids:
|
|
catalogue_rows = db.execute(
|
|
select(ServiceCatalogue).where(
|
|
ServiceCatalogue.id.in_(unique_service_ids),
|
|
ServiceCatalogue.is_active.is_(True),
|
|
)
|
|
).scalars().all()
|
|
catalogue_by_id = {row.id: row for row in catalogue_rows}
|
|
|
|
selections = db.execute(
|
|
select(FirmServiceSelection).where(
|
|
FirmServiceSelection.tenant_id == tenant_id,
|
|
FirmServiceSelection.service_catalogue_id.in_(unique_service_ids),
|
|
)
|
|
).scalars().all()
|
|
selection_by_catalogue = {
|
|
selection.service_catalogue_id: selection for selection in selections
|
|
}
|
|
|
|
for catalogue_id in unique_service_ids:
|
|
if catalogue_id not in catalogue_by_id:
|
|
skipped_count += 1
|
|
continue
|
|
|
|
selection = selection_by_catalogue.get(catalogue_id)
|
|
if action == 'enable':
|
|
if selection is None:
|
|
selection = FirmServiceSelection(
|
|
tenant_id=tenant_id,
|
|
service_catalogue_id=catalogue_id,
|
|
is_enabled=True,
|
|
default_branch_id=branch_id,
|
|
activated_by_user_id=user.id,
|
|
updated_by_user_id=user.id,
|
|
)
|
|
db.add(selection)
|
|
updated_count += 1
|
|
else:
|
|
changed = not bool(selection.is_enabled)
|
|
selection.is_enabled = True
|
|
if branch_id is not None and selection.default_branch_id != branch_id:
|
|
selection.default_branch_id = branch_id
|
|
changed = True
|
|
selection.updated_by_user_id = user.id
|
|
if changed:
|
|
updated_count += 1
|
|
else:
|
|
unchanged_count += 1
|
|
|
|
copied_count, had_existing_tasks = _copy_system_defaults_if_firm_tasks_empty(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
catalogue_id=catalogue_id,
|
|
user_id=user.id,
|
|
)
|
|
tasks_copied_count += copied_count
|
|
if had_existing_tasks:
|
|
services_with_existing_tasks += 1
|
|
elif copied_count == 0:
|
|
services_without_defaults += 1
|
|
else:
|
|
if selection is not None and selection.is_enabled:
|
|
selection.is_enabled = False
|
|
selection.updated_by_user_id = user.id
|
|
updated_count += 1
|
|
else:
|
|
unchanged_count += 1
|
|
|
|
db.commit()
|
|
elif not unique_service_ids:
|
|
action = 'no_selection'
|
|
|
|
query_parts = [
|
|
f'bulk_action={action}',
|
|
f'bulk_updated={updated_count}',
|
|
f'bulk_unchanged={unchanged_count}',
|
|
f'bulk_skipped={skipped_count}',
|
|
f'tasks_copied={tasks_copied_count}',
|
|
f'services_existing_tasks={services_with_existing_tasks}',
|
|
f'services_without_defaults={services_without_defaults}',
|
|
f'page={max(1, return_page)}',
|
|
f'per_page={return_per_page if return_per_page in (20, 50, 100) else 20}',
|
|
]
|
|
if return_q:
|
|
from urllib.parse import quote_plus
|
|
query_parts.append(f'q={quote_plus(return_q)}')
|
|
if return_category_id:
|
|
query_parts.append(f'category_id={return_category_id}')
|
|
if return_recurrence_type:
|
|
query_parts.append(f'recurrence_type={return_recurrence_type}')
|
|
if return_engagement_type:
|
|
query_parts.append(f'engagement_type={return_engagement_type}')
|
|
|
|
return RedirectResponse(
|
|
url='/services/catalogue?' + '&'.join(query_parts),
|
|
status_code=303,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/catalogue/{catalogue_id}/toggle')
|
|
def toggle_firm_service(request: Request, catalogue_id: int, default_branch_id: str = Form(''), 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_services(db, user):
|
|
return _redirect_denied()
|
|
tenant_id = _active_tenant_id(request, user)
|
|
row = get_catalogue(db, catalogue_id)
|
|
if not row or not row.is_active:
|
|
return RedirectResponse(url='/services/catalogue', status_code=303)
|
|
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
enabling = selection is None or not bool(selection.is_enabled)
|
|
if not selection:
|
|
selection = FirmServiceSelection(
|
|
tenant_id=tenant_id,
|
|
service_catalogue_id=catalogue_id,
|
|
is_enabled=True,
|
|
default_branch_id=int(default_branch_id) if str(default_branch_id).strip() else None,
|
|
activated_by_user_id=user.id,
|
|
updated_by_user_id=user.id,
|
|
)
|
|
db.add(selection)
|
|
else:
|
|
selection.is_enabled = not bool(selection.is_enabled)
|
|
if str(default_branch_id).strip():
|
|
selection.default_branch_id = int(default_branch_id)
|
|
selection.updated_by_user_id = user.id
|
|
|
|
copied_count = 0
|
|
had_existing_tasks = False
|
|
if enabling:
|
|
copied_count, had_existing_tasks = _copy_system_defaults_if_firm_tasks_empty(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
catalogue_id=catalogue_id,
|
|
user_id=user.id,
|
|
)
|
|
|
|
db.commit()
|
|
if enabling:
|
|
return RedirectResponse(
|
|
url=(
|
|
f'/services/catalogue?service_action=enabled'
|
|
f'&tasks_copied={copied_count}'
|
|
f'&existing_tasks={1 if had_existing_tasks else 0}'
|
|
),
|
|
status_code=303,
|
|
)
|
|
return RedirectResponse(url='/services/catalogue?service_action=disabled', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/templates')
|
|
def task_templates_list(request: Request):
|
|
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)
|
|
enabled_rows = list_firm_services_payload(db, tenant_id=tenant_id)
|
|
for row in enabled_rows:
|
|
row['task_count'] = len(get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=row['catalogue'].id))
|
|
return _render(request, 'modules/services/templates/services/task_templates_list.html', db, user, title='Firm Service Task Templates', rows=enabled_rows, can_manage_tasks=_can_manage_firm_tasks(db, user))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/templates/{catalogue_id}')
|
|
def task_templates_detail(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()
|
|
catalogue = get_catalogue(db, catalogue_id)
|
|
if not catalogue:
|
|
return RedirectResponse(url='/services/templates', status_code=303)
|
|
tenant_id = _active_tenant_id(request, user)
|
|
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
if not selection or not selection.is_enabled:
|
|
return RedirectResponse(url='/services', status_code=303)
|
|
tasks = get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
task_requirement_map = {
|
|
task.id: list_task_document_requirements(db, tenant_id=tenant_id, firm_task_template_id=task.id)
|
|
for task in tasks
|
|
}
|
|
task_template_file_map = {
|
|
task.id: list_task_document_templates(db, tenant_id=tenant_id, firm_task_template_id=task.id)
|
|
for task in tasks
|
|
}
|
|
return _render(
|
|
request,
|
|
'modules/services/templates/services/task_template_detail.html',
|
|
db,
|
|
user,
|
|
title=f'Firm Task Templates - {catalogue.service_name}',
|
|
service=catalogue,
|
|
selection=selection,
|
|
task_templates=tasks,
|
|
task_requirement_map=task_requirement_map,
|
|
task_template_file_map=task_template_file_map,
|
|
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),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/templates/{catalogue_id}/tasks/new')
|
|
def task_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), eligible_roles: list[str] = Form([]), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), 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()
|
|
catalogue = get_catalogue(db, catalogue_id)
|
|
if not catalogue:
|
|
return RedirectResponse(url='/services/templates', status_code=303)
|
|
tenant_id = _active_tenant_id(request, user)
|
|
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
if not selection or not selection.is_enabled:
|
|
return RedirectResponse(url='/services', status_code=303)
|
|
row = FirmServiceTaskTemplate(
|
|
tenant_id=tenant_id,
|
|
service_catalogue_id=catalogue_id,
|
|
task_name=task_name.strip(),
|
|
description=description.strip() or None,
|
|
default_role_name=default_role_name.strip() or None,
|
|
eligible_role_names=serialise_eligible_task_roles(eligible_roles, default_role_name),
|
|
sequence_no=sequence_no or next_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id),
|
|
is_mandatory=is_mandatory is not None,
|
|
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,
|
|
created_by_user_id=user.id,
|
|
updated_by_user_id=user.id,
|
|
)
|
|
db.add(row); db.commit()
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/defaults')
|
|
def default_templates_catalogue_list(request: Request):
|
|
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()
|
|
payload = list_catalogue_payload(db, page=1, per_page=500)
|
|
rows = payload['rows']
|
|
return _render(request, 'modules/services/templates/services/default_templates_list.html', db, user, title='Default Task Templates', rows=rows, can_manage_defaults=_has_perm(db, user, 'service_tasks.create'))
|
|
except Exception:
|
|
return _redirect_denied()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/defaults/export')
|
|
def default_tasks_export(request: Request):
|
|
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()
|
|
|
|
workbook_bytes = build_default_tasks_export(db)
|
|
filename = f"system_default_tasks_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.xlsx"
|
|
return StreamingResponse(
|
|
iter([workbook_bytes]),
|
|
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue/{catalogue_id}/defaults')
|
|
def default_templates_detail(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()
|
|
catalogue = get_catalogue(db, catalogue_id)
|
|
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)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/catalogue/{catalogue_id}/defaults/new')
|
|
def default_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), 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.create')
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
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, 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)
|
|
db.add(row); db.commit()
|
|
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/catalogue/{catalogue_id}/defaults/copy-to-firm')
|
|
def copy_defaults_to_firm(request: Request, catalogue_id: int, 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)
|
|
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
if not selection or not selection.is_enabled:
|
|
return RedirectResponse(url='/services', status_code=303)
|
|
existing_sequences = {t.sequence_no for t in get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=catalogue_id)}
|
|
defaults = get_default_task_templates(db, catalogue_id=catalogue_id)
|
|
next_seq = next_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
for d in defaults:
|
|
seq = d.sequence_no
|
|
if seq in existing_sequences:
|
|
seq = next_seq
|
|
next_seq += 1
|
|
existing_sequences.add(seq)
|
|
db.add(FirmServiceTaskTemplate(
|
|
tenant_id=tenant_id,
|
|
service_catalogue_id=catalogue_id,
|
|
task_name=d.task_name,
|
|
description=d.description,
|
|
sequence_no=seq,
|
|
default_role_name=d.default_role_name,
|
|
is_mandatory=d.is_mandatory,
|
|
requires_review=d.requires_review,
|
|
task_category=getattr(d, "task_category", None),
|
|
response_required=getattr(d, "response_required", False),
|
|
response_type=getattr(d, "response_type", "NONE") or "NONE",
|
|
evidence_required=getattr(d, "evidence_required", False),
|
|
remarks_required_if_no=getattr(d, "remarks_required_if_no", False),
|
|
is_aqmm_task=getattr(d, "is_aqmm_task", False),
|
|
aqmm_mandatory=getattr(d, "aqmm_mandatory", False),
|
|
aqmm_evidence_required=getattr(d, "aqmm_evidence_required", False),
|
|
aqmm_manager_review_required=getattr(d, "aqmm_manager_review_required", False),
|
|
aqmm_partner_review_required=getattr(d, "aqmm_partner_review_required", False),
|
|
aqmm_review_partner_required=getattr(d, "aqmm_review_partner_required", False),
|
|
aqmm_blocks_final_release=getattr(d, "aqmm_blocks_final_release", False),
|
|
aqmm_reference=getattr(d, "aqmm_reference", None),
|
|
is_active=d.is_active,
|
|
created_by_user_id=user.id,
|
|
updated_by_user_id=user.id,
|
|
))
|
|
db.commit()
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/templates/{catalogue_id}/tasks/{task_id}/document-requirements/new')
|
|
def firm_task_document_requirement_create(
|
|
request: Request,
|
|
catalogue_id: int,
|
|
task_id: int,
|
|
document_name: str = Form(...),
|
|
document_type: str = Form('GENERAL'),
|
|
is_mandatory: str | None = Form(None),
|
|
allowed_file_types: str = Form(''),
|
|
instructions: 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)
|
|
task = get_firm_task_template(db, task_id=task_id, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
if not task:
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=task_missing', status_code=303)
|
|
create_task_document_requirement(
|
|
db,
|
|
task_template=task,
|
|
document_name=document_name,
|
|
document_type=document_type,
|
|
is_mandatory=is_mandatory is not None,
|
|
allowed_file_types=allowed_file_types,
|
|
instructions=instructions,
|
|
sort_order=sort_order,
|
|
user=user,
|
|
)
|
|
db.commit()
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}?requirement_added=1', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/templates/{catalogue_id}/tasks/{task_id}/document-requirements/{requirement_id}/toggle')
|
|
def firm_task_document_requirement_toggle(
|
|
request: Request,
|
|
catalogue_id: int,
|
|
task_id: int,
|
|
requirement_id: int,
|
|
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)
|
|
task = get_firm_task_template(db, task_id=task_id, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
requirement = get_task_document_requirement(db, requirement_id=requirement_id, tenant_id=tenant_id)
|
|
if not task or not requirement or requirement.firm_task_template_id != task.id:
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=requirement_missing', status_code=303)
|
|
requirement.is_active = not bool(requirement.is_active)
|
|
requirement.updated_by_user_id = user.id
|
|
db.commit()
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}?requirement_updated=1', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/templates/{catalogue_id}/tasks/{task_id}/document-templates/upload')
|
|
def firm_task_document_template_upload(
|
|
request: Request,
|
|
catalogue_id: int,
|
|
task_id: int,
|
|
template_name: str = Form(''),
|
|
template_category: str = Form(''),
|
|
description: str = Form(''),
|
|
file: UploadFile = File(...),
|
|
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)
|
|
task = get_firm_task_template(db, task_id=task_id, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
if not task:
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=task_missing', status_code=303)
|
|
if not file or not file.filename:
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=missing_file', status_code=303)
|
|
save_task_document_template(
|
|
db,
|
|
task_template=task,
|
|
template_name=template_name or file.filename,
|
|
template_category=template_category,
|
|
description=description,
|
|
upload_file=file,
|
|
user=user,
|
|
)
|
|
db.commit()
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}?template_uploaded=1', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/document-templates/{template_id}/download')
|
|
def firm_task_document_template_download(request: Request, template_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)
|
|
template = db.get(FirmTaskDocumentTemplate, template_id)
|
|
if not template or template.tenant_id != tenant_id or not template.is_active:
|
|
return _redirect_denied()
|
|
path = template_absolute_path(template)
|
|
if not path.exists():
|
|
return RedirectResponse(url=f'/services/templates/{template.service_catalogue_id}?error=template_file_missing', status_code=303)
|
|
return FileResponse(path, filename=template.original_filename, media_type=template.content_type or 'application/octet-stream')
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/templates/{catalogue_id}/tasks/{task_id}/toggle-active')
|
|
def firm_task_template_toggle_active(
|
|
request: Request,
|
|
catalogue_id: int,
|
|
task_id: int,
|
|
csrf_token: str = Form(...),
|
|
):
|
|
"""Enable/disable a firm task template without changing the system default.
|
|
|
|
Existing engagement task instances are snapshots and are intentionally not
|
|
modified. This affects task generation for future engagements only.
|
|
"""
|
|
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)
|
|
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
if not selection or not selection.is_enabled:
|
|
return RedirectResponse(url='/services', status_code=303)
|
|
|
|
task = get_firm_task_template(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
catalogue_id=catalogue_id,
|
|
task_id=task_id,
|
|
)
|
|
if not task:
|
|
return RedirectResponse(
|
|
url=f'/services/templates/{catalogue_id}?error=task_missing',
|
|
status_code=303,
|
|
)
|
|
|
|
task.is_active = not bool(task.is_active)
|
|
task.updated_by_user_id = user.id
|
|
db.commit()
|
|
|
|
state = 'enabled' if task.is_active else 'disabled'
|
|
return RedirectResponse(
|
|
url=f'/services/templates/{catalogue_id}?task_{state}=1',
|
|
status_code=303,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/templates/{catalogue_id}/sync-open-engagements')
|
|
def sync_firm_tasks_to_open_engagements(
|
|
request: Request,
|
|
catalogue_id: int,
|
|
include_started_open_tasks: 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)
|
|
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
if not selection or not selection.is_enabled:
|
|
return RedirectResponse(url='/services', status_code=303)
|
|
|
|
result = sync_open_engagement_tasks_for_service(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
catalogue_id=catalogue_id,
|
|
user_id=user.id,
|
|
include_started_open_tasks=include_started_open_tasks is not None,
|
|
)
|
|
db.commit()
|
|
return RedirectResponse(
|
|
url=(
|
|
f'/services/templates/{catalogue_id}'
|
|
f'?synced=1&created={result["created"]}'
|
|
f'&updated_pending={result["updated_pending"]}'
|
|
f'&updated_started={result["updated_started"]}'
|
|
f'&preserved={result["preserved_history"]}'
|
|
),
|
|
status_code=303,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/templates/{catalogue_id}/tasks/{task_id}/edit')
|
|
def firm_task_template_edit_page(request: Request, catalogue_id: int, task_id: int):
|
|
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)
|
|
|
|
catalogue = get_catalogue(db, catalogue_id)
|
|
if not catalogue:
|
|
return RedirectResponse(url='/services/templates', status_code=303)
|
|
|
|
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
|
|
if not selection or not selection.is_enabled:
|
|
return RedirectResponse(url='/services', status_code=303)
|
|
|
|
task = get_firm_task_template(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
catalogue_id=catalogue_id,
|
|
task_id=task_id,
|
|
)
|
|
if not task:
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303)
|
|
|
|
return _render(
|
|
request,
|
|
'modules/services/templates/services/firm_task_form.html',
|
|
db,
|
|
user,
|
|
title=f'Edit Firm Task - {catalogue.service_name}',
|
|
service=catalogue,
|
|
task=task,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/templates/{catalogue_id}/tasks/{task_id}/edit')
|
|
def firm_task_template_edit_submit(
|
|
request: Request,
|
|
catalogue_id: int,
|
|
task_id: int,
|
|
task_name: str = Form(...),
|
|
description: str = Form(''),
|
|
default_role_name: str = Form(''),
|
|
eligible_roles: list[str] = Form([]),
|
|
sequence_no: int = Form(1),
|
|
is_mandatory: str | None = Form(None),
|
|
requires_review: str | None = Form(None),
|
|
task_category: str = Form(''),
|
|
response_required: str | None = Form(None),
|
|
response_type: str = Form('NONE'),
|
|
evidence_required: str | None = Form(None),
|
|
remarks_required_if_no: str | None = Form(None),
|
|
is_aqmm_task: str | None = Form(None),
|
|
aqmm_mandatory: str | None = Form(None),
|
|
aqmm_evidence_required: str | None = Form(None),
|
|
aqmm_manager_review_required: str | None = Form(None),
|
|
aqmm_partner_review_required: str | None = Form(None),
|
|
aqmm_review_partner_required: str | None = Form(None),
|
|
aqmm_blocks_final_release: str | None = Form(None),
|
|
aqmm_reference: str = Form(''),
|
|
is_active: str | None = Form(None),
|
|
sync_open_engagements: str | None = Form(None),
|
|
sync_started_open_tasks: 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)
|
|
|
|
task = get_firm_task_template(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
catalogue_id=catalogue_id,
|
|
task_id=task_id,
|
|
)
|
|
if not task:
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303)
|
|
|
|
task.task_name = task_name.strip()
|
|
task.description = description.strip() or None
|
|
task.default_role_name = default_role_name.strip() or None
|
|
task.eligible_role_names = serialise_eligible_task_roles(eligible_roles, default_role_name)
|
|
task.sequence_no = sequence_no
|
|
task.is_mandatory = is_mandatory is not None
|
|
task.requires_review = requires_review is not None
|
|
task.task_category = task_category.strip() or 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
|
|
task.remarks_required_if_no = remarks_required_if_no is not None
|
|
task.is_aqmm_task = is_aqmm_task is not None
|
|
task.aqmm_mandatory = aqmm_mandatory is not None
|
|
task.aqmm_evidence_required = aqmm_evidence_required is not None
|
|
task.aqmm_manager_review_required = aqmm_manager_review_required is not None
|
|
task.aqmm_partner_review_required = aqmm_partner_review_required is not None
|
|
task.aqmm_review_partner_required = aqmm_review_partner_required is not None
|
|
task.aqmm_blocks_final_release = aqmm_blocks_final_release is not None
|
|
task.aqmm_reference = aqmm_reference.strip() or None
|
|
task.is_active = is_active is not None
|
|
task.updated_by_user_id = user.id
|
|
|
|
sync_result = None
|
|
if sync_open_engagements is not None:
|
|
sync_result = sync_open_engagement_tasks_for_service(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
catalogue_id=catalogue_id,
|
|
user_id=user.id,
|
|
include_started_open_tasks=sync_started_open_tasks is not None,
|
|
)
|
|
|
|
db.commit()
|
|
if sync_result is not None:
|
|
return RedirectResponse(
|
|
url=(
|
|
f'/services/templates/{catalogue_id}'
|
|
f'?synced=1&created={sync_result["created"]}'
|
|
f'&updated_pending={sync_result["updated_pending"]}'
|
|
f'&updated_started={sync_result["updated_started"]}'
|
|
f'&preserved={sync_result["preserved_history"]}'
|
|
),
|
|
status_code=303,
|
|
)
|
|
return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get('/catalogue/{catalogue_id}/defaults/{task_id}/edit')
|
|
def default_task_template_edit_page(request: Request, catalogue_id: int, task_id: int):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse(url='/login', status_code=303)
|
|
|
|
try:
|
|
require_permission(db, user, 'service_tasks.edit')
|
|
except Exception:
|
|
return _redirect_denied()
|
|
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
|
|
catalogue = get_catalogue(db, catalogue_id)
|
|
if not catalogue:
|
|
return RedirectResponse(url='/services/defaults', status_code=303)
|
|
|
|
task = get_default_task_template(
|
|
db,
|
|
catalogue_id=catalogue_id,
|
|
task_id=task_id,
|
|
)
|
|
if not task:
|
|
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
|
|
|
|
return _render(
|
|
request,
|
|
'modules/services/templates/services/default_task_form.html',
|
|
db,
|
|
user,
|
|
title=f'Edit Default Task - {catalogue.service_name}',
|
|
service=catalogue,
|
|
task=task,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post('/catalogue/{catalogue_id}/defaults/{task_id}/edit')
|
|
def default_task_template_edit_submit(
|
|
request: Request,
|
|
catalogue_id: int,
|
|
task_id: int,
|
|
task_name: str = Form(...),
|
|
description: str = Form(''),
|
|
default_role_name: str = Form(''),
|
|
eligible_roles: list[str] = Form([]),
|
|
sequence_no: int = Form(1),
|
|
is_mandatory: str | None = Form(None),
|
|
requires_review: str | None = Form(None),
|
|
task_category: str = Form(''),
|
|
response_required: str | None = Form(None),
|
|
response_type: str = Form('NONE'),
|
|
evidence_required: str | None = Form(None),
|
|
remarks_required_if_no: str | None = Form(None),
|
|
is_aqmm_task: str | None = Form(None),
|
|
aqmm_mandatory: str | None = Form(None),
|
|
aqmm_evidence_required: str | None = Form(None),
|
|
aqmm_manager_review_required: str | None = Form(None),
|
|
aqmm_partner_review_required: str | None = Form(None),
|
|
aqmm_review_partner_required: str | None = Form(None),
|
|
aqmm_blocks_final_release: str | None = Form(None),
|
|
aqmm_reference: str = Form(''),
|
|
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)
|
|
|
|
try:
|
|
require_permission(db, user, 'service_tasks.edit')
|
|
except Exception:
|
|
return _redirect_denied()
|
|
|
|
if not _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
|
|
task = get_default_task_template(
|
|
db,
|
|
catalogue_id=catalogue_id,
|
|
task_id=task_id,
|
|
)
|
|
if not task:
|
|
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
|
|
|
|
task.task_name = task_name.strip()
|
|
task.description = description.strip() or None
|
|
task.default_role_name = default_role_name.strip() or None
|
|
task.eligible_role_names = serialise_eligible_task_roles(eligible_roles, default_role_name)
|
|
task.sequence_no = sequence_no
|
|
task.is_mandatory = is_mandatory is not None
|
|
task.requires_review = requires_review is not None
|
|
task.task_category = task_category.strip() or 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
|
|
task.remarks_required_if_no = remarks_required_if_no is not None
|
|
task.is_aqmm_task = is_aqmm_task is not None
|
|
task.aqmm_mandatory = aqmm_mandatory is not None
|
|
task.aqmm_evidence_required = aqmm_evidence_required is not None
|
|
task.aqmm_manager_review_required = aqmm_manager_review_required is not None
|
|
task.aqmm_partner_review_required = aqmm_partner_review_required is not None
|
|
task.aqmm_review_partner_required = aqmm_review_partner_required is not None
|
|
task.aqmm_blocks_final_release = aqmm_blocks_final_release is not None
|
|
task.aqmm_reference = aqmm_reference.strip() or None
|
|
task.is_active = is_active is not None
|
|
|
|
db.commit()
|
|
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', 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.
|
|
# -----------------------------------------------------------------------------
|
|
|
|
def _locked_partner_id(db, user) -> int | None:
|
|
return int(user.id) if _has_perm(db, user, "clients.view.own_only") else None
|
|
|
|
|
|
def _bulk_template_response(template_type: str, filename: str):
|
|
data = build_bulk_import_template(template_type)
|
|
return StreamingResponse(
|
|
iter([data]),
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.get("/bulk-imports")
|
|
def services_bulk_import_home(request: Request):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
can_client_assignment = _has_perm(db, user, "clients.edit")
|
|
can_firm_tasks = _has_perm(db, user, "service_tasks.create")
|
|
can_system_import = _is_system_admin(db, user)
|
|
can_due_date_extensions = _can_manage_firm_services(db, user) or can_system_import
|
|
if not (can_client_assignment or can_firm_tasks or can_system_import or can_due_date_extensions):
|
|
return _redirect_denied()
|
|
return _render(
|
|
request,
|
|
"modules/services/templates/services/bulk_imports/index.html",
|
|
db,
|
|
user,
|
|
title="Services Bulk Import",
|
|
can_client_assignment=can_client_assignment,
|
|
can_firm_tasks=can_firm_tasks,
|
|
can_system_import=can_system_import,
|
|
can_due_date_extensions=can_due_date_extensions,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/bulk-imports/templates/engagement-assignments.xlsx")
|
|
def download_client_service_assignment_template(request: Request):
|
|
return _bulk_template_response("client_service_assignments", "client_service_assignments_template.xlsx")
|
|
|
|
|
|
@router.get("/bulk-imports/templates/service-master.xlsx")
|
|
def download_service_master_template(request: Request):
|
|
return _bulk_template_response("service_master", "service_master_template.xlsx")
|
|
|
|
|
|
@router.get("/bulk-imports/templates/system-default-tasks.xlsx")
|
|
def download_system_default_tasks_template(request: Request):
|
|
return _bulk_template_response("system_default_tasks", "system_default_tasks_template.xlsx")
|
|
|
|
|
|
@router.get("/bulk-imports/templates/due-date-extensions.xlsx")
|
|
def download_due_date_extensions_template(request: Request):
|
|
return _bulk_template_response("due_date_extensions", "due_date_extensions_template.xlsx")
|
|
|
|
|
|
@router.get("/bulk-imports/templates/firm-task-templates.xlsx")
|
|
def download_firm_task_templates_template(request: Request):
|
|
return _bulk_template_response("firm_task_templates", "firm_task_templates_template.xlsx")
|
|
|
|
|
|
@router.post("/bulk-imports/engagement-assignments")
|
|
def upload_client_service_assignments(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
update_existing: 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)
|
|
try:
|
|
require_permission(db, user, "clients.edit")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
result = import_client_service_assignments(
|
|
db,
|
|
current_user=user,
|
|
tenant_id=_active_tenant_id(request, user),
|
|
locked_partner_id=_locked_partner_id(db, user),
|
|
file_bytes=file.file.read(),
|
|
update_existing=update_existing is not None,
|
|
)
|
|
return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Engagement Assignment Import Result", result=result, back_url="/services/bulk-imports")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/bulk-imports/service-master")
|
|
def upload_service_master(request: Request, file: UploadFile = File(...), update_existing: 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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
try:
|
|
require_permission(db, user, "services.create")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
result = import_service_master(
|
|
db,
|
|
current_user=user,
|
|
tenant_id=_active_tenant_id(request, user),
|
|
file_bytes=file.file.read(),
|
|
update_existing=update_existing is not None,
|
|
)
|
|
return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Service Master Import Result", result=result, back_url="/services/bulk-imports")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/bulk-imports/system-default-tasks")
|
|
def upload_system_default_tasks(request: Request, file: UploadFile = File(...), update_existing: 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 _is_system_admin(db, user):
|
|
return _redirect_denied()
|
|
try:
|
|
require_permission(db, user, "service_tasks.create")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
result = import_system_default_tasks(db, current_user=user, file_bytes=file.file.read(), update_existing=update_existing is not None)
|
|
return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="System Default Tasks Import Result", result=result, back_url="/services/bulk-imports")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/bulk-imports/due-date-extensions")
|
|
def upload_due_date_extensions(request: Request, file: UploadFile = File(...), update_existing: 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_services(db, user) or _is_system_admin(db, user)):
|
|
return _redirect_denied()
|
|
result = import_due_date_extensions(
|
|
db,
|
|
current_user=user,
|
|
tenant_id=_active_tenant_id(request, user),
|
|
file_bytes=file.file.read(),
|
|
update_existing=update_existing is not None,
|
|
)
|
|
return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Due Date Extension Import Result", result=result, back_url="/services/bulk-imports")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/bulk-imports/firm-task-templates")
|
|
def upload_firm_task_templates(request: Request, file: UploadFile = File(...), update_existing: 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)
|
|
try:
|
|
require_permission(db, user, "service_tasks.create")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
result = import_firm_task_templates(db, current_user=user, tenant_id=_active_tenant_id(request, user), file_bytes=file.file.read(), update_existing=update_existing is not None)
|
|
return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Firm Task Templates Import Result", result=result, back_url="/services/bulk-imports")
|
|
finally:
|
|
db.close()
|