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.orm 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
|
|
||||||
)
|
|
||||||
|
|
||||||
Base = 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():
|
async def get_db():
|
||||||
|
"""Dependency for FastAPI routes."""
|
||||||
|
AsyncSessionLocal = get_async_session()
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Responde SOLO con el JSON, sin texto adicional."""
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
message = self.client.messages.create(
|
message = self.client.messages.create(
|
||||||
model="claude-3-5-sonnet-20241022",
|
model="claude-3-haiku-20240307",
|
||||||
max_tokens=2000,
|
max_tokens=2000,
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt}
|
||||||
|
|||||||
@@ -32,6 +32,6 @@ python-dotenv==1.0.0
|
|||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
pytest==8.0.0
|
pytest>=7.0.0,<8.0.0
|
||||||
pytest-asyncio==0.23.4
|
pytest-asyncio==0.23.4
|
||||||
httpx==0.26.0
|
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