#!/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 import yaml 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-addresses', 'download-osm-paths'], 'Convert Data': ['convert-roads', 'convert-addresses', '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-paths': { 'sumter': {'type': 'upload', 'dest': '/data/latest/sumter/paths.shp.zip'}, # lake: not available }, '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 https://c.lakecountyfl.gov/ftp/GIS/GisDownloads/Shapefiles/Addresspoints.zip -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 https://c.lakecountyfl.gov/ftp/GIS/GisDownloads/Shapefiles/Streets.zip -O /data/latest/lake/roads.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-addresses': { 'lake': ['python', 'download-overpass.py', '--type', 'addresses', 'Lake County', 'Florida', '/data/latest/lake/osm-addresses.geojson'], 'sumter': ['python', 'download-overpass.py', '--type', 'addresses', 'Sumter County', 'Florida', '/data/latest/sumter/osm-addresses.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-addresses': { 'sumter': ['python', 'shp-to-geojson.py', '/data/latest/sumter/addresses.shp.zip', '/data/latest/sumter/county-addresses.geojson'], 'lake': ['python', 'shp-to-geojson.py', '/data/latest/lake/addresses.shp.zip', '/data/latest/lake/county-addresses.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/upload-file', methods=['POST']) def upload_file(): """Upload a shapefile ZIP to a path under /data/latest""" dest = request.form.get('dest') if not dest or not dest.startswith('/data/latest/'): return jsonify({'error': 'Invalid destination path'}), 400 latest = '/data/latest' if os.path.exists(latest) or os.path.islink(latest): if os.path.isdir(latest) and not os.path.islink(latest): return jsonify({'error': '/data/latest is a directory, not a symlink. Please run "Make New Latest" first.'}), 400 else: return jsonify({'error': '/data/latest does not exist. Please run "Make New Latest" first.'}), 400 if 'file' not in request.files: return jsonify({'error': 'No file provided'}), 400 f = request.files['file'] if not f.filename.lower().endswith('.zip'): return jsonify({'error': 'File must be a ZIP archive (.zip)'}), 400 os.makedirs(os.path.dirname(dest), exist_ok=True) f.save(dest) return jsonify({'success': True, 'path': dest}) @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) EXCEPTIONS_FILE = '/data/exceptions.yml' DEFAULT_EXCEPTIONS = [ {'from': "D Angelo Lane", 'to': "D'Angelo Lane"}, {'from': "Lajolla Circle", 'to': "La Jolla Circle"}, {'from': "Gardena Court", 'to': "Gardenia Court"}, {'from': "Glenmont Court", 'to': "Glenmount Court"}, {'from': "Pawleys Island Path", 'to': "Pawley's Island Path"}, {'from': "Oday Street", 'to': "O'Day Street"}, {'from': "Obrien Place", 'to': "O'Brien Place"}, {'from': "Ohara Court", 'to': "O'Hara Court"}, ] def _ensure_exceptions_file(): if not os.path.exists(EXCEPTIONS_FILE): os.makedirs(os.path.dirname(EXCEPTIONS_FILE), exist_ok=True) with open(EXCEPTIONS_FILE, 'w') as f: yaml.dump({'corrections': DEFAULT_EXCEPTIONS}, f, default_flow_style=False, allow_unicode=True) _ensure_exceptions_file() @app.route('/exceptions') def exceptions_page(): return render_template('exceptions.html') @app.route('/api/exceptions', methods=['GET']) def get_exceptions(): try: with open(EXCEPTIONS_FILE) as f: data = yaml.safe_load(f) or {} return jsonify({'corrections': data.get('corrections', [])}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/exceptions', methods=['POST']) def save_exceptions(): data = request.json corrections = data.get('corrections', []) # Validate for item in corrections: if not isinstance(item.get('from'), str) or not item['from'].strip(): return jsonify({'error': 'Each correction must have a non-empty "from" field'}), 400 if not isinstance(item.get('to'), str) or not item['to'].strip(): return jsonify({'error': 'Each correction must have a non-empty "to" field'}), 400 # Atomic write tmp = EXCEPTIONS_FILE + '.tmp' with open(tmp, 'w') as f: yaml.dump({'corrections': corrections}, f, default_flow_style=False, allow_unicode=True) os.replace(tmp, EXCEPTIONS_FILE) return jsonify({'success': True}) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)