#!/usr/bin/env python3 """ Flask web server for The Villages Import Tools """ from functools import wraps from flask import Flask, render_template, jsonify, request, send_from_directory, send_file, session, redirect, url_for, make_response from werkzeug.utils import safe_join from werkzeug.security import generate_password_hash, check_password_hash import subprocess import os import re import secrets import shlex import shutil import threading import json import base64 import gzip import logging from datetime import datetime, timezone import yaml try: import psutil _has_psutil = True except ImportError: _has_psutil = False try: from pywebpush import webpush, WebPushException from py_vapid import Vapid from cryptography.hazmat.primitives import serialization _has_webpush = True except ImportError: _has_webpush = False app = Flask(__name__, static_folder='static', template_folder='templates') # ── Secret key ──────────────────────────────────────────────────────────────── def _get_or_create_secret_key(): key = os.environ.get('FLASK_SECRET_KEY', '') if key: return key key_file = '/data/.secret_key' if os.path.exists(key_file): with open(key_file) as f: return f.read().strip() key = secrets.token_hex(32) try: os.makedirs('/data', exist_ok=True) with open(key_file, 'w') as f: f.write(key) except Exception: pass return key app.secret_key = _get_or_create_secret_key() # ── Users / auth ─────────────────────────────────────────────────────────────── USERS_FILE = '/data/users.yml' def _load_users(): try: with open(USERS_FILE) as f: data = yaml.safe_load(f) or {} return {u['username']: u['password_hash'] for u in data.get('users', [])} except Exception: return {} def _save_users(users_dict): users_list = [{'username': u, 'password_hash': h} for u, h in sorted(users_dict.items())] tmp = USERS_FILE + '.tmp' with open(tmp, 'w') as f: yaml.dump({'users': users_list}, f, default_flow_style=False, allow_unicode=True) os.replace(tmp, USERS_FILE) def _seed_admin(): """Create or update the admin user from the ADMIN_PASSWORD env var.""" password = os.environ.get('ADMIN_PASSWORD', '') if not password: return users = _load_users() users['admin'] = generate_password_hash(password) _save_users(users) _seed_admin() def require_auth(f): @wraps(f) def decorated(*args, **kwargs): if not session.get('username'): if request.is_json or request.path.startswith('/api/'): return jsonify({'error': 'Authentication required'}), 401 return redirect(url_for('login_page', next=request.path)) return f(*args, **kwargs) return decorated @app.route('/login', methods=['GET']) def login_page(): if session.get('username'): return redirect(request.args.get('next', '/')) return render_template('login.html', next=request.args.get('next', '/'), error=None) @app.route('/login', methods=['POST']) def login(): username = request.form.get('username', '').strip() password = request.form.get('password', '') next_url = request.form.get('next', '/') users = _load_users() if username and username in users and check_password_hash(users[username], password): session['username'] = username return redirect(next_url) return render_template('login.html', next=next_url, error='Invalid username or password') @app.route('/logout', methods=['POST']) def logout(): session.clear() return redirect('/') @app.route('/api/auth-status') def auth_status(): username = session.get('username') return jsonify({'authenticated': bool(username), 'username': username or ''}) # ── Job registry ────────────────────────────────────────────────────────────── # Every run is recorded under /data/jobs (metadata JSON + log file) so any # logged-in user, on any device, can see what ran, when, and how it ended - # even after the job finished or the server restarted. JOBS_DIR = '/data/jobs' JOBS_KEEP = 200 running_processes = {} jobs = {} # job_id -> metadata dict (see _new_job) process_logs = {} # job_id -> list of log lines jobs_lock = threading.Lock() def _utcnow(): return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z') def _job_meta_path(job_id): return os.path.join(JOBS_DIR, f'{job_id}.json') def _job_log_path(job_id): return os.path.join(JOBS_DIR, f'{job_id}.log') def _save_job(job): os.makedirs(JOBS_DIR, exist_ok=True) tmp = _job_meta_path(job['id']) + '.tmp' with open(tmp, 'w') as f: json.dump(job, f) os.replace(tmp, _job_meta_path(job['id'])) def _load_jobs(): """Load job history from disk. Jobs left 'running' by a previous server process can no longer be watched, so they are marked interrupted.""" if not os.path.isdir(JOBS_DIR): return for fname in os.listdir(JOBS_DIR): if not fname.endswith('.json'): continue try: with open(os.path.join(JOBS_DIR, fname)) as f: job = json.load(f) except (OSError, ValueError): continue if job.get('status') == 'running': job['status'] = 'interrupted' job['ended'] = job.get('ended') or _utcnow() _save_job(job) jobs[job['id']] = job def _prune_jobs(): """Keep only the newest JOBS_KEEP jobs on disk and in memory.""" finished = sorted((j for j in jobs.values() if j['status'] != 'running'), key=lambda j: j['started']) for job in finished[:max(0, len(jobs) - JOBS_KEEP)]: jobs.pop(job['id'], None) process_logs.pop(job['id'], None) for path in (_job_meta_path(job['id']), _job_log_path(job['id'])): try: os.remove(path) except OSError: pass def _job_logs(job_id): if job_id in process_logs: return process_logs[job_id] try: with open(_job_log_path(job_id)) as f: return f.readlines() except OSError: return [] _load_jobs() # ── Web push: notify subscribed browsers when a job finishes ───────────────── # VAPID keys are generated once into /data (like the Flask secret key), or set # VAPID_PRIVATE_KEY_FILE to use your own PEM. Browsers only allow push on HTTPS # (or http://localhost). VAPID_KEY_FILE = os.environ.get('VAPID_PRIVATE_KEY_FILE', '/data/.vapid_private.pem') VAPID_SUBJECT = os.environ.get('VAPID_SUBJECT', 'mailto:admin@example.org') PUSH_SUBSCRIPTIONS_FILE = '/data/push-subscriptions.json' push_lock = threading.Lock() _vapid = None def _get_vapid(): global _vapid if _vapid is None and _has_webpush: if os.path.exists(VAPID_KEY_FILE): _vapid = Vapid.from_file(VAPID_KEY_FILE) else: _vapid = Vapid() _vapid.generate_keys() _vapid.save_key(VAPID_KEY_FILE) os.chmod(VAPID_KEY_FILE, 0o600) return _vapid def _vapid_public_key(): raw = _get_vapid().public_key.public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) return base64.urlsafe_b64encode(raw).decode().rstrip('=') def _load_push_subscriptions(): try: with open(PUSH_SUBSCRIPTIONS_FILE) as f: return json.load(f) except (OSError, ValueError): return {} def _save_push_subscriptions(subs): tmp = PUSH_SUBSCRIPTIONS_FILE + '.tmp' with open(tmp, 'w') as f: json.dump(subs, f) os.replace(tmp, PUSH_SUBSCRIPTIONS_FILE) def _notify_job_finished(job): """Push a notification about a finished job to every subscribed browser. Subscriptions the push service reports as gone are dropped.""" if not _has_webpush: return with push_lock: subs = _load_push_subscriptions() if not subs: return payload = json.dumps({'job': job}) gone = [] for endpoint, entry in subs.items(): try: webpush(entry['subscription'], payload, vapid_private_key=VAPID_KEY_FILE, vapid_claims={'sub': VAPID_SUBJECT}, ttl=24 * 3600) except WebPushException as e: if e.response is not None and e.response.status_code in (404, 410): gone.append(endpoint) else: logging.warning('Web push to %s failed: %s', endpoint[:60], e) except Exception as e: logging.warning('Web push to %s failed: %s', endpoint[:60], e) if gone: with push_lock: subs = _load_push_subscriptions() for endpoint in gone: subs.pop(endpoint, None) _save_push_subscriptions(subs) def _cleanup_legacy_structure(): """Remove dated folders and symlink from the old /data/YYMMDD layout, migrating any files up to /data/{county}/ before deleting.""" data_dir = '/data' if not os.path.exists(data_dir): return symlink = os.path.join(data_dir, 'latest') if os.path.islink(symlink): os.unlink(symlink) dated_pattern = re.compile(r'^\d{6}$') for entry in os.listdir(data_dir): entry_path = os.path.join(data_dir, entry) if os.path.isdir(entry_path) and dated_pattern.match(entry): for county_dir in os.listdir(entry_path): src_county = os.path.join(entry_path, county_dir) if os.path.isdir(src_county): dst_county = os.path.join(data_dir, county_dir) os.makedirs(dst_county, exist_ok=True) for fname in os.listdir(src_county): src_file = os.path.join(src_county, fname) dst_file = os.path.join(dst_county, fname) if os.path.isfile(src_file) and not os.path.exists(dst_file): shutil.move(src_file, dst_file) shutil.rmtree(entry_path) _cleanup_legacy_structure() def _list_data_files(counties): """Files in each county's data folder, newest first: {county_id: [{name, ts}]}.""" data_files = {} for county in counties: county_dir = f'/data/{county["id"]}' if os.path.isdir(county_dir): files = [] for fname in sorted(os.listdir(county_dir)): fpath = os.path.join(county_dir, fname) if os.path.isfile(fpath): files.append({'name': fname, 'ts': int(os.path.getmtime(fpath))}) if files: files.sort(key=lambda f: f['ts'], reverse=True) data_files[county['id']] = files return data_files @app.route('/api/data-files') def data_files_api(): return jsonify(_list_data_files(get_counties())) @app.route('/') def index(): """Main index page with script execution buttons""" counties = get_counties() data_files = _list_data_files(counties) import json as _json return render_template('index.html', counties=counties, counties_json=_json.dumps(counties), data_files_json=_json.dumps(data_files), current_user=session.get('username')) @app.route('/map') def map_viewer(): """Map viewer page""" return render_template('map.html') # ── Reports ──────────────────────────────────────────────────────────────── @app.route('/reports') def reports_page(): """Reports index page""" return render_template('reports.html', current_user=session.get('username')) @app.route('/reports/city-boundaries') def city_boundaries_report(): """City name agreement report: county vs OSM addr:city, on a map""" counties = get_counties() available = [] pinned_cities = {} for county in counties: path = f'/data/{county["id"]}/city-comparison.geojson' if os.path.isfile(path): available.append(county['id']) pinned_cities[county['id']] = county.get('pinned_cities') or [] return render_template('city-boundaries-report.html', counties=counties, available_counties=available, pinned_cities=pinned_cities, current_user=session.get('username')) # ── map-checker: hand-traced boundary tool ────────────────────────────────── # # thevillages.com's Terms of Use prohibit automated access, so this only ever # saves what a human draws by hand while looking at the official map # themselves - nothing here fetches from thevillages.com. See map-checker/README.md. MAP_CHECKER_DIR = '/data/map-checker' MAP_CHECKER_BOUNDARY_FILE = os.path.join(MAP_CHECKER_DIR, 'boundary.geojson') MAP_CHECKER_DRAFT_FILE = os.path.join(MAP_CHECKER_DIR, 'draft.json') @app.route('/map-checker') @require_auth def map_checker_page(): """Boundary tracer: hand-trace a polygon against a reference map, save as GeoJSON. Requires login so tracing work is never done (and lost) as an anonymous session.""" return render_template('boundary-tracer.html', counties=get_counties(), current_user=session.get('username')) @app.route('/api/map-checker/boundary', methods=['GET']) @require_auth def get_map_checker_boundary(): if not os.path.isfile(MAP_CHECKER_BOUNDARY_FILE): return jsonify({'type': 'FeatureCollection', 'features': []}) with open(MAP_CHECKER_BOUNDARY_FILE) as f: return jsonify(json.load(f)) @app.route('/api/map-checker/boundary', methods=['POST']) @require_auth def save_map_checker_boundary(): data = request.json or {} if data.get('type') != 'FeatureCollection' or not isinstance(data.get('features'), list): return jsonify({'error': 'Expected a GeoJSON FeatureCollection'}), 400 for feat in data['features']: geom_type = ((feat.get('geometry') or {}).get('type')) if geom_type not in ('Polygon', 'MultiPolygon'): return jsonify({'error': f'Unsupported geometry type: {geom_type}'}), 400 os.makedirs(MAP_CHECKER_DIR, exist_ok=True) tmp = MAP_CHECKER_BOUNDARY_FILE + '.tmp' with open(tmp, 'w') as f: json.dump(data, f) os.replace(tmp, MAP_CHECKER_BOUNDARY_FILE) return jsonify({'success': True, 'features': len(data['features'])}) def _is_latlng_list(val): if not isinstance(val, list): return False for pt in val: if not (isinstance(pt, list) and len(pt) == 2 and all(isinstance(c, (int, float)) for c in pt)): return False return True @app.route('/api/map-checker/draft', methods=['GET']) @require_auth def get_map_checker_draft(): if not os.path.isfile(MAP_CHECKER_DRAFT_FILE): return jsonify({'polygons': [], 'inProgress': []}) with open(MAP_CHECKER_DRAFT_FILE) as f: return jsonify(json.load(f)) @app.route('/api/map-checker/draft', methods=['POST']) @require_auth def save_map_checker_draft(): """Autosave endpoint hit after every vertex/edit so in-progress tracing survives a crashed tab or dropped connection, even before the user explicitly finishes a polygon or clicks Save to server.""" data = request.json or {} polygons = data.get('polygons') in_progress = data.get('inProgress', []) if not isinstance(polygons, list): return jsonify({'error': 'Expected a polygons list'}), 400 for p in polygons: if not (isinstance(p, dict) and isinstance(p.get('name'), str) and _is_latlng_list(p.get('latlngs'))): return jsonify({'error': 'Malformed polygon entry'}), 400 if not _is_latlng_list(in_progress): return jsonify({'error': 'Malformed inProgress point list'}), 400 os.makedirs(MAP_CHECKER_DIR, exist_ok=True) tmp = MAP_CHECKER_DRAFT_FILE + '.tmp' with open(tmp, 'w') as f: json.dump({'polygons': polygons, 'inProgress': in_progress}, f) os.replace(tmp, MAP_CHECKER_DRAFT_FILE) return jsonify({'success': True}) def get_script_map(): """Build script map dynamically from county configuration.""" counties = get_counties() sm = { 'ls': 'ls -alR /data', } dl_roads = {}; dl_addrs = {}; dl_paths = {} osm_roads = {}; osm_addrs = {}; osm_paths = {} conv_paths = {} diff_roads = {}; diff_addrs = {}; diff_paths = {} city_report = {} for c in counties: cid = c['id'] name = c['name'] state = c['state'] base = f'/data/{cid}' # Each county download also converts the shapefile to GeoJSON, so the # diff can run straight after it. Uploaded paths are converted by a # separate convert-paths job that the dashboard starts after the upload. conv_roads = f'python shp-to-geojson.py {base}/roads.shp.zip {base}/county-roads.geojson' conv_addrs = f'python convert-addresses.py {base}/addresses.shp.zip {base}/county-addresses.geojson' conv_paths_cmd = f'python shp-to-geojson.py {base}/paths.shp.zip {base}/county-paths.geojson' if c.get('roads_url'): dl_roads[cid] = f'mkdir -p {base} && wget {c["roads_url"]} -O {base}/roads.shp.zip && {conv_roads}' if c.get('addresses_url'): dl_addrs[cid] = f'mkdir -p {base} && wget {c["addresses_url"]} -O {base}/addresses.shp.zip && {conv_addrs}' pu = c.get('paths_url', '') if pu == 'upload': dl_paths[cid] = {'type': 'upload', 'dest': f'{base}/paths.shp.zip'} conv_paths[cid] = conv_paths_cmd elif pu: dl_paths[cid] = f'mkdir -p {base} && wget {pu} -O {base}/paths.shp.zip && {conv_paths_cmd}' osm_roads[cid] = ['python', 'download-overpass.py', '--type', 'highways', name, state, f'{base}/osm-roads.geojson'] osm_addrs[cid] = ['python', 'download-overpass.py', '--type', 'addresses', name, state, f'{base}/osm-addresses.geojson'] if pu: osm_paths[cid] = ['python', 'download-overpass.py', '--type', 'paths', name, state, f'{base}/osm-paths.geojson'] diff_roads[cid] = ['python', 'diff-highways.py', f'{base}/osm-roads.geojson', f'{base}/county-roads.geojson', '--output', f'{base}/diff-roads.geojson'] diff_addrs[cid] = ['python', 'compare-addresses.py', '--local-file', f'{base}/county-addresses.geojson', '--osm-file', f'{base}/osm-addresses.geojson', '--output-dir', base] city_report[cid] = ['python', 'generate-city-report.py', '--existing-file', f'{base}/addresses-existing.geojson', '--output-dir', base] if pu: diff_paths[cid] = ['python', 'diff-highways.py', f'{base}/osm-paths.geojson', f'{base}/county-paths.geojson', '--output', f'{base}/diff-paths.geojson'] if dl_roads: sm['download-county-roads'] = dl_roads if dl_addrs: sm['download-county-addresses'] = dl_addrs if dl_paths: sm['download-county-paths'] = dl_paths sm['download-osm-roads'] = osm_roads sm['download-osm-addresses'] = osm_addrs if osm_paths: sm['download-osm-paths'] = osm_paths if conv_paths: sm['convert-paths'] = conv_paths sm['diff-roads'] = diff_roads sm['diff-addresses'] = diff_addrs if diff_paths: sm['diff-paths'] = diff_paths sm['generate-city-report'] = city_report # Point-in-polygon check against the traced/imported boundary at # /data/map-checker/boundary.geojson (see /map-checker and map-checker/README.md). # Not per-county like the scripts above - one boundary can span multiple # counties, so this checks all configured counties in a single run. county_args = ' '.join(f'--county {shlex.quote(c["id"])}' for c in counties) if county_args: sm['boundary-check'] = ( f'python map-checker/check-boundary.py {county_args} ' f'--boundary /data/map-checker/boundary.geojson ' f'--output /data/map-checker/flagged.geojson' ) return sm @app.route('/api/run-script', methods=['POST']) @require_auth def run_script(): """Execute a script in the background""" data = request.json script_name = data.get('script') county = data.get('county', '') if not script_name: return jsonify({'error': 'No script specified'}), 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] if isinstance(script_config, str): cmd = ['bash', '-c', script_config] elif isinstance(script_config, dict): 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) else: return jsonify({'error': 'Invalid script configuration'}), 400 job_id = f"{script_name}_{county}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" county_name = next((c['name'] for c in get_counties() if c['id'] == county), county) job = { 'id': job_id, 'script': script_name, 'label': str(data.get('label') or script_name)[:100], 'county': county, 'county_name': county_name, 'user': session.get('username'), 'started': _utcnow(), 'ended': None, 'status': 'running', 'exit_code': None, } with jobs_lock: jobs[job_id] = job process_logs[job_id] = [] _save_job(job) _prune_jobs() def run_command(): log_file = open(_job_log_path(job_id), 'w') def log(line): process_logs[job_id].append(line) log_file.write(line) log_file.flush() 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 for line in process.stdout: log(line) process.wait() job['exit_code'] = process.returncode if job.get('cancelled'): job['status'] = 'cancelled' log(f"\n[Job cancelled by {job['cancelled']}]") else: job['status'] = 'success' if process.returncode == 0 else 'failed' log(f"\n[Process completed with exit code {process.returncode}]") except Exception as e: job['status'] = 'failed' log(f"\n[ERROR: {str(e)}]") finally: running_processes.pop(job_id, None) log_file.close() with jobs_lock: job['ended'] = _utcnow() _save_job(job) # Finished logs live on disk; don't hold them in memory forever. process_logs.pop(job_id, None) _notify_job_finished(job) thread = threading.Thread(target=run_command) thread.daemon = True thread.start() return jsonify({'job_id': job_id, 'status': 'started', 'job': job}) @app.route('/api/jobs') @require_auth def list_jobs(): """Most recent job overall, plus the most recent job for each script/county pair, so every device can show the same banner and per-step status.""" with jobs_lock: ordered = sorted(jobs.values(), key=lambda j: j['started']) latest_by_step = {} for job in ordered: latest_by_step[f"{job['script']}|{job['county']}"] = job return jsonify({ 'latest': ordered[-1] if ordered else None, 'steps': latest_by_step, 'running': [j['id'] for j in ordered if j['status'] == 'running'], }) @app.route('/api/job-status/') @require_auth def job_status(job_id): """Get status and logs for a job""" job = jobs.get(job_id) if not job: return jsonify({'error': 'Job not found'}), 404 return jsonify({'job_id': job_id, 'running': job['status'] == 'running', 'job': job, 'logs': _job_logs(job_id)}) @app.route('/api/cancel-job', methods=['POST']) @require_auth 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: jobs[job_id]['cancelled'] = session.get('username') or 'user' running_processes[job_id].terminate() return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/sw.js') def service_worker(): """Served from the site root so the worker's scope covers every page.""" resp = make_response(send_from_directory(app.static_folder, 'sw.js')) resp.headers['Content-Type'] = 'application/javascript' resp.headers['Cache-Control'] = 'no-cache' return resp @app.route('/manifest.webmanifest') def web_manifest(): """Lets phones install the dashboard; iOS only allows web push for installed apps.""" resp = make_response(send_from_directory(app.static_folder, 'manifest.webmanifest')) resp.headers['Content-Type'] = 'application/manifest+json' return resp @app.route('/api/push/key') @require_auth def push_key(): if not _has_webpush: return jsonify({'error': 'pywebpush not installed on the server'}), 503 return jsonify({'publicKey': _vapid_public_key()}) @app.route('/api/push/subscribe', methods=['POST']) @require_auth def push_subscribe(): sub = (request.json or {}).get('subscription') or {} keys = sub.get('keys') or {} if not (isinstance(sub.get('endpoint'), str) and sub['endpoint'].startswith('https://') and keys.get('p256dh') and keys.get('auth')): return jsonify({'error': 'Invalid subscription'}), 400 with push_lock: subs = _load_push_subscriptions() subs[sub['endpoint']] = {'subscription': {'endpoint': sub['endpoint'], 'keys': { 'p256dh': keys['p256dh'], 'auth': keys['auth']}}, 'user': session.get('username'), 'created': _utcnow()} _save_push_subscriptions(subs) return jsonify({'success': True}) @app.route('/api/push/unsubscribe', methods=['POST']) @require_auth def push_unsubscribe(): endpoint = (request.json or {}).get('endpoint') with push_lock: subs = _load_push_subscriptions() if subs.pop(endpoint, None) is not None: _save_push_subscriptions(subs) return jsonify({'success': True}) @app.route('/api/upload-file', methods=['POST']) @require_auth def upload_file(): """Upload a shapefile ZIP to a path under /data""" dest = request.form.get('dest') if not dest or not dest.startswith('/data/'): return jsonify({'error': 'Invalid destination path'}), 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/clear-data', methods=['POST']) @require_auth def clear_data(): """Delete all files from every county data directory under /data.""" counties = get_counties() removed = [] errors = [] for county in counties: county_dir = f'/data/{county["id"]}' if not os.path.isdir(county_dir): continue for fname in os.listdir(county_dir): fpath = os.path.join(county_dir, fname) if os.path.isfile(fpath): try: os.remove(fpath) removed.append(f'{county["id"]}/{fname}') gz_copy = os.path.join(GZIP_CACHE_DIR, county['id'], fname + '.gz') if os.path.exists(gz_copy): os.remove(gz_copy) except Exception as e: errors.append(str(e)) if errors: return jsonify({'error': '; '.join(errors)}), 500 return jsonify({'success': True, 'removed': removed}) @app.route('/api/system-stats') def system_stats(): """Return current CPU, memory, and disk usage percentages.""" if not _has_psutil: return jsonify({'error': 'psutil not installed'}), 503 cpu = psutil.cpu_percent(interval=None) mem = psutil.virtual_memory().percent try: disk = psutil.disk_usage('/data').percent except Exception: disk = psutil.disk_usage('/').percent return jsonify({'cpu': cpu, 'mem': mem, 'disk': disk}) @app.route('/api/list-files') def list_files(): """List available GeoJSON files recursively with modification times.""" data_dir = '/data' files = {'diff': [], 'osm': [], 'county': []} if os.path.exists(data_dir): for root, dirs, filenames in os.walk(data_dir): dirs[:] = [d for d in dirs if d != 'osm_cache'] for filename in filenames: if filename.endswith('.geojson'): filepath = os.path.join(root, filename) rel_path = os.path.relpath(filepath, data_dir).replace('\\', '/') mtime = datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %H:%M') entry = {'path': rel_path, 'mtime': mtime} if 'diff' in filename.lower(): files['diff'].append(entry) elif 'osm' in filename.lower(): files['osm'].append(entry) else: files['county'].append(entry) return jsonify(files) GZIP_CACHE_DIR = '/data/.gzcache' _gzip_lock = threading.Lock() def _gzipped_copy(path, filename): """Path of a gzipped copy of path, (re)built when the source is newer. GeoJSON compresses ~10x, which matters for 50-150 MB county files.""" gz_path = os.path.join(GZIP_CACHE_DIR, filename + '.gz') with _gzip_lock: if not os.path.exists(gz_path) or os.path.getmtime(gz_path) < os.path.getmtime(path): os.makedirs(os.path.dirname(gz_path), exist_ok=True) tmp = gz_path + '.tmp' with open(path, 'rb') as src, gzip.open(tmp, 'wb', compresslevel=5) as dst: shutil.copyfileobj(src, dst, 1024 * 1024) os.replace(tmp, gz_path) return gz_path @app.route('/data/') def serve_data(filename): """Serve files from /data (GeoJSON gzipped when the browser accepts it)""" path = safe_join('/data', filename) if (path and filename.endswith('.geojson') and os.path.isfile(path) and 'gzip' in request.headers.get('Accept-Encoding', '')): resp = make_response(send_file(_gzipped_copy(path, filename), mimetype='application/geo+json')) resp.headers['Content-Encoding'] = 'gzip' resp.headers['Vary'] = 'Accept-Encoding' return resp return send_from_directory('/data', filename) # ── Config file download / upload ────────────────────────────────────────────── _ALLOWED_CONFIGS = {'counties.yml', 'exceptions.yml', 'exclusions.yml'} @app.route('/api/config/', methods=['GET']) @require_auth def download_config(filename): if filename not in _ALLOWED_CONFIGS: return jsonify({'error': 'Not found'}), 404 return send_from_directory('/data', filename, as_attachment=True) @app.route('/api/config/', methods=['POST']) @require_auth def upload_config(filename): if filename not in _ALLOWED_CONFIGS: return jsonify({'error': 'Not found'}), 404 if 'file' not in request.files: return jsonify({'error': 'No file provided'}), 400 f = request.files['file'] try: content = f.read().decode('utf-8') data = yaml.safe_load(content) if filename == 'counties.yml' and not isinstance((data or {}).get('counties'), list): return jsonify({'error': 'Invalid counties.yml: missing counties list'}), 400 if filename == 'exceptions.yml' and not isinstance((data or {}).get('corrections'), list): return jsonify({'error': 'Invalid exceptions.yml: missing corrections list'}), 400 if filename == 'exclusions.yml' and not isinstance((data or {}).get('exclusions'), list): return jsonify({'error': 'Invalid exclusions.yml: missing exclusions list'}), 400 except Exception as e: return jsonify({'error': f'Invalid YAML: {e}'}), 400 dest = f'/data/{filename}' tmp = dest + '.tmp' with open(tmp, 'w') as out: out.write(content) os.replace(tmp, dest) return jsonify({'success': True}) # ── Exclusions config ───────────────────────────────────────────────────────── EXCLUSIONS_FILE = '/data/exclusions.yml' def _ensure_exclusions_file(): if not os.path.exists(EXCLUSIONS_FILE): os.makedirs(os.path.dirname(EXCLUSIONS_FILE), exist_ok=True) with open(EXCLUSIONS_FILE, 'w') as f: yaml.dump({'exclusions': []}, f, default_flow_style=False, allow_unicode=True) _ensure_exclusions_file() def get_exclusions(): try: with open(EXCLUSIONS_FILE) as f: data = yaml.safe_load(f) or {} return data.get('exclusions', []) except Exception: return [] @app.route('/exclusions') def exclusions_page(): return render_template('exclusions.html', current_user=session.get('username')) @app.route('/api/exclusions', methods=['GET']) def get_exclusions_api(): return jsonify({'exclusions': get_exclusions()}) @app.route('/api/exclusions', methods=['POST']) @require_auth def save_exclusions(): data = request.json items = data.get('exclusions', []) for item in items: if not isinstance(item.get('field'), str) or not item['field'].strip(): return jsonify({'error': 'Each exclusion must have a non-empty "field"'}), 400 if not isinstance(item.get('value'), str) or not item['value'].strip(): return jsonify({'error': 'Each exclusion must have a non-empty "value"'}), 400 tmp = EXCLUSIONS_FILE + '.tmp' with open(tmp, 'w') as f: yaml.dump({'exclusions': items}, f, default_flow_style=False, allow_unicode=True) os.replace(tmp, EXCLUSIONS_FILE) return jsonify({'success': True}) @app.route('/api/exclusions/add', methods=['POST']) @require_auth def add_exclusions(): """Append new exclusion entries, skipping duplicates.""" data = request.json new_items = data.get('exclusions', []) existing = get_exclusions() existing_keys = {(e['field'], e['value']) for e in existing} added = 0 for item in new_items: field = (item.get('field') or '').strip() value = (item.get('value') or '').strip() if not field or not value: continue if (field, value) not in existing_keys: existing.append({'field': field, 'value': value}) existing_keys.add((field, value)) added += 1 tmp = EXCLUSIONS_FILE + '.tmp' with open(tmp, 'w') as f: yaml.dump({'exclusions': existing}, f, default_flow_style=False, allow_unicode=True) os.replace(tmp, EXCLUSIONS_FILE) return jsonify({'success': True, 'added': added}) # ── Counties config ──────────────────────────────────────────────────────────── COUNTIES_FILE = '/data/counties.yml' DEFAULT_COUNTIES = [ { 'id': 'sumter', 'name': 'Sumter County', 'state': 'Florida', 'color': '#28a745', 'roads_url': 'https://www.arcgis.com/sharing/rest/content/items/9177e17c72d3433aa79630c7eda84add/data', 'addresses_url': 'https://www.arcgis.com/sharing/rest/content/items/c75c5aac13a648968c5596b0665be28b/data', 'paths_url': 'upload', 'pinned_cities': ['The Villages'], }, { 'id': 'lake', 'name': 'Lake County', 'state': 'Florida', 'color': '#17a2b8', 'roads_url': 'https://c.lakecountyfl.gov/ftp/GIS/GisDownloads/Shapefiles/Streets.zip', 'addresses_url': 'https://c.lakecountyfl.gov/ftp/GIS/GisDownloads/Shapefiles/Addresspoints.zip', 'paths_url': '', 'pinned_cities': ['The Villages'], }, ] def _ensure_counties_file(): if not os.path.exists(COUNTIES_FILE): os.makedirs(os.path.dirname(COUNTIES_FILE), exist_ok=True) with open(COUNTIES_FILE, 'w') as f: yaml.dump({'counties': DEFAULT_COUNTIES}, f, default_flow_style=False, allow_unicode=True) _ensure_counties_file() def get_counties(): try: with open(COUNTIES_FILE) as f: data = yaml.safe_load(f) or {} return data.get('counties', DEFAULT_COUNTIES) except Exception: return DEFAULT_COUNTIES @app.route('/counties') def counties_page(): return render_template('counties.html', current_user=session.get('username')) @app.route('/api/counties', methods=['GET']) def get_counties_api(): try: return jsonify({'counties': get_counties()}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/counties', methods=['POST']) @require_auth def save_counties(): data = request.json counties = data.get('counties', []) for c in counties: if not c.get('id', '').strip(): return jsonify({'error': 'Each county must have a non-empty id'}), 400 if not c.get('name', '').strip(): return jsonify({'error': 'Each county must have a non-empty name'}), 400 tmp = COUNTIES_FILE + '.tmp' with open(tmp, 'w') as f: yaml.dump({'counties': counties}, f, default_flow_style=False, allow_unicode=True) os.replace(tmp, COUNTIES_FILE) return jsonify({'success': True}) # ── Exceptions config ────────────────────────────────────────────────────────── 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', current_user=session.get('username')) @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']) @require_auth def save_exceptions(): data = request.json corrections = data.get('corrections', []) 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 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)