Implement timer functions with full CRUD functionality and enhanced UI
- Add comprehensive routes for creating, editing, deleting, and toggling timer functions - Create new HTML templates for timer function overview, new, and edit pages - Extend Mithril editor component to support timer-specific settings like trigger type, frequency, and enabled status - Implement database schema and versioning for timer functions - Add UI improvements for timer function listing with detailed schedule and status information
This commit is contained in:
347
routes/timer.py
347
routes/timer.py
@@ -1,17 +1,356 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from jinja2_fragments import render_block
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import login_user, login_required, logout_user
|
||||
from flask_login import current_user, login_required
|
||||
from extensions import db, htmx, environment
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import json
|
||||
|
||||
'''
|
||||
CREATE TABLE timer_functions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
environment JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
version_number INT NOT NULL DEFAULT 1,
|
||||
|
||||
user_id INT NOT NULL, -- the referencing column
|
||||
|
||||
trigger_type VARCHAR(20) NOT NULL CHECK (
|
||||
trigger_type IN ('interval', 'date')
|
||||
),
|
||||
frequency_minutes INT, -- used if trigger_type = 'interval'
|
||||
run_date TIMESTAMPTZ, -- used if trigger_type = 'date' (one-off)
|
||||
|
||||
next_run TIMESTAMPTZ,
|
||||
last_run TIMESTAMPTZ,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Define the foreign key constraint
|
||||
CONSTRAINT fk_timer_functions_user
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES users (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE timer_function_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
timer_function_id INT NOT NULL,
|
||||
script TEXT NOT NULL,
|
||||
version_number INT NOT NULL,
|
||||
versioned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT fk_timer_function_versions
|
||||
FOREIGN KEY (timer_function_id)
|
||||
REFERENCES timer_functions (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION fn_timer_functions_versioning()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
next_version INT;
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- In an AFTER INSERT, the row already has been inserted with version_number default (1).
|
||||
-- We can optionally override that or ensure an initial version is recorded:
|
||||
INSERT INTO timer_function_versions (timer_function_id, script, version_number)
|
||||
VALUES (NEW.id, NEW.code, 1);
|
||||
|
||||
-- If desired, ensure timer_functions.version_number is set explicitly:
|
||||
UPDATE timer_functions
|
||||
SET version_number = 1
|
||||
WHERE id = NEW.id;
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
-- Only version if the 'code' changed
|
||||
IF NEW.code IS DISTINCT FROM OLD.code THEN
|
||||
-- Determine the next version number based on existing versions
|
||||
SELECT COALESCE(MAX(version_number), 0) + 1
|
||||
INTO next_version
|
||||
FROM timer_function_versions
|
||||
WHERE timer_function_id = NEW.id;
|
||||
|
||||
-- Insert new version record
|
||||
INSERT INTO timer_function_versions (timer_function_id, script, version_number)
|
||||
VALUES (NEW.id, NEW.code, next_version);
|
||||
|
||||
-- Manually update timer_functions to set version_number
|
||||
-- This second UPDATE will cause the trigger to fire again,
|
||||
-- but because code won't change, the trigger won't do another version bump.
|
||||
UPDATE timer_functions
|
||||
SET version_number = next_version
|
||||
WHERE id = NEW.id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER tr_timer_functions_versioning
|
||||
AFTER INSERT OR UPDATE
|
||||
ON timer_functions
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE fn_timer_functions_versioning();
|
||||
'''
|
||||
|
||||
DEFAULT_SCRIPT = """async (req) => {
|
||||
environment.count += 1
|
||||
console.log(`Executing ${environment.count}`)
|
||||
}"""
|
||||
|
||||
DEFAULT_ENVIRONMENT = """{
|
||||
"count": 0
|
||||
}"""
|
||||
|
||||
timer = Blueprint('timer', __name__)
|
||||
|
||||
@timer.route('/overview')
|
||||
@login_required
|
||||
def overview():
|
||||
timer_functions = db.execute("""
|
||||
SELECT id, name, code, environment, trigger_type,
|
||||
frequency_minutes, run_date, next_run,
|
||||
last_run, enabled
|
||||
FROM timer_functions
|
||||
WHERE user_id = %s
|
||||
ORDER BY id DESC
|
||||
""", [current_user.id])
|
||||
|
||||
if htmx:
|
||||
return render_block(environment, 'dashboard/timer_functions/overview.html', 'page')
|
||||
return render_template('dashboard/timer_functions/overview.html')
|
||||
return render_block(environment, 'dashboard/timer_functions/overview.html', 'page', timer_functions=timer_functions)
|
||||
return render_template('dashboard/timer_functions/overview.html', timer_functions=timer_functions)
|
||||
|
||||
@timer.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def new():
|
||||
if request.method == 'GET':
|
||||
args = {
|
||||
'name': 'foo',
|
||||
'script': DEFAULT_SCRIPT,
|
||||
'environment_info': DEFAULT_ENVIRONMENT,
|
||||
'user_id': current_user.id
|
||||
}
|
||||
if htmx:
|
||||
return render_block(environment, 'dashboard/timer_functions/new.html', 'page', **args)
|
||||
return render_template('dashboard/timer_functions/new.html', **args)
|
||||
|
||||
# Handle POST request
|
||||
try:
|
||||
data = request.json
|
||||
trigger_type = data.get('trigger_type')
|
||||
|
||||
# Validate trigger type
|
||||
if trigger_type not in ('interval', 'date'):
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Invalid trigger type"
|
||||
}), 400
|
||||
|
||||
# Calculate next_run based on trigger type
|
||||
next_run = 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
|
||||
|
||||
# Insert new timer function
|
||||
db.execute("""
|
||||
INSERT INTO timer_functions
|
||||
(name, code, environment, user_id, trigger_type,
|
||||
frequency_minutes, run_date, next_run, enabled)
|
||||
VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", [
|
||||
data.get('name'),
|
||||
data.get('script_content'),
|
||||
data.get('environment_info'),
|
||||
current_user.id,
|
||||
trigger_type,
|
||||
frequency_minutes if trigger_type == 'interval' else None,
|
||||
run_date if trigger_type == 'date' else None,
|
||||
next_run,
|
||||
True
|
||||
],
|
||||
commit=True)
|
||||
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": "Timer function created successfully"
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": f"Error creating timer function: {str(e)}"
|
||||
}), 400
|
||||
|
||||
@timer.route('/edit/<int:function_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit(function_id):
|
||||
if request.method == 'GET':
|
||||
# Fetch the timer function
|
||||
timer_function = db.execute("""
|
||||
SELECT id, name, code, environment, version_number, trigger_type,
|
||||
frequency_minutes, run_date, next_run,
|
||||
last_run, enabled
|
||||
FROM timer_functions
|
||||
WHERE id = %s AND user_id = %s
|
||||
""", [function_id, current_user.id], one=True)
|
||||
|
||||
if not timer_function:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Timer function not found"
|
||||
}), 404
|
||||
|
||||
# Format the environment JSON with indentation
|
||||
timer_function['environment'] = json.dumps(timer_function['environment'], indent=2)
|
||||
|
||||
args = {
|
||||
'function_id': function_id,
|
||||
'timer_function': timer_function
|
||||
}
|
||||
|
||||
if htmx:
|
||||
return render_block(environment, 'dashboard/timer_functions/edit.html', 'page', **args)
|
||||
return render_template('dashboard/timer_functions/edit.html', **args)
|
||||
|
||||
# Handle POST request
|
||||
try:
|
||||
data = request.json
|
||||
trigger_type = data.get('trigger_type')
|
||||
|
||||
# Validate trigger type
|
||||
if trigger_type not in ('interval', 'date'):
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Invalid trigger type"
|
||||
}), 400
|
||||
|
||||
# Calculate next_run based on trigger type
|
||||
next_run = 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
|
||||
|
||||
# Update timer function
|
||||
db.execute("""
|
||||
UPDATE timer_functions
|
||||
SET name = %s,
|
||||
code = %s,
|
||||
environment = %s::jsonb,
|
||||
trigger_type = %s,
|
||||
frequency_minutes = %s,
|
||||
run_date = %s,
|
||||
next_run = %s,
|
||||
enabled = %s
|
||||
WHERE id = %s AND user_id = %s
|
||||
RETURNING id
|
||||
""", [
|
||||
data.get('name'),
|
||||
data.get('script_content'),
|
||||
data.get('environment_info'),
|
||||
trigger_type,
|
||||
frequency_minutes if trigger_type == 'interval' else None,
|
||||
run_date if trigger_type == 'date' else None,
|
||||
next_run,
|
||||
data.get('is_enabled', True), # Default to True if not provided
|
||||
function_id,
|
||||
current_user.id
|
||||
],
|
||||
commit=True)
|
||||
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": "Timer function updated successfully"
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": f"Error updating timer function: {str(e)}"
|
||||
}), 400
|
||||
|
||||
@timer.route('/delete/<int:function_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete(function_id):
|
||||
try:
|
||||
# Delete the timer function, but only if it belongs to the current user
|
||||
result = db.execute("""
|
||||
DELETE FROM timer_functions
|
||||
WHERE id = %s AND user_id = %s
|
||||
RETURNING id
|
||||
""", [function_id, current_user.id], commit=True)
|
||||
|
||||
if not result:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Timer function not found or unauthorized"
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": "Timer function deleted successfully"
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": f"Error deleting timer function: {str(e)}"
|
||||
}), 400
|
||||
|
||||
@timer.route('/toggle/<int:function_id>', methods=['POST'])
|
||||
@login_required
|
||||
def toggle(function_id):
|
||||
try:
|
||||
# Toggle the enabled status
|
||||
result = db.execute("""
|
||||
UPDATE timer_functions
|
||||
SET enabled = NOT enabled
|
||||
WHERE id = %s AND user_id = %s
|
||||
RETURNING enabled
|
||||
""", [function_id, current_user.id], commit=True, one=True)
|
||||
|
||||
if not result:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Timer function not found or unauthorized"
|
||||
}), 404
|
||||
|
||||
# Fetch updated timer functions for the overview template
|
||||
timer_functions = db.execute("""
|
||||
SELECT id, name, code, environment, trigger_type,
|
||||
frequency_minutes, run_date, next_run,
|
||||
last_run, enabled
|
||||
FROM timer_functions
|
||||
WHERE user_id = %s
|
||||
ORDER BY id DESC
|
||||
""", [current_user.id])
|
||||
|
||||
if htmx:
|
||||
return render_block(environment, 'dashboard/timer_functions/overview.html', 'page',
|
||||
timer_functions=timer_functions)
|
||||
return render_template('dashboard/timer_functions/overview.html', timer_functions=timer_functions)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": f"Error toggling timer function: {str(e)}"
|
||||
}), 400
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user