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 .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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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