feat: implement project status management with 'on_hold' state and associated UI updates

-feat: ability to hard delete projects, plus a soft delete with auto pruning.
This commit is contained in:
serversdwn
2026-02-19 15:23:02 +00:00
parent dc77a362ce
commit 65362bab21
9 changed files with 300 additions and 20 deletions

View File

@@ -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)

View File

@@ -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):

View File

@@ -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."}
# ============================================================================