Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
submodules: true
# - run: sudo apt-get update && sudo apt-get install -y qemu-user-static
- name: Setup Nix
uses: cachix/install-nix-action@V27
uses: cachix/install-nix-action@v27
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
extra_nix_config: |
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,35 @@ poe migrate # run database migrations
poe env # show settings from .env file
poe jwt # generate a jwt with the given payload and ttl in seconds
poe check # check course definitions
poe sync_skills # push local skills to backend (deprecated)
poe sync_skills # sync local YAML skills with a running skills service
```

## Skill Synchronization

The `poe sync_skills` helper wraps `python scripts/sync_skills.py` and can both push local YAML files to a running skills
service and export the live tree to disk. The script automatically creates a short-lived admin JWT using the local
`JWT_SECRET`, so no manual token handling is needed for the standard development stack.

- Push local definitions (default host comes from `PUBLIC_BASE_URL` in `.env`):
```bash
poe sync_skills -- config/skills
```
- Target another host:
```bash
poe sync_skills -- --host http://localhost:8001 config/skills
```
- Re-use an existing JWT:
```bash
poe sync_skills -- --token "Bearer <token>" config/skills
```
- Export the live skill tree into YAML files (pass `--overwrite` to replace existing files):
```bash
poe sync_skills -- --pull --overwrite config/skills
```

Pass `--help` to view the full list of options, including `--token-file`, `--token-ttl`, and `--no-auth` for unsecured test
instances.

## PyCharm configuration
Configure the Python interpreter:

Expand Down
171 changes: 171 additions & 0 deletions api/database/challenges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
from __future__ import annotations

from contextlib import asynccontextmanager
from typing import Any, AsyncIterator

from sqlalchemy import Boolean, Column, DateTime, Integer, MetaData, String, Table
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.sql import Select

from api.settings import settings


metadata = MetaData()

challenges_course_tasks = Table(
"challenges_course_tasks",
metadata,
Column("task_id", String(36), primary_key=True),
Column("course_id", String(256), nullable=False),
Column("section_id", String(256), nullable=True),
Column("lecture_id", String(256), nullable=True),
)

challenges_subtasks = Table(
"challenges_subtasks",
metadata,
Column("id", String(36), primary_key=True),
Column("task_id", String(36), nullable=False),
Column("creator", String(36), nullable=False),
Column("creation_timestamp", DateTime(timezone=True), nullable=False),
Column("xp", Integer, nullable=False, default=0),
Column("coins", Integer, nullable=False, default=0),
Column("enabled", Boolean, nullable=False, default=True),
Column("ty", String(64), nullable=False),
Column("retired", Boolean, nullable=False, default=False),
)

challenges_user_subtasks = Table(
"challenges_user_subtasks",
metadata,
Column("user_id", String(36), primary_key=True),
Column("subtask_id", String(36), primary_key=True),
Column("solved_timestamp", DateTime(timezone=True), nullable=True),
Column("rating", String(32), nullable=True),
Column("rating_timestamp", DateTime(timezone=True), nullable=True),
Column("last_attempt_timestamp", DateTime(timezone=True), nullable=True),
Column("attempts", Integer, nullable=False, default=0),
)

_engine: AsyncEngine | None = None
_sessionmaker: sessionmaker[AsyncSession] | None = None


def _ensure_engine() -> AsyncEngine | None:
"""Initialise the challenges database engine if configured."""

url = settings.challenges_database_url
if not url:
return None

global _engine, _sessionmaker
if _engine is None:
url_info = make_url(url)
engine_kwargs: dict[str, Any] = {"pool_pre_ping": True, "echo": settings.sql_show_statements}
if url_info.get_backend_name().startswith("sqlite"):
# SQLite (used in tests) does not support these pooling options.
engine_kwargs["connect_args"] = {"check_same_thread": False}
else:
engine_kwargs.update(
pool_recycle=settings.pool_recycle, pool_size=settings.pool_size, max_overflow=settings.max_overflow
)

_engine = create_async_engine(url, **engine_kwargs)
_sessionmaker = sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)

return _engine


def challenges_configured() -> bool:
"""Return whether a challenges database connection string is configured."""

return settings.challenges_database_url is not None


@asynccontextmanager
async def challenges_session() -> AsyncIterator[AsyncSession]:
"""
Provide an AsyncSession for the challenges database.

The caller must ensure that a connection string is configured.
"""

if _sessionmaker is None and _ensure_engine() is None:
raise RuntimeError("Challenges database is not configured")

session_factory = _sessionmaker
if session_factory is None:
raise RuntimeError("Challenges database sessionmaker is unavailable")

async with session_factory() as session:
yield session


async def execute(statement: Any) -> None:
"""Execute a statement on the challenges database and commit."""

if _sessionmaker is None and _ensure_engine() is None:
raise RuntimeError("Challenges database is not configured")

session_factory = _sessionmaker
if session_factory is None:
raise RuntimeError("Challenges database sessionmaker is unavailable")

async with session_factory() as session:
await session.execute(statement)
await session.commit()


async def fetch_all(statement: Select | Any) -> list[Any]:
"""Fetch all rows for the given statement from the challenges database."""

if _sessionmaker is None and _ensure_engine() is None:
raise RuntimeError("Challenges database is not configured")

session_factory = _sessionmaker
if session_factory is None:
raise RuntimeError("Challenges database sessionmaker is unavailable")

async with session_factory() as session:
result = await session.execute(statement)
return list(result)


async def ensure_schema() -> None:
"""
Create the challenges tables if they do not exist.

This is primarily intended for tests, where an in-memory database is used.
"""

engine = _ensure_engine()
if engine is None:
return

async with engine.begin() as conn:
await conn.run_sync(metadata.create_all)


async def dispose_engine() -> None:
"""Dispose the current engine (used in tests to reset the state)."""

global _engine, _sessionmaker
if _engine is not None:
await _engine.dispose()
_engine = None
_sessionmaker = None


__all__ = [
"challenges_course_tasks",
"challenges_subtasks",
"challenges_user_subtasks",
"challenges_configured",
"challenges_session",
"dispose_engine",
"ensure_schema",
"execute",
"fetch_all",
]
Loading