update main to v0.10.0 #48
@@ -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)):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
project_merge.py — consolidate a duplicate project into another.
|
||||
|
||||
Use case: the metadata-backfill parser (and operators) create projects with
|
||||
slight name variations ("SR81" vs "SR 81", "Swank-Karns Crossing" vs
|
||||
"Swank-Karns Crossings", "Trumbull-Bryman Mont.Dam" vs
|
||||
"Trumbull-Brayman-Mont Dam"). Operator picks a SOURCE project to merge
|
||||
into a TARGET project; everything attached to source moves to target,
|
||||
same-named locations consolidate, and source is soft-deleted.
|
||||
|
||||
Public API:
|
||||
preview(db, source_id, target_id) → MergePreview
|
||||
execute(db, source_id, target_id, *, decided_by="operator") → MergeResult
|
||||
|
||||
Both raise HTTPException with appropriate 4xx codes for validation failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import (
|
||||
Project,
|
||||
ProjectModule,
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
UnitHistory,
|
||||
MonitoringSession,
|
||||
DataFile,
|
||||
)
|
||||
|
||||
log = logging.getLogger("backend.services.project_merge")
|
||||
|
||||
|
||||
# ── Dataclasses ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocationMergePlan:
|
||||
source_id: str
|
||||
source_name: str
|
||||
target_id: Optional[str] # None = will be inserted as-new under target project
|
||||
target_name: Optional[str] # name in target after merge
|
||||
action: str # "move" | "consolidate"
|
||||
assignments_moving: int
|
||||
sessions_moving: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergePreview:
|
||||
source_project_id: str
|
||||
source_project_name: str
|
||||
target_project_id: str
|
||||
target_project_name: str
|
||||
location_plans: list[LocationMergePlan] = field(default_factory=list)
|
||||
total_assignments_moving: int = 0
|
||||
total_sessions_moving: int = 0
|
||||
total_data_files_moving: int = 0
|
||||
modules_to_add: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergeResult:
|
||||
source_project_id: str
|
||||
target_project_id: str
|
||||
assignments_moved: int
|
||||
locations_moved: int
|
||||
locations_consolidated: int
|
||||
sessions_moved: int
|
||||
data_files_moved: int
|
||||
modules_added: list[str]
|
||||
audit_rows_written: int
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalise_name(s: Optional[str]) -> str:
|
||||
"""Case-insensitive, whitespace-collapsing name normalisation.
|
||||
|
||||
Lighter than metadata_backfill._normalise (no punctuation stripping)
|
||||
— for merging we want "Loc 1" and "Loc 1" to match but NOT "Loc 1"
|
||||
and "Loc-1" (those might be intentionally different). If operators
|
||||
DO want loose matching, they can rename one before merging.
|
||||
"""
|
||||
if not s:
|
||||
return ""
|
||||
import re
|
||||
return re.sub(r"\s+", " ", s.strip()).casefold()
|
||||
|
||||
|
||||
def _validate_pair(db: Session, source_id: str, target_id: str) -> tuple[Project, Project]:
|
||||
if source_id == target_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot merge a project into itself.")
|
||||
|
||||
source = db.query(Project).filter_by(id=source_id).first()
|
||||
target = db.query(Project).filter_by(id=target_id).first()
|
||||
if source is None:
|
||||
raise HTTPException(status_code=404, detail=f"Source project not found.")
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail=f"Target project not found.")
|
||||
if source.status == "deleted":
|
||||
raise HTTPException(status_code=400, detail=f"Source project '{source.name}' is already deleted.")
|
||||
if target.status == "deleted":
|
||||
raise HTTPException(status_code=400, detail=f"Target project '{target.name}' is deleted.")
|
||||
|
||||
return source, target
|
||||
|
||||
|
||||
# ── Preview ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def preview(db: Session, source_id: str, target_id: str) -> MergePreview:
|
||||
"""Build a preview of what the merge will do. No writes."""
|
||||
source, target = _validate_pair(db, source_id, target_id)
|
||||
|
||||
# Locations in source vs target.
|
||||
source_locs = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == source_id)
|
||||
.all()
|
||||
)
|
||||
target_locs = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == target_id)
|
||||
.all()
|
||||
)
|
||||
target_by_norm = {_normalise_name(l.name): l for l in target_locs}
|
||||
|
||||
location_plans: list[LocationMergePlan] = []
|
||||
total_assignments_moving = 0
|
||||
total_sessions_moving = 0
|
||||
|
||||
for sl in source_locs:
|
||||
n = _normalise_name(sl.name)
|
||||
tl = target_by_norm.get(n)
|
||||
|
||||
a_count = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.location_id == sl.id)
|
||||
.count()
|
||||
)
|
||||
s_count = (
|
||||
db.query(MonitoringSession)
|
||||
.filter(MonitoringSession.location_id == sl.id)
|
||||
.count()
|
||||
)
|
||||
total_assignments_moving += a_count
|
||||
total_sessions_moving += s_count
|
||||
|
||||
if tl is not None:
|
||||
location_plans.append(LocationMergePlan(
|
||||
source_id = sl.id,
|
||||
source_name = sl.name,
|
||||
target_id = tl.id,
|
||||
target_name = tl.name,
|
||||
action = "consolidate",
|
||||
assignments_moving = a_count,
|
||||
sessions_moving = s_count,
|
||||
))
|
||||
else:
|
||||
location_plans.append(LocationMergePlan(
|
||||
source_id = sl.id,
|
||||
source_name = sl.name,
|
||||
target_id = None,
|
||||
target_name = sl.name,
|
||||
action = "move",
|
||||
assignments_moving = a_count,
|
||||
sessions_moving = s_count,
|
||||
))
|
||||
|
||||
# DataFiles attached to the source project (if the table exists with a
|
||||
# project_id column). Optional — terra-view's DataFile model may not
|
||||
# always FK to project, so handle gracefully.
|
||||
df_count = 0
|
||||
try:
|
||||
df_count = (
|
||||
db.query(DataFile)
|
||||
.filter(DataFile.project_id == source_id)
|
||||
.count()
|
||||
)
|
||||
except Exception:
|
||||
df_count = 0
|
||||
total_data_files_moving = df_count
|
||||
|
||||
# Modules: add anything in source missing from target.
|
||||
src_modules = {
|
||||
m.module_type for m in db.query(ProjectModule)
|
||||
.filter(ProjectModule.project_id == source_id, ProjectModule.enabled.is_(True))
|
||||
.all()
|
||||
}
|
||||
tgt_modules = {
|
||||
m.module_type for m in db.query(ProjectModule)
|
||||
.filter(ProjectModule.project_id == target_id, ProjectModule.enabled.is_(True))
|
||||
.all()
|
||||
}
|
||||
modules_to_add = sorted(src_modules - tgt_modules)
|
||||
|
||||
warnings: list[str] = []
|
||||
# Surface conditions the operator should think about.
|
||||
consolidations = sum(1 for p in location_plans if p.action == "consolidate")
|
||||
if consolidations:
|
||||
warnings.append(
|
||||
f"{consolidations} location(s) with matching names will be consolidated "
|
||||
f"(source assignments will move to the target's existing location). "
|
||||
f"If your same-named locations are actually different sites, rename one first."
|
||||
)
|
||||
if source.client_name and target.client_name and source.client_name.strip().casefold() != target.client_name.strip().casefold():
|
||||
warnings.append(
|
||||
f"Client names differ: source is \"{source.client_name}\", target is "
|
||||
f"\"{target.client_name}\". Target's client name will be kept."
|
||||
)
|
||||
|
||||
return MergePreview(
|
||||
source_project_id = source.id,
|
||||
source_project_name = source.name,
|
||||
target_project_id = target.id,
|
||||
target_project_name = target.name,
|
||||
location_plans = location_plans,
|
||||
total_assignments_moving = total_assignments_moving,
|
||||
total_sessions_moving = total_sessions_moving,
|
||||
total_data_files_moving = total_data_files_moving,
|
||||
modules_to_add = modules_to_add,
|
||||
warnings = warnings,
|
||||
)
|
||||
|
||||
|
||||
# ── Execute ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def execute(
|
||||
db: Session,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
*,
|
||||
decided_by: str = "operator",
|
||||
) -> MergeResult:
|
||||
"""Perform the merge in a single transaction.
|
||||
|
||||
Steps:
|
||||
1. Re-validate the pair.
|
||||
2. For each location in source:
|
||||
- if a same-name location exists in target → "consolidate" mode:
|
||||
move source's assignments + sessions to target's location id,
|
||||
delete source's location.
|
||||
- else → "move" mode: just re-point the location's project_id.
|
||||
3. Move any remaining direct-to-project FK rows (DataFiles).
|
||||
4. Ensure target has all of source's modules.
|
||||
5. Soft-delete source project.
|
||||
6. Write a UnitHistory row per assignment that was moved
|
||||
(change_type='assignment_merged') so the deployment timeline
|
||||
on each affected unit reflects the merge.
|
||||
7. Commit.
|
||||
"""
|
||||
source, target = _validate_pair(db, source_id, target_id)
|
||||
|
||||
src_modules = {
|
||||
m.module_type for m in db.query(ProjectModule)
|
||||
.filter(ProjectModule.project_id == source_id, ProjectModule.enabled.is_(True))
|
||||
.all()
|
||||
}
|
||||
tgt_modules = {
|
||||
m.module_type for m in db.query(ProjectModule)
|
||||
.filter(ProjectModule.project_id == target_id, ProjectModule.enabled.is_(True))
|
||||
.all()
|
||||
}
|
||||
modules_to_add = sorted(src_modules - tgt_modules)
|
||||
|
||||
# ── 1. Locations + their dependents ───────────────────────────────
|
||||
source_locs = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == source_id)
|
||||
.all()
|
||||
)
|
||||
target_locs = (
|
||||
db.query(MonitoringLocation)
|
||||
.filter(MonitoringLocation.project_id == target_id)
|
||||
.all()
|
||||
)
|
||||
target_by_norm = {_normalise_name(l.name): l for l in target_locs}
|
||||
|
||||
assignments_moved = 0
|
||||
sessions_moved = 0
|
||||
locations_moved = 0
|
||||
locations_consolidated = 0
|
||||
audit_rows_written = 0
|
||||
|
||||
for sl in source_locs:
|
||||
n = _normalise_name(sl.name)
|
||||
tl = target_by_norm.get(n)
|
||||
|
||||
# Pull this location's assignments + sessions (we'll re-point them).
|
||||
assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.location_id == sl.id)
|
||||
.all()
|
||||
)
|
||||
sessions = (
|
||||
db.query(MonitoringSession)
|
||||
.filter(MonitoringSession.location_id == sl.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
if tl is not None:
|
||||
# Consolidate: move dependents to target's existing location;
|
||||
# then delete the source location.
|
||||
for a in assignments:
|
||||
old_loc_id = a.location_id
|
||||
a.location_id = tl.id
|
||||
a.project_id = target.id
|
||||
|
||||
db.add(UnitHistory(
|
||||
unit_id = a.unit_id,
|
||||
change_type = "assignment_merged",
|
||||
field_name = "unit_assignment.project_id",
|
||||
old_value = f"{source.name} / {sl.name}",
|
||||
new_value = f"{target.name} / {tl.name}",
|
||||
changed_at = datetime.utcnow(),
|
||||
source = "project_merge",
|
||||
notes = (
|
||||
f"Project merge: '{source.name}' → '{target.name}'. "
|
||||
f"Location consolidated by name match. "
|
||||
f"By: {decided_by}."
|
||||
),
|
||||
))
|
||||
audit_rows_written += 1
|
||||
assignments_moved += 1
|
||||
|
||||
for s in sessions:
|
||||
s.location_id = tl.id
|
||||
s.project_id = target.id
|
||||
sessions_moved += 1
|
||||
|
||||
# Delete the now-empty source location.
|
||||
db.delete(sl)
|
||||
locations_consolidated += 1
|
||||
else:
|
||||
# Move: just re-point this location to the target project.
|
||||
sl.project_id = target.id
|
||||
|
||||
for a in assignments:
|
||||
old_proj_id = a.project_id
|
||||
a.project_id = target.id
|
||||
|
||||
db.add(UnitHistory(
|
||||
unit_id = a.unit_id,
|
||||
change_type = "assignment_merged",
|
||||
field_name = "unit_assignment.project_id",
|
||||
old_value = f"{source.name} / {sl.name}",
|
||||
new_value = f"{target.name} / {sl.name}",
|
||||
changed_at = datetime.utcnow(),
|
||||
source = "project_merge",
|
||||
notes = (
|
||||
f"Project merge: '{source.name}' → '{target.name}'. "
|
||||
f"Location moved as-is. By: {decided_by}."
|
||||
),
|
||||
))
|
||||
audit_rows_written += 1
|
||||
assignments_moved += 1
|
||||
|
||||
for s in sessions:
|
||||
s.project_id = target.id
|
||||
sessions_moved += 1
|
||||
|
||||
locations_moved += 1
|
||||
|
||||
# ── 2. Direct-to-project rows (DataFiles, ScheduledActions) ──────
|
||||
data_files_moved = 0
|
||||
try:
|
||||
data_files = (
|
||||
db.query(DataFile)
|
||||
.filter(DataFile.project_id == source_id)
|
||||
.all()
|
||||
)
|
||||
for df in data_files:
|
||||
df.project_id = target.id
|
||||
data_files_moved += 1
|
||||
except Exception as e:
|
||||
log.warning("DataFile move skipped (model may differ): %s", e)
|
||||
|
||||
# ── 3. UnitAssignments that point directly at source.project_id with
|
||||
# no location (shouldn't happen but be defensive) ──────────────
|
||||
orphan_assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.project_id == source_id)
|
||||
.all()
|
||||
)
|
||||
for a in orphan_assignments:
|
||||
# Already moved if its location was moved. Catch any stragglers.
|
||||
if a.project_id == source_id:
|
||||
a.project_id = target.id
|
||||
|
||||
# ── 4. Modules ────────────────────────────────────────────────────
|
||||
import uuid
|
||||
for mod_type in modules_to_add:
|
||||
db.add(ProjectModule(
|
||||
id = str(uuid.uuid4()),
|
||||
project_id = target.id,
|
||||
module_type = mod_type,
|
||||
enabled = True,
|
||||
))
|
||||
|
||||
# Disable source's modules (defensive — source is being soft-deleted
|
||||
# but its modules table rows could still be inspected).
|
||||
for m in db.query(ProjectModule).filter(ProjectModule.project_id == source_id).all():
|
||||
m.enabled = False
|
||||
|
||||
# ── 5. Soft-delete source ─────────────────────────────────────────
|
||||
source.status = "deleted"
|
||||
source.deleted_at = datetime.utcnow()
|
||||
|
||||
# Final audit row on the source project itself (operator-facing).
|
||||
# We don't have a Project-level history table, so log on every
|
||||
# affected unit as a marker. Already done per-assignment above.
|
||||
|
||||
db.commit()
|
||||
|
||||
return MergeResult(
|
||||
source_project_id = source.id,
|
||||
target_project_id = target.id,
|
||||
assignments_moved = assignments_moved,
|
||||
locations_moved = locations_moved,
|
||||
locations_consolidated = locations_consolidated,
|
||||
sessions_moved = sessions_moved,
|
||||
data_files_moved = data_files_moved,
|
||||
modules_added = modules_to_add,
|
||||
audit_rows_written = audit_rows_written,
|
||||
)
|
||||
@@ -75,10 +75,266 @@
|
||||
Generate Combined Report
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="openMergeModal()"
|
||||
title="Merge this project into another (consolidates duplicates)"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
Merge into…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merge Modal -->
|
||||
<div id="merge-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Merge "{{ project.name }}" into another project</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Source project gets soft-deleted. All its locations, assignments, sessions, and files move to the target.
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="closeMergeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0 ml-3">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Target project
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="merge-target-input"
|
||||
placeholder="Type to search for the project to merge INTO…"
|
||||
autocomplete="off"
|
||||
oninput="onMergeTargetInput()"
|
||||
onfocus="onMergeTargetInput()"
|
||||
onblur="setTimeout(() => document.getElementById('merge-target-dropdown').classList.add('hidden'), 150)"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
|
||||
<input type="hidden" id="merge-target-id" value="">
|
||||
<div id="merge-target-dropdown"
|
||||
class="hidden absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- Preview pane -->
|
||||
<div id="merge-preview" class="mt-4 hidden">
|
||||
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">What will move</h4>
|
||||
<div id="merge-preview-body" class="space-y-3"></div>
|
||||
</div>
|
||||
|
||||
<div id="merge-error" class="hidden mt-3 text-sm text-red-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
||||
<button onclick="closeMergeModal()"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="merge-confirm-btn"
|
||||
onclick="confirmMerge()"
|
||||
disabled
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
Merge (permanent)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const _SOURCE_PROJECT_ID = "{{ project.id }}";
|
||||
const _SOURCE_PROJECT_NAME = {{ project.name|tojson }};
|
||||
|
||||
function _mergeEsc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function openMergeModal() {
|
||||
document.getElementById('merge-target-input').value = '';
|
||||
document.getElementById('merge-target-id').value = '';
|
||||
document.getElementById('merge-preview').classList.add('hidden');
|
||||
document.getElementById('merge-error').classList.add('hidden');
|
||||
document.getElementById('merge-confirm-btn').disabled = true;
|
||||
document.getElementById('merge-modal').classList.remove('hidden');
|
||||
setTimeout(() => document.getElementById('merge-target-input').focus(), 50);
|
||||
}
|
||||
|
||||
function closeMergeModal() {
|
||||
document.getElementById('merge-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
let _mergeTargetDebounce = null;
|
||||
async function onMergeTargetInput() {
|
||||
if (_mergeTargetDebounce) clearTimeout(_mergeTargetDebounce);
|
||||
_mergeTargetDebounce = setTimeout(_mergeFetchTargets, 150);
|
||||
}
|
||||
|
||||
async function _mergeFetchTargets() {
|
||||
const q = document.getElementById('merge-target-input').value.trim();
|
||||
const dropdown = document.getElementById('merge-target-dropdown');
|
||||
|
||||
let data;
|
||||
try {
|
||||
// Reuse the metadata-backfill projects_search endpoint — works for
|
||||
// any caller, returns existing-only (no create-new option needed here).
|
||||
const r = await fetch(`/api/admin/metadata_backfill/projects_search?q=${encodeURIComponent(q)}&limit=12`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
data = await r.json();
|
||||
} catch (e) {
|
||||
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-red-500">Search failed: ${_mergeEsc(e.message)}</div>`;
|
||||
dropdown.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out self.
|
||||
const candidates = (data.matches || []).filter(m => m.id !== _SOURCE_PROJECT_ID);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">No matches.</div>`;
|
||||
dropdown.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.innerHTML = candidates.map(m => {
|
||||
const scoreBadge = m.score >= 0.99
|
||||
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
|
||||
: `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">${(m.score*100).toFixed(0)}%</span>`;
|
||||
const meta = [];
|
||||
if (m.project_number) meta.push(_mergeEsc(m.project_number));
|
||||
if (m.client_name) meta.push(_mergeEsc(m.client_name));
|
||||
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
|
||||
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
||||
return `<button type="button"
|
||||
onmousedown="event.preventDefault()"
|
||||
onclick="onMergePickTarget('${_mergeEsc(m.id)}', ${JSON.stringify(m.name)})"
|
||||
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
|
||||
${metaLine}
|
||||
</button>`;
|
||||
}).join('');
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function onMergePickTarget(targetId, targetName) {
|
||||
document.getElementById('merge-target-input').value = targetName;
|
||||
document.getElementById('merge-target-id').value = targetId;
|
||||
document.getElementById('merge-target-dropdown').classList.add('hidden');
|
||||
await _loadMergePreview(targetId);
|
||||
}
|
||||
|
||||
async function _loadMergePreview(targetId) {
|
||||
const previewEl = document.getElementById('merge-preview');
|
||||
const bodyEl = document.getElementById('merge-preview-body');
|
||||
const errorEl = document.getElementById('merge-error');
|
||||
const confirmBtn = document.getElementById('merge-confirm-btn');
|
||||
|
||||
previewEl.classList.add('hidden');
|
||||
errorEl.classList.add('hidden');
|
||||
confirmBtn.disabled = true;
|
||||
bodyEl.innerHTML = '<div class="text-center py-3 text-sm text-gray-500"><div class="animate-spin rounded-full h-5 w-5 border-b-2 border-seismo-orange mx-auto mb-2"></div>Loading preview…</div>';
|
||||
previewEl.classList.remove('hidden');
|
||||
|
||||
let d;
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${_SOURCE_PROJECT_ID}/merge_preview?target_id=${encodeURIComponent(targetId)}`);
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || ('HTTP ' + r.status));
|
||||
}
|
||||
d = await r.json();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
previewEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Merging <strong>"${_mergeEsc(d.source_project_name)}"</strong> into <strong>"${_mergeEsc(d.target_project_name)}"</strong>:
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mt-2">
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Assignments</div><div class="text-lg font-bold">${d.total_assignments_moving}</div></div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Sessions</div><div class="text-lg font-bold">${d.total_sessions_moving}</div></div>
|
||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded p-2"><div class="text-xs text-gray-500 dark:text-gray-400">Data files</div><div class="text-lg font-bold">${d.total_data_files_moving}</div></div>
|
||||
</div>`;
|
||||
|
||||
if (d.location_plans.length > 0) {
|
||||
html += `<div class="mt-3">
|
||||
<h5 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Locations</h5>
|
||||
<div class="space-y-1 text-sm">`;
|
||||
for (const p of d.location_plans) {
|
||||
if (p.action === 'consolidate') {
|
||||
html += `<div class="text-gray-700 dark:text-gray-300">
|
||||
🔀 <strong>${_mergeEsc(p.source_name)}</strong> → consolidates into existing target <strong>"${_mergeEsc(p.target_name)}"</strong>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(${p.assignments_moving} assignments + ${p.sessions_moving} sessions move)</span>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="text-gray-700 dark:text-gray-300">
|
||||
→ <strong>${_mergeEsc(p.source_name)}</strong> moves to target as-is
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(${p.assignments_moving} assignments + ${p.sessions_moving} sessions)</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
if (d.modules_to_add.length > 0) {
|
||||
html += `<div class="mt-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
Modules to add to target: ${d.modules_to_add.map(m => `<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs">${_mergeEsc(m)}</code>`).join(' ')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (d.warnings.length > 0) {
|
||||
html += '<div class="mt-3 space-y-2">';
|
||||
for (const w of d.warnings) {
|
||||
html += `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded p-2 text-xs text-amber-800 dark:text-amber-300">⚠ ${_mergeEsc(w)}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += `<div class="mt-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-2 text-xs text-red-800 dark:text-red-300">
|
||||
<strong>⚠ This action is destructive.</strong> Source project will be soft-deleted (status='deleted').
|
||||
Audit rows will be written to unit_history for every moved assignment.
|
||||
</div>`;
|
||||
|
||||
bodyEl.innerHTML = html;
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
|
||||
async function confirmMerge() {
|
||||
const targetId = document.getElementById('merge-target-id').value;
|
||||
if (!targetId) return;
|
||||
const confirmBtn = document.getElementById('merge-confirm-btn');
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Merging…';
|
||||
try {
|
||||
const r = await fetch(`/api/projects/${_SOURCE_PROJECT_ID}/merge_into?target_id=${encodeURIComponent(targetId)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||
throw new Error(err.detail || ('HTTP ' + r.status));
|
||||
}
|
||||
const d = await r.json();
|
||||
// Redirect to the target project — source no longer exists in active state.
|
||||
window.location.href = `/projects/${d.target_project_id}`;
|
||||
} catch (e) {
|
||||
const errorEl = document.getElementById('merge-error');
|
||||
errorEl.textContent = 'Merge failed: ' + e.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Merge (permanent)';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add Module Modal -->
|
||||
<div id="add-module-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
|
||||
|
||||
Reference in New Issue
Block a user