diff --git a/backend/routers/projects.py b/backend/routers/projects.py index bc40b1b..13cb23a 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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)): """ diff --git a/backend/services/project_merge.py b/backend/services/project_merge.py new file mode 100644 index 0000000..53a1c5d --- /dev/null +++ b/backend/services/project_merge.py @@ -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, + ) diff --git a/templates/partials/projects/project_header.html b/templates/partials/projects/project_header.html index f3e3f2e..6aeff79 100644 --- a/templates/partials/projects/project_header.html +++ b/templates/partials/projects/project_header.html @@ -75,10 +75,266 @@ Generate Combined Report {% endif %} + + + + + +