feat: Added MinIO Docker service, Post/PostAttachment models with migra…

- "docker-compose.yml"
- "backend/config.py"
- "backend/minio_client.py"
- "backend/models.py"
- "backend/schemas.py"
- "backend/requirements.txt"
- "docker/nginx.conf"
- "alembic/versions/024_add_posts_and_attachments.py"

GSD-Task: S01/T01
This commit is contained in:
jlightner 2026-04-04 09:02:40 +00:00
parent 73736295c1
commit c163037a0f
8 changed files with 278 additions and 2 deletions

View file

@ -0,0 +1,44 @@
"""Add posts and post_attachments tables.
Revision ID: 024_add_posts_and_attachments
Revises: 023_add_personality_profile
"""
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID
from alembic import op
revision = "024_add_posts_and_attachments"
down_revision = "023_add_personality_profile"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"posts",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
sa.Column("creator_id", UUID(as_uuid=True), sa.ForeignKey("creators.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("body_json", JSONB, nullable=False),
sa.Column("is_published", sa.Boolean, nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
)
op.create_table(
"post_attachments",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
sa.Column("post_id", UUID(as_uuid=True), sa.ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("filename", sa.String(500), nullable=False),
sa.Column("object_key", sa.String(1000), nullable=False),
sa.Column("content_type", sa.String(255), nullable=False),
sa.Column("size_bytes", sa.BigInteger, nullable=False),
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("post_attachments")
op.drop_table("posts")

View file

@ -71,6 +71,13 @@ class Settings(BaseSettings):
# Debug mode — when True, pipeline captures full LLM prompts and responses # Debug mode — when True, pipeline captures full LLM prompts and responses
debug_mode: bool = False debug_mode: bool = False
# MinIO (file storage for post attachments)
minio_url: str = "chrysopedia-minio:9000"
minio_access_key: str = "chrysopedia"
minio_secret_key: str = "changeme-minio"
minio_bucket: str = "chrysopedia"
minio_secure: bool = False
# File storage # File storage
transcript_storage_path: str = "/data/transcripts" transcript_storage_path: str = "/data/transcripts"
video_metadata_path: str = "/data/video_meta" video_metadata_path: str = "/data/video_meta"

104
backend/minio_client.py Normal file
View file

@ -0,0 +1,104 @@
"""MinIO client singleton with lazy initialization.
Provides file upload, presigned download URL generation, and automatic
bucket creation for the Chrysopedia post attachment storage.
"""
from __future__ import annotations
import io
import logging
from datetime import timedelta
from minio import Minio
from minio.error import S3Error
from config import get_settings
logger = logging.getLogger(__name__)
_client: Minio | None = None
_bucket_ensured: bool = False
def get_minio_client() -> Minio:
"""Return the singleton MinIO client, creating it on first call."""
global _client
if _client is None:
settings = get_settings()
_client = Minio(
settings.minio_url,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_secure,
)
logger.info("MinIO client initialized (endpoint=%s)", settings.minio_url)
return _client
def ensure_bucket() -> None:
"""Create the configured bucket if it doesn't already exist."""
global _bucket_ensured
if _bucket_ensured:
return
settings = get_settings()
client = get_minio_client()
bucket = settings.minio_bucket
try:
if not client.bucket_exists(bucket):
client.make_bucket(bucket)
logger.info("Created MinIO bucket: %s", bucket)
else:
logger.debug("MinIO bucket already exists: %s", bucket)
_bucket_ensured = True
except S3Error as exc:
logger.error("MinIO bucket check/create failed: %s", exc)
raise
def upload_file(
object_key: str,
data: bytes | io.BytesIO,
length: int,
content_type: str = "application/octet-stream",
) -> None:
"""Upload a file to MinIO.
Args:
object_key: The storage path within the bucket.
data: File content as bytes or BytesIO stream.
length: Size in bytes.
content_type: MIME type for the object.
"""
ensure_bucket()
settings = get_settings()
client = get_minio_client()
stream = io.BytesIO(data) if isinstance(data, bytes) else data
client.put_object(
settings.minio_bucket,
object_key,
stream,
length,
content_type=content_type,
)
logger.info("Uploaded %s (%d bytes, %s)", object_key, length, content_type)
def generate_download_url(object_key: str, expires: int = 3600) -> str:
"""Generate a presigned GET URL for downloading a file.
Args:
object_key: The storage path within the bucket.
expires: URL validity in seconds (default 1 hour).
Returns:
Presigned URL string.
"""
settings = get_settings()
client = get_minio_client()
url: str = client.presigned_get_object(
settings.minio_bucket,
object_key,
expires=timedelta(seconds=expires),
)
return url

View file

@ -12,6 +12,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import ( from sqlalchemy import (
BigInteger,
Boolean, Boolean,
Enum, Enum,
Float, Float,
@ -144,6 +145,7 @@ class Creator(Base):
# relationships # relationships
videos: Mapped[list[SourceVideo]] = sa_relationship(back_populates="creator") videos: Mapped[list[SourceVideo]] = sa_relationship(back_populates="creator")
technique_pages: Mapped[list[TechniquePage]] = sa_relationship(back_populates="creator") technique_pages: Mapped[list[TechniquePage]] = sa_relationship(back_populates="creator")
posts: Mapped[list[Post]] = sa_relationship(back_populates="creator")
class User(Base): class User(Base):
@ -763,3 +765,52 @@ class CreatorFollow(Base):
# relationships # relationships
user: Mapped[User] = sa_relationship() user: Mapped[User] = sa_relationship()
creator: Mapped[Creator] = sa_relationship() creator: Mapped[Creator] = sa_relationship()
# ── Posts (Creator content feed) ─────────────────────────────────────────────
class Post(Base):
"""A rich text post by a creator, optionally with file attachments."""
__tablename__ = "posts"
id: Mapped[uuid.UUID] = _uuid_pk()
creator_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("creators.id", ondelete="CASCADE"), nullable=False, index=True,
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
body_json: Mapped[dict] = mapped_column(JSONB, nullable=False)
is_published: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false",
)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now(), onupdate=_now
)
# relationships
creator: Mapped[Creator] = sa_relationship(back_populates="posts")
attachments: Mapped[list[PostAttachment]] = sa_relationship(
back_populates="post", cascade="all, delete-orphan"
)
class PostAttachment(Base):
"""A file attachment on a post, stored in MinIO."""
__tablename__ = "post_attachments"
id: Mapped[uuid.UUID] = _uuid_pk()
post_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True,
)
filename: Mapped[str] = mapped_column(String(500), nullable=False)
object_key: Mapped[str] = mapped_column(String(1000), nullable=False)
content_type: Mapped[str] = mapped_column(String(255), nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)
# relationships
post: Mapped[Post] = sa_relationship(back_populates="attachments")

View file

@ -17,6 +17,7 @@ psycopg2-binary>=2.9,<3.0
watchdog>=4.0,<5.0 watchdog>=4.0,<5.0
PyJWT>=2.8,<3.0 PyJWT>=2.8,<3.0
bcrypt>=4.0,<6.0 bcrypt>=4.0,<6.0
minio>=7.2,<8.0
# Test dependencies # Test dependencies
pytest>=8.0,<10.0 pytest>=8.0,<10.0
pytest-asyncio>=0.24,<1.0 pytest-asyncio>=0.24,<1.0

View file

@ -769,3 +769,51 @@ class PersonalityProfile(BaseModel):
tone: ToneProfile = Field(default_factory=ToneProfile) tone: ToneProfile = Field(default_factory=ToneProfile)
style_markers: StyleMarkersProfile = Field(default_factory=StyleMarkersProfile) style_markers: StyleMarkersProfile = Field(default_factory=StyleMarkersProfile)
summary: str = "" summary: str = ""
# ── Posts (Creator content feed) ─────────────────────────────────────────────
class PostAttachmentRead(BaseModel):
"""Read schema for a file attachment on a post."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
filename: str
content_type: str
size_bytes: int
download_url: str | None = None
created_at: datetime
class PostCreate(BaseModel):
"""Create a new post."""
title: str = Field(..., min_length=1, max_length=500)
body_json: dict
is_published: bool = False
class PostUpdate(BaseModel):
"""Partial update for an existing post."""
title: str | None = Field(None, min_length=1, max_length=500)
body_json: dict | None = None
is_published: bool | None = None
class PostRead(BaseModel):
"""Full post with attachments."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
creator_id: uuid.UUID
title: str
body_json: dict
is_published: bool = False
created_at: datetime
updated_at: datetime
attachments: list[PostAttachmentRead] = Field(default_factory=list)
class PostListResponse(BaseModel):
"""Paginated list of posts."""
items: list[PostRead] = Field(default_factory=list)
total: int = 0

View file

@ -204,6 +204,27 @@ services:
start_period: 15s start_period: 15s
stop_grace_period: 15s stop_grace_period: 15s
# ── MinIO (file storage for post attachments) ──
chrysopedia-minio:
image: minio/minio
container_name: chrysopedia-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-chrysopedia}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-changeme-minio}
volumes:
- /vmPool/r/services/chrysopedia_minio:/data
networks:
- chrysopedia
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
stop_grace_period: 15s
# ── React web UI (nginx) ── # ── React web UI (nginx) ──
chrysopedia-web: chrysopedia-web:
build: build:

View file

@ -8,8 +8,8 @@ server {
# after container recreates # after container recreates
resolver 127.0.0.11 valid=30s ipv6=off; resolver 127.0.0.11 valid=30s ipv6=off;
# Allow large transcript uploads (up to 50MB) # Allow large file uploads (up to 100MB)
client_max_body_size 50m; client_max_body_size 100m;
# SPA fallback # SPA fallback
location / { location / {