feat: Add Learnable Languages
This commit is contained in:
parent
5f917f5e6d
commit
046504e6a1
8 changed files with 241 additions and 0 deletions
|
|
@ -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")
|
||||||
10
api/app/domain/models/learnable_language.py
Normal file
10
api/app/domain/models/learnable_language.py
Normal 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]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
69
api/app/routers/api/learnable_languages.py
Normal file
69
api/app/routers/api/learnable_languages.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ from .pos import router as pos_router
|
||||||
from .translate import router as translate_router
|
from .translate import router as translate_router
|
||||||
from .generation import router as generation_router
|
from .generation import router as generation_router
|
||||||
from .jobs import router as jobs_router
|
from .jobs import router as jobs_router
|
||||||
|
from .learnable_languages import router as learnable_languages_router
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
@ -11,3 +12,4 @@ api_router.include_router(pos_router)
|
||||||
api_router.include_router(translate_router)
|
api_router.include_router(translate_router)
|
||||||
api_router.include_router(generation_router)
|
api_router.include_router(generation_router)
|
||||||
api_router.include_router(jobs_router)
|
api_router.include_router(jobs_router)
|
||||||
|
api_router.include_router(learnable_languages_router)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
from .articles import router as article_router
|
from .articles import router as article_router
|
||||||
|
from .user_profile import router as user_profile_router
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
bff_router = APIRouter(prefix="/bff", tags=["bff"])
|
bff_router = APIRouter(prefix="/bff", tags=["bff"])
|
||||||
|
|
||||||
bff_router.include_router(article_router)
|
bff_router.include_router(article_router)
|
||||||
|
bff_router.include_router(user_profile_router)
|
||||||
|
|
|
||||||
43
api/app/routers/bff/user_profile.py
Normal file
43
api/app/routers/bff/user_profile.py
Normal 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
|
||||||
|
]
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue