diff --git a/routes/timer.py b/routes/timer.py index 207d591..fb185bf 100644 --- a/routes/timer.py +++ b/routes/timer.py @@ -149,6 +149,75 @@ DEFAULT_ENVIRONMENT = """{ timer = Blueprint('timer', __name__) +def calculate_next_run(trigger_type, frequency_minutes=None, run_date=None, cron_expression=None, base_time=None): + """ + Calculate the next execution time based on trigger configuration. + + Args: + trigger_type: One of 'interval', 'date', or 'cron' + frequency_minutes: Minutes between executions (for interval type) + run_date: Specific datetime to run (for date type) + cron_expression: Cron expression string (for cron type) + base_time: Base datetime to calculate from (defaults to now) + + Returns: + tuple: (next_run datetime, error_response dict or None) + If error_response is not None, it contains the error to return to client + """ + if base_time is None: + base_time = datetime.now(timezone.utc) + + next_run = None + + if trigger_type == 'interval': + if frequency_minutes is None: + return None, { + "status": "error", + "message": "frequency_minutes is required for interval trigger type" + } + next_run = base_time + timedelta(minutes=int(frequency_minutes)) + + elif trigger_type == 'date': + if run_date is None: + return None, { + "status": "error", + "message": "run_date is required for date trigger type" + } + # run_date should already be a datetime object + if isinstance(run_date, str): + run_date = datetime.fromisoformat(run_date) + next_run = run_date + + elif trigger_type == 'cron': + if cron_expression is None: + return None, { + "status": "error", + "message": "cron_expression is required for cron trigger type" + } + from croniter import croniter + + # Validate cron expression + try: + if not croniter.is_valid(cron_expression): + return None, { + "status": "error", + "message": "Invalid cron expression format" + } + # Calculate next run time from base_time + next_run = croniter(cron_expression, base_time).get_next(datetime) + except Exception as e: + return None, { + "status": "error", + "message": f"Invalid cron expression: {str(e)}" + } + else: + return None, { + "status": "error", + "message": f"Invalid trigger type: {trigger_type}" + } + + return next_run, None + @timer.route('/overview') @login_required def overview(): @@ -192,36 +261,28 @@ def new(): "message": "Invalid trigger type" }), 400 - # Calculate next_run based on trigger type - next_run = None + # Calculate next_run based on trigger type using centralized function + next_run, error = calculate_next_run( + trigger_type, + frequency_minutes=data.get('frequency_minutes'), + run_date=data.get('run_date'), + cron_expression=data.get('cron_expression') + ) + + if error: + return jsonify(error), 400 + + # Extract individual variables for database storage frequency_minutes = None run_date = None cron_expression = None if trigger_type == 'interval': frequency_minutes = int(data.get('frequency_minutes')) - next_run = datetime.now(timezone.utc) + timedelta(minutes=frequency_minutes) elif trigger_type == 'date': run_date = datetime.fromisoformat(data.get('run_date')) - next_run = run_date elif trigger_type == 'cron': - from croniter import croniter cron_expression = data.get('cron_expression') - - # Validate cron expression - try: - if not croniter.is_valid(cron_expression): - return jsonify({ - "status": "error", - "message": "Invalid cron expression format" - }), 400 - # Calculate first run time - next_run = croniter(cron_expression, datetime.now(timezone.utc)).get_next(datetime) - except Exception as e: - return jsonify({ - "status": "error", - "message": f"Invalid cron expression: {str(e)}" - }), 400 # Insert new timer function db.execute(""" @@ -301,36 +362,28 @@ def edit(function_id): "message": "Invalid trigger type" }), 400 - # Calculate next_run based on trigger type - next_run = None + # Calculate next_run based on trigger type using centralized function + next_run, error = calculate_next_run( + trigger_type, + frequency_minutes=data.get('frequency_minutes'), + run_date=data.get('run_date'), + cron_expression=data.get('cron_expression') + ) + + if error: + return jsonify(error), 400 + + # Extract individual variables for database storage frequency_minutes = None run_date = None cron_expression = None if trigger_type == 'interval': frequency_minutes = int(data.get('frequency_minutes')) - next_run = datetime.now(timezone.utc) + timedelta(minutes=frequency_minutes) elif trigger_type == 'date': run_date = datetime.fromisoformat(data.get('run_date')) - next_run = run_date elif trigger_type == 'cron': - from croniter import croniter cron_expression = data.get('cron_expression') - - # Validate cron expression - try: - if not croniter.is_valid(cron_expression): - return jsonify({ - "status": "error", - "message": "Invalid cron expression format" - }), 400 - # Calculate first run time - next_run = croniter(cron_expression, datetime.now(timezone.utc)).get_next(datetime) - except Exception as e: - return jsonify({ - "status": "error", - "message": f"Invalid cron expression: {str(e)}" - }), 400 # Update timer function db.execute(""" @@ -406,20 +459,48 @@ def delete(function_id): @login_required def toggle(function_id): try: - # Toggle the enabled status - result = db.execute(""" - UPDATE timer_functions - SET enabled = NOT enabled + # Fetch timer function details first to get trigger configuration + timer_function = db.execute(""" + SELECT enabled, trigger_type, frequency_minutes, run_date, cron_expression + FROM timer_functions WHERE id = %s AND user_id = %s - RETURNING enabled - """, [function_id, current_user.id], commit=True, one=True) + """, [function_id, current_user.id], one=True) - if not result: + if not timer_function: return jsonify({ "status": "error", "message": "Timer function not found or unauthorized" }), 404 + # Determine new enabled state (toggling) + new_enabled = not timer_function['enabled'] + + # If enabling the timer, recalculate next_run to prevent stale execution times + if new_enabled: + next_run, error = calculate_next_run( + timer_function['trigger_type'], + frequency_minutes=timer_function['frequency_minutes'], + run_date=timer_function['run_date'], + cron_expression=timer_function['cron_expression'] + ) + + if error: + return jsonify(error), 400 + + # Update both enabled status and next_run + db.execute(""" + UPDATE timer_functions + SET enabled = %s, next_run = %s + WHERE id = %s AND user_id = %s + """, [new_enabled, next_run, function_id, current_user.id], commit=True) + else: + # Just toggle the enabled status + db.execute(""" + UPDATE timer_functions + SET enabled = %s + WHERE id = %s AND user_id = %s + """, [new_enabled, function_id, current_user.id], commit=True) + # Fetch updated timer functions for the overview template timer_functions = db.execute(""" SELECT id, name, code, environment, trigger_type,