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:
parent
73736295c1
commit
c163037a0f
8 changed files with 278 additions and 2 deletions
44
alembic/versions/024_add_posts_and_attachments.py
Normal file
44
alembic/versions/024_add_posts_and_attachments.py
Normal 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")
|
||||||
|
|
@ -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
104
backend/minio_client.py
Normal 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
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 / {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue