diff --git a/demos/README.md b/demos/README.md index f2b85ac0..ad65c4c0 100644 --- a/demos/README.md +++ b/demos/README.md @@ -31,6 +31,7 @@ seeds sample data, and launches the app. | ๐Ÿ”ฎ Materialized Views | FastAPI + HTMX | `MaterializedView`, `sync_view()`, read-only queries, auto-updating views | [`materialized-views/`](materialized-views/) | | ๐Ÿ“Š Realtime Counters | FastAPI + HTMX | `CounterDocument`, `increment()`, `decrement()`, live analytics dashboard | [`realtime-counters/`](realtime-counters/) | | ๐Ÿ”ง Schema Migrations | FastAPI | `coodie migrate` CLI, apply/rollback/dry-run, `_coodie_migrations` state tracking, migration file authoring | [`schema-migrations/`](schema-migrations/) | +| ๐ŸŽญ Polymorphic CMS | FastAPI + HTMX | Single-table inheritance, `Discriminator` column, Article/Video/Podcast subtypes, type filtering | [`polymorphic-cms/`](polymorphic-cms/) | ## Shared Infrastructure diff --git a/demos/polymorphic-cms/Makefile b/demos/polymorphic-cms/Makefile new file mode 100644 index 00000000..55b7baf6 --- /dev/null +++ b/demos/polymorphic-cms/Makefile @@ -0,0 +1,43 @@ +COMPOSE := docker compose -f ../docker-compose.yml +KEYSPACE := cms + +.PHONY: db-up db-down seed run clean + +db-up: ## Start ScyllaDB and create keyspace + @echo "" + @echo " ๐ŸŽญ THE SHAPESHIFTER'S ARCHIVE โ€” Dimension-11" + @echo " โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + @echo " Morph-IX is initializing the content singularity..." + @echo "" + $(COMPOSE) up -d + @echo " ๐ŸŒ€ Waiting for ScyllaDB node to materialize..." + @until $(COMPOSE) exec scylladb nodetool status 2>/dev/null | grep -q "^UN"; do sleep 2; done + @echo " โœ“ ScyllaDB is UP โ€” dimensional anchor established" + @echo " ๐Ÿ”ง Creating keyspace '$(KEYSPACE)' (single table for all content types)..." + $(COMPOSE) exec scylladb cqlsh -e \ + "CREATE KEYSPACE IF NOT EXISTS $(KEYSPACE) \ + WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'};" + @echo " โœ“ Keyspace ready โ€” the Archive is open" + @echo "" + +db-down: ## Stop ScyllaDB + @echo " ๐ŸŒ€ Collapsing the content singularity... shutting down ScyllaDB" + $(COMPOSE) down + @echo " โœ“ Morph-IX has been disconnected" + +seed: db-up ## Seed sample polymorphic content (depends on db-up) + @echo " ๐ŸŽญ Morph-IX is generating polymorphic content..." + @echo "" + uv run python seed.py --count 30 + +run: seed ## Install deps, seed, and start the app + @echo "" + @echo " ๐ŸŽญ Launching The Shapeshifter's Archive..." + @echo " ๐Ÿ–ฅ http://127.0.0.1:8000 โ€” Dimension-11 portal active" + @echo " ๐Ÿ“ฐ Articles ยท ๐ŸŽฌ Videos ยท ๐ŸŽ™๏ธ Podcasts โ€” one table, one truth" + @echo "" + uv run uvicorn main:app --reload + +clean: db-down ## Stop DB and remove data volumes + $(COMPOSE) down -v + @echo " โœ“ All content forms purged โ€” the Archive is reset" diff --git a/demos/polymorphic-cms/README.md b/demos/polymorphic-cms/README.md new file mode 100644 index 00000000..bdf786a5 --- /dev/null +++ b/demos/polymorphic-cms/README.md @@ -0,0 +1,215 @@ +# ๐ŸŽญ coodie Polymorphic CMS Demo โ€” The Shapeshifter's Archive + +> *Dimension-11: The Shapeshifter's Archive* โ€” Classify polymorphic content +> before Morph-IX collapses the entire archive into a singularity of +> undifferentiated content. + +A runnable demo app showcasing **coodie**'s **single-table inheritance** via the +`Discriminator` column. Three content types โ€” `Article`, `Video`, and `Podcast` +โ€” share a single Cassandra table, with coodie automatically routing queries +based on the `content_type` discriminator value. + +Built with [FastAPI](https://fastapi.tiangolo.com/) and [HTMX](https://htmx.org/). + +## Quick Start + +```bash +cd demos/polymorphic-cms +make run +``` + +This single command starts ScyllaDB, creates the keyspace, seeds 30 mixed +content items, and launches the FastAPI app. + +## Prerequisites + +* Python โ‰ฅ 3.10 +* [uv](https://docs.astral.sh/uv/) (recommended) or pip +* Docker & Docker Compose (for ScyllaDB) + +## Step-by-Step + +### 1. Start ScyllaDB and create keyspace + +```bash +make db-up +``` + +### 2. Seed sample data + +```bash +make seed # 30 items (default) +uv run python seed.py --count 100 # custom count +``` + +### 3. Run the app + +```bash +uv run uvicorn main:app --reload +``` + +The API will be available at . +Interactive docs at . +**HTMX UI** at โ€” browse, create, and filter +polymorphic content. + +## How It Works โ€” Single-Table Inheritance + +All three content types live in **one Cassandra table** (`contents`): + +```python +from coodie.aio import Document +from coodie.fields import Discriminator, Indexed, PrimaryKey + +class Content(Document): + """Base content โ€” single table for all types.""" + id: Annotated[UUID, PrimaryKey()] = Field(default_factory=uuid4) + content_type: Annotated[str, Discriminator()] = "" + title: str + author: Annotated[str, Indexed()] + # ... shared fields ... + + class Settings: + name = "contents" + keyspace = "cms" + +class Article(Content): + body: Optional[str] = None + word_count: int = 0 + + class Settings: + __discriminator_value__ = "article" + +class Video(Content): + video_url: Optional[str] = None + duration_seconds: int = 0 + resolution: str = "1080p" + + class Settings: + __discriminator_value__ = "video" + +class Podcast(Content): + audio_url: Optional[str] = None + duration_seconds: int = 0 + episode_number: int = 0 + + class Settings: + __discriminator_value__ = "podcast" +``` + +**Key points:** +- The `Discriminator()` marker on `content_type` tells coodie this is a + polymorphic hierarchy. +- Each subclass sets `__discriminator_value__` in its `Settings`. +- `Article.find()` automatically adds `WHERE content_type = 'article'`. +- `Content.find()` returns all types โ€” coodie routes each row to the + correct subclass. + +### Environment variables + +| Variable | Default | Description | +|---|---|---| +| `SCYLLA_HOSTS` | `127.0.0.1` | Comma-separated ScyllaDB contact points | +| `SCYLLA_KEYSPACE` | `cms` | Keyspace to use | + +## Makefile Targets + +| Target | Description | +|---|---| +| `make db-up` | Start ScyllaDB and create the `cms` keyspace | +| `make db-down` | Stop ScyllaDB | +| `make seed` | Seed 30 polymorphic content items (depends on `db-up`) | +| `make run` | Install deps, seed data, and start the app | +| `make clean` | Stop DB and remove data volumes | + +## Seed Script + +The `seed.py` script generates realistic sample data with colorful +[rich](https://rich.readthedocs.io/) progress output themed around +Morph-IX's shapeshifting archive. + +```bash +# Generate 100 mixed content items +uv run python seed.py --count 100 +``` + +Each type gets its own colour in the progress display: +- ๐Ÿ“ฐ **Articles** โ€” coral (`#f97316`) +- ๐ŸŽฌ **Videos** โ€” violet (`#8b5cf6`) +- ๐ŸŽ™๏ธ **Podcasts** โ€” teal (`#14b8a6`) + +## Example API Requests + +### List all content + +```bash +curl http://127.0.0.1:8000/content +``` + +### Filter by type + +```bash +curl "http://127.0.0.1:8000/content?content_type=article" +curl "http://127.0.0.1:8000/content?content_type=video" +curl "http://127.0.0.1:8000/content?content_type=podcast" +``` + +### Create an article + +```bash +curl -X POST http://127.0.0.1:8000/content/article \ + -H "Content-Type: application/json" \ + -d '{ + "title": "The Art of Polymorphism", + "author": "Morph-IX", + "summary": "All content is one.", + "body": "A treatise on single-table inheritance...", + "word_count": 1500 + }' +``` + +### Create a video + +```bash +curl -X POST http://127.0.0.1:8000/content/video \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Shapeshifting 101", + "author": "Dr. Schema", + "video_url": "https://dim-11.stream/v/abc123", + "duration_seconds": 600, + "resolution": "1080p" + }' +``` + +### Create a podcast + +```bash +curl -X POST http://127.0.0.1:8000/content/podcast \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Ep. 1: The Day Content Became Sentient", + "author": "The Discriminator", + "audio_url": "https://morph-ix.audio/ep/001", + "duration_seconds": 1800, + "episode_number": 1 + }' +``` + +### Get a content item by ID + +```bash +curl http://127.0.0.1:8000/content/ +``` + +### Delete content + +```bash +curl -X DELETE http://127.0.0.1:8000/content/ +``` + +## Cleanup + +```bash +make clean +``` diff --git a/demos/polymorphic-cms/main.py b/demos/polymorphic-cms/main.py new file mode 100644 index 00000000..1d1a521a --- /dev/null +++ b/demos/polymorphic-cms/main.py @@ -0,0 +1,202 @@ +"""FastAPI Polymorphic CMS demo โ€” single-table inheritance with Discriminator.""" + +from __future__ import annotations + +__version__ = "0.1.0" + +import os +from contextlib import asynccontextmanager +from pathlib import Path +from typing import AsyncIterator +from uuid import UUID + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from coodie.aio import init_coodie + +from models import Article, Content, Podcast, Video + +BASE_DIR = Path(__file__).resolve().parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +# Map discriminator values โ†’ subclass for convenience +TYPE_MAP: dict[str, type[Content]] = { + "article": Article, + "video": Video, + "podcast": Podcast, +} + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Startup: connect to ScyllaDB and sync the shared content table.""" + hosts = os.getenv("SCYLLA_HOSTS", "127.0.0.1").split(",") + keyspace = os.getenv("SCYLLA_KEYSPACE", "cms") + await init_coodie(hosts=hosts, keyspace=keyspace) + # Sync the base table, then each subclass so subclass-specific columns + # (e.g. body, video_url, audio_url) are added via ALTER TABLE ADD. + await Content.sync_table() + await Article.sync_table() + await Video.sync_table() + await Podcast.sync_table() + yield + + +app = FastAPI( + title="Polymorphic CMS โ€” The Shapeshifter's Archive", + version="0.1.0", + lifespan=lifespan, +) + + +# ------------------------------------------------------------------ +# JSON API routes +# ------------------------------------------------------------------ + + +@app.get("/content", response_model=list[Content]) +async def list_content( + content_type: str | None = Query(default=None), + author: str | None = Query(default=None), +) -> list[Content]: + """List all content, optionally filtered by type or author.""" + if content_type and content_type in TYPE_MAP: + qs = TYPE_MAP[content_type].find() + else: + qs = Content.find() + if author: + qs = qs.filter(author=author).allow_filtering() + return await qs.all() + + +@app.post("/content/{content_type}", response_model=Content, status_code=201) +async def create_content(content_type: str, item: dict) -> Content: + """Create content of a given type.""" + cls = TYPE_MAP.get(content_type) + if cls is None: + raise HTTPException(status_code=400, detail=f"Unknown type: {content_type}") + doc = cls(**item) + await doc.save() + return doc + + +@app.get("/content/{content_id}", response_model=Content) +async def get_content(content_id: UUID) -> Content: + """Get a single content item by ID.""" + item = await Content.find_one(id=content_id) + if item is None: + raise HTTPException(status_code=404, detail="Content not found") + return item + + +@app.delete("/content/{content_id}", status_code=204) +async def delete_content(content_id: UUID) -> None: + """Delete a content item.""" + item = await Content.find_one(id=content_id) + if item is None: + raise HTTPException(status_code=404, detail="Content not found") + await item.delete() + + +# ------------------------------------------------------------------ +# HTMX UI routes +# ------------------------------------------------------------------ + + +@app.get("/", response_class=HTMLResponse) +async def ui_index(request: Request) -> HTMLResponse: + return templates.TemplateResponse("index.html", {"request": request}) + + +@app.get("/ui/content", response_class=HTMLResponse) +async def ui_list_content( + request: Request, + content_type: str | None = Query(default=None), +) -> HTMLResponse: + """List content cards, optionally filtered by type.""" + if content_type and content_type in TYPE_MAP: + items = await TYPE_MAP[content_type].find().all() + else: + items = await Content.find().all() + return templates.TemplateResponse( + "partials/content_list.html", + {"request": request, "items": items}, + ) + + +@app.get("/ui/content/{content_id}", response_class=HTMLResponse) +async def ui_content_detail(request: Request, content_id: UUID) -> HTMLResponse: + """Show full detail for a single content item.""" + item = await Content.find_one(id=content_id) + if item is None: + raise HTTPException(status_code=404, detail="Content not found") + return templates.TemplateResponse( + "partials/content_detail.html", + {"request": request, "item": item}, + ) + + +@app.post("/ui/content", response_class=HTMLResponse) +async def ui_create_content( + request: Request, + content_type: str = Form(), + title: str = Form(), + author: str = Form(), + summary: str = Form(default=""), + # Article fields + body: str = Form(default=""), + word_count: int = Form(default=0), + # Video fields + video_url: str = Form(default=""), + duration_seconds: int = Form(default=0), + resolution: str = Form(default="1080p"), + # Podcast fields + audio_url: str = Form(default=""), + episode_number: int = Form(default=0), +) -> HTMLResponse: + """Create content from the UI form and return the updated list.""" + cls = TYPE_MAP.get(content_type) + if cls is None: + raise HTTPException(status_code=400, detail=f"Unknown type: {content_type}") + + kwargs: dict = { + "title": title, + "author": author, + "summary": summary or None, + } + + if content_type == "article": + kwargs["body"] = body or None + kwargs["word_count"] = word_count + elif content_type == "video": + kwargs["video_url"] = video_url or None + kwargs["duration_seconds"] = duration_seconds + kwargs["resolution"] = resolution + elif content_type == "podcast": + kwargs["audio_url"] = audio_url or None + kwargs["duration_seconds"] = duration_seconds + kwargs["episode_number"] = episode_number + + doc = cls(**kwargs) + await doc.save() + + items = await Content.find().all() + return templates.TemplateResponse( + "partials/content_list.html", + {"request": request, "items": items}, + ) + + +@app.delete("/ui/content/{content_id}", response_class=HTMLResponse) +async def ui_delete_content(request: Request, content_id: UUID) -> HTMLResponse: + """Delete content and return the updated list.""" + item = await Content.find_one(id=content_id) + if item: + await item.delete() + items = await Content.find().all() + return templates.TemplateResponse( + "partials/content_list.html", + {"request": request, "items": items}, + ) diff --git a/demos/polymorphic-cms/models.py b/demos/polymorphic-cms/models.py new file mode 100644 index 00000000..b30cec20 --- /dev/null +++ b/demos/polymorphic-cms/models.py @@ -0,0 +1,70 @@ +"""Polymorphic CMS models โ€” single-table inheritance with Discriminator column. + +All content types (Article, Video, Podcast) share the ``contents`` table. +The ``content_type`` discriminator column lets coodie route queries to the +correct subclass automatically. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated, Optional +from uuid import UUID, uuid4 + +from pydantic import Field + +from coodie.aio import Document +from coodie.fields import Discriminator, Indexed, PrimaryKey + + +class Content(Document): + """Base content document โ€” the single table for all content types. + + Partition key = ``id``; ``content_type`` is the discriminator column. + """ + + id: Annotated[UUID, PrimaryKey()] = Field(default_factory=uuid4) + content_type: Annotated[str, Discriminator()] = "" + title: str + author: Annotated[str, Indexed()] + summary: Optional[str] = None + published: bool = False + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + ) + + class Settings: + name = "contents" + keyspace = "cms" + + +class Article(Content): + """A written article with body text and word count.""" + + body: Optional[str] = None + word_count: int = 0 + + class Settings: + __discriminator_value__ = "article" + + +class Video(Content): + """A video entry with URL, duration, and resolution.""" + + video_url: Optional[str] = None + duration_seconds: int = 0 + resolution: str = "1080p" + + class Settings: + __discriminator_value__ = "video" + + +class Podcast(Content): + """A podcast episode with audio URL, duration, and episode number.""" + + audio_url: Optional[str] = None + duration_seconds: int = 0 + episode_number: int = 0 + + class Settings: + __discriminator_value__ = "podcast" diff --git a/demos/polymorphic-cms/pyproject.toml b/demos/polymorphic-cms/pyproject.toml new file mode 100644 index 00000000..d8988cdf --- /dev/null +++ b/demos/polymorphic-cms/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "coodie-polymorphic-cms-demo" +version = "0.1.0" +description = "Polymorphic CMS demo showcasing coodie's single-table inheritance with Discriminator" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.100", + "uvicorn[standard]>=0.20", + "jinja2>=3.1", + "python-multipart>=0.0.22", + "coodie[scylla]", + "click>=8.0", + "faker>=18.0", + "rich>=13.0", +] + +[tool.uv.sources] +coodie = { path = "../..", editable = true } diff --git a/demos/polymorphic-cms/seed.py b/demos/polymorphic-cms/seed.py new file mode 100644 index 00000000..d6c61f08 --- /dev/null +++ b/demos/polymorphic-cms/seed.py @@ -0,0 +1,293 @@ +"""Seed the Shapeshifter's Archive with polymorphic content. + +Usage: + python seed.py # 30 mixed content items (default) + python seed.py --count 100 # 100 items +""" + +from __future__ import annotations + +import asyncio +import os +import random + +import click +from faker import Faker +from rich.console import Console +from rich.panel import Panel +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeRemainingColumn, +) +from rich.table import Table +from rich.text import Text + +from coodie.aio import init_coodie + +from models import Article, Content, Podcast, Video + +fake = Faker() +console = Console() + +# --- Type-specific colour mapping (coral / violet / teal) --- +TYPE_STYLES = { + "article": {"color": "#f97316", "icon": "๐Ÿ“ฐ", "label": "Article"}, + "video": {"color": "#8b5cf6", "icon": "๐ŸŽฌ", "label": "Video"}, + "podcast": {"color": "#14b8a6", "icon": "๐ŸŽ™๏ธ", "label": "Podcast"}, +} + +# --- Story-themed data pools --- + +ARTICLE_TITLES = [ + "The Quantum Semantics of Distributed Consensus", + "Why Your ORM Is Secretly a Shapeshifter", + "10 Things SCYLLA-9 Doesn't Want You to Know", + "A Field Guide to Interdimensional Content", + "The Art of Single-Table Inheritance", + "Polymorphism in the Age of Sentient Databases", + "Morph-IX Manifesto: All Content Is One", + "From Blog Post to Black Hole: A Content Journey", + "Dimensional Collapse and Schema Design", + "The Discriminator Diaries: Volume XVII", + "How I Stopped Worrying and Loved the Partition Key", + "Eventual Consistency: A Love Story", + "The Topology of Mutable Content Forms", + "Interdimensional Journalism and the Single Table", + "Morph-IX's Guide to Content Alchemy", +] + +VIDEO_TITLES = [ + "SCYLLA-9 Origins: The nodetool Incident", + "Shapeshifting Tutorial: Your First Discriminator", + "Live: Morph-IX Transforms a Podcast Into a Paper", + "Dimension-11 Travel Vlog (DO NOT WATCH ALONE)", + "How to Contain Polymorphic Content (Official Guide)", + "The Making of the Shapeshifter's Archive", + "Coodie Corps Training: Discriminator Basics", + "Time-lapse: Article Evolving Into a Video", + "Morph-IX Unboxing: New Content Forms", + "WARNING: This Video Was Originally a Meme", + "ScyllaDB Masterclass: Single-Table Inheritance", + "Behind the Scenes: The Content Singularity", +] + +PODCAST_TITLES = [ + "Ep. 1: The Day Content Became Sentient", + "Ep. 2: Interview with a Discriminator Column", + "Ep. 3: Morph-IX Therapy Session (LEAKED)", + "Ep. 4: Is Your Blog Post Actually a Podcast?", + "Ep. 5: The Sound of Schema Migration", + "Ep. 6: Dimensional Rift ASMR", + "Ep. 7: Debate: Tabs vs Spaces vs Content Types", + "Ep. 8: The Partition Key Whisperer", + "Ep. 9: Morph-IX Book Club โ€” 'Being and Nothingness'", + "Ep. 10: Live Q&A from the Content Singularity", + "Ep. 11: Why Morph-IX Refuses to Pick a Type", + "Ep. 12: Season Finale โ€” The Archive Speaks", +] + +AUTHORS = [ + "Morph-IX", + "Agent Cipher", + "Agent Flux", + "The Middleware", + "Captain Jinja", + "Commander Knit", + "Dr. Schema", + "Professor Partition", + "The Discriminator", + "Archivist Zero", +] + +SUMMARIES = [ + "Content that defies classification. Morph-IX approves.", + "This piece morphed three times during peer review.", + "Originally a haiku. Now it's a technical whitepaper.", + "WARNING: may spontaneously change form when unobserved.", + "Recovered from the Content Singularity. Handle with care.", + "Morph-IX claims this is their magnum opus. Again.", + "Peer-reviewed by sentient database fragments.", + "Contains trace amounts of interdimensional metadata.", + "Classified Level-7. Do not read aloud near polymorphic entities.", + "The last person who edited this became a podcast episode.", + "Approved for distribution across all 11 dimensions.", + "Morph-IX insists this content 'transcends type.'", +] + +RESOLUTIONS = ["480p", "720p", "1080p", "1440p", "4K"] + + +def _generate_article() -> Article: + return Article( + title=random.choice(ARTICLE_TITLES), + author=random.choice(AUTHORS), + summary=random.choice(SUMMARIES), + published=random.random() < 0.7, + body=fake.text(max_nb_chars=500), + word_count=random.randint(200, 5000), + ) + + +def _generate_video() -> Video: + return Video( + title=random.choice(VIDEO_TITLES), + author=random.choice(AUTHORS), + summary=random.choice(SUMMARIES), + published=random.random() < 0.7, + video_url=f"https://dim-11.stream/v/{fake.uuid4()[:8]}", + duration_seconds=random.randint(30, 7200), + resolution=random.choice(RESOLUTIONS), + ) + + +def _generate_podcast() -> Podcast: + ep = random.randint(1, 50) + return Podcast( + title=random.choice(PODCAST_TITLES), + author=random.choice(AUTHORS), + summary=random.choice(SUMMARIES), + published=random.random() < 0.7, + audio_url=f"https://morph-ix.audio/ep/{ep:03d}", + duration_seconds=random.randint(300, 5400), + episode_number=ep, + ) + + +GENERATORS = [_generate_article, _generate_video, _generate_podcast] + + +def _print_briefing() -> None: + """Print the Morph-IX mission briefing.""" + story = Text() + story.append("DIMENSION-11", style="bold #f97316") + story.append(" โ€” SCYLLA-9's fragment became ") + story.append("Morph-IX", style="bold #8b5cf6") + story.append(",\n") + story.append("an AI that can take the form of any content type. It publishes\n") + story.append("articles that turn into videos mid-sentence, podcasts that become\n") + story.append("blog posts when you pause them, and memes that evolve into\n") + story.append("peer-reviewed papers.\n\n") + story.append("All content is stored in a ") + story.append("single table", style="bold #14b8a6") + story.append(" with a ") + story.append("discriminator column", style="bold #14b8a6") + story.append(" โ€”\nbecause Morph-IX believes ") + story.append("all content is one", style="italic") + story.append(".\n\n") + story.append("The ") + story.append("Coodie Corps", style="bold #f97316") + story.append(" must classify and contain each form before\n") + story.append("the entire archive collapses into a singularity of\n") + story.append("undifferentiated content.\n\n") + story.append("๐ŸŽญ Initiating polymorphic content scan...", style="dim italic") + + console.print() + console.print( + Panel( + story, + title="[bold #8b5cf6]๐ŸŽญ MISSION BRIEFING โ€” DIMENSION-11 // THE SHAPESHIFTER'S ARCHIVE[/]", + border_style="#8b5cf6", + padding=(1, 2), + ) + ) + console.print() + + +async def _seed(count: int) -> None: + """Connect to ScyllaDB, sync table, and insert polymorphic content.""" + _print_briefing() + + hosts = os.getenv("SCYLLA_HOSTS", "127.0.0.1").split(",") + keyspace = os.getenv("SCYLLA_KEYSPACE", "cms") + + console.print("[dim]๐Ÿ“ก Establishing connection to ScyllaDB node...[/]") + await init_coodie(hosts=hosts, keyspace=keyspace) + console.print("[dim]๐Ÿ”ง Synchronizing content table (single-table for all types)...[/]") + await Content.sync_table() + await Article.sync_table() + await Video.sync_table() + await Podcast.sync_table() + console.print("[dim green]โœ“ Database ready.[/]") + console.print() + + # --- Generate content items --- + items: list[Content] = [] + for _ in range(count): + gen = random.choice(GENERATORS) + items.append(gen()) + + # --- Insert with type-coloured progress --- + counts: dict[str, int] = {"article": 0, "video": 0, "podcast": 0} + + with Progress( + SpinnerColumn(), + TextColumn("[bold #8b5cf6]๐ŸŽญ MORPH-IX[/]"), + BarColumn(bar_width=40, complete_style="#8b5cf6", finished_style="#14b8a6"), + MofNCompleteColumn(), + TimeRemainingColumn(), + console=console, + ) as progress: + task = progress.add_task("Generating content...", total=count) + for item in items: + await item.save() + disc = item.content_type + if not disc: + disc = type(item).__name__.lower() + counts[disc] = counts.get(disc, 0) + 1 + style = TYPE_STYLES.get(disc, TYPE_STYLES["article"]) + progress.update( + task, + advance=1, + description=f'[{style["color"]}]{style["icon"]} {style["label"]}[/] "{item.title[:40]}"', + ) + + # --- Summary table --- + console.print() + table = Table( + title="[bold #8b5cf6]๐ŸŽญ SHAPESHIFTER'S ARCHIVE โ€” Content Manifest[/]", + border_style="#8b5cf6", + title_style="bold #8b5cf6", + ) + table.add_column("Content Type", style="bold") + table.add_column("Count", justify="right", style="green") + table.add_column("Discriminator", style="dim") + table.add_row( + "[#f97316]๐Ÿ“ฐ Articles[/]", + str(counts.get("article", 0)), + "content_type = 'article'", + ) + table.add_row( + "[#8b5cf6]๐ŸŽฌ Videos[/]", + str(counts.get("video", 0)), + "content_type = 'video'", + ) + table.add_row( + "[#14b8a6]๐ŸŽ™๏ธ Podcasts[/]", + str(counts.get("podcast", 0)), + "content_type = 'podcast'", + ) + table.add_row( + "[bold]Total[/]", + f"[bold]{sum(counts.values())}[/]", + "(single table!)", + ) + console.print(table) + console.print() + console.print("[bold #8b5cf6]๐ŸŽญ The Shapeshifter's Archive has been sealed. All content forms are classified.[/]") + console.print("[dim] Launch the app with: uv run uvicorn main:app --reload[/]") + console.print() + + +@click.command() +@click.option("--count", default=30, help="Number of content items to generate") +def seed(count: int) -> None: + """Seed the Shapeshifter's Archive with polymorphic content.""" + asyncio.run(_seed(count)) + + +if __name__ == "__main__": + seed() diff --git a/demos/polymorphic-cms/templates/base.html b/demos/polymorphic-cms/templates/base.html new file mode 100644 index 00000000..3b07c560 --- /dev/null +++ b/demos/polymorphic-cms/templates/base.html @@ -0,0 +1,378 @@ + + + + + + ๐ŸŽญ The Shapeshifter's Archive โ€” Dimension-11 + + + + +
+
+

๐ŸŽญ The Shapeshifter's Archive

+ Dimension-11 // Morph-IX +
+
+
+ {% block content %}{% endblock %} +
+
+ coodie ยท Pydantic-native Cassandra ORM ยท Single-Table Inheritance via Discriminator +
+ + + + diff --git a/demos/polymorphic-cms/templates/index.html b/demos/polymorphic-cms/templates/index.html new file mode 100644 index 00000000..c72a66c6 --- /dev/null +++ b/demos/polymorphic-cms/templates/index.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block content %} + + +
+
๐ŸŽญ Mission Briefing โ€” Dimension-11
+ In Dimension-11, SCYLLA-9's fragment became Morph-IX, an AI that can take + the form of any content type. It publishes articles that turn into videos mid-sentence, + podcasts that become blog posts when you pause them, and memes that evolve into + peer-reviewed papers. All content is stored in a single table with a + discriminator column โ€” because Morph-IX believes all content is one. + The Coodie Corps must classify and contain each form before the archive + collapses into a singularity of undifferentiated content. +
+ +
+ +
+
+

๐ŸŽญ Create Content

+
+ + + + + + +
+
+
+
+ + +
+ + + + +
+ + +
+ + +
+
+
+ +
+
+
+ + +
+ + +
+
+
+
+
+ + +
+
+ +
+

๐Ÿ“š Content Archive

+ +
+ + + + +
+
+
Scanning polymorphic frequenciesโ€ฆ
+
+
+
+ + +
+
+
Select a content item to view its full form โ€” if it holds still long enough
+
+
+
+{% endblock %} diff --git a/demos/polymorphic-cms/templates/partials/content_detail.html b/demos/polymorphic-cms/templates/partials/content_detail.html new file mode 100644 index 00000000..e379c32c --- /dev/null +++ b/demos/polymorphic-cms/templates/partials/content_detail.html @@ -0,0 +1,60 @@ +
+

+ {% if item.content_type == 'article' %}๐Ÿ“ฐ{% elif item.content_type == 'video' %}๐ŸŽฌ{% elif item.content_type == 'podcast' %}๐ŸŽ™๏ธ{% else %}๐ŸŽญ{% endif %} + Content Detail +

+
+ {{ item.title }} +
+ +
+
Content Type + + + {% if item.content_type == 'article' %}๐Ÿ“ฐ Article{% elif item.content_type == 'video' %}๐ŸŽฌ Video{% elif item.content_type == 'podcast' %}๐ŸŽ™๏ธ Podcast{% else %}๐ŸŽญ {{ item.content_type }}{% endif %} + + +
+
Author {{ item.author }}
+
Status + + {% if item.published %} + Published + {% else %} + Draft + {% endif %} + +
+
Created {{ item.created_at.strftime("%Y-%m-%d %H:%M") }} UTC
+
+ + {% if item.summary %}

{{ item.summary }}

{% endif %} + + + {% if item.content_type == 'article' %} +
+
+
Word Count {{ item.word_count }} words
+
+ {% if item.body %}

{{ item.body[:500] }}{% if item.body|length > 500 %}โ€ฆ{% endif %}

{% endif %} +
+ {% elif item.content_type == 'video' %} +
+
+
Duration {{ (item.duration_seconds // 60) }}m {{ item.duration_seconds % 60 }}s
+
Resolution {{ item.resolution }}
+
+ {% if item.video_url %}{% endif %} +
+ {% elif item.content_type == 'podcast' %} +
+
+
Episode #{{ item.episode_number }}
+
Duration {{ (item.duration_seconds // 60) }}m {{ item.duration_seconds % 60 }}s
+
+ {% if item.audio_url %}{% endif %} +
+ {% endif %} + +
CONTENT-ID: {{ item.id }} ยท DISCRIMINATOR: {{ item.content_type }}
+
diff --git a/demos/polymorphic-cms/templates/partials/content_list.html b/demos/polymorphic-cms/templates/partials/content_list.html new file mode 100644 index 00000000..7bf41741 --- /dev/null +++ b/demos/polymorphic-cms/templates/partials/content_list.html @@ -0,0 +1,31 @@ +{% if items %} +{% for item in items %} +
+
+
{{ item.title }}
+ + {% if item.summary %}
{{ item.summary[:80] }}{% if item.summary|length > 80 %}โ€ฆ{% endif %}
{% endif %} +
+
+
{{ item.created_at.strftime("%Y-%m-%d") }}
+ +
+
+{% endfor %} +{% else %} +
No content detected โ€” create some above or run seed.py to populate the Archive
+{% endif %} diff --git a/src/coodie/aio/document.py b/src/coodie/aio/document.py index ee78d5e8..0dd92240 100644 --- a/src/coodie/aio/document.py +++ b/src/coodie/aio/document.py @@ -91,7 +91,7 @@ def _get_driver(cls) -> Any: @classmethod def _schema(cls) -> list[ColumnDefinition]: - if not hasattr(cls, "__schema__") or cls.__schema__ is None: + if "__schema__" not in cls.__dict__ or cls.__schema__ is None: cls.__schema__ = build_schema(cls) return cls.__schema__ diff --git a/src/coodie/sync/document.py b/src/coodie/sync/document.py index 1165211c..522011c1 100644 --- a/src/coodie/sync/document.py +++ b/src/coodie/sync/document.py @@ -91,7 +91,7 @@ def _get_driver(cls) -> Any: @classmethod def _schema(cls) -> list[ColumnDefinition]: - if not hasattr(cls, "__schema__") or cls.__schema__ is None: + if "__schema__" not in cls.__dict__ or cls.__schema__ is None: cls.__schema__ = build_schema(cls) return cls.__schema__