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

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