Skip to content

Commit 647b47f

Browse files
medevsclaude
andcommitted
Add PostgreSQL models, Alembic migrations, and Celery tasks
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 97ad5b9 commit 647b47f

10 files changed

Lines changed: 463 additions & 0 deletions

File tree

backend/alembic.ini

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# A generic, single database configuration.
2+
3+
[alembic]
4+
# path to migration scripts
5+
# Use absolute path relative to this file location
6+
script_location = %(here)s/alembic
7+
8+
# template used to generate migration file
9+
# file_template = %%(rev)s_%%(slug)s
10+
11+
# timezone to use when rendering the date within the migration file
12+
# as well as the filename.
13+
# If string_type is specified, this will be used as the format string.
14+
# timezone = UTC
15+
16+
# max length of characters to apply to the
17+
# "slug" field
18+
# truncate_slug_length = 40
19+
20+
# set to 'true' to run the environment during
21+
# the 'revision' command, regardless of autogenerate
22+
# revision_environment = false
23+
24+
# set to 'true' to allow .pyc and .pyo files without
25+
# a source .py file to be detected as revisions in the
26+
# versions/ directory
27+
# sourceless = false
28+
29+
# version location specification; this defaults
30+
# to alembic/versions. When using multiple version
31+
# directories, initial revisions must be specified with --version-path.
32+
# The path separator used here should be the separator specified by "version_path_separator"
33+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
34+
35+
# version path separator; As mentioned above, this is the character used to split
36+
# version_locations. The default within new alembic is "os", which uses os.pathsep.
37+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
38+
# Valid values for version_path_separator are:
39+
#
40+
# version_path_separator = :
41+
# version_path_separator = ;
42+
# version_path_separator = space
43+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
44+
45+
# set to 'true' to search inside of source files recursively
46+
# for revisions
47+
# recursive_version_locations = false
48+
49+
# the output encoding used when revision files
50+
# are written from script.py.mako
51+
# output_encoding = utf-8
52+
53+
sqlalchemy.url = postgresql://portfolio:portfolio_secret@postgres:5432/portfolio
54+
55+
56+
[post_write_hooks]
57+
# post_write_hooks defines scripts or Python functions that are run
58+
# on newly generated revision scripts. See the documentation for further
59+
# detail and examples
60+
61+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
62+
# hooks = black
63+
# black.type = console_scripts
64+
# black.entrypoint = black
65+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
66+
67+
# Logging configuration
68+
[loggers]
69+
keys = root,sqlalchemy,alembic
70+
71+
[handlers]
72+
keys = console
73+
74+
[formatters]
75+
keys = generic
76+
77+
[logger_root]
78+
level = WARN
79+
handlers = console
80+
qualname =
81+
82+
[logger_sqlalchemy]
83+
level = WARN
84+
handlers = console
85+
qualname = sqlalchemy.engine
86+
87+
[logger_alembic]
88+
level = INFO
89+
handlers = console
90+
qualname = alembic
91+
92+
[handler_console]
93+
class = StreamHandler
94+
args = (sys.stderr,)
95+
level = NOTSET
96+
formatter = generic
97+
98+
[formatter_generic]
99+
format = %(levelname)-5.5s [%(name)s] %(message)s
100+
datefmt = %H:%M:%S

backend/alembic/env.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# A generic env.py for Alembic migrations
2+
from logging.config import fileConfig
3+
4+
from sqlalchemy import engine_from_config
5+
from sqlalchemy import pool
6+
7+
from alembic import context
8+
import os
9+
import sys
10+
11+
# Add parent directory to path so we can import app modules
12+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13+
14+
# Import your models here so Alembic can see them
15+
from app.db.database import Base
16+
from app.db.models import Conversation, Message, Document, UploadedFile
17+
18+
# this is the Alembic Config object, which provides
19+
# access to the values within the .ini file in use.
20+
config = context.config
21+
22+
# Interpret the config file for Python logging.
23+
# This line sets up loggers basically.
24+
if config.config_file_name is not None:
25+
fileConfig(config.config_file_name)
26+
27+
# add your model's MetaData object here
28+
# for 'autogenerate' support
29+
target_metadata = Base.metadata
30+
31+
# Database URL from env or fallback
32+
db_url = os.getenv(
33+
"DATABASE_URL",
34+
"postgresql://portfolio:portfolio_secret@postgres:5432/portfolio"
35+
)
36+
# Convert asyncpg URL to psycopg2 for Alembic
37+
if "asyncpg" in db_url:
38+
db_url = db_url.replace("+asyncpg", "")
39+
40+
config.set_main_option("sqlalchemy.url", db_url)
41+
42+
def run_migrations_offline() -> None:
43+
"""Run migrations in 'offline' mode.
44+
45+
This configures the context with just a URL
46+
and not an Engine, though an Engine is acceptable
47+
here as well. By skipping the Engine creation
48+
we don't even need a DBAPI to be available.
49+
50+
Calls to context.execute() here emit the given string to the
51+
script output.
52+
53+
"""
54+
url = config.get_main_option("sqlalchemy.url")
55+
context.configure(
56+
url=url,
57+
target_metadata=target_metadata,
58+
literal_binds=True,
59+
dialect_opts={"paramstyle": "named"},
60+
)
61+
62+
with context.begin_transaction():
63+
context.run_migrations()
64+
65+
66+
def run_migrations_online() -> None:
67+
"""Run migrations in 'online' mode.
68+
69+
In this scenario we need to create an Engine
70+
and associate a connection with the context.
71+
72+
"""
73+
connectable = engine_from_config(
74+
config.get_section(config.config_ini_section, {}),
75+
prefix="sqlalchemy.",
76+
poolclass=pool.NullPool,
77+
)
78+
79+
with connectable.connect() as connection:
80+
context.configure(
81+
connection=connection, target_metadata=target_metadata
82+
)
83+
84+
with context.begin_transaction():
85+
context.run_migrations()
86+
87+
88+
if context.is_offline_mode():
89+
run_migrations_offline()
90+
else:
91+
run_migrations_online()

backend/alembic/script.py.mako

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
${imports if imports else ""}
11+
12+
# revision identifiers, used by Alembic.
13+
revision = ${repr(up_revision)}
14+
down_revision = ${repr(down_revision)}
15+
branch_labels = ${repr(branch_labels)}
16+
depends_on = ${repr(depends_on)}
17+
18+
19+
def upgrade() -> None:
20+
${upgrades if upgrades else "pass"}
21+
22+
23+
def downgrade() -> None:
24+
${downgrades if downgrades else "pass"}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Initial schema with all tables
2+
3+
Revision ID: 001_initial
4+
Revises:
5+
Create Date: 2024-12-13
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from sqlalchemy.dialects import postgresql
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '001_initial'
14+
down_revision = None
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
# Create conversations table
21+
op.create_table('conversations',
22+
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
23+
sa.Column('title', sa.String(255), nullable=True),
24+
sa.Column('session_id', sa.String(255), nullable=True),
25+
sa.Column('created_at', sa.DateTime(), nullable=True),
26+
sa.Column('updated_at', sa.DateTime(), nullable=True),
27+
sa.PrimaryKeyConstraint('id')
28+
)
29+
op.create_index('ix_conversations_session_id', 'conversations', ['session_id'], unique=False)
30+
31+
# Create messages table
32+
op.create_table('messages',
33+
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
34+
sa.Column('conversation_id', postgresql.UUID(as_uuid=True), nullable=False),
35+
sa.Column('role', sa.String(20), nullable=False),
36+
sa.Column('content', sa.Text(), nullable=False),
37+
sa.Column('tokens_used', sa.Integer(), nullable=True),
38+
sa.Column('latency_ms', sa.Integer(), nullable=True),
39+
sa.Column('created_at', sa.DateTime(), nullable=True),
40+
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ),
41+
sa.PrimaryKeyConstraint('id')
42+
)
43+
44+
# Create documents table (for RAG chunks)
45+
op.create_table('documents',
46+
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
47+
sa.Column('title', sa.String(255), nullable=True),
48+
sa.Column('source', sa.String(255), nullable=True),
49+
sa.Column('created_at', sa.DateTime(), nullable=True),
50+
sa.Column('content', sa.Text(), nullable=True),
51+
sa.Column('tsv', postgresql.TSVECTOR(), nullable=True),
52+
sa.PrimaryKeyConstraint('id')
53+
)
54+
op.create_index('idx_docs_tsv', 'documents', ['tsv'], unique=False, postgresql_using='gin')
55+
56+
# Create uploaded_files table
57+
op.create_table('uploaded_files',
58+
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
59+
sa.Column('filename', sa.String(255), nullable=False),
60+
sa.Column('file_type', sa.String(50), nullable=False),
61+
sa.Column('file_size', sa.Integer(), nullable=True),
62+
sa.Column('chunk_count', sa.Integer(), nullable=True),
63+
sa.Column('status', sa.String(20), nullable=True),
64+
sa.Column('error_message', sa.Text(), nullable=True),
65+
sa.Column('created_at', sa.DateTime(), nullable=True),
66+
sa.Column('processed_at', sa.DateTime(), nullable=True),
67+
sa.PrimaryKeyConstraint('id')
68+
)
69+
70+
71+
def downgrade() -> None:
72+
op.drop_table('uploaded_files')
73+
op.drop_index('idx_docs_tsv', table_name='documents', postgresql_using='gin')
74+
op.drop_table('documents')
75+
op.drop_table('messages')
76+
op.drop_index('ix_conversations_session_id', table_name='conversations')
77+
op.drop_table('conversations')

backend/app/db/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Database models and utilities."""
2+
from .database import Base, get_db, AsyncSessionLocal, engine
3+
from .models import Conversation, Message, Document, UploadedFile
4+
5+
__all__ = [
6+
"Base",
7+
"get_db",
8+
"AsyncSessionLocal",
9+
"engine",
10+
"Conversation",
11+
"Message",
12+
"Document",
13+
"UploadedFile"
14+
]

backend/app/db/database.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
2+
from sqlalchemy.orm import sessionmaker, declarative_base
3+
import os
4+
5+
# Database URL from environment variable
6+
DATABASE_URL = os.getenv(
7+
"DATABASE_URL",
8+
"postgresql+asyncpg://portfolio:portfolio_secret@postgres:5432/portfolio"
9+
)
10+
11+
# Create async engine
12+
engine = create_async_engine(DATABASE_URL, echo=False)
13+
14+
# Create async session factory
15+
AsyncSessionLocal = sessionmaker(
16+
engine, class_=AsyncSession, expire_on_commit=False
17+
)
18+
19+
# Declarative base for models
20+
Base = declarative_base()
21+
22+
async def get_db():
23+
"""Dependency for getting async database session."""
24+
async with AsyncSessionLocal() as session:
25+
try:
26+
yield session
27+
finally:
28+
await session.close()

0 commit comments

Comments
 (0)