feat: [api] Add Onboarding, and account validation endpoints
This commit is contained in:
parent
88c355053f
commit
3a3537831a
7 changed files with 193 additions and 1 deletions
|
|
@ -0,0 +1,24 @@
|
|||
"""add human_name to users
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-04-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0012"
|
||||
down_revision: Union[str, None] = "0011"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("human_name", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "human_name")
|
||||
|
|
@ -11,4 +11,5 @@ class Account:
|
|||
is_active: bool
|
||||
is_email_verified: bool
|
||||
created_at: datetime
|
||||
human_name: str | None = None
|
||||
learnable_languages: list[LearnableLanguage] = field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ class AccountService:
|
|||
account = Account(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
human_name=user.human_name,
|
||||
is_active=user.is_active,
|
||||
is_email_verified=user.is_email_verified,
|
||||
created_at=user.created_at,
|
||||
|
|
@ -137,6 +138,7 @@ class AccountService:
|
|||
return Account(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
human_name=user.human_name,
|
||||
is_active=user.is_active,
|
||||
is_email_verified=user.is_email_verified,
|
||||
created_at=user.created_at,
|
||||
|
|
@ -195,12 +197,66 @@ class AccountService:
|
|||
return Account(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
human_name=user.human_name,
|
||||
is_active=user.is_active,
|
||||
is_email_verified=user.is_email_verified,
|
||||
created_at=user.created_at,
|
||||
learnable_languages=languages,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Onboarding
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def complete_onboarding(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
human_name: str,
|
||||
language_pair: str,
|
||||
proficiencies: list[str],
|
||||
) -> Account:
|
||||
"""Record the user's name and first language pair, completing onboarding.
|
||||
|
||||
``language_pair`` is a comma-separated ``"source,target"`` string (e.g. ``"en,fr"``).
|
||||
|
||||
Usage::
|
||||
|
||||
account = await service.complete_onboarding(
|
||||
user_id, human_name="Alice", language_pair="en,fr", proficiencies=["B1"]
|
||||
)
|
||||
"""
|
||||
source, target = language_pair.split(",", 1)
|
||||
await user_repository.set_human_name(self.db, user_id, human_name)
|
||||
await self.add_learnable_language(user_id, source, target, proficiencies)
|
||||
return await self.get_account(user_id)
|
||||
|
||||
async def get_account_status(self, user_id: uuid.UUID) -> tuple[list[str], list[str]]:
|
||||
"""Return ``(problem_flags, error_messages)`` describing any blockers on the account.
|
||||
|
||||
Current flags:
|
||||
- ``unvalidated_email`` — the user has not verified their email address
|
||||
- ``no_onboarding`` — the user has not added any language pairs yet
|
||||
|
||||
Usage::
|
||||
|
||||
flags, messages = await service.get_account_status(user_id)
|
||||
if flags:
|
||||
... # surface to the user
|
||||
"""
|
||||
account = await self.get_account(user_id)
|
||||
flags: list[str] = []
|
||||
messages: list[str] = []
|
||||
|
||||
if not account.is_email_verified:
|
||||
flags.append("unvalidated_email")
|
||||
messages.append("Please validate your email address")
|
||||
|
||||
if not account.learnable_languages:
|
||||
flags.append("no_onboarding")
|
||||
messages.append("Please complete onboarding")
|
||||
|
||||
return flags, messages
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Language management
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class User(Base):
|
|||
)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
human_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -15,3 +17,10 @@ async def create(db: AsyncSession, email: str, hashed_password: str) -> User:
|
|||
async def get_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_human_name(db: AsyncSession, user_id: uuid.UUID, human_name: str) -> None:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one()
|
||||
user.human_name = human_name
|
||||
await db.commit()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...auth import verify_token
|
||||
|
|
@ -76,6 +76,71 @@ async def add_learnable_language(
|
|||
)
|
||||
|
||||
|
||||
class OnboardingRequest(BaseModel):
|
||||
human_name: str
|
||||
language_pairs: list[str]
|
||||
proficiencies: list[list[str]]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_shape(self) -> "OnboardingRequest":
|
||||
if len(self.language_pairs) != 1:
|
||||
raise ValueError("language_pairs must contain exactly one entry")
|
||||
if len(self.proficiencies) != 1:
|
||||
raise ValueError("proficiencies must contain exactly one entry")
|
||||
|
||||
pair = self.language_pairs[0]
|
||||
parts = pair.split(",")
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"language_pairs entry must be 'source,target', got {pair!r}")
|
||||
source, target = parts
|
||||
if source not in SUPPORTED_LANGUAGES:
|
||||
raise ValueError(f"Unsupported source language: {source!r}")
|
||||
if target not in SUPPORTED_LANGUAGES:
|
||||
raise ValueError(f"Unsupported target language: {target!r}")
|
||||
if source == target:
|
||||
raise ValueError("Source and target language must differ")
|
||||
|
||||
levels = self.proficiencies[0]
|
||||
if not (1 <= len(levels) <= 2):
|
||||
raise ValueError("proficiencies entry must contain 1 or 2 levels")
|
||||
invalid = [l for l in levels if l not in SUPPORTED_LEVELS]
|
||||
if invalid:
|
||||
raise ValueError(f"Invalid proficiency levels: {invalid}")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class AccountStatusResponse(BaseModel):
|
||||
problem_flags: list[str]
|
||||
error_messages: list[str]
|
||||
|
||||
|
||||
@router.post("/onboarding", status_code=status.HTTP_200_OK)
|
||||
async def complete_onboarding(
|
||||
body: OnboardingRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> dict:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
await AccountService(db).complete_onboarding(
|
||||
user_id=user_id,
|
||||
human_name=body.human_name,
|
||||
language_pair=body.language_pairs[0],
|
||||
proficiencies=body.proficiencies[0],
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/status", response_model=AccountStatusResponse)
|
||||
async def get_account_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> AccountStatusResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
flags, messages = await AccountService(db).get_account_status(user_id)
|
||||
return AccountStatusResponse(problem_flags=flags, error_messages=messages)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/learnable-languages/{language_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from ...auth import verify_token
|
||||
from ...config import settings
|
||||
from ...domain.services.account_service import AccountService
|
||||
from ...languages import SUPPORTED_LANGUAGES, SUPPORTED_LEVELS, LEVEL_ORDER, LEVEL_DESCRIPTIONS
|
||||
from ...outbound.postgres.database import get_db
|
||||
from ...outbound.postgres.repositories import email_verification_token_repository
|
||||
|
|
@ -61,6 +62,41 @@ def _build_proficiencies() -> list[ProficiencyOption]:
|
|||
]
|
||||
|
||||
|
||||
class AccountLanguagePair(BaseModel):
|
||||
id: str
|
||||
source_language: str
|
||||
target_language: str
|
||||
proficiencies: list[str]
|
||||
|
||||
|
||||
class AccountResponse(BaseModel):
|
||||
email: str
|
||||
human_name: str | None
|
||||
language_pairs: list[AccountLanguagePair]
|
||||
|
||||
|
||||
@router.get("", response_model=AccountResponse)
|
||||
async def get_account(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> AccountResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
account = await AccountService(db).get_account(user_id)
|
||||
return AccountResponse(
|
||||
email=account.email,
|
||||
human_name=account.human_name,
|
||||
language_pairs=[
|
||||
AccountLanguagePair(
|
||||
id=lang.id,
|
||||
source_language=lang.source_language,
|
||||
target_language=lang.target_language,
|
||||
proficiencies=lang.proficiencies,
|
||||
)
|
||||
for lang in account.learnable_languages
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/onboarding", response_model=OnboardingResponse)
|
||||
async def get_onboarding(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
|
|
|||
Loading…
Reference in a new issue