""" Goodwalk Backend CLI -------------------- A rich management interface for the Goodwalk CMS API. Usage (from backend/ directory): python cli.py — show help python cli.py dev — start dev server python cli.py migrate — run Alembic migrations python cli.py seed — seed admin user + sample CMS content python cli.py seed-content — seed site content from data/content.json python cli.py status — show DB connection + table row counts python cli.py create-admin — create a new admin user interactively python cli.py routes — list all registered API routes """ import asyncio import os import sys from pathlib import Path import typer from rich import box from rich.console import Console from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn from rich.prompt import Prompt, Confirm from rich.table import Table from rich.text import Text from rich import print as rprint # ── Setup ───────────────────────────────────────────────────────────────────── app = typer.Typer( name="goodwalk", help="[bold green]Goodwalk CMS API[/bold green] — management CLI", add_completion=False, rich_markup_mode="rich", ) console = Console() BANNER = """ [bold green] ██████╗ ██████╗ ██████╗ ██████╗ ██╗ ██╗ █████╗ ██╗ ██╗ ██╗ ██╔════╝ ██╔═══██╗██╔═══██╗██╔══██╗██║ ██║██╔══██╗██║ ██║ ██╔╝ ██║ ███╗██║ ██║██║ ██║██║ ██║██║ █╗ ██║███████║██║ █████╔╝ ██║ ██║██║ ██║██║ ██║██║ ██║██║███╗██║██╔══██║██║ ██╔═██╗ ╚██████╔╝╚██████╔╝╚██████╔╝██████╔╝╚███╔███╔╝██║ ██║███████╗██║ ██╗ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ [/bold green][dim] CMS API · FastAPI · PostgreSQL · JWT Auth[/dim] """ def _print_banner(): console.print(BANNER) def _load_settings(): """Import app settings (triggers .env load).""" try: from app.config import settings return settings except Exception as e: console.print(f"[bold red]✗ Failed to load settings:[/bold red] {e}") raise typer.Exit(1) def _make_engine(settings): from sqlalchemy.ext.asyncio import create_async_engine return create_async_engine(settings.DATABASE_URL, echo=False) # ── dev ─────────────────────────────────────────────────────────────────────── @app.command() def dev( host: str = typer.Option("127.0.0.1", "--host", "-h", help="Bind host"), port: int = typer.Option(8000, "--port", "-p", help="Bind port"), reload: bool = typer.Option(True, "--reload/--no-reload", help="Enable auto-reload"), ): """ [bold]Start the development server.[/bold] Launches Uvicorn with hot-reload enabled. Reads configuration from [cyan].env[/cyan]. """ _print_banner() settings = _load_settings() info = Table.grid(padding=(0, 2)) info.add_column(style="dim") info.add_column(style="bold cyan") info.add_row("API URL", f"http://{host}:{port}") info.add_row("Docs", f"http://{host}:{port}/docs") info.add_row("Redoc", f"http://{host}:{port}/redoc") info.add_row("Health", f"http://{host}:{port}/health") info.add_row("Database", settings.DATABASE_URL.split("@")[-1] if "@" in settings.DATABASE_URL else settings.DATABASE_URL) info.add_row("CORS Origins", settings.ALLOWED_ORIGINS) info.add_row("Auto-reload", "[green]on[/green]" if reload else "[yellow]off[/yellow]") console.print(Panel(info, title="[bold green]Starting Goodwalk CMS API[/bold green]", border_style="green")) console.print() import uvicorn uvicorn.run( "app.main:app", host=host, port=port, reload=reload, log_level="warning", # suppress uvicorn's own access log — we use RequestLogMiddleware access_log=False, ) # ── migrate ─────────────────────────────────────────────────────────────────── @app.command() def migrate( revision: str = typer.Argument("head", help="Alembic revision target (default: head)"), autogenerate: bool = typer.Option(False, "--autogenerate", "-a", help="Generate a new migration"), message: str = typer.Option("auto", "--message", "-m", help="Migration message (used with --autogenerate)"), ): """ [bold]Run database migrations via Alembic.[/bold] By default runs [cyan]alembic upgrade head[/cyan]. Pass [cyan]--autogenerate[/cyan] to generate a new migration from model changes. """ _print_banner() console.print(Panel("[bold]Running Alembic migrations[/bold]", border_style="cyan")) import subprocess if autogenerate: cmd = [sys.executable, "-m", "alembic", "revision", "--autogenerate", "-m", message] label = f"Generating migration: [cyan]{message}[/cyan]" else: cmd = [sys.executable, "-m", "alembic", "upgrade", revision] label = f"Upgrading to [cyan]{revision}[/cyan]" with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: task = progress.add_task(label, total=None) result = subprocess.run(cmd, capture_output=True, text=True) progress.update(task, completed=True) if result.returncode == 0: console.print(f"\n[bold green]✓ Migration complete[/bold green]") if result.stdout.strip(): console.print(f"[dim]{result.stdout.strip()}[/dim]") else: console.print(f"\n[bold red]✗ Migration failed[/bold red]") console.print(f"[red]{result.stderr.strip()}[/red]") raise typer.Exit(1) # ── seed ────────────────────────────────────────────────────────────────────── @app.command() def seed(): """ [bold]Seed the database with a default admin user and sample content.[/bold] Creates: • Admin user [cyan]admin@example.com[/cyan] / [cyan]changeme123[/cyan] • Sample Page, BlogPost, and SiteSettings rows • All site content sections from [cyan]data/content.json[/cyan] """ _print_banner() console.print(Panel("[bold]Seeding database[/bold]", border_style="cyan")) asyncio.run(_seed_async()) async def _seed_async(): settings = _load_settings() from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy import select, text from app.models import Base, User, Page, BlogPost, SiteSettings, ContentSection from app.auth.password import hash_password import json engine = _make_engine(settings) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) Session = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) results = [] async with Session() as session: # ── Admin user ────────────────────────────────────────────────────── existing = await session.execute(select(User).where(User.email == "admin@example.com")) if existing.scalar_one_or_none() is None: session.add(User( email="admin@example.com", hashed_password=hash_password("changeme123"), is_active=True, )) results.append(("[green]created[/green]", "User", "admin@example.com")) else: results.append(("[yellow]exists[/yellow] ", "User", "admin@example.com")) # ── Sample Page ───────────────────────────────────────────────────── existing = await session.execute(select(Page).where(Page.slug == "home")) if existing.scalar_one_or_none() is None: session.add(Page( title="Home", slug="home", body="

Welcome to Goodwalk

Professional dog walking services across Auckland Central.

", meta_title="Goodwalk Auckland Dog Walking Service", meta_description="Trusted, professional dog walking services across Auckland Central.", published=True, )) results.append(("[green]created[/green]", "Page", "home")) else: results.append(("[yellow]exists[/yellow] ", "Page", "home")) # ── Sample BlogPost ───────────────────────────────────────────────── existing = await session.execute(select(BlogPost).where(BlogPost.slug == "hello-world")) if existing.scalar_one_or_none() is None: session.add(BlogPost( title="Welcome to the Goodwalk Blog", slug="hello-world", excerpt="Our first blog post — introducing Goodwalk and the Tiny Gang.", body="

Welcome to the Goodwalk blog! We'll be sharing updates, tips, and stories from the Tiny Gang.

", author="Alessandra", tags=["news", "welcome"], published=True, )) results.append(("[green]created[/green]", "BlogPost", "hello-world")) else: results.append(("[yellow]exists[/yellow] ", "BlogPost", "hello-world")) # ── SiteSettings ──────────────────────────────────────────────────── existing = await session.execute(select(SiteSettings).limit(1)) if existing.scalar_one_or_none() is None: session.add(SiteSettings( site_name="Goodwalk", tagline="Unleashing Fun in Your Dog's Day!", logo_url="/images/logo-v6.png", footer_text="© 2026 Goodwalk. All rights reserved.", social_links={ "instagram": "https://www.instagram.com/goodwalk.nz", "facebook": "https://www.facebook.com/goodwalk.nz", "google": "https://g.page/goodwalk", }, )) results.append(("[green]created[/green]", "SiteSettings", "singleton")) else: results.append(("[yellow]exists[/yellow] ", "SiteSettings", "singleton")) # ── Content sections from content.json ────────────────────────────── content_file = Path(__file__).parent / "data" / "content.json" if content_file.exists(): with open(content_file, encoding="utf-8") as f: content = json.load(f) pages_data = content.get("pages", {}) sections = { "siteSettings": content.get("siteSettings", {}), "navigation": content.get("navigation", {}), "footer": content.get("footer", {}), "testimonials": content.get("testimonials", []), "pages.home": pages_data.get("home", {}), "pages.packWalks": pages_data.get("packWalks", {}), "pages.oneOnOneWalks": pages_data.get("oneOnOneWalks", {}), "pages.puppyVisits": pages_data.get("puppyVisits", {}), "pages.pricing": pages_data.get("pricing", {}), "pages.about": pages_data.get("about", {}), "pages.contact": pages_data.get("contact", {}), } for key, data in sections.items(): existing = await session.execute( select(ContentSection).where(ContentSection.key == key) ) row = existing.scalar_one_or_none() if row is None: session.add(ContentSection(key=key, data=data)) results.append(("[green]created[/green]", "Section", key)) else: row.data = data results.append(("[cyan]updated[/cyan] ", "Section", key)) else: console.print(f" [yellow]⚠ content.json not found at {content_file} — skipping sections[/yellow]") await session.commit() await engine.dispose() # ── Results table ──────────────────────────────────────────────────────── table = Table(box=box.ROUNDED, show_header=True, border_style="green") table.add_column("Status", style="bold", width=12) table.add_column("Type", style="cyan", width=16) table.add_column("Key / Identifier") for status, type_, key in results: table.add_row(status, type_, key) console.print() console.print(table) console.print(f"\n[bold green]✓ Seed complete[/bold green] — {len(results)} records processed\n") # ── seed-content ────────────────────────────────────────────────────────────── @app.command(name="seed-content") def seed_content(): """ [bold]Re-seed only the content sections from data/content.json.[/bold] Upserts all page sections, navigation, footer, testimonials, and settings. Safe to run multiple times — existing rows are updated, not duplicated. """ _print_banner() console.print(Panel("[bold]Seeding content sections from data/content.json[/bold]", border_style="cyan")) asyncio.run(_seed_content_async()) async def _seed_content_async(): import json settings = _load_settings() from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy import select from app.models import Base, ContentSection content_file = Path(__file__).parent / "data" / "content.json" if not content_file.exists(): console.print(f"[bold red]✗ Not found:[/bold red] {content_file}") raise typer.Exit(1) with open(content_file, encoding="utf-8") as f: content = json.load(f) pages_data = content.get("pages", {}) sections = { "siteSettings": content.get("siteSettings", {}), "navigation": content.get("navigation", {}), "footer": content.get("footer", {}), "testimonials": content.get("testimonials", []), "pages.home": pages_data.get("home", {}), "pages.packWalks": pages_data.get("packWalks", {}), "pages.oneOnOneWalks": pages_data.get("oneOnOneWalks", {}), "pages.puppyVisits": pages_data.get("puppyVisits", {}), "pages.pricing": pages_data.get("pricing", {}), "pages.about": pages_data.get("about", {}), "pages.contact": pages_data.get("contact", {}), } engine = _make_engine(settings) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) Session = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TaskProgressColumn(), console=console, ) as progress: task = progress.add_task("Upserting sections...", total=len(sections)) results = [] async with Session() as session: for key, data in sections.items(): existing = await session.execute( select(ContentSection).where(ContentSection.key == key) ) row = existing.scalar_one_or_none() if row is None: session.add(ContentSection(key=key, data=data)) results.append(("[green]inserted[/green]", key)) else: row.data = data results.append(("[cyan]updated[/cyan] ", key)) progress.advance(task) await session.commit() await engine.dispose() table = Table(box=box.SIMPLE, show_header=True, border_style="dim") table.add_column("Status", width=10) table.add_column("Section key") for status, key in results: table.add_row(status, key) console.print() console.print(table) console.print(f"[bold green]✓ Done[/bold green] — {len(results)} sections seeded\n") # ── status ──────────────────────────────────────────────────────────────────── @app.command() def status(): """ [bold]Show database connection status and table row counts.[/bold] Verifies the connection and prints a summary of all CMS data currently stored. """ _print_banner() asyncio.run(_status_async()) async def _status_async(): settings = _load_settings() from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy import select, func, text from app.models import User, Page, BlogPost, SiteSettings, ContentSection db_display = settings.DATABASE_URL.split("@")[-1] if "@" in settings.DATABASE_URL else settings.DATABASE_URL engine = _make_engine(settings) Session = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) with Progress(SpinnerColumn(), TextColumn("Connecting to database..."), console=console) as p: p.add_task("", total=None) try: async with Session() as session: await session.execute(text("SELECT 1")) connected = True except Exception as e: connected = False err = str(e) if not connected: console.print(Panel( f"[bold red]✗ Cannot connect[/bold red]\n[dim]{db_display}[/dim]\n\n[red]{err}[/red]", title="Database Status", border_style="red", )) raise typer.Exit(1) # Row counts async with Session() as session: counts = {} for label, model, filter_col in [ ("Pages (total)", Page, None), ("Pages (published)", Page, Page.published == True), ("Blog Posts (total)", BlogPost, None), ("Blog Posts (pub.)", BlogPost, BlogPost.published == True), ("Site Settings", SiteSettings, None), ("CMS Users", User, None), ("Content Sections", ContentSection, None), ]: stmt = select(func.count()).select_from(model) if filter_col is not None: stmt = stmt.where(filter_col) result = await session.execute(stmt) counts[label] = result.scalar_one() # Section keys sections_result = await session.execute( select(ContentSection.key, ContentSection.updated_at).order_by(ContentSection.key) ) section_rows = sections_result.all() await engine.dispose() # ── Connection panel ───────────────────────────────────────────────────── conn_info = Table.grid(padding=(0, 2)) conn_info.add_column(style="dim") conn_info.add_column(style="bold") conn_info.add_row("Status", "[bold green]● Connected[/bold green]") conn_info.add_row("Database", db_display) console.print(Panel(conn_info, title="[bold green]Database Connection[/bold green]", border_style="green")) console.print() # ── Row counts ─────────────────────────────────────────────────────────── counts_table = Table(box=box.ROUNDED, title="[bold]Table Row Counts[/bold]", border_style="cyan") counts_table.add_column("Table / Filter", style="cyan") counts_table.add_column("Rows", justify="right", style="bold white") for label, count in counts.items(): style = "green" if count > 0 else "dim" counts_table.add_row(label, f"[{style}]{count}[/{style}]") console.print(counts_table) console.print() # ── Section keys ───────────────────────────────────────────────────────── if section_rows: sec_table = Table(box=box.SIMPLE, title="[bold]Content Sections[/bold]", border_style="dim") sec_table.add_column("Key", style="cyan") sec_table.add_column("Last Updated", style="dim") for key, updated_at in section_rows: sec_table.add_row(key, updated_at.strftime("%Y-%m-%d %H:%M") if updated_at else "—") console.print(sec_table) console.print() # ── create-admin ────────────────────────────────────────────────────────────── @app.command(name="create-admin") def create_admin(): """ [bold]Interactively create a new CMS admin user.[/bold] Prompts for email and password. Password is bcrypt-hashed before storage. """ _print_banner() console.print(Panel("[bold]Create Admin User[/bold]", border_style="cyan")) email = Prompt.ask("[cyan]Email address[/cyan]") password = Prompt.ask("[cyan]Password[/cyan]", password=True) confirm = Prompt.ask("[cyan]Confirm password[/cyan]", password=True) if password != confirm: console.print("[bold red]✗ Passwords do not match[/bold red]") raise typer.Exit(1) if len(password) < 8: console.print("[bold red]✗ Password must be at least 8 characters[/bold red]") raise typer.Exit(1) asyncio.run(_create_admin_async(email, password)) async def _create_admin_async(email: str, password: str): settings = _load_settings() from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy import select from app.models import User from app.auth.password import hash_password engine = _make_engine(settings) Session = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) async with Session() as session: existing = await session.execute(select(User).where(User.email == email)) if existing.scalar_one_or_none() is not None: console.print(f"[bold yellow]⚠ User already exists:[/bold yellow] {email}") await engine.dispose() raise typer.Exit(1) user = User(email=email, hashed_password=hash_password(password), is_active=True) session.add(user) await session.commit() await session.refresh(user) await engine.dispose() console.print(f"\n[bold green]✓ Admin user created[/bold green]") info = Table.grid(padding=(0, 2)) info.add_column(style="dim") info.add_column(style="bold cyan") info.add_row("Email", email) info.add_row("ID", str(user.id)) info.add_row("Active", "[green]yes[/green]") console.print(Panel(info, border_style="green")) console.print() # ── routes ──────────────────────────────────────────────────────────────────── @app.command() def routes(): """ [bold]List all registered API routes.[/bold] Displays method, path, and handler name for every endpoint. """ _print_banner() from app.main import app as fastapi_app table = Table(box=box.ROUNDED, title="[bold]Registered API Routes[/bold]", border_style="cyan") table.add_column("Methods", style="bold yellow", width=20) table.add_column("Path", style="cyan") table.add_column("Handler", style="dim") table.add_column("Auth", justify="center", width=6) METHOD_COLORS = { "GET": "green", "POST": "yellow", "PUT": "blue", "DELETE": "red", "PATCH": "magenta", } route_rows = [] for route in fastapi_app.routes: if not hasattr(route, "methods"): continue methods = sorted(route.methods or []) path = route.path handler = route.endpoint.__name__ if hasattr(route, "endpoint") else "—" # Detect auth by checking if get_current_user is in dependencies deps = getattr(route, "dependencies", []) dep_names = [str(d) for d in deps] auth_required = any("get_current_user" in d for d in dep_names) # Also check endpoint's own dependencies via __wrapped__ or direct inspection endpoint_deps = getattr(route.endpoint, "__wrapped__", route.endpoint) method_str = " ".join( f"[{METHOD_COLORS.get(m, 'white')}]{m}[/{METHOD_COLORS.get(m, 'white')}]" for m in methods ) route_rows.append((path, method_str, handler, auth_required)) # Sort: /api/v1 first, then /api/, then rest def sort_key(r): p = r[0] if p.startswith("/api/v1"): return (0, p) if p.startswith("/api"): return (1, p) return (2, p) route_rows.sort(key=sort_key) for path, method_str, handler, auth in route_rows: auth_icon = "[yellow]🔒[/yellow]" if auth else "" table.add_row(method_str, path, handler, auth_icon) console.print(table) console.print(f"\n[dim]Total routes: {len(route_rows)}[/dim]\n") # ── Interactive shell ───────────────────────────────────────────────────────── MENU_ITEMS = [ ("1", "dev", "Start the FastAPI dev server [dim](background thread)[/dim]"), ("2", "migrate", "Run Alembic migrations [dim](upgrade head)[/dim]"), ("3", "seed", "Seed admin user + all content"), ("4", "seed-content", "Re-seed content.json sections only"), ("5", "status", "Database connection + row counts"), ("6", "create-admin", "Create a new CMS admin user"), ("7", "routes", "List all registered API routes"), ("q", "quit", "Exit the CLI"), ] # Tracks whether the dev server background thread is running _server_thread = None def _print_menu(): table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2), border_style="dim") table.add_column(style="bold cyan", width=4) table.add_column(style="bold white", width=18) table.add_column() for key, _, description in MENU_ITEMS: table.add_row(f"[{key}]", _, description) console.print(Panel(table, title="[bold green]Goodwalk CLI — What would you like to do?[/bold green]", border_style="green")) def _run_dev_in_background(host: str, port: int): """Start Uvicorn in a daemon thread so the prompt stays live.""" import threading import uvicorn global _server_thread if _server_thread is not None and _server_thread.is_alive(): console.print("[yellow]⚠ Dev server is already running[/yellow]") return config = uvicorn.Config("app.main:app", host=host, port=port, reload=False, log_level="warning", access_log=False) server = uvicorn.Server(config) def _target(): asyncio.run(server.serve()) _server_thread = threading.Thread(target=_target, daemon=True, name="uvicorn") _server_thread.start() console.print(f"\n[bold green]✓ Dev server started[/bold green] → [cyan]http://{host}:{port}[/cyan] | docs: [cyan]http://{host}:{port}/docs[/cyan]") console.print("[dim]Running in background — use [q] to exit the CLI (server stops with it)[/dim]\n") def _dispatch(choice: str): """Run the selected command without exiting the shell.""" choice = choice.strip().lower() if choice in ("1", "dev"): settings = _load_settings() _run_dev_in_background("127.0.0.1", 8000) elif choice in ("2", "migrate"): import subprocess autogen = Confirm.ask("Generate a new migration? (no = upgrade head)", default=False) if autogen: msg = Prompt.ask("Migration message", default="auto") cmd = [sys.executable, "-m", "alembic", "revision", "--autogenerate", "-m", msg] else: cmd = [sys.executable, "-m", "alembic", "upgrade", "head"] with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console) as p: p.add_task("Running migration...", total=None) result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: console.print("[bold green]✓ Migration complete[/bold green]") if result.stdout.strip(): console.print(f"[dim]{result.stdout.strip()}[/dim]") else: console.print(f"[bold red]✗ Migration failed[/bold red]\n[red]{result.stderr.strip()}[/red]") elif choice in ("3", "seed"): asyncio.run(_seed_async()) elif choice in ("4", "seed-content"): asyncio.run(_seed_content_async()) elif choice in ("5", "status"): asyncio.run(_status_async()) elif choice in ("6", "create-admin"): console.print(Panel("[bold]Create Admin User[/bold]", border_style="cyan")) email = Prompt.ask("[cyan]Email address[/cyan]") password = Prompt.ask("[cyan]Password[/cyan]", password=True) confirm = Prompt.ask("[cyan]Confirm password[/cyan]", password=True) if password != confirm: console.print("[bold red]✗ Passwords do not match[/bold red]") elif len(password) < 8: console.print("[bold red]✗ Password must be at least 8 characters[/bold red]") else: asyncio.run(_create_admin_async(email, password)) elif choice in ("7", "routes"): routes() elif choice in ("q", "quit", "exit"): console.print("\n[dim]Goodbye 👋[/dim]\n") raise SystemExit(0) else: console.print(f"[yellow]Unknown command:[/yellow] [bold]{choice}[/bold] — enter a number or letter from the menu") @app.command() def shell(): """ [bold]Launch the interactive shell.[/bold] Presents a menu after each command so you can keep working without restarting. The dev server runs in a background thread — the prompt stays live. """ _print_banner() console.print("[dim]Type a number, command name, or [bold]q[/bold] to quit. Ctrl+C also exits.[/dim]\n") while True: _print_menu() try: choice = Prompt.ask("[bold green]>[/bold green]", default="").strip() except (KeyboardInterrupt, EOFError): console.print("\n[dim]Goodbye 👋[/dim]\n") break if not choice: continue console.print() try: _dispatch(choice) except SystemExit: break except Exception as e: console.print(f"[bold red]✗ Error:[/bold red] {e}") console.print() # ── Entry point ─────────────────────────────────────────────────────────────── if __name__ == "__main__": # Running with no arguments → drop straight into the interactive shell if len(sys.argv) == 1: sys.argv.append("shell") app()