diff --git a/backend/migrate_add_project_deleted_at.py b/backend/migrate_add_project_deleted_at.py new file mode 100644 index 0000000..d15ed34 --- /dev/null +++ b/backend/migrate_add_project_deleted_at.py @@ -0,0 +1,56 @@ +""" +Migration: Add deleted_at column to projects table + +Adds columns: +- projects.deleted_at: Timestamp set when status='deleted'; data hard-deleted after 60 days +""" + +import sqlite3 +import sys +from pathlib import Path + + +def migrate(db_path: str): + """Run the migration.""" + print(f"Migrating database: {db_path}") + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'") + if not cursor.fetchone(): + print("projects table does not exist. Skipping migration.") + return + + cursor.execute("PRAGMA table_info(projects)") + existing_cols = {row[1] for row in cursor.fetchall()} + + if 'deleted_at' not in existing_cols: + print("Adding deleted_at column to projects...") + cursor.execute("ALTER TABLE projects ADD COLUMN deleted_at DATETIME") + else: + print("deleted_at column already exists. Skipping.") + + conn.commit() + print("Migration completed successfully!") + + except Exception as e: + print(f"Migration failed: {e}") + conn.rollback() + raise + finally: + conn.close() + + +if __name__ == "__main__": + db_path = "./data/terra-view.db" + + if len(sys.argv) > 1: + db_path = sys.argv[1] + + if not Path(db_path).exists(): + print(f"Database not found: {db_path}") + sys.exit(1) + + migrate(db_path) diff --git a/backend/models.py b/backend/models.py index 49ec9af..5f8eb99 100644 --- a/backend/models.py +++ b/backend/models.py @@ -155,7 +155,7 @@ class Project(Base): name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall") description = Column(Text, nullable=True) project_type_id = Column(String, nullable=False) # FK to ProjectType.id - status = Column(String, default="active") # active, completed, archived + status = Column(String, default="active") # active, on_hold, completed, archived, deleted # Project metadata client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick") @@ -166,6 +166,7 @@ class Project(Base): created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days class MonitoringLocation(Base): diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 2fcf0f0..4beec68 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -57,9 +57,11 @@ async def get_projects_list( """ query = db.query(Project) - # Filter by status if provided + # Filter by status if provided; otherwise exclude soft-deleted projects if status: query = query.filter(Project.status == status) + else: + query = query.filter(Project.status != "deleted") # Filter by project type if provided if project_type_id: @@ -118,9 +120,10 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)): Get summary statistics for projects overview. Returns HTML partial with stat cards. """ - # Count projects by status - total_projects = db.query(func.count(Project.id)).scalar() + # Count projects by status (exclude deleted) + total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar() active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar() + on_hold_projects = db.query(func.count(Project.id)).filter_by(status="on_hold").scalar() completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar() # Count total locations across all projects @@ -140,6 +143,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)): "request": request, "total_projects": total_projects, "active_projects": active_projects, + "on_hold_projects": on_hold_projects, "completed_projects": completed_projects, "total_locations": total_locations, "assigned_units": assigned_units, @@ -178,13 +182,13 @@ async def search_projects( if not q.strip(): # Return recent active projects when no search term projects = db.query(Project).filter( - Project.status != "archived" + Project.status.notin_(["archived", "deleted"]) ).order_by(Project.updated_at.desc()).limit(limit).all() else: search_term = f"%{q}%" projects = db.query(Project).filter( and_( - Project.status != "archived", + Project.status.notin_(["archived", "deleted"]), or_( Project.project_number.ilike(search_term), Project.client_name.ilike(search_term), @@ -223,13 +227,13 @@ async def search_projects_json( """ if not q.strip(): projects = db.query(Project).filter( - Project.status != "archived" + Project.status.notin_(["archived", "deleted"]) ).order_by(Project.updated_at.desc()).limit(limit).all() else: search_term = f"%{q}%" projects = db.query(Project).filter( and_( - Project.status != "archived", + Project.status.notin_(["archived", "deleted"]), or_( Project.project_number.ilike(search_term), Project.client_name.ilike(search_term), @@ -359,18 +363,76 @@ async def update_project( @router.delete("/{project_id}") async def delete_project(project_id: str, db: Session = Depends(get_db)): """ - Delete a project (soft delete by archiving). + Soft-delete a project. Sets status='deleted' and records deleted_at timestamp. + Data will be permanently removed after 60 days (or via /permanent endpoint). """ project = db.query(Project).filter_by(id=project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") - project.status = "archived" + project.status = "deleted" + project.deleted_at = datetime.utcnow() project.updated_at = datetime.utcnow() db.commit() - return {"success": True, "message": "Project archived successfully"} + return {"success": True, "message": "Project deleted. Data will be permanently removed after 60 days."} + + +@router.delete("/{project_id}/permanent") +async def permanently_delete_project(project_id: str, db: Session = Depends(get_db)): + """ + Hard-delete a project and all related data. Only allowed when status='deleted'. + Removes: locations, assignments, sessions, scheduled actions, recurring schedules. + """ + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + if project.status != "deleted": + raise HTTPException(status_code=400, detail="Project must be soft-deleted before permanent deletion.") + + # Delete related data + db.query(RecurringSchedule).filter_by(project_id=project_id).delete() + db.query(ScheduledAction).filter_by(project_id=project_id).delete() + db.query(RecordingSession).filter_by(project_id=project_id).delete() + db.query(UnitAssignment).filter_by(project_id=project_id).delete() + db.query(MonitoringLocation).filter_by(project_id=project_id).delete() + db.delete(project) + db.commit() + + return {"success": True, "message": "Project permanently deleted."} + + +@router.post("/{project_id}/hold") +async def hold_project(project_id: str, db: Session = Depends(get_db)): + """ + Put a project on hold. Pauses without archiving; assignments and schedules remain. + """ + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + project.status = "on_hold" + project.updated_at = datetime.utcnow() + db.commit() + + return {"success": True, "message": "Project put on hold."} + + +@router.post("/{project_id}/unhold") +async def unhold_project(project_id: str, db: Session = Depends(get_db)): + """ + Resume a project that was on hold. + """ + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + project.status = "active" + project.updated_at = datetime.utcnow() + db.commit() + + return {"success": True, "message": "Project resumed."} # ============================================================================ diff --git a/templates/partials/projects/project_dashboard.html b/templates/partials/projects/project_dashboard.html index 94e95b9..1cf04f6 100644 --- a/templates/partials/projects/project_dashboard.html +++ b/templates/partials/projects/project_dashboard.html @@ -13,6 +13,8 @@ {% if project.status == 'active' %} Active + {% elif project.status == 'on_hold' %} + On Hold {% elif project.status == 'completed' %} Completed {% elif project.status == 'archived' %} diff --git a/templates/partials/projects/project_list.html b/templates/partials/projects/project_list.html index 60d1d3e..3005438 100644 --- a/templates/partials/projects/project_list.html +++ b/templates/partials/projects/project_list.html @@ -34,6 +34,10 @@ Active + {% elif item.project.status == 'on_hold' %} + + On Hold + {% elif item.project.status == 'completed' %} Completed diff --git a/templates/partials/projects/project_list_compact.html b/templates/partials/projects/project_list_compact.html index d5f78e4..a2acf79 100644 --- a/templates/partials/projects/project_list_compact.html +++ b/templates/partials/projects/project_list_compact.html @@ -16,6 +16,8 @@ {% if item.project.status == 'active' %} Active + {% elif item.project.status == 'on_hold' %} + On Hold {% elif item.project.status == 'completed' %} Completed {% elif item.project.status == 'archived' %} diff --git a/templates/partials/projects/project_stats.html b/templates/partials/projects/project_stats.html index 30b5ac7..1cf8926 100644 --- a/templates/partials/projects/project_stats.html +++ b/templates/partials/projects/project_stats.html @@ -27,6 +27,20 @@ +
+
+
+

On Hold

+

{{ on_hold_projects }}

+
+
+ + + +
+
+
+
diff --git a/templates/projects/detail.html b/templates/projects/detail.html index fa6c79d..ef0b903 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -279,6 +279,7 @@ @@ -329,14 +330,39 @@

Danger Zone

-
-

- Archive this project to remove it from active listings. All data will be preserved. -

- +
+ +
+
+

Put Project On Hold

+

Pause this project without archiving. Assignments and schedules remain in place.

+
+
+ +
+
+ +
+
+

Archive Project

+

Remove from active listings. All data is preserved and can be restored.

+
+ +
+ +
+
+

Delete Project

+

Permanently removes all project data after a 60-day grace period. This action is difficult to undo.

+
+ +
@@ -596,6 +622,40 @@
+ + +