v1
This commit is contained in:
+306
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user