from flask import Blueprint, request, jsonify, render_template from flask_login import login_required, current_user from extensions import db, htmx from jinja2_fragments import render_block import json ''' -- 1. Create shared_environments table CREATE TABLE IF NOT EXISTS shared_environments ( id SERIAL PRIMARY KEY, user_id INT NOT NULL, name VARCHAR(255) NOT NULL, environment JSONB NOT NULL DEFAULT '{}', description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_shared_env_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, CONSTRAINT unique_shared_env_name_per_user UNIQUE (user_id, name) ); CREATE INDEX idx_shared_env_user ON shared_environments(user_id); -- 2. Create junction table for HTTP functions CREATE TABLE IF NOT EXISTS http_function_shared_envs ( id SERIAL PRIMARY KEY, http_function_id INT NOT NULL, shared_env_id INT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_http_function FOREIGN KEY (http_function_id) REFERENCES http_functions (id) ON DELETE CASCADE, CONSTRAINT fk_shared_env_http FOREIGN KEY (shared_env_id) REFERENCES shared_environments (id) ON DELETE CASCADE, CONSTRAINT unique_http_function_shared_env UNIQUE (http_function_id, shared_env_id) ); CREATE INDEX idx_http_func_shared_env ON http_function_shared_envs(http_function_id); -- 3. Create junction table for Timer functions CREATE TABLE IF NOT EXISTS timer_function_shared_envs ( id SERIAL PRIMARY KEY, timer_function_id INT NOT NULL, shared_env_id INT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_timer_function FOREIGN KEY (timer_function_id) REFERENCES timer_functions (id) ON DELETE CASCADE, CONSTRAINT fk_shared_env_timer FOREIGN KEY (shared_env_id) REFERENCES shared_environments (id) ON DELETE CASCADE, CONSTRAINT unique_timer_function_shared_env UNIQUE (timer_function_id, shared_env_id) ); CREATE INDEX idx_timer_func_shared_env ON timer_function_shared_envs(timer_function_id); CREATE INDEX idx_shared_env_versions ON shared_environment_versions(shared_env_id); -- 2. Add version_number column to main table ALTER TABLE shared_environments ADD COLUMN IF NOT EXISTS version_number INT NOT NULL DEFAULT 1; -- 3. Create trigger function for automatic versioning CREATE OR REPLACE FUNCTION fn_shared_env_versioning() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE next_version INT; BEGIN IF TG_OP = 'INSERT' THEN -- Create initial version record INSERT INTO shared_environment_versions (shared_env_id, environment, version_number) VALUES (NEW.id, NEW.environment, 1); -- Ensure version_number is set explicitly UPDATE shared_environments SET version_number = 1 WHERE id = NEW.id; RETURN NEW; ELSIF TG_OP = 'UPDATE' THEN -- Only create new version if environment JSON changed IF NEW.environment IS DISTINCT FROM OLD.environment THEN -- Get next version number SELECT COALESCE(MAX(version_number), 0) + 1 INTO next_version FROM shared_environment_versions WHERE shared_env_id = NEW.id; -- Insert new version record INSERT INTO shared_environment_versions (shared_env_id, environment, version_number) VALUES (NEW.id, NEW.environment, next_version); -- Update main table version number UPDATE shared_environments SET version_number = next_version WHERE id = NEW.id; END IF; RETURN NEW; END IF; RETURN NEW; END; $$; -- 4. Create trigger to fire on INSERT or UPDATE CREATE TRIGGER tr_shared_env_versioning AFTER INSERT OR UPDATE ON shared_environments FOR EACH ROW EXECUTE PROCEDURE fn_shared_env_versioning(); ''' shared_env = Blueprint('shared_env', __name__) @shared_env.route('/', methods=['GET']) @login_required def list_shared_environments(): """List all shared environments for the current user""" envs = db.execute( 'SELECT id, name, environment, description, created_at, updated_at FROM shared_environments WHERE user_id=%s ORDER BY name', [current_user.id] ) # Check if HTMX request if request.headers.get('HX-Request'): return render_template('dashboard/shared_environments/index.html', environments=envs) # For API/fetch requests, return JSON if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html: return jsonify(envs if envs else []) # For regular page loads return render_template('dashboard/shared_environments/index.html', environments=envs) @shared_env.route('/new', methods=['GET', 'POST']) @login_required def create(): """Create a new shared environment""" if request.method == 'GET': # Show creation form if htmx: return render_block('environment', 'dashboard/shared_environments/new.html', 'page') return render_template('dashboard/shared_environments/new.html') # Handle POST - create new shared environment try: data = request.json name = data.get('name') environment = data.get('environment') description = data.get('description', '') # Validate name if not name: return jsonify({'status': 'error', 'message': 'Name is required'}), 400 # Validate environment JSON if isinstance(environment, str): try: environment_dict = json.loads(environment) except json.JSONDecodeError: return jsonify({'status': 'error', 'message': 'Invalid JSON in environment'}), 400 else: environment_dict = environment # Create shared environment result = db.execute( 'INSERT INTO shared_environments (user_id, name, environment, description) VALUES (%s, %s, %s, %s) RETURNING id', [current_user.id, name, json.dumps(environment_dict), description], commit=True, one=True ) env_id = result['id'] if result else None return jsonify({ 'status': 'success', 'message': f'Shared environment "{name}" created successfully', 'id': env_id }), 201 except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @shared_env.route('/', methods=['GET']) @login_required def get(env_id): """Get a specific shared environment""" env = db.execute( 'SELECT id, name, environment, description FROM shared_environments WHERE id=%s AND user_id=%s', [env_id, current_user.id], one=True ) if not env: return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404 return jsonify(env) @shared_env.route('/', methods=['PUT']) @login_required def update(env_id): """Update a shared environment""" try: # Verify ownership existing = db.execute( 'SELECT id, name FROM shared_environments WHERE id=%s AND user_id=%s', [env_id, current_user.id], one=True ) if not existing: return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404 data = request.json name = data.get('name') environment = data.get('environment') description = data.get('description', '') # Validate environment JSON if isinstance(environment, str): try: environment_dict = json.loads(environment) except json.JSONDecodeError: return jsonify({'status': 'error', 'message': 'Invalid JSON in environment'}), 400 else: environment_dict = environment db.execute( 'UPDATE shared_environments SET name=%s, environment=%s, description=%s, updated_at=NOW() WHERE id=%s AND user_id=%s', [name, json.dumps(environment_dict), description, env_id, current_user.id], commit=True ) return jsonify({ 'status': 'success', 'message': f'Shared environment "{name}" updated successfully' }) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @shared_env.route('/', methods=['DELETE']) @login_required def delete(env_id): """Delete a shared environment""" try: # Verify ownership existing = db.execute( 'SELECT name FROM shared_environments WHERE id=%s AND user_id=%s', [env_id, current_user.id], one=True ) if not existing: return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404 db.execute( 'DELETE FROM shared_environments WHERE id=%s AND user_id=%s', [env_id, current_user.id], commit=True ) return jsonify({ 'status': 'success', 'message': f'Shared environment "{existing["name"]}" deleted successfully' }) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @shared_env.route('//linked-functions', methods=['GET']) @login_required def get_linked_functions(env_id): """Get all functions linked to this shared environment""" try: # Verify ownership existing = db.execute( 'SELECT id FROM shared_environments WHERE id=%s AND user_id=%s', [env_id, current_user.id], one=True ) if not existing: return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404 # Get linked HTTP functions http_functions = db.execute(''' SELECT hf.id, hf.name, 'http' as type FROM http_function_shared_envs hfse JOIN http_functions hf ON hfse.http_function_id = hf.id WHERE hfse.shared_env_id = %s AND hf.user_id = %s ORDER BY hf.name ''', [env_id, current_user.id]) # Get linked Timer functions timer_functions = db.execute(''' SELECT tf.id, tf.name, 'timer' as type FROM timer_function_shared_envs tfse JOIN timer_functions tf ON tfse.timer_function_id = tf.id WHERE tfse.shared_env_id = %s AND tf.user_id = %s ORDER BY tf.name ''', [env_id, current_user.id]) return jsonify({ 'http_functions': http_functions if http_functions else [], 'timer_functions': timer_functions if timer_functions else [] }) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @shared_env.route('//available-functions', methods=['GET']) @login_required def get_available_functions(env_id): """Get all functions that can be linked (not already linked)""" try: # Get all HTTP functions for this user all_http = db.execute( 'SELECT id, name FROM http_functions WHERE user_id=%s ORDER BY name', [current_user.id] ) # Get already linked HTTP functions linked_http = db.execute(''' SELECT http_function_id FROM http_function_shared_envs WHERE shared_env_id = %s ''', [env_id]) linked_http_ids = [row['http_function_id'] for row in (linked_http or [])] # Filter out already linked available_http = [func for func in (all_http or []) if func['id'] not in linked_http_ids] # Get all Timer functions for this user all_timer = db.execute( 'SELECT id, name FROM timer_functions WHERE user_id=%s ORDER BY name', [current_user.id] ) # Get already linked Timer functions linked_timer = db.execute(''' SELECT timer_function_id FROM timer_function_shared_envs WHERE shared_env_id = %s ''', [env_id]) linked_timer_ids = [row['timer_function_id'] for row in (linked_timer or [])] # Filter out already linked available_timer = [func for func in (all_timer or []) if func['id'] not in linked_timer_ids] return jsonify({ 'http_functions': available_http, 'timer_functions': available_timer }) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @shared_env.route('//link-function', methods=['POST']) @login_required def link_function(env_id): """Link a function to this shared environment""" try: # Verify ownership existing = db.execute( 'SELECT id FROM shared_environments WHERE id=%s AND user_id=%s', [env_id, current_user.id], one=True ) if not existing: return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404 data = request.json function_id = data.get('function_id') function_type = data.get('function_type') # 'http' or 'timer' if not function_id or not function_type: return jsonify({'status': 'error', 'message': 'Missing function_id or function_type'}), 400 if function_type == 'http': # Verify function ownership func = db.execute( 'SELECT id FROM http_functions WHERE id=%s AND user_id=%s', [function_id, current_user.id], one=True ) if not func: return jsonify({'status': 'error', 'message': 'Function not found'}), 404 # Link it db.execute( 'INSERT INTO http_function_shared_envs (http_function_id, shared_env_id) VALUES (%s, %s) ON CONFLICT DO NOTHING', [function_id, env_id], commit=True ) elif function_type == 'timer': # Verify function ownership func = db.execute( 'SELECT id FROM timer_functions WHERE id=%s AND user_id=%s', [function_id, current_user.id], one=True ) if not func: return jsonify({'status': 'error', 'message': 'Function not found'}), 404 # Link it db.execute( 'INSERT INTO timer_function_shared_envs (timer_function_id, shared_env_id) VALUES (%s, %s) ON CONFLICT DO NOTHING', [function_id, env_id], commit=True ) else: return jsonify({'status': 'error', 'message': 'Invalid function_type'}), 400 return jsonify({'status': 'success', 'message': 'Function linked successfully'}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @shared_env.route('//unlink-function', methods=['POST']) @login_required def unlink_function(env_id): """Unlink a function from this shared environment""" try: data = request.json function_id = data.get('function_id') function_type = data.get('function_type') # 'http' or 'timer' if not function_id or not function_type: return jsonify({'status': 'error', 'message': 'Missing function_id or function_type'}), 400 if function_type == 'http': db.execute( 'DELETE FROM http_function_shared_envs WHERE http_function_id=%s AND shared_env_id=%s', [function_id, env_id], commit=True ) elif function_type == 'timer': db.execute( 'DELETE FROM timer_function_shared_envs WHERE timer_function_id=%s AND shared_env_id=%s', [function_id, env_id], commit=True ) else: return jsonify({'status': 'error', 'message': 'Invalid function_type'}), 400 return jsonify({'status': 'success', 'message': 'Function unlinked successfully'}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @shared_env.route('//history', methods=['GET']) @login_required def history(env_id): """Get version history for a shared environment""" try: # Verify ownership existing = db.execute( 'SELECT id, name FROM shared_environments WHERE id=%s AND user_id=%s', [env_id, current_user.id], one=True ) if not existing: return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404 # Fetch all versions versions = db.execute(''' SELECT version_number, environment, versioned_at FROM shared_environment_versions WHERE shared_env_id = %s ORDER BY version_number DESC ''', [env_id]) # Convert datetime objects to ISO format strings for version in versions or []: version['versioned_at'] = version['versioned_at'].isoformat() if version.get('versioned_at') else None return jsonify({ 'status': 'success', 'env_name': existing['name'], 'versions': versions if versions else [] }) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @shared_env.route('//restore', methods=['POST']) @login_required def restore(env_id): """Restore a shared environment to a previous version""" try: version_number = request.json.get('version_number') if not version_number: return jsonify({'status': 'error', 'message': 'Version number is required'}), 400 # Verify ownership existing = db.execute( 'SELECT id, name FROM shared_environments WHERE id=%s AND user_id=%s', [env_id, current_user.id], one=True ) if not existing: return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404 # Fetch the selected version's environment data version_data = db.execute( 'SELECT environment FROM shared_environment_versions WHERE shared_env_id=%s AND version_number=%s', [env_id, version_number], one=True ) if not version_data: return jsonify({'status': 'error', 'message': 'Version not found'}), 404 # Update the shared environment with the old version's data # This will trigger the versioning function to create a new version db.execute( 'UPDATE shared_environments SET environment=%s, updated_at=NOW() WHERE id=%s', [json.dumps(version_data['environment']), env_id], commit=True ) return jsonify({ 'status': 'success', 'message': f'Restored to version {version_number}' }) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500