Files

307 lines
11 KiB
Python
Raw Permalink Normal View History

2026-04-18 07:23:55 +12:00
"""
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/<path:key>')
@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/<path:key>', 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('/<path:path>')
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)