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:
56
backend/migrate_add_project_deleted_at.py
Normal file
56
backend/migrate_add_project_deleted_at.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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."}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user