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."}
|
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}")
|
@router.get("/{project_id}")
|
||||||
async def get_project(project_id: str, db: Session = Depends(get_db)):
|
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
|
Generate Combined Report
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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>
|
</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 -->
|
<!-- 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 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">
|
<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