feat(projects): "Merge into…" button to consolidate duplicate projects
Operator-facing tool for cleaning up duplicate projects. Common after
the metadata-backfill parser auto-creates near-duplicates from operator
name variations ("SR81" vs "SR 81", "Swank-Karns Crossing" vs
"Swank-Karns Crossings", "Trumbull-Bryman Mont.Dam" vs
"Trumbull-Brayman-Mont Dam", etc.).
Workflow: visit the duplicate project's detail page, click "Merge into…"
in the header, search for the canonical target project from a typeahead,
review the preview (what assignments / locations / sessions will move,
any conflicts), confirm. Source is soft-deleted; everything else
re-points to the target. Smart consolidation: same-named locations in
both projects merge into one (source's assignments move to target's
existing location with the same name; source's empty location is then
deleted). Different-named locations move as-is.
Backend:
- backend/services/project_merge.py (new): preview() and execute()
functions. Transaction-safe. Per-assignment UnitHistory audit row
with change_type='assignment_merged' so the deployment timeline shows
the merge. Source modules disabled; missing modules added to target.
Handles edge cases: same project_id rejected, deleted projects rejected,
orphan project-direct assignments (no location) re-pointed defensively.
- backend/routers/projects.py: new endpoints
GET /api/projects/{source_id}/merge_preview?target_id=...
POST /api/projects/{source_id}/merge_into?target_id=...
Frontend (templates/partials/projects/project_header.html):
- "Merge into…" button in Project Actions area.
- Modal with typeahead (reuses /api/admin/metadata_backfill/projects_search)
scoped to existing projects only (no create-new option). Filters out
the source project from candidates so operator can't accidentally pick
it as target.
- Preview pane shows totals + per-location plan (consolidate vs move) +
warnings (mismatched client names, location consolidation note).
- Red "Merge (permanent)" confirm button only enables after a target is
picked and preview loads.
- On success, browser redirects to target project page.
Smoke verified: "Swank-Karns Crossing" (1 assignment) merged into
"Swank-Karns Crossings"; target now has 2 locations + 2 assignments,
source has 0 dangling rows, 1 project_merge audit entry written.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -688,6 +688,72 @@ async def restore_project(project_id: str, db: Session = Depends(get_db)):
|
||||
return {"success": True, "message": f"Project '{project.name}' restored."}
|
||||
|
||||
|
||||
# ── Project merge ──────────────────────────────────────────────────────────────
|
||||
# Consolidate a duplicate project into another. Common after the
|
||||
# metadata-backfill parser creates near-duplicate projects from name
|
||||
# variations operators typed on the BW device.
|
||||
# See backend/services/project_merge.py for the merge logic.
|
||||
|
||||
@router.get("/{source_id}/merge_preview")
|
||||
async def project_merge_preview(
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Preview what the merge will do — used by the confirmation modal.
|
||||
No writes."""
|
||||
from backend.services import project_merge as pm
|
||||
preview = pm.preview(db, source_id, target_id)
|
||||
return {
|
||||
"source_project_id": preview.source_project_id,
|
||||
"source_project_name": preview.source_project_name,
|
||||
"target_project_id": preview.target_project_id,
|
||||
"target_project_name": preview.target_project_name,
|
||||
"total_assignments_moving": preview.total_assignments_moving,
|
||||
"total_sessions_moving": preview.total_sessions_moving,
|
||||
"total_data_files_moving": preview.total_data_files_moving,
|
||||
"modules_to_add": preview.modules_to_add,
|
||||
"warnings": preview.warnings,
|
||||
"location_plans": [
|
||||
{
|
||||
"source_id": p.source_id,
|
||||
"source_name": p.source_name,
|
||||
"target_id": p.target_id,
|
||||
"target_name": p.target_name,
|
||||
"action": p.action,
|
||||
"assignments_moving": p.assignments_moving,
|
||||
"sessions_moving": p.sessions_moving,
|
||||
}
|
||||
for p in preview.location_plans
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{source_id}/merge_into")
|
||||
async def project_merge_execute(
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Execute the merge. Source project gets soft-deleted; all its
|
||||
locations / assignments / sessions / data_files / modules move to
|
||||
the target. Same-named locations consolidate."""
|
||||
from backend.services import project_merge as pm
|
||||
result = pm.execute(db, source_id, target_id, decided_by="operator")
|
||||
return {
|
||||
"success": True,
|
||||
"source_project_id": result.source_project_id,
|
||||
"target_project_id": result.target_project_id,
|
||||
"assignments_moved": result.assignments_moved,
|
||||
"locations_moved": result.locations_moved,
|
||||
"locations_consolidated": result.locations_consolidated,
|
||||
"sessions_moved": result.sessions_moved,
|
||||
"data_files_moved": result.data_files_moved,
|
||||
"modules_added": result.modules_added,
|
||||
"audit_rows_written": result.audit_rows_written,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{project_id}")
|
||||
async def get_project(project_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user