307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""
|
|
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)
|