feat: add support for one-off recording schedules with start and end datetime
This commit is contained in:
73
backend/migrate_add_oneoff_schedule_fields.py
Normal file
73
backend/migrate_add_oneoff_schedule_fields.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Migration: Add one-off schedule fields to recurring_schedules table
|
||||
|
||||
Adds start_datetime and end_datetime columns for one-off recording schedules.
|
||||
|
||||
Run this script once to update existing databases:
|
||||
python -m backend.migrate_add_oneoff_schedule_fields
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = "data/seismo_fleet.db"
|
||||
|
||||
|
||||
def migrate():
|
||||
"""Add one-off schedule columns to recurring_schedules table."""
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
return False
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='recurring_schedules'
|
||||
""")
|
||||
if not cursor.fetchone():
|
||||
print("recurring_schedules table does not exist yet. Will be created on app startup.")
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
cursor.execute("PRAGMA table_info(recurring_schedules)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
added = False
|
||||
|
||||
if "start_datetime" not in columns:
|
||||
print("Adding start_datetime column to recurring_schedules table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE recurring_schedules
|
||||
ADD COLUMN start_datetime DATETIME NULL
|
||||
""")
|
||||
added = True
|
||||
|
||||
if "end_datetime" not in columns:
|
||||
print("Adding end_datetime column to recurring_schedules table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE recurring_schedules
|
||||
ADD COLUMN end_datetime DATETIME NULL
|
||||
""")
|
||||
added = True
|
||||
|
||||
if added:
|
||||
conn.commit()
|
||||
print("Successfully added one-off schedule columns.")
|
||||
else:
|
||||
print("One-off schedule columns already exist.")
|
||||
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = migrate()
|
||||
exit(0 if success else 1)
|
||||
@@ -321,9 +321,10 @@ class RecurringSchedule(Base):
|
||||
"""
|
||||
Recurring schedule definitions for automated sound monitoring.
|
||||
|
||||
Supports two schedule types:
|
||||
Supports three schedule types:
|
||||
- "weekly_calendar": Select specific days with start/end times (e.g., Mon/Wed/Fri 7pm-7am)
|
||||
- "simple_interval": For 24/7 monitoring with daily stop/download/restart cycles
|
||||
- "one_off": Single recording session with specific start and end date/time
|
||||
"""
|
||||
__tablename__ = "recurring_schedules"
|
||||
|
||||
@@ -333,7 +334,7 @@ class RecurringSchedule(Base):
|
||||
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (optional, can use assignment)
|
||||
|
||||
name = Column(String, nullable=False) # "Weeknight Monitoring", "24/7 Continuous"
|
||||
schedule_type = Column(String, nullable=False) # "weekly_calendar" | "simple_interval"
|
||||
schedule_type = Column(String, nullable=False) # "weekly_calendar" | "simple_interval" | "one_off"
|
||||
device_type = Column(String, nullable=False) # "slm" | "seismograph"
|
||||
|
||||
# Weekly Calendar fields (schedule_type = "weekly_calendar")
|
||||
@@ -349,7 +350,11 @@ class RecurringSchedule(Base):
|
||||
cycle_time = Column(String, nullable=True) # "00:00" - time to run stop/download/restart
|
||||
include_download = Column(Boolean, default=True) # Download data before restart
|
||||
|
||||
# Automation options (applies to both schedule types)
|
||||
# One-Off fields (schedule_type = "one_off")
|
||||
start_datetime = Column(DateTime, nullable=True) # Exact start date+time (stored as UTC)
|
||||
end_datetime = Column(DateTime, nullable=True) # Exact end date+time (stored as UTC)
|
||||
|
||||
# Automation options (applies to all schedule types)
|
||||
auto_increment_index = Column(Boolean, default=True) # Auto-increment store/index number before start
|
||||
# When True: prevents "overwrite data?" prompts by using a new index each time
|
||||
|
||||
|
||||
@@ -186,6 +186,41 @@ async def create_recurring_schedule(
|
||||
created_schedules = []
|
||||
base_name = data.get("name", "Unnamed Schedule")
|
||||
|
||||
# Parse one-off datetime fields if applicable
|
||||
one_off_start = None
|
||||
one_off_end = None
|
||||
if data.get("schedule_type") == "one_off":
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
tz = ZoneInfo(data.get("timezone", "America/New_York"))
|
||||
|
||||
start_dt_str = data.get("start_datetime")
|
||||
end_dt_str = data.get("end_datetime")
|
||||
|
||||
if not start_dt_str or not end_dt_str:
|
||||
raise HTTPException(status_code=400, detail="One-off schedules require start and end date/time")
|
||||
|
||||
try:
|
||||
start_local = datetime.fromisoformat(start_dt_str).replace(tzinfo=tz)
|
||||
end_local = datetime.fromisoformat(end_dt_str).replace(tzinfo=tz)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid datetime format")
|
||||
|
||||
duration = end_local - start_local
|
||||
if duration.total_seconds() < 900:
|
||||
raise HTTPException(status_code=400, detail="Duration must be at least 15 minutes")
|
||||
if duration.total_seconds() > 86400:
|
||||
raise HTTPException(status_code=400, detail="Duration cannot exceed 24 hours")
|
||||
|
||||
from datetime import timezone as dt_timezone
|
||||
now_local = datetime.now(tz)
|
||||
if start_local <= now_local:
|
||||
raise HTTPException(status_code=400, detail="Start time must be in the future")
|
||||
|
||||
# Convert to UTC for storage
|
||||
one_off_start = start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||
one_off_end = end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||
|
||||
# Create a schedule for each location
|
||||
for location in locations:
|
||||
# Determine device type from location
|
||||
@@ -207,6 +242,8 @@ async def create_recurring_schedule(
|
||||
include_download=data.get("include_download", True),
|
||||
auto_increment_index=data.get("auto_increment_index", True),
|
||||
timezone=data.get("timezone", "America/New_York"),
|
||||
start_datetime=one_off_start,
|
||||
end_datetime=one_off_end,
|
||||
)
|
||||
|
||||
# Generate actions immediately so they appear right away
|
||||
|
||||
@@ -49,6 +49,8 @@ class RecurringScheduleService:
|
||||
include_download: bool = True,
|
||||
auto_increment_index: bool = True,
|
||||
timezone: str = "America/New_York",
|
||||
start_datetime: datetime = None,
|
||||
end_datetime: datetime = None,
|
||||
) -> RecurringSchedule:
|
||||
"""
|
||||
Create a new recurring schedule.
|
||||
@@ -57,7 +59,7 @@ class RecurringScheduleService:
|
||||
project_id: Project ID
|
||||
location_id: Monitoring location ID
|
||||
name: Schedule name
|
||||
schedule_type: "weekly_calendar" or "simple_interval"
|
||||
schedule_type: "weekly_calendar", "simple_interval", or "one_off"
|
||||
device_type: "slm" or "seismograph"
|
||||
unit_id: Specific unit (optional, can use assignment)
|
||||
weekly_pattern: Dict of day patterns for weekly_calendar
|
||||
@@ -66,6 +68,8 @@ class RecurringScheduleService:
|
||||
include_download: Whether to download data on cycle
|
||||
auto_increment_index: Whether to auto-increment store index before start
|
||||
timezone: Timezone for schedule times
|
||||
start_datetime: Start date+time in UTC (one_off only)
|
||||
end_datetime: End date+time in UTC (one_off only)
|
||||
|
||||
Returns:
|
||||
Created RecurringSchedule
|
||||
@@ -85,6 +89,8 @@ class RecurringScheduleService:
|
||||
auto_increment_index=auto_increment_index,
|
||||
enabled=True,
|
||||
timezone=timezone,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
)
|
||||
|
||||
# Calculate next occurrence
|
||||
@@ -213,6 +219,8 @@ class RecurringScheduleService:
|
||||
actions = self._generate_weekly_calendar_actions(schedule, horizon_days)
|
||||
elif schedule.schedule_type == "simple_interval":
|
||||
actions = self._generate_interval_actions(schedule, horizon_days)
|
||||
elif schedule.schedule_type == "one_off":
|
||||
actions = self._generate_one_off_actions(schedule)
|
||||
else:
|
||||
logger.warning(f"Unknown schedule type: {schedule.schedule_type}")
|
||||
return []
|
||||
@@ -431,6 +439,77 @@ class RecurringScheduleService:
|
||||
|
||||
return actions
|
||||
|
||||
def _generate_one_off_actions(
|
||||
self,
|
||||
schedule: RecurringSchedule,
|
||||
) -> List[ScheduledAction]:
|
||||
"""
|
||||
Generate start and stop actions for a one-off recording.
|
||||
|
||||
Unlike recurring types, this generates exactly one start and one stop action
|
||||
using the schedule's start_datetime and end_datetime directly.
|
||||
"""
|
||||
if not schedule.start_datetime or not schedule.end_datetime:
|
||||
logger.warning(f"One-off schedule {schedule.id} missing start/end datetime")
|
||||
return []
|
||||
|
||||
actions = []
|
||||
now_utc = datetime.utcnow()
|
||||
unit_id = self._resolve_unit_id(schedule)
|
||||
|
||||
# Skip if end time has already passed
|
||||
if schedule.end_datetime <= now_utc:
|
||||
return []
|
||||
|
||||
# Check if actions already exist for this schedule
|
||||
if self._action_exists(schedule.project_id, schedule.location_id, "start", schedule.start_datetime):
|
||||
return []
|
||||
|
||||
# Create START action (only if start time hasn't passed)
|
||||
if schedule.start_datetime > now_utc:
|
||||
start_notes = json.dumps({
|
||||
"schedule_name": schedule.name,
|
||||
"schedule_id": schedule.id,
|
||||
"schedule_type": "one_off",
|
||||
"auto_increment_index": schedule.auto_increment_index,
|
||||
})
|
||||
|
||||
start_action = ScheduledAction(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=schedule.project_id,
|
||||
location_id=schedule.location_id,
|
||||
unit_id=unit_id,
|
||||
action_type="start",
|
||||
device_type=schedule.device_type,
|
||||
scheduled_time=schedule.start_datetime,
|
||||
execution_status="pending",
|
||||
notes=start_notes,
|
||||
)
|
||||
actions.append(start_action)
|
||||
|
||||
# Create STOP action
|
||||
stop_notes = json.dumps({
|
||||
"schedule_name": schedule.name,
|
||||
"schedule_id": schedule.id,
|
||||
"schedule_type": "one_off",
|
||||
"include_download": schedule.include_download,
|
||||
})
|
||||
|
||||
stop_action = ScheduledAction(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=schedule.project_id,
|
||||
location_id=schedule.location_id,
|
||||
unit_id=unit_id,
|
||||
action_type="stop",
|
||||
device_type=schedule.device_type,
|
||||
scheduled_time=schedule.end_datetime,
|
||||
execution_status="pending",
|
||||
notes=stop_notes,
|
||||
)
|
||||
actions.append(stop_action)
|
||||
|
||||
return actions
|
||||
|
||||
def _calculate_next_occurrence(self, schedule: RecurringSchedule) -> Optional[datetime]:
|
||||
"""Calculate when the next action should occur."""
|
||||
if not schedule.enabled:
|
||||
@@ -471,6 +550,13 @@ class RecurringScheduleService:
|
||||
if cycle_utc > now_utc:
|
||||
return cycle_utc
|
||||
|
||||
elif schedule.schedule_type == "one_off":
|
||||
if schedule.start_datetime and schedule.start_datetime > now_utc:
|
||||
return schedule.start_datetime
|
||||
elif schedule.end_datetime and schedule.end_datetime > now_utc:
|
||||
return schedule.end_datetime
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_unit_id(self, schedule: RecurringSchedule) -> Optional[str]:
|
||||
|
||||
@@ -628,6 +628,15 @@ class SchedulerService:
|
||||
|
||||
for schedule in schedules:
|
||||
try:
|
||||
# Auto-disable one-off schedules whose end time has passed
|
||||
if schedule.schedule_type == "one_off" and schedule.end_datetime:
|
||||
if schedule.end_datetime <= datetime.utcnow():
|
||||
schedule.enabled = False
|
||||
schedule.next_occurrence = None
|
||||
db.commit()
|
||||
logger.info(f"Auto-disabled completed one-off schedule: {schedule.name}")
|
||||
continue
|
||||
|
||||
actions = service.generate_actions_for_schedule(schedule, horizon_days=7)
|
||||
total_generated += len(actions)
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user