#!/usr/bin/env python3 """ Flask web server for The Villages Import Tools """ from flask import Flask, render_template, jsonify, request, send_from_directory import subprocess import os import threading import json from datetime import datetime app = Flask(__name__, static_folder='static', template_folder='templates') # Store running processes running_processes = {} process_logs = {} # Global var latest_path = "/data/latest" @app.route('/') def index(): """Main index page with script execution buttons""" # Get available scripts and organize them script_map = get_script_map() # Organize scripts by category scripts_by_category = { 'Download County Data': ['download-county-addresses', 'download-county-roads', 'download-county-paths'], 'Download OSM Data': ['download-osm-roads', 'download-osm-paths'], 'Convert Data': ['convert-roads', 'convert-paths'], 'Diff Data': ['diff-roads', 'diff-paths', 'diff-addresses'], 'Utilities': ['ls', 'make-new-latest'] } # Get list of files data_files = {} if os.path.exists(latest_path): for countyFolder in os.listdir(latest_path): files = os.listdir('/data/latest/'+countyFolder) data_files[countyFolder] = files # data_files.append(countyFolder) return render_template('index.html', script_map=script_map, scripts_by_category=scripts_by_category, data_files=data_files ) @app.route('/map') def map_viewer(): """Map viewer page""" return render_template('map.html') def get_script_map(): """Get the map of available scripts and their commands""" return { 'ls': 'ls -al /data', 'make-new-latest': 'cd /data && NEWDIR=$(date +%y%m%d) && mkdir -p $NEWDIR/lake $NEWDIR/sumter && ln -sfn $NEWDIR latest', # todo: make a clean-old-data script 'download-county-addresses': { # deliver files with standardized names 'sumter': 'mkdir -p /data/latest/sumter && wget https://www.arcgis.com/sharing/rest/content/items/c75c5aac13a648968c5596b0665be28b/data -O /data/latest/sumter/addresses.shp.zip', 'lake': 'mkdir -p /data/latest/lake && wget [LAKE_URL_HERE] -O /data/latest/lake/addresses.shp.zip' }, 'download-county-roads': { # deliver files with standardized names 'sumter': 'mkdir -p /data/latest/sumter && wget https://www.arcgis.com/sharing/rest/content/items/9177e17c72d3433aa79630c7eda84add/data -O /data/latest/sumter/roads.shp.zip', 'lake': 'mkdir -p /data/latest/lake && wget [LAKE_URL_HERE] -O /data/latest/lake/roads.shp.zip' }, 'download-county-paths': { # deliver files with standardized names #'sumter': ['/data/latest/sumter/paths.shp.zip'], #'lake': ['/data/latest/lake/paths.shp.zip'] }, # todo: integrate osm downloading and shapefile converting into diff-roads like addresses 'download-osm-roads': { 'lake': ['python', 'download-overpass.py', '--type', 'highways', 'Lake County', 'Florida', '/data/latest/lake/osm-roads.geojson'], 'sumter': ['python', 'download-overpass.py', '--type', 'highways', 'Sumter County', 'Florida', '/data/latest/sumter/osm-roads.geojson'] }, 'download-osm-paths': { # todo: no lake county paths #'lake': ['python', 'download-overpass.py', '--type', 'highways', 'Lake County', 'Florida', '/data/latest/lake/osm-roads.geojson'], 'sumter': ['python', 'download-overpass.py', '--type', 'paths', 'Sumter County', 'Florida', '/data/latest/sumter/osm-paths.geojson'] }, # todo 'convert-roads': { 'sumter': ['python', 'shp-to-geojson.py', '/data/latest/sumter/roads.shp.zip', '/data/latest/sumter/county-roads.geojson'], 'lake': ['python', 'shp-to-geojson.py', '/data/latest/lake/roads.shp.zip', '/data/latest/lake/county-roads.geojson'] }, 'convert-paths': { #todo: delete sumter-multi-modal-convert.py ? 'sumter': ['python', 'shp-to-geojson.py', '/data/latest/sumter/paths.shp.zip', '/data/latest/sumter/county-paths.geojson'], }, 'diff-roads': { 'lake': ['python', 'diff-highways.py', '/data/latest/lake/osm-roads.geojson', '/data/latest/lake/county-roads.geojson', '--output', '/data/latest/lake/diff-roads.geojson'], 'sumter': ['python', 'diff-highways.py', '/data/latest/sumter/osm-roads.geojson', '/data/latest/sumter/county-roads.geojson', '--output', '/data/latest/sumter/diff-roads.geojson'] }, 'diff-paths': { #todo: no lake county data for paths #'lake': ['python', 'diff-highways.py', '/data/latest/lake/osm-paths.geojson', '/data/latest/lake/county-paths.geojson', '--output', '/data/latest/lake/diff-paths.geojson'], 'sumter': ['python', 'diff-highways.py', '/data/latest/sumter/osm-paths.geojson', '/data/latest/sumter/county-paths.geojson', '--output', '/data/latest/sumter/diff-paths.geojson'], }, # addresses need no osm download or shapefile convert, just county download 'diff-addresses': { #todo: delete sumter-address-convert.py ? 'lake': ['python', 'compare-addresses.py', 'Lake', 'Florida', '--local-zip', '/data/latest/lake/addresses.shp.zip', '--output-dir', '/data/latest/lake', '--cache-dir', '/data/osm_cache'], 'sumter': ['python', 'compare-addresses.py', 'Sumter', 'Florida', '--local-zip', '/data/latest/sumter/addresses.shp.zip', '--output-dir', '/data/latest/sumter', '--cache-dir', '/data/osm_cache'] }, } @app.route('/api/run-script', methods=['POST']) def run_script(): """Execute a script in the background""" data = request.json script_name = data.get('script') county = data.get('county', '') force_download = data.get('forceDownload', False) if not script_name: return jsonify({'error': 'No script specified'}), 400 # Check if /data/latest exists and is a symlink, not a regular directory # Skip this check for make-new-latest, ls skip_check_scripts = ['make-new-latest', 'ls'] if script_name not in skip_check_scripts: latest_path = '/data/latest' if os.path.exists(latest_path) or os.path.islink(latest_path): # Check if it's a directory but NOT a symlink if os.path.isdir(latest_path) and not os.path.islink(latest_path): return jsonify({'error': '/data/latest is a directory, not a symlink. Please run "Make New Latest" first.'}), 400 else: # /data/latest doesn't exist at all return jsonify({'error': '/data/latest does not exist. Please run "Make New Latest" first.'}), 400 script_map = get_script_map() if script_name not in script_map: return jsonify({'error': 'Unknown script'}), 400 script_config = script_map[script_name] # Handle both string commands and dict of county-specific commands if isinstance(script_config, str): # Simple string command (like 'ls') cmd = ['bash', '-c', script_config] elif isinstance(script_config, dict): # County-specific commands if not county: return jsonify({'error': 'County required for this script'}), 400 if county not in script_config: return jsonify({'error': f'County {county} not supported for {script_name}'}), 400 cmd_config = script_config[county] if isinstance(cmd_config, str): cmd = ['bash', '-c', cmd_config] else: cmd = list(cmd_config) # Make a copy to avoid modifying the original # Add --force-download flag for diff-addresses if requested if script_name == 'diff-addresses' and force_download and isinstance(cmd, list): cmd.append('--force-download') else: return jsonify({'error': 'Invalid script configuration'}), 400 # Generate a unique job ID job_id = f"{script_name}_{county}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" # Start process in background def run_command(): try: process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=os.path.dirname(os.path.dirname(__file__)) ) running_processes[job_id] = process process_logs[job_id] = [] # Stream output for line in process.stdout: process_logs[job_id].append(line) process.wait() process_logs[job_id].append(f"\n[Process completed with exit code {process.returncode}]") except Exception as e: process_logs[job_id].append(f"\n[ERROR: {str(e)}]") finally: if job_id in running_processes: del running_processes[job_id] thread = threading.Thread(target=run_command) thread.daemon = True thread.start() return jsonify({'job_id': job_id, 'status': 'started'}) @app.route('/api/job-status/') def job_status(job_id): """Get status and logs for a job""" is_running = job_id in running_processes logs = process_logs.get(job_id, []) return jsonify({ 'job_id': job_id, 'running': is_running, 'logs': logs }) @app.route('/api/cancel-job', methods=['POST']) def cancel_job(): """Cancel a running job""" data = request.json job_id = data.get('job_id') if not job_id: return jsonify({'error': 'No job ID specified'}), 400 if job_id not in running_processes: return jsonify({'error': 'Job not found or already completed'}), 404 try: process = running_processes[job_id] process.terminate() process_logs[job_id].append('\n[Job cancelled by user]') del running_processes[job_id] return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/list-files') def list_files(): """List available GeoJSON files""" data_dir = '/data' files = { 'diff': [], 'osm': [], 'county': [] } # Scan directories for geojson files if os.path.exists(data_dir): for root, dirs, filenames in os.walk(data_dir): for filename in filenames: if filename.endswith('.geojson'): rel_path = os.path.relpath(os.path.join(root, filename), data_dir) if 'diff' in filename.lower(): files['diff'].append(rel_path) elif 'osm' in filename.lower(): files['osm'].append(rel_path) elif any(county in filename.lower() for county in ['lake', 'sumter']): files['county'].append(rel_path) return jsonify(files) @app.route('/data/') def serve_data(filename): """Serve GeoJSON files""" return send_from_directory('/data', filename) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)