""" Goodwalk Flask CMS Backend -------------------------- Content stored in SQLite (data/goodwalk.db). Seeds from data/content.json on first run. Admin API protected by HTTP Basic Auth (ADMIN_PASSWORD env var). In production, also serves the static Svelte build from ../frontend/build. """ import json import logging import os import sqlite3 import traceback from datetime import datetime, timezone from functools import wraps from pathlib import Path from flask import Flask, jsonify, request, abort, send_from_directory, Response from flask_cors import CORS logging.basicConfig( level=logging.DEBUG, format='%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S', ) log = logging.getLogger(__name__) app = Flask(__name__, static_folder=None) app.config['PROPAGATE_EXCEPTIONS'] = True CORS(app) @app.errorhandler(Exception) def handle_exception(e): log.error('Unhandled exception on %s %s\n%s', request.method, request.path, traceback.format_exc()) return jsonify({'error': str(e)}), 500 DB_FILE = Path(__file__).parent / 'data' / 'goodwalk.db' SEED_FILE = Path(__file__).parent / 'data' / 'content.json' STATIC_DIR = Path(__file__).parent.parent / 'frontend' / 'build' ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'goodwalk-admin') # ── DB helpers ──────────────────────────────────────────────────────────────── def get_db(): conn = sqlite3.connect(DB_FILE) conn.row_factory = sqlite3.Row return conn def init_db(): DB_FILE.parent.mkdir(exist_ok=True) log.info('DB file: %s', DB_FILE) conn = get_db() conn.execute(''' CREATE TABLE IF NOT EXISTS content_sections ( key TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at TEXT NOT NULL ) ''') conn.commit() count = conn.execute('SELECT COUNT(*) FROM content_sections').fetchone()[0] log.info('DB has %d section(s)', count) if count == 0 and SEED_FILE.exists(): log.info('Seeding from %s', SEED_FILE) _seed_from_json(conn) log.info('Seed complete') elif count == 0: log.warning('No seed file found at %s — DB is empty', SEED_FILE) conn.close() def _seed_from_json(conn): with open(SEED_FILE, 'r', encoding='utf-8') as f: content = json.load(f) now = datetime.now(timezone.utc).isoformat() pages = content.get('pages', {}) sections = { 'siteSettings': content.get('siteSettings', {}), 'navigation': content.get('navigation', {}), 'footer': content.get('footer', {}), 'testimonials': content.get('testimonials', []), 'pages.home': pages.get('home', {}), 'pages.packWalks': pages.get('packWalks', {}), 'pages.oneOnOneWalks': pages.get('oneOnOneWalks', {}), 'pages.puppyVisits': pages.get('puppyVisits', {}), 'pages.pricing': pages.get('pricing', {}), 'pages.about': pages.get('about', {}), 'pages.contact': pages.get('contact', {}), 'onboarding': _default_onboarding(), } for key, data in sections.items(): conn.execute( 'INSERT OR IGNORE INTO content_sections (key, data, updated_at) VALUES (?, ?, ?)', (key, json.dumps(data, ensure_ascii=False), now) ) conn.commit() def _default_onboarding(): return { 'heading': 'Joining the Tiny Gang', 'intro': "Here's what to expect when you start with Goodwalk.", 'steps': [ { 'step': 1, 'title': 'Get in touch', 'body': "Fill out our contact form or send us an email. Tell us about your dog — breed, age, temperament — and we'll get back to you within 24 hours." }, { 'step': 2, 'title': 'Free Meet & Greet', 'body': "We come to you for a no-obligation meet and greet. We'll meet your dog, answer your questions, and make sure we're the right fit for each other." }, { 'step': 3, 'title': 'Assessment Walks', 'body': 'Your dog joins us for a minimum of two assessment walks. This lets us understand their personality, energy level, and compatibility with the current Gang.' }, { 'step': 4, 'title': "Join the Gang!", 'body': "Once cleared, your dog becomes a permanent Tiny Gang member. We agree on walk days, set up invoicing, and you're good to go." }, ], 'requirements': [ 'Current Auckland Council dog registration', 'Up-to-date vaccinations (C5 recommended)', 'Must pass two assessment walks', 'Dog must be sociable with other dogs', ], } def get_section(key): conn = get_db() row = conn.execute('SELECT data FROM content_sections WHERE key = ?', (key,)).fetchone() conn.close() return json.loads(row['data']) if row else None # ── Auth ────────────────────────────────────────────────────────────────────── def require_admin(f): @wraps(f) def decorated(*args, **kwargs): auth = request.authorization if not auth or auth.password != ADMIN_PASSWORD: return Response( 'Authentication required', 401, {'WWW-Authenticate': 'Basic realm="Goodwalk Admin"'} ) return f(*args, **kwargs) return decorated # ── Request logging ────────────────────────────────────────────────────────── @app.before_request def log_request(): log.debug('→ %s %s', request.method, request.path) @app.after_request def log_response(response): log.debug('← %s %s %s', request.method, request.path, response.status_code) return response # ── Public API ──────────────────────────────────────────────────────────────── @app.route('/api/site-settings') def site_settings(): return jsonify(get_section('siteSettings') or {}) @app.route('/api/navigation') def navigation(): return jsonify(get_section('navigation') or {'items': []}) @app.route('/api/footer') def footer(): return jsonify(get_section('footer') or {}) @app.route('/api/testimonials') def testimonials(): return jsonify(get_section('testimonials') or []) @app.route('/api/onboarding') def onboarding(): return jsonify(get_section('onboarding') or {}) @app.route('/api/pages/home') def page_home(): return jsonify(get_section('pages.home') or {}) @app.route('/api/pages/pack-walks') def page_pack_walks(): return jsonify(get_section('pages.packWalks') or {}) @app.route('/api/pages/1-1-walks') def page_one_on_one(): return jsonify(get_section('pages.oneOnOneWalks') or {}) @app.route('/api/pages/puppy-visits') def page_puppy_visits(): return jsonify(get_section('pages.puppyVisits') or {}) @app.route('/api/pages/pricing') def page_pricing(): return jsonify(get_section('pages.pricing') or {}) @app.route('/api/pages/about') def page_about(): return jsonify(get_section('pages.about') or {}) @app.route('/api/pages/contact') def page_contact(): return jsonify(get_section('pages.contact') or {}) # ── Contact form ────────────────────────────────────────────────────────────── @app.route('/api/contact', methods=['POST']) def contact_submit(): body = request.get_json(force=True, silent=True) if not body: return jsonify({'error': 'Invalid request'}), 400 for field in ['name', 'email', 'message']: if not body.get(field, '').strip(): return jsonify({'error': f'{field} is required'}), 422 print(f"[CONTACT] {body.get('name')} <{body.get('email')}> — {body.get('message', '')[:80]}") return jsonify({'success': True}) # ── Admin API ───────────────────────────────────────────────────────────────── @app.route('/api/admin/sections') @require_admin def admin_list_sections(): conn = get_db() rows = conn.execute( 'SELECT key, updated_at FROM content_sections ORDER BY key' ).fetchall() conn.close() return jsonify([{'key': r['key'], 'updated_at': r['updated_at']} for r in rows]) @app.route('/api/admin/sections/') @require_admin def admin_get_section(key): conn = get_db() row = conn.execute( 'SELECT key, data, updated_at FROM content_sections WHERE key = ?', (key,) ).fetchone() conn.close() if not row: abort(404) return jsonify({ 'key': row['key'], 'data': json.loads(row['data']), 'updated_at': row['updated_at'], }) @app.route('/api/admin/sections/', methods=['PUT']) @require_admin def admin_update_section(key): body = request.get_json(force=True, silent=True) if body is None: return jsonify({'error': 'Invalid JSON'}), 400 now = datetime.now(timezone.utc).isoformat() conn = get_db() conn.execute( '''INSERT INTO content_sections (key, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at''', (key, json.dumps(body, ensure_ascii=False), now) ) conn.commit() conn.close() return jsonify({'success': True, 'key': key, 'updated_at': now}) # ── Health ──────────────────────────────────────────────────────────────────── @app.route('/api/health') def health(): return jsonify({'status': 'ok'}) # ── Static frontend (production) ────────────────────────────────────────────── if STATIC_DIR.exists(): @app.route('/', defaults={'path': ''}) @app.route('/') def serve_static(path): file_path = STATIC_DIR / path if path and file_path.is_file(): return send_from_directory(STATIC_DIR, path) return send_from_directory(STATIC_DIR, 'index.html') if __name__ == '__main__': init_db() app.run(debug=True, port=5000)