Add controlled service catalogue merge workflow

This commit is contained in:
A R R R Associates
2026-07-17 22:59:02 +05:30
parent 9f51461548
commit 0b58bf182b
4 changed files with 504 additions and 13 deletions
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.modules.services.models import (
ClientServiceSubscription,
FirmServiceSelection,
FirmServiceTaskTemplate,
FirmTaskDocumentRequirement,
FirmTaskDocumentTemplate,
ServiceCatalogue,
ServiceDefaultTaskTemplate,
ServiceDueDateExtension,
ServiceDueDateRule,
)
@dataclass(frozen=True)
class MergePreview:
source: ServiceCatalogue
target: ServiceCatalogue
counts: dict[str, int]
conflicts: dict[str, int]
def _normalise(value: str | None) -> str:
return re.sub(r"[^a-z0-9]+", "", (value or "").strip().lower())
def _count(db: Session, model, condition) -> int:
return int(db.scalar(select(func.count(model.id)).where(condition)) or 0)
def build_merge_preview(db: Session, *, source_id: int, target_id: int) -> MergePreview:
if source_id == target_id:
raise ValueError("Source and target services must be different.")
source = db.get(ServiceCatalogue, source_id)
target = db.get(ServiceCatalogue, target_id)
if not source or not target:
raise ValueError("The selected source or target service no longer exists.")
counts = {
"firm_selections": _count(db, FirmServiceSelection, FirmServiceSelection.service_catalogue_id == source_id),
"system_default_tasks": _count(db, ServiceDefaultTaskTemplate, ServiceDefaultTaskTemplate.service_catalogue_id == source_id),
"firm_task_templates": _count(db, FirmServiceTaskTemplate, FirmServiceTaskTemplate.service_catalogue_id == source_id),
"document_requirements": _count(db, FirmTaskDocumentRequirement, FirmTaskDocumentRequirement.service_catalogue_id == source_id),
"document_templates": _count(db, FirmTaskDocumentTemplate, FirmTaskDocumentTemplate.service_catalogue_id == source_id),
"due_date_rules": _count(db, ServiceDueDateRule, ServiceDueDateRule.service_catalogue_id == source_id),
"due_date_extensions": _count(db, ServiceDueDateExtension, ServiceDueDateExtension.service_catalogue_id == source_id),
"client_subscriptions_preserved": _count(db, ClientServiceSubscription, ClientServiceSubscription.service_catalogue_id == source_id),
}
source_tenants = set(db.scalars(select(FirmServiceSelection.tenant_id).where(FirmServiceSelection.service_catalogue_id == source_id)).all())
target_tenants = set(db.scalars(select(FirmServiceSelection.tenant_id).where(FirmServiceSelection.service_catalogue_id == target_id)).all())
source_default_names = {
_normalise(name)
for name in db.scalars(select(ServiceDefaultTaskTemplate.task_name).where(ServiceDefaultTaskTemplate.service_catalogue_id == source_id)).all()
}
target_default_names = {
_normalise(name)
for name in db.scalars(select(ServiceDefaultTaskTemplate.task_name).where(ServiceDefaultTaskTemplate.service_catalogue_id == target_id)).all()
}
source_rules = {
_normalise(name)
for name in db.scalars(select(ServiceDueDateRule.rule_name).where(ServiceDueDateRule.service_catalogue_id == source_id)).all()
}
target_rules = {
_normalise(name)
for name in db.scalars(select(ServiceDueDateRule.rule_name).where(ServiceDueDateRule.service_catalogue_id == target_id)).all()
}
firm_task_conflicts = 0
source_firm_tasks = db.scalars(select(FirmServiceTaskTemplate).where(FirmServiceTaskTemplate.service_catalogue_id == source_id)).all()
target_firm_tasks = db.scalars(select(FirmServiceTaskTemplate).where(FirmServiceTaskTemplate.service_catalogue_id == target_id)).all()
target_task_keys = {(row.tenant_id, _normalise(row.task_name)) for row in target_firm_tasks}
for row in source_firm_tasks:
if (row.tenant_id, _normalise(row.task_name)) in target_task_keys:
firm_task_conflicts += 1
conflicts = {
"firm_selection_conflicts": len(source_tenants & target_tenants),
"system_default_task_name_conflicts": len(source_default_names & target_default_names),
"firm_task_name_conflicts": firm_task_conflicts,
"due_rule_name_conflicts": len(source_rules & target_rules),
}
return MergePreview(source=source, target=target, counts=counts, conflicts=conflicts)
def _merge_firm_selections(db: Session, *, source_id: int, target_id: int, actor_user_id: int) -> int:
"""Create/consolidate target selections while retaining source selections for history."""
processed = 0
source_rows = db.scalars(
select(FirmServiceSelection)
.where(FirmServiceSelection.service_catalogue_id == source_id)
.order_by(FirmServiceSelection.id.asc())
).all()
for source_row in source_rows:
target_row = db.scalar(
select(FirmServiceSelection).where(
FirmServiceSelection.tenant_id == source_row.tenant_id,
FirmServiceSelection.service_catalogue_id == target_id,
)
)
if target_row:
target_row.is_enabled = bool(target_row.is_enabled or source_row.is_enabled)
if target_row.default_branch_id is None and source_row.default_branch_id is not None:
target_row.default_branch_id = source_row.default_branch_id
target_row.updated_by_user_id = actor_user_id
target_row.updated_at_utc = datetime.now(timezone.utc)
else:
db.add(
FirmServiceSelection(
tenant_id=source_row.tenant_id,
service_catalogue_id=target_id,
is_enabled=bool(source_row.is_enabled),
default_branch_id=source_row.default_branch_id,
activated_by_user_id=source_row.activated_by_user_id or actor_user_id,
updated_by_user_id=actor_user_id,
)
)
source_row.is_enabled = False
source_row.updated_by_user_id = actor_user_id
source_row.updated_at_utc = datetime.now(timezone.utc)
processed += 1
db.flush()
return processed
def _merge_default_tasks(db: Session, *, source_id: int, target_id: int) -> tuple[int, int]:
target_rows = db.scalars(
select(ServiceDefaultTaskTemplate)
.where(ServiceDefaultTaskTemplate.service_catalogue_id == target_id)
.order_by(ServiceDefaultTaskTemplate.sequence_no.asc(), ServiceDefaultTaskTemplate.id.asc())
).all()
target_by_name = {_normalise(row.task_name): row for row in target_rows}
next_sequence = max((row.sequence_no for row in target_rows), default=0) + 1
moved = 0
skipped = 0
source_rows = db.scalars(
select(ServiceDefaultTaskTemplate)
.where(ServiceDefaultTaskTemplate.service_catalogue_id == source_id)
.order_by(ServiceDefaultTaskTemplate.sequence_no.asc(), ServiceDefaultTaskTemplate.id.asc())
).all()
for row in source_rows:
key = _normalise(row.task_name)
if key and key in target_by_name:
skipped += 1
db.delete(row)
continue
row.service_catalogue_id = target_id
row.sequence_no = next_sequence
next_sequence += 1
target_by_name[key] = row
moved += 1
db.flush()
return moved, skipped
def _merge_document_requirements(db: Session, *, source_template: FirmServiceTaskTemplate, target_template: FirmServiceTaskTemplate, target_id: int) -> None:
target_names = {
_normalise(row.document_name)
for row in db.scalars(
select(FirmTaskDocumentRequirement).where(FirmTaskDocumentRequirement.firm_task_template_id == target_template.id)
).all()
}
for row in db.scalars(
select(FirmTaskDocumentRequirement).where(FirmTaskDocumentRequirement.firm_task_template_id == source_template.id)
).all():
key = _normalise(row.document_name)
if key and key in target_names:
db.delete(row)
continue
row.firm_task_template_id = target_template.id
row.service_catalogue_id = target_id
target_names.add(key)
for row in db.scalars(
select(FirmTaskDocumentTemplate).where(FirmTaskDocumentTemplate.firm_task_template_id == source_template.id)
).all():
row.firm_task_template_id = target_template.id
row.service_catalogue_id = target_id
def _merge_firm_tasks(db: Session, *, source_id: int, target_id: int) -> tuple[int, int]:
target_rows = db.scalars(
select(FirmServiceTaskTemplate)
.where(FirmServiceTaskTemplate.service_catalogue_id == target_id)
.order_by(FirmServiceTaskTemplate.tenant_id.asc(), FirmServiceTaskTemplate.sequence_no.asc())
).all()
target_by_key = {(row.tenant_id, _normalise(row.task_name)): row for row in target_rows}
max_sequence_by_tenant: dict[int, int] = {}
for row in target_rows:
max_sequence_by_tenant[row.tenant_id] = max(max_sequence_by_tenant.get(row.tenant_id, 0), row.sequence_no)
moved = 0
merged = 0
source_rows = db.scalars(
select(FirmServiceTaskTemplate)
.where(FirmServiceTaskTemplate.service_catalogue_id == source_id)
.order_by(FirmServiceTaskTemplate.tenant_id.asc(), FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc())
).all()
for row in source_rows:
key = (row.tenant_id, _normalise(row.task_name))
target_row = target_by_key.get(key)
if target_row:
_merge_document_requirements(db, source_template=row, target_template=target_row, target_id=target_id)
db.flush()
db.delete(row)
merged += 1
continue
next_sequence = max_sequence_by_tenant.get(row.tenant_id, 0) + 1
max_sequence_by_tenant[row.tenant_id] = next_sequence
row.service_catalogue_id = target_id
row.sequence_no = next_sequence
for requirement in row.document_requirements:
requirement.service_catalogue_id = target_id
for template in row.document_templates:
template.service_catalogue_id = target_id
target_by_key[key] = row
moved += 1
db.flush()
return moved, merged
def _merge_due_rules(db: Session, *, source_id: int, target_id: int) -> tuple[int, int]:
target_rows = db.scalars(select(ServiceDueDateRule).where(ServiceDueDateRule.service_catalogue_id == target_id)).all()
target_by_name = {_normalise(row.rule_name): row for row in target_rows}
moved = 0
merged = 0
source_rows = db.scalars(select(ServiceDueDateRule).where(ServiceDueDateRule.service_catalogue_id == source_id)).all()
for row in source_rows:
target_row = target_by_name.get(_normalise(row.rule_name))
if target_row:
db.query(ServiceDueDateExtension).filter(ServiceDueDateExtension.due_date_rule_id == row.id).update(
{ServiceDueDateExtension.due_date_rule_id: target_row.id}, synchronize_session=False
)
db.query(ClientServiceSubscription).filter(ClientServiceSubscription.due_date_rule_id == row.id).update(
{ClientServiceSubscription.due_date_rule_id: target_row.id}, synchronize_session=False
)
db.delete(row)
merged += 1
else:
row.service_catalogue_id = target_id
target_by_name[_normalise(row.rule_name)] = row
moved += 1
db.query(ServiceDueDateExtension).filter(ServiceDueDateExtension.service_catalogue_id == source_id).update(
{ServiceDueDateExtension.service_catalogue_id: target_id}, synchronize_session=False
)
db.flush()
return moved, merged
def merge_service_catalogues(
db: Session,
*,
source_id: int,
target_id: int,
actor_user_id: int,
) -> dict[str, int | str]:
"""Safely consolidate reusable configuration into the target service.
Existing client subscriptions, execution tasks, invoices and consultant requests are
intentionally retained against the source service to preserve historical records.
The source service is disabled after configuration is moved, so all new work uses
the target service.
"""
preview = build_merge_preview(db, source_id=source_id, target_id=target_id)
source = preview.source
target = preview.target
selections_processed = _merge_firm_selections(
db,
source_id=source_id,
target_id=target_id,
actor_user_id=actor_user_id,
)
default_moved, default_skipped = _merge_default_tasks(db, source_id=source_id, target_id=target_id)
firm_moved, firm_merged = _merge_firm_tasks(db, source_id=source_id, target_id=target_id)
rules_moved, rules_merged = _merge_due_rules(db, source_id=source_id, target_id=target_id)
source.is_active = False
source.is_client_requestable = False
source.is_consultant_requestable = False
source.updated_by_user_id = actor_user_id
source.updated_at_utc = datetime.now(timezone.utc)
target.is_active = True
target.updated_by_user_id = actor_user_id
target.updated_at_utc = datetime.now(timezone.utc)
db.flush()
return {
"source_code": source.service_code,
"target_code": target.service_code,
"firm_selections_processed": selections_processed,
"default_tasks_moved": default_moved,
"default_tasks_deduplicated": default_skipped,
"firm_tasks_moved": firm_moved,
"firm_tasks_merged": firm_merged,
"due_rules_moved": rules_moved,
"due_rules_merged": rules_merged,
"historical_subscriptions_preserved": preview.counts["client_subscriptions_preserved"],
}
@@ -12,7 +12,7 @@
<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">Firm Task Templates</a> <a href="/services/templates" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Firm Task Templates</a>
{% if can_create %} {% if can_create %}
<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">System Default Tasks</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">System Default Tasks</a>
<a href="/services/catalogue/export" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 hover:bg-emerald-100">Export Excel</a> <a href="/services/catalogue/merge" class="rounded-xl border border-amber-300 px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50">Merge Services</a>
<a href="/services/catalogue/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Add Catalogue Service</a> <a href="/services/catalogue/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Add Catalogue Service</a>
{% endif %} {% endif %}
</div> </div>
@@ -0,0 +1,104 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">Merge Service Catalogue</h2>
<p class="text-sm text-slate-500">Consolidate an older duplicate service into the service that should be used for all future work.</p>
</div>
<a href="/services/catalogue" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Catalogue</a>
</div>
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
The source service will be disabled. Firm selections, default tasks, firm task templates, document requirements, document templates and due-date rules are consolidated into the target. Existing client subscriptions, completed or active execution work, invoices and consultant requests remain linked to the source service to preserve historical records.
</div>
{% if request.query_params.get('error') == 'confirmation' %}
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">The confirmation text did not match. No records were changed.</div>
{% elif request.query_params.get('error') or error %}
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">{{ error or 'The selected merge could not be prepared. No records were changed.' }}</div>
{% endif %}
<form method="get" action="/services/catalogue/merge" class="rounded-2xl bg-white p-5 shadow-soft">
<div class="grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-sm font-medium text-slate-700">Source service to disable</span>
<select name="source_id" required class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">-- Select old/duplicate service --</option>
{% for service in services %}
<option value="{{ service.id }}" {% if source_id == service.id %}selected{% endif %}>{{ service.service_code }} — {{ service.service_name }}{% if not service.is_active %} (Inactive){% endif %}</option>
{% endfor %}
</select>
</label>
<label class="space-y-2">
<span class="text-sm font-medium text-slate-700">Target service to keep</span>
<select name="target_id" required class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">-- Select surviving service --</option>
{% for service in services %}
<option value="{{ service.id }}" {% if target_id == service.id %}selected{% endif %}>{{ service.service_code }} — {{ service.service_name }}{% if not service.is_active %} (Inactive){% endif %}</option>
{% endfor %}
</select>
</label>
</div>
<div class="mt-4 flex justify-end">
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Preview Merge</button>
</div>
</form>
{% if preview %}
<div class="grid gap-4 lg:grid-cols-2">
<div class="rounded-2xl bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Merge direction</h3>
<div class="mt-4 space-y-3 text-sm">
<div class="rounded-xl border border-red-200 bg-red-50 p-3"><span class="font-semibold">Source:</span> {{ preview.source.service_code }} — {{ preview.source.service_name }}</div>
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3"><span class="font-semibold">Target:</span> {{ preview.target.service_code }} — {{ preview.target.service_name }}</div>
</div>
</div>
<div class="rounded-2xl bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Conflict handling</h3>
<dl class="mt-4 grid grid-cols-2 gap-3 text-sm">
<div><dt class="text-slate-500">Firm selections</dt><dd class="font-semibold">{{ preview.conflicts.firm_selection_conflicts }}</dd></div>
<div><dt class="text-slate-500">Default task names</dt><dd class="font-semibold">{{ preview.conflicts.system_default_task_name_conflicts }}</dd></div>
<div><dt class="text-slate-500">Firm task names</dt><dd class="font-semibold">{{ preview.conflicts.firm_task_name_conflicts }}</dd></div>
<div><dt class="text-slate-500">Due-rule names</dt><dd class="font-semibold">{{ preview.conflicts.due_rule_name_conflicts }}</dd></div>
</dl>
<p class="mt-4 text-xs text-slate-500">Conflicting firm selections are consolidated. Tasks and due rules with matching normalised names are deduplicated; non-matching items are appended to the target.</p>
</div>
</div>
<div 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">Source dependency</th><th class="px-4 py-3 text-right text-xs font-semibold uppercase text-slate-500">Count</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Merge treatment</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr><td class="px-4 py-3 text-sm">Firm selections</td><td class="px-4 py-3 text-right text-sm font-semibold">{{ preview.counts.firm_selections }}</td><td class="px-4 py-3 text-sm">Move or consolidate into target</td></tr>
<tr><td class="px-4 py-3 text-sm">System default tasks</td><td class="px-4 py-3 text-right text-sm font-semibold">{{ preview.counts.system_default_tasks }}</td><td class="px-4 py-3 text-sm">Move; matching task names deduplicated</td></tr>
<tr><td class="px-4 py-3 text-sm">Firm task templates</td><td class="px-4 py-3 text-right text-sm font-semibold">{{ preview.counts.firm_task_templates }}</td><td class="px-4 py-3 text-sm">Move; matching tenant/task names consolidated</td></tr>
<tr><td class="px-4 py-3 text-sm">Document requirements</td><td class="px-4 py-3 text-right text-sm font-semibold">{{ preview.counts.document_requirements }}</td><td class="px-4 py-3 text-sm">Remain with moved/consolidated task template</td></tr>
<tr><td class="px-4 py-3 text-sm">Document templates</td><td class="px-4 py-3 text-right text-sm font-semibold">{{ preview.counts.document_templates }}</td><td class="px-4 py-3 text-sm">Remain with moved/consolidated task template</td></tr>
<tr><td class="px-4 py-3 text-sm">Due-date rules</td><td class="px-4 py-3 text-right text-sm font-semibold">{{ preview.counts.due_date_rules }}</td><td class="px-4 py-3 text-sm">Move; matching rule names consolidated</td></tr>
<tr><td class="px-4 py-3 text-sm">Due-date extensions</td><td class="px-4 py-3 text-right text-sm font-semibold">{{ preview.counts.due_date_extensions }}</td><td class="px-4 py-3 text-sm">Move to target and surviving due rule</td></tr>
<tr><td class="px-4 py-3 text-sm">Client subscriptions</td><td class="px-4 py-3 text-right text-sm font-semibold">{{ preview.counts.client_subscriptions_preserved }}</td><td class="px-4 py-3 text-sm">Preserved on source for historical continuity</td></tr>
</tbody>
</table>
</div>
<form method="post" action="/services/catalogue/merge" class="rounded-2xl border border-red-200 bg-white p-5 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="source_id" value="{{ preview.source.id }}">
<input type="hidden" name="target_id" value="{{ preview.target.id }}">
<label class="space-y-2">
<span class="text-sm font-semibold text-red-800">Type the exact confirmation below</span>
<code class="block rounded-xl bg-slate-900 px-4 py-3 text-sm text-white">MERGE {{ preview.source.service_code }} INTO {{ preview.target.service_code }}</code>
<input name="confirmation" required autocomplete="off" class="w-full rounded-xl border border-red-300 px-3 py-2 text-sm" placeholder="Enter confirmation text">
</label>
<label class="mt-4 flex items-start gap-3 text-sm text-slate-700">
<input type="checkbox" required class="mt-1 rounded border-slate-300">
<span>I have verified the source and target. I understand that the source will be disabled and historical operational records will remain preserved.</span>
</label>
<div class="mt-5 flex justify-end">
<button class="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700">Merge and Disable Source</button>
</div>
</form>
{% endif %}
</div>
{% endblock %}
+83 -12
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, File, Form, Request, UploadFile from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from sqlalchemy import select from sqlalchemy import select
@@ -32,7 +30,7 @@ from app.modules.services.due_dates import (
list_due_rules, list_due_rules,
parse_optional_date, parse_optional_date,
) )
from app.modules.services.catalogue_export import build_service_catalogue_export from app.modules.services.catalogue_merge import build_merge_preview, merge_service_catalogues
from app.modules.services.task_documents import ( from app.modules.services.task_documents import (
create_task_document_requirement, create_task_document_requirement,
get_task_document_requirement, get_task_document_requirement,
@@ -335,8 +333,8 @@ def catalogue_list(request: Request, q: str = '', category_id: int | None = None
db.close() db.close()
@router.get('/catalogue/export') @router.get('/catalogue/merge')
def catalogue_export(request: Request): def catalogue_merge_page(request: Request, source_id: int | None = None, target_id: int | None = None):
db = CommonSessionLocal() db = CommonSessionLocal()
try: try:
user = get_current_user(request, db=db) user = get_current_user(request, db=db)
@@ -344,15 +342,88 @@ def catalogue_export(request: Request):
return RedirectResponse(url='/login', status_code=303) return RedirectResponse(url='/login', status_code=303)
if not _is_system_admin(db, user): if not _is_system_admin(db, user):
return _redirect_denied() return _redirect_denied()
require_permission(db, user, 'services.view') require_permission(db, user, 'services.edit')
workbook_bytes = build_service_catalogue_export(db) services = db.execute(
filename = f"service_catalogue_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.xlsx" select(ServiceCatalogue).order_by(ServiceCatalogue.is_active.desc(), ServiceCatalogue.service_code.asc())
return StreamingResponse( ).scalars().all()
iter([workbook_bytes]), preview = None
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', error = None
headers={'Content-Disposition': f'attachment; filename="{filename}"'}, 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: finally:
db.close() db.close()