feat: Add Learnable Languages

This commit is contained in:
wilson 2026-03-27 10:32:46 +00:00
parent 5f917f5e6d
commit 046504e6a1
8 changed files with 241 additions and 0 deletions

View file

@ -0,0 +1,35 @@
"""add learnable_languages table
Revision ID: 0004
Revises: 0003
Create Date: 2026-03-27
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0004"
down_revision: Union[str, None] = "0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"learnable_languages",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("source_language", sa.String(10), nullable=False),
sa.Column("target_language", sa.String(10), nullable=False),
sa.Column("proficiencies", postgresql.ARRAY(sa.String(5)), nullable=False),
sa.UniqueConstraint("user_id", "source_language", "target_language", name="uq_learnable_language_user_pair"),
)
op.create_index("ix_learnable_languages_user_id", "learnable_languages", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_learnable_languages_user_id", table_name="learnable_languages")
op.drop_table("learnable_languages")

View file

@ -0,0 +1,10 @@
from dataclasses import dataclass
@dataclass
class LearnableLanguage:
id: str
user_id: str
source_language: str
target_language: str
proficiencies: list[str]

View file

@ -0,0 +1,24 @@
import uuid
from sqlalchemy import String, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, ARRAY
from ..database import Base
class LearnableLanguageEntity(Base):
__tablename__ = "learnable_languages"
__table_args__ = (
UniqueConstraint("user_id", "source_language", "target_language", name="uq_learnable_language_user_pair"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True
)
source_language: Mapped[str] = mapped_column(String(10), nullable=False)
target_language: Mapped[str] = mapped_column(String(10), nullable=False)
proficiencies: Mapped[list[str]] = mapped_column(ARRAY(String(5)), nullable=False)

View file

@ -0,0 +1,56 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..entities.learnable_language_entity import LearnableLanguageEntity
from ....domain.models.learnable_language import LearnableLanguage
def _to_model(entity: LearnableLanguageEntity) -> LearnableLanguage:
return LearnableLanguage(
id=str(entity.id),
user_id=str(entity.user_id),
source_language=entity.source_language,
target_language=entity.target_language,
proficiencies=list(entity.proficiencies),
)
async def list_for_user(db: AsyncSession, user_id: uuid.UUID) -> list[LearnableLanguage]:
result = await db.execute(
select(LearnableLanguageEntity).where(LearnableLanguageEntity.user_id == user_id)
)
return [_to_model(e) for e in result.scalars().all()]
async def upsert(
db: AsyncSession,
user_id: uuid.UUID,
source_language: str,
target_language: str,
proficiencies: list[str],
) -> LearnableLanguage:
result = await db.execute(
select(LearnableLanguageEntity).where(
LearnableLanguageEntity.user_id == user_id,
LearnableLanguageEntity.source_language == source_language,
LearnableLanguageEntity.target_language == target_language,
)
)
entity = result.scalar_one_or_none()
if entity is None:
entity = LearnableLanguageEntity(
user_id=user_id,
source_language=source_language,
target_language=target_language,
proficiencies=proficiencies,
)
db.add(entity)
else:
entity.proficiencies = proficiencies
await db.commit()
await db.refresh(entity)
return _to_model(entity)

View file

@ -0,0 +1,69 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from ...auth import verify_token
from ...languages import SUPPORTED_LANGUAGES, SUPPORTED_LEVELS
from ...outbound.postgres.database import get_db
from ...outbound.postgres.repositories import learnable_language_repository
router = APIRouter(prefix="/learnable_languages", tags=["api"])
class LearnableLanguageRequest(BaseModel):
source_language: str
target_language: str
proficiencies: list[str]
@field_validator("proficiencies")
@classmethod
def validate_proficiencies(cls, v: list[str]) -> list[str]:
if not (1 <= len(v) <= 2):
raise ValueError("proficiencies must contain 1 or 2 levels")
invalid = [p for p in v if p not in SUPPORTED_LEVELS]
if invalid:
raise ValueError(f"Invalid proficiency levels: {invalid}. Supported: {sorted(SUPPORTED_LEVELS)}")
return v
class LearnableLanguageResponse(BaseModel):
id: str
source_language: str
target_language: str
proficiencies: list[str]
@router.post("", response_model=LearnableLanguageResponse, status_code=200)
async def upsert_learnable_language(
request: LearnableLanguageRequest,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> LearnableLanguageResponse:
if request.source_language not in SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=400,
detail=f"Unsupported source language '{request.source_language}'. Supported: {list(SUPPORTED_LANGUAGES)}",
)
if request.target_language not in SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=400,
detail=f"Unsupported target language '{request.target_language}'. Supported: {list(SUPPORTED_LANGUAGES)}",
)
if request.source_language == request.target_language:
raise HTTPException(status_code=400, detail="source_language and target_language must differ")
result = await learnable_language_repository.upsert(
db,
user_id=uuid.UUID(token_data["sub"]),
source_language=request.source_language,
target_language=request.target_language,
proficiencies=request.proficiencies,
)
return LearnableLanguageResponse(
id=result.id,
source_language=result.source_language,
target_language=result.target_language,
proficiencies=result.proficiencies,
)

View file

@ -2,6 +2,7 @@ from .pos import router as pos_router
from .translate import router as translate_router
from .generation import router as generation_router
from .jobs import router as jobs_router
from .learnable_languages import router as learnable_languages_router
from fastapi import APIRouter
@ -11,3 +12,4 @@ api_router.include_router(pos_router)
api_router.include_router(translate_router)
api_router.include_router(generation_router)
api_router.include_router(jobs_router)
api_router.include_router(learnable_languages_router)

View file

@ -1,7 +1,9 @@
from .articles import router as article_router
from .user_profile import router as user_profile_router
from fastapi import APIRouter
bff_router = APIRouter(prefix="/bff", tags=["bff"])
bff_router.include_router(article_router)
bff_router.include_router(user_profile_router)

View file

@ -0,0 +1,43 @@
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ...auth import verify_token
from ...outbound.postgres.database import get_db
from ...outbound.postgres.repositories import learnable_language_repository
router = APIRouter(prefix="/user_profile", tags=["bff"])
class LearnableLanguageItem(BaseModel):
id: str
source_language: str
target_language: str
proficiencies: list[str]
class UserProfileResponse(BaseModel):
learnable_languages: list[LearnableLanguageItem]
@router.get("", response_model=UserProfileResponse, status_code=200)
async def get_user_profile(
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> UserProfileResponse:
languages = await learnable_language_repository.list_for_user(
db, user_id=uuid.UUID(token_data["sub"])
)
return UserProfileResponse(
learnable_languages=[
LearnableLanguageItem(
id=lang.id,
source_language=lang.source_language,
target_language=lang.target_language,
proficiencies=lang.proficiencies,
)
for lang in languages
]
)