Files
function/routes/timer.py
Peter Stockings d4c0c0f262 Add home dashboard and Mithril rendering support
- Create new home route with comprehensive dashboard statistics
- Implement Mithril rendering support with new `mithril_loader.html` template
- Add new routes for home and test pages in `app.py`
- Create `lib/mithril.py` with Mithril rendering and error handling utilities
- Update dashboard template to use new home route
- Add detailed dashboard view with timer and HTTP function statistics
2025-02-20 23:35:46 +11:00

460 lines
15 KiB
Python

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 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,
invocation_count INT NOT NULL DEFAULT 0,
-- 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();
CREATE TABLE timer_function_invocations (
id SERIAL PRIMARY KEY,
timer_function_id INT NOT NULL,
status TEXT,
invocation_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
logs JSONB,
version_number INT NOT NULL,
CONSTRAINT fk_timer_function_invocations
FOREIGN KEY (timer_function_id)
REFERENCES timer_functions (id)
ON DELETE CASCADE
);
CREATE OR REPLACE FUNCTION fn_increment_invocation_count()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE timer_functions
SET invocation_count = invocation_count + 1
WHERE id = NEW.timer_function_id;
RETURN NEW;
END;
$$;
CREATE TRIGGER tr_increment_invocation_count
AFTER INSERT ON timer_function_invocations
FOR EACH ROW
EXECUTE PROCEDURE fn_increment_invocation_count();
'''
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, invocation_count
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)
@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, invocation_count
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, invocation_count
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
@timer.route('/logs/<int:function_id>')
@login_required
def logs(function_id):
# Fetch the timer function to verify ownership
timer_function = db.execute("""
SELECT id, name
FROM timer_functions
WHERE id = %s AND user_id = %s
""", [function_id, current_user.id], one=True)
if not timer_function:
flash('Timer function not found', 'error')
return redirect(url_for('timer.overview'))
# Fetch the invocation logs
timer_function_invocations = db.execute("""
SELECT id, timer_function_id, status, invocation_time,
logs, version_number
FROM timer_function_invocations
WHERE timer_function_id = %s
ORDER BY invocation_time DESC
LIMIT 100
""", [function_id])
args = {
'user_id': current_user.id,
'function_id': function_id,
'timer_function_invocations': timer_function_invocations
}
if htmx:
return render_block(environment, 'dashboard/timer_functions/logs.html', 'page', **args)
return render_template('dashboard/timer_functions/logs.html', **args)
@timer.route('/history/<int:function_id>')
@login_required
def history(function_id):
# Fetch the timer function to verify ownership
timer_function = db.execute("""
SELECT id, name, code, version_number
FROM timer_functions
WHERE id = %s AND user_id = %s
""", [function_id, current_user.id], one=True)
if not timer_function:
flash('Timer function not found', 'error')
return redirect(url_for('timer.overview'))
# Fetch all versions
versions = db.execute("""
SELECT version_number, script, versioned_at
FROM timer_function_versions
WHERE timer_function_id = %s
ORDER BY version_number DESC
""", [function_id])
# Convert datetime objects to ISO format strings
for version in versions:
version['versioned_at'] = version['versioned_at'].isoformat() if version['versioned_at'] else None
args = {
'user_id': current_user.id,
'function_id': function_id,
'timer_function': timer_function,
'versions': versions
}
if htmx:
return render_block(environment, 'dashboard/timer_functions/history.html', 'page', **args)
return render_template('dashboard/timer_functions/history.html', **args)