Compare commits
3 Commits
b3bf91880a
...
v0.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b15d434fce | |||
| 7b4e12c127 | |||
| 742a98a8ed |
@@ -1,5 +1,3 @@
|
|||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# Python cache / compiled
|
# Python cache / compiled
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -30,7 +28,6 @@ ENV/
|
|||||||
|
|
||||||
# Runtime data (mounted volumes)
|
# Runtime data (mounted volumes)
|
||||||
data/
|
data/
|
||||||
data-dev/
|
|
||||||
|
|
||||||
# Editors / OS junk
|
# Editors / OS junk
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"""
|
|
||||||
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")
|
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||||
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
status = Column(String, default="active") # active, completed, archived
|
||||||
|
|
||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
@@ -166,7 +166,6 @@ class Project(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=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):
|
class MonitoringLocation(Base):
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
@@ -49,18 +48,10 @@ def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
|
|||||||
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
# Exclude actions from paused/removed projects
|
|
||||||
paused_project_ids = [
|
|
||||||
p.id for p in db.query(Project.id).filter(
|
|
||||||
Project.status.in_(["on_hold", "archived", "deleted"])
|
|
||||||
).all()
|
|
||||||
]
|
|
||||||
|
|
||||||
# Query today's actions
|
# Query today's actions
|
||||||
actions = db.query(ScheduledAction).filter(
|
actions = db.query(ScheduledAction).filter(
|
||||||
ScheduledAction.scheduled_time >= today_start_utc,
|
ScheduledAction.scheduled_time >= today_start_utc,
|
||||||
ScheduledAction.scheduled_time < today_end_utc,
|
ScheduledAction.scheduled_time < today_end_utc,
|
||||||
ScheduledAction.project_id.notin_(paused_project_ids),
|
|
||||||
).order_by(ScheduledAction.scheduled_time.asc()).all()
|
).order_by(ScheduledAction.scheduled_time.asc()).all()
|
||||||
|
|
||||||
# Enrich with location/project info and parse results
|
# Enrich with location/project info and parse results
|
||||||
|
|||||||
@@ -57,11 +57,9 @@ async def get_projects_list(
|
|||||||
"""
|
"""
|
||||||
query = db.query(Project)
|
query = db.query(Project)
|
||||||
|
|
||||||
# Filter by status if provided; otherwise exclude soft-deleted projects
|
# Filter by status if provided
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(Project.status == status)
|
query = query.filter(Project.status == status)
|
||||||
else:
|
|
||||||
query = query.filter(Project.status != "deleted")
|
|
||||||
|
|
||||||
# Filter by project type if provided
|
# Filter by project type if provided
|
||||||
if project_type_id:
|
if project_type_id:
|
||||||
@@ -120,10 +118,9 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
Get summary statistics for projects overview.
|
Get summary statistics for projects overview.
|
||||||
Returns HTML partial with stat cards.
|
Returns HTML partial with stat cards.
|
||||||
"""
|
"""
|
||||||
# Count projects by status (exclude deleted)
|
# Count projects by status
|
||||||
total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
|
total_projects = db.query(func.count(Project.id)).scalar()
|
||||||
active_projects = db.query(func.count(Project.id)).filter_by(status="active").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()
|
completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar()
|
||||||
|
|
||||||
# Count total locations across all projects
|
# Count total locations across all projects
|
||||||
@@ -143,7 +140,6 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"total_projects": total_projects,
|
"total_projects": total_projects,
|
||||||
"active_projects": active_projects,
|
"active_projects": active_projects,
|
||||||
"on_hold_projects": on_hold_projects,
|
|
||||||
"completed_projects": completed_projects,
|
"completed_projects": completed_projects,
|
||||||
"total_locations": total_locations,
|
"total_locations": total_locations,
|
||||||
"assigned_units": assigned_units,
|
"assigned_units": assigned_units,
|
||||||
@@ -182,13 +178,13 @@ async def search_projects(
|
|||||||
if not q.strip():
|
if not q.strip():
|
||||||
# Return recent active projects when no search term
|
# Return recent active projects when no search term
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
Project.status.notin_(["archived", "deleted"])
|
Project.status != "archived"
|
||||||
).order_by(Project.updated_at.desc()).limit(limit).all()
|
).order_by(Project.updated_at.desc()).limit(limit).all()
|
||||||
else:
|
else:
|
||||||
search_term = f"%{q}%"
|
search_term = f"%{q}%"
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
and_(
|
and_(
|
||||||
Project.status.notin_(["archived", "deleted"]),
|
Project.status != "archived",
|
||||||
or_(
|
or_(
|
||||||
Project.project_number.ilike(search_term),
|
Project.project_number.ilike(search_term),
|
||||||
Project.client_name.ilike(search_term),
|
Project.client_name.ilike(search_term),
|
||||||
@@ -227,13 +223,13 @@ async def search_projects_json(
|
|||||||
"""
|
"""
|
||||||
if not q.strip():
|
if not q.strip():
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
Project.status.notin_(["archived", "deleted"])
|
Project.status != "archived"
|
||||||
).order_by(Project.updated_at.desc()).limit(limit).all()
|
).order_by(Project.updated_at.desc()).limit(limit).all()
|
||||||
else:
|
else:
|
||||||
search_term = f"%{q}%"
|
search_term = f"%{q}%"
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
and_(
|
and_(
|
||||||
Project.status.notin_(["archived", "deleted"]),
|
Project.status != "archived",
|
||||||
or_(
|
or_(
|
||||||
Project.project_number.ilike(search_term),
|
Project.project_number.ilike(search_term),
|
||||||
Project.client_name.ilike(search_term),
|
Project.client_name.ilike(search_term),
|
||||||
@@ -342,14 +338,6 @@ async def update_project(
|
|||||||
project.description = data["description"]
|
project.description = data["description"]
|
||||||
if "status" in data:
|
if "status" in data:
|
||||||
project.status = data["status"]
|
project.status = data["status"]
|
||||||
# Cancel pending scheduled actions when archiving
|
|
||||||
if data["status"] == "archived":
|
|
||||||
db.query(ScheduledAction).filter(
|
|
||||||
and_(
|
|
||||||
ScheduledAction.project_id == project_id,
|
|
||||||
ScheduledAction.execution_status == "pending",
|
|
||||||
)
|
|
||||||
).update({"execution_status": "cancelled"})
|
|
||||||
if "client_name" in data:
|
if "client_name" in data:
|
||||||
project.client_name = data["client_name"]
|
project.client_name = data["client_name"]
|
||||||
if "site_address" in data:
|
if "site_address" in data:
|
||||||
@@ -371,93 +359,18 @@ async def update_project(
|
|||||||
@router.delete("/{project_id}")
|
@router.delete("/{project_id}")
|
||||||
async def delete_project(project_id: str, db: Session = Depends(get_db)):
|
async def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Soft-delete a project. Sets status='deleted' and records deleted_at timestamp.
|
Delete a project (soft delete by archiving).
|
||||||
Data will be permanently removed after 60 days (or via /permanent endpoint).
|
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
project.status = "deleted"
|
project.status = "archived"
|
||||||
project.deleted_at = datetime.utcnow()
|
|
||||||
project.updated_at = datetime.utcnow()
|
project.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
# Cancel all pending scheduled actions
|
|
||||||
db.query(ScheduledAction).filter(
|
|
||||||
and_(
|
|
||||||
ScheduledAction.project_id == project_id,
|
|
||||||
ScheduledAction.execution_status == "pending",
|
|
||||||
)
|
|
||||||
).update({"execution_status": "cancelled"})
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"success": True, "message": "Project deleted. Data will be permanently removed after 60 days."}
|
return {"success": True, "message": "Project archived successfully"}
|
||||||
|
|
||||||
|
|
||||||
@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()
|
|
||||||
|
|
||||||
# Cancel pending scheduled actions so they don't appear in dashboards or fire
|
|
||||||
db.query(ScheduledAction).filter(
|
|
||||||
and_(
|
|
||||||
ScheduledAction.project_id == project_id,
|
|
||||||
ScheduledAction.execution_status == "pending",
|
|
||||||
)
|
|
||||||
).update({"execution_status": "cancelled"})
|
|
||||||
|
|
||||||
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."}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -697,14 +610,10 @@ async def get_project_schedules(
|
|||||||
"result": result_data,
|
"result": result_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
|
||||||
project_status = project.status if project else "active"
|
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/projects/schedule_list.html", {
|
return templates.TemplateResponse("partials/projects/schedule_list.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"schedules_by_date": schedules_by_date,
|
"schedules_by_date": schedules_by_date,
|
||||||
"project_status": project_status,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -497,9 +497,6 @@ async def get_schedule_list_partial(
|
|||||||
"""
|
"""
|
||||||
Return HTML partial for schedule list.
|
Return HTML partial for schedule list.
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
|
||||||
project_status = project.status if project else "active"
|
|
||||||
|
|
||||||
schedules = db.query(RecurringSchedule).filter_by(
|
schedules = db.query(RecurringSchedule).filter_by(
|
||||||
project_id=project_id
|
project_id=project_id
|
||||||
).order_by(RecurringSchedule.created_at.desc()).all()
|
).order_by(RecurringSchedule.created_at.desc()).all()
|
||||||
@@ -518,5 +515,4 @@ async def get_schedule_list_partial(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"schedules": schedule_data,
|
"schedules": schedule_data,
|
||||||
"project_status": project_status,
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from zoneinfo import ZoneInfo
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment, Project
|
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -332,12 +332,10 @@ class RecurringScheduleService:
|
|||||||
)
|
)
|
||||||
actions.append(start_action)
|
actions.append(start_action)
|
||||||
|
|
||||||
# Create STOP action (stop_cycle handles download when include_download is True)
|
# Create STOP action
|
||||||
stop_notes = json.dumps({
|
stop_notes = json.dumps({
|
||||||
"schedule_name": schedule.name,
|
"schedule_name": schedule.name,
|
||||||
"schedule_id": schedule.id,
|
"schedule_id": schedule.id,
|
||||||
"schedule_type": "weekly_calendar",
|
|
||||||
"include_download": schedule.include_download,
|
|
||||||
})
|
})
|
||||||
stop_action = ScheduledAction(
|
stop_action = ScheduledAction(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
@@ -352,6 +350,27 @@ class RecurringScheduleService:
|
|||||||
)
|
)
|
||||||
actions.append(stop_action)
|
actions.append(stop_action)
|
||||||
|
|
||||||
|
# Create DOWNLOAD action if enabled (1 minute after stop)
|
||||||
|
if schedule.include_download:
|
||||||
|
download_time = end_utc + timedelta(minutes=1)
|
||||||
|
download_notes = json.dumps({
|
||||||
|
"schedule_name": schedule.name,
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"schedule_type": "weekly_calendar",
|
||||||
|
})
|
||||||
|
download_action = ScheduledAction(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=schedule.project_id,
|
||||||
|
location_id=schedule.location_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
action_type="download",
|
||||||
|
device_type=schedule.device_type,
|
||||||
|
scheduled_time=download_time,
|
||||||
|
execution_status="pending",
|
||||||
|
notes=download_notes,
|
||||||
|
)
|
||||||
|
actions.append(download_action)
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
def _generate_interval_actions(
|
def _generate_interval_actions(
|
||||||
@@ -594,16 +613,8 @@ class RecurringScheduleService:
|
|||||||
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
|
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
def get_enabled_schedules(self) -> List[RecurringSchedule]:
|
def get_enabled_schedules(self) -> List[RecurringSchedule]:
|
||||||
"""Get all enabled recurring schedules for projects that are not on hold or deleted."""
|
"""Get all enabled recurring schedules."""
|
||||||
active_project_ids = [
|
return self.db.query(RecurringSchedule).filter_by(enabled=True).all()
|
||||||
p.id for p in self.db.query(Project.id).filter(
|
|
||||||
Project.status.notin_(["on_hold", "archived", "deleted"])
|
|
||||||
).all()
|
|
||||||
]
|
|
||||||
return self.db.query(RecurringSchedule).filter(
|
|
||||||
RecurringSchedule.enabled == True,
|
|
||||||
RecurringSchedule.project_id.in_(active_project_ids),
|
|
||||||
).all()
|
|
||||||
|
|
||||||
|
|
||||||
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:
|
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:
|
||||||
|
|||||||
@@ -107,19 +107,10 @@ class SchedulerService:
|
|||||||
try:
|
try:
|
||||||
# Find pending actions that are due
|
# Find pending actions that are due
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
# Only execute actions for active/completed projects (not on_hold, archived, or deleted)
|
|
||||||
active_project_ids = [
|
|
||||||
p.id for p in db.query(Project.id).filter(
|
|
||||||
Project.status.notin_(["on_hold", "archived", "deleted"])
|
|
||||||
).all()
|
|
||||||
]
|
|
||||||
|
|
||||||
pending_actions = db.query(ScheduledAction).filter(
|
pending_actions = db.query(ScheduledAction).filter(
|
||||||
and_(
|
and_(
|
||||||
ScheduledAction.execution_status == "pending",
|
ScheduledAction.execution_status == "pending",
|
||||||
ScheduledAction.scheduled_time <= now,
|
ScheduledAction.scheduled_time <= now,
|
||||||
ScheduledAction.project_id.in_(active_project_ids),
|
|
||||||
)
|
)
|
||||||
).order_by(ScheduledAction.scheduled_time).all()
|
).order_by(ScheduledAction.scheduled_time).all()
|
||||||
|
|
||||||
@@ -304,20 +295,9 @@ class SchedulerService:
|
|||||||
stop_cycle handles:
|
stop_cycle handles:
|
||||||
1. Stop measurement
|
1. Stop measurement
|
||||||
2. Enable FTP
|
2. Enable FTP
|
||||||
3. Download measurement folder to SLMM local storage
|
3. Download measurement folder
|
||||||
|
4. Verify download
|
||||||
After stop_cycle, if download succeeded, this method fetches the ZIP
|
|
||||||
from SLMM and extracts it into Terra-View's project directory, creating
|
|
||||||
DataFile records for each file.
|
|
||||||
"""
|
"""
|
||||||
import hashlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import zipfile
|
|
||||||
import httpx
|
|
||||||
from pathlib import Path
|
|
||||||
from backend.models import DataFile
|
|
||||||
|
|
||||||
# Parse notes for download preference
|
# Parse notes for download preference
|
||||||
include_download = True
|
include_download = True
|
||||||
try:
|
try:
|
||||||
@@ -328,7 +308,7 @@ class SchedulerService:
|
|||||||
pass # Notes is plain text, not JSON
|
pass # Notes is plain text, not JSON
|
||||||
|
|
||||||
# Execute the full stop cycle via device controller
|
# Execute the full stop cycle via device controller
|
||||||
# SLMM handles stop, FTP enable, and download to SLMM-local storage
|
# SLMM handles stop, FTP enable, and download
|
||||||
cycle_response = await self.device_controller.stop_cycle(
|
cycle_response = await self.device_controller.stop_cycle(
|
||||||
unit_id,
|
unit_id,
|
||||||
action.device_type,
|
action.device_type,
|
||||||
@@ -360,81 +340,10 @@ class SchedulerService:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# If SLMM downloaded the folder successfully, fetch the ZIP from SLMM
|
|
||||||
# and extract it into Terra-View's project directory, creating DataFile records
|
|
||||||
files_created = 0
|
|
||||||
if include_download and cycle_response.get("download_success") and active_session:
|
|
||||||
folder_name = cycle_response.get("downloaded_folder") # e.g. "Auto_0058"
|
|
||||||
remote_path = f"/NL-43/{folder_name}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
|
||||||
async with httpx.AsyncClient(timeout=600.0) as client:
|
|
||||||
zip_response = await client.post(
|
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder",
|
|
||||||
json={"remote_path": remote_path}
|
|
||||||
)
|
|
||||||
|
|
||||||
if zip_response.is_success and len(zip_response.content) > 22:
|
|
||||||
base_dir = Path(f"data/Projects/{action.project_id}/{active_session.id}/{folder_name}")
|
|
||||||
base_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
file_type_map = {
|
|
||||||
'.wav': 'audio', '.mp3': 'audio',
|
|
||||||
'.csv': 'data', '.txt': 'data', '.json': 'data', '.dat': 'data',
|
|
||||||
'.rnd': 'data', '.rnh': 'data',
|
|
||||||
'.log': 'log',
|
|
||||||
'.zip': 'archive',
|
|
||||||
'.jpg': 'image', '.jpeg': 'image', '.png': 'image',
|
|
||||||
'.pdf': 'document',
|
|
||||||
}
|
|
||||||
|
|
||||||
with zipfile.ZipFile(io.BytesIO(zip_response.content)) as zf:
|
|
||||||
for zip_info in zf.filelist:
|
|
||||||
if zip_info.is_dir():
|
|
||||||
continue
|
|
||||||
file_data = zf.read(zip_info.filename)
|
|
||||||
file_path = base_dir / zip_info.filename
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(file_path, 'wb') as f:
|
|
||||||
f.write(file_data)
|
|
||||||
checksum = hashlib.sha256(file_data).hexdigest()
|
|
||||||
ext = os.path.splitext(zip_info.filename)[1].lower()
|
|
||||||
data_file = DataFile(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
session_id=active_session.id,
|
|
||||||
file_path=str(file_path.relative_to("data")),
|
|
||||||
file_type=file_type_map.get(ext, 'data'),
|
|
||||||
file_size_bytes=len(file_data),
|
|
||||||
downloaded_at=datetime.utcnow(),
|
|
||||||
checksum=checksum,
|
|
||||||
file_metadata=json.dumps({
|
|
||||||
"source": "stop_cycle",
|
|
||||||
"remote_path": remote_path,
|
|
||||||
"unit_id": unit_id,
|
|
||||||
"folder_name": folder_name,
|
|
||||||
"relative_path": zip_info.filename,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
db.add(data_file)
|
|
||||||
files_created += 1
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"Created {files_created} DataFile records for session {active_session.id} from {folder_name}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"ZIP from SLMM for {folder_name} was empty or failed, skipping DataFile creation")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to extract ZIP and create DataFile records for {folder_name}: {e}")
|
|
||||||
# Don't fail the stop action — the device was stopped successfully
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "stopped",
|
"status": "stopped",
|
||||||
"session_id": active_session.id if active_session else None,
|
"session_id": active_session.id if active_session else None,
|
||||||
"cycle_response": cycle_response,
|
"cycle_response": cycle_response,
|
||||||
"files_created": files_created,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _execute_download(
|
async def _execute_download(
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ class SLMMClient:
|
|||||||
|
|
||||||
# Format as Auto_XXXX folder name
|
# Format as Auto_XXXX folder name
|
||||||
folder_name = f"Auto_{index_number:04d}"
|
folder_name = f"Auto_{index_number:04d}"
|
||||||
remote_path = f"/NL-43/{folder_name}"
|
remote_path = f"/NL43_DATA/{folder_name}"
|
||||||
|
|
||||||
# Download the folder
|
# Download the folder
|
||||||
result = await self.download_folder(unit_id, remote_path)
|
result = await self.download_folder(unit_id, remote_path)
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
services:
|
|
||||||
terra-view:
|
|
||||||
environment:
|
|
||||||
- ENVIRONMENT=development
|
|
||||||
ports:
|
|
||||||
- "1001:8001"
|
|
||||||
volumes:
|
|
||||||
- ./data-dev:/app/data
|
|
||||||
@@ -24,6 +24,30 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
|
# --- TERRA-VIEW DEVELOPMENT ---
|
||||||
|
terra-view-dev:
|
||||||
|
build: .
|
||||||
|
container_name: terra-view-dev
|
||||||
|
ports:
|
||||||
|
- "1001:8001"
|
||||||
|
volumes:
|
||||||
|
- ./data-dev:/app/data
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- ENVIRONMENT=development
|
||||||
|
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- slmm
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
# --- SLMM (Sound Level Meter Manager) ---
|
# --- SLMM (Sound Level Meter Manager) ---
|
||||||
slmm:
|
slmm:
|
||||||
build:
|
build:
|
||||||
@@ -37,8 +61,6 @@ services:
|
|||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PORT=8100
|
- PORT=8100
|
||||||
- CORS_ORIGINS=*
|
- CORS_ORIGINS=*
|
||||||
- TCP_IDLE_TTL=-1
|
|
||||||
- TCP_MAX_AGE=-1
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
||||||
@@ -49,3 +71,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
data-dev:
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if project.status == 'active' %}
|
{% if project.status == 'active' %}
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||||
{% elif project.status == 'on_hold' %}
|
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
|
||||||
{% elif project.status == 'completed' %}
|
{% elif project.status == 'completed' %}
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
<span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
||||||
{% elif project.status == 'archived' %}
|
{% elif project.status == 'archived' %}
|
||||||
|
|||||||
@@ -34,10 +34,6 @@
|
|||||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
{% elif item.project.status == 'on_hold' %}
|
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
|
|
||||||
On Hold
|
|
||||||
</span>
|
|
||||||
{% elif item.project.status == 'completed' %}
|
{% elif item.project.status == 'completed' %}
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
|
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
|
||||||
Completed
|
Completed
|
||||||
|
|||||||
@@ -16,8 +16,6 @@
|
|||||||
|
|
||||||
{% if item.project.status == 'active' %}
|
{% if item.project.status == 'active' %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||||
{% elif item.project.status == 'on_hold' %}
|
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
|
||||||
{% elif item.project.status == 'completed' %}
|
{% elif item.project.status == 'completed' %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
||||||
{% elif item.project.status == 'archived' %}
|
{% elif item.project.status == 'archived' %}
|
||||||
|
|||||||
@@ -27,20 +27,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">On Hold</p>
|
|
||||||
<p class="text-3xl font-bold text-amber-600 dark:text-amber-400">{{ on_hold_projects }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
|
||||||
<svg class="w-8 h-8 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% if schedules %}
|
{% if schedules %}
|
||||||
{% for item in schedules %}
|
{% for item in schedules %}
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
|
||||||
{% if project_status == 'on_hold' or not item.schedule.enabled %}opacity-60{% endif %}">
|
{% if not item.schedule.enabled %}opacity-60{% endif %}">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
@@ -29,15 +29,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Status badge -->
|
<!-- Status badge -->
|
||||||
{% if project_status == 'on_hold' %}
|
{% if item.schedule.enabled %}
|
||||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
|
||||||
On Hold
|
|
||||||
</span>
|
|
||||||
{% elif project_status == 'archived' %}
|
|
||||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
|
||||||
Archived
|
|
||||||
</span>
|
|
||||||
{% elif item.schedule.enabled %}
|
|
||||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
@@ -106,8 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions (hidden when project is on hold or archived) -->
|
<!-- Actions -->
|
||||||
{% if project_status not in ('on_hold', 'archived') %}
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
{% if item.schedule.enabled %}
|
{% if item.schedule.enabled %}
|
||||||
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
|
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
|
||||||
@@ -140,7 +131,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -19,8 +19,7 @@
|
|||||||
<!-- Actions for this date -->
|
<!-- Actions for this date -->
|
||||||
<div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700">
|
<div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700">
|
||||||
{% for item in date_group.actions %}
|
{% for item in date_group.actions %}
|
||||||
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow
|
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||||
{% if project_status == 'on_hold' and item.schedule.execution_status == 'pending' %}opacity-60{% endif %}">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
@@ -55,11 +54,6 @@
|
|||||||
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
||||||
Pending
|
Pending
|
||||||
</span>
|
</span>
|
||||||
{% if project_status == 'on_hold' %}
|
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
|
|
||||||
On Hold
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
{% elif item.schedule.execution_status == 'completed' %}
|
{% elif item.schedule.execution_status == 'completed' %}
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
|
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
|
||||||
Completed
|
Completed
|
||||||
@@ -163,8 +157,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions (hidden when project is on hold or archived) -->
|
<!-- Actions -->
|
||||||
{% if project_status not in ('on_hold', 'archived') %}
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
{% if item.schedule.execution_status == 'pending' %}
|
{% if item.schedule.execution_status == 'pending' %}
|
||||||
<button onclick="executeSchedule('{{ item.schedule.id }}')"
|
<button onclick="executeSchedule('{{ item.schedule.id }}')"
|
||||||
@@ -184,7 +177,6 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -163,25 +163,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Deploy / Bench Toggle -->
|
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-white">Deployment Status</span>
|
|
||||||
<p id="slm-settings-deploy-desc" class="text-xs text-gray-500 dark:text-gray-400">Unit is currently deployed in the field</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" id="slm-settings-deploy-btn"
|
|
||||||
onclick="toggleSLMDeployed()"
|
|
||||||
class="px-3 py-1.5 text-sm rounded-lg font-medium transition-colors">
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FTP Enable Toggle -->
|
<!-- FTP Enable Toggle -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
<label class="flex items-center justify-between cursor-pointer">
|
<label class="flex items-center justify-between cursor-pointer">
|
||||||
@@ -283,9 +264,6 @@ async function openSLMSettingsModal(unitId) {
|
|||||||
// FTP enabled from SLMM
|
// FTP enabled from SLMM
|
||||||
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
|
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
|
||||||
|
|
||||||
// Deploy/bench status from Terra-View
|
|
||||||
updateDeployButton(unitData.deployed !== false);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load SLM settings:', error);
|
console.error('Failed to load SLM settings:', error);
|
||||||
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
||||||
@@ -547,77 +525,6 @@ async function saveFTPSettings(event) {
|
|||||||
return saveSLMSettings(event);
|
return saveSLMSettings(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the deploy/bench button appearance based on current deployed state
|
|
||||||
function updateDeployButton(isDeployed) {
|
|
||||||
const btn = document.getElementById('slm-settings-deploy-btn');
|
|
||||||
const desc = document.getElementById('slm-settings-deploy-desc');
|
|
||||||
const icon = btn.closest('.border').querySelector('svg');
|
|
||||||
|
|
||||||
if (isDeployed) {
|
|
||||||
btn.textContent = 'Bench Unit';
|
|
||||||
btn.className = 'px-3 py-1.5 text-sm rounded-lg font-medium transition-colors bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600';
|
|
||||||
desc.textContent = 'Unit is currently deployed in the field';
|
|
||||||
icon.classList.remove('text-gray-400');
|
|
||||||
icon.classList.add('text-green-500');
|
|
||||||
} else {
|
|
||||||
btn.textContent = 'Deploy Unit';
|
|
||||||
btn.className = 'px-3 py-1.5 text-sm rounded-lg font-medium transition-colors bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50';
|
|
||||||
desc.textContent = 'Unit is currently benched (not in field)';
|
|
||||||
icon.classList.remove('text-green-500');
|
|
||||||
icon.classList.add('text-gray-400');
|
|
||||||
}
|
|
||||||
// Store current state on button for toggle reference
|
|
||||||
btn.dataset.deployed = isDeployed ? '1' : '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle deploy/bench status
|
|
||||||
async function toggleSLMDeployed() {
|
|
||||||
const unitId = document.getElementById('slm-settings-unit-id').value;
|
|
||||||
const btn = document.getElementById('slm-settings-deploy-btn');
|
|
||||||
const errorDiv = document.getElementById('slm-settings-error');
|
|
||||||
const successDiv = document.getElementById('slm-settings-success');
|
|
||||||
|
|
||||||
const currentlyDeployed = btn.dataset.deployed === '1';
|
|
||||||
const newDeployed = !currentlyDeployed;
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = newDeployed ? 'Deploying...' : 'Benching...';
|
|
||||||
errorDiv.classList.add('hidden');
|
|
||||||
successDiv.classList.add('hidden');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('deployed', newDeployed ? 'true' : 'false');
|
|
||||||
|
|
||||||
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errData.detail || 'Failed to update deployment status');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDeployButton(newDeployed);
|
|
||||||
successDiv.textContent = newDeployed ? 'Unit marked as deployed.' : 'Unit marked as benched.';
|
|
||||||
successDiv.classList.remove('hidden');
|
|
||||||
setTimeout(() => successDiv.classList.add('hidden'), 3000);
|
|
||||||
|
|
||||||
// Refresh any SLM list on the page
|
|
||||||
if (typeof htmx !== 'undefined') {
|
|
||||||
htmx.trigger('#slm-list', 'load');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
btn.disabled = false;
|
|
||||||
updateDeployButton(currentlyDeployed); // restore button state
|
|
||||||
errorDiv.textContent = 'Error: ' + error.message;
|
|
||||||
errorDiv.classList.remove('hidden');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal on background click
|
// Close modal on background click
|
||||||
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
|
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
|
||||||
if (e.target === this) {
|
if (e.target === this) {
|
||||||
|
|||||||
@@ -50,12 +50,12 @@
|
|||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
Schedules
|
Schedules
|
||||||
</button>
|
</button>
|
||||||
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
|
<button onclick="switchTab('sessions')"
|
||||||
data-tab="sessions"
|
data-tab="sessions"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
Recording Sessions
|
Recording Sessions
|
||||||
</button>
|
</button>
|
||||||
<button id="data-tab-btn" onclick="switchTab('data')"
|
<button onclick="switchTab('data')"
|
||||||
data-tab="data"
|
data-tab="data"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
Data Files
|
Data Files
|
||||||
@@ -279,7 +279,6 @@
|
|||||||
<select name="status" id="settings-status"
|
<select name="status" id="settings-status"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="on_hold">On Hold</option>
|
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="archived">Archived</option>
|
<option value="archived">Archived</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -330,39 +329,14 @@
|
|||||||
<!-- Danger Zone -->
|
<!-- Danger Zone -->
|
||||||
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
|
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
|
||||||
<div class="space-y-3">
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
<!-- On Hold -->
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 flex items-center justify-between gap-4">
|
Archive this project to remove it from active listings. All data will be preserved.
|
||||||
<div>
|
</p>
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Put Project On Hold</p>
|
<button onclick="archiveProject()"
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Pause this project without archiving. Assignments and schedules remain in place.</p>
|
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||||
</div>
|
Archive Project
|
||||||
<div id="hold-btn-container" class="shrink-0">
|
</button>
|
||||||
<!-- Rendered by updateDangerZone() based on current status -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Archive -->
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-600 rounded-lg p-4 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Archive Project</p>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Remove from active listings. All data is preserved and can be restored.</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="archiveProject()"
|
|
||||||
class="shrink-0 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm">
|
|
||||||
Archive
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Delete -->
|
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Delete Project</p>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Permanently removes all project data after a 60-day grace period. This action is difficult to undo.</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="openDeleteModal()"
|
|
||||||
class="shrink-0 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -622,40 +596,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Project Confirmation Modal -->
|
|
||||||
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
|
||||||
<div class="flex items-center gap-3 mb-4">
|
|
||||||
<div class="p-2 bg-red-100 dark:bg-red-900/40 rounded-lg">
|
|
||||||
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Delete Project</h3>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
This project will be soft-deleted and <strong class="text-gray-900 dark:text-white">permanently removed after 60 days</strong>. All associated locations, assignments, and sessions will be lost.
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Type <span class="font-mono font-bold text-red-600 dark:text-red-400">delete</span> to confirm:
|
|
||||||
</p>
|
|
||||||
<input type="text" id="delete-confirm-input"
|
|
||||||
placeholder="type delete"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4 focus:outline-none focus:ring-2 focus:ring-red-500"
|
|
||||||
autocomplete="off">
|
|
||||||
<div class="flex gap-3 justify-end">
|
|
||||||
<button onclick="closeDeleteModal()"
|
|
||||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button id="confirm-delete-btn" disabled onclick="executeDeleteProject()"
|
|
||||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm disabled:opacity-40 disabled:cursor-not-allowed">
|
|
||||||
Delete Project
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const projectId = "{{ project_id }}";
|
const projectId = "{{ project_id }}";
|
||||||
let editingLocationId = null;
|
let editingLocationId = null;
|
||||||
@@ -714,19 +654,14 @@ async function loadProjectDetails() {
|
|||||||
document.getElementById('settings-start-date').value = formatDate(data.start_date);
|
document.getElementById('settings-start-date').value = formatDate(data.start_date);
|
||||||
document.getElementById('settings-end-date').value = formatDate(data.end_date);
|
document.getElementById('settings-end-date').value = formatDate(data.end_date);
|
||||||
|
|
||||||
// Update tab labels and visibility based on project type
|
// Update tab labels based on project type
|
||||||
const isSoundProject = projectTypeId === 'sound_monitoring';
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
if (isSoundProject) {
|
|
||||||
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
||||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||||
document.getElementById('add-location-label').textContent = 'Add NRL';
|
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||||
}
|
}
|
||||||
// Recording Sessions and Data Files tabs are SLM-only
|
|
||||||
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
|
||||||
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
|
||||||
|
|
||||||
document.getElementById('settings-error').classList.add('hidden');
|
document.getElementById('settings-error').classList.add('hidden');
|
||||||
updateDangerZone();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project details:', err);
|
console.error('Failed to load project details:', err);
|
||||||
}
|
}
|
||||||
@@ -1092,78 +1027,6 @@ function archiveProject() {
|
|||||||
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function holdProject() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/hold`, { method: 'POST' });
|
|
||||||
if (!response.ok) throw new Error('Failed to put project on hold');
|
|
||||||
await loadProjectDetails();
|
|
||||||
updateDangerZone();
|
|
||||||
htmx.trigger('#project-header', 'load');
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to put project on hold: ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unholdProject() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/unhold`, { method: 'POST' });
|
|
||||||
if (!response.ok) throw new Error('Failed to resume project');
|
|
||||||
await loadProjectDetails();
|
|
||||||
updateDangerZone();
|
|
||||||
htmx.trigger('#project-header', 'load');
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to resume project: ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDangerZone() {
|
|
||||||
const status = document.getElementById('settings-status').value;
|
|
||||||
const container = document.getElementById('hold-btn-container');
|
|
||||||
if (!container) return;
|
|
||||||
if (status === 'on_hold') {
|
|
||||||
container.innerHTML = `<button onclick="unholdProject()"
|
|
||||||
class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors text-sm">
|
|
||||||
Resume Project
|
|
||||||
</button>`;
|
|
||||||
} else {
|
|
||||||
container.innerHTML = `<button onclick="holdProject()"
|
|
||||||
class="px-4 py-2 border border-amber-500 text-amber-600 dark:text-amber-400 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors text-sm">
|
|
||||||
Put On Hold
|
|
||||||
</button>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDeleteModal() {
|
|
||||||
document.getElementById('delete-confirm-input').value = '';
|
|
||||||
document.getElementById('confirm-delete-btn').disabled = true;
|
|
||||||
document.getElementById('delete-project-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('delete-confirm-input').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDeleteModal() {
|
|
||||||
document.getElementById('delete-project-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeDeleteProject() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
|
|
||||||
if (!response.ok) throw new Error('Failed to delete project');
|
|
||||||
closeDeleteModal();
|
|
||||||
window.location.href = '/projects';
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to delete project: ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const input = document.getElementById('delete-confirm-input');
|
|
||||||
if (input) {
|
|
||||||
input.addEventListener('input', function() {
|
|
||||||
document.getElementById('confirm-delete-btn').disabled = this.value !== 'delete';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Schedule Modal Functions
|
// Schedule Modal Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 mb-8"
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
||||||
hx-get="/api/projects/stats"
|
hx-get="/api/projects/stats"
|
||||||
hx-trigger="load, every 30s"
|
hx-trigger="load, every 30s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
@@ -27,7 +27,6 @@
|
|||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
@@ -44,11 +43,6 @@
|
|||||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
Active
|
Active
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('on_hold')"
|
|
||||||
id="tab-on_hold"
|
|
||||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
|
||||||
On Hold
|
|
||||||
</button>
|
|
||||||
<button onclick="switchTab('completed')"
|
<button onclick="switchTab('completed')"
|
||||||
id="tab-completed"
|
id="tab-completed"
|
||||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
|
|||||||
Reference in New Issue
Block a user