feat(phase1): Complete development environment configuration
- Configure Alembic for database migrations with initial schema - Fix base.py to use lazy loading for async engine (avoids import-time issues) - Change AI model from sonnet to haiku (claude-3-haiku-20240307) - Fix pytest version compatibility with pytest-asyncio - Add frontend package-lock.json Phase 1 tasks completed: - F1.1: Development environment (Docker, Node.js 20, Python 3.12, venv) - F1.2: PostgreSQL with 8 categories seeded - F1.3: Redis connection verified - F1.4: Anthropic API configured and tested - F1.5: Backend server + WebSocket verified Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
42
backend/alembic.ini
Normal file
42
backend/alembic.ini
Normal file
@@ -0,0 +1,42 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url = postgresql://trivia:trivia@localhost:5432/trivia
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
52
backend/alembic/env.py
Normal file
52
backend/alembic/env.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the backend directory to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models import Category, Question, GameSession, GameEvent, Admin
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
108
backend/alembic/versions/65d30b7402cf_initial_schema.py
Normal file
108
backend/alembic/versions/65d30b7402cf_initial_schema.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 65d30b7402cf
|
||||
Revises:
|
||||
Create Date: 2026-01-26 08:04:31.725883
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '65d30b7402cf'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('admins',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=100), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_admins_id'), 'admins', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_admins_username'), 'admins', ['username'], unique=True)
|
||||
op.create_table('categories',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('icon', sa.String(length=50), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_index(op.f('ix_categories_id'), 'categories', ['id'], unique=False)
|
||||
op.create_table('game_sessions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('room_code', sa.String(length=6), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=True),
|
||||
sa.Column('team_a_score', sa.Integer(), nullable=True),
|
||||
sa.Column('team_b_score', sa.Integer(), nullable=True),
|
||||
sa.Column('current_team', sa.String(length=1), nullable=True),
|
||||
sa.Column('questions_used', sa.ARRAY(sa.Integer()), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_game_sessions_id'), 'game_sessions', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_game_sessions_room_code'), 'game_sessions', ['room_code'], unique=True)
|
||||
op.create_table('questions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||
sa.Column('question_text', sa.Text(), nullable=False),
|
||||
sa.Column('correct_answer', sa.String(length=500), nullable=False),
|
||||
sa.Column('alt_answers', sa.ARRAY(sa.String()), nullable=True),
|
||||
sa.Column('difficulty', sa.Integer(), nullable=False),
|
||||
sa.Column('points', sa.Integer(), nullable=False),
|
||||
sa.Column('time_seconds', sa.Integer(), nullable=False),
|
||||
sa.Column('date_active', sa.Date(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=True),
|
||||
sa.Column('fun_fact', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_questions_date_active'), 'questions', ['date_active'], unique=False)
|
||||
op.create_index(op.f('ix_questions_id'), 'questions', ['id'], unique=False)
|
||||
op.create_table('game_events',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('session_id', sa.Integer(), nullable=False),
|
||||
sa.Column('event_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('player_name', sa.String(length=100), nullable=True),
|
||||
sa.Column('team', sa.String(length=1), nullable=True),
|
||||
sa.Column('question_id', sa.Integer(), nullable=True),
|
||||
sa.Column('answer_given', sa.Text(), nullable=True),
|
||||
sa.Column('was_correct', sa.Boolean(), nullable=True),
|
||||
sa.Column('was_steal', sa.Boolean(), nullable=True),
|
||||
sa.Column('points_earned', sa.Integer(), nullable=True),
|
||||
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['game_sessions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_game_events_id'), 'game_events', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_game_events_id'), table_name='game_events')
|
||||
op.drop_table('game_events')
|
||||
op.drop_index(op.f('ix_questions_id'), table_name='questions')
|
||||
op.drop_index(op.f('ix_questions_date_active'), table_name='questions')
|
||||
op.drop_table('questions')
|
||||
op.drop_index(op.f('ix_game_sessions_room_code'), table_name='game_sessions')
|
||||
op.drop_index(op.f('ix_game_sessions_id'), table_name='game_sessions')
|
||||
op.drop_table('game_sessions')
|
||||
op.drop_index(op.f('ix_categories_id'), table_name='categories')
|
||||
op.drop_table('categories')
|
||||
op.drop_index(op.f('ix_admins_username'), table_name='admins')
|
||||
op.drop_index(op.f('ix_admins_id'), table_name='admins')
|
||||
op.drop_table('admins')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,25 +1,32 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Convert postgresql:// to postgresql+asyncpg://
|
||||
database_url = settings.database_url.replace(
|
||||
"postgresql://", "postgresql+asyncpg://"
|
||||
)
|
||||
|
||||
engine = create_async_engine(database_url, echo=True)
|
||||
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_async_engine():
|
||||
"""Create async engine on demand to avoid import-time config issues."""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
database_url = settings.database_url.replace(
|
||||
"postgresql://", "postgresql+asyncpg://"
|
||||
)
|
||||
return create_async_engine(database_url, echo=True)
|
||||
|
||||
|
||||
def get_async_session():
|
||||
"""Create async session factory."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
engine = get_async_engine()
|
||||
return sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""Dependency for FastAPI routes."""
|
||||
AsyncSessionLocal = get_async_session()
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
|
||||
@@ -63,7 +63,7 @@ Responde SOLO con el JSON, sin texto adicional."""
|
||||
|
||||
try:
|
||||
message = self.client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
model="claude-3-haiku-20240307",
|
||||
max_tokens=2000,
|
||||
messages=[
|
||||
{"role": "user", "content": prompt}
|
||||
|
||||
@@ -32,6 +32,6 @@ python-dotenv==1.0.0
|
||||
apscheduler==3.10.4
|
||||
|
||||
# Testing
|
||||
pytest==8.0.0
|
||||
pytest>=7.0.0,<8.0.0
|
||||
pytest-asyncio==0.23.4
|
||||
httpx==0.26.0
|
||||
|
||||
4412
frontend/package-lock.json
generated
Normal file
4412
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user