From 046504e6a10ba6eaf8cddf6cd990dc3c08bfd5d4 Mon Sep 17 00:00:00 2001 From: wilson Date: Fri, 27 Mar 2026 10:32:46 +0000 Subject: [PATCH] feat: Add Learnable Languages --- .../20260327_0004_add_learnable_languages.py | 35 ++++++++++ api/app/domain/models/learnable_language.py | 10 +++ .../entities/learnable_language_entity.py | 24 +++++++ .../learnable_language_repository.py | 56 +++++++++++++++ api/app/routers/api/learnable_languages.py | 69 +++++++++++++++++++ api/app/routers/api/main.py | 2 + api/app/routers/bff/main.py | 2 + api/app/routers/bff/user_profile.py | 43 ++++++++++++ 8 files changed, 241 insertions(+) create mode 100644 api/alembic/versions/20260327_0004_add_learnable_languages.py create mode 100644 api/app/domain/models/learnable_language.py create mode 100644 api/app/outbound/postgres/entities/learnable_language_entity.py create mode 100644 api/app/outbound/postgres/repositories/learnable_language_repository.py create mode 100644 api/app/routers/api/learnable_languages.py create mode 100644 api/app/routers/bff/user_profile.py diff --git a/api/alembic/versions/20260327_0004_add_learnable_languages.py b/api/alembic/versions/20260327_0004_add_learnable_languages.py new file mode 100644 index 0000000..e0a218f --- /dev/null +++ b/api/alembic/versions/20260327_0004_add_learnable_languages.py @@ -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") diff --git a/api/app/domain/models/learnable_language.py b/api/app/domain/models/learnable_language.py new file mode 100644 index 0000000..4c6760d --- /dev/null +++ b/api/app/domain/models/learnable_language.py @@ -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] diff --git a/api/app/outbound/postgres/entities/learnable_language_entity.py b/api/app/outbound/postgres/entities/learnable_language_entity.py new file mode 100644 index 0000000..33070cb --- /dev/null +++ b/api/app/outbound/postgres/entities/learnable_language_entity.py @@ -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) diff --git a/api/app/outbound/postgres/repositories/learnable_language_repository.py b/api/app/outbound/postgres/repositories/learnable_language_repository.py new file mode 100644 index 0000000..245783c --- /dev/null +++ b/api/app/outbound/postgres/repositories/learnable_language_repository.py @@ -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) diff --git a/api/app/routers/api/learnable_languages.py b/api/app/routers/api/learnable_languages.py new file mode 100644 index 0000000..2c3b7bb --- /dev/null +++ b/api/app/routers/api/learnable_languages.py @@ -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, + ) diff --git a/api/app/routers/api/main.py b/api/app/routers/api/main.py index 76054d7..bef4d48 100644 --- a/api/app/routers/api/main.py +++ b/api/app/routers/api/main.py @@ -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) diff --git a/api/app/routers/bff/main.py b/api/app/routers/bff/main.py index efd7c76..a20a9c7 100644 --- a/api/app/routers/bff/main.py +++ b/api/app/routers/bff/main.py @@ -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) diff --git a/api/app/routers/bff/user_profile.py b/api/app/routers/bff/user_profile.py new file mode 100644 index 0000000..9b36952 --- /dev/null +++ b/api/app/routers/bff/user_profile.py @@ -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 + ] + )